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

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 (151) hide show
  1. package/README.md +93 -2
  2. package/dist/builder/env-shadow-loader.cjs +41 -1
  3. package/dist/builder/page-entry-loader.cjs +28 -1
  4. package/dist/builder/page-files.d.ts +0 -1
  5. package/dist/builder/public-env.d.ts +0 -1
  6. package/dist/builder/react-versions.d.ts +0 -1
  7. package/dist/builder/rspack-config.d.ts +0 -1
  8. package/dist/builder/rspack-config.js +4 -0
  9. package/dist/builder/rspack-config.js.map +1 -1
  10. package/dist/builder/server-only-imports.d.ts +0 -1
  11. package/dist/cli/build.d.ts +0 -1
  12. package/dist/cli/build.js +37 -35
  13. package/dist/cli/build.js.map +1 -1
  14. package/dist/cli/dev.d.ts +0 -1
  15. package/dist/cli/dev.js +15 -1
  16. package/dist/cli/dev.js.map +1 -1
  17. package/dist/cli/exit.d.ts +12 -0
  18. package/dist/cli/exit.js +16 -0
  19. package/dist/cli/exit.js.map +1 -0
  20. package/dist/cli/index.d.ts +0 -1
  21. package/dist/cli/index.js +74 -20
  22. package/dist/cli/index.js.map +1 -1
  23. package/dist/cli/start.d.ts +0 -1
  24. package/dist/cli/start.js +3 -2
  25. package/dist/cli/start.js.map +1 -1
  26. package/dist/config.d.ts +0 -1
  27. package/dist/deploy/aws-lambda/runtime.d.ts +0 -1
  28. package/dist/deploy/build-marker.d.ts +7 -2
  29. package/dist/deploy/build-marker.js +6 -0
  30. package/dist/deploy/build-marker.js.map +1 -1
  31. package/dist/deploy/cloudflare/build.d.ts +0 -1
  32. package/dist/deploy/cloudflare/build.js +6 -0
  33. package/dist/deploy/cloudflare/build.js.map +1 -1
  34. package/dist/deploy/cloudflare/runtime.d.ts +0 -1
  35. package/dist/deploy/cloudflare/runtime.js +24 -2
  36. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  37. package/dist/deploy/contract.d.ts +20 -2
  38. package/dist/deploy/contract.js.map +1 -1
  39. package/dist/deploy/filesystem.d.ts +0 -1
  40. package/dist/deploy/node/runtime.d.ts +0 -1
  41. package/dist/deploy/presets.d.ts +7 -1
  42. package/dist/deploy/presets.js +13 -2
  43. package/dist/deploy/presets.js.map +1 -1
  44. package/dist/deploy/public-paths.d.ts +42 -0
  45. package/dist/deploy/public-paths.js +66 -0
  46. package/dist/deploy/public-paths.js.map +1 -0
  47. package/dist/deploy/vercel/build.d.ts +0 -1
  48. package/dist/deploy/vercel/build.js +6 -0
  49. package/dist/deploy/vercel/build.js.map +1 -1
  50. package/dist/deploy/vercel/runtime.d.ts +0 -1
  51. package/dist/index.d.ts +0 -1
  52. package/dist/router.d.ts +20 -5
  53. package/dist/router.js.map +1 -1
  54. package/dist/runtime/boundaries.d.ts +0 -1
  55. package/dist/runtime/client.d.ts +0 -1
  56. package/dist/runtime/context.d.ts +15 -2
  57. package/dist/runtime/context.js +15 -1
  58. package/dist/runtime/context.js.map +1 -1
  59. package/dist/runtime/control.d.ts +15 -1
  60. package/dist/runtime/control.js +17 -0
  61. package/dist/runtime/control.js.map +1 -1
  62. package/dist/runtime/dev-protocol.d.ts +0 -1
  63. package/dist/runtime/empty-server-app.d.ts +0 -1
  64. package/dist/runtime/entry.client.d.ts +0 -1
  65. package/dist/runtime/entry.client.js +31 -4
  66. package/dist/runtime/entry.client.js.map +1 -1
  67. package/dist/runtime/entry.rsc.d.ts +6 -4
  68. package/dist/runtime/entry.rsc.js +473 -56
  69. package/dist/runtime/entry.rsc.js.map +1 -1
  70. package/dist/runtime/entry.ssr.d.ts +19 -5
  71. package/dist/runtime/entry.ssr.js +22 -47
  72. package/dist/runtime/entry.ssr.js.map +1 -1
  73. package/dist/runtime/failure-document.d.ts +11 -0
  74. package/dist/runtime/failure-document.js +35 -0
  75. package/dist/runtime/failure-document.js.map +1 -0
  76. package/dist/runtime/flight-inject.d.ts +0 -1
  77. package/dist/runtime/flight-inject.js +15 -9
  78. package/dist/runtime/flight-inject.js.map +1 -1
  79. package/dist/runtime/hot-update.d.ts +0 -1
  80. package/dist/runtime/navigation.d.ts +6 -1
  81. package/dist/runtime/navigation.js +6 -0
  82. package/dist/runtime/navigation.js.map +1 -1
  83. package/dist/runtime/request.d.ts +23 -1
  84. package/dist/runtime/request.js +39 -0
  85. package/dist/runtime/request.js.map +1 -1
  86. package/dist/runtime/server.d.ts +0 -1
  87. package/dist/runtime/validate-entries.d.ts +0 -1
  88. package/dist/runtime/validate-entries.js +114 -10
  89. package/dist/runtime/validate-entries.js.map +1 -1
  90. package/dist/server/headers.d.ts +37 -4
  91. package/dist/server/headers.js +49 -13
  92. package/dist/server/headers.js.map +1 -1
  93. package/dist/server/load-config.d.ts +0 -1
  94. package/dist/server/load-env.d.ts +0 -1
  95. package/dist/server/prerendered.d.ts +14 -2
  96. package/dist/server/prerendered.js +42 -4
  97. package/dist/server/prerendered.js.map +1 -1
  98. package/dist/server/server-config.d.ts +0 -1
  99. package/dist/server/shutdown.d.ts +0 -1
  100. package/dist/server/ssg.d.ts +6 -1
  101. package/dist/server/ssg.js +30 -7
  102. package/dist/server/ssg.js.map +1 -1
  103. package/dist/server/static.d.ts +0 -1
  104. package/dist/server/static.js +5 -5
  105. package/dist/server/static.js.map +1 -1
  106. package/package.json +1 -1
  107. package/dist/builder/page-files.d.ts.map +0 -1
  108. package/dist/builder/public-env.d.ts.map +0 -1
  109. package/dist/builder/react-versions.d.ts.map +0 -1
  110. package/dist/builder/rspack-config.d.ts.map +0 -1
  111. package/dist/builder/server-only-imports.d.ts.map +0 -1
  112. package/dist/cli/build.d.ts.map +0 -1
  113. package/dist/cli/dev.d.ts.map +0 -1
  114. package/dist/cli/index.d.ts.map +0 -1
  115. package/dist/cli/start.d.ts.map +0 -1
  116. package/dist/config.d.ts.map +0 -1
  117. package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
  118. package/dist/deploy/build-marker.d.ts.map +0 -1
  119. package/dist/deploy/cloudflare/build.d.ts.map +0 -1
  120. package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
  121. package/dist/deploy/contract.d.ts.map +0 -1
  122. package/dist/deploy/filesystem.d.ts.map +0 -1
  123. package/dist/deploy/node/runtime.d.ts.map +0 -1
  124. package/dist/deploy/presets.d.ts.map +0 -1
  125. package/dist/deploy/vercel/build.d.ts.map +0 -1
  126. package/dist/deploy/vercel/runtime.d.ts.map +0 -1
  127. package/dist/index.d.ts.map +0 -1
  128. package/dist/router.d.ts.map +0 -1
  129. package/dist/runtime/boundaries.d.ts.map +0 -1
  130. package/dist/runtime/client.d.ts.map +0 -1
  131. package/dist/runtime/context.d.ts.map +0 -1
  132. package/dist/runtime/control.d.ts.map +0 -1
  133. package/dist/runtime/dev-protocol.d.ts.map +0 -1
  134. package/dist/runtime/empty-server-app.d.ts.map +0 -1
  135. package/dist/runtime/entry.client.d.ts.map +0 -1
  136. package/dist/runtime/entry.rsc.d.ts.map +0 -1
  137. package/dist/runtime/entry.ssr.d.ts.map +0 -1
  138. package/dist/runtime/flight-inject.d.ts.map +0 -1
  139. package/dist/runtime/hot-update.d.ts.map +0 -1
  140. package/dist/runtime/navigation.d.ts.map +0 -1
  141. package/dist/runtime/request.d.ts.map +0 -1
  142. package/dist/runtime/server.d.ts.map +0 -1
  143. package/dist/runtime/validate-entries.d.ts.map +0 -1
  144. package/dist/server/headers.d.ts.map +0 -1
  145. package/dist/server/load-config.d.ts.map +0 -1
  146. package/dist/server/load-env.d.ts.map +0 -1
  147. package/dist/server/prerendered.d.ts.map +0 -1
  148. package/dist/server/server-config.d.ts.map +0 -1
  149. package/dist/server/shutdown.d.ts.map +0 -1
  150. package/dist/server/ssg.d.ts.map +0 -1
  151. package/dist/server/static.d.ts.map +0 -1
