@webjsdev/server 0.9.0 → 0.10.0
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.
- package/README.md +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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
|
+
}
|
package/src/module-graph.js
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
138
|
-
* author intent, so eagerly preloading its target would over-fetch
|
|
139
|
-
* page load
|
|
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
|
|
423
|
-
* behind it (#197 / #170). Omit `skip` for the un-pruned superset
|
|
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
|
-
//
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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 /
|
|
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)
|