@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
|
@@ -1,115 +1,208 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
|
+
// Called directly rather than as `<Page {...props} />`: a spread would drop the non-enumerable `ctx`
|
|
5
|
+
// prop. See `pageProps`.
|
|
6
6
|
import { jsx } from 'react/jsx-runtime';
|
|
7
|
-
// The bare specifier, not `/server.node`: the
|
|
8
|
-
//
|
|
9
|
-
// picks one — so a non-Node deploy target gets its own build instead of Node's by hard-coded path.
|
|
7
|
+
// The bare specifier, not `/server.node`: the RSC layer's `conditionNames` picks the build for the
|
|
8
|
+
// deploy target being compiled for.
|
|
10
9
|
import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, renderToReadableStream, } from 'react-server-dom-rspack/server';
|
|
11
|
-
//
|
|
12
|
-
// this file knows anything about where it is running. See `deploy/contract.ts`.
|
|
10
|
+
// Aliased at build time to the selected preset's runtime — the one place this file knows where it runs.
|
|
13
11
|
import { runtime } from '@rshono/deploy';
|
|
14
12
|
// @ts-expect-error — resolved by the '@rshono/routes' alias to the app's routes.ts
|
|
15
13
|
import { routes as userRoutes } from '@rshono/routes';
|
|
16
14
|
// @ts-expect-error — resolved by the '@rshono/server-app' alias (src/server.ts or the empty fallback)
|
|
17
15
|
import * as serverAppModule from '@rshono/server-app';
|
|
18
16
|
import { isPageRoute } from '../router.js';
|
|
19
|
-
import { appendVary, etagMatches } from '../server/headers.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
17
|
+
import { appendVary, etagMatches, varyWith } from '../server/headers.js';
|
|
18
|
+
import { PRERENDER_NONCE_HEADER } from '../server/prerendered.js';
|
|
19
|
+
import { beginPageRender, getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
|
|
20
|
+
import { cameFromPayload, isControlSignal, RedirectSignal } from './control.js';
|
|
22
21
|
import { renderHTML } from './entry.ssr.js';
|
|
22
|
+
import { failureDocument } from './failure-document.js';
|
|
23
23
|
import { RouterProvider } from './navigation.js';
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
import { asksForRsc, isActionRequest, isBrowserFormPost, parseRenderRequest, requestWantsRsc, RSC_VARY_HEADER, wantsRsc } from './request.js';
|
|
25
|
+
import { assertEndpointModule, assertPageModule, assertRouteModules, validateRoutesModule, validateServerApp } from './validate-entries.js';
|
|
26
|
+
const serverApp = validateServerApp(serverAppModule);
|
|
27
|
+
// Compiled into the bundle from rshono.config.ts by DefinePlugin; there is no runtime env-var interface.
|
|
28
|
+
const { isDev } = __RSHONO_CONFIG__;
|
|
29
|
+
/**
|
|
30
|
+
* How long a prerendered page may be reused before revalidating. Also what `public/` files get.
|
|
31
|
+
*
|
|
32
|
+
* Not a config field, deliberately: it is a per-response header, and `rshono.config.ts` is compiled into the
|
|
33
|
+
* bundle — a cache policy you cannot change without a rebuild is the wrong shape. An app that wants a longer
|
|
34
|
+
* `max-age`, or a `stale-while-revalidate`, sets it from middleware **after `await next()`**:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* server.use('/docs/*', async (c, next) => {
|
|
38
|
+
* await next();
|
|
39
|
+
* c.header('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* After, because the response below is built with `cache-control` in the bag it hands `c.body(...)`, and that
|
|
44
|
+
* replaces anything prepared before the handler ran. The `ETag` is untouched either way, so revalidation
|
|
45
|
+
* still costs a 304 rather than the page.
|
|
46
|
+
*
|
|
47
|
+
* `c.header()` rather than `c.res.headers.set(...)`, which is the same choice the response floor makes and
|
|
48
|
+
* for the same reason: it is the one that also works on a response the app did not build. See the floor.
|
|
49
|
+
*/
|
|
36
50
|
const SSG_CACHE_CONTROL = 'public, max-age=300';
|
|
37
51
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
52
|
+
* What a page response gets when nothing else set one: without it a shared cache is free to store a logged-in
|
|
53
|
+
* user's page and hand it to someone else. `private, no-cache` forbids that without blocking bfcache, which
|
|
54
|
+
* `no-store` would.
|
|
40
55
|
*/
|
|
56
|
+
const PAGE_CACHE_CONTROL = 'private, no-cache';
|
|
57
|
+
/** The two content types a page can be served as, from the same URL — which is what makes `Vary` non-optional. */
|
|
41
58
|
const PAGE_CONTENT_TYPE = /^(?:text\/html|text\/x-component)\b/;
|
|
42
|
-
// The CSP is fixed per build apart from the nonce, so assemble everything but `script-src` once.
|
|
43
|
-
const CSP_STATIC = Object.entries(CONFIG.cspDirectives)
|
|
44
|
-
.filter(([name]) => name !== 'script-src')
|
|
45
|
-
.map(([name, value]) => `${name} ${value}`)
|
|
46
|
-
.join('; ');
|
|
47
|
-
const CSP_SCRIPT_SRC = CONFIG.cspDirectives['script-src'] ?? "'self'";
|
|
48
|
-
// Called here rather than at the top of the deploy runtime's own module so the timing is unchanged:
|
|
49
|
-
// `.env` is loaded once every import above has been evaluated, exactly as before.
|
|
50
59
|
runtime.loadEnv();
|
|
51
|
-
|
|
60
|
+
// Checked rather than cast: these two modules are the app's, and a mistake in either used to surface as a
|
|
61
|
+
// `TypeError` from somewhere else entirely — or, for a duplicated path, as nothing at all.
|
|
62
|
+
const routeConfig = validateRoutesModule(userRoutes);
|
|
52
63
|
export const routes = routeConfig.routes;
|
|
53
64
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* the
|
|
58
|
-
*
|
|
59
|
-
* `
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* compared case-insensitively; the scheme is not compared, so this alone won't stop an
|
|
65
|
-
* `http://` origin posting to the `https://` site (HSTS is the control for that).
|
|
65
|
+
* The result of an action that has already run, for the request whose *render* then failed.
|
|
66
|
+
*
|
|
67
|
+
* The action and the page it answers with are one response: an action returns its value through the payload
|
|
68
|
+
* of the page rendered after it. So when that render throws — a page module that will not load, most
|
|
69
|
+
* plausibly a chunk that went away mid-deploy — `onError` renders the `error` page in its place, and without
|
|
70
|
+
* this the reply carries no `returnValue` at all. The caller of an action that ran, and may well have
|
|
71
|
+
* written something, would be told only that a field was missing.
|
|
72
|
+
*
|
|
73
|
+
* Keyed on the Hono context the way `beginPageRender` keys its own marker, and weakly, so nothing outlives
|
|
74
|
+
* the request it belongs to.
|
|
66
75
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
const actionResults = new WeakMap();
|
|
77
|
+
/**
|
|
78
|
+
* Whether this process is `rshono build`'s prerender pass rather than a server answering requests.
|
|
79
|
+
*
|
|
80
|
+
* Set by `build.ts` before it imports the app bundle, which inlines its own copy of the module graph — so
|
|
81
|
+
* `process.env` is what crosses that boundary, the same channel `runtime/context.ts` and the `node` runtime
|
|
82
|
+
* already read. Unforgeable from outside: it is an environment variable of the build process, and a deployed
|
|
83
|
+
* server never has one.
|
|
84
|
+
*/
|
|
85
|
+
const prerendering = typeof process !== 'undefined' && !!process.env?.RSHONO_PRERENDER;
|
|
86
|
+
/**
|
|
87
|
+
* The per-request CSP nonce, if the app asked for one.
|
|
88
|
+
*
|
|
89
|
+
* The framework never mints it: `secureHeaders()` does, when its policy contains the `NONCE`
|
|
90
|
+
* placeholder, and stores it here — so all the framework does is stamp the value into the render. It is
|
|
91
|
+
* readable from a route handler because `secureHeaders` resolves its directives before `next()`.
|
|
92
|
+
*
|
|
93
|
+
* Always `undefined` while prerendering, because a nonce is per request and a prerendered file is not. The
|
|
94
|
+
* pass renders through the app's full middleware, so `secureHeaders()` mints one at *build* time and it was
|
|
95
|
+
* stamped into the document that ships — frozen, and identical in every copy. Which of two bad things that
|
|
96
|
+
* caused depended on the app's policy: under a global nonce policy the file was never served (the request
|
|
97
|
+
* has a nonce of its own, so `mustRenderForNonce` renders it fresh) and the build reported prerendering
|
|
98
|
+
* pages the deployment would never read; under a policy scoped to some other path, `secureHeaders` never ran
|
|
99
|
+
* on this one, nothing forced a re-render, and the stale build-time nonce shipped — picked up by the client
|
|
100
|
+
* as `__webpack_nonce__`. Asking for the document without a nonce is what the flight variant already gets,
|
|
101
|
+
* and it settles both.
|
|
102
|
+
*/
|
|
103
|
+
function cspNonce(c) {
|
|
104
|
+
return prerendering ? undefined : c.get('secureHeadersNonce');
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return
|
|
106
|
+
/**
|
|
107
|
+
* The build-time half of `mustRenderForNonce`, as a header on the document the prerender pass asked for —
|
|
108
|
+
* {@link PRERENDER_NONCE_HEADER}, where the contract is written down.
|
|
109
|
+
*
|
|
110
|
+
* {@link cspNonce} masks the nonce so none is stamped into a file, but *whether one was minted* is exactly
|
|
111
|
+
* what decides that file's fate: a request for this path will mint its own, so the document is re-rendered
|
|
112
|
+
* per request and the copy on disk is never read. Read here rather than through `cspNonce` for that reason —
|
|
113
|
+
* this is the one place the unmasked value is the answer.
|
|
114
|
+
*
|
|
115
|
+
* Empty on a deployed server: `prerendering` is an environment variable of the build process.
|
|
116
|
+
*/
|
|
117
|
+
function prerenderNonceHeader(c) {
|
|
118
|
+
return prerendering && c.get('secureHeadersNonce') !== undefined ? { [PRERENDER_NONCE_HEADER]: '1' } : null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether a `<form action={serverAction}>` post came from another site, and so must not be allowed to run one.
|
|
122
|
+
*
|
|
123
|
+
* This is not CSRF policy — that is `csrf()` from Hono, registered in `src/server.ts`, which runs ahead of
|
|
124
|
+
* every page route and covers far more than this. It is the framework declining to run *its own* action
|
|
125
|
+
* mechanism for a request that mechanism cannot legitimately produce, in the one place the two action shapes
|
|
126
|
+
* are not equally exposed:
|
|
127
|
+
*
|
|
128
|
+
* - A client-initiated action carries `x-rsc-action`, which is not a CORS-simple header. A cross-origin caller
|
|
129
|
+
* needs a preflight it will not be given, so that shape cannot be forged from a browser at all.
|
|
130
|
+
* - A form post is one of the three `enctype` values a browser `<form>` can send, with no header of its own —
|
|
131
|
+
* the content types that need no preflight. It is forgeable, and an app with no `src/server.ts` has nothing
|
|
132
|
+
* standing in front of it. All three, not the two React writes: see {@link isBrowserFormPost}.
|
|
133
|
+
*
|
|
134
|
+
* Both halves are required, because either alone refuses something real:
|
|
135
|
+
*
|
|
136
|
+
* - `Sec-Fetch-Site` is the browser's own statement of provenance, unforgeable by page script, and every
|
|
137
|
+
* browser that can post a form to a server action sends it. `cross-site` and `same-site` are the two labels
|
|
138
|
+
* that mean "not from this origin" — `same-site` is what a *sibling subdomain* gets, which is a user-content
|
|
139
|
+
* host, a stale CNAME or a subdomain takeover, so leaving it to `csrf()` meant an app without one could have
|
|
140
|
+
* any `'use server'` export driven from next door. An absent header means a non-browser client, which cannot
|
|
141
|
+
* be a CSRF victim. `same-origin` and `none` settle it on their own, and are what a genuine post carries
|
|
142
|
+
* however many proxies rewrote `Host` on the way in.
|
|
143
|
+
* - An `Origin` that is the app's own contradicts either label — a browser calls a post from the app's own pages
|
|
144
|
+
* `same-origin` — so the pair is a shape no browser produces, and refusing it would only catch a proxy or a
|
|
145
|
+
* test client setting the label by hand while posting from the app itself. Nothing else clears the label:
|
|
146
|
+
* an `Origin` of `null` (a sandboxed iframe, a `data:` URL, `Referrer-Policy: no-referrer`) and no `Origin`
|
|
147
|
+
* at all are both refused. A browser attaches one to every non-GET request, so neither is a shape it
|
|
148
|
+
* produces — but a security predicate that says "not proven foreign" rather than "proven local" fails open
|
|
149
|
+
* the day something does produce it.
|
|
150
|
+
*
|
|
151
|
+
* `publicUrl(c)` rather than `c.req.url`, so it honours `trustProxy` and compares against the origin the
|
|
152
|
+
* browser actually used — which behind a proxy, `rshono dev`'s included, is not the one the server was reached
|
|
153
|
+
* on.
|
|
154
|
+
*
|
|
155
|
+
* **The cost is wider than "an action you meant to allow".** This runs on the request's shape alone, before
|
|
156
|
+
* the body is read, because knowing whether a given post carries an action means buffering an untrusted body
|
|
157
|
+
* to look for a `$ACTION_*` field. So a **page route cannot accept any cross-site form post at all**, whether
|
|
158
|
+
* or not an action is in it: a SAML ACS callback, OIDC `response_mode=form_post`, and most payment-gateway
|
|
159
|
+
* returns all arrive in exactly this shape and are all refused, `csrf()`'s allowlist included. Refusing
|
|
160
|
+
* before parsing is the right trade — the alternative is reading a body from anyone who asks — but it is a
|
|
161
|
+
* real limitation and the 403 and the README both say so. An `{ type: 'endpoint' }` route is the way to
|
|
162
|
+
* accept one: those call the app handler directly and never reach `renderPage`, so they never reach this.
|
|
163
|
+
*/
|
|
164
|
+
function refusesCrossSiteForm(c) {
|
|
165
|
+
const site = c.req.header('sec-fetch-site');
|
|
166
|
+
if (site !== 'cross-site' && site !== 'same-site')
|
|
167
|
+
return false;
|
|
168
|
+
return c.req.header('origin') !== publicUrl(c).origin;
|
|
100
169
|
}
|
|
101
170
|
/** A browser navigation or a crawler, as opposed to a fetch that would rather have plain text. */
|
|
102
171
|
function acceptsHtml(c) {
|
|
103
172
|
return c.req.header('accept')?.includes('text/html') ?? false;
|
|
104
173
|
}
|
|
105
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Every plain-text answer the framework gives on a page route: the 404s, the refusals on the action path and
|
|
176
|
+
* the last-resort 500. One function, because the header bag is what gets forgotten — and the four call sites
|
|
177
|
+
* used to make three different choices about it, which left the next one to guess.
|
|
178
|
+
*
|
|
179
|
+
* Both headers are set here rather than left to the response floor, which only decorates page *content
|
|
180
|
+
* types* and so never sees a `text/plain` answer.
|
|
181
|
+
*
|
|
182
|
+
* - **`cache-control`** matters in one case and is consistency in the rest. A 404 is heuristically cacheable
|
|
183
|
+
* under RFC 9111, so without this a shared cache may store the plain-text one — while the rendered HTML
|
|
184
|
+
* 404 beside it, the same answer to the same request from a client that asked for HTML, is correctly
|
|
185
|
+
* private. The action refusals answer a POST, which no cache stores, and 400/403/500 are not in that list
|
|
186
|
+
* either; saying it anyway costs a header and settles the question for good.
|
|
187
|
+
* - **`vary: RSC`** because a page URL has two representations, and every one of these is an answer to a
|
|
188
|
+
* request that could have asked for either.
|
|
189
|
+
*/
|
|
190
|
+
function plainRefusal(c, message, status) {
|
|
191
|
+
return c.text(message, status, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The 404 for an app with no `notFound` page, and for a client that wanted neither HTML nor a flight payload.
|
|
195
|
+
*/
|
|
196
|
+
function plainNotFound(c) {
|
|
197
|
+
return plainRefusal(c, 'Not Found', 404);
|
|
198
|
+
}
|
|
199
|
+
/** A lazy once-cell: runs `load` at most once, but clears a rejection so a later call can retry. */
|
|
106
200
|
function once(load) {
|
|
107
201
|
let promise;
|
|
108
202
|
return () => {
|
|
109
203
|
if (!promise) {
|
|
110
204
|
const pending = (promise = load());
|
|
111
205
|
pending.catch(() => {
|
|
112
|
-
// Only clear if we're still holding the rejected promise (a later successful load may have already replaced it).
|
|
113
206
|
if (promise === pending)
|
|
114
207
|
promise = undefined;
|
|
115
208
|
});
|
|
@@ -118,90 +211,126 @@ function once(load) {
|
|
|
118
211
|
};
|
|
119
212
|
}
|
|
120
213
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* guarding the *stream*, not just the function scope). The timer is released on
|
|
124
|
-
* exactly one of: the stream finishing ({@link RenderDeadline.guard}), an explicit
|
|
125
|
-
* {@link RenderDeadline.clear} on an error path, or the signal aborting (client
|
|
126
|
-
* disconnect, or the deadline firing itself). A manually-cleared timer instead of
|
|
127
|
-
* `AbortSignal.timeout()` so a fast response doesn't leave one pending to fire later.
|
|
128
|
-
*
|
|
129
|
-
* One deadline covers the whole request — server action included, not just the render — so a
|
|
130
|
-
* hung action can't pin a socket open indefinitely either.
|
|
214
|
+
* Passes `stream` through untouched, calling `done` however it ends — for the flight-only response
|
|
215
|
+
* path, which has no transform of its own to hang a completion hook off. `done` must be idempotent.
|
|
131
216
|
*/
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const signal = AbortSignal.any([requestSignal, controller.signal]);
|
|
137
|
-
const clear = () => clearTimeout(timer);
|
|
138
|
-
signal.addEventListener('abort', clear, { once: true });
|
|
139
|
-
return {
|
|
140
|
-
signal,
|
|
141
|
-
clear,
|
|
142
|
-
guard: (stream) => stream.pipeThrough(new TransformStream({ flush: clear })),
|
|
143
|
-
race(work) {
|
|
144
|
-
if (signal.aborted)
|
|
145
|
-
return Promise.reject(signal.reason);
|
|
146
|
-
const { promise: aborted, reject } = Promise.withResolvers();
|
|
147
|
-
const onAbort = () => reject(signal.reason);
|
|
148
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
149
|
-
// `Promise.race` subscribes to `work` either way, so abandoning it can't surface as an
|
|
150
|
-
// unhandled rejection. Detach on settle so a long-lived signal doesn't accumulate listeners.
|
|
151
|
-
return Promise.race([work, aborted]).finally(() => signal.removeEventListener('abort', onAbort));
|
|
217
|
+
function releaseWhenDone(stream, done) {
|
|
218
|
+
return stream.pipeThrough(new TransformStream({
|
|
219
|
+
transform(chunk, controller) {
|
|
220
|
+
controller.enqueue(chunk);
|
|
152
221
|
},
|
|
222
|
+
flush: done,
|
|
223
|
+
cancel: done,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A macrotask boundary: `setImmediate` where there is one — the current turn's check phase rather than a
|
|
228
|
+
* timer — and `setTimeout` as the portable fallback, for a runtime without it. `flight-inject.ts` has the
|
|
229
|
+
* same two lines and the same reason; they are not shared because that module belongs to the SSR layer, and
|
|
230
|
+
* importing it here would give the RSC layer its own instance of it.
|
|
231
|
+
*/
|
|
232
|
+
const deferTask = typeof setImmediate === 'function'
|
|
233
|
+
? setImmediate
|
|
234
|
+
: (run) => {
|
|
235
|
+
setTimeout(run, 0);
|
|
153
236
|
};
|
|
237
|
+
/**
|
|
238
|
+
* Dev-only: says that a control signal arrived too late to be one.
|
|
239
|
+
*
|
|
240
|
+
* The root fix is in app code, so authoring time is where this has to be said, and a docs paragraph is not
|
|
241
|
+
* where anyone reads it. `isDev` is the baked build flag, so a production server pays one boolean check on a
|
|
242
|
+
* path that has already gone wrong — and says nothing.
|
|
243
|
+
*/
|
|
244
|
+
function warnLateControlSignal(c, signal) {
|
|
245
|
+
const isRedirect = signal instanceof RedirectSignal;
|
|
246
|
+
console.warn(`[rshono] ${isRedirect ? `redirect(${JSON.stringify(signal.location)})` : 'notFound()'} was called from a boundary that ` +
|
|
247
|
+
`resolved after the page shell had already been sent (${c.req.method} ${c.req.path}), so it cannot become a real ` +
|
|
248
|
+
`${isRedirect ? '3xx' : '404'}: the response is committed as 200 text/html. A browser with JavaScript follows the ` +
|
|
249
|
+
`digest that rides the payload; one without stays on the Suspense fallback, and a crawler indexes the 200.${isRedirect
|
|
250
|
+
? ''
|
|
251
|
+
: ' 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.'}` +
|
|
252
|
+
' Decide before the render starts streaming — in Hono middleware, or in the page component body above the boundary.');
|
|
154
253
|
}
|
|
155
254
|
/**
|
|
156
255
|
* Builds the props a page component is called with.
|
|
157
256
|
*
|
|
158
|
-
* `
|
|
159
|
-
* a `'use client'` component unchanged. The `URL` is this page's own — `Ctx` parses its own — so a
|
|
160
|
-
* page that mutates it cannot disturb anything else on the request.
|
|
161
|
-
*
|
|
162
|
-
* `ctx` is *defined* rather than assigned, and both parts of how carry their weight:
|
|
257
|
+
* `ctx` is *defined* rather than assigned, and both halves are load-bearing:
|
|
163
258
|
*
|
|
164
|
-
* - **A getter**, so nothing is built for the pages that never read it, and
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* enumerable properties, and `ctx.raw` is the Hono {@link Context} — whose `env` holds the
|
|
170
|
-
* runtime's bindings. An enumerable `ctx` ships every one of them, secrets included, to the
|
|
171
|
-
* browser in dev, and grows a small page's flight payload by well over 10 kB. Production never
|
|
172
|
-
* serializes a server component's props at all, so this is the dev half of the same guarantee.
|
|
259
|
+
* - **A getter**, so nothing is built for the pages that never read it, and a `render: 'static'` page
|
|
260
|
+
* that does gets {@link getRequestContext}'s prerendering error rather than a bare `undefined`.
|
|
261
|
+
* - **Non-enumerable**, so React's dev-only serialization of a server component's props skips it. That
|
|
262
|
+
* walks own enumerable properties, and `ctx.hono.env` holds the runtime's bindings — an enumerable
|
|
263
|
+
* `ctx` would ship every secret to the browser in dev.
|
|
173
264
|
*
|
|
174
|
-
* The cost
|
|
175
|
-
*
|
|
265
|
+
* The cost: the element is created by handing this object to `jsx()` by reference, since a
|
|
266
|
+
* `<Page {...props} />` spread would silently drop `ctx`.
|
|
176
267
|
*/
|
|
177
268
|
function pageProps(c, errorInfo) {
|
|
178
269
|
const props = { url: publicUrl(c), params: readParams(c), ...(errorInfo ? { error: errorInfo } : null) };
|
|
179
|
-
Object.defineProperty(props, 'ctx', { get:
|
|
270
|
+
Object.defineProperty(props, 'ctx', { get: getRequestContext, enumerable: false, configurable: true });
|
|
180
271
|
return props;
|
|
181
272
|
}
|
|
182
273
|
async function renderComponent(c, Page, opts) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
274
|
+
// React's renderers must never be handed `c.req.raw.signal`: they add an `abort` listener and only
|
|
275
|
+
// remove it if the abort fires, so on the happy path it stays on a request-lifetime signal and pins the
|
|
276
|
+
// whole rendered tree. The render gets its own controller and the request signal forwards into it —
|
|
277
|
+
// `release` detaches on the normal path. Not `AbortSignal.any()`, whose composite signals Node holds in
|
|
278
|
+
// a process-lifetime set until they abort.
|
|
279
|
+
const requestSignal = c.req.raw.signal;
|
|
280
|
+
const renderAbort = new AbortController();
|
|
281
|
+
const signal = renderAbort.signal;
|
|
282
|
+
const forwardAbort = () => renderAbort.abort(requestSignal.reason);
|
|
283
|
+
if (requestSignal.aborted)
|
|
284
|
+
renderAbort.abort(requestSignal.reason);
|
|
285
|
+
else
|
|
286
|
+
requestSignal.addEventListener('abort', forwardAbort, { once: true });
|
|
287
|
+
const release = () => requestSignal.removeEventListener('abort', forwardAbort);
|
|
288
|
+
// Documents only: the nonce goes on the bootstrap scripts and the `<meta>` React hydrates from, and a
|
|
289
|
+
// flight payload has neither — which is what keeps a prerendered one servable under a nonce-based CSP.
|
|
290
|
+
const nonce = opts.isRsc ? undefined : cspNonce(c);
|
|
186
291
|
const props = pageProps(c, opts.errorInfo);
|
|
187
292
|
const root = (_jsxs(_Fragment, { children: [nonce && _jsx("meta", { property: "csp-nonce", nonce: nonce }), Page.entryCssFiles?.map((href) => (_jsx("link", { rel: "stylesheet", href: href, precedence: "default" }, href))), _jsx(RouterProvider, { href: props.url.href, params: props.params, children: jsx(Page, props) })] }));
|
|
188
|
-
// `notFound` only when
|
|
293
|
+
// `notFound` only when true, so an ordinary page's payload doesn't carry the key.
|
|
189
294
|
const rscPayload = { root, formState: opts.formState, returnValue: opts.returnValue, ...(opts.notFound ? { notFound: true } : null) };
|
|
295
|
+
// The last thing before the render: past here the response head is the framework's, so `ctx.setHeader()`
|
|
296
|
+
// starts throwing — while the action `renderPage` just ran, and the middleware around it, legitimately wrote.
|
|
297
|
+
beginPageRender(c);
|
|
190
298
|
let controlSignal;
|
|
299
|
+
/** Set once `renderHTML` has returned, which is where the response head stops being changeable. */
|
|
300
|
+
let shellFlushed = false;
|
|
191
301
|
const rscStream = renderToReadableStream(rscPayload, {
|
|
192
302
|
temporaryReferences: opts.temporaryReferences,
|
|
193
303
|
signal,
|
|
194
304
|
onError(error) {
|
|
195
305
|
if (isControlSignal(error)) {
|
|
196
306
|
controlSignal = error;
|
|
307
|
+
if (shellFlushed) {
|
|
308
|
+
if (isDev)
|
|
309
|
+
warnLateControlSignal(c, error);
|
|
310
|
+
// Past the shell the status line, the headers and the first bytes are on the wire, so the digest
|
|
311
|
+
// React writes into the payload is the only path left — and everything still rendering is for a
|
|
312
|
+
// page the browser is about to navigate away from. Winding it down stops those boundaries, runs
|
|
313
|
+
// `flight-inject`'s cancel/flush, and fires the `release()` above now rather than whenever the
|
|
314
|
+
// doomed render happens to finish.
|
|
315
|
+
//
|
|
316
|
+
// One macrotask later rather than from inside `onError`, where React is still handling the error
|
|
317
|
+
// that produced the digest — the row carrying it is the only recovery the client has, and an abort
|
|
318
|
+
// that re-entered React there could cut the render off before it is written. Measured to survive
|
|
319
|
+
// either way on react-server-dom-rspack 0.1.0; deferred anyway, because that ordering is an
|
|
320
|
+
// internal the `^19.1.0` peer range does not promise, and the cost is one macrotask on a render
|
|
321
|
+
// that is already doomed. The reason is the signal itself, so every boundary the abort errors
|
|
322
|
+
// carries the digest rather than a bare AbortError the client would paint as a fault.
|
|
323
|
+
if (!signal.aborted)
|
|
324
|
+
deferTask(() => renderAbort.abort(error));
|
|
325
|
+
}
|
|
197
326
|
return error.digest;
|
|
198
327
|
}
|
|
199
328
|
if (!signal.aborted)
|
|
200
|
-
reportServerError(error, { source: 'render',
|
|
329
|
+
reportServerError(error, { source: 'render', hono: c, message: '[rshono] render error:' });
|
|
201
330
|
},
|
|
202
331
|
});
|
|
203
332
|
if (opts.isRsc) {
|
|
204
|
-
return c.body(
|
|
333
|
+
return c.body(releaseWhenDone(rscStream, release), (opts.status ?? 200), {
|
|
205
334
|
'content-type': 'text/x-component;charset=utf-8',
|
|
206
335
|
});
|
|
207
336
|
}
|
|
@@ -212,77 +341,266 @@ async function renderComponent(c, Page, opts) {
|
|
|
212
341
|
formState: opts.formState,
|
|
213
342
|
signal,
|
|
214
343
|
nonce,
|
|
215
|
-
|
|
216
|
-
|
|
344
|
+
onDone: release,
|
|
345
|
+
onShellError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR shell error:' }),
|
|
346
|
+
onError: (error) => reportServerError(error, { source: 'ssr', hono: c, message: '[rshono] SSR error:' }),
|
|
217
347
|
});
|
|
218
348
|
}
|
|
219
349
|
catch (error) {
|
|
220
|
-
|
|
350
|
+
// The render is abandoned, so stop it: a boundary still resolving is work for a response that will never
|
|
351
|
+
// be sent, and it holds the tee's SSR branch open behind it. The `signal.aborted` guard in `onError`
|
|
352
|
+
// above is what keeps the resulting cancellations from being reported as render errors of their own.
|
|
353
|
+
renderAbort.abort(error);
|
|
354
|
+
release();
|
|
221
355
|
if (controlSignal)
|
|
222
356
|
throw controlSignal;
|
|
223
357
|
throw error;
|
|
224
358
|
}
|
|
359
|
+
// `renderHTML` returns at *shell ready*, so from here the response is the one that ships. Set before the
|
|
360
|
+
// check below rather than after it, so nothing lands in the window between the two: a signal that arrives
|
|
361
|
+
// in it is handled there *and* schedules an abort, and aborting an aborted controller is a no-op.
|
|
362
|
+
shellFlushed = true;
|
|
225
363
|
if (controlSignal) {
|
|
226
|
-
|
|
364
|
+
// The shell resolved, so this response is live: React is being pumped into the payload-injecting
|
|
365
|
+
// transform, and a `redirect()` that surfaced from a boundary settling just before the shell was ready
|
|
366
|
+
// lands here. Nothing will read that stream now — the signal becomes a redirect instead — so it is stopped
|
|
367
|
+
// rather than left to render to completion for a response that was replaced.
|
|
368
|
+
//
|
|
369
|
+
// Both calls, because they stop different halves. `abort` reaches the two renders through the signal they
|
|
370
|
+
// were handed; cancelling the response readable propagates back through the transform to release the teed
|
|
371
|
+
// flight branch it holds a reader on, which `abort` alone does not.
|
|
372
|
+
renderAbort.abort();
|
|
373
|
+
void ssrResult.stream.cancel().catch(() => {
|
|
374
|
+
// Already errored or locked — there is nothing left to release either way.
|
|
375
|
+
});
|
|
376
|
+
release();
|
|
227
377
|
throw controlSignal;
|
|
228
378
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
379
|
+
return c.body(ssrResult.stream, (opts.status ?? 200), {
|
|
380
|
+
'content-type': 'text/html;charset=utf-8',
|
|
381
|
+
...prerenderNonceHeader(c),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The answer to an action request whose body could not be read or decoded — a malformed request, the same
|
|
386
|
+
* class of thing as the unknown-action-id 400 beside it, and answered the same way.
|
|
387
|
+
*
|
|
388
|
+
* Deliberately silent. Action ids are public — they are bare string literals in the client chunks — so
|
|
389
|
+
* anyone can post a valid id with a body that will not decode, and while that reached `reportServerError` it
|
|
390
|
+
* was an unauthenticated way to page whoever owns the error tracker, once per request. Nothing here is the
|
|
391
|
+
* server being wrong, so there is nothing to report; the status is the whole message.
|
|
392
|
+
*
|
|
393
|
+
* The text says no more than that on purpose: which of `text()`, `formData()`, `decodeReply` or
|
|
394
|
+
* `decodeAction` gave up is React's or undici's internal shape, and repeating it back would describe the
|
|
395
|
+
* framework's decoding to a caller who cannot act on it.
|
|
396
|
+
*/
|
|
397
|
+
function malformedAction(c) {
|
|
398
|
+
return plainRefusal(c, 'Bad Request: malformed server action request', 400);
|
|
399
|
+
}
|
|
400
|
+
/** The field name React gives the action id of a form with no `useActionState`. See {@link formActionId}. */
|
|
401
|
+
const ACTION_ID_FIELD = '$ACTION_ID_';
|
|
402
|
+
/**
|
|
403
|
+
* The action id a `<form action={serverAction}>` post names in a field *name*, or `null` for the shape that
|
|
404
|
+
* does not.
|
|
405
|
+
*
|
|
406
|
+
* Two shapes reach `decodeAction`, and only one puts the id somewhere readable. A form with no
|
|
407
|
+
* `useActionState` posts a bare `$ACTION_ID_<id>` field, whose name is what `decodeAction` itself matches on
|
|
408
|
+
* — so reading it here is not a second decoder of React's form format. `useActionState`'s shape puts the id
|
|
409
|
+
* *inside* an encoded `$ACTION_<n>:0` value instead, and reading that would be exactly the drift
|
|
410
|
+
* `decodeFormState`'s guard below is about: two decoders disagreeing about a wire format neither owns.
|
|
411
|
+
*/
|
|
412
|
+
function formActionId(formData) {
|
|
413
|
+
for (const key of formData.keys()) {
|
|
414
|
+
if (key.startsWith(ACTION_ID_FIELD))
|
|
415
|
+
return key.slice(ACTION_ID_FIELD.length);
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The first action the server bundle holds itself, as the id to ask `loadServerAction` about when a form
|
|
421
|
+
* post names none this can see.
|
|
422
|
+
*
|
|
423
|
+
* Entries declaring `chunks` are skipped for {@link checkServerActions}'s reason: `loadServerAction` is a
|
|
424
|
+
* bare `__webpack_require__`, so a module that has not been loaded yet throws for a reason that says nothing
|
|
425
|
+
* about the deployment — and a probe that answers "broken" for a healthy app is the one failure direction
|
|
426
|
+
* this must not have.
|
|
427
|
+
*/
|
|
428
|
+
const probeActionId = (() => {
|
|
429
|
+
for (const [id, entry] of Object.entries(__rspack_rsc_manifest__.serverManifest)) {
|
|
430
|
+
if ((entry.chunks?.length ?? 0) === 0)
|
|
431
|
+
return id;
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
})();
|
|
435
|
+
/**
|
|
436
|
+
* Whether the app's `'use server'` module is what `decodeAction` could not load, rather than the caller's
|
|
437
|
+
* body being what it could not decode.
|
|
438
|
+
*
|
|
439
|
+
* Asked by loading an action for real, because nothing on the error says which of the two it was: React
|
|
440
|
+
* reports a missing module and an undecodable field through the same channel, and the message is its own
|
|
441
|
+
* internal shape either way. `loadServerAction` is `__webpack_require__` plus a `typeof === 'function'`
|
|
442
|
+
* check, so a second call after the first succeeded is a cache hit.
|
|
443
|
+
*
|
|
444
|
+
* The id it asks about is the one the body names where the body names one, and otherwise any of the app's —
|
|
445
|
+
* which is the same question, because Rspack concatenates the whole `'use server'` graph into a single
|
|
446
|
+
* server module (see {@link checkServerActions} and the G1 note): if one action cannot be loaded at run
|
|
447
|
+
* time, none of them can.
|
|
448
|
+
*
|
|
449
|
+
* **Both ways of being wrong land on the old behaviour, deliberately.** No manifest to probe, or a probe
|
|
450
|
+
* that succeeds while the id the caller actually named is broken — which needs `useActionState`'s shape
|
|
451
|
+
* *and* a per-entry manifest corruption rather than a module that will not evaluate — answers `false`, and
|
|
452
|
+
* the 400 stands exactly as it did. What must never happen is the other direction: a malformed body
|
|
453
|
+
* answering 500 and paging whoever owns the error tracker, which is what {@link malformedAction} exists to
|
|
454
|
+
* prevent. A probe that throws is the deployment being broken for every caller, so a 500 is then right for
|
|
455
|
+
* this one too.
|
|
456
|
+
*/
|
|
457
|
+
function cannotLoadServerActions(formData) {
|
|
458
|
+
const named = formActionId(formData);
|
|
459
|
+
const id = named !== null && Object.hasOwn(__rspack_rsc_manifest__.serverManifest, named) ? named : probeActionId;
|
|
460
|
+
if (id === undefined)
|
|
461
|
+
return false;
|
|
462
|
+
try {
|
|
463
|
+
loadServerAction(id);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return true;
|
|
236
468
|
}
|
|
237
|
-
return c.body(deadline.guard(ssrResult.stream), (ssrResult.status ?? opts.status ?? 200), headers);
|
|
238
469
|
}
|
|
239
470
|
async function renderPage(c, loadPage) {
|
|
240
471
|
const request = c.req.raw;
|
|
472
|
+
// Ahead of the classification below, and keyed on the request's *shape* rather than on it: every `enctype`
|
|
473
|
+
// a browser form can post is refused, not only the two React writes. `text/plain` is the third, and keying
|
|
474
|
+
// this off `parseRenderRequest` left it classified `document` and let through — the one enctype the
|
|
475
|
+
// framework never decodes was also the one it never refused, while the README promised that a page route
|
|
476
|
+
// refuses every cross-site form post. See {@link isBrowserFormPost} and `refusesCrossSiteForm`.
|
|
477
|
+
if (isBrowserFormPost(request) && refusesCrossSiteForm(c)) {
|
|
478
|
+
// Named for what was actually refused. The check runs before the body is read — it has to, since knowing
|
|
479
|
+
// whether a post carries an action means buffering an untrusted body — so "to a server action" claimed
|
|
480
|
+
// something this code cannot know, and said it to a caller whose post very often has no action in it.
|
|
481
|
+
return plainRefusal(c, "Forbidden: cross-site form post to a page route — a page route cannot accept one, because a form post to a page is how a server action is called. Use an { type: 'endpoint' } route.", 403);
|
|
482
|
+
}
|
|
241
483
|
const renderRequest = parseRenderRequest(request);
|
|
242
|
-
// Created before the action runs so the deadline covers the whole request, then handed to
|
|
243
|
-
// `renderComponent` so the render doesn't get a fresh budget of its own.
|
|
244
|
-
const deadline = createRenderDeadline(request.signal, renderTimeoutMs);
|
|
245
484
|
let returnValue;
|
|
246
485
|
let formState;
|
|
247
486
|
let temporaryReferences;
|
|
248
487
|
let actionStatus;
|
|
249
488
|
if (isActionRequest(renderRequest)) {
|
|
250
|
-
if (!isSameOriginAction(c)) {
|
|
251
|
-
deadline.clear();
|
|
252
|
-
return c.text('Forbidden: cross-origin server action rejected', 403);
|
|
253
|
-
}
|
|
254
489
|
if (renderRequest.kind === 'rsc-action') {
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// request into an unhandled 500. `hasOwn` so `__proto__` doesn't resolve to a manifest entry.
|
|
490
|
+
// Before the body is decoded, so an unknown id costs nothing to reject. `hasOwn` so `__proto__`
|
|
491
|
+
// does not resolve to a manifest entry.
|
|
258
492
|
if (!Object.hasOwn(__rspack_rsc_manifest__.serverManifest, renderRequest.actionId)) {
|
|
259
|
-
|
|
260
|
-
|
|
493
|
+
return plainRefusal(c, 'Bad Request: unknown server action', 400);
|
|
494
|
+
}
|
|
495
|
+
let args;
|
|
496
|
+
try {
|
|
497
|
+
const contentType = request.headers.get('content-type');
|
|
498
|
+
const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text();
|
|
499
|
+
temporaryReferences = createTemporaryReferenceSet();
|
|
500
|
+
args = await decodeReply(body, { temporaryReferences });
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return malformedAction(c);
|
|
504
|
+
}
|
|
505
|
+
// Outside that guard, deliberately. Everything inside it reads or decodes the *body*, which is the
|
|
506
|
+
// caller's to get wrong; loading the action is not. The id is one `hasOwn` above proved the manifest
|
|
507
|
+
// holds, so no client can steer this — what is left is the bundle being incomplete: a chunk
|
|
508
|
+
// `__webpack_require__` cannot find after a partial deploy, or a module that throws as it evaluates.
|
|
509
|
+
// A 400 there would tell the one caller in that guard who is *not* at fault that they are, and tell
|
|
510
|
+
// the operator who needs paging nothing at all.
|
|
511
|
+
let action;
|
|
512
|
+
try {
|
|
513
|
+
action = loadServerAction(renderRequest.actionId);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
// Attributed to the action rather than left to the top-level handler's `source: 'request'`: the
|
|
517
|
+
// request was fine, the deployment is not. Then re-thrown, so the app's `error` page answers 500 —
|
|
518
|
+
// `reportServerError` de-duplicates, so `onError` reporting it again is a no-op.
|
|
519
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action could not be loaded:' });
|
|
520
|
+
throw error;
|
|
261
521
|
}
|
|
262
|
-
const contentType = request.headers.get('content-type');
|
|
263
|
-
const body = contentType?.startsWith('multipart/form-data') ? await request.formData() : await request.text();
|
|
264
|
-
temporaryReferences = createTemporaryReferenceSet();
|
|
265
|
-
const args = await decodeReply(body, { temporaryReferences });
|
|
266
|
-
const action = loadServerAction(renderRequest.actionId);
|
|
267
522
|
try {
|
|
268
|
-
returnValue = { ok: true, value: await
|
|
523
|
+
returnValue = { ok: true, value: await action(...args) };
|
|
269
524
|
}
|
|
270
525
|
catch (error) {
|
|
271
526
|
if (isControlSignal(error))
|
|
272
527
|
throw error;
|
|
273
|
-
// React sends a thrown action error to the client as an opaque marker
|
|
274
|
-
//
|
|
275
|
-
reportServerError(error, { source: 'action',
|
|
528
|
+
// In production React sends a thrown action error to the client as an opaque marker, so this is
|
|
529
|
+
// the only place the real one is visible.
|
|
530
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
|
|
276
531
|
returnValue = { ok: false, error };
|
|
277
532
|
actionStatus = 500;
|
|
278
533
|
}
|
|
534
|
+
// The action is done and its result is the caller's, whatever becomes of the render below. See
|
|
535
|
+
// {@link actionResults}.
|
|
536
|
+
actionResults.set(c, returnValue);
|
|
279
537
|
}
|
|
280
538
|
else {
|
|
281
|
-
|
|
282
|
-
|
|
539
|
+
// A `<form action={serverAction}>` post, which is the path that runs before hydration and with
|
|
540
|
+
// JavaScript off. Unlike the client-initiated one it carries no custom header, so it is also the only
|
|
541
|
+
// action shape a browser can be made to send from another site — refused above, on the shape of the
|
|
542
|
+
// request rather than on this classification: see `refusesCrossSiteForm`.
|
|
543
|
+
let formData;
|
|
544
|
+
try {
|
|
545
|
+
formData = await request.formData();
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return malformedAction(c);
|
|
549
|
+
}
|
|
550
|
+
let decodedAction;
|
|
551
|
+
try {
|
|
552
|
+
decodedAction = await decodeAction(formData);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
// Split from the read above, for the reason F3 split the client-initiated path: `decodeAction` is
|
|
556
|
+
// two things at once. It reads the caller's body *and* — through `loadServerReference` —
|
|
557
|
+
// `__webpack_require__`s the module the action lives in, so the 400 that is right about a body is
|
|
558
|
+
// wrong whenever the deployment is what failed. Which of the two it was is nowhere on the error, so
|
|
559
|
+
// it is asked directly. See {@link cannotLoadServerActions}.
|
|
560
|
+
if (cannotLoadServerActions(formData)) {
|
|
561
|
+
// The original error, not the probe's: this one is the fault the request actually met. Attributed
|
|
562
|
+
// and re-thrown exactly as the `rsc-action` branch does, so the app's `error` page answers 500 and
|
|
563
|
+
// the operator is paged instead of the caller being blamed.
|
|
564
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action could not be loaded:' });
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
return malformedAction(c);
|
|
568
|
+
}
|
|
283
569
|
if (decodedAction) {
|
|
284
|
-
|
|
285
|
-
|
|
570
|
+
let result;
|
|
571
|
+
try {
|
|
572
|
+
result = await decodedAction();
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (isControlSignal(error))
|
|
576
|
+
throw error;
|
|
577
|
+
// Reported here so a no-JS form post is attributed to the action that threw, exactly as the
|
|
578
|
+
// client-initiated path is — the top-level handler would call it a `request`. Then re-thrown,
|
|
579
|
+
// because this path has no client boundary and no `useActionState` to hand the error to, so the
|
|
580
|
+
// app's `error` page is the honest answer. `reportServerError` de-duplicates, so the re-throw
|
|
581
|
+
// reaching `onError` does not report it a second time.
|
|
582
|
+
reportServerError(error, { source: 'action', hono: c, message: '[rshono] server action error:' });
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
formState = (await decodeFormState(result, formData)) ?? undefined;
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
// The action has already run, and may well have written something, so this cannot be refused the
|
|
590
|
+
// way an undecodable body *before* it is — the guard above answers 400 precisely because nothing
|
|
591
|
+
// has happened yet. What failed here is rebuilding the `useActionState` key from the body's
|
|
592
|
+
// `$ACTION_REF_` metadata, and a body React wrote always carries it: this is reachable only from a
|
|
593
|
+
// hand-made one, which pairs a `$ACTION_ID_` React took for the call with `$ACTION_REF_`/
|
|
594
|
+
// `$ACTION_KEY` fields it did not write. So the page is rendered with no form state, which is
|
|
595
|
+
// exactly what a form without `useActionState` gets.
|
|
596
|
+
//
|
|
597
|
+
// Silent for the same reason `malformedAction` is: action ids are public, so anyone can post one,
|
|
598
|
+
// and reporting from here would put an unauthenticated caller back in touch with whoever owns the
|
|
599
|
+
// error tracker — for a request that has already had its effects. In dev it is worth a line, since
|
|
600
|
+
// there the likely cause is a React or bundler version whose form fields this does not understand.
|
|
601
|
+
if (isDev)
|
|
602
|
+
console.warn('[rshono] a form post carried form-state fields that could not be decoded; rendering without them:', error);
|
|
603
|
+
}
|
|
286
604
|
}
|
|
287
605
|
}
|
|
288
606
|
}
|
|
@@ -293,102 +611,212 @@ async function renderPage(c, loadPage) {
|
|
|
293
611
|
formState,
|
|
294
612
|
returnValue,
|
|
295
613
|
temporaryReferences,
|
|
296
|
-
deadline,
|
|
297
614
|
});
|
|
298
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* A thrown value as an `Error`, because that is what Hono's dispatcher requires before it will hand one to
|
|
618
|
+
* `app.onError`.
|
|
619
|
+
*
|
|
620
|
+
* `compose` routes a rejection to the error handler only when it is `instanceof Error`, and **re-throws
|
|
621
|
+
* anything else** — which rejects `app.fetch` and leaves the answer to whatever is hosting it. Under
|
|
622
|
+
* `@hono/node-server` that is a bodiless 500 raised outside the app: no `error` page, nothing on stderr, no
|
|
623
|
+
* `onServerError` report, and not even the response floor's `x-content-type-options` / `referrer-policy` /
|
|
624
|
+
* `x-frame-options`, since nothing below it ever unwinds. So `throw 'a plain string'` from an endpoint used
|
|
625
|
+
* to answer strictly worse than `throw new Error('…')` beside it, and `RouteConfig.error` promises the page
|
|
626
|
+
* for a throw from "an endpoint, a server action, or middleware" — all three reachable this way.
|
|
627
|
+
*
|
|
628
|
+
* The test is `instanceof Error` and nothing wider, deliberately: it has to be the *same* test `compose`
|
|
629
|
+
* makes, so that what this converts is exactly what Hono would have re-thrown. A duck-typed check would
|
|
630
|
+
* convert a cross-realm `Error` the dispatcher was about to handle, and leave one it was not.
|
|
631
|
+
*
|
|
632
|
+
* Converting only a non-`Error` is also what keeps the rest of the error path intact: both control signals
|
|
633
|
+
* extend `Error`, `cameFromPayload` reads a `digest` off the thrown object, and `reportServerError`
|
|
634
|
+
* de-duplicates on its identity — a wrapper around any of those would break all three.
|
|
635
|
+
*
|
|
636
|
+
* The value goes on `cause` rather than into the message alone. It is the only thing that carries what the
|
|
637
|
+
* app actually threw, an error tracker walks it, and the wrapper's own `stack` starts *here*, in the
|
|
638
|
+
* framework, so it says nothing about where the throw came from.
|
|
639
|
+
*/
|
|
640
|
+
function asError(value) {
|
|
641
|
+
if (value instanceof Error)
|
|
642
|
+
return value;
|
|
643
|
+
let described;
|
|
644
|
+
try {
|
|
645
|
+
// Quoted for a string, so an empty one is still visible; anything else prints however it prints. A
|
|
646
|
+
// `toString` that throws in its turn leaves only the type to say, and this must not fail in a catch.
|
|
647
|
+
described = typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
described = typeof value;
|
|
651
|
+
}
|
|
652
|
+
return new Error(`[rshono] a non-Error value was thrown: ${described}`, { cause: value });
|
|
653
|
+
}
|
|
299
654
|
function buildApp() {
|
|
300
655
|
const app = new Hono();
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
app.use(runtime.compress);
|
|
305
|
-
// Cheap, unconditional headers that only matter when something else has gone wrong: stop
|
|
306
|
-
// content-type sniffing, keep the full URL (paths, query) out of cross-origin referrers, and
|
|
307
|
-
// refuse to be framed by another origin (clickjacking). `frame-ancestors` in the opt-in CSP is
|
|
308
|
-
// stricter and takes precedence where both apply; this is the floor for everyone else.
|
|
309
|
-
// Set after `next()` so a route or middleware that sets its own value wins.
|
|
656
|
+
// A floor, not a policy: an app wanting the full set registers `secureHeaders()` in src/server.ts, and
|
|
657
|
+
// because this is registered first it unwinds last, so the "only if unset" checks stand aside for it.
|
|
658
|
+
// Owned by the framework so that an app with no src/server.ts at all still gets it.
|
|
310
659
|
app.use(async (c, next) => {
|
|
311
660
|
await next();
|
|
312
661
|
const headers = c.res.headers;
|
|
662
|
+
/**
|
|
663
|
+
* The headers this response still needs, collected before any of them is written — because the writing
|
|
664
|
+
* is the part that cannot go through `headers.set(…)`.
|
|
665
|
+
*
|
|
666
|
+
* A handler is free to return a `Response` it did not build. `Response.redirect(…)` and every result of
|
|
667
|
+
* `fetch()` carry an **immutable** header bag, and handing one straight back is ordinary Hono — proxying
|
|
668
|
+
* an upstream is the commonest thing a Worker does. `.set()` on one throws `TypeError: immutable`, which
|
|
669
|
+
* is what this floor used to answer such a handler with: the throw reached `onError`, the app's 500 page
|
|
670
|
+
* went out in place of the redirect, and the app's error tracker got `immutable` and a minified frame
|
|
671
|
+
* naming nothing anyone could act on.
|
|
672
|
+
*
|
|
673
|
+
* Invisible on the targets anyone develops against, which is the whole hazard: `@hono/node-server`
|
|
674
|
+
* installs a mutable `Response` shim as a global, so `node` and `vercel` — dev, `rshono start` and every
|
|
675
|
+
* suite that drives them — cannot see the bug that `cloudflare` and `aws-lambda` will. The framework
|
|
676
|
+
* already knew the mechanism: it is written on `serveAsset` in `cloudflare/runtime.ts`, which repairs the
|
|
677
|
+
* framework's *own* asset responses by hand.
|
|
678
|
+
*
|
|
679
|
+
* `c.header()` is the repair, and Hono's own — it rebuilds a finalized response as
|
|
680
|
+
* `new Response(res.body, res)` before writing. It does that on *every* call, so collecting is what keeps
|
|
681
|
+
* it to one rebuild: the first write goes through `c.header()` and the rest to the bag it hands back
|
|
682
|
+
* (measured at 1.2 µs against 4.9 µs for five separate calls), and a response that already carries all of
|
|
683
|
+
* these is not rebuilt at all.
|
|
684
|
+
*/
|
|
685
|
+
const writes = [];
|
|
313
686
|
if (!headers.has('x-content-type-options'))
|
|
314
|
-
|
|
687
|
+
writes.push(['x-content-type-options', 'nosniff']);
|
|
315
688
|
if (!headers.has('referrer-policy'))
|
|
316
|
-
|
|
689
|
+
writes.push(['referrer-policy', 'strict-origin-when-cross-origin']);
|
|
317
690
|
if (!headers.has('x-frame-options'))
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
// here
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
691
|
+
writes.push(['x-frame-options', 'SAMEORIGIN']);
|
|
692
|
+
// React Refresh compiles updates with `eval`, so a dev build cannot run under a production CSP.
|
|
693
|
+
// Widened here so one policy in src/server.ts serves both.
|
|
694
|
+
if (isDev) {
|
|
695
|
+
const csp = headers.get('content-security-policy');
|
|
696
|
+
if (csp?.includes('script-src ')) {
|
|
697
|
+
writes.push(['content-security-policy', csp.replace('script-src ', "script-src 'unsafe-eval' ")]);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Page responses only.
|
|
701
|
+
if (PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? '')) {
|
|
702
|
+
const vary = varyWith(headers.get('vary'), RSC_VARY_HEADER);
|
|
703
|
+
if (vary !== null)
|
|
704
|
+
writes.push(['vary', vary]);
|
|
705
|
+
if (!headers.has('cache-control'))
|
|
706
|
+
writes.push(['cache-control', PAGE_CACHE_CONTROL]);
|
|
707
|
+
}
|
|
708
|
+
let writable;
|
|
709
|
+
for (const [name, value] of writes) {
|
|
710
|
+
if (writable)
|
|
711
|
+
writable.set(name, value);
|
|
712
|
+
else {
|
|
713
|
+
c.header(name, value);
|
|
714
|
+
writable = c.res.headers;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
332
717
|
});
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
718
|
+
// The backstop for {@link asError}, and *second* for a reason: the middleware that throws is the one whose
|
|
719
|
+
// own half after `await next()` is skipped, so converting in the floor above would buy `onError` at the
|
|
720
|
+
// cost of the headers that floor exists to set. Here the conversion happens below it, `compose` hands the
|
|
721
|
+
// `Error` to `onError` at this level, and the floor unwinds over the response that comes back.
|
|
722
|
+
//
|
|
723
|
+
// Second also means *above the app's own middleware*, which is what the two conversions further down make
|
|
724
|
+
// up for: an endpoint or a page route converts at its own handler, so a middleware's post-`next()` half
|
|
725
|
+
// still runs, exactly as it does for a thrown `Error`. What is left over is a *middleware* that throws a
|
|
726
|
+
// non-`Error` itself — then everything registered outside it is skipped, and nothing short of a change in
|
|
727
|
+
// `compose` could do otherwise.
|
|
728
|
+
app.use(async (c, next) => {
|
|
729
|
+
try {
|
|
730
|
+
await next();
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
throw asError(error);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
// First, so the app's own middleware (`csrf()`, `bodyLimit()`, auth, logging) wraps everything registered
|
|
737
|
+
// below: the page routes, and the asset handlers too. Assets used to be mounted ahead of this, which left
|
|
738
|
+
// `/_static/*` answered by a terminal handler nothing of the app's ever saw — no `secureHeaders()`, and so
|
|
739
|
+
// no HSTS on exactly the requests a downgrade attack lands on. The flip side is the same one it always had,
|
|
740
|
+
// one path wider: a *terminal* handler in src/server.ts shadows a page at the same path, and unscoped
|
|
741
|
+
// middleware now also runs for `/_static`, which is a reserved prefix an app should not be matching on
|
|
742
|
+
// purpose anyway.
|
|
344
743
|
if (serverApp) {
|
|
345
744
|
app.route('/', serverApp);
|
|
346
745
|
}
|
|
347
|
-
|
|
746
|
+
runtime.mountStaticAssets(app);
|
|
747
|
+
const memoizePage = (page, label) => once(async () => assertPageModule(await page.component(), label));
|
|
348
748
|
const loadNotFoundPage = routeConfig.notFound ? memoizePage(routeConfig.notFound, 'the notFound page') : null;
|
|
349
749
|
/** Turns a thrown `redirect()` / `notFound()` into the response it stands for. */
|
|
350
750
|
const respondToControlSignal = async (c, signal) => {
|
|
351
|
-
const isRsc =
|
|
751
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
352
752
|
if (signal instanceof RedirectSignal) {
|
|
353
753
|
if (isRsc) {
|
|
354
|
-
|
|
754
|
+
// No `signal`: two fields and no component tree, so there is nothing worth aborting.
|
|
755
|
+
return c.body(renderToReadableStream({ root: null, redirect: signal.location }), 200, {
|
|
355
756
|
'content-type': 'text/x-component;charset=utf-8',
|
|
356
757
|
});
|
|
357
758
|
}
|
|
358
|
-
|
|
759
|
+
// Both page defaults by hand: `c.redirect` builds a bodiless response with no content type, so the
|
|
760
|
+
// middleware that would apply them skips it on `PAGE_CONTENT_TYPE`. They are not decoration here —
|
|
761
|
+
// `301` and `308` are cacheable with no explicit `Cache-Control` at all, so a session-gated permanent
|
|
762
|
+
// redirect could otherwise be stored by a shared cache and replayed to another visitor, and without
|
|
763
|
+
// `Vary` this document redirect could answer an `RSC: 1` fetch that needs the payload above.
|
|
764
|
+
//
|
|
765
|
+
// Written straight to the bag, unlike the floor: `c.redirect` builds this response here and now, so it
|
|
766
|
+
// is Hono's own and mutable. `Response.redirect()` — the one an *app* would reach for — is not.
|
|
767
|
+
const redirected = c.redirect(signal.location, signal.status);
|
|
768
|
+
appendVary(redirected.headers, RSC_VARY_HEADER);
|
|
769
|
+
if (!redirected.headers.has('cache-control'))
|
|
770
|
+
redirected.headers.set('cache-control', PAGE_CACHE_CONTROL);
|
|
771
|
+
return redirected;
|
|
359
772
|
}
|
|
360
773
|
if (loadNotFoundPage) {
|
|
361
|
-
|
|
774
|
+
try {
|
|
775
|
+
return await renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
// The `notFound` page is already the answer to a request that went wrong, so a failure here has
|
|
779
|
+
// nowhere to escalate to: this runs from `onError` as well as from the page handler, and Hono calls
|
|
780
|
+
// `onError` inside its own catch — a throw from there rejects `app.fetch`, which
|
|
781
|
+
// `@hono/node-server` turns into a bodiless 500 with nothing in the log. So it is answered here, the
|
|
782
|
+
// way a failing `error` page is answered below.
|
|
783
|
+
//
|
|
784
|
+
// A `redirect()` from the page is the exception: nothing is committed yet, the branch above cannot
|
|
785
|
+
// fail, and this is what the same page does when `app.notFound` renders it — so it is honoured, and
|
|
786
|
+
// recurses exactly once.
|
|
787
|
+
if (error instanceof RedirectSignal)
|
|
788
|
+
return respondToControlSignal(c, error);
|
|
789
|
+
reportServerError(error, { source: 'render', hono: c, message: '[rshono] the notFound page failed to render:' });
|
|
790
|
+
}
|
|
362
791
|
}
|
|
363
|
-
|
|
364
|
-
return c.text('Not Found', 404, { vary: 'Accept' });
|
|
792
|
+
return plainNotFound(c);
|
|
365
793
|
};
|
|
366
794
|
for (const route of routes) {
|
|
367
795
|
if (isPageRoute(route)) {
|
|
368
|
-
// Both
|
|
796
|
+
// Both fixed for the life of the process, so resolved once rather than per request.
|
|
369
797
|
const servesPrerendered = !isDev && route.render === 'static';
|
|
370
|
-
const loadPage = once(() =>
|
|
371
|
-
|
|
798
|
+
const loadPage = once(async () => assertPageModule(await route.component(), `"${route.path}"`));
|
|
799
|
+
/** Everything the route answers, before a HEAD has its body taken off it. */
|
|
800
|
+
const respond = async (c) => {
|
|
372
801
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
802
|
+
// A HEAD takes the GET path, prerendered bytes included: the headers it promises are the ones a
|
|
803
|
+
// GET would send, `etag` and `content-length` among them.
|
|
804
|
+
if (servesPrerendered && (c.req.method === 'GET' || c.req.method === 'HEAD')) {
|
|
805
|
+
const isRsc = asksForRsc(c.req.raw);
|
|
806
|
+
// One fixed set of bytes cannot carry a per-request nonce, so a document has to be rendered
|
|
807
|
+
// where the app's CSP has one. Decided per request, so an app whose policy carries no `NONCE`
|
|
808
|
+
// keeps its prerendered documents. A flight payload never carries a nonce either way.
|
|
809
|
+
const mustRenderForNonce = !isRsc && cspNonce(c) !== undefined;
|
|
810
|
+
if (!mustRenderForNonce) {
|
|
380
811
|
const page = await runtime.readPrerendered(c, isRsc ? 'flight' : 'html');
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// Answered outside `runWithContext`: no app code runs on this path, so there is no
|
|
386
|
-
// `getContext()` to serve and no reason to pay for the AsyncLocalStorage scope.
|
|
812
|
+
// Request-independent by construction, so it is safe to cache publicly; the ETag turns the
|
|
813
|
+
// revalidation that follows into a 304. Answered outside `runWithContext` — no app code runs
|
|
814
|
+
// on this path, so there is no `getRequestContext()` to serve.
|
|
387
815
|
if (page !== null) {
|
|
388
816
|
const headers = {
|
|
389
817
|
'cache-control': SSG_CACHE_CONTROL,
|
|
390
818
|
etag: page.etag,
|
|
391
|
-
vary:
|
|
819
|
+
vary: RSC_VARY_HEADER,
|
|
392
820
|
'content-type': isRsc ? 'text/x-component;charset=utf-8' : 'text/html;charset=utf-8',
|
|
393
821
|
};
|
|
394
822
|
if (etagMatches(c.req.header('if-none-match'), page.etag))
|
|
@@ -402,38 +830,84 @@ function buildApp() {
|
|
|
402
830
|
catch (error) {
|
|
403
831
|
if (isControlSignal(error))
|
|
404
832
|
return runWithContext(c, () => respondToControlSignal(c, error));
|
|
405
|
-
|
|
833
|
+
// Converted here rather than left to the backstop above the app's middleware, so a page or an
|
|
834
|
+
// action that throws a non-`Error` reaches `onError` from this route's own dispatch level — which
|
|
835
|
+
// is where a thrown `Error` reaches it, and so the only place from which a middleware's
|
|
836
|
+
// post-`next()` half runs. See {@link asError}.
|
|
837
|
+
throw asError(error);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
const handler = async (c) => {
|
|
841
|
+
const response = await respond(c);
|
|
842
|
+
// Hono dispatches a HEAD to the GET route and then rebuilds the response as `new Response(null, res)`,
|
|
843
|
+
// which drops the body without reading it — so for a rendered page nothing ever consumes the stream:
|
|
844
|
+
// `flight-inject`'s `cancel` never runs, and neither does the `release()` that detaches the abort
|
|
845
|
+
// forwarder from the request signal. Cancelling here is what ends the render nobody asked to read.
|
|
846
|
+
if (c.req.method === 'HEAD' && response.body !== null) {
|
|
847
|
+
void response.body.cancel().catch(() => {
|
|
848
|
+
// Already errored or locked, and there is nothing left to release either way.
|
|
849
|
+
});
|
|
850
|
+
return new Response(null, response);
|
|
406
851
|
}
|
|
852
|
+
return response;
|
|
407
853
|
};
|
|
408
854
|
app.on(['GET', 'POST'], route.path, handler);
|
|
409
855
|
}
|
|
410
856
|
else {
|
|
411
|
-
|
|
857
|
+
// Checked on the way out of the thunk rather than at the call site, so `once`'s cache holds a handler
|
|
858
|
+
// that has already been proven to be one — and a rejection clears, so the message repeats per request.
|
|
859
|
+
const loadEndpoint = once(async () => assertEndpointModule(await route.server(), `"${route.path}"`));
|
|
412
860
|
const handler = async (c, next) => {
|
|
413
|
-
const
|
|
414
|
-
|
|
861
|
+
const endpointHandler = await loadEndpoint();
|
|
862
|
+
try {
|
|
863
|
+
// Awaited rather than returned, so a rejection is this frame's to catch: the same conversion the
|
|
864
|
+
// page route makes, for the same reason. See {@link asError}.
|
|
865
|
+
// Hono's `Handler` leaves its return parameter defaulted to `any`, so this hands back exactly what
|
|
866
|
+
// the app's own handler is declared to return — there is nothing narrower to assert here.
|
|
867
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
868
|
+
return await endpointHandler(c, next);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
throw asError(error);
|
|
872
|
+
}
|
|
415
873
|
};
|
|
416
|
-
|
|
417
|
-
|
|
874
|
+
// De-duplicated, because `app.on(['GET', 'GET'], …)` registers the path twice. Validation refuses
|
|
875
|
+
// `'all'` inside a list, so a list that reaches here is concrete methods only.
|
|
876
|
+
const methods = [...new Set([route.method ?? 'all'].flat())];
|
|
877
|
+
if (methods.includes('all'))
|
|
418
878
|
app.all(route.path, handler);
|
|
419
|
-
else
|
|
420
|
-
app.on(method.toUpperCase(), route.path, handler);
|
|
879
|
+
else {
|
|
880
|
+
app.on(methods.map((method) => method.toUpperCase()), route.path, handler);
|
|
881
|
+
}
|
|
421
882
|
}
|
|
422
883
|
}
|
|
423
884
|
runtime.mountPublicFallback(app);
|
|
424
885
|
app.notFound(async (c) => {
|
|
425
|
-
const isRsc =
|
|
886
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
426
887
|
if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
|
|
427
|
-
|
|
888
|
+
// `notFound: true` here as well as on the signal path: the two produce the same page for the same
|
|
889
|
+
// status, and a payload that says so from one route and not the other is a wire contract with a hole
|
|
890
|
+
// in it. The client reads it to tell "this is the 404 page" from "this is the page you asked for".
|
|
891
|
+
return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true }));
|
|
428
892
|
}
|
|
429
|
-
return c
|
|
893
|
+
return plainNotFound(c);
|
|
430
894
|
});
|
|
431
895
|
const loadErrorPage = routeConfig.error ? memoizePage(routeConfig.error, 'the error page') : null;
|
|
432
896
|
app.onError(async (error, c) => {
|
|
433
897
|
if (isControlSignal(error))
|
|
434
898
|
return runWithContext(c, () => respondToControlSignal(c, error));
|
|
435
|
-
|
|
436
|
-
|
|
899
|
+
// Registering an `onError` replaces Hono's default handler, which is what turns an `HTTPException`
|
|
900
|
+
// into the response it carries — without this, `csrf()`'s 403 and `bodyLimit()`'s 413 would both
|
|
901
|
+
// surface as a 500 error page. Rebuilt through `c`, as Hono's default does, so headers middleware
|
|
902
|
+
// already prepared survive.
|
|
903
|
+
if (error instanceof HTTPException) {
|
|
904
|
+
const res = error.getResponse();
|
|
905
|
+
return c.newResponse(res.body, res);
|
|
906
|
+
}
|
|
907
|
+
// Not a shell failure re-thrown from `renderComponent`: {@link cameFromPayload} says why.
|
|
908
|
+
if (!cameFromPayload(error))
|
|
909
|
+
reportServerError(error, { source: 'request', hono: c, message: '[rshono] request error:' });
|
|
910
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
437
911
|
if (loadErrorPage && (isRsc || acceptsHtml(c))) {
|
|
438
912
|
const errorInfo = isDev
|
|
439
913
|
? {
|
|
@@ -442,25 +916,110 @@ function buildApp() {
|
|
|
442
916
|
}
|
|
443
917
|
: { message: 'Internal Server Error' };
|
|
444
918
|
try {
|
|
445
|
-
return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo }));
|
|
919
|
+
return await runWithContext(c, async () => renderComponent(c, await loadErrorPage(), { status: 500, isRsc, errorInfo, returnValue: actionResults.get(c) }));
|
|
446
920
|
}
|
|
447
921
|
catch (renderError) {
|
|
448
|
-
|
|
922
|
+
// A `redirect()` is not a render failure, and this is the one catch on that path that used to treat
|
|
923
|
+
// it as one — a 500 and a misleading line in the app's error tracker, where the `notFound` page's
|
|
924
|
+
// identical branch answers the redirect. Nothing is committed yet and the branch that answers it
|
|
925
|
+
// cannot fail, which is the same reasoning as there.
|
|
926
|
+
//
|
|
927
|
+
// `notFound()` is deliberately **not** honoured here: it would render the `notFound` page from
|
|
928
|
+
// inside the error path, which can fail in its turn and has nowhere left to escalate to. It stays
|
|
929
|
+
// reported.
|
|
930
|
+
if (renderError instanceof RedirectSignal)
|
|
931
|
+
return respondToControlSignal(c, renderError);
|
|
932
|
+
// Not as a render failure, for a signal — that message describes a bug in the page, and this is the
|
|
933
|
+
// framework declining to act. An author who wrote `notFound()` in their `error` page and read "the
|
|
934
|
+
// error page failed to render: NotFoundSignal" would go looking for the wrong thing.
|
|
935
|
+
const message = isControlSignal(renderError)
|
|
936
|
+
? '[rshono] notFound() from the error page is not honoured — it would render the notFound page from inside the error path. The framework 500 answers instead:'
|
|
937
|
+
: '[rshono] the error page failed to render:';
|
|
938
|
+
// An `error` page whose *own* render threw arrives here as a payload stand-in, its real fault
|
|
939
|
+
// already reported as `render` by the render that produced it. So the line still goes to stderr —
|
|
940
|
+
// with two `render` reports on one request it is what says which of them was the error page — but
|
|
941
|
+
// it is not a second report of one fault. {@link cameFromPayload}.
|
|
942
|
+
if (cameFromPayload(renderError))
|
|
943
|
+
console.error(message, renderError);
|
|
944
|
+
else
|
|
945
|
+
reportServerError(renderError, { source: 'request', hono: c, message });
|
|
449
946
|
}
|
|
450
947
|
}
|
|
948
|
+
// A client that asked for HTML gets a document, even here: this is the last resort for an app with no
|
|
949
|
+
// `error` page, and for one whose `error` page threw in its turn, and a browser handed `text/plain` in
|
|
950
|
+
// either case shows a bare line of text where the app used to show a page. Everything else — a flight
|
|
951
|
+
// fetch, curl, a probe — gets the plain line, which is what it asked for.
|
|
952
|
+
if (!isRsc && acceptsHtml(c)) {
|
|
953
|
+
return c.html(failureDocument(error), 500, { vary: RSC_VARY_HEADER, 'cache-control': PAGE_CACHE_CONTROL });
|
|
954
|
+
}
|
|
451
955
|
const detail = isDev ? `\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}` : '';
|
|
452
|
-
return c
|
|
956
|
+
return plainRefusal(c, `Internal Server Error${detail}`, 500);
|
|
453
957
|
});
|
|
454
958
|
return app;
|
|
455
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* Loads the app's server actions, so a `'use server'` module that cannot be evaluated is a line in the build
|
|
962
|
+
* log rather than a 500 on the first action anyone calls.
|
|
963
|
+
*
|
|
964
|
+
* Nothing on the server imports these modules until an action is called: a `'use client'` component that
|
|
965
|
+
* calls one gets a `createServerReference` stub, so the only thing holding them is the manifest. That makes
|
|
966
|
+
* an action module the one part of an app a build never touched — and the failure it hides is not one route,
|
|
967
|
+
* it is every action that module holds, which in practice is all of them (Rspack compiles the app's whole
|
|
968
|
+
* `'use server'` graph into a single server module).
|
|
969
|
+
*
|
|
970
|
+
* One id per *module*, because that is the granularity of the failure that matters — and evaluating a module
|
|
971
|
+
* that throws once per action id would repeat whatever it did before throwing. The rest of that module's ids
|
|
972
|
+
* are then cache hits, so they are checked too: `loadServerAction` also refuses an export that is not a
|
|
973
|
+
* function, and that is per id.
|
|
974
|
+
*
|
|
975
|
+
* Entries declaring `chunks` are skipped. `loadServerAction` is a bare `__webpack_require__`, so a module
|
|
976
|
+
* that has not been loaded yet would throw here for a reason that says nothing about the app — and every
|
|
977
|
+
* action this framework has produced sits in the server bundle itself, with no chunks to load.
|
|
978
|
+
*
|
|
979
|
+
* A warning, not a failure, for the same reason {@link assertRouteModules} warns: a module can legitimately
|
|
980
|
+
* decline to evaluate in a build — one that reads a secret out of the environment, say — and a build is not
|
|
981
|
+
* where that gets decided.
|
|
982
|
+
*/
|
|
983
|
+
function checkServerActions() {
|
|
984
|
+
const entries = Object.entries(__rspack_rsc_manifest__.serverManifest).filter(([, entry]) => (entry.chunks?.length ?? 0) === 0);
|
|
985
|
+
/** Action ids by the module that holds them, in manifest order. */
|
|
986
|
+
const byModule = new Map();
|
|
987
|
+
for (const [actionId, entry] of entries)
|
|
988
|
+
byModule.set(entry.id, [...(byModule.get(entry.id) ?? []), actionId]);
|
|
989
|
+
for (const actionIds of byModule.values()) {
|
|
990
|
+
for (const actionId of actionIds) {
|
|
991
|
+
try {
|
|
992
|
+
loadServerAction(actionId);
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
996
|
+
console.warn(` ⚠ the module holding ${actionIds.length} of the app's ${entries.length} server action(s) could not be loaded at build time — ${reason}\n` +
|
|
997
|
+
` Calling one of those actions loads that module first, so if it fails the same way at run time, each of them answers 500.`);
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Everything about the app a build can check without serving a request: every route's own module — page and
|
|
1005
|
+
* endpoint alike, `notFound` and `error` included — and then the server actions, which no route reaches.
|
|
1006
|
+
*
|
|
1007
|
+
* Called by `rshono build` once this bundle is imported for the prerender pass, so a route that could never
|
|
1008
|
+
* serve a request fails the build rather than answering 500 in production. Exported from here rather than
|
|
1009
|
+
* driven from the CLI because both halves read module-scope state — `routeConfig`, and the manifest the RSC
|
|
1010
|
+
* plugin injects — neither of which leaves this bundle.
|
|
1011
|
+
*/
|
|
1012
|
+
export const checkAppModules = async () => {
|
|
1013
|
+
await assertRouteModules(routeConfig);
|
|
1014
|
+
checkServerActions();
|
|
1015
|
+
};
|
|
456
1016
|
export const app = buildApp();
|
|
457
1017
|
/**
|
|
458
|
-
* The app, handed to whatever is hosting it
|
|
1018
|
+
* The app, handed to whatever is hosting it: this binds a port and exports nothing where rshono owns the
|
|
1019
|
+
* process, and *is* the export the platform looks for where the host owns it — so one entry serves both.
|
|
459
1020
|
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* both without a per-platform entry file. `app` and `routes` stay named exports either way, because
|
|
463
|
-
* `rshono build` imports them to prerender `render: 'static'` routes.
|
|
1021
|
+
* `app` and `routes` stay named exports either way, because `rshono build` imports them to prerender
|
|
1022
|
+
* `render: 'static'` routes.
|
|
464
1023
|
*/
|
|
465
1024
|
export default runtime.serveApp(app);
|
|
466
1025
|
//# sourceMappingURL=entry.rsc.js.map
|