@rshono/core 1.0.0-rc.17 → 1.0.0-rc.19

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 (82) hide show
  1. package/README.md +34 -4
  2. package/dist/builder/env-shadow-loader.cjs +58 -2
  3. package/dist/builder/rspack-config.d.ts.map +1 -1
  4. package/dist/builder/rspack-config.js +51 -7
  5. package/dist/builder/rspack-config.js.map +1 -1
  6. package/dist/builder/server-only-imports.d.ts +33 -0
  7. package/dist/builder/server-only-imports.d.ts.map +1 -0
  8. package/dist/builder/server-only-imports.js +51 -0
  9. package/dist/builder/server-only-imports.js.map +1 -0
  10. package/dist/cli/build.d.ts.map +1 -1
  11. package/dist/cli/build.js +35 -3
  12. package/dist/cli/build.js.map +1 -1
  13. package/dist/cli/dev.d.ts.map +1 -1
  14. package/dist/cli/dev.js +49 -0
  15. package/dist/cli/dev.js.map +1 -1
  16. package/dist/cli/index.js +14 -8
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/config.d.ts +10 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
  22. package/dist/deploy/cloudflare/runtime.js +31 -5
  23. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  24. package/dist/deploy/contract.d.ts +6 -2
  25. package/dist/deploy/contract.d.ts.map +1 -1
  26. package/dist/deploy/contract.js.map +1 -1
  27. package/dist/deploy/filesystem.d.ts.map +1 -1
  28. package/dist/deploy/filesystem.js +2 -1
  29. package/dist/deploy/filesystem.js.map +1 -1
  30. package/dist/deploy/node/runtime.d.ts.map +1 -1
  31. package/dist/deploy/node/runtime.js +3 -4
  32. package/dist/deploy/node/runtime.js.map +1 -1
  33. package/dist/deploy/presets.d.ts +8 -0
  34. package/dist/deploy/presets.d.ts.map +1 -1
  35. package/dist/deploy/presets.js +1 -0
  36. package/dist/deploy/presets.js.map +1 -1
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/router.d.ts +93 -25
  42. package/dist/router.d.ts.map +1 -1
  43. package/dist/router.js.map +1 -1
  44. package/dist/runtime/boundaries.d.ts +11 -4
  45. package/dist/runtime/boundaries.d.ts.map +1 -1
  46. package/dist/runtime/boundaries.js.map +1 -1
  47. package/dist/runtime/context.d.ts +60 -9
  48. package/dist/runtime/context.d.ts.map +1 -1
  49. package/dist/runtime/context.js +61 -8
  50. package/dist/runtime/context.js.map +1 -1
  51. package/dist/runtime/entry.client.js +156 -21
  52. package/dist/runtime/entry.client.js.map +1 -1
  53. package/dist/runtime/entry.rsc.d.ts +7 -0
  54. package/dist/runtime/entry.rsc.d.ts.map +1 -1
  55. package/dist/runtime/entry.rsc.js +202 -57
  56. package/dist/runtime/entry.rsc.js.map +1 -1
  57. package/dist/runtime/flight-inject.d.ts +1 -1
  58. package/dist/runtime/flight-inject.d.ts.map +1 -1
  59. package/dist/runtime/flight-inject.js +207 -40
  60. package/dist/runtime/flight-inject.js.map +1 -1
  61. package/dist/runtime/request.d.ts.map +1 -1
  62. package/dist/runtime/request.js +10 -0
  63. package/dist/runtime/request.js.map +1 -1
  64. package/dist/runtime/validate-entries.d.ts +78 -0
  65. package/dist/runtime/validate-entries.d.ts.map +1 -0
  66. package/dist/runtime/validate-entries.js +301 -0
  67. package/dist/runtime/validate-entries.js.map +1 -0
  68. package/dist/server/prerendered.d.ts +30 -11
  69. package/dist/server/prerendered.d.ts.map +1 -1
  70. package/dist/server/prerendered.js +72 -14
  71. package/dist/server/prerendered.js.map +1 -1
  72. package/dist/server/server-config.d.ts +20 -1
  73. package/dist/server/server-config.d.ts.map +1 -1
  74. package/dist/server/server-config.js +27 -1
  75. package/dist/server/server-config.js.map +1 -1
  76. package/dist/server/ssg.d.ts.map +1 -1
  77. package/dist/server/ssg.js +170 -27
  78. package/dist/server/ssg.js.map +1 -1
  79. package/dist/server/static.d.ts.map +1 -1
  80. package/dist/server/static.js +7 -1
  81. package/dist/server/static.js.map +1 -1
  82. package/package.json +5 -4
