@rshono/core 1.0.0-rc.2 → 1.0.0-rc.21

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 (208) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -158
  3. package/bin/rshono.mjs +3 -4
  4. package/dist/builder/env-shadow-loader.cjs +123 -8
  5. package/dist/builder/page-entry-loader.cjs +28 -1
  6. package/dist/builder/page-files.d.ts +0 -1
  7. package/dist/builder/page-files.js +7 -3
  8. package/dist/builder/page-files.js.map +1 -1
  9. package/dist/builder/public-env.d.ts +7 -1
  10. package/dist/builder/public-env.js +7 -0
  11. package/dist/builder/public-env.js.map +1 -1
  12. package/dist/builder/react-versions.d.ts +12 -0
  13. package/dist/builder/react-versions.js +74 -0
  14. package/dist/builder/react-versions.js.map +1 -0
  15. package/dist/builder/rspack-config.d.ts +13 -4
  16. package/dist/builder/rspack-config.js +136 -53
  17. package/dist/builder/rspack-config.js.map +1 -1
  18. package/dist/builder/server-only-imports.d.ts +32 -0
  19. package/dist/builder/server-only-imports.js +51 -0
  20. package/dist/builder/server-only-imports.js.map +1 -0
  21. package/dist/cli/build.d.ts +2 -3
  22. package/dist/cli/build.js +51 -13
  23. package/dist/cli/build.js.map +1 -1
  24. package/dist/cli/dev.d.ts +2 -3
  25. package/dist/cli/dev.js +149 -45
  26. package/dist/cli/dev.js.map +1 -1
  27. package/dist/cli/exit.d.ts +12 -0
  28. package/dist/cli/exit.js +16 -0
  29. package/dist/cli/exit.js.map +1 -0
  30. package/dist/cli/index.d.ts +0 -1
  31. package/dist/cli/index.js +91 -30
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/start.d.ts +0 -1
  34. package/dist/cli/start.js +15 -18
  35. package/dist/cli/start.js.map +1 -1
  36. package/dist/config.d.ts +72 -78
  37. package/dist/config.js +17 -1
  38. package/dist/config.js.map +1 -1
  39. package/dist/deploy/aws-lambda/runtime.d.ts +4 -7
  40. package/dist/deploy/aws-lambda/runtime.js +5 -8
  41. package/dist/deploy/aws-lambda/runtime.js.map +1 -1
  42. package/dist/deploy/build-marker.d.ts +9 -6
  43. package/dist/deploy/build-marker.js +8 -4
  44. package/dist/deploy/build-marker.js.map +1 -1
  45. package/dist/deploy/cloudflare/build.d.ts +0 -1
  46. package/dist/deploy/cloudflare/build.js +17 -13
  47. package/dist/deploy/cloudflare/build.js.map +1 -1
  48. package/dist/deploy/cloudflare/runtime.d.ts +2 -6
  49. package/dist/deploy/cloudflare/runtime.js +73 -68
  50. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  51. package/dist/deploy/contract.d.ts +57 -41
  52. package/dist/deploy/contract.js.map +1 -1
  53. package/dist/deploy/filesystem.d.ts +3 -6
  54. package/dist/deploy/filesystem.js +23 -20
  55. package/dist/deploy/filesystem.js.map +1 -1
  56. package/dist/deploy/node/runtime.d.ts +5 -3
  57. package/dist/deploy/node/runtime.js +23 -9
  58. package/dist/deploy/node/runtime.js.map +1 -1
  59. package/dist/deploy/presets.d.ts +34 -30
  60. package/dist/deploy/presets.js +56 -44
  61. package/dist/deploy/presets.js.map +1 -1
  62. package/dist/deploy/public-paths.d.ts +42 -0
  63. package/dist/deploy/public-paths.js +66 -0
  64. package/dist/deploy/public-paths.js.map +1 -0
  65. package/dist/deploy/vercel/build.d.ts +0 -1
  66. package/dist/deploy/vercel/build.js +25 -16
  67. package/dist/deploy/vercel/build.js.map +1 -1
  68. package/dist/deploy/vercel/runtime.d.ts +10 -7
  69. package/dist/deploy/vercel/runtime.js +48 -11
  70. package/dist/deploy/vercel/runtime.js.map +1 -1
  71. package/dist/index.d.ts +19 -17
  72. package/dist/index.js +20 -10
  73. package/dist/index.js.map +1 -1
  74. package/dist/router.d.ts +212 -120
  75. package/dist/router.js +3 -8
  76. package/dist/router.js.map +1 -1
  77. package/dist/runtime/boundaries.d.ts +62 -48
  78. package/dist/runtime/boundaries.js +32 -34
  79. package/dist/runtime/boundaries.js.map +1 -1
  80. package/dist/runtime/client.d.ts +20 -11
  81. package/dist/runtime/client.js +20 -10
  82. package/dist/runtime/client.js.map +1 -1
  83. package/dist/runtime/context.d.ts +304 -123
  84. package/dist/runtime/context.js +377 -149
  85. package/dist/runtime/context.js.map +1 -1
  86. package/dist/runtime/control.d.ts +15 -1
  87. package/dist/runtime/control.js +24 -0
  88. package/dist/runtime/control.js.map +1 -1
  89. package/dist/runtime/dev-protocol.d.ts +4 -9
  90. package/dist/runtime/dev-protocol.js.map +1 -1
  91. package/dist/runtime/empty-server-app.d.ts +0 -1
  92. package/dist/runtime/entry.client.d.ts +4 -1
  93. package/dist/runtime/entry.client.js +455 -313
  94. package/dist/runtime/entry.client.js.map +1 -1
  95. package/dist/runtime/entry.rsc.d.ts +15 -7
  96. package/dist/runtime/entry.rsc.js +811 -252
  97. package/dist/runtime/entry.rsc.js.map +1 -1
  98. package/dist/runtime/entry.ssr.d.ts +27 -10
  99. package/dist/runtime/entry.ssr.js +30 -48
  100. package/dist/runtime/entry.ssr.js.map +1 -1
  101. package/dist/runtime/failure-document.d.ts +11 -0
  102. package/dist/runtime/failure-document.js +35 -0
  103. package/dist/runtime/failure-document.js.map +1 -0
  104. package/dist/runtime/flight-inject.d.ts +23 -0
  105. package/dist/runtime/flight-inject.js +403 -0
  106. package/dist/runtime/flight-inject.js.map +1 -0
  107. package/dist/runtime/hot-update.d.ts +44 -0
  108. package/dist/runtime/hot-update.js +44 -0
  109. package/dist/runtime/hot-update.js.map +1 -0
  110. package/dist/runtime/navigation.d.ts +46 -54
  111. package/dist/runtime/navigation.js +22 -63
  112. package/dist/runtime/navigation.js.map +1 -1
  113. package/dist/runtime/request.d.ts +42 -8
  114. package/dist/runtime/request.js +84 -7
  115. package/dist/runtime/request.js.map +1 -1
  116. package/dist/runtime/server.d.ts +20 -18
  117. package/dist/runtime/server.js +22 -17
  118. package/dist/runtime/server.js.map +1 -1
  119. package/dist/runtime/validate-entries.d.ts +77 -0
  120. package/dist/runtime/validate-entries.js +405 -0
  121. package/dist/runtime/validate-entries.js.map +1 -0
  122. package/dist/server/headers.d.ts +41 -15
  123. package/dist/server/headers.js +53 -24
  124. package/dist/server/headers.js.map +1 -1
  125. package/dist/server/load-config.d.ts +4 -5
  126. package/dist/server/load-config.js +22 -14
  127. package/dist/server/load-config.js.map +1 -1
  128. package/dist/server/load-env.d.ts +0 -1
  129. package/dist/server/prerendered.d.ts +88 -34
  130. package/dist/server/prerendered.js +184 -24
  131. package/dist/server/prerendered.js.map +1 -1
  132. package/dist/server/server-config.d.ts +35 -50
  133. package/dist/server/server-config.js +27 -74
  134. package/dist/server/server-config.js.map +1 -1
  135. package/dist/server/shutdown.d.ts +5 -4
  136. package/dist/server/shutdown.js +5 -3
  137. package/dist/server/shutdown.js.map +1 -1
  138. package/dist/server/ssg.d.ts +9 -7
  139. package/dist/server/ssg.js +209 -65
  140. package/dist/server/ssg.js.map +1 -1
  141. package/dist/server/static.d.ts +0 -1
  142. package/dist/server/static.js +7 -1
  143. package/dist/server/static.js.map +1 -1
  144. package/package.json +21 -23
  145. package/dist/builder/page-files.d.ts.map +0 -1
  146. package/dist/builder/public-env.d.ts.map +0 -1
  147. package/dist/builder/rspack-config.d.ts.map +0 -1
  148. package/dist/cli/build.d.ts.map +0 -1
  149. package/dist/cli/dev.d.ts.map +0 -1
  150. package/dist/cli/index.d.ts.map +0 -1
  151. package/dist/cli/start.d.ts.map +0 -1
  152. package/dist/config.d.ts.map +0 -1
  153. package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
  154. package/dist/deploy/build-marker.d.ts.map +0 -1
  155. package/dist/deploy/bun/runtime.d.ts +0 -11
  156. package/dist/deploy/bun/runtime.d.ts.map +0 -1
  157. package/dist/deploy/bun/runtime.js +0 -22
  158. package/dist/deploy/bun/runtime.js.map +0 -1
  159. package/dist/deploy/cloudflare/build.d.ts.map +0 -1
  160. package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
  161. package/dist/deploy/contract.d.ts.map +0 -1
  162. package/dist/deploy/deno/runtime.d.ts +0 -11
  163. package/dist/deploy/deno/runtime.d.ts.map +0 -1
  164. package/dist/deploy/deno/runtime.js +0 -16
  165. package/dist/deploy/deno/runtime.js.map +0 -1
  166. package/dist/deploy/filesystem.d.ts.map +0 -1
  167. package/dist/deploy/listen.d.ts +0 -20
  168. package/dist/deploy/listen.d.ts.map +0 -1
  169. package/dist/deploy/listen.js +0 -24
  170. package/dist/deploy/listen.js.map +0 -1
  171. package/dist/deploy/netlify/build.d.ts +0 -8
  172. package/dist/deploy/netlify/build.d.ts.map +0 -1
  173. package/dist/deploy/netlify/build.js +0 -52
  174. package/dist/deploy/netlify/build.js.map +0 -1
  175. package/dist/deploy/netlify/runtime.d.ts +0 -13
  176. package/dist/deploy/netlify/runtime.d.ts.map +0 -1
  177. package/dist/deploy/netlify/runtime.js +0 -24
  178. package/dist/deploy/netlify/runtime.js.map +0 -1
  179. package/dist/deploy/node/runtime.d.ts.map +0 -1
  180. package/dist/deploy/presets.d.ts.map +0 -1
  181. package/dist/deploy/vercel/build.d.ts.map +0 -1
  182. package/dist/deploy/vercel/runtime.d.ts.map +0 -1
  183. package/dist/index.d.ts.map +0 -1
  184. package/dist/router.d.ts.map +0 -1
  185. package/dist/runtime/boundaries.d.ts.map +0 -1
  186. package/dist/runtime/client.d.ts.map +0 -1
  187. package/dist/runtime/context.d.ts.map +0 -1
  188. package/dist/runtime/control.d.ts.map +0 -1
  189. package/dist/runtime/dev-protocol.d.ts.map +0 -1
  190. package/dist/runtime/empty-server-app.d.ts.map +0 -1
  191. package/dist/runtime/entry.client.d.ts.map +0 -1
  192. package/dist/runtime/entry.rsc.d.ts.map +0 -1
  193. package/dist/runtime/entry.ssr.d.ts.map +0 -1
  194. package/dist/runtime/navigation.d.ts.map +0 -1
  195. package/dist/runtime/request.d.ts.map +0 -1
  196. package/dist/runtime/server.d.ts.map +0 -1
  197. package/dist/server/compress.d.ts +0 -15
  198. package/dist/server/compress.d.ts.map +0 -1
  199. package/dist/server/compress.js +0 -76
  200. package/dist/server/compress.js.map +0 -1
  201. package/dist/server/headers.d.ts.map +0 -1
  202. package/dist/server/load-config.d.ts.map +0 -1
  203. package/dist/server/load-env.d.ts.map +0 -1
  204. package/dist/server/prerendered.d.ts.map +0 -1
  205. package/dist/server/server-config.d.ts.map +0 -1
  206. package/dist/server/shutdown.d.ts.map +0 -1
  207. package/dist/server/ssg.d.ts.map +0 -1
  208. package/dist/server/static.d.ts.map +0 -1
