@rshono/core 1.0.0-rc.6 → 1.0.0-rc.8

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 (88) hide show
  1. package/README.md +195 -168
  2. package/dist/builder/page-files.d.ts.map +1 -1
  3. package/dist/builder/page-files.js +7 -3
  4. package/dist/builder/page-files.js.map +1 -1
  5. package/dist/builder/public-env.d.ts +6 -0
  6. package/dist/builder/public-env.d.ts.map +1 -1
  7. package/dist/builder/public-env.js +6 -0
  8. package/dist/builder/public-env.js.map +1 -1
  9. package/dist/builder/rspack-config.d.ts.map +1 -1
  10. package/dist/builder/rspack-config.js +16 -19
  11. package/dist/builder/rspack-config.js.map +1 -1
  12. package/dist/cli/dev.d.ts.map +1 -1
  13. package/dist/cli/dev.js +63 -35
  14. package/dist/cli/dev.js.map +1 -1
  15. package/dist/cli/index.js +3 -4
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/config.d.ts +56 -55
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +17 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/deploy/contract.d.ts +15 -10
  22. package/dist/deploy/contract.d.ts.map +1 -1
  23. package/dist/deploy/contract.js.map +1 -1
  24. package/dist/deploy/filesystem.d.ts.map +1 -1
  25. package/dist/deploy/filesystem.js +6 -7
  26. package/dist/deploy/filesystem.js.map +1 -1
  27. package/dist/deploy/node/runtime.d.ts +5 -5
  28. package/dist/deploy/node/runtime.d.ts.map +1 -1
  29. package/dist/deploy/node/runtime.js +7 -11
  30. package/dist/deploy/node/runtime.js.map +1 -1
  31. package/dist/deploy/presets.d.ts +2 -6
  32. package/dist/deploy/presets.d.ts.map +1 -1
  33. package/dist/deploy/presets.js +2 -6
  34. package/dist/deploy/presets.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/router.d.ts +74 -36
  40. package/dist/router.d.ts.map +1 -1
  41. package/dist/router.js +2 -3
  42. package/dist/router.js.map +1 -1
  43. package/dist/runtime/boundaries.d.ts +10 -0
  44. package/dist/runtime/boundaries.d.ts.map +1 -1
  45. package/dist/runtime/boundaries.js +6 -0
  46. package/dist/runtime/boundaries.js.map +1 -1
  47. package/dist/runtime/client.d.ts +5 -3
  48. package/dist/runtime/client.d.ts.map +1 -1
  49. package/dist/runtime/client.js +5 -3
  50. package/dist/runtime/client.js.map +1 -1
  51. package/dist/runtime/context.d.ts +195 -45
  52. package/dist/runtime/context.d.ts.map +1 -1
  53. package/dist/runtime/context.js +277 -59
  54. package/dist/runtime/context.js.map +1 -1
  55. package/dist/runtime/control.d.ts.map +1 -1
  56. package/dist/runtime/control.js +7 -0
  57. package/dist/runtime/control.js.map +1 -1
  58. package/dist/runtime/entry.client.js +29 -47
  59. package/dist/runtime/entry.client.js.map +1 -1
  60. package/dist/runtime/entry.rsc.d.ts.map +1 -1
  61. package/dist/runtime/entry.rsc.js +109 -140
  62. package/dist/runtime/entry.rsc.js.map +1 -1
  63. package/dist/runtime/flight-inject.d.ts +4 -4
  64. package/dist/runtime/flight-inject.js +2 -2
  65. package/dist/runtime/flight-inject.js.map +1 -1
  66. package/dist/runtime/navigation.d.ts +11 -0
  67. package/dist/runtime/navigation.d.ts.map +1 -1
  68. package/dist/runtime/navigation.js +3 -0
  69. package/dist/runtime/navigation.js.map +1 -1
  70. package/dist/runtime/request.d.ts +3 -3
  71. package/dist/runtime/request.js +3 -3
  72. package/dist/runtime/request.js.map +1 -1
  73. package/dist/runtime/server.d.ts +7 -12
  74. package/dist/runtime/server.d.ts.map +1 -1
  75. package/dist/runtime/server.js +15 -12
  76. package/dist/runtime/server.js.map +1 -1
  77. package/dist/server/prerendered.d.ts +20 -35
  78. package/dist/server/prerendered.d.ts.map +1 -1
  79. package/dist/server/prerendered.js +8 -18
  80. package/dist/server/prerendered.js.map +1 -1
  81. package/dist/server/server-config.d.ts +10 -29
  82. package/dist/server/server-config.d.ts.map +1 -1
  83. package/dist/server/server-config.js +4 -70
  84. package/dist/server/server-config.js.map +1 -1
  85. package/dist/server/ssg.d.ts.map +1 -1
  86. package/dist/server/ssg.js +10 -11
  87. package/dist/server/ssg.js.map +1 -1
  88. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Hono } from 'hono';
