feat: add node module and buildPnpmTask helper #37

Open
kennethz wants to merge 4 commits from feat/node-pnpm into main
Member

Why

Kennel only supports JavaScript projects through Deno. Projects that can't move to Deno yet (maps, for example, is on Bun) had no supported way onto devenv and kennel. In the Discord thread, Anish said a pnpm/Node helper was fine as an alternative, so this adds one. Deno stays the recommended default, and the docs say so.

What this adds

  • A Node.js dev environment. Set scottylabs.node.enable = true and the shell gets Node.js 24 and pnpm, installs dependencies on entry, and runs lint, type-check and tests on every commit, the same way the Deno setup does.
  • A build helper, buildPnpmTask. It builds a pnpm project into something kennel can deploy: either a static site or a backend service.
  • Docs. The deploying guide has a new "Node.js with pnpm" section with setup examples, the things that behave differently from your local shell, and steps for moving off Bun.

Nothing changes for existing Deno projects.

How a project uses it

# devenv.nix
scottylabs.node.enable = true;
scottylabs.node.dirs = [ "sites/web" ]; # only if the JS isn't at the repo root

# flake.nix
web = helpers.buildPnpmTask {
  src = ./.;
  cwd = "apps/web";
  pname = "web";
  hash = "sha256-...";
};
api = helpers.buildPnpmTask {
  src = ./.;
  cwd = "apps/api";
  pname = "api";
  hash = "sha256-...";
  main = "dist/index.js"; # makes it a service
};

Things to know

  • Dependency hash. Each project needs a hash for its dependencies. Set it to "", build once, and copy the hash from the error. Update it whenever pnpm-lock.yaml changes, or kennel's build fails. A different pnpm version also changes the hash, so recheck it after a nixpkgs update.
  • No install scripts. Steps that normally run on install, like prisma generate, have to go in the build script.
  • Matching pnpm versions. If package.json has a packageManager field for a different pnpm major, the shell and the build end up on different pnpm versions. Remove the field or pick the matching pnpm (see the docs).

This PR doesn't touch maps; Sushrut's PR is handling that onboarding.

Technical details

Changes

  • nix/modules/node.nix (new): scottylabs.node.
    • Options: enable, package (Node.js, default pkgs.nodejs_24), pnpm (default pkgs.pnpm), dirs (default [ "." ], mirrors deno.dirs), react.enable, checkScript (default typecheck), testScript (default test).
    • pnpm install runs in each of dirs on shell entry.
    • Hooks pnpm-check and pnpm-test run pnpm run --if-present <script> in each of dirs, failing on the first error. In a workspace, the root script fans out (pnpm -r ... or turbo).
    • The store is at .devenv/state/pnpm via both npm_config_store_dir and pnpm_config_store_dir. pnpm 10 only reads the first and pnpm 12 only the second.
  • nix/modules/oxc.nix (new): the oxlint hook, oxfmt and OXLINT_TSGOLINT_PATH move here from deno.nix, so both toolchains share one definition instead of defining the hook twice. React plugins are on when either toolchain's react.enable is set.
  • nix/lib/build-pnpm-task.nix (new): buildPnpmTask, exposed in mkLib.
    • Arguments: src, cwd, pname, version, hash, script (default build, null skips it), output (default dist), main, nodejs, pnpm.
    • Dependencies come from nixpkgs' fetchPnpmDeps (fetcherVersion 4) and pnpmConfigHook. buildEnv is exported into the build only, as with the other helpers.
    • On Linux it drops musl packages and the dangling links they leave inside node_modules, then runs autoPatchelf over the native addons, like buildDenoTask.
    • Without main, it copies <cwd>/<output> to $out.
    • With main, it copies the built tree (including node_modules, whose links point into the workspace root) to $out/lib, and writes $out/bin/<pname>. That wrapper runs node $out/lib/<cwd>/<main>, keeps the caller's working directory so secretspec.toml is found, and defaults NODE_ENV to production.
  • Supporting changes:
    • nix/modules/default.nix imports the two new modules.
    • flake.nix adds buildPnpmTask to mkLib.
    • .forgejo/actions/prepare/action.yml caches .devenv/state/pnpm and adds pnpm-lock.yaml to the cache key.
    • nix/modules/security.nix: the osv-scanner description lists pnpm-lock.yaml.
  • Docs:
    • guides/deploying.md: the JS section is split into Deno and Node.js with pnpm, and gains the hash workflow, the build-vs-shell differences, and the Bun migration steps.
    • reference/build-helpers.md documents buildPnpmTask.
    • guides/ci.md mentions the pnpm cache, guides/features.md adds node to the Sentry platform examples, and nix/README.md lists Node.js.

