@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/index.js
CHANGED
|
@@ -27,6 +27,7 @@ export {
|
|
|
27
27
|
listPinned,
|
|
28
28
|
auditPinned,
|
|
29
29
|
findOutdated,
|
|
30
|
+
hasVendorPin,
|
|
30
31
|
updatePinned,
|
|
31
32
|
readPinFile,
|
|
32
33
|
serveDownloadedBundle,
|
|
@@ -46,8 +47,25 @@ export { revalidatePath, revalidateAll } from './src/html-cache.js';
|
|
|
46
47
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
|
47
48
|
export { broadcast } from './src/broadcast.js';
|
|
48
49
|
export { json, readBody } from './src/json.js';
|
|
50
|
+
export { sitemap, sitemapIndex } from './src/sitemap.js';
|
|
49
51
|
export { attachWebSocket } from './src/websocket.js';
|
|
50
52
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
|
51
53
|
|
|
52
54
|
// Auth (NextAuth-style)
|
|
53
55
|
export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
|
|
56
|
+
|
|
57
|
+
// Test harness helpers (issue #267): thin builders over handle()
|
|
58
|
+
export {
|
|
59
|
+
testRequest,
|
|
60
|
+
toRequest,
|
|
61
|
+
getCsrf,
|
|
62
|
+
readCsrfCookie,
|
|
63
|
+
getSetCookies,
|
|
64
|
+
cookiesToHeader,
|
|
65
|
+
withCookies,
|
|
66
|
+
withSessionCookie,
|
|
67
|
+
loginAndGetCookies,
|
|
68
|
+
actionEndpoint,
|
|
69
|
+
invokeActionForTest,
|
|
70
|
+
rawActionRequest,
|
|
71
|
+
} from './src/testing.js';
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
9
|
"./check": "./src/check.js",
|
|
10
|
+
"./testing": "./src/testing.js",
|
|
10
11
|
"./webjs-config.schema.json": "./webjs-config.schema.json"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
package/src/actions.js
CHANGED
|
@@ -33,6 +33,60 @@ function jsonBodyLimit() {
|
|
|
33
33
|
*/
|
|
34
34
|
export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Run an attached input validator against an action's input, shared by the RPC
|
|
38
|
+
* path (`invokeAction`) and the `expose()` REST path (`invokeExposedAction`) so
|
|
39
|
+
* the contract is identical across transports (#245). The framework only CALLS
|
|
40
|
+
* the validator and shapes the result; it ships no validation library.
|
|
41
|
+
*
|
|
42
|
+
* @param {(input: any) => any} validate the attached validator
|
|
43
|
+
* @param {any} input the value handed to the validator (the action's first arg
|
|
44
|
+
* on RPC, the merged query/params/body object on REST)
|
|
45
|
+
* @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
|
|
46
|
+
* `ok: true` carries the value to pass to the action (the validated `data`,
|
|
47
|
+
* the transformed return, or the original input). `ok: false` carries the
|
|
48
|
+
* structured failure result the caller serializes back; on a THROWN validator
|
|
49
|
+
* it also carries the original error on `thrown` so a caller can salvage a
|
|
50
|
+
* schema lib's structured `issues` (the REST path does, for back-compat).
|
|
51
|
+
*/
|
|
52
|
+
export function runValidate(validate, input) {
|
|
53
|
+
let out;
|
|
54
|
+
try {
|
|
55
|
+
out = validate(input);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Throw-to-reject (the classic `Schema.parse` contract). Map to a sanitized
|
|
58
|
+
// failure result; a thrown validator is a 400 (bad input), not a 500.
|
|
59
|
+
// Surface only the message; `thrown` lets the REST caller recover `issues`.
|
|
60
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
61
|
+
return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
|
|
62
|
+
}
|
|
63
|
+
// Disambiguate an ActionResult envelope from a plain transformed value: a
|
|
64
|
+
// return is an envelope ONLY when it is an object carrying a boolean
|
|
65
|
+
// `success` OR a `fieldErrors`. Anything else is a transformed input.
|
|
66
|
+
if (out && typeof out === 'object' && !Array.isArray(out) &&
|
|
67
|
+
(typeof (/** @type any */ (out).success) === 'boolean' || 'fieldErrors' in out)) {
|
|
68
|
+
const env = /** @type any */ (out);
|
|
69
|
+
if (env.success === true) {
|
|
70
|
+
// `{ success: true, data? }` → valid; use `data` when present, else input.
|
|
71
|
+
return { ok: true, value: 'data' in env ? env.data : input };
|
|
72
|
+
}
|
|
73
|
+
// Failure: `{ success: false, ... }` or `{ fieldErrors }` (no literal
|
|
74
|
+
// success). Shape the structured field-error result.
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
result: {
|
|
78
|
+
success: false,
|
|
79
|
+
...(env.fieldErrors ? { fieldErrors: env.fieldErrors } : {}),
|
|
80
|
+
...(env.message || env.error ? { error: env.message || env.error } : {}),
|
|
81
|
+
status: typeof env.status === 'number' ? env.status : 422,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// A non-envelope return transforms the input (back-compat with
|
|
86
|
+
// `validate: Schema.parse`); `undefined` means "no transform", keep input.
|
|
87
|
+
return { ok: true, value: out === undefined ? input : out };
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
/** Build a serialized Response with webjs content-type. */
|
|
37
91
|
async function rpcResponse(payload, init = {}) {
|
|
38
92
|
const s = getSerializer();
|
|
@@ -145,6 +199,12 @@ export async function buildActionIndex(appDir, dev) {
|
|
|
145
199
|
if (typeof fn !== 'function') continue;
|
|
146
200
|
const http = getExposed(fn);
|
|
147
201
|
if (!http) continue;
|
|
202
|
+
// A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
|
|
203
|
+
// `validate` but NO `method`/`path` (it does not create a REST route),
|
|
204
|
+
// so it is not an exposed route. Only `expose()` sets a path; skip a
|
|
205
|
+
// validate-only attachment here (its validator is read at RPC call
|
|
206
|
+
// time via getExposed in invokeAction).
|
|
207
|
+
if (!http.path || !http.method) continue;
|
|
148
208
|
const { pattern, paramNames } = pathToPattern(http.path);
|
|
149
209
|
httpRoutes.push({
|
|
150
210
|
method: http.method,
|
|
@@ -241,7 +301,8 @@ export function serveServerOnlyStub(relPath) {
|
|
|
241
301
|
`Cannot import "${relPath}" from browser code. ` +
|
|
242
302
|
`This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
|
|
243
303
|
`Either add 'use server' at the top of the file to expose its exports as RPC, ` +
|
|
244
|
-
`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`;
|
|
245
306
|
return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
|
|
246
307
|
throw new Error(${JSON.stringify(msg)});
|
|
247
308
|
`;
|
|
@@ -335,6 +396,30 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
335
396
|
const mod = await loadModule(file, idx.dev);
|
|
336
397
|
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
337
398
|
if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
|
|
399
|
+
// Input validation (#245): a validator attached via `validateInput(fn, ...)`
|
|
400
|
+
// or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
|
|
401
|
+
// this RPC path too, not just the REST path. The RPC stub sends an args
|
|
402
|
+
// array; an action conventionally takes one input object, so validate the
|
|
403
|
+
// FIRST arg (matching how the REST path validates its single merged object).
|
|
404
|
+
const attached = getExposed(fn);
|
|
405
|
+
if (attached && typeof attached.validate === 'function') {
|
|
406
|
+
const v = runValidate(attached.validate, args[0]);
|
|
407
|
+
if (!v.ok) {
|
|
408
|
+
if (v.thrown !== undefined) {
|
|
409
|
+
// A THROWN validator behaves like a thrown action: a sanitized error
|
|
410
|
+
// response (non-200, so the client stub throws), with prod stack
|
|
411
|
+
// sanitization. The onError sink sees the original error too.
|
|
412
|
+
if (typeof onError === 'function') onError(v.thrown);
|
|
413
|
+
return actionErrorResponse(v.thrown, idx.dev);
|
|
414
|
+
}
|
|
415
|
+
// A structured `{ success: false, fieldErrors }` envelope is a NORMAL
|
|
416
|
+
// result the action author renders (`result.fieldErrors`): serialized as
|
|
417
|
+
// a 200 RPC payload so the client stub returns it as a real object rather
|
|
418
|
+
// than throwing, the failure status riding inside the envelope.
|
|
419
|
+
return rpcResponse(v.result);
|
|
420
|
+
}
|
|
421
|
+
args = [v.value, ...args.slice(1)];
|
|
422
|
+
}
|
|
338
423
|
try {
|
|
339
424
|
const result = await fn(...args);
|
|
340
425
|
return rpcResponse(result ?? null);
|
|
@@ -470,17 +555,26 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
|
|
|
470
555
|
}
|
|
471
556
|
let arg = { ...query, ...params, ...body };
|
|
472
557
|
if (route.validate) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
:
|
|
482
|
-
|
|
558
|
+
// Run the validator through the SHARED contract (#245) so the REST path and
|
|
559
|
+
// the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
|
|
560
|
+
// a transform-return identically. A structured failure becomes a 422 JSON
|
|
561
|
+
// `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
|
|
562
|
+
// transform-return keeps replacing the input (back-compat).
|
|
563
|
+
const v = runValidate(route.validate, arg);
|
|
564
|
+
if (!v.ok) {
|
|
565
|
+
if (v.thrown !== undefined) {
|
|
566
|
+
// A thrown validator (the classic `Schema.parse` style): keep the exact
|
|
567
|
+
// legacy REST shape, including a schema lib's structured `issues` array.
|
|
568
|
+
const msg = v.result.error || 'Invalid input';
|
|
569
|
+
const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
|
|
570
|
+
? /** @type any */ (v.thrown).issues
|
|
571
|
+
: undefined;
|
|
572
|
+
return Response.json({ error: msg, issues }, { status: 400 });
|
|
573
|
+
}
|
|
574
|
+
const { status, ...payload } = v.result;
|
|
575
|
+
return Response.json(payload, { status });
|
|
483
576
|
}
|
|
577
|
+
arg = v.value;
|
|
484
578
|
}
|
|
485
579
|
const mod = await loadModule(route.file, idx.dev);
|
|
486
580
|
const fn = mod[route.fnName];
|
|
@@ -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
|
+
}
|