@@ -14,12 +14,14 @@ import { routes as userRoutes } from '@rshono/routes';
14
14
  // @ts-expect-error — resolved by the '@rshono/server-app' alias (src/server.ts or the empty fallback)
15
15
  import * as serverAppModule from '@rshono/server-app';
16
16
  import { isPageRoute } from '../router.js';
17
- import { appendVary, etagMatches } from '../server/headers.js';
17
+ import { appendVary, etagMatches, varyWith } from '../server/headers.js';
18
+ import { PRERENDER_NONCE_HEADER } from '../server/prerendered.js';
18
19
  import { beginPageRender, getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
19
- import { isControlSignal, RedirectSignal } from './control.js';
20
+ import { cameFromPayload, isControlSignal, RedirectSignal } from './control.js';
20
21
  import { renderHTML } from './entry.ssr.js';
22
+ import { failureDocument } from './failure-document.js';
21
23
  import { RouterProvider } from './navigation.js';
22
- import { asksForRsc, isActionRequest, parseRenderRequest, requestWantsRsc, RSC_VARY_HEADER, wantsRsc } from './request.js';
24
+ import { asksForRsc, isActionRequest, isBrowserFormPost, parseRenderRequest, requestWantsRsc, RSC_VARY_HEADER, wantsRsc } from './request.js';
23
25
  import { assertEndpointModule, assertPageModule, assertRouteModules, validateRoutesModule, validateServerApp } from './validate-entries.js';
24
26
  const serverApp = validateServerApp(serverAppModule);
25
27
  // Compiled into the bundle from rshono.config.ts by DefinePlugin; there is no runtime env-var interface.
@@ -34,13 +36,16 @@ const { isDev } = __RSHONO_CONFIG__;
34
36
  * ```ts
35
37
  * server.use('/docs/*', async (c, next) => {
36
38
  * await next();
37
- * c.res.headers.set('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
39
+ * c.header('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
38
40
  * });
39
41
  * ```
40
42
  *
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.
43
+ * After, because the response below is built with `cache-control` in the bag it hands `c.body(...)`, and that
44
+ * replaces anything prepared before the handler ran. The `ETag` is untouched either way, so revalidation
45
+ * still costs a 304 rather than the page.
46
+ *
47
+ * `c.header()` rather than `c.res.headers.set(...)`, which is the same choice the response floor makes and
48
+ * for the same reason: it is the one that also works on a response the app did not build. See the floor.
44
49
  */
45
50
  const SSG_CACHE_CONTROL = 'public, max-age=300';
46
51
  /**
@@ -69,15 +74,48 @@ export const routes = routeConfig.routes;
69
74
  * the request it belongs to.
70
75
  */
71
76
  const actionResults = new WeakMap();
77
+ /**
78
+ * Whether this process is `rshono build`'s prerender pass rather than a server answering requests.
79
+ *
80
+ * Set by `build.ts` before it imports the app bundle, which inlines its own copy of the module graph — so
81
+ * `process.env` is what crosses that boundary, the same channel `runtime/context.ts` and the `node` runtime
82
+ * already read. Unforgeable from outside: it is an environment variable of the build process, and a deployed
83
+ * server never has one.
84
+ */
85
+ const prerendering = typeof process !== 'undefined' && !!process.env?.RSHONO_PRERENDER;
72
86
  /**
73
87
  * The per-request CSP nonce, if the app asked for one.
74
88
  *
75
89
  * The framework never mints it: `secureHeaders()` does, when its policy contains the `NONCE`
76
90
  * placeholder, and stores it here — so all the framework does is stamp the value into the render. It is
77
91
  * readable from a route handler because `secureHeaders` resolves its directives before `next()`.
92
+ *
93
+ * Always `undefined` while prerendering, because a nonce is per request and a prerendered file is not. The
94
+ * pass renders through the app's full middleware, so `secureHeaders()` mints one at *build* time and it was
95
+ * stamped into the document that ships — frozen, and identical in every copy. Which of two bad things that
96
+ * caused depended on the app's policy: under a global nonce policy the file was never served (the request
97
+ * has a nonce of its own, so `mustRenderForNonce` renders it fresh) and the build reported prerendering
98
+ * pages the deployment would never read; under a policy scoped to some other path, `secureHeaders` never ran
99
+ * on this one, nothing forced a re-render, and the stale build-time nonce shipped — picked up by the client
100
+ * as `__webpack_nonce__`. Asking for the document without a nonce is what the flight variant already gets,
101
+ * and it settles both.
78
102
  */
79
103
  function cspNonce(c) {
80
- return c.get('secureHeadersNonce');
104
+ return prerendering ? undefined : c.get('secureHeadersNonce');
105
+ }
106
+ /**
107
+ * The build-time half of `mustRenderForNonce`, as a header on the document the prerender pass asked for —
108
+ * {@link PRERENDER_NONCE_HEADER}, where the contract is written down.
109
+ *
110
+ * {@link cspNonce} masks the nonce so none is stamped into a file, but *whether one was minted* is exactly
111
+ * what decides that file's fate: a request for this path will mint its own, so the document is re-rendered
112
+ * per request and the copy on disk is never read. Read here rather than through `cspNonce` for that reason —
113
+ * this is the one place the unmasked value is the answer.
114
+ *
115
+ * Empty on a deployed server: `prerendering` is an environment variable of the build process.
116
+ */
117
+ function prerenderNonceHeader(c) {
118
+ return prerendering && c.get('secureHeadersNonce') !== undefined ? { [PRERENDER_NONCE_HEADER]: '1' } : null;
81
119
  }
82
120
  /**
83
121
  * Whether a `<form action={serverAction}>` post came from another site, and so must not be allowed to run one.
@@ -89,9 +127,9 @@ function cspNonce(c) {
89
127
  *
90
128
  * - A client-initiated action carries `x-rsc-action`, which is not a CORS-simple header. A cross-origin caller
91
129
  * needs a preflight it will not be given, so that shape cannot be forged from a browser at all.
92
- * - A form post is `multipart/form-data` or `application/x-www-form-urlencoded` with no header of its own —
130
+ * - A form post is one of the three `enctype` values a browser `<form>` can send, with no header of its own —
93
131
  * the content types that need no preflight. It is forgeable, and an app with no `src/server.ts` has nothing
94
- * standing in front of it.
132
+ * standing in front of it. All three, not the two React writes: see {@link isBrowserFormPost}.
95
133
  *
96
134
  * Both halves are required, because either alone refuses something real:
97
135
  *
@@ -112,8 +150,16 @@ function cspNonce(c) {
112
150
  *
113
151
  * `publicUrl(c)` rather than `c.req.url`, so it honours `trustProxy` and compares against the origin the
114
152
  * browser actually used — which behind a proxy, `rshono dev`'s included, is not the one the server was reached
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.
153
+ * on.
154
+ *
155
+ * **The cost is wider than "an action you meant to allow".** This runs on the request's shape alone, before
156
+ * the body is read, because knowing whether a given post carries an action means buffering an untrusted body
157
+ * to look for a `$ACTION_*` field. So a **page route cannot accept any cross-site form post at all**, whether
158
+ * or not an action is in it: a SAML ACS callback, OIDC `response_mode=form_post`, and most payment-gateway
159
+ * returns all arrive in exactly this shape and are all refused, `csrf()`'s allowlist included. Refusing
160
+ * before parsing is the right trade — the alternative is reading a body from anyone who asks — but it is a
161
+ * real limitation and the 403 and the README both say so. An `{ type: 'endpoint' }` route is the way to
162
+ * accept one: those call the app handler directly and never reach `renderPage`, so they never reach this.
117
163
  */
118
164
  function refusesCrossSiteForm(c) {
119
165
  const site = c.req.header('sec-fetch-site');
@@ -126,15 +172,29 @@ function acceptsHtml(c) {
126
172
  return c.req.header('accept')?.includes('text/html') ?? false;
127
173
  }
128
174
  /**
129
- * The 404 for an app with no `notFound` page, and for a client that wanted neither HTML nor a flight
130
- * payload. It carries the `Vary` too: this is one of the answers a page URL gives depending on the `RSC` request header.
175
+ * Every plain-text answer the framework gives on a page route: the 404s, the refusals on the action path and
176
+ * the last-resort 500. One function, because the header bag is what gets forgotten — and the four call sites
177
+ * used to make three different choices about it, which left the next one to guess.
178
+ *
179
+ * Both headers are set here rather than left to the response floor, which only decorates page *content
180
+ * types* and so never sees a `text/plain` answer.
181
+ *
182
+ * - **`cache-control`** matters in one case and is consistency in the rest. A 404 is heuristically cacheable
183
+ * under RFC 9111, so without this a shared cache may store the plain-text one — while the rendered HTML
184
+ * 404 beside it, the same answer to the same request from a client that asked for HTML, is correctly
185
+ * private. The action refusals answer a POST, which no cache stores, and 400/403/500 are not in that list
186
+ * either; saying it anyway costs a header and settles the question for good.
187
+ * - **`vary: RSC`** because a page URL has two representations, and every one of these is an answer to a
188
+ * request that could have asked for either.
189
+ */
190
+ function plainRefusal(c, message, status) {
191
+ return c.text(message, status, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
192
+ }
193
+ /**
194
+ * The 404 for an app with no `notFound` page, and for a client that wanted neither HTML nor a flight payload.
131
195
  */
132
196
  function plainNotFound(c) {
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 });
197
+ return plainRefusal(c, 'Not Found', 404);
138
198
  }
139
199
  /** A lazy once-cell: runs `load` at most once, but clears a rejection so a later call can retry. */
140
200
  function once(load) {
@@ -287,6 +347,10 @@ async function renderComponent(c, Page, opts) {
287
347
  });
288
348
  }
