@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.
Files changed (208) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -158
  3. package/bin/rshono.mjs +3 -4
  4. package/dist/builder/env-shadow-loader.cjs +123 -8
  5. package/dist/builder/page-entry-loader.cjs +28 -1
  6. package/dist/builder/page-files.d.ts +0 -1
  7. package/dist/builder/page-files.js +7 -3
  8. package/dist/builder/page-files.js.map +1 -1
  9. package/dist/builder/public-env.d.ts +7 -1
  10. package/dist/builder/public-env.js +7 -0
  11. package/dist/builder/public-env.js.map +1 -1
  12. package/dist/builder/react-versions.d.ts +12 -0
  13. package/dist/builder/react-versions.js +74 -0
  14. package/dist/builder/react-versions.js.map +1 -0
  15. package/dist/builder/rspack-config.d.ts +13 -4
  16. package/dist/builder/rspack-config.js +136 -53
  17. package/dist/builder/rspack-config.js.map +1 -1
  18. package/dist/builder/server-only-imports.d.ts +32 -0
  19. package/dist/builder/server-only-imports.js +51 -0
  20. package/dist/builder/server-only-imports.js.map +1 -0
  21. package/dist/cli/build.d.ts +2 -3
  22. package/dist/cli/build.js +51 -13
  23. package/dist/cli/build.js.map +1 -1
  24. package/dist/cli/dev.d.ts +2 -3
  25. package/dist/cli/dev.js +149 -45
  26. package/dist/cli/dev.js.map +1 -1
  27. package/dist/cli/exit.d.ts +12 -0
  28. package/dist/cli/exit.js +16 -0
  29. package/dist/cli/exit.js.map +1 -0
  30. package/dist/cli/index.d.ts +0 -1
  31. package/dist/cli/index.js +91 -30
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/start.d.ts +0 -1
  34. package/dist/cli/start.js +15 -18
  35. package/dist/cli/start.js.map +1 -1
  36. package/dist/config.d.ts +72 -78
  37. package/dist/config.js +17 -1
  38. package/dist/config.js.map +1 -1
  39. package/dist/deploy/aws-lambda/runtime.d.ts +4 -7
  40. package/dist/deploy/aws-lambda/runtime.js +5 -8
  41. package/dist/deploy/aws-lambda/runtime.js.map +1 -1
  42. package/dist/deploy/build-marker.d.ts +9 -6
  43. package/dist/deploy/build-marker.js +8 -4
  44. package/dist/deploy/build-marker.js.map +1 -1
  45. package/dist/deploy/cloudflare/build.d.ts +0 -1
  46. package/dist/deploy/cloudflare/build.js +17 -13
  47. package/dist/deploy/cloudflare/build.js.map +1 -1
  48. package/dist/deploy/cloudflare/runtime.d.ts +2 -6
  49. package/dist/deploy/cloudflare/runtime.js +73 -68
  50. package/dist/deploy/cloudflare/runtime.js.map +1 -1
  51. package/dist/deploy/contract.d.ts +57 -41
  52. package/dist/deploy/contract.js.map +1 -1
  53. package/dist/deploy/filesystem.d.ts +3 -6
  54. package/dist/deploy/filesystem.js +23 -20
  55. package/dist/deploy/filesystem.js.map +1 -1
  56. package/dist/deploy/node/runtime.d.ts +5 -3
  57. package/dist/deploy/node/runtime.js +23 -9
  58. package/dist/deploy/node/runtime.js.map +1 -1
  59. package/dist/deploy/presets.d.ts +34 -30
  60. package/dist/deploy/presets.js +56 -44
  61. package/dist/deploy/presets.js.map +1 -1
  62. package/dist/deploy/public-paths.d.ts +42 -0
  63. package/dist/deploy/public-paths.js +66 -0
  64. package/dist/deploy/public-paths.js.map +1 -0
  65. package/dist/deploy/vercel/build.d.ts +0 -1
  66. package/dist/deploy/vercel/build.js +25 -16
  67. package/dist/deploy/vercel/build.js.map +1 -1
  68. package/dist/deploy/vercel/runtime.d.ts +10 -7
  69. package/dist/deploy/vercel/runtime.js +48 -11
  70. package/dist/deploy/vercel/runtime.js.map +1 -1
  71. package/dist/index.d.ts +19 -17
  72. package/dist/index.js +20 -10
  73. package/dist/index.js.map +1 -1
  74. package/dist/router.d.ts +212 -120
  75. package/dist/router.js +3 -8
  76. package/dist/router.js.map +1 -1
  77. package/dist/runtime/boundaries.d.ts +62 -48
  78. package/dist/runtime/boundaries.js +32 -34
  79. package/dist/runtime/boundaries.js.map +1 -1
  80. package/dist/runtime/client.d.ts +20 -11
  81. package/dist/runtime/client.js +20 -10
  82. package/dist/runtime/client.js.map +1 -1
  83. package/dist/runtime/context.d.ts +304 -123
  84. package/dist/runtime/context.js +377 -149
  85. package/dist/runtime/context.js.map +1 -1
  86. package/dist/runtime/control.d.ts +15 -1
  87. package/dist/runtime/control.js +24 -0
  88. package/dist/runtime/control.js.map +1 -1
  89. package/dist/runtime/dev-protocol.d.ts +4 -9
  90. package/dist/runtime/dev-protocol.js.map +1 -1
  91. package/dist/runtime/empty-server-app.d.ts +0 -1
  92. package/dist/runtime/entry.client.d.ts +4 -1
  93. package/dist/runtime/entry.client.js +455 -313
  94. package/dist/runtime/entry.client.js.map +1 -1
  95. package/dist/runtime/entry.rsc.d.ts +15 -7
  96. package/dist/runtime/entry.rsc.js +811 -252
  97. package/dist/runtime/entry.rsc.js.map +1 -1
  98. package/dist/runtime/entry.ssr.d.ts +27 -10
  99. package/dist/runtime/entry.ssr.js +30 -48
  100. package/dist/runtime/entry.ssr.js.map +1 -1
  101. package/dist/runtime/failure-document.d.ts +11 -0
  102. package/dist/runtime/failure-document.js +35 -0
  103. package/dist/runtime/failure-document.js.map +1 -0
  104. package/dist/runtime/flight-inject.d.ts +23 -0
  105. package/dist/runtime/flight-inject.js +403 -0
  106. package/dist/runtime/flight-inject.js.map +1 -0
  107. package/dist/runtime/hot-update.d.ts +44 -0
  108. package/dist/runtime/hot-update.js +44 -0
  109. package/dist/runtime/hot-update.js.map +1 -0
  110. package/dist/runtime/navigation.d.ts +46 -54
  111. package/dist/runtime/navigation.js +22 -63
  112. package/dist/runtime/navigation.js.map +1 -1
  113. package/dist/runtime/request.d.ts +42 -8
  114. package/dist/runtime/request.js +84 -7
  115. package/dist/runtime/request.js.map +1 -1
  116. package/dist/runtime/server.d.ts +20 -18
  117. package/dist/runtime/server.js +22 -17
  118. package/dist/runtime/server.js.map +1 -1
  119. package/dist/runtime/validate-entries.d.ts +77 -0
  120. package/dist/runtime/validate-entries.js +405 -0
  121. package/dist/runtime/validate-entries.js.map +1 -0
  122. package/dist/server/headers.d.ts +41 -15
  123. package/dist/server/headers.js +53 -24
  124. package/dist/server/headers.js.map +1 -1
  125. package/dist/server/load-config.d.ts +4 -5
  126. package/dist/server/load-config.js +22 -14
  127. package/dist/server/load-config.js.map +1 -1
  128. package/dist/server/load-env.d.ts +0 -1
  129. package/dist/server/prerendered.d.ts +88 -34
  130. package/dist/server/prerendered.js +184 -24
  131. package/dist/server/prerendered.js.map +1 -1
  132. package/dist/server/server-config.d.ts +35 -50
  133. package/dist/server/server-config.js +27 -74
  134. package/dist/server/server-config.js.map +1 -1
  135. package/dist/server/shutdown.d.ts +5 -4
  136. package/dist/server/shutdown.js +5 -3
  137. package/dist/server/shutdown.js.map +1 -1
  138. package/dist/server/ssg.d.ts +9 -7
  139. package/dist/server/ssg.js +209 -65
  140. package/dist/server/ssg.js.map +1 -1
  141. package/dist/server/static.d.ts +0 -1
  142. package/dist/server/static.js +7 -1
  143. package/dist/server/static.js.map +1 -1
  144. package/package.json +21 -23
  145. package/dist/builder/page-files.d.ts.map +0 -1
  146. package/dist/builder/public-env.d.ts.map +0 -1
  147. package/dist/builder/rspack-config.d.ts.map +0 -1
  148. package/dist/cli/build.d.ts.map +0 -1
  149. package/dist/cli/dev.d.ts.map +0 -1
  150. package/dist/cli/index.d.ts.map +0 -1
  151. package/dist/cli/start.d.ts.map +0 -1
  152. package/dist/config.d.ts.map +0 -1
  153. package/dist/deploy/aws-lambda/runtime.d.ts.map +0 -1
  154. package/dist/deploy/build-marker.d.ts.map +0 -1
  155. package/dist/deploy/bun/runtime.d.ts +0 -11
  156. package/dist/deploy/bun/runtime.d.ts.map +0 -1
  157. package/dist/deploy/bun/runtime.js +0 -22
  158. package/dist/deploy/bun/runtime.js.map +0 -1
  159. package/dist/deploy/cloudflare/build.d.ts.map +0 -1
  160. package/dist/deploy/cloudflare/runtime.d.ts.map +0 -1
  161. package/dist/deploy/contract.d.ts.map +0 -1
  162. package/dist/deploy/deno/runtime.d.ts +0 -11
  163. package/dist/deploy/deno/runtime.d.ts.map +0 -1
  164. package/dist/deploy/deno/runtime.js +0 -16
  165. package/dist/deploy/deno/runtime.js.map +0 -1
  166. package/dist/deploy/filesystem.d.ts.map +0 -1
  167. package/dist/deploy/listen.d.ts +0 -20
  168. package/dist/deploy/listen.d.ts.map +0 -1
  169. package/dist/deploy/listen.js +0 -24
  170. package/dist/deploy/listen.js.map +0 -1
  171. package/dist/deploy/netlify/build.d.ts +0 -8
  172. package/dist/deploy/netlify/build.d.ts.map +0 -1
  173. package/dist/deploy/netlify/build.js +0 -52
  174. package/dist/deploy/netlify/build.js.map +0 -1
  175. package/dist/deploy/netlify/runtime.d.ts +0 -13
  176. package/dist/deploy/netlify/runtime.d.ts.map +0 -1
  177. package/dist/deploy/netlify/runtime.js +0 -24
  178. package/dist/deploy/netlify/runtime.js.map +0 -1
  179. package/dist/deploy/node/runtime.d.ts.map +0 -1
  180. package/dist/deploy/presets.d.ts.map +0 -1
  181. package/dist/deploy/vercel/build.d.ts.map +0 -1
  182. package/dist/deploy/vercel/runtime.d.ts.map +0 -1
  183. package/dist/index.d.ts.map +0 -1
  184. package/dist/router.d.ts.map +0 -1
  185. package/dist/runtime/boundaries.d.ts.map +0 -1
  186. package/dist/runtime/client.d.ts.map +0 -1
  187. package/dist/runtime/context.d.ts.map +0 -1
  188. package/dist/runtime/control.d.ts.map +0 -1
  189. package/dist/runtime/dev-protocol.d.ts.map +0 -1
  190. package/dist/runtime/empty-server-app.d.ts.map +0 -1
  191. package/dist/runtime/entry.client.d.ts.map +0 -1
  192. package/dist/runtime/entry.rsc.d.ts.map +0 -1
  193. package/dist/runtime/entry.ssr.d.ts.map +0 -1
  194. package/dist/runtime/navigation.d.ts.map +0 -1
  195. package/dist/runtime/request.d.ts.map +0 -1
  196. package/dist/runtime/server.d.ts.map +0 -1
  197. package/dist/server/compress.d.ts +0 -15
  198. package/dist/server/compress.d.ts.map +0 -1
  199. package/dist/server/compress.js +0 -76
  200. package/dist/server/compress.js.map +0 -1
  201. package/dist/server/headers.d.ts.map +0 -1
  202. package/dist/server/load-config.d.ts.map +0 -1
  203. package/dist/server/load-env.d.ts.map +0 -1
  204. package/dist/server/prerendered.d.ts.map +0 -1
  205. package/dist/server/server-config.d.ts.map +0 -1
  206. package/dist/server/shutdown.d.ts.map +0 -1
  207. package/dist/server/ssg.d.ts.map +0 -1
  208. package/dist/server/static.d.ts.map +0 -1
