@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.74",
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
- function clearCookie(name) {
114
- return `${name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`;
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
 
@@ -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
+ }
@@ -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
- * @returns {Promise<Array<{ tag: string, className: string, moduleUrl: string, file: string }>>}
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
- SCAN_CACHE.set(file, { mtimeMs, size, comps });
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),
@@ -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
- ].map(rel).sort();
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).
@@ -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: lazyComponents } =
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
- opts.elidableComponents,
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
- opts.elidableComponents,
1189
+ preloadSkip,
1173
1190
  opts.serverFiles,
1174
1191
  ),
1175
1192
  );