@@ -1,51 +1,59 @@
1
+ // `__RSHONO_CONFIG__` is a global const, and an `import` cannot bring one into scope — a path reference is
2
+ // the only way to reach it, which is the whole reason that file is separate. See its header.
3
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
1
4
  /// <reference path="../types/rshono-config.d.ts" />
2
5
  /**
3
- * The request context: {@link getContext} and the {@link Ctx} wrapper it returns,
4
- * the {@link redirect} / {@link notFound} control-flow helpers, and the
5
- * {@link onServerError} reporting funnel — plus the `@internal` plumbing that binds
6
- * a request to the async context in the first place.
7
- *
8
- * The public half of this module is re-exported by `runtime/server.ts`, which is
9
- * what the `@rshono/core/server` subpath resolves to; import *that* from an app. Nothing
10
- * here is safe in a `'use client'` module — those run in the browser, with no bound
11
- * request context.
6
+ * The request context: {@link getRequestContext} and the {@link RequestContext} it returns, the
7
+ * {@link redirect} / {@link notFound} control-flow helpers, and the {@link onServerError} reporting
8
+ * funnel — plus the `@internal` plumbing that binds a request to the async context.
9
+ *
10
+ * The public half is re-exported by `runtime/server.ts`, which is what `@rshono/core/server` resolves
11
+ * to; an app imports that.
12
12
  */
