From b7d4d2dbd5537c8763be5f8e72df7930e666ca4a Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 20:05:39 +0530 Subject: [PATCH 1/4] fix: resolve dynamic # imports on Bun dev without a file: path A Bun runtime onResolve result reaches a dynamic import() as a broken file:/ path, so 0.8.85 and 0.8.86 answered 500 for any server module with relative imports reached through await import('#...'), which is how Crisp provisions projects. The # alias is now rewritten to an absolute path when the module source is loaded, which keeps a new file next to a server module resolvable without touching resolution. The stress test now runs the app's own copies of the packages and adds the dynamic-import case. Refs #1575 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- packages/server/AGENTS.md | 2 +- packages/server/src/action-seed-bun.js | 4 +- packages/server/src/action-seed.js | 5 +- packages/server/src/dev/bun-alias-resolve.js | 71 ------------------- packages/server/src/dev/bun-app-source.js | 37 +++++++++- packages/server/src/dev/handler.js | 7 -- .../test/dev/hot-reload-robustness.test.js | 16 ----- scripts/dev-reload-stress.mjs | 12 ++++ test/bun/dev-reload-stress.mjs | 17 +++-- 9 files changed, 67 insertions(+), 104 deletions(-) delete mode 100644 packages/server/src/dev/bun-alias-resolve.js diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 467276312..c9fbc383c 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -77,7 +77,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `dev-source-locations.js` | Dev source locations (#1499). `annotateSourceLocations(source, file)` lexes a JS / TS module (comments, strings, regex vs division, nested template literals) and inserts ` data-webjs-src=":"` right after each element opening tag name in the STATIC text of `html` tagged templates, never adding a newline. Skips closing tags, comments, attribute values, raw-text bodies, `svg` / `math` descendants, document and head elements, and `css` / `svg` templates. Fails open (returns the source unchanged). `isSourceLocationCandidate` excludes `node_modules`, `*.server.*` and files outside the app. Applied to served app modules by `dev/serve.js` when the handler was built with `WEBJS_SOURCE_LOCATIONS=1` under dev. | | `dev-import.js` | `devImportSpecifier(file, dev)`: the specifier every dev re-import uses (#1550; pages, layouts, route handlers, action modules, middleware, metadata routes, env schema, instrumentation, readiness, WS routes). In dev it carries a `?t=` query naming the file's CONTENT (#1575; a random one per call made every request a new, never-freed module instance): on the file URL under Node, on the PLAIN absolute path under Bun, which drops a `file://` specifier's query (so a URL-plus-query cache-bust was a silent no-op there). A path holding `?` or `#` falls back to the file URL. Outside dev it is the plain file URL. `devImport(file, dev)` imports through it and never re-imports a specifier that failed (on Bun under `--hot` that never settles). Tests: `test/dev/dev-import.test.js` (runs on both runtimes, including a real re-import round trip). | | `process-shared.js` | `shared(name, make)`: one value per process under `Symbol.for('webjs.server.')` (#1590). Every piece of state an app reaches through this package's API (the request, action-signal, action-middleware, instrumentation and seed `AsyncLocalStorage`s, action identity, the default cache store, the html-cache generation, the session map, broadcast clients) goes through it, because after a `bun --hot` reset the app imports a fresh copy of the package while the first run's server keeps serving. New app-facing module state MUST use it. | -| `dev/hot-host.js`, `dev/bun-app-source.js`, `dev/bun-alias-resolve.js` | One dev server per process under `bun --hot` (#1575). `startServer` registers a host on `globalThis` (`Symbol.for('webjs.dev.hotHosts')`); a re-run (the CLI's `lib/dev-hot-rerun.js`, or a second `startServer`) calls `host.rerun()`, which re-derives the analysis without a reload frame, and exits the child when `@webjsdev/server` itself changed. App modules load through a fresh-reading `Bun.plugin` (`bun-app-source.js`; Bun keeps a replaced file's old source) and `#` imports resolve through the app's map (`bun-alias-resolve.js`; Bun's directory cache goes stale), so the watcher pokes a temp-dir sentinel for a registry reset when an edited module is imported by another (`needsRegistryReset` on the handler), new, or `*.server.*`. Tests: `test/dev/hot-reload-robustness.test.js`, end to end `test/bun/dev-reload-stress.mjs` (both runtimes) over `scripts/dev-reload-stress.mjs`. | +| `dev/hot-host.js`, `dev/bun-app-source.js` | One dev server per process under `bun --hot` (#1575). `startServer` registers a host on `globalThis` (`Symbol.for('webjs.dev.hotHosts')`); a re-run (the CLI's `lib/dev-hot-rerun.js`, or a second `startServer`) calls `host.rerun()`, which re-derives the analysis without a reload frame, and exits the child when `@webjsdev/server` itself changed. App modules load through a fresh-reading `Bun.plugin` (`bun-app-source.js`; Bun keeps a replaced file's old source). There is deliberately NO `onResolve` for `#` aliases (a Bun runtime `onResolve` result reaches a dynamic `import()` as a broken `file:/...` path, the 0.8.85/0.8.86 regression); instead both plugins rewrite an app `#` specifier to the absolute path when the file exists (`rewriteAppAliases`), since Bun's directory cache goes stale for a new file, so the watcher pokes a temp-dir sentinel for a registry reset when an edited module is imported by another (`needsRegistryReset` on the handler), new, or `*.server.*`. Tests: `test/dev/hot-reload-robustness.test.js`, end to end `test/bun/dev-reload-stress.mjs` (both runtimes) over `scripts/dev-reload-stress.mjs`. | | `dev-source-locations-hook.js` | The SSR half of #1499: `registerSourceLocationHook(appDir)` installs a process-global load hook scoped to registered app roots (`module.registerHooks` calling `nextLoad` first, so `.ts` keeps `module-typescript` and Node still strips; on Bun a `Bun.plugin` whose filter excludes `node_modules` and `*.server.*` by lookahead, so the `'use server'` seed plugin keeps every server module whatever the registration order). Installed by `createRequestHandler` in dev only. | | `dev-embed.js` | The dev embed bridge, server half (#1498). `parseEmbedOrigins` normalizes `WEBJS_EMBED_ORIGINS` (http(s) origins only; a wildcard, bare host or other scheme is dropped with a warning). `setEmbedOrigins` is the module-level switch `createRequestHandler` sets at construction (the parsed value in dev, `[]` otherwise), read by `embedScriptTag`, which `ssr/head.js`'s `wrapHead` emits right after the public-env shim when `opts.dev` and origins are set (with the request's CSP nonce; `''` otherwise, so the off case is byte-identical). `allowEmbedFraming` drops `X-Frame-Options` and widens a CSP `frame-ancestors`; `dev/handler.js` applies it last in the header merge, in dev only. | | `dev-embed-client.js` | The dev embed bridge, browser half (#1498). `installEmbedBridge(origins)` posts `ready` / `navigate` / `console` / `error` / `network` / `server-error` / `select` messages to an allowed parent and handles `navigate` / `reload` / `inspect` / `hold` host commands. `select` carries the click's modifier keys, `hold` (#1532) sets the `__webjsEmbedHold` page global (persisted in `sessionStorage`) and on release calls the reload client's `__webjsDevReleaseHold`, and a reload of the same path restores the scroll saved at `pagehide`. Import-free and comment-light by contract: `dev-embed.js` reads this file, strips its comments and `export`, and inlines it, so the browser test drives the shipping code. `server-error` relays the `webjs:dev-overlay` event `dev-overlay.js`'s `renderDevOverlay` dispatches once an overlay is actually on screen. | diff --git a/packages/server/src/action-seed-bun.js b/packages/server/src/action-seed-bun.js index 9bbd00301..f3c6331a0 100644 --- a/packages/server/src/action-seed-bun.js +++ b/packages/server/src/action-seed-bun.js @@ -36,7 +36,7 @@ * serverFileRe: RegExp, * }} helpers */ -export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) { +export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe, rewriteAliases = (s) => s }) { Bun.plugin({ name: 'webjs-action-seed', setup(build) { @@ -60,7 +60,7 @@ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverF // Fail-open: any faceting error serves the raw source (no seeding for // this module), the Bun analog of the Node hook's `nextLoad` fallback. } - return { contents: src, loader }; + return { contents: rewriteAliases(src, absPath), loader }; }); }, }); diff --git a/packages/server/src/action-seed.js b/packages/server/src/action-seed.js index a0027c8f5..d434e78c9 100644 --- a/packages/server/src/action-seed.js +++ b/packages/server/src/action-seed.js @@ -663,7 +663,10 @@ export async function registerActionHooks(opts = {}) { g[installed] = true; // Bun has no module.registerHooks; install the same facade via Bun.plugin. const { installBunSeedPlugin } = await import('./action-seed-bun.js'); - installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE }); + // Dev rewrites the app's `#` imports to absolute paths (#1575); the + // rewrite reads the app roots dev registered, so it is a no-op in prod. + const { rewriteAppAliases } = await import('./dev/bun-app-source.js'); + installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppAliases }); _flags.hookInstalled = true; return; } diff --git a/packages/server/src/dev/bun-alias-resolve.js b/packages/server/src/dev/bun-alias-resolve.js deleted file mode 100644 index 80ee523fa..000000000 --- a/packages/server/src/dev/bun-alias-resolve.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Resolve the app's `#` subpath imports ourselves under `webjs dev` on Bun - * (#1575). - * - * Bun caches a directory's entries when it resolves through it and, under - * `bun --hot`, refreshes that cache only for a directory it watches. It does - * not watch a directory that holds only modules a `Bun.plugin` served (an - * `actions/` folder of `*.server.ts` files), and a `package.json "imports"` - * specifier (`#modules/x/new.ts`) resolves through that stale listing, while a - * relative one does not. So a file an agent added next to an action was - * "Cannot find module" for the rest of the process, through every reload. This - * `onResolve` expands the alias through the same map the module graph uses - * (`expandImportAlias`) and hands Bun an absolute path that exists, which Bun - * then loads normally (the seed plugin's `onLoad` still applies by path). A - * specifier it cannot place falls through to Bun's own resolver, so an error - * reads exactly as it would without it. - * - * Dev only (production never reloads) and Bun only (Node's resolver reads the - * file system on every resolve). - * - * @module dev/bun-alias-resolve - */ -import { existsSync, statSync } from 'node:fs'; -import { resolve, sep } from 'node:path'; -import { expandImportAlias } from '../module-graph.js'; - -const ROOTS = Symbol.for('webjs.dev.bunAliasRoots'); - -/** - * Resolve `spec` imported by `importer` against the app's `#` alias map, or - * null when it is not an app alias or the target is not a file. - * - * @param {string} spec - * @param {string} importer absolute path, possibly with a query - * @param {string} appDir absolute - * @returns {string | null} - */ -export function resolveAppAlias(spec, importer, appDir) { - if (!spec.startsWith('#')) return null; - const from = String(importer || '').split('?')[0]; - if (!from.startsWith(appDir + sep) || from.includes(`${sep}node_modules${sep}`)) return null; - const rel = expandImportAlias(spec, appDir); - if (!rel) return null; - const abs = resolve(appDir, rel); - try { return existsSync(abs) && statSync(abs).isFile() ? abs : null; } catch { return null; } -} - -/** - * Install the resolver for `appDir`, once per process. - * @param {string} appDir absolute - */ -export function registerBunAliasResolver(appDir) { - const g = /** @type {any} */ (globalThis); - if (!g.Bun || typeof g.Bun.plugin !== 'function') return; - if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set(); - if (g[ROOTS].has(appDir)) return; - g[ROOTS].add(appDir); - g.Bun.plugin({ - name: 'webjs-dev-alias', - setup(build) { - build.onResolve({ filter: /^#/ }, (args) => { - try { - const path = resolveAppAlias(args.path, args.importer, appDir); - return path ? { path } : undefined; - } catch { - return undefined; - } - }); - }, - }); -} diff --git a/packages/server/src/dev/bun-app-source.js b/packages/server/src/dev/bun-app-source.js index 4c8a8287d..c66fc8947 100644 --- a/packages/server/src/dev/bun-app-source.js +++ b/packages/server/src/dev/bun-app-source.js @@ -22,7 +22,9 @@ * * @module dev/bun-app-source */ -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { expandImportAlias } from '../module-graph.js'; const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots'); @@ -59,8 +61,39 @@ export function registerBunAppSource(appDir) { const abs = args.path.split('?')[0]; // A read failure (the file is gone) propagates, which Bun reports as // the load error it would have raised itself. - return { contents: readFileSync(abs, 'utf8'), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' }; + return { contents: rewriteAppAliases(readFileSync(abs, 'utf8'), abs), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' }; }); }, }); } + +/** Static `import`/`export … from '#x'` and `import('#x')` with a literal specifier. */ +const ALIAS_SPEC_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(#[^'"\s]+)\2/g; + +/** + * Rewrite the app's `#` import specifiers in a module's source to the absolute + * path they name (#1575), when that file exists. Bun's resolver serves a `#` + * import of a file created after it listed the directory as "Cannot find + * module" under `bun --hot`, and a runtime `onResolve` cannot stand in for it: + * its result reaches a dynamic `import()` as a broken `file:/...` path (the + * 0.8.85 / 0.8.86 regression). An absolute path resolves through neither. + * Anything that is not an app alias, or names no file yet, is left as written, + * so Bun reports a missing module exactly as before. + * + * @param {string} src + * @param {string} file absolute path of the module + * @returns {string} + */ +export function rewriteAppAliases(src, file) { + if (!src.includes('#')) return src; + const g = /** @type {any} */ (globalThis); + const roots = g[ROOTS] instanceof Set ? [...g[ROOTS]] : []; + const appDir = roots.find((r) => file.startsWith(r + '/')); + if (!appDir || file.includes('/node_modules/')) return src; + return src.replace(ALIAS_SPEC_RE, (m, lead, q, spec) => { + const rel = expandImportAlias(spec, appDir); + if (!rel) return m; + const abs = resolvePath(appDir, rel); + try { return existsSync(abs) && statSync(abs).isFile() ? `${lead}${q}${abs}${q}` : m; } catch { return m; } + }); +} diff --git a/packages/server/src/dev/handler.js b/packages/server/src/dev/handler.js index e250dd616..99f433f7f 100644 --- a/packages/server/src/dev/handler.js +++ b/packages/server/src/dev/handler.js @@ -1,6 +1,5 @@ import { readFile } from 'node:fs/promises'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; -import { registerBunAliasResolver } from './bun-alias-resolve.js'; import { registerBunAppSource } from './bun-app-source.js'; import { serverRuntime } from '../listener-core.js'; import { createHash } from 'node:crypto'; @@ -300,12 +299,6 @@ export async function createRequestHandler(opts) { // what a bound `
` resolves through, so gating the hook // on seeding would mean `webjs.seed: false` silently broke every no-JS form. await registerActionHooks({ seed: await readSeedEnabled(appDir), dev }); - // Bun's resolver keeps a stale directory listing for a `#` import under - // `bun --hot` (#1575); resolve the app's aliases ourselves in dev. - if (dev && serverRuntime() === 'bun') { - registerBunAliasResolver(appDir); - try { const real = realpathSync(appDir); if (real !== appDir) registerBunAliasResolver(real); } catch { /* appDir only */ } - } // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or // `webjs.dev.sourceLocations: true`) under `webjs dev` diff --git a/packages/server/test/dev/hot-reload-robustness.test.js b/packages/server/test/dev/hot-reload-robustness.test.js index 26ed3cb00..61b116817 100644 --- a/packages/server/test/dev/hot-reload-robustness.test.js +++ b/packages/server/test/dev/hot-reload-robustness.test.js @@ -10,7 +10,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { hotRerunCapable, needsHotReset, hotSentinelPath, hotHostKey, getHotHost, setHotHost, deleteHotHost } from '../../src/dev/hot-host.js'; -import { resolveAppAlias } from '../../src/dev/bun-alias-resolve.js'; import { appSourceFilter } from '../../src/dev/bun-app-source.js'; import { buildModuleGraph } from '../../src/module-graph.js'; import { devImport } from '../../src/dev-import.js'; @@ -68,21 +67,6 @@ test('the app-source filter takes app modules and leaves node_modules and *.serv for (const p of ['/srv/app/node_modules/x/i.js', '/srv/app/modules/a.server.ts', '/srv/other/app/page.ts', '/srv/app/app/style.css']) assert.ok(!f.test(p), p); }); -test('a # alias resolves to the file on disk, only for an importer inside the app', () => { - const dir = tempApp(); - try { - mkdirSync(join(dir, 'modules/u'), { recursive: true }); - const importer = join(dir, 'modules/a.server.ts'); - assert.equal(resolveAppAlias('#modules/u/new.ts', importer + '?t=1', dir), null, 'not there yet'); - writeFileSync(join(dir, 'modules/u/new.ts'), 'export const v = 1;\n'); - assert.equal(resolveAppAlias('#modules/u/new.ts', importer + '?t=1', dir), join(dir, 'modules/u/new.ts')); - assert.equal(resolveAppAlias('#modules/u/new.ts', join(dir, 'node_modules/p/i.js'), dir), null, 'a package keeps its own map'); - assert.equal(resolveAppAlias('./u/new.ts', importer, dir), null, 'not an alias'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - test('an importer written before its import target gains the edge once the target exists', async () => { const dir = tempApp(); try { diff --git a/scripts/dev-reload-stress.mjs b/scripts/dev-reload-stress.mjs index 834dd088c..63aea1635 100644 --- a/scripts/dev-reload-stress.mjs +++ b/scripts/dev-reload-stress.mjs @@ -262,6 +262,18 @@ export async function runDevReloadStress({ appDir, base, only = [], timeoutMs = settle('feature-action', () => call(f, 'feat'), has('act:F1'), t0)]); return [a, b]; }); + S('dynamic-alias-import', async () => { + // A server module that imports a sibling relatively, reached through a + // dynamic `#` import (Crisp's provisioning does exactly this). A Bun + // runtime onResolve turned that into a `file:/...` path (#1575 regression). + write(`modules/${NS}d/types.ts`, `export const T = 'dyn-ok';\n`); + write(`modules/${NS}d/engine.server.ts`, `import { T } from './types.ts';\nexport function eng() { return T; }\n`); + write(`app/api/${NS}d/route.ts`, `export async function GET() {\n const { eng } = await import('#modules/${NS}d/engine.server.ts');\n return new Response('route:' + eng());\n}\n`); + const t0 = performance.now(); + const r = await settle('dynamic-alias-import', () => get(`/api/${NS}d`), has('route:dyn-ok'), t0); + rm(`modules/${NS}d`); rm(`app/api/${NS}d`); + return r; + }); S('mass-churn', async () => { // a git-checkout-like burst: delete and recreate many files for (let i = 0; i < 12; i++) write(`app/${NS}/m${i}/page.ts`, page('m' + i)); diff --git a/test/bun/dev-reload-stress.mjs b/test/bun/dev-reload-stress.mjs index 0b3c29643..1c20ff91d 100644 --- a/test/bun/dev-reload-stress.mjs +++ b/test/bun/dev-reload-stress.mjs @@ -23,9 +23,9 @@ */ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, cpSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runDevReloadStress, runDevReloadSoak, summarizeStress, listenerRssMb } from '../../scripts/dev-reload-stress.mjs'; @@ -50,9 +50,17 @@ try { put('components/.keep', ''); put('modules/.keep', ''); mkdirSync(join(dir, 'node_modules/@webjsdev'), { recursive: true }); - for (const p of ['core', 'server']) symlinkSync(join(ROOT, 'packages', p), join(dir, 'node_modules/@webjsdev', p)); + // A COPY, not a symlink, like a real install: on Bun a framework package + // symlinked from outside the app breaks a dynamic `#` import on its own, + // which would mask the dynamic-alias-import check. + for (const p of ['core', 'server', 'cli']) { + cpSync(join(ROOT, 'packages', p), join(dir, 'node_modules/@webjsdev', p), { recursive: true, dereference: true, filter: (src) => basename(src) !== 'test' }); + } - child = spawn(process.execPath, [CLI, 'dev', '--port', String(PORT)], { + // The CLI runs from the app's own node_modules, as in a real install: under + // `bun --hot` an entry outside the app breaks dynamic `#` imports by itself, + // and a CLI outside it would load whatever server its own location resolves. + child = spawn(process.execPath, [join(dir, 'node_modules/@webjsdev/cli/bin/webjs.js'), 'dev', '--port', String(PORT)], { cwd: dir, detached: true, stdio: ['ignore', 'pipe', 'pipe'], @@ -71,6 +79,7 @@ try { const results = await runDevReloadStress({ appDir: dir, base: BASE, timeoutMs: 10_000, tolerateRestarts: !isBun }); const failed = results.filter((r) => !r.ok); console.log(`${runtime}: ${summarizeStress(results)}`); + if (failed.length) console.log((log.match(/.*(Cannot find|ENOENT|error:).*/g) || []).slice(0, 6).join("\n")); assert.deepEqual(failed, [], `the dev server did not settle on the current files on ${runtime}\n--- server log (tail) ---\n${log.slice(-6000)}`); if (isBun) { From 57e70408dbc811d2968db66feeab4c420a56cabf Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 20:21:52 +0530 Subject: [PATCH 2/4] chore: add a release gate that runs the packed candidate in real apps 0.8.85 and 0.8.86 passed every unit suite and still broke real Bun apps. The gate packs core, server and cli, runs a fresh postgres scaffold on Bun and Node through page renders, sign-up and log-in, a bound form action, request context and a server module reached by a dynamic # import, before and after the agent-style reload stress, and smoke-tests Crisp downstream with the candidate swapped in. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- framework-dev.md | 10 ++ scripts/release-gate.mjs | 325 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 scripts/release-gate.mjs diff --git a/framework-dev.md b/framework-dev.md index 472d7b079..ab98ff06b 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -359,6 +359,16 @@ The first three attempts were a STATIC scan over test sources, and each went bli --- +### Release gate: run it before every publish (`node scripts/release-gate.mjs`) + +**No `@webjsdev/*` release merges until the release gate passes.** 0.8.85 and 0.8.86 shipped with green unit suites and still broke real Bun apps (a dynamic `#` import of a server module 500'd every project page in Crisp; a bound form action failed after any edit), because nothing ran the PACKED packages inside a real app. The gate does: + +1. the suites: `npm test` (Node) and `node scripts/run-bun-tests.js` (Bun); run `npm run test:browser` too; +2. a fresh `webjs create --db postgres` app on Bun AND Node with the candidate packed by `npm pack` (core's `dist` is rebuilt first): every static page renders, sign up and log in, a bound ``, `headers()` request context, a `*.server.ts` with relative imports reached statically and through `await import('#...')`, then the agent-style reload stress (`scripts/dev-reload-stress.mjs`) and every check again after the edits, then `webjs check` and `webjs typecheck`; +3. with `--crisp /apps/web`, the downstream smoke: the candidate swapped into Crisp's install (restored after), Crisp's server and browser suites, and a fresh `bun run dev` loading `/`, `/dashboard`, the provisioning module chain, a server action (`createProject`) and `/projects/`, before and after an edit (`GATE_CRISP_PROVISION=1` also waits for a real project to provision). + +It needs a Postgres at `GATE_PG` (default `postgres://crisp:crisp@localhost:5432`) and creates and drops its own databases. Paste its summary into the release PR. **If a release is found broken after publish, deprecate it at once** (`npm deprecate @webjsdev/@ "; use "`) and ship the fix through the gate. + ### Changelog: per-package, per-version, auto-generated WebJs ships per-package per-version changelogs under `changelog//.md`. The model: **a version bump is the trigger**. When any commit on `main` changes the `version` field in `packages//package.json`, the scripts/backfill-changelog.js generator emits a new `changelog//.md` summarising every conventional-commit (`feat:` / `fix:` / `breaking:` / `perf:`) that landed in that package since the prior bump. The website renders the union of all packages' files at `/changelog`. diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs new file mode 100644 index 000000000..78fc912ab --- /dev/null +++ b/scripts/release-gate.mjs @@ -0,0 +1,325 @@ +/** + * The release gate (#1575 follow-up): what must pass before ANY @webjsdev + * release is published. Two regressions shipped in 0.8.85 / 0.8.86 because the + * unit suites were green while a real app on Bun was not, so the gate runs the + * CANDIDATE packages (packed from this checkout, exactly what npm would get) + * inside real apps. + * + * node scripts/release-gate.mjs [--crisp ] [--skip-suites] [--only=bun|node] + * + * Stages, each printed PASS / FAIL, exit non-zero on any failure: + * 1. suites `npm test` (Node) and `node scripts/run-bun-tests.js` (Bun). + * 2. scaffold for Bun and Node: `webjs create --db postgres` with the packed + * candidate, then `webjs dev` and the app checks: every static page renders + * (no 500, no "not a server action"), sign up and log in through the bound + * form actions, a probe page with a bound ``, request + * context (`headers()`), a `*.server.ts` with relative imports reached both + * statically and through `await import('#...')`. Then the agent-style + * reload stress (`scripts/dev-reload-stress.mjs`), and every check again + * after the edits. Then `webjs check` and `webjs typecheck`. + * 3. crisp (with --crisp): the candidate linked into Crisp's install, its + * server and browser suites, and a fresh `bun run dev` loading /, + * /dashboard, a project page, the provisioning module chain, and a server + * action, before and after an edit. GATE_CRISP_PROVISION=1 also creates a + * real project (one pilots machine) and deletes it. + * + * Needs a Postgres at GATE_PG (default postgres://crisp:crisp@localhost:5432), + * where it creates and drops one scratch database per app. + */ +import { spawn, spawnSync, execFileSync } from 'node:child_process'; +import { lstatSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync, cpSync, appendFileSync, renameSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash, randomBytes } from 'node:crypto'; +import { runDevReloadStress, summarizeStress } from './dev-reload-stress.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; +const only = (args.find((a) => a.startsWith('--only=')) || '').slice(7); +const PG = process.env.GATE_PG || 'postgres://crisp:crisp@localhost:5432'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const results = []; +const record = (stage, name, ok, detail = '') => { + results.push({ stage, name, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} [${stage}] ${name}${detail ? ` ${detail}` : ''}`); +}; + +function run(cmd, argv, opts = {}) { + const r = spawnSync(cmd, argv, { stdio: opts.quiet ? 'pipe' : 'inherit', encoding: 'utf8', timeout: opts.timeoutMs ?? 1_800_000, ...opts }); + return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}` }; +} + +/** Pack core, server and cli as npm would publish them. */ +function packCandidate() { + // Build core's dist from THIS checkout (a linked worktree has it as a symlink + // into another checkout, which `npm pack` would silently leave out). + const dist = join(ROOT, 'packages/core/dist'); + try { if (lstatSync(dist).isSymbolicLink()) rmSync(dist); } catch {} + execFileSync('node', ['scripts/build-framework-dist.js'], { cwd: ROOT, stdio: 'pipe' }); + const dir = mkdtempSync(join(tmpdir(), 'webjs-gate-pack-')); + const tarballs = {}; + for (const pkg of ['core', 'server', 'cli']) { + const out = execFileSync('npm', ['pack', '--silent', '--pack-destination', dir], { cwd: join(ROOT, 'packages', pkg), encoding: 'utf8' }).trim().split('\n').pop(); + tarballs[`@webjsdev/${pkg}`] = join(dir, out); + } + return tarballs; +} + +/** Start a dev server in its own process group; resolves once `/` answers. */ +async function startDev(cwd, cmd, argv, port, env = {}) { + let log = ''; + const child = spawn(cmd, argv, { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PORT: String(port), ...env } }); + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + const deadline = Date.now() + 180_000; + for (;;) { + try { const r = await fetch(`http://localhost:${port}/`); if (r.status < 500) break; } catch {} + if (Date.now() > deadline || child.exitCode !== null) throw new Error(`dev server did not come up\n${log.slice(-3000)}`); + await sleep(300); + } + return { child, log: () => log, stop: async () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} await sleep(800); try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }; +} + +/** Static page URLs from the app tree (no dynamic segments). */ +function staticPages(appDir) { + const out = []; + const walk = (dir, url) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isDirectory()) { + if (e.name.startsWith('_') || e.name.startsWith('[') || e.name === 'crash' || e.name.startsWith('.') || e.name.startsWith('zz')) continue; + walk(join(dir, e.name), /^\(.*\)$/.test(e.name) ? url : `${url}/${e.name}`); + } else if (/^page\.m?[jt]s$/.test(e.name)) out.push(url || '/'); + } + }; + walk(join(appDir, 'app'), ''); + return out.sort(); +} + +/** Submit every POST form on `path` with its hidden fields plus test values. */ +async function submitForms(base, path, cookie, values) { + const res = await fetch(base + path, { headers: cookie ? { cookie } : {} }); + const html = await res.text(); + const forms = [...html.matchAll(/]*method="post"[^>]*)>([\s\S]*?)<\/form>/gi)].map((m) => [m[0], m[2], /\baction="(\/[^"]*)"/.exec(m[1])?.[1]]); + const statuses = []; + for (const f of forms) { + const fd = new FormData(); + for (const m of f[1].matchAll(/]*)>/gi)) { + const name = /name="([^"]+)"/.exec(m[1])?.[1]; + if (!name) continue; + const type = /type="([^"]+)"/.exec(m[1])?.[1] || 'text'; + const val = /value="([^"]*)"/.exec(m[1])?.[1] ?? ''; + fd.append(name, type === 'hidden' ? val : (values[name] ?? values[type] ?? 'gate')); + } + // A submitter button can carry the action identity (name + value). + const btn = /]*\bname="([^"]+)"[^>]*)>/i.exec(f[1]); + if (btn) { const v = /value="([^"]*)"/.exec(btn[1])?.[1] ?? ''; fd.append(btn[2], v); } + const r = await fetch(base + (f[2] || path), { method: 'POST', body: fd, redirect: 'manual', headers: { origin: base, ...(cookie ? { cookie } : {}) } }); + statuses.push(r.status); + } + return { count: forms.length, statuses }; +} + +const probeFiles = { + 'modules/zzgate/types.ts': "export const T = 'gate-ok';\n", + 'modules/zzgate/engine.server.ts': "import { T } from './types.ts';\nexport function eng() { return T; }\n", + 'modules/zzgate/act.server.ts': "'use server';\nimport { T } from './types.ts';\nexport async function act(_input?: unknown) { return { success: true as const, data: T }; }\n", + 'app/zzgate/page.ts': "import { html } from '@webjsdev/core';\nimport { headers } from '@webjsdev/server';\nimport { act } from '#modules/zzgate/act.server.ts';\nexport default async function P() {\n const { eng } = await import('#modules/zzgate/engine.server.ts');\n const ua = headers().get('x-gate') || 'none';\n return html`

