@rshono/core 1.0.0-rc.6 → 1.0.0-rc.8
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 +195 -168
- 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.map +1 -1
- package/dist/builder/rspack-config.js +16 -19
- package/dist/builder/rspack-config.js.map +1 -1
- 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 +3 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/config.d.ts +56 -55
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +17 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/contract.d.ts +15 -10
- package/dist/deploy/contract.d.ts.map +1 -1
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts.map +1 -1
- package/dist/deploy/filesystem.js +6 -7
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts +5 -5
- package/dist/deploy/node/runtime.d.ts.map +1 -1
- package/dist/deploy/node/runtime.js +7 -11
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/deploy/presets.d.ts +2 -6
- package/dist/deploy/presets.d.ts.map +1 -1
- package/dist/deploy/presets.js +2 -6
- package/dist/deploy/presets.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +74 -36
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +2 -3
- package/dist/router.js.map +1 -1
- package/dist/runtime/boundaries.d.ts +10 -0
- package/dist/runtime/boundaries.d.ts.map +1 -1
- package/dist/runtime/boundaries.js +6 -0
- package/dist/runtime/boundaries.js.map +1 -1
- package/dist/runtime/client.d.ts +5 -3
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +5 -3
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/context.d.ts +195 -45
- package/dist/runtime/context.d.ts.map +1 -1
- package/dist/runtime/context.js +277 -59
- 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/entry.client.js +29 -47
- 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 +109 -140
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/flight-inject.d.ts +4 -4
- package/dist/runtime/flight-inject.js +2 -2
- package/dist/runtime/flight-inject.js.map +1 -1
- package/dist/runtime/navigation.d.ts +11 -0
- package/dist/runtime/navigation.d.ts.map +1 -1
- package/dist/runtime/navigation.js +3 -0
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/request.d.ts +3 -3
- package/dist/runtime/request.js +3 -3
- 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/prerendered.d.ts +20 -35
- package/dist/server/prerendered.d.ts.map +1 -1
- package/dist/server/prerendered.js +8 -18
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +10 -29
- package/dist/server/server-config.d.ts.map +1 -1
- package/dist/server/server-config.js +4 -70
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/ssg.d.ts.map +1 -1
- package/dist/server/ssg.js +10 -11
- package/dist/server/ssg.js.map +1 -1
- package/package.json +1 -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 { getRequestContext, publicUrl, readParams, reportServerError, runWithContext } from './context.js';
|
|
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
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, cspEnabled, checkOrigin, maxBodyBytes } = 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;
|
|
@@ -133,50 +112,26 @@ function releaseWhenDone(stream, done) {
|
|
|
133
112
|
cancel: done,
|
|
134
113
|
}));
|
|
135
114
|
}
|
|
136
|
-
/**
|
|
137
|
-
* There is no render deadline here any more.
|
|
138
|
-
*
|
|
139
|
-
* A `renderTimeout` setting used to wrap every request in an `AbortController` with a timer, covering
|
|
140
|
-
* the server action as well as the flight and SSR passes. Every host this deploys to enforces its own
|
|
141
|
-
* request timeout — Workers, Vercel and Lambda all do — so the only shape that needed one was a Node
|
|
142
|
-
* process facing the internet directly, and there a proxy is both the conventional and the more capable
|
|
143
|
-
* place for it. The README shows the middleware for a Node deploy that wants one in-process.
|
|
144
|
-
*
|
|
145
|
-
* What the deadline *also* did was carry the client-disconnect signal into the two React renderers, and
|
|
146
|
-
* that is kept — but not by handing them `c.req.raw.signal`. Both React renderers add an `abort`
|
|
147
|
-
* listener to whatever signal they are given and only remove it if the abort fires, so a listener left on
|
|
148
|
-
* a request-lifetime signal pins the flight request, the Fizz request and the whole rendered tree. That
|
|
149
|
-
* is not theoretical: handing them the request signal directly cost ~169 kB per `/ssr` request, never
|
|
150
|
-
* reclaimed by a major GC, and took GC from 5.8% to 12.9% of wall time under load.
|
|
151
|
-
*
|
|
152
|
-
* The deadline's own `AbortController` was, accidentally, what had kept that collectable — its signal
|
|
153
|
-
* died with the render. So {@link renderComponent} keeps a per-render controller and forwards the request
|
|
154
|
-
* signal into it, releasing the forwarder when the response ends. Never through `AbortSignal.any([...])`,
|
|
155
|
-
* which reads better and leaks worse: a signal it produced is *composite*, and Node holds every composite
|
|
156
|
-
* signal carrying an `abort` listener in a process-lifetime set (`gcPersistentSignals`) until it either
|
|
157
|
-
* aborts or loses its last listener.
|
|
158
|
-
*/
|
|
159
115
|
/**
|
|
160
116
|
* Builds the props a page component is called with.
|
|
161
117
|
*
|
|
162
|
-
* `url` and `params` match `useNavigation()` field for field, so a read can move between a page and
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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.
|
|
165
121
|
*
|
|
166
|
-
* `ctx` is *defined* rather than assigned, and both
|
|
122
|
+
* `ctx` is *defined* rather than assigned, and both halves of how carry their weight:
|
|
167
123
|
*
|
|
168
124
|
* - **A getter**, so nothing is built for the pages that never read it, and so a `render: 'static'`
|
|
169
|
-
* page that does read it gets {@link getRequestContext}'s
|
|
170
|
-
* 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`.
|
|
171
127
|
* - **Non-enumerable**, so React's *development-only* serialization of a server component's props
|
|
172
128
|
* (the debug channel behind component stacks and the performance track) skips it. That walks own
|
|
173
|
-
* enumerable properties, and `ctx.
|
|
174
|
-
* runtime's bindings. An enumerable `ctx`
|
|
175
|
-
* browser in dev, and
|
|
176
|
-
* 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.
|
|
177
132
|
*
|
|
178
|
-
* The cost is that the element has to be created by handing this object to `jsx()` *by reference*:
|
|
179
|
-
*
|
|
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.
|
|
180
135
|
*/
|
|
181
136
|
function pageProps(c, errorInfo) {
|
|
182
137
|
const props = { url: publicUrl(c), params: readParams(c), ...(errorInfo ? { error: errorInfo } : null) };
|
|
@@ -184,18 +139,16 @@ function pageProps(c, errorInfo) {
|
|
|
184
139
|
return props;
|
|
185
140
|
}
|
|
186
141
|
async function renderComponent(c, Page, opts) {
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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).
|
|
192
147
|
//
|
|
193
|
-
// So the render gets a controller of its own and the request signal only forwards into it
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// `
|
|
197
|
-
// carrying an `abort` listener in a process-lifetime set until it aborts or loses its last listener,
|
|
198
|
-
// which is a worse leak than the one being fixed here.
|
|
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.
|
|
199
152
|
const requestSignal = c.req.raw.signal;
|
|
200
153
|
const renderAbort = new AbortController();
|
|
201
154
|
const signal = renderAbort.signal;
|
|
@@ -206,11 +159,19 @@ async function renderComponent(c, Page, opts) {
|
|
|
206
159
|
requestSignal.addEventListener('abort', forwardAbort, { once: true });
|
|
207
160
|
/** Detaches the forwarder once the response has been written, so nothing survives the request. */
|
|
208
161
|
const release = () => requestSignal.removeEventListener('abort', forwardAbort);
|
|
209
|
-
|
|
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);
|
|
210
166
|
const props = pageProps(c, opts.errorInfo);
|
|
211
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) })] }));
|
|
212
168
|
// `notFound` only when it is true, so the flight payload of an ordinary page doesn't carry the key.
|
|
213
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);
|
|
214
175
|
let controlSignal;
|
|
215
176
|
const rscStream = renderToReadableStream(rscPayload, {
|
|
216
177
|
temporaryReferences: opts.temporaryReferences,
|
|
@@ -251,15 +212,9 @@ async function renderComponent(c, Page, opts) {
|
|
|
251
212
|
release();
|
|
252
213
|
throw controlSignal;
|
|
253
214
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// directive in `cspDirectives` can widen the policy but can't accidentally drop the nonce.
|
|
258
|
-
// Dev additionally needs 'unsafe-eval' for react-refresh.
|
|
259
|
-
const scriptSrc = `script-src ${CSP_SCRIPT_SRC} 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`;
|
|
260
|
-
headers['content-security-policy'] = CSP_STATIC ? `${CSP_STATIC}; ${scriptSrc}` : scriptSrc;
|
|
261
|
-
}
|
|
262
|
-
return c.body(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
|
+
});
|
|
263
218
|
}
|
|
264
219
|
async function renderPage(c, loadPage) {
|
|
265
220
|
const request = c.req.raw;
|
|
@@ -269,9 +224,6 @@ async function renderPage(c, loadPage) {
|
|
|
269
224
|
let temporaryReferences;
|
|
270
225
|
let actionStatus;
|
|
271
226
|
if (isActionRequest(renderRequest)) {
|
|
272
|
-
if (!isSameOriginAction(c)) {
|
|
273
|
-
return c.text('Forbidden: cross-origin server action rejected', 403);
|
|
274
|
-
}
|
|
275
227
|
if (renderRequest.kind === 'rsc-action') {
|
|
276
228
|
// Checked before the body is decoded, so an unknown id costs nothing to reject — and
|
|
277
229
|
// `loadServerAction` would otherwise fault on the missing manifest entry, turning a bad
|
|
@@ -317,11 +269,13 @@ async function renderPage(c, loadPage) {
|
|
|
317
269
|
}
|
|
318
270
|
function buildApp() {
|
|
319
271
|
const app = new Hono();
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
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.
|
|
325
279
|
app.use(async (c, next) => {
|
|
326
280
|
await next();
|
|
327
281
|
const headers = c.res.headers;
|
|
@@ -331,31 +285,33 @@ function buildApp() {
|
|
|
331
285
|
headers.set('referrer-policy', 'strict-origin-when-cross-origin');
|
|
332
286
|
if (!headers.has('x-frame-options'))
|
|
333
287
|
headers.set('x-frame-options', 'SAMEORIGIN');
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// 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.
|
|
337
300
|
if (!PAGE_CONTENT_TYPE.test(headers.get('content-type') ?? ''))
|
|
338
301
|
return;
|
|
339
302
|
appendVary(headers, 'Accept');
|
|
340
|
-
// Without this a page carries no cache directives at all, and a shared cache — a CDN, a
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// 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.
|
|
345
307
|
if (!headers.has('cache-control'))
|
|
346
308
|
headers.set('cache-control', 'private, no-cache');
|
|
347
309
|
});
|
|
348
|
-
// A memory-exhaustion guard for *every* route — pages and actions, `{ type: 'endpoint' }` routes
|
|
349
|
-
// and the src/server.ts sub-app alike — since anything that buffers a body (`.json()`,
|
|
350
|
-
// `.formData()`) is exposed, not just server actions. Rejects an over-cap `Content-Length` up
|
|
351
|
-
// front and otherwise counts the stream, so a chunked or under-reported body is still cut off.
|
|
352
|
-
if (maxBodyBytes > 0) {
|
|
353
|
-
app.use(bodyLimit({ maxSize: maxBodyBytes, onError: (c) => c.text('Payload Too Large', 413) }));
|
|
354
|
-
}
|
|
355
310
|
runtime.mountStaticAssets(app);
|
|
356
|
-
// Mounted ahead of the page routes so the sub-app's middleware (auth, logging, trailing-slash
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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.
|
|
359
315
|
if (serverApp) {
|
|
360
316
|
app.route('/', serverApp);
|
|
361
317
|
}
|
|
@@ -366,7 +322,9 @@ function buildApp() {
|
|
|
366
322
|
const isRsc = requestWantsRsc(c.req.raw);
|
|
367
323
|
if (signal instanceof RedirectSignal) {
|
|
368
324
|
if (isRsc) {
|
|
369
|
-
|
|
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, {
|
|
370
328
|
'content-type': 'text/x-component;charset=utf-8',
|
|
371
329
|
});
|
|
372
330
|
}
|
|
@@ -375,8 +333,7 @@ function buildApp() {
|
|
|
375
333
|
if (loadNotFoundPage) {
|
|
376
334
|
return renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc, notFound: true });
|
|
377
335
|
}
|
|
378
|
-
|
|
379
|
-
return c.text('Not Found', 404, { vary: 'Accept' });
|
|
336
|
+
return plainNotFound(c);
|
|
380
337
|
};
|
|
381
338
|
for (const route of routes) {
|
|
382
339
|
if (isPageRoute(route)) {
|
|
@@ -388,15 +345,17 @@ function buildApp() {
|
|
|
388
345
|
if (servesPrerendered && c.req.method === 'GET') {
|
|
389
346
|
const isRsc = acceptsRsc(c.req.raw);
|
|
390
347
|
// A prerendered file is one fixed set of bytes, so it cannot carry a per-request nonce:
|
|
391
|
-
//
|
|
392
|
-
// carries a nonce, so it stays servable from the build either way.
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
//
|
|
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;
|
|
396
355
|
if (!mustRenderForNonce) {
|
|
397
356
|
const page = await runtime.readPrerendered(c, isRsc ? 'flight' : 'html');
|
|
398
357
|
// A prerendered page is request-independent by construction, so it is safe to cache
|
|
399
|
-
// 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
|
|
400
359
|
// revalidation that follows into a 304 rather than the page all over again.
|
|
401
360
|
//
|
|
402
361
|
// Answered outside `runWithContext`: no app code runs on this path, so there is no
|
|
@@ -443,12 +402,22 @@ function buildApp() {
|
|
|
443
402
|
if (loadNotFoundPage && (isRsc || acceptsHtml(c))) {
|
|
444
403
|
return runWithContext(c, async () => renderComponent(c, await loadNotFoundPage(), { status: 404, isRsc }));
|
|
445
404
|
}
|
|
446
|
-
return c
|
|
405
|
+
return plainNotFound(c);
|
|
447
406
|
});
|
|
448
407
|
const loadErrorPage = routeConfig.error ? memoizePage(routeConfig.error, 'the error page') : null;
|
|
449
408
|
app.onError(async (error, c) => {
|
|
450
409
|
if (isControlSignal(error))
|
|
451
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
|
+
}
|
|
452
421
|
reportServerError(error, { source: 'request', request: c.req.raw, message: '[rshono] request error:' });
|
|
453
422
|
const isRsc = requestWantsRsc(c.req.raw);
|
|
454
423
|
if (loadErrorPage && (isRsc || acceptsHtml(c))) {
|