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/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/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/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/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/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..0c2b954eb 100644 --- a/packages/server/src/dev/handler.js +++ b/packages/server/src/dev/handler.js @@ -1,7 +1,7 @@ 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 { 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'; @@ -135,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, @@ -300,12 +303,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..382364334 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 { @@ -130,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/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/scripts/release-gate.mjs b/scripts/release-gate.mjs new file mode 100644 index 000000000..d7b8d0b7a --- /dev/null +++ b/scripts/release-gate.mjs @@ -0,0 +1,483 @@ +/** + * 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, 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'; +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 }; +} + + +/** 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", + // 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. */ +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/dashboard'))) await authFlow(stage, base, when); +} + +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')); + // 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 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 }); + 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); + const base = `http://localhost:${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' }); + 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'); + 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') { + 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; + 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 { + 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 }); + } +} + + +/** + * 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); +} +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); + +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); 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) { 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 }); +}