13
13
  import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
14
14
  import { AsyncLocalStorage } from 'node:async_hooks';
15
15
  import { NotFoundSignal, RedirectSignal } from './control.js';
16
16
  const contextStorage = new AsyncLocalStorage();
17
- /** One {@link Ctx} per Hono {@link Context}, so repeated `getContext()` calls in a request share its lazy getters. */
17
+ /** One {@link RequestContext} per Hono {@link Context}, so repeated `getRequestContext()` calls share its lazy getters. */
18
18
  const wrappers = new WeakMap();
19
+ // Keyed on the Hono context rather than held as a field, so marking a request never forces the lazily
20
+ // built `RequestContext` wrapper into existence.
21
+ const rendering = new WeakSet();
19
22
  /**
20
- * `process.env`, snapshotted on first read.
23
+ * Marks the request as having entered its page render, which is what makes
24
+ * {@link RequestContext.setHeader} and `ctx.cookies.set()` start throwing.
21
25
  *
22
- * It is not a plain object — every enumeration crosses into the host environment, which made
23
- * spreading it (~20µs) by far the most expensive thing {@link Ctx.env} did, once per request that
24
- * touched it. Snapshotted lazily rather than at module load because `loadEnvFiles()` runs *after*
25
- * this module is imported, so an eager copy would miss everything from `.env`. The trade-off: a
26
- * `process.env` mutation after the first `ctx.env` read is not picked up.
26
+ * @internal
27
27
  */
28
+ export function beginPageRender(c) {
29
+ rendering.add(c);
30
+ }
31
+ /** The shared refusal for a response write that arrived too late — the message names where it belongs instead. */
32
+ function tooLateToWrite(call) {
33
+ throw new Error(`[rshono] ${call} was called while rendering a page, which is too late to affect the response. ` +
34
+ 'A page streams, so its response head is already committed by the time the component runs — the ' +
35
+ 'write would land on a full page load and be silently dropped on a soft navigation. Do it from a ' +
36
+ "'use server' action instead; or, in middleware and { type: 'endpoint' } routes — which are handed " +
37
+ "Hono's `c` directly and run outside the request context — with `c.header(…)` / `setCookie(c, …)`.");
38
+ }
39
+ /** The shared refusal for a Hono `Context` member a page has no way to use. See the stubs on {@link RequestContext}. */
40
+ function notOnContext(call, instead) {
41
+ throw new Error(`[rshono] ctx.${call} does not exist. A page returns JSX and the framework builds the response from it, ` +
42
+ `so Hono's response builders have nothing to return to. ${instead}`);
43
+ }
44
+ // Snapshotted rather than spread per request: enumerating `process.env` crosses into the host
45
+ // environment (~20µs). Lazily, because `loadEnvFiles()` runs after this module is imported — so a
46
+ // mutation after the first `ctx.env` read is not picked up.
28
47
  let envSnapshot;
29
48
  function processEnv() {
30
49
  return (envSnapshot ??= typeof process !== 'undefined' && process.env ? { ...process.env } : {});
31
50
  }
32
- /**
33
- * True when this process is the SSG build prerendering `render: 'static'` routes,
34
- * rather than a server handling real requests. `build.ts` sets `RSHONO_PRERENDER`
35
- * before importing the app bundle and starting the prerender pass; the app bundle
36
- * inlines its own copy of this module, so a shared `process.env` (not a module-level
37
- * flag) is what reliably crosses that boundary. Read by {@link getContext} to turn a
38
- * static route's request-context read into a clear build-time error instead of
39
- * silently baking synthetic build-time values (a `localhost` URL, no cookies, build
40
- * env) into the snapshot.
41
- */
51
+ // Set by `build.ts` before it imports the app bundle, which inlines its own copy of this module — so
52
+ // `process.env` is what crosses that boundary rather than a module-level flag.
42
53
  const prerendering = typeof process !== 'undefined' && !!process.env?.RSHONO_PRERENDER;
43
54
  /**
44
- * Runs `fn` with the given Hono {@link Context} bound as the ambient request
45
- * context, so that {@link getContext} resolves to it anywhere in the call tree.
46
- *
47
- * Framework internal — the request handler wraps every render and action in
48
- * this. Application code should reach for {@link getContext} instead.
55
+ * Runs `fn` with `c` bound as the ambient request context, so {@link getRequestContext} resolves to it
56
+ * anywhere in the call tree.
49
57
  *
50
58
  * @internal
51
59
  */
@@ -53,12 +61,7 @@ export function runWithContext(c, fn) {
53
61
  return contextStorage.run(c, fn);
54
62
  }
55
63
  /**
56
- * Reads the matched route params, returning an empty object when there is no
57
- * active route match (rather than throwing). Shared by {@link Ctx.params} and the
58
- * request renderer so the fallback behaviour stays in one place.
59
- *
60
- * Framework internal — read params from {@link Ctx.params} or a page's
61
- * `PageProps` instead.
64
+ * The matched route params, or an empty object when there is no active match.
62
65
  *
63
66
  * @internal
64
67
  */
@@ -75,172 +78,339 @@ function firstForwardedValue(header) {
75
78
  const first = header?.split(',')[0]?.trim();
76
79
  return first || undefined;
77
80
  }
78
- // DefinePlugin inlines the config into the server bundle, but this module is the public
79
- // `@rshono/core/server` entry and could be loaded by tooling that doesn't (a unit test, a one-off script).
80
- // Read through `typeof` so that degrades to the safe answer — don't trust — instead of a ReferenceError.
81
+ // Read through `typeof`: DefinePlugin inlines this into the server bundle, but the module is also the
82
+ // public `@rshono/core/server` entry, which tooling can load without one. Absent means don't trust.
81
83
  const trustProxy = typeof __RSHONO_CONFIG__ !== 'undefined' && __RSHONO_CONFIG__.trustProxy;
