@rshono/core 1.0.0-rc.0 → 1.0.0-rc.10

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 (143) hide show
  1. package/README.md +202 -159
  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 +3 -3
  10. package/dist/builder/rspack-config.d.ts.map +1 -1
  11. package/dist/builder/rspack-config.js +62 -16
  12. package/dist/builder/rspack-config.js.map +1 -1
  13. package/dist/cli/build.d.ts +2 -2
  14. package/dist/cli/build.js.map +1 -1
  15. package/dist/cli/dev.d.ts +2 -2
  16. package/dist/cli/dev.d.ts.map +1 -1
  17. package/dist/cli/dev.js +63 -35
  18. package/dist/cli/dev.js.map +1 -1
  19. package/dist/cli/index.js +10 -10
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/config.d.ts +58 -70
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +17 -1
  24. package/dist/config.js.map +1 -1
  25. package/dist/deploy/cloudflare/build.js +1 -1
  26. package/dist/deploy/cloudflare/build.js.map +1 -1
  27. package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
  28. package/dist/deploy/cloudflare/runtime.js +7 -37
  29. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  30. package/dist/deploy/contract.d.ts +22 -16
  31. package/dist/deploy/contract.d.ts.map +1 -1
  32. package/dist/deploy/contract.js.map +1 -1
  33. package/dist/deploy/filesystem.d.ts +1 -1
  34. package/dist/deploy/filesystem.d.ts.map +1 -1
  35. package/dist/deploy/filesystem.js +7 -10
  36. package/dist/deploy/filesystem.js.map +1 -1
  37. package/dist/deploy/node/runtime.d.ts +4 -0
  38. package/dist/deploy/node/runtime.d.ts.map +1 -1
  39. package/dist/deploy/node/runtime.js +24 -3
  40. package/dist/deploy/node/runtime.js.map +1 -1
  41. package/dist/deploy/presets.d.ts +3 -3
  42. package/dist/deploy/presets.d.ts.map +1 -1
  43. package/dist/deploy/presets.js +13 -30
  44. package/dist/deploy/presets.js.map +1 -1
  45. package/dist/deploy/vercel/runtime.d.ts.map +1 -1
  46. package/dist/deploy/vercel/runtime.js +0 -3
  47. package/dist/deploy/vercel/runtime.js.map +1 -1
  48. package/dist/index.d.ts +6 -9
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +8 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/router.d.ts +85 -48
  53. package/dist/router.d.ts.map +1 -1
  54. package/dist/router.js +4 -6
  55. package/dist/router.js.map +1 -1
  56. package/dist/runtime/boundaries.d.ts +39 -25
  57. package/dist/runtime/boundaries.d.ts.map +1 -1
  58. package/dist/runtime/boundaries.js +22 -17
  59. package/dist/runtime/boundaries.js.map +1 -1
  60. package/dist/runtime/client.d.ts +9 -8
  61. package/dist/runtime/client.d.ts.map +1 -1
  62. package/dist/runtime/client.js +9 -8
  63. package/dist/runtime/client.js.map +1 -1
  64. package/dist/runtime/context.d.ts +216 -70
  65. package/dist/runtime/context.d.ts.map +1 -1
  66. package/dist/runtime/context.js +299 -93
  67. package/dist/runtime/context.js.map +1 -1
  68. package/dist/runtime/control.d.ts.map +1 -1
  69. package/dist/runtime/control.js +7 -0
  70. package/dist/runtime/control.js.map +1 -1
  71. package/dist/runtime/dev-protocol.d.ts.map +1 -1
  72. package/dist/runtime/dev-protocol.js.map +1 -1
  73. package/dist/runtime/entry.client.d.ts +4 -0
  74. package/dist/runtime/entry.client.d.ts.map +1 -1
  75. package/dist/runtime/entry.client.js +190 -154
  76. package/dist/runtime/entry.client.js.map +1 -1
  77. package/dist/runtime/entry.rsc.d.ts.map +1 -1
  78. package/dist/runtime/entry.rsc.js +145 -159
  79. package/dist/runtime/entry.rsc.js.map +1 -1
  80. package/dist/runtime/entry.ssr.d.ts +9 -1
  81. package/dist/runtime/entry.ssr.d.ts.map +1 -1
  82. package/dist/runtime/entry.ssr.js +36 -18
  83. package/dist/runtime/entry.ssr.js.map +1 -1
  84. package/dist/runtime/flight-inject.d.ts +31 -0
  85. package/dist/runtime/flight-inject.d.ts.map +1 -0
  86. package/dist/runtime/flight-inject.js +221 -0
  87. package/dist/runtime/flight-inject.js.map +1 -0
  88. package/dist/runtime/navigation.d.ts +20 -38
  89. package/dist/runtime/navigation.d.ts.map +1 -1
  90. package/dist/runtime/navigation.js +10 -53
  91. package/dist/runtime/navigation.js.map +1 -1
  92. package/dist/runtime/request.d.ts +6 -0
  93. package/dist/runtime/request.d.ts.map +1 -1
  94. package/dist/runtime/request.js +8 -0
  95. package/dist/runtime/request.js.map +1 -1
  96. package/dist/runtime/server.d.ts +7 -12
  97. package/dist/runtime/server.d.ts.map +1 -1
  98. package/dist/runtime/server.js +15 -12
  99. package/dist/runtime/server.js.map +1 -1
  100. package/dist/server/headers.d.ts +9 -9
  101. package/dist/server/headers.js +9 -9
  102. package/dist/server/headers.js.map +1 -1
  103. package/dist/server/load-config.d.ts +2 -2
  104. package/dist/server/load-config.d.ts.map +1 -1
  105. package/dist/server/load-config.js +22 -11
  106. package/dist/server/load-config.js.map +1 -1
  107. package/dist/server/prerendered.d.ts +43 -15
  108. package/dist/server/prerendered.d.ts.map +1 -1
  109. package/dist/server/prerendered.js +47 -0
  110. package/dist/server/prerendered.js.map +1 -1
  111. package/dist/server/server-config.d.ts +13 -39
  112. package/dist/server/server-config.d.ts.map +1 -1
  113. package/dist/server/server-config.js +5 -74
  114. package/dist/server/server-config.js.map +1 -1
  115. package/dist/server/ssg.d.ts +2 -2
  116. package/dist/server/ssg.d.ts.map +1 -1
  117. package/dist/server/ssg.js +21 -34
  118. package/dist/server/ssg.js.map +1 -1
  119. package/package.json +13 -16
  120. package/dist/deploy/bun/runtime.d.ts +0 -11
  121. package/dist/deploy/bun/runtime.d.ts.map +0 -1
  122. package/dist/deploy/bun/runtime.js +0 -22
  123. package/dist/deploy/bun/runtime.js.map +0 -1
  124. package/dist/deploy/deno/runtime.d.ts +0 -11
  125. package/dist/deploy/deno/runtime.d.ts.map +0 -1
  126. package/dist/deploy/deno/runtime.js +0 -16
  127. package/dist/deploy/deno/runtime.js.map +0 -1
  128. package/dist/deploy/listen.d.ts +0 -20
  129. package/dist/deploy/listen.d.ts.map +0 -1
  130. package/dist/deploy/listen.js +0 -24
  131. package/dist/deploy/listen.js.map +0 -1
  132. package/dist/deploy/netlify/build.d.ts +0 -8
  133. package/dist/deploy/netlify/build.d.ts.map +0 -1
  134. package/dist/deploy/netlify/build.js +0 -52
  135. package/dist/deploy/netlify/build.js.map +0 -1
  136. package/dist/deploy/netlify/runtime.d.ts +0 -13
  137. package/dist/deploy/netlify/runtime.d.ts.map +0 -1
  138. package/dist/deploy/netlify/runtime.js +0 -24
  139. package/dist/deploy/netlify/runtime.js.map +0 -1
  140. package/dist/server/compress.d.ts +0 -15
  141. package/dist/server/compress.d.ts.map +0 -1
  142. package/dist/server/compress.js +0 -76
  143. package/dist/server/compress.js.map +0 -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 { getContext, 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
- import { acceptsRsc, isActionRequest, parseRenderRequest, wantsRsc } from './request.js';
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, renderTimeoutMs, cspEnabled, checkOrigin, maxBodyBytes, compress: compressEnabled } = 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;
@@ -118,75 +97,81 @@ function once(load) {
118
97
  };
