@webjsdev/server 0.8.58 → 0.8.60

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/src/ssr.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
7
7
  import { jsonForScriptTag } from './script-tag-json.js';
8
8
  import { transitiveDeps, bareImports } from './module-graph.js';
9
- import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
9
+ import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from './action-seed.js';
10
10
  import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
11
11
  import {
12
12
  readRevalidate,
@@ -41,7 +41,7 @@ export async function ssrPage(route, params, url, opts) {
41
41
  // and is threaded back through `opts.pageModule` so renderChain reuses the
42
42
  // same evaluation (no double-load). A cache HIT serves the stored HTML
43
43
  // without re-running the page function. Skipped entirely (no opt-in read,
44
- // no double behaviour) for the page-action re-render (actionData / a non-200
44
+ // no double behaviour) for the form-action re-render (actionData / a non-200
45
45
  // status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
46
46
  // on the request and must not be shared under the full-URL key.
47
47
  const cacheEligible =
@@ -57,7 +57,15 @@ export async function ssrPage(route, params, url, opts) {
57
57
  revalidateSeconds = readRevalidate(pageMod);
58
58
  if (revalidateSeconds !== null) {
59
59
  const hit = await readHtmlCache(url);
60
- if (hit) return cachedHtmlResponse(hit, opts.req, url);
60
+ if (hit) {
61
+ const cached = cachedHtmlResponse(hit, opts.req, url);
62
+ // A cache hit returns before any seed work runs, so it would otherwise
63
+ // report nothing at all, which reads as "seeding is broken" when it is
64
+ // really the cache answering (#1309). The seed block rides INSIDE the
65
+ // cached bytes, so the seeds are exactly as fresh as the HTML.
66
+ if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache');
67
+ return cached;
68
+ }
61
69
  }
62
70
  } catch {
63
71
  // A load / store failure falls through to a normal fresh render: the
@@ -288,16 +296,40 @@ export async function ssrPage(route, params, url, opts) {
288
296
  // block (those slow regions keep the stale-while-revalidate refetch). An
289
297
  // empty collector yields '' so the output stays byte-identical.
290
298
  let outBody = streamBody;
291
- if (seedCollector && suspenseCtx.pending.length === 0) {
292
- const seedScript = await buildSeedScript(seedCollector);
299
+ // Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept
300
+ // DISTINCT from `collected=0` on purpose: the counting lives with the
301
+ // collector rather than behind the seed gate, so a seeding-DISABLED app
302
+ // never looks like a seeding-BROKEN one.
303
+ let seedHeader = 'off';
304
+ const streamed = suspenseCtx.pending.length > 0;
305
+ if (seedCollector && streamed) {
306
+ // A streamed render's deferred boundaries resolve AFTER the first flush,
307
+ // so their results cannot ride this block and none is emitted in prod. In
308
+ // DEV emit the marker alone, so the client reports the CAUSE instead of
309
+ // leaving the developer to guess why every action call went to the network.
310
+ seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`;
311
+ if (opts.dev) {
312
+ const marker = await buildSeedScript(null, { dev: true, reason: 'streamed' });
313
+ if (marker) outBody = streamBody + marker;
314
+ }
315
+ } else if (seedCollector) {
316
+ const seedScript = await buildSeedScript(seedCollector, { dev: opts.dev });
293
317
  if (seedScript) outBody = streamBody + seedScript;
318
+ // `emitted` differs from `collected` exactly when the serializer threw and
319
+ // dropped the whole block, which is otherwise a completely invisible
320
+ // failure. In DEV that throw still emits a marker-only block, so the
321
+ // browser can name the cause; it carries no seeds, so it must NOT count as
322
+ // emitted or the header would report a total success on the very failure
323
+ // it exists to expose.
324
+ const dropped = seedScript === SEED_DROP_BLOCK;
325
+ seedHeader = `collected=${seedCollector.size}, emitted=${seedScript && !dropped ? seedCollector.size : 0}`;
294
326
  }
295
327
  const res = streamingHtmlResponse(
296
328
  prefix,
297
329
  outBody,
298
330
  closer,
299
331
  suspenseCtx,
300
- // Normally 200. After a failed page `action` submission the caller passes
332
+ // Normally 200. After a failed form-action submission the caller passes
301
333
  // 422 (or another 4xx) so the re-rendered page with field errors carries
302
334
  // the right status for both the no-JS reload and the enhanced swap (#244).
303
335
  opts.status || 200,
@@ -307,6 +339,11 @@ export async function ssrPage(route, params, url, opts) {
307
339
  nonce,
308
340
  opts.dev,
309
341
  );
342
+ // Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
343
+ // hit from the outside, so an app whose seeding silently broke looks exactly
344
+ // like one where it works. Dev only: a production header would publish how
345
+ // many server calls a page made, for no benefit.
346
+ if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
310
347
  // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
311
348
  // outer-layout chrome, so these bytes are only valid for a request that
312
349
  // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
@@ -346,7 +383,7 @@ export async function ssrPage(route, params, url, opts) {
346
383
  // navigation (an auth bounce, a gate). 302 Found is the conventional
347
384
  // code there, so it is the default when the caller did not pick one. An
348
385
  // explicit `redirect(url, status)` overrides it. (Action redirects, a
349
- // POST, default to 307 in page-action.js so the method is preserved.)
386
+ // POST, default to 307 in form-dispatch.js so the method is preserved.)
350
387
  return new Response(null, { status: e.status || 302, headers: { location: e.url } });
351
388
  }
352
389
  if (isNotFound(err)) {
@@ -459,7 +496,7 @@ export async function ssrNotFound(notFoundFile, opts) {
459
496
  /**
460
497
  * 403 response for a thrown forbidden() (#848). Renders the nearest
461
498
  * forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
462
- * page-render catch (ssr.js) and the page-action write path (page-action.js) so
499
+ * page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
463
500
  * both behave identically.
464
501
  * @param {{ forbiddens?: string[] }} route
465
502
  * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
@@ -674,10 +711,13 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
674
711
  }
675
712
 
676
713
  async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
677
- // Reuse a caller-supplied page module when present (the page-action
678
- // re-render passes the exact module whose `action` just ran, so the
679
- // failure re-render shares that single evaluation instead of re-importing
680
- // and re-running the module's top-level side effects).
714
+ // Reuse a caller-supplied page module when present. The only producer is the
715
+ // HTML-cache path above, which loads the module to read `export const
716
+ // revalidate` and threads it back so this does not load it a second time.
717
+ // (The removed page `action` export used to be a second producer: it ran in
718
+ // the page module, so the 422 re-render could share that evaluation. A form
719
+ // action lives in a `.server.*` module instead, so the re-render loads the
720
+ // page module itself, exactly as a plain render does.)
681
721
  const page = pageModule || await loadModule(route.file, dev);
682
722
  if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
683
723
  let tree = await page.default(ctx);
@@ -1657,6 +1697,36 @@ function wrapHead(opts) {
1657
1697
  // importmap-rails) applies nonce on every modulepreload tag for
1658
1698
  // the same reason.
1659
1699
  const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
1700
+ // Core runtime modulepreload (#1118). Every module the boot imports pulls
1701
+ // `@webjsdev/core`, but the boot script names only page and component URLs,
1702
+ // so without this hint the browser discovers core only after one of those is
1703
+ // fetched AND parsed: one full round trip into the load, which is exactly
1704
+ // where the pre-boot click window lives on a cold, throttled connection.
1705
+ //
1706
+ // The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
1707
+ // map's targets are already base-path-prefixed and content-hashed), so it is
1708
+ // byte-identical to what the import resolves to. A differing href makes the
1709
+ // browser treat the preload and the import as two resources and fetch core
1710
+ // twice. That is why this copies the vendor loop below, not the module loop
1711
+ // above. `vendorPreloadTargets` still excludes core deliberately; this hint
1712
+ // is emitted from the head builder, not the vendor path.
1713
+ //
1714
+ // Gated on the boot actually shipping something. A fully elided page ships no
1715
+ // boot module and must not be handed a preload for a runtime it never loads
1716
+ // (#780), which also keeps it off `global-error.{js,ts}`, whose document is
1717
+ // returned verbatim with no importmap and no boot script.
1718
+ if (opts.moduleUrls.length || lazyEntries) {
1719
+ const coreMap = buildImportMap();
1720
+ const coreHref = coreMap.imports['@webjsdev/core'];
1721
+ if (coreHref) {
1722
+ const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
1723
+ const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
1724
+ linkTags.push(
1725
+ `<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
1726
+ `${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
1727
+ );
1728
+ }
1729
+ }
1660
1730
  // Sub-path deployment (issue #256): the modulepreload href is prefixed with
1661
1731
  // the base path (a no-op when empty), but `crossorigin` / `integrity` are
1662
1732
  // decided on the ORIGINAL url, so the integrity lookup still keys on the
@@ -2052,7 +2122,7 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
2052
2122
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
2053
2123
  // Default: no caching. Pages are dynamic by default: the developer
2054
2124
  // opts in to caching explicitly via metadata.cacheControl. A non-200 does
2055
- // NOT inherit it (#1140): the page-action re-render is a 422 carrying the
2125
+ // NOT inherit it (#1140): the form-action re-render is a 422 carrying the
2056
2126
  // submitter's own field values and errors, which must never be handed to a
2057
2127
  // shared cache just because the page opted into public caching.
2058
2128
  headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
@@ -2095,6 +2165,13 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
2095
2165
  try {
2096
2166
  const resolved = await p.promise;
2097
2167
  const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
2168
+ // A fresh scan that cannot see the shell the boundary sits in.
2169
+ // That used to require carrying the boundary's form scope
2170
+ // (#1207), or a `<button formaction=${fn}>` inside a bound
2171
+ // form's boundary read as form-less, was refused, and the catch
2172
+ // below turned it into an empty boundary on a 200 in
2173
+ // production. #1307 made a bound submitter self-sufficient, so
2174
+ // there is nothing left to carry.
2098
2175
  const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
2099
2176
  ctx.nextId = sub.nextId;
2100
2177
  for (const n of sub.pending) ctx.pending.push(n);
@@ -2141,13 +2218,12 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
2141
2218
  * serves a single evaluation; in dev a cache-bust query forces a fresh
2142
2219
  * evaluation so source edits take effect (which also re-runs the module's
2143
2220
  * top-level side effects, the reason pages/layouts must keep their top level
2144
- * side-effect-free). Exported so page-action.js loads the page module the same
2145
- * way the SSR re-render does.
2221
+ * side-effect-free).
2146
2222
  *
2147
2223
  * @param {string} file
2148
2224
  * @param {boolean} dev
2149
2225
  */
2150
- export async function loadModule(file, dev) {
2226
+ async function loadModule(file, dev) {
2151
2227
  const url = pathToFileURL(file).toString();
2152
2228
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2153
2229
  return import(url + bust);
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 page `action` or a `route` handler
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(), JSPM_GENERATE_TIMEOUT_MS);
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 ${JSPM_GENERATE_TIMEOUT_MS}ms`
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
- console.error(`[webjs] download ${url} failed: ${e && e.message}`);
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
- console.error(`[webjs] hash ${url} failed: ${e && e.message}`);
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
- const NPM_TIMEOUT_MS = 10_000;
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