@real-router/rsc-server-plugin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -0
- package/dist/cjs/errors.d.ts +97 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +2 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.d.ts +330 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/errors.d.mts +97 -0
- package/dist/esm/errors.d.mts.map +1 -0
- package/dist/esm/errors.mjs +2 -0
- package/dist/esm/errors.mjs.map +1 -0
- package/dist/esm/index.d.mts +330 -0
- package/dist/esm/index.d.mts.map +1 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +77 -0
- package/src/actionFactory.ts +148 -0
- package/src/buildRscPayload.ts +64 -0
- package/src/constants.ts +16 -0
- package/src/errors.ts +6 -0
- package/src/factory.ts +56 -0
- package/src/getSsrRscMode.ts +41 -0
- package/src/index.ts +28 -0
- package/src/invalidate.ts +36 -0
- package/src/types.ts +122 -0
package/README.md
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# @real-router/rsc-server-plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@real-router/rsc-server-plugin)
|
|
4
|
+
[](https://www.npmjs.com/package/@real-router/rsc-server-plugin)
|
|
5
|
+
[](https://bundlejs.com/?q=@real-router/rsc-server-plugin&treeshake=[*])
|
|
6
|
+
[](../../LICENSE)
|
|
7
|
+
|
|
8
|
+
> Per-route `ReactNode` (RSC payload) loading for [Real-Router](https://github.com/greydragon888/real-router). Intercepts `start()` to load Server Components before Flight rendering. **Bundler-agnostic** — the plugin **never imports** a Flight renderer; the caller picks one of `@vitejs/plugin-rsc`, `react-server-dom-webpack`, `react-server-dom-turbopack`, or `react-server-dom-parcel`. Examples in this README and in the [wiki](https://github.com/greydragon888/real-router/wiki/RSC-Integration) use the Vite import path (`@vitejs/plugin-rsc/rsc`); other bundlers expose the same `renderToReadableStream` shape under their own paths (`react-server-dom-webpack/server.edge`, `react-server-dom-turbopack/server`, `react-server-dom-parcel/server`) — swap the import, keep the call site.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
// Without plugin: manual per-route Server Component dispatch
|
|
12
|
+
const state = await router.start(url);
|
|
13
|
+
const node = await getNodeForRoute(state.name, state.params); // manual
|
|
14
|
+
|
|
15
|
+
// With plugin:
|
|
16
|
+
router.usePlugin(rscServerPluginFactory(loaders));
|
|
17
|
+
const state = await router.start(url);
|
|
18
|
+
const node = state.context.rsc; // resolved automatically
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @real-router/rsc-server-plugin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Peer dependencies:** `@real-router/core`, `react` (>=19.0.0). No bundler dependency — the caller picks the Flight renderer.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { createRouter } from "@real-router/core";
|
|
33
|
+
import { cloneRouter } from "@real-router/core/api";
|
|
34
|
+
import { serializeRouterState } from "@real-router/core/utils";
|
|
35
|
+
import { rscServerPluginFactory } from "@real-router/rsc-server-plugin";
|
|
36
|
+
import type { RscLoaderFactoryMap } from "@real-router/rsc-server-plugin";
|
|
37
|
+
import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc";
|
|
38
|
+
|
|
39
|
+
const loaders: RscLoaderFactoryMap = {
|
|
40
|
+
"users.profile": () => async (params) => {
|
|
41
|
+
const user = await fetchUser(params.id);
|
|
42
|
+
return <UserProfile user={user} />;
|
|
43
|
+
},
|
|
44
|
+
home: () => () => <HomePage />,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const baseRouter = createRouter(routes, { defaultRoute: "home", allowNotFound: true });
|
|
48
|
+
|
|
49
|
+
// Per-request SSR
|
|
50
|
+
const router = cloneRouter(baseRouter, { db: requestDb });
|
|
51
|
+
router.usePlugin(rscServerPluginFactory(loaders));
|
|
52
|
+
|
|
53
|
+
const state = await router.start(req.url);
|
|
54
|
+
|
|
55
|
+
// 1) Pipe RSC Flight payload (the bundler-specific renderer is *yours*)
|
|
56
|
+
if (state.context.rsc) {
|
|
57
|
+
const flightStream = renderToReadableStream(state.context.rsc);
|
|
58
|
+
// … pipe to HTTP response or inline-inject into HTML
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2) Serialize state for client hydration — strip "rsc" (not JSON-serializable)
|
|
62
|
+
const ssrState = serializeRouterState(state, { excludeContext: ["rsc"] });
|
|
63
|
+
|
|
64
|
+
router.dispose();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Loaders are keyed by **route name** (not path). Each value is a **factory function** `(router, getDependency) => loaderFn` returning the compiled loader. The factory runs once at plugin registration; the returned loader is cached.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import type { RscLoaderFactoryMap } from "@real-router/rsc-server-plugin";
|
|
73
|
+
|
|
74
|
+
const loaders: RscLoaderFactoryMap = {
|
|
75
|
+
home: () => () => <HomePage />, // sync ReactNode
|
|
76
|
+
"users.profile": () => async (params) => { // async ReactNode
|
|
77
|
+
const user = await fetchUser(params.id);
|
|
78
|
+
return <UserProfile user={user} />;
|
|
79
|
+
},
|
|
80
|
+
"posts.list": (_router, getDep) => async () => { // DI via getDependency
|
|
81
|
+
const db = getDep("db");
|
|
82
|
+
const posts = await db.posts.findAll();
|
|
83
|
+
return <PostsList posts={posts} />;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Routes without a matching entry leave `state.context.rsc` as `undefined` and `getSsrRscMode(state)` falls back to `"full"`.
|
|
89
|
+
|
|
90
|
+
## Per-route SSR mode
|
|
91
|
+
|
|
92
|
+
`rsc-server-plugin` accepts the same `{ ssr?, loader? }` shape as `ssr-data-plugin`, but with a strict subset of `SsrMode`: only `"full"` and `"client-only"` are allowed. Passing `"data-only"` (RSC has no semantically meaningful "data without component") throws at factory time.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const loaders: RscLoaderFactoryMap = {
|
|
96
|
+
home: () => () => <HomePage />, // short form, defaults to "full"
|
|
97
|
+
"admin.dashboard": { ssr: false }, // false → "client-only"
|
|
98
|
+
"docs.detail": {
|
|
99
|
+
ssr: (state) => state.params.format === "pdf" ? "client-only" : "full",
|
|
100
|
+
loader: () => () => <Doc />,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| `ssr` value | mode marker | loader behaviour |
|
|
106
|
+
| ---------------------------- | ----------------- | ------------------------- |
|
|
107
|
+
| omitted / `true` / `"full"` | `"full"` | runs (composes with #596) |
|
|
108
|
+
| `false` / `"client-only"` | `"client-only"` | **skipped** unconditionally |
|
|
109
|
+
| `(state) => RscSsrMode` | resolver result | resolved per-navigation |
|
|
110
|
+
|
|
111
|
+
Read the resolved mode via `getSsrRscMode(state)` (returns `"full"` for routes without an entry):
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import { getSsrRscMode } from "@real-router/rsc-server-plugin";
|
|
115
|
+
|
|
116
|
+
const mode = getSsrRscMode(state); // RscSsrMode = "full" | "client-only"
|
|
117
|
+
|
|
118
|
+
if (mode === "full") {
|
|
119
|
+
const flight = renderToReadableStream(buildRscPayload(state));
|
|
120
|
+
// … pipe Flight + SSR HTML
|
|
121
|
+
}
|
|
122
|
+
// mode === "client-only" → no Server Component was rendered server-side
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Why `ReactNode`, not Flight bytes?
|
|
126
|
+
|
|
127
|
+
The plugin publishes a `ReactNode`, not a pre-rendered Flight `Uint8Array`. This keeps the plugin:
|
|
128
|
+
|
|
129
|
+
- **Bundler-agnostic** — `react-server-dom-{webpack,turbopack,parcel,esm}` have incompatible `renderToReadableStream` signatures; the caller picks the right one
|
|
130
|
+
- **Streaming-friendly** — Flight rendering happens out-of-band, in parallel with HTML SSR
|
|
131
|
+
- **Aligned with industry** — both React Router 7 (`unstable_RSCStaticRouter`) and TanStack Start (`renderServerComponent`) use the same model
|
|
132
|
+
|
|
133
|
+
The Flight render itself is one line:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
const flight = renderToReadableStream(state.context.rsc);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Serialization
|
|
140
|
+
|
|
141
|
+
`state.context.rsc` is a `ReactNode` tree (functions, symbols) and cannot be JSON-serialized. Use `serializeRouterState`'s `excludeContext` option to strip it before client transport:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { serializeRouterState } from "@real-router/core/utils";
|
|
145
|
+
|
|
146
|
+
const ssrJson = serializeRouterState(state, { excludeContext: ["rsc"] });
|
|
147
|
+
// JSON contains state.context.data and other namespaces, but not state.context.rsc
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## SSR-Only by Design (with explicit CSR revalidation channel)
|
|
151
|
+
|
|
152
|
+
This plugin intercepts `start()` only — not `navigate()`. In SSR, the flow is:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
cloneRouter → usePlugin → start(url) → ReactNode resolved → state.context.rsc
|
|
156
|
+
↓
|
|
157
|
+
renderToReadableStream(node)
|
|
158
|
+
↓
|
|
159
|
+
Flight stream → HTTP
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Client-side navigation does **not** re-run the RSC loader by default — application-layer fetching (React Query, Suspense, RSC `/__rsc` endpoint) owns CSR data. The one explicit exception is the `invalidate()` revalidation channel below.
|
|
163
|
+
|
|
164
|
+
## Client-side revalidation (`invalidate`)
|
|
165
|
+
|
|
166
|
+
After a mutation, mark the `"rsc"` namespace stale on the router. The next navigation (including a same-route reload) re-runs the RSC loader for the destination route and overwrites `state.context.rsc` before `TRANSITION_SUCCESS` fires — so subscribers see the fresh `ReactNode`.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import { invalidate } from "@real-router/rsc-server-plugin";
|
|
170
|
+
|
|
171
|
+
// Fire-and-forget — stale until the user navigates somewhere.
|
|
172
|
+
invalidate(router, "rsc");
|
|
173
|
+
|
|
174
|
+
// Explicit await — pair with a same-route reload.
|
|
175
|
+
invalidate(router, "rsc");
|
|
176
|
+
await router.navigate(state.name, state.params, { reload: true });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The flag is **preserved** until a successful, non-cancelled loader write. So a navigation that lands on a route without an entry, a `client-only` route, a mode-only entry, or one that gets cancelled mid-loader (newer `navigate()` aborts the older controller) all leave the flag set for the next attempt. A loader rejection also leaves the flag set — retry re-runs the loader.
|
|
180
|
+
|
|
181
|
+
Idempotent — multiple `invalidate()` calls between refreshes collapse to one re-run. Surgical for multi-namespace routes — only `"rsc"` re-runs; a side-by-side [`@real-router/ssr-data-plugin`](https://www.npmjs.com/package/@real-router/ssr-data-plugin) keeps its cached `state.context.data` unless its own `invalidate()` was also called.
|
|
182
|
+
|
|
183
|
+
### Cancellation-aware loaders
|
|
184
|
+
|
|
185
|
+
The leave handler passes the navigation's `AbortController.signal` as the second loader argument so loaders can abort their in-flight work (DB query, RSC stream, …) when a newer navigation supersedes:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
"users.profile": (_router, getDep) => async (params, ctx) => {
|
|
189
|
+
const db = getDep("db");
|
|
190
|
+
const user = await db.users.findById(params.id, { signal: ctx?.signal });
|
|
191
|
+
|
|
192
|
+
return <UserProfile user={user} />;
|
|
193
|
+
},
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The start interceptor calls the loader without a context. **Robust loaders check `signal.aborted` upfront** — a signal aborted before `addEventListener("abort", …)` does NOT auto-fire the listener.
|
|
197
|
+
|
|
198
|
+
Non-breaking via TypeScript contravariance — existing `(params) => …` loaders continue to compile and work unchanged.
|
|
199
|
+
|
|
200
|
+
## Post-hydration loader skip
|
|
201
|
+
|
|
202
|
+
When the application uses `hydrateRouter()` from `@real-router/core/utils`, the parsed server-serialized state is briefly deposited on a one-shot internal scratchpad before `start()` runs. The plugin reads this scratchpad and **reuses the server-resolved value** if `state.context.rsc` is already present for the same route name — skipping the redundant client-side `ReactNode` resolution on first paint.
|
|
203
|
+
|
|
204
|
+
In practice, RSC apps usually `excludeContext: ["rsc"]` from the JSON payload (a `ReactNode` tree contains functions/symbols and isn't JSON-serializable). In that case the scratchpad has no `rsc` namespace and the loader runs as today. The skip path matters when the bundler-specific Flight pipeline arranges to thread an already-resolved `ReactNode` through hydration.
|
|
205
|
+
|
|
206
|
+
The skip is single-shot — only the first `start()` triggered by `hydrateRouter` consumes the scratchpad. Composes with per-route mode: `"client-only"` skips the loader regardless of scratchpad contents (mode wins).
|
|
207
|
+
|
|
208
|
+
## Typed Loader Errors (`@real-router/rsc-server-plugin/errors`)
|
|
209
|
+
|
|
210
|
+
Mirror of [`@real-router/ssr-data-plugin/errors`](../ssr-data-plugin/README.md#typed-loader-errors-real-routerssr-data-pluginerrors) — same shared source under `shared/ssr/errors.ts`. RSC apps can import error classes without adding `ssr-data-plugin` as a dependency:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import {
|
|
214
|
+
LoaderNotFound,
|
|
215
|
+
LoaderRedirect,
|
|
216
|
+
} from "@real-router/rsc-server-plugin/errors";
|
|
217
|
+
|
|
218
|
+
const loaders: RscLoaderFactoryMap = {
|
|
219
|
+
"users.profile": (_router, getDep) => async (params) => {
|
|
220
|
+
const user = await getDep("db").users.findById(params.id);
|
|
221
|
+
if (!user) throw new LoaderNotFound(`user:${params.id}`);
|
|
222
|
+
return <UserProfile user={user} />;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// In the RSC fetch handler:
|
|
227
|
+
try {
|
|
228
|
+
const state = await router.start(pathname);
|
|
229
|
+
return new Response(renderToReadableStream(buildRscPayload(state)));
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error?.code === "LOADER_NOT_FOUND") {
|
|
232
|
+
return new Response("Not Found", { status: 404 });
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`LoaderNotFound`, `LoaderRedirect`, `LoaderTimeout`, `withTimeout` — same shape and structural `code` discriminator as the data-plugin counterparts.
|
|
239
|
+
|
|
240
|
+
## Cleanup
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
const unsubscribe = router.usePlugin(rscServerPluginFactory(loaders));
|
|
244
|
+
|
|
245
|
+
// Later — releases "rsc" namespace claim and stops the start interceptor
|
|
246
|
+
unsubscribe();
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
In SSR, `router.dispose()` handles cleanup automatically.
|
|
250
|
+
|
|
251
|
+
## Server Actions (`rscActionPluginFactory`)
|
|
252
|
+
|
|
253
|
+
For RSC apps that ship Server Actions, this package also exports a **second factory** — `rscActionPluginFactory(getResult)` — that publishes the action result (`returnValue` / `formState`) to `state.context.rscAction`. It claims a separate `"rscAction"` namespace, so it composes with `rscServerPluginFactory` and `ssr-data-plugin` on the same router. Action results are produced *outside* the loader pipeline (typically in the request fetch handler, before the router exists for that request), so they're surfaced via a closure-captured resolver rather than a per-route map.
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import {
|
|
257
|
+
buildRscPayload,
|
|
258
|
+
rscActionPluginFactory,
|
|
259
|
+
rscServerPluginFactory,
|
|
260
|
+
type RscActionResult,
|
|
261
|
+
} from "@real-router/rsc-server-plugin";
|
|
262
|
+
// Vite path — swap for `react-server-dom-{webpack,turbopack,parcel}/server.*`
|
|
263
|
+
// when you use a different bundler. The plugin itself imports nothing here.
|
|
264
|
+
import {
|
|
265
|
+
decodeAction,
|
|
266
|
+
decodeFormState,
|
|
267
|
+
decodeReply,
|
|
268
|
+
loadServerAction,
|
|
269
|
+
renderToReadableStream,
|
|
270
|
+
} from "@vitejs/plugin-rsc/rsc";
|
|
271
|
+
|
|
272
|
+
let actionResult: RscActionResult | undefined;
|
|
273
|
+
|
|
274
|
+
if (request.method === "POST") {
|
|
275
|
+
const isFormPost = request.headers
|
|
276
|
+
.get("content-type")
|
|
277
|
+
?.includes("multipart/form-data");
|
|
278
|
+
|
|
279
|
+
if (isFormPost) {
|
|
280
|
+
// Progressive enhancement path — POST without JS.
|
|
281
|
+
const formData = await request.formData();
|
|
282
|
+
const decoded = await decodeAction(formData);
|
|
283
|
+
const result = await decoded();
|
|
284
|
+
const formState = await decodeFormState(result, formData);
|
|
285
|
+
|
|
286
|
+
actionResult = formState ? { formState } : undefined;
|
|
287
|
+
} else {
|
|
288
|
+
// Hydrated client path — setServerCallback dispatched the call.
|
|
289
|
+
const actionId = request.headers.get("rsc-action") ?? "";
|
|
290
|
+
const fn = await loadServerAction(actionId);
|
|
291
|
+
const args = await decodeReply(await request.text());
|
|
292
|
+
|
|
293
|
+
actionResult = { returnValue: { ok: true, data: await fn(...args) } };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const router = cloneRouter(baseRouter, requestDeps);
|
|
298
|
+
|
|
299
|
+
router.usePlugin(
|
|
300
|
+
rscServerPluginFactory(loaders),
|
|
301
|
+
rscActionPluginFactory(() => actionResult), // closure captures live mutation
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const state = await router.start(new URL(request.url).pathname);
|
|
305
|
+
const flight = renderToReadableStream(buildRscPayload(state));
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Rules:
|
|
309
|
+
|
|
310
|
+
- `getResult` is **validated at factory time** as a function — a TS-cast bypass that smuggles `null`/`async` through throws `TypeError` synchronously, **before** the `"rscAction"` namespace is claimed.
|
|
311
|
+
- The return value is **validated per `start()`** — must be `undefined` (skip the write) or a plain object. Arrays, primitives, and `Promise`/thenables are rejected with a typed message pointing back at the call site. The most common consumer mistake is wiring an `async` getResult; the runtime guard surfaces that explicitly.
|
|
312
|
+
- `state.context.rscAction` is **JSON-friendly** — `serializeRouterState(state)` works without `excludeContext`. Pass `excludeContext: ["rsc", "rscAction"]` only if the result carries server-only secrets you don't want to ship to the client.
|
|
313
|
+
- The two plugins coexist regardless of registration order; both namespaces are exclusive (double-registration throws `RouterError(CONTEXT_NAMESPACE_ALREADY_CLAIMED)`).
|
|
314
|
+
- `buildRscPayload(state, rootOverride?)` reads `state.context.rsc` + `state.context.rscAction` and returns the canonical `RscPayload<TReturn, TFormState>` Flight shape. `returnValue` / `formState` are **omitted** (not set to `undefined`) when their source is missing — type-safe under `exactOptionalPropertyTypes: true`.
|
|
315
|
+
|
|
316
|
+
For the full integration recipe (HTML + `/__rsc` endpoints, dev/prod bundler config, Flight injection), see the [Wiki: RSC Integration](https://github.com/greydragon888/real-router/wiki/RSC-Integration) guide.
|
|
317
|
+
|
|
318
|
+
## Example
|
|
319
|
+
|
|
320
|
+
- [examples/web/react/ssr-examples/ssr-rsc](../../examples/web/react/ssr-examples/ssr-rsc) — End-to-end dogfooding example: Express + `@vitejs/plugin-rsc` + this plugin, with Flight injection, client navigation via `/__rsc?route=…`, revalidation, and **Server Actions** wired through `rscActionPluginFactory` (see `entry.rsc.tsx` + `NotificationBanner.tsx`). The Playwright suite covers **27 scenarios** including initial HTML load, client nav, revalidation **happy path + in-flight defer** (Scenarios 3 + 3b), 404 routing, per-request isolation under concurrent load, `/__rsc` content-type assertions, loader-driven HTTP status (404/500), search-param flow, browser back/forward, interleaved-click abort, per-route Cache-Control, ETag absence on streamed responses, and the full Server Action lifecycle (form rendering, mutation, `useActionState` validation errors, `NotificationBanner` cross-component reflection via `state.context.rscAction`). `RevalidateButton` calls `invalidate(router, "rsc")` for API symmetry — see [`src/client-components/RevalidateButton.tsx`](../../examples/web/react/ssr-examples/ssr-rsc/src/client-components/RevalidateButton.tsx).
|
|
321
|
+
|
|
322
|
+
## Documentation
|
|
323
|
+
|
|
324
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md) — Design decisions and data flow
|
|
325
|
+
- [INVARIANTS.md](INVARIANTS.md) — Property-based invariants
|
|
326
|
+
- [Wiki: RSC Integration](https://github.com/greydragon888/real-router/wiki/RSC-Integration) — End-to-end integration guide
|
|
327
|
+
|
|
328
|
+
## Related Packages
|
|
329
|
+
|
|
330
|
+
| Package | Description |
|
|
331
|
+
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
|
332
|
+
| [@real-router/core](https://www.npmjs.com/package/@real-router/core) | Core router (required peer dependency) |
|
|
333
|
+
| [@real-router/ssr-data-plugin](https://www.npmjs.com/package/@real-router/ssr-data-plugin) | Sibling plugin for plain JSON data (`state.context.data`) |
|
|
334
|
+
| [@real-router/react](https://www.npmjs.com/package/@real-router/react) | React bindings |
|
|
335
|
+
|
|
336
|
+
## License
|
|
337
|
+
|
|
338
|
+
[MIT](../../LICENSE) © [Oleg Ivanov](https://github.com/greydragon888)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//#region ../../shared/ssr/errors.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Typed loader errors that SSR pipelines translate into HTTP semantics.
|
|
4
|
+
*
|
|
5
|
+
* The `ssr-data-plugin` and `rsc-server-plugin` are intentionally
|
|
6
|
+
* HTTP-agnostic — they only await the loader and write the resolved value
|
|
7
|
+
* to `state.context.<namespace>`. Loaders bridge to HTTP status codes by
|
|
8
|
+
* throwing one of these named errors; application-layer middleware catches
|
|
9
|
+
* them and maps each `code` to the right status (302/308, 404, 504).
|
|
10
|
+
*
|
|
11
|
+
* Structural discrimination via `code` (not `instanceof`) so consumers
|
|
12
|
+
* can match across realms / bundle boundaries without coupling to the
|
|
13
|
+
* class identity.
|
|
14
|
+
*
|
|
15
|
+
* Re-exported from both plugins under the `./errors` subpath:
|
|
16
|
+
* `@real-router/ssr-data-plugin/errors` and
|
|
17
|
+
* `@real-router/rsc-server-plugin/errors`.
|
|
18
|
+
*/
|
|
19
|
+
declare class LoaderRedirect extends Error {
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly status: 301 | 302 | 307 | 308;
|
|
22
|
+
readonly code = "LOADER_REDIRECT";
|
|
23
|
+
constructor(target: string, status?: 301 | 302 | 307 | 308);
|
|
24
|
+
}
|
|
25
|
+
declare class LoaderNotFound extends Error {
|
|
26
|
+
readonly resource: string;
|
|
27
|
+
readonly code = "LOADER_NOT_FOUND";
|
|
28
|
+
constructor(resource: string);
|
|
29
|
+
}
|
|
30
|
+
declare class LoaderTimeout extends Error {
|
|
31
|
+
readonly route: string;
|
|
32
|
+
readonly ms: number;
|
|
33
|
+
readonly code = "LOADER_TIMEOUT";
|
|
34
|
+
constructor(route: string, ms: number);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Race a loader against a deadline, with cooperative cancellation.
|
|
38
|
+
*
|
|
39
|
+
* The loader is invoked with `{ signal }` — a composed `AbortSignal` that
|
|
40
|
+
* aborts on the first of:
|
|
41
|
+
* - the deadline elapsing (`internalController.abort()` fires synchronously
|
|
42
|
+
* *before* the race rejects with `LoaderTimeout`, so a loader that
|
|
43
|
+
* threads `signal` into its I/O — e.g. `fetch(url, { signal })` — can
|
|
44
|
+
* actually cancel the underlying work);
|
|
45
|
+
* - `options.upstreamSignal` aborting (typically the request-scoped abort
|
|
46
|
+
* wired by `cloneRouter(base, { abortSignal })` for client-disconnect).
|
|
47
|
+
*
|
|
48
|
+
* Composition uses `AbortSignal.any([upstream, internal])` (Node 20.3+).
|
|
49
|
+
* If `upstreamSignal` is already aborted at call time, the loader is *not*
|
|
50
|
+
* invoked and the timer is *not* started — the rejection mirrors
|
|
51
|
+
* `upstreamSignal.reason ?? new DOMException("Aborted", "AbortError")`.
|
|
52
|
+
*
|
|
53
|
+
* On deadline, the same `LoaderTimeout` instance is used as both the
|
|
54
|
+
* `signal.reason` and the rejection reason — they refer to one object.
|
|
55
|
+
* On upstream abort during execution, the race rejects with the loader's
|
|
56
|
+
* own error (typically `AbortError`), *not* `LoaderTimeout`.
|
|
57
|
+
*
|
|
58
|
+
* Cancellation is cooperative: loaders that don't propagate `signal` into
|
|
59
|
+
* their I/O still run to completion in the background — the race result
|
|
60
|
+
* is unaffected, but resources are not freed early.
|
|
61
|
+
*
|
|
62
|
+
* The `setTimeout` handle is cleared via `.finally()` on the work promise
|
|
63
|
+
* so a fast-path success doesn't leak it. `Promise.race`'s internal
|
|
64
|
+
* `Promise.resolve(p).then(resolve, reject)` consumes any late losing
|
|
65
|
+
* rejection — no `unhandledRejection` for late loader settlements.
|
|
66
|
+
*
|
|
67
|
+
* Requires Node 20.3+ for `AbortSignal.any`.
|
|
68
|
+
*
|
|
69
|
+
* ### `ms` corner cases (Node `setTimeout` clamping)
|
|
70
|
+
*
|
|
71
|
+
* `ms` is forwarded verbatim to `setTimeout`, which means `Infinity`, `NaN`,
|
|
72
|
+
* and negative values are **NOT** safe sentinels for "no deadline":
|
|
73
|
+
*
|
|
74
|
+
* - `withTimeout("r", Infinity, …)` — Node clamps to `1` ms and emits a
|
|
75
|
+
* `TimeoutOverflowWarning`. The race rejects with `LoaderTimeout` after
|
|
76
|
+
* 1 ms, not "never". Use a separate code path (e.g. invoke the loader
|
|
77
|
+
* directly without wrapping) when you genuinely want no deadline.
|
|
78
|
+
* - `withTimeout("r", NaN, …)` — same: Node clamps to `1` ms with a
|
|
79
|
+
* warning.
|
|
80
|
+
* - `withTimeout("r", -1, …)` — Node clamps to `1` ms with a warning.
|
|
81
|
+
* - `withTimeout("r", 0, …)` — fires on the next tick. A synchronous-
|
|
82
|
+
* resolving loader (`() => Promise.resolve(v)`) typically wins the race,
|
|
83
|
+
* but any async I/O loses. Treat `0` as "fire immediately" rather than
|
|
84
|
+
* "no deadline".
|
|
85
|
+
*
|
|
86
|
+
* No runtime guard is added — the clamping is a Node-level concern and
|
|
87
|
+
* adding `if (!Number.isFinite(ms) || ms < 0) throw` would be a breaking
|
|
88
|
+
* change for callers relying on the current clamp semantics.
|
|
89
|
+
*/
|
|
90
|
+
declare function withTimeout<T>(routeName: string, ms: number, loader: (deps: {
|
|
91
|
+
signal: AbortSignal;
|
|
92
|
+
}) => Promise<T>, options?: {
|
|
93
|
+
upstreamSignal?: AbortSignal | null;
|
|
94
|
+
}): Promise<T>;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { LoaderNotFound, LoaderRedirect, LoaderTimeout, withTimeout };
|
|
97
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","names":[],"sources":["../../../../shared/ssr/errors.ts"],"mappings":";;AAkBA;;;;;;;;;;;;;AAYA;;;cAZa,cAAA,SAAuB,KAAA;EAAA,SAIvB,MAAA;EAAA,SACA,MAAA;EAAA,SAJF,IAAA;cAGE,MAAA,UACA,MAAA;AAAA;AAAA,cAOA,cAAA,SAAuB,KAAA;EAAA,SAGb,QAAA;EAAA,SAFZ,IAAA;cAEY,QAAA;AAAA;AAAA,cAMV,aAAA,SAAsB,KAAA;EAAA,SAItB,KAAA;EAAA,SACA,EAAA;EAAA,SAJF,IAAA;cAGE,KAAA,UACA,EAAA;AAAA;;;;;AA6Db;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAgB,WAAA,GAAA,CACd,SAAA,UACA,EAAA,UACA,MAAA,GAAS,IAAA;EAAQ,MAAA,EAAQ,WAAA;AAAA,MAAkB,OAAA,CAAQ,CAAA,GACnD,OAAA;EAAY,cAAA,GAAiB,WAAA;AAAA,IAC5B,OAAA,CAAQ,CAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=class extends Error{code=`LOADER_REDIRECT`;constructor(e,t=302){super(`Redirect to ${e}`),this.target=e,this.status=t,this.name=`LoaderRedirect`}},t=class extends Error{code=`LOADER_NOT_FOUND`;constructor(e){super(`Resource not found: ${e}`),this.resource=e,this.name=`LoaderNotFound`}},n=class extends Error{code=`LOADER_TIMEOUT`;constructor(e,t){super(`Loader for "${e}" exceeded ${t}ms`),this.route=e,this.ms=t,this.name=`LoaderTimeout`}};function r(e,t,r,i){let a=i?.upstreamSignal;if(a?.aborted)return Promise.reject(a.reason??new DOMException(`The operation was aborted.`,`AbortError`));let o=new AbortController,s=a?AbortSignal.any([a,o.signal]):o.signal,c,l=new Promise((r,i)=>{c=setTimeout(()=>{let r=new n(e,t);o.abort(r),i(r)},t)}),u=(async()=>r({signal:s}))().finally(()=>{c!==void 0&&clearTimeout(c)});return Promise.race([u,l])}exports.LoaderNotFound=t,exports.LoaderRedirect=e,exports.LoaderTimeout=n,exports.withTimeout=r;
|
|
2
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../../../shared/ssr/errors.ts"],"sourcesContent":["/**\n * Typed loader errors that SSR pipelines translate into HTTP semantics.\n *\n * The `ssr-data-plugin` and `rsc-server-plugin` are intentionally\n * HTTP-agnostic — they only await the loader and write the resolved value\n * to `state.context.<namespace>`. Loaders bridge to HTTP status codes by\n * throwing one of these named errors; application-layer middleware catches\n * them and maps each `code` to the right status (302/308, 404, 504).\n *\n * Structural discrimination via `code` (not `instanceof`) so consumers\n * can match across realms / bundle boundaries without coupling to the\n * class identity.\n *\n * Re-exported from both plugins under the `./errors` subpath:\n * `@real-router/ssr-data-plugin/errors` and\n * `@real-router/rsc-server-plugin/errors`.\n */\n\nexport class LoaderRedirect extends Error {\n readonly code = \"LOADER_REDIRECT\";\n\n constructor(\n readonly target: string,\n readonly status: 301 | 302 | 307 | 308 = 302,\n ) {\n super(`Redirect to ${target}`);\n this.name = \"LoaderRedirect\";\n }\n}\n\nexport class LoaderNotFound extends Error {\n readonly code = \"LOADER_NOT_FOUND\";\n\n constructor(readonly resource: string) {\n super(`Resource not found: ${resource}`);\n this.name = \"LoaderNotFound\";\n }\n}\n\nexport class LoaderTimeout extends Error {\n readonly code = \"LOADER_TIMEOUT\";\n\n constructor(\n readonly route: string,\n readonly ms: number,\n ) {\n super(`Loader for \"${route}\" exceeded ${ms}ms`);\n this.name = \"LoaderTimeout\";\n }\n}\n\n/**\n * Race a loader against a deadline, with cooperative cancellation.\n *\n * The loader is invoked with `{ signal }` — a composed `AbortSignal` that\n * aborts on the first of:\n * - the deadline elapsing (`internalController.abort()` fires synchronously\n * *before* the race rejects with `LoaderTimeout`, so a loader that\n * threads `signal` into its I/O — e.g. `fetch(url, { signal })` — can\n * actually cancel the underlying work);\n * - `options.upstreamSignal` aborting (typically the request-scoped abort\n * wired by `cloneRouter(base, { abortSignal })` for client-disconnect).\n *\n * Composition uses `AbortSignal.any([upstream, internal])` (Node 20.3+).\n * If `upstreamSignal` is already aborted at call time, the loader is *not*\n * invoked and the timer is *not* started — the rejection mirrors\n * `upstreamSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\")`.\n *\n * On deadline, the same `LoaderTimeout` instance is used as both the\n * `signal.reason` and the rejection reason — they refer to one object.\n * On upstream abort during execution, the race rejects with the loader's\n * own error (typically `AbortError`), *not* `LoaderTimeout`.\n *\n * Cancellation is cooperative: loaders that don't propagate `signal` into\n * their I/O still run to completion in the background — the race result\n * is unaffected, but resources are not freed early.\n *\n * The `setTimeout` handle is cleared via `.finally()` on the work promise\n * so a fast-path success doesn't leak it. `Promise.race`'s internal\n * `Promise.resolve(p).then(resolve, reject)` consumes any late losing\n * rejection — no `unhandledRejection` for late loader settlements.\n *\n * Requires Node 20.3+ for `AbortSignal.any`.\n *\n * ### `ms` corner cases (Node `setTimeout` clamping)\n *\n * `ms` is forwarded verbatim to `setTimeout`, which means `Infinity`, `NaN`,\n * and negative values are **NOT** safe sentinels for \"no deadline\":\n *\n * - `withTimeout(\"r\", Infinity, …)` — Node clamps to `1` ms and emits a\n * `TimeoutOverflowWarning`. The race rejects with `LoaderTimeout` after\n * 1 ms, not \"never\". Use a separate code path (e.g. invoke the loader\n * directly without wrapping) when you genuinely want no deadline.\n * - `withTimeout(\"r\", NaN, …)` — same: Node clamps to `1` ms with a\n * warning.\n * - `withTimeout(\"r\", -1, …)` — Node clamps to `1` ms with a warning.\n * - `withTimeout(\"r\", 0, …)` — fires on the next tick. A synchronous-\n * resolving loader (`() => Promise.resolve(v)`) typically wins the race,\n * but any async I/O loses. Treat `0` as \"fire immediately\" rather than\n * \"no deadline\".\n *\n * No runtime guard is added — the clamping is a Node-level concern and\n * adding `if (!Number.isFinite(ms) || ms < 0) throw` would be a breaking\n * change for callers relying on the current clamp semantics.\n */\nexport function withTimeout<T>(\n routeName: string,\n ms: number,\n loader: (deps: { signal: AbortSignal }) => Promise<T>,\n options?: { upstreamSignal?: AbortSignal | null },\n): Promise<T> {\n const upstream = options?.upstreamSignal;\n\n if (upstream?.aborted) {\n // `signal.reason` is normally set automatically by the spec\n // (`controller.abort()` without an argument yields a `DOMException`),\n // but the field is writable, so we fall back to a fresh `AbortError`\n // if some caller produced an aborted signal with `reason === undefined`.\n return Promise.reject(\n upstream.reason ??\n new DOMException(\"The operation was aborted.\", \"AbortError\"),\n );\n }\n\n const internal = new AbortController();\n const composed = upstream\n ? AbortSignal.any([upstream, internal.signal])\n : internal.signal;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeoutPromise = new Promise<T>((_, reject) => {\n timer = setTimeout(() => {\n const error = new LoaderTimeout(routeName, ms);\n internal.abort(error);\n reject(error);\n }, ms);\n });\n\n const work = (async () => loader({ signal: composed }))().finally(() => {\n if (timer !== undefined) clearTimeout(timer);\n });\n\n return Promise.race<T>([work, timeoutPromise]);\n}\n"],"mappings":"mEAkBA,IAAa,EAAb,cAAoC,KAAM,CACxC,KAAgB,kBAEhB,YACE,EACA,EAAyC,IACzC,CACA,MAAM,eAAe,IAAS,CAHrB,KAAA,OAAA,EACA,KAAA,OAAA,EAGT,KAAK,KAAO,mBAIH,EAAb,cAAoC,KAAM,CACxC,KAAgB,mBAEhB,YAAY,EAA2B,CACrC,MAAM,uBAAuB,IAAW,CADrB,KAAA,SAAA,EAEnB,KAAK,KAAO,mBAIH,EAAb,cAAmC,KAAM,CACvC,KAAgB,iBAEhB,YACE,EACA,EACA,CACA,MAAM,eAAe,EAAM,aAAa,EAAG,IAAI,CAHtC,KAAA,MAAA,EACA,KAAA,GAAA,EAGT,KAAK,KAAO,kBA0DhB,SAAgB,EACd,EACA,EACA,EACA,EACY,CACZ,IAAM,EAAW,GAAS,eAE1B,GAAI,GAAU,QAKZ,OAAO,QAAQ,OACb,EAAS,QACP,IAAI,aAAa,6BAA8B,aAAa,CAC/D,CAGH,IAAM,EAAW,IAAI,gBACf,EAAW,EACb,YAAY,IAAI,CAAC,EAAU,EAAS,OAAO,CAAC,CAC5C,EAAS,OAET,EACE,EAAiB,IAAI,SAAY,EAAG,IAAW,CACnD,EAAQ,eAAiB,CACvB,IAAM,EAAQ,IAAI,EAAc,EAAW,EAAG,CAC9C,EAAS,MAAM,EAAM,CACrB,EAAO,EAAM,EACZ,EAAG,EACN,CAEI,GAAQ,SAAY,EAAO,CAAE,OAAQ,EAAU,CAAC,GAAG,CAAC,YAAc,CAClE,IAAU,IAAA,IAAW,aAAa,EAAM,EAC5C,CAEF,OAAO,QAAQ,KAAQ,CAAC,EAAM,EAAe,CAAC"}
|