119
98
  }
120
99
  /**
121
- * Owns the render-deadline lifecycle in one place (the RAII / `defer` pattern — a
122
- * `using` declaration doesn't fit because the timer must outlive this call to keep
123
- * guarding the *stream*, not just the function scope). The timer is released on
124
- * exactly one of: the stream finishing ({@link RenderDeadline.guard}), an explicit
125
- * {@link RenderDeadline.clear} on an error path, or the signal aborting (client
126
- * disconnect, or the deadline firing itself). A manually-cleared timer instead of
127
- * `AbortSignal.timeout()` so a fast response doesn't leave one pending to fire later.
100
+ * Passes `stream` through untouched, calling `done` once it ends — however it ends.
128
101
  *
129
- * One deadline covers the whole request — server action included, not just the render — so a
130
- * hung action can't pin a socket open indefinitely either.
102
+ * Used on the flight-only response path, which has no transform of its own to hang a completion hook
103
+ * off (the document path has {@link injectFlightPayload}). `done` must be idempotent: a cancelled
104
+ * response can reach both `cancel` and the abort forwarder.
131
105
  */
132
- function createRenderDeadline(requestSignal, ms) {
133
- const controller = new AbortController();
134
- const timer = setTimeout(() => controller.abort(new Error(`[rshono] request exceeded ${ms}ms`)), ms);
135
- timer.unref?.();
136
- const signal = AbortSignal.any([requestSignal, controller.signal]);
137
- const clear = () => clearTimeout(timer);
138
- signal.addEventListener('abort', clear, { once: true });
139
- return {
140
- signal,
141
- clear,
142
- guard: (stream) => stream.pipeThrough(new TransformStream({ flush: clear })),
143
- race(work) {
144
- if (signal.aborted)
145
- return Promise.reject(signal.reason);
146
- const { promise: aborted, reject } = Promise.withResolvers();
147
- const onAbort = () => reject(signal.reason);
148
- signal.addEventListener('abort', onAbort, { once: true });
149
- // `Promise.race` subscribes to `work` either way, so abandoning it can't surface as an
150
- // unhandled rejection. Detach on settle so a long-lived signal doesn't accumulate listeners.
151
- return Promise.race([work, aborted]).finally(() => signal.removeEventListener('abort', onAbort));
106
+ function releaseWhenDone(stream, done) {
107
+ return stream.pipeThrough(new TransformStream({
108
+ transform(chunk, controller) {
109
+ controller.enqueue(chunk);
152
110
  },
153
- };
111
+ flush: done,
112
+ cancel: done,
113
+ }));
154
114
  }
155
115
  /**
156
116
  * Builds the props a page component is called with.
157
117
  *
158
- * `url` and `params` match `useNavigation()` field for field, so a read can move between a page and
159
- * a `'use client'` component unchanged. The `URL` is this page's own — `Ctx` parses its own — so a
160
- * 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.
161
121
  *
162
- * `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:
163
123
  *
164
124
  * - **A getter**, so nothing is built for the pages that never read it, and so a `render: 'static'`
165
- * page that does read it gets {@link getContext}'s own "no per-request context while
166
- * 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`.
167
127
  * - **Non-enumerable**, so React's *development-only* serialization of a server component's props
168
128
  * (the debug channel behind component stacks and the performance track) skips it. That walks own
169
- * enumerable properties, and `ctx.raw` is the Hono {@link Context} — whose `env` holds the
170
- * runtime's bindings. An enumerable `ctx` ships every one of them, secrets included, to the
171
- * browser in dev, and grows a small page's flight payload by well over 10 kB. Production never
172
- * 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.
173
132
  *
174
- * The cost is that the element has to be created by handing this object to `jsx()` *by reference*:
175
- * 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.
176
135
  */
177
136
  function pageProps(c, errorInfo) {
178
137
  const props = { url: publicUrl(c), params: readParams(c), ...(errorInfo ? { error: errorInfo } : null) };
179
- Object.defineProperty(props, 'ctx', { get: getContext, enumerable: false, configurable: true });
138
+ Object.defineProperty(props, 'ctx', { get: getRequestContext, enumerable: false, configurable: true });
180
139
  return props;
181
140
  }
182
141
  async function renderComponent(c, Page, opts) {
183
- const deadline = opts.deadline ?? createRenderDeadline(c.req.raw.signal, renderTimeoutMs);
184
- const signal = deadline.signal;
185
- const nonce = cspEnabled && !opts.isRsc ? crypto.randomUUID() : undefined;
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).
147
+ //
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.
152
+ const requestSignal = c.req.raw.signal;
153
+ const renderAbort = new AbortController();
154
+ const signal = renderAbort.signal;
155
+ const forwardAbort = () => renderAbort.abort(requestSignal.reason);
156
+ if (requestSignal.aborted)
157
+ renderAbort.abort(requestSignal.reason);
158
+ else
159
+ requestSignal.addEventListener('abort', forwardAbort, { once: true });
160
+ /** Detaches the forwarder once the response has been written, so nothing survives the request. */
161
+ const release = () => requestSignal.removeEventListener('abort', forwardAbort);
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);
186
166
  const props = pageProps(c, opts.errorInfo);
187
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) })] }));
188
168
  // `notFound` only when it is true, so the flight payload of an ordinary page doesn't carry the key.
