Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions docs/reference/extensions/express-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ The Express extension streamlines the process of building Express
application rocks.

It facilitates the installation of Express application dependencies, including
Node.js and npm, inside the rock. Additionally, it transfers your project files
to ``/app`` within the rock.
Node.js and npm, inside the rock. Extension discovers location of the
``package.json``, but can only package a single application. If application
defines ``build`` script, development dependencies will be installed,
``npm run build`` called, and entries matching ``file`` array (excluding
entries from ``.npmignore``, if exists) will be packages. If ``files``
array is not defined and ``.npmignore`` does not exist, only files from
the ``dist/`` directory will be packaged.

By default, the system foundation, or base, is set as ``bare`` to generate a
lightweight image.
Expand All @@ -23,14 +28,19 @@ bases.
Project requirements
--------------------

There are 3 requirements to be able to use the ``expressjs-framework``
There are two requirements to be able to use the ``expressjs-framework``
extension:

1. The application should reside in the ``app`` directory.
2. The application should have a ``package.json`` file.
3. The ``package.json`` file should define the ``start`` script.
1. The application should have a ``package.json`` file.
2. The ``package.json`` file should define the ``start`` script.
For more information, see the `npm documentation <https://docs.npmjs.com/cli/v11/configuring-npm/package-json>`_.

If application defines ``build`` script in ``package.json`` file, it is
recommended to have ``files`` array describing the entries to be included
or have an appropriate ``.npmignore`` file to exclude entries not required
at the run time. If ``files`` array is not defined and ``.npmignore``
does not exist, only the ``dist/`` directory will be packaged.

.. _reference-express-framework-npm-include-node:

Node.js version
Expand Down
197 changes: 184 additions & 13 deletions rockcraft/extensions/expressjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"""An extension for the NodeJS based Javascript application extension."""

import json
from pathlib import Path
from typing import Any, cast

import craft_cli
from typing_extensions import override

from rockcraft.errors import ExtensionError
Expand Down Expand Up @@ -144,19 +146,8 @@ def _gen_install_app_part(self) -> dict[str, Any]:
"""
install_app_part: dict[str, Any] = {
"plugin": "npm",
"source": f"{self.IMAGE_BASE_DIR}/",
"override-build": (
"rm -rf node_modules\n"
"craftctl default\n"
"npm config set script-shell=bash --location project\n"
"cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/"
f"{self._app_name}/.npmrc\n"
# we can not user `permissions` block here because it doesn't work with symlinks
# bug: https://github.com/canonical/rockcraft/issues/660
f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}\n"
f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app\n"
f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/app\n"
),
"source": f"{self._source_root}/",
"override-build": self._gen_override_build(),
}
if self._rock_base == "bare":
install_app_part["override-build"] = (
Expand Down Expand Up @@ -184,6 +175,41 @@ def _gen_install_app_part(self) -> dict[str, Any]:
install_app_part["build-environment"] = [{"UV_USE_IO_URING": "0"}]
return install_app_part

def _gen_override_build(self) -> str:
"""Generate the override-build script snippet.

Script consists of three parts:
- lines before 'craftctl default'
- 'craftctl default' invocation
- lines after 'craftctl default'
"""
return self._gen_shell_script(
self._gen_override_build_pre_default(),
["craftctl default"],
self._gen_override_build_post_default(),
)

def _gen_override_build_pre_default(self) -> list[str]:
"""Generate override-build script part used before 'craftctl default'."""
return ["rm -rf node_modules"]

def _gen_override_build_post_default(self) -> list[str]:
"""Generate override-build script part used after 'craftctl default'."""
return [
"npm config set script-shell=bash --location project",
"cp ${CRAFT_PART_BUILD}/.npmrc ${CRAFT_PART_INSTALL}/lib/node_modules/"
f"{self._app_name}/.npmrc",
# we can not user `permissions` block here because it doesn't work with symlinks
# bug: https://github.com/canonical/rockcraft/issues/660
f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}",
f"ln -s /lib/node_modules/{self._app_name} ${{CRAFT_PART_INSTALL}}/app",
f"chown -R {USER_UID}:{USER_UID} ${{CRAFT_PART_INSTALL}}/app",
]

def _gen_shell_script(self, *snippets: list[str]) -> str:
"""Generate multiline shell script from lists of strings."""
return "\n".join(line for fragment in snippets for line in fragment) + "\n"

def _gen_app_build_packages(self) -> list[str]:
"""Return the build packages for the install app part."""
if self._user_npm_include_node:
Expand Down Expand Up @@ -226,6 +252,11 @@ def _rock_base(self) -> str:
"""Return the base of the rockcraft project."""
return self.yaml_data["base"]

@property
def _source_root(self) -> str:
"""Return relative path to the source tree root directory."""
return self.IMAGE_BASE_DIR

@property
def _app_package_json(self) -> dict[str, Any]:
"""Return the app package.json contents."""
Expand Down Expand Up @@ -268,6 +299,13 @@ class ExpressJSFrameworkV2(ExpressJSFramework):
supported base and experimental status differs.
"""

