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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ jobs:
# path under `npm test`.
- name: webjs SQLite busy_timeout on Bun
run: bun test/bun/sqlite-busy-timeout.mjs
# Vendor specifier scan on Bun (#1399): the importmap is now derived by
# walking the module graph from the browser-bound entries, so an app
# scaffolded with `--runtime bun` runs the whole analysis under Bun. A set
# that drifted between runtimes would serve a Bun author a different
# importmap from the same source. Also asserts the runtime set stays a
# subset of the pin set, the relation pinned/unpinned parity rests on.
- name: Vendor specifier scan on Bun
run: bun test/bun/vendor-scan.mjs
# The Bun test MATRIX (#509): run the runtime-sensitive node:test suite
# (core + server + cross-package test/) under Bun, file by file, classifying
# each result. Documented Node-only files + Bun-test-runner-quirk files are
Expand Down
2 changes: 1 addition & 1 deletion framework-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ The scaffold gate is one of a FAMILY of tier-2 coverage gates that keep the fram

### The e2e's elision-off server resolves vendors from the repo (#1228)

`test/e2e/e2e.test.mjs`'s `differential elision (#181)` block runs the blog twice and asserts the two builds render identically. The two builds do not have the same network footprint, and that asymmetry is created by elision itself. Elision ON drops `components/vendor-badge.ts`, the blog's only vendor consumer, so `scanBareImports` finds nothing, `api.jspm.io` is never called, `dayjs` never enters the importmap, and the browser never contacts a third party. Elision OFF ships that component, so the same page acquires two live jspm dependencies: a blocking `api.jspm.io/generate` POST on the server's cold first request, and a `https://ga.jspm.io/...` module fetch inside `app/page.ts`'s graph in the browser.
`test/e2e/e2e.test.mjs`'s `differential elision (#181)` block runs the blog twice and asserts the two builds render identically. The two builds do not have the same network footprint, and that asymmetry is created by elision itself. Elision ON drops `components/vendor-badge.ts`, the blog's only vendor consumer, so the dev server's vendor scan (`reachedBareImports`, which prunes the elision skip set out of the graph walk) finds nothing, `api.jspm.io` is never called, `dayjs` never enters the importmap, and the browser never contacts a third party. Elision OFF ships that component, so the same page acquires two live jspm dependencies: a blocking `api.jspm.io/generate` POST on the server's cold first request, and a `https://ga.jspm.io/...` module fetch inside `app/page.ts`'s graph in the browser.

An ES module graph instantiates as a unit, so a failure at either point means `app/page.ts` never evaluates and nothing it imports registers, including components whose own modules fetched fine. The visible symptom is `customElements.define` never running, which reads as an elision defect and is not one. That is what redded this block on and off from 2026-08-02.

