@webjsdev/server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Loading and wrapping middleware the way Next.js does.
3
+ *
4
+ * - The root file is `middleware.{ts,js,mts,mjs}` or, Next 16's name,
5
+ * `proxy.{ts,js,mts,mjs}`. Both at once is a boot error.
6
+ * - The function is the default export, else a named `middleware`, else a
7
+ * named `proxy` export (Next accepts all three).
8
+ * - `export const config = { matcher }` scopes it: a string, an array, or
9
+ * `{ source, has, missing }` objects. A request whose (base-path-stripped)
10
+ * path matches no entry skips straight to `next()`.
11
+ * - Returning `undefined` continues (Next's `NextResponse.next()` is the
12
+ * default), `next()` is memoized so calling it twice runs the rest of the
13
+ * chain once, and `next.waitUntil(promise)` keeps background work going
14
+ * after the response without failing it.
15
+ * - `request.cookies` is Next's RequestCookies over the request's cookies, and reading
16
+ * `request.nextUrl` (Next's extended URL) throws in dev with the fix.
17
+ * - A load failure FAILS CLOSED: a middleware that guards a route and cannot
18
+ * load must never let the request through unguarded. The root one rejects
19
+ * the analysis (dev overlay, prod readiness 503), a segment one answers 500.
20
+ *
21
+ * @module middleware-load
22
+ */
23
+ import { join } from 'node:path';
24
+ import { stat } from 'node:fs/promises';
25
+ import { devImport } from './dev-import.js';
26
+ import { requestCookieStore, markDynamicAccess } from './context.js';
27
+ import { parseCookieHeader, requestCookiesView } from './cookie-store.js';
28
+
29
+ const MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
30
+ const PROXY_FILES = ['proxy.ts', 'proxy.js', 'proxy.mts', 'proxy.mjs'];
31
+
32
+ /** @param {string} p */
33
+ async function exists(p) {
34
+ try { await stat(p); return true; } catch { return false; }
35
+ }
36
+
37
+ /**
38
+ * The root middleware file, or null. Throws when both a `middleware.*` and a
39
+ * `proxy.*` exist, since only one can be the app's middleware.
40
+ * @param {string} appDir
41
+ * @returns {Promise<string | null>}
42
+ */
43
+ export async function findRootMiddleware(appDir) {
44
+ let mw = null;
45
+ let px = null;
46
+ for (const n of MIDDLEWARE_FILES) if (!mw && await exists(join(appDir, n))) mw = n;
47
+ for (const n of PROXY_FILES) if (!px && await exists(join(appDir, n))) px = n;
48
+ if (mw && px) {
49
+ const err = new Error(`[webjs] both ${mw} and ${px} exist at the app root. They are the same thing (proxy is Next 16's name for middleware), so only one may exist. Delete one, or merge them.`);
50
+ /** @type {any} */ (err).code = 'WEBJS_APP_LAYOUT';
51
+ throw err;
52
+ }
53
+ const name = mw || px;
54
+ return name ? join(appDir, name) : null;
55
+ }
56
+
57
+ /**
58
+ * The middleware function a module exports: `default`, then `middleware`,
59
+ * then `proxy`. Throws naming the file when there is none.
60
+ * @param {Record<string, unknown>} mod
61
+ * @param {string} file
62
+ * @returns {Function}
63
+ */
64
+ export function resolveMiddlewareExport(mod, file) {
65
+ for (const k of ['default', 'middleware', 'proxy']) {
66
+ if (typeof mod[k] === 'function') return /** @type {Function} */ (mod[k]);
67
+ }
68
+ throw new Error(`[webjs] ${file} exports no middleware function. Export it as the default, or as a named \`middleware\` (or \`proxy\`) function, as in Next.js.`);
69
+ }
70
+
71
+ /**
72
+ * @typedef {{ type: 'header' | 'cookie' | 'query' | 'host', key?: string, value?: string }} RouteHas
73
+ * @typedef {string | { source: string, has?: RouteHas[], missing?: RouteHas[] }} MatcherEntry
74
+ */
75
+
76
+ /** `has` / `missing` value patterns, compiled once each (they come from the config, not the request). */
77
+ const hasPatterns = new Map();
78
+
79
+ /** @param {string} source */
80
+ function hasPattern(source) {
81
+ let re = hasPatterns.get(source);
82
+ if (!re) { re = new RegExp(`^${source}$`); hasPatterns.set(source, re); }
83
+ return re;
84
+ }
85
+
86
+ /**
87
+ * A port of Next's `matchHas` (prepare-destination.ts): each `has` condition
88
+ * must hold and no `missing` one may. A condition with no `value` checks
89
+ * presence; a `value` is a regex anchored to the whole value.
90
+ * @param {Request} req
91
+ * @param {URL} url
92
+ * @param {RouteHas[]} [has]
93
+ * @param {RouteHas[]} [missing]
94
+ */
95
+ export function matchHas(req, url, has = [], missing = []) {
96
+ /** @type {Map<string, string> | null} */
97
+ let cookies = null;
98
+ /** @param {RouteHas} item */
99
+ const holds = (item) => {
100
+ /** @type {string | undefined | null} */
101
+ let value;
102
+ switch (item.type) {
103
+ case 'header': value = req.headers.get(String(item.key).toLowerCase()); break;
104
+ case 'cookie': value = (cookies ||= parseCookieHeader(req.headers.get('cookie'))).get(String(item.key)); break;
105
+ case 'query': value = url.searchParams.get(String(item.key)); break;
106
+ case 'host': value = (req.headers.get('host') || url.host).split(':', 1)[0].toLowerCase(); break;
107
+ default: return false;
108
+ }
109
+ if (value == null || value === '') return false;
110
+ if (!item.value) return true;
111
+ return hasPattern(item.value).test(value);
112
+ };
113
+ return has.every(holds) && !missing.some(holds);
114
+ }
115
+
116
+ /**
117
+ * Compile `config.matcher` into a predicate over a request, or null when the
118
+ * middleware has no matcher (it runs for every request). Each source is a
119
+ * Next path pattern (`/dashboard/:path*`, `/((?!api|_next).*)`) compiled with
120
+ * URLPattern; a path with a trailing slash is retried without it.
121
+ * @param {unknown} matcher
122
+ * @param {string} file for the error message
123
+ * @returns {((req: Request, url: URL) => boolean) | null}
124
+ */
125
+ export function compileMatcher(matcher, file) {
126
+ if (matcher == null) return null;
127
+ const list = Array.isArray(matcher) ? matcher : [matcher];
128
+ const entries = list.map((m) => {
129
+ const e = typeof m === 'string' ? { source: m } : m;
130
+ if (!e || typeof e.source !== 'string' || !e.source.startsWith('/')) {
131
+ throw new Error(`[webjs] ${file}: config.matcher entries must be a path starting with "/" or { source, has?, missing? }; got ${JSON.stringify(m)}.`);
132
+ }
133
+ let pattern;
134
+ try { pattern = new URLPattern({ pathname: e.source }); } catch (err) {
135
+ throw new Error(`[webjs] ${file}: config.matcher "${e.source}" is not a valid path pattern (${/** @type {Error} */ (err).message}).`);
136
+ }
137
+ // Compile the has / missing patterns now, so a bad one fails at load.
138
+ for (const item of [...(e.has || []), ...(e.missing || [])]) {
139
+ if (item && item.value) {
140
+ try { hasPattern(String(item.value)); } catch (err) {
141
+ throw new Error(`[webjs] ${file}: config.matcher has/missing value ${JSON.stringify(item.value)} is not a valid pattern: ${/** @type any */ (err).message}`);
142
+ }
143
+ }
144
+ }
145
+ return { pattern, has: e.has, missing: e.missing };
146
+ });
147
+ return (req, url) => {
148
+ const path = url.pathname;
149
+ const alt = path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : null;
150
+ return entries.some((e) => (e.pattern.test({ pathname: path }) || (alt !== null && e.pattern.test({ pathname: alt })))
151
+ && matchHas(req, url, e.has, e.missing));
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Give a middleware's or a route handler's request Next's extras (#1686:
157
+ * `NextRequest` is what both receive in Next.js): `request.cookies` (Next's
158
+ * `RequestCookies`: a set there changes what the rest of this request reads and
159
+ * sends nothing to the browser, `cookies().set()` does that) and, in dev, a
160
+ * `request.nextUrl` that throws naming `new URL(request.url)`.
161
+ * @param {Request} req
162
+ * @param {boolean} dev
163
+ */
164
+ export function decorateRequest(req, dev) {
165
+ try {
166
+ if (!Object.prototype.hasOwnProperty.call(req, 'cookies')) {
167
+ Object.defineProperty(req, 'cookies', { configurable: true, get: () => { markDynamicAccess(); return requestCookiesView(requestCookieStore()); } });
168
+ }
169
+ if (!Object.prototype.hasOwnProperty.call(req, 'nextUrl')) {
170
+ Object.defineProperty(req, 'nextUrl', {
171
+ configurable: true,
172
+ get() {
173
+ if (dev) throw new Error('[webjs] request.nextUrl is Next.js\'s extended URL, which WebJs does not add: middleware receives the platform Request. Use new URL(request.url) instead.');
174
+ return new URL(req.url);
175
+ },
176
+ });
177
+ }
178
+ } catch { /* a frozen request: leave it as the platform made it */ }
179
+ }
180
+
181
+ /**
182
+ * Wrap a middleware function with Next's call semantics (see the module doc).
183
+ * @param {Function} fn
184
+ * @param {((req: Request, url: URL) => boolean) | null} matcher
185
+ * @param {{ dev: boolean, file: string, logger?: { error?: (...a: any[]) => void } }} opts
186
+ * @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
187
+ */
188
+ export function wrapMiddleware(fn, matcher, opts) {
189
+ return async (req, next) => {
190
+ if (matcher) {
191
+ let url;
192
+ try { url = new URL(req.url); } catch { return next(); }
193
+ if (!matcher(req, url)) return next();
194
+ }
195
+ /** @type {Promise<Response> | null} */
196
+ let ran = null;
197
+ const once = /** @type {(() => Promise<Response>) & { waitUntil: (p: Promise<unknown>) => void }} */ (
198
+ Object.assign(() => (ran ||= Promise.resolve().then(next)), {
199
+ waitUntil(/** @type {Promise<unknown>} */ p) {
200
+ Promise.resolve(p).catch((e) => opts.logger?.error?.(`[webjs] next.waitUntil promise rejected in ${opts.file}`, { err: String(e) }));
201
+ },
202
+ }));
203
+ decorateRequest(req, opts.dev);
204
+ const result = await fn(req, once);
205
+ if (result === undefined || result === null) return once();
206
+ if (result instanceof Response) return result;
207
+ throw new Error(`[webjs] ${opts.file} returned ${typeof result === 'object' ? 'a non-Response object' : typeof result}. Middleware returns a Response to answer, or calls next() (returning nothing also continues).`);
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Load a middleware module into its wrapped form. Throws (fail closed) when
213
+ * the module fails to import, exports no function, or has a bad matcher.
214
+ * @param {string} file
215
+ * @param {boolean} dev
216
+ * @param {{ error?: (...a: any[]) => void }} [logger]
217
+ */
218
+ export async function loadMiddlewareFile(file, dev, logger) {
219
+ const mod = await devImport(file, dev);
220
+ // Segment middleware is loaded on every request, so the wrapper (and the
221
+ // URLPattern compile of its matcher) is memoized on the module object: the
222
+ // same module until its source changes (devImport keys a dev re-import on
223
+ // the file's content), so an edit gets a fresh wrapper and nothing else does.
224
+ const hit = WRAPPED.get(mod);
225
+ if (hit && hit.dev === dev && hit.logger === logger) return hit.wrapped;
226
+ const fn = resolveMiddlewareExport(mod, file);
227
+ const config = mod.config && typeof mod.config === 'object' ? /** @type {any} */ (mod.config) : {};
228
+ const matcher = compileMatcher(config.matcher, file);
229
+ const wrapped = wrapMiddleware(fn, matcher, { dev, file, logger });
230
+ WRAPPED.set(mod, { dev, logger, wrapped });
231
+ return wrapped;
232
+ }
233
+
234
+ /** @type {WeakMap<object, { dev: boolean, logger: unknown, wrapped: Function }>} */
235
+ const WRAPPED = new WeakMap();
236
+
237
+ /**
238
+ * Load the optional root middleware (or proxy). Null when there is none;
239
+ * throws when it cannot be loaded, so the analysis refuses to serve.
240
+ * @param {string} appDir
241
+ * @param {boolean} dev
242
+ * @param {{ error?: (...a: any[]) => void }} [logger]
243
+ */
244
+ export async function loadRootMiddleware(appDir, dev, logger) {
245
+ const file = await findRootMiddleware(appDir);
246
+ if (!file) return null;
247
+ return loadMiddlewareFile(file, dev, logger);
248
+ }
@@ -46,7 +46,7 @@ export function appImportsMap(appDir) {
46
46
  * Expand a `package.json "imports"` subpath alias (e.g. `#lib/db.server.ts`
47
47
  * with the scaffold's catch-all `"#*": "./*"`) to its real APP-RELATIVE target
48
48
  * string (`./lib/db.server.ts`).
49
- * The security-critical seam (#555): the graph walker, auth gate, elision, and
49
+ * The security-critical seam (#555): the graph walker, auth gate, boot frontier, and
50
50
  * `no-server-import-in-browser-module` all route through `resolveImport`, so
51
51
  * expanding the alias here (to the real path) is what stops an alias from
52
52
  * laundering a `.server.ts` past those checks. Driven off the actual `"imports"`
@@ -124,6 +124,54 @@ const EXPORT_FROM_RE = /\bexport\b[^'";`]+?\sfrom\s+['"]([^'"]+)['"]/gd;
124
124
  */
125
125
  const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/gd;
126
126
 
127
+ /**
128
+ * Every import specifier a module's source names, with its position: static
129
+ * `import`, `export … from`, then string-literal dynamic `import('…')`, each
130
+ * group in source order. `start` / `end` delimit the specifier text inside its
131
+ * quotes in `src`, so a caller can splice it (#1689, the dev server's browser
132
+ * module versions).
133
+ *
134
+ * All three regexes run over the FULLY-BLANKED mask (see `parseFile`), so a
135
+ * keyword inside a comment, string, template or regex never anchors a match,
136
+ * and the specifier is read back from the raw source at the group indices.
137
+ * The static half is exactly what `parseFile` builds the graph from, so the two
138
+ * can never disagree. The dynamic half differs from the graph's own dynamic
139
+ * scan in one place: a dynamic import written inside a template `${}` hole is
140
+ * blanked here and so not listed (the graph scan reads hole code).
141
+ *
142
+ * A statement-leading type-only clause is skipped, because the TS stripper
143
+ * erases it and it never becomes a fetch or a runtime edge (#805):
144
+ * `import type { X }`, `import type * as X`, `import type Foo`,
145
+ * `export type { X }`, `export type *`. Two carve-outs keep this from
146
+ * over-dropping. `import type from '...'` is a VALUE default import whose
147
+ * binding is named `type`, and a MIXED `import { type A, b }` does not lead
148
+ * with `type`, so both are kept.
149
+ *
150
+ * @param {string} src
151
+ * @returns {Array<{ start: number, end: number, spec: string, dynamic: boolean }>}
152
+ */
153
+ export function importSpecifierRanges(src) {
154
+ const masked = redactStringsAndTemplates(src, true);
155
+ /** @type {Array<{ start: number, end: number, spec: string, dynamic: boolean }>} */
156
+ const out = [];
157
+ for (const re of [IMPORT_RE, EXPORT_FROM_RE, DYNAMIC_IMPORT_RE]) {
158
+ const dynamic = re === DYNAMIC_IMPORT_RE;
159
+ for (const m of masked.matchAll(re)) {
160
+ if (!dynamic) {
161
+ // m[0] is over the mask, which keeps the binding syntax verbatim.
162
+ const lead = m[0];
163
+ const typeOnly =
164
+ /^(?:import|export)\s+type\s*[{*]/.test(lead) ||
165
+ (/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
166
+ if (typeOnly) continue;
167
+ }
168
+ const [start, end] = /** @type {[number, number]} */ (m.indices?.[1]);
169
+ out.push({ start, end, spec: src.slice(start, end), dynamic });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
127
175
  /**
128
176
  * @typedef {Map<string, Set<string>>} ModuleGraph
129
177
  * A map of absolute file path → Set of absolute file paths it STATICALLY imports.
@@ -134,9 +182,9 @@ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/gd;
134
182
  * the static graph and keyed by it in a WeakMap. The authorization gate
135
183
  * (`reachableFromEntries`) unions these in so a lazily-imported app module is
136
184
  * servable instead of 404ing, but the preload walk (`transitiveDeps`) and the
137
- * elision analysis stay on the static graph ONLY: a dynamic import is lazy by
138
- * author intent, so eagerly preloading its target would over-fetch on every
139
- * page load, and feeding dynamic edges into elision could flip a verdict. The
185
+ * boot frontier stay on the static graph ONLY: a dynamic import is lazy by
186
+ * author intent, so eagerly preloading or booting its target would over-fetch
187
+ * on every page load. The
140
188
  * module is fetched at call time, now served correctly by the gate.
141
189
  * @type {WeakMap<ModuleGraph, ModuleGraph>}
142
190
  */
@@ -358,9 +406,10 @@ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
358
406
  * @param {ModuleGraph} graph
359
407
  * @param {string[]} entryFiles absolute paths of browser-bound entries
360
408
  * @param {string} appDir
409
+ * @param {Set<string>} [stopAt] files never admitted or walked (the route-table files)
361
410
  * @returns {Set<string>}
362
411
  */
363
- export function reachableFromEntries(graph, entryFiles, appDir) {
412
+ export function reachableFromEntries(graph, entryFiles, appDir, stopAt) {
364
413
  // The gate unions in dynamic-import edges (#751) so a lazily-imported app
365
414
  // module (`await import('./widget.ts')`) is servable instead of 404ing. A
366
415
  // dynamically-imported module's OWN static imports are then walked normally
@@ -392,6 +441,9 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
392
441
  for (const dep of set) {
393
442
  if (visited.has(dep)) continue;
394
443
  if (!dep.startsWith(appDir)) continue;
444
+ // A route-table file never loads in the browser (#1686), even when a
445
+ // component imports one, so it is neither servable nor walked.
446
+ if (stopAt && stopAt.has(dep)) continue;
395
447
  visited.add(dep);
396
448
  queue.push(dep);
397
449
  }
@@ -419,9 +471,8 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
419
471
  * imported by a server file must never enter the importmap.
420
472
  *
421
473
  * `skip` files are neither collected nor traversed into, so a specifier
422
- * reachable ONLY through an elided component drops out along with the subtree
423
- * behind it (#197 / #170). Omit `skip` for the un-pruned superset the pin path
424
- * needs.
474
+ * reachable ONLY through a route module drops out along with the subtree
475
+ * behind it (#197 / #170). Omit `skip` for the un-pruned superset.
425
476
  *
426
477
  * @param {ModuleGraph} graph
427
478
  * @param {string[]} entryFiles absolute paths
@@ -556,7 +607,7 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic)
556
607
  // graph edge to whatever path that string names. Since this caller only
557
608
  // checks keyword-in-code-position, blank every literal body so a
558
609
  // code-example `import` string never becomes an edge.
559
- // Scan for edges over the FULLY-BLANKED mask: every comment / string /
610
+ // `importSpecifierRanges` scans for edges over the FULLY-BLANKED mask: every comment / string /
560
611
  // template / regex body is blanked to spaces (delimiters kept), `${...}`
561
612
  // holes stay as code. Reason (#753, found differentially): a stray `import` /
562
613
  // `export` word inside a comment, template, OR regex literal must not anchor a
@@ -570,48 +621,23 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare, bareDynamic)
570
621
  // left for a per-match position guard to reject. The specifier itself is a
571
622
  // string (blanked here), so it is read back from raw `src` via the match's
572
623
  // group indices (the `d` flag).
573
- const masked = redactStringsAndTemplates(src, true);
574
624
  /** @type {string[]} local (relative / aliased) static specifiers */
575
625
  const specs = [];
576
626
  /** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */
577
627
  const bareDeps = new Set();
578
- for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
579
- for (const m of masked.matchAll(re)) {
580
- // A statement-leading `import type { X }` / `import type * as X` /
581
- // `import type Foo` (default type import) / `export type { X }` /
582
- // `export type *` is fully erased by the TS stripper, so it never becomes
583
- // a browser fetch or a runtime edge. Skip it, so a type-only import of a
584
- // `.server.ts` (a common, correct way to share a derived row type) is not
585
- // mistaken for a runtime dependency by the gate / elision / the
586
- // no-server-import check. Two carve-outs keep this from over-dropping:
587
- // - `import type from '...'` is a VALUE default import whose binding is
588
- // literally named `type` (the stripper keeps it), so `type` directly
589
- // followed by `from` is NOT type-only.
590
- // - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
591
- // kept (its `b` binding is a real runtime edge).
592
- // (m[0] here is over the mask, which keeps the binding syntax verbatim.)
593
- // #805
594
- const lead = m[0];
595
- const typeOnly =
596
- /^(?:import|export)\s+type\s*[{*]/.test(lead) ||
597
- (/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
598
- if (typeOnly) continue;
599
- // The specifier is read from RAW src at the match's captured-group indices
600
- // (the group is the blanked string body in the mask; its position is the
601
- // real specifier in src).
602
- const spec = src.slice(m.indices[1][0], m.indices[1][1]);
603
- // Only resolve relative imports + `#`-style subpath aliases (#555)
604
- // within the project. A bare npm specifier (dayjs) has no alias match
605
- // and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
606
- // can emit a modulepreload for the reached vendor URL (#754); an aliased
607
- // `#lib/x.server.ts` IS followed so the graph / auth gate / elision see
608
- // the real path through the alias.
609
- if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
610
- if (isVendorSpecifier(spec)) bareDeps.add(spec);
611
- continue;
612
- }
613
- specs.push(spec);
628
+ for (const { spec, dynamic: isDynamic } of importSpecifierRanges(src)) {
629
+ if (isDynamic) continue;
630
+ // Only resolve relative imports + `#`-style subpath aliases (#555)
631
+ // within the project. A bare npm specifier (dayjs) has no alias match
632
+ // and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
633
+ // can emit a modulepreload for the reached vendor URL (#754); an aliased
634
+ // `#lib/x.server.ts` IS followed so the graph / auth gate / frontier see
635
+ // the real path through the alias.
636
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
637
+ if (isVendorSpecifier(spec)) bareDeps.add(spec);
638
+ continue;
614
639
  }
640
+ specs.push(spec);
615
641
  }
616
642
  // Dynamic `import('…')` with a string-literal specifier (#751): a separate
617
643
  // edge class so the gate admits the lazily-loaded module (no 404) without
@@ -704,7 +730,7 @@ export function resolveImport(spec, fromFile, appDir) {
704
730
  if (aliased) {
705
731
  // `#`-style subpath alias (#555): the app-relative target is resolved
706
732
  // against the app root, then run through the same extension fallback below
707
- // so the boundary / elision / preload all see the real on-disk path.
733
+ // so the boundary / frontier / preload all see the real on-disk path.
708
734
  target = resolve(appDir, aliased);
709
735
  } else if (spec.startsWith('/')) {
710
736
  // Absolute from app root (how browser sees it)