dyn:${eng()} hdr:${ua}

`;\n}\n", +}; + +/** The app checks, run before and after the reload stress. */ +async function appChecks(stage, appDir, base, when) { + const bad = []; + for (const p of staticPages(appDir)) { + const r = await fetch(base + p).catch((e) => ({ status: 0, text: async () => String(e) })); + const body = await r.text(); + if (r.status >= 500 || /is not a server action/.test(body)) bad.push(`${p} ${r.status}`); + } + record(stage, `${when}: every static page renders`, bad.length === 0, bad.slice(0, 5).join(', ')); + const probe = await fetch(base + '/zzgate', { headers: { 'x-gate': 'seen' } }); + const body = await probe.text(); + record(stage, `${when}: dynamic # import of a *.server.ts with relative imports`, probe.status === 200 && body.includes('dyn:gate-ok'), `${probe.status}`); + record(stage, `${when}: request context (headers())`, body.includes('hdr:seen')); + const bound = await submitForms(base, '/zzgate', '', { q: 'x' }); + record(stage, `${when}: bound form action submits`, bound.count === 1 && bound.statuses.every((s) => s < 400) && !/not a server action/.test(body), JSON.stringify(bound)); + if (existsSync(join(appDir, 'app/features/auth/signup'))) { + const email = `gate-${randomBytes(4).toString('hex')}@example.com`; + const su = await submitForms(base, '/features/auth/signup', '', { name: 'Gate', email, password: 'gate-password-1' }); + const li = await submitForms(base, '/features/auth/login', '', { email, password: 'gate-password-1' }); + record(stage, `${when}: sign up and log in`, su.count > 0 && li.count > 0 && [...su.statuses, ...li.statuses].every((s) => s < 400), JSON.stringify({ su, li })); + } +} + +async function scaffoldStage(runtime, tarballs, port) { + const stage = `scaffold:${runtime}`; + const parent = mkdtempSync(join(tmpdir(), `webjs-gate-${runtime}-`)); + const appDir = join(parent, 'gateapp'); + const db = `webjs_gate_${runtime}_${Date.now().toString(36)}`; + let dev; + try { + const created = run('node', [join(ROOT, 'packages/cli/bin/webjs.js'), 'create', 'gateapp', '--template', 'full-stack', '--runtime', runtime, '--db', 'postgres', '--no-install'], { cwd: parent, quiet: true }); + record(stage, 'create', created.ok, created.ok ? '' : created.out.slice(-800)); + if (!created.ok) return; + const pkgPath = join(appDir, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + for (const [name, tgz] of Object.entries(tarballs)) pkg.dependencies[name] = `file:${tgz}`; + pkg.overrides = { ...(pkg.overrides || {}), ...Object.fromEntries(Object.entries(tarballs).map(([n, t]) => [n, `file:${t}`])) }; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); + const inst = runtime === 'bun' ? run('bun', ['install'], { cwd: appDir, quiet: true }) : run('npm', ['install', '--no-audit', '--no-fund'], { cwd: appDir, quiet: true }); + record(stage, 'install candidate', inst.ok, inst.ok ? '' : inst.out.slice(-800)); + if (!inst.ok) return; + const mk = run('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(PG + '/postgres')});c.connect().then(()=>c.query('CREATE DATABASE ${db}')).then(()=>c.end())`], { cwd: appDir, quiet: true }); + if (!mk.ok) { record(stage, 'create database', false, mk.out.slice(-400)); return; } + // `create --no-install` skips the migration generation an installing create runs. + const gen = run(runtime === 'bun' ? 'bunx' : 'npx', [...(runtime === 'bun' ? ['--bun'] : []), 'webjs', 'db', 'generate'], { cwd: appDir, quiet: true }); + record(stage, 'webjs db generate', gen.ok, gen.ok ? '' : gen.out.slice(-600)); + const env = readFileSync(join(appDir, '.env.example'), 'utf8') + .replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${PG}/${db}`) + .replace(/^PORT=.*$/m, `PORT=${port}`) + .replace(/^AUTH_SECRET=.*$/m, `AUTH_SECRET=${randomBytes(32).toString('hex')}`); + writeFileSync(join(appDir, '.env'), env); + for (const [f, s] of Object.entries(probeFiles)) { mkdirSync(dirname(join(appDir, f)), { recursive: true }); writeFileSync(join(appDir, f), s); } + const base = `http://localhost:${port}`; + dev = runtime === 'bun' ? await startDev(appDir, 'bun', ['run', 'dev'], port) : await startDev(appDir, 'npm', ['run', 'dev'], port); + await sleep(1500); + await appChecks(stage, appDir, base, 'fresh'); + const stress = await runDevReloadStress({ appDir, base, timeoutMs: 10_000, tolerateRestarts: runtime === 'node' }); + const failed = stress.filter((r) => !r.ok); + record(stage, `reload stress: ${summarizeStress(stress)}`, failed.length === 0, failed.map((f) => `${f.name}: ${f.why}`).join('; ').slice(0, 600)); + // Edit the probe files themselves, then check everything again. + appendFileSync(join(appDir, 'app/zzgate/page.ts'), '// edited\n'); + appendFileSync(join(appDir, 'modules/zzgate/act.server.ts'), '// edited\n'); + appendFileSync(join(appDir, 'modules/zzgate/engine.server.ts'), '// edited\n'); + await sleep(runtime === 'node' ? 4000 : 1500); + await appChecks(stage, appDir, base, 'after edits'); + if (runtime === 'bun') { + const restarts = (dev.log().match(/restarting the dev server/g) || []).length; + record(stage, 'no dev-server restart on Bun', restarts === 0, `${restarts} restarts`); + } + if (results.some((r) => r.stage === stage && !r.ok)) { + console.log((dev.log().match(/.*(error|Error|ENOENT|does not exist|Cannot find).*/g) || []).slice(0, 15).join('\n')); + } + await dev.stop(); dev = null; + for (const f of Object.keys(probeFiles)) rmSync(join(appDir, f), { force: true }); + const x = runtime === 'bun' ? ['bunx', ['--bun', 'webjs']] : ['npx', ['webjs']]; + const check = run(x[0], [...x[1], 'check'], { cwd: appDir, quiet: true }); + record(stage, 'webjs check', check.ok, check.ok ? '' : check.out.slice(-600)); + const tc = run(x[0], [...x[1], 'typecheck'], { cwd: appDir, quiet: true }); + record(stage, 'webjs typecheck', tc.ok, tc.ok ? '' : tc.out.slice(-600)); + } catch (e) { + record(stage, 'unexpected error', false, String(e && e.stack || e).slice(0, 1500)); + } finally { + if (dev) await dev.stop(); + run('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(PG + '/postgres')});c.connect().then(()=>c.query('DROP DATABASE IF EXISTS ${db} WITH (FORCE)')).then(()=>c.end())`], { cwd: appDir, quiet: true }); + rmSync(parent, { recursive: true, force: true }); + } +} + +/** Crisp downstream smoke with the candidate swapped into its install. */ +async function crispStage(crispWeb, tarballs, port) { + const stage = 'crisp'; + const repo = resolve(crispWeb, '../..'); + const nm = join(repo, 'node_modules/@webjsdev'); + const backup = mkdtempSync(join(repo, 'node_modules', '.webjs-gate-backup-')); + const db = `webjs_gate_crisp_${Date.now().toString(36)}`; + let dev; + try { + for (const [name, tgz] of Object.entries(tarballs)) { + const short = name.split('/')[1]; + if (existsSync(join(nm, short))) renameSync(join(nm, short), join(backup, short)); + mkdirSync(join(nm, short), { recursive: true }); + execFileSync('tar', ['-xzf', tgz, '-C', join(nm, short), '--strip-components=1']); + } + if (existsSync(join(backup, 'server', 'node_modules'))) cpSync(join(backup, 'server', 'node_modules'), join(nm, 'server', 'node_modules'), { recursive: true }); + const mainEnv = readFileSync(join(crispWeb, '.env'), 'utf8'); + const mk = run('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(PG + '/postgres')});c.connect().then(()=>c.query('CREATE DATABASE ${db}')).then(()=>c.end())`], { cwd: crispWeb, quiet: true }); + if (!mk.ok) { record(stage, 'create database', false, mk.out.slice(-400)); return; } + const testEnv = { ...process.env, DATABASE_URL: `${PG}/${db}` }; + const mig = run('bun', ['run', 'db:migrate'], { cwd: crispWeb, quiet: true, env: testEnv }); + record(stage, 'migrate', mig.ok, mig.ok ? '' : mig.out.slice(-600)); + for (const suite of ['test:server', 'test:browser']) { + let r = run('bun', ['run', suite], { cwd: crispWeb, quiet: true, env: testEnv, timeoutMs: 3_600_000 }); + // One retry, reported: a pass on the retry is a flake to look at, not a pass. + let note = ''; + if (!r.ok) { const first = r.out; r = run('bun', ['run', suite], { cwd: crispWeb, quiet: true, env: testEnv, timeoutMs: 3_600_000 }); note = r.ok ? 'FLAKY: failed once, passed on retry' : (first.match(/.*(fail|Error|❌).*/gi) || []).slice(0, 8).join(' | '); } + record(stage, `bun run ${suite}`, r.ok, r.ok ? note : (note || r.out.slice(-1500))); + } + // A fresh dev server on the scratch database. + const envPath = join(crispWeb, '.env'); + const saved = readFileSync(envPath, 'utf8'); + writeFileSync(envPath, mainEnv.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${PG}/${db}`).replace(/^PORT=.*$/m, `PORT=${port}`).replace(/^APP_URL=.*$/m, `APP_URL=http://localhost:${port}`)); + try { + dev = await startDev(crispWeb, 'bun', ['run', 'dev'], port, { NODE_ENV: 'development' }); + } finally { + writeFileSync(envPath, saved); + } + const base = `http://localhost:${port}`; + const cookie = execFileSync('bun', ['-e', "const { devSignInCookie } = await import('#modules/auth/dev-login.server.ts'); console.log((await devSignInCookie(process.env.CRISP_DEV_LOGIN_SECRET ?? null)) ?? ''); process.exit(0);"], { cwd: crispWeb, encoding: 'utf8', env: { ...process.env, ...Object.fromEntries(mainEnv.split('\n').filter((l) => /^[A-Z_]+=/.test(l)).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])), DATABASE_URL: `${PG}/${db}`, NODE_ENV: 'development' } }).trim().split(';')[0]; + record(stage, 'dev sign-in cookie', cookie.startsWith('webjs.auth=')); + const probe = join(crispWeb, 'app/api/zzgate/route.ts'); + mkdirSync(dirname(probe), { recursive: true }); + writeFileSync(probe, "export async function GET() {\n const { startInitialRun } = await import('#modules/runs/engine.server.ts');\n const p = await import('#modules/projects/provision.server.ts');\n return new Response(typeof startInitialRun + ':' + typeof p.provisionInBackground);\n}\n"); + const hash = createHash('sha256').update(join(crispWeb, 'modules/projects/actions/create-project.server.ts')).digest('hex').slice(0, 10); + const delHash = createHash('sha256').update(join(crispWeb, 'modules/projects/actions/delete-project.server.ts')).digest('hex').slice(0, 10); + const created = []; + const checks = async (when) => { + for (const p of ['/', '/dashboard']) { + const r = await fetch(base + p, { headers: { cookie }, redirect: 'manual' }); + record(stage, `${when}: ${p}`, r.status < 400, `${r.status}`); + } + const chain = await (await fetch(base + '/api/zzgate')).text(); + record(stage, `${when}: provisioning module chain loads`, chain === 'function:function', chain.slice(0, 200)); + const r = await fetch(`${base}/__webjs/action/${hash}/createProject`, { method: 'POST', headers: { origin: base, cookie, 'content-type': 'application/vnd.webjs+json' }, body: JSON.stringify([{ prompt: 'release gate smoke: a todo list' }]) }); + const text = await r.text(); + const id = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.exec(text)?.[0]; + record(stage, `${when}: server action (createProject)`, r.status === 200 && !!id, `${r.status} ${text.slice(0, 160)}`); + if (id) { + created.push(id); + const page = await fetch(`${base}/projects/${id}`, { headers: { cookie } }); + record(stage, `${when}: /projects/`, page.status === 200, `${page.status}`); + if (process.env.GATE_CRISP_PROVISION === '1') { + let status = ''; + for (let i = 0; i < 120 && !/ready|failed/.test(status); i++) { + await sleep(2000); + status = execFileSync('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(`${PG}/${db}`)});c.connect().then(()=>c.query("select status from projects where id='${id}'")).then((r)=>{console.log(r.rows[0]?.status||'');return c.end()})`], { cwd: crispWeb, encoding: 'utf8' }).trim(); + } + record(stage, `${when}: provisioning reaches ready`, status === 'ready', status); + } + } + }; + await checks('fresh'); + appendFileSync(join(crispWeb, 'modules/projects/actions/create-project.server.ts'), '// gate edit\n'); + appendFileSync(probe, '// gate edit\n'); + await sleep(1500); + await checks('after edits'); + for (const id of created) { + await fetch(`${base}/__webjs/action/${delHash}/deleteProject`, { method: 'POST', headers: { origin: base, cookie, 'content-type': 'application/vnd.webjs+json' }, body: JSON.stringify([{ id }]) }).catch(() => {}); + } + } catch (e) { + record(stage, 'unexpected error', false, String(e && e.stack || e).slice(0, 1500)); + } finally { + if (dev) await dev.stop(); + // Undo the probe edits and restore Crisp's own install. + const cp = join(crispWeb, 'modules/projects/actions/create-project.server.ts'); + if (existsSync(cp)) writeFileSync(cp, readFileSync(cp, 'utf8').replace(/\/\/ gate edit\n$/m, '')); + rmSync(join(crispWeb, 'app/api/zzgate'), { recursive: true, force: true }); + for (const short of readdirSync(backup)) { rmSync(join(nm, short), { recursive: true, force: true }); renameSync(join(backup, short), join(nm, short)); } + rmSync(backup, { recursive: true, force: true }); + run('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(PG + '/postgres')});c.connect().then(()=>c.query('DROP DATABASE IF EXISTS ${db} WITH (FORCE)')).then(()=>c.end())`], { cwd: crispWeb, quiet: true }); + } +} + +if (!args.includes('--skip-suites')) { + record('suites', 'npm test (Node)', run('npm', ['test'], { cwd: ROOT, quiet: true }).ok); + record('suites', 'Bun matrix (scripts/run-bun-tests.js)', run('node', ['scripts/run-bun-tests.js'], { cwd: ROOT, quiet: true }).ok); +} +const tarballs = packCandidate(); +console.log(`candidate: ${Object.values(tarballs).map((t) => t.split(sep).pop()).join(', ')}`); +for (const [i, rt] of ['bun', 'node'].entries()) if (!only || only === rt) await scaffoldStage(rt, tarballs, 9300 + i); +const crisp = opt('--crisp'); +if (crisp) await crispStage(resolve(crisp), tarballs, 9310); + +const failed = results.filter((r) => !r.ok); +console.log(`\nrelease gate: ${results.length - failed.length}/${results.length} passed${failed.length ? `; FAILED: ${failed.map((f) => `[${f.stage}] ${f.name}`).join(', ')}` : ''}`); +process.exit(failed.length ? 1 : 0); From 9c3fc323a18d6f3975c8410553344a8e7d497950 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 7 Oct 2026 20:19:47 +0530 Subject: [PATCH 3/4] Run the Bun dev child with the transpiler cache off Bun caches the transpile of every source over 50KB on disk, keyed by content, with the paths a Bun.plugin onResolve returned baked in. The dev alias resolver (#1575) returns absolute paths, so a large app module was pinned to the checkout that first ran `webjs dev`: any other copy of the same file (a worktree, a moved app, a later `webjs start`) imported from that old directory, a 500 once it was gone and the wrong code while it was not. It made the website home page 500 on Bun in every local worktree. Bun reads the setting at startup, so the supervisor sets it on the child. Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- .github/workflows/ci.yml | 6 ++ package.json | 4 + packages/cli/bin/webjs.js | 2 +- packages/cli/lib/dev-supervisor.js | 17 +++- .../dev-supervisor/dev-supervisor.test.js | 10 +++ test/bun/dev-transpiler-cache.mjs | 87 +++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 test/bun/dev-transpiler-cache.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c28c4413..83223849d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,6 +254,12 @@ jobs: # to be proven on both. - name: webjs dev source locations on Bun run: bun test/bun/dev-source-locations.mjs + # Bun's on-disk transpiler cache keys a large module by content and keeps + # the absolute paths the dev alias resolver returned, so the dev child + # runs with it off; otherwise another checkout of the file imports from + # the first one. + - name: webjs dev transpiler cache on Bun + run: bun test/bun/dev-transpiler-cache.mjs # The app-source deploy signal (#899) is derived from an fs source walk + # a node:crypto digest, so it must be byte-identical on the Bun.serve path. - name: App-source deploy signal on Bun diff --git a/package.json b/package.json index ca735bc02..5066f9aba 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,10 @@ "title": "bun proof: dev-source-locations", "run": "bun test/bun/dev-source-locations.mjs" }, + { + "title": "bun proof: dev-transpiler-cache", + "run": "bun test/bun/dev-transpiler-cache.mjs" + }, { "title": "bun proof: dev-public-before-warm", "run": "bun test/bun/dev-public-before-warm.mjs" diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 9f0fa8358..3c5421c48 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -591,7 +591,7 @@ async function main() { superviseDevServer({ cwd: process.cwd(), plan, - env: { ...process.env, __WEBJS_DEV_CHILD: '1' }, + env: { ...process.env, ...plan.env, __WEBJS_DEV_CHILD: '1' }, onExit: (code) => { killTasks(); process.exit(code); }, }); break; diff --git a/packages/cli/lib/dev-supervisor.js b/packages/cli/lib/dev-supervisor.js index c2fbe950b..6be923f34 100644 --- a/packages/cli/lib/dev-supervisor.js +++ b/packages/cli/lib/dev-supervisor.js @@ -78,7 +78,7 @@ export function isBootFile(path) { * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim. * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely). * @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin. - * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }} + * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], env?: Record, restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }} * `inline` runs the server in this process (no reload watcher); `supervise` * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at @@ -101,6 +101,7 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false return { mode: 'supervise', args: ['--hot', ...argv], + env: BUN_CHILD_ENV, restartOnChange: true, restartFor: (p) => plugin(p) || isBootFile(p), inPlaceRestartFor: isBootFile, @@ -110,6 +111,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch }; } +/** + * Extra env for the Bun dev child: turn off Bun's runtime transpiler cache. + * + * Bun caches the transpile of every source over 50KB on disk, keyed by the + * file's CONTENT, with the import paths a `Bun.plugin` `onResolve` returned + * baked in. The dev alias resolver (#1575) returns absolute paths, so the + * cached output of a large app module pins its `#` imports to the checkout + * that first ran `webjs dev`. Any other copy of the same file (a git worktree, + * a moved or copied app, a later `webjs start`) then imports from that old + * directory: a 500 when it is gone, the other copy's code when it is not. The + * variable is read at process start, so it has to be set on the child. + */ +const BUN_CHILD_ENV = Object.freeze({ BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }); + const SERVER_MODULE = /\.server\.m?[jt]s$/; const APP_MODULE = /\.m?[jt]s$/; diff --git a/packages/cli/test/dev-supervisor/dev-supervisor.test.js b/packages/cli/test/dev-supervisor/dev-supervisor.test.js index 86d8cf2d6..16655c424 100644 --- a/packages/cli/test/dev-supervisor/dev-supervisor.test.js +++ b/packages/cli/test/dev-supervisor/dev-supervisor.test.js @@ -168,3 +168,13 @@ test('the in-place IPC message matches the one @webjsdev/server sends (#1575)', const server = await import('../../../server/src/dev/hot-host.js'); assert.equal(cli.HOT_IN_PLACE_MESSAGE, server.HOT_IN_PLACE_MESSAGE); }); + +test('the Bun child runs with the transpiler cache off; Node gets no extra env', () => { + // Bun's on-disk transpiler cache keys a large module by content and bakes in + // the absolute paths the dev alias resolver returned, so another checkout of + // the same file imports from the first one. The child must never write it. + const bun = planDevSupervisor({ isBun: true, argv: ARGV, noHot: false }); + assert.deepEqual(bun.env, { BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }); + const node = planDevSupervisor({ isBun: false, argv: ARGV, noHot: false }); + assert.equal(node.env, undefined); +}); diff --git a/test/bun/dev-transpiler-cache.mjs b/test/bun/dev-transpiler-cache.mjs new file mode 100644 index 000000000..9d9e23191 --- /dev/null +++ b/test/bun/dev-transpiler-cache.mjs @@ -0,0 +1,87 @@ +/** + * `webjs dev` on Bun must not write Bun's runtime transpiler cache. + * + * Bun caches the transpile of every source over 50KB on disk, keyed by the + * file's CONTENT, and the cached output carries the import paths a + * `Bun.plugin` `onResolve` returned. The dev alias resolver (#1575) returns + * absolute paths, so a large app module's `#` imports were pinned to the + * checkout that first ran `webjs dev`. Any other copy of the same file (a git + * worktree, a moved app, a later `webjs start`) then imported from that old + * directory: a 500 once it was gone, the other copy's code while it was not. + * The supervisor now runs the Bun child with the cache off. + * + * The proof points the cache at a temp directory, serves a >50KB page that + * imports through a `#` alias, and asserts nothing was written there. Reverting + * the fix leaves a `.pile` entry for the page. Run: `bun test/bun/dev-transpiler-cache.mjs`. + */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (!process.versions.bun) { + console.log('SKIP dev-transpiler-cache is a Bun-only proof'); + process.exit(0); +} + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const CLI = join(ROOT, 'packages/cli/bin/webjs.js'); +const PORT = 9950 + (process.pid % 40); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const dir = mkdtempSync(join(tmpdir(), 'webjs-tcache-')); +const cache = join(dir, '.bun-transpiler-cache'); +let child; +let log = ''; +try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'tcache', type: 'module', webjs: {}, imports: { '#lib/*': './lib/*' }, + })); + mkdirSync(join(dir, 'node_modules/@webjsdev'), { recursive: true }); + symlinkSync(join(ROOT, 'packages/core'), join(dir, 'node_modules/@webjsdev/core')); + symlinkSync(join(ROOT, 'packages/server'), join(dir, 'node_modules/@webjsdev/server')); + mkdirSync(join(dir, 'lib')); + writeFileSync(join(dir, 'lib/msg.ts'), `export const msg = 'FROM_ALIAS';\n`); + mkdirSync(join(dir, 'app')); + // Over Bun's 50KB threshold, so Bun would cache this module's transpile. + const filler = Array.from({ length: 1200 }, (_, i) => `// filler line ${i} keeps this module above the transpiler cache threshold`).join('\n'); + writeFileSync(join(dir, 'app/page.ts'), [ + `import { html } from '@webjsdev/core';`, + `import { msg } from '#lib/msg.ts';`, + filler, + `export default function Page() { return html\`