84
+ // Read the same way, for the same reason. Absent means the platform passes no bindings — see the getter.
85
+ const envBindings = typeof __RSHONO_CONFIG__ !== 'undefined' && __RSHONO_CONFIG__.envBindings;
82
86
  /**
83
- * Resolves the browser-facing {@link URL} for a request.
87
+ * The browser-facing {@link URL} for a request, resolved from Hono's {@link Context} — a fresh
88
+ * instance per call.
84
89
  *
85
- * `c.req.url` reflects the internal address the server was reached on, which is wrong behind a
86
- * proxy or load balancer. `X-Forwarded-Host` / `X-Forwarded-Proto` fix that up — **but only when
87
- * `trustProxy` is enabled in `rshono.config.ts`** (always the case under `rshono dev`). Those
88
- * headers are client-supplied: honouring them unconditionally lets anyone who can reach the server
89
- * dictate the origin of every absolute URL the app builds — canonical tags, emails, redirects — and
90
- * poison a shared cache with them. So the default is to ignore them entirely.
90
+ * `c.req.url` is the internal address the server was reached on, which is wrong behind a proxy;
91
+ * `X-Forwarded-Host` / `-Proto` correct it, but only when `trustProxy` is enabled in
92
+ * `rshono.config.ts` they are client-supplied, so trusting them unconditionally would let anyone
93
+ * dictate the origin of every absolute URL the app builds.
91
94
  *
92
- * Framework internal prefer {@link Ctx.url}, which caches the result per request.
95
+ * This is the form for **middleware**, which is handed `c` and runs outside the request context — and
96
+ * so the way to give Hono's own middleware the origin the browser actually used. In a server component
97
+ * or action, prefer {@link RequestContext.url}, the same value cached per request.
93
98
  *
94
- * @internal
99
+ * @param c - The Hono {@link Context} for the request.
100
+ * @returns The browser-facing URL — proxy-corrected under `trustProxy`, `c.req.url` otherwise.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * // src/server.ts — a CSRF check that still works behind a proxy that rewrites Host
105
+ * import { publicUrl } from '@rshono/core/server';
106
+ * import { csrf } from 'hono/csrf';
107
+ *
108
+ * server.use(csrf({ origin: (origin, c) => origin === publicUrl(c).origin }));
109
+ * ```
110
+ *
111
+ * @see {@link https://www.rshono.com/docs/configuration#proxy-headers | Docs — proxy headers}
95
112
  */
96
113
  export function publicUrl(c) {
97
114
  const url = new URL(c.req.url);
98
115
  if (!trustProxy)
99
116
  return url;
100
117
  const forwardedHost = firstForwardedValue(c.req.header('x-forwarded-host'));
101
- // Parsed rather than assigned to `url.host`, because that setter *keeps the existing port* when
102
- // the new value has none — leaving the internal port on the public URL (`example.com:3000`).
118
+ // Parsed, not assigned to `url.host`: that setter keeps the existing port when the new value has none.
103
119
  const forwarded = forwardedHost ? URL.parse(`http://${forwardedHost}`) : null;
104
120
  if (forwarded) {
105
121
  url.hostname = forwarded.hostname;
106
- url.port = forwarded.port; // '' when the forwarded host carries no port, which clears it
122
+ url.port = forwarded.port;
107
123
  }
108
- // Restricted to the two schemes a browser can actually have requested; anything else (a proxy
109
- // sending junk, or a client trying its luck) leaves the scheme alone.
124
+ // Only the two schemes a browser could have requested; anything else leaves the scheme alone.
110
125
  const forwardedProto = firstForwardedValue(c.req.header('x-forwarded-proto'));
111
126
  if (forwardedProto === 'http' || forwardedProto === 'https')
112
127
  url.protocol = forwardedProto;
113
128
  return url;
114
129
  }
115
130
  /**
116
- * Ergonomic, read-mostly wrapper around Hono's {@link Context} for use inside
117
- * server components and server actions.
131
+ * Read-mostly wrapper around Hono's {@link Context}, for server components and server actions.
132
+ *
133
+ * Obtain one with {@link getRequestContext}, or take it off a page's `ctx` prop — the same object.
134
+ * Never construct it yourself. One instance is reused for the whole request, so its lazy getters
135
+ * ({@link RequestContext.url}, {@link RequestContext.env}) are computed at most once.
118
136
  *
119
- * Obtain one with {@link getContext}, orin a page component take it straight
120
- * off the `ctx` prop, which is this same object. Never construct it yourself. One
121
- * instance is reused for the lifetime of a request, so its lazy getters
122
- * ({@link Ctx.url}, {@link Ctx.env}) are computed at most once.
137
+ * The eight members that only throw`redirect`, `notFound`, `json`, `text`, `html`, `body`, `status`,
138
+ * `header` — are **permanent, and exist to throw**. Every one of them is a silent no-op when reached through
139
+ * {@link RequestContext.hono} from a page, so a stub that names the thing that does work is the difference
140
+ * between a message and a page that renders while quietly ignoring half of what it was asked for. They carry
141
+ * `@deprecated` for the strike-through an editor draws with it, not because they are on the way out: nothing
142
+ * will un-deprecate or remove them, and dropping them would leave `ctx.redirect('/x')` as
143
+ * "property does not exist", which says what is wrong and not what to do.
123
144
  *
124
- * @typeParam E - The Hono {@link Env} describing this app's `Bindings` and
125
- * `Variables`, so {@link Ctx.var} and {@link Ctx.env} stay typed.
145
+ * @typeParam E - The Hono {@link Env} describing this app's `Bindings` and `Variables`, so
146
+ * {@link RequestContext.var} and {@link RequestContext.env} stay typed.
126
147
  *
127
148
  * @example
128
149
  * ```tsx
129
- * import { getContext } from '@rshono/core/server';
150
+ * import { getRequestContext } from '@rshono/core/server';
130
151
  *
131
152
  * export default async function Whoami() {
132
- * const ctx = getContext();
153
+ * const ctx = getRequestContext();
133
154
  * const session = ctx.cookies.get('session');
134
155
  * return <p>{ctx.url.pathname} — {session ?? 'anonymous'}</p>;
135
156
  * }
136
157
  * ```
158
+ *
159
+ * @see {@link https://www.rshono.com/docs/api#rshonocoreserver | Docs — `@rshono/core/server`}
160
+ * @see {@link https://hono.dev/docs/api/context | Hono — Context}, reachable in full via {@link RequestContext.hono}
137
161
  */