189
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);
190
175
  let controlSignal;
191
176
  const rscStream = renderToReadableStream(rscPayload, {
192
177
  temporaryReferences: opts.temporaryReferences,
@@ -201,7 +186,7 @@ async function renderComponent(c, Page, opts) {
201
186
  },
202
187
  });
203
188
  if (opts.isRsc) {
204
- return c.body(deadline.guard(rscStream), (opts.status ?? 200), {
189
+ return c.body(releaseWhenDone(rscStream, release), (opts.status ?? 200), {
205
190
  'content-type': 'text/x-component;charset=utf-8',
206
191
  });
207
192
  }
@@ -212,51 +197,38 @@ async function renderComponent(c, Page, opts) {
212
197
  formState: opts.formState,
213
198
  signal,
214
199
  nonce,
200
+ onDone: release,
215
201
  onShellError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR shell error:' }),
216
202
  onError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR error:' }),
217
203
  });
218
204
  }
219
205
  catch (error) {
220
- deadline.clear();
206
+ release();
221
207
  if (controlSignal)
222
208
  throw controlSignal;
223
209
  throw error;
224
210
  }
225
211
  if (controlSignal) {
226
- deadline.clear();
212
+ release();
227
213
  throw controlSignal;
228
214
  }
229
- const headers = { 'content-type': 'text/html;charset=utf-8' };
230
- if (nonce) {
231
- // The nonce is always appended to whatever `script-src` resolved to, so overriding the
232
- // directive in `cspDirectives` can widen the policy but can't accidentally drop the nonce.
233
- // Dev additionally needs 'unsafe-eval' for react-refresh.
234
- const scriptSrc = `script-src ${CSP_SCRIPT_SRC} 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`;
235
- headers['content-security-policy'] = CSP_STATIC ? `${CSP_STATIC}; ${scriptSrc}` : scriptSrc;
236
- }
237
- return c.body(deadline.guard(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
+ });
238
218
  }