\${msg}

\`; }`, + '', + ].join('\n')); + + child = spawn(process.execPath, [CLI, 'dev', '--port', String(PORT)], { + cwd: dir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'development', BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }, + }); + child.stdout.on('data', (d) => { log += d; }); + child.stderr.on('data', (d) => { log += d; }); + + let served = false; + for (const deadline = Date.now() + 30_000; Date.now() < deadline && !served; await sleep(200)) { + try { served = (await (await fetch(`http://localhost:${PORT}/`)).text()).includes('FROM_ALIAS'); } catch { /* not up yet */ } + } + assert.ok(served, `the page never served its # import\n--- server log ---\n${log}`); + + // The CLI's own supervisor process still caches the framework's large + // modules, which is harmless (no plugin runs there). What must never be + // cached is the APP page, whose entry would carry the resolved alias path. + const pinned = (existsSync(cache) ? readdirSync(cache) : []) + .filter((f) => readFileSync(join(cache, f)).includes('PAGE_MARKER') || readFileSync(join(cache, f)).includes(join(dir, 'lib'))); + assert.deepEqual(pinned, [], `webjs dev cached the app page's transpile (${pinned.join(', ')}), pinning its # import to this checkout`); + console.log(`OK webjs dev on bun ${process.versions.bun} keeps the app page out of the transpiler cache`); +} finally { + if (child && child.pid) { + try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch {} } + await sleep(500); + try { process.kill(-child.pid, 'SIGKILL'); } catch {} + } + rmSync(dir, { recursive: true, force: true }); +} From 742583deb8963997a0650758cba7b3dea7f112cb Mon Sep 17 00:00:00 2001 From: t Date: Wed, 7 Oct 2026 20:47:23 +0530 Subject: [PATCH 4/4] fix: empty Bun's transpiler cache once after the 0.8.85/0.8.86 dev builds Those versions baked broken file:/ import paths into Bun's on-disk transpiler cache, keyed by the file's content, so an unchanged module kept failing after the fix (webjs start, or any process with the cache on). The first WebJs server on Bun now empties that cache once per machine. The release gate warms a cache with 0.8.86 first, checks webjs start on Bun, signs in and out repeatedly with a signed-in page that reads the current user, and runs the app and the website again from a second path with the cache warm. Refs #1575 Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo --- packages/server/src/bun-transpiler-cache.js | 64 +++++++ packages/server/src/dev/handler.js | 4 + .../test/dev/hot-reload-robustness.test.js | 19 ++ scripts/release-gate.mjs | 180 ++++++++++++++++-- 4 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 packages/server/src/bun-transpiler-cache.js diff --git a/packages/server/src/bun-transpiler-cache.js b/packages/server/src/bun-transpiler-cache.js new file mode 100644 index 000000000..9731d2ca0 --- /dev/null +++ b/packages/server/src/bun-transpiler-cache.js @@ -0,0 +1,64 @@ +/** + * Evict Bun transpiler-cache entries written by @webjsdev/server 0.8.85 and + * 0.8.86 (#1575). + * + * Those versions resolved the app's `#` imports through a Bun runtime + * `onResolve` in dev. Bun caches the transpile of every source over 50KB on + * disk, keyed by the file's CONTENT, with the resolved import paths baked in, + * so a large app module kept a broken `file:/...` path (or an absolute path + * into whichever checkout first ran it) after the fix: same bytes, same key, + * same poisoned output, a 500 on every request that reaches it. Nothing in a + * fixed version writes such an entry any more, but the old entries outlive the + * upgrade. + * + * The cache is only a cache, so the cure is to empty it once per machine: the + * first WebJs server on Bun at or after this version removes the entries and + * leaves a marker beside them, and every later start sees the marker and does + * nothing. Bun rebuilds what it needs on the next load. + * + * @module bun-transpiler-cache + */ +import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const MARKER = 'webjs-1575-evicted'; + +/** + * The directory Bun keeps its runtime transpiler cache in, or null when the + * cache is off (`BUN_RUNTIME_TRANSPILER_CACHE_PATH` set to `0` or empty). + * @param {NodeJS.ProcessEnv} env + * @param {string} home + * @returns {string | null} + */ +export function bunTranspilerCacheDir(env = process.env, home = homedir()) { + const set = env.BUN_RUNTIME_TRANSPILER_CACHE_PATH; + if (set !== undefined) return set === '0' || set === '' ? null : set; + return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'bun', '@t@'); +} + +/** + * Empty the Bun transpiler cache once per machine (see the module comment). + * Never throws: a cache it cannot read or write is left alone. + * @param {{ env?: NodeJS.ProcessEnv, home?: string, isBun?: boolean }} [opts] + * @returns {number} entries removed (0 when there was nothing to do) + */ +export function evictPoisonedBunTranspilerCache({ env = process.env, home = homedir(), isBun = !!process.versions.bun } = {}) { + if (!isBun) return 0; + const dir = bunTranspilerCacheDir(env, home); + if (!dir) return 0; + const marker = join(dirname(dir), MARKER); + try { + if (existsSync(marker)) return 0; + let removed = 0; + if (existsSync(dir)) { + for (const name of readdirSync(dir)) { + try { rmSync(join(dir, name), { recursive: true, force: true }); removed++; } catch { /* in use; leave it */ } + } + } + writeFileSync(marker, `emptied by @webjsdev/server at ${new Date().toISOString()} (#1575)\n`); + return removed; + } catch { + return 0; + } +} diff --git a/packages/server/src/dev/handler.js b/packages/server/src/dev/handler.js index 99f433f7f..0c2b954eb 100644 --- a/packages/server/src/dev/handler.js +++ b/packages/server/src/dev/handler.js @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { registerBunAppSource } from './bun-app-source.js'; +import { evictPoisonedBunTranspilerCache } from '../bun-transpiler-cache.js'; import { serverRuntime } from '../listener-core.js'; import { createHash } from 'node:crypto'; import { join, relative, resolve, sep } from 'node:path'; @@ -134,6 +135,9 @@ export async function createRequestHandler(opts) { // on Bun. Doing it here pays the (one-time) amaro import up front and surfaces // a missing-amaro error at boot rather than on the first `.ts` request. await ensureStripper(); + // Old (0.8.85 / 0.8.86) Bun transpiler-cache entries carry broken import + // paths keyed by unchanged file content; empty the cache once (#1575). + evictPoisonedBunTranspilerCache(); const appDir = resolve(opts.appDir); // Load /.env into process.env BEFORE anything else. // buildActionIndex below imports server-only files (lib/*.server.ts, diff --git a/packages/server/test/dev/hot-reload-robustness.test.js b/packages/server/test/dev/hot-reload-robustness.test.js index 61b116817..382364334 100644 --- a/packages/server/test/dev/hot-reload-robustness.test.js +++ b/packages/server/test/dev/hot-reload-robustness.test.js @@ -114,3 +114,22 @@ test('an in-place server marks its reload frames; a restarting one does not (#15 assert.match(frames(new SseHub({ inPlace: true })), /"inPlace":true/); assert.doesNotMatch(frames(new SseHub()), /inPlace/); }); + +test('the Bun transpiler cache is emptied once per machine, then left alone (#1575)', async () => { + const { evictPoisonedBunTranspilerCache, bunTranspilerCacheDir } = await import('../../src/bun-transpiler-cache.js'); + const home = mkdtempSync(join(tmpdir(), 'webjs-tcache-')); + try { + const env = { XDG_CACHE_HOME: join(home, 'c') }; + const dir = bunTranspilerCacheDir(env, home); + assert.equal(dir, join(home, 'c', 'bun', '@t@')); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'poisoned.pile'), 'import("file:/x/engine.server.ts")'); + assert.equal(evictPoisonedBunTranspilerCache({ env, home, isBun: true }), 1); + writeFileSync(join(dir, 'fresh.pile'), 'ok'); + assert.equal(evictPoisonedBunTranspilerCache({ env, home, isBun: true }), 0, 'once only'); + assert.equal(evictPoisonedBunTranspilerCache({ env: { BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' }, home, isBun: true }), 0, 'cache off'); + assert.equal(evictPoisonedBunTranspilerCache({ env, home, isBun: false }), 0, 'Node never touches it'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index 78fc912ab..d7b8d0b7a 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -27,7 +27,7 @@ * where it creates and drops one scratch database per app. */ import { spawn, spawnSync, execFileSync } from 'node:child_process'; -import { lstatSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync, cpSync, appendFileSync, renameSync } from 'node:fs'; +import { lstatSync, realpathSync, symlinkSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync, cpSync, appendFileSync, renameSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -121,11 +121,79 @@ async function submitForms(base, path, cookie, values) { return { count: forms.length, statuses }; } + +/** Fetch with a cookie jar, following redirects by hand; reports a loop. */ +async function follow(base, path, jar, init = {}) { + let url = base + path; + const seen = []; + for (let hop = 0; hop < 10; hop++) { + const r = await fetch(url, { ...init, redirect: 'manual', headers: { ...(init.headers || {}), cookie: jarHeader(jar), origin: base } }); + for (const c of r.headers.getSetCookie?.() || []) { + const [pair] = c.split(';'); const i = pair.indexOf('='); + const k = pair.slice(0, i).trim(), v = pair.slice(i + 1); + if (/max-age=0|expires=thu, 01 jan 1970/i.test(c) || v === '') jar.delete(k); else jar.set(k, v); + } + if (r.status < 300 || r.status >= 400) return { status: r.status, body: await r.text(), hops: seen }; + const loc = new URL(r.headers.get('location') || '/', url).toString(); + seen.push(new URL(loc).pathname); + if (seen.filter((x) => x === new URL(loc).pathname).length > 2) return { status: 'loop', body: '', hops: seen }; + url = loc; init = {}; + } + return { status: 'loop', body: '', hops: seen }; +} +const jarHeader = (jar) => [...jar].map(([k, v]) => `${k}=${v}`).join('; '); + +/** Read the POST form fields (hidden values plus test values) of a page. */ +async function formOf(base, path, jar, values) { + const html = (await follow(base, path, jar)).body; + const m = /]*method="post"[^>]*)>([\s\S]*?)<\/form>/i.exec(html); + if (!m) return null; + const fd = new FormData(); + for (const i of m[2].matchAll(/]*)>/gi)) { + const name = /name="([^"]+)"/.exec(i[1])?.[1]; if (!name) continue; + const type = /type="([^"]+)"/.exec(i[1])?.[1] || 'text'; + fd.append(name, type === 'hidden' ? (/value="([^"]*)"/.exec(i[1])?.[1] ?? '') : (values[name] ?? values[type] ?? 'gate')); + } + return { action: /\baction="(\/[^"]*)"/.exec(m[1])?.[1] || path, fd }; +} + +/** + * Sign up, then sign in and out three times: each signed-in visit to the + * dashboard must show the user its query loaded (a signed-in page whose query + * reads the current user), and nothing may redirect in a loop. + */ +async function authFlow(stage, base, when) { + const name = `Gate${randomBytes(3).toString('hex')}`; + const email = `${name.toLowerCase()}@example.com`; + const values = { name, email, password: 'gate-password-1' }; + const jar = new Map(); + const su = await formOf(base, '/features/auth/signup', jar, values); + const sr = su ? await follow(base, su.action, jar, { method: 'POST', body: su.fd }) : { status: 'no form' }; + const problems = []; + if (sr.status === 'loop' || sr.status >= 500 || sr.status === 'no form') problems.push(`signup ${sr.status}`); + for (let round = 0; round < 3; round++) { + jar.clear(); + const li = await formOf(base, '/features/auth/login', jar, values); + const lr = li ? await follow(base, li.action, jar, { method: 'POST', body: li.fd }) : { status: 'no form', hops: [] }; + if (lr.status === 'loop' || lr.status >= 400 || lr.status === 'no form') { problems.push(`login#${round} ${lr.status} ${lr.hops.join('>')}`); continue; } + const dash = await follow(base, '/features/auth/dashboard', jar); + if (dash.status !== 200 || !dash.body.includes(name)) problems.push(`dashboard#${round} ${dash.status} ${dash.hops.join('>')} user ${dash.body.includes(name) ? 'shown' : 'MISSING'}`); + const so = await follow(base, '/api/auth/signout', jar, { method: 'POST', body: new FormData() }); + if (so.status === 'loop' || so.status >= 500) problems.push(`signout#${round} ${so.status}`); + const out = await follow(base, '/features/auth/dashboard', jar); + if (out.status === 'loop' || out.body.includes(name)) problems.push(`after signout#${round} ${out.status} ${out.body.includes(name) ? 'still signed in' : ''}`); + } + record(stage, `${when}: sign up, sign in/out x3, signed-in page shows the current user, no redirect loop`, problems.length === 0, problems.join('; ')); +} + const probeFiles = { 'modules/zzgate/types.ts': "export const T = 'gate-ok';\n", 'modules/zzgate/engine.server.ts': "import { T } from './types.ts';\nexport function eng() { return T; }\n", 'modules/zzgate/act.server.ts': "'use server';\nimport { T } from './types.ts';\nexport async function act(_input?: unknown) { return { success: true as const, data: T }; }\n", - 'app/zzgate/page.ts': "import { html } from '@webjsdev/core';\nimport { headers } from '@webjsdev/server';\nimport { act } from '#modules/zzgate/act.server.ts';\nexport default async function P() {\n const { eng } = await import('#modules/zzgate/engine.server.ts');\n const ua = headers().get('x-gate') || 'none';\n return html`

