diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0de76d93b..4b672d17d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/framework-dev.md b/framework-dev.md index d4c503259..ebc0fcf35 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -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. diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 7f423583c..cc656b287 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -31,7 +31,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | File | What it owns | |---|---| | `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. **Root middleware resolution:** `loadMiddleware` tries `middleware.ts` / `.js` / `.mts` / `.mjs` beside `appDir` in that order (`.ts` first, matching the dev supervisor's watch order). It used to look up the single literal `middleware.js`, so a root `middleware.ts`, what the scaffold writes and what the docs document, was silently never loaded: no error, no warning, indistinguishable from an app with no middleware. Tests: `test/dev/root-middleware-resolution.test.js` plus the cross-runtime `test/bun/root-middleware.mjs`. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; the page-render branch stamps its frame with `withBasePath(url.pathname, basePath()) + url.search` so the browser gate can compare it against `location` (the RAW pathname, and the base path put back, since the ingress strip removed it), SKIPS the hook entirely for a request carrying `x-webjs-prefetch: 1` so a speculative link prefetch never raises an overlay or becomes the retained error, and after a successful GET render clears `state.lastDevError` when it is still the same object AND names this url, so the replay cannot resurrect a superseded error while a good render of an unrelated page leaves a still-current one standing (#1047); `reloadClientJs` also calls `installDevOverlayNavSync()` so the overlay tracks the page on screen; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **Graceful reload (#893):** a reload never paints into a half-restarted server (Node's `node --watch` briefly kills the process on an app edit), so the client gates every reload on a `/__webjs/version` readiness probe (probe-then-reload, instant under an in-process reload, waits out a restart), and a reconnect after a drop is itself treated as an edit signal so an app edit whose in-process reload frame was killed with the old process still reloads without a manual refresh (the SSE `hello` carries a short `retry: 300` so the reconnect is prompt). The reconnect-reload lives in the `dev-reload-worker.js` relay (shared connection) and the per-tab `__webjsDirectEvents` fallback. **Extra watch roots (#894):** `startServer`'s recursive `fs.watch` also follows the dirs in `webjs.dev.watch` (`readDevWatchPathsFromApp`), for content the app reads from OUTSIDE its appDir (blog markdown in a repo-root `blog/`). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there | -| `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order | +| `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order. The table also carries the app-ROOT convention files that are not router stems: `instrumentationClient` (#1399, resolved here rather than by each caller, because a consumer that built its own table and forgot to attach it lost a browser-bound entry and inverted the vendor pin-is-a-superset invariant) | | `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests | | `ssr.js` | SSR pipeline: nested layouts, metadata → `
`, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the form-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the form-action re-render and partial-nav (`X-Webjs-Have`) requests. **Partial responses are never shared-cacheable (#1140):** a reduced `X-Webjs-Have` body is sliced by a request header, so `privateFragment(res)` rewrites its `Cache-Control` to `private` (stripping `public` / `s-maxage` / `proxy-revalidate`, and any qualified `private="field"`, which per RFC 9111 would leave the response shared-storable). `Vary: X-Webjs-Have` is still sent, but as belt-and-braces: it is not the guarantee, because Cloudflare and others honour only `Accept-Encoding`. The helper is quote-aware (a comma inside `no-cache="Set-Cookie,X-Foo"` is not a separator), fails CLOSED on an absent header (no `Cache-Control` is heuristically shared-storable), and is exported for unit testing (not re-exported from the package index, so it is not public API). The fragment KEEPS its ETag, which is why `conditional-get.js` no longer excludes `private`. A non-200 (the 422 form-action re-render, which carries the submitter's own field values) never inherits the page's `cacheControl`. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame:` sample never becomes an edge). Scope note for dynamic imports (#751): a string-literal `import('./x.ts')` is now a GATE edge (servable), but this rule's server-import detection still runs over STATIC edges, so a dynamic `import('./x.server.ts')` of a no-`'use server'` utility is not flagged (its throw-at-load is deferred to call time). A computed `import(expr)` is not a check rule at all (it would false-positive on a valid computed npm / reachable-app import, failing the check-is-correctness-only line); the dev server surfaces it with a 404 hint when the target 404s.) |
-| `vendor.js` | Resolve bare-specifier npm deps. `resolveVendorImports(appDir, getBareImports)` reads `.webjs/vendor/importmap.json` if present (committed pin file) and short-circuits BEFORE running the bare-import scan; only when there is no pin file does it invoke the `getBareImports` thunk (the whole-app `scanBareImports` walk) and call `api.jspm.io/generate`. The scan keeps framework-internal packages off the jspm path BEFORE the generate call: `@webjsdev/core` is served locally (the `BUILTIN` set), and the server-only `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (the `FRAMEWORK_SERVER_ONLY` set) are excluded (#713) so a server-only package never reaches jspm, leaving the 401 fallback below for genuine third-party private deps. `jspmGenerate` resolves the WHOLE install set in ONE generate call (a single `install[]` array) so jspm computes one mutually-consistent graph (#446): a direct dep and a transitive that needs a newer version of the same package share one URL instead of skewing (direct pinned local, transitive floating to jspm-latest -> missing-export crash). Per-package isolation survives as a FALLBACK ONLY: on a permanent 401 (an unresolvable private/server-only install) it probes each install alone, drops the unresolvable one(s), and re-runs the unified call over the resolvable subset so survivors stay coherent; on a transient 5xx/network failure it serves merged per-install fragments and flags the resolve for retry (`lastLiveResolveFailed`). `pinAll` builds the same `install[]` and calls the same `jspmGenerate`, and now ALSO persists the flattened transitive entries the unified resolve returns (`derivePinParts` recovers their pkg/version/subpath from the resolved URL), so a `webjs vendor pin` snapshot and the live runtime importmap agree on the same specifier->URL set for a given dep set (the vendor-runtime parity invariant). **Found-but-uninstalled diagnostic (#953):** the version gate skips any scanned specifier whose package is not installed (`getPackageVersion` returns nothing), and `pinAll` returns those skipped specifiers in `droppedUnresolvable`, DISTINCT from `noBareImports` (a genuinely empty scan). A partial pin carries `droppedUnresolvable` alongside the written `pins`; an all-dropped set returns it with no pin file. The CLI branches on it to name the specifiers and point at `npm install` instead of the misleading "no bare-specifier npm imports found". No change to WHAT gets pinned (an uninstalled package was never pinnable), so vendor-runtime parity holds. So a pinned app does no vendor static analysis at boot (runtime-first); the elision-aware prune of a pinned map (`prunePinToReachable`) runs lazily in `ensureReady`, not at boot. **SRI integrity (sha384, keyed by the FINAL URL) is returned on BOTH paths.** The pin path returns the committed `integrity` verbatim; the LIVE path computes it after resolving via `computeLiveIntegrity`, which fetches each cross-origin (`https://`) target and hashes the raw bytes (`fetchLiveIntegrity` -> `sha384Integrity`), skipping same-origin `/__webjs/...` targets (#235). Bounded (parallel with a small concurrency cap + a per-fetch timeout) and FAIL-OPEN: a bundle fetch failure skips that one URL's integrity and emits a single count-based `console.warn`, never breaking the resolve, so a CDN hiccup cannot take the app down. Hashes are cached per process by URL (`liveIntegrityCache`, cleared by `clearVendorCache`) so a re-resolve does not re-fetch an immutable bundle; this is NOT a persistent cache (that is the pin file's job). The returned `integrity` map keys on the same FINAL URL that `vendorIntegrityFor(url)` looks up, so ssr.js's `integrityAttr` / importmap emission fires for free on the live path too. Backs the `webjs vendor pin / unpin / list / audit / outdated / update` CLI surface plus the `--from ` (jspm, jsdelivr, unpkg, skypack) and `--download` modes. `--download` mode also serves cached bundle files from `.webjs/vendor/`. After a `pin`, the CLI calls `ensureVendorCommittable(appDir)`: vendoring is opt-in, so the pins are meant for source control, and a `.gitignore` that excludes `.webjs/` would swallow them. The helper probes `git check-ignore`; if the pin output is ignored it heals the app's own `.gitignore` (rewrites a bare `.webjs` directory exclusion to the contents-glob form, then appends the `!.webjs/vendor/` negation, then re-probes and reverts if a broader rule still wins), and otherwise is a clean no-op. It NEVER fabricates a `.gitignore` (a parent-repo or `.git/info/exclude` source yields a printed notice instead), so the no-vendor default is untouched. **Importmap-coherence validation (#450):** `checkImportmapCoherence(imports, { getManifest })` inspects a PRODUCED importmap (it does NOT re-resolve, that is #446's job) and for each resolved package checks that the version pinned for every OTHER resolved package it declares a dependency / peer range on satisfies that range, returning conflicts naming both packages, the range, and the pinned version (defense-in-depth for a hand-edited pin, a partial vendor pin, or the #446 skew). Pure in `(imports, getManifest)` and built on the dependency-free `satisfiesSemverRange` + `extractPinnedVersions` (version parsed from the importmap URL), so the SAME pinned dep set yields the SAME verdict over a live importmap and a vendored `.webjs/vendor/importmap.json` (the runtime-vs-vendored parity invariant). Surfaced via `webjs doctor` (warn-only), degrades to `unverified` when a manifest is unavailable, and never warns on a semver range shape it cannot statically evaluate. |
-| `module-graph.js` | Dependency graph for transitive preload hints. Both walks (`transitiveDeps` for preloads, `reachableFromEntries` for the auth gate) stop at `.server.*` boundaries, so a preload set is always a subset of the servable set. The import scanner runs its regexes over the FULLY-BLANKED mask (`redactStringsAndTemplates(src, true)`, which blanks every comment / string / template / regex body to spaces, delimiters kept) and reads each specifier back from raw `src` via the match's group indices (the `d` flag). Scanning over the blanked mask means a stray `import`/`export` word inside ANY literal or comment (example code in an `html\`\`` template, a `// comment`, a `/regex/`) can never anchor a regex match whose lazy body spans across the literal's closing delimiter into the NEXT real `from ''` and consumes it, silently dropping that real edge (a differential test against a real AST caught this class across the comment / template / regex delimiters, #753). A differential test (`packages/server/test/scanner-fuzz/`) proves the lexer's import-edge, `register()` / `customElements.define()`, and WebComponent class-body extraction agrees with a real TypeScript parse over the repo corpus plus adversarial fixtures (the register/class corpus tolerates only verdict-safe over-matches, never a MISS). **Dynamic-import edges (#751):** a string-literal `import('./widget.ts')` (matched by `DYNAMIC_IMPORT_RE`, same `#`-alias rules as the static scan) is tracked as a SEPARATE edge class kept in a `WeakMap` keyed by the graph (read via `dynamicEdges(graph)`). The dynamic scan does NOT run over the fully-blanked mask the static scan uses (which blanks `${...}` template-hole code too): it runs over `redactToPlaceholders(src)` (#918), which keeps hole code readable so a dynamic import written directly inside a hole (`html\`${import('./x.ts')}\``, real code in an expression position) is captured instead of 404ing, while string / template-TEXT bodies are tokenized to `__STR___` (the specifier recovered from the `literals` array) and comment / regex bodies blanked, so a dynamic import written as literal TEXT is still not an edge. The static `IMPORT_RE` / `EXPORT_FROM_RE` stay on the blanked mask (a statement cannot appear in a `${}` hole, so only the dynamic form needs hole-awareness) and `DYNAMIC_IMPORT_RE` is tightly anchored on `import(` (no lazy cross-delimiter span), so the placeholder scan does not reopen the #753 swallow class. `reachableFromEntries` (the gate) unions these in so a lazily-imported app module is servable instead of 404ing, and a dynamically-imported module's own static subtree is walked too; but `transitiveDeps` (preload) and the elision analysis stay on the STATIC graph only, so a dynamic import is admitted-but-not-preloaded (lazy by author intent) and never flips an elision verdict. The `.server.*` boundary holds for dynamic edges (a dynamic `import('./x.server.ts')` is admitted as a stub, not traversed into). A computed `import(expr)` cannot be captured and stays out (a `webjs check` warning surfaces it). **`#` path-alias expansion (#555):** `appImportsMap(appDir)` reads + caches the app's `package.json "imports"` map, and `expandImportAlias(spec, appDir)` expands a matching `#`-prefixed specifier (e.g. `#lib/db.server.ts` under the scaffold's catch-all `"#*": "./*"`) to its real app-relative target. `resolveImport` calls it BEFORE the relative branch and `parseFile` lets alias specs through, so the graph / auth gate / elision / `no-server-import-in-browser-module` all see the REAL path (an alias cannot launder a `.server.ts` past the boundary). Key-shape-agnostic (wildcard + exact, any base); `IMPORTS_CACHE` is cleared per appDir on each `buildModuleGraph`. **Bare (npm vendor) edges (#754):** a bare specifier (`dayjs`, `@scope/pkg/sub`) is NOT a static graph edge (the gate / elision are unchanged), but the exact specifier is recorded per file in a SEPARATE `WeakMap` keyed by the graph (read via `bareImports(graph)`) so `ssr.js` can map it to a vendor importmap URL and emit a `modulepreload` (flattening the CDN waterfall one level). `node:` builtins + protocol specifiers are excluded; because the scan runs over the fully-blanked mask (#753), a `from ''` written inside example code cannot become a phantom vendor edge (the earlier #754 quote-position guard is subsumed by the blanked-mask scan). |
+| `vendor.js` | Resolve bare-specifier npm deps. `resolveVendorImports(appDir, getBareImports)` reads `.webjs/vendor/importmap.json` if present (committed pin file) and short-circuits BEFORE running the bare-import scan; only when there is no pin file does it invoke the `getBareImports` thunk and call `api.jspm.io/generate`. **The scan is ROOTED IN THE MODULE GRAPH (#1399), not in a filesystem walk.** Only files reachable from a browser-bound entry (`browser-entries.js`'s `browserEntryFiles`, the same entry set the dev server's authorization gate uses) contribute a specifier, so a build script under `scripts/`, a tooling config, a test helper, and an unreferenced module all drop out by REACHABILITY rather than by name; there is no exclusion list any more (the old one was open-ended and had already gone stale). Two entry points share one implementation: out-of-process callers (`pinAll`, `webjs doctor`) call `scanBareImports(appDir)`, which builds its own graph + route table + component scan (roughly 70-200ms on the in-repo apps) and applies NO elision pruning; the dev server calls `reachedBareImports(graph, entries, appDir, skip)` with the analysis it already has plus its elision skip set. That asymmetry is deliberate and load-bearing: the pin side is a SUPERSET of the runtime side by construction (same roots, no prune), which is exactly the relation `prunePinToReachable` needs to intersect a committed pin down to the runtime answer (#197). Because the specifiers now come from the graph's own blanked-mask scanner (`bareImports`), an `import` written as example text inside a template literal is no longer a phantom vendor edge, and the framework runs ONE vendor-specifier scanner instead of two that disagreed (the importmap and the #754 modulepreload hints now share it). The scan keeps framework-internal packages off the jspm path BEFORE the generate call: `@webjsdev/core` is served locally (the `BUILTIN` set), and the server-only `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (the `FRAMEWORK_SERVER_ONLY` set) are excluded (#713) so a server-only package never reaches jspm, leaving the 401 fallback below for genuine third-party private deps. `jspmGenerate` resolves the WHOLE install set in ONE generate call (a single `install[]` array) so jspm computes one mutually-consistent graph (#446): a direct dep and a transitive that needs a newer version of the same package share one URL instead of skewing (direct pinned local, transitive floating to jspm-latest -> missing-export crash). Per-package isolation survives as a FALLBACK ONLY: on a permanent 401 (an unresolvable private/server-only install) it probes each install alone, drops the unresolvable one(s), and re-runs the unified call over the resolvable subset so survivors stay coherent; on a transient 5xx/network failure it serves merged per-install fragments and flags the resolve for retry (`lastLiveResolveFailed`). `pinAll` builds the same `install[]` and calls the same `jspmGenerate`, and now ALSO persists the flattened transitive entries the unified resolve returns (`derivePinParts` recovers their pkg/version/subpath from the resolved URL), so a `webjs vendor pin` snapshot and the live runtime importmap agree on the same specifier->URL set for a given dep set (the vendor-runtime parity invariant). **Found-but-uninstalled diagnostic (#953):** the version gate skips any scanned specifier whose package is not installed (`getPackageVersion` returns nothing), and `pinAll` returns those skipped specifiers in `droppedUnresolvable`, DISTINCT from `noBareImports` (a genuinely empty scan). A partial pin carries `droppedUnresolvable` alongside the written `pins`; an all-dropped set returns it with no pin file. The CLI branches on it to name the specifiers and point at `npm install` instead of the misleading "no bare-specifier npm imports found". No change to WHAT gets pinned (an uninstalled package was never pinnable), so vendor-runtime parity holds. So a pinned app does no vendor static analysis at boot (runtime-first); the elision-aware prune of a pinned map (`prunePinToReachable`) runs lazily in `ensureReady`, not at boot. **SRI integrity (sha384, keyed by the FINAL URL) is returned on BOTH paths.** The pin path returns the committed `integrity` verbatim; the LIVE path computes it after resolving via `computeLiveIntegrity`, which fetches each cross-origin (`https://`) target and hashes the raw bytes (`fetchLiveIntegrity` -> `sha384Integrity`), skipping same-origin `/__webjs/...` targets (#235). Bounded (parallel with a small concurrency cap + a per-fetch timeout) and FAIL-OPEN: a bundle fetch failure skips that one URL's integrity and emits a single count-based `console.warn`, never breaking the resolve, so a CDN hiccup cannot take the app down. Hashes are cached per process by URL (`liveIntegrityCache`, cleared by `clearVendorCache`) so a re-resolve does not re-fetch an immutable bundle; this is NOT a persistent cache (that is the pin file's job). The returned `integrity` map keys on the same FINAL URL that `vendorIntegrityFor(url)` looks up, so ssr.js's `integrityAttr` / importmap emission fires for free on the live path too. Backs the `webjs vendor pin / unpin / list / audit / outdated / update` CLI surface plus the `--from ` (jspm, jsdelivr, unpkg, skypack) and `--download` modes. `--download` mode also serves cached bundle files from `.webjs/vendor/`. After a `pin`, the CLI calls `ensureVendorCommittable(appDir)`: vendoring is opt-in, so the pins are meant for source control, and a `.gitignore` that excludes `.webjs/` would swallow them. The helper probes `git check-ignore`; if the pin output is ignored it heals the app's own `.gitignore` (rewrites a bare `.webjs` directory exclusion to the contents-glob form, then appends the `!.webjs/vendor/` negation, then re-probes and reverts if a broader rule still wins), and otherwise is a clean no-op. It NEVER fabricates a `.gitignore` (a parent-repo or `.git/info/exclude` source yields a printed notice instead), so the no-vendor default is untouched. **Importmap-coherence validation (#450):** `checkImportmapCoherence(imports, { getManifest })` inspects a PRODUCED importmap (it does NOT re-resolve, that is #446's job) and for each resolved package checks that the version pinned for every OTHER resolved package it declares a dependency / peer range on satisfies that range, returning conflicts naming both packages, the range, and the pinned version (defense-in-depth for a hand-edited pin, a partial vendor pin, or the #446 skew). Pure in `(imports, getManifest)` and built on the dependency-free `satisfiesSemverRange` + `extractPinnedVersions` (version parsed from the importmap URL), so the SAME pinned dep set yields the SAME verdict over a live importmap and a vendored `.webjs/vendor/importmap.json` (the runtime-vs-vendored parity invariant). Surfaced via `webjs doctor` (warn-only), degrades to `unverified` when a manifest is unavailable, and never warns on a semver range shape it cannot statically evaluate. |
+| `module-graph.js` | Dependency graph for transitive preload hints. Both walks (`transitiveDeps` for preloads, `reachableFromEntries` for the auth gate) stop at `.server.*` boundaries, so a preload set is always a subset of the servable set. The import scanner runs its regexes over the FULLY-BLANKED mask (`redactStringsAndTemplates(src, true)`, which blanks every comment / string / template / regex body to spaces, delimiters kept) and reads each specifier back from raw `src` via the match's group indices (the `d` flag). Scanning over the blanked mask means a stray `import`/`export` word inside ANY literal or comment (example code in an `html\`\`` template, a `// comment`, a `/regex/`) can never anchor a regex match whose lazy body spans across the literal's closing delimiter into the NEXT real `from ''` and consumes it, silently dropping that real edge (a differential test against a real AST caught this class across the comment / template / regex delimiters, #753). A differential test (`packages/server/test/scanner-fuzz/`) proves the lexer's import-edge, `register()` / `customElements.define()`, and WebComponent class-body extraction agrees with a real TypeScript parse over the repo corpus plus adversarial fixtures (the register/class corpus tolerates only verdict-safe over-matches, never a MISS). **Dynamic-import edges (#751):** a string-literal `import('./widget.ts')` (matched by `DYNAMIC_IMPORT_RE`, same `#`-alias rules as the static scan) is tracked as a SEPARATE edge class kept in a `WeakMap` keyed by the graph (read via `dynamicEdges(graph)`). The dynamic scan does NOT run over the fully-blanked mask the static scan uses (which blanks `${...}` template-hole code too): it runs over `redactToPlaceholders(src)` (#918), which keeps hole code readable so a dynamic import written directly inside a hole (`html\`${import('./x.ts')}\``, real code in an expression position) is captured instead of 404ing, while string / template-TEXT bodies are tokenized to `__STR___` (the specifier recovered from the `literals` array) and comment / regex bodies blanked, so a dynamic import written as literal TEXT is still not an edge. The static `IMPORT_RE` / `EXPORT_FROM_RE` stay on the blanked mask (a statement cannot appear in a `${}` hole, so only the dynamic form needs hole-awareness) and `DYNAMIC_IMPORT_RE` is tightly anchored on `import(` (no lazy cross-delimiter span), so the placeholder scan does not reopen the #753 swallow class. `reachableFromEntries` (the gate) unions these in so a lazily-imported app module is servable instead of 404ing, and a dynamically-imported module's own static subtree is walked too; but `transitiveDeps` (preload) and the elision analysis stay on the STATIC graph only, so a dynamic import is admitted-but-not-preloaded (lazy by author intent) and never flips an elision verdict. The `.server.*` boundary holds for dynamic edges (a dynamic `import('./x.server.ts')` is admitted as a stub, not traversed into). A computed `import(expr)` cannot be captured and stays out (a `webjs check` warning surfaces it). **`#` path-alias expansion (#555):** `appImportsMap(appDir)` reads + caches the app's `package.json "imports"` map, and `expandImportAlias(spec, appDir)` expands a matching `#`-prefixed specifier (e.g. `#lib/db.server.ts` under the scaffold's catch-all `"#*": "./*"`) to its real app-relative target. `resolveImport` calls it BEFORE the relative branch and `parseFile` lets alias specs through, so the graph / auth gate / elision / `no-server-import-in-browser-module` all see the REAL path (an alias cannot launder a `.server.ts` past the boundary). Key-shape-agnostic (wildcard + exact, any base); `IMPORTS_CACHE` is cleared per appDir on each `buildModuleGraph`. **Bare (npm vendor) edges (#754):** a bare specifier (`dayjs`, `@scope/pkg/sub`) is NOT a static graph edge (the gate / elision are unchanged), but the exact specifier is recorded per file in a SEPARATE `WeakMap` keyed by the graph (read via `bareImports(graph)`) so `ssr.js` can map it to a vendor importmap URL and emit a `modulepreload` (flattening the CDN waterfall one level). `node:` builtins + protocol specifiers are excluded; because the scan runs over the fully-blanked mask (#753), a `from ''` written inside example code cannot become a phantom vendor edge (the earlier #754 quote-position guard is subsumed by the blanked-mask scan). `reachableBareSpecifiers(graph, entryFiles, appDir, skip?)` composes those edges with a `reachableFromEntries`-shaped walk to answer "which vendor specifiers could the browser load from these entries", which is what `vendor.js` builds the importmap from (#1399). It follows DYNAMIC edges as well as static ones, unlike `transitiveDeps`: a lazily `import('./chart.ts')`-ed module must not be PRELOADED, but its bare specifier still has to resolve when the import finally runs, so it belongs in the importmap. `.server.*` files are reached but their bare imports are not collected (the source never ships, so a DB driver behind a server file must never be vendored), and a `skip` file is neither collected nor traversed into, so an elided-only specifier drops out along with the subtree behind it (#170 / #197). |
| `importmap.js` | Browser import-map builder. `setCoreInstall(coreDir, distMode)` binds the importmap to the resolved `@webjsdev/core` install and runs `buildCoreEntries()`, which reads the package's `package.json` and derives one importmap line per exported subpath from its `exports` field, picking the `default` condition in dist mode and the `source` (`src/*.js`) condition otherwise. In dist mode the browser surface is ONE self-contained bundle: the `exports` `default` for the always-load browser subpaths (`/directives`, `/context`, `/task`, `/client-router`) all point at `dist/webjs-core-browser.js`, so those entries plus the bare specifier collapse onto that single file (each import picks its named exports from it) instead of a fan of per-subpath bundles + code-split chunks. `/lazy-loader` keeps its own file (on-demand). In src/dev mode each subpath stays granular (`src/*.js`) since there is no bundle to collapse into. `dev.js` calls `setCoreInstall` at boot based on `existsSync(coreDir/dist/webjs-core.js) && existsSync(coreDir/dist/webjs-core-browser.js)`. The bare `@webjsdev/core` specifier always points at the BROWSER entry (`index-browser.js` or `dist/webjs-core-browser.js`); the slim entry drops `renderToString`, `renderToStream`, and `setCspNonceProvider` so server-only bytes do not ride the wire. Node-side consumers resolve via the package.json exports and still get the full `index.js`. `buildImportMap({ fingerprint })` content-hashes each same-origin target via `asset-hash.js`'s `withAssetHash` when `fingerprint` is true (the served map); the internal `importMapHash()` computation passes `false` so the published build id stays a stable per-deploy fingerprint independent of per-file hashes (#243). `vendorPreconnectOrigins(max?)` derives the cross-origin vendor CDN origins from the resolved vendor map (`_extraEntries`), most-common first + bounded, for the auto vendor preconnect (#243): returns `[]` for a same-origin pinned / empty map. **`#` alias browser scopes (#555):** `importAliasBrowserEntries(importsMap, topLevelDirs)` derives the browser importmap entries for the app's `"imports"` aliases, derived from the SAME map the server resolver reads (lockstep). The scaffold's catch-all `"#*": "./*"` expands into one trailing-slash prefix scope per top-level dir (`#lib/` -> `/lib/`, ...; a bare `#` cannot prefix-match, so dev.js's `appTopLevelDirs` scan supplies the dirs and a new folder is covered on the next boot); a per-dir or exact key maps directly. `setImportAliasEntries` binds them at boot and folds them into `buildImportMap`. **`vendorPreloadTargets(specifiers)` (#754):** maps a set of reached bare specifiers to `[{ href, integrity }]` taken DIRECTLY from `buildImportMap().imports[spec]` (byte-identical to the importmap target, so the browser does not double-fetch) + the matching `integrity`; excludes `@webjsdev/core*` (same-origin, already on the boot path), dedups by href, and DROPS a specifier absent from the importmap (unpinned / unreached / elided, so no over-fetch). `ssr.js` feeds it the reached vendor set and emits a `modulepreload` per target. |
+| `browser-entries.js` | `browserEntryFiles(routeTable, components)`: the browser-bound ENTRY files, every module the client boot can import directly (page / layout, the error / loading / not-found / forbidden / unauthorized boundaries, the two root-only boundaries, instrumentation-client, and every discovered component, since a `static lazy` one is fetched by the lazy loader rather than imported by a page). Its own module because two consumers root at it: `dev.js` expands it through `reachableFromEntries` into the serve-time authorization gate, and `vendor.js` expands it through `reachableBareSpecifiers` into the importmap (#1399), so an out-of-process `pinAll` / `webjs doctor` roots at exactly the entries the running server does without booting one. It FORWARDS the route table's fields rather than discovering any of them, so every entry has to be on the table before it is called. `instrumentation-client.*` is the one that is not a router stem, and it was attached by `dev.js` alone until #1399: the vendor scan builds its own table, so it silently lost that entry, which inverted the pin-is-a-superset invariant and left a pinned app with no importmap entry for the first module its boot imports. `buildRouteTable` resolves it now, so a new consumer cannot reintroduce the miss. Anything else added to the table later belongs there for the same reason |
| `component-scanner.js` | Maps every webjs component class to its browser-visible URL |
| `component-elision.js` | Static analyser deciding which display-only component modules can be elided from the browser, plus the serve-time side-effect-import stripper. Conservative denylist of interactivity signals (single source of truth). `analyzeElision` also returns `shippedRouteModules` (#646): for each page/layout that ships whole (neither inert nor import-only), the first client-effecting blocker that pins it (a non-component on a component-free path from the module, #963, or `null` when the module's own code is the cause) plus a human `reason`. It further returns `componentVerdicts` (#1308): per component FILE, its sorted tag list, whether it ships, and the EVIDENCE that forced it (`own` / `observed` / `closure` / `render` / `import` / `unreadable`, first match wins) plus the module that did the forcing. Both are projections of what the passes already computed, never a second analysis. A reporting layer over the existing verdict, consumed by the `webjs doctor` advisory |
| `elision-report.js` | `analyzeAppElision(appDir)` (#646, #1308): builds the module graph + runs `analyzeElision` ONCE, returning the WHOLE verdict as a sorted, app-relative, JSON-serializable `{ analysed, skipped, components, routeModules, orphans, summary }`. Both directions: which component modules the browser never downloads and why each shipped one ships, which page/layout is inert / import-only / shipped, and every orphan class that gets no verdict at all. Consumed by `webjs elision` (plus `--json`), the MCP `list_elision` tool, and BOTH `webjs doctor` elision checks, which share one call so the graph is built once. `skipped` names why nothing was analysed (`no-app` / `elide-off` / `unanalysable`), and the report caches nothing (every consumer runs once and exits). A reporting layer over the analysis, NOT a build (webjs is no-build) |
diff --git a/packages/server/index.d.ts b/packages/server/index.d.ts
index bc094db9d..ad770d647 100644
--- a/packages/server/index.d.ts
+++ b/packages/server/index.d.ts
@@ -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): Promise>;
+/**
+ * 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>;
/** 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. */
diff --git a/packages/server/src/browser-entries.js b/packages/server/src/browser-entries.js
new file mode 100644
index 000000000..f4e6bab5a
--- /dev/null
+++ b/packages/server/src/browser-entries.js
@@ -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>} routeTable
+ * @param {Awaited>} components
+ * @returns {Set}
+ */
+export function browserEntryFiles(routeTable, components) {
+ /** @type {Set} */
+ 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;
+}
diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js
index a681396c6..954488bc3 100644
--- a/packages/server/src/dev.js
+++ b/packages/server/src/dev.js
@@ -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';
@@ -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';
@@ -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);
- // 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
@@ -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
@@ -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();
@@ -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) {
@@ -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
@@ -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>} routeTable
- * @param {Awaited>} moduleGraph
- * @param {Awaited>} components
- * @param {string} appDir
- * @returns {Set}
- */
/**
* Collect every page + layout file across the route table. These are the
* modules the client boot script imports, and thus the candidates for
@@ -2871,34 +2841,6 @@ function collectRouteModules(routeTable) {
return [...mods];
}
-function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
- /** @type {Set} */
- 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).
diff --git a/packages/server/src/module-graph.js b/packages/server/src/module-graph.js
index 08035a2a5..b0126858c 100644
--- a/packages/server/src/module-graph.js
+++ b/packages/server/src/module-graph.js
@@ -174,6 +174,28 @@ export function bareImports(graph) {
return BARE_EDGES.get(graph) || new Map();
}
+/**
+ * Bare (npm vendor) specifiers imported DYNAMICALLY (`await import('dayjs')`),
+ * kept apart from `BARE_EDGES` for the same reason `DYNAMIC_EDGES` is kept
+ * apart from the static graph: a lazily-imported vendor must RESOLVE when the
+ * import finally runs, so it belongs in the importmap, but it must not be
+ * preloaded (that would fetch a bundle the author deliberately deferred). So
+ * `reachableBareSpecifiers` (the importmap) unions both maps while
+ * `bareImports` (the #754 modulepreload source) stays static-only.
+ * @type {WeakMap>>}
+ */
+const BARE_DYNAMIC_EDGES = new WeakMap();
+
+/**
+ * The dynamically-imported bare specifiers per file for a built graph, or an
+ * empty map if none.
+ * @param {ModuleGraph} graph
+ * @returns {Map>}
+ */
+export function dynamicBareImports(graph) {
+ return BARE_DYNAMIC_EDGES.get(graph) || new Map();
+}
+
/**
* Every app source file walked while building a graph (#899). `graph` itself
* holds only files that HAVE imports, so a leaf (e.g. a no-import `.server.ts`)
@@ -224,12 +246,16 @@ export async function buildModuleGraph(appDir) {
const dynamic = new Map();
/** @type {Map>} bare vendor specifiers per file (#754) */
const bare = new Map();
+ /** @type {Map>} DYNAMICALLY-imported bare vendor specifiers
+ * per file: importmap-bound but never preloaded (#1399) */
+ const bareDynamic = new Map();
/** @type {Set} every file walked this build (graph holds only files
* with deps, so a separate set is needed to know what is still live). */
const seen = new Set();
- await walk(appDir, appDir, graph, seen, dynamic, bare);
+ await walk(appDir, appDir, graph, seen, dynamic, bare, bareDynamic);
if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
if (bare.size) BARE_EDGES.set(graph, bare);
+ if (bareDynamic.size) BARE_DYNAMIC_EDGES.set(graph, bareDynamic);
if (seen.size) SEEN_FILES.set(graph, seen);
// Evict parse-cache entries for files no longer in the tree (a rebuild after
// a rename or delete), so a long dev session does not accumulate dead
@@ -374,13 +400,82 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
return visited;
}
+/**
+ * Bare npm vendor specifiers reachable from `entryFiles`, the importmap
+ * analogue of ssr.js's per-page `reachedVendorSpecifiers` (#754).
+ *
+ * Walks the SAME edges the authorization gate walks (static + dynamic, #751,
+ * stopping at `.server.*` boundaries) so the answer is exactly "what a browser
+ * could load from these entries", then unions each reached file's recorded
+ * bare edges, static AND dynamic. Both dynamic forms are included here even
+ * though `transitiveDeps` (preload) ignores them, for the same reason: a
+ * lazily imported module is not preloaded, but its bare specifier still has to
+ * resolve when the import finally runs, so it belongs in the importmap. That
+ * is why a dynamically-imported VENDOR gets its own map (`dynamicBareImports`)
+ * rather than joining `bareImports`, which feeds the preload hints.
+ *
+ * `.server.*` files are reached (the browser fetches a stub at that URL) but
+ * their bare imports are NOT collected: the source never ships, so a DB driver
+ * imported by a server file must never enter the importmap.
+ *
+ * `skip` files are neither collected nor traversed into, so a specifier
+ * reachable ONLY through an elided component drops out along with the subtree
+ * behind it (#197 / #170). Omit `skip` for the un-pruned superset the pin path
+ * needs.
+ *
+ * @param {ModuleGraph} graph
+ * @param {string[]} entryFiles absolute paths
+ * @param {string} appDir
+ * @param {Set} [skip] absolute paths to exclude and not traverse
+ * @returns {Set}
+ */
+export function reachableBareSpecifiers(graph, entryFiles, appDir, skip) {
+ /** @type {Set} */
+ const specs = new Set();
+ const bare = BARE_EDGES.get(graph);
+ const bareDynamic = BARE_DYNAMIC_EDGES.get(graph);
+ if (!bare?.size && !bareDynamic?.size) return specs;
+ const dynamic = DYNAMIC_EDGES.get(graph);
+ /** @type {Set} */
+ const visited = new Set();
+ /** @type {string[]} */
+ const queue = [];
+ for (const entry of entryFiles) {
+ if (!entry || !entry.startsWith(appDir)) continue;
+ if (skip && skip.has(entry)) continue;
+ visited.add(entry);
+ queue.push(entry);
+ }
+ while (queue.length) {
+ const file = /** @type {string} */ (queue.shift());
+ if (SERVER_FILE_RE.test(file)) continue;
+ for (const map of [bare, bareDynamic]) {
+ const fileBare = map?.get(file);
+ if (fileBare) for (const spec of fileBare) specs.add(spec);
+ }
+ const staticDeps = graph.get(file);
+ const dynDeps = dynamic && dynamic.get(file);
+ for (const set of [staticDeps, dynDeps]) {
+ if (!set) continue;
+ for (const dep of set) {
+ if (visited.has(dep)) continue;
+ if (!dep.startsWith(appDir)) continue;
+ if (skip && skip.has(dep)) continue;
+ visited.add(dep);
+ queue.push(dep);
+ }
+ }
+ }
+ return specs;
+}
+
/**
* Recursively walk a directory, parse imports, and populate the graph.
* @param {string} dir
* @param {string} appDir
* @param {ModuleGraph} graph
*/
-async function walk(dir, appDir, graph, seen, dynamic, bare) {
+async function walk(dir, appDir, graph, seen, dynamic, bare, bareDynamic) {
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); }
catch { return; }
@@ -398,9 +493,9 @@ async function walk(dir, appDir, graph, seen, dynamic, bare) {
if (e.name.startsWith('.')) continue;
const full = join(dir, e.name);
if (e.isDirectory()) {
- await walk(full, appDir, graph, seen, dynamic, bare);
+ await walk(full, appDir, graph, seen, dynamic, bare, bareDynamic);
} else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
- await parseFile(full, appDir, graph, seen, dynamic, bare);
+ await parseFile(full, appDir, graph, seen, dynamic, bare, bareDynamic);
}
}
}
@@ -428,7 +523,7 @@ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
* @param {string} appDir
* @param {ModuleGraph} graph
*/
-async function parseFile(file, appDir, graph, seen, dynamic, bare) {
+async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic) {
let mtimeMs, size;
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
catch { return; }
@@ -438,6 +533,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
if (cached.deps.size) graph.set(file, cached.deps);
if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps);
if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps);
+ if (cached.bareDynDeps && cached.bareDynDeps.size && bareDynamic) bareDynamic.set(file, cached.bareDynDeps);
return;
}
@@ -541,19 +637,28 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
// `,`/`)`), preserving the documented limitation.
const { redacted: holeAware, literals } = redactToPlaceholders(src);
const dynDeps = new Set();
+ const bareDynDeps = new Set();
for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) {
const token = m[1];
const pm = /^__STR_(\d+)__$/.exec(token);
const spec = pm ? literals[Number(pm[1])] : token;
if (spec == null) continue;
- if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
+ // A dynamically-imported VENDOR (`await import('dayjs')`). Not a graph
+ // edge (there is no local file to serve) and not a preload (lazy by
+ // intent), but it still needs an importmap entry or the import fails to
+ // resolve when it runs. Recorded in its own map for that one consumer.
+ if (isVendorSpecifier(spec)) bareDynDeps.add(spec);
+ continue;
+ }
const resolved = resolveImport(spec, file, appDir);
if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
}
- PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps });
+ PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps, bareDynDeps });
if (deps.size) graph.set(file, deps);
if (dynDeps.size && dynamic) dynamic.set(file, dynDeps);
if (bareDeps.size && bare) bare.set(file, bareDeps);
+ if (bareDynDeps.size && bareDynamic) bareDynamic.set(file, bareDynDeps);
}
/**
diff --git a/packages/server/src/router.js b/packages/server/src/router.js
index 7019b19ad..4ab9f3a95 100644
--- a/packages/server/src/router.js
+++ b/packages/server/src/router.js
@@ -1,5 +1,6 @@
import { join, relative, sep, posix } from 'node:path';
import { walk } from './fs-walk.js';
+import { findInstrumentationClient } from './instrumentation.js';
/**
* @typedef {{
@@ -35,6 +36,7 @@ import { walk } from './fs-walk.js';
* globalError: string | null,
* globalNotFound: string | null,
* metadataRoutes: MetadataRoute[],
+ * instrumentationClient: string | null,
* appDir: string
* }} RouteTable
*/
@@ -185,7 +187,15 @@ export async function buildRouteTable(appDir) {
}
pages.sort(compareSpecificity);
- return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, appDir };
+ // `instrumentation-client.*` is an app-ROOT convention file, not a router
+ // stem, but it is a browser-bound entry (the boot imports it first), so the
+ // table is where every consumer already looks for one. Resolved HERE rather
+ // than by each caller: it used to be attached by the dev server alone, and a
+ // second consumer that built its own table (the vendor scan) silently lost
+ // the entry, which inverted the pin-is-a-superset invariant. Deriving it in
+ // the one place that builds the table makes that class of miss impossible.
+ const instrumentationClient = await findInstrumentationClient(appDir);
+ return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, instrumentationClient, appDir };
}
/**
diff --git a/packages/server/src/vendor.js b/packages/server/src/vendor.js
index 26ea514eb..9e4c38b93 100644
--- a/packages/server/src/vendor.js
+++ b/packages/server/src/vendor.js
@@ -47,6 +47,10 @@ import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import { digestBase64 } from './crypto-utils.js';
import { BUFFERED_MARKER } from './conditional-get.js';
+import { buildModuleGraph, reachableBareSpecifiers } from './module-graph.js';
+import { browserEntryFiles } from './browser-entries.js';
+import { scanComponents } from './component-scanner.js';
+import { buildRouteTable } from './router.js';
/**
* Set of package names whose importmap entries are populated by the
@@ -79,34 +83,75 @@ const BUILTIN = new Set(['@webjsdev/core', '@webjsdev/core/']);
const FRAMEWORK_SERVER_ONLY = new Set(['@webjsdev/cli', '@webjsdev/server', '@webjsdev/mcp']);
/**
- * Scan source files under `dir` for bare import specifiers reachable
- * from the browser. Returns a Set of package names.
- *
- * Excludes:
- * - `node_modules`, `.webjs`, `public` directories
- * - Any directory starting with `_` (webjs `_private/` convention)
- * - `test/` and `tests/` (server-only by WebJs convention)
- * - Files whose name marks them as server-only:
- * * `*.server.{js,ts,mjs,mts}` (path-level boundary)
- * * `route.{js,ts,mjs,mts}` (file-router HTTP handler)
- * * `middleware.{js,ts,mjs,mts}` (file-router middleware)
- * - Any file whose first non-whitespace content is `'use server'`
- * - `import type` statements (TypeScript erases them at compile time)
- * - `import` strings inside `/* … */` block comments or `//` line comments
- *
- * @param {string} dir
+ * Bare npm specifiers that could reach the browser, filtered of the framework
+ * packages that must never be vendored.
+ *
+ * The scan is ROOTED IN THE MODULE GRAPH, not in a filesystem walk. Only files
+ * reachable from a browser-bound entry (page / layout / error / loading /
+ * not-found / forbidden / unauthorized / instrumentation-client / any
+ * component) contribute, which is the same authorization gate the dev server
+ * uses to decide what it will serve at all. A file nothing imports contributes
+ * nothing, so a build script under `scripts/`, a tooling config, a test helper
+ * and an unreferenced module all drop out by reachability rather than by name
+ * (there is no exclusion LIST any more, which is the point: the old one was
+ * open-ended and went stale).
+ *
+ * Excluded on top of unreachability:
+ * - `.server.{js,ts,mjs,mts}` files. They are REACHED (the browser fetches an
+ * RPC or throw-at-load stub at that URL) but their source never ships, so
+ * their bare imports must not enter the importmap.
+ * - `@webjsdev/core` and its subpaths (BUILTIN, served locally).
+ * - `@webjsdev/cli` / `@webjsdev/server` / `@webjsdev/mcp` (#713).
+ * - `import type` statements and any `import` written inside a comment,
+ * string, template literal or regex body. Both come free from the module
+ * graph's blanked-mask scanner (#753 / #805); this function no longer has
+ * a scanner of its own.
+ *
+ * This is the OUT-OF-PROCESS entry point (`pinAll`, `webjs doctor`). It builds
+ * its own graph, route table and component scan, roughly 70-200ms on the
+ * in-repo apps, and applies NO elision pruning, so the result is a SUPERSET of
+ * what the running server serves. That superset relation is what lets
+ * `prunePinToReachable` intersect a committed pin down to the runtime answer
+ * (#197). The dev server calls `reachedBareImports` instead, with the graph it
+ * already has and its elision skip set.
+ *
+ * @param {string} appDir
* @returns {Promise>}
*/
-export async function scanBareImports(dir, skipFiles) {
- /** @type {Set} */
- const found = new Set();
- await walk(dir, found, skipFiles);
+export async function scanBareImports(appDir) {
+ let graph, components, routeTable;
+ try {
+ graph = await buildModuleGraph(appDir);
+ components = await scanComponents(appDir);
+ routeTable = await buildRouteTable(appDir);
+ } catch {
+ // An app the analysis cannot process yields no vendor specifiers rather
+ // than a throw, matching how check.js and elision-report.js degrade. The
+ // dev server surfaces the real problem.
+ return new Set();
+ }
+ return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], appDir);
+}
+
+/**
+ * The in-process form: the caller already has a module graph, the entry set,
+ * and (on the runtime path) the elision skip set. Used by the dev server so a
+ * vendor resolve does not rebuild analysis it just built.
+ *
+ * @param {import('./module-graph.js').ModuleGraph} graph
+ * @param {string[]} entryFiles
+ * @param {string} appDir
+ * @param {Set} [skip] elided / inert / import-only modules
+ * @returns {Set}
+ */
+export function reachedBareImports(graph, entryFiles, appDir, skip) {
+ const found = reachableBareSpecifiers(graph, entryFiles, appDir, skip);
for (const b of BUILTIN) found.delete(b);
- // Drop server-only framework packages (and their subpaths) so they never
- // reach the importmap / jspm path (#713).
+ // Drop core subpaths (served locally via `/__webjs/core/*`) and server-only
+ // framework packages (#713) so neither reaches the importmap / jspm path.
for (const spec of found) {
const p = extractPackageName(spec);
- if (p && FRAMEWORK_SERVER_ONLY.has(p)) found.delete(spec);
+ if (p && (BUILTIN.has(p) || FRAMEWORK_SERVER_ONLY.has(p))) found.delete(spec);
}
return found;
}
@@ -135,98 +180,6 @@ export function extractPackageName(spec) {
return spec.split('/')[0];
}
-// Matches `import { x } from 'pkg'`, `import 'pkg'`, `import * as x from 'pkg'`.
-// The `(?!type\s)` negative lookahead skips `import type … from 'pkg'`
-// because TypeScript type-only imports are fully erased at compile time
-// and never reach the browser.
-const IMPORT_RE = /\bimport\s+(?!type\s)(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
-const DYNAMIC_IMPORT_RE = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
-
-const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
-const LINE_COMMENT_RE = /(^|[^:])\/\/.*$/gm;
-function stripComments(src) {
- return src.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '$1');
-}
-
-/**
- * Filename matches webjs's server-only file-router conventions.
- *
- * @param {string} name basename of the file
- */
-function isServerOnlyFile(name) {
- if (/\.server\.(js|ts|mjs|mts)$/.test(name)) return true;
- if (/^route\.(js|ts|mjs|mts)$/.test(name)) return true;
- if (/^middleware\.(js|ts|mjs|mts)$/.test(name)) return true;
- return false;
-}
-
-/**
- * Tooling config files at any depth. They import test runners, build
- * helpers, AI plugins etc. that legitimately cannot resolve through
- * jspm.io (e.g. `@web/test-runner-playwright` pulls in `playwright-core`
- * with subpaths jspm.io can't bundle). Their bare imports must never
- * reach the importmap.
- */
-const CONFIG_FILE_RE = /\.config\.(js|ts|mjs|mts|cjs|cts)$/;
-
-/**
- * @param {string} dir
- * @param {Set} found
- */
-async function walk(dir, found, skipFiles) {
- let entries;
- try { entries = await readdir(dir, { withFileTypes: true }); }
- catch { return; }
- for (const e of entries) {
- if (
- e.name === 'node_modules' ||
- e.name === '.webjs' ||
- e.name === 'public' ||
- e.name === 'test' ||
- e.name === 'tests' ||
- e.name.startsWith('_') ||
- // Skip ALL dot-prefixed dirs (.opencode, .claude, .github, .husky,
- // .git, .vscode, .idea, .cursor, …). They hold tooling / IDE /
- // agent state that imports packages the browser will never load
- // (e.g. `@opencode-ai/plugin`). The walker visits dirs and files
- // separately; this guard only fires for directory entries because
- // dot-prefixed *files* (e.g. `.env.d.ts` someday) still need the
- // extension check below.
- (e.isDirectory() && e.name.startsWith('.'))
- ) continue;
- const full = join(dir, e.name);
- if (e.isDirectory()) {
- await walk(full, found, skipFiles);
- } else if (skipFiles && skipFiles.has(full)) {
- // Display-only component file: its imports are stripped from the
- // served source, so a vendor specifier reachable ONLY through it
- // never loads in the browser and must not enter the importmap. A
- // specifier also imported by a shipping file still appears via that
- // file's scan, so shared deps are retained.
- continue;
- } else if (/\.(js|ts|mjs|mts)$/.test(e.name) && !isServerOnlyFile(e.name) && !CONFIG_FILE_RE.test(e.name)) {
- try {
- const raw = await readFile(full, 'utf8');
- if (raw.trimStart().startsWith("'use server'") || raw.trimStart().startsWith('"use server"')) continue;
- const src = stripComments(raw);
- // We keep the FULL specifier (with subpath), not just the package
- // name. `import 'dayjs/plugin/utc'` adds `'dayjs/plugin/utc'` to the
- // set, not just `'dayjs'`. vendorImportMapEntries needs the
- // subpath to emit a per-specifier importmap entry; jspm.io
- // resolves each subpath independently via the package's `exports`
- // field. extractPackageName is still applied to filter out
- // relative / absolute / protocol-URL specifiers.
- for (const m of src.matchAll(IMPORT_RE)) {
- if (extractPackageName(m[1])) found.add(m[1]);
- }
- for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
- if (extractPackageName(m[1])) found.add(m[1]);
- }
- } catch { /* unreadable file */ }
- }
- }
-}
-
/**
* Resolve a package's installed directory on disk, handling both direct
* installation and npm workspace hoisting.
diff --git a/packages/server/test/importmap/vendor-preload.test.js b/packages/server/test/importmap/vendor-preload.test.js
index de8fe3ea0..6a27b5378 100644
--- a/packages/server/test/importmap/vendor-preload.test.js
+++ b/packages/server/test/importmap/vendor-preload.test.js
@@ -294,13 +294,28 @@ test('a dropped page\'s SSR-only RELATIVE HELPER vendor is NOT preloaded (#754 r
// Round-2 finding: the walk reached a dropped page's relative helper, whose
// vendor was collected even though the helper never ships. An inert page
// imports `./fmt.js` (a pure SSR helper) which imports dayjs as a binding; the
- // page module is dropped, so neither fmt.js nor dayjs ships. dayjs must NOT be
- // preloaded, EVEN THOUGH it stays in the app-wide importmap (the helper's bare
- // import is scanned regardless of the dropped page), which is what makes this
- // the walk-roots fix and not importmap pruning.
+ // page module is dropped, so neither fmt.js nor dayjs ships, and dayjs must
+ // NOT be preloaded on that route.
+ //
+ // Multi-route for the same reason as the SIBLING-route test above, and since
+ // #1399 for a second one. A sibling `/live` ships dayjs through an interactive
+ // widget, which is what keeps dayjs in the app-wide importmap and therefore
+ // makes the over-fetch possible at all. Single-route, this now asserts nothing:
+ // the vendor scan is rooted in the module graph and does not traverse into a
+ // skipped module, so the subtree behind the dropped page (fmt.js, and dayjs
+ // with it) is pruned from the importmap outright, and a specifier absent from
+ // the importmap can never be preloaded whatever the walk does.
const appDir = makeApp({
pin: { imports: { dayjs: DAYJS_URL }, integrity: { [DAYJS_URL]: DAYJS_INTEGRITY } },
});
+ writeVendorWidget(appDir); // app/widget.js: interactive (@click), imports dayjs
+ mkdirSync(join(appDir, 'app', 'live'), { recursive: true });
+ writeFileSync(
+ join(appDir, 'app', 'live', 'page.js'),
+ `import { html } from ${JSON.stringify(HTML_URL)};\n` +
+ `import '../widget.js';\n` +
+ `export default () => html\` \`;\n`,
+ );
writeFileSync(
join(appDir, 'app', 'fmt.js'),
`import dayjs from 'dayjs';\nexport const fmt = () => typeof dayjs;\n`,
@@ -314,9 +329,9 @@ test('a dropped page\'s SSR-only RELATIVE HELPER vendor is NOT preloaded (#754 r
const app = await createRequestHandler({ appDir, dev: false });
await app.warmup();
const html = await (await app.handle(new Request('http://x/'))).text();
- // Precondition: dayjs stays in the app-wide importmap (the helper's import is
- // scanned), so excluding it from the preload set is the fix, not importmap pruning.
- assert.ok(/"dayjs"/.test(html), 'dayjs is in the app-wide importmap (helper import scanned)');
+ // Precondition: dayjs stays in the app-wide importmap (the sibling /live keeps
+ // it reachable), so excluding it from the preload set is the fix under test.
+ assert.ok(/"dayjs"/.test(html), 'dayjs is in the app-wide importmap (sibling /live ships it)');
// Precondition: the inert page module is dropped from the boot.
assert.ok(!modulepreloadLinks(html).some((l) => l.includes('/app/page.js')), 'the inert page is dropped');
// The fix: the SSR-only helper's vendor is NOT preloaded.
@@ -421,3 +436,41 @@ test('vendorPreloadTargets still excludes core: the hint comes from the head bui
// fingerprinted deploy and core double-fetches.
assert.deepEqual(vendorPreloadTargets(['@webjsdev/core', '@webjsdev/core/lazy-loader']), []);
});
+
+/* ---------------- the served importmap, through the real server ---------------- */
+
+test('a vendor imported only by instrumentation-client survives the pin prune (real runtime path)', async () => {
+ // `instrumentation-client.*` is an app-ROOT convention file, not a router
+ // stem, so it reaches the browser-bound entry set only because
+ // `buildRouteTable` resolves it onto the table. When the dev server alone
+ // attached it, the vendor scan (which builds its own table) lost the entry.
+ //
+ // A pinned app is where that becomes user-visible: `prunePinToReachable`
+ // intersects the committed pin with the runtime's reachable set, and it can
+ // only SHRINK, so an entry the scan cannot see is dropped from the served
+ // map. The boot imports instrumentation-client FIRST, so the browser then
+ // hits an unresolved bare specifier before anything else runs.
+ //
+ // This boots a real handler and reads the importmap off the served HTML, so
+ // it exercises dev.js's own scan closure rather than a hand-rebuilt
+ // stand-in: it fails if any link in the real runtime path stops carrying the
+ // entry, not only if the one line a mirror copies goes missing.
+ const appDir = makeApp({
+ pin: { imports: { dayjs: DAYJS_URL }, integrity: { [DAYJS_URL]: DAYJS_INTEGRITY } },
+ });
+ writeFileSync(
+ join(appDir, 'instrumentation-client.js'),
+ `import dayjs from 'dayjs';\nglobalThis.__booted = typeof dayjs;\n`,
+ );
+ writeFileSync(
+ join(appDir, 'app', 'page.js'),
+ `import { html } from ${JSON.stringify(HTML_URL)};\n` +
+ `export default () => html\`hi \`;\n`,
+ );
+ const app = await createRequestHandler({ appDir, dev: false });
+ await app.warmup();
+ const html = await (await app.handle(new Request('http://x/'))).text();
+ assert.equal(importmapTarget(html, 'dayjs'), DAYJS_URL,
+ 'the pin prune must keep a vendor reachable only from instrumentation-client');
+ await setCoreInstall(CORE_DIR, true);
+});
diff --git a/packages/server/test/module-graph/bare-imports.test.js b/packages/server/test/module-graph/bare-imports.test.js
index 3528d0ba5..f02c901d0 100644
--- a/packages/server/test/module-graph/bare-imports.test.js
+++ b/packages/server/test/module-graph/bare-imports.test.js
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
-import { buildModuleGraph, bareImports } from '../../src/module-graph.js';
+import { buildModuleGraph, bareImports, dynamicBareImports } from '../../src/module-graph.js';
/**
* A bare npm vendor specifier (`dayjs`, `@scope/pkg/sub`) is recorded as a
@@ -131,3 +131,120 @@ test('bare edges survive the parse cache on rebuild', async () => {
const graph2 = await buildModuleGraph(dir);
assert.ok(bareImports(graph2).get(clock)?.has('dayjs'), 'cached rebuild keeps the bare edge');
});
+
+// --- dynamically-imported vendors (#1399) ---
+//
+// `await import('dayjs')` needs an importmap entry (or the import fails to
+// resolve when it runs) but must NOT be preloaded (lazy by author intent), so
+// it is recorded in its own map rather than in `bareImports`, which is what
+// the #754 modulepreload hints read.
+
+test('a dynamically-imported vendor is a dynamic bare edge, not a static one', async () => {
+ const dir = await makeApp({
+ 'components/chart.ts': `export async function load() { return import('chart-pkg'); }`,
+ });
+ const graph = await buildModuleGraph(dir);
+ const chart = join(dir, 'components/chart.ts');
+ assert.ok(dynamicBareImports(graph).get(chart)?.has('chart-pkg'), 'recorded as a dynamic bare edge');
+ assert.ok(!bareImports(graph).get(chart)?.has('chart-pkg'),
+ 'must stay out of the preload source: the author deferred this fetch');
+});
+
+test('a statically AND dynamically imported vendor appears in both maps', async () => {
+ const dir = await makeApp({
+ 'components/both.ts': `import dayjs from 'dayjs';
+ export async function more() { return import('dayjs/plugin/utc'); }
+ export const d = dayjs;`,
+ });
+ const graph = await buildModuleGraph(dir);
+ const both = join(dir, 'components/both.ts');
+ assert.ok(bareImports(graph).get(both)?.has('dayjs'), 'the static one preloads');
+ assert.ok(dynamicBareImports(graph).get(both)?.has('dayjs/plugin/utc'), 'the dynamic one does not');
+ assert.ok(!bareImports(graph).get(both)?.has('dayjs/plugin/utc'));
+});
+
+test('dynamic bare edges survive the parse cache on rebuild', async () => {
+ const dir = await makeApp({
+ 'components/lazy.ts': `export const go = () => import('lazy-pkg');`,
+ });
+ const lazy = join(dir, 'components/lazy.ts');
+ await buildModuleGraph(dir); // warms the parse cache
+ const graph2 = await buildModuleGraph(dir);
+ assert.ok(dynamicBareImports(graph2).get(lazy)?.has('lazy-pkg'), 'cached rebuild keeps the dynamic bare edge');
+});
+
+test('a dynamic import written as example text is not a dynamic bare edge', async () => {
+ const dir = await makeApp({
+ 'components/docs.ts': `export const SAMPLE = \`await import('phantom-pkg')\`;
+ export const real = () => import('real-lazy-pkg');`,
+ });
+ const graph = await buildModuleGraph(dir);
+ const docs = join(dir, 'components/docs.ts');
+ const found = dynamicBareImports(graph).get(docs);
+ assert.ok(found?.has('real-lazy-pkg'), 'the real dynamic import is an edge');
+ assert.ok(!found?.has('phantom-pkg'), 'a template-literal sample is not an edge');
+});
+
+// --- scanner robustness (moved here from the vendor tests in #1399, where the
+// filesystem walk they exercised used to live) ---
+
+test('handles CRLF line endings', async () => {
+ const dir = await makeApp({ 'components/a.ts': "import 'crlf-pkg-a';\r\nimport 'crlf-pkg-b';\r\n" });
+ const graph = await buildModuleGraph(dir);
+ const bare = bareImports(graph).get(join(dir, 'components/a.ts'));
+ assert.ok(bare?.has('crlf-pkg-a'), 'CRLF line should not hide imports');
+ assert.ok(bare?.has('crlf-pkg-b'));
+});
+
+test('handles a UTF-8 BOM at file start', async () => {
+ // UTF-8 BOM is the three bytes 0xEF 0xBB 0xBF.
+ const dir = await makeApp({ 'components/a.ts': '' + "import 'bom-pkg';" });
+ const graph = await buildModuleGraph(dir);
+ assert.ok(bareImports(graph).get(join(dir, 'components/a.ts'))?.has('bom-pkg'),
+ 'BOM at file start should not hide the first import');
+});
+
+test('does not crash on an unterminated string literal', async () => {
+ // User mid-edit: a half-written file. The scan is regex-based over a
+ // redaction mask, so it tolerates this; Node would still fail to LOAD the
+ // file, but the scan stays correct for every OTHER file in the project.
+ const dir = await makeApp({
+ 'components/broken.ts': "import 'unterminated\n// some other code",
+ 'components/ok.ts': "import 'still-found';",
+ });
+ const graph = await buildModuleGraph(dir);
+ // We do not assert what the broken file yields, only that it did not stop
+ // the walk.
+ assert.ok(bareImports(graph).get(join(dir, 'components/ok.ts'))?.has('still-found'),
+ 'a broken file must not stop the scan');
+});
+
+test('handles a multi-MB file without exploding memory or time', async () => {
+ const padding = Array(50000).fill('// boring line\n').join('');
+ const dir = await makeApp({ 'components/big.ts': padding + "import 'buried-import';\n" + padding });
+ const t0 = Date.now();
+ const graph = await buildModuleGraph(dir);
+ const elapsed = Date.now() - t0;
+ assert.ok(bareImports(graph).get(join(dir, 'components/big.ts'))?.has('buried-import'),
+ 'an import buried in a large file must be found');
+ assert.ok(elapsed < 5000, `scan should complete in under 5s; took ${elapsed}ms`);
+});
+
+test('an import string inside a comment is not a bare edge (JSDoc examples etc.)', async () => {
+ const dir = await makeApp({
+ 'components/a.ts': `
+ /**
+ * Example usage:
+ * import { clsx } from 'clsx';
+ */
+ // import 'commented-out-pkg';
+ import real from 'real-only-pkg';
+ export const r = real;
+ `,
+ });
+ const graph = await buildModuleGraph(dir);
+ const bare = bareImports(graph).get(join(dir, 'components/a.ts'));
+ assert.ok(bare?.has('real-only-pkg'));
+ assert.ok(!bare?.has('clsx'), 'a JSDoc-comment import must be skipped');
+ assert.ok(!bare?.has('commented-out-pkg'), 'a line-comment import must be skipped');
+});
diff --git a/packages/server/test/vendor/scan-parity.test.js b/packages/server/test/vendor/scan-parity.test.js
new file mode 100644
index 000000000..cbdb8959e
--- /dev/null
+++ b/packages/server/test/vendor/scan-parity.test.js
@@ -0,0 +1,114 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdir, writeFile, rm, readFile } from 'node:fs/promises';
+import { join, dirname } from 'node:path';
+import { tmpdir } from 'node:os';
+
+import { scanBareImports, reachedBareImports, prunePinToReachable } from '../../src/vendor.js';
+import { buildModuleGraph } from '../../src/module-graph.js';
+import { browserEntryFiles } from '../../src/browser-entries.js';
+import { scanComponents } from '../../src/component-scanner.js';
+import { buildRouteTable } from '../../src/router.js';
+import { analyzeElision } from '../../src/component-elision.js';
+
+/**
+ * The pin/runtime parity invariant (#197, #446), asserted directly rather than
+ * left implicit inside a scan test.
+ *
+ * A pinned app and an unpinned app must serve the SAME importmap. That holds
+ * not because the two sides compute the same set, but because the runtime
+ * INTERSECTS a committed pin down to its own reachable set via
+ * `prunePinToReachable`. Which only works while the pin is a SUPERSET of the
+ * runtime set, so the two sides are deliberately asymmetric (#1399):
+ *
+ * - pin side (`scanBareImports`, used by `pinAll` / `webjs doctor`): rooted
+ * at the browser-bound entries with NO elision analysis. Cheaper, and a
+ * superset by construction.
+ * - runtime side (`reachedBareImports` with the dev server's skip set):
+ * the same roots, additionally pruned of elided / inert / import-only
+ * modules and the subtrees behind them.
+ *
+ * This file is what fails if a future change narrows the pin path without
+ * narrowing the runtime path.
+ */
+
+async function makeApp(files) {
+ const dir = join(tmpdir(), `webjs-test-scan-parity-${Date.now()}`);
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'parity-fixture', private: true }));
+ for (const [rel, contents] of Object.entries(files)) {
+ const abs = join(dir, rel);
+ await mkdir(dirname(abs), { recursive: true });
+ await writeFile(abs, contents);
+ }
+ return dir;
+}
+
+/** The three analyses `dev.js` runs, plus the skip set it derives from them. */
+async function runtimeScan(dir) {
+ const graph = await buildModuleGraph(dir);
+ const components = await scanComponents(dir);
+ const routeTable = await buildRouteTable(dir);
+ const routeModules = new Set();
+ for (const page of routeTable.pages || []) {
+ if (page.file) routeModules.add(page.file);
+ for (const f of page.layouts || []) routeModules.add(f);
+ }
+ const r = await analyzeElision(components, [...routeModules], graph, (f) => readFile(f, 'utf8'), dir);
+ const skip = new Set([...r.elidableComponents, ...r.inertRouteModules, ...r.importOnlyRouteModules.keys()]);
+ return reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], dir, skip);
+}
+
+test('the runtime vendor set is a subset of the pin set, and a build script is in neither', async () => {
+ const dir = await makeApp({
+ 'app/page.ts': `import '../components/counter.ts';
+ import '../components/badge.ts';
+ export default function Page() { return null; }`,
+ // Shipping: an @click is an interactivity signal, so it is never elided.
+ 'components/counter.ts': `import { WebComponent, html, prop } from '@webjsdev/core';
+ import { z } from 'zod';
+ class Counter extends WebComponent({ count: prop(Number) }) {
+ render() { return html\`\`; }
+ }
+ Counter.register('x-counter');`,
+ // Display-only: no interactivity signal, so elision drops it and dayjs
+ // with it (the #170 property).
+ 'components/badge.ts': `import { WebComponent, html } from '@webjsdev/core';
+ import dayjs from 'dayjs';
+ class Badge extends WebComponent({}) {
+ render() { return html\`\${dayjs().format()}\`; }
+ }
+ Badge.register('x-badge');`,
+ // Unreachable from any browser entry: the #1399 repro.
+ 'scripts/build.mjs': `import { chromium } from 'playwright';\nexport const c = chromium;`,
+ // A browser entry `buildRouteTable` does NOT discover: dev.js attaches it
+ // separately, so the pin path has to as well. Its vendor is the shape that
+ // breaks the subset relation if the pin path misses the entry, and a pinned
+ // app would then serve no importmap entry for the first module its boot
+ // imports.
+ 'instrumentation-client.ts': `import * as a from 'analytics-sdk';\na.init();`,
+ });
+
+ const pin = await scanBareImports(dir);
+ const runtime = await runtimeScan(dir);
+
+ assert.ok([...runtime].every((s) => pin.has(s)),
+ `runtime must be a subset of pin; runtime=${JSON.stringify([...runtime])} pin=${JSON.stringify([...pin])}`);
+ assert.ok(pin.has('zod') && runtime.has('zod'), 'a shipping vendor is in both');
+ assert.ok(pin.has('analytics-sdk') && runtime.has('analytics-sdk'),
+ 'instrumentation-client is a browser entry on BOTH sides');
+ assert.ok(pin.has('dayjs'), 'the pin superset keeps an elided-only vendor');
+ assert.ok(!runtime.has('dayjs'), 'the runtime set drops an elided-only vendor');
+ assert.ok(!pin.has('playwright') && !runtime.has('playwright'),
+ 'an unreachable build script is in neither set');
+
+ // The served-map parity statement, in the terms #197 wrote it: a committed
+ // pin, pruned to the runtime's reachable set, serves what an unpinned app
+ // serves.
+ const pinAsImportMap = Object.fromEntries([...pin].map((s) => [s, `https://cdn.example.com/${s}.js`]));
+ const { imports } = prunePinToReachable(pinAsImportMap, {}, runtime);
+ assert.ok(!('dayjs' in imports), 'a pinned app serves no dayjs entry either');
+ assert.ok('zod' in imports, 'the shipping vendor survives the prune');
+
+ await rm(dir, { recursive: true, force: true });
+});
diff --git a/packages/server/test/vendor/vendor.test.js b/packages/server/test/vendor/vendor.test.js
index 6a1318c80..e0e9893e6 100644
--- a/packages/server/test/vendor/vendor.test.js
+++ b/packages/server/test/vendor/vendor.test.js
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
import {
extractPackageName,
scanBareImports,
+ reachedBareImports,
vendorImportMapEntries,
getPackageVersion,
jspmGenerate,
@@ -23,6 +24,10 @@ import {
findOutdated,
updatePinned,
} from '../../src/vendor.js';
+import { buildModuleGraph } from '../../src/module-graph.js';
+import { browserEntryFiles } from '../../src/browser-entries.js';
+import { scanComponents } from '../../src/component-scanner.js';
+import { buildRouteTable } from '../../src/router.js';
import { withJspmDouble } from '../../../../test/fixtures/jspm-double.mjs';
// --- extractPackageName ---
@@ -70,107 +75,72 @@ test('extractPackageName: lone @scope with no package name returns null', () =>
});
// --- scanBareImports ---
-
-test('scanBareImports: finds bare specifiers in source files', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-${Date.now()}`);
+//
+// The scan is rooted in the module graph (#1399), so every fixture below is a
+// real minimal app: a `package.json`, an `app/page.ts` the route table finds,
+// and whatever the page reaches from there. A loose file in a bare temp dir is
+// unreachable by construction and contributes nothing, which is the point.
+
+/**
+ * Write a minimal app under a fresh temp dir. Keys are app-relative paths.
+ * A `package.json` is supplied unless the fixture writes its own.
+ */
+async function makeVendorApp(slug, files) {
+ const dir = join(tmpdir(), `webjs-test-vendor-${slug}-${Date.now()}`);
await mkdir(dir, { recursive: true });
+ if (!files['package.json']) {
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'scan-fixture', private: true }));
+ }
+ for (const [rel, contents] of Object.entries(files)) {
+ const abs = join(dir, rel);
+ await mkdir(dirname(abs), { recursive: true });
+ await writeFile(abs, contents);
+ }
+ return dir;
+}
- await writeFile(join(dir, 'a.js'), `
- import dayjs from 'dayjs';
- import { something } from '@scope/lib';
- import './local.js';
- `);
- await writeFile(join(dir, 'b.ts'), `
- import { z } from 'zod';
- const d = await import('dynamic-pkg');
- `);
- await writeFile(join(dir, 'c.server.ts'), `
- import pg from 'pg';
- `);
+test('scanBareImports: finds bare specifiers in source files', async () => {
+ const dir = await makeVendorApp('basic', {
+ 'app/page.ts': `
+ import dayjs from 'dayjs';
+ import { something } from '@scope/lib';
+ import './local.js';
+ import '../b.ts';
+ import { core } from '@webjsdev/core';
+ export default function Page() { return [dayjs, something, core]; }
+ `,
+ 'app/local.js': `export const local = 1;`,
+ 'b.ts': `
+ import { z } from 'zod';
+ const d = await import('dynamic-pkg');
+ export const b = [z, d];
+ `,
+ 'c.server.ts': `import pg from 'pg';\nexport const c = pg;`,
+ });
const found = await scanBareImports(dir);
assert.ok(found.has('dayjs'));
assert.ok(found.has('@scope/lib'));
- assert.ok(found.has('zod'));
- assert.ok(found.has('dynamic-pkg'));
+ assert.ok(found.has('zod'), 'a specifier one hop from the page is reached');
+ assert.ok(found.has('dynamic-pkg'), 'a dynamic-import specifier is reached');
assert.ok(!found.has('pg'), 'server-only imports should be skipped');
assert.ok(!found.has('./local.js'), 'relative imports should be excluded');
- assert.ok(!found.has('@webjsdev/core'));
-
- await rm(dir, { recursive: true, force: true });
-});
-
-test('scanBareImports: skipFiles excludes specifiers reachable only via elided components', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-skip-${Date.now()}`);
- await mkdir(dir, { recursive: true });
-
- // badge (elided) imports dayjs; shared is imported by both badge and a
- // shipping file; counter (shipping) imports zod.
- await writeFile(join(dir, 'badge.ts'), `import dayjs from 'dayjs';\nimport sh from 'shared-pkg';`);
- await writeFile(join(dir, 'counter.ts'), `import { z } from 'zod';\nimport sh from 'shared-pkg';`);
-
- const skip = new Set([join(dir, 'badge.ts')]);
- const found = await scanBareImports(dir, skip);
-
- assert.ok(!found.has('dayjs'), 'dayjs is only in the elided component, should drop');
- assert.ok(found.has('zod'), 'zod is in a shipping component, should stay');
- assert.ok(found.has('shared-pkg'), 'a dep shared with a shipping file is retained');
-
- await rm(dir, { recursive: true, force: true });
-});
-
-test('scanBareImports: skips route.ts and middleware.ts (file-router server-only convention)', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-router-skip-${Date.now()}`);
- await mkdir(join(dir, 'app', 'api', 'posts'), { recursive: true });
- await mkdir(join(dir, 'app', 'dashboard'), { recursive: true });
-
- await writeFile(
- join(dir, 'app', 'api', 'posts', 'route.ts'),
- `import Database from 'pg';
- import 'server-only-helper';`,
- );
-
- await writeFile(
- join(dir, 'app', 'dashboard', 'middleware.ts'),
- `import { WebSocketServer } from 'ws';
- import 'another-server-thing';`,
- );
-
- await writeFile(
- join(dir, 'middleware.ts'),
- `import 'root-mw-server-only';`,
- );
-
- await writeFile(
- join(dir, 'app', 'dashboard', 'page.ts'),
- `import dayjs from 'dayjs';`,
- );
-
- const found = await scanBareImports(dir);
-
- assert.ok(found.has('dayjs'), 'page.ts imports should be scanned');
- assert.ok(!found.has('pg'), 'route.ts imports must be skipped');
- assert.ok(!found.has('server-only-helper'), 'route.ts imports must be skipped');
- assert.ok(!found.has('ws'), 'middleware.ts imports must be skipped');
- assert.ok(!found.has('another-server-thing'), 'middleware.ts imports must be skipped');
- assert.ok(!found.has('root-mw-server-only'), 'root middleware.ts imports must be skipped');
+ assert.ok(!found.has('@webjsdev/core'), 'core is served locally, never vendored');
await rm(dir, { recursive: true, force: true });
});
test('scanBareImports: skips server-only @webjsdev pkgs (#713)', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-server-only-${Date.now()}`);
- await mkdir(join(dir, 'app'), { recursive: true });
- // A page that legitimately imports a server-only framework pkg name directly
- // (defensive: even if surfaced, it must not reach the jspm path).
- await writeFile(
- join(dir, 'app', 'page.ts'),
- `import dayjs from 'dayjs';
+ const dir = await makeVendorApp('server-only', {
+ // A page that legitimately imports a server-only framework pkg name directly
+ // (defensive: even if surfaced, it must not reach the jspm path).
+ 'app/page.ts': `import dayjs from 'dayjs';
import '@webjsdev/cli/bin/webjs.js';
import '@webjsdev/server/some';
- import '@webjsdev/mcp';`,
- );
+ import '@webjsdev/mcp';
+ export default function Page() { return dayjs; }`,
+ });
const found = await scanBareImports(dir);
@@ -182,32 +152,15 @@ test('scanBareImports: skips server-only @webjsdev pkgs (#713)', async () => {
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: skips test/ and tests/ directories', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-test-skip-${Date.now()}`);
- await mkdir(join(dir, 'test'), { recursive: true });
- await mkdir(join(dir, 'tests'), { recursive: true });
-
- await writeFile(join(dir, 'test', 'a.test.ts'), `import 'test-only-pkg';`);
- await writeFile(join(dir, 'tests', 'b.test.ts'), `import 'another-test-pkg';`);
- await writeFile(join(dir, 'app.ts'), `import 'real-dep';`);
-
- const found = await scanBareImports(dir);
- assert.ok(found.has('real-dep'));
- assert.ok(!found.has('test-only-pkg'));
- assert.ok(!found.has('another-test-pkg'));
-
- await rm(dir, { recursive: true, force: true });
-});
-
test('scanBareImports: skips import type statements (TS erases them)', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-typeimport-skip-${Date.now()}`);
- await mkdir(dir, { recursive: true });
-
- await writeFile(join(dir, 'a.ts'), `
- import type { WebSocket } from 'ws';
- import type { Database as DB } from 'pg';
- import dayjs from 'dayjs';
- `);
+ const dir = await makeVendorApp('typeimport-skip', {
+ 'app/page.ts': `
+ import type { WebSocket } from 'ws';
+ import type { Database as DB } from 'pg';
+ import dayjs from 'dayjs';
+ export default function Page() { return dayjs; }
+ `,
+ });
const found = await scanBareImports(dir);
assert.ok(found.has('dayjs'), 'real value imports remain');
@@ -218,16 +171,16 @@ test('scanBareImports: skips import type statements (TS erases them)', async ()
});
test('scanBareImports: preserves full specifiers including subpaths', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-subpath-${Date.now()}`);
- await mkdir(dir, { recursive: true });
-
- await writeFile(join(dir, 'a.ts'), `
- import dayjs from 'dayjs';
- import utc from 'dayjs/plugin/utc';
- import timezone from 'dayjs/plugin/timezone';
- import { Turbo } from '@hotwired/turbo';
- import frame from '@hotwired/turbo/elements/turbo-frame';
- `);
+ const dir = await makeVendorApp('subpath', {
+ 'app/page.ts': `
+ import dayjs from 'dayjs';
+ import utc from 'dayjs/plugin/utc';
+ import timezone from 'dayjs/plugin/timezone';
+ import { Turbo } from '@hotwired/turbo';
+ import frame from '@hotwired/turbo/elements/turbo-frame';
+ export default function Page() { return [dayjs, utc, timezone, Turbo, frame]; }
+ `,
+ });
const found = await scanBareImports(dir);
assert.ok(found.has('dayjs'), 'root dayjs import preserved');
@@ -239,149 +192,111 @@ test('scanBareImports: preserves full specifiers including subpaths', async () =
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: skips import strings inside comments (JSDoc examples etc.)', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-comments-skip-${Date.now()}`);
- await mkdir(dir, { recursive: true });
-
- await writeFile(join(dir, 'a.ts'), `
- /**
- * Example usage:
- * import { clsx } from 'clsx';
- * import { twMerge } from 'tailwind-merge';
- */
- // import 'commented-out-pkg';
- import real from 'real-only-pkg';
- `);
+// Replaces the four name-list tests this block used to carry (route/middleware,
+// test//tests/, dot-dirs, *.config.*). Their subject was an exclusion LIST that
+// no longer exists: reachability decides, so the filename is irrelevant and an
+// unreferenced module drops out even when no name rule would ever have caught it.
+test('scanBareImports: an unreachable file contributes nothing, whatever it is called', async () => {
+ const dir = await makeVendorApp('unreachable', {
+ 'app/page.ts': `import '../components/widget.ts';\nexport default function Page() { return null; }`,
+ 'components/widget.ts': `import real from 'real-dep';\nexport const w = real;`,
+ 'scripts/generate-og.mjs': `import { chromium } from 'playwright';\nexport const c = chromium;`,
+ 'web-test-runner.config.js': `import { x } from 'tooling-pkg';\nexport const t = x;`,
+ 'test/helper.ts': `import 'test-only-pkg';`,
+ '.github/tool.js': `import 'dot-dir-pkg';`,
+ 'app/api/x/route.ts': `import pg from 'pg';\nexport const GET = () => pg;`,
+ 'middleware.ts': `import 'root-mw-pkg';\nexport default async (req, next) => next();`,
+ // A plain unreferenced module at the app root. No name-based exclusion
+ // would ever have caught this one; only reachability does.
+ 'orphan.ts': `import 'orphan-pkg';`,
+ });
const found = await scanBareImports(dir);
- assert.ok(found.has('real-only-pkg'));
- assert.ok(!found.has('clsx'), 'JSDoc-comment imports must be skipped');
- assert.ok(!found.has('tailwind-merge'), 'JSDoc-comment imports must be skipped');
- assert.ok(!found.has('commented-out-pkg'), 'line-comment imports must be skipped');
+ assert.ok(found.has('real-dep'), 'a specifier a page reaches is found');
+ for (const spec of ['playwright', 'tooling-pkg', 'test-only-pkg', 'dot-dir-pkg', 'pg', 'root-mw-pkg', 'orphan-pkg']) {
+ assert.ok(!found.has(spec), `${spec} is unreachable from any browser entry and must not be vendored`);
+ }
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: skips node_modules and _private dirs', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-skip-${Date.now()}`);
- await mkdir(join(dir, 'node_modules'), { recursive: true });
- await mkdir(join(dir, '_private'), { recursive: true });
-
- await writeFile(join(dir, 'node_modules', 'x.js'), `import 'should-not-find';`);
- await writeFile(join(dir, '_private', 'y.js'), `import 'also-hidden';`);
- await writeFile(join(dir, 'app.js'), `import 'visible';`);
+test('scanBareImports: a bare specifier imported only from scripts/ never reaches the result', async () => {
+ // The literal #1399 repro: website/scripts/generate-og.mjs imports playwright,
+ // which jspm 401s on every cold analysis.
+ const dir = await makeVendorApp('scripts-dir', {
+ 'app/page.ts': `import dayjs from 'dayjs';\nexport default function Page() { return dayjs; }`,
+ 'scripts/generate-og.mjs': `import { chromium } from 'playwright';\nexport const c = chromium;`,
+ });
const found = await scanBareImports(dir);
- assert.ok(found.has('visible'));
- assert.ok(!found.has('should-not-find'));
- assert.ok(!found.has('also-hidden'));
+ assert.ok(found.has('dayjs'), 'the page vendor is still found');
+ assert.ok(!found.has('playwright'), 'a build script is not a browser entry');
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: handles CRLF line endings', async () => {
- const dir = join(tmpdir(), `webjs-scan-crlf-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- await writeFile(join(dir, 'a.ts'), "import 'crlf-pkg-a';\r\nimport 'crlf-pkg-b';\r\n");
- const found = await scanBareImports(dir);
- assert.ok(found.has('crlf-pkg-a'), 'CRLF line should not hide imports');
- assert.ok(found.has('crlf-pkg-b'));
- await rm(dir, { recursive: true, force: true });
-});
-
-test('scanBareImports: handles UTF-8 BOM at file start', async () => {
- const dir = join(tmpdir(), `webjs-scan-bom-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- // UTF-8 BOM is the three bytes 0xEF 0xBB 0xBF
- await writeFile(join(dir, 'a.ts'), Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from("import 'bom-pkg';")]));
- const found = await scanBareImports(dir);
- assert.ok(found.has('bom-pkg'), 'BOM at file start should not hide the first import');
- await rm(dir, { recursive: true, force: true });
-});
-
-test('scanBareImports: does not crash on unterminated string literal', async () => {
- const dir = join(tmpdir(), `webjs-scan-broken-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- // User mid-edit: half-written file with unterminated string. Scanner
- // is regex-based so it tolerates this gracefully; the Node runtime
- // would still fail to load the file but the scan stays correct for
- // all OTHER files in the project.
- await writeFile(join(dir, 'broken.ts'), "import 'unterminated\n// some other code");
- await writeFile(join(dir, 'ok.ts'), "import 'still-found';");
- const found = await scanBareImports(dir);
- // The unterminated literal in broken.ts is consumed by the IMPORT_RE
- // (which matches `[^'"]+`); it greedily eats to the next quote in
- // the file. We don't assert what it extracts. We assert the scanner
- // did not crash and still found the OK file's import.
- assert.ok(found.has('still-found'), 'a broken file must not stop the scan');
- await rm(dir, { recursive: true, force: true });
-});
+test('scanBareImports: an import written inside a template literal is not a vendor specifier', async () => {
+ // website/lib/samples.ts holds docs code samples in exported template
+ // literals. The old comment-stripping scanner could not tell them from real
+ // imports, so drizzle-orm was sent to jspm on every cold analysis.
+ const dir = await makeVendorApp('template-sample', {
+ 'app/page.ts': `
+ import { z } from 'zod';
+ export const ACTION_SAMPLE = \`
+ 'use server';
+ import { eq } from 'drizzle-orm';
+ export async function x() {}
+ \`;
+ export default function Page() { return [z, ACTION_SAMPLE]; }
+ `,
+ });
-test('scanBareImports: handles deeply nested directories', async () => {
- const dir = join(tmpdir(), `webjs-scan-deep-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- let path = dir;
- for (let i = 0; i < 25; i++) {
- path = join(path, `level${i}`);
- await mkdir(path, { recursive: true });
- }
- await writeFile(join(path, 'deep.ts'), "import 'deep-pkg';");
const found = await scanBareImports(dir);
- assert.ok(found.has('deep-pkg'), 'imports at 25 levels deep must be found');
- await rm(dir, { recursive: true, force: true });
-});
+ assert.ok(found.has('zod'), 'the real import is found');
+ assert.ok(!found.has('drizzle-orm'), 'an import written as example text is not an edge');
-test('scanBareImports: handles a multi-MB file without exploding memory or time', async () => {
- const dir = join(tmpdir(), `webjs-scan-large-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- const padding = Array(50000).fill('// boring line\n').join('');
- // ~5MB file: padding + one buried import + more padding
- await writeFile(join(dir, 'big.ts'), padding + "import 'buried-import';\n" + padding);
- const t0 = Date.now();
- const found = await scanBareImports(dir);
- const elapsed = Date.now() - t0;
- assert.ok(found.has('buried-import'), 'import buried in a large file must be found');
- assert.ok(elapsed < 5000, `scan should complete in under 5s; took ${elapsed}ms`);
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: skips dot-prefixed dirs (.opencode, .claude, .github, .husky, .git)', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-dotdirs-${Date.now()}`);
- // Each dot-dir holds a file with a bare import that would break jspm.io.
- for (const name of ['.opencode', '.claude', '.github', '.husky', '.git', '.vscode']) {
- await mkdir(join(dir, name), { recursive: true });
- await writeFile(join(dir, name, 'a.ts'), `import { foo } from "${name}-only-pkg";`);
- }
- await writeFile(join(dir, 'app.ts'), `import 'visible';`);
+test('scanBareImports: a specifier reachable only through a dynamic import is retained', async () => {
+ // The importmap must cover a lazily-imported module's specifier even though
+ // the preload set (transitiveDeps) deliberately ignores dynamic edges: the
+ // import has to resolve when it finally runs.
+ const dir = await makeVendorApp('dynamic-only', {
+ 'app/page.ts': `export default async function Page() { const m = await import('../chart.ts'); return m; }`,
+ 'chart.ts': `import c from 'chart-pkg';\nexport default c;`,
+ });
const found = await scanBareImports(dir);
- assert.ok(found.has('visible'));
- for (const name of ['.opencode', '.claude', '.github', '.husky', '.git', '.vscode']) {
- assert.ok(!found.has(`${name}-only-pkg`), `expected ${name} to be excluded`);
- }
+ assert.ok(found.has('chart-pkg'), 'a dynamically-reached vendor still needs an importmap entry');
await rm(dir, { recursive: true, force: true });
});
-test('scanBareImports: skips *.config.{js,ts,mjs,cjs} files at any depth', async () => {
- const dir = join(tmpdir(), `webjs-test-vendor-configs-${Date.now()}`);
- await mkdir(dir, { recursive: true });
- // Common tooling config files at project root. They import test
- // runners / build helpers that legitimately cannot resolve via jspm.io.
- await writeFile(join(dir, 'web-test-runner.config.js'), `import { x } from 'tooling-pkg-1';`);
- await writeFile(join(dir, 'vitest.config.ts'), `import { y } from 'tooling-pkg-2';`);
- await writeFile(join(dir, 'tailwind.config.mjs'), `import { z } from 'tooling-pkg-3';`);
- await writeFile(join(dir, 'app.ts'), `import 'real-pkg';`);
+test('reachedBareImports: the elision skip set prunes a specifier and the subtree behind it', async () => {
+ // badge (elided) imports dayjs plus a relative ./fmt.ts that imports fmt-pkg;
+ // counter (shipping) imports zod and the same shared-pkg as badge.
+ const dir = await makeVendorApp('skip', {
+ 'app/page.ts': `import '../components/badge.ts';\nimport '../components/counter.ts';\nexport default function Page() { return null; }`,
+ 'components/badge.ts': `import dayjs from 'dayjs';\nimport sh from 'shared-pkg';\nimport { f } from './fmt.ts';\nexport const b = [dayjs, sh, f];`,
+ 'components/fmt.ts': `import fmt from 'fmt-pkg';\nexport const f = fmt;`,
+ 'components/counter.ts': `import { z } from 'zod';\nimport sh from 'shared-pkg';\nexport const c = [z, sh];`,
+ });
- const found = await scanBareImports(dir);
- assert.ok(found.has('real-pkg'));
- assert.ok(!found.has('tooling-pkg-1'));
- assert.ok(!found.has('tooling-pkg-2'));
- assert.ok(!found.has('tooling-pkg-3'));
+ const graph = await buildModuleGraph(dir);
+ const components = await scanComponents(dir);
+ const routeTable = await buildRouteTable(dir);
+ const entries = [...browserEntryFiles(routeTable, components)];
+ const skip = new Set([join(dir, 'components', 'badge.ts')]);
+ const found = reachedBareImports(graph, entries, dir, skip);
+
+ assert.ok(!found.has('dayjs'), 'dayjs is only in the elided component, should drop');
+ assert.ok(!found.has('fmt-pkg'), 'the subtree behind an elided component drops with it');
+ assert.ok(found.has('zod'), 'zod is in a shipping component, should stay');
+ assert.ok(found.has('shared-pkg'), 'a dep shared with a shipping file is retained');
await rm(dir, { recursive: true, force: true });
});
-
// --- getPackageVersion ---
test('getPackageVersion: returns installed version for a known package', () => {
@@ -967,6 +882,36 @@ test('pinAll default: writes importmap.json with jspm.io URLs', async () => {
}
});
+test('pinAll: a vendor imported only by instrumentation-client is pinned (real pin path)', async () => {
+ // `instrumentation-client.*` is an app-ROOT convention file, not a router
+ // stem, so it reaches the entry set only because `buildRouteTable` resolves
+ // it onto the table. It used to be attached by the dev server ALONE, so the
+ // vendor scan, which builds its own table, lost the entry: the specifier
+ // resolved live on an unpinned app and was pinned on none. Since
+ // `prunePinToReachable` can only SHRINK a pin, the pinned app then served an
+ // importmap with no entry for the first module its boot imports.
+ //
+ // This drives `pinAll` itself rather than a hand-rebuilt stand-in, so it
+ // fails if ANY link in the real pin path stops carrying the entry, not only
+ // if the one line a mirror happens to copy goes missing.
+ const dir = await makeTempAppWithSource({
+ 'app/page.ts': `export default function Page() { return null; }`,
+ 'instrumentation-client.ts': `import pico from 'picocolors';\npico.red('boot');`,
+ });
+ try {
+ await withJspmDouble({}, async () => {
+ const result = await pinAll(dir);
+ assert.ok(!result.failed, 'pin should not be flagged failed');
+ const file = await readPinFile(dir);
+ assert.ok(file, 'pin file should exist');
+ assert.match(file.imports['picocolors'], /^https:\/\/ga\.jspm\.io\/npm:picocolors@/,
+ 'a vendor reachable ONLY from instrumentation-client must be pinned');
+ });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
+
test('pinAll: a flattened transitive is pinned with a derivable bundle name (#446)', async () => {
// The unified resolve hoists a transitive to top level, and `pinAll` has to
// pin it even though it was never a directly scanned install: without it a
diff --git a/test/bun/vendor-scan.mjs b/test/bun/vendor-scan.mjs
new file mode 100644
index 000000000..58d49f8ca
--- /dev/null
+++ b/test/bun/vendor-scan.mjs
@@ -0,0 +1,114 @@
+/**
+ * Cross-runtime proof that the vendor specifier scan (#1399) is IDENTICAL
+ * under whichever runtime runs it:
+ *
+ * node test/bun/vendor-scan.mjs
+ * bun test/bun/vendor-scan.mjs
+ *
+ * WebJs runs on Node 24+ AND Bun (#508), and an app scaffolded with `--runtime
+ * bun` runs its whole analysis under Bun, so a vendor set that differed
+ * between runtimes would serve a Bun author a different importmap from the
+ * same source. The scan is `readdir` plus regular expressions over a redaction
+ * mask with no runtime-specific API, so there is nothing legitimate to skip
+ * and this file carries no DENYLIST entry.
+ *
+ * Both entry points are asserted, because they answer deliberately different
+ * questions and the relation between them is load-bearing: `scanBareImports`
+ * is the out-of-process pin-side superset (no elision pruning) and
+ * `reachedBareImports` is the dev server's in-process runtime set (pruned).
+ * `prunePinToReachable` intersects the first down to the second, so a runtime
+ * set that stopped being a subset would break pinned/unpinned parity (#197).
+ *
+ * Imports are RELATIVE on purpose: a bare `@webjsdev/server` specifier
+ * resolves through the primary checkout's node_modules in a linked worktree,
+ * which would test a different copy of the source than the branch. Run from
+ * the repo root so `@webjsdev/core` resolves for the fixture's components.
+ */
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+import { scanBareImports, reachedBareImports } from '../../packages/server/src/vendor.js';
+import { buildModuleGraph } from '../../packages/server/src/module-graph.js';
+import { browserEntryFiles } from '../../packages/server/src/browser-entries.js';
+import { scanComponents } from '../../packages/server/src/component-scanner.js';
+import { buildRouteTable } from '../../packages/server/src/router.js';
+import { analyzeElision } from '../../packages/server/src/component-elision.js';
+
+const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
+const dir = mkdtempSync(join(tmpdir(), 'webjs-vendor-scan-x-'));
+
+const write = (rel, src) => {
+ const abs = join(dir, rel);
+ mkdirSync(join(abs, '..'), { recursive: true });
+ writeFileSync(abs, src);
+};
+
+try {
+ write('package.json', JSON.stringify({ name: 'vendor-scan-fixture', type: 'module' }));
+
+ // Interactive: an @event binding ships it, so its vendor is in BOTH sets.
+ write('components/counter.js', `
+import { WebComponent, html } from '@webjsdev/core';
+import { z } from 'zod';
+export class Counter extends WebComponent {
+ render() { return html\`\`; }
+}
+Counter.register('my-counter');
+`);
+ // Display-only: elided, so its vendor is in the pin superset only (#170).
+ write('components/badge.js', `
+import { WebComponent, html } from '@webjsdev/core';
+import dayjs from 'dayjs';
+export class Badge extends WebComponent {
+ render() { return html\`\${dayjs().format()}\`; }
+}
+Badge.register('my-badge');
+`);
+ // Server-only: reached (the browser fetches a stub) but its source never
+ // ships, so its driver import must not be vendored.
+ write('lib/db.server.js', "import pg from 'pg';\nexport const db = pg;");
+ // Unreachable from any browser entry: the literal #1399 repro.
+ write('scripts/generate-og.mjs', "import { chromium } from 'playwright';\nexport const c = chromium;");
+ // A browser entry `buildRouteTable` does not discover (dev.js attaches it
+ // separately), so both sides have to attach it or the subset relation breaks.
+ write('instrumentation-client.js', "import * as a from 'analytics-sdk';\na.init();");
+ // A docs code sample in a template literal. The old comment-only scanner
+ // could not tell it from a real import.
+ write('app/page.js', `
+import { html } from '@webjsdev/core';
+import '../components/counter.js';
+import '../components/badge.js';
+import '../lib/db.server.js';
+export const SAMPLE = \`import { eq } from 'drizzle-orm';\`;
+export default () => html\` \`;
+`);
+
+ const pin = [...await scanBareImports(dir)].sort();
+
+ const graph = await buildModuleGraph(dir);
+ const components = await scanComponents(dir);
+ const routeTable = await buildRouteTable(dir);
+ const routeModules = new Set();
+ for (const page of routeTable.pages || []) {
+ if (page.file) routeModules.add(page.file);
+ for (const f of page.layouts || []) routeModules.add(f);
+ }
+ const r = await analyzeElision(components, [...routeModules], graph, async (f) => readFileSync(f, 'utf8'), dir);
+ const skip = new Set([...r.elidableComponents, ...r.inertRouteModules, ...r.importOnlyRouteModules.keys()]);
+ const rt = [...reachedBareImports(graph, [...browserEntryFiles(routeTable, components)], dir, skip)].sort();
+
+ assert.deepEqual(pin, ['analytics-sdk', 'dayjs', 'zod'],
+ 'pin side: reachable vendors with no elision pruning, so the elided badge keeps dayjs and '
+ + 'instrumentation-client keeps analytics-sdk; pg is behind a server boundary and '
+ + 'playwright / drizzle-orm were never edges');
+ assert.deepEqual(rt, ['analytics-sdk', 'zod'],
+ 'runtime side: the same roots, pruned of the elided component and the subtree behind it');
+ assert.ok(rt.every((s) => pin.includes(s)),
+ 'the runtime set must stay a subset of the pin set (#197 relies on it)');
+
+ console.log(`OK vendor scan is identical on ${runtime}: pin=${JSON.stringify(pin)} runtime=${JSON.stringify(rt)}`);
+} finally {
+ rmSync(dir, { recursive: true, force: true });
+}
diff --git a/test/bun/vendor-scan.test.mjs b/test/bun/vendor-scan.test.mjs
new file mode 100644
index 000000000..2f5e22d82
--- /dev/null
+++ b/test/bun/vendor-scan.test.mjs
@@ -0,0 +1,11 @@
+/**
+ * Run the cross-runtime vendor-scan proof (#1399) under WHICHEVER runtime
+ * executes the suite. Picked up by the root `node --test` runner (Node path);
+ * the CI `bun` job also runs `bun test/bun/vendor-scan.mjs` for the Bun path.
+ * The proof is a plain assert script (not `*.test.mjs`), so importing it runs it.
+ */
+import { test } from 'node:test';
+
+test('the vendor specifier scan is identical on this runtime (#1399)', async () => {
+ await import('./vendor-scan.mjs');
+});
diff --git a/test/e2e/fixtures/stub-jspm.mjs b/test/e2e/fixtures/stub-jspm.mjs
index 015d808c0..a3ef734c0 100644
--- a/test/e2e/fixtures/stub-jspm.mjs
+++ b/test/e2e/fixtures/stub-jspm.mjs
@@ -5,8 +5,9 @@
*
* Why this exists. The `differential elision (#181)` block runs the blog twice
* and asserts the two builds render identically. Elision ON drops
- * `components/vendor-badge.ts`, the blog's only vendor consumer, so
- * `scanBareImports` finds nothing, `api.jspm.io` is never called, `dayjs`
+ * `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.
* That is the #170 property and another test asserts it. Elision OFF ships
* that component, so the same page picks up two internet dependencies the ON
diff --git a/website/app/docs/no-build/page.ts b/website/app/docs/no-build/page.ts
index acc3b4fb9..5cff28a87 100644
--- a/website/app/docs/no-build/page.ts
+++ b/website/app/docs/no-build/page.ts
@@ -99,12 +99,12 @@ Content-Type: text/html
Bare specifiers (npm packages)
The browser can't resolve import dayjs from 'dayjs' on its own. WebJs follows the Rails 7 + importmap-rails posture: bare specifiers resolve through an importmap to jspm.io CDN URLs, and the browser fetches the bundle directly from jspm.io. The WebJs server doesn't bundle, cache, or proxy vendor packages.
- - Scan every
.js / .ts file under the app for bare import specifiers (skipping node_modules, .server.{js,ts} files, route.{js,ts} / middleware.{js,ts}, test/, 'use server' modules, type-only imports, and imports inside comments).
+ - Walk the module graph from every browser-bound entry (a page, a layout, an error / loading / not-found / forbidden / unauthorized boundary,
instrumentation-client, any component) and collect the bare import specifiers the reachable files declare. This is the same reachability the dev server uses to decide what it will serve at all, so a file nothing imports contributes nothing: a build script under scripts/, a tooling config, a test helper and an unreferenced module all drop out because no browser entry reaches them, not because of their name. A .server.{js,ts} file is reached (the browser fetches a stub at that URL) but its imports are not collected, since its source never ships. Type-only imports, and an import written inside a comment, a string, or a template literal, are never edges.
- For each discovered package, resolve the installed version from
node_modules/<pkg>/package.json.
- Call
api.jspm.io/generate once on the first request with the full install list as a single batch (e.g. ['dayjs@1.11.13', 'zod@3.23.8']), so jspm.io resolves the whole set as one mutually-consistent graph. A directly-imported package and a transitive that needs a newer version of the same package agree on one URL, instead of skewing. jspm.io returns a fully-resolved importmap fragment with correct entry paths. If one install can't be resolved (a private or server-only dep), WebJs falls back to resolving the rest so one bad package never collapses the whole map.
- Emit those URLs verbatim in the page's
<script type="importmap">. Browser fetches directly from ga.jspm.io; webjs's server is never on the vendor-bytes path.
- Native modules and server-only packages (node:*, pg) are filtered out by the scanner (they're imported only from .server.{js,ts} / route.{js,ts} / middleware.{js,ts} files, which the scanner skips). Server packages never reach the browser.
+ Native modules and server-only packages (node:*, pg) never enter the importmap. A node: or protocol specifier is not a vendor edge at all, and a driver imported from a .server.{js,ts} utility is behind a boundary the walk stops at. A route.{js,ts} handler or a middleware.{js,ts} drops out for the more basic reason that nothing browser-bound imports it, so the walk never arrives. Server packages never reach the browser.
Coherence is also verified as a check, not only at resolution. Resolving the whole install set as one batch (above) produces a coherent graph, but a hand-edited importmap or a partial vendor pin can still skew a transitive version. webjs doctor runs an importmap-coherence check that validates every resolved client dep's declared dependency/peer ranges against the versions actually pinned (including the generated .webjs/vendor/importmap.json), and names the conflicting packages, the required range, and the pinned version when they disagree. So a broken graph is caught before the browser runs it.
Optional: commit resolved URLs via webjs vendor pin