Node.js Workflows
This page covers how to ship Node.js apps with their npm dependencies on a yoe
image. yoe doesn’t use npm as a package manager — npm-installed packages live in
a per-app node_modules tree baked into a regular apk, so the on-device package
manager stays apk-only and rebuilding the image rebuilds node_modules from
your package.json (and package-lock.json if present).
Packaging a Node.js app with npm dependencies
The nodejs_app class in module-core/classes/nodejs.star creates an app
directory under /usr/lib/node-apps/<name> on the target, runs npm install
against your package.json so the listed packages land in node_modules/ next
to your code, and ships the whole tree as a regular .apk. It gets the same
caching and signing as any other unit and brings in nodejs automatically via
runtime_deps.
Each app lives in its own source directory next to the unit’s .star file and
uses a normal Node.js project layout — package.json is the source of truth for
deps, exactly like a developer would use locally.
A minimal app:
load("//classes/nodejs.star", "nodejs_app")
nodejs_app(
name = "nodejs-hello",
version = "1.0.0",
description = "Greeter that renders ASCII art via figlet",
runtime_deps = ["busybox"], # /bin/sh for the wrapper
tasks = [
task("install-app", steps = [
install_file("package.json",
"$DESTDIR/usr/lib/node-apps/nodejs-hello/package.json",
mode = 0o644),
install_file("hello.js",
"$DESTDIR/usr/lib/node-apps/nodejs-hello/hello.js",
mode = 0o644),
"cat > $DESTDIR/usr/bin/nodejs-hello <<'__WRAP__'\n" +
"#!/bin/sh\n" +
"exec node /usr/lib/node-apps/nodejs-hello/hello.js \"$@\"\n" +
"__WRAP__",
"chmod 0755 $DESTDIR/usr/bin/nodejs-hello",
]),
],
)
With a package.json like this next to the unit:
{
"name": "nodejs-hello",
"version": "1.0.0",
"private": true,
"dependencies": {
"figlet": "1.7.0"
}
}
After yoe build nodejs-hello, the resulting apk installs:
/usr/lib/node-apps/nodejs-hello/—package.json,hello.js, andnode_modules/(figlet and its transitive deps)/usr/bin/nodejs-hello— a one-line/bin/shwrapper that runs the app vianode
On the device, nodejs-hello "hi" works without the user knowing node_modules
is involved.
How the task order works
nodejs_app wraps the user-supplied tasks between two class-owned tasks:
nodejs-setup— creates$DESTDIR/<install_path>so install_file steps have a target directory.- (your tasks) — copy
package.json(and optionallypackage-lock.json), then any JS/asset files, into$APP_BUILDusinginstall_file(). Emit your/usr/binwrapper here too. nodejs-install— runsnpm ciif a lockfile is present, otherwisenpm install, against the stagedpackage.json. Then rewrites any build-time path baked intonode_modulesback to the on-target absolute path and writes theentry_pointswrappers.
If you ship a package-lock.json alongside package.json, npm ci makes the
install fully reproducible — recommended for production units. Without a
lockfile you get whatever satisfies the version ranges in dependencies{} at
build time.
entry_points shortcut
For apps whose main entry point is just “run a binary from node_modules/.bin”
or “run a script from a package,” skip the manual wrapper script and use
entry_points:
nodejs_app(
name = "myapp",
version = "1.0.0",
entry_points = {
# /usr/bin/myapp runs `node_modules/.bin/myapp`
"myapp": "myapp",
# /usr/bin/lint runs `node node_modules/eslint/bin/eslint.js`
"lint": "eslint:bin/eslint.js",
},
tasks = [
task("install-app", steps = [
install_file("package.json",
"$DESTDIR/usr/lib/node-apps/myapp/package.json"),
]),
],
)
"pkg" resolves to node_modules/.bin/pkg. "pkg:script" resolves to
node node_modules/pkg/script.
Pure-JS packages vs native bindings
Pure-JavaScript packages (figlet, commander, chalk, express and its
deps, etc.) install out of the box. Packages with native bindings (anything
using node-gyp, prebuild, or a binding.gyp) need their build-time
libraries and headers in the toolchain container. Add them as deps:
nodejs_app(
name = "sqlite-app",
version = "1.0.0",
deps = ["sqlite"], # better-sqlite3 links libsqlite
tasks = [
task("install-app", steps = [
install_file("package.json",
"$DESTDIR/usr/lib/node-apps/sqlite-app/package.json"),
]),
],
)
When a package ships musl-compatible prebuilt binaries, npm will use those and you can skip the headers.
Customising the install path
By default the app lives at /usr/lib/node-apps/<name>. Override with
install_path when an app needs a different location — for example, when an
upstream config or service file points at a fixed path:
nodejs_app(
name = "myapp",
version = "1.0.0",
install_path = "/opt/myapp",
tasks = [
task("install-app", steps = [
install_file("package.json", "$DESTDIR/opt/myapp/package.json"),
]),
],
)
The wrapper script(s) emitted by entry_points follow the install path
automatically.
nodejs-image
module-core/images/nodejs-image.star boots into a userland with node, npm,
the dev-image diagnostic tools, and the nodejs-hello demo pre-installed. Run
yoe build nodejs-image && yoe run nodejs-image to get a QEMU VM where
nodejs-hello "..." renders an ASCII-art banner — useful as a smoke test that
nodejs_app works end-to-end on your machine before you spend npm’s download
budget on a real app.