@webjsdev/server 0.8.12 → 0.8.14

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/src/dev.js CHANGED
@@ -115,6 +115,7 @@ function shouldAccessLog(pathname) {
115
115
  }
116
116
  import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
117
117
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
118
+ import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash } from './asset-hash.js';
118
119
  import { urlFromRequest } from './forwarded.js';
119
120
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
120
121
  import {
@@ -472,6 +473,15 @@ export async function createRequestHandler(opts) {
472
473
  existsSync(join(distDir, 'webjs-core-browser.js'));
473
474
  await setCoreInstall(coreDir, distComplete);
474
475
 
476
+ // Content-hash asset URLs for immutable caching (issue #243). Bind the
477
+ // asset-hash module to the app + core roots and enable fingerprinting in
478
+ // PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
479
+ // the dev importmap / boot / preload output is byte-identical to before.
480
+ // This runs AFTER setBasePath / setCoreInstall, but those recompute the
481
+ // importmap hash with `{ fingerprint: false }`, so the boot-published build
482
+ // id is a stable deploy fingerprint independent of per-file content hashes.
483
+ setAssetRoots({ appDir, coreDir, enabled: !dev });
484
+
475
485
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
476
486
  // deterministic vendor map that is cheap to read (one file, no analysis, no
477
487
  // network). Resolve it AT BOOT and publish the build id immediately so the
@@ -678,6 +688,25 @@ export async function createRequestHandler(opts) {
678
688
  : { elidableComponents: new Set(), inertRouteModules: new Set() };
679
689
  state.elidableComponents = r.elidableComponents;
680
690
  state.inertRouteModules = r.inertRouteModules;
691
+ // Fold the elision verdict into app-module content hashes (#243): an
692
+ // app module's served body is elision-transformed, so a verdict flip
693
+ // must bust its `?v` even when its source is byte-identical. A stable
694
+ // string of the sorted elidable + inert paths, RELATIVIZED to appDir
695
+ // so the fingerprint is a property of the app's STRUCTURE, not its
696
+ // filesystem location (two deploys at different absolute paths, or
697
+ // two identical apps, produce the same fingerprint). asset-hash
698
+ // digests it into each app-module hash; '' when nothing is elidable,
699
+ // so a no-elision app's hash stays exactly `sha256(bytes)`.
700
+ {
701
+ // `appDir + sep` boundary (matches asset-hash's containment guard)
702
+ // so a sibling-prefix dir cannot be mis-relativized.
703
+ const rel = (p) => (p.startsWith(appDir + sep) ? p.slice(appDir.length) : p);
704
+ const elidedPaths = [
705
+ ...state.elidableComponents,
706
+ ...state.inertRouteModules,
707
+ ].map(rel).sort();
708
+ setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
709
+ }
681
710
  t.elision = now() - m;
682
711
  if (dev) {
683
712
  for (const { className, file } of await findOrphanComponents(appDir)) {
@@ -834,6 +863,11 @@ export async function createRequestHandler(opts) {
834
863
  // best-effort (see emitRouteTypes).
835
864
  if (dev) void emitRouteTypes();
836
865
  clearVendorCache();
866
+ // Content-hash asset cache (#243): clear so a changed file re-hashes and
867
+ // its emitted `?v` busts the stale immutable copy. A no-op in dev (the
868
+ // module is never enabled there), kept for correctness + the deploy-busts
869
+ // regression test, which clears + re-hashes to observe a changed `?v`.
870
+ clearAssetHashCache();
837
871
  state.tsCache.clear();
838
872
  // Invalidate the lazy analysis; the next request rebuilds the graph,
839
873
  // component scan, gate, action index, middleware, elision, and vendor map.
@@ -1131,7 +1165,12 @@ export async function createRequestHandler(opts) {
1131
1165
  // interactivity site-wide. Matched on the decoded path, like handleCore.
1132
1166
  let assetPath = probePath;
1133
1167
  try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
1134
- const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev });
1168
+ // Content-hash fingerprint (#243): a `?v=` query means an immutable,
1169
+ // content-addressed asset url. Detected off the raw request url here
1170
+ // (probePath was parsed from it above).
1171
+ let assetVersioned = false;
1172
+ try { assetVersioned = new URL(req.url).searchParams.has('v'); } catch { /* none */ }
1173
+ const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev, versioned: assetVersioned });
1135
1174
  if (staticResp) return staticResp;
1136
1175
  // Build all whole-app analysis on the first request (memoized), before
1137
1176
  // any SSR, module serve, gate check, action dispatch, or middleware runs.
@@ -1173,7 +1212,14 @@ export async function createRequestHandler(opts) {
1173
1212
  const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
1174
1213
  let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
1175
1214
  const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1176
- return withBasePath(url, basePathValue);
1215
+ // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
1216
+ // the SAME url the body's modulepreload + boot specifiers request (#243).
1217
+ // A bare url would warm a different url and waste the hint. No-op in dev.
1218
+ // Best-effort: a request landing in the narrow pre-warm window (before the
1219
+ // elision verdict is set) hashes against an empty fingerprint, so the hint
1220
+ // can mismatch the post-warm body for that one request, only a wasted
1221
+ // speculative preload, never a stale body (the body url is authoritative).
1222
+ return withAssetHash(withBasePath(url, basePathValue), basePathValue);
1177
1223
  });
1178
1224
  return { moduleUrls };
1179
1225
  }
@@ -1415,11 +1461,15 @@ export async function startServer(opts) {
1415
1461
  *
1416
1462
  * @param {string} path decoded pathname
1417
1463
  * @param {string} method upper-cased HTTP method
1418
- * @param {{ coreDir: string, appDir: string, dev: boolean }} ctx
1464
+ * @param {{ coreDir: string, appDir: string, dev: boolean, versioned?: boolean }} ctx
1465
+ * `versioned` is true when the request carried a `?v=` query (a
1466
+ * content-hash-fingerprinted url, issue #243); the core module is then served
1467
+ * `immutable` (1 year) instead of the 1h fallback, since the hash in the url
1468
+ * busts the cache on the next deploy.
1419
1469
  * @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
1420
1470
  */
1421
1471
  async function tryServeFrameworkStatic(path, method, ctx) {
1422
- const { coreDir, appDir, dev } = ctx;
1472
+ const { coreDir, appDir, dev, versioned } = ctx;
1423
1473
 
1424
1474
  // Dev live-reload client.
1425
1475
  if (path === '/__webjs/reload.js') {
@@ -1451,7 +1501,11 @@ async function tryServeFrameworkStatic(path, method, ctx) {
1451
1501
  if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
1452
1502
  return new Response('forbidden', { status: 403 });
1453
1503
  }
1454
- return fileResponse(abs, { dev, immutable: false });
1504
+ // A `?v=<hash>` request is content-addressed, so serve it `immutable` (#243):
1505
+ // the hash busts the cache on the next core bump, so the regression the
1506
+ // un-versioned 1h cap guards against (a stale immutable core after a bump)
1507
+ // cannot recur. An un-fingerprinted request keeps the 1h fallback.
1508
+ return fileResponse(abs, { dev, immutable: !!versioned });
1455
1509
  }
1456
1510
 
1457
1511
  // Vendor URL handler for `webjs vendor pin --download` mode only. In default
@@ -1494,6 +1548,11 @@ async function handleCore(req, ctx) {
1494
1548
  let path;
1495
1549
  try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
1496
1550
  const method = req.method.toUpperCase();
1551
+ // Content-hash fingerprint (#243): a `?v=` query marks a content-addressed
1552
+ // asset url that may be served `immutable` (the hash busts the cache on a
1553
+ // byte change). The pathname (query stripped by `url.pathname`) resolves the
1554
+ // file as today; only the cache header changes when `?v` is present.
1555
+ const versioned = url.searchParams.has('v');
1497
1556
 
1498
1557
  // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
1499
1558
  // framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
@@ -1501,7 +1560,7 @@ async function handleCore(req, ctx) {
1501
1560
  // so they are not repeated here. This fallback covers the (currently
1502
1561
  // unreachable) case of handleCore being entered for one of those assets, so
1503
1562
  // the routing stays correct if a future caller bypasses the early path.
1504
- const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev });
1563
+ const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev, versioned });
1505
1564
  if (frameworkStatic) return frameworkStatic;
1506
1565
 
1507
1566
  // Internal server-action RPC endpoint
@@ -1559,7 +1618,8 @@ async function handleCore(req, ctx) {
1559
1618
  if (!abs.startsWith(publicRoot)) {
1560
1619
  return new Response(null, { status: 404 });
1561
1620
  }
1562
- if (await exists(abs)) return fileResponse(abs, { dev, immutable: false });
1621
+ // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
1622
+ if (await exists(abs)) return fileResponse(abs, { dev, immutable: versioned });
1563
1623
  }
1564
1624
 
1565
1625
  // User source modules (served as ES modules, with action-file rewriting).
@@ -1631,13 +1691,15 @@ async function handleCore(req, ctx) {
1631
1691
  elidableComponents: state.elidableComponents,
1632
1692
  appDir,
1633
1693
  };
1694
+ // A `?v=<hash>` app-module request is content-addressed -> immutable
1695
+ // (#243); an un-fingerprinted request keeps the 1h fallback.
1634
1696
  if (/\.m?ts$/.test(abs)) {
1635
- return tsResponse(abs, dev, elideOpts, state.tsCache);
1697
+ return tsResponse(abs, dev, elideOpts, state.tsCache, versioned);
1636
1698
  }
1637
1699
  if (/\.m?js$/.test(abs)) {
1638
- return jsModuleResponse(abs, dev, elideOpts);
1700
+ return jsModuleResponse(abs, dev, elideOpts, versioned);
1639
1701
  }
1640
- return fileResponse(abs, { dev, immutable: false });
1702
+ return fileResponse(abs, { dev, immutable: versioned });
1641
1703
  }
1642
1704
  }
