@webjsdev/server 0.9.0 → 0.10.1

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.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
package/src/ssr/render.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
2
+ import { headerSafeLocation } from '../redirect-location.js';
2
3
  import { vendorPreloadTargets } from '../importmap.js';
3
4
  import {
4
5
  componentPreloads, pagePreloadHints, toUrlPath,
@@ -8,10 +9,11 @@ import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '
8
9
  import {
9
10
  readRevalidate,
10
11
  readHtmlCache,
12
+ htmlCacheGeneration,
11
13
  HTML_CACHE_MARKER,
12
14
  } from '../html-cache.js';
13
15
  import { requestedFrameId, extractFrameSubtree } from '../frame-render.js';
14
- import { makeThenable } from '../thenable-params.js';
16
+ import { makeThenable, searchParamsRecord } from '../thenable-params.js';
15
17
  import { buildDocumentParts, wrapInDocument, layoutSegmentPath, pageSegmentPath, boundarySegmentPath, layoutsForBoundary, regionRouteKey, wrapWithChildrenMarker } from './document.js';
16
18
  import { escapeHtml } from './escape.js';
17
19
  import {
@@ -19,6 +21,9 @@ import {
19
21
  } from './responses.js';
20
22
  import { devImport } from '../dev-import.js';
21
23
  import { applySeoDefaults } from './seo.js';
24
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from '../action-error.js';
25
+ import { readFlash, encodeFlash, FLASH_COOKIE } from '../action-flash.js';
26
+ import { requestCookieStore } from '../context.js';
22
27
 
23
28
 
24
29
  /**
@@ -123,11 +128,25 @@ function nearest(arr) {
123
128
  }
124
129
 
125
130
  /**
131
+ * Collect and merge metadata across the route's layouts (outermost first) and
132
+ * its page, Next.js style. `generateMetadata(props, parent)` and
133
+ * `generateViewport(props, parent)` receive `parent`, a promise of the
134
+ * metadata (or viewport) resolved from the segments above, with `title`
135
+ * normalized to Next's `{ absolute, template }`.
136
+ *
137
+ * A thrown `notFound()` / `redirect()` / `forbidden()` / `unauthorized()`
138
+ * propagates, so it answers the request exactly as it would from the page. Any
139
+ * other error is logged and reported through `onError`, and that segment's
140
+ * metadata is skipped, so a broken `generateMetadata` never fails the page.
141
+ *
126
142
  * @param {import('../router.js').PageRoute} route
127
143
  * @param {Record<string,unknown>} ctx
128
- * @param {boolean} dev
144
+ * @param {{ dev?: boolean, onError?: (e: unknown) => void }} opts
129
145
  */
130
- async function collectMetadata(route, ctx, dev) {
146
+ async function collectMetadata(route, ctx, opts) {
147
+ const dev = !!opts.dev;
148
+ /** @type {Record<string, any>} */
149
+ let viewportSoFar = {};
131
150
  /** @type {Record<string, any>} */
132
151
  let meta = {};
133
152
  // Carry the title template forward across layers. Once an outer layout
@@ -141,7 +160,7 @@ async function collectMetadata(route, ctx, dev) {
141
160
  const mod = await loadModule(file, dev);
142
161
  let m = null;
143
162
  if (typeof mod.generateMetadata === 'function') {
144
- m = await mod.generateMetadata(ctx);
163
+ m = await mod.generateMetadata(ctx, Promise.resolve(resolvedParent(meta, titleTemplate)));
145
164
  } else if (mod.metadata) {
146
165
  m = mod.metadata;
147
166
  }
@@ -152,14 +171,17 @@ async function collectMetadata(route, ctx, dev) {
152
171
  // working. Likewise for `themeColor` and `colorScheme`.
153
172
  let vp = null;
154
173
  if (typeof mod.generateViewport === 'function') {
155
- vp = await mod.generateViewport(ctx);
174
+ vp = await mod.generateViewport(ctx, Promise.resolve({ ...viewportSoFar }));
156
175
  } else if (mod.viewport) {
157
176
  vp = mod.viewport;
158
177
  }
159
178
  if (vp && typeof vp === 'object') {
179
+ viewportSoFar = { ...viewportSoFar, ...vp };
160
180
  m = { ...(m || {}), _viewport: { ...(m && m._viewport), ...vp } };
161
181
  // Allow `themeColor` / `colorScheme` to live on the viewport export.
162
- if (typeof vp.themeColor === 'string' && !(m && m.themeColor)) {
182
+ // `themeColor` may be a string, a `{ color, media }` descriptor, or an
183
+ // array of them (Next's shape); head.js emits one <meta> per entry.
184
+ if (vp.themeColor && !(m && m.themeColor)) {
163
185
  m.themeColor = vp.themeColor;
164
186
  }
165
187
  if (typeof vp.colorScheme === 'string') m.colorScheme = vp.colorScheme;
@@ -175,6 +197,11 @@ async function collectMetadata(route, ctx, dev) {
175
197
  // { template, default, absolute }: `absolute` overrides everything;
176
198
  // `template` is captured for deeper layers; `default` is the value
177
199
  // used when no deeper layer supplies a plain title string.
200
+ // A layer's own template applies to the layers BELOW it, never to
201
+ // its own `default`, which goes through the closest PARENT template
202
+ // as Next.js documents ("augments title.template from the closest
203
+ // parent segment", #1686).
204
+ const parentTemplate = titleTemplate;
178
205
  if (typeof t.template === 'string') titleTemplate = t.template;
179
206
  if (typeof t.absolute === 'string') {
180
207
  resolved.title = t.absolute;
@@ -182,15 +209,22 @@ async function collectMetadata(route, ctx, dev) {
182
209
  // it for deeper segments below, but the *current* segment is
183
210
  // rendered absolute.
184
211
  } else if (typeof t.default === 'string') {
185
- resolved.title = t.default;
212
+ resolved.title = parentTemplate ? parentTemplate.replace('%s', t.default) : t.default;
186
213
  } else {
187
214
  delete resolved.title;
188
215
  }
189
216
  }
190
217
  }
191
218
  meta = { ...meta, ...resolved };
192
- } catch {
193
- // ignore: metadata collection never fails the request
219
+ } catch (e) {
220
+ // Control flow answers the request, as it would from the page.
221
+ if (isControlFlowThrow(e)) throw e;
222
+ // Anything else is a bug in that segment's metadata: say so, report it,
223
+ // and render the page with the metadata the other segments produced.
224
+ console.error(`[webjs] metadata failed in ${file}:`, e);
225
+ if (typeof opts.onError === 'function') {
226
+ try { opts.onError(e); } catch { /* a throwing sink must not affect the response */ }
227
+ }
194
228
  }
195
229
  }
196
230
  try {
@@ -200,6 +234,19 @@ async function collectMetadata(route, ctx, dev) {
200
234
  }
201
235
  }
202
236
 
237
+ /**
238
+ * The `parent` metadata a `generateMetadata` receives: a shallow snapshot of
239
+ * what the outer segments resolved, `title` in Next's `{ absolute, template }`
240
+ * shape so `(await parent).title.absolute` works as it does in Next.
241
+ * @param {Record<string, any>} meta
242
+ * @param {string | null} template
243
+ */
244
+ function resolvedParent(meta, template) {
245
+ const snap = { ...meta };
246
+ snap.title = { absolute: typeof meta.title === 'string' ? meta.title : '', template };
247
+ return snap;
248
+ }
249
+
203
250
  /**
204
251
  * Like layoutSegmentPath but for `loading.{js,ts}` files. Strips the
205
252
  * `loading.ext` filename from the URL path under app/.
@@ -470,7 +517,11 @@ async function defaultBoundaryInChain(raw, opts) {
470
517
  * @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
471
518
  */
472
519
  function rawTemplate(raw) {
473
- return { _$webjs: 'template', strings: [raw], values: [] };
520
+ // `raw` marks the strings as the framework's own markup, which is what the
521
+ // renderers require of a template result (a tagged template's strings have it).
522
+ const strings = [raw];
523
+ Object.defineProperty(strings, 'raw', { value: [raw] });
524
+ return { _$webjs: 'template', strings, values: [] };
474
525
  }
475
526
 
476
527
  /**
@@ -486,6 +537,35 @@ function wrapLayoutsFor(route, boundaryFile) {
486
537
  return layoutsForBoundary(route.layouts, boundarySegmentPath(boundaryFile));
487
538
  }
488
539
 
540
+ /**
541
+ * The metadata of a 404 / 403 / 401 page, resolved like a page's (#1686): the
542
+ * layouts that wrap the boundary, then the boundary file's own `metadata` or
543
+ * `generateMetadata`, so the title runs through the layouts' template and a
544
+ * layout's description and openGraph carry over, as in Next.js. The
545
+ * framework's default page (no boundary file) keeps its own `fallbackTitle`;
546
+ * a boundary file that names no title takes the layouts' default.
547
+ * @param {string | null} file
548
+ * @param {string} fallbackTitle
549
+ * @param {{ dev: boolean, route?: any, ctx?: Record<string, unknown>,
550
+ * params?: Record<string, string>, url?: URL, onError?: (e: unknown) => void }} opts
551
+ * @returns {Promise<Record<string, unknown>>}
552
+ */
553
+ async function boundaryMetadata(file, fallbackTitle, opts) {
554
+ const layouts = opts.route ? (file ? wrapLayoutsFor(opts.route, file) : opts.route.layouts || []) : [];
555
+ const metadataFiles = file ? [...layouts, file] : layouts;
556
+ const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
557
+ /** @type {Record<string, any>} */
558
+ let meta = {};
559
+ try {
560
+ meta = await collectMetadata(/** @type any */ ({ metadataFiles }), ctx, { dev: opts.dev, onError: opts.onError });
561
+ } catch {
562
+ // A control-flow throw from a layout's metadata cannot re-route an error
563
+ // page that is already the answer; the fallback title stands.
564
+ }
565
+ if (!file || meta.title === undefined) meta = { ...meta, title: file && meta.title !== undefined ? meta.title : fallbackTitle };
566
+ return meta;
567
+ }
568
+
489
569
  /**
490
570
  * Marker for a throw that came from the LAYOUT-wrapping phase of a boundary
491
571
  * render, as opposed to the boundary's own tree (#1298). Non-enumerable, so it
@@ -652,24 +732,17 @@ function reportBoundaryLayoutError(err, opts, cfg = {}) {
652
732
  * The boot module set for a boundary response: what actually RENDERED, the
653
733
  * boundary module plus the layouts wrapping it (#1298).
654
734
  *
655
- * Applies the SAME inert / import-only substitution as the happy-path boot
656
- * (#963), so a boundary never ships a route module the normal render drops.
657
- * Pre-substitution, an import-only layout with a bare `.server.*` import
658
- * (legal, it never loads client-side) would load here whole and crash the
659
- * boundary page's boot on the throw-at-load stub, killing the sibling
660
- * component registrations in the same module script.
661
- *
662
- * The substitution applies to the boundary file harmlessly: only page and
663
- * layout files are fed to the elision analysis, so a boundary is never inert
664
- * and never import-only, gets no verdict, and is pushed as-is. Every boundary
665
- * kind is a `browserEntryFiles` entry (browser-entries.js), so each of these
666
- * URLs is servable through the auth gate.
735
+ * Applies the SAME frontier substitution as the happy-path boot, so a
736
+ * boundary response never ships a route module (#1686): the boundary and each
737
+ * wrapping layout are replaced by the components they import. Shipped whole, a
738
+ * layout with a bare `.server.*` import (legal, it never loads client-side)
739
+ * would crash the boundary page's boot on the throw-at-load stub, killing the
740
+ * sibling component registrations in the same module script.
667
741
  *
668
742
  * @param {string | null} boundaryFile Null for the framework's default body,
669
743
  * which has no module of its own; only the layouts boot then.
670
744
  * @param {string[]} wrapLayouts
671
- * @param {{ appDir: string, inertRouteModules?: Set<string>,
672
- * importOnlyRouteModules?: Map<string, string[]>,
745
+ * @param {{ appDir: string, routeFrontiers?: Map<string, string[]>,
673
746
  * instrumentationClient?: string }} opts
674
747
  * @returns {string[]}
675
748
  */
@@ -682,10 +755,10 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
682
755
  // `lazyFiles` is deliberately not passed: a boundary render has no lazy map
683
756
  // of its own to register a diverted frontier component with, so a `static
684
757
  // lazy` component imported by a boundary is imported eagerly, as before.
685
- return assembleBootModules(boundaryFile ? [boundaryFile, ...wrapLayouts] : wrapLayouts, {
758
+ // Nesting order, as on the happy path: the wrapping layouts, then the boundary.
759
+ return assembleBootModules(boundaryFile ? [...wrapLayouts, boundaryFile] : wrapLayouts, {
686
760
  appDir: opts.appDir,
687
- inert: opts.inertRouteModules,
688
- importOnly: opts.importOnlyRouteModules,
761
+ frontiers: opts.routeFrontiers,
689
762
  lazyFiles: undefined,
690
763
  instrumentationClient: opts.instrumentationClient,
691
764
  }).moduleUrls;
@@ -703,12 +776,39 @@ function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
703
776
  function boundaryCtx(params, url, actionData) {
704
777
  return {
705
778
  params: makeThenable(params || {}),
706
- searchParams: makeThenable(url ? Object.fromEntries(url.searchParams.entries()) : {}),
779
+ searchParams: makeThenable(url ? searchParamsRecord(url.searchParams) : {}),
707
780
  url: url ? url.toString() : '',
708
781
  actionData,
709
782
  };
710
783
  }
711
784
 
785
+ /**
786
+ * The markup a streamed Suspense boundary renders when its content throws
787
+ * notFound(), forbidden() or unauthorized() after the 200 went out (#1686):
788
+ * the nearest matching boundary file, else the default heading. Null for any
789
+ * other throw, which keeps the streamed-error behaviour.
790
+ * @param {unknown} e
791
+ * @param {any} route
792
+ * @param {Record<string, unknown>} ctx
793
+ * @param {{ dev: boolean, globalNotFound?: string | null, onError?: (e: unknown) => void }} opts
794
+ * @returns {Promise<string | null>}
795
+ */
796
+ async function streamedAccessFallback(e, route, ctx, opts) {
797
+ const kind = isNotFound(e) ? 'notFounds' : isForbidden(e) ? 'forbiddens' : isUnauthorized(e) ? 'unauthorizeds' : null;
798
+ if (!kind) return null;
799
+ const heading = kind === 'notFounds' ? '404: Not found' : kind === 'forbiddens' ? '403: Forbidden' : '401: Unauthorized';
800
+ const file = nearest(route[kind]) || (kind === 'notFounds' ? opts.globalNotFound : null);
801
+ if (!file) return `<h1>${heading}</h1>`;
802
+ try {
803
+ const mod = await loadModule(file, opts.dev);
804
+ if (typeof mod.default !== 'function') return `<h1>${heading}</h1>`;
805
+ return await renderToString(await mod.default(ctx), { ssr: true, dev: opts.dev });
806
+ } catch (err) {
807
+ reportBoundaryLayoutError(err, opts, { what: STAGE_BOUNDARY, overlay: true });
808
+ return `<h1>${heading}</h1>`;
809
+ }
810
+ }
811
+
712
812
  /**
713
813
  * Render a simple boundary page (forbidden / unauthorized, #848) at the given
714
814
  * default heading. Loads the nearest boundary module when one exists, else
@@ -729,6 +829,9 @@ async function ssrBoundaryHtml(file, heading, opts) {
729
829
  let body = `<h1>${heading}</h1>`;
730
830
  /** @type {string[]} */
731
831
  let moduleUrls = [];
832
+ // Whether the boundary module imported, so a broken one is reported once
833
+ // (by the render below) and not again by the metadata read.
834
+ let loaded = false;
732
835
  // No boundary FILE, but a route matched: the framework's own default page is
733
836
  // what renders, and it belongs inside the layouts just as a custom one does.
734
837
  // This is the COMMON case for 403 / 401, since most apps ship no
@@ -743,6 +846,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
743
846
  const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
744
847
  try {
745
848
  const mod = await loadModule(file, opts.dev);
849
+ loaded = true;
746
850
  if (mod.default) {
747
851
  const tree = await mod.default(ctx);
748
852
  try {
@@ -819,7 +923,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
819
923
  }
820
924
  const nonce = getNonce();
821
925
  return wrapInDocument(body, {
822
- metadata: { title: heading.replace(/^\d+:\s*/, '') },
926
+ metadata: await boundaryMetadata(loaded ? file : null, heading.replace(/^\d+:\s*/, ''), opts),
823
927
  moduleUrls,
824
928
  dev: opts.dev,
825
929
  nonce,
@@ -840,6 +944,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
840
944
  let body = '<h1>404: Not found</h1>';
841
945
  /** @type {string[]} */
842
946
  let moduleUrls = [];
947
+ let loaded = false;
843
948
  if (!notFoundFile && opts.route) {
844
949
  const wrapped = await defaultBoundaryInChain('<h1>404: Not found</h1>', opts);
845
950
  if (wrapped) { body = wrapped.body; moduleUrls = wrapped.moduleUrls; }
@@ -848,6 +953,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
848
953
  const ctx = opts.ctx || boundaryCtx(opts.params, opts.url, undefined);
849
954
  try {
850
955
  const mod = await loadModule(notFoundFile, opts.dev);
956
+ loaded = true;
851
957
  if (mod.default) {
852
958
  const tree = await mod.default(ctx);
853
959
  try {
@@ -889,13 +995,28 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
889
995
  }
890
996
  const nonce = getNonce();
891
997
  return wrapInDocument(body, {
892
- metadata: { title: 'Not found' },
998
+ // The boundary's own `metadata` / `generateMetadata` over its layouts', as
999
+ // a `not-found.tsx` resolves in Next; "Not found" is only the fallback.
1000
+ metadata: await boundaryMetadata(loaded ? notFoundFile : null, 'Not found', opts),
893
1001
  moduleUrls,
894
1002
  dev: opts.dev,
895
1003
  nonce,
896
1004
  });
897
1005
  }
898
1006
 
1007
+ /**
1008
+ * Whether a flash belongs to the page at `url`: the path its success redirect
1009
+ * named, compared without a trailing slash. A flash with no recorded path (a
1010
+ * cookie from before the path was recorded) is shown as before.
1011
+ * @param {{ path?: string }} flash
1012
+ * @param {URL} url
1013
+ */
1014
+ function flashTargets(flash, url) {
1015
+ if (!flash.path) return true;
1016
+ const norm = (/** @type {string} */ p) => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p);
1017
+ return norm(flash.path) === norm(url.pathname);
1018
+ }
1019
+
899
1020
  /**
900
1021
  * SSR a matched page route to a Response.
901
1022
  *
@@ -910,10 +1031,38 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
910
1031
  * @param {import('../router.js').PageRoute} route
911
1032
  * @param {Record<string,string>} params
912
1033
  * @param {URL} url
913
- * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('../module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown>, cspEnabled?: boolean }} opts
1034
+ * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('../module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown>, cspEnabled?: boolean, renderError?: unknown, onError?: (error: unknown) => void }} opts
1035
+ * `renderError`: a form action's throw (form-dispatch.js), answered with the
1036
+ * page's nearest error boundary and never from the HTML cache.
914
1037
  * @returns {Promise<Response>}
915
1038
  */
916
1039
  export async function ssrPage(route, params, url, opts) {
1040
+ // A form action's result carried across its 303 (action-flash.js): shown
1041
+ // once as actionData on the page the redirect lands on, then marked shown
1042
+ // so a reload does not repeat it (it stays the next submission's prevState).
1043
+ // Read BEFORE the HTML-cache lookup, since a page carrying actionData is
1044
+ // never served from or written to the cache. A speculative prefetch never
1045
+ // consumes it.
1046
+ // Set when this render shows a flash: its body then carries one visitor's
1047
+ // action result, so the response must never reach a shared cache (below).
1048
+ // Shown only on the page the success redirect named (flash.path), and never
1049
+ // on an error render. Another page or tab rendered inside the 60 seconds, or
1050
+ // a <webjs-frame> loading another path, leaves it for the right one. A frame
1051
+ // render OF that path is the landing of a form submitted inside the frame
1052
+ // (the redirect is followed with x-webjs-frame), so it shows it.
1053
+ let showsFlash = false;
1054
+ if (!opts.actionData && opts.renderError === undefined && opts.req
1055
+ && opts.req.headers.get('x-webjs-prefetch') !== '1') {
1056
+ const flash = await readFlash(opts.req);
1057
+ if (flash && !flash.shown && flashTargets(flash, url)) {
1058
+ showsFlash = true;
1059
+ opts = { ...opts, actionData: flash.result };
1060
+ try {
1061
+ const shown = await encodeFlash({ ...flash, shown: true });
1062
+ if (shown) requestCookieStore().set(FLASH_COOKIE, shown, { httpOnly: true, sameSite: 'lax', maxAge: 60 });
1063
+ } catch { /* no request scope (a direct ssrPage call): nothing to rewrite */ }
1064
+ }
1065
+ }
917
1066
  // Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
918
1067
  // that declares `export const revalidate = N` is ever cached (the page
919
1068
  // module export is the single trigger). The page module is loaded ONCE up
@@ -924,7 +1073,16 @@ export async function ssrPage(route, params, url, opts) {
924
1073
  // no double behaviour) for the form-action re-render (actionData / a non-200
925
1074
  // status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
926
1075
  // on the request and must not be shared under the full-URL key.
1076
+ // The cache generation this render starts under (#1689). A dev rebuild, or
1077
+ // a `revalidateAll()`, during the render makes its body describe the source
1078
+ // before the bump, so the funnel must not store it under the bumped key.
1079
+ // Read before the first await, so the whole render counts.
1080
+ const cacheGeneration = htmlCacheGeneration();
1081
+ // A failed form action's error render (`renderError`, form-dispatch.js) is
1082
+ // never a cache read: a warm entry would answer 200 with the page and the
1083
+ // throw would never reach the error boundary or onError.
927
1084
  const cacheEligible =
1085
+ opts.renderError === undefined &&
928
1086
  !opts.actionData &&
929
1087
  !opts.status &&
930
1088
  !opts.pageModule &&
@@ -960,7 +1118,7 @@ export async function ssrPage(route, params, url, opts) {
960
1118
  // `params.id` still works, `await params` also works (Next 15/16 parity).
961
1119
  // The `then` is non-enumerable so spread / JSON / Object.keys are unchanged.
962
1120
  params: makeThenable(params),
963
- searchParams: makeThenable(Object.fromEntries(url.searchParams.entries())),
1121
+ searchParams: makeThenable(searchParamsRecord(url.searchParams)),
964
1122
  url: url.toString(),
965
1123
  // Populated only when this render is the re-render after a failed page
966
1124
  // `action` submission (#244). The page function and every layout receive
@@ -971,9 +1129,23 @@ export async function ssrPage(route, params, url, opts) {
971
1129
  };
972
1130
 
973
1131
  // Collect metadata across layouts (outermost first) then page.
974
- const metadata = await collectMetadata(route, ctx, opts.dev);
1132
+ // A control-flow throw from generateMetadata (notFound(), redirect(), ...)
1133
+ // is rethrown inside the try below, so it answers exactly as a page throw.
1134
+ /** @type {Record<string, any>} */
1135
+ let metadata = {};
1136
+ /** @type {unknown} */
1137
+ let metadataThrow = null;
1138
+ try {
1139
+ metadata = await collectMetadata(route, ctx, { dev: opts.dev, onError: opts.onError });
1140
+ } catch (e) {
1141
+ metadataThrow = e;
1142
+ }
975
1143
 
976
1144
  try {
1145
+ if (metadataThrow) throw metadataThrow;
1146
+ // A form action that threw (form-dispatch.js): the page answers with its
1147
+ // nearest error boundary exactly as a failed render does (#1686).
1148
+ if (opts.renderError !== undefined) throw opts.renderError;
977
1149
  const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
978
1150
  // Parse the partial-nav "have" header from the client. The server walks
979
1151
  // the target route's layout chain innermost → outermost and
@@ -1108,8 +1280,7 @@ export async function ssrPage(route, params, url, opts) {
1108
1280
  const lazyFiles = opts.lazyComponentFiles;
1109
1281
  const { moduleUrls, frontierLazy } = shippedBootModules(route, {
1110
1282
  appDir: opts.appDir,
1111
- inert: opts.inertRouteModules,
1112
- importOnly: opts.importOnlyRouteModules,
1283
+ frontiers: opts.routeFrontiers,
1113
1284
  lazyFiles,
1114
1285
  instrumentationClient: opts.instrumentationClient,
1115
1286
  });
@@ -1120,11 +1291,11 @@ export async function ssrPage(route, params, url, opts) {
1120
1291
  // preloads and instead loaded via IntersectionObserver when they
1121
1292
  // enter the viewport.
1122
1293
  const { eager: eagerComponents, lazy: renderedLazy } =
1123
- componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents, lazyFiles);
1294
+ componentPreloads(suspenseCtx.usedComponents, opts.appDir, lazyFiles);
1124
1295
  const lazyComponents = { ...frontierLazy, ...renderedLazy };
1125
1296
  // Both preload passes (the app-module hints and the vendor hints, #754)
1126
1297
  // root at the BOOT's shipped set plus the rendered components and skip a
1127
- // lazy component (#1524) or an elided one and the subtree only it reaches;
1298
+ // lazy component (#1524) and the subtree only it reaches;
1128
1299
  // `pagePreloadHints` walks that closure ONCE for both (#1644), where the
1129
1300
  // two passes used to walk it twice. The over-fetch rules (#780, #754) are
1130
1301
  // documented on the helper.
@@ -1133,7 +1304,6 @@ export async function ssrPage(route, params, url, opts) {
1133
1304
  appDir: opts.appDir,
1134
1305
  moduleUrls,
1135
1306
  eagerComponents,
1136
- elidableComponents: opts.elidableComponents,
1137
1307
  lazyFiles,
1138
1308
  serverFiles: opts.serverFiles,
1139
1309
  });
@@ -1210,12 +1380,17 @@ export async function ssrPage(route, params, url, opts) {
1210
1380
  metadata,
1211
1381
  nonce,
1212
1382
  opts.dev,
1383
+ (e) => streamedAccessFallback(e, route, ctx, opts),
1213
1384
  );
1214
1385
  // Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
1215
1386
  // hit from the outside, so an app whose seeding silently broke looks exactly
1216
1387
  // like one where it works. Dev only: a production header would publish how
1217
1388
  // many server calls a page made, for no benefit.
1218
1389
  if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
1390
+ // A 200 takes the page's metadata.cacheControl, but a render showing a
1391
+ // flash is per-visitor (like the 422 re-render, which is never cached), so
1392
+ // a page marked public must not hand this body to a shared cache.
1393
+ if (showsFlash) res.headers.set('cache-control', 'private, no-store');
1219
1394
  // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
1220
1395
  // outer-layout chrome, so these bytes are only valid for a request that
1221
1396
  // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
@@ -1245,7 +1420,7 @@ export async function ssrPage(route, params, url, opts) {
1245
1420
  // and strips this internal marker. The CSP guard is decided here (the SSR
1246
1421
  // side knows whether a nonce was stamped into the body).
1247
1422
  if (revalidateSeconds !== null && !opts.cspEnabled) {
1248
- res.headers.set(HTML_CACHE_MARKER, String(revalidateSeconds));
1423
+ res.headers.set(HTML_CACHE_MARKER, `${revalidateSeconds}:${cacheGeneration}`);
1249
1424
  }
1250
1425
  return res;
1251
1426
  } catch (err) {
@@ -1254,9 +1429,9 @@ export async function ssrPage(route, params, url, opts) {
1254
1429
  // A redirect thrown during a GET page/layout render is a GET-to-GET
1255
1430
  // navigation (an auth bounce, a gate). 302 Found is the conventional
1256
1431
  // code there, so it is the default when the caller did not pick one. An
1257
- // explicit `redirect(url, status)` overrides it. (Action redirects, a
1258
- // POST, default to 307 in form-dispatch.js so the method is preserved.)
1259
- return new Response(null, { status: e.status || 302, headers: { location: e.url } });
1432
+ // explicit `redirect(url, status)` overrides it. (A form action's
1433
+ // redirect, a POST, always answers 303 in form-dispatch.js.)
1434
+ return new Response(null, { status: e.status || 302, headers: { location: headerSafeLocation(e.url) } });
1260
1435
  }
1261
1436
  if (isNotFound(err)) {
1262
1437
  // Nearest not-found.{js,ts} in the page's chain wins (#848 fix: previously
@@ -1282,6 +1457,21 @@ export async function ssrPage(route, params, url, opts) {
1282
1457
  // rendering the boundary, so the sink gets the ORIGINAL error even if the
1283
1458
  // boundary itself swallows or transforms it. notFound / redirect are
1284
1459
  // sentinels (control flow), not errors, so they are excluded above.
1460
+ //
1461
+ // Production sanitizes what the boundary sees (Next.js parity): error.ts
1462
+ // and global-error.ts receive a generic message plus a `digest`, never the
1463
+ // raw message (a driver error, a path, a connection string), and the full
1464
+ // error is logged under the same digest so a user-reported digest maps
1465
+ // back to it. The sinks get the original error, carrying the digest too.
1466
+ // Dev passes the real error through unchanged.
1467
+ let boundaryErr = err;
1468
+ if (!opts.dev) {
1469
+ let digest;
1470
+ try { digest = await errorDigest(err); } catch { digest = crypto.randomUUID().replace(/-/g, '').slice(0, 10); }
1471
+ try { if (err && typeof err === 'object' && /** @type {any} */ (err).digest == null) /** @type {any} */ (err).digest = digest; } catch { /* frozen */ }
1472
+ console.error(`[webjs] render error [digest=${digest}]:`, err);
1473
+ boundaryErr = Object.assign(new Error(GENERIC_ERROR_MESSAGE), { digest });
1474
+ }
1285
1475
  if (typeof opts.onError === 'function') {
1286
1476
  try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
1287
1477
  }
@@ -1314,7 +1504,7 @@ export async function ssrPage(route, params, url, opts) {
1314
1504
  try {
1315
1505
  const mod = await loadModule(route.errors[i], opts.dev);
1316
1506
  if (!mod.default) continue;
1317
- const tree = await mod.default({ ...ctx, error: err });
1507
+ const tree = await mod.default({ ...ctx, error: boundaryErr });
1318
1508
  // The layouts that wrap THIS boundary: its own segment and every
1319
1509
  // ancestor (#1298). A layout deeper than the boundary never rendered.
1320
1510
  const wrapLayouts = layoutsForBoundary(route.layouts, boundarySegmentPath(route.errors[i]));
@@ -1327,11 +1517,10 @@ export async function ssrPage(route, params, url, opts) {
1327
1517
  // needed (a throw out of renderToString is not attributable to one
1328
1518
  // layout anyway, so tracking would be absent exactly when it mattered).
1329
1519
  const body = await renderBoundaryInChain(tree, route, route.errors[i], ctx, opts.dev);
1330
- // Apply the SAME inert / import-only substitution as the happy-path
1331
- // boot (#963): the error page must not ship a page/layout module the
1332
- // normal render drops. Pre-substitution, an import-only page with a
1333
- // bare `.server.*` import (legal, it never loads client-side) would
1334
- // load here whole and crash the error page's boot on the throw-at-load
1520
+ // Apply the SAME frontier substitution as the happy-path boot
1521
+ // (#1686): the error page must not ship a route module. Shipped whole,
1522
+ // one with a bare `.server.*` import (legal, it never loads
1523
+ // client-side) would crash the error page's boot on the throw-at-load
1335
1524
  // stub, killing the sibling component registrations in the same
1336
1525
  // module script.
1337
1526
  //
@@ -1339,10 +1528,7 @@ export async function ssrPage(route, params, url, opts) {
1339
1528
  // the layouts wrapping it. It used to be the page module plus every
1340
1529
  // layout, so a boundary shipped the page (which never ran) and any
1341
1530
  // deeper layout (which never ran either), while omitting the boundary
1342
- // itself, and a component the boundary rendered never upgraded. The
1343
- // substitution applies to the boundary file harmlessly: only page and
1344
- // layout files are fed to the elision analysis, so a boundary is never
1345
- // inert and never import-only, gets no verdict, and is pushed as-is.
1531
+ // itself, and a component the boundary rendered never upgraded.
1346
1532
  const errModuleUrls = boundaryModuleUrls(route.errors[i], wrapLayouts, opts);
1347
1533
  const html = wrapInDocument(body, { metadata, moduleUrls: errModuleUrls, dev: opts.dev, nonce: errNonce });
1348
1534
  return htmlResponse(html, 500, opts.req, url);
@@ -1380,7 +1566,7 @@ export async function ssrPage(route, params, url, opts) {
1380
1566
  try {
1381
1567
  const mod = await loadModule(opts.globalError, opts.dev);
1382
1568
  if (mod.default) {
1383
- const tree = await mod.default({ error: err });
1569
+ const tree = await mod.default({ error: boundaryErr });
1384
1570
  const body = await renderToString(tree, { ssr: true, dev: opts.dev });
1385
1571
  return htmlResponse(body, 500, opts.req, url);
1386
1572
  }
@@ -1398,10 +1584,10 @@ export async function ssrPage(route, params, url, opts) {
1398
1584
  }
1399
1585
  }
1400
1586
  // Default: dev shows stack, prod shows a terse message (no stack trace leaks).
1401
- console.error('[webjs] unhandled render error:', err);
1587
+ if (opts.dev) console.error('[webjs] unhandled render error:', err);
1402
1588
  const body = opts.dev
1403
1589
  ? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(safeErrorText(err))}</pre>`
1404
- : `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
1590
+ : `<h1>Server error</h1><p>Something went wrong. Please try again.</p><p><small>Digest: ${escapeHtml(String(/** @type {any} */ (boundaryErr).digest || ''))}</small></p>`;
1405
1591
  return htmlResponse(
1406
1592
  wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
1407
1593
  500,