3
- import { bodyLimit } from 'hono/body-limit';
3
+ import { HTTPException } from 'hono/http-exception';
4
4
  // The JSX factory, by name: the page element is created with this directly rather than as
5
5
  // `<Page {...props} />`, because a spread would drop the non-enumerable `ctx` prop. See `pageProps`.
6
6
  import { jsx } from 'react/jsx-runtime';
7
7
  // The bare specifier, not `/server.node`: the package ships a build per runtime behind export
8
- // conditions (`node`, `workerd`, `deno`, `edge-light`), and the RSC layer's `conditionNames` is what
9
- // picks one — so a non-Node deploy target gets its own build instead of Node's by hard-coded path.
8
+ // conditions, and the RSC layer's `conditionNames` picks one — so a non-Node deploy target gets its
9
+ // own build rather than Node's by hard-coded path.
10
10
  import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, renderToReadableStream, } from 'react-server-dom-rspack/server';
11
11
  // Resolved by the '@rshono/deploy' alias to the selected preset's runtime module — the one place
12
12
  // this file knows anything about where it is running. See `deploy/contract.ts`.
@@ -17,21 +17,17 @@ import { routes as userRoutes } from '@rshono/routes';
17
17
  import * as serverAppModule from '@rshono/server-app';
18
18
  import { isPageRoute } from '../router.js';
19
19
  import { appendVary, etagMatches } from '../server/headers.js';