239
219
  async function renderPage(c, loadPage) {
240
220
  const request = c.req.raw;
241
221
  const renderRequest = parseRenderRequest(request);
242
- // Created before the action runs so the deadline covers the whole request, then handed to
243
- // `renderComponent` so the render doesn't get a fresh budget of its own.
244
- const deadline = createRenderDeadline(request.signal, renderTimeoutMs);
245
222
  let returnValue;
246
223
  let formState;
247
224
  let temporaryReferences;
248
225
  let actionStatus;
249
226
  if (isActionRequest(renderRequest)) {
250
- if (!isSameOriginAction(c)) {
251
- deadline.clear();
252
- return c.text('Forbidden: cross-origin server action rejected', 403);
253
- }
254
227
  if (renderRequest.kind === 'rsc-action') {
255
228
  // Checked before the body is decoded, so an unknown id costs nothing to reject — and
256
229
  // `loadServerAction` would otherwise fault on the missing manifest entry, turning a bad
257
230
  // request into an unhandled 500. `hasOwn` so `__proto__` doesn't resolve to a manifest entry.
258
231
  if (!Object.hasOwn(__rspack_rsc_manifest__.serverManifest, renderRequest.actionId)) {
259
- deadline.clear();
260
232
  return c.text('Bad Request: unknown server action', 400);
261
233
  }
262
234
  const contentType = request.headers.get('content-type');
@@ -265,7 +237,7 @@ async function renderPage(c, loadPage) {
265
237
  const args = await decodeReply(body, { temporaryReferences });
266
238
  const action = loadServerAction(renderRequest.actionId);
267
239
  try {
268
- returnValue = { ok: true, value: await deadline.race(action.apply(null, args)) };
240
+ returnValue = { ok: true, value: await action.apply(null, args) };
269
241
  }
270
242
  catch (error) {
271
243
  if (isControlSignal(error))
@@ -281,7 +253,7 @@ async function renderPage(c, loadPage) {
281
253
  const formData = await request.formData();
282
254
  const decodedAction = await decodeAction(formData, __rspack_rsc_manifest__.serverManifest);
283
255
  if (decodedAction) {
284
- const result = await deadline.race(decodedAction());
256
+ const result = await decodedAction();
285
257
  formState = (await decodeFormState(result, formData, __rspack_rsc_manifest__.serverManifest)) ?? undefined;
286
258
  }
287
259
  }
@@ -293,20 +265,17 @@ async function renderPage(c, loadPage) {
293
265
  formState,
294
266
  returnValue,
295
267
  temporaryReferences,
296
- deadline,
297
268
  });
298
269
  }
299
270
  function buildApp() {
300
271
  const app = new Hono();
301
- // Outermost, so it wraps the finished response — headers and all — on the way out. A platform that
302
- // compresses for us (or cannot stream a compressor) offers none, and the setting has nothing to do.
303
- if (compressEnabled && runtime.compress)
304
- app.use(runtime.compress);
305
- // Cheap, unconditional headers that only matter when something else has gone wrong: stop
306
- // content-type sniffing, keep the full URL (paths, query) out of cross-origin referrers, and
307
- // refuse to be framed by another origin (clickjacking). `frame-ancestors` in the opt-in CSP is
308
- // stricter and takes precedence where both apply; this is the floor for everyone else.
309
- // 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.
310
279
  app.use(async (c, next) => {
311
280
  await next();
312
281
  const headers = c.res.headers;
@@ -316,31 +285,33 @@ function buildApp() {
316
285
  headers.set('referrer-policy', 'strict-origin-when-cross-origin');
317
286
  if (!headers.has('x-frame-options'))
318
287
  headers.set('x-frame-options', 'SAMEORIGIN');
319
- // Page responses only, from here down. Two things are true of them and of nothing else served
320
- // here: one URL answers with either an HTML document or a flight payload depending on `Accept`,
321
- // 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.
322
300
  if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
323
301
  return;
324
302
  appendVary(headers, 'Accept');
325
- // Without this a page carries no cache directives at all, and a shared cache — a CDN, a
326
- // corporate proxy — is free to store a logged-in user's page and hand it to someone else.
327
- // `private` forbids exactly that; `no-cache` makes the browser revalidate its own copy rather
328
- // than re-showing a stale personalised page. Neither blocks bfcache, which `no-store` would.
329
- // 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.
330
307
  if (!headers.has('cache-control'))
331
308
  headers.set('cache-control', 'private, no-cache');
332
309
  });
333
- // A memory-exhaustion guard for *every* route — pages and actions, `{ type: 'endpoint' }` routes
334
- // and the src/server.ts sub-app alike — since anything that buffers a body (`.json()`,
335
- // `.formData()`) is exposed, not just server actions. Rejects an over-cap `Content-Length` up
336
- // front and otherwise counts the stream, so a chunked or under-reported body is still cut off.
337
- if (maxBodyBytes > 0) {
338
- app.use(bodyLimit({ maxSize: maxBodyBytes, onError: (c) => c.text('Payload Too Large', 413) }));
339
- }
340
310
  runtime.mountStaticAssets(app);
341
- // Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash)
342
- // wraps page requests too. The flip side: a *terminal* handler in src/server.ts at the same path
343
- // 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.
344
315
  if (serverApp) {
345
316
  app.route('/', serverApp);
346
317
  }
@@ -348,10 +319,12 @@ function buildApp() {
348
319
  const loadNotFoundPage = routeConfig.notFound ? memoizePage(routeConfig.notFound, 'the notFound page') : null;
349
320
  /** Turns a thrown `redirect()` / `notFound()` into the response it stands for. */
350
321
  const respondToControlSignal = async (c, signal) => {
351
- const isRsc = wantsRsc(parseRenderRequest(c.req.raw));
322
+ const isRsc = requestWantsRsc(c.req.raw);
352
323
  if (signal instanceof RedirectSignal) {
353
324
  if (isRsc) {
354
- 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, {
355
328
  'content-type': 'text/x-component;charset=utf-8',
356
329
  });
357
330
  }
@@ -360,8 +333,7 @@ function buildApp() {
360
333
  if (loadNotFoundPage) {
361
334
  return renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
362
335
  }
363
- // Plain text, but still one of the two answers this URL gives depending on `Accept`.
364
- return c.text('Not Found', 404, { vary: 'Accept' });
336
+ return plainNotFound(c);
365
337
  };
366
338
  for (const route of routes) {
367
339
  if (isPageRoute(route)) {
@@ -372,18 +344,22 @@ function buildApp() {
372
344
  try {
373
345
  if (servesPrerendered && c.req.method === 'GET') {
374
346
  const isRsc = acceptsRsc(c.req.raw);
375
- // Both representations are prerendered, so a soft navigation is served from the build too
376
- // rather than re-rendering a page that was already built. The exception is the HTML
377
- // under `csp`, which has to be rendered per request to carry its nonce — the flight
378
- // payload never carries one, so it stays servable either way.
379
- if (!(cspEnabled && !isRsc)) {
347
+ // A prerendered file is one fixed set of bytes, so it cannot carry a per-request nonce:
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;
355
+ if (!mustRenderForNonce) {
380
356
  const page = await runtime.readPrerendered(c, isRsc ? 'flight' : 'html');
381
357
  // A prerendered page is request-independent by construction, so it is safe to cache
382
- // 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
383
359
  // revalidation that follows into a 304 rather than the page all over again.
384
360
  //
385
361
  // Answered outside `runWithContext`: no app code runs on this path, so there is no
386
- // `getContext()` to serve and no reason to pay for the AsyncLocalStorage scope.
362
+ // `getRequestContext()` to serve and no reason to pay for the AsyncLocalStorage scope.
387
363
  if (page !== null) {
388
364
  const headers = {
389
365
  'cache-control': SSG_CACHE_CONTROL,
@@ -422,18 +398,28 @@ function buildApp() {
422
398
  }
423
399
  runtime.mountPublicFallback(app);
424
400
  app.notFound(async (c) => {
425
- const isRsc = wantsRsc(parseRenderRequest(c.req.raw));
401
+ const isRsc = requestWantsRsc(c.req.raw);
426
402
  if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
427
403
  return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc }));
428
404
  }
429
- return c.text('Not Found', 404, { vary: 'Accept' });
405
+ return plainNotFound(c);
430
406
  });
431
407
  const loadErrorPage = routeConfig.error ? memoizePage(routeConfig.error, 'the error page') : null;
432
408
  app.onError(async (error, c) => {
433
409
  if (isControlSignal(error))
434
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
+ }
435
421
  reportServerError(error, { source: 'request', request: c.req.raw, message: '[rshono] request error:' });
436
- const isRsc = wantsRsc(parseRenderRequest(c.req.raw));
422
+ const isRsc = requestWantsRsc(c.req.raw);
437
423
  if (loadErrorPage && (isRsc || acceptsHtml(c))) {
438
424
  const errorInfo = isDev
439
425
  ? {