dyn:${eng()} hdr:${ua}

`;\n}\n", + // Over Bun's 50KB transpiler-cache threshold, so its compiled form is cached + // on disk: the shape 0.8.85/0.8.86 poisoned for good. + 'modules/zzgate/big.server.ts': `export async function bigEng() {\n const { eng } = await import('#modules/zzgate/engine.server.ts');\n return eng();\n}\n${'// padding to cross the transpiler-cache threshold\n'.repeat(1300)}`, + 'app/zzgate/page.ts': "import { html } from '@webjsdev/core';\nimport { headers } from '@webjsdev/server';\nimport { act } from '#modules/zzgate/act.server.ts';\nimport { bigEng } from '#modules/zzgate/big.server.ts';\nexport default async function P() {\n const { eng } = await import('#modules/zzgate/engine.server.ts');\n if (await bigEng() !== eng()) throw new Error('big module mismatch');\n const ua = headers().get('x-gate') || 'none';\n return html`

dyn:${eng()} hdr:${ua}

`;\n}\n", }; /** The app checks, run before and after the reload stress. */ @@ -143,12 +211,7 @@ async function appChecks(stage, appDir, base, when) { record(stage, `${when}: request context (headers())`, body.includes('hdr:seen')); const bound = await submitForms(base, '/zzgate', '', { q: 'x' }); record(stage, `${when}: bound form action submits`, bound.count === 1 && bound.statuses.every((s) => s < 400) && !/not a server action/.test(body), JSON.stringify(bound)); - if (existsSync(join(appDir, 'app/features/auth/signup'))) { - const email = `gate-${randomBytes(4).toString('hex')}@example.com`; - const su = await submitForms(base, '/features/auth/signup', '', { name: 'Gate', email, password: 'gate-password-1' }); - const li = await submitForms(base, '/features/auth/login', '', { email, password: 'gate-password-1' }); - record(stage, `${when}: sign up and log in`, su.count > 0 && li.count > 0 && [...su.statuses, ...li.statuses].every((s) => s < 400), JSON.stringify({ su, li })); - } + if (existsSync(join(appDir, 'app/features/auth/dashboard'))) await authFlow(stage, base, when); } async function scaffoldStage(runtime, tarballs, port) { @@ -163,10 +226,16 @@ async function scaffoldStage(runtime, tarballs, port) { if (!created.ok) return; const pkgPath = join(appDir, 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + // A Bun cache of the gate's own, so a run models one user's machine. + const cacheHome = join(parent, 'cache'); + const devEnv = runtime === 'bun' ? { XDG_CACHE_HOME: cacheHome } : {}; + for (const [f, src] of Object.entries(probeFiles)) { mkdirSync(dirname(join(appDir, f)), { recursive: true }); writeFileSync(join(appDir, f), src); } for (const [name, tgz] of Object.entries(tarballs)) pkg.dependencies[name] = `file:${tgz}`; pkg.overrides = { ...(pkg.overrides || {}), ...Object.fromEntries(Object.entries(tarballs).map(([n, t]) => [n, `file:${t}`])) }; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); - const inst = runtime === 'bun' ? run('bun', ['install'], { cwd: appDir, quiet: true }) : run('npm', ['install', '--no-audit', '--no-fund'], { cwd: appDir, quiet: true }); + const install = () => (runtime === 'bun' ? run('bun', ['install'], { cwd: appDir, quiet: true }) : run('npm', ['install', '--no-audit', '--no-fund'], { cwd: appDir, quiet: true })); + let inst = install(); + if (!inst.ok) inst = install(); // a shared package cache can race another install record(stage, 'install candidate', inst.ok, inst.ok ? '' : inst.out.slice(-800)); if (!inst.ok) return; const mk = run('node', ['-e', `const {Client}=require('pg');const c=new Client(${JSON.stringify(PG + '/postgres')});c.connect().then(()=>c.query('CREATE DATABASE ${db}')).then(()=>c.end())`], { cwd: appDir, quiet: true }); @@ -179,9 +248,24 @@ async function scaffoldStage(runtime, tarballs, port) { .replace(/^PORT=.*$/m, `PORT=${port}`) .replace(/^AUTH_SECRET=.*$/m, `AUTH_SECRET=${randomBytes(32).toString('hex')}`); writeFileSync(join(appDir, '.env'), env); - for (const [f, s] of Object.entries(probeFiles)) { mkdirSync(dirname(join(appDir, f)), { recursive: true }); writeFileSync(join(appDir, f), s); } const base = `http://localhost:${port}`; - dev = runtime === 'bun' ? await startDev(appDir, 'bun', ['run', 'dev'], port) : await startDev(appDir, 'npm', ['run', 'dev'], port); + if (runtime === 'bun' && process.env.GATE_POISON_FROM !== '') { + // First run the app on a broken release the way a user did before + // upgrading (0.8.86 by default), so this machine's Bun cache holds the + // entries it poisoned; the candidate must serve correctly regardless. + const from = process.env.GATE_POISON_FROM || '0.8.86'; + const prior = JSON.parse(readFileSync(pkgPath, 'utf8')); + const old = { ...prior, overrides: {}, dependencies: { ...prior.dependencies, '@webjsdev/server': from, '@webjsdev/cli': process.env.GATE_POISON_CLI || '0.10.68', '@webjsdev/core': process.env.GATE_POISON_CORE || '0.7.64' } }; + writeFileSync(pkgPath, JSON.stringify(old, null, 2)); + if (run('bun', ['install'], { cwd: appDir, quiet: true }).ok) { + const d0 = await startDev(appDir, 'bun', ['run', 'dev'], port, devEnv).catch(() => null); + if (d0) { const r = await fetch(base + '/zzgate').catch(() => ({ status: 0 })); console.log(` (${from} served /zzgate ${r.status} while warming the cache)`); await d0.stop(); } + } + writeFileSync(pkgPath, JSON.stringify(prior, null, 2)); + const re = run('bun', ['install'], { cwd: appDir, quiet: true }); + record(stage, `reinstall candidate after a ${from} run`, re.ok, re.ok ? '' : re.out.slice(-600)); + } + dev = runtime === 'bun' ? await startDev(appDir, 'bun', ['run', 'dev'], port, devEnv) : await startDev(appDir, 'npm', ['run', 'dev'], port); await sleep(1500); await appChecks(stage, appDir, base, 'fresh'); const stress = await runDevReloadStress({ appDir, base, timeoutMs: 10_000, tolerateRestarts: runtime === 'node' }); @@ -191,6 +275,9 @@ async function scaffoldStage(runtime, tarballs, port) { appendFileSync(join(appDir, 'app/zzgate/page.ts'), '// edited\n'); appendFileSync(join(appDir, 'modules/zzgate/act.server.ts'), '// edited\n'); appendFileSync(join(appDir, 'modules/zzgate/engine.server.ts'), '// edited\n'); + for (const f of ['app/features/auth/dashboard/page.ts', 'modules/auth/queries/current-user.server.ts', 'modules/auth/auth.server.ts']) { + if (existsSync(join(appDir, f))) appendFileSync(join(appDir, f), '// edited\n'); + } await sleep(runtime === 'node' ? 4000 : 1500); await appChecks(stage, appDir, base, 'after edits'); if (runtime === 'bun') { @@ -201,12 +288,45 @@ async function scaffoldStage(runtime, tarballs, port) { console.log((dev.log().match(/.*(error|Error|ENOENT|does not exist|Cannot find).*/g) || []).slice(0, 15).join('\n')); } await dev.stop(); dev = null; + if (runtime === 'bun') { + // `webjs start` keeps Bun's transpiler cache on: the path a poisoned + // entry would break. + const st = await startDev(appDir, 'bun', ['run', 'start'], port, devEnv).catch((e) => ({ err: e })); + if (st.err) record(stage, 'webjs start on Bun', false, String(st.err).slice(0, 600)); + else { + const r = await fetch(base + '/zzgate'); + const b = await r.text(); + record(stage, 'webjs start on Bun serves the dynamic # import (warm cache)', r.status === 200 && b.includes('dyn:gate-ok'), `${r.status}`); + await st.stop(); + } + } for (const f of Object.keys(probeFiles)) rmSync(join(appDir, f), { force: true }); const x = runtime === 'bun' ? ['bunx', ['--bun', 'webjs']] : ['npx', ['webjs']]; const check = run(x[0], [...x[1], 'check'], { cwd: appDir, quiet: true }); record(stage, 'webjs check', check.ok, check.ok ? '' : check.out.slice(-600)); const tc = run(x[0], [...x[1], 'typecheck'], { cwd: appDir, quiet: true }); record(stage, 'webjs typecheck', tc.ok, tc.ok ? '' : tc.out.slice(-600)); + if (runtime === 'bun') { + // The same app at a SECOND path with Bun's caches warm from the first: + // it must serve its own files (a cache keyed by content once pinned + // imports to the first checkout). + const copy = join(parent, 'gateapp-copy'); + cpSync(appDir, copy, { recursive: true, verbatimSymlinks: true }); + writeFileSync(join(copy, '.env'), readFileSync(join(appDir, '.env'), 'utf8').replace(/^PORT=.*$/m, `PORT=${port + 20}`)); + const home = join(copy, 'app/page.ts'); + writeFileSync(home, readFileSync(home, 'utf8').replace(/export default/, "export const gateCopy = 'gate-copy-marker';\nexport default")); + appendFileSync(join(copy, 'app/features/auth/dashboard/page.ts'), '// copy\n'); + const dev2 = await startDev(copy, 'bun', ['run', 'dev'], port + 20); + try { + const base2 = `http://localhost:${port + 20}`; + const bad = []; + for (const p of staticPages(copy)) { const r = await fetch(base2 + p); if (r.status >= 500) bad.push(`${p} ${r.status}`); } + record(stage, 'second path, warm Bun cache: every static page renders', bad.length === 0, bad.slice(0, 5).join(', ')); + await authFlow(stage, base2, 'second path'); + const leaked = /\/gateapp\//.test(dev2.log()) && /ENOENT|Cannot find/.test(dev2.log()); + record(stage, 'second path imports nothing from the first', !leaked); + } finally { await dev2.stop(); } + } } catch (e) { record(stage, 'unexpected error', false, String(e && e.stack || e).slice(0, 1500)); } finally { @@ -310,6 +430,43 @@ async function crispStage(crispWeb, tarballs, port) { } } + +/** + * The repo's website run under Bun from this checkout, then from a SECOND git + * worktree of the same commit with Bun's caches warm: both must render `/`. + */ +async function websiteStage(port) { + const stage = 'website'; + if (!existsSync(join(ROOT, 'website'))) return; + const cli = join(ROOT, 'packages/cli/bin/webjs.js'); + const second = join(mkdtempSync(join(tmpdir(), 'webjs-gate-site-')), 'wt'); + let dev; + try { + for (const [label, dir, p] of [['this checkout', join(ROOT, 'website'), port], ['second worktree, warm cache', join(second, 'website'), port + 1]]) { + if (label.startsWith('second')) { + execFileSync('git', ['worktree', 'add', '--detach', second, 'HEAD'], { cwd: ROOT, stdio: 'pipe' }); + symlinkSync(realpathSync(join(ROOT, 'node_modules')), join(second, 'node_modules')); + for (const pkg of readdirSync(join(ROOT, 'packages'))) { + const nm = join(ROOT, 'packages', pkg, 'node_modules'); + if (existsSync(nm) && !existsSync(join(second, 'packages', pkg, 'node_modules'))) symlinkSync(realpathSync(nm), join(second, 'packages', pkg, 'node_modules')); + } + rmSync(join(second, 'packages/core/dist'), { recursive: true, force: true }); + symlinkSync(realpathSync(join(ROOT, 'packages/core/dist')), join(second, 'packages/core/dist')); + } + dev = await startDev(dir, 'bun', ['--bun', cli, 'dev', '--port', String(p)], p); + const r = await fetch(`http://localhost:${p}/`); + record(stage, `website / on Bun from ${label}`, r.status === 200, `${r.status}`); + await dev.stop(); dev = null; + } + } catch (e) { + record(stage, 'unexpected error', false, String(e && e.stack || e).slice(0, 1200)); + } finally { + if (dev) await dev.stop(); + try { execFileSync('git', ['worktree', 'remove', '--force', second], { cwd: ROOT, stdio: 'pipe' }); } catch {} + rmSync(dirname(second), { recursive: true, force: true }); + } +} + if (!args.includes('--skip-suites')) { record('suites', 'npm test (Node)', run('npm', ['test'], { cwd: ROOT, quiet: true }).ok); record('suites', 'Bun matrix (scripts/run-bun-tests.js)', run('node', ['scripts/run-bun-tests.js'], { cwd: ROOT, quiet: true }).ok); @@ -317,6 +474,7 @@ if (!args.includes('--skip-suites')) { const tarballs = packCandidate(); console.log(`candidate: ${Object.values(tarballs).map((t) => t.split(sep).pop()).join(', ')}`); for (const [i, rt] of ['bun', 'node'].entries()) if (!only || only === rt) await scaffoldStage(rt, tarballs, 9300 + i); +if (!only || only === 'website') await websiteStage(9330); const crisp = opt('--crisp'); if (crisp) await crispStage(resolve(crisp), tarballs, 9310);