Design notes

  • I didn't use devenv's languages.javascript, to match deno.nix, which also sets up its toolchain directly.
  • Service mode ships the whole workspace, dev dependencies included. pnpm deploy --prod could trim it in a follow-up.
  • pnpm import can't read bun.lock, so moving off Bun means resolving dependencies fresh. The migration steps say this.
  • Node 24 runs a TypeScript main directly only if it uses erasable syntax (no enum, namespace or parameter properties). Otherwise the build script compiles it.

Testing

  • Built a two-package pnpm workspace against the flake's nixpkgs pin, with both the default pnpm 12 and pnpm_10:
    • A Vite site: static output, with a buildEnv variable baked in.
    • A Hono server with main set: it answered /api/health when started from an unrelated directory. Rebuilding it with script = null also worked, and a dangling symlink owned by the project survived the build.
  • Built the generated check hook with dirs = [ "a" "b" ] and ran it. It ran in both directories, failed when one script exited nonzero, and passed once that script was removed.
  • Evaluated the modules with Deno only, Node only, and both. Hooks, env and packages are as expected, and the Deno-only result matches main.
  • Confirmed by hand:
    • pnpm 12 follows a packageManager: pnpm@10 field in the shell.
    • Each pnpm version reads only its own store-dir variable.
    • pnpm import rejects maps' bun.lock.
    • Node 24 runs an erasable-only .ts file and rejects enum.
  • nix build .#docs succeeds, and the cross-page link to deploying.html#nodejs-with-pnpm resolves.
  • nixfmt, deadnix --fail, statix, mdformat and block-ai-slop pass.
  • With pnpm 12, rebuilding the dependency fetch (nix build --rebuild) produced the same hash, so it's reproducible. pnpm 10 and pnpm 12 produce different hashes for the same lockfile, which is why the docs say to recheck the hash after a pnpm or nixpkgs update.
  • My environment sits behind a TLS-intercepting proxy, so the test harness (not the helper) pointed SSL_CERT_FILE at the proxy's CA inside the fetch. That doesn't affect the output hash.