package/README.md CHANGED
@@ -1,36 +1,58 @@
1
- # rshono
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
- Minimalist web framework — [Hono](https://hono.dev) + [Rspack](https://rspack.rs) + [React Server Components](https://react.dev/reference/rsc/server-components).
5
+ <h1 align="center">@rshono/core</h1>
4
6
 
5
- > **Alpha.** The framework itself is covered by an end-to-end suite (see [Testing](#testing)), but it
6
- > is built on Rspack's experimental RSC support (`rspack.experiments.rsc`) and `react-server-dom-rspack`,
7
- > which is still `0.0.x`. Those two move underneath us, so `@rspack/core` and `react-server-dom-rspack`
8
- > are pinned to exact versions and a release of rshono is what moves them.
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, streaming SSR with RSC hydration, server actions with progressive enhancement, soft navigation, build-time prerendering, and hard env/secret safety.
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
- npm create @rshono@latest my-app # scaffold one, with a deploy target and tooling of your choosing
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 + SSG
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 (see Configuration)
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
- everything else is yours to arrange
50
+ everything else is yours to arrange
31
51
  ```
32
52
 
33
- Only the two files under `src/` mean anything to the framework; there is no convention attached to any other name or directory. `@/…` resolves to `src/…` in both compilers, so add the matching `paths` to your `tsconfig.json` if you use it — relative, and with no `baseUrl`, which TypeScript 7 removed:
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 importing server-only modules from it (e.g. inside `staticPaths`) is safe. A plain array (no special pages) is accepted as shorthand.
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 **default-exports a server component** — nothing else. Under the hood each page carries Rspack's `'use server-entry'` directive (it attaches the page's client JS/CSS assets to the component — per-page code splitting with no asset manifest), but the framework **injects it automatically** for every component referenced with the inline `component: () => import('…')` thunk form in routes.ts. This also works for routes added while the dev server is running.
66
-
67
- If a component is wired up some other way (variable indirection, barrel re-exports, computed specifiers), write `'use server-entry'` as the first line of the page module yourself a manually written directive is always respected. The framework throws a descriptive error when neither happened.
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 }` (`PageProps<'/profile/:id'>` types `params.id`). `url` is a real `URL` — read `url.pathname` and `url.searchParams` off it — and the pair matches what a `'use client'` component gets from `useNavigation()`, so a read moves across the line unchanged.
81
- - **`ctx` is the request context** — cookies, headers, env, middleware variables, the proxy-aware URL. It is the same object `getContext()` returns from `@rshono/core/server`, handed over so a page needs no import; reach for `getContext()` in the places that get no props (a nested server component, a `'use server'` action). Type `ctx.var` / `ctx.env` for your app by passing its Hono `Env`: `PageProps<'/profile/:id', MyEnv>`.
82
- - Reading `ctx` on a **`render: 'static'`** page throws — a page rendered once at build time has no request to read. Use `params` and `url`, which are available either way, or make the route `render: 'dynamic'`. One quiet caveat: a prerendered `url` is the build-time one (`siteUrl` + the path, no query), and that one file answers every request whatever its own query — so `url.searchParams` is always empty there. Read the query with `useNavigation().url` on the client instead.
83
- - Pages render the **entire document** (`<html>…</html>`), usually via a shared layout component.
84
- - Interactive parts are `'use client'` components imported by the page; only those ship JavaScript.
85
- - A fully interactive page is a thin server component wrapping a `'use client'` component.
86
- - Page props are **server-only and never serialized** React puts a server component's output on the wire, not its props. `ctx` is additionally non-enumerable, which keeps it out of React's dev-only debug payload (an enumerable one would ship the whole Hono context, bindings included, to the browser in dev).
87
- - **`ctx` cannot cross into a `'use client'` component**; it wraps the live request and response, which don't exist in the browser. Passing it explicitly (`<Counter ctx={ctx} />`) fails the render with React's _"Only plain objects … can be passed to Client Components"_, naming the prop. Spreading page props instead (`<Counter {...props} />`) drops it silently, since a spread copies enumerables only — though that spread fails anyway on `url`, which is enumerable and just as unserializable. Either way: read what you need on the server and pass plain values down (`url.href`, not `url`).
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>` / `useActionState` — forms keep working before hydration and with JavaScript disabled (progressive enhancement). Every action response carries a fresh page payload, so server-rendered UI updates automatically after mutations.
99
-
100
- ## Full Hono underneath
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
- - `{ type: 'endpoint' }` routes export a Hono `handler` from a server module (it only ever runs on the server).
103
- - `src/server.ts` may default-export a whole Hono sub-app: any method, streaming, cookies, middleware. `export type AppType = typeof server` gives end-to-end type safety with `hono/client`.
104
- - The sub-app is mounted at `/` **ahead of the page routes**, so its middleware (auth, logging, trailing-slash) wraps page requests too. The flip side: a _terminal_ handler in `src/server.ts` at the same path as a page route shadows the page.
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
- ## Styling
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
- - Drop anything you want served verbatim into `public/` it's mounted at the **web root**, so `public/favicon.ico` `/favicon.ico`, `public/robots.txt` → `/robots.txt`, `public/.well-known/…` resolves too. This is the home for the conventional files browsers and crawlers request by path.
140
- - It's a **fallback**: your routes always win, and unmatched paths still fall through to the `notFound` page so a `public/` file never shadows a real route. `build` copies `public/` into `dist/` so a deployed build is self-contained.
141
- - Hashed bundle output is served separately under `/_static/` with long-lived immutable caching; `public/` files get a short `max-age` (and `no-cache` in dev).
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
- ## Prerendering (`render: 'static'`)
151
+ [Hono docs](https://www.rshono.com/docs/hono).
144
152
 
145
- A static route is built once and served from disk in **both** representations — `index.html` for a hard load, `index.rsc` for the flight payload a soft navigation asks for. Serving only the document would mean every in-app click re-rendered a page the build had already produced, so the prerender would pay off for crawlers and nobody else. Both carry a weak `ETag`, so a revalidation costs a 304.
153
+ ## Styling
146
154
 
147
- Set **`siteUrl`** if your static pages build absolute URLs — a canonical tag, an `og:url`, an absolute link. A prerendered file is one set of bytes handed to everyone, so there is no request to read a `Host` from and the origin has to be decided at build time; without `siteUrl` it is `http://localhost`, and the build warns. Dynamic routes are unaffected — they resolve the URL per request, `siteUrl` or not.
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
- If a page can't be prerendered (its `staticPaths` is missing, or it didn't render cleanly at build time) the build says so and that route falls back to rendering per request.
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 client/server boundary is the RSC directives — `'use client'` and `'use server'` — not filenames, and `process.env` access follows it. There is no `*.server` naming convention.
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
- - **Client bundle**: `process.env` is _replaced at build time_ with a literal containing only `NODE_ENV` and `PUBLIC_`-prefixed variables. A stray `process.env.DATABASE_URL` in client code compiles to `undefined` — the value cannot ship. This is a hard guarantee, not tree-shaking, and it covers your `node_modules` too.
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
- An optional `rshono.config.ts` (`.js` / `.mjs` also work) at the project root tunes the framework. Every field is optional; delete the file to accept all defaults.
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 — see Deployment (--deploy or RSHONO_DEPLOY override)
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
- `defineConfig` is an identity helper for editor autocomplete; `export default { … } satisfies RSHonoConfig` works too. `deploy`/`port`/`host`/`rspack` are consumed by the CLI; the framework settings (`trustProxy`, `checkOrigin`, `allowedOrigins`, `csp`, `cspDirectives`, `bodySizeLimit`, `renderTimeout`, `compress`) are resolved from this file at build time and **compiled into the server bundle** — there is no parallel env-var interface for them (environment variables are for secrets). Changing one of these settings means a rebuild. The two deployment-conventional exceptions stay env-overridable: `--port`/`PORT` and `HOST` win over the file, which wins over the built-in default. Point `rshono build` at a different config with `--config <path>`.
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
- ```ts
204
- // src/server.ts
205
- import { onServerError } from '@rshono/core/server';
206
-
207
- onServerError((error, { source, request }) => {
208
- Sentry.captureException(error, { tags: { source }, extra: { url: request.url } });
209
- });
210
- ```
211
-
212
- - **Error responses**: thrown server-action errors are logged server-side and redacted in the production payload (React sends no message or digest for them) — so return values, not throws, for anything the user should see. Custom 404/500 pages are real server components declared in routes.ts (`notFound` / `error`); the error page's `error` prop is message-only in production, message + stack in dev.
213
- - **No blank screens.** Three fallbacks behind the `error` page, so a failure is always something you can read:
214
- - An **uncaught client-side render error** makes React tear down its root — which here is the whole `document`, so the page would go genuinely white with the reason only in the console. The runtime paints a fatal overlay over it instead: full stack and component stack in dev, a generic notice plus a reload button in production (the dev detail is compiled out of the production bundle).
215
- - If **SSR fails before the shell is sent**, the `error` page can't be reached either, so the framework answers with its own visible 500 document — the real message and stack in dev, generic in production. It deliberately attaches no client runtime: the flight payload came from the same failed render, so hydrating it would tear the document down and blank the message.
216
- - A **client bootstrap failure** (a truncated or malformed initial payload) is reported and surfaced rather than becoming a silent unhandled rejection.
217
-
218
- ## Testing
219
-
220
- `pnpm --filter @rshono/core test` builds the package and runs everything that doesn't need a browser:
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
- `pnpm --filter @rshono/core test:browser` runs the Playwright suite against a production build: hydration, soft navigation, prefetch-on-hover, `useNavigation`, client-initiated actions, boundary fallbacks, scroll restoration and the fatal overlay — the client runtime, which no amount of asserting on HTML can reach.
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
- ## How it works
242
+ ## Prerendering (`render: 'static'`)
232
243
 
233
- Two coordinated Rspack compilers (native RSC support, `rspack.experiments.rsc`):
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
- - **client** (`target: web`) → `dist/static`: hydration runtime, `'use client'` chunks, CSS.
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
- In dev, the CLI watches both bundles, runs the server bundle in a worker thread (restarted per rebuild; requests gate on readiness — no dropped connections), and fronts everything on one port with static serving and an SSE channel: client edits hot-apply via react-refresh, server component edits re-fetch the payload in place — browser state survives both.
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
- In production, `dist/server/main.mjs` is self-contained (React, Hono and the framework are bundled in; your other npm dependencies resolve from `node_modules`): `rshono start` or any process manager running `node dist/server/main.mjs`.
307
+ ## Requirements & limitations
241
308
 
242
- Everything in that bundle that depends on _where_ it runs — binding a port, serving `/_static` and `public/`, reading a prerendered page, gzipping, loading `.env` — sits behind a single interface (`DeployRuntime`) that the build resolves per `deploy` target, so the request-handling code has no platform in it. The entry's default export is whatever the platform expects: nothing where rshono owns the process, a `fetch` handler where the host does.
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
- ## Deployment
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
- | `deploy` | Handoff | Assets & prerendered pages | After `build` |
249
- | ------------ | -------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- |
250
- | `node` | binds a port | from `dist/` on disk | `rshono start` |
251
- | `bun` | `{ fetch, port }` default export | from `dist/` on disk | `bun dist/server/main.mjs` |
252
- | `deno` | `{ fetch }` default export | from `dist/` on disk | `deno serve -A dist/server/main.mjs` |
253
- | `cloudflare` | `{ fetch }` default export | Workers Assets; prerendered pages read via the `ASSETS` binding | `wrangler deploy` |
254
- | `vercel` | web handler in a Node function | CDN for assets; prerendered pages inside the function | `vercel deploy --prebuilt` |
255
- | `netlify` | web handler, Functions v2 | CDN for assets; prerendered pages inside the function | `netlify deploy --build=false --dir=.netlify/publish` |
256
- | `aws-lambda` | streaming handler (Function URL) | from the deployment package | zip `dist/`, handler `dist/server/main.mjs` |
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
- Every target streams: a page's HTML reaches the browser as it renders, which is the whole reason the SSR shell is worth having. That is the bar a new target has to clear.
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
- Notes worth knowing before choosing one:
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
- - **Cloudflare** bundles all your dependencies (a Worker resolves no `node_modules` at runtime), so a dependency that needs a real `node:` API beyond `nodejs_compat` will not work. The build scaffolds a `wrangler.jsonc` if the project has none — including `nodejs_compat`, which the request context needs for `AsyncLocalStorage` — and never touches it again. Bindings (D1, KV, R2) arrive as `getContext().env`; they are not available under `rshono dev`, which is plain Node.
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
- ## Requirements & limitations
450
+ Two coordinated Rspack compilers, using native RSC support (`rspack.experiments.rsc`):
269
451
 
270
- - Node ≥ 22.1 (worker threads, `process.loadEnvFile`, `Promise.withResolvers`, `URL.parse`), React 19.1 (the floor `react-server-dom-rspack` itself requires).
271
- - Responses are gzipped, not brotli — one encoding every client accepts, chosen per chunk so streaming survives. Set `compress: false` behind a proxy that does better.
272
- - Dev-mode proxy doesn't forward WebSocket upgrades to a custom sub-app (prod is unaffected — the bundle owns the socket there).
273
- - Dev source maps embed the original source of `'use server'` action modules (dev binds to 127.0.0.1 only; production ships no client source maps).
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.