289
349
  catch (error) {
350
+ // The render is abandoned, so stop it: a boundary still resolving is work for a response that will never
351
+ // be sent, and it holds the tee's SSR branch open behind it. The `signal.aborted` guard in `onError`
352
+ // above is what keeps the resulting cancellations from being reported as render errors of their own.
353
+ renderAbort.abort(error);
290
354
  release();
291
355
  if (controlSignal)
292
356
  throw controlSignal;
@@ -312,12 +376,110 @@ async function renderComponent(c, Page, opts) {
312
376
  release();
313
377
  throw controlSignal;
314
378
  }
315
- return c.body(ssrResult.stream, (ssrResult.status ?? opts.status ?? 200), {
379
+ return c.body(ssrResult.stream, (opts.status ?? 200), {
316
380
  'content-type': 'text/html;charset=utf-8',
381
+ ...prerenderNonceHeader(c),
317
382
  });
318
383
  }
384
+ /**
385
+ * The answer to an action request whose body could not be read or decoded — a malformed request, the same
386
+ * class of thing as the unknown-action-id 400 beside it, and answered the same way.
387
+ *
388
+ * Deliberately silent. Action ids are public — they are bare string literals in the client chunks — so
389
+ * anyone can post a valid id with a body that will not decode, and while that reached `reportServerError` it
390
+ * was an unauthenticated way to page whoever owns the error tracker, once per request. Nothing here is the
391
+ * server being wrong, so there is nothing to report; the status is the whole message.
392
+ *
393
+ * The text says no more than that on purpose: which of `text()`, `formData()`, `decodeReply` or
394
+ * `decodeAction` gave up is React's or undici's internal shape, and repeating it back would describe the
395
+ * framework's decoding to a caller who cannot act on it.
396
+ */
397
+ function malformedAction(c) {
398
+ return plainRefusal(c, 'Bad Request: malformed server action request', 400);
399
+ }
400
+ /** The field name React gives the action id of a form with no `useActionState`. See {@link formActionId}. */
401
+ const ACTION_ID_FIELD = '$ACTION_ID_';
402
+ /**
403
+ * The action id a `<form action={serverAction}>` post names in a field *name*, or `null` for the shape that
404
+ * does not.
405
+ *
406
+ * Two shapes reach `decodeAction`, and only one puts the id somewhere readable. A form with no
407
+ * `useActionState` posts a bare `$ACTION_ID_<id>` field, whose name is what `decodeAction` itself matches on
408
+ * — so reading it here is not a second decoder of React's form format. `useActionState`'s shape puts the id
409
+ * *inside* an encoded `$ACTION_<n>:0` value instead, and reading that would be exactly the drift
410
+ * `decodeFormState`'s guard below is about: two decoders disagreeing about a wire format neither owns.
411
+ */
412
+ function formActionId(formData) {
413
+ for (const key of formData.keys()) {
414
+ if (key.startsWith(ACTION_ID_FIELD))
415
+ return key.slice(ACTION_ID_FIELD.length);
416
+ }
417
+ return null;
418
+ }
419
+ /**
420
+ * The first action the server bundle holds itself, as the id to ask `loadServerAction` about when a form
421
+ * post names none this can see.
422
+ *
423
+ * Entries declaring `chunks` are skipped for {@link checkServerActions}'s reason: `loadServerAction` is a
424
+ * bare `__webpack_require__`, so a module that has not been loaded yet throws for a reason that says nothing
425
+ * about the deployment — and a probe that answers "broken" for a healthy app is the one failure direction
426
+ * this must not have.
427
+ */
428
+ const probeActionId = (() => {
429
+ for (const [id, entry] of Object.entries(__rspack_rsc_manifest__.serverManifest)) {
430
+ if ((entry.chunks?.length ?? 0) === 0)
431
+ return id;
432
+ }
433
+ return undefined;
434
+ })();
435
+ /**
436
+ * Whether the app's `'use server'` module is what `decodeAction` could not load, rather than the caller's
437
+ * body being what it could not decode.
438
+ *
439
+ * Asked by loading an action for real, because nothing on the error says which of the two it was: React
440
+ * reports a missing module and an undecodable field through the same channel, and the message is its own
441
+ * internal shape either way. `loadServerAction` is `__webpack_require__` plus a `typeof === 'function'`
442
+ * check, so a second call after the first succeeded is a cache hit.
443
+ *
444
+ * The id it asks about is the one the body names where the body names one, and otherwise any of the app's —
445
+ * which is the same question, because Rspack concatenates the whole `'use server'` graph into a single
446
+ * server module (see {@link checkServerActions} and the G1 note): if one action cannot be loaded at run
447
+ * time, none of them can.
448
+ *
449
+ * **Both ways of being wrong land on the old behaviour, deliberately.** No manifest to probe, or a probe
450
+ * that succeeds while the id the caller actually named is broken — which needs `useActionState`'s shape
451
+ * *and* a per-entry manifest corruption rather than a module that will not evaluate — answers `false`, and
452
+ * the 400 stands exactly as it did. What must never happen is the other direction: a malformed body
453
+ * answering 500 and paging whoever owns the error tracker, which is what {@link malformedAction} exists to
454
+ * prevent. A probe that throws is the deployment being broken for every caller, so a 500 is then right for
455
+ * this one too.
456
+ */
457
+ function cannotLoadServerActions(formData) {
458
+ const named = formActionId(formData);
459
+ const id = named !== null && Object.hasOwn(__rspack_rsc_manifest__.serverManifest, named) ? named : probeActionId;
460
+ if (id === undefined)
461
+ return false;
462
+ try {
463
+ loadServerAction(id);
464
+ return false;
465
+ }
466
+ catch {
467
+ return true;
468
+ }
469
+ }
319
470
  async function renderPage(c, loadPage) {
320
471
  const request = c.req.raw;
472
+ // Ahead of the classification below, and keyed on the request's *shape* rather than on it: every `enctype`
473
+ // a browser form can post is refused, not only the two React writes. `text/plain` is the third, and keying
474
+ // this off `parseRenderRequest` left it classified `document` and let through — the one enctype the
475
+ // framework never decodes was also the one it never refused, while the README promised that a page route
476
+ // refuses every cross-site form post. See {@link isBrowserFormPost} and `refusesCrossSiteForm`.
477
+ if (isBrowserFormPost(request) && refusesCrossSiteForm(c)) {
478
+ // Named for what was actually refused. The check runs before the body is read — it has to, since knowing
479
+ // whether a post carries an action means buffering an untrusted body — so "to a server action" claimed
480
+ // something this code cannot know, and said it to a caller whose post very often has no action in it.
481
+ return plainRefusal(c, "Forbidden: cross-site form post to a page route — a page route cannot accept one, because a form post to a page is how a server action is called. Use an { type: 'endpoint' } route.", 403);
482
+ }
321
483
  const renderRequest = parseRenderRequest(request);
322
484
  let returnValue;
323
485
  let formState;
@@ -328,13 +490,35 @@ async function renderPage(c, loadPage) {
328
490
  // Before the body is decoded, so an unknown id costs nothing to reject. `hasOwn` so `__proto__`
329
491
  // does not resolve to a manifest entry.
330
492
  if (!Object.hasOwn(__rspack_rsc_manifest__.serverManifest, renderRequest.actionId)) {
331
- return c.text('Bad Request: unknown server action', 400);
493
+ return plainRefusal(c, 'Bad Request: unknown server action', 400);
494
+ }
495
+ let args;
496
+ try {
497
+ const contentType = request.headers.get('content-type');
498
+ const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text();
499
+ temporaryReferences = createTemporaryReferenceSet();
500
+ args = await decodeReply(body, { temporaryReferences });
501
+ }
502
+ catch {
503
+ return malformedAction(c);
504
+ }
505
+ // Outside that guard, deliberately. Everything inside it reads or decodes the *body*, which is the
506
+ // caller's to get wrong; loading the action is not. The id is one `hasOwn` above proved the manifest
507
+ // holds, so no client can steer this — what is left is the bundle being incomplete: a chunk
508
+ // `__webpack_require__` cannot find after a partial deploy, or a module that throws as it evaluates.
509
+ // A 400 there would tell the one caller in that guard who is *not* at fault that they are, and tell
510
+ // the operator who needs paging nothing at all.
511
+ let action;
512
+ try {
513
+ action = loadServerAction(renderRequest.actionId);
514
+ }
515
+ catch (error) {
516
+ // Attributed to the action rather than left to the top-level handler's `source: 'request'`: the
517
+ // request was fine, the deployment is not. Then re-thrown, so the app's `error` page answers 500 —
518
+ // `reportServerError` de-duplicates, so `onError` reporting it again is a no-op.
519
+ reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action could not be loaded:' });
520
+ throw error;
332
521
  }
333
- const contentType = request.headers.get('content-type');
334
- const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text();
335
- temporaryReferences = createTemporaryReferenceSet();
336
- const args = await decodeReply(body, { temporaryReferences });
337
- const action = loadServerAction(renderRequest.actionId);
338
522
  try {
339
523
  returnValue = { ok: true, value: await action(...args) };
340
524
  }
@@ -354,12 +538,34 @@ async function renderPage(c, loadPage) {
354
538
  else {
355
539
  // A `<form action={serverAction}>` post, which is the path that runs before hydration and with
356
540
  // JavaScript off. Unlike the client-initiated one it carries no custom header, so it is also the only
357
- // action shape a browser can be made to send from another site: see `refusesCrossSiteForm`.
358
- if (refusesCrossSiteForm(c)) {
359
- return c.text('Forbidden: cross-site form post to a server action', 403, { vary: RSC_VARY_HEADER });
541
+ // action shape a browser can be made to send from another site — refused above, on the shape of the
542
+ // request rather than on this classification: see `refusesCrossSiteForm`.
543
+ let formData;
544
+ try {
545
+ formData = await request.formData();
546
+ }
547
+ catch {
548
+ return malformedAction(c);
549
+ }
550
+ let decodedAction;
551
+ try {
552
+ decodedAction = await decodeAction(formData);
553
+ }
554
+ catch (error) {
555
+ // Split from the read above, for the reason F3 split the client-initiated path: `decodeAction` is
556
+ // two things at once. It reads the caller's body *and* — through `loadServerReference` —
557
+ // `__webpack_require__`s the module the action lives in, so the 400 that is right about a body is
558
+ // wrong whenever the deployment is what failed. Which of the two it was is nowhere on the error, so
559
+ // it is asked directly. See {@link cannotLoadServerActions}.
560
+ if (cannotLoadServerActions(formData)) {
561
+ // The original error, not the probe's: this one is the fault the request actually met. Attributed
562
+ // and re-thrown exactly as the `rsc-action` branch does, so the app's `error` page answers 500 and
563
+ // the operator is paged instead of the caller being blamed.
564
+ reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action could not be loaded:' });
565
+ throw error;
566
+ }
567
+ return malformedAction(c);
360
568
  }
361
- const formData = await request.formData();
362
- const decodedAction = await decodeAction(formData, __rspack_rsc_manifest__.serverManifest);
363
569
  if (decodedAction) {
364
570
  let result;
365
571
  try {
@@ -376,7 +582,25 @@ async function renderPage(c, loadPage) {
376
582
  reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
377
583
  throw error;
378
584
  }
379
- formState = (await decodeFormState(result, formData, __rspack_rsc_manifest__.serverManifest)) ?? undefined;
585
+ try {
586
+ formState = (await decodeFormState(result, formData)) ?? undefined;
587
+ }
588
+ catch (error) {
589
+ // The action has already run, and may well have written something, so this cannot be refused the
590
+ // way an undecodable body *before* it is — the guard above answers 400 precisely because nothing
591
+ // has happened yet. What failed here is rebuilding the `useActionState` key from the body's
592
+ // `$ACTION_REF_` metadata, and a body React wrote always carries it: this is reachable only from a
593
+ // hand-made one, which pairs a `$ACTION_ID_` React took for the call with `$ACTION_REF_`/
594
+ // `$ACTION_KEY` fields it did not write. So the page is rendered with no form state, which is
595
+ // exactly what a form without `useActionState` gets.
596
+ //
597
+ // Silent for the same reason `malformedAction` is: action ids are public, so anyone can post one,
598
+ // and reporting from here would put an unauthenticated caller back in touch with whoever owns the
599
+ // error tracker — for a request that has already had its effects. In dev it is worth a line, since
600
+ // there the likely cause is a React or bundler version whose form fields this does not understand.
601
+ if (isDev)
602
+ console.warn('[rshono] a form post carried form-state fields that could not be decoded; rendering without them:', error);
603
+ }
380
604
  }
381
605
  }
382
606
  }
@@ -389,6 +613,44 @@ async function renderPage(c, loadPage) {
389
613
  temporaryReferences,
390
614
  });
391
615
  }
616
+ /**
617
+ * A thrown value as an `Error`, because that is what Hono's dispatcher requires before it will hand one to
618
+ * `app.onError`.
619
+ *
620
+ * `compose` routes a rejection to the error handler only when it is `instanceof Error`, and **re-throws
621
+ * anything else** — which rejects `app.fetch` and leaves the answer to whatever is hosting it. Under
622
+ * `@hono/node-server` that is a bodiless 500 raised outside the app: no `error` page, nothing on stderr, no
623
+ * `onServerError` report, and not even the response floor's `x-content-type-options` / `referrer-policy` /
624
+ * `x-frame-options`, since nothing below it ever unwinds. So `throw 'a plain string'` from an endpoint used
625
+ * to answer strictly worse than `throw new Error('…')` beside it, and `RouteConfig.error` promises the page
626
+ * for a throw from "an endpoint, a server action, or middleware" — all three reachable this way.
627
+ *
628
+ * The test is `instanceof Error` and nothing wider, deliberately: it has to be the *same* test `compose`
629
+ * makes, so that what this converts is exactly what Hono would have re-thrown. A duck-typed check would
630
+ * convert a cross-realm `Error` the dispatcher was about to handle, and leave one it was not.
631
+ *
632
+ * Converting only a non-`Error` is also what keeps the rest of the error path intact: both control signals
633
+ * extend `Error`, `cameFromPayload` reads a `digest` off the thrown object, and `reportServerError`
634
+ * de-duplicates on its identity — a wrapper around any of those would break all three.
635
+ *
636
+ * The value goes on `cause` rather than into the message alone. It is the only thing that carries what the
637
+ * app actually threw, an error tracker walks it, and the wrapper's own `stack` starts *here*, in the
638
+ * framework, so it says nothing about where the throw came from.
639
+ */
640
+ function asError(value) {
641
+ if (value instanceof Error)
642
+ return value;
643
+ let described;
644
+ try {
645
+ // Quoted for a string, so an empty one is still visible; anything else prints however it prints. A
646
+ // `toString` that throws in its turn leaves only the type to say, and this must not fail in a catch.
647
+ described = typeof value === 'string' ? JSON.stringify(value) : String(value);
648
+ }
649
+ catch {
650
+ described = typeof value;
651
+ }
652
+ return new Error(`[rshono] a non-Error value was thrown: ${described}`, { cause: value });
653
+ }
392
654
  function buildApp() {
393
655
  const app = new Hono();
394
656
  // A floor, not a policy: an app wanting the full set registers `secureHeaders()` in src/server.ts, and
@@ -397,26 +659,79 @@ function buildApp() {
397
659
  app.use(async (c, next) => {
398
660
  await next();
399
661
  const headers = c.res.headers;
662
+ /**
663
+ * The headers this response still needs, collected before any of them is written — because the writing
664
+ * is the part that cannot go through `headers.set(…)`.
665
+ *
666
+ * A handler is free to return a `Response` it did not build. `Response.redirect(…)` and every result of
667
+ * `fetch()` carry an **immutable** header bag, and handing one straight back is ordinary Hono — proxying
668
+ * an upstream is the commonest thing a Worker does. `.set()` on one throws `TypeError: immutable`, which
669
+ * is what this floor used to answer such a handler with: the throw reached `onError`, the app's 500 page
670
+ * went out in place of the redirect, and the app's error tracker got `immutable` and a minified frame
671
+ * naming nothing anyone could act on.
672
+ *
673
+ * Invisible on the targets anyone develops against, which is the whole hazard: `@hono/node-server`
674
+ * installs a mutable `Response` shim as a global, so `node` and `vercel` — dev, `rshono start` and every
675
+ * suite that drives them — cannot see the bug that `cloudflare` and `aws-lambda` will. The framework
676
+ * already knew the mechanism: it is written on `serveAsset` in `cloudflare/runtime.ts`, which repairs the
677
+ * framework's *own* asset responses by hand.
678
+ *
679
+ * `c.header()` is the repair, and Hono's own — it rebuilds a finalized response as
680
+ * `new Response(res.body, res)` before writing. It does that on *every* call, so collecting is what keeps
681
+ * it to one rebuild: the first write goes through `c.header()` and the rest to the bag it hands back
682
+ * (measured at 1.2 µs against 4.9 µs for five separate calls), and a response that already carries all of
683
+ * these is not rebuilt at all.
684
+ */
685
+ const writes = [];
400
686
  if (!headers.has('x-content-type-options'))
401
- headers.set('x-content-type-options', 'nosniff');
687
+ writes.push(['x-content-type-options', 'nosniff']);
402
688
  if (!headers.has('referrer-policy'))
403
- headers.set('referrer-policy', 'strict-origin-when-cross-origin');
689
+ writes.push(['referrer-policy', 'strict-origin-when-cross-origin']);
404
690
  if (!headers.has('x-frame-options'))
405
- headers.set('x-frame-options', 'SAMEORIGIN');
691
+ writes.push(['x-frame-options', 'SAMEORIGIN']);
406
692
  // React Refresh compiles updates with `eval`, so a dev build cannot run under a production CSP.
407
693
  // Widened here so one policy in src/server.ts serves both.
408
694
  if (isDev) {
409
695
  const csp = headers.get('content-security-policy');
410
696
  if (csp?.includes('script-src ')) {
411
- headers.set('content-security-policy', csp.replace('script-src ', "script-src 'unsafe-eval' "));
697
+ writes.push(['content-security-policy', csp.replace('script-src ', "script-src 'unsafe-eval' ")]);
698
+ }
699
+ }
700
+ // Page responses only.
701
+ if (PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? '')) {
702
+ const vary = varyWith(headers.get('vary'), RSC_VARY_HEADER);
703
+ if (vary !== null)
704
+ writes.push(['vary', vary]);
705
+ if (!headers.has('cache-control'))
706
+ writes.push(['cache-control', PAGE_CACHE_CONTROL]);
707
+ }
708
+ let writable;
709
+ for (const [name, value] of writes) {
710
+ if (writable)
711
+ writable.set(name, value);
712
+ else {
713
+ c.header(name, value);
714
+ writable = c.res.headers;
412
715
  }
413
716
  }
414
- // Page responses only from here down.
415
- if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
416
- return;
417
- appendVary(headers, RSC_VARY_HEADER);
418
- if (!headers.has('cache-control'))
419
- headers.set('cache-control', PAGE_CACHE_CONTROL);
717
+ });
718
+ // The backstop for {@link asError}, and *second* for a reason: the middleware that throws is the one whose
719
+ // own half after `await next()` is skipped, so converting in the floor above would buy `onError` at the
720
+ // cost of the headers that floor exists to set. Here the conversion happens below it, `compose` hands the
721
+ // `Error` to `onError` at this level, and the floor unwinds over the response that comes back.
722
+ //
723
+ // Second also means *above the app's own middleware*, which is what the two conversions further down make
724
+ // up for: an endpoint or a page route converts at its own handler, so a middleware's post-`next()` half
725
+ // still runs, exactly as it does for a thrown `Error`. What is left over is a *middleware* that throws a
726
+ // non-`Error` itself — then everything registered outside it is skipped, and nothing short of a change in
727
+ // `compose` could do otherwise.
728
+ app.use(async (c, next) => {
729
+ try {
730
+ await next();
731
+ }
732
+ catch (error) {
733
+ throw asError(error);
734
+ }
420
735
  });
421
736
  // First, so the app's own middleware (`csrf()`, `bodyLimit()`, auth, logging) wraps everything registered
422
737
  // below: the page routes, and the asset handlers too. Assets used to be mounted ahead of this, which left
@@ -441,7 +756,19 @@ function buildApp() {
441
756
  'content-type': 'text/x-component;charset=utf-8',
442
757
  });
443
758
  }
444
- return c.redirect(signal.location, signal.status);
759
+ // Both page defaults by hand: `c.redirect` builds a bodiless response with no content type, so the
760
+ // middleware that would apply them skips it on `PAGE_CONTENT_TYPE`. They are not decoration here —
761
+ // `301` and `308` are cacheable with no explicit `Cache-Control` at all, so a session-gated permanent
762
+ // redirect could otherwise be stored by a shared cache and replayed to another visitor, and without
763
+ // `Vary` this document redirect could answer an `RSC: 1` fetch that needs the payload above.
764
+ //
765
+ // Written straight to the bag, unlike the floor: `c.redirect` builds this response here and now, so it
766
+ // is Hono's own and mutable. `Response.redirect()` — the one an *app* would reach for — is not.
767
+ const redirected = c.redirect(signal.location, signal.status);
768
+ appendVary(redirected.headers, RSC_VARY_HEADER);
769
+ if (!redirected.headers.has('cache-control'))
770
+ redirected.headers.set('cache-control', PAGE_CACHE_CONTROL);
771
+ return redirected;
445
772
  }
446
773
  if (loadNotFoundPage) {
447
774
  try {
@@ -503,7 +830,11 @@ function buildApp() {
503
830
  catch (error) {
504
831
  if (isControlSignal(error))
505
832
  return runWithContext(c, () => respondToControlSignal(c, error));
506
- throw error;
833
+ // Converted here rather than left to the backstop above the app's middleware, so a page or an
834
+ // action that throws a non-`Error` reaches `onError` from this route's own dispatch level — which
835
+ // is where a thrown `Error` reaches it, and so the only place from which a middleware's
836
+ // post-`next()` half runs. See {@link asError}.
837
+ throw asError(error);
507
838
  }
508
839
  };
509
840
  const handler = async (c) => {
@@ -528,10 +859,17 @@ function buildApp() {
528
859
  const loadEndpoint = once(async () => assertEndpointModule(await route.server(), `"${route.path}"`));
529
860
  const handler = async (c, next) => {
530
861
  const endpointHandler = await loadEndpoint();
531
- // Hono's `Handler` leaves its return parameter defaulted to `any`, so this hands back exactly what
532
- // the app's own handler is declared to return — there is nothing narrower to assert here.
533
- // eslint-disable-next-line @typescript-eslint/no-unsafe-return
534
- return endpointHandler(c, next);
862
+ try {
863
+ // Awaited rather than returned, so a rejection is this frame's to catch: the same conversion the
864
+ // page route makes, for the same reason. See {@link asError}.
865
+ // Hono's `Handler` leaves its return parameter defaulted to `any`, so this hands back exactly what
866
+ // the app's own handler is declared to return — there is nothing narrower to assert here.
867
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
868
+ return await endpointHandler(c, next);
869
+ }
870
+ catch (error) {
871
+ throw asError(error);
872
+ }
535
873
  };
536
874
  // De-duplicated, because `app.on(['GET', 'GET'], …)` registers the path twice. Validation refuses
537
875
  // `'all'` inside a list, so a list that reaches here is concrete methods only.
@@ -566,7 +904,9 @@ function buildApp() {
566
904
  const res = error.getResponse();
567
905
  return c.newResponse(res.body, res);
568
906
  }
569
- reportServerError(error, { source: 'request', hono: c, message: '[rshono] request error:' });
907
+ // Not a shell failure re-thrown from `renderComponent`: {@link cameFromPayload} says why.
908
+ if (!cameFromPayload(error))
909
+ reportServerError(error, { source: 'request', hono: c, message: '[rshono] request error:' });
570
910
  const isRsc = requestWantsRsc(c.req.raw);
571
911
  if (loadErrorPage && (isRsc || acceptsHtml(c))) {
572
912
  const errorInfo = isDev
@@ -579,23 +919,100 @@ function buildApp() {
579
919
  return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo, returnValue: actionResults.get(c) }));
580
920
  }
581
921
  catch (renderError) {
582
- reportServerError(renderError, { source: 'request', hono: c, message: '[rshono] the error page failed to render:' });
922
+ // A `redirect()` is not a render failure, and this is the one catch on that path that used to treat
923
+ // it as one — a 500 and a misleading line in the app's error tracker, where the `notFound` page's
924
+ // identical branch answers the redirect. Nothing is committed yet and the branch that answers it
925
+ // cannot fail, which is the same reasoning as there.
926
+ //
927
+ // `notFound()` is deliberately **not** honoured here: it would render the `notFound` page from
928
+ // inside the error path, which can fail in its turn and has nowhere left to escalate to. It stays
929
+ // reported.
930
+ if (renderError instanceof RedirectSignal)
931
+ return respondToControlSignal(c, renderError);
932
+ // Not as a render failure, for a signal — that message describes a bug in the page, and this is the
933
+ // framework declining to act. An author who wrote `notFound()` in their `error` page and read "the
934
+ // error page failed to render: NotFoundSignal" would go looking for the wrong thing.
935
+ const message = isControlSignal(renderError)
936
+ ? '[rshono] notFound() from the error page is not honoured — it would render the notFound page from inside the error path. The framework 500 answers instead:'
937
+ : '[rshono] the error page failed to render:';
938
+ // An `error` page whose *own* render threw arrives here as a payload stand-in, its real fault
939
+ // already reported as `render` by the render that produced it. So the line still goes to stderr —
940
+ // with two `render` reports on one request it is what says which of them was the error page — but
941
+ // it is not a second report of one fault. {@link cameFromPayload}.
942
+ if (cameFromPayload(renderError))
943
+ console.error(message, renderError);
944
+ else
945
+ reportServerError(renderError, { source: 'request', hono: c, message });
583
946
  }
584
947
  }
948
+ // A client that asked for HTML gets a document, even here: this is the last resort for an app with no
949
+ // `error` page, and for one whose `error` page threw in its turn, and a browser handed `text/plain` in
950
+ // either case shows a bare line of text where the app used to show a page. Everything else — a flight
951
+ // fetch, curl, a probe — gets the plain line, which is what it asked for.
952
+ if (!isRsc && acceptsHtml(c)) {
953
+ return c.html(failureDocument(error), 500, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
954
+ }
585
955
  const detail = isDev ? `\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}` : '';
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 });
956
+ return plainRefusal(c, `Internal Server Error${detail}`, 500);
589
957
  });
