@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.
- package/LICENSE +21 -0
- package/README.md +348 -158
- package/bin/rshono.mjs +3 -4
- package/dist/builder/env-shadow-loader.cjs +123 -8
- package/dist/builder/page-entry-loader.cjs +28 -1
- package/dist/builder/page-files.d.ts +0 -1
- package/dist/builder/page-files.js +7 -3
- package/dist/builder/page-files.js.map +1 -1
- package/dist/builder/public-env.d.ts +7 -1
- package/dist/builder/public-env.js +7 -0
- package/dist/builder/public-env.js.map +1 -1
- package/dist/builder/react-versions.d.ts +12 -0
- package/dist/builder/react-versions.js +74 -0
- package/dist/builder/react-versions.js.map +1 -0
- package/dist/builder/rspack-config.d.ts +13 -4
- package/dist/builder/rspack-config.js +136 -53
- package/dist/builder/rspack-config.js.map +1 -1
- package/dist/builder/server-only-imports.d.ts +32 -0
- package/dist/builder/server-only-imports.js +51 -0
- package/dist/builder/server-only-imports.js.map +1 -0
- package/dist/cli/build.d.ts +2 -3
- package/dist/cli/build.js +51 -13
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev.d.ts +2 -3
- package/dist/cli/dev.js +149 -45
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/exit.d.ts +12 -0
- package/dist/cli/exit.js +16 -0
- package/dist/cli/exit.js.map +1 -0
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +91 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/start.d.ts +0 -1
- package/dist/cli/start.js +15 -18
- package/dist/cli/start.js.map +1 -1
- package/dist/config.d.ts +72 -78
- package/dist/config.js +17 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/aws-lambda/runtime.d.ts +4 -7
- package/dist/deploy/aws-lambda/runtime.js +5 -8
- package/dist/deploy/aws-lambda/runtime.js.map +1 -1
- package/dist/deploy/build-marker.d.ts +9 -6
- package/dist/deploy/build-marker.js +8 -4
- package/dist/deploy/build-marker.js.map +1 -1
- package/dist/deploy/cloudflare/build.d.ts +0 -1
- package/dist/deploy/cloudflare/build.js +17 -13
- package/dist/deploy/cloudflare/build.js.map +1 -1
- package/dist/deploy/cloudflare/runtime.d.ts +2 -6
- package/dist/deploy/cloudflare/runtime.js +73 -68
- package/dist/deploy/cloudflare/runtime.js.map +1 -1
- package/dist/deploy/contract.d.ts +57 -41
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts +3 -6
- package/dist/deploy/filesystem.js +23 -20
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts +5 -3
- package/dist/deploy/node/runtime.js +23 -9
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/deploy/presets.d.ts +34 -30
- package/dist/deploy/presets.js +56 -44
- package/dist/deploy/presets.js.map +1 -1
- package/dist/deploy/public-paths.d.ts +42 -0
- package/dist/deploy/public-paths.js +66 -0
- package/dist/deploy/public-paths.js.map +1 -0
- package/dist/deploy/vercel/build.d.ts +0 -1
- package/dist/deploy/vercel/build.js +25 -16
- package/dist/deploy/vercel/build.js.map +1 -1
- package/dist/deploy/vercel/runtime.d.ts +10 -7
- package/dist/deploy/vercel/runtime.js +48 -11
- package/dist/deploy/vercel/runtime.js.map +1 -1
- package/dist/index.d.ts +19 -17
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +212 -120
- package/dist/router.js +3 -8
- package/dist/router.js.map +1 -1
- package/dist/runtime/boundaries.d.ts +62 -48
- package/dist/runtime/boundaries.js +32 -34
- package/dist/runtime/boundaries.js.map +1 -1
- package/dist/runtime/client.d.ts +20 -11
- package/dist/runtime/client.js +20 -10
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/context.d.ts +304 -123
- package/dist/runtime/context.js +377 -149
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/control.d.ts +15 -1
- package/dist/runtime/control.js +24 -0
- package/dist/runtime/control.js.map +1 -1
- package/dist/runtime/dev-protocol.d.ts +4 -9
- package/dist/runtime/dev-protocol.js.map +1 -1
- package/dist/runtime/empty-server-app.d.ts +0 -1
- package/dist/runtime/entry.client.d.ts +4 -1
- package/dist/runtime/entry.client.js +455 -313
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.d.ts +15 -7
- package/dist/runtime/entry.rsc.js +811 -252
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/entry.ssr.d.ts +27 -10
- package/dist/runtime/entry.ssr.js +30 -48
- package/dist/runtime/entry.ssr.js.map +1 -1
- package/dist/runtime/failure-document.d.ts +11 -0
- package/dist/runtime/failure-document.js +35 -0
- package/dist/runtime/failure-document.js.map +1 -0
- package/dist/runtime/flight-inject.d.ts +23 -0
- package/dist/runtime/flight-inject.js +403 -0
- package/dist/runtime/flight-inject.js.map +1 -0
- package/dist/runtime/hot-update.d.ts +44 -0
- package/dist/runtime/hot-update.js +44 -0
- package/dist/runtime/hot-update.js.map +1 -0
- package/dist/runtime/navigation.d.ts +46 -54
- package/dist/runtime/navigation.js +22 -63
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/request.d.ts +42 -8
- package/dist/runtime/request.js +84 -7
- package/dist/runtime/request.js.map +1 -1
- package/dist/runtime/server.d.ts +20 -18
- package/dist/runtime/server.js +22 -17
- package/dist/runtime/server.js.map +1 -1
- package/dist/runtime/validate-entries.d.ts +77 -0
- package/dist/runtime/validate-entries.js +405 -0
- package/dist/runtime/validate-entries.js.map +1 -0
- package/dist/server/headers.d.ts +41 -15
- package/dist/server/headers.js +53 -24
- package/dist/server/headers.js.map +1 -1
- package/dist/server/load-config.d.ts +4 -5
- package/dist/server/load-config.js +22 -14
- package/dist/server/load-config.js.map +1 -1
- package/dist/server/load-env.d.ts +0 -1
- package/dist/server/prerendered.d.ts +88 -34
- package/dist/server/prerendered.js +184 -24
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +35 -50
- package/dist/server/server-config.js +27 -74
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/shutdown.d.ts +5 -4
- package/dist/server/shutdown.js +5 -3
- package/dist/server/shutdown.js.map +1 -1
- package/dist/server/ssg.d.ts +9 -7
- package/dist/server/ssg.js +209 -65
- package/dist/server/ssg.js.map +1 -1
- package/dist/server/static.d.ts +0 -1
- package/dist/server/static.js +7 -1
- package/dist/server/static.js.map +1 -1
- package/package.json +21 -23
- package/dist/builder/page-files.d.ts.map +0 -1
- package/dist/builder/public-env.d.ts.map +0 -1
- package/dist/builder/rspack-config.d.ts.map +0 -1
- package/dist/cli/build.d.ts.map +0 -1
- package/dist/cli/dev.d.ts.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/start.d.ts.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
- package/dist/deploy/build-marker.d.ts.map +0 -1
- package/dist/deploy/bun/runtime.d.ts +0 -11
- package/dist/deploy/bun/runtime.d.ts.map +0 -1
- package/dist/deploy/bun/runtime.js +0 -22
- package/dist/deploy/bun/runtime.js.map +0 -1
- package/dist/deploy/cloudflare/build.d.ts.map +0 -1
- package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
- package/dist/deploy/contract.d.ts.map +0 -1
- package/dist/deploy/deno/runtime.d.ts +0 -11
- package/dist/deploy/deno/runtime.d.ts.map +0 -1
- package/dist/deploy/deno/runtime.js +0 -16
- package/dist/deploy/deno/runtime.js.map +0 -1
- package/dist/deploy/filesystem.d.ts.map +0 -1
- package/dist/deploy/listen.d.ts +0 -20
- package/dist/deploy/listen.d.ts.map +0 -1
- package/dist/deploy/listen.js +0 -24
- package/dist/deploy/listen.js.map +0 -1
- package/dist/deploy/netlify/build.d.ts +0 -8
- package/dist/deploy/netlify/build.d.ts.map +0 -1
- package/dist/deploy/netlify/build.js +0 -52
- package/dist/deploy/netlify/build.js.map +0 -1
- package/dist/deploy/netlify/runtime.d.ts +0 -13
- package/dist/deploy/netlify/runtime.d.ts.map +0 -1
- package/dist/deploy/netlify/runtime.js +0 -24
- package/dist/deploy/netlify/runtime.js.map +0 -1
- package/dist/deploy/node/runtime.d.ts.map +0 -1
- package/dist/deploy/presets.d.ts.map +0 -1
- package/dist/deploy/vercel/build.d.ts.map +0 -1
- package/dist/deploy/vercel/runtime.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/router.d.ts.map +0 -1
- package/dist/runtime/boundaries.d.ts.map +0 -1
- package/dist/runtime/client.d.ts.map +0 -1
- package/dist/runtime/context.d.ts.map +0 -1
- package/dist/runtime/control.d.ts.map +0 -1
- package/dist/runtime/dev-protocol.d.ts.map +0 -1
- package/dist/runtime/empty-server-app.d.ts.map +0 -1
- package/dist/runtime/entry.client.d.ts.map +0 -1
- package/dist/runtime/entry.rsc.d.ts.map +0 -1
- package/dist/runtime/entry.ssr.d.ts.map +0 -1
- package/dist/runtime/navigation.d.ts.map +0 -1
- package/dist/runtime/request.d.ts.map +0 -1
- package/dist/runtime/server.d.ts.map +0 -1
- package/dist/server/compress.d.ts +0 -15
- package/dist/server/compress.d.ts.map +0 -1
- package/dist/server/compress.js +0 -76
- package/dist/server/compress.js.map +0 -1
- package/dist/server/headers.d.ts.map +0 -1
- package/dist/server/load-config.d.ts.map +0 -1
- package/dist/server/load-env.d.ts.map +0 -1
- package/dist/server/prerendered.d.ts.map +0 -1
- package/dist/server/server-config.d.ts.map +0 -1
- package/dist/server/shutdown.d.ts.map +0 -1
- package/dist/server/ssg.d.ts.map +0 -1
- package/dist/server/static.d.ts.map +0 -1
package/dist/runtime/context.js
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
|
79
|
-
// `@rshono/core/server` entry
|
|
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
|
-
*
|
|
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`
|
|
86
|
-
*
|
|
87
|
-
* `
|
|
88
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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;
|
|
122
|
+
url.port = forwarded.port;
|
|
107
123
|
}
|
|
108
|
-
//
|
|
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
|
-
*
|
|
117
|
-
*
|
|
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
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
150
|
+
* import { getRequestContext } from '@rshono/core/server';
|
|
130
151
|
*
|
|
131
152
|
* export default async function Whoami() {
|
|
132
|
-
* const ctx =
|
|
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
|
|
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}
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
170
|
-
return (this.#
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
|
181
|
-
return
|
|
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
|
-
*
|
|
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
|
|
192
|
-
*
|
|
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
|
-
* @
|
|
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 =
|
|
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
|
-
/**
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
|
240
|
-
*
|
|
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
|
|
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 {
|
|
421
|
+
* import { getRequestContext, redirect } from '@rshono/core/server';
|
|
252
422
|
*
|
|
253
423
|
* export async function login(form: FormData) {
|
|
254
|
-
*
|
|
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
|
|
431
|
+
export function getRequestContext() {
|
|
260
432
|
if (prerendering) {
|
|
261
|
-
throw new Error("[rshono]
|
|
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
|
|
436
|
+
'the getRequestContext() call.');
|
|
265
437
|
}
|
|
266
438
|
const c = contextStorage.getStore();
|
|
267
439
|
if (!c) {
|
|
268
|
-
throw new Error('[rshono]
|
|
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
|
|
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
|
|
279
|
-
*
|
|
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
|
|
282
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
*
|
|
303
|
-
*
|
|
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
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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
|
|
321
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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, {
|
|
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);
|