@webjsdev/server 0.8.57 → 0.8.59
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 +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/stream.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The payload is plain HTML, a `<webjs-stream action target>` wrapping one
|
|
7
7
|
* `<template>`. These helpers compose that string for the two delivery paths,
|
|
8
8
|
* a content-negotiated HTTP form response and a `broadcast()` / WS message, so
|
|
9
|
-
* an app never hand-writes the markup. A
|
|
9
|
+
* an app never hand-writes the markup. A form-bound action or a `route` handler
|
|
10
10
|
* branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
|
|
11
11
|
* router asked for a stream and a normal render otherwise (the JS-off degrade).
|
|
12
12
|
*
|
package/src/testing.d.ts
CHANGED
|
@@ -42,6 +42,25 @@ export declare function withCookies(init: RequestInit | undefined, cookieValue:
|
|
|
42
42
|
/** Merge a session cookie string into a `RequestInit`'s `Cookie` header. */
|
|
43
43
|
export declare function withSessionCookie(init: RequestInit | undefined, sessionCookie: string): RequestInit;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Submit a page's `<form action={action}>` the way a browser with JS OFF does:
|
|
47
|
+
* render the page, reuse the identity the server put in its hidden
|
|
48
|
+
* `__webjs_action` field, and POST it back with the given fields. A POST that
|
|
49
|
+
* omits that field is not a form submission and is answered `405`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function submitForm(
|
|
52
|
+
handle: Handle,
|
|
53
|
+
path: string,
|
|
54
|
+
fields?: Record<string, string> | URLSearchParams | FormData,
|
|
55
|
+
opts?: {
|
|
56
|
+
index?: number;
|
|
57
|
+
match?: string | RegExp;
|
|
58
|
+
cookies?: string;
|
|
59
|
+
submitPath?: string;
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
},
|
|
62
|
+
): Promise<Response>;
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Drive the REAL credentials login (`POST /api/auth/signin/credentials` by
|
|
47
66
|
* default) and capture the genuine signed session `Set-Cookie`.
|
package/src/testing.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
+
import { FORM_ACTION_FIELD } from '@webjsdev/core';
|
|
36
37
|
import { getSerializer } from './serializer.js';
|
|
37
38
|
import { join, sep } from 'node:path';
|
|
38
39
|
|
|
@@ -200,6 +201,118 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
|
|
|
200
201
|
return { cookies, setCookies, response: res };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Submit a page's `<form action=${action}>` the way a browser with JS OFF does
|
|
206
|
+
* (#1155): render the page, take the identity the server put in its hidden
|
|
207
|
+
* `__webjs_action` field, and POST it back with the author's fields.
|
|
208
|
+
*
|
|
209
|
+
* A bound form carries that field because the identity is what tells the
|
|
210
|
+
* dispatcher WHICH action to run, so a hand-written POST that omits it is not a
|
|
211
|
+
* form submission at all and is answered `405`. Reproducing the scrape in every
|
|
212
|
+
* app test is how that ends up looking like a database problem instead: the
|
|
213
|
+
* status is wrong, an assertion fails, and a `catch` around it reports a skip.
|
|
214
|
+
* Hence a helper, so the identity is never the test's business.
|
|
215
|
+
*
|
|
216
|
+
* The form is located by INDEX (`opts.index`, default the first on the page),
|
|
217
|
+
* which is enough for the common one-form page. For a page with several, pass
|
|
218
|
+
* `opts.match`: a string or RegExp that must appear in the form's markup, so
|
|
219
|
+
* the test names the form by something an author recognizes (a heading, a field
|
|
220
|
+
* name, an id) rather than by counting.
|
|
221
|
+
*
|
|
222
|
+
* @param {Handle} handle
|
|
223
|
+
* @param {string} path the page to render AND, by default, to submit back to
|
|
224
|
+
* @param {Record<string, string> | URLSearchParams | FormData} [fields]
|
|
225
|
+
* @param {{
|
|
226
|
+
* index?: number,
|
|
227
|
+
* match?: string | RegExp,
|
|
228
|
+
* cookies?: string,
|
|
229
|
+
* submitPath?: string,
|
|
230
|
+
* headers?: Record<string, string>,
|
|
231
|
+
* }} [opts]
|
|
232
|
+
* @returns {Promise<Response>} the submission response (a `303` on success, a
|
|
233
|
+
* `422` re-render on a failure result)
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* The `<form>...</form>` blocks of a rendered page.
|
|
237
|
+
*
|
|
238
|
+
* A bare `/<form[\s\S]*?<\/form>/g` gets two things wrong that a test helper
|
|
239
|
+
* must not, because both fail SILENTLY: a form inside an HTML comment is
|
|
240
|
+
* matched (so `opts.index` counts a form that is not on the page), and a
|
|
241
|
+
* `</form>` sitting inside an ATTRIBUTE VALUE ends the match early (so the
|
|
242
|
+
* identity field falls outside it and the helper reports an unbound form).
|
|
243
|
+
* Comments are stripped first, and the scan skips quoted attribute values while
|
|
244
|
+
* looking for the end of the start tag.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} html
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
function findForms(html) {
|
|
250
|
+
const src = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
251
|
+
const out = [];
|
|
252
|
+
const open = /<form\b/gi;
|
|
253
|
+
let m;
|
|
254
|
+
while ((m = open.exec(src))) {
|
|
255
|
+
// Walk the start tag, skipping quoted values so `data-x="<form>"` is inert.
|
|
256
|
+
let i = m.index + m[0].length;
|
|
257
|
+
let quote = '';
|
|
258
|
+
for (; i < src.length; i++) {
|
|
259
|
+
const c = src[i];
|
|
260
|
+
if (quote) { if (c === quote) quote = ''; continue; }
|
|
261
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
262
|
+
if (c === '>') break;
|
|
263
|
+
}
|
|
264
|
+
const close = src.toLowerCase().indexOf('</form>', i);
|
|
265
|
+
if (close === -1) continue;
|
|
266
|
+
out.push(src.slice(m.index, close + '</form>'.length));
|
|
267
|
+
open.lastIndex = close;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function submitForm(handle, path, fields = {}, opts = {}) {
|
|
273
|
+
const getInit = opts.cookies ? withCookies({}, opts.cookies) : {};
|
|
274
|
+
const page = await testRequest(handle, path, getInit);
|
|
275
|
+
const html = await page.text();
|
|
276
|
+
|
|
277
|
+
const forms = findForms(html);
|
|
278
|
+
const candidates = opts.match
|
|
279
|
+
? forms.filter((f) => (opts.match instanceof RegExp ? opts.match.test(f) : f.includes(opts.match)))
|
|
280
|
+
: forms;
|
|
281
|
+
const form = candidates[opts.index || 0];
|
|
282
|
+
if (!form) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`submitForm: no ${opts.match ? 'matching ' : ''}<form> found on ${path} `
|
|
285
|
+
+ `(status ${page.status}, ${forms.length} form(s) on the page).`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const id = form.match(/name="__webjs_action"\s+value="([^"]*)"/);
|
|
289
|
+
if (!id) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`submitForm: the <form> on ${path} carries no __webjs_action field, so it `
|
|
292
|
+
+ 'is not bound to a server action. Write it as `<form action=${myAction}>` '
|
|
293
|
+
+ 'with the action imported from a `use server` module.',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = new URLSearchParams();
|
|
298
|
+
body.set(FORM_ACTION_FIELD, id[1]);
|
|
299
|
+
const entries = fields instanceof URLSearchParams || fields instanceof FormData
|
|
300
|
+
? fields.entries()
|
|
301
|
+
: Object.entries(fields);
|
|
302
|
+
for (const [k, v] of entries) body.append(k, String(v));
|
|
303
|
+
|
|
304
|
+
const init = {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(opts.headers || {}) },
|
|
307
|
+
body: body.toString(),
|
|
308
|
+
};
|
|
309
|
+
return await testRequest(
|
|
310
|
+
handle,
|
|
311
|
+
opts.submitPath || path,
|
|
312
|
+
opts.cookies ? withCookies(init, opts.cookies) : init,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
203
316
|
/**
|
|
204
317
|
* Compute the RPC endpoint path for an action, addressing it the same way the
|
|
205
318
|
* generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
|
package/src/vendor.js
CHANGED
|
@@ -338,6 +338,27 @@ let lastLiveResolveFailed = false;
|
|
|
338
338
|
|
|
339
339
|
const JSPM_GENERATE_ENDPOINT = 'https://api.jspm.io/generate';
|
|
340
340
|
const JSPM_GENERATE_TIMEOUT_MS = 10_000;
|
|
341
|
+
// Bounds a single bundle GET during the SERVER's warmup live-integrity pass
|
|
342
|
+
// (`fetchLiveIntegrity`). Short on purpose: that pass gates readiness, so a
|
|
343
|
+
// stalled CDN must not hold the first request, and it is additionally capped
|
|
344
|
+
// by INTEGRITY_TOTAL_BUDGET_MS across all URLs.
|
|
345
|
+
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
346
|
+
|
|
347
|
+
// Bounds a single bundle GET made by the pin command, which either writes the
|
|
348
|
+
// bytes to disk (`downloadBundle`) or fetches them to hash
|
|
349
|
+
// (`fetchIntegrity`). Deliberately six times the warmup budget, because the
|
|
350
|
+
// two are not the same situation: a pin is a one-shot command a person ran and
|
|
351
|
+
// is waiting on, with a whole multi-megabyte package to transfer, while the
|
|
352
|
+
// warmup is a server holding a request. Ten seconds is generous for the
|
|
353
|
+
// latter and tight for the former on a slow link.
|
|
354
|
+
//
|
|
355
|
+
// 60s matches what importmap-rails effectively allows. It sets no timeout at
|
|
356
|
+
// all, but Ruby's Net::HTTP defaults open_timeout and read_timeout to 60s, so
|
|
357
|
+
// a Rails pin is bounded at a minute without asking. JavaScript's fetch() has
|
|
358
|
+
// no default whatsoever, which is why this has to be explicit: without it a
|
|
359
|
+
// CDN that accepts the connection and then stalls hangs the pin forever, with
|
|
360
|
+
// no ambient deadline on a CLI run to cut it short.
|
|
361
|
+
const PIN_BUNDLE_TIMEOUT_MS = 60_000;
|
|
341
362
|
|
|
342
363
|
/**
|
|
343
364
|
* Provider names accepted by `webjs vendor pin --from <provider>`.
|
|
@@ -387,12 +408,15 @@ export function normalizeProvider(name) {
|
|
|
387
408
|
*
|
|
388
409
|
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', '@codemirror/lint@6.9.6']
|
|
389
410
|
* @param {string} provider one of SUPPORTED_PROVIDERS
|
|
411
|
+
* @param {number} [timeoutMs] defaults to the SERVER budget; a CLI caller
|
|
412
|
+
* passes the longer one, since it is a command someone is waiting on rather
|
|
413
|
+
* than a request being held open.
|
|
390
414
|
* @returns {Promise<JspmCallResult>}
|
|
391
415
|
*/
|
|
392
|
-
async function jspmCall(installs, provider) {
|
|
416
|
+
async function jspmCall(installs, provider, timeoutMs = JSPM_GENERATE_TIMEOUT_MS) {
|
|
393
417
|
const label = installs.length === 1 ? `'${installs[0]}'` : `${installs.length} packages`;
|
|
394
418
|
const controller = new AbortController();
|
|
395
|
-
const timer = setTimeout(() => controller.abort(),
|
|
419
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
396
420
|
try {
|
|
397
421
|
const response = await fetch(JSPM_GENERATE_ENDPOINT, {
|
|
398
422
|
method: 'POST',
|
|
@@ -440,7 +464,7 @@ async function jspmCall(installs, provider) {
|
|
|
440
464
|
return { ok: true, imports, transient: false };
|
|
441
465
|
} catch (e) {
|
|
442
466
|
const msg = e && e.name === 'AbortError'
|
|
443
|
-
? `timed out after ${
|
|
467
|
+
? `timed out after ${timeoutMs}ms`
|
|
444
468
|
: `${e && e.message}`;
|
|
445
469
|
console.error(`[webjs] could not vendor ${label} via ${provider}: ${msg}`);
|
|
446
470
|
return { ok: false, imports: {}, transient: true };
|
|
@@ -466,8 +490,8 @@ async function jspmCall(installs, provider) {
|
|
|
466
490
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
467
491
|
* @returns {Promise<Record<string, string>>}
|
|
468
492
|
*/
|
|
469
|
-
async function jspmResolveOne(install, provider = 'jspm') {
|
|
470
|
-
const { ok, imports, transient } = await jspmProbeOne(install, provider);
|
|
493
|
+
async function jspmResolveOne(install, provider = 'jspm', timeoutMs) {
|
|
494
|
+
const { ok, imports, transient } = await jspmProbeOne(install, provider, timeoutMs);
|
|
471
495
|
// Preserve the public contract: an empty map on any failure, and the
|
|
472
496
|
// module-global retry flag set ONLY on a transient one (a permanent 401
|
|
473
497
|
// for an unresolvable private/server-only dep is tolerated).
|
|
@@ -492,13 +516,13 @@ async function jspmResolveOne(install, provider = 'jspm') {
|
|
|
492
516
|
* @param {string} provider
|
|
493
517
|
* @returns {Promise<JspmCallResult>}
|
|
494
518
|
*/
|
|
495
|
-
function jspmProbeOne(install, provider) {
|
|
519
|
+
function jspmProbeOne(install, provider, timeoutMs) {
|
|
496
520
|
const cacheKey = `${provider}::probe::${install}`;
|
|
497
521
|
const existing = jspmCache.get(cacheKey);
|
|
498
522
|
if (existing) return existing;
|
|
499
523
|
|
|
500
524
|
const promise = (async () => {
|
|
501
|
-
const result = await jspmCall([install], provider);
|
|
525
|
+
const result = await jspmCall([install], provider, timeoutMs);
|
|
502
526
|
// Do not cache a failure: a transient one must be re-attempted on the
|
|
503
527
|
// next resolve, and a permanent one is cheap to re-confirm and must not
|
|
504
528
|
// pin a stale "unresolvable" verdict across a dependency change.
|
|
@@ -543,14 +567,18 @@ function jspmProbeOne(install, provider) {
|
|
|
543
567
|
*
|
|
544
568
|
* @param {Array<string>} installs e.g. ['dayjs@1.11.13', 'clsx@2.1.1']
|
|
545
569
|
* @param {string} [provider] one of SUPPORTED_PROVIDERS; defaults to 'jspm'
|
|
570
|
+
* @param {number} [timeoutMs] per-call budget. Defaults to the SERVER one,
|
|
571
|
+
* because this runs on a cold first request as well as from the CLI; the two
|
|
572
|
+
* pin commands pass PIN_BUNDLE_TIMEOUT_MS instead. importmap-rails only ever
|
|
573
|
+
* resolves from the CLI, so its flat 60s has no request path to slow down.
|
|
546
574
|
* @returns {Promise<Record<string, string>>}
|
|
547
575
|
*/
|
|
548
|
-
export async function jspmGenerate(installs, provider = 'jspm') {
|
|
576
|
+
export async function jspmGenerate(installs, provider = 'jspm', timeoutMs) {
|
|
549
577
|
if (installs.length === 0) return {};
|
|
550
578
|
|
|
551
579
|
// A single install has no cross-package graph to reconcile, so the
|
|
552
580
|
// isolated path IS the coherent path; reuse the per-install cache.
|
|
553
|
-
if (installs.length === 1) return jspmResolveOne(installs[0], provider);
|
|
581
|
+
if (installs.length === 1) return jspmResolveOne(installs[0], provider, timeoutMs);
|
|
554
582
|
|
|
555
583
|
// Stable key regardless of scan order so the same dep set hits cache.
|
|
556
584
|
const unifiedKey = `${provider}::unified::${[...installs].sort().join('\n')}`;
|
|
@@ -558,7 +586,7 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
558
586
|
if (cached) return cached;
|
|
559
587
|
|
|
560
588
|
const promise = (async () => {
|
|
561
|
-
const unified = await jspmCall(installs, provider);
|
|
589
|
+
const unified = await jspmCall(installs, provider, timeoutMs);
|
|
562
590
|
if (unified.ok) return unified.imports;
|
|
563
591
|
|
|
564
592
|
// The unified call failed. Drop the cached failure so a later retry
|
|
@@ -570,14 +598,14 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
570
598
|
// per-install fragments (each may still be cached / reachable) so we
|
|
571
599
|
// serve whatever we can, and flag the transient failure for retry.
|
|
572
600
|
lastLiveResolveFailed = true;
|
|
573
|
-
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider))));
|
|
601
|
+
return mergePerInstall(await Promise.all(installs.map(i => jspmResolveOne(i, provider, timeoutMs))));
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
// Permanent failure: at least one install is unresolvable. Probe each
|
|
577
605
|
// in isolation to learn which ones jspm can resolve, then re-run the
|
|
578
606
|
// unified call over only those so the survivors form one consistent
|
|
579
607
|
// graph (restores #446 coherence for the resolvable subset).
|
|
580
|
-
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider)));
|
|
608
|
+
const probes = await Promise.all(installs.map(i => jspmProbeOne(i, provider, timeoutMs)));
|
|
581
609
|
|
|
582
610
|
// A GOOD package whose isolated probe failed TRANSIENTLY (a network blip
|
|
583
611
|
// mid-probe) must NOT be classified as unresolvable and dropped. Only a
|
|
@@ -612,11 +640,11 @@ export async function jspmGenerate(installs, provider = 'jspm') {
|
|
|
612
640
|
return mergePerInstall(probes.map(p => p.imports));
|
|
613
641
|
}
|
|
614
642
|
if (resolvable.length === 0) return {};
|
|
615
|
-
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider);
|
|
643
|
+
if (resolvable.length === 1) return jspmResolveOne(resolvable[0], provider, timeoutMs);
|
|
616
644
|
|
|
617
645
|
// Re-run unified over the resolvable subset. If even that fails (a
|
|
618
646
|
// conflict among the survivors), fall back to their merged fragments.
|
|
619
|
-
const retry = await jspmCall(resolvable, provider);
|
|
647
|
+
const retry = await jspmCall(resolvable, provider, timeoutMs);
|
|
620
648
|
if (retry.ok) return retry.imports;
|
|
621
649
|
return mergePerInstall(resolvable.map(i => probes[installs.indexOf(i)].imports));
|
|
622
650
|
})();
|
|
@@ -679,6 +707,12 @@ export async function vendorImportMapEntries(bareImports, appDir) {
|
|
|
679
707
|
export function clearVendorCache() {
|
|
680
708
|
jspmCache.clear();
|
|
681
709
|
liveIntegrityCache.clear();
|
|
710
|
+
// Deliberately does NOT touch `lastLiveResolveFailed`, which looks like an
|
|
711
|
+
// omission and is not. `resolveVendorImports` is the flag's only reader and
|
|
712
|
+
// it resets the flag on entry, while the pinned short-circuit above that
|
|
713
|
+
// returns `ok: true` outright, so a value left behind by an earlier
|
|
714
|
+
// `pinAll` or `vendorImportMapEntries` can never be observed. Clearing it
|
|
715
|
+
// here would be a change nothing could write a failing test for (#1150).
|
|
682
716
|
}
|
|
683
717
|
|
|
684
718
|
/**
|
|
@@ -1071,14 +1105,21 @@ async function writePinFile(appDir, imports, integrity, provider) {
|
|
|
1071
1105
|
* success or null on failure. The integrity hash is computed from the
|
|
1072
1106
|
* downloaded bytes so it's always consistent with what's on disk.
|
|
1073
1107
|
*
|
|
1108
|
+
* Bounded by PIN_BUNDLE_TIMEOUT_MS. `pinAll(dir, { download: true })`
|
|
1109
|
+
* runs this once per resolved URL on a CLI run with no ambient
|
|
1110
|
+
* deadline, so a CDN that accepts the connection and then stalls would
|
|
1111
|
+
* otherwise hang the pin with nothing to interrupt it (#1150).
|
|
1112
|
+
*
|
|
1074
1113
|
* @param {string} url
|
|
1075
1114
|
* @param {string} appDir
|
|
1076
1115
|
* @param {string} filename
|
|
1077
1116
|
* @returns {Promise<{ bytes: number, integrity: string } | null>}
|
|
1078
1117
|
*/
|
|
1079
1118
|
async function downloadBundle(url, appDir, filename) {
|
|
1119
|
+
const controller = new AbortController();
|
|
1120
|
+
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1080
1121
|
try {
|
|
1081
|
-
const response = await fetch(url);
|
|
1122
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1082
1123
|
if (!response.ok) {
|
|
1083
1124
|
console.error(`[webjs] download ${url} returned ${response.status}`);
|
|
1084
1125
|
return null;
|
|
@@ -1094,8 +1135,13 @@ async function downloadBundle(url, appDir, filename) {
|
|
|
1094
1135
|
await writeFile(join(pinDir(appDir), filename), buf);
|
|
1095
1136
|
return { bytes: buf.byteLength, integrity: await sha384Integrity(buf) };
|
|
1096
1137
|
} catch (e) {
|
|
1097
|
-
|
|
1138
|
+
const why = e && e.name === 'AbortError'
|
|
1139
|
+
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1140
|
+
: e && e.message;
|
|
1141
|
+
console.error(`[webjs] download ${url} failed: ${why}`);
|
|
1098
1142
|
return null;
|
|
1143
|
+
} finally {
|
|
1144
|
+
clearTimeout(timer);
|
|
1099
1145
|
}
|
|
1100
1146
|
}
|
|
1101
1147
|
|
|
@@ -1105,12 +1151,21 @@ async function downloadBundle(url, appDir, filename) {
|
|
|
1105
1151
|
* so the importmap can carry SRI hashes even when bundles aren't
|
|
1106
1152
|
* locally vendored.
|
|
1107
1153
|
*
|
|
1154
|
+
* Bounded by PIN_BUNDLE_TIMEOUT_MS, the same budget `downloadBundle`
|
|
1155
|
+
* gets, since it transfers the same bytes and differs only in whether
|
|
1156
|
+
* they are written to disk. Default-mode `pinAll` runs this once per
|
|
1157
|
+
* resolved URL, so a CDN that accepts the connection and then stalls
|
|
1158
|
+
* would otherwise hang the pin with nothing to interrupt it: there is
|
|
1159
|
+
* no ambient deadline on a CLI run (#1150).
|
|
1160
|
+
*
|
|
1108
1161
|
* @param {string} url
|
|
1109
1162
|
* @returns {Promise<string | null>} the integrity string, or null on failure
|
|
1110
1163
|
*/
|
|
1111
1164
|
async function fetchIntegrity(url) {
|
|
1165
|
+
const controller = new AbortController();
|
|
1166
|
+
const timer = setTimeout(() => controller.abort(), PIN_BUNDLE_TIMEOUT_MS);
|
|
1112
1167
|
try {
|
|
1113
|
-
const response = await fetch(url);
|
|
1168
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1114
1169
|
if (!response.ok) {
|
|
1115
1170
|
console.error(`[webjs] hash ${url} returned ${response.status}`);
|
|
1116
1171
|
return null;
|
|
@@ -1121,8 +1176,13 @@ async function fetchIntegrity(url) {
|
|
|
1121
1176
|
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1122
1177
|
return await sha384Integrity(buf);
|
|
1123
1178
|
} catch (e) {
|
|
1124
|
-
|
|
1179
|
+
const why = e && e.name === 'AbortError'
|
|
1180
|
+
? `timed out after ${PIN_BUNDLE_TIMEOUT_MS}ms`
|
|
1181
|
+
: e && e.message;
|
|
1182
|
+
console.error(`[webjs] hash ${url} failed: ${why}`);
|
|
1125
1183
|
return null;
|
|
1184
|
+
} finally {
|
|
1185
|
+
clearTimeout(timer);
|
|
1126
1186
|
}
|
|
1127
1187
|
}
|
|
1128
1188
|
|
|
@@ -1254,7 +1314,7 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1254
1314
|
installs.push(install);
|
|
1255
1315
|
partsByInstall.set(spec, { pkg, version, subpath });
|
|
1256
1316
|
}
|
|
1257
|
-
const resolved = await jspmGenerate(installs, from);
|
|
1317
|
+
const resolved = await jspmGenerate(installs, from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1258
1318
|
|
|
1259
1319
|
/** @type {Record<string, string>} */
|
|
1260
1320
|
const importmap = {};
|
|
@@ -1496,7 +1556,11 @@ export async function listPinned(appDir) {
|
|
|
1496
1556
|
// ---------------------------------------------------------------------------
|
|
1497
1557
|
|
|
1498
1558
|
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
1499
|
-
|
|
1559
|
+
// The npm registry is reached only by `audit`, `outdated`, and `update`,
|
|
1560
|
+
// all CLI commands, so it takes the same generous budget the pin bundle
|
|
1561
|
+
// fetch does rather than the server's. importmap-rails makes these same two
|
|
1562
|
+
// calls with Ruby's 60s Net::HTTP default.
|
|
1563
|
+
const NPM_TIMEOUT_MS = 60_000;
|
|
1500
1564
|
|
|
1501
1565
|
/**
|
|
1502
1566
|
* Fetch one URL from registry.npmjs.org with a small timeout. Returns
|
|
@@ -1696,7 +1760,7 @@ export async function updatePinned(appDir, opts = {}) {
|
|
|
1696
1760
|
if (specPkg !== pkg) continue;
|
|
1697
1761
|
const subpath = spec.slice(specPkg.length);
|
|
1698
1762
|
const install = `${pkg}@${latest}${subpath}`;
|
|
1699
|
-
const resolved = await jspmGenerate([install], from);
|
|
1763
|
+
const resolved = await jspmGenerate([install], from, PIN_BUNDLE_TIMEOUT_MS);
|
|
1700
1764
|
const newUrl = resolved[spec];
|
|
1701
1765
|
if (!newUrl) continue;
|
|
1702
1766
|
newImports[spec] = newUrl;
|
|
@@ -2169,7 +2233,6 @@ export async function checkImportmapCoherence(imports, opts) {
|
|
|
2169
2233
|
*/
|
|
2170
2234
|
const liveIntegrityCache = new Map();
|
|
2171
2235
|
|
|
2172
|
-
const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
|
|
2173
2236
|
// Cap concurrent bundle fetches so a large dep set does not open dozens of
|
|
2174
2237
|
// sockets at once during warmup. Matches the bounded posture of the rest of
|
|
2175
2238
|
// vendor.js (the jspm resolve is per-package but the network is the shared
|
package/webjs-config.schema.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://webjs.dev/schemas/webjs-config.schema.json",
|
|
4
4
|
"title": "webjs config block",
|
|
5
|
-
"description": "Schema for the `webjs` object in a
|
|
6
|
-
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions
|
|
5
|
+
"description": "Schema for the `webjs` object in a WebJs app's package.json. Each key maps to a documented reader, mostly a server reader and for a few keys a CLI one. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
|
|
6
|
+
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the reader functions. Adding a `webjs.*` key means updating all three (plus the KNOWN_KEYS drift test). The reader inventory is deliberately NOT repeated here: packages/server/AGENTS.md holds the one canonical list and the procedure, because every duplicate of that list drifted.",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"default": 5000
|
|
161
161
|
},
|
|
162
162
|
"dev": {
|
|
163
|
-
"description": "Dev task orchestration (#550). `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js)
|
|
163
|
+
"description": "Dev task orchestration (#550). Split per sub-key: `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), while `regenerate` (readRegenerateRules in dev-regenerate.js) and `watch` (readDevWatchPathsFromApp in dev.js) are read by the server. webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers) alongside the server. Replaces the old predev hook + concurrently watchers.",
|
|
164
164
|
"type": "object",
|
|
165
165
|
"additionalProperties": false,
|
|
166
166
|
"properties": {
|
|
@@ -216,6 +216,22 @@
|
|
|
216
216
|
"items": { "type": "string" }
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
},
|
|
220
|
+
"doctor": {
|
|
221
|
+
"description": "`webjs doctor` policy (#1257). Read by the CLI (readDoctorPolicy in packages/cli/lib/doctor.js), NOT the server.",
|
|
222
|
+
"type": "object",
|
|
223
|
+
"additionalProperties": false,
|
|
224
|
+
"properties": {
|
|
225
|
+
"gate": {
|
|
226
|
+
"description": "Per-check severity, keyed by the stable DOCTOR_CODES code (NODE_VERSION, UNMARKED_ASSET_LINKS, ELISION_CARRIERS, ...). `error` fails the exit, `warn` reports without failing, `off` silences the check: its finding is not printed and it cannot fail the exit, including under --strict (it still appears on the checklist as [off], in the summary's silenced count, and in full under --json). A code with no entry keeps its default: error for a hard toolchain failure, warn otherwise. This is how CI gates on a chosen subset without --strict making every warning fatal. A malformed gate is a hard error, so a typo cannot silently un-gate CI: that covers an unknown code, a bad severity, a wrong SHAPE (a non-object `doctor` or `gate`), and a misspelled sibling of `gate` such as `gates`. A result that could not check (a network or toolchain outage) is capped at warn and can never be escalated to error.",
|
|
227
|
+
"type": "object",
|
|
228
|
+
"propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" },
|
|
229
|
+
"additionalProperties": {
|
|
230
|
+
"type": "string",
|
|
231
|
+
"enum": ["off", "warn", "error"]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
219
235
|
}
|
|
220
236
|
}
|
|
221
237
|
}
|