diff --git a/rockcraft/extensions/__init__.py b/rockcraft/extensions/__init__.py
index 621619b56..1815ebc35 100644
--- a/rockcraft/extensions/__init__.py
+++ b/rockcraft/extensions/__init__.py
@@ -17,6 +17,7 @@
"""Extension processor and related utilities."""
from ._utils import apply_extensions
+from .expressjs import ExpressJSFramework
from .fastapi import FastAPIFramework
from .go import GoFramework
from .gunicorn import DjangoFramework, FlaskFramework
@@ -31,6 +32,7 @@
]
register("django-framework", DjangoFramework)
+register("expressjs-framework", ExpressJSFramework)
register("fastapi-framework", FastAPIFramework)
register("flask-framework", FlaskFramework)
register("go-framework", GoFramework)
diff --git a/rockcraft/extensions/expressjs.py b/rockcraft/extensions/expressjs.py
new file mode 100644
index 000000000..e6db3292d
--- /dev/null
+++ b/rockcraft/extensions/expressjs.py
@@ -0,0 +1,194 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2024 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""An extension for the NodeJS based Javascript application extension."""
+import json
+import re
+from typing import Any, Dict, Tuple
+
+from overrides import override
+
+from ..errors import ExtensionError
+from .extension import Extension
+
+
+class ExpressJSFramework(Extension):
+ """An extension for constructing Javascript applications based on the ExpressJS framework."""
+
+ IMAGE_BASE_DIR = "app/"
+ EXPRESS_GENERATOR_DIRS = [
+ "bin",
+ "public",
+ "routes",
+ "views",
+ "app.js",
+ "package.json",
+ "package-lock.json",
+ "node_modules",
+ ]
+ RUNTIME_DEPENDENCIES = ["ca-certificates_data", "libpq5", "node"]
+
+ @staticmethod
+ @override
+ def get_supported_bases() -> Tuple[str, ...]:
+ """Return supported bases."""
+ return "bare", "ubuntu@22.04", "ubuntu@24.04"
+
+ @staticmethod
+ @override
+ def is_experimental(base: str | None) -> bool:
+ """Check if the extension is in an experimental state."""
+ return True
+
+ @override
+ def get_root_snippet(self) -> Dict[str, Any]:
+ """Fill in some default root components.
+
+ Default values:
+ - run_user: _daemon_
+ - build-base: ubuntu:22.04 (only if user specify bare without a build-base)
+ - platform: amd64
+ - services: a service to run the ExpressJS server
+ - parts: see ExpressJSFramework._gen_parts
+ """
+ self._check_project()
+
+ snippet: Dict[str, Any] = {
+ "run-user": "_daemon_",
+ "services": {
+ "app": {
+ "override": "replace",
+ "command": "npm start",
+ "startup": "enabled",
+ "on-success": "shutdown",
+ "on-failure": "shutdown",
+ "working-dir": "/app",
+ }
+ },
+ }
+
+ snippet["parts"] = {
+ "expressjs-framework/install-app": self._gen_install_app_part(),
+ "expressjs-framework/runtime-dependencies": self._gen_runtime_dependencies_part(),
+ }
+ return snippet
+
+ @override
+ def get_part_snippet(self) -> dict[str, Any]:
+ """Return the part snippet to apply to existing parts.
+
+ This is unused but is required by the ABC.
+ """
+ return {}
+
+ @override
+ def get_parts_snippet(self) -> dict[str, Any]:
+ """Return the parts to add to parts.
+
+ This is unused but is required by the ABC.
+ """
+ return {}
+
+ def _gen_install_app_part(self) -> dict:
+ """Generate the install app part using NPM plugin."""
+ return {
+ "plugin": "npm",
+ "npm-include-node": False,
+ "source": "app/",
+ "organise": self._app_organise,
+ "override-prime": f"rm -rf lib/node_modules/{self._app_name}",
+ }
+
+ def _gen_runtime_dependencies_part(self) -> dict:
+ """Generate the install dependencies part using dump plugin."""
+ return {
+ "plugin": "nil",
+ "stage-packages": self.RUNTIME_DEPENDENCIES,
+ }
+
+ @property
+ def _app_package_json(self):
+ """Return the app package.json contents."""
+ package_json_file = self.project_root / "package.json"
+ if not package_json_file.exists():
+ raise ExtensionError(
+ "missing package.json file",
+ doc_slug="/reference/extensions/expressjs-framework",
+ logpath_report=False,
+ )
+ package_json_contents = package_json_file.read_text(encoding="utf-8")
+ return json.loads(package_json_contents)
+
+ @property
+ def _app_name(self) -> str:
+ """Return the application name as defined on package.json."""
+ return self._app_package_json["name"]
+
+ @property
+ def _app_organise(self):
+ """Return the organised mapping for the ExpressJS project.
+
+ Use the paths generated by the
+ express-generator (https://expressjs.com/en/starter/generator.html) tool by default if no
+ user prime paths are provided. Use only user prime paths otherwise.
+ """
+ user_prime: list[str] = (
+ self.yaml_data.get("parts", {})
+ .get("expressjs-framework/install-app", {})
+ .get("prime", [])
+ )
+ print(user_prime)
+ if not all(re.match(f"-? *{self.IMAGE_BASE_DIR}/", p) for p in user_prime):
+ raise ExtensionError(
+ "expressjs-framework extension requires the 'prime' entry in the "
+ f"expressjs-framework/install-app part to start with {self.IMAGE_BASE_DIR}/",
+ doc_slug="/reference/extensions/expressjs-framework",
+ logpath_report=False,
+ )
+ if not user_prime:
+ user_prime = self.EXPRESS_GENERATOR_DIRS
+ project_relative_file_paths = [
+ prime_path.removeprefix(self.IMAGE_BASE_DIR) for prime_path in user_prime
+ ]
+ lib_dir = f"lib/node_modules/{self._app_name}"
+ return {
+ f"{lib_dir}/{f}": f"app/{f}"
+ for f in project_relative_file_paths
+ if (self.project_root / "app" / f).exists()
+ }
+
+ def _check_project(self):
+ """Ensure this extension can apply to the current rockcraft project.
+
+ The ExpressJS framework assumes that:
+ - The npm start script exists.
+ - The application name is defined.
+ """
+ if (
+ "scripts" not in self._app_package_json
+ or "start" not in self._app_package_json["scripts"]
+ ):
+ raise ExtensionError(
+ "missing start script",
+ doc_slug="/reference/extensions/expressjs-framework",
+ logpath_report=False,
+ )
+ if "name" not in self._app_package_json:
+ raise ExtensionError(
+ "missing application name",
+ doc_slug="/reference/extensions/expressjs-framework",
+ logpath_report=False,
+ )
diff --git a/tests/unit/extensions/test_expressjs.py b/tests/unit/extensions/test_expressjs.py
new file mode 100644
index 000000000..ba05dc0b7
--- /dev/null
+++ b/tests/unit/extensions/test_expressjs.py
@@ -0,0 +1,153 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2024 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+import pytest
+
+from rockcraft import extensions
+from rockcraft.errors import ExtensionError
+
+
+@pytest.fixture(name="expressjs_input_yaml")
+def expressjs_input_yaml_fixture():
+ return {
+ "name": "foo-bar",
+ "base": "ubuntu@24.04",
+ "platforms": {"amd64": {}},
+ "extensions": ["expressjs-framework"],
+ }
+
+
+@pytest.fixture
+def expressjs_extension(mock_extensions, monkeypatch):
+ monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1")
+ extensions.register("expressjs-framework", extensions.ExpressJSFramework)
+
+
+@pytest.fixture
+def expressjs_project_name():
+ return "test-expressjs-project"
+
+
+@pytest.fixture
+def package_json_file(tmp_path, expressjs_project_name):
+ (tmp_path / "package.json").write_text(
+ f"""{{
+ "name": "{expressjs_project_name}",
+ "scripts": {{
+ "start": "node ./bin/www"
+ }}
+}}"""
+ )
+
+
+@pytest.mark.usefixtures("expressjs_extension", "package_json_file")
+def test_expressjs_extension_default(
+ tmp_path, expressjs_project_name, expressjs_input_yaml
+):
+ applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml)
+
+ assert applied == {
+ "base": "ubuntu@24.04",
+ "name": "foo-bar",
+ "platforms": {
+ "amd64": {},
+ },
+ "run-user": "_daemon_",
+ "parts": {
+ "expressjs-framework/install-app": {
+ "plugin": "npm",
+ "npm-include-node": False,
+ "source": "app/",
+ "organise": {
+ f"lib/node_modules/{expressjs_project_name}/package.json": "app/package.json",
+ },
+ "override-prime": f"rm -rf lib/node_modules/{expressjs_project_name}",
+ },
+ "expressjs-framework/runtime-dependencies": {
+ "plugin": "nil",
+ "stage-packages": [
+ "ca-certificates_data",
+ "libpq5",
+ "node",
+ ],
+ },
+ },
+ "services": {
+ "app": {
+ "command": "npm start",
+ "on-failure": "shutdown",
+ "on-success": "shutdown",
+ "override": "replace",
+ "startup": "enabled",
+ "working-dir": "/app",
+ },
+ },
+ }
+
+
+@pytest.mark.usefixtures("expressjs_extension")
+def test_expressjs_no_package_json_error(tmp_path, expressjs_input_yaml):
+ with pytest.raises(ExtensionError) as exc:
+ extensions.apply_extensions(tmp_path, expressjs_input_yaml)
+ assert str(exc.value) == "missing package.json file"
+ assert str(exc.value.doc_slug) == "/reference/extensions/expressjs-framework"
+
+
+@pytest.mark.parametrize(
+ "package_json_contents, error_message",
+ [
+ ("{}", "missing start script"),
+ ('{"scripts":{}}', "missing start script"),
+ ('{"scripts":{"start":"node ./bin/www"}}', "missing application name"),
+ ],
+)
+@pytest.mark.usefixtures("expressjs_extension")
+def test_expressjs_invalid_package_json_scripts_error(
+ tmp_path, expressjs_input_yaml, package_json_contents, error_message
+):
+ (tmp_path / "package.json").write_text(package_json_contents)
+ with pytest.raises(ExtensionError) as exc:
+ extensions.apply_extensions(tmp_path, expressjs_input_yaml)
+ assert str(exc.value) == error_message
+ assert str(exc.value.doc_slug) == "/reference/extensions/expressjs-framework"
+
+
+@pytest.mark.parametrize(
+ "existing_files, missing_files, expected_organise",
+ [
+ pytest.param(
+ ["lib/node_modules/test-expressjs-project/app.js"],
+ [],
+ {"lib/node_modules/test-expressjs-project/app.js": "app/app.js"},
+ id="single file defined",
+ ),
+ ],
+)
+@pytest.mark.usefixtures("expressjs_extension", "package_json_file")
+def test_expressjs_install_app_prime_to_organise_map(
+ tmp_path, expressjs_input_yaml, existing_files, missing_files, expected_organise
+):
+ for file in existing_files:
+ (tmp_path / file).parent.mkdir(parents=True)
+ (tmp_path / file).touch()
+ prime_files = [*existing_files, *missing_files]
+ expressjs_input_yaml["parts"] = {
+ "expressjs-framework/install-app": {"prime": prime_files}
+ }
+ applied = extensions.apply_extensions(tmp_path, expressjs_input_yaml)
+ assert (
+ applied["parts"]["expressjs-framework/install-app"]["organise"]
+ == expected_organise
+ )