@webjsdev/server 0.8.13 → 0.8.15
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.d.ts +650 -0
- package/package.json +15 -4
- package/src/actions.js +2 -1
- package/src/asset-hash.js +266 -0
- package/src/check.d.ts +38 -0
- package/src/dev.js +87 -16
- package/src/importmap.js +68 -9
- package/src/ssr.js +75 -7
- package/src/testing.d.ts +123 -0
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/check.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type overlay for `@webjsdev/server/check` (the convention validator backing
|
|
3
|
+
* `webjs check`, exported on its own subpath so the CLI can load it without
|
|
4
|
+
* booting the full server).
|
|
5
|
+
*
|
|
6
|
+
* The runtime is packages/server/src/check.js (JSDoc-annotated JavaScript);
|
|
7
|
+
* this overlay exists so a TypeScript consumer's `import { checkConventions }
|
|
8
|
+
* from '@webjsdev/server/check'` resolves precise types instead of emitting
|
|
9
|
+
* TS7016. Zero runtime cost.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single convention violation reported by `checkConventions`. */
|
|
13
|
+
export interface Violation {
|
|
14
|
+
/** The rule name that fired (e.g. `no-browser-globals-in-render`). */
|
|
15
|
+
rule: string;
|
|
16
|
+
/** The offending file path. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** What is wrong. */
|
|
19
|
+
message: string;
|
|
20
|
+
/** How to fix it. */
|
|
21
|
+
fix: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A rule's name + human description, for help text / docs. */
|
|
25
|
+
export interface RuleDescriptor {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** All available correctness rules with descriptions. */
|
|
31
|
+
export declare const RULES: RuleDescriptor[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run every correctness check against an app directory, returning the list of
|
|
35
|
+
* violations (empty when the app is clean). Correctness-only; report-only, no
|
|
36
|
+
* autofix.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkConventions(appDir: string): Promise<Violation[]>;
|
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
|
});
|