@rshono/core 1.0.0-rc.17 → 1.0.0-rc.19
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/README.md +34 -4
- package/dist/builder/env-shadow-loader.cjs +58 -2
- package/dist/builder/rspack-config.d.ts.map +1 -1
- package/dist/builder/rspack-config.js +51 -7
- package/dist/builder/rspack-config.js.map +1 -1
- package/dist/builder/server-only-imports.d.ts +33 -0
- package/dist/builder/server-only-imports.d.ts.map +1 -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.map +1 -1
- package/dist/cli/build.js +35 -3
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev.d.ts.map +1 -1
- package/dist/cli/dev.js +49 -0
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/index.js +14 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
- package/dist/deploy/cloudflare/runtime.js +31 -5
- package/dist/deploy/cloudflare/runtime.js.map +1 -1
- package/dist/deploy/contract.d.ts +6 -2
- package/dist/deploy/contract.d.ts.map +1 -1
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts.map +1 -1
- package/dist/deploy/filesystem.js +2 -1
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts.map +1 -1
- package/dist/deploy/node/runtime.js +3 -4
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/deploy/presets.d.ts +8 -0
- package/dist/deploy/presets.d.ts.map +1 -1
- package/dist/deploy/presets.js +1 -0
- package/dist/deploy/presets.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +93 -25
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js.map +1 -1
- package/dist/runtime/boundaries.d.ts +11 -4
- package/dist/runtime/boundaries.d.ts.map +1 -1
- package/dist/runtime/boundaries.js.map +1 -1
- package/dist/runtime/context.d.ts +60 -9
- package/dist/runtime/context.d.ts.map +1 -1
- package/dist/runtime/context.js +61 -8
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/entry.client.js +156 -21
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.d.ts +7 -0
- package/dist/runtime/entry.rsc.d.ts.map +1 -1
- package/dist/runtime/entry.rsc.js +202 -57
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/flight-inject.d.ts +1 -1
- package/dist/runtime/flight-inject.d.ts.map +1 -1
- package/dist/runtime/flight-inject.js +207 -40
- package/dist/runtime/flight-inject.js.map +1 -1
- package/dist/runtime/request.d.ts.map +1 -1
- package/dist/runtime/request.js +10 -0
- package/dist/runtime/request.js.map +1 -1
- package/dist/runtime/validate-entries.d.ts +78 -0
- package/dist/runtime/validate-entries.d.ts.map +1 -0
- package/dist/runtime/validate-entries.js +301 -0
- package/dist/runtime/validate-entries.js.map +1 -0
- package/dist/server/prerendered.d.ts +30 -11
- package/dist/server/prerendered.d.ts.map +1 -1
- package/dist/server/prerendered.js +72 -14
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +20 -1
- package/dist/server/server-config.d.ts.map +1 -1
- package/dist/server/server-config.js +27 -1
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/ssg.d.ts.map +1 -1
- package/dist/server/ssg.js +170 -27
- package/dist/server/ssg.js.map +1 -1
- package/dist/server/static.d.ts.map +1 -1
- package/dist/server/static.js +7 -1
- package/dist/server/static.js.map +1 -1
- package/package.json +5 -4
|
@@ -20,16 +20,55 @@ import { isControlSignal, RedirectSignal } from './control.js';
|
|
|
20
20
|
import { renderHTML } from './entry.ssr.js';
|
|
21
21
|
import { RouterProvider } from './navigation.js';
|
|
22
22
|
import { asksForRsc, isActionRequest, parseRenderRequest, requestWantsRsc, RSC_VARY_HEADER, wantsRsc } from './request.js';
|
|
23
|
-
|
|
23
|
+
import { assertEndpointModule, assertPageModule, assertRouteModules, validateRoutesModule, validateServerApp } from './validate-entries.js';
|
|
24
|
+
const serverApp = validateServerApp(serverAppModule);
|
|
24
25
|
// Compiled into the bundle from rshono.config.ts by DefinePlugin; there is no runtime env-var interface.
|
|
25
26
|
const { isDev } = __RSHONO_CONFIG__;
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* How long a prerendered page may be reused before revalidating. Also what `public/` files get.
|
|
29
|
+
*
|
|
30
|
+
* Not a config field, deliberately: it is a per-response header, and `rshono.config.ts` is compiled into the
|
|
31
|
+
* bundle — a cache policy you cannot change without a rebuild is the wrong shape. An app that wants a longer
|
|
32
|
+
* `max-age`, or a `stale-while-revalidate`, sets it from middleware **after `await next()`**:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* server.use('/docs/*', async (c, next) => {
|
|
36
|
+
* await next();
|
|
37
|
+
* c.res.headers.set('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* After, because the response below is built with `cache-control` in the bag it hands `c.body(...)`, and
|
|
42
|
+
* that replaces a header prepared with `c.header(...)` before the handler ran. The `ETag` is untouched
|
|
43
|
+
* either way, so revalidation still costs a 304 rather than the page.
|
|
44
|
+
*/
|
|
27
45
|
const SSG_CACHE_CONTROL = 'public, max-age=300';
|
|
46
|
+
/**
|
|
47
|
+
* What a page response gets when nothing else set one: without it a shared cache is free to store a logged-in
|
|
48
|
+
* user's page and hand it to someone else. `private, no-cache` forbids that without blocking bfcache, which
|
|
49
|
+
* `no-store` would.
|
|
50
|
+
*/
|
|
51
|
+
const PAGE_CACHE_CONTROL = 'private, no-cache';
|
|
28
52
|
/** The two content types a page can be served as, from the same URL — which is what makes `Vary` non-optional. */
|
|
29
53
|
const PAGE_CONTENT_TYPE = /^(?:text\/html|text\/x-component)\b/;
|
|
30
54
|
runtime.loadEnv();
|
|
31
|
-
|
|
55
|
+
// Checked rather than cast: these two modules are the app's, and a mistake in either used to surface as a
|
|
56
|
+
// `TypeError` from somewhere else entirely — or, for a duplicated path, as nothing at all.
|
|
57
|
+
const routeConfig = validateRoutesModule(userRoutes);
|
|
32
58
|
export const routes = routeConfig.routes;
|
|
59
|
+
/**
|
|
60
|
+
* The result of an action that has already run, for the request whose *render* then failed.
|
|
61
|
+
*
|
|
62
|
+
* The action and the page it answers with are one response: an action returns its value through the payload
|
|
63
|
+
* of the page rendered after it. So when that render throws — a page module that will not load, most
|
|
64
|
+
* plausibly a chunk that went away mid-deploy — `onError` renders the `error` page in its place, and without
|
|
65
|
+
* this the reply carries no `returnValue` at all. The caller of an action that ran, and may well have
|
|
66
|
+
* written something, would be told only that a field was missing.
|
|
67
|
+
*
|
|
68
|
+
* Keyed on the Hono context the way `beginPageRender` keys its own marker, and weakly, so nothing outlives
|
|
69
|
+
* the request it belongs to.
|
|
70
|
+
*/
|
|
71
|
+
const actionResults = new WeakMap();
|
|
33
72
|
/**
|
|
34
73
|
* The per-request CSP nonce, if the app asked for one.
|
|
35
74
|
*
|
|
@@ -56,37 +95,31 @@ function cspNonce(c) {
|
|
|
56
95
|
*
|
|
57
96
|
* Both halves are required, because either alone refuses something real:
|
|
58
97
|
*
|
|
59
|
-
* - `Sec-Fetch-Site
|
|
60
|
-
* a form to a server action sends it.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
98
|
+
* - `Sec-Fetch-Site` is the browser's own statement of provenance, unforgeable by page script, and every
|
|
99
|
+
* browser that can post a form to a server action sends it. `cross-site` and `same-site` are the two labels
|
|
100
|
+
* that mean "not from this origin" — `same-site` is what a *sibling subdomain* gets, which is a user-content
|
|
101
|
+
* host, a stale CNAME or a subdomain takeover, so leaving it to `csrf()` meant an app without one could have
|
|
102
|
+
* any `'use server'` export driven from next door. An absent header means a non-browser client, which cannot
|
|
103
|
+
* be a CSRF victim. `same-origin` and `none` settle it on their own, and are what a genuine post carries
|
|
104
|
+
* however many proxies rewrote `Host` on the way in.
|
|
105
|
+
* - An `Origin` that is the app's own contradicts either label — a browser calls a post from the app's own pages
|
|
63
106
|
* `same-origin` — so the pair is a shape no browser produces, and refusing it would only catch a proxy or a
|
|
64
|
-
* test client setting the label by hand while posting from the app itself.
|
|
107
|
+
* test client setting the label by hand while posting from the app itself. Nothing else clears the label:
|
|
108
|
+
* an `Origin` of `null` (a sandboxed iframe, a `data:` URL, `Referrer-Policy: no-referrer`) and no `Origin`
|
|
109
|
+
* at all are both refused. A browser attaches one to every non-GET request, so neither is a shape it
|
|
110
|
+
* produces — but a security predicate that says "not proven foreign" rather than "proven local" fails open
|
|
111
|
+
* the day something does produce it.
|
|
65
112
|
*
|
|
66
113
|
* `publicUrl(c)` rather than `c.req.url`, so it honours `trustProxy` and compares against the origin the
|
|
67
114
|
* browser actually used — which behind a proxy, `rshono dev`'s included, is not the one the server was reached
|
|
68
|
-
* on. The cost is that an app deliberately accepting
|
|
69
|
-
* an `{ type: 'endpoint' }` route is for.
|
|
115
|
+
* on. The cost is that an app deliberately accepting *form* posts to an action from another origin of its own
|
|
116
|
+
* cannot, `csrf()`'s allowlist included; that is what an `{ type: 'endpoint' }` route is for.
|
|
70
117
|
*/
|
|
71
118
|
function refusesCrossSiteForm(c) {
|
|
72
|
-
|
|
119
|
+
const site = c.req.header('sec-fetch-site');
|
|
120
|
+
if (site !== 'cross-site' && site !== 'same-site')
|
|
73
121
|
return false;
|
|
74
|
-
|
|
75
|
-
return origin !== undefined && origin !== publicUrl(c).origin;
|
|
76
|
-
}
|
|
77
|
-
async function loadPageModule(load, label) {
|
|
78
|
-
const mod = await load();
|
|
79
|
-
const Page = mod.default;
|
|
80
|
-
if (typeof Page !== 'function') {
|
|
81
|
-
throw new Error(`[rshono] The page module for ${label} must default-export a server component.`);
|
|
82
|
-
}
|
|
83
|
-
if (!Page.entryJsFiles) {
|
|
84
|
-
throw new Error(`[rshono] The page component for ${label} is missing its client-asset info ('use server-entry'). ` +
|
|
85
|
-
"The directive is added automatically for inline `component: () => import('…')` thunks in routes.ts. " +
|
|
86
|
-
"If this page is wired up another way, put 'use server-entry' on the first line of the page module yourself — " +
|
|
87
|
-
"and make sure the page is a server component (a 'use client' page must be wrapped by a server component instead).");
|
|
88
|
-
}
|
|
89
|
-
return Page;
|
|
122
|
+
return c.req.header('origin') !== publicUrl(c).origin;
|
|
90
123
|
}
|
|
91
124
|
/** A browser navigation or a crawler, as opposed to a fetch that would rather have plain text. */
|
|
92
125
|
function acceptsHtml(c) {
|
|
@@ -97,7 +130,11 @@ function acceptsHtml(c) {
|
|
|
97
130
|
* payload. It carries the `Vary` too: this is one of the answers a page URL gives depending on the `RSC` request header.
|
|
98
131
|
*/
|
|
99
132
|
function plainNotFound(c) {
|
|
100
|
-
|
|
133
|
+
// `cache-control` explicitly, because the default above is applied to page *content types* and this is
|
|
134
|
+
// `text/plain`. A 404 is heuristically cacheable under RFC 9111, so without it a shared cache may store
|
|
135
|
+
// this one — while the rendered HTML 404 next to it, the same answer to the same request from a client
|
|
136
|
+
// that asked for HTML, is correctly private.
|
|
137
|
+
return c.text('Not Found', 404, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
|
|
101
138
|
}
|
|
102
139
|
/** A lazy once-cell: runs `load` at most once, but clears a rejection so a later call can retry. */
|
|
103
140
|
function once(load) {
|
|
@@ -126,6 +163,34 @@ function releaseWhenDone(stream, done) {
|
|
|
126
163
|
cancel: done,
|
|
127
164
|
}));
|
|
128
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* A macrotask boundary: `setImmediate` where there is one — the current turn's check phase rather than a
|
|
168
|
+
* timer — and `setTimeout` as the portable fallback, for a runtime without it. `flight-inject.ts` has the
|
|
169
|
+
* same two lines and the same reason; they are not shared because that module belongs to the SSR layer, and
|
|
170
|
+
* importing it here would give the RSC layer its own instance of it.
|
|
171
|
+
*/
|
|
172
|
+
const deferTask = typeof setImmediate === 'function'
|
|
173
|
+
? setImmediate
|
|
174
|
+
: (run) => {
|
|
175
|
+
setTimeout(run, 0);
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* Dev-only: says that a control signal arrived too late to be one.
|
|
179
|
+
*
|
|
180
|
+
* The root fix is in app code, so authoring time is where this has to be said, and a docs paragraph is not
|
|
181
|
+
* where anyone reads it. `isDev` is the baked build flag, so a production server pays one boolean check on a
|
|
182
|
+
* path that has already gone wrong — and says nothing.
|
|
183
|
+
*/
|
|
184
|
+
function warnLateControlSignal(c, signal) {
|
|
185
|
+
const isRedirect = signal instanceof RedirectSignal;
|
|
186
|
+
console.warn(`[rshono] ${isRedirect ? `redirect(${JSON.stringify(signal.location)})` : 'notFound()'} was called from a boundary that ` +
|
|
187
|
+
`resolved after the page shell had already been sent (${c.req.method} ${c.req.path}), so it cannot become a real ` +
|
|
188
|
+
`${isRedirect ? '3xx' : '404'}: the response is committed as 200 text/html. A browser with JavaScript follows the ` +
|
|
189
|
+
`digest that rides the payload; one without stays on the Suspense fallback, and a crawler indexes the 200.${isRedirect
|
|
190
|
+
? ''
|
|
191
|
+
: ' A JavaScript client asks for the page once more, in case the signal comes early enough that time to be a real 404 — and shows a plain "Page not found" panel when it does not, rather than reloading into the same response forever.'}` +
|
|
192
|
+
' Decide before the render starts streaming — in Hono middleware, or in the page component body above the boundary.');
|
|
193
|
+
}
|
|
129
194
|
/**
|
|
130
195
|
* Builds the props a page component is called with.
|
|
131
196
|
*
|
|
@@ -171,16 +236,37 @@ async function renderComponent(c, Page, opts) {
|
|
|
171
236
|
// starts throwing — while the action `renderPage` just ran, and the middleware around it, legitimately wrote.
|
|
172
237
|
beginPageRender(c);
|
|
173
238
|
let controlSignal;
|
|
239
|
+
/** Set once `renderHTML` has returned, which is where the response head stops being changeable. */
|
|
240
|
+
let shellFlushed = false;
|
|
174
241
|
const rscStream = renderToReadableStream(rscPayload, {
|
|
175
242
|
temporaryReferences: opts.temporaryReferences,
|
|
176
243
|
signal,
|
|
177
244
|
onError(error) {
|
|
178
245
|
if (isControlSignal(error)) {
|
|
179
246
|
controlSignal = error;
|
|
247
|
+
if (shellFlushed) {
|
|
248
|
+
if (isDev)
|
|
249
|
+
warnLateControlSignal(c, error);
|
|
250
|
+
// Past the shell the status line, the headers and the first bytes are on the wire, so the digest
|
|
251
|
+
// React writes into the payload is the only path left — and everything still rendering is for a
|
|
252
|
+
// page the browser is about to navigate away from. Winding it down stops those boundaries, runs
|
|
253
|
+
// `flight-inject`'s cancel/flush, and fires the `release()` above now rather than whenever the
|
|
254
|
+
// doomed render happens to finish.
|
|
255
|
+
//
|
|
256
|
+
// One macrotask later rather than from inside `onError`, where React is still handling the error
|
|
257
|
+
// that produced the digest — the row carrying it is the only recovery the client has, and an abort
|
|
258
|
+
// that re-entered React there could cut the render off before it is written. Measured to survive
|
|
259
|
+
// either way on react-server-dom-rspack 0.1.0; deferred anyway, because that ordering is an
|
|
260
|
+
// internal the `^19.1.0` peer range does not promise, and the cost is one macrotask on a render
|
|
261
|
+
// that is already doomed. The reason is the signal itself, so every boundary the abort errors
|
|
262
|
+
// carries the digest rather than a bare AbortError the client would paint as a fault.
|
|
263
|
+
if (!signal.aborted)
|
|
264
|
+
deferTask(() => renderAbort.abort(error));
|
|
265
|
+
}
|
|
180
266
|
return error.digest;
|
|
181
267
|
}
|
|
182
268
|
if (!signal.aborted)
|
|
183
|
-
reportServerError(error, { source: 'render',
|
|
269
|
+
reportServerError(error, { source: 'render', hono: c, message: '[rshono] render error:' });
|
|
184
270
|
},
|
|
185
271
|
});
|
|
186
272
|
if (opts.isRsc) {
|
|
@@ -196,8 +282,8 @@ async function renderComponent(c, Page, opts) {
|
|
|
196
282
|
signal,
|
|
197
283
|
nonce,
|
|
198
284
|
onDone: release,
|
|
199
|
-
onShellError: (error) => reportServerError(error, { source: 'ssr',
|
|
200
|
-
onError: (error) => reportServerError(error, { source: 'ssr',
|
|
285
|
+
onShellError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR shell error:' }),
|
|
286
|
+
onError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR error:' }),
|
|
201
287
|
});
|
|
202
288
|
}
|
|
203
289
|
catch (error) {
|
|
@@ -206,6 +292,10 @@ async function renderComponent(c, Page, opts) {
|
|
|
206
292
|
throw controlSignal;
|
|
207
293
|
throw error;
|
|
208
294
|
}
|
|
295
|
+
// `renderHTML` returns at *shell ready*, so from here the response is the one that ships. Set before the
|
|
296
|
+
// check below rather than after it, so nothing lands in the window between the two: a signal that arrives
|
|
297
|
+
// in it is handled there *and* schedules an abort, and aborting an aborted controller is a no-op.
|
|
298
|
+
shellFlushed = true;
|
|
209
299
|
if (controlSignal) {
|
|
210
300
|
// The shell resolved, so this response is live: React is being pumped into the payload-injecting
|
|
211
301
|
// transform, and a `redirect()` that surfaced from a boundary settling just before the shell was ready
|
|
@@ -253,10 +343,13 @@ async function renderPage(c, loadPage) {
|
|
|
253
343
|
throw error;
|
|
254
344
|
// In production React sends a thrown action error to the client as an opaque marker, so this is
|
|
255
345
|
// the only place the real one is visible.
|
|
256
|
-
reportServerError(error, { source: 'action',
|
|
346
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
|
|
257
347
|
returnValue = { ok: false, error };
|
|
258
348
|
actionStatus = 500;
|
|
259
349
|
}
|
|
350
|
+
// The action is done and its result is the caller's, whatever becomes of the render below. See
|
|
351
|
+
// {@link actionResults}.
|
|
352
|
+
actionResults.set(c, returnValue);
|
|
260
353
|
}
|
|
261
354
|
else {
|
|
262
355
|
// A `<form action={serverAction}>` post, which is the path that runs before hydration and with
|
|
@@ -280,7 +373,7 @@ async function renderPage(c, loadPage) {
|
|
|
280
373
|
// because this path has no client boundary and no `useActionState` to hand the error to, so the
|
|
281
374
|
// app's `error` page is the honest answer. `reportServerError` de-duplicates, so the re-throw
|
|
282
375
|
// reaching `onError` does not report it a second time.
|
|
283
|
-
reportServerError(error, { source: 'action',
|
|
376
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
|
|
284
377
|
throw error;
|
|
285
378
|
}
|
|
286
379
|
formState = (await decodeFormState(result, formData, __rspack_rsc_manifest__.serverManifest)) ?? undefined;
|
|
@@ -300,8 +393,7 @@ function buildApp() {
|
|
|
300
393
|
const app = new Hono();
|
|
301
394
|
// A floor, not a policy: an app wanting the full set registers `secureHeaders()` in src/server.ts, and
|
|
302
395
|
// because this is registered first it unwinds last, so the "only if unset" checks stand aside for it.
|
|
303
|
-
// Owned by the framework
|
|
304
|
-
// app's own middleware never sees.
|
|
396
|
+
// Owned by the framework so that an app with no src/server.ts at all still gets it.
|
|
305
397
|
app.use(async (c, next) => {
|
|
306
398
|
await next();
|
|
307
399
|
const headers = c.res.headers;
|
|
@@ -323,18 +415,21 @@ function buildApp() {
|
|
|
323
415
|
if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
|
|
324
416
|
return;
|
|
325
417
|
appendVary(headers, RSC_VARY_HEADER);
|
|
326
|
-
// Without a default a shared cache is free to store a logged-in user's page and hand it to someone
|
|
327
|
-
// else. `private, no-cache` forbids that without blocking bfcache, which `no-store` would.
|
|
328
418
|
if (!headers.has('cache-control'))
|
|
329
|
-
headers.set('cache-control',
|
|
419
|
+
headers.set('cache-control', PAGE_CACHE_CONTROL);
|
|
330
420
|
});
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
//
|
|
421
|
+
// First, so the app's own middleware (`csrf()`, `bodyLimit()`, auth, logging) wraps everything registered
|
|
422
|
+
// below: the page routes, and the asset handlers too. Assets used to be mounted ahead of this, which left
|
|
423
|
+
// `/_static/*` answered by a terminal handler nothing of the app's ever saw — no `secureHeaders()`, and so
|
|
424
|
+
// no HSTS on exactly the requests a downgrade attack lands on. The flip side is the same one it always had,
|
|
425
|
+
// one path wider: a *terminal* handler in src/server.ts shadows a page at the same path, and unscoped
|
|
426
|
+
// middleware now also runs for `/_static`, which is a reserved prefix an app should not be matching on
|
|
427
|
+
// purpose anyway.
|
|
334
428
|
if (serverApp) {
|
|
335
429
|
app.route('/', serverApp);
|
|
336
430
|
}
|
|
337
|
-
|
|
431
|
+
runtime.mountStaticAssets(app);
|
|
432
|
+
const memoizePage = (page, label) => once(async () => assertPageModule(await page.component(), label));
|
|
338
433
|
const loadNotFoundPage = routeConfig.notFound ? memoizePage(routeConfig.notFound, 'the notFound page') : null;
|
|
339
434
|
/** Turns a thrown `redirect()` / `notFound()` into the response it stands for. */
|
|
340
435
|
const respondToControlSignal = async (c, signal) => {
|
|
@@ -349,7 +444,23 @@ function buildApp() {
|
|
|
349
444
|
return c.redirect(signal.location, signal.status);
|
|
350
445
|
}
|
|
351
446
|
if (loadNotFoundPage) {
|
|
352
|
-
|
|
447
|
+
try {
|
|
448
|
+
return await renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
// The `notFound` page is already the answer to a request that went wrong, so a failure here has
|
|
452
|
+
// nowhere to escalate to: this runs from `onError` as well as from the page handler, and Hono calls
|
|
453
|
+
// `onError` inside its own catch — a throw from there rejects `app.fetch`, which
|
|
454
|
+
// `@hono/node-server` turns into a bodiless 500 with nothing in the log. So it is answered here, the
|
|
455
|
+
// way a failing `error` page is answered below.
|
|
456
|
+
//
|
|
457
|
+
// A `redirect()` from the page is the exception: nothing is committed yet, the branch above cannot
|
|
458
|
+
// fail, and this is what the same page does when `app.notFound` renders it — so it is honoured, and
|
|
459
|
+
// recurses exactly once.
|
|
460
|
+
if (error instanceof RedirectSignal)
|
|
461
|
+
return respondToControlSignal(c, error);
|
|
462
|
+
reportServerError(error, { source: 'render', hono: c, message: '[rshono] the notFound page failed to render:' });
|
|
463
|
+
}
|
|
353
464
|
}
|
|
354
465
|
return plainNotFound(c);
|
|
355
466
|
};
|
|
@@ -357,10 +468,13 @@ function buildApp() {
|
|
|
357
468
|
if (isPageRoute(route)) {
|
|
358
469
|
// Both fixed for the life of the process, so resolved once rather than per request.
|
|
359
470
|
const servesPrerendered = !isDev && route.render === 'static';
|
|
360
|
-
const loadPage = once(() =>
|
|
361
|
-
|
|
471
|
+
const loadPage = once(async () => assertPageModule(await route.component(), `"${route.path}"`));
|
|
472
|
+
/** Everything the route answers, before a HEAD has its body taken off it. */
|
|
473
|
+
const respond = async (c) => {
|
|
362
474
|
try {
|
|
363
|
-
|
|
475
|
+
// A HEAD takes the GET path, prerendered bytes included: the headers it promises are the ones a
|
|
476
|
+
// GET would send, `etag` and `content-length` among them.
|
|
477
|
+
if (servesPrerendered && (c.req.method === 'GET' || c.req.method === 'HEAD')) {
|
|
364
478
|
const isRsc = asksForRsc(c.req.raw);
|
|
365
479
|
// One fixed set of bytes cannot carry a per-request nonce, so a document has to be rendered
|
|
366
480
|
// where the app's CSP has one. Decided per request, so an app whose policy carries no `NONCE`
|
|
@@ -392,29 +506,51 @@ function buildApp() {
|
|
|
392
506
|
throw error;
|
|
393
507
|
}
|
|
394
508
|
};
|
|
509
|
+
const handler = async (c) => {
|
|
510
|
+
const response = await respond(c);
|
|
511
|
+
// Hono dispatches a HEAD to the GET route and then rebuilds the response as `new Response(null, res)`,
|
|
512
|
+
// which drops the body without reading it — so for a rendered page nothing ever consumes the stream:
|
|
513
|
+
// `flight-inject`'s `cancel` never runs, and neither does the `release()` that detaches the abort
|
|
514
|
+
// forwarder from the request signal. Cancelling here is what ends the render nobody asked to read.
|
|
515
|
+
if (c.req.method === 'HEAD' && response.body !== null) {
|
|
516
|
+
void response.body.cancel().catch(() => {
|
|
517
|
+
// Already errored or locked, and there is nothing left to release either way.
|
|
518
|
+
});
|
|
519
|
+
return new Response(null, response);
|
|
520
|
+
}
|
|
521
|
+
return response;
|
|
522
|
+
};
|
|
395
523
|
app.on(['GET', 'POST'], route.path, handler);
|
|
396
524
|
}
|
|
397
525
|
else {
|
|
398
|
-
|
|
526
|
+
// Checked on the way out of the thunk rather than at the call site, so `once`'s cache holds a handler
|
|
527
|
+
// that has already been proven to be one — and a rejection clears, so the message repeats per request.
|
|
528
|
+
const loadEndpoint = once(async () => assertEndpointModule(await route.server(), `"${route.path}"`));
|
|
399
529
|
const handler = async (c, next) => {
|
|
400
|
-
const
|
|
530
|
+
const endpointHandler = await loadEndpoint();
|
|
401
531
|
// Hono's `Handler` leaves its return parameter defaulted to `any`, so this hands back exactly what
|
|
402
532
|
// the app's own handler is declared to return — there is nothing narrower to assert here.
|
|
403
533
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
404
534
|
return endpointHandler(c, next);
|
|
405
535
|
};
|
|
406
|
-
|
|
407
|
-
|
|
536
|
+
// De-duplicated, because `app.on(['GET', 'GET'], …)` registers the path twice. Validation refuses
|
|
537
|
+
// `'all'` inside a list, so a list that reaches here is concrete methods only.
|
|
538
|
+
const methods = [...new Set([route.method ?? 'all'].flat())];
|
|
539
|
+
if (methods.includes('all'))
|
|
408
540
|
app.all(route.path, handler);
|
|
409
|
-
else
|
|
410
|
-
app.on(method.toUpperCase(), route.path, handler);
|
|
541
|
+
else {
|
|
542
|
+
app.on(methods.map((method) => method.toUpperCase()), route.path, handler);
|
|
543
|
+
}
|
|
411
544
|
}
|
|
412
545
|
}
|
|
413
546
|
runtime.mountPublicFallback(app);
|
|
414
547
|
app.notFound(async (c) => {
|
|
415
548
|
const isRsc = requestWantsRsc(c.req.raw);
|
|
416
549
|
if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
|
|
417
|
-
|
|
550
|
+
// `notFound: true` here as well as on the signal path: the two produce the same page for the same
|
|
551
|
+
// status, and a payload that says so from one route and not the other is a wire contract with a hole
|
|
552
|
+
// in it. The client reads it to tell "this is the 404 page" from "this is the page you asked for".
|
|
553
|
+
return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true }));
|
|
418
554
|
}
|
|
419
555
|
return plainNotFound(c);
|
|
420
556
|
});
|
|
@@ -430,7 +566,7 @@ function buildApp() {
|
|
|
430
566
|
const res = error.getResponse();
|
|
431
567
|
return c.newResponse(res.body, res);
|
|
432
568
|
}
|
|
433
|
-
reportServerError(error, { source: 'request',
|
|
569
|
+
reportServerError(error, { source: 'request', hono: c, message: '[rshono] request error:' });
|
|
434
570
|
const isRsc = requestWantsRsc(c.req.raw);
|
|
435
571
|
if (loadErrorPage && (isRsc || acceptsHtml(c))) {
|
|
436
572
|
const errorInfo = isDev
|
|
@@ -440,17 +576,26 @@ function buildApp() {
|
|
|
440
576
|
}
|
|
441
577
|
: { message: 'Internal Server Error' };
|
|
442
578
|
try {
|
|
443
|
-
return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo }));
|
|
579
|
+
return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo, returnValue: actionResults.get(c) }));
|
|
444
580
|
}
|
|
445
581
|
catch (renderError) {
|
|
446
|
-
reportServerError(renderError, { source: 'request',
|
|
582
|
+
reportServerError(renderError, { source: 'request', hono: c, message: '[rshono] the error page failed to render:' });
|
|
447
583
|
}
|
|
448
584
|
}
|
|
449
585
|
const detail = isDev ? `\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}` : '';
|
|
450
|
-
|
|
586
|
+
// Same reason as `plainNotFound`, minus the urgency: a 500 is not heuristically cacheable, so this half
|
|
587
|
+
// is consistency — the framework's own answers should not differ in what they promise about caching.
|
|
588
|
+
return c.text(`Internal Server Error${detail}`, 500, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
|
|
451
589
|
});
|
|
452
590
|
return app;
|
|
453
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* Resolves and checks every route's own module — page and endpoint alike, `notFound` and `error` included.
|
|
594
|
+
* Called by `rshono build` once this bundle is imported for the prerender pass, so a route that could never
|
|
595
|
+
* serve a request fails the build rather than answering 500 in production. Exported from here rather than
|
|
596
|
+
* driven from the CLI because the fallback pages are on `routeConfig`, which does not leave this module.
|
|
597
|
+
*/
|
|
598
|
+
export const checkRouteModules = () => assertRouteModules(routeConfig);
|
|
454
599
|
export const app = buildApp();
|
|
455
600
|
/**
|
|
456
601
|
* The app, handed to whatever is hosting it: this binds a port and exports nothing where rshono owns the
|