1643
1705
 
@@ -1991,8 +2053,10 @@ async function fileResponse(abs, opts) {
1991
2053
  * @param {string} abs
1992
2054
  * @param {boolean} dev
1993
2055
  * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} elideOpts
2056
+ * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
2057
+ * serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
1994
2058
  */
1995
- async function jsModuleResponse(abs, dev, elideOpts) {
2059
+ async function jsModuleResponse(abs, dev, elideOpts, immutable) {
1996
2060
  let source;
1997
2061
  try { source = await readFile(abs, 'utf8'); }
1998
2062
  catch { return new Response('Not found', { status: 404 }); }
@@ -2003,7 +2067,7 @@ async function jsModuleResponse(abs, dev, elideOpts) {
2003
2067
  // weak ETag + 304 (see fileResponse).
2004
2068
  const headers = {
2005
2069
  'content-type': 'application/javascript; charset=utf-8',
2006
- 'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2070
+ 'cache-control': dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600',
2007
2071
  [BUFFERED_MARKER]: '1',
2008
2072
  };
2009
2073
  return new Response(code, { status: 200, headers });
@@ -2049,15 +2113,21 @@ async function stripTs(source, _abs) {
2049
2113
  * @param {boolean} dev
2050
2114
  * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
2051
2115
  * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
2116
+ * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
2117
+ * serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
2118
+ * the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
2052
2119
  */
2053
- async function tsResponse(abs, dev, elideOpts, cache) {
2120
+ async function tsResponse(abs, dev, elideOpts, cache, immutable) {
2121
+ // The body bytes are identical with or without `?v`; only the cache header
2122
+ // changes, so the per-mtime cache stays a single entry.
2123
+ const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
2054
2124
  const st = await stat(abs);
2055
2125
  const cached = cache.get(abs);
2056
2126
  if (cached && cached.mtimeMs === st.mtimeMs) {
2057
2127
  return new Response(cached.code, {
2058
2128
  headers: {
2059
2129
  'content-type': 'application/javascript; charset=utf-8',
2060
- 'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2130
+ 'cache-control': cacheControl,
2061
2131
  [BUFFERED_MARKER]: '1',
2062
2132
  },
2063
2133
  });
@@ -2088,7 +2158,8 @@ async function tsResponse(abs, dev, elideOpts, cache) {
2088
2158
  `webjs is buildless: only erasable TS syntax is supported. ` +
2089
2159
  `Replace enum / namespace / parameter-property / legacy-decorator / ` +
2090
2160
  `import = require constructs with their erasable equivalents. ` +
2091
- `Run \`webjs check\` for guidance (no-non-erasable-typescript rule).`
2161
+ `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
2162
+ `Docs: https://docs.webjs.com/docs/typescript`
2092
2163
  // Prod: terse, no path leak, no Node-message leak (Node's
2093
2164
  // message can include source snippets). Operators get the
2094
2165
  // detail in server logs above.
@@ -2114,7 +2185,7 @@ async function tsResponse(abs, dev, elideOpts, cache) {
2114
2185
  return new Response(code, {
2115
2186
  headers: {
2116
2187
  'content-type': 'application/javascript; charset=utf-8',
2117
- 'cache-control': dev ? 'no-cache' : 'public, max-age=3600',
2188
+ 'cache-control': cacheControl,
2118
2189
  [BUFFERED_MARKER]: '1',
2119
2190
  },
2120
2191
  });
package/src/importmap.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { digestHex } from './crypto-utils.js';
4
4
  import { jsonForScriptTag } from './script-tag-json.js';
5
5
  import { withBasePath } from './base-path.js';
6
+ import { withAssetHash } from './asset-hash.js';
6
7
 
7
8
  // Local attribute escaper. Matches ssr.js's escapeAttr (the source
8
9
  // of truth for HTML attribute escaping in this package). Kept inline
@@ -53,7 +54,7 @@ let _basePath = '';
53
54
  */
54
55
  export async function setBasePath(basePath) {
55
56
  _basePath = basePath || '';
56
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
57
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
57
58
  }
58
59
 
59
60
  /**
@@ -87,7 +88,7 @@ let _vendorIntegrity = {};
87
88
  export async function setVendorEntries(entries, integrity) {
88
89
  _extraEntries = entries;
89
90
  _vendorIntegrity = integrity || {};
90
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
91
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
91
92
  }
92
93
 
93
94
  /**
@@ -228,7 +229,7 @@ let _coreEntries = {
228
229
  */
229
230
  export async function setCoreInstall(coreDir, distMode) {
230
231
  _coreEntries = buildCoreEntries(coreDir, !!distMode);
231
- _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
232
+ _importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
232
233
  }
233
234
 
234
235
  /**
@@ -319,7 +320,21 @@ export function buildCoreEntries(coreDir, distMode) {
319
320
  return out;
320
321
  }
321
322
 
322
- export function buildImportMap() {
323
+ /**
324
+ * Build the import map object.
325
+ *
326
+ * @param {{ fingerprint?: boolean }} [opts] When `fingerprint` is true (the
327
+ * default, used by the SERVED map in `importMapTag`), same-origin targets
328
+ * get a `?v=<content-hash>` suffix for immutable caching (issue #243); a
329
+ * cross-origin CDN target is left untouched (`withAssetHash` skips it, so
330
+ * its SRI key is unchanged). When false (used by the internal
331
+ * `importMapHash()` computation), no `?v` is appended, so the published
332
+ * build id stays a STABLE deploy fingerprint independent of per-file
333
+ * content hashes (the build id only needs to be stable within a deploy).
334
+ * In dev, `withAssetHash` is a no-op regardless, so both forms are equal.
335
+ */
336
+ export function buildImportMap(opts = {}) {
337
+ const fingerprint = opts.fingerprint !== false;
323
338
  const merged = {
324
339
  ..._coreEntries,
325
340
  ..._extraEntries,
@@ -333,11 +348,18 @@ export function buildImportMap() {
333
348
  /** @type {Record<string, string>} */
334
349
  const imports = {};
335
350
  // Prefix every same-origin absolute target with the sub-path base
336
- // (issue #256). `withBasePath` is a no-op when the base path is empty
337
- // (so a root-mounted app's map is byte-identical) and leaves a
338
- // cross-origin `https://` CDN vendor target untouched (only the
339
- // framework's own `/__webjs/*` and same-origin vendor targets move).
340
- for (const k of Object.keys(merged).sort()) imports[k] = withBasePath(merged[k], _basePath);
351
+ // (issue #256), THEN (issue #243) append `?v=<content-hash>` to a
352
+ // same-origin target for immutable caching. The order is basePath then
353
+ // `?v`, so a sub-path app emits `<basePath>/app/foo.js?v=hash`.
354
+ // `withBasePath` is a no-op when the base path is empty and leaves a
355
+ // cross-origin `https://` CDN vendor target untouched; `withAssetHash`
356
+ // is a no-op in dev / when disabled and also leaves a cross-origin target
357
+ // untouched (only the framework's own `/__webjs/*` core + same-origin
358
+ // app/public targets get a `?v`).
359
+ for (const k of Object.keys(merged).sort()) {
360
+ const based = withBasePath(merged[k], _basePath);
361
+ imports[k] = fingerprint ? withAssetHash(based, _basePath) : based;
362
+ }
341
363
 
342
364
  // Emit `integrity` per the importmap-integrity spec (Chrome 132+,
343
365
  // Safari 18.4+, Firefox flagged). Browsers without support ignore
@@ -366,6 +388,43 @@ export function buildImportMap() {
366
388
  return out;
367
389
  }
368
390
 
391
+ /**
392
+ * Derive the cross-origin vendor CDN origins from the resolved vendor
393
+ * importmap, most-common first (issue #243, auto vendor preconnect). For an
394
+ * UNPINNED app resolving vendors live from a cross-origin CDN (ga.jspm.io,
395
+ * or jsdelivr / unpkg / skypack with `--from`), this returns that origin so
396
+ * the head can warm DNS + TLS + TCP before the importmap resolves. A
397
+ * same-origin PINNED app (vendors served from `/__webjs/vendor/*` on the
398
+ * app's own origin), or an app with no cross-origin vendors, returns `[]`.
399
+ *
400
+ * Derived from the resolved targets (`_extraEntries`, the vendor map), NOT a
401
+ * hardcoded string, so a `--from jsdelivr` app preconnects to jsdelivr. The
402
+ * origins are deduped + sorted by frequency (the primary CDN first) and the
403
+ * list is bounded so a map spanning several CDNs cannot emit an unbounded
404
+ * preconnect set.
405
+ *
406
+ * @param {number} [max] cap on the number of origins returned (default 2)
407
+ * @returns {string[]} e.g. `['https://ga.jspm.io']`
408
+ */
409
+ export function vendorPreconnectOrigins(max = 2) {
410
+ /** @type {Map<string, number>} */
411
+ const counts = new Map();
412
+ for (const target of Object.values(_extraEntries)) {
413
+ if (typeof target !== 'string') continue;
414
+ // Only an absolute `scheme://host` cross-origin url has an origin to
415
+ // preconnect. A same-origin `/__webjs/vendor/*` (downloaded-pin) target
416
+ // is `/`-rooted and skipped (no cross-origin to warm).
417
+ if (!/^https?:\/\//i.test(target)) continue;
418
+ let origin;
419
+ try { origin = new URL(target).origin; } catch { continue; }
420
+ counts.set(origin, (counts.get(origin) || 0) + 1);
421
+ }
422
+ return [...counts.entries()]
423
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
424
+ .slice(0, Math.max(0, max))
425
+ .map(([origin]) => origin);
426
+ }
427
+
369
428
  /**
370
429
  * Serialise the import map to an HTML script tag string.
371
430
  *
package/src/sitemap.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Sitemap helpers (issue #276). Two pure functions that turn an array of
3
+ * entries into spec-valid XML, so an author of `app/sitemap.{js,ts}` does not
4
+ * hand-roll the `<urlset>` string (escaping URLs, formatting lastmod dates) and
5
+ * has a built-in path to shard a large site via a `<sitemapindex>`.
6
+ *
7
+ * Both are OPTIONAL and tiny. The dev server serves a STRING returned from a
8
+ * `sitemap.{js,ts}` default export as `application/xml`, so the author returns
9
+ * `sitemap([...])` from that function. They can still return a raw Response or a
10
+ * hand-built string, so this is thin XML serialization, not a sitemap framework.
11
+ *
12
+ * Reference: the sitemaps.org 0.9 protocol; Next.js `sitemap.ts` (a typed array
13
+ * serialized to XML) and `generateSitemaps()` (the index for sharding).
14
+ */
15
+
16
+ /** Per-URL entry for {@link sitemap}.
17
+ * @typedef {object} SitemapEntry
18
+ * @property {string} url Absolute URL (REQUIRED). XML-escaped on emit.
19
+ * @property {string|Date} [lastModified] Last-modified time, formatted as W3C
20
+ * datetime (ISO 8601). A Date is `toISOString()`'d; a string passes through.
21
+ * @property {'always'|'hourly'|'daily'|'weekly'|'monthly'|'yearly'|'never'} [changeFrequency]
22
+ * How often the page changes. An invalid value is dropped (not emitted).
23
+ * @property {number} [priority] Priority 0.0..1.0. Out-of-range / non-numeric
24
+ * is dropped.
25
+ */
26
+
27
+ /** Child-sitemap entry for {@link sitemapIndex}.
28
+ * @typedef {object} SitemapIndexEntry
29
+ * @property {string} url Absolute URL of a child sitemap (REQUIRED).
30
+ * @property {string|Date} [lastModified] Last-modified time (W3C datetime).
31
+ */
32
+
33
+ const VALID_CHANGEFREQ = new Set([
34
+ 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never',
35
+ ]);
36
+
37
+ const MAX_URLS = 50000;
38
+
39
+ /**
40
+ * XML-escape text destined for an element body. This is the security-critical
41
+ * part: a url derived from author data or a DB can contain `&` or `<`, which
42
+ * would break the XML document or allow injection if emitted raw. The five
43
+ * predefined XML entities are replaced (`&` first, so the entities the other
44
+ * replacements introduce are not double-escaped).
45
+ * @param {string} value
46
+ * @returns {string}
47
+ */
48
+ function escapeXml(value) {
49
+ return String(value)
50
+ // Strip the C0 control characters XML 1.0 forbids (everything below 0x20
51
+ // except tab, newline, and carriage return). A stray one from a corrupt
52
+ // DB-derived URL would otherwise make the WHOLE sitemap document invalid
53
+ // and silently rejected by search engines, the exact failure this helper
54
+ // exists to prevent. Done before entity-escaping so it cannot interfere.
55
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
56
+ .replace(/&/g, '&amp;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&apos;');
61
+ }
62
+
63
+ /**
64
+ * Format a `lastModified` value as a W3C datetime (ISO 8601) string. A Date
65
+ * becomes its `toISOString()`; a non-empty string passes through verbatim (the
66
+ * spec accepts both `YYYY-MM-DD` and a full timestamp). Anything else yields
67
+ * null so the `<lastmod>` element is omitted rather than emitted broken.
68
+ * @param {string|Date|undefined} value
69
+ * @returns {string|null}
70
+ */
71
+ function formatLastmod(value) {
72
+ if (value instanceof Date) {
73
+ const ms = value.getTime();
74
+ if (Number.isNaN(ms)) return null;
75
+ return value.toISOString();
76
+ }
77
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Validate + format a `priority` to one decimal in the 0.0..1.0 range. Returns
83
+ * null (so the element is dropped) for a non-number or an out-of-range value.
84
+ * @param {number|undefined} value
85
+ * @returns {string|null}
86
+ */
87
+ function formatPriority(value) {
88
+ if (typeof value !== 'number' || Number.isNaN(value)) return null;
89
+ if (value < 0 || value > 1) return null;
90
+ return value.toFixed(1);
91
+ }
92
+
93
+ /**
94
+ * Serialize an array of {@link SitemapEntry} into a complete `<urlset>` XML
95
+ * document. Element order per the protocol is loc, lastmod, changefreq,
96
+ * priority. A malformed entry (missing / non-string / empty `url`) is skipped so
97
+ * it cannot produce broken XML. Output is deterministic.
98
+ * @param {SitemapEntry[]} entries
99
+ * @returns {string} a `<?xml ?>` + `<urlset>` document
100
+ */
101
+ export function sitemap(entries) {
102
+ const list = Array.isArray(entries) ? entries : [];
103
+ if (list.length > MAX_URLS) {
104
+ console.warn(
105
+ `[webjs] sitemap() received ${list.length} URLs, over the ${MAX_URLS} per-file limit. ` +
106
+ 'Shard the site with sitemapIndex() instead. Emitting all URLs anyway.'
107
+ );
108
+ }
109
+ const urls = [];
110
+ for (const entry of list) {
111
+ if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
112
+ console.warn('[webjs] sitemap(): skipping an entry with a missing or invalid url');
113
+ continue;
114
+ }
115
+ const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
116
+ const lastmod = formatLastmod(entry.lastModified);
117
+ if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
118
+ if (typeof entry.changeFrequency === 'string' && VALID_CHANGEFREQ.has(entry.changeFrequency)) {
119
+ parts.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
120
+ }
121
+ const priority = formatPriority(entry.priority);
122
+ if (priority !== null) parts.push(` <priority>${priority}</priority>`);
123
+ urls.push(` <url>\n${parts.join('\n')}\n </url>`);
124
+ }
125
+ return (
126
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
127
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
128
+ (urls.length ? urls.join('\n') + '\n' : '') +
129
+ '</urlset>\n'
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Serialize an array of {@link SitemapIndexEntry} into a `<sitemapindex>` XML
135
+ * document, one `<sitemap>` per child sitemap URL. Same escaping + lastmod rules
136
+ * and same skip-malformed-entry behavior as {@link sitemap}. Use it from the
137
+ * root `app/sitemap.{js,ts}` to point at sharded child sitemaps served by
138
+ * `route.{js,ts}` handlers.
139
+ * @param {SitemapIndexEntry[]} sitemaps
140
+ * @returns {string} a `<?xml ?>` + `<sitemapindex>` document
141
+ */
142
+ export function sitemapIndex(sitemaps) {
143
+ const list = Array.isArray(sitemaps) ? sitemaps : [];
144
+ const items = [];
145
+ for (const entry of list) {
146
+ if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
147
+ console.warn('[webjs] sitemapIndex(): skipping an entry with a missing or invalid url');
148
+ continue;
149
+ }
150
+ const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
151
+ const lastmod = formatLastmod(entry.lastModified);
152
+ if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
153
+ items.push(` <sitemap>\n${parts.join('\n')}\n </sitemap>`);
154
+ }
155
+ return (
156
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
157
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
158
+ (items.length ? items.join('\n') + '\n' : '') +
159
+ '</sitemapindex>\n'
160
+ );
161
+ }
package/src/ssr.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
+ import { withAssetHash } from './asset-hash.js';
6
7
  import { jsonForScriptTag } from './script-tag-json.js';
7
8
  import { readToken, newToken, cookieHeader } from './csrf.js';
8
9
  import { transitiveDeps } from './module-graph.js';
@@ -786,8 +787,14 @@ function wrapHead(opts) {
786
787
  // (read from importmap.js's module state), the same value the importmap
787
788
  // targets were prefixed with, so the boot specifiers and the map agree.
788
789
  const bp = basePath();
790
+ // Content-hash asset URLs (issue #243): after base-path-prefixing, append
791
+ // `?v=<hash>` to a same-origin module specifier for immutable caching. A
792
+ // no-op in dev (so the boot script is byte-identical) and for a bare/
793
+ // cross-origin specifier; same compose order as the importmap targets
794
+ // (basePath then `?v`).
795
+ const fp = (u) => withAssetHash(withBasePath(u, bp), bp);
789
796
  const imports = opts.moduleUrls
790
- .map((u) => `import ${jsonForScriptTag(withBasePath(u, bp))};`)
797
+ .map((u) => `import ${jsonForScriptTag(fp(u))};`)
791
798
  .join('\n');
792
799
  const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
793
800
  ? opts.lazyComponents
@@ -795,9 +802,14 @@ function wrapHead(opts) {
795
802
  // The lazy map's values are same-origin module URLs `observeLazy` will
796
803
  // dynamically import, so prefix them with the base path too (no-op when
797
804
  // empty).
798
- const lazyEntries = rawLazyEntries && bp
805
+ // The lazy map's values are same-origin module URLs `observeLazy` will
806
+ // dynamically import, so base-path-prefix AND content-hash them (#243), the
807
+ // same as the eager boot specifiers. `fp` is a pure no-op in dev and at the
808
+ // root mount with fingerprinting off, so the mapped map equals the raw one
809
+ // byte-for-byte there; only prod fingerprinting / a sub-path mount changes it.
810
+ const lazyEntries = rawLazyEntries
799
811
  ? Object.fromEntries(
800
- Object.entries(rawLazyEntries).map(([tag, u]) => [tag, withBasePath(u, bp)]),
812
+ Object.entries(rawLazyEntries).map(([tag, u]) => [tag, fp(u)]),
801
813
  )
802
814
  : rawLazyEntries;
803
815
  const lazyBoot = lazyEntries
@@ -1102,16 +1114,20 @@ function wrapHead(opts) {
1102
1114
  // decided on the ORIGINAL url, so the integrity lookup still keys on the
1103
1115
  // unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
1104
1116
  // crossorigin attribute.
1105
- const bpPreload = basePath();
1117
+ // Content-hash (#243): the href additionally gets a `?v=<hash>` after the
1118
+ // base-path prefix (a no-op in dev / for a cross-origin url), but
1119
+ // `crossorigin` / `integrity` are still decided on the ORIGINAL url, so the
1120
+ // integrity lookup keys on the unprefixed/unhashed map url and a cross-origin
1121
+ // CDN url (never prefixed, never hashed) keeps its crossorigin attribute.
1106
1122
  for (const url of opts.moduleUrls) {
1107
1123
  linkTags.push(
1108
- `<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
1124
+ `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1109
1125
  `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1110
1126
  );
1111
1127
  }
1112
1128
  for (const url of opts.preloads || []) {
1113
1129
  linkTags.push(
1114
- `<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
1130
+ `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1115
1131
  `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1116
1132
  );
1117
1133
  }
@@ -1125,6 +1141,58 @@ function wrapHead(opts) {
1125
1141
  }
1126
1142
  }
1127
1143
 
1144
+ // preconnect / dns-prefetch hints (issue #243). `metadata.preconnect` and
1145
+ // `metadata.dnsPrefetch` each take a URL string, `{ url, crossorigin? }`, or
1146
+ // an array of those. A preconnect warms DNS + TLS + TCP; dns-prefetch only
1147
+ // resolves DNS (no crossorigin). The framework ALSO auto-emits ONE preconnect
1148
+ // to the resolved vendor CDN origin for an unpinned cross-origin app, so the
1149
+ // browser warms that connection before the importmap resolves. The author's
1150
+ // declared origins are tracked so the auto one is not a duplicate.
1151
+ /** @type {Set<string>} the origins the author already declared a preconnect to */
1152
+ const declaredPreconnectOrigins = new Set();
1153
+ /** @param {unknown} hint @returns {{ url: string, crossorigin?: string|boolean } | null} */
1154
+ const normalizeHint = (hint) => {
1155
+ if (typeof hint === 'string') return hint ? { url: hint } : null;
1156
+ if (hint && typeof hint === 'object' && typeof (/** @type {any} */ (hint).url) === 'string') {
1157
+ return /** @type {any} */ (hint);
1158
+ }
1159
+ return null;
1160
+ };
1161
+ /** @param {unknown} value @returns {Array<{ url: string, crossorigin?: string|boolean }>} */
1162
+ const toHints = (value) => {
1163
+ if (value == null) return [];
1164
+ const list = Array.isArray(value) ? value : [value];
1165
+ const out = [];
1166
+ for (const h of list) {
1167
+ const n = normalizeHint(h);
1168
+ if (n) out.push(n);
1169
+ }
1170
+ return out;
1171
+ };
1172
+ /** @param {string|boolean|undefined} co @returns {string} */
1173
+ const crossoriginAttr = (co) => {
1174
+ if (co === undefined || co === false) return '';
1175
+ if (co === true || co === '') return ' crossorigin';
1176
+ return ` crossorigin="${escapeAttr(String(co))}"`;
1177
+ };
1178
+ for (const h of toHints(m.preconnect)) {
1179
+ try { declaredPreconnectOrigins.add(new URL(h.url).origin); } catch { /* relative / opaque; track by raw href instead */ declaredPreconnectOrigins.add(h.url); }
1180
+ linkTags.push(`<link rel="preconnect" href="${escapeAttr(h.url)}"${crossoriginAttr(h.crossorigin)}>`);
1181
+ }
1182
+ for (const h of toHints(m.dnsPrefetch)) {
1183
+ // dns-prefetch never carries crossorigin (it only resolves DNS).
1184
+ linkTags.push(`<link rel="dns-prefetch" href="${escapeAttr(h.url)}">`);
1185
+ }
1186
+ // Auto vendor preconnect: warm the cross-origin vendor CDN connection for an
1187
+ // unpinned app. Deduped against an author-declared preconnect to the same
1188
+ // origin; emits none for a same-origin pinned app or one with no cross-origin
1189
+ // vendors (vendorPreconnectOrigins returns []). crossorigin is required (the
1190
+ // importmap fetches the module as a CORS request).
1191
+ for (const origin of vendorPreconnectOrigins()) {
1192
+ if (declaredPreconnectOrigins.has(origin)) continue;
1193
+ linkTags.push(`<link rel="preconnect" href="${escapeAttr(origin)}" crossorigin>`);
1194
+ }
1195
+
1128
1196
  // icons: { icon, apple, shortcut, other }. Each entry can be a string
1129
1197
  // (URL), an object { url, sizes?, type? }, or an array of those.
1130
1198
  // - icon → <link rel="icon">