@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 ADDED
@@ -0,0 +1,338 @@
1
+ # @real-router/rsc-server-plugin
2
+
3
+ [![npm](https://img.shields.io/npm/v/@real-router/rsc-server-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/rsc-server-plugin)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@real-router/rsc-server-plugin.svg?style=flat-square)](https://www.npmjs.com/package/@real-router/rsc-server-plugin)
5
+ [![bundle size](https://deno.bundlejs.com/?q=@real-router/rsc-server-plugin&treeshake=[*]&badge=detailed)](https://bundlejs.com/?q=@real-router/rsc-server-plugin&treeshake=[*])
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](../../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"}