@@ -20,16 +20,55 @@ import { isControlSignal, RedirectSignal } from './control.js';
20
20
  import { renderHTML } from './entry.ssr.js';
21
21
  import { RouterProvider } from './navigation.js';
22
22
  import { asksForRsc, isActionRequest, parseRenderRequest, requestWantsRsc, RSC_VARY_HEADER, wantsRsc } from './request.js';
23
- const serverApp = (serverAppModule.default ?? null);
23
+ import { assertEndpointModule, assertPageModule, assertRouteModules, validateRoutesModule, validateServerApp } from './validate-entries.js';
24
+ const serverApp = validateServerApp(serverAppModule);
24
25
  // Compiled into the bundle from rshono.config.ts by DefinePlugin; there is no runtime env-var interface.
25
26
  const { isDev } = __RSHONO_CONFIG__;
26
- /** How long a prerendered page may be reused before revalidating. Also what `public/` files get. */
27
+ /**
28
+ * How long a prerendered page may be reused before revalidating. Also what `public/` files get.
29
+ *
30
+ * Not a config field, deliberately: it is a per-response header, and `rshono.config.ts` is compiled into the
31
+ * bundle — a cache policy you cannot change without a rebuild is the wrong shape. An app that wants a longer
32
+ * `max-age`, or a `stale-while-revalidate`, sets it from middleware **after `await next()`**:
33
+ *
34
+ * ```ts
35
+ * server.use('/docs/*', async (c, next) => {
36
+ * await next();
37
+ * c.res.headers.set('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
38
+ * });
39
+ * ```
40
+ *
41
+ * After, because the response below is built with `cache-control` in the bag it hands `c.body(...)`, and
42
+ * that replaces a header prepared with `c.header(...)` before the handler ran. The `ETag` is untouched
43
+ * either way, so revalidation still costs a 304 rather than the page.
44
+ */
27
45
  const SSG_CACHE_CONTROL = 'public, max-age=300';
46
+ /**
47
+ * What a page response gets when nothing else set one: without it a shared cache is free to store a logged-in
48
+ * user's page and hand it to someone else. `private, no-cache` forbids that without blocking bfcache, which
49
+ * `no-store` would.
50
+ */
51
+ const PAGE_CACHE_CONTROL = 'private, no-cache';
28
52
  /** The two content types a page can be served as, from the same URL — which is what makes `Vary` non-optional. */
29
53
  const PAGE_CONTENT_TYPE = /^(?:text\/html|text\/x-component)\b/;
30
54
  runtime.loadEnv();
31
- const routeConfig = userRoutes;
55
+ // Checked rather than cast: these two modules are the app's, and a mistake in either used to surface as a
56
+ // `TypeError` from somewhere else entirely — or, for a duplicated path, as nothing at all.
57
+ const routeConfig = validateRoutesModule(userRoutes);
32
58
  export const routes = routeConfig.routes;
