From a6d71a8e5b53a1b21cb37f3e54092f8d6a012e8b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:45:32 +0530 Subject: [PATCH 01/10] feat: make the TS stripper pluggable so webjs runs on Bun and Node (#508) --- package-lock.json | 13 ++++ packages/server/package.json | 3 + packages/server/src/action-seed.js | 12 ++++ packages/server/src/dev.js | 11 ++- packages/server/src/node-version.js | 9 +++ packages/server/src/ts-strip.js | 108 ++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/ts-strip.js diff --git a/package-lock.json b/package-lock.json index 71f800ebd..177ff45b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2521,6 +2521,16 @@ "node": ">= 14" } }, + "node_modules/amaro": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/amaro/-/amaro-1.1.10.tgz", + "integrity": "sha512-ceFv+QA3SlhFsn0hu8Q8oyj36YZdIgoJFpyS2sGJGK2dyncwcMWuBlNzhXfc1oLWtbDWM2Ol2rrzOYa+HNyEjg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -6914,6 +6924,9 @@ }, "engines": { "node": ">=24.0.0" + }, + "optionalDependencies": { + "amaro": "^1.1.10" } }, "packages/server/node_modules/ws": { diff --git a/packages/server/package.json b/packages/server/package.json index a7b0335ff..bdc1e3e86 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,5 +51,8 @@ ], "engines": { "node": ">=24.0.0" + }, + "optionalDependencies": { + "amaro": "^1.1.10" } } diff --git a/packages/server/src/action-seed.js b/packages/server/src/action-seed.js index fbd5e6cbd..49e9f734d 100644 --- a/packages/server/src/action-seed.js +++ b/packages/server/src/action-seed.js @@ -258,6 +258,18 @@ function seedLoadHook(url, context, nextLoad) { * second call. */ export function registerSeedHooks() { + // The seed facade rides Node's synchronous `module.registerHooks` (#472). + // Bun (and any runtime without that API, #508) cannot install the hook, so + // seeding simply stays OFF there: `seedingEnabled()` returns false, ssr.js + // emits no seed block, and the client RPC stub falls back to a normal fetch. + // This is the same fail-open posture as a key miss, never wrong data. + if (typeof nodeModule.registerHooks !== 'function') { + if (!_registered) { + _registered = true; + console.warn('[webjs] SSR action-result seeding (#472) is disabled: module.registerHooks is unavailable on this runtime (e.g. Bun). Async-render components will re-fetch on hydration; no correctness impact.'); + } + return; + } _enabled = true; if (_registered) return; _registered = true; diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index c17e03542..e91d9006f 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -61,6 +61,7 @@ import { hashFile, } from './actions.js'; import { registerSeedHooks } from './action-seed.js'; +import { stripTypeScript, ensureStripper } from './ts-strip.js'; import { defaultLogger } from './logger.js'; import { assertNodeVersion } from './node-version.js'; import { applyEnvValidation } from './env-schema.js'; @@ -441,6 +442,10 @@ export async function createRequestHandler(opts) { // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno) // gets the actionable message at boot, not a cryptic API failure mid-request. assertNodeVersion({ onFail: 'throw' }); + // Resolve the TS stripper backend at boot (#508): the Node built-in, or amaro + // 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(); const appDir = resolve(opts.appDir); // Load /.env into process.env BEFORE anything else. // buildActionIndex below imports server-only files (lib/*.server.ts, @@ -2201,12 +2206,16 @@ async function exists(p) { * rules catch these at edit time. There is no bundler fallback; * webjs is buildless end-to-end. * + * The backend is the runtime-appropriate stripper (#508): Node 24+'s built-in + * `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, equally + * position-preserving), resolved once via `./ts-strip.js`. + * * @param {string} source * @param {string} _abs (unused; preserved for symmetry with prior signature) * @returns {Promise} */ async function stripTs(source, _abs) { - return nodeModule.stripTypeScriptTypes(source); + return stripTypeScript(source); } /** diff --git a/packages/server/src/node-version.js b/packages/server/src/node-version.js index cedfa5f55..0c38ef8e5 100644 --- a/packages/server/src/node-version.js +++ b/packages/server/src/node-version.js @@ -89,6 +89,15 @@ export function requiredNodeMajor() { * @returns {void} */ export function assertNodeVersion(opts = {}) { + // Bun (#508) satisfies webjs's requirements through a different mechanism: the + // TS strip comes from `amaro` (resolved by ts-strip.js), and `fs.watch` / + // `node:crypto` are provided by its node-compat layer, even though Bun reports + // a Node version string. The Node-major gate is a proxy for "the Node built-ins + // exist", which does not hold on Bun, so skip it there. Only when no explicit + // `current` is passed (real runtime detection, not a unit test override). + if (opts.current === undefined && typeof process !== 'undefined' && process.versions && process.versions.bun) { + return; + } const current = opts.current ?? process.versions.node; const requiredMajor = opts.requiredMajor ?? requiredNodeMajor(); const onFail = opts.onFail ?? 'throw'; diff --git a/packages/server/src/ts-strip.js b/packages/server/src/ts-strip.js new file mode 100644 index 000000000..8c7318c60 --- /dev/null +++ b/packages/server/src/ts-strip.js @@ -0,0 +1,108 @@ +/** + * Pluggable TypeScript stripper (#508), the one seam that lets webjs run on both + * Node and Bun. + * + * webjs serves `.ts` / `.mts` source to the browser as JavaScript by ERASING the + * type syntax in place (position-preserving whitespace replacement, so no + * sourcemap is shipped and stack traces stay byte-exact). On Node 24+ that is the + * built-in `module.stripTypeScriptTypes`. Bun has no such built-in, so this + * module picks the backend at boot: + * + * - **Node 24+**: the built-in `module.stripTypeScriptTypes` (zero deps, the + * default; nothing about the Node path changes). + * - **Bun (or any runtime lacking the built-in)**: `amaro`, lazily imported. + * Node's built-in is itself a thin wrapper over `amaro`'s `strip-only` mode, + * so the output is BYTE-IDENTICAL and equally position-preserving. `amaro` is + * an `optionalDependency` of `@webjsdev/server`: a Node-only install that + * prunes optionals still runs (it never loads amaro), while a Bun install + * gets it. + * + * `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend (used by the tests to + * exercise the amaro path under Node, and an escape hatch). + * + * Erasable TypeScript only, either way: non-erasable syntax (`enum`, value + * `namespace`, parameter properties, legacy decorators, `import = require`) + * throws at strip time, which the `erasable-typescript-only` / + * `no-non-erasable-typescript` lint rules catch at edit time. + */ +import * as nodeModule from 'node:module'; + +/** @typedef {{ fn: (source: string) => string, name: 'builtin' | 'amaro' }} Stripper */ + +/** @type {Stripper | null} */ +let _strip = null; +/** @type {Promise | null} */ +let _resolving = null; + +/** Forced backend from the env, or '' for auto-detect. */ +function forcedBackend() { + const v = String(process.env.WEBJS_TS_STRIPPER || '').toLowerCase().trim(); + return v === 'builtin' || v === 'amaro' ? v : ''; +} + +/** + * Resolve the active stripper backend once. Auto-detects (built-in when present, + * else amaro); honors a `WEBJS_TS_STRIPPER` override. + * @returns {Promise} + */ +async function resolve() { + const forced = forcedBackend(); + const hasBuiltin = typeof nodeModule.stripTypeScriptTypes === 'function'; + if (forced !== 'amaro' && (forced === 'builtin' || hasBuiltin)) { + if (forced === 'builtin' && !hasBuiltin) { + throw new Error('WEBJS_TS_STRIPPER=builtin but module.stripTypeScriptTypes is unavailable on this runtime.'); + } + return { fn: (source) => nodeModule.stripTypeScriptTypes(source), name: 'builtin' }; + } + // amaro backend (Bun, a Node without the built-in, or forced). + let amaro; + try { + amaro = await import('amaro'); + } catch (e) { + const why = e && e.message ? e.message : String(e); + throw new Error( + "webjs needs a TypeScript stripper: this runtime has no built-in " + + "`module.stripTypeScriptTypes` (e.g. Bun) and the `amaro` fallback failed " + + "to load. Install `amaro` (it is an optionalDependency of @webjsdev/server) " + + "to run webjs here. Underlying: " + why, + ); + } + const tx = amaro.transformSync || (amaro.default && amaro.default.transformSync); + if (typeof tx !== 'function') { + throw new Error('webjs: the resolved `amaro` module has no `transformSync` export.'); + } + return { fn: (source) => tx(source, { mode: 'strip-only' }).code, name: 'amaro' }; +} + +/** + * Resolve (once, memoized) and return the active stripper. Call at boot so the + * backend is ready and a missing-amaro error surfaces early rather than on the + * first `.ts` request. + * @returns {Promise} + */ +export async function ensureStripper() { + if (_strip) return _strip; + if (!_resolving) _resolving = resolve().then((s) => { _strip = s; _resolving = null; return s; }); + return _resolving; +} + +/** + * Strip TypeScript types from `source`, resolving the backend on first use. + * @param {string} source + * @returns {Promise} + */ +export async function stripTypeScript(source) { + const s = _strip || await ensureStripper(); + return s.fn(source); +} + +/** The active backend name, or null before resolution (diagnostics / tests). */ +export function stripperName() { + return _strip ? _strip.name : null; +} + +/** Test seam: forget the resolved backend so the next call re-detects. */ +export function __resetStripper() { + _strip = null; + _resolving = null; +} From 17ec5bdd9f6a96c0e88054f1537454218f77a5f8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:49:49 +0530 Subject: [PATCH 02/10] test: cross-runtime smoke (SSR + TS strip + action RPC) for Node and Bun (#508) --- test/bun/smoke.mjs | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/bun/smoke.mjs diff --git a/test/bun/smoke.mjs b/test/bun/smoke.mjs new file mode 100644 index 000000000..df4393c63 --- /dev/null +++ b/test/bun/smoke.mjs @@ -0,0 +1,81 @@ +/** + * Cross-runtime smoke test (#508): boot a minimal webjs app through + * `createRequestHandler` and assert SSR HTML plus TypeScript-stripped `.ts` + * serving, under WHICHEVER runtime executes this file. Run it under both: + * + * node test/bun/smoke.mjs + * bun test/bun/smoke.mjs + * + * On Node the stripper is the built-in `module.stripTypeScriptTypes`; on Bun + * (which lacks it) the `amaro` fallback resolves transparently. A plain assert + * script (not node:test) so the SAME file runs identically on both runtimes; it + * exits non-zero on failure. Run it from the repo root so the bare + * `@webjsdev/server` specifier resolves to the workspace package. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createRequestHandler, hashFile } from '@webjsdev/server'; +import { stringify, parse } from '@webjsdev/core'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// Absolute file URL to @webjsdev/core, injected into the fixture so its SSR +// imports resolve regardless of the temp-dir location (the same trick the Node +// integration tests use). +const CORE = pathToFileURL(resolve(__dirname, '../../packages/core/index.js')).toString(); +const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; + +const dir = mkdtempSync(join(tmpdir(), 'webjs-runtime-smoke-')); +const w = (rel, body) => { const abs = join(dir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, body); }; +try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'smoke', type: 'module', webjs: {} })); + w('app/layout.ts', `import { html } from ${JSON.stringify(CORE)};\nexport default ({ children }: { children: unknown }) => html\`\${children}\`;\n`); + w('app/page.ts', `import { html } from ${JSON.stringify(CORE)};\nimport '../components/hi-there.ts';\nexport default function Page() { return html\`
\`; }\n`); + w('components/hi-there.ts', `import { WebComponent, html } from ${JSON.stringify(CORE)};\nexport class HiThere extends WebComponent {\n greet(name: string): string { return 'hi ' + name; }\n render() { const n: number = 42; return html\`

\${this.greet('there')} \${n}

\`; }\n}\nHiThere.register('hi-there');\n`); + // A server action, to round-trip the RPC wire (serializer + dispatch) on the runtime. + const actionFile = join(dir, 'actions/echo.server.ts'); + w('actions/echo.server.ts', `'use server';\nexport async function echo(input: { n: number; at: Date }) { return { doubled: input.n * 2, at: input.at }; }\n`); + + const app = await createRequestHandler({ appDir: dir, dev: true }); + if (app.warmup) await app.warmup(); + + // 1. SSR renders the component's resolved output (no JS needed). + const page = await app.handle(new Request('http://localhost/')); + assert.equal(page.status, 200, 'GET / should be 200'); + const pageHtml = await page.text(); + assert.ok(pageHtml.includes('hi there 42'), `SSR HTML should contain the rendered text; got:\n${pageHtml.slice(0, 400)}`); + + // 2. The `.ts` component is served as JavaScript with its types stripped. + const comp = await app.handle(new Request('http://localhost/components/hi-there.ts')); + assert.equal(comp.status, 200, 'GET the .ts component should be 200'); + const js = await comp.text(); + assert.ok(!/:\s*string/.test(js) && !/:\s*number/.test(js), `served component should have its type annotations stripped; got:\n${js}`); + assert.ok(js.includes('class HiThere') && js.includes("register('hi-there')"), 'stripped output should still be valid JS (class + register intact)'); + // Position-preserving strip: the method/render lines keep their line numbers + // (the source has 6 code lines; assert the class body line structure survives + // rather than an exact count, which dev-time transforms may pad). + assert.ok(js.includes('\nexport class HiThere'), 'the class declaration stays on its own line (strip preserved newlines)'); + + // 3. A server action round-trips over RPC (the serializer + dispatch), with a + // rich Date value, proving the action path works on this runtime. + const cookieRes = await app.handle(new Request('http://localhost/')); + const m = (cookieRes.headers.get('set-cookie') || '').match(/webjs_csrf=([^;]+)/); + const token = m ? decodeURIComponent(m[1]) : ''; + const hash = await hashFile(actionFile); + const when = new Date('2021-02-03T04:05:06.000Z'); + const rpc = await app.handle(new Request(`http://localhost/__webjs/action/${hash}/echo`, { + method: 'POST', + headers: { 'content-type': 'application/vnd.webjs+json', 'x-webjs-csrf': token, cookie: `webjs_csrf=${token}` }, + body: await stringify([{ n: 21, at: when }]), + })); + assert.equal(rpc.status, 200, 'the action RPC should be 200'); + const result = parse(await rpc.text()); + assert.equal(result.doubled, 42, 'the action ran server-side and returned the computed value'); + assert.ok(result.at instanceof Date && result.at.getTime() === when.getTime(), 'a rich Date round-trips through the RPC serializer on this runtime'); + + console.log(`OK webjs runtime smoke passed on ${runtime} (SSR + TS strip + server-action RPC)`); +} finally { + rmSync(dir, { recursive: true, force: true }); +} From 4bc9b985b78da0b632c4069749b34ae55509d1e9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:53:14 +0530 Subject: [PATCH 03/10] refactor: move TS-strip + its warning suppression into the ts-strip seam (#508) --- packages/server/src/dev.js | 42 +--------- packages/server/src/ts-strip.js | 23 +++++ .../test/node-version/node-version.test.js | 54 ++++++++---- .../server/test/ts-strip/ts-strip.test.js | 84 +++++++++++++++++++ 4 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 packages/server/test/ts-strip/ts-strip.test.js diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index e91d9006f..9a5f592de 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -4,47 +4,13 @@ import { existsSync, readFileSync } from 'node:fs'; import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib'; import { join, extname, resolve, dirname, relative, sep } from 'node:path'; import { createRequire } from 'node:module'; -// Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the -// `stripTypeScriptTypes` named export does not exist, and a NAMED import of a -// missing builtin export is a LINK-TIME SyntaxError that fires before any -// module body runs, which would defeat the Node-version preflight (issue #238) -// by crashing the import of @webjsdev/server itself. A namespace import links -// on every Node (the property is just `undefined` at runtime on old Node), so -// importing @webjsdev/server succeeds and `assertNodeVersion()` at the top of -// createRequestHandler throws the clean "you need Node 24+" message instead. -import * as nodeModule from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; // Server-side `.ts` imports are handled natively by Node 24+'s default -// type-stripping (`process.features.typescript === 'strip'`). No loader -// hook required. The browser-bound TypeScript request handler uses -// `module.stripTypeScriptTypes` for the same transform, so SSR and -// hydration produce identical JS. -// -// Runtime backing: Node ships `stripTypeScriptTypes` via the `amaro` -// package internally (wraps SWC's WASM TypeScript transform in a -// position-preserving strip-only mode). If the framework ever needs -// to run on Bun, Deno, or another runtime that does NOT expose the -// equivalent built-in, we will need to install `amaro` directly (or -// an equivalent: Sucrase preserves lines but not columns; SWC's -// strip-only also works). The fast-path `stripTs` helper would -// change one import line. -// -// Suppress the one-shot ExperimentalWarning that Node prints the -// first time `stripTypeScriptTypes` is called. The API is committed -// per Node 24's release notes; the warning is a holdover. We keep -// every other warning intact. -const _origEmitWarning = process.emitWarning.bind(process); -process.emitWarning = function (warning, type, code, ctor) { - const msg = warning && warning.message ? warning.message : String(warning); - if ( - (type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) && - msg.includes('stripTypeScriptTypes') - ) { - return; - } - return _origEmitWarning(warning, type, code, ctor); -}; +// type-stripping (`process.features.typescript === 'strip'`) or by Bun. The +// BROWSER-bound `.ts` request handler erases types via the pluggable stripper in +// `./ts-strip.js` (Node's built-in `module.stripTypeScriptTypes`, or `amaro` on +// Bun), so SSR and hydration produce identical JS on either runtime (#508). import { buildRouteTable, matchPage, matchApi } from './router.js'; import { generateRouteTypes } from './route-types.js'; diff --git a/packages/server/src/ts-strip.js b/packages/server/src/ts-strip.js index 8c7318c60..f16cdf6bf 100644 --- a/packages/server/src/ts-strip.js +++ b/packages/server/src/ts-strip.js @@ -25,8 +25,31 @@ * throws at strip time, which the `erasable-typescript-only` / * `no-non-erasable-typescript` lint rules catch at edit time. */ +// Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the +// named export does not exist, and a NAMED import of a missing builtin export is +// a LINK-TIME SyntaxError that fires before any module body runs, which would +// defeat the Node-version preflight by crashing the import of @webjsdev/server +// itself. A namespace import links on every runtime (the property is just +// `undefined` where absent, e.g. Bun) and we branch on it at runtime instead. import * as nodeModule from 'node:module'; +// Suppress the one-shot ExperimentalWarning Node prints the first time +// `stripTypeScriptTypes` is called (the API is committed per Node 24's release +// notes; the warning is a holdover). Installed here, co-located with the strip +// call, so it covers every consumer of this module (dev.js, the CLI, tests), +// not just the dev server. Every other warning passes through untouched. +const _origEmitWarning = process.emitWarning.bind(process); +process.emitWarning = function (warning, type, code, ctor) { + const msg = warning && warning.message ? warning.message : String(warning); + if ( + (type === 'ExperimentalWarning' || (warning && warning.name === 'ExperimentalWarning')) && + msg.includes('stripTypeScriptTypes') + ) { + return; + } + return _origEmitWarning(warning, type, code, ctor); +}; + /** @typedef {{ fn: (source: string) => string, name: 'builtin' | 'amaro' }} Stripper */ /** @type {Stripper | null} */ diff --git a/packages/server/test/node-version/node-version.test.js b/packages/server/test/node-version/node-version.test.js index 92eddefb2..898ac54b4 100644 --- a/packages/server/test/node-version/node-version.test.js +++ b/packages/server/test/node-version/node-version.test.js @@ -143,35 +143,59 @@ test('assertNodeVersion exits non-zero on an old Node (exit mode)', () => { assert.ok(logged.includes('24')); }); -/* ---------------- link-safety regression (dev.js) ---------------- */ +/* ---------------- Bun admission (#508) ---------------- */ -test('dev.js does NOT NAMED-import stripTypeScriptTypes from node:module', async () => { +test('assertNodeVersion is a no-op on Bun even when it reports a low Node version', () => { + // Bun (#508) gets its TS strip from amaro and node:* from its compat layer, so + // the Node-major gate (a proxy for "the Node built-ins exist") must not block + // it, even if a future Bun reports a Node version below the floor. + const orig = process.versions.bun; + try { + process.versions.bun = '1.3.14'; + assert.doesNotThrow(() => assertNodeVersion({ requiredMajor: 24 })); + } finally { + if (orig === undefined) delete process.versions.bun; else process.versions.bun = orig; + } +}); + +test('an explicit `current` still enforces the gate even under Bun (unit-test override)', () => { + // The Bun bypass only applies to real runtime detection (no `current` passed), + // so a test that injects an old `current` still exercises the failure path. + const orig = process.versions.bun; + try { + process.versions.bun = '1.3.14'; + assert.throws(() => assertNodeVersion({ current: '20.0.0', requiredMajor: 24, onFail: 'throw' }), /20\.0\.0/); + } finally { + if (orig === undefined) delete process.versions.bun; else process.versions.bun = orig; + } +}); + +/* ---------------- link-safety regression (ts-strip.js) ---------------- */ + +test('ts-strip.js does NOT NAMED-import stripTypeScriptTypes from node:module', async () => { // Regression for the PR #282 defeat: a NAMED import of a missing builtin - // export (`import { stripTypeScriptTypes }`) is a LINK-TIME SyntaxError on - // Node < 22.13, which crashes the import of @webjsdev/server BEFORE - // assertNodeVersion can fire, so an embedded host (and the CLI's server-side - // belt) gets the cryptic error the guard exists to replace. dev.js must use a - // namespace import (`import * as nodeModule from 'node:module'`) so the module - // LINKS on every Node and the clean preflight message wins instead. + // export (`import { stripTypeScriptTypes }`) is a LINK-TIME SyntaxError on a + // runtime lacking it (Node < 22.13, Bun), which crashes the import of + // @webjsdev/server BEFORE the runtime check can fire. The strip seam (now in + // ts-strip.js) must use a namespace import so the module LINKS everywhere and + // the built-in-vs-amaro branch is taken at runtime instead. const { readFileSync } = await import('node:fs'); const { fileURLToPath } = await import('node:url'); const src = readFileSync( - fileURLToPath(new URL('../../src/dev.js', import.meta.url)), + fileURLToPath(new URL('../../src/ts-strip.js', import.meta.url)), 'utf8', ); - // No named import of stripTypeScriptTypes from node:module. assert.equal( /import\s*\{[^}]*\bstripTypeScriptTypes\b[^}]*\}\s*from\s*['"]node:module['"]/.test(src), false, - 'dev.js must not name-import stripTypeScriptTypes from node:module', + 'ts-strip.js must not name-import stripTypeScriptTypes from node:module', ); - // And it must reach the API through a namespace binding instead. assert.ok( /import\s*\*\s*as\s+\w+\s+from\s*['"]node:module['"]/.test(src), - 'dev.js must namespace-import node:module', + 'ts-strip.js must namespace-import node:module', ); assert.ok( - /\bnodeModule\.stripTypeScriptTypes\(/.test(src), - 'the strip call site must use the namespace binding', + /\bnodeModule\.stripTypeScriptTypes\b/.test(src), + 'the strip seam must reach the built-in through the namespace binding', ); }); diff --git a/packages/server/test/ts-strip/ts-strip.test.js b/packages/server/test/ts-strip/ts-strip.test.js new file mode 100644 index 000000000..9e397ff32 --- /dev/null +++ b/packages/server/test/ts-strip/ts-strip.test.js @@ -0,0 +1,84 @@ +/** + * The pluggable TypeScript stripper seam (#508). Node uses the built-in + * `module.stripTypeScriptTypes`; Bun (and any runtime without it) uses `amaro`. + * These tests run under Node, so they exercise the built-in by default AND the + * amaro backend via the `WEBJS_TS_STRIPPER=amaro` override, asserting the two are + * byte-identical (which is why the Bun path is safe: amaro is exactly what Node's + * built-in wraps). + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import * as nodeModule from 'node:module'; +import { stripTypeScript, ensureStripper, stripperName, __resetStripper } from '../../src/ts-strip.js'; + +const SAMPLES = [ + 'const x: number = 5;\nexport const y = (z: { a: number }): number => z.a;\n', + 'import type { Foo } from "./foo.ts";\nexport function f(a: T): T { return a; }\n', + 'class C { declare x: number; constructor() { this.x = 1; } m(s: string): void {} }\n', + 'export type T = { a: number };\nexport const v: T = { a: 1 };\n', +]; + +afterEach(() => { + delete process.env.WEBJS_TS_STRIPPER; + __resetStripper(); +}); + +test('default backend on Node is the built-in stripper', async () => { + __resetStripper(); + const s = await ensureStripper(); + assert.equal(s.name, 'builtin'); + assert.equal(stripperName(), 'builtin'); +}); + +test('the built-in backend strips erasable TypeScript', async () => { + __resetStripper(); + const out = await stripTypeScript('const n: number = 1;\n'); + assert.equal(out, nodeModule.stripTypeScriptTypes('const n: number = 1;\n')); + assert.ok(!out.includes(': number')); +}); + +test('WEBJS_TS_STRIPPER=amaro forces the amaro backend', async () => { + process.env.WEBJS_TS_STRIPPER = 'amaro'; + __resetStripper(); + const s = await ensureStripper(); + assert.equal(s.name, 'amaro'); + assert.equal(stripperName(), 'amaro'); +}); + +test('the amaro backend output is byte-identical to the Node built-in (so Bun is safe)', async () => { + for (const src of SAMPLES) { + const builtin = nodeModule.stripTypeScriptTypes(src); + process.env.WEBJS_TS_STRIPPER = 'amaro'; + __resetStripper(); + const amaroOut = await stripTypeScript(src); + assert.equal(amaroOut, builtin, `amaro and built-in must agree for:\n${src}`); + delete process.env.WEBJS_TS_STRIPPER; + } +}); + +test('the amaro backend is position-preserving (same line count)', async () => { + process.env.WEBJS_TS_STRIPPER = 'amaro'; + __resetStripper(); + const src = 'const a: number = 1;\nfunction f(x: string): void {}\nexport const g = 2;\n'; + const out = await stripTypeScript(src); + assert.equal(out.split('\n').length, src.split('\n').length); +}); + +test('WEBJS_TS_STRIPPER=builtin forces the built-in when present', async () => { + process.env.WEBJS_TS_STRIPPER = 'builtin'; + __resetStripper(); + const s = await ensureStripper(); + assert.equal(s.name, 'builtin'); +}); + +test('the resolved backend is memoized (resolves once)', async () => { + __resetStripper(); + const a = await ensureStripper(); + const b = await ensureStripper(); + assert.equal(a, b, 'ensureStripper returns the same memoized backend'); +}); + +test('stripperName is null before resolution', () => { + __resetStripper(); + assert.equal(stripperName(), null); +}); From e861cade80afae2df85a58beb42261bebbc90089 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:54:42 +0530 Subject: [PATCH 04/10] test: run the runtime smoke in npm test (Node) and a Bun CI job (#508) --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ test/bun/runtime-smoke.test.mjs | 12 ++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 test/bun/runtime-smoke.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42c16781..07ac28ec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,26 @@ jobs: npx prisma db seed - run: npm test + bun: + name: Bun runtime smoke (#508) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + # npm ci installs the workspace + the `amaro` optionalDependency that the + # Bun stripper backend needs. + - run: npm ci + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + # Boot a webjs app under Bun: SSR + TypeScript strip (via amaro, since Bun + # has no built-in stripper) + a server-action RPC round-trip. + - name: webjs runtime smoke on Bun + run: bun test/bun/smoke.mjs + browser: name: Browser (web-test-runner / Playwright) runs-on: ubuntu-latest diff --git a/test/bun/runtime-smoke.test.mjs b/test/bun/runtime-smoke.test.mjs new file mode 100644 index 000000000..caf2cb478 --- /dev/null +++ b/test/bun/runtime-smoke.test.mjs @@ -0,0 +1,12 @@ +/** + * Run the cross-runtime smoke (#508) under WHICHEVER runtime executes the test + * suite. Picked up by the root `node --test` runner, so `npm test` exercises the + * Node path; CI runs `bun test/bun/smoke.mjs` separately for the Bun path. The + * smoke is a plain assert script (`smoke.mjs`, not `*.test.mjs`, so the runner + * does not double-run it); importing it executes it and throws on any failure. + */ +import { test } from 'node:test'; + +test('webjs boots, strips TypeScript, and round-trips a server action on this runtime (#508)', async () => { + await import('./smoke.mjs'); +}); From 1dfb796de3ed49fe4ae42c1e6953b4dc55ffdfb9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:56:12 +0530 Subject: [PATCH 05/10] docs: note Node-or-Bun + the ts-strip seam in AGENTS (#508) --- AGENTS.md | 2 +- packages/server/AGENTS.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dff746d8a..a48717cb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, per-f An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. The component runtime API matches lit (reactive `static properties`, the lit lifecycle hooks, ReactiveControllers, the `lit-html` directive set, `html` / `css` templates) so lit training data transfers directly, but webjs ships its own no-build implementation under `packages/core/src/`. Decorators are the one lit exception (invariant 10); use `declare` + `static properties`. -- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped via Node 24+'s `module.stripTypeScriptTypes` (invariant 10 + `agent-docs/typescript.md`). Node 24+ is required, enforced by an early `assertNodeVersion()` preflight. +- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `agent-docs/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. Edge runtimes (no filesystem) are a separate, later target. - **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR. - **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `` navigates, `
` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `` + server action would do. - **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`. diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 62c1852a5..8a6cd83f7 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -61,7 +61,8 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `asset-hash.js` | Content-hash asset URLs for immutable caching (#243, feature 1). `setAssetRoots({ appDir, coreDir, enabled })` binds the app + core roots and enables fingerprinting in PROD only (`dev.js` passes `enabled: !dev`, so dev is a pure no-op). `assetHashFor(absPath)` computes (and memoizes in a `Map`) a short 12-hex prefix of a sha-256 over the file BYTES (`node:crypto`, synchronous so the emit hot path stays sync), returning `''` on a read failure (NOT memoized, so a transient failure re-attempts). For a file under `_appDir` it ALSO folds in `_elisionFp` (set by `setElisionFingerprint`, called from `ensureReady`): an app module's served body is elision-transformed (a display-only import stripped), so a verdict flip must bust the importer's `?v` even when its source is byte-identical. The fingerprint is a relativized digest of the elidable + inert set (empty when nothing is elidable, leaving an app module's hash at exactly `sha256(bytes)`); core / `public/` files are never elision-transformed, so they hash over bytes alone. `routeFor` (the 103 Early Hints) applies `withAssetHash` too, so the hint and the body request the same URL. `withAssetHash(url, basePath?)` appends `?v=` to a framework-emitted SAME-ORIGIN absolute url (the importmap targets, the modulepreload hrefs, the boot specifiers): a NO-OP when disabled (dev), for a CROSS-ORIGIN / protocol-relative / relative url (so a `https://` jspm vendor target keeps its exact url, its #235 SRI key intact, and is never re-fingerprinted; an already-version-named `/__webjs/vendor/*` bundle is left alone too), and when the url does not resolve to a readable same-origin file (1h fallback). Composes with `withBasePath` (basePath then `?v`; it strips the base path before file resolution). `clearAssetHashCache()` is wired into `dev.js`'s rebuild path next to `clearVendorCache` so a changed file re-hashes (the deploy-busts mechanism). The emit is in `importmap.js`'s `buildImportMap` (`fingerprint` flag, true for the SERVED map, false for the internal hash so the build id stays a stable per-deploy fingerprint) and `ssr.js`'s `wrapHead` (boot specifiers + modulepreload hrefs + lazy entries). The SERVE seam is `dev.js`: a request carrying a `?v=` query is served `Cache-Control: public, max-age=31536000, immutable` (vs the 1h fallback) by `fileResponse` (`immutable` flag), `jsModuleResponse` / `tsResponse` (an `immutable` arg), and the core serve in `tryServeFrameworkStatic` (a `versioned` ctx flag); the pathname (query stripped) resolves the file as today, only the cache header changes. Dev stays `no-cache` regardless. **`versionModuleImports(source, importerAbs)` (#369)** is the matching SERVED-SOURCE rewrite: a layout/page/component imports its dependencies with a BARE relative specifier (`import '../components/x.ts'`), and the browser resolves that against the importer's `?v=`-versioned URL WITHOUT inheriting the `?v` query, so it would fetch the un-versioned URL (a different cache key from the `?v=`-versioned modulepreload hint -> wasted preload, double download, 1h cache). The pass appends the target's `?v=` (the same `assetHashFor` the preload href uses, so the URLs are byte-identical) to every same-origin relative / root-absolute static-import specifier in a served module, matching over a redaction mask (so a templated example import is skipped). Bare specifiers (importmap-resolved, versioned at their target) and `.server.*` stubs (bare-URL, not in the preload set) are left untouched. Run AFTER `elideImportsFromSource` in `jsModuleResponse` / `tsResponse`; a pure no-op when disabled (dev), so dev source stays byte-faithful. | | `csp.js` | CSP nonce minting + `Content-Security-Policy` header building (#233). `readCspConfig` normalizes the `webjs.csp` package.json key (off by default; `true` = strict default policy, object = custom directives + `reportOnly`); `mintNonce` is the per-request CSPRNG nonce; `buildCspHeader` substitutes the nonce into the policy. Plugs into the #232 `applySecurityHeaders` seam in `dev.js`'s `handle()` | | `env-schema.js` | Boot-time env-var validation (#236). `validateEnv(schema, env)` is the PURE validator: it checks an env object against a schema (an object of `name -> type-name | options`, supporting `string`/`number`/`boolean`/`url`/`enum`, `required`/`optional`/`default`, `minLength`/`pattern`), collecting ALL errors at once and returning the coerced + defaulted values to write back. A schema may instead be a FUNCTION `(env) => void` (the escape hatch for zod etc.), whose throw becomes the single error. `loadEnvSchema(appDir)` reads the optional app-root `env.{js,ts}` (null when absent, so opt-in); `applyEnvValidation(appDir)` is the side-effecting boot wrapper called from `createRequestHandler` right after the `.env` auto-load: it validates `process.env`, applies coerced values back, and THROWS a clear aggregated Error on failure (CLI exits non-zero, embedded host rejects), consistent with the Node-version preflight. `formatEnvErrors` composes the aggregated message. | -| `node-version.js` | Node-version preflight guard (#238). `checkNodeVersion(current, requiredMajor)` is the PURE comparison; `assertNodeVersion({ onFail })` is the side-effecting wrapper that throws a clear Error (embedded server, called at the top of `createRequestHandler`) or exits non-zero (CLI). The minimum is sourced from this package's own `engines.node` via `requiredNodeMajor()` so it never drifts. Fails fast on an older Node with a message naming the found + required version (the built-in TS strip + recursive `fs.watch` need 24+), instead of a cryptic late failure. For the embedded-host throw to actually fire, `dev.js` namespace-imports `node:module` (`import * as nodeModule`) rather than name-importing `stripTypeScriptTypes`, so importing `@webjsdev/server` LINKS on old Node instead of link-failing before the guard runs (PR #282 fix; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js` for the same reason). | +| `ts-strip.js` | Pluggable TypeScript stripper (#508), the seam that lets webjs run on both Node and Bun. `stripTypeScript(source)` erases types in place (position-preserving, no sourcemap) via the backend `ensureStripper()` resolves once at boot: Node 24+'s built-in `module.stripTypeScriptTypes` (zero deps, the default), or `amaro` on a runtime lacking it (Bun), lazily imported. Node's built-in is itself a wrapper over amaro's `strip-only` mode, so the output is BYTE-IDENTICAL (a unit test asserts it). `amaro` is an `optionalDependency` of this package: a Node-only install that prunes optionals still runs (it never loads amaro); a Bun install gets it. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Namespace-imports `node:module` (not a named `stripTypeScriptTypes` import) so the module LINKS on a runtime lacking the builtin (Bun, old Node) instead of link-failing before the runtime check (the PR #282 link-safety pattern, now living here). Also hosts the one-shot `stripTypeScriptTypes` ExperimentalWarning suppression, co-located with the call. `dev.js`'s `stripTs` delegates here and `createRequestHandler` calls `ensureStripper()` at boot. | +| `node-version.js` | Node-version preflight guard (#238). `checkNodeVersion(current, requiredMajor)` is the PURE comparison; `assertNodeVersion({ onFail })` is the side-effecting wrapper that throws a clear Error (embedded server, called at the top of `createRequestHandler`) or exits non-zero (CLI). The minimum is sourced from this package's own `engines.node` via `requiredNodeMajor()` so it never drifts. Fails fast on an older Node with a message naming the found + required version (the built-in TS strip + recursive `fs.watch` need 24+), instead of a cryptic late failure. **Admits Bun (#508):** when `process.versions.bun` is set and no explicit `current` is passed, the gate is a no-op (Bun gets its TS strip from `amaro` via `ts-strip.js` and `node:*` from its compat layer, even though it reports a Node version string). The link-safety pattern (namespace-import `node:module`, not a named `stripTypeScriptTypes` import) now lives in `ts-strip.js` since that is where the built-in is reached (PR #282 reasoning; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js`). | | `conditional-get.js` | RFC 7232 conditional GET (ETag + If-None-Match -> 304) (#240). `applyConditionalGet(req, res)` is the shared funnel: for a cacheable GET/HEAD response (status 200, `Cache-Control` present and NOT `no-store` / `private`) it attaches a WEAK content-hash `ETag` (`W/"..."`) over the response's OWN body bytes when one is absent, then returns a `304 Not Modified` (no body, validators + caching headers preserved) when the request's `If-None-Match` matches (weak comparison, `*` wildcard, comma lists). `ifNoneMatchSatisfied` is the pure matcher. The ETag is WEAK because it hashes the uncompressed body and `sendWebResponse` reuses it across identity / gzip / br codings, which a strong validator may not do (RFC 7232 2.3.3). **The funnel only reads a body that a serve branch positively marked buffered** via the internal `BUFFERED_MARKER` (`x-webjs-buffered`) header it stamps on a string / bytes body (`htmlResponse` + the non-streaming `streamingHtmlResponse`, `fileResponse`, `jsModuleResponse`, `tsResponse`, `serveDownloadedBundle`). Both internal markers are stripped at the funnel and never reach a client. EXCLUDED: `no-store` / `private` responses (no cross-session 304 on per-user content); non-GET/HEAD; non-200; a genuinely-streamed Suspense body (flagged with `STREAM_MARKER` / `x-webjs-stream` by `ssr.js`); and **any unmarked body, which is how a user `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream` that never ends) is never buffered into memory or awaited forever**. A web `Response` exposes a `ReadableStream` body for a string and a live stream alike, so the explicit marker is the only safe discriminator. Wired once at the response funnel in `dev.js`'s `handle()` (AFTER `applySecurityHeaders` + the X-Request-Id / CSP header steps), so it covers SSR HTML pages, static assets, app source modules, and the core / vendor runtime uniformly. The serve branches no longer compute their own ETag; the funnel is the single ETag authority (dev + prod). | | `body-limit.js` | Request body-size limits (413) + node:http server timeouts (#237). `readBodyLimits` resolves the JSON/RPC (`webjs.maxBodyBytes`, default 1 MiB) and form/multipart (`webjs.maxMultipartBytes`, default 10 MiB) caps from package.json + the `WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES` env overrides (env wins, `0` disables). `computeServerTimeouts` resolves `requestTimeout` (30s) / `headersTimeout` (20s) / `keepAliveTimeout` (5s), clamping `headersTimeout` strictly under `requestTimeout` per node semantics. `readBytesBounded` / `readTextBounded` / `readFormDataBounded` are the single bounded-read funnel every body-read site (RPC in `actions.js`, `readBody` in `json.js`, the page-action form in `page-action.js`) routes through: a `Content-Length` over the limit is a fast reject, a chunked body is counted while streaming and abandoned past the cap, so an over-limit body is never buffered whole. `BodyLimitError` (caught and mapped to 413 by `api.js`) is how `readBody` inside a route handler signals over-limit; the RPC / page-action paths return `payloadTooLarge()` inline | | `testing.js` | handle() test-harness helpers (#267), exported from `index.js` AND the `./testing` subpath. THIN builders over `createRequestHandler(...).handle()`: `testRequest(handle, path, init?)` fires a native Request through the real pipeline; `getCsrf(handle)` mints a `{ token, cookie, header }` pair off the first SSR response (reusing `csrf.js`'s `CSRF_COOKIE` / `CSRF_HEADER`); `loginAndGetCookies(handle, creds)` drives the REAL credentials login (`/api/auth/signin/credentials`) and captures the genuine signed session `Set-Cookie`; `actionEndpoint(appDir, file, fn)` computes the `/__webjs/action//` path via `hashFile` (the same scheme the stub uses); `invokeActionForTest(app, file, fn, args)` round-trips an action through that REAL endpoint (serializer + CSRF + prod error sanitization), and `rawActionRequest(...)` returns the raw Response (no throw, `omitCsrf` for the 403 case). No test-framework machinery; reuses the real serializer (`serializer.js` -> `@webjsdev/core`) and cookie/header names, never a fake | From 52e08421bda9dc0806d23ecd07d8deec246d0a53 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 14 Jun 2026 13:58:22 +0530 Subject: [PATCH 06/10] docs: document running on Bun across getting-started, deployment, scaffold (#508) --- docs/app/docs/deployment/page.ts | 4 ++-- docs/app/docs/getting-started/page.ts | 2 +- packages/cli/templates/AGENTS.md | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/app/docs/deployment/page.ts b/docs/app/docs/deployment/page.ts index fd3197195..237005734 100644 --- a/docs/app/docs/deployment/page.ts +++ b/docs/app/docs/deployment/page.ts @@ -5,7 +5,7 @@ export const metadata = { title: 'Deployment | webjs' }; export default function Deployment() { return html`

Deployment

-

webjs runs as a standard Node.js server. There is no static export, no serverless adapter, no edge runtime. Deploy it anywhere you can run Node 24+ (the minimum is set by Node's built-in TypeScript type-stripping): a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal.

+

webjs runs as a standard server on Node 24+ or Bun. There is no static export, no serverless adapter, and no edge runtime yet. Deploy it anywhere you can run Node or Bun: a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal. On Node the minimum is set by the built-in TypeScript type-stripping; on Bun the stripping comes from amaro automatically, so the same source runs on either.

Dev vs Prod

webjs has two modes, controlled by the npm script (which wraps the underlying webjs dev / webjs start CLI):

@@ -400,7 +400,7 @@ pm2 start "webjs start" --name my-app

Deployment Checklist