@webjsdev/server 0.8.13 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.13",
3
+ "version": "0.8.14",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -301,7 +301,8 @@ export function serveServerOnlyStub(relPath) {
301
301
  `Cannot import "${relPath}" from browser code. ` +
302
302
  `This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
303
303
  `Either add 'use server' at the top of the file to expose its exports as RPC, ` +
304
- `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead.`;
304
+ `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
305
+ `Docs: https://docs.webjs.com/docs/server-actions`;
305
306
  return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
306
307
  throw new Error(${JSON.stringify(msg)});
307
308
  `;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Content-hash asset URLs for immutable caching (issue #243, feature 1).
3
+ *
4
+ * The problem: every served app module (`.js` / `.ts`) and `public/` asset
5
+ * ships `Cache-Control: public, max-age=3600` because its URL is
6
+ * un-versioned. The framework cannot serve them `immutable` (1 year) without
7
+ * a per-file fingerprint, because a deploy that changes a module's bytes
8
+ * keeps the SAME url, so a year-long immutable copy at an edge CDN would
9
+ * silently brick the next deploy (a real regression, see the core-serve note
10
+ * in `dev.js`). The importmap build id (`publishedBuildId`) does NOT change
11
+ * on an app-module byte change, so it cannot be the per-asset fingerprint.
12
+ *
13
+ * The fix: a PER-FILE content hash computed at serve time (no build step).
14
+ *
15
+ * 1. `withAssetHash(url)` appends `?v=<hash>` to a framework-emitted
16
+ * SAME-ORIGIN absolute url (the importmap targets, the modulepreload
17
+ * hrefs, the boot script specifiers). It is a NO-OP in DEV (so dev
18
+ * output is byte-identical to before), a NO-OP for a CROSS-ORIGIN url
19
+ * (a `https://` jspm vendor target, which jspm already versions and
20
+ * which carries SRI keyed by the un-hashed url), and for a same-origin
21
+ * `/`-rooted url it resolves the underlying file, looks up its hash, and
22
+ * appends `?v=<hash>`.
23
+ *
24
+ * 2. The serve path (`dev.js`) serves any request carrying a `?v` query
25
+ * `Cache-Control: public, max-age=31536000, immutable`. An un-fingerprinted
26
+ * request keeps the 1h fallback. Dev stays `no-cache` regardless.
27
+ *
28
+ * Deploy-busts invariant: a deploy that changes a module's bytes changes its
29
+ * hash, so its emitted url changes, so a returning client fetches the new url
30
+ * instead of serving the stale immutable copy.
31
+ *
32
+ * The hash is a short prefix of a sha-256 over the file BYTES, computed
33
+ * synchronously (so the emit hot path stays sync) and memoized in a
34
+ * `Map<absPath, hash>`. The cache is cleared on the fs.watch rebuild (dev) so
35
+ * a changed file re-hashes, wired next to `clearVendorCache` in `dev.js`.
36
+ *
37
+ * @module asset-hash
38
+ */
39
+
40
+ import { createHash } from 'node:crypto';
41
+ import { readFileSync } from 'node:fs';
42
+ import { join, resolve, sep } from 'node:path';
43
+
44
+ /**
45
+ * Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
46
+ * distinguish builds of one file while keeping the url short. Collisions
47
+ * across DIFFERENT bytes of the SAME url are the only failure mode, and at
48
+ * 48 bits that is astronomically unlikely; a same-bytes-same-hash is exactly
49
+ * the desired behaviour.
50
+ *
51
+ * @type {number}
52
+ */
53
+ const HASH_LEN = 12;
54
+
55
+ /**
56
+ * Whether fingerprinting is active. Off in dev (so dev output is
57
+ * byte-identical to before this feature) and off until `setAssetRoots`
58
+ * binds the app + core roots. Both gates make `withAssetHash` a pure
59
+ * pass-through, which is what keeps the inert case byte-identical.
60
+ *
61
+ * @type {boolean}
62
+ */
63
+ let _enabled = false;
64
+
65
+ /** @type {string} */
66
+ let _appDir = '';
67
+ /** @type {string} */
68
+ let _coreDir = '';
69
+
70
+ /**
71
+ * A fingerprint of the current elision verdict (the elidable-component +
72
+ * inert-route set), folded into an APP MODULE's hash. An app module's SERVED
73
+ * body is not its raw source: `dev.js` runs `elideImportsFromSource` over it,
74
+ * stripping a side-effect import of a display-only component. That strip is a
75
+ * property of the IMPORTED component's verdict, not of the importer's own
76
+ * bytes, so when a component flips display-only <-> interactive the importer's
77
+ * served body changes while its source is byte-identical. Hashing the source
78
+ * alone would keep the same `?v`, and a returning client would hold the stale
79
+ * immutable copy (the now-interactive component never imported, so never
80
+ * hydrated) for the full immutable TTL. Folding the verdict fingerprint into
81
+ * the hash busts every app module's url on a verdict change, so the stale-copy
82
+ * window cannot open. Empty when no module is elidable (then an app module's
83
+ * hash is exactly `sha256(bytes)`), and only mixed for files under `_appDir`
84
+ * (core / public assets are never elision-transformed). Set by `dev.js`'s
85
+ * `ensureReady` after `analyzeElision`, re-set on every rebuild.
86
+ *
87
+ * @type {string}
88
+ */
89
+ let _elisionFp = '';
90
+
91
+ /**
92
+ * Memoized per-file content hash. Keyed by absolute path. Cleared on the
93
+ * fs.watch rebuild so a changed file re-hashes.
94
+ *
95
+ * @type {Map<string, string>}
96
+ */
97
+ const _hashCache = new Map();
98
+
99
+ /**
100
+ * Bind the asset-hash module to the app + core install roots and enable
101
+ * fingerprinting. Called once at boot by `dev.js` for a PROD server only
102
+ * (`dev === false`); a dev server never calls this, so `_enabled` stays
103
+ * false and `withAssetHash` is a pure no-op.
104
+ *
105
+ * @param {{ appDir: string, coreDir: string, enabled: boolean }} opts
106
+ * @returns {void}
107
+ */
108
+ export function setAssetRoots(opts) {
109
+ _appDir = opts.appDir || '';
110
+ _coreDir = opts.coreDir || '';
111
+ _enabled = !!opts.enabled && !!_appDir && !!_coreDir;
112
+ }
113
+
114
+ /**
115
+ * Set the elision-verdict fingerprint folded into app-module hashes (see
116
+ * `_elisionFp`). A no-op when the value is unchanged; on a change it clears the
117
+ * hash cache so every app module re-hashes against the new verdict. `dev.js`
118
+ * calls this from `ensureReady` after `analyzeElision` (and again on each
119
+ * rebuild), passing a stable digest of the elidable-component + inert-route set
120
+ * (or `''` when nothing is elidable).
121
+ *
122
+ * @param {string} fp
123
+ * @returns {void}
124
+ */
125
+ export function setElisionFingerprint(fp) {
126
+ const next = fp || '';
127
+ if (next === _elisionFp) return;
128
+ _elisionFp = next;
129
+ _hashCache.clear();
130
+ }
131
+
132
+ /**
133
+ * Clear the per-file hash cache. Wired into the dev rebuild path next to
134
+ * `clearVendorCache` so a changed file re-hashes (dev never fingerprints,
135
+ * but a future serve that re-derives the map after a rebuild stays correct,
136
+ * and tests rely on the clear to observe a re-hash after a byte change).
137
+ *
138
+ * @returns {void}
139
+ */
140
+ export function clearAssetHashCache() {
141
+ _hashCache.clear();
142
+ }
143
+
144
+ /**
145
+ * Compute (and memoize) the short content hash for an absolute file path.
146
+ * Returns `''` when the file cannot be read, so the caller emits the url
147
+ * UNCHANGED (no `?v`), failing safe to the 1h fallback rather than breaking
148
+ * the url.
149
+ *
150
+ * @param {string} absPath
151
+ * @returns {string} the short hex hash, or `''` on a read failure
152
+ */
153
+ export function assetHashFor(absPath) {
154
+ const cached = _hashCache.get(absPath);
155
+ if (cached !== undefined) return cached;
156
+ try {
157
+ const bytes = readFileSync(absPath);
158
+ const h = createHash('sha256').update(bytes);
159
+ // An app module's served body is elision-transformed, so fold the verdict
160
+ // fingerprint in (see `_elisionFp`). Core / public files are never
161
+ // transformed, so they hash over their bytes alone. Empty fp leaves an app
162
+ // module's hash at exactly `sha256(bytes)`.
163
+ if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)) {
164
+ h.update('\0');
165
+ h.update(_elisionFp);
166
+ }
167
+ const hash = h.digest('hex').slice(0, HASH_LEN);
168
+ _hashCache.set(absPath, hash);
169
+ return hash;
170
+ } catch {
171
+ // A transient read failure is NOT memoized (returning '' fails safe to the
172
+ // 1h fallback), so a later emit re-attempts instead of pinning the file to
173
+ // the fallback for the process lifetime.
174
+ return '';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve a framework-emitted same-origin absolute url to the absolute file
180
+ * path it serves, or `null` when the url does not map to a fingerprintable
181
+ * same-origin file. Mirrors the serve-path resolution in `dev.js`:
182
+ * - `/__webjs/core/<rel>` -> `<coreDir>/<rel>` (the core runtime)
183
+ * - any other `/<rel>` -> `<appDir>/<rel>` (app modules + public/)
184
+ *
185
+ * Containment-checked against the respective root (the same trailing-separator
186
+ * boundary guard the serve path uses), so a `..`-laden url that escapes the
187
+ * root resolves to `null` and is emitted unchanged.
188
+ *
189
+ * The `?v` query / `#hash` are stripped before resolution (a freshly-emitted
190
+ * url never carries one, but stay robust). A cross-origin / protocol-relative
191
+ * / relative url is NOT a `/`-rooted same-origin path and returns `null`.
192
+ *
193
+ * @param {string} url
194
+ * @returns {string | null}
195
+ */
196
+ function resolveUrlToFile(url) {
197
+ // Only a single-leading-slash same-origin path. A protocol-relative
198
+ // `//host` or an absolute `scheme://` url (a vendor CDN target) is not ours.
199
+ if (typeof url !== 'string' || url[0] !== '/' || url[1] === '/') return null;
200
+ // Strip any query / hash before mapping to a file.
201
+ let pathname = url;
202
+ const q = pathname.indexOf('?');
203
+ if (q !== -1) pathname = pathname.slice(0, q);
204
+ const h = pathname.indexOf('#');
205
+ if (h !== -1) pathname = pathname.slice(0, h);
206
+ let decoded = pathname;
207
+ try { decoded = decodeURIComponent(pathname); } catch { /* keep raw */ }
208
+
209
+ const CORE_PREFIX = '/__webjs/core/';
210
+ if (decoded.startsWith(CORE_PREFIX)) {
211
+ const rel = decoded.slice(CORE_PREFIX.length);
212
+ const abs = resolve(_coreDir, rel);
213
+ if (abs !== _coreDir && !abs.startsWith(_coreDir + sep)) return null;
214
+ return abs;
215
+ }
216
+ // A `/__webjs/*` path that is not a core module (e.g. a downloaded
217
+ // `/__webjs/vendor/<pkg>@<ver>.js` bundle) is already version-named, so it
218
+ // does not need fingerprinting and is left unchanged.
219
+ if (decoded.startsWith('/__webjs/')) return null;
220
+
221
+ const abs = join(_appDir, decoded);
222
+ if (abs !== _appDir && !abs.startsWith(_appDir + sep)) return null;
223
+ return abs;
224
+ }
225
+
226
+ /**
227
+ * Append `?v=<content-hash>` to a framework-emitted same-origin absolute url
228
+ * for immutable caching. Composes with `withBasePath`: call ORDER is basePath
229
+ * then `withAssetHash` (the `?v` is a query the ingress base-path strip never
230
+ * touches, and the file resolution here works on the post-basePath url because
231
+ * a `<basePath>/app/foo.js` still resolves under `_appDir` only after the
232
+ * basePath is stripped, so callers apply `withAssetHash` to the ALREADY
233
+ * base-path-prefixed url and pass the base path so we strip it for resolution).
234
+ *
235
+ * No-op when:
236
+ * - fingerprinting is disabled (dev, or roots unset) -> byte-identical output;
237
+ * - the url is cross-origin / protocol-relative / relative (a vendor CDN
238
+ * target keeps its exact url, so its SRI key, computed over the un-hashed
239
+ * url in #235, still matches, and jspm already versions it);
240
+ * - the url does not resolve to a readable same-origin file (fail-safe to the
241
+ * 1h fallback).
242
+ *
243
+ * @param {string} url the (possibly base-path-prefixed) same-origin url
244
+ * @param {string} [basePath] the active base path, stripped before file resolution
245
+ * @returns {string}
246
+ */
247
+ export function withAssetHash(url, basePath = '') {
248
+ if (!_enabled) return url;
249
+ if (typeof url !== 'string' || url[0] !== '/' || url[1] === '/') return url;
250
+ // Resolve against the file system using the base-path-stripped url, so a
251
+ // sub-path-deployed `<basePath>/app/foo.js` maps to `<appDir>/app/foo.js`.
252
+ let forResolve = url;
253
+ if (basePath && url.startsWith(basePath + '/')) {
254
+ forResolve = url.slice(basePath.length);
255
+ } else if (basePath && url === basePath) {
256
+ forResolve = '/';
257
+ }
258
+ const abs = resolveUrlToFile(forResolve);
259
+ if (!abs) return url;
260
+ const hash = assetHashFor(abs);
261
+ if (!hash) return url;
262
+ // The freshly-emitted url never already carries a query, but stay robust:
263
+ // merge with `&` if one is somehow present.
264
+ const sepChar = url.includes('?') ? '&' : '?';
265
+ return `${url}${sepChar}v=${hash}`;
266
+ }
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/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">