59
+ /**
60
+ * The result of an action that has already run, for the request whose *render* then failed.
61
+ *
62
+ * The action and the page it answers with are one response: an action returns its value through the payload
63
+ * of the page rendered after it. So when that render throws — a page module that will not load, most
64
+ * plausibly a chunk that went away mid-deploy — `onError` renders the `error` page in its place, and without
65
+ * this the reply carries no `returnValue` at all. The caller of an action that ran, and may well have
66
+ * written something, would be told only that a field was missing.
67
+ *
68
+ * Keyed on the Hono context the way `beginPageRender` keys its own marker, and weakly, so nothing outlives
69
+ * the request it belongs to.
70
+ */
71
+ const actionResults = new WeakMap();
33
72
  /**
34
73
  * The per-request CSP nonce, if the app asked for one.
35
74
  *
@@ -56,37 +95,31 @@ function cspNonce(c) {
56
95
  *
57
96
  * Both halves are required, because either alone refuses something real:
58
97
  *
59
- * - `Sec-Fetch-Site: cross-site` is the browser's own statement of provenance, and every browser that can post
60
- * a form to a server action sends it. An absent header means a non-browser client, which cannot be a CSRF
61
- * victim; `same-site` is left alone too, since a subdomain policy is `csrf()`'s to express.
62
- * - An `Origin` that is the app's own contradicts that label — a browser calls a post from the app's own pages
98
+ * - `Sec-Fetch-Site` is the browser's own statement of provenance, unforgeable by page script, and every
99
+ * browser that can post a form to a server action sends it. `cross-site` and `same-site` are the two labels
100
+ * that mean "not from this origin" — `same-site` is what a *sibling subdomain* gets, which is a user-content
101
+ * host, a stale CNAME or a subdomain takeover, so leaving it to `csrf()` meant an app without one could have
102
+ * any `'use server'` export driven from next door. An absent header means a non-browser client, which cannot
103
+ * be a CSRF victim. `same-origin` and `none` settle it on their own, and are what a genuine post carries
104
+ * however many proxies rewrote `Host` on the way in.
105
+ * - An `Origin` that is the app's own contradicts either label — a browser calls a post from the app's own pages
63
106
  * `same-origin` — so the pair is a shape no browser produces, and refusing it would only catch a proxy or a
64
- * test client setting the label by hand while posting from the app itself.
107
+ * test client setting the label by hand while posting from the app itself. Nothing else clears the label:
108
+ * an `Origin` of `null` (a sandboxed iframe, a `data:` URL, `Referrer-Policy: no-referrer`) and no `Origin`
109
+ * at all are both refused. A browser attaches one to every non-GET request, so neither is a shape it
110
+ * produces — but a security predicate that says "not proven foreign" rather than "proven local" fails open
111
+ * the day something does produce it.
65
112
  *
66
113
  * `publicUrl(c)` rather than `c.req.url`, so it honours `trustProxy` and compares against the origin the
67
114
  * browser actually used — which behind a proxy, `rshono dev`'s included, is not the one the server was reached
68
- * on. The cost is that an app deliberately accepting cross-site *form* posts to an action cannot; that is what
69
- * an `{ type: 'endpoint' }` route is for.
115
+ * on. The cost is that an app deliberately accepting *form* posts to an action from another origin of its own
116
+ * cannot, `csrf()`'s allowlist included; that is what an `{ type: 'endpoint' }` route is for.
70
117
  */
71
118
  function refusesCrossSiteForm(c) {
72
- if (c.req.header('sec-fetch-site') !== 'cross-site')
119
+ const site = c.req.header('sec-fetch-site');
120
+ if (site !== 'cross-site' && site !== 'same-site')
73
121
  return false;
74
- const origin = c.req.header('origin');
75
- return origin !== undefined && origin !== publicUrl(c).origin;
76
- }
77
- async function loadPageModule(load, label) {
78
- const mod = await load();
79
- const Page = mod.default;
80
- if (typeof Page !== 'function') {
81
- throw new Error(`[rshono] The page module for ${label} must default-export a server component.`);
82
- }
83
- if (!Page.entryJsFiles) {
84
- throw new Error(`[rshono] The page component for ${label} is missing its client-asset info ('use server-entry'). ` +
85
- "The directive is added automatically for inline `component: () => import('…')` thunks in routes.ts. " +
86
- "If this page is wired up another way, put 'use server-entry' on the first line of the page module yourself — " +
87
- "and make sure the page is a server component (a 'use client' page must be wrapped by a server component instead).");
88
- }
89
- return Page;
122
+ return c.req.header('origin') !== publicUrl(c).origin;
90
123
  }
91
124
  /** A browser navigation or a crawler, as opposed to a fetch that would rather have plain text. */
