From 3f315f696ce1f9c24b84bbd7c69148a6127807bb Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Mon, 10 Aug 2026 13:39:17 -0400 Subject: [PATCH] =?UTF-8?q?fix(dev):=20version-safe=20Next=20dev=20launche?= =?UTF-8?q?r=20=E2=80=94=20quick-start=20worked=20only=20on=20new=20Node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm --filter composer dev` (and `pnpm dev` for the studio) died with exit 9 on Node 22.0–22.3: the scripts inlined NODE_OPTIONS=--localstorage-file, which only EXISTS from Node 22.4 — older allowed-by-engines Nodes reject it ("not allowed in NODE_OPTIONS") before Next starts. Reproduced byte-for-byte on a real v22.0.0 binary. The prior clean-checkout verification missed it because "clean" covered files, not the machine's Node (v25, where the flag is valid). The flag is load-bearing where it exists — Node ≥ 25's stub server-side localStorage global crashes Next dev SSR without it (CONTRIBUTING said "don't remove it") — so the fix keeps the protection and removes the bomb: each app's dev now goes through scripts/next-dev.mjs, which probes process.allowedNodeEnvironmentFlags and adds --localstorage-file only where this Node permits it. The hazard and the flag ship together, so every supported Node gets exactly what it needs. Verified: - Node 22.0.0 (real binary): old inline form exits 9; via the launcher, composer dev boots to HTTP 200. - Node 22.4.0: flag allowed (boundary bracketed empirically). - Node 25.2.1, CLEAN worktree: `pnpm install` + the documented `pnpm --filter composer dev` → HTTP 200, with the SSR-protection branch engaged; agent boots independently on :8787; web twin boots on :3000. - Regression guard (5 unit tests, fail-first proven: the no-inline-flag assertion fails on the old package.json): no package script may hardcode the flag; env builder branches correctly on old/new Node and preserves caller NODE_OPTIONS. Composer units 24/24. CONTRIBUTING troubleshooting updated to point at the launcher. README needs no change — the documented command now works on everything engines allows. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 7 ++-- apps/composer/package.json | 4 +-- apps/composer/scripts/next-dev.mjs | 37 +++++++++++++++++++++ apps/composer/scripts/next-dev.test.mjs | 44 +++++++++++++++++++++++++ apps/web/package.json | 2 +- apps/web/scripts/next-dev.mjs | 37 +++++++++++++++++++++ 6 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 apps/composer/scripts/next-dev.mjs create mode 100644 apps/composer/scripts/next-dev.test.mjs create mode 100644 apps/web/scripts/next-dev.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84c21a3..936735f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,8 +103,11 @@ A scenario is data plus (optionally) a deterministic responder — no UI code: - **Live tab says the agent is offline** — start it: `pnpm --filter agent dev`. - **`next dev` crashes with `localStorage.getItem is not a function`** — - Node ≥ 25 ships a stub Web Storage global; the dev script already sets - `--localstorage-file`. Don't remove it. + Node ≥ 25 ships a stub Web Storage global; the dev launcher + (`apps/*/scripts/next-dev.mjs`) adds `--localstorage-file` automatically + wherever the running Node supports it. Don't bypass the launcher or inline + the flag in NODE_OPTIONS: Node < 22.4 rejects it with exit 9 before Next + even starts (that regression is unit-guarded). - **Blank page / `Cannot find module './NNN.js'` in dev** — a `next build` ran while `next dev` was up and corrupted `.next`. Stop dev, `rm -rf apps/web/.next`, restart. diff --git a/apps/composer/package.json b/apps/composer/package.json index efd1e01..4a158f9 100644 --- a/apps/composer/package.json +++ b/apps/composer/package.json @@ -2,10 +2,10 @@ "name": "composer", "version": "0.1.0", "private": true, - "description": "The catalog composer: connect a project, inspect and enrich its discovered contract, map it through a data profile, preview the emitted A2UI catalog (wireframe or native registries), and validate the governed artifacts. Authoring UI only — every transform runs in published packages, locally via the agent.", + "description": "The catalog composer: connect a project, inspect and enrich its discovered contract, map it through a data profile, preview the emitted A2UI catalog (wireframe or native registries), and validate the governed artifacts. Authoring UI only \u2014 every transform runs in published packages, locally via the agent.", "license": "Apache-2.0", "scripts": { - "dev": "pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/demo-assets.mjs && NODE_OPTIONS=--localstorage-file=/tmp/dspack-composer-dev-localstorage next dev --port 3001", + "dev": "pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/demo-assets.mjs && node scripts/next-dev.mjs dev --port 3001", "build": "pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/demo-assets.mjs && next build", "test": "node scripts/demo-assets.mjs && vitest run --passWithNoTests", "typecheck": "node scripts/demo-assets.mjs && tsc -p tsconfig.json" diff --git a/apps/composer/scripts/next-dev.mjs b/apps/composer/scripts/next-dev.mjs new file mode 100644 index 0000000..ed0f92c --- /dev/null +++ b/apps/composer/scripts/next-dev.mjs @@ -0,0 +1,37 @@ +/** + * Version-safe Next dev launcher. + * + * Node ≥ 25 ships a stub server-side `localStorage` global whose methods are + * undefined unless backed by `--localstorage-file` — dependencies' `typeof + * localStorage` guards then pass and crash Next dev SSR (docs/ + * IMPLEMENTATION_LOG.md). But the flag only EXISTS from Node 22.4, and our + * engines floor is 22: putting it inline in NODE_OPTIONS made `pnpm --filter + * composer dev` die with exit 9 ("not allowed in NODE_OPTIONS") on Node + * 22.0–22.3 before Next ever started. So probe what THIS Node permits and + * pass the flag only where it exists — the protection stays wherever the + * hazard exists (they ship together), and the documented quick-start works + * on every supported Node. Twin: apps/web/scripts/next-dev.mjs. + */ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const STORAGE_FILE = "/tmp/dspack-composer-dev-localstorage"; + +/** Pure env builder, unit-tested: add the flag only when this Node allows it. */ +export function webstorageEnv(baseEnv, allowedFlags, file = STORAGE_FILE) { + if (!allowedFlags.has("--localstorage-file")) return { ...baseEnv }; + const flag = `--localstorage-file=${file}`; + const prior = baseEnv.NODE_OPTIONS; + return { ...baseEnv, NODE_OPTIONS: prior ? `${prior} ${flag}` : flag }; +} + +const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").at(-1)); +if (invokedDirectly) { + const require = createRequire(import.meta.url); + const nextBin = require.resolve("next/dist/bin/next"); + const child = spawn(process.execPath, [nextBin, ...process.argv.slice(2)], { + stdio: "inherit", + env: webstorageEnv(process.env, process.allowedNodeEnvironmentFlags), + }); + child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +} diff --git a/apps/composer/scripts/next-dev.test.mjs b/apps/composer/scripts/next-dev.test.mjs new file mode 100644 index 0000000..d87cbd4 --- /dev/null +++ b/apps/composer/scripts/next-dev.test.mjs @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { webstorageEnv } from "./next-dev.mjs"; + +/** + * The quick-start regression guard: `pnpm --filter composer dev` must work on + * EVERY engines-supported Node. Node < 22.4 rejects --localstorage-file in + * NODE_OPTIONS with exit 9 (reproduced on a real v22.0.0 binary), so the flag + * may never sit inline in a package script — only the version-probing + * launcher may add it, and only where this Node allows it. + */ +describe("next-dev launcher (quick-start regression)", () => { + it("no package script hardcodes --localstorage-file in NODE_OPTIONS", () => { + for (const rel of ["../package.json", "../../web/package.json"]) { + const pkg = JSON.parse(readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8")); + for (const [name, script] of Object.entries(pkg.scripts ?? {})) { + expect(script, `${pkg.name} ${name}`).not.toMatch(/NODE_OPTIONS=.*--localstorage-file/); + } + } + }); + + it("old Node (flag not allowed): env passes through untouched — no exit-9 bomb", () => { + const env = webstorageEnv({ PATH: "/bin" }, new Set(["--max-old-space-size"])); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.PATH).toBe("/bin"); + }); + + it("new Node (flag allowed): the SSR protection is applied", () => { + const env = webstorageEnv({}, new Set(["--localstorage-file"])); + expect(env.NODE_OPTIONS).toBe("--localstorage-file=/tmp/dspack-composer-dev-localstorage"); + }); + + it("preserves a caller's existing NODE_OPTIONS", () => { + const env = webstorageEnv({ NODE_OPTIONS: "--max-old-space-size=4096" }, new Set(["--localstorage-file"])); + expect(env.NODE_OPTIONS).toBe("--max-old-space-size=4096 --localstorage-file=/tmp/dspack-composer-dev-localstorage"); + }); + + it("the RUNNING Node agrees with its own probe (sanity)", () => { + const env = webstorageEnv({}, process.allowedNodeEnvironmentFlags); + const has = process.allowedNodeEnvironmentFlags.has("--localstorage-file"); + expect(!!env.NODE_OPTIONS).toBe(has); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 0760466..2eb8165 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "description": "The studio: canvas, timeline, X-ray, wire view. Phase 1: the contract's worked example rendered through Astryx with the theme dial.", "license": "Apache-2.0", "scripts": { - "dev": "pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/take-home-assets.mjs && NODE_OPTIONS=--localstorage-file=/tmp/dspack-studio-dev-localstorage next dev", + "dev": "pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/take-home-assets.mjs && node scripts/next-dev.mjs dev", "build": "pnpm --filter @dspack-studio/contracts build:catalogs && pnpm --filter @dspack-studio/shadcn-renderers build:css && node scripts/take-home-assets.mjs && next build", "test": "vitest run", "start": "next start", diff --git a/apps/web/scripts/next-dev.mjs b/apps/web/scripts/next-dev.mjs new file mode 100644 index 0000000..288b232 --- /dev/null +++ b/apps/web/scripts/next-dev.mjs @@ -0,0 +1,37 @@ +/** + * Version-safe Next dev launcher. + * + * Node ≥ 25 ships a stub server-side `localStorage` global whose methods are + * undefined unless backed by `--localstorage-file` — dependencies' `typeof + * localStorage` guards then pass and crash Next dev SSR (docs/ + * IMPLEMENTATION_LOG.md). But the flag only EXISTS from Node 22.4, and our + * engines floor is 22: putting it inline in NODE_OPTIONS made `pnpm dev` + * die with exit 9 ("not allowed in NODE_OPTIONS") on Node 22.0–22.3 before + * Next ever started. So probe what THIS Node permits and pass the flag only + * where it exists — the protection stays wherever the hazard exists (they + * ship together), and the quick-start works on every supported Node. + * Twin: apps/composer/scripts/next-dev.mjs. + */ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const STORAGE_FILE = "/tmp/dspack-studio-dev-localstorage"; + +/** Pure env builder: add the flag only when this Node allows it. */ +export function webstorageEnv(baseEnv, allowedFlags, file = STORAGE_FILE) { + if (!allowedFlags.has("--localstorage-file")) return { ...baseEnv }; + const flag = `--localstorage-file=${file}`; + const prior = baseEnv.NODE_OPTIONS; + return { ...baseEnv, NODE_OPTIONS: prior ? `${prior} ${flag}` : flag }; +} + +const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").at(-1)); +if (invokedDirectly) { + const require = createRequire(import.meta.url); + const nextBin = require.resolve("next/dist/bin/next"); + const child = spawn(process.execPath, [nextBin, ...process.argv.slice(2)], { + stdio: "inherit", + env: webstorageEnv(process.env, process.allowedNodeEnvironmentFlags), + }); + child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0))); +}