Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form action=${fn}>`, `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 <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/<id>`, 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/<pkg>@<version> "<why>; use <good version>"`) and ship the fix through the gate.

### Changelog: per-package, per-version, auto-generated

WebJs ships per-package per-version changelogs under `changelog/<pkg>/<version>.md`. The model: **a version bump is the trigger**. When any commit on `main` changes the `version` field in `packages/<pkg>/package.json`, the scripts/backfill-changelog.js generator emits a new `changelog/<pkg>/<version>.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`.
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/bin/webjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/lib/dev-supervisor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>, 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
Expand All @@ -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,
Expand All @@ -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$/;

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/test/dev-supervisor/dev-supervisor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<file>:<line>"` 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.<name>')` (#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. |
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/action-seed-bun.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
});
},
});
Expand Down
5 changes: 4 additions & 1 deletion packages/server/src/action-seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
64 changes: 64 additions & 0 deletions packages/server/src/bun-transpiler-cache.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading