@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/index.js +18 -0
- package/package.json +2 -1
- package/src/actions.js +105 -11
- package/src/asset-hash.js +266 -0
- package/src/dev.js +87 -16
- package/src/importmap.js +68 -9
- package/src/sitemap.js +161 -0
- package/src/ssr.js +75 -7
- package/src/testing.js +400 -0
- package/src/vendor.js +143 -6
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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':
|
|
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':
|
|
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
|
-
|
|
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)
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
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, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, ''');
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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">
|