92
125
  function acceptsHtml(c) {
@@ -97,7 +130,11 @@ function acceptsHtml(c) {
97
130
  * payload. It carries the `Vary` too: this is one of the answers a page URL gives depending on the `RSC` request header.
98
131
  */
99
132
  function plainNotFound(c) {
100
- return c.text('Not Found', 404, { vary: RSC_VARY_HEADER });
133
+ // `cache-control` explicitly, because the default above is applied to page *content types* and this is
134
+ // `text/plain`. A 404 is heuristically cacheable under RFC 9111, so without it a shared cache may store
135
+ // this one — while the rendered HTML 404 next to it, the same answer to the same request from a client
136
+ // that asked for HTML, is correctly private.
137
+ return c.text('Not Found', 404, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
101
138
  }
102
139
  /** A lazy once-cell: runs `load` at most once, but clears a rejection so a later call can retry. */
103
140
  function once(load) {
@@ -126,6 +163,34 @@ function releaseWhenDone(stream, done) {
126
163
  cancel: done,
127
164
  }));
128
165
  }
166
+ /**
167
+ * A macrotask boundary: `setImmediate` where there is one — the current turn's check phase rather than a
168
+ * timer — and `setTimeout` as the portable fallback, for a runtime without it. `flight-inject.ts` has the
169
+ * same two lines and the same reason; they are not shared because that module belongs to the SSR layer, and
170
+ * importing it here would give the RSC layer its own instance of it.
171
+ */
172
+ const deferTask = typeof setImmediate === 'function'
173
+ ? setImmediate
174
+ : (run) => {
175
+ setTimeout(run, 0);
176
+ };
177
+ /**
178
+ * Dev-only: says that a control signal arrived too late to be one.
179
+ *
180
+ * The root fix is in app code, so authoring time is where this has to be said, and a docs paragraph is not
181
+ * where anyone reads it. `isDev` is the baked build flag, so a production server pays one boolean check on a
182
+ * path that has already gone wrong — and says nothing.
183
+ */
184
+ function warnLateControlSignal(c, signal) {
185
+ const isRedirect = signal instanceof RedirectSignal;
186
+ console.warn(`[rshono] ${isRedirect ? `redirect(${JSON.stringify(signal.location)})` : 'notFound()'} was called from a boundary that ` +
187
+ `resolved after the page shell had already been sent (${c.req.method} ${c.req.path}), so it cannot become a real ` +
188
+ `${isRedirect ? '3xx' : '404'}: the response is committed as 200 text/html. A browser with JavaScript follows the ` +
189
+ `digest that rides the payload; one without stays on the Suspense fallback, and a crawler indexes the 200.${isRedirect
190
+ ? ''
191
+ : ' A JavaScript client asks for the page once more, in case the signal comes early enough that time to be a real 404 — and shows a plain "Page not found" panel when it does not, rather than reloading into the same response forever.'}` +
192
+ ' Decide before the render starts streaming — in Hono middleware, or in the page component body above the boundary.');
193
+ }
129
194
  /**
130
195
  * Builds the props a page component is called with.
131
196
  *
@@ -171,16 +236,37 @@ async function renderComponent(c, Page, opts) {
171
236
  // starts throwing — while the action `renderPage` just ran, and the middleware around it, legitimately wrote.
172
237
  beginPageRender(c);
173
238
  let controlSignal;
239
+ /** Set once `renderHTML` has returned, which is where the response head stops being changeable. */
240
+ let shellFlushed = false;
174
241
  const rscStream = renderToReadableStream(rscPayload, {
175
242
  temporaryReferences: opts.temporaryReferences,
176
243
  signal,
177
244
  onError(error) {
178
245
  if (isControlSignal(error)) {
179
246
  controlSignal = error;
247
+ if (shellFlushed) {
248
+ if (isDev)
249
+ warnLateControlSignal(c, error);
250
+ // Past the shell the status line, the headers and the first bytes are on the wire, so the digest
251
+ // React writes into the payload is the only path left — and everything still rendering is for a
252
+ // page the browser is about to navigate away from. Winding it down stops those boundaries, runs
253
+ // `flight-inject`'s cancel/flush, and fires the `release()` above now rather than whenever the
254
+ // doomed render happens to finish.
255
+ //
256
+ // One macrotask later rather than from inside `onError`, where React is still handling the error
257
+ // that produced the digest — the row carrying it is the only recovery the client has, and an abort
258
+ // that re-entered React there could cut the render off before it is written. Measured to survive
259
+ // either way on react-server-dom-rspack 0.1.0; deferred anyway, because that ordering is an
260
+ // internal the `^19.1.0` peer range does not promise, and the cost is one macrotask on a render
261
+ // that is already doomed. The reason is the signal itself, so every boundary the abort errors
262
+ // carries the digest rather than a bare AbortError the client would paint as a fault.
263
+ if (!signal.aborted)
264
+ deferTask(() => renderAbort.abort(error));
265
+ }
180
266
  return error.digest;
181
267
  }
182
268
  if (!signal.aborted)
183
- reportServerError(error, { source: 'render', request: c.req.raw, message: '[rshono] render error:' });
269
+ reportServerError(error, { source: 'render', hono: c, message: '[rshono] render error:' });
184
270
  },
185
271
  });
186
272
  if (opts.isRsc) {
@@ -196,8 +282,8 @@ async function renderComponent(c, Page, opts) {
196
282
  signal,
197
283
  nonce,
198
284
  onDone: release,
199
- onShellError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR shell error:' }),
200
- onError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR error:' }),
285
+ onShellError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR shell error:' }),
286
+ onError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR error:' }),
201
287
  });
202
288
  }