PACKAGE_JSON = "package.json"

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._package_json_file_cache = None
self._app_package_json_cache = None

@staticmethod
@override
def get_supported_bases() -> tuple[str, ...]:
Expand All @@ -283,5 +321,138 @@ def is_experimental(base: str | None) -> bool:
"""
return True

def _gen_override_build_pre_default(self) -> list[str]:
"""Generate override-build script part used before 'craftctl default'."""
override_build = ["rm -rf node_modules"]
if self._has_build_script:
if not self._has_defined_files:
craft_cli.emit.warning(
"No .npmignore or '.files[]' in package.json, all files will be packaged"
)
craft_cli.emit.warning(
"This may not be what you want to pack after running 'npm run build'"
)
override_build.extend(
[
"npm install --include=dev",
"npm run build",
]
)
# Note: there is no need to cleanup node_modules locally (like 'npm prune --omit=dev')
# npm_plugin calls 'npm pack' and it always ignores node_modules
# see https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files
# npm install will user package.json from tgz to obtain the list of modules later
return override_build

def _gen_override_build_post_default(self) -> list[str]:
"""Generate override-build script part used after 'craftctl default'."""
override_build = []
if self._has_build_script: # Q: and not self._has_defined_files: ?
override_build.extend(
[
f"mkdir -p ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}/dist",
f"cp -r dist/. ${{CRAFT_PART_INSTALL}}/lib/node_modules/{self._app_name}/dist",
]
)
override_build.extend(super()._gen_override_build_post_default())
return override_build

@property
def _source_root(self) -> str:
"""Return relative path to the source tree root directory."""
parent = self._package_json_file.parent
if self.project_root == parent:
return "."
return str(parent.relative_to(self.project_root))

@property
def _package_json_file(self) -> Path:
"""Return package.json path.

Search for package.json in the project root directory first.
If not found, search non-hidden subdirectories exactly one level deep.

Returns:
Path: path to the discovered package.json file;

Raises:
ExtensionError If the file is missing entirely, or if multiple
package.json files are found in the subdirectories.

"""
if self._package_json_file_cache is not None:
return self._package_json_file_cache

root_package_json = self.project_root / self.PACKAGE_JSON
if root_package_json.is_file():
self._package_json_file_cache = root_package_json
return root_package_json

package_json = None
for path in self.project_root.glob(f"[!.]*/{self.PACKAGE_JSON}"):
if path.is_file():
if package_json is not None: # more than one found: error
raise ExtensionError(
"multiple package.json files",
doc_slug="/reference/extensions/express-framework/#project-requirements",
logpath_report=False,
)
package_json = path

if package_json is None:
raise ExtensionError(
"missing package.json file",
doc_slug="/reference/extensions/express-framework/#project-requirements",
logpath_report=False,
)

self._package_json_file_cache = package_json
return package_json

@property
def _app_package_json(self) -> dict[str, Any]:
"""Return the app package.json contents."""
if self._app_package_json_cache is not None:
return self._app_package_json_cache

package_json_contents = self._package_json_file.read_text(encoding="utf-8")
try:
app_package_json = json.loads(package_json_contents)
if not isinstance(app_package_json, dict):
raise ExtensionError(
"invalid package.json file",
doc_slug="/reference/extensions/express-framework/#project-requirements",
logpath_report=False,
)
except json.JSONDecodeError as exc:
raise ExtensionError(
"failed to parse package.json; it might contain invalid JSON",
doc_slug="/reference/extensions/express-framework/#project-requirements",
logpath_report=False,
) from exc
else:
self._app_package_json_cache = cast(dict[str, Any], app_package_json)
return self._app_package_json_cache

@property
def _app_name(self) -> str:
"""Return the application name as defined on package.json."""
return self._app_package_json["name"]

@property
def _has_defined_files(self) -> bool:
"""Check if package.json::files[] is defined or .npmignore exists."""
return (
"files" in self._app_package_json
or (self._package_json_file.parent / ".npmignore").is_file()
)

@property
def _has_build_script(self) -> bool:
return (
"scripts" in self._app_package_json
and "build" in self._app_package_json["scripts"]
)


ExpressJSFrameworkFactory = _FrameworkFactory(ExpressJSFramework, ExpressJSFrameworkV2)
3 changes: 3 additions & 0 deletions tests/spread/rockcraft/extension-expressjs-build/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package-lock.json
src
*.yaml
18 changes: 18 additions & 0 deletions tests/spread/rockcraft/extension-expressjs-build/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "express-typescript-app",
"version": "1.0.0",
"description": "Core Express runtime with TypeScript development environment",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^5.0.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.14.9",
"typescript": "^5.5.2"
}
}
11 changes: 11 additions & 0 deletions tests/spread/rockcraft/extension-expressjs-build/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import express, { Request, Response, Express } from 'express';

const app: Express = express();

const port: number = 3000;

app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
});

app.listen(port, () => console.log(`Server listening on :${port}`));
Loading
Loading