@webjsdev/server 0.8.58 → 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/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
@@ -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 webjs app's package.json. Each key maps to a documented reader (a server reader, or for `dev`/`start` the CLI's readAppTasks). 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 (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js, and the CLI's readAppTasks in packages/cli/lib/app-tasks.js for dev/start, #550). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
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); `regenerate` is read by the server (readRegenerateRules in dev-regenerate.js). 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.",
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
  }
@@ -1,259 +0,0 @@
1
- import { isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
2
- import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized, loadModule } from './ssr.js';
3
- import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
- import { getBodyLimits } from './context.js';
5
- import { propagateTrustedRemoteIp } from './rate-limit.js';
6
- import { makeThenable } from './thenable-params.js';
7
-
8
- /**
9
- * Page server actions: a `page.{js,ts}` may export an `action` function that
10
- * handles a non-GET/HEAD submission to the page's own URL. This is webjs's
11
- * Remix-style page-action path, adapted to the no-build progressive-enhancement
12
- * model: a `<form method="POST">` submits with JS disabled, the action runs on
13
- * the server, and a validation failure re-renders the SAME page with field
14
- * errors and the user's typed values preserved.
15
- *
16
- * Behavior (see #244):
17
- * - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
18
- * render does (3xx / 404). A thrown `redirect()` may target an external URL
19
- * (it is the explicit nav sentinel, author-controlled).
20
- * - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
21
- * present, else to the page's own path (Post/Redirect/Get, so a reload does
22
- * not resubmit). `result.redirect` MUST be a same-site local path (see
23
- * `sameSiteRedirect`), a non-local value is ignored to avoid an
24
- * open-redirect through a user-controlled action result.
25
- * - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
26
- * the result on `ctx.actionData`, so the page template can read
27
- * `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
28
- *
29
- * Failure detection is robust (it does not require a literal `success: false`):
30
- * see `isFailureResult`.
31
- *
32
- * @typedef {{
33
- * success?: boolean,
34
- * data?: unknown,
35
- * error?: string,
36
- * fieldErrors?: Record<string,string>,
37
- * values?: Record<string,string>,
38
- * status?: number,
39
- * redirect?: string,
40
- * }} ActionResult
41
- */
42
-
43
- /**
44
- * Whether an action result is a FAILURE (re-render the page) rather than a
45
- * success (PRG redirect). A result is a failure when ANY of these hold:
46
- * - `result.success === false` (explicit), OR
47
- * - `result.fieldErrors` is present (per-field validation messages), OR
48
- * - `result.error` is present AND `result.success !== true`.
49
- *
50
- * Success is the explicit `success: true`, or a bare value (or
51
- * undefined/null) carrying no error markers. This means an action that returns
52
- * `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
53
- * is still treated as a failure and its error is surfaced, not swallowed.
54
- *
55
- * @param {ActionResult | null | undefined} result
56
- * @returns {boolean}
57
- */
58
- function isFailureResult(result) {
59
- if (!result || typeof result !== 'object') return false;
60
- if (result.success === false) return true;
61
- if (result.fieldErrors != null) return true;
62
- if (result.error != null && result.success !== true) return true;
63
- return false;
64
- }
65
-
66
- /**
67
- * Restrict a page action's `result.redirect` to a SAME-SITE local target.
68
- * Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
69
- * Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
70
- * URL. A user-controlled redirect target is an open-redirect vector, so a
71
- * non-local value is dropped and the caller falls back to the page's own path.
72
- *
73
- * A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
74
- * routed through here: that is the author-controlled escape hatch for a
75
- * legitimate external redirect.
76
- *
77
- * @param {unknown} target
78
- * @returns {string | null} the safe local path, or null when not same-site
79
- */
80
- function sameSiteRedirect(target) {
81
- if (typeof target !== 'string') return null;
82
- // Must start with a single slash (a leading `//` is protocol-relative and
83
- // would navigate cross-origin).
84
- if (!target.startsWith('/') || target.startsWith('//')) return null;
85
- // A backslash after the leading slash (`/\evil.com`) is normalized by some
86
- // browsers into a protocol-relative URL, so reject it too.
87
- if (target.startsWith('/\\')) return null;
88
- return target;
89
- }
90
-
91
- /**
92
- * Load a page module and return its `action` export, or null if it has none.
93
- * Uses ssr.js's shared `loadModule`, so page-action loading is consistent with
94
- * the SSR re-render. In prod the URL is stable and Node's module cache serves
95
- * one evaluation; in dev a cache-bust forces a fresh evaluation.
96
- *
97
- * @param {string} file absolute path to the page module
98
- * @param {boolean} dev
99
- * @returns {Promise<{ action: Function, module: Record<string, unknown> } | null>}
100
- */
101
- export async function loadPageAction(file, dev) {
102
- try {
103
- const mod = await loadModule(file, dev);
104
- return typeof mod.action === 'function'
105
- ? { action: /** @type {Function} */ (mod.action), module: mod }
106
- : null;
107
- } catch {
108
- return null;
109
- }
110
- }
111
-
112
- /**
113
- * Read the submitted body ONCE, bounded by the form/multipart limit (issue
114
- * #237), and return both a `FormData` (handed to the action as `formData`) and a
115
- * rebuilt `Request` carrying the already-read bytes (handed to the action as
116
- * `request`, so it can still call `request.json()` / `request.formData()`). The
117
- * body is consumed off the ORIGINAL request directly, NOT via `req.clone()`: a
118
- * tee'd clone whose reader is cancelled mid-stream (the over-limit case)
119
- * deadlocks the untaken branch, hanging the response.
120
- *
121
- * An over-limit body is reported as `tooLarge` (the caller returns 413) and is
122
- * never buffered whole. A form posts more than a JSON RPC call (textarea, small
123
- * upload), so it uses the higher `multipart` cap. A non-form content type yields
124
- * an empty FormData so the action signature stays stable; the rebuilt request
125
- * still carries the raw bytes for the action to parse however it likes.
126
- *
127
- * @param {Request} req
128
- * @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
129
- */
130
- async function parseFormBody(req) {
131
- const ct = req.headers.get('content-type') || '';
132
- const limits = getBodyLimits();
133
- const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
134
- const { tooLarge, bytes } = await readBytesBounded(req, limit);
135
- if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
136
-
137
- // Rebuild a fresh Request from the bytes so the action can re-read the body.
138
- // SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
139
- // client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
140
- // remote IP forward out of band (the rebuild is a new object, so the listener's
141
- // WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
142
- // page `action` (the no-JS form write path, e.g. login throttling) would read
143
- // the spoofable header on Bun.
144
- const headers = new Headers(req.headers);
145
- headers.delete('x-webjs-remote-ip');
146
- const rebuilt = new Request(req.url, {
147
- method: req.method,
148
- headers,
149
- body: bytes && bytes.byteLength ? bytes : undefined,
150
- });
151
- propagateTrustedRemoteIp(req, rebuilt);
152
-
153
- const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
154
- let formData = new FormData();
155
- if (isForm) {
156
- // Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
157
- const forParse = new Request(req.url, {
158
- method: 'POST',
159
- headers: ct ? { 'content-type': ct } : undefined,
160
- body: bytes && bytes.byteLength ? bytes : undefined,
161
- });
162
- formData = await forParse.formData();
163
- }
164
- return { tooLarge: false, formData, request: rebuilt };
165
- }
166
-
167
- /**
168
- * Run a page `action` for a non-GET/HEAD request and produce the HTTP response.
169
- * The caller has already confirmed the path matches a page route AND that the
170
- * page module exports an `action` (via `loadPageAction`). The action runs inside
171
- * the same segment middleware as the page (the caller wraps this).
172
- *
173
- * The failure re-render reuses the SAME page module instance whose `action` just
174
- * ran (passed through as `pageModule`), so the page module is loaded once per
175
- * POST rather than evaluated a second time.
176
- *
177
- * @param {import('./router.js').PageRoute} route
178
- * @param {Record<string,string>} params
179
- * @param {URL} url
180
- * @param {{ action: Function, module: Record<string, unknown> }} loaded the page module's `action` plus the loaded module
181
- * @param {Request} req
182
- * @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
183
- * @returns {Promise<Response>}
184
- */
185
- export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
186
- const { action, module: pageModule } = loaded;
187
- const searchParams = Object.fromEntries(url.searchParams.entries());
188
- let formData = new FormData();
189
- // The body is read ONCE here (bounded). `actionReq` is a rebuilt request the
190
- // action can re-read; on a parse failure it falls back to the original `req`.
191
- let actionReq = req;
192
- try {
193
- const parsed = await parseFormBody(req);
194
- // Over the form/multipart limit (issue #237): 413 before the action runs.
195
- if (parsed.tooLarge) return payloadTooLarge();
196
- formData = parsed.formData;
197
- actionReq = parsed.request;
198
- } catch {
199
- formData = new FormData();
200
- }
201
-
202
- /** @type {ActionResult | undefined} */
203
- let result;
204
- try {
205
- // params / searchParams are awaitable AND sync-readable here too (#848).
206
- result = await action({
207
- request: actionReq,
208
- params: makeThenable(params),
209
- searchParams: makeThenable(searchParams),
210
- url,
211
- formData,
212
- });
213
- } catch (err) {
214
- if (isRedirect(err)) {
215
- const e = /** @type any */ (err);
216
- // A thrown redirect from an action (a POST) defaults to 307 Temporary
217
- // Redirect, which is method-preserving so the action's intent survives
218
- // the bounce; an explicit `redirect(url, status)` overrides it. This is
219
- // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
220
- // the SUCCESS-result path below.
221
- return new Response(null, { status: e.status || 307, headers: { location: e.url } });
222
- }
223
- if (isNotFound(err)) {
224
- return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
225
- }
226
- // forbidden()/unauthorized() from a page action render the same 403/401
227
- // boundary as the page-render path (#848), not a generic 500.
228
- if (isForbidden(err)) {
229
- return ssrForbidden(route, { ...ssrOpts, req, url });
230
- }
231
- if (isUnauthorized(err)) {
232
- return ssrUnauthorized(route, { ...ssrOpts, req, url });
233
- }
234
- throw err;
235
- }
236
-
237
- // A page action MAY return a `Response` directly (e.g. a content-negotiated
238
- // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
239
- // content type and the router applies it (a stream body surgically). With JS
240
- // off the same action returns a normal ActionResult instead, so the PRG /
241
- // re-render paths below still drive the no-JS form.
242
- if (result instanceof Response) return result;
243
-
244
- if (!isFailureResult(result)) {
245
- // SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
246
- // honored when it is a same-site local path; otherwise fall back to the
247
- // page's own path so a poisoned value cannot become an open redirect.
248
- const ownPath = (url.pathname + url.search) || '/';
249
- const safe = result ? sameSiteRedirect(result.redirect) : null;
250
- return new Response(null, { status: 303, headers: { location: safe || ownPath } });
251
- }
252
-
253
- // FAILURE: re-render the SAME page with the action result available on
254
- // ctx.actionData, status 422. Repopulation is the page author's job (native
255
- // `value=${actionData.values?.field}`). Pass the already-loaded page module so
256
- // the re-render shares this POST's single evaluation.
257
- const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
258
- return ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status, pageModule });
259
- }