## Why Kennel only supports JavaScript projects through Deno. Projects that can't move to Deno yet (maps, for example, is on Bun) had no supported way onto devenv and kennel. In the Discord thread, Anish said a pnpm/Node helper was fine as an alternative, so this adds one. Deno stays the recommended default, and the docs say so. ## What this adds - **A Node.js dev environment.** Set `scottylabs.node.enable = true` and the shell gets Node.js 24 and pnpm, installs dependencies on entry, and runs lint, type-check and tests on every commit, the same way the Deno setup does. - **A build helper, `buildPnpmTask`.** It builds a pnpm project into something kennel can deploy: either a static site or a backend service. - **Docs.** The deploying guide has a new "Node.js with pnpm" section with setup examples, the things that behave differently from your local shell, and steps for moving off Bun. Nothing changes for existing Deno projects. ## How a project uses it ```nix # devenv.nix scottylabs.node.enable = true; scottylabs.node.dirs = [ "sites/web" ]; # only if the JS isn't at the repo root # flake.nix web = helpers.buildPnpmTask { src = ./.; cwd = "apps/web"; pname = "web"; hash = "sha256-..."; }; api = helpers.buildPnpmTask { src = ./.; cwd = "apps/api"; pname = "api"; hash = "sha256-..."; main = "dist/index.js"; # makes it a service }; ``` ## Things to know - **Dependency hash.** Each project needs a `hash` for its dependencies. Set it to `""`, build once, and copy the hash from the error. Update it whenever `pnpm-lock.yaml` changes, or kennel's build fails. A different pnpm version also changes the hash, so recheck it after a nixpkgs update. - **No install scripts.** Steps that normally run on install, like `prisma generate`, have to go in the build script. - **Matching pnpm versions.** If `package.json` has a `packageManager` field for a different pnpm major, the shell and the build end up on different pnpm versions. Remove the field or pick the matching pnpm (see the docs). This PR doesn't touch maps; Sushrut's PR is handling that onboarding. ## Technical details ### Changes - **`nix/modules/node.nix` (new): `scottylabs.node`.** - Options: `enable`, `package` (Node.js, default `pkgs.nodejs_24`), `pnpm` (default `pkgs.pnpm`), `dirs` (default `[ "." ]`, mirrors `deno.dirs`), `react.enable`, `checkScript` (default `typecheck`), `testScript` (default `test`). - `pnpm install` runs in each of `dirs` on shell entry. - Hooks `pnpm-check` and `pnpm-test` run `pnpm run --if-present <script>` in each of `dirs`, failing on the first error. In a workspace, the root script fans out (`pnpm -r ...` or turbo). - The store is at `.devenv/state/pnpm` via both `npm_config_store_dir` and `pnpm_config_store_dir`. pnpm 10 only reads the first and pnpm 12 only the second. - **`nix/modules/oxc.nix` (new):** the oxlint hook, oxfmt and `OXLINT_TSGOLINT_PATH` move here from `deno.nix`, so both toolchains share one definition instead of defining the hook twice. React plugins are on when either toolchain's `react.enable` is set. - **`nix/lib/build-pnpm-task.nix` (new): `buildPnpmTask`, exposed in `mkLib`.** - Arguments: `src`, `cwd`, `pname`, `version`, `hash`, `script` (default `build`, `null` skips it), `output` (default `dist`), `main`, `nodejs`, `pnpm`. - Dependencies come from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4) and `pnpmConfigHook`. `buildEnv` is exported into the build only, as with the other helpers. - On Linux it drops musl packages and the dangling links they leave inside `node_modules`, then runs autoPatchelf over the native addons, like `buildDenoTask`. - Without `main`, it copies `<cwd>/<output>` to `$out`. - With `main`, it copies the built tree (including `node_modules`, whose links point into the workspace root) to `$out/lib`, and writes `$out/bin/<pname>`. That wrapper runs `node $out/lib/<cwd>/<main>`, keeps the caller's working directory so `secretspec.toml` is found, and defaults `NODE_ENV` to `production`. - **Supporting changes:** - `nix/modules/default.nix` imports the two new modules. - `flake.nix` adds `buildPnpmTask` to `mkLib`. - `.forgejo/actions/prepare/action.yml` caches `.devenv/state/pnpm` and adds `pnpm-lock.yaml` to the cache key. - `nix/modules/security.nix`: the osv-scanner description lists `pnpm-lock.yaml`. - **Docs:** - `guides/deploying.md`: the JS section is split into Deno and Node.js with pnpm, and gains the hash workflow, the build-vs-shell differences, and the Bun migration steps. - `reference/build-helpers.md` documents `buildPnpmTask`. - `guides/ci.md` mentions the pnpm cache, `guides/features.md` adds `node` to the Sentry platform examples, and `nix/README.md` lists Node.js. ### Design notes - I didn't use devenv's `languages.javascript`, to match `deno.nix`, which also sets up its toolchain directly. - Service mode ships the whole workspace, dev dependencies included. `pnpm deploy --prod` could trim it in a follow-up. - `pnpm import` can't read `bun.lock`, so moving off Bun means resolving dependencies fresh. The migration steps say this. - Node 24 runs a TypeScript `main` directly only if it uses erasable syntax (no `enum`, `namespace` or parameter properties). Otherwise the build script compiles it. ### Testing - Built a two-package pnpm workspace against the flake's nixpkgs pin, with both the default pnpm 12 and `pnpm_10`: - A Vite site: static output, with a `buildEnv` variable baked in. - A Hono server with `main` set: it answered `/api/health` when started from an unrelated directory. Rebuilding it with `script = null` also worked, and a dangling symlink owned by the project survived the build. - Built the generated check hook with `dirs = [ "a" "b" ]` and ran it. It ran in both directories, failed when one script exited nonzero, and passed once that script was removed. - Evaluated the modules with Deno only, Node only, and both. Hooks, env and packages are as expected, and the Deno-only result matches `main`. - Confirmed by hand: - pnpm 12 follows a `packageManager: pnpm@10` field in the shell. - Each pnpm version reads only its own store-dir variable. - `pnpm import` rejects maps' `bun.lock`. - Node 24 runs an erasable-only `.ts` file and rejects `enum`. - `nix build .#docs` succeeds, and the cross-page link to `deploying.html#nodejs-with-pnpm` resolves. - nixfmt, `deadnix --fail`, statix, mdformat and block-ai-slop pass. - With pnpm 12, rebuilding the dependency fetch (`nix build --rebuild`) produced the same hash, so it's reproducible. pnpm 10 and pnpm 12 produce different hashes for the same lockfile, which is why the docs say to recheck the hash after a pnpm or nixpkgs update. - My environment sits behind a TLS-intercepting proxy, so the test harness (not the helper) pointed `SSL_CERT_FILE` at the proxy's CA inside the fetch. That doesn't affect the output hash.
scottylabs.node provides Node.js 24 and pnpm, runs the root typecheck
and test scripts as commit hooks, and keeps the pnpm store under
.devenv/state so CI caches it. The oxlint hook, oxfmt and tsgolint path
move from deno.nix into a shared oxc.nix used by both toolchains.

