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
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
- a factory-declared reactive property that is not `{ state: true }`
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed. TypeScript types are erased before the analyser reads a module, so an annotation can never be a blocker however call-shaped it looks (`readonly (readonly [number, number, number])[]` is fine)
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
- being rendered by a component that itself ships

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Pick a runtime from the deploy target, not the code. Default to Node unless you
Three seams pick a runtime-specific implementation, all inside the framework, none in your app:

- **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
- **The type stripper.** WebJs serves `.ts` / `.tsx` as ES modules by erasing the types in place with no bundler. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
- **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
- **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).

## Node vs Bun at a glance
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/webjs-doc-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ applies, then update or consciously skip each.
relevant `references/` file.
2. **`README.md`** (repo root). Update when a headline capability changes (the
feature list, the quickstart, the runtime/template matrix).
3. **The docs site: `website/app/docs/<topic>/page.tsx`.** This is the
3. **The docs site: `website/app/docs/<topic>/page.ts`.** This is the
user-facing documentation at webjs.dev/docs. Find the topic page(s) that cover
the area (`server-actions`, `routing`, `components`, `caching`, `configuration`,
`client-router`, `data-fetching`, ...) and update them. `llms.txt` /
Expand Down Expand Up @@ -102,7 +102,7 @@ The surface checks below are independent reads, so run them fanned out: one read
4. Verify: re-run the grep and confirm each applicable surface now describes the
new behaviour, and no surface still describes the old one.
5. Respect the prose-punctuation invariant (#11) and run `webjs check` if any
code-shaped doc (a `.tsx` doc page) changed.
code-shaped doc (a `.ts` doc page) changed.

## Audit-mode procedure (sweep shipped work for drift)

Expand Down
21 changes: 21 additions & 0 deletions packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,27 @@ conditional leaves a CSP-off document one newline shorter than a CSP-on one).
map), so a `#` import is never sent to the resolver; the rare alias mapped to
a real package (`"#x": "some-pkg"`) is consequently not vendored, an accepted
limitation since the scaffold's catch-all `"#*": "./*"` is always local.
Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` /
`.tsx` module through the framework's own stripper (`eraseTypesForScan`)
before scanning it, because the scans are lexical and TS annotations are
call-shaped often enough to matter (`readonly (readonly [number, number,
number])[]` is an identifier immediately followed by `(`, which the
module-scope scan read as a top-level call, pinning every importing route
module, #1423). The stripper rather than a lexical annotation matcher, so the
analyser and the runtime agree by construction, and it is position-preserving,
so every offset the other scans depend on survives. The extension set is the
union of the TypeScript-carrying extensions in the three filters that seed
`allFiles`, NOT the servable set: `scanComponents` admits `/\.m?[jt]sx?$/`,
wider than the graph walker and the router, so a `.tsx` component reaches the
analysis and narrowing to `.ts` / `.mts` silently un-erases it. A non-TS file,
and a source the stripper rejects (non-erasable syntax, which invariant 10
forbids, or real JSX in a `.tsx`), are scanned as authored, the conservative
direction; a missing stripper BACKEND is the one failure that warns, since it
un-erases the whole app rather than one file. Results are memoized per file
and validated against the source, so a dev rebuild re-strips only what
changed. Consequence for a DIRECT caller of `hasModuleScopeSideEffect` /
`analyzeComponentSource`: both still take source as given, so handing either
one `.ts` as authored opts back into that false positive.
Import-only modules join the elision fingerprint (a verdict flip busts `?v`)
and the bare-import scan exclusion (an SSR-only page import is no longer
vendored), like inert modules. `collectRouteModules` (`dev.js`) feeds only
Expand Down
197 changes: 172 additions & 25 deletions packages/server/src/component-elision.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
redactToPlaceholders,
} from './js-scan.js';
import { transitiveDeps, expandImportAlias } from './module-graph.js';
import { ensureStripper } from './ts-strip.js';

/**
* Named imports from a `@webjsdev/core` specifier that imply the
Expand Down Expand Up @@ -267,6 +268,23 @@ const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
* `customElements.define(...)` legitimately uses it and must not force ship). */
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;

/**
* Constructors that produce inert DATA with no side effect, so a module-scope
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
* parsed URL) is not client work and must not pin an importing page/layout
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
*/
const PURE_DATA_CONSTRUCTORS = new Set([
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
'Error', 'TypeError', 'RangeError', 'SyntaxError',
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
'BigInt64Array', 'BigUint64Array',
]);

/**
* Module-scope client work, detected by an ALLOWLIST of safe top-level forms
* rather than a denylist of browser globals. A module that runs ANY code when
Expand All @@ -285,39 +303,33 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
*
* Scans the redacted copy (strings / templates / comments blanked, regex
* literals and nested `${...}` interpolation tracked by the lexer) so template
* prose and JSDoc / TS type annotations cannot trip it; quoted-string bodies,
* which redaction keeps verbatim for other rules, are blanked here too so a
* string like `"foo()"` or `"{"` is not read as a call and cannot unbalance
* the brace scan. The unbalanced-brace and unterminated-string fallbacks below
* are defense in depth: with the lexer tracking regex literals, neither should
* trigger on valid code, but if either does the module ships rather than risk
* hiding client work.
* prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
* verbatim for other rules, are blanked here too so a string like `"foo()"` or
* `"{"` is not read as a call and cannot unbalance the brace scan. The
* unbalanced-brace and unterminated-string fallbacks below are defense in
* depth: with the lexer tracking regex literals, neither should trigger on
* valid code, but if either does the module ships rather than risk hiding
* client work.
*
* PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
* and literals, NOT type syntax, and TS annotations are call-shaped often
* enough to matter: `readonly (readonly [number, number, number])[]` is an
* identifier immediately followed by `(`, so a module holding nothing but a
* typed data literal reads as running code at module scope (#1423). The
* analyser erases types before it calls this (`eraseTypesForScan`), so the
* request path is covered; a DIRECT caller handing it `.ts` source as authored
* is opting into that false positive.
*
* Over-detection is safe (a top-level arrow whose body calls something, or a
* pure top-level helper call, only ships). The accepted residual misses, all
* contrived and structural (so they do not rot), are a call buried inside a
* top-level object / array initializer or a destructuring default, and a
* side-effecting tagged-template hole evaluated at module scope.
*
* @param {string} src raw module source
*/
/**
* Constructors that produce inert DATA with no side effect, so a module-scope
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
* parsed URL) is not client work and must not pin an importing page/layout
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
* @param {string} src module source, type-erased if it came from a TypeScript file
* @param {string[]} [literals] the redaction's literal bodies, when already computed
* @returns {boolean}
*/
const PURE_DATA_CONSTRUCTORS = new Set([
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
'Error', 'TypeError', 'RangeError', 'SyntaxError',
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
'BigInt64Array', 'BigUint64Array',
]);

export function hasModuleScopeSideEffect(src, literals) {
let redacted = src;
if (!literals) {
Expand Down Expand Up @@ -871,6 +883,131 @@ export function extractRenderedTags(src) {
return tags;
}

/**
* The extensions that can carry TypeScript type syntax INTO this analysis.
*
* Derive it from what actually reaches `allFiles`, which is seeded from three
* places with three different filters: the component set from `scanComponents`
* (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and
* the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set
* is the widest and it does not pass through the other two, so a `.tsx`
* component arrives here whatever the walker and the router admit. Hence
* `.tsx`, even though nothing in WebJs serves one: leaving it out is how the
* #1423 false positive comes back for exactly the file class no other filter
* would have let through. `.cts` is absent because no filter admits it.
*
* The rule to hold, if any of those three filters moves: this set is every
* TypeScript-carrying extension in their UNION. Narrowing it to the servable
* set instead is a change that looks tighter and silently un-erases files.
*/
const TS_SOURCE_RE = /\.m?tsx?$/;

/**
* Per-file memo of {@link eraseTypesForScan}, holding the source it was derived
* from so a hit is proven rather than assumed. One entry per file, so it is
* bounded by the app's file count and needs no eviction policy; a deleted file
* leaves one dead entry until the process ends, which is cheaper than tracking
* liveness for it.
*
* @type {Map<string, { src: string, out: string }>}
*/
const STRIP_CACHE = new Map();

/**
* Return `src` with its TYPE syntax erased, so every scan below reads the code
* that actually RUNS rather than the code as authored (#1423).
*
* The scans are lexical heuristics, and TypeScript annotations are syntax the
* heuristics were never designed for: `readonly (readonly [number, number,
* number])[]` is an identifier immediately followed by `(`, which the
* top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a
* module holding nothing but a typed data literal was classified as running
* code at module scope and pinned every route module that imported it. Type
* syntax is erased before anything runs, so it can never be a runtime signal,
* and the only sound reading of it is none at all.
*
* This uses the framework's own stripper rather than a lexical annotation
* matcher: it is the same erasure the browser is served, so the analyser and
* the runtime agree by construction, where a hand-rolled matcher would meet
* generics, `as`, `satisfies`, and conditional types and get some of them
* wrong. It is position-preserving whitespace replacement, so every offset,
* line, and column the other scans depend on is unchanged.
*
* A non-TypeScript file is returned untouched (there is nothing to erase, and
* running a TS parser over one buys only new ways to fail). A PER-FILE strip
* failure also returns the source untouched, which is the pre-#1423 behaviour
* and therefore the conservative direction: the scans then over-detect at
* worst. Two things take that path, both silent because both are one file's
* problem. A non-erasable module, which invariant 10 forbids and `webjs check`
* catches at edit time, so it is a broken app rather than a supported one. And
* a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript
* and rejects, so a JSX component is scanned as authored while a `.tsx` that is
* only TypeScript is erased normally.
*
* A missing stripper BACKEND is the failure that does not stay silent, and it
* is why the backend is resolved once by the caller rather than per file. It
* means no TypeScript in the app can be erased (a runtime with neither the
* built-in nor `amaro`), so every module is scanned as authored and the #1423
* verdict comes back app-wide. Swallowing that alongside a syntax rejection
* would make an app-wide regression indistinguishable from one broken file, so
* `resolveScanStripper` warns once and this returns the source untouched.
*
* Results are memoized per file and validated against the exact source, since
* this runs on every dev rebuild and stripping costs roughly 50x the read it
* sits beside. Validating on content rather than mtime means a same-mtime
* rewrite cannot serve a stale strip.
*
* @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable
* @param {string} file absolute path, the cache key and the extension source
* @param {string} src
* @returns {string}
*/
function eraseTypesForScan(stripper, file, src) {
if (!stripper || !TS_SOURCE_RE.test(file)) return src;
const hit = STRIP_CACHE.get(file);
if (hit && hit.src === src) return hit.out;
let out;
try {
out = stripper.fn(src);
} catch {
out = src;
}
STRIP_CACHE.set(file, { src, out });
return out;
}

/** Whether {@link resolveScanStripper} has already reported a missing backend. */
let warnedNoStripper = false;

/**
* Resolve the TypeScript stripper backend once for a whole analysis run, or
* `null` when this runtime has none.
*
* `analyzeElision` runs outside the dev request handler too (`webjs check`,
* `webjs elision`), and `ensureStripper` is called on the dev path only, so
* this is where the no-backend case surfaces for the other entry points. It
* warns once per process rather than per file: the condition is a property of
* the runtime, so repeating it once per module would bury it.
*
* @returns {Promise<import('./ts-strip.js').Stripper | null>}
*/
async function resolveScanStripper() {
try {
return await ensureStripper();
} catch (e) {
if (!warnedNoStripper) {
warnedNoStripper = true;
console.warn(
'[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' +
'is not erased before the scans read a module. A type annotation can then read as ' +
'module-scope work, which ships modules that would otherwise be elided. Underlying: ' +
(e && e.message ? e.message : String(e)),
);
}
return null;
}
}

/**
* Compute the set of component FILES whose browser download can be
* elided. A file is elidable only when every component it defines is
Expand Down Expand Up @@ -978,6 +1115,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
}

// Resolve the stripper ONCE for the run: the backend is a property of the
// runtime, not of a file, and the no-backend case must be reported rather
// than swallowed per module (see `eraseTypesForScan`).
const scanStripper = await resolveScanStripper();

for (const file of allFiles) {
if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
let src;
Expand All @@ -989,6 +1131,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
continue;
}
if (typeof src !== 'string') continue;
// Erase type syntax first, so every scan below reads the code that RUNS
// (#1423). This is the single point where the analyser reads a file, so
// doing it here covers the module-scope, template, import, and component
// scans at once.
src = eraseTypesForScan(scanStripper, file, src);
// Mask comments once for every signal scan below (#179): a `<tag>`, an
// `@event`, a browser global, an `import`, or a `whenDefined` written in a
// comment must not register as a real signal. String and template content
Expand Down
Loading
Loading