@rshono/core 1.0.0-rc.2 → 1.0.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +348 -158
- package/bin/rshono.mjs +3 -4
- package/dist/builder/env-shadow-loader.cjs +123 -8
- package/dist/builder/page-entry-loader.cjs +28 -1
- package/dist/builder/page-files.d.ts +0 -1
- package/dist/builder/page-files.js +7 -3
- package/dist/builder/page-files.js.map +1 -1
- package/dist/builder/public-env.d.ts +7 -1
- package/dist/builder/public-env.js +7 -0
- package/dist/builder/public-env.js.map +1 -1
- package/dist/builder/react-versions.d.ts +12 -0
- package/dist/builder/react-versions.js +74 -0
- package/dist/builder/react-versions.js.map +1 -0
- package/dist/builder/rspack-config.d.ts +13 -4
- package/dist/builder/rspack-config.js +136 -53
- package/dist/builder/rspack-config.js.map +1 -1
- package/dist/builder/server-only-imports.d.ts +32 -0
- package/dist/builder/server-only-imports.js +51 -0
- package/dist/builder/server-only-imports.js.map +1 -0
- package/dist/cli/build.d.ts +2 -3
- package/dist/cli/build.js +51 -13
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/dev.d.ts +2 -3
- package/dist/cli/dev.js +149 -45
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/exit.d.ts +12 -0
- package/dist/cli/exit.js +16 -0
- package/dist/cli/exit.js.map +1 -0
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +91 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/start.d.ts +0 -1
- package/dist/cli/start.js +15 -18
- package/dist/cli/start.js.map +1 -1
- package/dist/config.d.ts +72 -78
- package/dist/config.js +17 -1
- package/dist/config.js.map +1 -1
- package/dist/deploy/aws-lambda/runtime.d.ts +4 -7
- package/dist/deploy/aws-lambda/runtime.js +5 -8
- package/dist/deploy/aws-lambda/runtime.js.map +1 -1
- package/dist/deploy/build-marker.d.ts +9 -6
- package/dist/deploy/build-marker.js +8 -4
- package/dist/deploy/build-marker.js.map +1 -1
- package/dist/deploy/cloudflare/build.d.ts +0 -1
- package/dist/deploy/cloudflare/build.js +17 -13
- package/dist/deploy/cloudflare/build.js.map +1 -1
- package/dist/deploy/cloudflare/runtime.d.ts +2 -6
- package/dist/deploy/cloudflare/runtime.js +73 -68
- package/dist/deploy/cloudflare/runtime.js.map +1 -1
- package/dist/deploy/contract.d.ts +57 -41
- package/dist/deploy/contract.js.map +1 -1
- package/dist/deploy/filesystem.d.ts +3 -6
- package/dist/deploy/filesystem.js +23 -20
- package/dist/deploy/filesystem.js.map +1 -1
- package/dist/deploy/node/runtime.d.ts +5 -3
- package/dist/deploy/node/runtime.js +23 -9
- package/dist/deploy/node/runtime.js.map +1 -1
- package/dist/deploy/presets.d.ts +34 -30
- package/dist/deploy/presets.js +56 -44
- package/dist/deploy/presets.js.map +1 -1
- package/dist/deploy/public-paths.d.ts +42 -0
- package/dist/deploy/public-paths.js +66 -0
- package/dist/deploy/public-paths.js.map +1 -0
- package/dist/deploy/vercel/build.d.ts +0 -1
- package/dist/deploy/vercel/build.js +25 -16
- package/dist/deploy/vercel/build.js.map +1 -1
- package/dist/deploy/vercel/runtime.d.ts +10 -7
- package/dist/deploy/vercel/runtime.js +48 -11
- package/dist/deploy/vercel/runtime.js.map +1 -1
- package/dist/index.d.ts +19 -17
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +212 -120
- package/dist/router.js +3 -8
- package/dist/router.js.map +1 -1
- package/dist/runtime/boundaries.d.ts +62 -48
- package/dist/runtime/boundaries.js +32 -34
- package/dist/runtime/boundaries.js.map +1 -1
- package/dist/runtime/client.d.ts +20 -11
- package/dist/runtime/client.js +20 -10
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/context.d.ts +304 -123
- package/dist/runtime/context.js +377 -149
- package/dist/runtime/context.js.map +1 -1
- package/dist/runtime/control.d.ts +15 -1
- package/dist/runtime/control.js +24 -0
- package/dist/runtime/control.js.map +1 -1
- package/dist/runtime/dev-protocol.d.ts +4 -9
- package/dist/runtime/dev-protocol.js.map +1 -1
- package/dist/runtime/empty-server-app.d.ts +0 -1
- package/dist/runtime/entry.client.d.ts +4 -1
- package/dist/runtime/entry.client.js +455 -313
- package/dist/runtime/entry.client.js.map +1 -1
- package/dist/runtime/entry.rsc.d.ts +15 -7
- package/dist/runtime/entry.rsc.js +811 -252
- package/dist/runtime/entry.rsc.js.map +1 -1
- package/dist/runtime/entry.ssr.d.ts +27 -10
- package/dist/runtime/entry.ssr.js +30 -48
- package/dist/runtime/entry.ssr.js.map +1 -1
- package/dist/runtime/failure-document.d.ts +11 -0
- package/dist/runtime/failure-document.js +35 -0
- package/dist/runtime/failure-document.js.map +1 -0
- package/dist/runtime/flight-inject.d.ts +23 -0
- package/dist/runtime/flight-inject.js +403 -0
- package/dist/runtime/flight-inject.js.map +1 -0
- package/dist/runtime/hot-update.d.ts +44 -0
- package/dist/runtime/hot-update.js +44 -0
- package/dist/runtime/hot-update.js.map +1 -0
- package/dist/runtime/navigation.d.ts +46 -54
- package/dist/runtime/navigation.js +22 -63
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/request.d.ts +42 -8
- package/dist/runtime/request.js +84 -7
- package/dist/runtime/request.js.map +1 -1
- package/dist/runtime/server.d.ts +20 -18
- package/dist/runtime/server.js +22 -17
- package/dist/runtime/server.js.map +1 -1
- package/dist/runtime/validate-entries.d.ts +77 -0
- package/dist/runtime/validate-entries.js +405 -0
- package/dist/runtime/validate-entries.js.map +1 -0
- package/dist/server/headers.d.ts +41 -15
- package/dist/server/headers.js +53 -24
- package/dist/server/headers.js.map +1 -1
- package/dist/server/load-config.d.ts +4 -5
- package/dist/server/load-config.js +22 -14
- package/dist/server/load-config.js.map +1 -1
- package/dist/server/load-env.d.ts +0 -1
- package/dist/server/prerendered.d.ts +88 -34
- package/dist/server/prerendered.js +184 -24
- package/dist/server/prerendered.js.map +1 -1
- package/dist/server/server-config.d.ts +35 -50
- package/dist/server/server-config.js +27 -74
- package/dist/server/server-config.js.map +1 -1
- package/dist/server/shutdown.d.ts +5 -4
- package/dist/server/shutdown.js +5 -3
- package/dist/server/shutdown.js.map +1 -1
- package/dist/server/ssg.d.ts +9 -7
- package/dist/server/ssg.js +209 -65
- package/dist/server/ssg.js.map +1 -1
- package/dist/server/static.d.ts +0 -1
- package/dist/server/static.js +7 -1
- package/dist/server/static.js.map +1 -1
- package/package.json +21 -23
- package/dist/builder/page-files.d.ts.map +0 -1
- package/dist/builder/public-env.d.ts.map +0 -1
- package/dist/builder/rspack-config.d.ts.map +0 -1
- package/dist/cli/build.d.ts.map +0 -1
- package/dist/cli/dev.d.ts.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/start.d.ts.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
- package/dist/deploy/build-marker.d.ts.map +0 -1
- package/dist/deploy/bun/runtime.d.ts +0 -11
- package/dist/deploy/bun/runtime.d.ts.map +0 -1
- package/dist/deploy/bun/runtime.js +0 -22
- package/dist/deploy/bun/runtime.js.map +0 -1
- package/dist/deploy/cloudflare/build.d.ts.map +0 -1
- package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
- package/dist/deploy/contract.d.ts.map +0 -1
- package/dist/deploy/deno/runtime.d.ts +0 -11
- package/dist/deploy/deno/runtime.d.ts.map +0 -1
- package/dist/deploy/deno/runtime.js +0 -16
- package/dist/deploy/deno/runtime.js.map +0 -1
- package/dist/deploy/filesystem.d.ts.map +0 -1
- package/dist/deploy/listen.d.ts +0 -20
- package/dist/deploy/listen.d.ts.map +0 -1
- package/dist/deploy/listen.js +0 -24
- package/dist/deploy/listen.js.map +0 -1
- package/dist/deploy/netlify/build.d.ts +0 -8
- package/dist/deploy/netlify/build.d.ts.map +0 -1
- package/dist/deploy/netlify/build.js +0 -52
- package/dist/deploy/netlify/build.js.map +0 -1
- package/dist/deploy/netlify/runtime.d.ts +0 -13
- package/dist/deploy/netlify/runtime.d.ts.map +0 -1
- package/dist/deploy/netlify/runtime.js +0 -24
- package/dist/deploy/netlify/runtime.js.map +0 -1
- package/dist/deploy/node/runtime.d.ts.map +0 -1
- package/dist/deploy/presets.d.ts.map +0 -1
- package/dist/deploy/vercel/build.d.ts.map +0 -1
- package/dist/deploy/vercel/runtime.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/router.d.ts.map +0 -1
- package/dist/runtime/boundaries.d.ts.map +0 -1
- package/dist/runtime/client.d.ts.map +0 -1
- package/dist/runtime/context.d.ts.map +0 -1
- package/dist/runtime/control.d.ts.map +0 -1
- package/dist/runtime/dev-protocol.d.ts.map +0 -1
- package/dist/runtime/empty-server-app.d.ts.map +0 -1
- package/dist/runtime/entry.client.d.ts.map +0 -1
- package/dist/runtime/entry.rsc.d.ts.map +0 -1
- package/dist/runtime/entry.ssr.d.ts.map +0 -1
- package/dist/runtime/navigation.d.ts.map +0 -1
- package/dist/runtime/request.d.ts.map +0 -1
- package/dist/runtime/server.d.ts.map +0 -1
- package/dist/server/compress.d.ts +0 -15
- package/dist/server/compress.d.ts.map +0 -1
- package/dist/server/compress.js +0 -76
- package/dist/server/compress.js.map +0 -1
- package/dist/server/headers.d.ts.map +0 -1
- package/dist/server/load-config.d.ts.map +0 -1
- package/dist/server/load-env.d.ts.map +0 -1
- package/dist/server/prerendered.d.ts.map +0 -1
- package/dist/server/server-config.d.ts.map +0 -1
- package/dist/server/shutdown.d.ts.map +0 -1
- package/dist/server/ssg.d.ts.map +0 -1
- package/dist/server/static.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,36 +1,58 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/rshono/rshono/main/logo.svg" alt="" width="72" height="72" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">@rshono/core</h1>
|
|
4
6
|
|
|
5
|
-
>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
>
|
|
7
|
+
<p align="center">
|
|
8
|
+
Minimalist web framework —
|
|
9
|
+
<a href="https://hono.dev">Hono</a> +
|
|
10
|
+
<a href="https://rspack.rs">Rspack</a> +
|
|
11
|
+
<a href="https://react.dev/reference/rsc/server-components">React Server Components</a>.
|
|
12
|
+
</p>
|
|
9
13
|
|
|
10
|
-
One required file (`src/routes.ts`), one optional file (`src/server.ts`), and you get a dev server with HMR,
|
|
14
|
+
One required file (`src/routes.ts`), one optional file (`src/server.ts`), and you get a dev server with HMR,
|
|
15
|
+
streaming SSR with RSC hydration, server actions with progressive enhancement, soft navigation, build-time
|
|
16
|
+
prerendering, and hard env/secret safety.
|
|
17
|
+
|
|
18
|
+
> **Release candidate.** The framework itself is covered end to end (see [Testing](#testing)) and its API is
|
|
19
|
+
> settled. What is not settled underneath it: Rspack's RSC support is an experimental API
|
|
20
|
+
> (`rspack.experiments.rsc`) and `react-server-dom-rspack` has not reached 1.0, so its own minor bumps are
|
|
21
|
+
> breaking by convention. Both are pinned to exact versions — in the manifests and in workspace overrides —
|
|
22
|
+
> and a release of rshono is what moves them, so an upstream change reaches you as a tested release rather
|
|
23
|
+
> than as a broken install. That is the whole of the caveat, and it is the reason to read the
|
|
24
|
+
> [changelog](../../CHANGELOG.md) before upgrading.
|
|
25
|
+
|
|
26
|
+
**Full documentation: [rshono.com/docs](https://www.rshono.com/docs).**
|
|
11
27
|
|
|
12
28
|
```bash
|
|
13
|
-
|
|
29
|
+
npx @rshono/create@latest my-app # scaffold one, with a deploy target and tooling of your choosing
|
|
14
30
|
```
|
|
15
31
|
|
|
16
32
|
```bash
|
|
17
33
|
rshono dev # dev server with HMR (default port 3000)
|
|
18
|
-
rshono build # production build: client + server bundles +
|
|
34
|
+
rshono build # production build: client + server bundles + prerendered pages
|
|
19
35
|
rshono start # run the production build
|
|
20
36
|
```
|
|
21
37
|
|
|
38
|
+
`--port` / `PORT` sets the port for `dev` and `start`. **`HOST` applies to `start` only** — `dev` always binds
|
|
39
|
+
`127.0.0.1`, because its source maps embed the original source of your `'use server'` modules, and it warns if
|
|
40
|
+
you set one. `--config <path>` points `build` at another config file.
|
|
41
|
+
|
|
22
42
|
## Project layout
|
|
23
43
|
|
|
24
44
|
```
|
|
25
|
-
rshono.config.ts optional — every field has a default
|
|
45
|
+
rshono.config.ts optional — every field has a default
|
|
26
46
|
public/ optional — served verbatim at the web root
|
|
27
47
|
src/
|
|
28
48
|
routes.ts required — the route table
|
|
29
49
|
server.ts optional — a Hono sub-app mounted ahead of the page routes
|
|
30
|
-
…
|
|
50
|
+
… everything else is yours to arrange
|
|
31
51
|
```
|
|
32
52
|
|
|
33
|
-
Only
|
|
53
|
+
Only those two files under `src/` mean anything to the framework; no other name or directory carries a
|
|
54
|
+
convention. `@/…` resolves to `src/…` in both compilers, so add the matching `paths` to `tsconfig.json` if
|
|
55
|
+
you use it — relative, and with no `baseUrl`, which TypeScript 7 removed:
|
|
34
56
|
|
|
35
57
|
```json
|
|
36
58
|
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
|
|
@@ -58,13 +80,15 @@ export const routes = defineRoutes({
|
|
|
58
80
|
});
|
|
59
81
|
```
|
|
60
82
|
|
|
61
|
-
`routes.ts` only ever runs on the server
|
|
83
|
+
`routes.ts` only ever runs on the server, so importing server-only modules from it (inside `staticPaths`,
|
|
84
|
+
say) is safe. A plain array, with no special pages, is accepted as shorthand.
|
|
85
|
+
[Routing docs](https://www.rshono.com/docs/routing).
|
|
62
86
|
|
|
63
87
|
## Pages are server components
|
|
64
88
|
|
|
65
|
-
Every page module
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
Every page module default-exports a server component — nothing else. It renders the whole document
|
|
90
|
+
(`<html>…</html>`), may be `async`, and awaits its data directly. Interactive parts are `'use client'`
|
|
91
|
+
components the page imports; only those ship JavaScript.
|
|
68
92
|
|
|
69
93
|
```tsx
|
|
70
94
|
import type { PageProps } from '@rshono/core';
|
|
@@ -77,14 +101,22 @@ export default async function Profile({ params, ctx }: PageProps<'/profile/:id'>
|
|
|
77
101
|
}
|
|
78
102
|
```
|
|
79
103
|
|
|
80
|
-
- Pages receive `{ url, params, ctx }`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
- Pages receive `{ url, params, ctx }` — `PageProps<'/profile/:id'>` types `params.id`, and `url` is a real
|
|
105
|
+
`URL`. The same pair reaches a `'use client'` component from `useNavigation()`, so a read moves across the
|
|
106
|
+
boundary unchanged.
|
|
107
|
+
- **`ctx` is the request context** — `ctx.req`, cookies, env, middleware variables, the proxy-aware URL. It is
|
|
108
|
+
the same object `getRequestContext()` returns from `@rshono/core/server`, handed over so a page needs no
|
|
109
|
+
import. Reading it on a `render: 'static'` page throws: a page rendered once at build time has no request.
|
|
110
|
+
A page can only _read_ it — `cookies.set` and `setHeader` throw there, because a page streams and its
|
|
111
|
+
response head is already committed; set them from a `'use server'` action or from middleware instead.
|
|
112
|
+
- Page props are server-only and never serialized, and `ctx` cannot cross into a client component. Read what
|
|
113
|
+
you need on the server and pass plain values down (`url.href`, not `url`).
|
|
114
|
+
- The framework injects Rspack's `'use server-entry'` directive for every component referenced with the
|
|
115
|
+
inline `component: () => import('…')` thunk — that is what attaches the page's JS and CSS to it, so code
|
|
116
|
+
splitting needs no asset manifest. Wire a component up some other way and you write the directive
|
|
117
|
+
yourself; the framework throws a descriptive error when neither happened.
|
|
118
|
+
|
|
119
|
+
[Pages docs](https://www.rshono.com/docs/pages).
|
|
88
120
|
|
|
89
121
|
## Server actions
|
|
90
122
|
|
|
@@ -95,179 +127,337 @@ export default async function Profile({ params, ctx }: PageProps<'/profile/:id'>
|
|
|
95
127
|
export async function createUser(data: { name: string; email: string }) { … }
|
|
96
128
|
```
|
|
97
129
|
|
|
98
|
-
Call them directly from client code (typed args and result), or wire them to `<form action>` /
|
|
99
|
-
|
|
100
|
-
|
|
130
|
+
Call them directly from client code (typed args and result), or wire them to `<form action>` /
|
|
131
|
+
`useActionState` — forms keep working before hydration and with JavaScript disabled. Every action response
|
|
132
|
+
carries a fresh page payload, so server-rendered UI updates after a mutation.
|
|
133
|
+
**Every `'use server'` export is a public HTTP endpoint**, so authenticate, authorize and validate inside the
|
|
134
|
+
action. [Server actions docs](https://www.rshono.com/docs/pages#server-actions), and
|
|
135
|
+
[how to use them](https://www.rshono.com/docs/usage).
|
|
101
136
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
137
|
+
Rspack compiles an app's whole `'use server'` graph into **one** server module, and calling any action loads
|
|
138
|
+
it — so a module that throws while it evaluates takes every action in the app with it, not just its own.
|
|
139
|
+
Nothing on the server imports these modules until an action is called, which used to make that a green build
|
|
140
|
+
followed by a 500 on the first click. `rshono build` now loads them and warns if one will not, and at run
|
|
141
|
+
time a failure to load is reported as an action fault, not answered as a bad request.
|
|
105
142
|
|
|
106
|
-
##
|
|
107
|
-
|
|
108
|
-
- `import './styles.css'` from any component. Rspack's native CSS pipeline compiles it, and the import attaches the stylesheet to the importing page's assets — so CSS is code-split per route and arrives with the page that needs it, `<link>`ed in the streamed HTML rather than fetched after hydration. `*.module.css` gets a class map.
|
|
109
|
-
- **There is no PostCSS in the framework**, and no dependency on any of it — native CSS is fast, and it is everything a plain stylesheet needs. What it cannot do is read CSS that isn't finished yet: `@import 'tailwindcss'`, `@theme` and `@apply` are all parse-time nonsense to it. A stylesheet that needs a plugin puts the plugin in front of that parser itself, through the [`rspack` hook](#configuration-rshonoconfigts), and installs the two packages a PostCSS pass takes.
|
|
110
|
-
- **Tailwind** is exactly that, and nothing else:
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
npm i -D tailwindcss @tailwindcss/postcss postcss postcss-loader
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
// rshono.config.ts — the hook is called once per compiler, so this reaches both graphs
|
|
118
|
-
export default defineConfig({
|
|
119
|
-
rspack(config) {
|
|
120
|
-
config.module!.rules!.push({ test: /\.css$/i, use: ['postcss-loader'], type: 'css/auto' });
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
```js
|
|
126
|
-
// postcss.config.mjs — the plugin list, which postcss-loader finds on its own
|
|
127
|
-
export default { plugins: { '@tailwindcss/postcss': {} } };
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
```css
|
|
131
|
-
/* src/styles.css */
|
|
132
|
-
@import 'tailwindcss';
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Keep `type: 'css/auto'` rather than `'css'`, or `*.module.css` stops being a CSS module. `npm create @rshono@latest --tailwind` writes all four of these for you.
|
|
136
|
-
|
|
137
|
-
## Static files
|
|
143
|
+
## Full Hono underneath
|
|
138
144
|
|
|
139
|
-
-
|
|
140
|
-
-
|
|
141
|
-
|
|
145
|
+
- `{ type: 'endpoint' }` routes export a Hono `handler` from a server module.
|
|
146
|
+
- `src/server.ts` may default-export a whole Hono sub-app: any method, streaming, cookies, middleware.
|
|
147
|
+
`export type AppType = typeof server` gives end-to-end type safety with `hono/client`.
|
|
148
|
+
- It is mounted at `/` **ahead of the page routes**, so its middleware (auth, logging, trailing-slash) wraps
|
|
149
|
+
page requests too. The flip side: a _terminal_ handler at the same path as a page route shadows the page.
|
|
142
150
|
|
|
143
|
-
|
|
151
|
+
[Hono docs](https://www.rshono.com/docs/hono).
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
## Styling
|
|
146
154
|
|
|
147
|
-
|
|
155
|
+
`import './styles.css'` from any component. Rspack's native CSS pipeline compiles it, and the import
|
|
156
|
+
attaches the stylesheet to the importing page — so CSS is code-split per route and `<link>`ed in the streamed
|
|
157
|
+
HTML rather than fetched after hydration. `*.module.css` gets a class map.
|
|
148
158
|
|
|
149
|
-
|
|
159
|
+
**There is no PostCSS in the framework**, so a stylesheet that needs a plugin brings one, through the
|
|
160
|
+
[`rspack` hook](#configuration-rshonoconfigts). Tailwind is exactly that and nothing else — four packages, a
|
|
161
|
+
`postcss.config.mjs` and one rule; `npx @rshono/create@latest --tailwind` writes all of it.
|
|
162
|
+
[Styling docs](https://www.rshono.com/docs/styling).
|
|
150
163
|
|
|
151
164
|
## Env & secret safety
|
|
152
165
|
|
|
153
|
-
The
|
|
166
|
+
The boundary is the RSC directives — `'use client'` and `'use server'` — not filenames, and `process.env`
|
|
167
|
+
follows it.
|
|
168
|
+
|
|
169
|
+
- In the **client bundle** `process.env` is _replaced at build time_ with a literal holding only `NODE_ENV`
|
|
170
|
+
and `PUBLIC_`-prefixed variables. A stray `process.env.DATABASE_URL` in client code compiles to
|
|
171
|
+
`undefined`; the value cannot ship. That is a build-time substitution, not tree-shaking, and it covers
|
|
172
|
+
`node_modules` too.
|
|
173
|
+
- Your `'use client'` modules see the same `PUBLIC_`-only view **while being SSR'd**, so a secret read there
|
|
174
|
+
renders empty instead of leaking into the HTML stream, and SSR output still agrees with hydration.
|
|
175
|
+
- Server components and `'use server'` actions read the real `process.env`. Anything a server component
|
|
176
|
+
_renders_ is public by definition.
|
|
177
|
+
- `.env.local` and `.env` are loaded automatically; the real environment wins.
|
|
154
178
|
|
|
155
|
-
|
|
156
|
-
- **`'use client'` modules are also SSR'd on the server**, and there they see the same `PUBLIC_`-only view. A `process.env.SECRET` in a client component renders empty instead of leaking into the HTML stream, and SSR output always agrees with hydration. This SSR-side shadowing is scoped to your own `src/` — a _third-party_ client component that reads `process.env` during SSR sees the real environment, so treat a dependency that does that as you would any other dependency handling secrets.
|
|
157
|
-
- **Server components and `'use server'` actions read the real `process.env`.** They run only on the server — server components stay in the server graph, actions compile to server references — so a secret read there never reaches the browser. Read secrets in server code and pass derived data down.
|
|
158
|
-
- `.env.local` and `.env` are loaded automatically (real environment wins).
|
|
159
|
-
- Anything a server component _renders_ is public by definition — whatever you put in the tree ships in the flight payload.
|
|
160
|
-
- Keeping a server-only module out of the client bundle is the module graph's job: import it only from server code. For a hard failure if that ever slips, add React's `server-only` package — the RSC layer resolves its `react-server` condition, so importing it from client code throws.
|
|
179
|
+
[Environment and secrets](https://www.rshono.com/docs/configuration#environment-and-secrets).
|
|
161
180
|
|
|
162
181
|
## Configuration: rshono.config.ts
|
|
163
182
|
|
|
164
|
-
|
|
183
|
+
Optional (`.js` / `.mjs` also work). Every field is optional; delete the file to accept all defaults.
|
|
165
184
|
|
|
166
185
|
```ts
|
|
167
186
|
import { defineConfig } from '@rshono/core';
|
|
168
187
|
|
|
169
188
|
export default defineConfig({
|
|
170
|
-
deploy: 'node', // hosting platform to build for
|
|
189
|
+
deploy: 'node', // hosting platform to build for (--deploy or RSHONO_DEPLOY override)
|
|
171
190
|
siteUrl: 'https://example.com', // public origin, baked into prerendered pages' absolute URLs
|
|
172
|
-
port: 3000, // default port for dev/start (--port or PORT env override)
|
|
173
|
-
host: '0.0.0.0', // bind address for start (HOST env overrides)
|
|
174
191
|
trustProxy: false, // honour X-Forwarded-Host/-Proto — only behind a proxy you control
|
|
175
|
-
checkOrigin: true, // CSRF origin check on server-action POSTs
|
|
176
|
-
allowedOrigins: [], // extra origins allowed to post actions, e.g. ['https://admin.example.com']
|
|
177
|
-
csp: false, // strict per-request-nonce Content-Security-Policy
|
|
178
|
-
cspDirectives: {}, // widen the built-in CSP, e.g. { 'img-src': "'self' https://cdn.example.com" }
|
|
179
|
-
bodySizeLimit: '1mb', // request body cap: '512kb' | 4_000_000 | false to disable
|
|
180
|
-
renderTimeout: 10_000, // ms deadline for a request (action + flight + SSR)
|
|
181
|
-
compress: true, // gzip compressible responses (streaming-safe)
|
|
182
192
|
rspack(config, { isServer, isDev }) {
|
|
183
193
|
return config; // escape hatch: mutate the generated Rspack config
|
|
184
194
|
},
|
|
185
195
|
});
|
|
186
196
|
```
|
|
187
197
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
## Security & hardening
|
|
191
|
-
|
|
192
|
-
- **Every `'use server'` export is a public HTTP endpoint.** That's the RSC model, not an rshono choice: the client is handed an id for each action and can call it with whatever arguments it likes. The CSRF check below proves a request came from your own site — it says nothing about _who_ sent it. Authenticate and authorize inside the action (and validate its arguments) exactly as you would in a route handler.
|
|
193
|
-
- **CSRF**: server-action POSTs are origin-checked automatically — a cross-origin `Origin` (compared against your own host) is rejected with 403, as is anything the browser labels `Sec-Fetch-Site: cross-site`/`same-site`. A browser-asserted `Sec-Fetch-Site: same-origin` is accepted directly, which is what keeps the check from misfiring behind a proxy that rewrites `Host`. Applies to both client-initiated calls and no-JS form posts. Turn it off with `checkOrigin: false` behind a gateway that already enforces it, or list trusted cross-origins in `allowedOrigins` (full origins or bare hosts; a malformed entry fails the build).
|
|
194
|
-
- **Proxy headers are not trusted by default.** `X-Forwarded-Host` / `-Proto` are client-supplied, so honouring them blindly lets anyone who can reach the server dictate the origin of every absolute URL the app builds (`getContext().url`, a page's `url` prop) — poisoning canonical tags, emails and redirects, and any shared cache in front. Set `trustProxy: true` only when a proxy you control sets those headers; `rshono dev` forces it on for its own localhost-bound proxy.
|
|
195
|
-
- **Request deadline**: every request races a timeout (`renderTimeout`, default 10000) and the client-disconnect signal — covering the server action as well as flight + SSR — so neither a hung data fetch nor a hung mutation can pin sockets open.
|
|
196
|
-
- **Request-body limit**: request bodies are capped (`bodySizeLimit`, default 1048576 = 1 MiB) before they're buffered into memory — oversized bodies are rejected with `413 Payload Too Large`. This covers **every** route, not just server actions: `{ type: 'endpoint' }` routes and the `src/server.ts` sub-app are equally exposed the moment they call `.json()` or `.formData()`. An over-cap `Content-Length` is refused up front; bodies that omit it (chunked) are cut off mid-stream. Set to `false`/`0` to disable (e.g. behind a proxy that already enforces a limit, or to stream a large upload yourself). Raise it for large multipart uploads.
|
|
197
|
-
- **Baseline response headers**: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin` and `X-Frame-Options: SAMEORIGIN` on every response, unconditionally. The framing header is the floor for everyone who hasn't opted into `csp` — that policy's `frame-ancestors 'none'` is stricter and takes precedence where both apply. Set any of them in your own middleware to override.
|
|
198
|
-
- **Caching defaults**: a dynamic page is answered with `Cache-Control: private, no-cache` — a page is request-specific by default (cookies, session, headers), and with no directives at all a shared cache is free to store one user's page and serve it to the next. `private` forbids exactly that, and `no-cache` makes the browser revalidate rather than re-show a stale personalised page; neither disables bfcache the way `no-store` would. Set your own value (from middleware, or `getContext().header(…)`) and it is left alone. Prerendered pages keep `public, max-age=300` and carry a weak `ETag`, so a revalidation costs a 304 instead of the page.
|
|
199
|
-
- **`Vary: Accept` on page responses.** One URL answers with an HTML document or a flight payload depending on `Accept`. Without `Vary` a cache keyed on the URL alone will eventually hand a document to a soft navigation that asked for flight — a hard reload at best. Compression appends `Accept-Encoding` to the same header rather than replacing it.
|
|
200
|
-
- **CSP (opt-in)**: set `csp: true` to send a strict per-request-nonce `Content-Security-Policy` with every HTML document (nonce stamped on bootstrap scripts, inlined flight payload, and dynamically loaded chunks). Beyond `default-src 'self'` it also closes the gaps `default-src` doesn't cover — `base-uri`, `object-src`, `frame-ancestors`, `form-action` — so it blocks framing and third-party assets until you widen it with `cspDirectives` (the nonce is always re-appended to `script-src`, and `''` drops a directive). While enabled, the **document** for a `render: 'static'` route is rendered per request — a prerendered file can't carry a per-request nonce. Its flight payload never carries one, so soft navigations are still served from the prerender.
|
|
201
|
-
- **Error reporting**: every error the framework catches — a thrown action, a failed render, SSR falling over, anything reaching the top-level handler — goes through one funnel. Register a handler at the top level of `src/server.ts` to send them somewhere real; they keep going to `stderr` either way, and a handler that throws is caught rather than failing the request.
|
|
198
|
+
That is the whole file. It holds what the **build** decides; per-request security is Hono middleware in
|
|
199
|
+
`src/server.ts`, which is mounted ahead of the page routes and so wraps renders and server actions too:
|
|
202
200
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
- **unit** — the parsers and path maths (`bodySizeLimit`, `allowedOrigins`, SSG paths and traversal, control-signal digests, page-file scanning, `Vary`/`ETag` helpers). Imports the built `dist/`, so it also proves the published output loads in plain Node.
|
|
223
|
-
- **compression** — that gzip does not swallow a streamed response: a chunk the renderer flushes has to reach the client while the response is still open, which is the one property the platform `CompressionStream` would quietly break.
|
|
224
|
-
- **production e2e** — builds `examples/rs-basic`, boots the real production server, and asserts pages, flight protocol, actions (client + progressive enhancement), CSRF rejection, secret stripping in bundles _and_ rendered HTML, SSG output with `ETag`/304, cache and security headers, and error reporting. Settings baked into the bundle (CSP, CSRF allowlist/origin-check, body-size cap) each get their own build from a fixture config (`test/fixtures/`, via `rshono build --config`).
|
|
225
|
-
- **minimal app** — a fixture with `src/routes.ts` and nothing else: no `server.ts`, no `public/`, no config, no `notFound`/`error` pages. Everything the docs call optional, actually left out.
|
|
226
|
-
- **postcss** — a Tailwind fixture wiring the loader up through the `rspack` hook, from an `@import "tailwindcss"` nothing could resolve through to compiled utilities in the stylesheet the served page links. The documented four lines, actually run.
|
|
227
|
-
- **dev** — a smoke test through the dev server's worker + proxy.
|
|
201
|
+
```ts
|
|
202
|
+
// src/server.ts
|
|
203
|
+
import { publicUrl } from '@rshono/core/server';
|
|
204
|
+
import { bodyLimit } from 'hono/body-limit'; // https://hono.dev/docs/middleware/builtin/body-limit
|
|
205
|
+
import { csrf } from 'hono/csrf'; // https://hono.dev/docs/middleware/builtin/csrf
|
|
206
|
+
import { NONCE, secureHeaders } from 'hono/secure-headers'; // https://hono.dev/docs/middleware/builtin/secure-headers
|
|
207
|
+
|
|
208
|
+
// Caps a body before anything buffers it — pages, actions and your own handlers alike. 413 over cap.
|
|
209
|
+
server.use(bodyLimit({ maxSize: 1024 * 1024 }));
|
|
210
|
+
|
|
211
|
+
// Rejects a cross-origin POST with 403 before it can reach a server action. `publicUrl(c)` rather
|
|
212
|
+
// than Hono's default, which compares against the address the server was *reached* on — the internal
|
|
213
|
+
// one behind any proxy. It honours `trustProxy`.
|
|
214
|
+
server.use(csrf({ origin: (origin, c) => origin === publicUrl(c).origin }));
|
|
215
|
+
|
|
216
|
+
// HSTS, COOP, CORP and the rest. `NONCE` in `scriptSrc` makes the CSP per-request: Hono mints the
|
|
217
|
+
// nonce, rshono stamps it onto the bootstrap scripts and the inlined flight payload.
|
|
218
|
+
server.use(secureHeaders({ contentSecurityPolicy: { scriptSrc: ["'self'", NONCE] } }));
|
|
219
|
+
```
|
|
228
220
|
|
|
229
|
-
`
|
|
221
|
+
`create-rshono` scaffolds the first two. Under `rshono dev` the framework widens `script-src` with
|
|
222
|
+
`'unsafe-eval'` for React Refresh — never in a build — so one policy serves both, and a route with a
|
|
223
|
+
nonce in play falls back to rendering its _document_ per request: fixed bytes cannot carry a fresh nonce.
|
|
224
|
+
Its flight payload has no nonce to go stale and is still served from disk, so `rshono build` marks such a
|
|
225
|
+
page `(flight only)` rather than counting it as fully prerendered. Everything else Hono ships works the same way:
|
|
226
|
+
`cors`, `basicAuth`, `jwt`, `timeout`, `requestId`, `ipRestriction`. A middleware that rejects by
|
|
227
|
+
throwing an `HTTPException` keeps its own status rather than becoming the 500 page.
|
|
228
|
+
[Middleware docs](https://www.rshono.com/docs/hono#security-middleware) · [Hono](https://hono.dev/docs).
|
|
229
|
+
|
|
230
|
+
`deploy` and `rspack` are consumed by the CLI; `trustProxy` is **compiled into the server bundle** at build
|
|
231
|
+
time, so changing it means a rebuild and there is no parallel env-var interface for it (environment
|
|
232
|
+
variables are for secrets). The port and bind address are deliberately not config fields — on every host
|
|
233
|
+
that runs this, the environment is what sets them. [Configuration docs](https://www.rshono.com/docs/configuration).
|
|
234
|
+
|
|
235
|
+
The defaults, in short: untrusted proxy headers, `nosniff` / `Referrer-Policy` / `X-Frame-Options` on
|
|
236
|
+
every response (a floor your own `secureHeaders()` overrides),
|
|
237
|
+
`private, no-cache` plus `Vary: RSC` on dynamic pages, `public, max-age=300` and a weak `ETag` on
|
|
238
|
+
prerendered ones, and errors redacted in production — with one `onServerError()` funnel for reporting them
|
|
239
|
+
and three fallbacks (a fatal client overlay, a visible 500 document, a reported bootstrap failure) so a
|
|
240
|
+
failure is never a blank screen.
|
|
230
241
|
|
|
231
|
-
##
|
|
242
|
+
## Prerendering (`render: 'static'`)
|
|
232
243
|
|
|
233
|
-
|
|
244
|
+
A static route is built once and served from disk in **both** representations — `index.html` for a hard load,
|
|
245
|
+
`index.rsc` for the flight payload a soft navigation asks for — each with a weak `ETag`. Set **`siteUrl`** if
|
|
246
|
+
those pages build absolute URLs: one set of bytes is handed to everyone, so the origin has to be decided at
|
|
247
|
+
build time (without it the build warns and uses `http://localhost`). A page that can't be prerendered — no
|
|
248
|
+
`staticPaths`, or it didn't render cleanly — is reported by the build and falls back to rendering per
|
|
249
|
+
request. [Static rendering docs](https://www.rshono.com/docs/routing#static-rendering).
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
- **server** (`target: node`) → `dist/server/main.mjs`: the app server itself — a Hono app assembled from your routes, rendered through two layers (RSC layer with the `react-server` condition → flight payload; SSR layer → HTML stream with the payload inlined for hydration).
|
|
251
|
+
## Deployment
|
|
237
252
|
|
|
238
|
-
|
|
253
|
+
`rshono build` targets one platform. Pick it with `deploy` in the config, `--deploy <name>` or
|
|
254
|
+
`RSHONO_DEPLOY`, in that precedence order; the default is `node`. `rshono dev` always runs the Node dev
|
|
255
|
+
server whatever you choose — the target is a property of the build, not of developing.
|
|
256
|
+
|
|
257
|
+
| `deploy` | Handoff | Assets & prerendered pages | After `build` |
|
|
258
|
+
| ------------ | -------------------------------- | --------------------------------------------------------------- | ------------------------------------------- |
|
|
259
|
+
| `node` | binds a port | from `dist/` on disk | `rshono start` |
|
|
260
|
+
| `cloudflare` | `{ fetch }` default export | Workers Assets; prerendered pages read via the `ASSETS` binding | `wrangler deploy` |
|
|
261
|
+
| `vercel` | web handler in a Node function | CDN for assets; prerendered pages inside the function | `vercel deploy --prebuilt` |
|
|
262
|
+
| `aws-lambda` | streaming handler (Function URL) | from the deployment package | zip `dist/`, handler `dist/server/main.mjs` |
|
|
263
|
+
|
|
264
|
+
One target per _handoff_ — the thing an app cannot arrange for itself. Everything else about a platform sits
|
|
265
|
+
behind one `DeployRuntime` interface, and `node`, `vercel` and `aws-lambda` share a filesystem implementation.
|
|
266
|
+
Every target streams, which is the bar a new one has to clear.
|
|
267
|
+
|
|
268
|
+
- **`node` is not only Node.** Anything that runs a Node process runs this build — a VPS, a container, a PaaS.
|
|
269
|
+
Bun (`bun dist/server/main.mjs`) and Deno (`deno run -A dist/server/main.mjs`) are expected to as well,
|
|
270
|
+
since the listener is `@hono/node-server`; they had a target each, which held nothing beyond a default
|
|
271
|
+
export. The suite runs on Node, so treat those two as an expectation rather than a guarantee.
|
|
272
|
+
- **Don't build a handler out of the `app` export.** The entry calls `runtime.serveApp(app)` at module scope,
|
|
273
|
+
so importing a `node` build binds a port as a side effect. Build for the target you deploy to, and
|
|
274
|
+
`rshono start` will refuse a build made for another platform.
|
|
275
|
+
- **Streaming is the fragile part of a serverless target, and it fails silently** — `supportsResponseStreaming`
|
|
276
|
+
on Vercel, `streamifyResponse` plus a `RESPONSE_STREAM` Function URL on Lambda. Getting those right is what
|
|
277
|
+
the presets are for.
|
|
278
|
+
- **Most of what `vercel` and `aws-lambda` upload is the source map.** `dist/server/main.mjs.map` is roughly
|
|
279
|
+
three quarters of `dist/server`, and both targets take the whole directory — `vercel` copies it into the
|
|
280
|
+
function, the `aws-lambda` handoff is "zip `dist/`". That is deliberate: the map is what turns the
|
|
281
|
+
`onServerError` funnel from minified frames into real ones, and it is never served to anyone. But it is
|
|
282
|
+
upload weight rather than cold-start weight — nothing parses it unless a stack trace is being mapped — so
|
|
283
|
+
delete it from the package if size matters more to you than readable production traces.
|
|
284
|
+
- **Prerendered pages are never CDN-served**: one URL answers with a document or a flight payload depending on
|
|
285
|
+
the `RSC` request header, and a path-keyed CDN cannot choose. `/_static` and `public/` do go straight to the
|
|
286
|
+
CDN.
|
|
287
|
+
- **`public/` beats a route on `cloudflare` and `vercel`, and loses to it on `node` and `aws-lambda`.** Where
|
|
288
|
+
the app owns the whole surface, `public/` is mounted after every route and answers only what none of them
|
|
289
|
+
claimed. Where a CDN sits in front it is part of the static output, so the platform answers from it before
|
|
290
|
+
the app is invoked — the framework cannot reorder either one. So `public/index.html` beside a page route at
|
|
291
|
+
`/` serves the file on two targets and renders the page on the other two. `rshono build` warns when a
|
|
292
|
+
`public/` file lands on a route's path, on the targets where it matters.
|
|
293
|
+
- **The serverless targets bundle your dependencies**; `node` bundles only the ones a `'use client'` component
|
|
294
|
+
pulls in. A function is an uploaded directory with no `node_modules` to resolve against, so `vercel`,
|
|
295
|
+
`aws-lambda` and `cloudflare` compile everything in. The cost is that a native addon — or a package that
|
|
296
|
+
reads its own files off disk — fails the build on those targets rather than the deploy; reach for the
|
|
297
|
+
`rspack` hook, or deploy to `node`.
|
|
298
|
+
|
|
299
|
+
On `node` a server component's dependencies stay external and resolve from `node_modules`, but anything
|
|
300
|
+
reachable from a `'use client'` component is compiled in on every target. It has to be: the `PUBLIC_`-only
|
|
301
|
+
`process.env` view is applied by a loader, and a loader cannot run on a module the bundle only imports by
|
|
302
|
+
name — an external third-party client component would be SSR'd against the real environment. Nothing is
|
|
303
|
+
given up, since the same module is in the browser bundle and so was always required to be bundleable.
|
|
304
|
+
|
|
305
|
+
[Deployment docs](https://www.rshono.com/docs/deployment), including Cloudflare bindings and the AWS setup.
|
|
239
306
|
|
|
240
|
-
|
|
307
|
+
## Requirements & limitations
|
|
241
308
|
|
|
242
|
-
|
|
309
|
+
- **Node ≥ 22.18** (worker threads, `process.loadEnvFile`, `Promise.withResolvers`, `URL.parse`, and native
|
|
310
|
+
TypeScript stripping, so a `.ts` config needs no loader) and **React ≥ 19.1** (the floor
|
|
311
|
+
`react-server-dom-rspack` requires).
|
|
312
|
+
- **ESM only.** The package declares `import` and `types` conditions and no `require` one, so
|
|
313
|
+
`require('@rshono/core')` is `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than a working call — deliberately,
|
|
314
|
+
since the framework's own graph is ESM throughout. Use `import`, or `await import()` from CommonJS.
|
|
315
|
+
- No response compression, no base path (`siteUrl` is a bare origin), and wildcard, optional and regex
|
|
316
|
+
params cannot be prerendered.
|
|
317
|
+
- **No link prefetching**, by choice: a link is one fetch at click time and nothing before it. Speculative
|
|
318
|
+
fetching on hover spends every visitor's bandwidth to help some of them — so a navigation here costs a
|
|
319
|
+
round trip that Next.js and TanStack Start have usually already paid.
|
|
320
|
+
- No incremental static regeneration: `render: 'static'` is decided at build time, and a static page changes
|
|
321
|
+
when you rebuild.
|
|
322
|
+
- **Soft navigation needs the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)**
|
|
323
|
+
— Chrome/Edge 135, Firefox 147, Safari 26.2, [Baseline](https://web.dev/blog/baseline-navigation-api) since
|
|
324
|
+
January 2026. Where it is missing there is no interception at all and every link is a real browser load,
|
|
325
|
+
which a server-rendered app answers correctly; only the soft part is gone. Scroll restoration, the fragment
|
|
326
|
+
jump and the post-navigation focus reset are all the browser's.
|
|
327
|
+
- **`redirect()` and `notFound()` must be reached before the page shell is sent.** A page streams: the status
|
|
328
|
+
line and the first bytes go out as soon as the shell is ready, and HTTP has no take-backs after that. Called
|
|
329
|
+
from a `<Suspense>` boundary that resolves later, the signal can no longer be a 3xx or a 404 — the response
|
|
330
|
+
is already committed as `200 text/html`. The signal still rides the payload as a digest, so a browser with
|
|
331
|
+
JavaScript acts on it: `redirect()` navigates, and `notFound()` — which has nowhere to navigate to — asks
|
|
332
|
+
for the page once more and then paints a plain "Page not found" panel, since a second identical response is
|
|
333
|
+
proof that reloading will not help. A visitor without JavaScript is left on the fallback under a 200, and a
|
|
334
|
+
crawler indexes that 200 as a soft 404. The fix is app-side: decide in Hono
|
|
335
|
+
middleware, or in the page component body above the boundary. `rshono dev` warns when it happens.
|
|
336
|
+
|
|
337
|
+
**On a soft navigation there is no shell to beat, so every `notFound()` degrades.** A flight fetch is
|
|
338
|
+
committed as `200 text/x-component` the moment the render hands its stream back — before anything has been
|
|
339
|
+
awaited — so a `notFound()` from the _first line_ of a page component is already too late to be a 404. It
|
|
340
|
+
rides the payload as a digest, and the client recovers by reloading the page for real (once per URL per
|
|
341
|
+
tab, then a plain "Page not found" panel). Every in-app click that lands on something missing therefore
|
|
342
|
+
costs an extra round trip and a full document parse. **`redirect()` is not affected** — the same digest
|
|
343
|
+
becomes a soft `push()` to the new location, which is what the navigation was going to be anyway. Prefer a
|
|
344
|
+
`redirect()` where either would do, and decide in Hono middleware where the status still has to be a real 404.
|
|
345
|
+
|
|
346
|
+
**A page that _throws_ lands in the same place: the flight response is a `200`.** A document request for it
|
|
347
|
+
is a 500 answered by your `error` page, but the payload's status was committed before the render failed, so
|
|
348
|
+
the error rides it as a row and the client runtime paints its error UI — which is the design working, and
|
|
349
|
+
is what makes a soft navigation onto a broken page recoverable rather than a blank tab. What it costs is
|
|
350
|
+
observability: **an uptime monitor or a CDN log watching for 5xx sees nothing** for a soft navigation, and
|
|
351
|
+
the ratio between the two statuses depends on how much of your traffic is in-app clicks. Count failures
|
|
352
|
+
through `onServerError()`, which fires for both. Buffering the payload to learn whether it failed is the one
|
|
353
|
+
fix that is not available: it would cost every page its streaming.
|
|
354
|
+
|
|
355
|
+
- **A page route answers `GET`, `POST` and `HEAD`.** Every other method is a 404 rather than a 405: the
|
|
356
|
+
`Allow` header a 405 owes the client means tracking the methods registered per path, which is state on a hot
|
|
357
|
+
path for a distinction nothing acts on differently here. An endpoint route is the way to answer a `PUT`,
|
|
358
|
+
`PATCH`, `DELETE` or `OPTIONS`.
|
|
359
|
+
- **A page route refuses every cross-site form post**, not only one carrying a server action. A form post to
|
|
360
|
+
a page is how a `<form action={serverAction}>` reaches the server, and whether a given one holds an action
|
|
361
|
+
can only be known by reading the body — so the framework refuses on `Sec-Fetch-Site` and the request's
|
|
362
|
+
shape, before parsing, rather than buffering a body for anyone who asks. "Shape" is all three `enctype`
|
|
363
|
+
values a browser form can send — `application/x-www-form-urlencoded`, `multipart/form-data` and
|
|
364
|
+
`text/plain` — since what makes one forgeable from another site is the shape and not what is in it. The
|
|
365
|
+
cases this rules out are real ones: a **SAML ACS callback**, OIDC **`response_mode=form_post`**, and most
|
|
366
|
+
payment-gateway returns all arrive as a cross-site POST in exactly that shape. `csrf()`'s allowlist does
|
|
367
|
+
not widen it — this is the framework declining to run its own action mechanism, ahead of any app policy.
|
|
368
|
+
**Receive them on an `{ type: 'endpoint' }` route**, which calls your Hono handler directly and never
|
|
369
|
+
reaches the page renderer, then redirect to the page.
|
|
370
|
+
- **`/_static` is reserved**, on every deploy target and under `rshono dev`: it is where the hashed client
|
|
371
|
+
bundle is served from, mounted ahead of the route table and answering its whole subtree. A route whose path
|
|
372
|
+
is `/_static` or sits below it is refused by name — it could never have answered a request. A parameterised
|
|
373
|
+
route that happens to overlap the prefix (`/:section/thing`) is left alone; it loses those paths and
|
|
374
|
+
answers the rest.
|
|
375
|
+
- **A page's `ctx` prop is non-enumerable**, so `<Child {...props} />` hands a _server_ child
|
|
376
|
+
`ctx: undefined` — silently, since a spread copies enumerables only, and the type still says it is there.
|
|
377
|
+
It cannot be otherwise: an enumerable `ctx` would put `ctx.hono.env`, every binding and secret, into
|
|
378
|
+
React's dev-only serialization of a server component's props. Nested server components are meant to call
|
|
379
|
+
`getRequestContext()` rather than be handed the context.
|
|
380
|
+
- **`rshono build` does not type-check.** swc strips types and `tsc` is never invoked, so the build is as fast
|
|
381
|
+
as it is and no faster because it skipped something. Several of the framework's guarantees are types alone —
|
|
382
|
+
the `handler` an endpoint module owes, and `defineRoutes`' path ↔ props and `staticPaths` ↔ path checks — so
|
|
383
|
+
run `tsc --noEmit` (`npm run typecheck` in a scaffolded app) in CI beside the build. The mistakes that make
|
|
384
|
+
a route unservable are checked at build time whether or not you do.
|
|
385
|
+
- **`.env` and `rshono.config.ts` are read once, at startup**, and what a build needs from them is compiled
|
|
386
|
+
in — so `rshono dev` does not pick up an edit to either, and a rebuild that serves the old value looks like
|
|
387
|
+
nothing happened. Restart it; it watches both and says so when one changes.
|
|
388
|
+
- The dev proxy doesn't forward WebSocket upgrades to a custom sub-app; production is unaffected.
|
|
389
|
+
- **`rshono dev` answers `/_static` itself, so your middleware does not run for an asset** — where a build
|
|
390
|
+
serves assets through the app and they carry HSTS, your CSP and everything else it sets. The dev front-end
|
|
391
|
+
owns the prefix on purpose: every request it proxies waits on the server rebuild, and the client bundle is
|
|
392
|
+
built by a separate compiler, so proxying assets would stall the browser's JS and CSS on a save that only
|
|
393
|
+
touched a server component. The cost is that a policy is developed against files it does not apply to;
|
|
394
|
+
check a header that has to be on an asset against `rshono build` and `rshono start`.
|
|
395
|
+
- Dev source maps embed the original source of `'use server'` modules (dev binds 127.0.0.1 only, and
|
|
396
|
+
production ships no client source maps).
|
|
243
397
|
|
|
244
|
-
##
|
|
245
|
-
|
|
246
|
-
`rshono build` targets one platform. Pick it with `deploy` in the config, `--deploy <name>`, or `RSHONO_DEPLOY` (in that precedence order); the default is `node`. `rshono dev` always runs the Node dev server whatever you choose — the target is a property of the build, not of developing.
|
|
398
|
+
## Testing
|
|
247
399
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
400
|
+
`pnpm --filter @rshono/core test` builds the package and runs everything that needs no browser:
|
|
401
|
+
|
|
402
|
+
- **unit** — the parsers and path maths, against the built `dist/`, which also proves the published output
|
|
403
|
+
loads in plain Node.
|
|
404
|
+
- **production e2e** — builds `apps/testbed`, boots the real production server, and asserts pages, the flight
|
|
405
|
+
protocol, actions (client and no-JS), CSRF rejection, secret stripping in bundles _and_ rendered HTML, SSG
|
|
406
|
+
output with `ETag`/304, cache and security headers, and error reporting. The hardened permutations — a
|
|
407
|
+
nonce CSP, a CSRF allowlist, a small body cap — are middleware, so they run against that same build
|
|
408
|
+
under a different environment; `trustProxy` is baked in and gets a fixture config.
|
|
409
|
+
- **the other deploy targets** — the `cloudflare` bundle driven as `fetch(request, env, ctx)` against a
|
|
410
|
+
stand-in `ASSETS` binding, a real build per serverless target checked against the handoff its platform
|
|
411
|
+
expects, and `rshono start`'s refusal to run a build made for another one.
|
|
412
|
+
- **minimal app**, a fixture with `src/routes.ts` and nothing else (everything the docs call optional, left
|
|
413
|
+
out); **postcss**, the documented Tailwind wiring actually run; **dev**, a smoke test through the dev
|
|
414
|
+
server's worker and proxy.
|
|
415
|
+
|
|
416
|
+
`pnpm --filter @rshono/core test:browser` runs the Playwright suite against a production build: hydration,
|
|
417
|
+
soft navigation, `useNavigation`, client-initiated actions, boundary fallbacks and the fatal overlay — the
|
|
418
|
+
client runtime, which no amount of asserting on HTML can reach.
|
|
419
|
+
|
|
420
|
+
`test:coverage` gates the build tooling, **not the request hot path** — worth knowing before reading anything
|
|
421
|
+
into the number. Node measures what this process loads, and the modules that answer a request only ever run
|
|
422
|
+
inside the bundled testbed in a child process: `entry.rsc`, `entry.client`, `entry.ssr`, `boundaries`,
|
|
423
|
+
`navigation` and every `deploy/*/runtime` appear nowhere in the report, and `runtime/` in it means `context`,
|
|
424
|
+
`control`, `flight-inject`, `hot-update`, `request` and `validate-entries` alone. Those absent modules are the
|
|
425
|
+
most thoroughly tested code in the package — by the e2e suites above, over HTTP, which is the only way to test
|
|
426
|
+
them at all. So the percentage covers the builder, the CLI, the deploy build steps and the pure runtime
|
|
427
|
+
helpers; treat its floors as a ratchet on those, and never as a statement about the framework as a whole.
|
|
428
|
+
|
|
429
|
+
The other half can be measured on demand, and is worth a look before a release. Every process the suite
|
|
430
|
+
spawns writes its own V8 coverage if you ask for it, and the testbed's bundle carries a source map that
|
|
431
|
+
reaches back into this package's `src/`, so the request path can be remapped onto the files it came from:
|
|
257
432
|
|
|
258
|
-
|
|
433
|
+
```bash
|
|
434
|
+
# from the repository root, so the testbed the coverage points into is under the working directory
|
|
435
|
+
NODE_V8_COVERAGE=.coverage-e2e node --test packages/core/test/prod.test.mjs
|
|
436
|
+
npx c8 report --temp-directory .coverage-e2e --reporter=text --all=false --exclude-after-remap \
|
|
437
|
+
--exclude='**/node_modules/**' --exclude='**/testbed/src/**' --exclude='**/webpack/**' \
|
|
438
|
+
--exclude='**/test/**' --exclude='**/drift-*/**' --exclude='**/*.css'
|
|
439
|
+
```
|
|
259
440
|
|
|
260
|
-
|
|
441
|
+
One suite at a time, because a full run builds the testbed several times over — production, dev, cloudflare,
|
|
442
|
+
vercel — and each bundle is a separate path holding the same sources, which no total can add up. Last taken
|
|
443
|
+
this way, the production e2e suite alone reached **97.4% of `entry.rsc.tsx`** (92.3% of its branches),
|
|
444
|
+
**100% of `entry.ssr.tsx`, `navigation.tsx` and the node runtime**, and 88% of `boundaries.tsx` — the rest of
|
|
445
|
+
which is its client half, and belongs to the browser suite. That is why the gate is left where it is rather
|
|
446
|
+
than tightened: the code it cannot see is not the code that is thin.
|
|
261
447
|
|
|
262
|
-
|
|
263
|
-
- **Prerendered pages are never CDN-served.** One URL answers with an HTML document or a flight payload depending on `Accept`, and a path-keyed CDN cannot choose, so the app always handles page URLs. Assets under `/_static` and `public/` do go straight to the CDN where there is one.
|
|
264
|
-
- **Compression** is left to the platform on `cloudflare`, `vercel` and `netlify`; the framework's streaming gzip is used on `node`, `bun`, `deno` and `aws-lambda`. Your `compress` setting only decides whether an available compressor is used.
|
|
265
|
-
- **AWS** means a Lambda Function URL with the invoke mode set to `RESPONSE_STREAM`, usually with CloudFront in front for `/_static` and `public/`. **Lambda@Edge is deliberately not a target**: CloudFront returns the response as a value rather than a stream, caps a generated origin-request response near 1 MB, and supports no environment variables at all — so `getContext().env` would be empty there, which is a documented feature quietly doing nothing.
|
|
266
|
-
- `rshono start` refuses a build made for another platform rather than starting a bundle with no listener in it.
|
|
448
|
+
## How it works
|
|
267
449
|
|
|
268
|
-
|
|
450
|
+
Two coordinated Rspack compilers, using native RSC support (`rspack.experiments.rsc`):
|
|
269
451
|
|
|
270
|
-
-
|
|
271
|
-
-
|
|
272
|
-
|
|
273
|
-
|
|
452
|
+
- **client** (`target: web`) → `dist/static`: hydration runtime, `'use client'` chunks, CSS.
|
|
453
|
+
- **server** (`target: node`) → `dist/server/main.mjs`: a Hono app assembled from your routes, rendered
|
|
454
|
+
through two layers — the RSC layer, with the `react-server` condition, produces the flight payload; the SSR
|
|
455
|
+
layer turns it into an HTML stream with the payload inlined for hydration.
|
|
456
|
+
|
|
457
|
+
In dev the CLI watches both bundles, runs the server bundle in a worker thread (restarted per rebuild,
|
|
458
|
+
requests gated on readiness so nothing drops), and fronts everything on one port with static serving and an
|
|
459
|
+
SSE channel: client edits hot-apply via react-refresh, server component edits re-fetch the payload in place,
|
|
460
|
+
and browser state survives both. In production `dist/server/main.mjs` has React, Hono and the framework
|
|
461
|
+
bundled in. On `node` your server-side dependencies still resolve from `node_modules` beside it; on the
|
|
462
|
+
serverless targets they are bundled too, because nothing installs them there. Whatever a `'use client'`
|
|
463
|
+
component reaches is bundled on every target, which is what lets the env shadow cover it.
|