590
958
  return app;
591
959
  }
592
960
  /**
593
- * Resolves and checks every route's own module — page and endpoint alike, `notFound` and `error` included.
961
+ * Loads the app's server actions, so a `'use server'` module that cannot be evaluated is a line in the build
962
+ * log rather than a 500 on the first action anyone calls.
963
+ *
964
+ * Nothing on the server imports these modules until an action is called: a `'use client'` component that
965
+ * calls one gets a `createServerReference` stub, so the only thing holding them is the manifest. That makes
966
+ * an action module the one part of an app a build never touched — and the failure it hides is not one route,
967
+ * it is every action that module holds, which in practice is all of them (Rspack compiles the app's whole
968
+ * `'use server'` graph into a single server module).
969
+ *
970
+ * One id per *module*, because that is the granularity of the failure that matters — and evaluating a module
971
+ * that throws once per action id would repeat whatever it did before throwing. The rest of that module's ids
972
+ * are then cache hits, so they are checked too: `loadServerAction` also refuses an export that is not a
973
+ * function, and that is per id.
974
+ *
975
+ * Entries declaring `chunks` are skipped. `loadServerAction` is a bare `__webpack_require__`, so a module
976
+ * that has not been loaded yet would throw here for a reason that says nothing about the app — and every
977
+ * action this framework has produced sits in the server bundle itself, with no chunks to load.
978
+ *
979
+ * A warning, not a failure, for the same reason {@link assertRouteModules} warns: a module can legitimately
980
+ * decline to evaluate in a build — one that reads a secret out of the environment, say — and a build is not
981
+ * where that gets decided.
982
+ */
983
+ function checkServerActions() {
984
+ const entries = Object.entries(__rspack_rsc_manifest__.serverManifest).filter(([, entry]) => (entry.chunks?.length ?? 0) === 0);
985
+ /** Action ids by the module that holds them, in manifest order. */
986
+ const byModule = new Map();
987
+ for (const [actionId, entry] of entries)
988
+ byModule.set(entry.id, [...(byModule.get(entry.id) ?? []), actionId]);
989
+ for (const actionIds of byModule.values()) {
990
+ for (const actionId of actionIds) {
991
+ try {
992
+ loadServerAction(actionId);
993
+ }
994
+ catch (error) {
995
+ const reason = error instanceof Error ? error.message : String(error);
996
+ console.warn(` ⚠ the module holding ${actionIds.length} of the app's ${entries.length} server action(s) could not be loaded at build time — ${reason}\n` +
997
+ ` Calling one of those actions loads that module first, so if it fails the same way at run time, each of them answers 500.`);
998
+ break;
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Everything about the app a build can check without serving a request: every route's own module — page and
1005
+ * endpoint alike, `notFound` and `error` included — and then the server actions, which no route reaches.
1006
+ *
594
1007
  * Called by `rshono build` once this bundle is imported for the prerender pass, so a route that could never
595
1008
  * 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.
1009
+ * driven from the CLI because both halves read module-scope state — `routeConfig`, and the manifest the RSC
1010
+ * plugin injects — neither of which leaves this bundle.
597
1011
  */
598
- export const checkRouteModules = () => assertRouteModules(routeConfig);
1012
+ export const checkAppModules = async () => {
1013
+ await assertRouteModules(routeConfig);
1014
+ checkServerActions();
1015
+ };
599
1016
  export const app = buildApp();
600
1017
  /**
601
1018
  * The app, handed to whatever is hosting it: this binds a port and exports nothing where rshono owns the