@webjsdev/server 0.8.12 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +18 -0
- package/package.json +2 -1
- package/src/actions.js +105 -11
- package/src/asset-hash.js +266 -0
- package/src/dev.js +87 -16
- package/src/importmap.js +68 -9
- package/src/sitemap.js +161 -0
- package/src/ssr.js +75 -7
- package/src/testing.js +400 -0
- package/src/vendor.js +143 -6
package/src/testing.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side test harness helpers (issue #267).
|
|
3
|
+
*
|
|
4
|
+
* `createRequestHandler(...).handle(request)` drives the FULL webjs pipeline
|
|
5
|
+
* (middleware, routing, SSR, page actions, server-action RPC, auth + CSRF), and
|
|
6
|
+
* the framework's own suite already drives it. These helpers are THIN builders
|
|
7
|
+
* over the native `Request` / `Response` around that `handle()`, so an app test
|
|
8
|
+
* reads as a one-liner instead of hand-assembling a `Request`, minting a CSRF
|
|
9
|
+
* pair, or serializing an action payload by hand.
|
|
10
|
+
*
|
|
11
|
+
* They are intentionally not a test framework: each is a few lines, returns
|
|
12
|
+
* native objects, and reuses the REAL cookie / header names (`csrf.js`) and the
|
|
13
|
+
* REAL wire serializer (`serializer.js` -> `@webjsdev/core`), never a parallel
|
|
14
|
+
* fake. The most robust auth helper drives the REAL login through `handle()` and
|
|
15
|
+
* captures the `Set-Cookie`, so it exercises the production cookie shape.
|
|
16
|
+
*
|
|
17
|
+
* Usage (a scaffolded app test):
|
|
18
|
+
*
|
|
19
|
+
* ```js
|
|
20
|
+
* import { createRequestHandler } from '@webjsdev/server';
|
|
21
|
+
* import { testRequest, getCsrf, invokeActionForTest } from '@webjsdev/server/testing';
|
|
22
|
+
*
|
|
23
|
+
* const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
24
|
+
*
|
|
25
|
+
* // 1. fire a request through the real pipeline
|
|
26
|
+
* const res = await testRequest(app.handle, '/about');
|
|
27
|
+
*
|
|
28
|
+
* // 2. round-trip an action through the real /__webjs/action/<hash>/<fn> path
|
|
29
|
+
* const out = await invokeActionForTest(app, 'modules/m/act.server.js', 'echo', [new Date()]);
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @module testing
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
+
import { CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
37
|
+
import { getSerializer } from './serializer.js';
|
|
38
|
+
import { join, sep } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A `handle` is whatever `createRequestHandler(...).handle` is: a function
|
|
42
|
+
* taking a native `Request` and returning a `Promise<Response>`.
|
|
43
|
+
* @typedef {(req: Request) => Promise<Response>} Handle
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Dummy origin used to turn a bare path (`/about`) into an absolute URL. */
|
|
47
|
+
const DUMMY_ORIGIN = 'http://webjs.test';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Coerce a path-or-Request `input` into a native `Request`.
|
|
51
|
+
*
|
|
52
|
+
* A string starting with `/` is prefixed with a dummy origin (the pipeline only
|
|
53
|
+
* reads `url.pathname` + `url.search`, so the origin is irrelevant); a full URL
|
|
54
|
+
* string is used as-is; a `Request` is passed through (with `init` overrides
|
|
55
|
+
* merged when provided).
|
|
56
|
+
*
|
|
57
|
+
* @param {string | Request} input
|
|
58
|
+
* @param {RequestInit} [init]
|
|
59
|
+
* @returns {Request}
|
|
60
|
+
*/
|
|
61
|
+
export function toRequest(input, init) {
|
|
62
|
+
if (input instanceof Request) {
|
|
63
|
+
return init ? new Request(input, init) : input;
|
|
64
|
+
}
|
|
65
|
+
const url = input.startsWith('/') ? DUMMY_ORIGIN + input : input;
|
|
66
|
+
return new Request(url, init);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fire a request through the real `handle()` pipeline and return the `Response`.
|
|
71
|
+
*
|
|
72
|
+
* The documented one-liner: `const res = await testRequest(app.handle, '/about')`.
|
|
73
|
+
* Accepts a bare path (prefixed with a dummy origin), a full URL string, or a
|
|
74
|
+
* pre-built `Request`. `init` is the standard `RequestInit` (method, headers,
|
|
75
|
+
* body, ...).
|
|
76
|
+
*
|
|
77
|
+
* @param {Handle} handle
|
|
78
|
+
* @param {string | Request} input
|
|
79
|
+
* @param {RequestInit} [init]
|
|
80
|
+
* @returns {Promise<Response>}
|
|
81
|
+
*/
|
|
82
|
+
export function testRequest(handle, input, init) {
|
|
83
|
+
return handle(toRequest(input, init));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s).
|
|
88
|
+
* Returns null when the response set no CSRF cookie.
|
|
89
|
+
*
|
|
90
|
+
* @param {Response} res
|
|
91
|
+
* @returns {string | null}
|
|
92
|
+
*/
|
|
93
|
+
export function readCsrfCookie(res) {
|
|
94
|
+
const cookies = getSetCookies(res);
|
|
95
|
+
for (const c of cookies) {
|
|
96
|
+
const m = new RegExp(`(?:^|\\s)${CSRF_COOKIE}=([^;]+)`).exec(c);
|
|
97
|
+
if (m) {
|
|
98
|
+
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Collect every `Set-Cookie` value off a `Response`, preferring the
|
|
106
|
+
* structured `getSetCookie()` (which keeps multiple cookies separate) and
|
|
107
|
+
* falling back to the single combined header.
|
|
108
|
+
*
|
|
109
|
+
* @param {Response} res
|
|
110
|
+
* @returns {string[]}
|
|
111
|
+
*/
|
|
112
|
+
export function getSetCookies(res) {
|
|
113
|
+
const h = res.headers;
|
|
114
|
+
if (typeof h.getSetCookie === 'function') {
|
|
115
|
+
const list = h.getSetCookie();
|
|
116
|
+
if (list && list.length) return list;
|
|
117
|
+
}
|
|
118
|
+
const single = h.get('set-cookie');
|
|
119
|
+
return single ? [single] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Turn a list of `Set-Cookie` header values into a single `Cookie` request
|
|
124
|
+
* header value (`name=value; name2=value2`). Strips the cookie attributes
|
|
125
|
+
* (`Path`, `Max-Age`, ...), keeping only the `name=value` pair of each.
|
|
126
|
+
*
|
|
127
|
+
* @param {string[]} setCookies
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function cookiesToHeader(setCookies) {
|
|
131
|
+
return setCookies
|
|
132
|
+
.map((c) => c.split(';', 1)[0].trim())
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join('; ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Do an initial GET to mint a CSRF token, returning the `{ cookie, token }`
|
|
139
|
+
* pair so a test can send a CSRF-valid action request. `cookie` is a ready-made
|
|
140
|
+
* `Cookie` request-header value; `token` is the bare value for the
|
|
141
|
+
* `x-webjs-csrf` header. Reuses the REAL cookie / header names from `csrf.js`.
|
|
142
|
+
*
|
|
143
|
+
* @param {Handle} handle
|
|
144
|
+
* @param {string} [path] the GET path used to issue the cookie (default `/`)
|
|
145
|
+
* @returns {Promise<{ cookie: string, token: string, header: string }>}
|
|
146
|
+
*/
|
|
147
|
+
export async function getCsrf(handle, path = '/') {
|
|
148
|
+
const res = await testRequest(handle, path);
|
|
149
|
+
const token = readCsrfCookie(res);
|
|
150
|
+
if (!token) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`getCsrf: GET ${path} issued no ${CSRF_COOKIE} cookie. The first SSR ` +
|
|
153
|
+
`response issues it; hit a real page route (or pass a path that renders).`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
token,
|
|
158
|
+
header: CSRF_HEADER,
|
|
159
|
+
cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Merge an extra cookie string into a `RequestInit`'s `Cookie` header,
|
|
165
|
+
* preserving any cookie already on `init.headers`.
|
|
166
|
+
*
|
|
167
|
+
* @param {RequestInit} [init]
|
|
168
|
+
* @param {string} cookieValue a `name=value; name2=value2` string
|
|
169
|
+
* @returns {RequestInit}
|
|
170
|
+
*/
|
|
171
|
+
export function withCookies(init = {}, cookieValue) {
|
|
172
|
+
const headers = new Headers(init.headers || {});
|
|
173
|
+
const existing = headers.get('cookie');
|
|
174
|
+
headers.set('cookie', existing ? `${existing}; ${cookieValue}` : cookieValue);
|
|
175
|
+
return { ...init, headers };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Attach an auth/session cookie (captured from a real login `Set-Cookie`) to a
|
|
180
|
+
* `RequestInit` so a follow-up request hits a protected route as the logged-in
|
|
181
|
+
* user. A thin convenience over `withCookies`; the cookie value comes from the
|
|
182
|
+
* REAL session mechanism (see `loginAndGetCookies`), never a fabricated shape.
|
|
183
|
+
*
|
|
184
|
+
* @param {RequestInit} [init]
|
|
185
|
+
* @param {string} sessionCookie a `Cookie` header value (e.g. from `loginAndGetCookies`)
|
|
186
|
+
* @returns {RequestInit}
|
|
187
|
+
*/
|
|
188
|
+
export function withSessionCookie(init = {}, sessionCookie) {
|
|
189
|
+
return withCookies(init, sessionCookie);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Drive the REAL credentials login through `handle()` and return the resulting
|
|
194
|
+
* cookies as a ready-to-reuse `Cookie` request-header value, so a test can hit a
|
|
195
|
+
* protected route as a logged-in user. This exercises the production auth flow
|
|
196
|
+
* (the `app/api/auth/[...path]/route.ts` handler + `createAuth`'s
|
|
197
|
+
* `Set-Cookie`), so the captured cookie is the genuine signed session cookie,
|
|
198
|
+
* not a fabricated one.
|
|
199
|
+
*
|
|
200
|
+
* Defaults target `createAuth`'s credentials sign-in route
|
|
201
|
+
* (`POST /api/auth/signin/credentials`, the path its handler actually routes a
|
|
202
|
+
* credentials login through), posting a urlencoded `email` + `password`.
|
|
203
|
+
* Override `loginPath` / `body` / `method` for a different auth wiring.
|
|
204
|
+
*
|
|
205
|
+
* @param {Handle} handle
|
|
206
|
+
* @param {{ email: string, password: string }} credentials
|
|
207
|
+
* @param {{
|
|
208
|
+
* loginPath?: string,
|
|
209
|
+
* method?: string,
|
|
210
|
+
* body?: BodyInit,
|
|
211
|
+
* contentType?: string,
|
|
212
|
+
* expectStatuses?: number[],
|
|
213
|
+
* }} [opts]
|
|
214
|
+
* @returns {Promise<{ cookies: string, setCookies: string[], response: Response }>}
|
|
215
|
+
* `cookies` is the combined `Cookie` header value; `setCookies` is the raw
|
|
216
|
+
* `Set-Cookie` list; `response` is the login response (a 302 on success).
|
|
217
|
+
*/
|
|
218
|
+
export async function loginAndGetCookies(handle, credentials, opts = {}) {
|
|
219
|
+
const loginPath = opts.loginPath || '/api/auth/signin/credentials';
|
|
220
|
+
const method = opts.method || 'POST';
|
|
221
|
+
const contentType = opts.contentType || 'application/x-www-form-urlencoded';
|
|
222
|
+
const body =
|
|
223
|
+
opts.body != null
|
|
224
|
+
? opts.body
|
|
225
|
+
: new URLSearchParams({
|
|
226
|
+
email: credentials.email,
|
|
227
|
+
password: credentials.password,
|
|
228
|
+
}).toString();
|
|
229
|
+
|
|
230
|
+
const res = await testRequest(handle, loginPath, {
|
|
231
|
+
method,
|
|
232
|
+
headers: { 'content-type': contentType },
|
|
233
|
+
body,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const setCookies = getSetCookies(res);
|
|
237
|
+
const cookies = cookiesToHeader(setCookies);
|
|
238
|
+
if (!cookies) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`loginAndGetCookies: POST ${loginPath} returned no Set-Cookie (status ` +
|
|
241
|
+
`${res.status}). The credentials were likely rejected, or the login path ` +
|
|
242
|
+
`is wrong for this app's auth wiring (override opts.loginPath / opts.body).`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return { cookies, setCookies, response: res };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Compute the RPC endpoint path for an action, addressing it the same way the
|
|
250
|
+
* generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
|
|
251
|
+
* path (`hashFile` in `actions.js`, the single source of truth) plus the
|
|
252
|
+
* exported function name.
|
|
253
|
+
*
|
|
254
|
+
* `serverFilePath` may be absolute, or relative to `appDir` (joined with the
|
|
255
|
+
* OS separator, matching `resolveServerModule`).
|
|
256
|
+
*
|
|
257
|
+
* @param {string} appDir the app root passed to `createRequestHandler`
|
|
258
|
+
* @param {string} serverFilePath the `.server.{js,ts}` file (absolute or appDir-relative)
|
|
259
|
+
* @param {string} fnName the exported action function name
|
|
260
|
+
* @returns {Promise<string>} the path, e.g. `/__webjs/action/<hash>/<fn>`
|
|
261
|
+
*/
|
|
262
|
+
export async function actionEndpoint(appDir, serverFilePath, fnName) {
|
|
263
|
+
const abs = isAbsolutePath(serverFilePath)
|
|
264
|
+
? serverFilePath
|
|
265
|
+
: join(appDir, serverFilePath.split('/').join(sep));
|
|
266
|
+
const hash = await hashFile(abs);
|
|
267
|
+
return `/__webjs/action/${hash}/${fnName}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @param {string} p */
|
|
271
|
+
function isAbsolutePath(p) {
|
|
272
|
+
return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Round-trip a registered server action through the REAL
|
|
277
|
+
* `/__webjs/action/<hash>/<fn>` endpoint: serialize `args` with the webjs
|
|
278
|
+
* serializer (exactly as the client stub does), POST them with a valid CSRF
|
|
279
|
+
* cookie + header, and parse the response with the serializer.
|
|
280
|
+
*
|
|
281
|
+
* Unlike a DIRECT import of the action (which calls the function in-process),
|
|
282
|
+
* this exercises the production path: CSRF validation, the wire serializer
|
|
283
|
+
* round-trip (so a `Date` / `Map` / `BigInt` arg or return is genuinely
|
|
284
|
+
* encoded + decoded), and prod error sanitization (a thrown error surfaces as a
|
|
285
|
+
* sanitized message, never the stack). That is why it catches a
|
|
286
|
+
* serializer / CSRF / error-sanitization regression a direct import would miss.
|
|
287
|
+
*
|
|
288
|
+
* The action is addressed via `actionEndpoint` (the file-path hash + fn name),
|
|
289
|
+
* the same scheme the generated stub uses.
|
|
290
|
+
*
|
|
291
|
+
* On a non-2xx response the parsed `error` is thrown as an `Error` whose
|
|
292
|
+
* `status` property carries the HTTP status, mirroring the client stub's
|
|
293
|
+
* behavior (so a CSRF 403 or a sanitized 500 surfaces as a throw a test can
|
|
294
|
+
* assert on).
|
|
295
|
+
*
|
|
296
|
+
* @param {{ handle: Handle, appDir: string } | Handle} app
|
|
297
|
+
* the handler object (`{ handle, appDir }`, e.g. the `createRequestHandler`
|
|
298
|
+
* return) or, when you pass `opts.appDir` and `opts.endpoint`, just the
|
|
299
|
+
* `handle` function.
|
|
300
|
+
* @param {string} serverFilePath the `.server.{js,ts}` action file (absolute or appDir-relative)
|
|
301
|
+
* @param {string} fnName the exported action function name
|
|
302
|
+
* @param {unknown[]} [args] positional args (serialized as the stub sends an arg array)
|
|
303
|
+
* @param {{
|
|
304
|
+
* csrf?: { cookie: string, token: string },
|
|
305
|
+
* appDir?: string,
|
|
306
|
+
* extraCookies?: string,
|
|
307
|
+
* throwOnError?: boolean,
|
|
308
|
+
* }} [opts]
|
|
309
|
+
* @returns {Promise<any>} the parsed action return value
|
|
310
|
+
*/
|
|
311
|
+
export async function invokeActionForTest(app, serverFilePath, fnName, args = [], opts = {}) {
|
|
312
|
+
const handle = typeof app === 'function' ? app : app.handle;
|
|
313
|
+
const appDir = opts.appDir || (typeof app === 'object' ? app.appDir : undefined);
|
|
314
|
+
if (!appDir) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
'invokeActionForTest: appDir unknown. Pass the createRequestHandler ' +
|
|
317
|
+
'return value (which carries appDir), or set opts.appDir.',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const throwOnError = opts.throwOnError !== false;
|
|
321
|
+
|
|
322
|
+
const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
|
|
323
|
+
const csrf = opts.csrf || (await getCsrf(handle));
|
|
324
|
+
|
|
325
|
+
const serializer = getSerializer();
|
|
326
|
+
const body = await serializer.serialize(args);
|
|
327
|
+
|
|
328
|
+
let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
|
|
329
|
+
if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
|
|
330
|
+
|
|
331
|
+
const res = await testRequest(handle, endpoint, {
|
|
332
|
+
method: 'POST',
|
|
333
|
+
headers: {
|
|
334
|
+
'content-type': RPC_CONTENT_TYPE,
|
|
335
|
+
[CSRF_HEADER]: csrf.token,
|
|
336
|
+
cookie,
|
|
337
|
+
},
|
|
338
|
+
body,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const text = await res.text();
|
|
342
|
+
const ct = res.headers.get('content-type') || '';
|
|
343
|
+
let parsed;
|
|
344
|
+
if (ct.includes(RPC_CONTENT_TYPE)) parsed = serializer.deserialize(text);
|
|
345
|
+
else if (ct.includes('application/json')) parsed = text ? JSON.parse(text) : null;
|
|
346
|
+
else parsed = text;
|
|
347
|
+
|
|
348
|
+
if (!res.ok && throwOnError) {
|
|
349
|
+
const msg = (parsed && parsed.error) || `webjs action ${fnName} -> ${res.status}`;
|
|
350
|
+
const err = /** @type {Error & { status?: number, response?: Response }} */ (new Error(msg));
|
|
351
|
+
err.status = res.status;
|
|
352
|
+
err.response = res;
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
return parsed;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Lower-level variant of `invokeActionForTest` that returns the raw `Response`
|
|
360
|
+
* (never throws on a non-2xx), so a test can assert on the status directly
|
|
361
|
+
* (e.g. a CSRF-missing request returns 403). Send `opts.csrf = null` /
|
|
362
|
+
* `opts.omitCsrf = true` to deliberately omit the CSRF header + cookie.
|
|
363
|
+
*
|
|
364
|
+
* @param {{ handle: Handle, appDir: string } | Handle} app
|
|
365
|
+
* @param {string} serverFilePath
|
|
366
|
+
* @param {string} fnName
|
|
367
|
+
* @param {unknown[]} [args]
|
|
368
|
+
* @param {{
|
|
369
|
+
* csrf?: { cookie: string, token: string } | null,
|
|
370
|
+
* omitCsrf?: boolean,
|
|
371
|
+
* appDir?: string,
|
|
372
|
+
* extraCookies?: string,
|
|
373
|
+
* contentType?: string,
|
|
374
|
+
* }} [opts]
|
|
375
|
+
* @returns {Promise<Response>}
|
|
376
|
+
*/
|
|
377
|
+
export async function rawActionRequest(app, serverFilePath, fnName, args = [], opts = {}) {
|
|
378
|
+
const handle = typeof app === 'function' ? app : app.handle;
|
|
379
|
+
const appDir = opts.appDir || (typeof app === 'object' ? app.appDir : undefined);
|
|
380
|
+
if (!appDir) {
|
|
381
|
+
throw new Error('rawActionRequest: appDir unknown. Pass the handler object or set opts.appDir.');
|
|
382
|
+
}
|
|
383
|
+
const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
|
|
384
|
+
const serializer = getSerializer();
|
|
385
|
+
const body = await serializer.serialize(args);
|
|
386
|
+
|
|
387
|
+
/** @type {Record<string,string>} */
|
|
388
|
+
const headers = { 'content-type': opts.contentType || RPC_CONTENT_TYPE };
|
|
389
|
+
if (!opts.omitCsrf && opts.csrf !== null) {
|
|
390
|
+
const csrf = opts.csrf || (await getCsrf(handle));
|
|
391
|
+
headers[CSRF_HEADER] = csrf.token;
|
|
392
|
+
let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
|
|
393
|
+
if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
|
|
394
|
+
headers.cookie = cookie;
|
|
395
|
+
} else if (opts.extraCookies) {
|
|
396
|
+
headers.cookie = opts.extraCookies;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
400
|
+
}
|
package/src/vendor.js
CHANGED
|
@@ -462,6 +462,7 @@ export async function vendorImportMapEntries(bareImports, appDir) {
|
|
|
462
462
|
*/
|
|
463
463
|
export function clearVendorCache() {
|
|
464
464
|
jspmCache.clear();
|
|
465
|
+
liveIntegrityCache.clear();
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
// ---------------------------------------------------------------------------
|
|
@@ -1299,10 +1300,12 @@ function maxSemverVersion(versions) {
|
|
|
1299
1300
|
* 2. Live api.jspm.io/generate (fallback when no pin file exists)
|
|
1300
1301
|
*
|
|
1301
1302
|
* Returns both `imports` (the URL map) and `integrity` (SRI hashes
|
|
1302
|
-
* keyed by URL). Integrity is populated
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1303
|
+
* keyed by the FINAL URL). Integrity is populated on BOTH paths (#235):
|
|
1304
|
+
* the pin file supplies it directly, and the live-API path now hashes
|
|
1305
|
+
* each cross-origin bundle after resolving (bounded + fail-open, see
|
|
1306
|
+
* `computeLiveIntegrity`), so an unpinned app also serves SRI. A fetch
|
|
1307
|
+
* failure for one URL degrades to a missing hash for that URL plus a
|
|
1308
|
+
* one-time warning, never a broken resolve.
|
|
1306
1309
|
*
|
|
1307
1310
|
* @param {string} appDir
|
|
1308
1311
|
* @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
|
|
@@ -1358,6 +1361,130 @@ export function prunePinToReachable(imports, integrity, reachable) {
|
|
|
1358
1361
|
return { imports: keptImports, integrity: keptIntegrity };
|
|
1359
1362
|
}
|
|
1360
1363
|
|
|
1364
|
+
/**
|
|
1365
|
+
* Per-process cache of SHA-384 integrity hashes for live-resolved vendor
|
|
1366
|
+
* URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given
|
|
1367
|
+
* versioned URL is immutable, so once hashed it never needs re-fetching
|
|
1368
|
+
* within the process: a re-resolve (e.g. after a file-watcher rebuild
|
|
1369
|
+
* that did not change the dep) reuses the hash instead of re-downloading.
|
|
1370
|
+
* Cleared by `clearVendorCache` alongside the jspm fragment cache so a
|
|
1371
|
+
* version bump re-hashes. This is NOT a persistent cache (that is the pin
|
|
1372
|
+
* file's job); it only avoids redundant fetches in one running process.
|
|
1373
|
+
*
|
|
1374
|
+
* @type {Map<string, string>}
|
|
1375
|
+
*/
|
|
1376
|
+
const liveIntegrityCache = new Map();
|
|
1377
|
+
|
|
1378
|
+
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
1379
|
+
// Cap concurrent bundle fetches so a large dep set does not open dozens of
|
|
1380
|
+
// sockets at once during warmup. Matches the bounded posture of the rest of
|
|
1381
|
+
// vendor.js (the jspm resolve is per-package but the network is the shared
|
|
1382
|
+
// constraint).
|
|
1383
|
+
const INTEGRITY_FETCH_CONCURRENCY = 6;
|
|
1384
|
+
// Total wall-clock budget for the whole live-integrity hashing phase. It runs
|
|
1385
|
+
// inside the readiness-gating warmup, so even a CDN that serves the importmap
|
|
1386
|
+
// then hangs on every bundle GET must not stall the first request for the sum
|
|
1387
|
+
// of per-fetch timeouts. Once the budget passes, the remaining URLs are left
|
|
1388
|
+
// without integrity (the same fail-open fallback as a fetch failure) instead of
|
|
1389
|
+
// waiting out a 10s timeout each. A healthy CDN finishes in well under this.
|
|
1390
|
+
const INTEGRITY_TOTAL_BUDGET_MS = 15_000;
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Fetch a single cross-origin URL with a bounded timeout and return its
|
|
1394
|
+
* SHA-384 SRI hash, or null on any failure (network, timeout, non-ok).
|
|
1395
|
+
* Fail-OPEN by design: a CDN hiccup must never break warmup, so a failure
|
|
1396
|
+
* is a skipped hash (the URL serves without `integrity`, the pre-#235
|
|
1397
|
+
* behavior for that one URL), not a thrown error.
|
|
1398
|
+
*
|
|
1399
|
+
* @param {string} url
|
|
1400
|
+
* @returns {Promise<string | null>}
|
|
1401
|
+
*/
|
|
1402
|
+
async function fetchLiveIntegrity(url) {
|
|
1403
|
+
const cached = liveIntegrityCache.get(url);
|
|
1404
|
+
if (cached) return cached;
|
|
1405
|
+
const controller = new AbortController();
|
|
1406
|
+
const timer = setTimeout(() => controller.abort(), INTEGRITY_FETCH_TIMEOUT_MS);
|
|
1407
|
+
try {
|
|
1408
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1409
|
+
if (!response.ok) return null;
|
|
1410
|
+
// Hash the raw response bytes (arrayBuffer -> Uint8Array), the same
|
|
1411
|
+
// primitive the browser's SRI implementation hashes. Decoding to a
|
|
1412
|
+
// string first would risk encoding round-trip drift. See the matching
|
|
1413
|
+
// comment in downloadBundle / fetchIntegrity.
|
|
1414
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1415
|
+
const sri = await sha384Integrity(buf);
|
|
1416
|
+
liveIntegrityCache.set(url, sri);
|
|
1417
|
+
return sri;
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
} finally {
|
|
1421
|
+
clearTimeout(timer);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Compute SRI integrity for the CROSS-ORIGIN targets of a live-resolved
|
|
1427
|
+
* import map. Same-origin targets (the `@webjsdev/core/*` runtime under
|
|
1428
|
+
* `/__webjs/core/...` and any local `/__webjs/vendor/...` bundle) are
|
|
1429
|
+
* skipped: they are served by the framework and already trusted, and SRI
|
|
1430
|
+
* is a cross-origin defense.
|
|
1431
|
+
*
|
|
1432
|
+
* The returned map is keyed by the FINAL URL (the import-map target
|
|
1433
|
+
* value), matching `vendorIntegrityFor(url)`'s lookup key so ssr.js emits
|
|
1434
|
+
* the `integrity` sibling for free.
|
|
1435
|
+
*
|
|
1436
|
+
* Bounded and fail-open: cross-origin bundles are fetched in parallel with
|
|
1437
|
+
* a small concurrency cap and a per-fetch timeout, and a failed fetch is
|
|
1438
|
+
* skipped (no integrity for that one URL) rather than breaking the resolve.
|
|
1439
|
+
* A single one-time `console.warn` reports the count of URLs that could not
|
|
1440
|
+
* be hashed (no per-URL spam).
|
|
1441
|
+
*
|
|
1442
|
+
* @param {Record<string, string>} imports specifier -> final URL
|
|
1443
|
+
* @returns {Promise<Record<string, string>>} integrity keyed by final URL
|
|
1444
|
+
*/
|
|
1445
|
+
async function computeLiveIntegrity(imports) {
|
|
1446
|
+
// De-duplicate by URL: two specifiers can resolve to the same bundle URL
|
|
1447
|
+
// (a bare import and one of its subpaths), so hash each URL once.
|
|
1448
|
+
const urls = [...new Set(Object.values(imports))].filter((u) => /^https:\/\//.test(u));
|
|
1449
|
+
/** @type {Record<string, string>} */
|
|
1450
|
+
const integrity = {};
|
|
1451
|
+
if (urls.length === 0) return integrity;
|
|
1452
|
+
|
|
1453
|
+
const failed = [];
|
|
1454
|
+
let next = 0;
|
|
1455
|
+
const deadline = Date.now() + INTEGRITY_TOTAL_BUDGET_MS;
|
|
1456
|
+
async function worker() {
|
|
1457
|
+
// Stop claiming new URLs once the total budget passes; a URL already
|
|
1458
|
+
// in flight still settles under its own per-fetch timeout.
|
|
1459
|
+
while (next < urls.length && Date.now() < deadline) {
|
|
1460
|
+
const url = urls[next++];
|
|
1461
|
+
const sri = await fetchLiveIntegrity(url);
|
|
1462
|
+
if (sri) integrity[url] = sri;
|
|
1463
|
+
else failed.push(url);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const workerCount = Math.min(INTEGRITY_FETCH_CONCURRENCY, urls.length);
|
|
1467
|
+
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
1468
|
+
// Any URL never claimed because the budget passed also fails open (served
|
|
1469
|
+
// without integrity), the same outcome as a fetch failure.
|
|
1470
|
+
while (next < urls.length) failed.push(urls[next++]);
|
|
1471
|
+
|
|
1472
|
+
if (failed.length) {
|
|
1473
|
+
// One-time, count-based warning. The app still boots and the imports
|
|
1474
|
+
// still work; only these URLs lack SRI (served as before #235). Run
|
|
1475
|
+
// `webjs vendor pin` to lock in integrity, or retry once the CDN is
|
|
1476
|
+
// healthy. Naming one example URL aids diagnosis without per-URL spam.
|
|
1477
|
+
console.warn(
|
|
1478
|
+
`[webjs] could not compute SRI for ${failed.length} live-resolved ` +
|
|
1479
|
+
`vendor URL(s) (e.g. ${failed[0]}); they will load WITHOUT integrity. ` +
|
|
1480
|
+
`This is a fail-open fallback for a CDN fetch failure or the warmup ` +
|
|
1481
|
+
`time budget; the app still works. Run \`webjs vendor pin\` to lock in ` +
|
|
1482
|
+
`SRI hashes.`,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
return integrity;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1361
1488
|
export async function resolveVendorImports(appDir, getBareImports) {
|
|
1362
1489
|
const file = await readPinFile(appDir);
|
|
1363
1490
|
// A committed pin file IS the import map. The whole-app bare-import scan is
|
|
@@ -1366,16 +1493,26 @@ export async function resolveVendorImports(appDir, getBareImports) {
|
|
|
1366
1493
|
// solely here, only when there is no pin file.
|
|
1367
1494
|
if (file) {
|
|
1368
1495
|
// A pin file is a deterministic disk read: always "ok" (no live CDN call
|
|
1369
|
-
// that could partially fail). This is the recommended prod posture.
|
|
1496
|
+
// that could partially fail). This is the recommended prod posture. The
|
|
1497
|
+
// pin's own integrity is used verbatim; the live-hash path below is NOT
|
|
1498
|
+
// taken for a pinned app.
|
|
1370
1499
|
return { imports: file.imports, integrity: file.integrity || {}, ok: true };
|
|
1371
1500
|
}
|
|
1372
1501
|
lastLiveResolveFailed = false;
|
|
1373
1502
|
const bareImports = await getBareImports();
|
|
1374
1503
|
const imports = await vendorImportMapEntries(bareImports, appDir);
|
|
1504
|
+
// Fill the SRI gap for live-resolved (unpinned) apps (#235): hash each
|
|
1505
|
+
// cross-origin bundle and key the integrity by its final URL, the same
|
|
1506
|
+
// shape the pin path uses and `vendorIntegrityFor` looks up. Bounded +
|
|
1507
|
+
// fail-open, so a CDN fetch failure degrades to a missing hash for that
|
|
1508
|
+
// URL (a warning), never a broken resolve. This runs only AFTER a live
|
|
1509
|
+
// resolve produced URLs; if the resolve itself failed there is nothing to
|
|
1510
|
+
// hash.
|
|
1511
|
+
const integrity = await computeLiveIntegrity(imports);
|
|
1375
1512
|
// ok=false means at least one install could not be resolved (CDN unreachable
|
|
1376
1513
|
// / timeout / non-ok), so `imports` is partial. The caller must not memoize
|
|
1377
1514
|
// this as done; it should retry once the CDN recovers.
|
|
1378
|
-
return { imports, integrity
|
|
1515
|
+
return { imports, integrity, ok: !lastLiveResolveFailed };
|
|
1379
1516
|
}
|
|
1380
1517
|
|
|
1381
1518
|
/**
|