@rshono/core 1.0.0-rc.0 → 1.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -159
- package/dist/builder/page-files.d.ts.map +1 -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 +6 -0
- package/dist/builder/public-env.d.ts.map +1 -1
- package/dist/builder/public-env.js +6 -0
- package/dist/builder/public-env.js.map +1 -1
- package/dist/builder/rspack-config.d.ts +3 -3
- package/dist/builder/rspack-config.d.ts.map +1 -1
- package/dist/builder/rspack-config.js +62 -16
- package/dist/builder/rspack-config.js.map +1 -1
- package/dist/cli/build.d.ts +2 -2
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev.d.ts +2 -2
- package/dist/cli/dev.d.ts.map +1 -1
- package/dist/cli/dev.js +63 -35
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/index.js +10 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts +58 -70
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +17 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/cloudflare/build.js +1 -1
- package/dist/deploy/cloudflare/build.js.map +1 -1
- package/dist/deploy/cloudflare/runtime.d.ts.map +1 -1
- package/dist/deploy/cloudflare/runtime.js +7 -37
- package/dist/deploy/cloudflare/runtime.js.map +1 -1
- package/dist/deploy/contract.d.ts +22 -16
- package/dist/deploy/contract.d.ts.map +1 -1
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts +1 -1
- package/dist/deploy/filesystem.d.ts.map +1 -1
- package/dist/deploy/filesystem.js +7 -10
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts +4 -0
- package/dist/deploy/node/runtime.d.ts.map +1 -1
- package/dist/deploy/node/runtime.js +24 -3
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/deploy/presets.d.ts +3 -3
- package/dist/deploy/presets.d.ts.map +1 -1
- package/dist/deploy/presets.js +13 -30
- package/dist/deploy/presets.js.map +1 -1
- package/dist/deploy/vercel/runtime.d.ts.map +1 -1
- package/dist/deploy/vercel/runtime.js +0 -3
- package/dist/deploy/vercel/runtime.js.map +1 -1
- package/dist/index.d.ts +6 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +85 -48
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +4 -6
- package/dist/router.js.map +1 -1
- package/dist/runtime/boundaries.d.ts +39 -25
- package/dist/runtime/boundaries.d.ts.map +1 -1
- package/dist/runtime/boundaries.js +22 -17
- package/dist/runtime/boundaries.js.map +1 -1
- package/dist/runtime/client.d.ts +9 -8
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +9 -8
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/context.d.ts +216 -70
- package/dist/runtime/context.d.ts.map +1 -1
- package/dist/runtime/context.js +299 -93
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/control.d.ts.map +1 -1
- package/dist/runtime/control.js +7 -0
- package/dist/runtime/control.js.map +1 -1
- package/dist/runtime/dev-protocol.d.ts.map +1 -1
- package/dist/runtime/dev-protocol.js.map +1 -1
- package/dist/runtime/entry.client.d.ts +4 -0
- package/dist/runtime/entry.client.d.ts.map +1 -1
- package/dist/runtime/entry.client.js +190 -154
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.d.ts.map +1 -1
- package/dist/runtime/entry.rsc.js +145 -159
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/entry.ssr.d.ts +9 -1
- package/dist/runtime/entry.ssr.d.ts.map +1 -1
- package/dist/runtime/entry.ssr.js +36 -18
- package/dist/runtime/entry.ssr.js.map +1 -1
- package/dist/runtime/flight-inject.d.ts +31 -0
- package/dist/runtime/flight-inject.d.ts.map +1 -0
- package/dist/runtime/flight-inject.js +221 -0
- package/dist/runtime/flight-inject.js.map +1 -0
- package/dist/runtime/navigation.d.ts +20 -38
- package/dist/runtime/navigation.d.ts.map +1 -1
- package/dist/runtime/navigation.js +10 -53
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/request.d.ts +6 -0
- package/dist/runtime/request.d.ts.map +1 -1
- package/dist/runtime/request.js +8 -0
- package/dist/runtime/request.js.map +1 -1
- package/dist/runtime/server.d.ts +7 -12
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +15 -12
- package/dist/runtime/server.js.map +1 -1
- package/dist/server/headers.d.ts +9 -9
- package/dist/server/headers.js +9 -9
- package/dist/server/headers.js.map +1 -1
- package/dist/server/load-config.d.ts +2 -2
- package/dist/server/load-config.d.ts.map +1 -1
- package/dist/server/load-config.js +22 -11
- package/dist/server/load-config.js.map +1 -1
- package/dist/server/prerendered.d.ts +43 -15
- package/dist/server/prerendered.d.ts.map +1 -1
- package/dist/server/prerendered.js +47 -0
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +13 -39
- package/dist/server/server-config.d.ts.map +1 -1
- package/dist/server/server-config.js +5 -74
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/ssg.d.ts +2 -2
- package/dist/server/ssg.d.ts.map +1 -1
- package/dist/server/ssg.js +21 -34
- package/dist/server/ssg.js.map +1 -1
- package/package.json +13 -16
- 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/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/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/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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import {
|
|
3
|
+
import { HTTPException } from 'hono/http-exception';
|
|
4
4
|
// The JSX factory, by name: the page element is created with this directly rather than as
|
|
5
5
|
// `<Page {...props} />`, because a spread would drop the non-enumerable `ctx` prop. See `pageProps`.
|
|
6
6
|
import { jsx } from 'react/jsx-runtime';
|
|
7
7
|
// The bare specifier, not `/server.node`: the package ships a build per runtime behind export
|
|
8
|
-
// conditions
|
|
9
|
-
//
|
|
8
|
+
// conditions, and the RSC layer's `conditionNames` picks one — so a non-Node deploy target gets its
|
|
9
|
+
// own build rather than Node's by hard-coded path.
|
|
10
10
|
import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, renderToReadableStream, } from 'react-server-dom-rspack/server';
|
|
11
11
|
// Resolved by the '@rshono/deploy' alias to the selected preset's runtime module — the one place
|
|
12
12
|
// this file knows anything about where it is running. See `deploy/contract.ts`.
|
|
@@ -17,21 +17,17 @@ import { routes as userRoutes } from '@rshono/routes';
|
|
|
17
17
|
import * as serverAppModule from '@rshono/server-app';
|
|
18
18
|
import { isPageRoute } from '../router.js';
|
|
19
19
|
import { appendVary, etagMatches } from '../server/headers.js';
|
|
20
|
-
import {
|
|
20
|
+
import { beginPageRender, getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
|
|
21
21
|
import { isControlSignal, RedirectSignal } from './control.js';
|
|
22
22
|
import { renderHTML } from './entry.ssr.js';
|
|
23
23
|
import { RouterProvider } from './navigation.js';
|
|
24
|
-
import { acceptsRsc, isActionRequest, parseRenderRequest, wantsRsc } from './request.js';
|
|
24
|
+
import { acceptsRsc, isActionRequest, parseRenderRequest, requestWantsRsc, wantsRsc } from './request.js';
|
|
25
25
|
const serverApp = (serverAppModule.default ?? null);
|
|
26
|
-
// Framework settings resolved from rshono.config.ts and compiled into the bundle at build time
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
const CONFIG = __RSHONO_CONFIG__;
|
|
32
|
-
const { isDev, renderTimeoutMs, cspEnabled, checkOrigin, maxBodyBytes, compress: compressEnabled } = CONFIG;
|
|
33
|
-
/** Extra cross-origin hosts permitted to post server actions, beyond the app's own origin. */
|
|
34
|
-
const allowedOrigins = new Set(CONFIG.allowedOrigins);
|
|
26
|
+
// Framework settings resolved from rshono.config.ts and compiled into the bundle at build time (see
|
|
27
|
+
// builder/rspack-config.ts). There is no runtime env-var interface for these — env is for secrets.
|
|
28
|
+
// `isDev` among them: the build mode is decided by which command produced the bundle, and not every
|
|
29
|
+
// runtime this deploys to has a `process.env.NODE_ENV` to read.
|
|
30
|
+
const { isDev } = __RSHONO_CONFIG__;
|
|
35
31
|
/** How long a prerendered page may be reused before revalidating. Also what `public/` files get. */
|
|
36
32
|
const SSG_CACHE_CONTROL = 'public, max-age=300';
|
|
37
33
|
/**
|
|
@@ -39,50 +35,25 @@ const SSG_CACHE_CONTROL = 'public, max-age=300';
|
|
|
39
35
|
* the `Accept` header, which is what makes `Vary` non-optional here.
|
|
40
36
|
*/
|
|
41
37
|
const PAGE_CONTENT_TYPE = /^(?:text\/html|text\/x-component)\b/;
|
|
42
|
-
// The CSP is fixed per build apart from the nonce, so assemble everything but `script-src` once.
|
|
43
|
-
const CSP_STATIC = Object.entries(CONFIG.cspDirectives)
|
|
44
|
-
.filter(([name]) => name !== 'script-src')
|
|
45
|
-
.map(([name, value]) => `${name} ${value}`)
|
|
46
|
-
.join('; ');
|
|
47
|
-
const CSP_SCRIPT_SRC = CONFIG.cspDirectives['script-src'] ?? "'self'";
|
|
48
38
|
// Called here rather than at the top of the deploy runtime's own module so the timing is unchanged:
|
|
49
39
|
// `.env` is loaded once every import above has been evaluated, exactly as before.
|
|
50
40
|
runtime.loadEnv();
|
|
51
41
|
const routeConfig = userRoutes;
|
|
52
42
|
export const routes = routeConfig.routes;
|
|
53
43
|
/**
|
|
54
|
-
*
|
|
44
|
+
* The per-request CSP nonce, if the app asked for one.
|
|
55
45
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* `X-Forwarded-Host`, which the client controls unless `trustProxy` says a proxy owns it).
|
|
46
|
+
* The framework does not mint this. `secureHeaders()` from `hono/secure-headers` does, when its
|
|
47
|
+
* policy contains the `NONCE` placeholder, and it stores it here — so an app opts into a
|
|
48
|
+
* nonce-based CSP by registering that middleware in `src/server.ts` and the framework's only job is
|
|
49
|
+
* to stamp the value into the render.
|
|
61
50
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `http://` origin posting to the `https://` site (HSTS is the control for that).
|
|
51
|
+
* Readable from a route handler because `secureHeaders` resolves its directives *before* calling
|
|
52
|
+
* `next()`; the header itself is written on the way back out. Absent when the app registered no CSP,
|
|
53
|
+
* or one with no nonce in it — a fixed policy needs nothing from the render.
|
|
66
54
|
*/
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
return true;
|
|
70
|
-
const secFetchSite = c.req.header('sec-fetch-site');
|
|
71
|
-
if (secFetchSite === 'same-origin')
|
|
72
|
-
return true;
|
|
73
|
-
const origin = c.req.header('origin');
|
|
74
|
-
const originHost = origin ? URL.parse(origin)?.host.toLowerCase() || null : null;
|
|
75
|
-
if (origin !== undefined && originHost === null)
|
|
76
|
-
return false; // an Origin we can't parse is untrusted.
|
|
77
|
-
const trusted = originHost !== null && (originHost === publicUrl(c).host.toLowerCase() || allowedOrigins.has(originHost));
|
|
78
|
-
if (secFetchSite && secFetchSite !== 'none') {
|
|
79
|
-
// The browser tells us (unforgeably) this didn't originate from our own site — only a
|
|
80
|
-
// trusted (allowlisted) Origin may proceed.
|
|
81
|
-
return trusted;
|
|
82
|
-
}
|
|
83
|
-
if (originHost === null)
|
|
84
|
-
return true;
|
|
85
|
-
return trusted;
|
|
55
|
+
function cspNonce(c) {
|
|
56
|
+
return c.get('secureHeadersNonce');
|
|
86
57
|
}
|
|
87
58
|
async function loadPageModule(load, label) {
|
|
88
59
|
const mod = await load();
|
|
@@ -102,6 +73,14 @@ async function loadPageModule(load, label) {
|
|
|
102
73
|
function acceptsHtml(c) {
|
|
103
74
|
return c.req.header('accept')?.includes('text/html') ?? false;
|
|
104
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The 404 for an app with no `notFound` page, and for a client that wanted neither HTML nor a flight
|
|
78
|
+
* payload. Shared for the `Vary`: this is still one of the answers a page URL gives depending on
|
|
79
|
+
* `Accept`, and a cache not told so can hand a plain-text 404 to a browser asking for a document.
|
|
80
|
+
*/
|
|
81
|
+
function plainNotFound(c) {
|
|
82
|
+
return c.text('Not Found', 404, { vary: 'Accept' });
|
|
83
|
+
}
|
|
105
84
|
/** A lazy once-cell: runs `load` at most once and caches the promise, but clears a rejection so a later call can retry. */
|
|
106
85
|
function once(load) {
|
|
107
86
|
let promise;
|
|
@@ -118,75 +97,81 @@ function once(load) {
|
|
|
118
97
|
};
|
|
119
98
|
}
|
|
120
99
|
/**
|
|
121
|
-
*
|
|
122
|
-
* `using` declaration doesn't fit because the timer must outlive this call to keep
|
|
123
|
-
* guarding the *stream*, not just the function scope). The timer is released on
|
|
124
|
-
* exactly one of: the stream finishing ({@link RenderDeadline.guard}), an explicit
|
|
125
|
-
* {@link RenderDeadline.clear} on an error path, or the signal aborting (client
|
|
126
|
-
* disconnect, or the deadline firing itself). A manually-cleared timer instead of
|
|
127
|
-
* `AbortSignal.timeout()` so a fast response doesn't leave one pending to fire later.
|
|
100
|
+
* Passes `stream` through untouched, calling `done` once it ends — however it ends.
|
|
128
101
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
102
|
+
* Used on the flight-only response path, which has no transform of its own to hang a completion hook
|
|
103
|
+
* off (the document path has {@link injectFlightPayload}). `done` must be idempotent: a cancelled
|
|
104
|
+
* response can reach both `cancel` and the abort forwarder.
|
|
131
105
|
*/
|
|
132
|
-
function
|
|
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));
|
|
106
|
+
function releaseWhenDone(stream, done) {
|
|
107
|
+
return stream.pipeThrough(new TransformStream({
|
|
108
|
+
transform(chunk, controller) {
|
|
109
|
+
controller.enqueue(chunk);
|
|
152
110
|
},
|
|
153
|
-
|
|
111
|
+
flush: done,
|
|
112
|
+
cancel: done,
|
|
113
|
+
}));
|
|
154
114
|
}
|
|
155
115
|
/**
|
|
156
116
|
* Builds the props a page component is called with.
|
|
157
117
|
*
|
|
158
|
-
* `url` and `params` match `useNavigation()` field for field, so a read can move between a page and
|
|
159
|
-
*
|
|
160
|
-
*
|
|
118
|
+
* `url` and `params` match `useNavigation()` field for field, so a read can move between a page and a
|
|
119
|
+
* `'use client'` component unchanged. The `URL` is this page's own, so mutating it cannot disturb
|
|
120
|
+
* anything else on the request.
|
|
161
121
|
*
|
|
162
|
-
* `ctx` is *defined* rather than assigned, and both
|
|
122
|
+
* `ctx` is *defined* rather than assigned, and both halves of how carry their weight:
|
|
163
123
|
*
|
|
164
124
|
* - **A getter**, so nothing is built for the pages that never read it, and so a `render: 'static'`
|
|
165
|
-
* page that does read it gets {@link
|
|
166
|
-
* prerendering" error rather than a bare `undefined
|
|
125
|
+
* page that does read it gets {@link getRequestContext}'s "no per-request context while
|
|
126
|
+
* prerendering" error rather than a bare `undefined`.
|
|
167
127
|
* - **Non-enumerable**, so React's *development-only* serialization of a server component's props
|
|
168
128
|
* (the debug channel behind component stacks and the performance track) skips it. That walks own
|
|
169
|
-
* enumerable properties, and `ctx.
|
|
170
|
-
* runtime's bindings. An enumerable `ctx`
|
|
171
|
-
* browser in dev, and
|
|
172
|
-
* serializes a server component's props at all, so this is the dev half of the same guarantee.
|
|
129
|
+
* enumerable properties, and `ctx.hono` is the Hono {@link Context} — whose `env` holds the
|
|
130
|
+
* runtime's bindings. An enumerable `ctx` would ship every one of them, secrets included, to the
|
|
131
|
+
* browser in dev, and add well over 10 kB to a small page's flight payload.
|
|
173
132
|
*
|
|
174
|
-
* The cost is that the element has to be created by handing this object to `jsx()` *by reference*:
|
|
175
|
-
*
|
|
133
|
+
* The cost is that the element has to be created by handing this object to `jsx()` *by reference*: a
|
|
134
|
+
* `<Page {...props} />` spread copies enumerable properties only, and would drop `ctx` silently.
|
|
176
135
|
*/
|
|
177
136
|
function pageProps(c, errorInfo) {
|
|
178
137
|
const props = { url: publicUrl(c), params: readParams(c), ...(errorInfo ? { error: errorInfo } : null) };
|
|
179
|
-
Object.defineProperty(props, 'ctx', { get:
|
|
138
|
+
Object.defineProperty(props, 'ctx', { get: getRequestContext, enumerable: false, configurable: true });
|
|
180
139
|
return props;
|
|
181
140
|
}
|
|
182
141
|
async function renderComponent(c, Page, opts) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
142
|
+
// A client going away aborts the render — but React's renderers must never be handed
|
|
143
|
+
// `c.req.raw.signal` to watch for it. Both add an `abort` listener and only remove it if the abort
|
|
144
|
+
// fires, so on the happy path that listener stays on a request-lifetime signal and through it pins
|
|
145
|
+
// the flight request, the Fizz request and the whole rendered tree (~169 kB per `/ssr` request,
|
|
146
|
+
// never reclaimed by a major GC).
|
|
147
|
+
//
|
|
148
|
+
// So the render gets a controller of its own and the request signal only forwards into it: the
|
|
149
|
+
// forwarder is `once`, so an abort removes it, and `release` removes it on the normal path. Not
|
|
150
|
+
// `AbortSignal.any()` — a signal it produced is *composite*, and Node holds every composite signal
|
|
151
|
+
// carrying an `abort` listener in a process-lifetime set until it aborts or loses its last listener.
|
|
152
|
+
const requestSignal = c.req.raw.signal;
|
|
153
|
+
const renderAbort = new AbortController();
|
|
154
|
+
const signal = renderAbort.signal;
|
|
155
|
+
const forwardAbort = () => renderAbort.abort(requestSignal.reason);
|
|
156
|
+
if (requestSignal.aborted)
|
|
157
|
+
renderAbort.abort(requestSignal.reason);
|
|
158
|
+
else
|
|
159
|
+
requestSignal.addEventListener('abort', forwardAbort, { once: true });
|
|
160
|
+
/** Detaches the forwarder once the response has been written, so nothing survives the request. */
|
|
161
|
+
const release = () => requestSignal.removeEventListener('abort', forwardAbort);
|
|
162
|
+
// Documents only: the nonce goes on the bootstrap scripts and the `<meta>` React hydrates from,
|
|
163
|
+
// and a flight payload has neither. Leaving it off there is also what keeps a prerendered flight
|
|
164
|
+
// payload servable from disk under a nonce-based CSP.
|
|
165
|
+
const nonce = opts.isRsc ? undefined : cspNonce(c);
|
|
186
166
|
const props = pageProps(c, opts.errorInfo);
|
|
187
167
|
const root = (_jsxs(_Fragment, { children: [nonce && _jsx("meta", { property: "csp-nonce", nonce: nonce }), Page.entryCssFiles?.map((href) => (_jsx("link", { rel: "stylesheet", href: href, precedence: "default" }, href))), _jsx(RouterProvider, { href: props.url.href, params: props.params, children: jsx(Page, props) })] }));
|
|
188
168
|
// `notFound` only when it is true, so the flight payload of an ordinary page doesn't carry the key.
|
|
189
169
|
const rscPayload = { root, formState: opts.formState, returnValue: opts.returnValue, ...(opts.notFound ? { notFound: true } : null) };
|
|
170
|
+
// Past this line the response head belongs to the framework, so `ctx.setHeader()` and
|
|
171
|
+
// `ctx.cookies.set()` start throwing rather than writing somewhere nothing will read. It has to be
|
|
172
|
+
// the last thing before the render: a server action ran earlier in `renderPage` and legitimately
|
|
173
|
+
// writes to the response, and so does any middleware that wrapped this handler.
|
|
174
|
+
beginPageRender(c);
|
|
190
175
|
let controlSignal;
|
|
191
176
|
const rscStream = renderToReadableStream(rscPayload, {
|
|
192
177
|
temporaryReferences: opts.temporaryReferences,
|
|
@@ -201,7 +186,7 @@ async function renderComponent(c, Page, opts) {
|
|
|
201
186
|
},
|
|
202
187
|
});
|
|
203
188
|
if (opts.isRsc) {
|
|
204
|
-
return c.body(
|
|
189
|
+
return c.body(releaseWhenDone(rscStream, release), (opts.status ?? 200), {
|
|
205
190
|
'content-type': 'text/x-component;charset=utf-8',
|
|
206
191
|
});
|
|
207
192
|
}
|
|
@@ -212,51 +197,38 @@ async function renderComponent(c, Page, opts) {
|
|
|
212
197
|
formState: opts.formState,
|
|
213
198
|
signal,
|
|
214
199
|
nonce,
|
|
200
|
+
onDone: release,
|
|
215
201
|
onShellError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR shell error:' }),
|
|
216
202
|
onError: (error) => reportServerError(error, { source: 'ssr', request: c.req.raw, message: '[rshono] SSR error:' }),
|
|
217
203
|
});
|
|
218
204
|
}
|
|
219
205
|
catch (error) {
|
|
220
|
-
|
|
206
|
+
release();
|
|
221
207
|
if (controlSignal)
|
|
222
208
|
throw controlSignal;
|
|
223
209
|
throw error;
|
|
224
210
|
}
|
|
225
211
|
if (controlSignal) {
|
|
226
|
-
|
|
212
|
+
release();
|
|
227
213
|
throw controlSignal;
|
|
228
214
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// directive in `cspDirectives` can widen the policy but can't accidentally drop the nonce.
|
|
233
|
-
// Dev additionally needs 'unsafe-eval' for react-refresh.
|
|
234
|
-
const scriptSrc = `script-src ${CSP_SCRIPT_SRC} 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`;
|
|
235
|
-
headers['content-security-policy'] = CSP_STATIC ? `${CSP_STATIC}; ${scriptSrc}` : scriptSrc;
|
|
236
|
-
}
|
|
237
|
-
return c.body(deadline.guard(ssrResult.stream), (ssrResult.status ?? opts.status ?? 200), headers);
|
|
215
|
+
return c.body(ssrResult.stream, (ssrResult.status ?? opts.status ?? 200), {
|
|
216
|
+
'content-type': 'text/html;charset=utf-8',
|
|
217
|
+
});
|
|
238
218
|
}
|
|
239
219
|
async function renderPage(c, loadPage) {
|
|
240
220
|
const request = c.req.raw;
|
|
241
221
|
const renderRequest = parseRenderRequest(request);
|
|
242
|
-
// Created before the action runs so the deadline covers the whole request, then handed to
|
|
243
|
-
// `renderComponent` so the render doesn't get a fresh budget of its own.
|
|
244
|
-
const deadline = createRenderDeadline(request.signal, renderTimeoutMs);
|
|
245
222
|
let returnValue;
|
|
246
223
|
let formState;
|
|
247
224
|
let temporaryReferences;
|
|
248
225
|
let actionStatus;
|
|
249
226
|
if (isActionRequest(renderRequest)) {
|
|
250
|
-
if (!isSameOriginAction(c)) {
|
|
251
|
-
deadline.clear();
|
|
252
|
-
return c.text('Forbidden: cross-origin server action rejected', 403);
|
|
253
|
-
}
|
|
254
227
|
if (renderRequest.kind === 'rsc-action') {
|
|
255
228
|
// Checked before the body is decoded, so an unknown id costs nothing to reject — and
|
|
256
229
|
// `loadServerAction` would otherwise fault on the missing manifest entry, turning a bad
|
|
257
230
|
// request into an unhandled 500. `hasOwn` so `__proto__` doesn't resolve to a manifest entry.
|
|
258
231
|
if (!Object.hasOwn(__rspack_rsc_manifest__.serverManifest, renderRequest.actionId)) {
|
|
259
|
-
deadline.clear();
|
|
260
232
|
return c.text('Bad Request: unknown server action', 400);
|
|
261
233
|
}
|
|
262
234
|
const contentType = request.headers.get('content-type');
|
|
@@ -265,7 +237,7 @@ async function renderPage(c, loadPage) {
|
|
|
265
237
|
const args = await decodeReply(body, { temporaryReferences });
|
|
266
238
|
const action = loadServerAction(renderRequest.actionId);
|
|
267
239
|
try {
|
|
268
|
-
returnValue = { ok: true, value: await
|
|
240
|
+
returnValue = { ok: true, value: await action.apply(null, args) };
|
|
269
241
|
}
|
|
270
242
|
catch (error) {
|
|
271
243
|
if (isControlSignal(error))
|
|
@@ -281,7 +253,7 @@ async function renderPage(c, loadPage) {
|
|
|
281
253
|
const formData = await request.formData();
|
|
282
254
|
const decodedAction = await decodeAction(formData, __rspack_rsc_manifest__.serverManifest);
|
|
283
255
|
if (decodedAction) {
|
|
284
|
-
const result = await
|
|
256
|
+
const result = await decodedAction();
|
|
285
257
|
formState = (await decodeFormState(result, formData, __rspack_rsc_manifest__.serverManifest)) ?? undefined;
|
|
286
258
|
}
|
|
287
259
|
}
|
|
@@ -293,20 +265,17 @@ async function renderPage(c, loadPage) {
|
|
|
293
265
|
formState,
|
|
294
266
|
returnValue,
|
|
295
267
|
temporaryReferences,
|
|
296
|
-
deadline,
|
|
297
268
|
});
|
|
298
269
|
}
|
|
299
270
|
function buildApp() {
|
|
300
271
|
const app = new Hono();
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// stricter and takes precedence where both apply; this is the floor for everyone else.
|
|
309
|
-
// Set after `next()` so a route or middleware that sets its own value wins.
|
|
272
|
+
// Baseline security headers: stop content-type sniffing, keep the full URL out of cross-origin
|
|
273
|
+
// referrers, and refuse to be framed by another origin. A floor, not a policy — an app that wants
|
|
274
|
+
// the full set registers `secureHeaders()` from `hono/secure-headers` in src/server.ts, and
|
|
275
|
+
// because this is registered first it unwinds *last* and the "only if unset" checks stand aside
|
|
276
|
+
// for whatever that set. Kept in the framework rather than left to the app because it is
|
|
277
|
+
// registered ahead of `mountStaticAssets`, which is a terminal handler the sub-app never sees:
|
|
278
|
+
// without this, `/_static/*` would carry no security headers at all.
|
|
310
279
|
app.use(async (c, next) => {
|
|
311
280
|
await next();
|
|
312
281
|
const headers = c.res.headers;
|
|
@@ -316,31 +285,33 @@ function buildApp() {
|
|
|
316
285
|
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
|
317
286
|
if (!headers.has('x-frame-options'))
|
|
318
287
|
headers.set('x-frame-options', 'SAMEORIGIN');
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
// and
|
|
288
|
+
// React Refresh compiles updates with `eval`, so a dev build cannot run under any CSP an app
|
|
289
|
+
// would sensibly write for production. Widened here rather than asked of the app, so one policy
|
|
290
|
+
// in src/server.ts serves both and nobody debugs a blocked HMR socket. Only ever in dev, and
|
|
291
|
+
// only when the app has a `script-src` to widen.
|
|
292
|
+
if (isDev) {
|
|
293
|
+
const csp = headers.get('content-security-policy');
|
|
294
|
+
if (csp?.includes('script-src ')) {
|
|
295
|
+
headers.set('content-security-policy', csp.replace('script-src ', "script-src 'unsafe-eval' "));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Page responses only, from here down: one URL answers with either an HTML document or a flight
|
|
299
|
+
// payload depending on `Accept`, and the default page is request-specific.
|
|
322
300
|
if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
|
|
323
301
|
return;
|
|
324
302
|
appendVary(headers, 'Accept');
|
|
325
|
-
// Without this a page carries no cache directives at all, and a shared cache — a CDN, a
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// A prerendered page, or anything a route set deliberately, already has its own value.
|
|
303
|
+
// Without this a page carries no cache directives at all, and a shared cache — a CDN, a corporate
|
|
304
|
+
// proxy — is free to store a logged-in user's page and hand it to someone else. `private` forbids
|
|
305
|
+
// that; `no-cache` makes the browser revalidate rather than re-show a stale personalised page.
|
|
306
|
+
// Neither blocks bfcache, which `no-store` would. A prerendered page already has its own value.
|
|
330
307
|
if (!headers.has('cache-control'))
|
|
331
308
|
headers.set('cache-control', 'private, no-cache');
|
|
332
309
|
});
|
|
333
|
-
// A memory-exhaustion guard for *every* route — pages and actions, `{ type: 'endpoint' }` routes
|
|
334
|
-
// and the src/server.ts sub-app alike — since anything that buffers a body (`.json()`,
|
|
335
|
-
// `.formData()`) is exposed, not just server actions. Rejects an over-cap `Content-Length` up
|
|
336
|
-
// front and otherwise counts the stream, so a chunked or under-reported body is still cut off.
|
|
337
|
-
if (maxBodyBytes > 0) {
|
|
338
|
-
app.use(bodyLimit({ maxSize: maxBodyBytes, onError: (c) => c.text('Payload Too Large', 413) }));
|
|
339
|
-
}
|
|
340
310
|
runtime.mountStaticAssets(app);
|
|
341
|
-
// Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash
|
|
342
|
-
//
|
|
343
|
-
//
|
|
311
|
+
// Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash,
|
|
312
|
+
// and the security middleware the framework no longer owns — `csrf()`, `bodyLimit()`,
|
|
313
|
+
// `secureHeaders()`) wraps page requests too. The flip side: a *terminal* handler in
|
|
314
|
+
// src/server.ts at the same path as a page route wins over the page.
|
|
344
315
|
if (serverApp) {
|
|
345
316
|
app.route('/', serverApp);
|
|
346
317
|
}
|
|
@@ -348,10 +319,12 @@ function buildApp() {
|
|
|
348
319
|
const loadNotFoundPage = routeConfig.notFound ? memoizePage(routeConfig.notFound, 'the notFound page') : null;
|
|
349
320
|
/** Turns a thrown `redirect()` / `notFound()` into the response it stands for. */
|
|
350
321
|
const respondToControlSignal = async (c, signal) => {
|
|
351
|
-
const isRsc =
|
|
322
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
352
323
|
if (signal instanceof RedirectSignal) {
|
|
353
324
|
if (isRsc) {
|
|
354
|
-
|
|
325
|
+
// No `signal`, deliberately — see `renderComponent`. This payload is two fields and no
|
|
326
|
+
// component tree, so there is nothing worth aborting and nothing worth a listener.
|
|
327
|
+
return c.body(renderToReadableStream({ root: null, redirect: signal.location }), 200, {
|
|
355
328
|
'content-type': 'text/x-component;charset=utf-8',
|
|
356
329
|
});
|
|
357
330
|
}
|
|
@@ -360,8 +333,7 @@ function buildApp() {
|
|
|
360
333
|
if (loadNotFoundPage) {
|
|
361
334
|
return renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
|
|
362
335
|
}
|
|
363
|
-
|
|
364
|
-
return c.text('Not Found', 404, { vary: 'Accept' });
|
|
336
|
+
return plainNotFound(c);
|
|
365
337
|
};
|
|
366
338
|
for (const route of routes) {
|
|
367
339
|
if (isPageRoute(route)) {
|
|
@@ -372,18 +344,22 @@ function buildApp() {
|
|
|
372
344
|
try {
|
|
373
345
|
if (servesPrerendered && c.req.method === 'GET') {
|
|
374
346
|
const isRsc = acceptsRsc(c.req.raw);
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
347
|
+
// A prerendered file is one fixed set of bytes, so it cannot carry a per-request nonce:
|
|
348
|
+
// where the app's CSP has one, the *document* has to be rendered per request. The flight
|
|
349
|
+
// payload never carries a nonce, so it stays servable from the build either way.
|
|
350
|
+
//
|
|
351
|
+
// Decided per request rather than per build, which is what moving the CSP to
|
|
352
|
+
// `secureHeaders()` buys: an app whose policy carries no `NONCE` keeps its prerendered
|
|
353
|
+
// documents, where the old build-time `csp: true` flag gave every one of them up.
|
|
354
|
+
const mustRenderForNonce = !isRsc && cspNonce(c) !== undefined;
|
|
355
|
+
if (!mustRenderForNonce) {
|
|
380
356
|
const page = await runtime.readPrerendered(c, isRsc ? 'flight' : 'html');
|
|
381
357
|
// A prerendered page is request-independent by construction, so it is safe to cache
|
|
382
|
-
// publicly; the short max-age matches what `public/` files get
|
|
358
|
+
// publicly; the short max-age matches what `public/` files get, and the ETag turns the
|
|
383
359
|
// revalidation that follows into a 304 rather than the page all over again.
|
|
384
360
|
//
|
|
385
361
|
// Answered outside `runWithContext`: no app code runs on this path, so there is no
|
|
386
|
-
// `
|
|
362
|
+
// `getRequestContext()` to serve and no reason to pay for the AsyncLocalStorage scope.
|
|
387
363
|
if (page !== null) {
|
|
388
364
|
const headers = {
|
|
389
365
|
'cache-control': SSG_CACHE_CONTROL,
|
|
@@ -422,18 +398,28 @@ function buildApp() {
|
|
|
422
398
|
}
|
|
423
399
|
runtime.mountPublicFallback(app);
|
|
424
400
|
app.notFound(async (c) => {
|
|
425
|
-
const isRsc =
|
|
401
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
426
402
|
if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
|
|
427
403
|
return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc }));
|
|
428
404
|
}
|
|
429
|
-
return c
|
|
405
|
+
return plainNotFound(c);
|
|
430
406
|
});
|
|
431
407
|
const loadErrorPage = routeConfig.error ? memoizePage(routeConfig.error, 'the error page') : null;
|
|
432
408
|
app.onError(async (error, c) => {
|
|
433
409
|
if (isControlSignal(error))
|
|
434
410
|
return runWithContext(c, () => respondToControlSignal(c, error));
|
|
411
|
+
// Registering an `onError` at all replaces Hono's default handler, which is what would otherwise
|
|
412
|
+
// turn an `HTTPException` into the response it carries. Without this every middleware that
|
|
413
|
+
// rejects a request by throwing one — `csrf()` with a 403, `bodyLimit()` with a 413, an app's own
|
|
414
|
+
// `throw new HTTPException(401)` — would surface as a 500 error page instead of its own status.
|
|
415
|
+
// Rebuilt through `c` rather than returned as-is, exactly as Hono's own default does, so headers
|
|
416
|
+
// already prepared on the context (`c.header(…)` from middleware that ran first) survive.
|
|
417
|
+
if (error instanceof HTTPException) {
|
|
418
|
+
const res = error.getResponse();
|
|
419
|
+
return c.newResponse(res.body, res);
|
|
420
|
+
}
|
|
435
421
|
reportServerError(error, { source: 'request', request: c.req.raw, message: '[rshono] request error:' });
|
|
436
|
-
const isRsc =
|
|
422
|
+
const isRsc = requestWantsRsc(c.req.raw);
|
|
437
423
|
if (loadErrorPage && (isRsc || acceptsHtml(c))) {
|
|
438
424
|
const errorInfo = isDev
|
|
439
425
|
? {
|