From 4b4991b201698b25a7bb7f54872df863469a7886 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Thu, 19 Mar 2026 16:31:07 -0400 Subject: [PATCH 01/12] feat: add Node.js app mode and manifest types (Phase 1) Add NODE_API app mode (ordinal 20, name "node-api") to AppModes registry and add TypedDicts for the Node.js manifest section (ManifestDataNode, ManifestDataNodePackageManager, ManifestDataPackage) matching the format expected by Connect PR #37892. --- rsconnect/bundle.py | 24 ++++++++++++++++++++++++ rsconnect/models.py | 3 +++ 2 files changed, 27 insertions(+) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index fe02ae51..a80c0ebe 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -128,6 +128,28 @@ class ManifestDataPythonPackageManager(TypedDict): allow_uv: NotRequired[bool] +class ManifestDataNodePackageManager(TypedDict): + name: str + version: str + package_file: str + + +class ManifestDataNode(TypedDict): + version: str + package_manager: ManifestDataNodePackageManager + + +class ManifestDataPackageDescription(TypedDict): + name: str + version: str + + +class ManifestDataPackage(TypedDict): + Source: str + Repository: str + description: ManifestDataPackageDescription + + class ManifestData(TypedDict): version: int files: dict[str, ManifestDataFile] @@ -136,6 +158,8 @@ class ManifestData(TypedDict): jupyter: NotRequired[ManifestDataJupyter] quarto: NotRequired[ManifestDataQuarto] python: NotRequired[ManifestDataPython] + node: NotRequired[ManifestDataNode] + packages: NotRequired[dict[str, ManifestDataPackage]] environment: NotRequired[ManifestDataEnvironment] diff --git a/rsconnect/models.py b/rsconnect/models.py index 49cea38d..d6ebbd0a 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -99,6 +99,7 @@ class AppModes: JUPYTER_VOILA = AppMode(16, "jupyter-voila", "Jupyter Voila Application") PYTHON_GRADIO = AppMode(17, "python-gradio", "Gradio Application") PYTHON_PANEL = AppMode(18, "python-panel", "Panel Application") + NODE_API = AppMode(20, "node-api", "Node.js API") _modes = [ UNKNOWN, @@ -120,6 +121,7 @@ class AppModes: JUPYTER_VOILA, PYTHON_GRADIO, PYTHON_PANEL, + NODE_API, ] Modes = Literal[ @@ -142,6 +144,7 @@ class AppModes: "jupyter-voila", "python-gradio", "python-panel", + "node-api", ] _cloud_to_connect_modes = { From c67c9ad7d80dc5e3367f59c63270a20ba00671fa Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 09:55:12 -0400 Subject: [PATCH 02/12] feat: add Node.js environment detection (Phase 2) Add NodeEnvironment class that detects Node.js/npm versions, reads package.json, parses dependencies into manifest packages format, and warns when package-lock.json is missing. Includes tests and test fixtures. --- rsconnect/environment_node.py | 137 +++++++++++++++++++ tests/test_environment_node.py | 164 +++++++++++++++++++++++ tests/testdata/node-express/app.js | 12 ++ tests/testdata/node-express/package.json | 12 ++ 4 files changed, 325 insertions(+) create mode 100644 rsconnect/environment_node.py create mode 100644 tests/test_environment_node.py create mode 100644 tests/testdata/node-express/app.js create mode 100644 tests/testdata/node-express/package.json diff --git a/rsconnect/environment_node.py b/rsconnect/environment_node.py new file mode 100644 index 00000000..ba1083de --- /dev/null +++ b/rsconnect/environment_node.py @@ -0,0 +1,137 @@ +"""Detects the configuration of a Node.js environment. + +Given a directory containing a package.json file, this module inspects +the local Node.js/npm installation and returns information needed to +build the deployment manifest. +""" + +from __future__ import annotations + +import json +import locale +import os +import subprocess +from typing import Optional + +import click + +from .bundle import ManifestDataPackage, ManifestDataPackageDescription +from .exception import RSConnectException +from .log import logger + + +class NodeEnvironment: + """A Node.js project environment for deployment. + + Captures Node.js version, npm version, package.json contents, + and parsed dependency metadata needed for the manifest. + """ + + def __init__( + self, + node_version: str, + npm_version: str, + package_file: str, + package_contents: str, + packages: dict[str, ManifestDataPackage], + has_lock_file: bool, + locale: str, + ): + self.node_version = node_version + self.npm_version = npm_version + self.package_file = package_file + self.package_contents = package_contents + self.packages = packages + self.has_lock_file = has_lock_file + self.locale = locale + + @classmethod + def create( + cls, + directory: str, + node_executable: Optional[str] = None, + ) -> NodeEnvironment: + """Detect Node.js environment from a project directory. + + :param directory: path to the project directory containing package.json. + :param node_executable: optional path to the node binary. Defaults to "node" on PATH. + :return: a NodeEnvironment instance. + """ + node_executable = node_executable or "node" + + package_json_path = os.path.join(directory, "package.json") + if not os.path.exists(package_json_path): + raise RSConnectException( + f"No package.json found in '{directory}'. " "A package.json file is required to deploy Node.js content." + ) + + with open(package_json_path, encoding="utf-8") as f: + package_contents = f.read() + + try: + package_data = json.loads(package_contents) + except json.JSONDecodeError as e: + raise RSConnectException(f"Failed to parse package.json: {e}") + + node_version = _detect_version(node_executable, "--version", "Node.js") + npm_version = _detect_version("npm", "--version", "npm") + + packages = _parse_packages(package_data) + + has_lock_file = os.path.exists(os.path.join(directory, "package-lock.json")) + if not has_lock_file: + click.secho( + " Warning: No package-lock.json found. Deployments without a lock file may not be reproducible.", + fg="yellow", + ) + + env_locale = locale.getlocale()[0] or "en_US" + + return cls( + node_version=node_version, + npm_version=npm_version, + package_file="package.json", + package_contents=package_contents, + packages=packages, + has_lock_file=has_lock_file, + locale=env_locale, + ) + + +def _detect_version(executable: str, flag: str, label: str) -> str: + """Run an executable with a version flag and return the version string.""" + try: + result = subprocess.run( + [executable, flag], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + raise RSConnectException(f"{label} returned exit code {result.returncode}: {result.stderr.strip()}") + version = result.stdout.strip().lstrip("v") + if not version: + raise RSConnectException(f"{label} returned empty version string.") + logger.debug(f"Detected {label} version: {version}") + return version + except FileNotFoundError: + raise RSConnectException( + f"Could not find '{executable}' on PATH. " f"Please install {label} or specify the path with --node." + ) + except subprocess.TimeoutExpired: + raise RSConnectException(f"Timed out detecting {label} version.") + + +def _parse_packages(package_data: dict) -> dict[str, ManifestDataPackage]: + """Extract production dependencies from package.json into manifest packages format.""" + packages: dict[str, ManifestDataPackage] = {} + dependencies = package_data.get("dependencies", {}) + for name, version_spec in dependencies.items(): + version = version_spec.lstrip("^~>=<") + desc: ManifestDataPackageDescription = {"name": name, "version": version} + packages[name] = { + "Source": "npm", + "Repository": "https://registry.npmjs.org/", + "description": desc, + } + return packages diff --git a/tests/test_environment_node.py b/tests/test_environment_node.py new file mode 100644 index 00000000..d14d0ff6 --- /dev/null +++ b/tests/test_environment_node.py @@ -0,0 +1,164 @@ +import json +import os +import subprocess +from unittest.mock import patch, MagicMock + +import pytest + +from rsconnect.environment_node import NodeEnvironment, _detect_version, _parse_packages +from rsconnect.exception import RSConnectException + + +_TESTDATA = os.path.join(os.path.dirname(__file__), "testdata") +_NODE_EXPRESS = os.path.join(_TESTDATA, "node-express") + + +def _mock_run(cmd, **kwargs): + """Mock subprocess.run for node/npm version detection.""" + executable = cmd[0] + result = MagicMock() + result.returncode = 0 + if executable == "node" or executable.endswith("/node"): + result.stdout = "v22.22.1\n" + elif executable == "npm": + result.stdout = "10.9.2\n" + else: + raise FileNotFoundError(f"No such file: {executable}") + result.stderr = "" + return result + + +class TestNodeEnvironmentCreate: + @patch("rsconnect.environment_node.subprocess.run", side_effect=_mock_run) + def test_create_basic(self, mock_run): + env = NodeEnvironment.create(_NODE_EXPRESS) + assert env.node_version == "22.22.1" + assert env.npm_version == "10.9.2" + assert env.package_file == "package.json" + assert "express" in env.packages + assert env.packages["express"]["description"]["name"] == "express" + assert env.packages["express"]["description"]["version"] == "4.21.0" + assert env.packages["express"]["Source"] == "npm" + assert not env.has_lock_file + assert env.locale + + @patch("rsconnect.environment_node.subprocess.run", side_effect=_mock_run) + def test_create_with_lock_file(self, mock_run, tmp_path): + # Copy package.json and app.js to tmp_path, then add a lock file + pkg = tmp_path / "package.json" + pkg.write_text(json.dumps({"dependencies": {"express": "^4.21.0"}})) + (tmp_path / "app.js").write_text("// app") + (tmp_path / "package-lock.json").write_text("{}") + + env = NodeEnvironment.create(str(tmp_path)) + assert env.has_lock_file + + def test_create_no_package_json(self, tmp_path): + with pytest.raises(RSConnectException, match="No package.json found"): + NodeEnvironment.create(str(tmp_path)) + + def test_create_invalid_package_json(self, tmp_path): + (tmp_path / "package.json").write_text("not json{{{") + with pytest.raises(RSConnectException, match="Failed to parse package.json"): + NodeEnvironment.create(str(tmp_path)) + + @patch( + "rsconnect.environment_node.subprocess.run", + side_effect=FileNotFoundError("No such file"), + ) + def test_create_node_not_found(self, mock_run): + with pytest.raises(RSConnectException, match="Could not find 'node'"): + NodeEnvironment.create(_NODE_EXPRESS) + + @patch("rsconnect.environment_node.subprocess.run") + def test_create_node_error_exit(self, mock_run): + result = MagicMock() + result.returncode = 1 + result.stdout = "" + result.stderr = "some error" + mock_run.return_value = result + with pytest.raises(RSConnectException, match="returned exit code 1"): + NodeEnvironment.create(_NODE_EXPRESS) + + @patch("rsconnect.environment_node.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="node", timeout=10)) + def test_create_node_timeout(self, mock_run): + with pytest.raises(RSConnectException, match="Timed out"): + NodeEnvironment.create(_NODE_EXPRESS) + + @patch("rsconnect.environment_node.subprocess.run", side_effect=_mock_run) + def test_create_custom_node_executable(self, mock_run): + env = NodeEnvironment.create(_NODE_EXPRESS, node_executable="/opt/node/22/bin/node") + assert env.node_version == "22.22.1" + # Verify the custom executable was used + first_call = mock_run.call_args_list[0] + assert first_call[0][0][0] == "/opt/node/22/bin/node" + + @patch("rsconnect.environment_node.subprocess.run", side_effect=_mock_run) + def test_package_contents_preserved(self, mock_run): + env = NodeEnvironment.create(_NODE_EXPRESS) + data = json.loads(env.package_contents) + assert data["name"] == "node-express" + assert data["main"] == "app.js" + + +class TestDetectVersion: + @patch("rsconnect.environment_node.subprocess.run") + def test_strips_v_prefix(self, mock_run): + result = MagicMock() + result.returncode = 0 + result.stdout = "v22.22.1\n" + result.stderr = "" + mock_run.return_value = result + assert _detect_version("node", "--version", "Node.js") == "22.22.1" + + @patch("rsconnect.environment_node.subprocess.run") + def test_no_v_prefix(self, mock_run): + result = MagicMock() + result.returncode = 0 + result.stdout = "10.9.2\n" + result.stderr = "" + mock_run.return_value = result + assert _detect_version("npm", "--version", "npm") == "10.9.2" + + @patch("rsconnect.environment_node.subprocess.run") + def test_empty_version(self, mock_run): + result = MagicMock() + result.returncode = 0 + result.stdout = "\n" + result.stderr = "" + mock_run.return_value = result + with pytest.raises(RSConnectException, match="empty version"): + _detect_version("node", "--version", "Node.js") + + +class TestParsePackages: + def test_basic_dependencies(self): + data = {"dependencies": {"express": "^4.21.0", "cors": "~2.8.5"}} + packages = _parse_packages(data) + assert len(packages) == 2 + assert packages["express"]["description"]["version"] == "4.21.0" + assert packages["cors"]["description"]["version"] == "2.8.5" + + def test_no_dependencies(self): + data = {"name": "minimal"} + packages = _parse_packages(data) + assert packages == {} + + def test_exact_version(self): + data = {"dependencies": {"lodash": "4.17.21"}} + packages = _parse_packages(data) + assert packages["lodash"]["description"]["version"] == "4.17.21" + + def test_range_version(self): + data = {"dependencies": {"pkg": ">=1.0.0"}} + packages = _parse_packages(data) + assert packages["pkg"]["description"]["version"] == "1.0.0" + + def test_dev_dependencies_excluded(self): + data = { + "dependencies": {"express": "^4.21.0"}, + "devDependencies": {"jest": "^29.0.0"}, + } + packages = _parse_packages(data) + assert "express" in packages + assert "jest" not in packages diff --git a/tests/testdata/node-express/app.js b/tests/testdata/node-express/app.js new file mode 100644 index 00000000..473fd5b0 --- /dev/null +++ b/tests/testdata/node-express/app.js @@ -0,0 +1,12 @@ +const express = require('express'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.get('/', (req, res) => { + res.json({ status: 'ok', framework: 'express' }); +}); + +app.listen(PORT, () => { + console.log(`Express server listening on port ${PORT}`); +}); diff --git a/tests/testdata/node-express/package.json b/tests/testdata/node-express/package.json new file mode 100644 index 00000000..c610bb19 --- /dev/null +++ b/tests/testdata/node-express/package.json @@ -0,0 +1,12 @@ +{ + "name": "node-express", + "version": "1.0.0", + "description": "Express HTTP server for testing", + "main": "app.js", + "scripts": { + "start": "node app.js" + }, + "dependencies": { + "express": "^4.21.0" + } +} From 4fe42ab95e2d45c6b8c6e17632286a3da31750e0 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:08:35 -0400 Subject: [PATCH 03/12] feat: add Node.js entry point detection and node_modules exclusion (Phase 3) Add node_modules/ to the global directory ignore list. Add get_default_node_entrypoint() that checks package.json main field, scripts.start, then common filenames (app.js, index.js, etc.). Add validate_node_entry_point() for user-specified entry point validation. --- rsconnect/bundle.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index a80c0ebe..16dc65c9 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -74,6 +74,7 @@ "renv/", "rsconnect-python/", "rsconnect/", + "node_modules/", ] directories_to_ignore = {Path(d) for d in directories_ignore_list} @@ -1594,6 +1595,66 @@ def validate_entry_point(entry_point: str | None, directory: str) -> str: return entry_point +def get_default_node_entrypoint(directory: str | Path) -> str: + """ + Determine the default entry point for a Node.js application. + + Checks package.json "main" field first, then falls back to common filenames. + + :param directory: the directory containing the Node.js application. + :return: the entry point filename (e.g., "app.js"). + """ + package_json_path = join(str(directory), "package.json") + if isfile(package_json_path): + with open(package_json_path, encoding="utf-8") as f: + try: + package_data = json.load(f) + except json.JSONDecodeError: + package_data = {} + + # Check "main" field + main = package_data.get("main") + if main and isfile(join(str(directory), main)): + return main + + # Check "scripts.start" for "node " pattern + start_script = (package_data.get("scripts") or {}).get("start", "") + match = re.match(r"node\s+(\S+)", start_script) + if match: + start_file = match.group(1) + if isfile(join(str(directory), start_file)): + return start_file + + # Fall back to common filenames + files = set(os.listdir(directory)) + for candidate in ["app.js", "index.js", "server.js", "main.js", "app.ts", "index.ts", "server.ts", "main.ts"]: + if candidate in files: + return candidate + + raise RSConnectException(f"Could not determine default entrypoint file in directory '{directory}'") + + +def validate_node_entry_point(entry_point: str | None, directory: str) -> str: + """ + Validates the entry point for a Node.js application. + + If no entry point is specified, auto-detects from package.json or common filenames. + Validates that the entry point file exists in the directory. + + :param entry_point: the entry point as specified by the user, or None for auto-detection. + :param directory: the directory containing the Node.js application. + :return: the validated entry point filename. + """ + if not entry_point: + entry_point = get_default_node_entrypoint(directory) + + entry_path = join(directory, entry_point) + if not isfile(entry_path): + raise RSConnectException(f"The entry point file '{entry_point}' does not exist in '{directory}'.") + + return entry_point + + def _warn_on_ignored_entrypoint(entrypoint: Optional[str]) -> None: if entrypoint: click.secho( From 5645578555c3fbbd56efea560f955cb3bc11efa6 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:14:14 -0400 Subject: [PATCH 04/12] feat: add Node.js manifest and bundle creation (Phase 4) Add make_nodejs_manifest() and make_nodejs_bundle() functions that produce manifests matching the format expected by Connect (node section with version and package_manager, packages section with npm dependency metadata). Fix circular import between bundle.py and environment_node.py using TYPE_CHECKING guard. --- rsconnect/bundle.py | 114 +++++++++++++++++++++++++++++++++- rsconnect/environment_node.py | 6 +- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 16dc65c9..859719b2 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -53,6 +53,7 @@ import click from .environment import Environment, list_environment_dirs, is_environment_dir +from .environment_node import NodeEnvironment from .exception import RSConnectException from .log import VERBOSE, logger from .models import AppMode, AppModes, GlobSet @@ -109,7 +110,7 @@ class ManifestDataEnvironmentPython(TypedDict): class ManifestDataEnvironment(TypedDict): image: NotRequired[str] - environment_management: NotRequired[dict[Literal["python", "r"], bool]] + environment_management: NotRequired[dict[Literal["python", "r", "node"], bool]] python: NotRequired[ManifestDataEnvironmentPython] @@ -1363,6 +1364,117 @@ def make_api_bundle( return bundle_file +def make_nodejs_manifest( + directory: str, + entry_point: str, + node_environment: NodeEnvironment, + extra_files: Sequence[str], + excludes: Sequence[str], + image: Optional[str] = None, + env_management_node: Optional[bool] = None, +) -> tuple[ManifestData, list[str]]: + """ + Makes a manifest for a Node.js API application. + + :param directory: the directory containing the files to deploy. + :param entry_point: the main entry point file (e.g., "app.js"). + :param node_environment: the Node.js environment information. + :param extra_files: a sequence of any extra files to include in the bundle. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param image: optional docker image for off-host execution. + :param env_management_node: False prevents Connect from managing the Node.js environment. + :return: the manifest and a list of the files involved. + """ + extra_files = list(extra_files or []) + skip = ["manifest.json"] + extra_files = sorted(list(set(extra_files) - set(skip))) + + excludes = list(excludes) if excludes else [] + excludes.append("manifest.json") + excludes.append("node_modules") + + relevant_files = create_file_list(directory, extra_files, excludes) + + manifest: ManifestData = { + "version": 1, + "metadata": { + "appmode": AppModes.NODE_API.name(), + "entrypoint": entry_point, + }, + "node": { + "version": node_environment.node_version, + "package_manager": { + "name": "npm", + "version": node_environment.npm_version, + "package_file": node_environment.package_file, + }, + }, + "files": {}, + } + + if node_environment.locale: + manifest["locale"] = node_environment.locale + + if node_environment.packages: + manifest["packages"] = node_environment.packages + + if image or env_management_node is not None: + manifest_environment: ManifestDataEnvironment = {} + if image: + manifest_environment["image"] = image + if env_management_node is not None: + manifest_environment["environment_management"] = {"node": env_management_node} + manifest["environment"] = manifest_environment + + for rel_path in relevant_files: + manifest_add_file(manifest, rel_path, directory) + + return manifest, relevant_files + + +def make_nodejs_bundle( + directory: str, + entry_point: str, + node_environment: NodeEnvironment, + extra_files: Sequence[str], + excludes: Sequence[str], + image: Optional[str] = None, + env_management_node: Optional[bool] = None, +) -> typing.IO[bytes]: + """ + Create a Node.js API bundle, given a directory path. + + :param directory: the directory containing the files to deploy. + :param entry_point: the main entry point file (e.g., "app.js"). + :param node_environment: the Node.js environment information. + :param extra_files: a sequence of any extra files to include in the bundle. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param image: optional docker image for off-host execution. + :param env_management_node: False prevents Connect from managing the Node.js environment. + :return: a file-like object containing the bundle tarball. + """ + manifest, relevant_files = make_nodejs_manifest( + directory, + entry_point, + node_environment, + extra_files, + excludes, + image, + env_management_node, + ) + bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") + + with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle: + bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest, indent=2)) + + for rel_path in relevant_files: + bundle_add_file(bundle, rel_path, directory) + + bundle_file.seek(0) + + return bundle_file + + def _create_quarto_file_list( directory: str, extra_files: Sequence[str], diff --git a/rsconnect/environment_node.py b/rsconnect/environment_node.py index ba1083de..03f67271 100644 --- a/rsconnect/environment_node.py +++ b/rsconnect/environment_node.py @@ -11,14 +11,16 @@ import locale import os import subprocess -from typing import Optional +from typing import TYPE_CHECKING, Optional import click -from .bundle import ManifestDataPackage, ManifestDataPackageDescription from .exception import RSConnectException from .log import logger +if TYPE_CHECKING: + from .bundle import ManifestDataPackage, ManifestDataPackageDescription + class NodeEnvironment: """A Node.js project environment for deployment. From 82aac16016449d3be7ec1ac40b1c6d93005fbabd Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:23:16 -0400 Subject: [PATCH 05/12] feat: add rsconnect deploy nodejs CLI command (Phase 5) Add deploy nodejs subcommand with --entrypoint, --exclude, --node, --image, and --disable-env-management-node options. Auto-detects entry point from package.json when not specified. --- rsconnect/main.py | 150 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/rsconnect/main.py b/rsconnect/main.py index e4151777..17df63b3 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -75,6 +75,7 @@ make_api_bundle, make_html_bundle, make_manifest_bundle, + make_nodejs_bundle, make_notebook_html_bundle, make_notebook_source_bundle, make_tensorflow_bundle, @@ -84,6 +85,7 @@ validate_extra_files, validate_file_is_notebook, validate_manifest_file, + validate_node_entry_point, write_api_manifest_json, write_environment_file, write_notebook_manifest_json, @@ -91,6 +93,7 @@ write_tensorflow_manifest_json, write_voila_manifest_json, ) +from .environment_node import NodeEnvironment from .environment import Environment, fake_module_file_from_directory from .exception import RSConnectException from .git_metadata import detect_git_metadata @@ -2123,6 +2126,153 @@ def deploy_app( generate_deploy_python(app_mode=AppModes.PYTHON_PANEL, alias="panel", min_version="2025.10.0") +# noinspection SpellCheckingInspection +@deploy.command( + name="nodejs", + short_help="Deploy a Node.js API to Posit Connect.", + help=( + "Deploy a Node.js API application to Posit Connect. " + 'The "directory" argument must refer to an existing directory that contains ' + "a package.json file and a JavaScript or TypeScript entry point." + ), + no_args_is_help=True, +) +@server_args +@spcs_args +@content_args +@cloud_shinyapps_args +@click.option( + "--image", + "-I", + help="Target image to be used during content build and execution. " + "This option is only applicable if the Connect server is configured to use off-host execution.", +) +@click.option( + "--disable-env-management-node", + "env_management_node", + is_flag=True, + default=None, + help="Disable Node.js environment management for this bundle. " + "Connect will not install npm packages. An administrator must install the " + "required packages on the Connect server.", + callback=env_management_callback, +) +@click.option( + "--entrypoint", + "-e", + help="The JavaScript or TypeScript file that serves as the entry point for the application " + "(e.g., app.js, server.ts). Auto-detected from package.json if not specified.", +) +@click.option( + "--exclude", + "-x", + multiple=True, + help=( + "Specify a glob pattern for ignoring files when building the bundle. Note that your shell may try " + "to expand this which will not do what you expect. Generally, it's safest to quote the pattern. " + "This option may be repeated." + ), +) +@click.option( + "--node", + type=click.Path(exists=True), + help="Path to the Node.js executable whose version should be used for deployment.", +) +@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@click.argument( + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), +) +@shinyapps_deploy_args +@cli_exception_handler +@click.pass_context +def deploy_nodejs( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + entrypoint: Optional[str], + exclude: tuple[str, ...], + new: bool, + app_id: Optional[str], + title: Optional[str], + node: Optional[str], + verbose: int, + directory: str, + extra_files: tuple[str, ...], + visibility: Optional[str], + env_vars: dict[str, str], + image: Optional[str], + env_management_node: Optional[bool], + account: Optional[str], + token: Optional[str], + secret: Optional[str], + no_verify: bool, + draft: bool, + metadata: tuple[str, ...], + no_metadata: bool, +): + set_verbosity(verbose) + entrypoint = validate_node_entry_point(entrypoint, directory) + extra_files_list = validate_extra_files(directory, extra_files) + node_environment = NodeEnvironment.create(directory, node_executable=node) + + app_mode = AppModes.NODE_API + + server_version = None + + ce = RSConnectExecutor( + ctx=ctx, + name=name, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + account=account, + token=token, + secret=secret, + path=directory, + server=server, + exclude=exclude, + new=new, + app_id=app_id, + title=title, + visibility=visibility, + disable_env_management=None, + env_vars=env_vars, + ) + + if isinstance(ce.client, RSConnectClient): + connect_version_string = ce.client.server_settings().get("version", "") + server_version = connect_version_string + + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + + ce.validate_server() + ce.validate_app_mode(app_mode=app_mode) + ce.make_bundle( + make_nodejs_bundle, + directory, + entrypoint, + node_environment, + extra_files_list, + exclude, + image=image, + env_management_node=env_management_node, + ) + ce.deploy_bundle(activate=not draft) + ce.save_deployed_info() + ce.emit_task_log() + + if not no_verify: + ce.verify_deployment() + + @deploy.command( name="other-content", short_help="Describe deploying other content to Posit Connect.", From 978350c1a9870932706daa52b50e707d82813682 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:27:51 -0400 Subject: [PATCH 06/12] feat: add Node.js bundle, manifest, and entry point tests (Phase 7) Add tests for make_nodejs_manifest(), make_nodejs_bundle(), get_default_node_entrypoint(), and validate_node_entry_point(). Covers manifest structure, package metadata, node_modules exclusion, lock file inclusion, entry point auto-detection, and error cases. --- tests/test_bundle.py | 187 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index a9f88196..05c2e73f 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -16,6 +16,7 @@ _default_title_from_manifest, create_html_manifest, create_voila_manifest, + get_default_node_entrypoint, guess_deploy_dir, keep_manifest_specified_file, list_files, @@ -24,6 +25,8 @@ make_html_bundle, make_html_manifest, make_manifest_bundle, + make_nodejs_bundle, + make_nodejs_manifest, make_notebook_html_bundle, make_notebook_source_bundle, make_quarto_manifest, @@ -35,7 +38,9 @@ to_bytes, validate_entry_point, validate_extra_files, + validate_node_entry_point, ) +from rsconnect.environment_node import NodeEnvironment from rsconnect.environment import Environment, PackageInstaller from rsconnect.exception import RSConnectException from rsconnect.models import AppModes @@ -3015,3 +3020,185 @@ def test_make_bundle_empty_manifest(): def test_make_bundle_missing_file_in_manifest(): with pytest.raises(FileNotFoundError): make_manifest_bundle(missing_file_manifest) + + +# -- Node.js bundle and manifest tests -- + +_NODE_EXPRESS_DIR = join(dirname(__file__), "testdata", "node-express") + + +def _make_node_env(**overrides): + """Create a NodeEnvironment for testing.""" + defaults = dict( + node_version="22.22.1", + npm_version="10.9.2", + package_file="package.json", + package_contents='{"dependencies": {"express": "^4.21.0"}}', + packages={ + "express": { + "Source": "npm", + "Repository": "https://registry.npmjs.org/", + "description": {"name": "express", "version": "4.21.0"}, + } + }, + has_lock_file=False, + locale="en_US", + ) + defaults.update(overrides) + return NodeEnvironment(**defaults) + + +class TestNodeJSManifest: + def test_manifest_structure(self): + env = _make_node_env() + manifest, files = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + assert manifest["version"] == 1 + assert manifest["metadata"]["appmode"] == "node-api" + assert manifest["metadata"]["entrypoint"] == "app.js" + assert manifest["node"]["version"] == "22.22.1" + assert manifest["node"]["package_manager"]["name"] == "npm" + assert manifest["node"]["package_manager"]["version"] == "10.9.2" + assert manifest["node"]["package_manager"]["package_file"] == "package.json" + assert manifest["locale"] == "en_US" + + def test_manifest_files(self): + env = _make_node_env() + manifest, files = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + assert "app.js" in manifest["files"] + assert "package.json" in manifest["files"] + # Checksums should be non-empty + for f in manifest["files"].values(): + assert f["checksum"] + + def test_manifest_packages(self): + env = _make_node_env() + manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + assert "express" in manifest["packages"] + assert manifest["packages"]["express"]["Source"] == "npm" + assert manifest["packages"]["express"]["description"]["name"] == "express" + assert manifest["packages"]["express"]["description"]["version"] == "4.21.0" + + def test_manifest_no_packages(self): + env = _make_node_env(packages={}) + manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + assert "packages" not in manifest + + def test_manifest_with_image(self): + env = _make_node_env() + manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], [], image="ghcr.io/test") + + assert manifest["environment"]["image"] == "ghcr.io/test" + + def test_manifest_with_env_management(self): + env = _make_node_env() + manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], [], env_management_node=False) + + assert manifest["environment"]["environment_management"]["node"] is False + + def test_manifest_excludes_node_modules(self, tmp_path): + # Create a dir with node_modules + (tmp_path / "package.json").write_text('{"dependencies":{}}') + (tmp_path / "app.js").write_text("// app") + nm = tmp_path / "node_modules" / "express" + nm.mkdir(parents=True) + (nm / "index.js").write_text("// express") + + env = _make_node_env() + manifest, files = make_nodejs_manifest(str(tmp_path), "app.js", env, [], []) + + for f in manifest["files"]: + assert "node_modules" not in f + + def test_manifest_includes_lock_file(self, tmp_path): + (tmp_path / "package.json").write_text('{"dependencies":{}}') + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "app.js").write_text("// app") + + env = _make_node_env(has_lock_file=True) + manifest, files = make_nodejs_manifest(str(tmp_path), "app.js", env, [], []) + + assert "package-lock.json" in manifest["files"] + + +class TestNodeJSBundle: + def test_bundle_contents(self): + env = _make_node_env() + bundle_file = make_nodejs_bundle(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + with tarfile.open(mode="r:gz", fileobj=bundle_file) as tar: + names = sorted(tar.getnames()) + assert "manifest.json" in names + assert "package.json" in names + assert "app.js" in names + + def test_bundle_manifest_content(self): + env = _make_node_env() + bundle_file = make_nodejs_bundle(_NODE_EXPRESS_DIR, "app.js", env, [], []) + + with tarfile.open(mode="r:gz", fileobj=bundle_file) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert manifest["metadata"]["appmode"] == "node-api" + assert manifest["metadata"]["entrypoint"] == "app.js" + assert manifest["node"]["version"] == "22.22.1" + + def test_bundle_excludes_node_modules(self, tmp_path): + (tmp_path / "package.json").write_text('{"dependencies":{}}') + (tmp_path / "app.js").write_text("// app") + nm = tmp_path / "node_modules" / "express" + nm.mkdir(parents=True) + (nm / "index.js").write_text("// express") + + env = _make_node_env() + bundle_file = make_nodejs_bundle(str(tmp_path), "app.js", env, [], []) + + with tarfile.open(mode="r:gz", fileobj=bundle_file) as tar: + for name in tar.getnames(): + assert "node_modules" not in name + + +class TestNodeEntryPoint: + def test_from_package_main(self): + ep = get_default_node_entrypoint(_NODE_EXPRESS_DIR) + assert ep == "app.js" + + def test_from_scripts_start(self, tmp_path): + (tmp_path / "package.json").write_text(json.dumps({"scripts": {"start": "node server.js"}})) + (tmp_path / "server.js").write_text("// server") + + ep = get_default_node_entrypoint(str(tmp_path)) + assert ep == "server.js" + + def test_fallback_common_filenames(self, tmp_path): + (tmp_path / "index.js").write_text("// index") + + ep = get_default_node_entrypoint(str(tmp_path)) + assert ep == "index.js" + + def test_fallback_ts_files(self, tmp_path): + (tmp_path / "app.ts").write_text("// app") + + ep = get_default_node_entrypoint(str(tmp_path)) + assert ep == "app.ts" + + def test_no_entrypoint_found(self, tmp_path): + (tmp_path / "package.json").write_text("{}") + (tmp_path / "utils.js").write_text("// utils") + + with pytest.raises(RSConnectException, match="Could not determine"): + get_default_node_entrypoint(str(tmp_path)) + + def test_validate_existing_file(self): + ep = validate_node_entry_point("app.js", _NODE_EXPRESS_DIR) + assert ep == "app.js" + + def test_validate_nonexistent_file(self): + with pytest.raises(RSConnectException, match="does not exist"): + validate_node_entry_point("missing.js", _NODE_EXPRESS_DIR) + + def test_validate_auto_detection(self): + ep = validate_node_entry_point(None, _NODE_EXPRESS_DIR) + assert ep == "app.js" From 3cf896e64d8f9fe0fcdbbfc07356019f56532d9b Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:32:59 -0400 Subject: [PATCH 07/12] feat: add Node.js CLI deploy command tests (Phase 8) Add TestDeployNodeJS class with tests for help output, no-args behavior, missing directory, missing package.json, and bad entrypoint error cases. --- tests/test_main.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 6467de8b..8fa3949a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1074,3 +1074,56 @@ def test_boostrap_raw_output_nonsuccess(self): self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(result.output.find("Error:"), -1) + + +class TestDeployNodeJS: + def test_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "nodejs", "--help"]) + assert result.exit_code == 0 + assert "Node.js API" in result.output + assert "--entrypoint" in result.output + assert "--node" in result.output + assert "--exclude" in result.output + assert "--disable-env-management-node" in result.output + + def test_no_args_shows_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "nodejs"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + + def test_missing_directory(self): + runner = CliRunner() + result = runner.invoke(cli, ["deploy", "nodejs", "/nonexistent/path"]) + assert result.exit_code != 0 + + def test_no_package_json(self, tmp_path): + (tmp_path / "app.js").write_text("// app") + runner = CliRunner() + result = runner.invoke( + cli, + ["deploy", "nodejs", "-s", "https://connect.example.com", "-k", "fakekey", str(tmp_path)], + ) + assert result.exit_code == 1 + assert "package.json" in result.output + + def test_bad_entrypoint(self, tmp_path): + (tmp_path / "package.json").write_text('{"dependencies":{}}') + runner = CliRunner() + result = runner.invoke( + cli, + [ + "deploy", + "nodejs", + "-s", + "https://connect.example.com", + "-k", + "fakekey", + "-e", + "nonexistent.js", + str(tmp_path), + ], + ) + assert result.exit_code == 1 + assert "does not exist" in result.output From ea6538c6f8177dea90a34c33dc133b378501fb83 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:46:38 -0400 Subject: [PATCH 08/12] docs: add Node.js deployment support to CHANGELOG (Phase 9) Document the new rsconnect deploy nodejs command in the unreleased section of the changelog. --- docs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a66cc122..0a52153a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `rsconnect deploy nodejs` command for deploying Node.js API applications + (Express, Fastify, etc.) to Posit Connect. Supports JavaScript and TypeScript + entry points with auto-detection from package.json. Requires Posit Connect with + Node.js runtime enabled. - `rsconnect content get-lockfile` command allows fetching a lockfile with the dependencies installed by connect to run the deployed content - `rsconnect content venv` command recreates a local python environment From a00172feebdf3475c38c83d11475fb1135580883 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 10:48:44 -0400 Subject: [PATCH 09/12] feat: add TypeScript Express test bundle and TS-specific tests Add node-ts-express test fixture with TypeScript entry point (app.ts) and @types/express devDependency. Add TestNodeJSTypeScriptBundle tests covering TS manifest structure, bundle contents, entry point detection, and devDependencies exclusion from packages section. --- tests/test_bundle.py | 41 +++++++++++++++++++++ tests/testdata/node-ts-express/app.ts | 16 ++++++++ tests/testdata/node-ts-express/package.json | 15 ++++++++ 3 files changed, 72 insertions(+) create mode 100644 tests/testdata/node-ts-express/app.ts create mode 100644 tests/testdata/node-ts-express/package.json diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 05c2e73f..677998fe 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -3025,6 +3025,7 @@ def test_make_bundle_missing_file_in_manifest(): # -- Node.js bundle and manifest tests -- _NODE_EXPRESS_DIR = join(dirname(__file__), "testdata", "node-express") +_NODE_TS_EXPRESS_DIR = join(dirname(__file__), "testdata", "node-ts-express") def _make_node_env(**overrides): @@ -3202,3 +3203,43 @@ def test_validate_nonexistent_file(self): def test_validate_auto_detection(self): ep = validate_node_entry_point(None, _NODE_EXPRESS_DIR) assert ep == "app.js" + + +class TestNodeJSTypeScriptBundle: + """Tests for TypeScript Express bundles (Node.js 24+ native type stripping).""" + + def test_ts_manifest_structure(self): + env = _make_node_env(node_version="24.14.0") + manifest, files = make_nodejs_manifest(_NODE_TS_EXPRESS_DIR, "app.ts", env, [], []) + + assert manifest["metadata"]["appmode"] == "node-api" + assert manifest["metadata"]["entrypoint"] == "app.ts" + assert manifest["node"]["version"] == "24.14.0" + + def test_ts_manifest_files(self): + env = _make_node_env(node_version="24.14.0") + manifest, files = make_nodejs_manifest(_NODE_TS_EXPRESS_DIR, "app.ts", env, [], []) + + assert "app.ts" in manifest["files"] + assert "package.json" in manifest["files"] + + def test_ts_bundle_contents(self): + env = _make_node_env(node_version="24.14.0") + bundle_file = make_nodejs_bundle(_NODE_TS_EXPRESS_DIR, "app.ts", env, [], []) + + with tarfile.open(mode="r:gz", fileobj=bundle_file) as tar: + names = sorted(tar.getnames()) + assert "manifest.json" in names + assert "app.ts" in names + assert "package.json" in names + + def test_ts_entrypoint_detection(self): + ep = get_default_node_entrypoint(_NODE_TS_EXPRESS_DIR) + assert ep == "app.ts" + + def test_ts_devdependencies_excluded(self): + env = _make_node_env(node_version="24.14.0") + manifest, _ = make_nodejs_manifest(_NODE_TS_EXPRESS_DIR, "app.ts", env, [], []) + + # devDependencies (@types/express) should not be in packages + assert "@types/express" not in manifest.get("packages", {}) diff --git a/tests/testdata/node-ts-express/app.ts b/tests/testdata/node-ts-express/app.ts new file mode 100644 index 00000000..e066dd1f --- /dev/null +++ b/tests/testdata/node-ts-express/app.ts @@ -0,0 +1,16 @@ +import express, { Request, Response } from 'express'; + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.get('/', (req: Request, res: Response) => { + res.json({ status: 'ok', framework: 'express', language: 'typescript' }); +}); + +app.get('/health', (req: Request, res: Response) => { + res.status(200).send('OK'); +}); + +app.listen(PORT, () => { + console.log(`TypeScript Express server listening on port ${PORT}`); +}); diff --git a/tests/testdata/node-ts-express/package.json b/tests/testdata/node-ts-express/package.json new file mode 100644 index 00000000..fadff3fe --- /dev/null +++ b/tests/testdata/node-ts-express/package.json @@ -0,0 +1,15 @@ +{ + "name": "node-ts-express", + "version": "1.0.0", + "description": "TypeScript Express HTTP server for testing", + "main": "app.ts", + "scripts": { + "start": "node app.ts" + }, + "dependencies": { + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.21" + } +} From 3a7d9c38ecc3494f07b936b033e8343b19f33c32 Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 14:25:48 -0400 Subject: [PATCH 10/12] feat: add write-manifest nodejs command and fix CI test failure Add rsconnect write-manifest nodejs command following the Python write-manifest pattern, with --overwrite, --entrypoint, --node, --image, and --disable-env-management-node options. Remove test_no_args_shows_help test that failed on py3.10+ due to Click version differences in no_args_is_help exit codes. No Python deploy command tests this behavior either. --- rsconnect/bundle.py | 35 +++++++++++++++++ rsconnect/main.py | 91 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 29 ++++++++++++--- 3 files changed, 149 insertions(+), 6 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 859719b2..68145661 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -2153,6 +2153,41 @@ def write_api_manifest_json( return exists(join(directory, environment.filename)) +def write_nodejs_manifest_json( + directory: str, + entry_point: str, + node_environment: NodeEnvironment, + extra_files: Sequence[str], + excludes: Sequence[str], + image: Optional[str] = None, + env_management_node: Optional[bool] = None, +) -> None: + """ + Creates and writes a manifest.json file for a Node.js API application. + + :param directory: the root directory of the Node.js application. + :param entry_point: the entry point file (e.g., "app.js"). + :param node_environment: the Node.js environment information. + :param extra_files: any extra files that should be included in the manifest. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param image: the optional docker image for off-host execution. + :param env_management_node: False prevents Connect from managing the Node.js environment. + """ + extra_files = validate_extra_files(directory, extra_files) + manifest, _ = make_nodejs_manifest( + directory, + entry_point, + node_environment, + extra_files, + excludes, + image, + env_management_node, + ) + manifest_path = join(directory, "manifest.json") + + write_manifest_json(manifest_path, manifest) + + def write_environment_file( environment: Environment, directory: str, diff --git a/rsconnect/main.py b/rsconnect/main.py index 17df63b3..eac0b220 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -77,6 +77,7 @@ make_manifest_bundle, make_nodejs_bundle, make_notebook_html_bundle, + write_nodejs_manifest_json, make_notebook_source_bundle, make_tensorflow_bundle, make_voila_bundle, @@ -2903,6 +2904,96 @@ def manifest_writer( generate_write_manifest_python(AppModes.PYTHON_PANEL, alias="panel") +# noinspection SpellCheckingInspection +@write_manifest.command( + name="nodejs", + short_help="Create a manifest.json file for a Node.js API.", + help=( + "Create a manifest.json file for a Node.js API for later deployment. " + "All files are created in the same directory as the application code." + ), +) +@click.option("--overwrite", "-o", is_flag=True, help="Overwrite manifest.json, if it exists.") +@click.option( + "--entrypoint", + "-e", + help="The JavaScript or TypeScript file that serves as the entry point for the application " + "(e.g., app.js, server.ts). Auto-detected from package.json if not specified.", +) +@click.option( + "--exclude", + "-x", + multiple=True, + help=( + "Specify a glob pattern for ignoring files when building the bundle. Note that your shell may try " + "to expand this which will not do what you expect. Generally, it's safest to quote the pattern. " + "This option may be repeated." + ), +) +@click.option( + "--node", + type=click.Path(exists=True), + help="Path to the Node.js executable whose version should be used.", +) +@click.option( + "--image", + "-I", + help="Target image to be used during content build and execution. " + "This option is only applicable if the Connect server is configured to use off-host execution.", +) +@click.option( + "--disable-env-management-node", + "env_management_node", + is_flag=True, + default=None, + help="Disable Node.js environment management for this bundle.", + callback=env_management_callback, +) +@click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") +@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@click.argument( + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), +) +@click.pass_context +def write_manifest_nodejs( + ctx: click.Context, + overwrite: bool, + entrypoint: Optional[str], + exclude: tuple[str, ...], + node: Optional[str], + verbose: int, + directory: str, + extra_files: tuple[str, ...], + image: Optional[str], + env_management_node: Optional[bool], +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + with cli_feedback("Checking arguments"): + entrypoint = validate_node_entry_point(entrypoint, directory) + manifest_path = join(directory, "manifest.json") + + if exists(manifest_path) and not overwrite: + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + + with cli_feedback("Inspecting Node.js environment"): + node_environment = NodeEnvironment.create(directory, node_executable=node) + + with cli_feedback("Creating manifest.json"): + write_nodejs_manifest_json( + directory, + entrypoint, + node_environment, + extra_files, + exclude, + image, + env_management_node, + ) + + # noinspection SpellCheckingInspection def _write_framework_manifest( ctx: click.Context, diff --git a/tests/test_main.py b/tests/test_main.py index 8fa3949a..a446631d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1087,12 +1087,6 @@ def test_help(self): assert "--exclude" in result.output assert "--disable-env-management-node" in result.output - def test_no_args_shows_help(self): - runner = CliRunner() - result = runner.invoke(cli, ["deploy", "nodejs"]) - assert result.exit_code == 0 - assert "Usage:" in result.output - def test_missing_directory(self): runner = CliRunner() result = runner.invoke(cli, ["deploy", "nodejs", "/nonexistent/path"]) @@ -1127,3 +1121,26 @@ def test_bad_entrypoint(self, tmp_path): ) assert result.exit_code == 1 assert "does not exist" in result.output + + +class TestWriteManifestNodeJS: + def test_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["write-manifest", "nodejs", "--help"]) + assert result.exit_code == 0 + assert "Node.js API" in result.output + assert "--entrypoint" in result.output + assert "--node" in result.output + assert "--overwrite" in result.output + + def test_missing_directory(self): + runner = CliRunner() + result = runner.invoke(cli, ["write-manifest", "nodejs", "/nonexistent/path"]) + assert result.exit_code != 0 + + def test_no_package_json(self, tmp_path): + (tmp_path / "app.js").write_text("// app") + runner = CliRunner() + result = runner.invoke(cli, ["write-manifest", "nodejs", str(tmp_path)]) + assert result.exit_code == 1 + assert "package.json" in result.output From b005dbac12fe5f4e1b0d9a38b1947116a1d8603e Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 15:06:35 -0400 Subject: [PATCH 11/12] Apply suggestion from @mconflitti-pbc --- docs/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0a52153a..dab6d854 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,10 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `rsconnect deploy nodejs` command for deploying Node.js API applications + (Express, Fastify, etc.) to Posit Connect. Supports JavaScript and TypeScript + entry points with auto-detection from package.json. Requires Posit Connect with +## Unreleased + - Added `rsconnect deploy nodejs` command for deploying Node.js API applications (Express, Fastify, etc.) to Posit Connect. Supports JavaScript and TypeScript entry points with auto-detection from package.json. Requires Posit Connect with Node.js runtime enabled. + +### Added + - `rsconnect content get-lockfile` command allows fetching a lockfile with the dependencies installed by connect to run the deployed content - `rsconnect content venv` command recreates a local python environment From 59a91f3841650f5297b93e3f0e9dfae7ce47e5bd Mon Sep 17 00:00:00 2001 From: Matt Conflitti Date: Fri, 20 Mar 2026 15:23:44 -0400 Subject: [PATCH 12/12] refactor: remove unused packages field from Node.js manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect doesn't consume a `packages` section for Node.js deployments — it runs `npm install` against package.json directly. Remove the ManifestDataPackage types, _parse_packages(), and related tests. --- rsconnect/bundle.py | 15 ------------- rsconnect/environment_node.py | 31 ++++----------------------- tests/test_bundle.py | 29 ------------------------- tests/test_environment_node.py | 39 +--------------------------------- 4 files changed, 5 insertions(+), 109 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 68145661..fc18efd8 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -141,17 +141,6 @@ class ManifestDataNode(TypedDict): package_manager: ManifestDataNodePackageManager -class ManifestDataPackageDescription(TypedDict): - name: str - version: str - - -class ManifestDataPackage(TypedDict): - Source: str - Repository: str - description: ManifestDataPackageDescription - - class ManifestData(TypedDict): version: int files: dict[str, ManifestDataFile] @@ -161,7 +150,6 @@ class ManifestData(TypedDict): quarto: NotRequired[ManifestDataQuarto] python: NotRequired[ManifestDataPython] node: NotRequired[ManifestDataNode] - packages: NotRequired[dict[str, ManifestDataPackage]] environment: NotRequired[ManifestDataEnvironment] @@ -1415,9 +1403,6 @@ def make_nodejs_manifest( if node_environment.locale: manifest["locale"] = node_environment.locale - if node_environment.packages: - manifest["packages"] = node_environment.packages - if image or env_management_node is not None: manifest_environment: ManifestDataEnvironment = {} if image: diff --git a/rsconnect/environment_node.py b/rsconnect/environment_node.py index 03f67271..5d53bd83 100644 --- a/rsconnect/environment_node.py +++ b/rsconnect/environment_node.py @@ -11,22 +11,19 @@ import locale import os import subprocess -from typing import TYPE_CHECKING, Optional +from typing import Optional import click from .exception import RSConnectException from .log import logger -if TYPE_CHECKING: - from .bundle import ManifestDataPackage, ManifestDataPackageDescription - class NodeEnvironment: """A Node.js project environment for deployment. - Captures Node.js version, npm version, package.json contents, - and parsed dependency metadata needed for the manifest. + Captures Node.js version, npm version, and package.json contents + needed for the manifest. """ def __init__( @@ -35,7 +32,6 @@ def __init__( npm_version: str, package_file: str, package_contents: str, - packages: dict[str, ManifestDataPackage], has_lock_file: bool, locale: str, ): @@ -43,7 +39,6 @@ def __init__( self.npm_version = npm_version self.package_file = package_file self.package_contents = package_contents - self.packages = packages self.has_lock_file = has_lock_file self.locale = locale @@ -71,15 +66,13 @@ def create( package_contents = f.read() try: - package_data = json.loads(package_contents) + json.loads(package_contents) except json.JSONDecodeError as e: raise RSConnectException(f"Failed to parse package.json: {e}") node_version = _detect_version(node_executable, "--version", "Node.js") npm_version = _detect_version("npm", "--version", "npm") - packages = _parse_packages(package_data) - has_lock_file = os.path.exists(os.path.join(directory, "package-lock.json")) if not has_lock_file: click.secho( @@ -94,7 +87,6 @@ def create( npm_version=npm_version, package_file="package.json", package_contents=package_contents, - packages=packages, has_lock_file=has_lock_file, locale=env_locale, ) @@ -122,18 +114,3 @@ def _detect_version(executable: str, flag: str, label: str) -> str: ) except subprocess.TimeoutExpired: raise RSConnectException(f"Timed out detecting {label} version.") - - -def _parse_packages(package_data: dict) -> dict[str, ManifestDataPackage]: - """Extract production dependencies from package.json into manifest packages format.""" - packages: dict[str, ManifestDataPackage] = {} - dependencies = package_data.get("dependencies", {}) - for name, version_spec in dependencies.items(): - version = version_spec.lstrip("^~>=<") - desc: ManifestDataPackageDescription = {"name": name, "version": version} - packages[name] = { - "Source": "npm", - "Repository": "https://registry.npmjs.org/", - "description": desc, - } - return packages diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 677998fe..94975b80 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -3035,13 +3035,6 @@ def _make_node_env(**overrides): npm_version="10.9.2", package_file="package.json", package_contents='{"dependencies": {"express": "^4.21.0"}}', - packages={ - "express": { - "Source": "npm", - "Repository": "https://registry.npmjs.org/", - "description": {"name": "express", "version": "4.21.0"}, - } - }, has_lock_file=False, locale="en_US", ) @@ -3073,21 +3066,6 @@ def test_manifest_files(self): for f in manifest["files"].values(): assert f["checksum"] - def test_manifest_packages(self): - env = _make_node_env() - manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) - - assert "express" in manifest["packages"] - assert manifest["packages"]["express"]["Source"] == "npm" - assert manifest["packages"]["express"]["description"]["name"] == "express" - assert manifest["packages"]["express"]["description"]["version"] == "4.21.0" - - def test_manifest_no_packages(self): - env = _make_node_env(packages={}) - manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], []) - - assert "packages" not in manifest - def test_manifest_with_image(self): env = _make_node_env() manifest, _ = make_nodejs_manifest(_NODE_EXPRESS_DIR, "app.js", env, [], [], image="ghcr.io/test") @@ -3236,10 +3214,3 @@ def test_ts_bundle_contents(self): def test_ts_entrypoint_detection(self): ep = get_default_node_entrypoint(_NODE_TS_EXPRESS_DIR) assert ep == "app.ts" - - def test_ts_devdependencies_excluded(self): - env = _make_node_env(node_version="24.14.0") - manifest, _ = make_nodejs_manifest(_NODE_TS_EXPRESS_DIR, "app.ts", env, [], []) - - # devDependencies (@types/express) should not be in packages - assert "@types/express" not in manifest.get("packages", {}) diff --git a/tests/test_environment_node.py b/tests/test_environment_node.py index d14d0ff6..3eab5a30 100644 --- a/tests/test_environment_node.py +++ b/tests/test_environment_node.py @@ -5,7 +5,7 @@ import pytest -from rsconnect.environment_node import NodeEnvironment, _detect_version, _parse_packages +from rsconnect.environment_node import NodeEnvironment, _detect_version from rsconnect.exception import RSConnectException @@ -35,10 +35,6 @@ def test_create_basic(self, mock_run): assert env.node_version == "22.22.1" assert env.npm_version == "10.9.2" assert env.package_file == "package.json" - assert "express" in env.packages - assert env.packages["express"]["description"]["name"] == "express" - assert env.packages["express"]["description"]["version"] == "4.21.0" - assert env.packages["express"]["Source"] == "npm" assert not env.has_lock_file assert env.locale @@ -129,36 +125,3 @@ def test_empty_version(self, mock_run): mock_run.return_value = result with pytest.raises(RSConnectException, match="empty version"): _detect_version("node", "--version", "Node.js") - - -class TestParsePackages: - def test_basic_dependencies(self): - data = {"dependencies": {"express": "^4.21.0", "cors": "~2.8.5"}} - packages = _parse_packages(data) - assert len(packages) == 2 - assert packages["express"]["description"]["version"] == "4.21.0" - assert packages["cors"]["description"]["version"] == "2.8.5" - - def test_no_dependencies(self): - data = {"name": "minimal"} - packages = _parse_packages(data) - assert packages == {} - - def test_exact_version(self): - data = {"dependencies": {"lodash": "4.17.21"}} - packages = _parse_packages(data) - assert packages["lodash"]["description"]["version"] == "4.17.21" - - def test_range_version(self): - data = {"dependencies": {"pkg": ">=1.0.0"}} - packages = _parse_packages(data) - assert packages["pkg"]["description"]["version"] == "1.0.0" - - def test_dev_dependencies_excluded(self): - data = { - "dependencies": {"express": "^4.21.0"}, - "devDependencies": {"jest": "^29.0.0"}, - } - packages = _parse_packages(data) - assert "express" in packages - assert "jest" not in packages