203
289
  catch (error) {
@@ -206,6 +292,10 @@ async function renderComponent(c, Page, opts) {
206
292
  throw controlSignal;
207
293
  throw error;
208
294
  }
295
+ // `renderHTML` returns at *shell ready*, so from here the response is the one that ships. Set before the
296
+ // check below rather than after it, so nothing lands in the window between the two: a signal that arrives
297
+ // in it is handled there *and* schedules an abort, and aborting an aborted controller is a no-op.
298
+ shellFlushed = true;
209
299
  if (controlSignal) {
210
300
  // The shell resolved, so this response is live: React is being pumped into the payload-injecting
211
301
  // transform, and a `redirect()` that surfaced from a boundary settling just before the shell was ready
@@ -253,10 +343,13 @@ async function renderPage(c, loadPage) {
253
343
  throw error;
254
344
  // In production React sends a thrown action error to the client as an opaque marker, so this is
255
345
  // the only place the real one is visible.
256
- reportServerError(error, { source: 'action', request, message: '[rshono] server action error:' });
346
+ reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
257
347
  returnValue = { ok: false, error };
258
348
  actionStatus = 500;
259
349
  }
350
+ // The action is done and its result is the caller's, whatever becomes of the render below. See
351
+ // {@link actionResults}.
352
+ actionResults.set(c, returnValue);
260
353
  }
261
354
  else {
262
355
  // A `<form action={serverAction}>` post, which is the path that runs before hydration and with
@@ -280,7 +373,7 @@ async function renderPage(c, loadPage) {
280
373
  // because this path has no client boundary and no `useActionState` to hand the error to, so the
281
374
  // app's `error` page is the honest answer. `reportServerError` de-duplicates, so the re-throw
282
375
  // reaching `onError` does not report it a second time.
283
- reportServerError(error, { source: 'action', request, message: '[rshono] server action error:' });
376
+ reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
284
377
  throw error;
285
378
  }
286
379
  formState = (await decodeFormState(result, formData, __rspack_rsc_manifest__.serverManifest)) ?? undefined;
@@ -300,8 +393,7 @@ function buildApp() {
300
393
  const app = new Hono();
301
394
  // A floor, not a policy: an app wanting the full set registers `secureHeaders()` in src/server.ts, and
302
395
  // because this is registered first it unwinds last, so the "only if unset" checks stand aside for it.
303
- // Owned by the framework because it also has to cover `mountStaticAssets` — a terminal handler the
304
- // app's own middleware never sees.
396
+ // Owned by the framework so that an app with no src/server.ts at all still gets it.
305
397
  app.use(async (c, next) => {
306
398
  await next();
307
399
  const headers = c.res.headers;
@@ -323,18 +415,21 @@ function buildApp() {
323
415
  if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
324
416
  return;
325
417
  appendVary(headers, RSC_VARY_HEADER);
326
- // Without a default a shared cache is free to store a logged-in user's page and hand it to someone
327
- // else. `private, no-cache` forbids that without blocking bfcache, which `no-store` would.
328
418
  if (!headers.has('cache-control'))
329
- headers.set('cache-control', 'private, no-cache');
419
+ headers.set('cache-control', PAGE_CACHE_CONTROL);
330
420
  });
331
- runtime.mountStaticAssets(app);
332
- // Ahead of the page routes, so the app's own middleware (`csrf()`, `bodyLimit()`, auth, logging) wraps
333
- // page requests too. The flip side: a *terminal* handler in src/server.ts shadows a page at the same path.
421
+ // First, so the app's own middleware (`csrf()`, `bodyLimit()`, auth, logging) wraps everything registered
422
+ // below: the page routes, and the asset handlers too. Assets used to be mounted ahead of this, which left
423
+ // `/_static/*` answered by a terminal handler nothing of the app's ever saw — no `secureHeaders()`, and so
424
+ // no HSTS on exactly the requests a downgrade attack lands on. The flip side is the same one it always had,
425
+ // one path wider: a *terminal* handler in src/server.ts shadows a page at the same path, and unscoped
426
+ // middleware now also runs for `/_static`, which is a reserved prefix an app should not be matching on
427
+ // purpose anyway.
334
428
  if (serverApp) {
335
429
  app.route('/', serverApp);
336
430
  }
337
- const memoizePage = (page, label) => once(() => loadPageModule(page.component, label));
431
+ runtime.mountStaticAssets(app);
432
+ const memoizePage = (page, label) => once(async () => assertPageModule(await page.component(), label));
338
433
  const loadNotFoundPage = routeConfig.notFound ? memoizePage(routeConfig.notFound, 'the notFound page') : null;
339
434
  /** Turns a thrown `redirect()` / `notFound()` into the response it stands for. */
340
435
  const respondToControlSignal = async (c, signal) => {
@@ -349,7 +444,23 @@ function buildApp() {
349
444
  return c.redirect(signal.location, signal.status);
350
445
  }
351
446
  if (loadNotFoundPage) {
352
- return renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
447
+ try {
448
+ return await renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
449
+ }
450
+ catch (error) {
451
+ // The `notFound` page is already the answer to a request that went wrong, so a failure here has
452
+ // nowhere to escalate to: this runs from `onError` as well as from the page handler, and Hono calls
453
+ // `onError` inside its own catch — a throw from there rejects `app.fetch`, which
454
+ // `@hono/node-server` turns into a bodiless 500 with nothing in the log. So it is answered here, the
455
+ // way a failing `error` page is answered below.
456
+ //
457
+ // A `redirect()` from the page is the exception: nothing is committed yet, the branch above cannot
458
+ // fail, and this is what the same page does when `app.notFound` renders it — so it is honoured, and
459
+ // recurses exactly once.
460
+ if (error instanceof RedirectSignal)
461
+ return respondToControlSignal(c, error);
462
+ reportServerError(error, { source: 'render', hono: c, message: '[rshono] the notFound page failed to render:' });
463
+ }
353
464
  }
354
465
  return plainNotFound(c);
355
466
  };
@@ -357,10 +468,13 @@ function buildApp() {
357
468
  if (isPageRoute(route)) {
358
469
  // Both fixed for the life of the process, so resolved once rather than per request.
359
470
  const servesPrerendered = !isDev && route.render === 'static';
360
- const loadPage = once(() => loadPageModule(route.component, `"${route.path}"`));
361
- const handler = async (c) => {
471
+ const loadPage = once(async () => assertPageModule(await route.component(), `"${route.path}"`));
472
+ /** Everything the route answers, before a HEAD has its body taken off it. */
473
+ const respond = async (c) => {
362
474
  try {
363
- if (servesPrerendered && c.req.method === 'GET') {
475
+ // A HEAD takes the GET path, prerendered bytes included: the headers it promises are the ones a
476
+ // GET would send, `etag` and `content-length` among them.
477
+ if (servesPrerendered && (c.req.method === 'GET' || c.req.method === 'HEAD')) {
364
478
  const isRsc = asksForRsc(c.req.raw);
365
479
  // One fixed set of bytes cannot carry a per-request nonce, so a document has to be rendered
366
480
  // where the app's CSP has one. Decided per request, so an app whose policy carries no `NONCE`
@@ -392,29 +506,51 @@ function buildApp() {
392
506
  throw error;
393
507
  }
394
508
  };
509
+ const handler = async (c) => {
510
+ const response = await respond(c);
511
+ // Hono dispatches a HEAD to the GET route and then rebuilds the response as `new Response(null, res)`,
512
+ // which drops the body without reading it — so for a rendered page nothing ever consumes the stream:
513
+ // `flight-inject`'s `cancel` never runs, and neither does the `release()` that detaches the abort
514
+ // forwarder from the request signal. Cancelling here is what ends the render nobody asked to read.
515
+ if (c.req.method === 'HEAD' && response.body !== null) {
516
+ void response.body.cancel().catch(() => {
517
+ // Already errored or locked, and there is nothing left to release either way.
518
+ });
519
+ return new Response(null, response);
520
+ }
521
+ return response;
522
+ };
395
523
  app.on(['GET', 'POST'], route.path, handler);
396
524
  }
397
525
  else {
398
- const loadEndpoint = once(() => route.server());
526
+ // Checked on the way out of the thunk rather than at the call site, so `once`'s cache holds a handler
527
+ // that has already been proven to be one — and a rejection clears, so the message repeats per request.
528
+ const loadEndpoint = once(async () => assertEndpointModule(await route.server(), `"${route.path}"`));
399
529
  const handler = async (c, next) => {
400
- const { handler: endpointHandler } = await loadEndpoint();
530
+ const endpointHandler = await loadEndpoint();
401
531
  // Hono's `Handler` leaves its return parameter defaulted to `any`, so this hands back exactly what
402
532
  // the app's own handler is declared to return — there is nothing narrower to assert here.
403
533
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
404
534
  return endpointHandler(c, next);
405
535
  };
406
- const method = route.method ?? 'all';
407
- if (method === 'all')
536
+ // De-duplicated, because `app.on(['GET', 'GET'], …)` registers the path twice. Validation refuses
537
+ // `'all'` inside a list, so a list that reaches here is concrete methods only.
538
+ const methods = [...new Set([route.method ?? 'all'].flat())];
539
+ if (methods.includes('all'))
408
540
  app.all(route.path, handler);
409
- else
410
- app.on(method.toUpperCase(), route.path, handler);
541
+ else {
542
+ app.on(methods.map((method) => method.toUpperCase()), route.path, handler);
543
+ }
411
544
  }
412
545
  }
413
546
  runtime.mountPublicFallback(app);
414
547
  app.notFound(async (c) => {
415
548
  const isRsc = requestWantsRsc(c.req.raw);
416
549
  if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
417
- return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc }));
550
+ // `notFound: true` here as well as on the signal path: the two produce the same page for the same
551
+ // status, and a payload that says so from one route and not the other is a wire contract with a hole
552
+ // in it. The client reads it to tell "this is the 404 page" from "this is the page you asked for".
553
+ return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true }));
418
554
  }
419
555
  return plainNotFound(c);
420
556
  });
@@ -430,7 +566,7 @@ function buildApp() {
430
566
  const res = error.getResponse();
431
567
  return c.newResponse(res.body, res);
432
568
  }
433
- reportServerError(error, { source: 'request', request: c.req.raw, message: '[rshono] request error:' });
569
+ reportServerError(error, { source: 'request', hono: c, message: '[rshono] request error:' });
434
570
  const isRsc = requestWantsRsc(c.req.raw);
435
571
  if (loadErrorPage && (isRsc || acceptsHtml(c))) {
436
572
  const errorInfo = isDev
@@ -440,17 +576,26 @@ function buildApp() {
440
576
  }
441
577
  : { message: 'Internal Server Error' };
442
578
  try {
443
- return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo }));
579
+ return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo, returnValue: actionResults.get(c) }));
444
580
  }