138
- export class Ctx {
162
+ export class RequestContext {
139
163
  #raw;
140
164
  #url;
141
165
  #env;
166
+ #params;
167
+ /**
168
+ * One instance is created per request and handed out by {@link getRequestContext} or the `ctx` page
169
+ * prop. Application code never calls this.
170
+ *
171
+ * @internal
172
+ */
142
173
  constructor(c) {
143
174
  this.#raw = c;
144
175
  }
145
176
  /**
146
- * The underlying Hono {@link Context}. Escape hatch for anything this wrapper does not expose.
177
+ * The underlying Hono {@link Context} the escape hatch for what this wrapper does not expose, such
178
+ * as `executionCtx.waitUntil()` on Workers.
147
179
  *
148
- * A getter over a private field rather than a plain property, so it is not an *own enumerable*
149
- * one which matters more than it looks. React's diagnostic for a value that cannot be sent to a
150
- * client component (`describeObjectForErrorMessage`) walks `Object.keys` recursively with no depth
151
- * limit and no cycle guard, and the Hono context graph reaches the socket and the whole server
152
- * through `req.raw` and `env`. While this was a plain property, passing a `Ctx` to a `'use client'`
153
- * component blew the stack *inside that message builder* — so React's actual, accurate "you cannot
154
- * pass this" error never got printed. Hidden from `Object.keys`, the walk stops here.
180
+ * Its response builders (`redirect`, `json`, `body`, `status`, …) still do nothing from inside a
181
+ * page: reaching them through here bypasses the errors the stubs on this class throw, it does not
182
+ * make them work.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * getRequestContext().hono.executionCtx.waitUntil(logAsync()); // Workers
187
+ * ```
188
+ *
189
+ * @see {@link https://hono.dev/docs/api/context | Hono — Context}
155
190
  */
156
- get raw() {
191
+ // Every member here is a getter or method so that none is *own enumerable*: React's "you cannot pass
192
+ // this to a client component" diagnostic walks `Object.keys` recursively with no cycle guard, and the
193
+ // Hono context graph reaches the socket through `req.raw`. `cookies` is the one own property, and it
194
+ // is a shallow object of four functions.
195
+ get hono() {
157
196
  return this.#raw;
158
197
  }
159
- /** The parsed Hono request (`c.req`) — headers, body parsing, param access, etc. */
198
+ /**
199
+ * The parsed request — method, headers, path params, query and the body readers. Hono's
200
+ * {@link Context.req}, unwrapped, so `ctx.req.header('authorization')` rather than
201
+ * `ctx.hono.req.header(…)`.
202
+ *
203
+ * Reads only; setting a *response* header is {@link RequestContext.setHeader}, deliberately spelled
204
+ * differently.
205
+ *
206
+ * @example
207
+ * ```ts
208
+ * const ctx = getRequestContext();
209
+ * ctx.req.method; // 'GET'
210
+ * ctx.req.header('authorization'); // string | undefined
211
+ * ctx.req.query('tab'); // string | undefined
212
+ * ```
213
+ *
214
+ * @see {@link https://hono.dev/docs/api/request | Hono — HonoRequest}
215
+ */
160
216
  get req() {
161
217
  return this.#raw.req;
162
218
  }
163
219
  /**
164
- * The browser-facing request URL, proxy-header aware (see {@link publicUrl})
165
- * read `url.pathname`, `url.searchParams` and the rest off it. Parsed once and
166
- * cached, so the same instance comes back on every read within a request; treat
167
- * it as read-only for that reason.
220
+ * Matched route params for this request, e.g. `{ id: '42' }` for `/profile/:id`. Empty when no route
221
+ * matched.
222
+ *
223
+ * A page is handed the same record as its `params` prop, typed key-by-key from its route path, and
224
+ * that is the better read where it exists. This is for everywhere else — a nested server component,
225
+ * or a `'use server'` action.
168
226
  */
169
- get url() {
170
- return (this.#url ??= publicUrl(this.#raw));
171
- }
172
- /** The HTTP method of the request, e.g. `GET` or `POST`. */
173
- get method() {
174
- return this.#raw.req.method;
227
+ get params() {
228
+ return (this.#params ??= readParams(this.#raw));
175
229
  }
176
230
  /**
177
- * Matched route params, e.g. `{ id }` for a `/users/[id]` route. Returns an
178
- * empty object when there is no active route match (rather than throwing).
231
+ * The browser-facing request URL. Parsed once and cached, so every read within a request returns the
232
+ * same instance treat it as read-only.
233
+ *
234
+ * `X-Forwarded-Host` / `-Proto` are honoured only when `trustProxy` is enabled in
235
+ * `rshono.config.ts`, since any client can send them.
236
+ *
237
+ * @example `const tab = getRequestContext().url.searchParams.get('tab');`
238
+ *
239
+ * @see {@link https://www.rshono.com/docs/configuration#proxy-headers | Docs — proxy headers}
179
240
  */
180
- get params() {
181
- return readParams(this.#raw);
241
+ get url() {
242
+ return (this.#url ??= publicUrl(this.#raw));
182
243
  }
183
244
  /**
184
- * Typed variables set by middleware via `c.set('user', …)`, read here as
185
- * `ctx.var.user`. Type them by parameterising this class's {@link Env}.
245
+ * Typed variables set by middleware via `c.set('user', …)`, read here as `ctx.var.user`. Type them by
246
+ * parameterising this class's {@link Env}.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * type AppEnv = { Variables: { user: { id: string } } };
251
+ * const { user } = getRequestContext<AppEnv>().var; // typed, set by your middleware
252
+ * ```
253
+ *
254
+ * @see {@link https://hono.dev/docs/api/context#var | Hono — c.var}
255
+ * @see {@link https://www.rshono.com/docs/hono#typing-the-context | Docs — typing the context}
186
256
  */
187
257
  get var() {
188
258
  return this.#raw.var;
189
259
  }
190
260
  /**
191
- * Environment for the request: process env vars merged with runtime bindings
192
- * (bindings win on conflict). Computed once and cached.
261
+ * Environment for the request: process env vars, merged on a bindings platform with the bindings, which
262
+ * win on conflict.
263
+ *
264
+ * The `process.env` half is snapshotted **once per process**, not per request — enumerating it crosses
265
+ * into the host environment, and doing that on every request is a cost with nothing to show for it. So a
266
+ * `process.env` mutation made after the first `ctx.env` read anywhere in the process is never seen here.
267
+ * Read `process.env` directly if you have one. The bindings half is per request, since it comes off the
268
+ * request's own Hono context.
269
+ *
270
+ * Bindings are merged **only where the platform supplies them** — `deploy: 'cloudflare'`, today. Hono's
271
+ * `c.env` is whatever the host passed as the second argument to `app.fetch`, and off Workers that is the
272
+ * adapter's own private state: `{ incoming, outgoing }` on Node and Vercel, the entire invocation —
273
+ * headers, cookies, `authorization` — on Lambda. Merging it would make `ctx.env` uncloneable on one and
274
+ * a disclosure vector on the other, both behind names this type declares `string | undefined`. Reach for
275
+ * {@link RequestContext.hono}`.env` if you really do want the adapter's argument.
276
+ *
277
+ * @example `const key = getRequestContext().env.STRIPE_SECRET_KEY;`
193
278
  *
194
- * @example `const key = getContext().env.STRIPE_SECRET_KEY;`
279
+ * @see {@link https://hono.dev/docs/api/context#env | Hono — c.env}
280
+ * @see {@link https://www.rshono.com/docs/configuration#environment-and-secrets | Docs — environment and secrets}
195
281
  */
196
282
  get env() {
197
283
  if (this.#env)
198
284
  return this.#env;
199
- const bindings = this.#raw.env;
200
- // The snapshot is shared, so hand it back as-is when there are no bindings to merge over it.
285
+ const bindings = envBindings ? this.#raw.env : undefined;
201
286
  return (this.#env = (bindings ? { ...processEnv(), ...bindings } : processEnv()));
202
287
  }
203
- /** Sets a response header. Thin pass-through to `c.header(name, value)`. */
204
- header(name, value) {
205
- this.#raw.header(name, value);
206
- }
207
288
  /**
208
289
  * Read and write request/response cookies.
209
290
  *
210
291
  * @example
211
292
  * ```ts
212
- * const ctx = getContext();
293
+ * const ctx = getRequestContext();
213
294
  * ctx.cookies.get('session'); // string | undefined
214
295
  * ctx.cookies.set('session', id, { httpOnly: true, sameSite: 'Lax', path: '/' });
215
296
  * ctx.cookies.delete('session', { path: '/' });
216
297
  * ```
298
+ *
299
+ * @see {@link https://hono.dev/docs/helpers/cookie | Hono — cookie helper}, which this wraps
217
300
  */
218
301
  cookies = {
219
- /** Reads a single cookie by name, or `undefined` if absent. */
302
+ /** Reads a single cookie by name, or `undefined` if absent. Safe anywhere, a page included. */
220
303
  get: (name) => getCookie(this.#raw, name),
221
- /** Reads every cookie as a `{ name: value }` record. */
304
+ /** Reads every cookie as a `{ name: value }` record. Safe anywhere, a page included. */
222
305
  all: () => getCookie(this.#raw),
223
- /** Sets a cookie on the response. See Hono's {@link CookieOptions} for `path`, `httpOnly`, `maxAge`, etc. */
224
- set: (name, value, options) => setCookie(this.#raw, name, value, options),
225
- /** Clears a cookie. Pass the same `path`/`domain` it was set with so the browser matches it. */
306
+ /**
307
+ * Sets a cookie on the response. See Hono's {@link CookieOptions} for `path`, `httpOnly`, `maxAge`
308
+ * and the rest.
309
+ *
310
+ * **Throws inside a page render** — a `Set-Cookie` is a special case of
311
+ * {@link RequestContext.setHeader}. Set cookies from a `'use server'` action, or with Hono's
312
+ * `setCookie(c, …)` in middleware and endpoint routes.
313
+ *
314
+ * @throws If called while a page is rendering, where it could not reach the browser reliably.
315
+ *
316
+ * @see {@link https://hono.dev/docs/helpers/cookie#options | Hono — cookie options}
317
+ */
318
+ set: (name, value, options) => {
319
+ this.#assertWritable('ctx.cookies.set()');
320
+ setCookie(this.#raw, name, value, options);
321
+ },
322
+ /**
323
+ * Clears a cookie. Pass the same `path`/`domain` it was set with so the browser matches it.
324
+ * Throws inside a page render, exactly as `set` does.
325
+ *
326
+ * @throws If called while a page is rendering.
327
+ */
226
328
  delete: (name, options) => {
329
+ this.#assertWritable('ctx.cookies.delete()');
227
330
  deleteCookie(this.#raw, name, options);
228
331
  },
229
332
  };
333
+ #assertWritable(call) {
334
+ if (rendering.has(this.#raw))
335
+ tooLateToWrite(call);
336
+ }
337
+ /**
338
+ * Sets a header on the response — from a `'use server'` action, which is the one place a request
339
+ * context exists *and* the response is still open.
340
+ *
341
+ * From inside a page it throws: a page streams, so its response head is already committed by then,
342
+ * and the write would land on a full page load but vanish on a soft navigation.
343
+ *
344
+ * Middleware and `{ type: 'endpoint' }` routes are handed Hono's `c` directly and use `c.header(…)`.
345
+ * That is also where a header belonging to the *page* goes — `Cache-Control`, `X-Robots-Tag` — since
346
+ * middleware runs before the render.
347
+ *
348
+ * @param name - Header name, case-insensitive.
349
+ * @param value - Header value.
350
+ * @param options - `{ append: true }` to add another value rather than replace.
351
+ * @throws If called while a page is rendering, where it could not reach the browser reliably.
352
+ *
353
+ * @example
354
+ * ```ts
355
+ * 'use server';
356
+ * export async function logout() {
357
+ * const ctx = getRequestContext();
358
+ * ctx.cookies.delete('session', { path: '/' });
359
+ * ctx.setHeader('clear-site-data', '"cache", "storage"');
360
+ * redirect('/');
361
+ * }
362
+ * ```
363
+ */
364
+ setHeader(name, value, options) {
365
+ this.#assertWritable('ctx.setHeader()');
366
+ this.#raw.header(name, value, options);
367
+ }
368
+ // Hono's response builders, restated as errors naming what to use instead — through `ctx.hono` every
369
+ // one of them is a silent no-op from a page. Permanent, deliberately: see the class doc. `@deprecated`
370
+ // strikes them through in autocomplete; the unread `..._args` is so `ctx.redirect('/x')` reaches the
371
+ // thrown message rather than an arity error.
372
+ /** @deprecated Not available on a page's context — use `redirect()` from `@rshono/core/server`. */
373
+ redirect(..._args) {
374
+ return notOnContext('redirect(location, status?)', "Use `redirect()` from '@rshono/core/server', which throws a signal the framework turns into a real redirect.");
375
+ }
376
+ /** @deprecated Not available on a page's context — use `notFound()` from `@rshono/core/server`. */
377
+ notFound(..._args) {
378
+ return notOnContext('notFound()', "Use `notFound()` from '@rshono/core/server', which aborts the render and shows the app's not-found page.");
379
+ }
380
+ /** @deprecated A page renders JSX. For a JSON response, use an `{ type: 'endpoint' }` route. */
381
+ json(..._args) {
382
+ return notOnContext('json(object)', "For a JSON response use an { type: 'endpoint' } route; to read the request body use `ctx.req.json()`.");
383
+ }
384
+ /** @deprecated A page renders JSX. For a text response, use an `{ type: 'endpoint' }` route. */
385
+ text(..._args) {
386
+ return notOnContext('text(string)', "For a text response use an { type: 'endpoint' } route; to read the request body use `ctx.req.text()`.");
387
+ }
388
+ /** @deprecated A page renders JSX, which the framework turns into HTML for you. */
389
+ html(..._args) {
390
+ return notOnContext('html(string)', "A page's JSX is already its HTML; for a hand-built HTML response use an { type: 'endpoint' } route.");
391
+ }
392
+ /** @deprecated Not available on a page's context. To read the request body, use `ctx.req`. */
393
+ body(..._args) {
394
+ return notOnContext('body(data, …)', "To read the *request* body use `ctx.req.json()` / `ctx.req.text()` / `ctx.req.formData()`; to build a response, use an { type: 'endpoint' } route.");
395
+ }
396
+ /** @deprecated A page's status is set by the framework — use `notFound()`, or an endpoint route. */
397
+ status(..._args) {
398
+ return notOnContext('status(code)', "A page's status is the framework's: 200, 404 via `notFound()`, 500 when it throws. For any other code use an { type: 'endpoint' } route.");
399
+ }
400
+ /** @deprecated Renamed — use `ctx.setHeader(name, value)`, which is valid from a `'use server'` action. */
401
+ header(..._args) {
402
+ return notOnContext('header(name, value)', "Use `ctx.setHeader(name, value)` from a 'use server' action, or `c.header(…)` in middleware — a page renders too late to set one.");
403
+ }
230
404
  }
231
405
  /**
232
- * Returns the {@link Ctx} for the current request.
233
- *
234
- * This is the primary entry point for reading request data from a server
235
- * component or server action — the URL, cookies, params, env, and middleware
236
- * variables. The returned wrapper is memoised per request, so repeated calls in
237
- * the same request are cheap and return the same instance.
406
+ * The {@link RequestContext} for the current request — URL, cookies, params, env and middleware
407
+ * variables — read from a server component or a server action. Memoised per request, so repeated calls
408
+ * return the same instance.
238
409
  *
239
- * A **page** component is handed the very same object as its `ctx` prop, so this
240
- * import is for everywhere else: a nested server component, or a `'use server'`
241
- * action module — neither of which receives props from the framework.
410
+ * A page is handed that same object as its `ctx` prop, so this import is for everywhere else: a nested
411
+ * server component, or a `'use server'` action module.
242
412
  *
243
- * @typeParam E - The app's Hono {@link Env}, to type {@link Ctx.var} and {@link Ctx.env}.
413
+ * @typeParam E - The app's Hono {@link Env}, to type {@link RequestContext.var} and {@link RequestContext.env}.
244
414
  * @throws If called at module load, where there is no ambient context to resolve.
245
415
  * @throws If called while prerendering a `render: 'static'` route, which has no
246
416
  * per-request context at build time — mark the route `render: 'dynamic'` instead.
@@ -248,47 +418,48 @@ export class Ctx {
248
418
  * @example
249
419
  * ```ts
250
420
  * 'use server';
251
- * import { getContext, redirect } from '@rshono/core/server';
421
+ * import { getRequestContext, redirect } from '@rshono/core/server';
252
422
  *
253
423
  * export async function login(form: FormData) {
254
- * getContext().cookies.set('session', String(form.get('email')), { httpOnly: true });
424
+ * getRequestContext().cookies.set('session', String(form.get('email')), { httpOnly: true });
255
425
  * redirect('/dashboard');
256
426
  * }
257
427
  * ```
428
+ *
429
+ * @see {@link https://www.rshono.com/docs/api#rshonocoreserver | Docs — `@rshono/core/server`}
258
430
  */
259
- export function getContext() {
431
+ export function getRequestContext() {
260
432
  if (prerendering) {
261
- throw new Error("[rshono] getContext() was called while prerendering a `render: 'static'` route. A static page " +
433
+ throw new Error("[rshono] getRequestContext() was called while prerendering a `render: 'static'` route. A static page " +
262
434
  'is rendered once at build time, so it has no per-request context to read (URL, cookies, ' +
263
435
  "headers, env). Change this route to `render: 'dynamic'` so it renders per request, or remove " +
264
- 'the getContext() call.');
436
+ 'the getRequestContext() call.');
265
437
  }
266
438
  const c = contextStorage.getStore();
267
439
  if (!c) {
268
- throw new Error('[rshono] getContext() was called outside a request. It only works inside a server component or a server action, not at module load.');
440
+ throw new Error('[rshono] getRequestContext() was called outside a request. It only works inside a server component or a server action, not at module load.');
269
441
  }
270
442
  let ctx = wrappers.get(c);
271
443
  if (!ctx) {
272
- ctx = new Ctx(c);
444
+ ctx = new RequestContext(c);
273
445
  wrappers.set(c, ctx);
274
446
  }
275
447
  return ctx;
276
448
  }
277
449
  /**
278
- * Redirects the request to `location` by throwing a control signal that the
279
- * framework catches and turns into an HTTP redirect response.
450
+ * Redirects the request to `location`, by throwing a control signal the framework turns into an HTTP
451
+ * redirect.
280
452
  *
281
- * Because it throws, it never returns TypeScript narrows away any code after
282
- * the call, and you do not need to `return` it. Do not wrap it in a `try/catch`
283
- * that swallows the signal.
453
+ * Because it throws it never returns, so TypeScript narrows away everything after the call and there
454
+ * is nothing to `return`. Don't wrap it in a `try/catch` that swallows the signal.
284
455
  *
285
456
  * @param location - Absolute path or URL to redirect to, e.g. `/dashboard`.
286
- * @param status - Redirect {@link RedirectStatus}; defaults to `303` (See Other),
287
- * the correct choice after a form action so the browser follows up with a `GET`.
457
+ * @param status - Redirect {@link RedirectStatus}; defaults to `303` (See Other), which is what makes
458
+ * the browser follow up with a `GET` after a form action.
288
459
  *
289
460
  * @example
290
461
  * ```ts
291
- * const session = getContext().cookies.get('session');
462
+ * const session = getRequestContext().cookies.get('session');
292
463
  * if (!session) redirect('/login');
293
464
  * // session is defined below this line
294
465
  * ```
@@ -297,32 +468,69 @@ export function redirect(location, status = 303) {
297
468
  throw new RedirectSignal(location, status);
298
469
  }
299
470
  /**
300
- * Aborts the current render with a 404, rendering the app's not-found page.
471
+ * Aborts the current render with a 404, rendering the app's `notFound` page.
472
+ *
473
+ * Like {@link redirect} it throws a control signal and never returns, so TypeScript narrows away
474
+ * everything after the call. Don't catch-and-swallow it.
301
475
  *
302
- * Like {@link redirect}, this throws a control signal and never returns, so
303
- * TypeScript narrows away everything after the call. Do not catch-and-swallow it.
476
+ * **A real 404 status is only possible on a document load.** A soft navigation asks for a flight payload,
477
+ * and that response is committed as `200 text/x-component` the moment the render hands its stream back —
478
+ * before anything is awaited — so there is no shell to beat and calling this from the first line of a page
479
+ * is already too late. The signal still reaches the browser as a digest, and the client recovers by
480
+ * reloading the page for real: correct, but an extra round trip and a full document parse every time. Unlike
481
+ * {@link redirect}, which the same client turns into a soft navigation and which costs nothing. Where the
482
+ * status matters — a crawler, a monitor — decide in Hono middleware, ahead of the render.
304
483
  *
305
484
  * @example
306
485
  * ```tsx
307
- * const user = await db.user.find(getContext().params.id);
308
- * if (!user) notFound();
309
- * return <Profile user={user} />; // user is non-null here
486
+ * export default async function Page({ params }: PageProps<'/users/:id'>) {
487
+ * const user = await db.user.find(params.id);
488
+ * if (!user) notFound();
489
+ * return <Profile user={user} />; // user is non-null here
490
+ * }
310
491
  * ```
311
492
  */
312
493
  export function notFound() {
313
494
  throw new NotFoundSignal();
314
495
  }
315
496
  let errorHandler;
497
+ /**
498
+ * The platform's "keep this invocation alive" hook, or a no-op where there is none.
499
+ *
500
+ * `c.executionCtx` *throws* rather than answering `undefined` where a platform has no execution context, so
501
+ * a handler that reached for it itself would have its report swallowed by the guard in
502
+ * {@link reportServerError} — on exactly the platforms where nothing needed holding open.
503
+ */
504
+ function keepAlive(c, promise) {
505
+ // Caught here rather than left to the platform: under `--unhandled-rejections=strict` a rejected report
506
+ // would end the process, and a failed report must never be worse than no report.
507
+ const settled = Promise.resolve(promise).catch((error) => {
508
+ console.error('[rshono] a promise passed to the onServerError waitUntil rejected:', error);
509
+ });
510
+ try {
511
+ c.executionCtx.waitUntil(settled);
512
+ }
513
+ catch {
514
+ // No execution context: nothing here cuts the work off, so there is nothing to hold open.
515
+ }
516
+ }
517
+ /**
518
+ * Errors already forwarded, so one fault is reported once however many stages it crosses.
519
+ *
520
+ * A thrown server action is reported where it is known to be an action and then re-thrown, which lands it in
521
+ * the top-level handler as well — and a funnel that counts the same error twice, under two different
522
+ * `source`s, is worse than one that only ever names the outer stage.
523
+ */
524
+ const alreadyReported = new WeakSet();
316
525
  /**
317
526
  * Registers a handler for every error the framework catches, so they can reach an error tracker
318
527
  * (Sentry, Datadog, a log pipeline) instead of only `stderr`.
319
528
  *
320
- * Call it **once, at the top level of `src/server.ts`** that module is imported as the server
321
- * starts, before any request is served. Registering again replaces the previous handler.
529
+ * Call it once, at the top level of `src/server.ts`, which is imported as the server starts.
530
+ * Registering again replaces the previous handler. Errors still go to `stderr` either way, and a
531
+ * handler that throws is caught and logged — reporting can never fail a request.
322
532
  *
323
- * Errors are still written to `stderr` either way, so a handler adds a destination rather than
324
- * replacing one. A handler that throws is caught and logged: reporting must never be able to fail
325
- * a request.
533
+ * @typeParam E - The app's Hono {@link Env}, to type the `hono` context the handler is given.
326
534
  *
327
535
  * @example
328
536
  * ```ts
@@ -330,28 +538,48 @@ let errorHandler;
330
538
  * import * as Sentry from '@sentry/node';
331
539
  * import { onServerError } from '@rshono/core/server';
332
540
  *
333
- * onServerError((error, { source, request }) => {
334
- * Sentry.captureException(error, { tags: { source }, extra: { url: request.url } });
541
+ * onServerError((error, { source, request, hono, waitUntil }) => {
542
+ * // `waitUntil` so a serverless invocation is not frozen before the report is sent.
543
+ * waitUntil(
544
+ * Sentry.captureException(error, {
545
+ * tags: { source, requestId: hono.var.requestId },
546
+ * extra: { url: request.url },
547
+ * }),
548
+ * );
335
549
  * });
336
550
  * ```
551
+ *
552
+ * @see {@link https://www.rshono.com/docs/hono#error-reporting | Docs — error reporting}
337
553
  */
338
554
  export function onServerError(handler) {
339
555
  errorHandler = handler;
340
556
  }
341
557
  /**
342
- * Logs an error and forwards it to the registered {@link ServerErrorHandler}.
343
- *
344
- * Framework internal — the single funnel every caught server-side error goes through, so that
345
- * adding a reporting destination is one registration rather than a hook per call site.
558
+ * Logs an error and forwards it to the registered {@link ServerErrorHandler} — the single funnel every
559
+ * caught server-side error goes through.
346
560
  *
347
561
  * @internal
348
562
  */
349
563
  export function reportServerError(error, info) {
564
+ // The first stage to recognise it wins, since that is the one that knows what it was. A primitive throw
565
+ // cannot be tracked and is reported wherever it is caught.
566
+ if (typeof error === 'object' && error !== null) {
567
+ if (alreadyReported.has(error))
568
+ return;
569
+ alreadyReported.add(error);
570
+ }
350
571
  console.error(info.message, error);
351
572
  if (!errorHandler)
352
573
  return;
353
574
  try {
354
- errorHandler(error, { source: info.source, request: info.request });
575
+ errorHandler(error, {
576
+ source: info.source,
577
+ request: info.hono.req.raw,
578
+ // The handler was registered for the app's own `Env`, which `onServerError` erased to store it; this
579
+ // puts the context back in the shape it was registered with. Same trade as {@link getRequestContext}.
580
+ hono: info.hono,
581
+ waitUntil: (promise) => keepAlive(info.hono, promise),
582
+ });
355
583
  }
356
584
  catch (handlerError) {
357
585
  console.error('[rshono] the onServerError handler threw:', handlerError);