Expand Down
7 changes: 4 additions & 3 deletions packages/server/AGENTS.md

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/server/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,13 @@ export declare function setVendorEntries(
// vendor.js (the `webjs vendor` CLI surface)
// ---------------------------------------------------------------------------

/** Scan a directory's source for bare-specifier npm imports. */
export declare function scanBareImports(dir: string, skipFiles?: Set<string>): Promise<Set<string>>;
/**
* Bare-specifier npm imports an app could load in the browser, found by walking
* the module graph from every browser-bound entry rather than by scanning the
* directory. Applies no elision pruning, so the result is a superset of what the
* running server serves.
*/
export declare function scanBareImports(dir: string): Promise<Set<string>>;
/** Extract the package name from an import specifier (`dayjs/plugin/utc` -> `dayjs`). */
export declare function extractPackageName(spec: string): string | null;
/** Resolve bare imports to CDN importmap entries. */
Expand Down
64 changes: 64 additions & 0 deletions packages/server/src/browser-entries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The browser-bound ENTRY files of an app: every module the client boot can
* import directly.
*
* Feeding these to `reachableFromEntries` produces webjs's equivalent of
* Next.js's bundler-produced page manifest, derived lazily on the first
* request (and re-derived on every rebuild) instead of at compile time. The
* dev server uses that closure as an authorization gate on its source-file
* branch: in-set is served (subject to the `.server.{js,ts}` stub guardrail),
* out-of-set 404s. Feeding them to `reachableBareSpecifiers` produces the
* vendor importmap, which is why this half is its own module: the CLI-side
* vendor paths (`pinAll`, `webjs doctor`) root at exactly the entries the gate
* does, without booting a server.
*
* Entries:
* - page / layout (both re-run on the client for hydration)
* - the error / loading / not-found / forbidden / unauthorized boundaries
* - the two root-only boundaries (global-error, global-not-found)
* - instrumentation-client (imported first in the boot)
* - every discovered component (a `static lazy` one is fetched by the lazy
* loader rather than imported by a page, so the component scan is an entry
* source in its own right)
*
* NOT entries, because the browser never fetches them as a module:
* - route.{js,ts} (API handlers) and middleware.{js,ts}
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
* - .server.{js,ts} files (the browser gets a stub, not the source)
*/

/**
* Components are passed in (rather than rescanned) so the caller can share one
* scan with `primeComponentRegistry`, saving a full appDir walk per analysis.
*
* @param {Awaited<ReturnType<typeof import('./router.js').buildRouteTable>>} routeTable
* @param {Awaited<ReturnType<typeof import('./component-scanner.js').scanComponents>>} components
* @returns {Set<string>}
*/
export function browserEntryFiles(routeTable, components) {
/** @type {Set<string>} */
const entries = new Set();
for (const page of routeTable.pages) {
if (page.file) entries.add(page.file);
for (const f of page.layouts || []) entries.add(f);
for (const f of page.errors || []) entries.add(f);
for (const f of page.loadings || []) entries.add(f);
for (const f of page.forbiddens || []) entries.add(f);
for (const f of page.unauthorizeds || []) entries.add(f);
}
if (routeTable.notFound) entries.add(routeTable.notFound);
if (routeTable.notFounds) {
for (const f of routeTable.notFounds.values()) entries.add(f);
}
if (routeTable.globalError) entries.add(routeTable.globalError);
if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
// instrumentation-client is browser-bound (imported first in the boot), so it
// must be servable through the gate.
if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
// Lazy components live in the registry but no page imports their
// class directly; the lazy-loader fetches their module URLs on
// viewport entry. Add every discovered component file as an entry so
// the graph walk covers both eager and lazy paths.
for (const c of components) entries.add(c.file);
return entries;
}
86 changes: 14 additions & 72 deletions packages/server/src/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { stripTypeScript, ensureStripper } from './ts-strip.js';
import { defaultLogger } from './logger.js';
import { assertNodeVersion } from './node-version.js';
import { applyEnvValidation } from './env-schema.js';
import { runInstrumentation, findInstrumentationClient } from './instrumentation.js';
import { runInstrumentation } from './instrumentation.js';
import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
import { buildInfoResponse } from './build-info.js';
import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
Expand All @@ -48,8 +48,9 @@ import {
varyWithAcceptEncoding,
DEV_BOOT_ID,
} from './listener-core.js';
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
import { reachedBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap, seenFilesFor } from './module-graph.js';
import { browserEntryFiles } from './browser-entries.js';
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
import { analyzeElision, elideImportsFromSource } from './component-elision.js';

Expand Down Expand Up @@ -738,10 +739,7 @@ export async function createRequestHandler(opts) {
// eagerly: it is a cheap directory scan (no code reads), and routing, Early
// Hints, and WebSocket lookups need it available before the first request.
const routeTable = await buildRouteTable(appDir);
Comment thread
vivek7405 marked this conversation as resolved.
// instrumentation-client.{js,ts} (#848) is an app-ROOT file (sibling of app/,
// like env.js / readiness.js), not a router stem. Resolve it once and stash it
// on the route table so ssrOpts + the browser-servable gate reach it uniformly.
routeTable.instrumentationClient = await findInstrumentationClient(appDir);

// Auto-linked favicons: tell the head builder which icon metadata routes
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
// Bound here rather than threaded through ssrOpts, matching
Expand Down Expand Up @@ -830,6 +828,10 @@ export async function createRequestHandler(opts) {
elidableComponents: new Set(),
inertRouteModules: new Set(),
importOnlyRouteModules: new Map(),
// The browser-bound ENTRY files (pages / layouts / boundaries / components),
// kept alongside the expanded gate set because the vendor scan roots at the
// entries rather than at the closure.
browserEntryFiles: new Set(),
browserBoundFiles: null,
// Transformed-source cache (stripped TS + applied elision). Per-handler,
// NOT module-global: the cached bytes bake in THIS handler's elision
Expand Down Expand Up @@ -946,7 +948,8 @@ export async function createRequestHandler(opts) {
const components = await scanComponents(appDir);
await primeComponentRegistry(appDir, components);
t.scan = now() - m; m = now();
state.browserBoundFiles = computeBrowserBoundFiles(state.routeTable, state.moduleGraph, components, appDir);
state.browserEntryFiles = browserEntryFiles(state.routeTable, components);
state.browserBoundFiles = reachableFromEntries(state.moduleGraph, [...state.browserEntryFiles], appDir);
t.gate = now() - m; m = now();
state.actionIndex = await buildActionIndex(appDir, dev);
t.actions = now() - m; m = now();
Expand Down Expand Up @@ -1140,7 +1143,10 @@ export async function createRequestHandler(opts) {
if (vendorResolveInFlight) return vendorResolveInFlight;
vendorResolveInFlight = (async () => {
try {
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));
const scan = async () => {
const skip = new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]);
return reachedBareImports(state.moduleGraph, [...state.browserEntryFiles], appDir, skip);
};
const v = await resolveVendorImports(appDir, scan);
let { imports, integrity } = v;
if (bootVendorPinned) {
Expand Down Expand Up @@ -1212,7 +1218,6 @@ export async function createRequestHandler(opts) {
// The route table is the only eager artifact (cheap directory scan); rebuild
// it so routing reflects added/removed route files immediately.
state.routeTable = await buildRouteTable(appDir);
state.routeTable.instrumentationClient = await findInstrumentationClient(appDir);
// Adding or deleting app/icon.* changes whether the head auto-links it.
setMetadataIconRoutes(state.routeTable.metadataRoutes);
// Refresh the generated route types (#258) so adding/removing a route file
Expand Down Expand Up @@ -2817,41 +2822,6 @@ function debounce(fn, ms) {
};
}

/**
* Walk the route table + component scanner to collect every file the
* browser may legitimately fetch as an ES module, then expand via the
* module graph into the full transitive closure.
*
* This is webjs's equivalent of Next.js's bundler-produced page
* manifest, derived lazily on the first request (and re-derived on every
* rebuild) instead of at compile time. The dev server's source-file branch uses the returned
* Set as an authorization gate: in-set → served (subject to the
* .server.{js,ts} stub guardrail); out-of-set → 404.
*
* Browser-bound entries:
* - page.{js,ts,mjs,mts} (re-runs on client for hydration)
* - layout.{js,ts,mjs,mts} (same)
* - error.{js,ts,mjs,mts} (same)
* - loading.{js,ts,mjs,mts} (same)
* - not-found.{js,ts,mjs,mts} (same)
* - component files discovered by the scanner (eager + lazy)
*
* Server-only entries (NOT in the set):
* - route.{js,ts} (API handlers, never fetched as JS module)
* - middleware.{js,ts}
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
* - .server.{js,ts} files (browser gets a stub, not the source)
*
* Components are passed in (rather than rescanned) so the caller can
* share one scan with `primeComponentRegistry`. Saves a full
* appDir walk on each analysis (the first request and every rebuild).
*
* @param {Awaited<ReturnType<typeof buildRouteTable>>} routeTable
* @param {Awaited<ReturnType<typeof buildModuleGraph>>} moduleGraph
* @param {Awaited<ReturnType<typeof scanComponents>>} components
* @param {string} appDir
* @returns {Set<string>}
*/
/**
* Collect every page + layout file across the route table. These are the
* modules the client boot script imports, and thus the candidates for
Expand All @@ -2871,34 +2841,6 @@ function collectRouteModules(routeTable) {
return [...mods];
}

function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
/** @type {Set<string>} */
const entries = new Set();
for (const page of routeTable.pages) {
if (page.file) entries.add(page.file);
for (const f of page.layouts || []) entries.add(f);
for (const f of page.errors || []) entries.add(f);
for (const f of page.loadings || []) entries.add(f);
for (const f of page.forbiddens || []) entries.add(f);
for (const f of page.unauthorizeds || []) entries.add(f);
}
if (routeTable.notFound) entries.add(routeTable.notFound);
if (routeTable.notFounds) {
for (const f of routeTable.notFounds.values()) entries.add(f);
}
if (routeTable.globalError) entries.add(routeTable.globalError);
if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
// instrumentation-client is browser-bound (imported first in the boot), so it
// must be servable through the gate.
if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
// Lazy components live in the registry but no page imports their
// class directly; the lazy-loader fetches their module URLs on
// viewport entry. Add every discovered component file as an entry so
// the graph walk covers both eager and lazy paths.
for (const c of components) entries.add(c.file);
return reachableFromEntries(moduleGraph, [...entries], appDir);
}

/**
* List the app's top-level source directory names, for expanding a `#*`
* catch-all import alias into one browser importmap prefix scope per dir (#555).
Expand Down
Loading
Loading