20
- import { getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
20
+ import { beginPageRender, getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
21
21
  import { isControlSignal, RedirectSignal } from './control.js';
22
22
  import { renderHTML } from './entry.ssr.js';
23
23
  import { RouterProvider } from './navigation.js';
24
24
  import { acceptsRsc, isActionRequest, parseRenderRequest, requestWantsRsc, wantsRsc } from './request.js';
25
25
  const serverApp = (serverAppModule.default ?? null);
26
- // Framework settings resolved from rshono.config.ts and compiled into the bundle at build time
27
- // (see builder/rspack-config.ts). These have no runtime env-var interface — env is for secrets.
28
- // Named after their ServerConfig fields, so what a setting does here is one grep from where it is
29
- // resolved. `isDev` among them: the build mode is decided by which command produced the bundle, not
30
- // by `process.env.NODE_ENV`, and not every runtime this deploys to has a `process` to read anyway.
31
- const CONFIG = __RSHONO_CONFIG__;
32
- const { isDev, cspEnabled, checkOrigin, maxBodyBytes } = CONFIG;
33
- /** Extra cross-origin hosts permitted to post server actions, beyond the app's own origin. */
34
- const allowedOrigins = new Set(CONFIG.allowedOrigins);
26
+ // Framework settings resolved from rshono.config.ts and compiled into the bundle at build time (see
27
+ // builder/rspack-config.ts). There is no runtime env-var interface for these — env is for secrets.
28
+ // `isDev` among them: the build mode is decided by which command produced the bundle, and not every
29
+ // runtime this deploys to has a `process.env.NODE_ENV` to read.
30
+ const { isDev } = __RSHONO_CONFIG__;
35
31
  /** How long a prerendered page may be reused before revalidating. Also what `public/` files get. */
36
32
  const SSG_CACHE_CONTROL = 'public, max-age=300';
37
33
  /**
@@ -39,50 +35,25 @@ const SSG_CACHE_CONTROL = 'public, max-age=300';
39
35
  * the `Accept` header, which is what makes `Vary` non-optional here.
40
36
  */
41
37
  const PAGE_CONTENT_TYPE = /^(?:text\/html|text\/x-component)\b/;
42
- // The CSP is fixed per build apart from the nonce, so assemble everything but `script-src` once.
43
- const CSP_STATIC = Object.entries(CONFIG.cspDirectives)
44
- .filter(([name]) => name !== 'script-src')
45
- .map(([name, value]) => `${name} ${value}`)
46
- .join('; ');
47
- const CSP_SCRIPT_SRC = CONFIG.cspDirectives['script-src'] ?? "'self'";
48
38
  // Called here rather than at the top of the deploy runtime's own module so the timing is unchanged:
49
39
  // `.env` is loaded once every import above has been evaluated, exactly as before.
50
40
  runtime.loadEnv();
51
41
  const routeConfig = userRoutes;
52
42
  export const routes = routeConfig.routes;
53
43
  /**
54
- * CSRF guard for server-action POSTs, layering the two signals a browser gives us.
44
+ * The per-request CSP nonce, if the app asked for one.
55
45
  *
56
- * `Sec-Fetch-Site` is set by the browser and unforgeable by page script, so `same-origin` settles
57
- * the question on its own — and short-circuiting on it is what keeps the check from misfiring on a
58
- * legitimate request whose `Host` the proxy rewrote. Everything else falls back to comparing the
59
- * `Origin` host against our own (see {@link publicUrl} — deliberately *not* against a raw
60
- * `X-Forwarded-Host`, which the client controls unless `trustProxy` says a proxy owns it).
46
+ * The framework does not mint this. `secureHeaders()` from `hono/secure-headers` does, when its
47
+ * policy contains the `NONCE` placeholder, and it stores it here — so an app opts into a
48
+ * nonce-based CSP by registering that middleware in `src/server.ts` and the framework's only job is
49
+ * to stamp the value into the render.
61
50
  *
62
- * A missing `Origin` is treated as same-origin: no-JS form posts from older browsers omit it, and
63
- * those same browsers send no `Sec-Fetch-Site` either, so there is nothing left to check. Hosts are
64
- * compared case-insensitively; the scheme is not compared, so this alone won't stop an
65
- * `http://` origin posting to the `https://` site (HSTS is the control for that).
51
+ * Readable from a route handler because `secureHeaders` resolves its directives *before* calling
52
+ * `next()`; the header itself is written on the way back out. Absent when the app registered no CSP,
53
+ * or one with no nonce in it — a fixed policy needs nothing from the render.
66
54
  */
67
- function isSameOriginAction(c) {
68
- if (!checkOrigin)
69
- return true;
70
- const secFetchSite = c.req.header('sec-fetch-site');
71
- if (secFetchSite === 'same-origin')
72
- return true;
73
- const origin = c.req.header('origin');
74
- const originHost = origin ? URL.parse(origin)?.host.toLowerCase() || null : null;
75
- if (origin !== undefined && originHost === null)
76
- return false; // an Origin we can't parse is untrusted.
77
- const trusted = originHost !== null && (originHost === publicUrl(c).host.toLowerCase() || allowedOrigins.has(originHost));
78
- if (secFetchSite && secFetchSite !== 'none') {
79
- // The browser tells us (unforgeably) this didn't originate from our own site — only a
80
- // trusted (allowlisted) Origin may proceed.
81
- return trusted;
82
- }
83
- if (originHost === null)
84
- return true;
85
- return trusted;
55
+ function cspNonce(c) {
56
+ return c.get('secureHeadersNonce');
86
57
  }
87
58
  async function loadPageModule(load, label) {
88
59
  const mod = await load();
@@ -102,6 +73,14 @@ async function loadPageModule(load, label) {
102
73
  function acceptsHtml(c) {
103
74
  return c.req.header('accept')?.includes('text/html') ?? false;
104
75
  }
76
+ /**
77
+ * The 404 for an app with no `notFound` page, and for a client that wanted neither HTML nor a flight
78
+ * payload. Shared for the `Vary`: this is still one of the answers a page URL gives depending on
79
+ * `Accept`, and a cache not told so can hand a plain-text 404 to a browser asking for a document.
80
+ */
81
+ function plainNotFound(c) {
82
+ return c.text('Not Found', 404, { vary: 'Accept' });
83
+ }
105
84
  /** A lazy once-cell: runs `load` at most once and caches the promise, but clears a rejection so a later call can retry. */
106
85
  function once(load) {
107
86
  let promise;
@@ -133,50 +112,26 @@ function releaseWhenDone(stream, done) {
133
112
  cancel: done,
134
113
  }));
135
114
  }
136
- /**
137
- * There is no render deadline here any more.
138
- *
139
- * A `renderTimeout` setting used to wrap every request in an `AbortController` with a timer, covering
140
- * the server action as well as the flight and SSR passes. Every host this deploys to enforces its own
141
- * request timeout — Workers, Vercel and Lambda all do — so the only shape that needed one was a Node
142
- * process facing the internet directly, and there a proxy is both the conventional and the more capable
143
- * place for it. The README shows the middleware for a Node deploy that wants one in-process.
144
- *
145
- * What the deadline *also* did was carry the client-disconnect signal into the two React renderers, and
146
- * that is kept — but not by handing them `c.req.raw.signal`. Both React renderers add an `abort`
147
- * listener to whatever signal they are given and only remove it if the abort fires, so a listener left on
148
- * a request-lifetime signal pins the flight request, the Fizz request and the whole rendered tree. That
149
- * is not theoretical: handing them the request signal directly cost ~169 kB per `/ssr` request, never
150
- * reclaimed by a major GC, and took GC from 5.8% to 12.9% of wall time under load.
151
- *
152
- * The deadline's own `AbortController` was, accidentally, what had kept that collectable — its signal
153
- * died with the render. So {@link renderComponent} keeps a per-render controller and forwards the request
154
- * signal into it, releasing the forwarder when the response ends. Never through `AbortSignal.any([...])`,
155
- * which reads better and leaks worse: a signal it produced is *composite*, and Node holds every composite
156
- * signal carrying an `abort` listener in a process-lifetime set (`gcPersistentSignals`) until it either
157
- * aborts or loses its last listener.
158
- */
159
115
  /**
160
116
  * Builds the props a page component is called with.
161
117
  *
162
- * `url` and `params` match `useNavigation()` field for field, so a read can move between a page and
163
- * a `'use client'` component unchanged. The `URL` is this page's own — `RequestContext` parses its own — so a
164
- * page that mutates it cannot disturb anything else on the request.
118
+ * `url` and `params` match `useNavigation()` field for field, so a read can move between a page and a
119
+ * `'use client'` component unchanged. The `URL` is this page's own, so mutating it cannot disturb
120
+ * anything else on the request.
165
121
  *
166
- * `ctx` is *defined* rather than assigned, and both parts of how carry their weight:
122
+ * `ctx` is *defined* rather than assigned, and both halves of how carry their weight:
167
123
  *
168
124
  * - **A getter**, so nothing is built for the pages that never read it, and so a `render: 'static'`
169
- * page that does read it gets {@link getRequestContext}'s own "no per-request context while
170
- * prerendering" error rather than a bare `undefined` — one explanation, in one place.
125
+ * page that does read it gets {@link getRequestContext}'s "no per-request context while
126
+ * prerendering" error rather than a bare `undefined`.
171
127
  * - **Non-enumerable**, so React's *development-only* serialization of a server component's props
172
128
  * (the debug channel behind component stacks and the performance track) skips it. That walks own
173
- * enumerable properties, and `ctx.raw` is the Hono {@link Context} — whose `env` holds the
174
- * runtime's bindings. An enumerable `ctx` ships every one of them, secrets included, to the
175
- * browser in dev, and grows a small page's flight payload by well over 10 kB. Production never
176
- * serializes a server component's props at all, so this is the dev half of the same guarantee.
129
+ * enumerable properties, and `ctx.hono` is the Hono {@link Context} — whose `env` holds the
130
+ * runtime's bindings. An enumerable `ctx` would ship every one of them, secrets included, to the
131
+ * browser in dev, and add well over 10 kB to a small page's flight payload.
177
132
  *
178
- * The cost is that the element has to be created by handing this object to `jsx()` *by reference*:
179
- * a `<Page {...props} />` spread copies enumerable properties only, and would drop `ctx` silently.
133
+ * The cost is that the element has to be created by handing this object to `jsx()` *by reference*: a
134
+ * `<Page {...props} />` spread copies enumerable properties only, and would drop `ctx` silently.
180
135
  */
181
136
  function pageProps(c, errorInfo) {
182
137
  const props = { url: publicUrl(c), params: readParams(c), ...(errorInfo ? { error: errorInfo } : null) };
@@ -184,18 +139,16 @@ function pageProps(c, errorInfo) {
184
139
  return props;
185
140
  }
186
141
  async function renderComponent(c, Page, opts) {
187
- // The client going away is the one thing that still aborts a render — but React's renderers must not
188
- // be handed `c.req.raw.signal` to watch for it. Both of them add an `abort` listener and only remove
189
- // it if the abort fires, so on the happy path the listener stays on a signal that lives as long as the
190
- // request object, and through it pins the flight request, the Fizz request and the whole rendered
191
- // tree. Measured at ~169 kB per `/ssr` request, never reclaimed by a major GC.
142
+ // A client going away aborts the render — but React's renderers must never be handed
143
+ // `c.req.raw.signal` to watch for it. Both add an `abort` listener and only remove it if the abort
144
+ // fires, so on the happy path that listener stays on a request-lifetime signal and through it pins
145
+ // the flight request, the Fizz request and the whole rendered tree (~169 kB per `/ssr` request,
146
+ // never reclaimed by a major GC).
192
147
  //
193
- // So the render gets a controller of its own and the request signal only forwards into it. The
194
- // forwarding listener is `once`, so an abort removes it, and {@link release} removes it on the normal
195
- // path — which is what keeps the request from retaining the render. Deliberately not
196
- // `AbortSignal.any()`: a signal it produced is *composite*, and Node holds every composite signal
197
- // carrying an `abort` listener in a process-lifetime set until it aborts or loses its last listener,
198
- // which is a worse leak than the one being fixed here.
148
+ // So the render gets a controller of its own and the request signal only forwards into it: the
149
+ // forwarder is `once`, so an abort removes it, and `release` removes it on the normal path. Not
150
+ // `AbortSignal.any()` — a signal it produced is *composite*, and Node holds every composite signal
151
+ // carrying an `abort` listener in a process-lifetime set until it aborts or loses its last listener.
199
152
  const requestSignal = c.req.raw.signal;
200
153
  const renderAbort = new AbortController();
201
154
  const signal = renderAbort.signal;
@@ -206,11 +159,19 @@ async function renderComponent(c, Page, opts) {
206
159
  requestSignal.addEventListener('abort', forwardAbort, { once: true });
207
160
  /** Detaches the forwarder once the response has been written, so nothing survives the request. */
208
161
  const release = () => requestSignal.removeEventListener('abort', forwardAbort);
209
- const nonce = cspEnabled && !opts.isRsc ? crypto.randomUUID() : undefined;
162
+ // Documents only: the nonce goes on the bootstrap scripts and the `<meta>` React hydrates from,
163
+ // and a flight payload has neither. Leaving it off there is also what keeps a prerendered flight
164
+ // payload servable from disk under a nonce-based CSP.
165
+ const nonce = opts.isRsc ? undefined : cspNonce(c);
210
166
  const props = pageProps(c, opts.errorInfo);
211
167
  const root = (_jsxs(_Fragment, { children: [nonce && _jsx("meta", { property: "csp-nonce", nonce: nonce }), Page.entryCssFiles?.map((href) => (_jsx("link", { rel: "stylesheet", href: href, precedence: "default" }, href))), _jsx(RouterProvider, { href: props.url.href, params: props.params, children: jsx(Page, props) })] }));
212
168
  // `notFound` only when it is true, so the flight payload of an ordinary page doesn't carry the key.
213
169
  const rscPayload = { root, formState: opts.formState, returnValue: opts.returnValue, ...(opts.notFound ? { notFound: true } : null) };
170
+ // Past this line the response head belongs to the framework, so `ctx.setHeader()` and
171
+ // `ctx.cookies.set()` start throwing rather than writing somewhere nothing will read. It has to be
172
+ // the last thing before the render: a server action ran earlier in `renderPage` and legitimately
173
+ // writes to the response, and so does any middleware that wrapped this handler.
174
+ beginPageRender(c);
214
175
  let controlSignal;
215
176
  const rscStream = renderToReadableStream(rscPayload, {
216
177
  temporaryReferences: opts.temporaryReferences,
@@ -251,15 +212,9 @@ async function renderComponent(c, Page, opts) {
251
212
  release();
252
213
  throw controlSignal;
253
214
  }
254
- const headers = { 'content-type': 'text/html;charset=utf-8' };
255
- if (nonce) {
256
- // The nonce is always appended to whatever `script-src` resolved to, so overriding the
257
- // directive in `cspDirectives` can widen the policy but can't accidentally drop the nonce.
258
- // Dev additionally needs 'unsafe-eval' for react-refresh.
259
- const scriptSrc = `script-src ${CSP_SCRIPT_SRC} 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`;
260
- headers['content-security-policy'] = CSP_STATIC ? `${CSP_STATIC}; ${scriptSrc}` : scriptSrc;
261
- }
262
- return c.body(ssrResult.stream, (ssrResult.status ?? opts.status ?? 200), headers);
215
+ return c.body(ssrResult.stream, (ssrResult.status ?? opts.status ?? 200), {
216
+ 'content-type': 'text/html;charset=utf-8',
217
+ });
263
218
  }
264
219
  async function renderPage(c, loadPage) {
265
220
  const request = c.req.raw;
@@ -269,9 +224,6 @@ async function renderPage(c, loadPage) {
269
224
  let temporaryReferences;
270
225
  let actionStatus;
271
226
  if (isActionRequest(renderRequest)) {
272
- if (!isSameOriginAction(c)) {
273
- return c.text('Forbidden: cross-origin server action rejected', 403);
274
- }
275
227
  if (renderRequest.kind === 'rsc-action') {
276
228
  // Checked before the body is decoded, so an unknown id costs nothing to reject — and
277
229
  // `loadServerAction` would otherwise fault on the missing manifest entry, turning a bad
@@ -317,11 +269,13 @@ async function renderPage(c, loadPage) {
317
269
  }
318
270
  function buildApp() {
319
271
  const app = new Hono();
320
- // Cheap, unconditional headers that only matter when something else has gone wrong: stop
321
- // content-type sniffing, keep the full URL (paths, query) out of cross-origin referrers, and
322
- // refuse to be framed by another origin (clickjacking). `frame-ancestors` in the opt-in CSP is
323
- // stricter and takes precedence where both apply; this is the floor for everyone else.
324
- // Set after `next()` so a route or middleware that sets its own value wins.
272
+ // Baseline security headers: stop content-type sniffing, keep the full URL out of cross-origin
273
+ // referrers, and refuse to be framed by another origin. A floor, not a policy — an app that wants
274
+ // the full set registers `secureHeaders()` from `hono/secure-headers` in src/server.ts, and
275
+ // because this is registered first it unwinds *last* and the "only if unset" checks stand aside
276
+ // for whatever that set. Kept in the framework rather than left to the app because it is
277
+ // registered ahead of `mountStaticAssets`, which is a terminal handler the sub-app never sees:
278
+ // without this, `/_static/*` would carry no security headers at all.
325
279
  app.use(async (c, next) => {
326
280
  await next();
327
281
  const headers = c.res.headers;
@@ -331,31 +285,33 @@ function buildApp() {
331
285
  headers.set('referrer-policy', 'strict-origin-when-cross-origin');
332
286
  if (!headers.has('x-frame-options'))
333
287
  headers.set('x-frame-options', 'SAMEORIGIN');
334
- // Page responses only, from here down. Two things are true of them and of nothing else served
335
- // here: one URL answers with either an HTML document or a flight payload depending on `Accept`,
336
- // and the default page is request-specific (cookies, session, headers).
288
+ // React Refresh compiles updates with `eval`, so a dev build cannot run under any CSP an app
289
+ // would sensibly write for production. Widened here rather than asked of the app, so one policy
290
+ // in src/server.ts serves both and nobody debugs a blocked HMR socket. Only ever in dev, and
291
+ // only when the app has a `script-src` to widen.
292
+ if (isDev) {
293
+ const csp = headers.get('content-security-policy');
294
+ if (csp?.includes('script-src ')) {
295
+ headers.set('content-security-policy', csp.replace('script-src ', "script-src 'unsafe-eval' "));
296
+ }
297
+ }
298
+ // Page responses only, from here down: one URL answers with either an HTML document or a flight
299
+ // payload depending on `Accept`, and the default page is request-specific.
337
300
  if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
338
301
  return;
339
302
  appendVary(headers, 'Accept');
340
- // Without this a page carries no cache directives at all, and a shared cache — a CDN, a
341
- // corporate proxy — is free to store a logged-in user's page and hand it to someone else.
342
- // `private` forbids exactly that; `no-cache` makes the browser revalidate its own copy rather
343
- // than re-showing a stale personalised page. Neither blocks bfcache, which `no-store` would.
344
- // A prerendered page, or anything a route set deliberately, already has its own value.
303
+ // Without this a page carries no cache directives at all, and a shared cache — a CDN, a corporate
304
+ // proxy — is free to store a logged-in user's page and hand it to someone else. `private` forbids
305
+ // that; `no-cache` makes the browser revalidate rather than re-show a stale personalised page.
306
+ // Neither blocks bfcache, which `no-store` would. A prerendered page already has its own value.
345
307
  if (!headers.has('cache-control'))
346
308
  headers.set('cache-control', 'private, no-cache');
347
309
  });
348
- // A memory-exhaustion guard for *every* route — pages and actions, `{ type: 'endpoint' }` routes
349
- // and the src/server.ts sub-app alike — since anything that buffers a body (`.json()`,
350
- // `.formData()`) is exposed, not just server actions. Rejects an over-cap `Content-Length` up
351
- // front and otherwise counts the stream, so a chunked or under-reported body is still cut off.
352
- if (maxBodyBytes > 0) {
353
- app.use(bodyLimit({ maxSize: maxBodyBytes, onError: (c) => c.text('Payload Too Large', 413) }));
354
- }
355
310
  runtime.mountStaticAssets(app);
356
- // Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash)
357
- // wraps page requests too. The flip side: a *terminal* handler in src/server.ts at the same path
358
- // as a page route wins over the page.
311
+ // Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash,
312
+ // and the security middleware the framework no longer owns — `csrf()`, `bodyLimit()`,
313
+ // `secureHeaders()`) wraps page requests too. The flip side: a *terminal* handler in
314
+ // src/server.ts at the same path as a page route wins over the page.
359
315
  if (serverApp) {
360
316
  app.route('/', serverApp);
361
317
  }
@@ -366,7 +322,9 @@ function buildApp() {
366
322
  const isRsc = requestWantsRsc(c.req.raw);
367
323
  if (signal instanceof RedirectSignal) {
368
324
  if (isRsc) {
369
- return c.body(renderToReadableStream({ root: null, redirect: signal.location }, { signal: c.req.raw.signal }), 200, {
325
+ // No `signal`, deliberately — see `renderComponent`. This payload is two fields and no
326
+ // component tree, so there is nothing worth aborting and nothing worth a listener.
327
+ return c.body(renderToReadableStream({ root: null, redirect: signal.location }), 200, {
370
328
  'content-type': 'text/x-component;charset=utf-8',
371
329
  });
372
330
  }
@@ -375,8 +333,7 @@ function buildApp() {
375
333
  if (loadNotFoundPage) {
376
334
  return renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
377
335
  }
378
- // Plain text, but still one of the two answers this URL gives depending on `Accept`.
379
- return c.text('Not Found', 404, { vary: 'Accept' });
336
+ return plainNotFound(c);
380
337
  };
381
338
  for (const route of routes) {
382
339
  if (isPageRoute(route)) {
@@ -388,15 +345,17 @@ function buildApp() {
388
345
  if (servesPrerendered && c.req.method === 'GET') {
389
346
  const isRsc = acceptsRsc(c.req.raw);
390
347
  // A prerendered file is one fixed set of bytes, so it cannot carry a per-request nonce:
391
- // under `csp` the *document* has to be rendered per request. The flight payload never
392
- // carries a nonce, so it stays servable from the build either way.
393
- const mustRenderForNonce = cspEnabled && !isRsc;
394
- // Otherwise both representations come from the build, so a soft navigation is served from
395
- // it too rather than re-rendering a page that was already built.
348
+ // where the app's CSP has one, the *document* has to be rendered per request. The flight
349
+ // payload never carries a nonce, so it stays servable from the build either way.
350
+ //
351
+ // Decided per request rather than per build, which is what moving the CSP to
352
+ // `secureHeaders()` buys: an app whose policy carries no `NONCE` keeps its prerendered
353
+ // documents, where the old build-time `csp: true` flag gave every one of them up.
354
+ const mustRenderForNonce = !isRsc && cspNonce(c) !== undefined;
396
355
  if (!mustRenderForNonce) {
397
356
  const page = await runtime.readPrerendered(c, isRsc ? 'flight' : 'html');
398
357
  // A prerendered page is request-independent by construction, so it is safe to cache
399
- // publicly; the short max-age matches what `public/` files get. The ETag turns the
358
+ // publicly; the short max-age matches what `public/` files get, and the ETag turns the
400
359
  // revalidation that follows into a 304 rather than the page all over again.
401
360
  //
402
361
  // Answered outside `runWithContext`: no app code runs on this path, so there is no
@@ -443,12 +402,22 @@ function buildApp() {
443
402
  if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
444
403
  return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc }));
445
404
  }
446
- return c.text('Not Found', 404, { vary: 'Accept' });
405
+ return plainNotFound(c);
447
406
  });
448
407
  const loadErrorPage = routeConfig.error ? memoizePage(routeConfig.error, 'the error page') : null;
449
408
  app.onError(async (error, c) => {
450
409
  if (isControlSignal(error))
451
410
  return runWithContext(c, () => respondToControlSignal(c, error));
411
+ // Registering an `onError` at all replaces Hono's default handler, which is what would otherwise
412
+ // turn an `HTTPException` into the response it carries. Without this every middleware that
413
+ // rejects a request by throwing one — `csrf()` with a 403, `bodyLimit()` with a 413, an app's own
414
+ // `throw new HTTPException(401)` — would surface as a 500 error page instead of its own status.
415
+ // Rebuilt through `c` rather than returned as-is, exactly as Hono's own default does, so headers
416
+ // already prepared on the context (`c.header(…)` from middleware that ran first) survive.
417
+ if (error instanceof HTTPException) {
418
+ const res = error.getResponse();
419
+ return c.newResponse(res.body, res);
420
+ }
452
421
  reportServerError(error, { source: 'request', request: c.req.raw, message: '[rshono] request error:' });
453
422
  const isRsc = requestWantsRsc(c.req.raw);
454
423
  if (loadErrorPage && (isRsc || acceptsHtml(c))) {