445
581
  catch (renderError) {
446
- reportServerError(renderError, { source: 'request', request: c.req.raw, message: '[rshono] the error page failed to render:' });
582
+ reportServerError(renderError, { source: 'request', hono: c, message: '[rshono] the error page failed to render:' });
447
583
  }
448
584
  }
449
585
  const detail = isDev ? `\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}` : '';
450
- return c.text(`Internal Server Error${detail}`, 500, { vary: RSC_VARY_HEADER });
586
+ // Same reason as `plainNotFound`, minus the urgency: a 500 is not heuristically cacheable, so this half
587
+ // is consistency — the framework's own answers should not differ in what they promise about caching.
588
+ return c.text(`Internal Server Error${detail}`, 500, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
451
589
  });
452
590
  return app;
453
591
  }
592
+ /**
593
+ * Resolves and checks every route's own module — page and endpoint alike, `notFound` and `error` included.
594
+ * Called by `rshono build` once this bundle is imported for the prerender pass, so a route that could never
595
+ * serve a request fails the build rather than answering 500 in production. Exported from here rather than
596
+ * driven from the CLI because the fallback pages are on `routeConfig`, which does not leave this module.
597
+ */
598
+ export const checkRouteModules = () => assertRouteModules(routeConfig);
454
599
  export const app = buildApp();
455
600
  /**
456
601
  * The app, handed to whatever is hosting it: this binds a port and exports nothing where rshono owns the