@webjsdev/server 0.8.74 → 0.8.76
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/package.json +1 -1
- package/src/auth.js +37 -16
- package/src/component-elision.js +88 -0
- package/src/component-scanner.js +26 -6
- package/src/dev/handler.js +10 -2
- package/src/dev/serve.js +32 -4
- package/src/ssr/preloads.js +7 -2
- package/src/ssr/render.js +21 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.76",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/auth.js
CHANGED
|
@@ -13,6 +13,8 @@ import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY
|
|
|
13
13
|
|
|
14
14
|
const enc = new TextEncoder();
|
|
15
15
|
const dec = new TextDecoder();
|
|
16
|
+
// Base names. In production each is sent with the `__Host-` prefix
|
|
17
|
+
// (cookieName below); development over http keeps the plain name.
|
|
16
18
|
const AUTH_COOKIE = 'webjs.auth';
|
|
17
19
|
const STATE_COOKIE = 'webjs.auth.state';
|
|
18
20
|
// Carries the post-sign-in target across the OAuth round trip (#1495). Signed
|
|
@@ -104,14 +106,33 @@ function parseCookies(header) {
|
|
|
104
106
|
return out;
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The name a cookie is sent under. In production it carries the `__Host-`
|
|
111
|
+
* prefix (#1538): a browser stores a `__Host-` cookie only when it is
|
|
112
|
+
* `Secure`, has `Path=/` and has NO `Domain` attribute, so an app on a sibling
|
|
113
|
+
* subdomain (two apps on `a.example.app` and `b.example.app`, with
|
|
114
|
+
* `example.app` not on the Public Suffix List) can no longer plant its own
|
|
115
|
+
* `webjs.auth` with `Domain=.example.app` and sign a visitor in to the wrong
|
|
116
|
+
* account (cookie tossing, session fixation). Production never reads the
|
|
117
|
+
* unprefixed name: a fallback would reopen exactly that hole.
|
|
118
|
+
* Development over http keeps the plain name, since `__Host-` needs `Secure`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} base @param {boolean} secure
|
|
121
|
+
*/
|
|
122
|
+
export function cookieName(base, secure) {
|
|
123
|
+
return secure ? `__Host-${base}` : base;
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
function setCookie(name, value, maxAge, secure) {
|
|
108
127
|
let s = `${name}=${encodeURIComponent(value)}; Max-Age=${Math.floor(maxAge / 1000)}; Path=/; HttpOnly; SameSite=Lax`;
|
|
109
128
|
if (secure) s += '; Secure';
|
|
110
129
|
return s;
|
|
111
130
|
}
|
|
112
131
|
|
|
113
|
-
|
|
114
|
-
|
|
132
|
+
// A `__Host-` cookie is cleared only by a Set-Cookie that also says Secure;
|
|
133
|
+
// a browser ignores one without it, and the cookie would stay.
|
|
134
|
+
function clearCookie(name, secure = false) {
|
|
135
|
+
return `${name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
// -- JWT --------------------------------------------------------------------
|
|
@@ -260,7 +281,7 @@ export function createAuth(config) {
|
|
|
260
281
|
// page cacheable so a logged-in body could be served to the next visitor.
|
|
261
282
|
markDynamicAccess();
|
|
262
283
|
const cookies = parseCookies(req.headers.get('cookie') || '');
|
|
263
|
-
const raw = cookies[AUTH_COOKIE];
|
|
284
|
+
const raw = cookies[cookieName(AUTH_COOKIE, secure())];
|
|
264
285
|
if (!raw) return null;
|
|
265
286
|
|
|
266
287
|
if (strategy === 'jwt') {
|
|
@@ -284,11 +305,11 @@ export function createAuth(config) {
|
|
|
284
305
|
if (strategy === 'jwt') {
|
|
285
306
|
let token = { sub: user.id, name: user.name, email: user.email, image: user.image, role: user.role, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + maxAge };
|
|
286
307
|
if (cb.jwt) token = await cb.jwt({ token, user });
|
|
287
|
-
return setCookie(AUTH_COOKIE, await encodeJwt(token, secret), maxAgeMs, secure());
|
|
308
|
+
return setCookie(cookieName(AUTH_COOKIE, secure()), await encodeJwt(token, secret), maxAgeMs, secure());
|
|
288
309
|
}
|
|
289
310
|
const sid = randomId();
|
|
290
311
|
await dbStore.save(sid, user, maxAgeMs);
|
|
291
|
-
return setCookie(AUTH_COOKIE, await sign(sid, secret), maxAgeMs, secure());
|
|
312
|
+
return setCookie(cookieName(AUTH_COOKIE, secure()), await sign(sid, secret), maxAgeMs, secure());
|
|
292
313
|
}
|
|
293
314
|
|
|
294
315
|
// -- auth() ---------------------------------------------------------------
|
|
@@ -333,12 +354,12 @@ export function createAuth(config) {
|
|
|
333
354
|
async function signOutFn(opts = {}) {
|
|
334
355
|
const hdrs = new Headers();
|
|
335
356
|
hdrs.set('location', opts.redirectTo || pages.signOut || '/');
|
|
336
|
-
hdrs.append('set-cookie', clearCookie(AUTH_COOKIE));
|
|
357
|
+
hdrs.append('set-cookie', clearCookie(cookieName(AUTH_COOKIE, secure()), secure()));
|
|
337
358
|
|
|
338
359
|
if (strategy === 'database' && dbStore) {
|
|
339
360
|
const r = opts.req || getRequest();
|
|
340
361
|
if (r) {
|
|
341
|
-
const raw = parseCookies(r.headers.get('cookie') || '')[AUTH_COOKIE];
|
|
362
|
+
const raw = parseCookies(r.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure())];
|
|
342
363
|
if (raw) { const sid = await unsign(raw, secret); if (sid) await dbStore.destroy(sid); }
|
|
343
364
|
}
|
|
344
365
|
}
|
|
@@ -361,14 +382,14 @@ export function createAuth(config) {
|
|
|
361
382
|
|
|
362
383
|
const hdrs = new Headers();
|
|
363
384
|
hdrs.set('location', url.toString());
|
|
364
|
-
hdrs.append('set-cookie', setCookie(STATE_COOKIE, await sign(state, secret), 600_000, secure()));
|
|
385
|
+
hdrs.append('set-cookie', setCookie(cookieName(STATE_COOKIE, secure()), await sign(state, secret), 600_000, secure()));
|
|
365
386
|
// The post-sign-in target rides in its own signed cookie (#1495). With no
|
|
366
387
|
// valid target, any leftover cookie from an abandoned earlier attempt is
|
|
367
388
|
// cleared so it cannot steer this sign-in somewhere stale.
|
|
368
389
|
const target = safeRedirectPath(opts.redirectTo);
|
|
369
390
|
hdrs.append('set-cookie', target
|
|
370
|
-
? setCookie(REDIRECT_COOKIE, await sign(target, secret), 600_000, secure())
|
|
371
|
-
: clearCookie(REDIRECT_COOKIE));
|
|
391
|
+
? setCookie(cookieName(REDIRECT_COOKIE, secure()), await sign(target, secret), 600_000, secure())
|
|
392
|
+
: clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
|
|
372
393
|
return new Response(null, { status: 302, headers: hdrs });
|
|
373
394
|
}
|
|
374
395
|
|
|
@@ -381,7 +402,7 @@ export function createAuth(config) {
|
|
|
381
402
|
* @returns {Promise<string | null>}
|
|
382
403
|
*/
|
|
383
404
|
async function readRedirectCookie(req) {
|
|
384
|
-
const raw = parseCookies(req.headers.get('cookie') || '')[REDIRECT_COOKIE];
|
|
405
|
+
const raw = parseCookies(req.headers.get('cookie') || '')[cookieName(REDIRECT_COOKIE, secure())];
|
|
385
406
|
if (!raw) return null;
|
|
386
407
|
return safeRedirectPath(await unsign(raw, secret));
|
|
387
408
|
}
|
|
@@ -392,7 +413,7 @@ export function createAuth(config) {
|
|
|
392
413
|
const state = url.searchParams.get('state');
|
|
393
414
|
if (!code || !state) return new Response('Missing code or state', { status: 400 });
|
|
394
415
|
|
|
395
|
-
const rawState = parseCookies(req.headers.get('cookie') || '')[STATE_COOKIE];
|
|
416
|
+
const rawState = parseCookies(req.headers.get('cookie') || '')[cookieName(STATE_COOKIE, secure())];
|
|
396
417
|
if (!rawState) return new Response('Missing state cookie', { status: 403 });
|
|
397
418
|
const verified = await unsign(rawState, secret);
|
|
398
419
|
if (!verified || verified !== state) return new Response('Invalid state', { status: 403 });
|
|
@@ -430,8 +451,8 @@ export function createAuth(config) {
|
|
|
430
451
|
if (ok === false) {
|
|
431
452
|
const denied = new Headers();
|
|
432
453
|
denied.set('location', pages.error || '/?error=AccessDenied');
|
|
433
|
-
denied.append('set-cookie', clearCookie(STATE_COOKIE));
|
|
434
|
-
denied.append('set-cookie', clearCookie(REDIRECT_COOKIE));
|
|
454
|
+
denied.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
|
|
455
|
+
denied.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
|
|
435
456
|
return new Response(null, { status: 302, headers: denied });
|
|
436
457
|
}
|
|
437
458
|
}
|
|
@@ -439,8 +460,8 @@ export function createAuth(config) {
|
|
|
439
460
|
const hdrs = new Headers();
|
|
440
461
|
hdrs.set('location', (await readRedirectCookie(req)) || '/');
|
|
441
462
|
hdrs.append('set-cookie', await writeSession(user));
|
|
442
|
-
hdrs.append('set-cookie', clearCookie(STATE_COOKIE));
|
|
443
|
-
hdrs.append('set-cookie', clearCookie(REDIRECT_COOKIE));
|
|
463
|
+
hdrs.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
|
|
464
|
+
hdrs.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
|
|
444
465
|
return new Response(null, { status: 302, headers: hdrs });
|
|
445
466
|
}
|
|
446
467
|
|
package/src/component-elision.js
CHANGED
|
@@ -1519,3 +1519,91 @@ export function elideImportsFromSource(source, importerAbs, moduleGraph, elidabl
|
|
|
1519
1519
|
out += source.slice(last);
|
|
1520
1520
|
return out;
|
|
1521
1521
|
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Defer side-effect imports of `static lazy` components (#1524). A lazy
|
|
1525
|
+
* component that a shipping module imports for registration used to load
|
|
1526
|
+
* eagerly as part of that module's static graph, which defeated `static lazy`
|
|
1527
|
+
* whenever the class had to be imported at all (and SSR needs the import: the
|
|
1528
|
+
* server only knows the tag is lazy once the class registered). The SERVER
|
|
1529
|
+
* keeps the real import; this rewrites only the BROWSER copy, replacing the
|
|
1530
|
+
* import line in place (same line, so positions stay stable) with a
|
|
1531
|
+
* registration on the lazy loader:
|
|
1532
|
+
*
|
|
1533
|
+
* import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({"x-tag":"/url"}));
|
|
1534
|
+
*
|
|
1535
|
+
* The loader then imports the module the first time an element with one of
|
|
1536
|
+
* its tags is in (or near) the viewport: a hidden tab panel when it first
|
|
1537
|
+
* shows, a closed dialog's content when it opens, a below-the-fold element
|
|
1538
|
+
* when it scrolls near. Its MutationObserver also catches a tag rendered
|
|
1539
|
+
* later on the client, so an imported-but-not-yet-rendered component still
|
|
1540
|
+
* registers when it appears.
|
|
1541
|
+
*
|
|
1542
|
+
* Same contract as {@link elideImportsFromSource}: only a side-effect import
|
|
1543
|
+
* is rewritten (a binding import needs its value now, so it stays eager), the
|
|
1544
|
+
* match runs over a redacted copy so an import-looking line inside a string,
|
|
1545
|
+
* template, or comment is never touched, and an importer with no lazy
|
|
1546
|
+
* dependency in the graph returns untouched with no regex work. Run it AFTER
|
|
1547
|
+
* the elision strip: an elided import is already a comment by then.
|
|
1548
|
+
*
|
|
1549
|
+
* @param {string} source module source (already type-stripped if TS)
|
|
1550
|
+
* @param {string} importerAbs absolute path of the importing module
|
|
1551
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
1552
|
+
* @param {Map<string, string[]> | undefined} lazyFiles lazy component file -> the tags it registers
|
|
1553
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
1554
|
+
* @param {string} appDir
|
|
1555
|
+
* @param {(abs: string) => string} urlFor the browser URL the loader imports (base path + content hash applied)
|
|
1556
|
+
* @returns {string}
|
|
1557
|
+
*/
|
|
1558
|
+
export function deferLazyImportsFromSource(source, importerAbs, moduleGraph, lazyFiles, resolveImport, appDir, urlFor) {
|
|
1559
|
+
if (!lazyFiles || lazyFiles.size === 0) return source;
|
|
1560
|
+
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
1561
|
+
if (!deps) return source;
|
|
1562
|
+
let hasLazyDep = false;
|
|
1563
|
+
for (const d of deps) {
|
|
1564
|
+
if (lazyFiles.has(d)) { hasLazyDep = true; break; }
|
|
1565
|
+
}
|
|
1566
|
+
if (!hasLazyDep) return source;
|
|
1567
|
+
|
|
1568
|
+
const redacted = redactStringsAndTemplates(source);
|
|
1569
|
+
let out = '';
|
|
1570
|
+
let last = 0;
|
|
1571
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
1572
|
+
const start = /** @type {number} */ (m.index);
|
|
1573
|
+
const end = start + m[0].length;
|
|
1574
|
+
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
1575
|
+
const tags = resolved ? lazyFiles.get(resolved) : undefined;
|
|
1576
|
+
out += source.slice(last, start);
|
|
1577
|
+
if (tags && tags.length) {
|
|
1578
|
+
const url = urlFor(/** @type {string} */ (resolved));
|
|
1579
|
+
const entries = Object.fromEntries(tags.map((t) => [t, url]));
|
|
1580
|
+
out += `${m[1]}import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy(${JSON.stringify(entries)}));`;
|
|
1581
|
+
} else {
|
|
1582
|
+
out += source.slice(start, end);
|
|
1583
|
+
}
|
|
1584
|
+
last = end;
|
|
1585
|
+
}
|
|
1586
|
+
out += source.slice(last);
|
|
1587
|
+
return out;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* The lazy component files of an app (#1524): every scanned component file
|
|
1592
|
+
* that declares `static lazy = true`, mapped to the tags it registers. An
|
|
1593
|
+
* elided (display-only) file is left out: its import is stripped outright,
|
|
1594
|
+
* which beats deferring it.
|
|
1595
|
+
*
|
|
1596
|
+
* @param {Array<{ tag: string, file: string, lazy?: boolean }>} components
|
|
1597
|
+
* @param {Set<string>} [elidable]
|
|
1598
|
+
* @returns {Map<string, string[]>}
|
|
1599
|
+
*/
|
|
1600
|
+
export function lazyComponentFiles(components, elidable) {
|
|
1601
|
+
/** @type {Map<string, string[]>} */
|
|
1602
|
+
const out = new Map();
|
|
1603
|
+
for (const c of components) {
|
|
1604
|
+
if (!c.lazy || (elidable && elidable.has(c.file))) continue;
|
|
1605
|
+
const tags = out.get(c.file);
|
|
1606
|
+
if (tags) { if (!tags.includes(c.tag)) tags.push(c.tag); } else out.set(c.file, [c.tag]);
|
|
1607
|
+
}
|
|
1608
|
+
return out;
|
|
1609
|
+
}
|
package/src/component-scanner.js
CHANGED
|
@@ -28,7 +28,7 @@ import { redactToPlaceholders } from './js-scan.js';
|
|
|
28
28
|
* after a single `stat`). Makes the component scan incremental for large apps.
|
|
29
29
|
* Keyed by mtime AND size (a same-tick length-changing edit is caught even on
|
|
30
30
|
* coarse-mtime filesystems).
|
|
31
|
-
* @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }
|
|
31
|
+
* @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }>, lazy: boolean }>}
|
|
32
32
|
*/
|
|
33
33
|
const SCAN_CACHE = new Map();
|
|
34
34
|
|
|
@@ -75,16 +75,34 @@ export function extractComponents(src) {
|
|
|
75
75
|
return results;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Does this component module declare `static lazy = true` (#1524)? Matched on
|
|
80
|
+
* the comment- and string-redacted source, so the phrase inside a comment or a
|
|
81
|
+
* string literal does not count. The answer is per FILE: a lazy module is
|
|
82
|
+
* loaded whole, so every tag it registers is deferred together (one component
|
|
83
|
+
* per file is the convention anyway).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} src
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function declaresLazy(src) {
|
|
89
|
+
if (!src.includes('lazy')) return false;
|
|
90
|
+
const { redacted } = redactToPlaceholders(src);
|
|
91
|
+
return /\bstatic\s+(?:override\s+|readonly\s+)*lazy\s*(?::\s*boolean\s*)?=\s*true\b/.test(redacted);
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
/**
|
|
79
95
|
* Walk an app directory, return every discovered component with its
|
|
80
96
|
* browser-visible URL (rooted at `/`, matching how the dev server
|
|
81
97
|
* serves module files).
|
|
82
98
|
*
|
|
83
99
|
* @param {string} appDir
|
|
84
|
-
*
|
|
100
|
+
* `lazy` is true when the file declares `static lazy = true` (#1524).
|
|
101
|
+
*
|
|
102
|
+
* @returns {Promise<Array<{ tag: string, className: string, moduleUrl: string, file: string, lazy: boolean }>>}
|
|
85
103
|
*/
|
|
86
104
|
export async function scanComponents(appDir) {
|
|
87
|
-
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string }>} */
|
|
105
|
+
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string, lazy: boolean }>} */
|
|
88
106
|
const components = [];
|
|
89
107
|
/** @type {Set<string>} live component files this scan, for cache eviction */
|
|
90
108
|
const seen = new Set();
|
|
@@ -97,20 +115,22 @@ export async function scanComponents(appDir) {
|
|
|
97
115
|
let mtimeMs, size;
|
|
98
116
|
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; } catch { continue; }
|
|
99
117
|
seen.add(file); // mark live (hit and miss) for cache eviction
|
|
100
|
-
let comps;
|
|
118
|
+
let comps, lazy;
|
|
101
119
|
const cached = SCAN_CACHE.get(file);
|
|
102
120
|
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
103
121
|
comps = cached.comps;
|
|
122
|
+
lazy = cached.lazy;
|
|
104
123
|
} else {
|
|
105
124
|
let src;
|
|
106
125
|
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
107
126
|
comps = extractComponents(src);
|
|
108
|
-
|
|
127
|
+
lazy = comps.length > 0 && declaresLazy(src);
|
|
128
|
+
SCAN_CACHE.set(file, { mtimeMs, size, comps, lazy });
|
|
109
129
|
}
|
|
110
130
|
if (!comps.length) continue;
|
|
111
131
|
const moduleUrl = toUrlPath(file, appDir);
|
|
112
132
|
for (const c of comps) {
|
|
113
|
-
components.push({ ...c, moduleUrl, file });
|
|
133
|
+
components.push({ ...c, moduleUrl, file, lazy });
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
// Evict scan-cache entries for files no longer walked (renamed/deleted),
|
package/src/dev/handler.js
CHANGED
|
@@ -33,7 +33,7 @@ import { reachedBareImports, resolveVendorImports, clearVendorCache, hasVendorPi
|
|
|
33
33
|
import { browserEntryFiles } from '../browser-entries.js';
|
|
34
34
|
import { buildModuleGraph, seenFilesFor, appImportsMap, reachableFromEntries } from '../module-graph.js';
|
|
35
35
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from '../component-scanner.js';
|
|
36
|
-
import { analyzeElision } from '../component-elision.js';
|
|
36
|
+
import { analyzeElision, lazyComponentFiles } from '../component-elision.js';
|
|
37
37
|
|
|
38
38
|
import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from '../importmap.js';
|
|
39
39
|
import { stripBasePath, withBasePath } from '../base-path.js';
|
|
@@ -442,6 +442,10 @@ export async function createRequestHandler(opts) {
|
|
|
442
442
|
elidableComponents: new Set(),
|
|
443
443
|
inertRouteModules: new Set(),
|
|
444
444
|
importOnlyRouteModules: new Map(),
|
|
445
|
+
// `static lazy` component files -> their tags (#1524): a side-effect import
|
|
446
|
+
// of one is served as a lazy-loader registration, and the preload walks
|
|
447
|
+
// skip it. Independent of the elision switch.
|
|
448
|
+
lazyComponentFiles: new Map(),
|
|
445
449
|
// The browser-bound ENTRY files (pages / layouts / boundaries / components),
|
|
446
450
|
// kept alongside the expanded gate set because the vendor scan roots at the
|
|
447
451
|
// entries rather than at the closure.
|
|
@@ -609,6 +613,7 @@ export async function createRequestHandler(opts) {
|
|
|
609
613
|
state.elidableComponents = r.elidableComponents;
|
|
610
614
|
state.inertRouteModules = r.inertRouteModules;
|
|
611
615
|
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
616
|
+
state.lazyComponentFiles = lazyComponentFiles(components, state.elidableComponents);
|
|
612
617
|
// Dev live-reload classification (#1398): derive the three sets the
|
|
613
618
|
// watch-event classifier reads. `browserBoundFiles` above is the
|
|
614
619
|
// AUTHORIZATION gate (it walks from every entry, elided ones
|
|
@@ -647,7 +652,10 @@ export async function createRequestHandler(opts) {
|
|
|
647
652
|
// component imports), so a flip in / out of that class must bust
|
|
648
653
|
// the importer's `?v` too.
|
|
649
654
|
...state.importOnlyRouteModules.keys(),
|
|
650
|
-
|
|
655
|
+
// A file flipping to or from `static lazy` changes how every
|
|
656
|
+
// importer serves its import of it (#1524).
|
|
657
|
+
...[...state.lazyComponentFiles.keys()].map((f) => `lazy:${f}`),
|
|
658
|
+
].map((p) => (p.startsWith('lazy:') ? `lazy:${rel(p.slice(5))}` : rel(p))).sort();
|
|
651
659
|
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
652
660
|
}
|
|
653
661
|
// App-source fingerprint for the HTML cache key (#318): a
|
package/src/dev/serve.js
CHANGED
|
@@ -27,11 +27,12 @@ import { maybeRegenerate } from '../dev-regenerate.js';
|
|
|
27
27
|
import { stripTypeScript } from '../ts-strip.js';
|
|
28
28
|
import { serveDownloadedBundle } from '../vendor.js';
|
|
29
29
|
import { resolveImport } from '../module-graph.js';
|
|
30
|
-
import { elideImportsFromSource } from '../component-elision.js';
|
|
30
|
+
import { elideImportsFromSource, deferLazyImportsFromSource } from '../component-elision.js';
|
|
31
31
|
import { annotateSourceLocations, isSourceLocationCandidate, sourceLocationFile } from '../dev-source-locations.js';
|
|
32
32
|
import { basePath } from '../importmap.js';
|
|
33
33
|
import { withBasePath } from '../base-path.js';
|
|
34
|
-
import { versionModuleImports } from '../asset-hash.js';
|
|
34
|
+
import { versionModuleImports, withAssetHash } from '../asset-hash.js';
|
|
35
|
+
import { toUrlPath } from '../ssr/preloads.js';
|
|
35
36
|
import { BUFFERED_MARKER } from '../conditional-get.js';
|
|
36
37
|
import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
|
|
37
38
|
|
|
@@ -322,6 +323,10 @@ export async function handleCore(req, ctx) {
|
|
|
322
323
|
const elideOpts = {
|
|
323
324
|
moduleGraph: state.moduleGraph,
|
|
324
325
|
elidableComponents: state.elidableComponents,
|
|
326
|
+
// A browser test imports the component it tests and expects it defined
|
|
327
|
+
// when the import resolves, so the test handler serves every import as
|
|
328
|
+
// written (#1524).
|
|
329
|
+
lazyComponentFiles: state.testMode ? undefined : state.lazyComponentFiles,
|
|
325
330
|
appDir,
|
|
326
331
|
// Dev source locations (#1499): annotate the served module's `html`
|
|
327
332
|
// templates exactly as the SSR load hook annotates the server's copy.
|
|
@@ -416,6 +421,7 @@ export async function handleCore(req, ctx) {
|
|
|
416
421
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
417
422
|
serverFiles: state.actionIndex.fileToHash,
|
|
418
423
|
elidableComponents: state.elidableComponents,
|
|
424
|
+
lazyComponentFiles: state.lazyComponentFiles,
|
|
419
425
|
inertRouteModules: state.inertRouteModules,
|
|
420
426
|
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
421
427
|
notFoundFile: state.routeTable.notFound,
|
|
@@ -686,7 +692,7 @@ export async function fileResponse(abs, opts) {
|
|
|
686
692
|
*
|
|
687
693
|
* @param {string} abs
|
|
688
694
|
* @param {boolean} dev
|
|
689
|
-
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
|
|
695
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} elideOpts
|
|
690
696
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
691
697
|
* serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
|
|
692
698
|
*/
|
|
@@ -697,6 +703,7 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
697
703
|
let code = elideImportsFromSource(
|
|
698
704
|
annotateServedModule(source, abs, elideOpts), abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
699
705
|
);
|
|
706
|
+
code = deferLazyImports(code, abs, elideOpts);
|
|
700
707
|
// Version same-origin relative import specifiers so the URL the browser
|
|
701
708
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
702
709
|
// A no-op in dev (fingerprinting disabled).
|
|
@@ -711,6 +718,26 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
711
718
|
return new Response(code, { status: 200, headers });
|
|
712
719
|
}
|
|
713
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Serve a side-effect import of a `static lazy` component as a lazy-loader
|
|
723
|
+
* registration (#1524). The URL the loader imports is the one the boot script
|
|
724
|
+
* would use: base-path-prefixed, then content-hashed (`?v=`, a no-op in dev),
|
|
725
|
+
* the same composition as `wrapHead`'s lazy map in `ssr/head.js`. It is
|
|
726
|
+
* computed here because `versionModuleImports` (which runs next) deliberately
|
|
727
|
+
* leaves string literals alone.
|
|
728
|
+
*
|
|
729
|
+
* @param {string} code
|
|
730
|
+
* @param {string} abs
|
|
731
|
+
* @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} opts
|
|
732
|
+
* @returns {string}
|
|
733
|
+
*/
|
|
734
|
+
function deferLazyImports(code, abs, opts) {
|
|
735
|
+
if (!opts.lazyComponentFiles || opts.lazyComponentFiles.size === 0) return code;
|
|
736
|
+
const bp = basePath();
|
|
737
|
+
const urlFor = (file) => withAssetHash(withBasePath(toUrlPath(file, opts.appDir), bp), bp);
|
|
738
|
+
return deferLazyImportsFromSource(code, abs, opts.moduleGraph, opts.lazyComponentFiles, resolveImport, opts.appDir, urlFor);
|
|
739
|
+
}
|
|
740
|
+
|
|
714
741
|
/**
|
|
715
742
|
* Dev source locations (#1499): stamp `data-webjs-src` on the element opening
|
|
716
743
|
* tags of a served app module's `html` templates, the same transform the SSR
|
|
@@ -767,7 +794,7 @@ export async function stripTs(source, _abs) {
|
|
|
767
794
|
*
|
|
768
795
|
* @param {string} abs
|
|
769
796
|
* @param {boolean} dev
|
|
770
|
-
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
|
|
797
|
+
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [elideOpts]
|
|
771
798
|
* @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
|
|
772
799
|
* @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
|
|
773
800
|
* serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
|
|
@@ -841,6 +868,7 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
|
|
|
841
868
|
code = elideImportsFromSource(
|
|
842
869
|
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
843
870
|
);
|
|
871
|
+
code = deferLazyImports(code, abs, elideOpts);
|
|
844
872
|
}
|
|
845
873
|
// Version same-origin relative import specifiers so the URL the browser
|
|
846
874
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
package/src/ssr/preloads.js
CHANGED
|
@@ -35,12 +35,17 @@ export function toUrlPath(file, appDir) {
|
|
|
35
35
|
* are stripped from the served source, so preloading their module would
|
|
36
36
|
* fetch JS the browser never executes.
|
|
37
37
|
*
|
|
38
|
+
* A tag counts as lazy when its class says so at runtime (`isLazy`) OR its
|
|
39
|
+
* file declares `static lazy = true` in source (`lazyFiles`, #1524), the same
|
|
40
|
+
* per-file verdict the served-source rewrite uses, so the two never disagree.
|
|
41
|
+
*
|
|
38
42
|
* @param {Set<string>} usedTags
|
|
39
43
|
* @param {string} appDir
|
|
40
44
|
* @param {Set<string>} [elidable] absolute paths of elidable component files
|
|
45
|
+
* @param {Map<string, string[]>} [lazyFiles] lazy component file -> its tags
|
|
41
46
|
* @returns {{ eager: string[], lazy: Record<string, string> }}
|
|
42
47
|
*/
|
|
43
|
-
export function componentPreloads(usedTags, appDir, elidable) {
|
|
48
|
+
export function componentPreloads(usedTags, appDir, elidable, lazyFiles) {
|
|
44
49
|
const eager = [];
|
|
45
50
|
/** @type {Record<string, string>} */
|
|
46
51
|
const lazy = {};
|
|
@@ -52,7 +57,7 @@ export function componentPreloads(usedTags, appDir, elidable) {
|
|
|
52
57
|
if (!abs.startsWith(appDir)) continue;
|
|
53
58
|
if (elidable && elidable.has(abs)) continue;
|
|
54
59
|
const url = toUrlPath(abs, appDir);
|
|
55
|
-
if (isLazy(tag)) {
|
|
60
|
+
if (isLazy(tag) || (lazyFiles && lazyFiles.has(abs))) {
|
|
56
61
|
lazy[tag] = url;
|
|
57
62
|
} else {
|
|
58
63
|
eager.push(url);
|
package/src/ssr/render.js
CHANGED
|
@@ -1105,13 +1105,23 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1105
1105
|
// registers; one nested behind an emitted component is absent here and
|
|
1106
1106
|
// loads via its importer's own imports. Dedup so a component shared
|
|
1107
1107
|
// across the page and a layout (or two layouts) is emitted once.
|
|
1108
|
+
//
|
|
1109
|
+
// A `static lazy` component in that frontier (#1524) is registered with the
|
|
1110
|
+
// lazy loader instead of imported, exactly as a shipping module's served
|
|
1111
|
+
// source defers its side-effect import of one, so the boot never fetches
|
|
1112
|
+
// it up front and a tag revealed later still loads on first sight.
|
|
1108
1113
|
const inert = opts.inertRouteModules;
|
|
1109
1114
|
const importOnly = opts.importOnlyRouteModules;
|
|
1115
|
+
const lazyFiles = opts.lazyComponentFiles;
|
|
1110
1116
|
const moduleUrls = [];
|
|
1117
|
+
/** @type {Record<string, string>} */
|
|
1118
|
+
const frontierLazy = {};
|
|
1111
1119
|
{
|
|
1112
1120
|
const seen = new Set();
|
|
1113
1121
|
const push = (abs) => {
|
|
1114
1122
|
const u = toUrlPath(abs, opts.appDir);
|
|
1123
|
+
const tags = lazyFiles && lazyFiles.get(abs);
|
|
1124
|
+
if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
|
|
1115
1125
|
if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
|
|
1116
1126
|
};
|
|
1117
1127
|
for (const f of [route.file, ...route.layouts]) {
|
|
@@ -1137,8 +1147,15 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1137
1147
|
// the same preload twice. Lazy components are excluded from
|
|
1138
1148
|
// preloads and instead loaded via IntersectionObserver when they
|
|
1139
1149
|
// enter the viewport.
|
|
1140
|
-
const { eager: eagerComponents, lazy:
|
|
1141
|
-
componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
|
|
1150
|
+
const { eager: eagerComponents, lazy: renderedLazy } =
|
|
1151
|
+
componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents, lazyFiles);
|
|
1152
|
+
const lazyComponents = { ...frontierLazy, ...renderedLazy };
|
|
1153
|
+
// The preload walks skip a lazy component and the subtree reachable only
|
|
1154
|
+
// through it (#1524), the same way they skip an elided one: the browser
|
|
1155
|
+
// does not fetch it until the loader asks.
|
|
1156
|
+
const preloadSkip = lazyFiles && lazyFiles.size
|
|
1157
|
+
? new Set([...(opts.elidableComponents || []), ...lazyFiles.keys()])
|
|
1158
|
+
: opts.elidableComponents;
|
|
1142
1159
|
// The walk roots for BOTH preload passes are the BOOT's actually-shipped
|
|
1143
1160
|
// module set (`moduleUrls`, which already drops inert page/layout modules and
|
|
1144
1161
|
// substitutes an import-only page with its components), NOT the raw route
|
|
@@ -1155,7 +1172,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1155
1172
|
shippedRoots,
|
|
1156
1173
|
opts.appDir,
|
|
1157
1174
|
opts.serverFiles,
|
|
1158
|
-
|
|
1175
|
+
preloadSkip,
|
|
1159
1176
|
);
|
|
1160
1177
|
// Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
|
|
1161
1178
|
// vendor URLs the page's SHIPPED modules actually import, fetched in parallel
|
|
@@ -1169,7 +1186,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1169
1186
|
shippedRoots,
|
|
1170
1187
|
eagerComponents,
|
|
1171
1188
|
opts.appDir,
|
|
1172
|
-
|
|
1189
|
+
preloadSkip,
|
|
1173
1190
|
opts.serverFiles,
|
|
1174
1191
|
),
|
|
1175
1192
|
);
|