buildPnpmTask builds a pnpm project or workspace member from
pnpm-lock.yaml through nixpkgs' fetchPnpmDeps. It copies a script's
output directory for a static site, or with main set wraps the built
workspace in a bin/<pname> launcher for a backend service.
A packageManager field naming another pnpm major makes the shell switch
versions while the Nix build keeps nixpkgs' pnpm, so expose
scottylabs.node.pnpm and a matching pnpm argument on buildPnpmTask.

The deploying guide now covers when to use Node.js over Deno, the
devenv and flake setup, keeping the dependency hash in step with
pnpm-lock.yaml, how the build differs from the shell (no install
scripts, type stripping for a TypeScript main), and moving off Bun.
Also list pnpm-lock.yaml among the lockfiles osv-scanner reads and node
among the Sentry platform examples.
fix: support non-root node projects and services without a build
Some checks failed
kennel/build build succeeded
kennel/deploy deployment healthy
CI / check (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / check-1 (pull_request) Has been cancelled
f30f8722c4
Projects that keep their JavaScript beside another language, such as a
Rust backend with a frontend in sites/web, need pnpm to run outside the
repo root, so add scottylabs.node.dirs mirroring scottylabs.deno.dirs.

buildPnpmTask now skips the build when script is null, defaults
NODE_ENV to production in the service wrapper, and only deletes the
dangling symlinks it leaves inside node_modules.

Kennel Deployments

Service URL
docs https://kennel-docs-pr-37.scottylabs.net

Last updated for commit afbe923.

<!-- kennel-deployment --> ### Kennel Deployments | Service | URL | | --- | --- | | docs | https://kennel-docs-pr-37.scottylabs.net | Last updated for commit `afbe923`.
docs: note that the pnpm version changes the dependency hash
Some checks failed
CI / check-1 (pull_request) Failing after 16m19s
kennel/build build succeeded
kennel/deploy deployment healthy
CI / build (pull_request) Successful in 2m52s
CI / check (pull_request) Failing after 0s
nixbot/nix-eval evaluation failed
nixbot/nix-build merge conflict
afbe9230c9
Some checks failed
CI / check-1 (pull_request) Failing after 16m19s
kennel/build build succeeded
kennel/deploy deployment healthy
CI / build (pull_request) Successful in 2m52s
CI / check (pull_request) Failing after 0s
nixbot/nix-eval evaluation failed
nixbot/nix-build merge conflict
This pull request has changes conflicting with the target branch.
  • flake.nix
  • nix/README.md
  • nix/flake-module/per-system/node.nix
  • nix/flake-module/per-system/oxc.nix
  • nix/modules/default.nix
  • nix/modules/deno.nix
  • nix/modules/security.nix
  • sites/docs/src/guides/ci.md
  • sites/docs/src/guides/deploying.md
  • sites/docs/src/guides/features.md
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/node-pnpm:feat/node-pnpm
git switch feat/node-pnpm
Sign in to join this conversation.
No description provided.