@real-router/ssr-data-plugin 0.3.4 → 0.4.1

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 (42) hide show
  1. package/README.md +237 -8
  2. package/dist/cjs/deferRegistry-DiIRW23O.js +2 -0
  3. package/dist/cjs/deferRegistry-DiIRW23O.js.map +1 -0
  4. package/dist/cjs/errors.d.ts +97 -0
  5. package/dist/cjs/errors.d.ts.map +1 -0
  6. package/dist/cjs/errors.js +2 -0
  7. package/dist/cjs/errors.js.map +1 -0
  8. package/dist/cjs/index-CeNUv7rM.d.ts +104 -0
  9. package/dist/cjs/index-CeNUv7rM.d.ts.map +1 -0
  10. package/dist/cjs/index.d.ts +75 -5
  11. package/dist/cjs/index.d.ts.map +1 -1
  12. package/dist/cjs/index.js +1 -1
  13. package/dist/cjs/index.js.map +1 -1
  14. package/dist/cjs/server.d.ts +53 -0
  15. package/dist/cjs/server.d.ts.map +1 -0
  16. package/dist/cjs/server.js +2 -0
  17. package/dist/cjs/server.js.map +1 -0
  18. package/dist/esm/deferRegistry-BV6amRWX.mjs +2 -0
  19. package/dist/esm/deferRegistry-BV6amRWX.mjs.map +1 -0
  20. package/dist/esm/errors.d.mts +97 -0
  21. package/dist/esm/errors.d.mts.map +1 -0
  22. package/dist/esm/errors.mjs +2 -0
  23. package/dist/esm/errors.mjs.map +1 -0
  24. package/dist/esm/index-B2jQWtUu.d.mts +104 -0
  25. package/dist/esm/index-B2jQWtUu.d.mts.map +1 -0
  26. package/dist/esm/index.d.mts +75 -5
  27. package/dist/esm/index.d.mts.map +1 -1
  28. package/dist/esm/index.mjs +1 -1
  29. package/dist/esm/index.mjs.map +1 -1
  30. package/dist/esm/server.d.mts +53 -0
  31. package/dist/esm/server.d.mts.map +1 -0
  32. package/dist/esm/server.mjs +2 -0
  33. package/dist/esm/server.mjs.map +1 -0
  34. package/package.json +30 -2
  35. package/src/errors.ts +6 -0
  36. package/src/factory.ts +7 -2
  37. package/src/getSsrDataMode.ts +29 -0
  38. package/src/index.ts +14 -0
  39. package/src/invalidate.ts +38 -0
  40. package/src/server.ts +319 -0
  41. package/src/types.ts +26 -7
  42. package/src/validation.ts +0 -4
package/README.md CHANGED
@@ -33,13 +33,14 @@ import { createRouter } from "@real-router/core";
33
33
  import { cloneRouter } from "@real-router/core/api";
34
34
  import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";
35
35
  import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";
36
+ import { routes } from "./routes"; // your app's route tree
36
37
 
37
38
  const loaders: DataLoaderFactoryMap = {
38
39
  "users.profile": () => async (params) => fetchUser(params.id),
39
40
  "users.list": () => async () => fetchUsers(),
40
41
  };
41
42
 
42
- // Base router — created once
43
+ // Base router — created once at module load
43
44
  const baseRouter = createRouter(routes, { defaultRoute: "home", allowNotFound: true });
44
45
 
45
46
  // Per-request SSR
@@ -55,19 +56,62 @@ router.dispose();
55
56
 
56
57
  ## Configuration
57
58
 
58
- Loaders are keyed by **route name** (not path). Each value is a **factory function** `(router, getDependency) => loaderFn` that receives the router instance and a dependency getter. The factory runs once at plugin registration; the returned loader is cached. Each loader receives route `params` and returns a `Promise`:
59
+ Entries are keyed by **route name** (not path). Each value is either a **factory function** `(router, getDependency) => loaderFn` (short form) or an object `{ ssr?, loader? }` with optional per-route SSR mode. The factory runs once at plugin registration; the returned loader is cached. Each loader receives route `params` and returns `Promise<unknown> | unknown`:
59
60
 
60
61
  ```typescript
61
62
  import type { DataLoaderFactoryMap } from "@real-router/ssr-data-plugin";
62
63
 
63
64
  const loaders: DataLoaderFactoryMap = {
65
+ // Short form — defaults to ssr: "full"
64
66
  home: () => async () => ({ featured: await fetchFeatured() }),
65
- "users.profile": () => async (params) => ({ user: await fetchUser(params.id) }),
66
- "users.list": () => async () => ({ users: await fetchUsers() }),
67
+
68
+ // Object form opt out of server rendering for this route
69
+ "admin.dashboard": { ssr: false },
70
+
71
+ // Object form — server fetches data, app ships shell + JSON
72
+ "users.profile": {
73
+ ssr: "data-only",
74
+ loader: () => async (params) => ({ user: await fetchUser(params.id) }),
75
+ },
76
+
77
+ // Function-form resolver — mode resolved per-navigation
78
+ "docs.detail": {
79
+ ssr: (state) => state.params.format === "pdf" ? "client-only" : "full",
80
+ loader: () => async (params) => ({ doc: await fetchDoc(params.id) }),
81
+ },
67
82
  };
68
83
  ```
69
84
 
70
- Routes without a matching loader produce no data — `state.context.data` is `undefined`.
85
+ Routes without a matching entry produce no data — `state.context.data` is `undefined` and `getSsrDataMode(state)` falls back to `"full"`.
86
+
87
+ ## Per-route SSR mode
88
+
89
+ Three modes are supported. The plugin publishes the resolved mode to `state.context.ssrDataMode`; read it via `getSsrDataMode(state)`:
90
+
91
+ | `ssr` value | mode marker | loader behaviour |
92
+ | ---------------------------- | ----------------- | ------------------------- |
93
+ | omitted / `true` / `"full"` | `"full"` | runs (composes with #596) |
94
+ | `"data-only"` | `"data-only"` | runs (composes with #596) |
95
+ | `false` / `"client-only"` | `"client-only"` | **skipped** unconditionally |
96
+ | `(state) => SsrMode` | resolver result | resolved per-navigation |
97
+
98
+ `"client-only"` is **symmetric**: the loader is skipped on every `start()` call (server and client). The application reads `getSsrDataMode(state)` and triggers its own client-side fetch (React Query, `useEffect`, Suspense). This keeps the plugin free of environment detection.
99
+
100
+ ```typescript
101
+ import { getSsrDataMode } from "@real-router/ssr-data-plugin";
102
+
103
+ const state = await router.start(url);
104
+ const mode = getSsrDataMode(state); // "full" | "data-only" | "client-only"
105
+
106
+ if (mode === "full") {
107
+ return renderToString(<App router={router} />);
108
+ }
109
+ return `<div data-ssr-mode="${mode}"></div>`;
110
+ ```
111
+
112
+ The function-form resolver receives `state` **before** the mode is written, so it should not read `state.context.ssrDataMode`. Branch on `state.params`, `state.path`, or `state.name`.
113
+
114
+ See [`examples/web/react/ssr-examples/ssr-mixed/`](../../examples/web/react/ssr-examples/ssr-mixed) for a hybrid pipeline that demonstrates all three modes from a single `entry-server.tsx`.
71
115
 
72
116
  ## Accessing Data
73
117
 
@@ -80,7 +124,7 @@ const data = state.context.data; // loaded data, or undefined if no loader match
80
124
 
81
125
  The plugin claims the `"data"` namespace on `state.context` via the [claim-based API](https://github.com/greydragon888/real-router/wiki/plugin-architecture). Module augmentation on `@real-router/types` provides type safety for `state.context.data`.
82
126
 
83
- ## SSR-Only by Design
127
+ ## SSR-Only by Design (with explicit CSR revalidation channel)
84
128
 
85
129
  This plugin intercepts `start()` only — not `navigate()`. In SSR, the flow is:
86
130
 
@@ -88,7 +132,183 @@ This plugin intercepts `start()` only — not `navigate()`. In SSR, the flow is:
88
132
  cloneRouter → usePlugin → start(url) → data loaded → state.context.data → renderToString
89
133
  ```
90
134
 
91
- Client-side navigation and data fetching is the application's responsibility (React Query, Suspense, `useEffect`, etc.).
135
+ Client-side navigation does **not** re-run the loader by default — application-layer fetching (React Query, Suspense, `useEffect`) owns CSR data. The one explicit exception is the `invalidate()` revalidation channel below.
136
+
137
+ ## Client-side revalidation (`invalidate`)
138
+
139
+ After a mutation, mark the `"data"` namespace stale on the router. The next navigation (including a same-route reload) re-runs the loader for the destination route and overwrites `state.context.data` before `TRANSITION_SUCCESS` fires — so subscribers see the fresh payload.
140
+
141
+ ```typescript
142
+ import { invalidate } from "@real-router/ssr-data-plugin";
143
+
144
+ // Fire-and-forget — stale until the user navigates somewhere.
145
+ invalidate(router, "data");
146
+
147
+ // Explicit await — pair with a same-route reload.
148
+ invalidate(router, "data");
149
+ await router.navigate(state.name, state.params, { reload: true });
150
+ ```
151
+
152
+ The flag is **preserved** until a successful, non-cancelled loader write. So a navigation that lands on a route without a loader 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.
153
+
154
+ Idempotent — multiple `invalidate()` calls between refreshes collapse to one re-run. Survives `cloneRouter()` boundaries: each clone has its own flag set. Surgical for multi-namespace routes — only `"data"` re-runs; a side-by-side [`@real-router/rsc-server-plugin`](https://www.npmjs.com/package/@real-router/rsc-server-plugin) keeps its cached `state.context.rsc` unless its own `invalidate()` was also called.
155
+
156
+ ### Cancellation-aware loaders
157
+
158
+ The leave handler passes the navigation's `AbortController.signal` as the second loader argument so loaders can abort their in-flight work (fetch, DB query, …) when a newer navigation supersedes:
159
+
160
+ ```typescript
161
+ "users.profile": () => async (params, ctx) => {
162
+ // Network layer cancels on rapid double-click — second click aborts
163
+ // the first nav's controller, fetch sees `signal.aborted` and rejects.
164
+ const response = await fetch(`/api/user/${params.id}`, {
165
+ signal: ctx?.signal,
166
+ });
167
+
168
+ return response.json();
169
+ },
170
+ ```
171
+
172
+ The start interceptor calls the loader without a context — SSR boot path apps thread a request-scoped signal via `cloneRouter(base, { abortSignal })` + `getDep("abortSignal")` + [`withTimeout({ upstreamSignal })`](https://github.com/greydragon888/real-router/wiki/SSR-Cancellation).
173
+
174
+ **Robust loaders check `signal.aborted` upfront** — a signal aborted before `addEventListener("abort", …)` does NOT auto-fire the listener. Pattern documented in the `home` loader of every `ssr-mixed/` example.
175
+
176
+ Non-breaking via TypeScript contravariance — existing `(params) => …` loaders without the second arg continue to work; they just don't observe cancellation.
177
+
178
+ ## Post-hydration loader skip
179
+
180
+ 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.data` is already present for the same route name — skipping the redundant client-side loader call on first paint.
181
+
182
+ ```typescript
183
+ // Server: state.context.data populated by the loader, serialized into HTML
184
+ const html = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;
185
+
186
+ // Client: hydrateRouter feeds the scratchpad, plugin sees it and skips re-load
187
+ await hydrateRouter(router, window.__SSR_STATE__);
188
+ // loader was NOT called — state.context.data === server's value
189
+ ```
190
+
191
+ The skip is single-shot — only the first `start()` triggered by `hydrateRouter`consumes the scratchpad. Subsequent navigations run the loader normally. Composes with per-route mode: `"client-only"` skips the loader regardless of scratchpad contents (mode wins).
192
+
193
+ **Mode marker is always written.** Even on a scratchpad-hit the plugin still calls `claim.write(state, mode)` for `state.context.ssrDataMode` before the loader-skip branch runs. So a route configured `ssr: "full"` keeps `getSsrDataMode(state) === "full"` on the client after hydration even when the loader was skipped — UI conditionals that branch on `ssrDataMode` don't need to special-case the post-hydration first paint. Symmetric on the rsc-server-plugin side (`state.context.ssrRscMode`).
194
+
195
+ ## Typed Loader Errors (`@real-router/ssr-data-plugin/errors`)
196
+
197
+ The plugin is HTTP-agnostic — it only awaits the loader and writes the result to `state.context.data`. To bridge loader failures to HTTP semantics (404, 30x, 504), import typed error classes from the `errors` subpath and let your handler catch them:
198
+
199
+ ```typescript
200
+ import {
201
+ LoaderNotFound,
202
+ LoaderRedirect,
203
+ LoaderTimeout,
204
+ withTimeout,
205
+ } from "@real-router/ssr-data-plugin/errors";
206
+
207
+ const loaders: DataLoaderFactoryMap = {
208
+ "users.profile": (_router, getDep) => (params) => {
209
+ const upstreamSignal = (
210
+ getDep as unknown as (k: string) => AbortSignal | undefined
211
+ )("abortSignal");
212
+
213
+ return withTimeout(
214
+ "users.profile",
215
+ 250,
216
+ async ({ signal }) => {
217
+ // signal aborts on the 250 ms deadline OR on client disconnect
218
+ // (upstream); fetch propagates the abort to the network layer.
219
+ const user = await fetchUser(params.id, { signal });
220
+ if (!user) throw new LoaderNotFound(`user:${params.id}`);
221
+ return { user };
222
+ },
223
+ { upstreamSignal },
224
+ );
225
+ },
226
+ "users.legacy": () => (params) => {
227
+ throw new LoaderRedirect(`/users/${params.id}`, 301);
228
+ },
229
+ };
230
+
231
+ // In the handler:
232
+ try {
233
+ const state = await router.start(url);
234
+ return renderHtml(state);
235
+ } catch (error) {
236
+ if (error?.code === "LOADER_NOT_FOUND") return res.status(404).send("Not Found");
237
+ if (error?.code === "LOADER_REDIRECT") return res.redirect(error.status, error.target);
238
+ if (error?.code === "LOADER_TIMEOUT") return res.status(504).send("Timeout");
239
+ throw error;
240
+ }
241
+ ```
242
+
243
+ Discriminator is the `code` field — match structurally without `instanceof`. Identical errors are also re-exported from `@real-router/rsc-server-plugin/errors` (same shared source) so RSC apps don't need to add a `ssr-data-plugin` dependency just to throw `LoaderNotFound`.
244
+
245
+ ## Deferred data with `defer()` (#610)
246
+
247
+ Loaders may return a `defer({ critical, deferred })` payload to split the response into a **critical** bundle (resolved before the shell renders) and a **deferred** record of named promises (streamed after via inline `<script>__rrDefer__("key", json)</script>` tags). React 19's `<Suspense>` + `use(promise)` and the cross-framework `<Await>` / `useDeferred(key)` adapters consume the deferred map natively:
248
+
249
+ ```typescript
250
+ import { defer, LoaderNotFound } from "@real-router/ssr-data-plugin";
251
+
252
+ "products.detail": () => (params) => {
253
+ const product = getProduct(params.id);
254
+ if (!product) throw new LoaderNotFound(`product:${params.id}`);
255
+
256
+ return defer({
257
+ critical: { product }, // awaited, lands in state.context.data
258
+ deferred: { // streamed, lands in state.context.ssrDataDeferred
259
+ reviews: fetchReviews(params.id),
260
+ related: fetchRelated(params.id),
261
+ },
262
+ });
263
+ };
264
+ ```
265
+
266
+ The plugin writes:
267
+
268
+ - `state.context.data` — `critical` (existing contract; consumers reading `state.context.data` see no change)
269
+ - `state.context.ssrDataDeferred` — `Record<string, Promise<unknown>>`. Server: real loader-returned promises. Client (post-hydration): registry-backed promises that resolve as inline settle scripts land.
270
+ - `state.context.ssrDataDeferredKeys` — declared key list. Included in the serialized SSR state so the client plugin can reconstruct the deferred map on hydration.
271
+
272
+ **Reserved keys.** `defer()` throws `TypeError(/is reserved/)` for `__proto__`, `constructor`, or `prototype` as deferred-map keys — defence-in-depth against prototype-chain corruption during client-side reconstruction.
273
+
274
+ **Shallow-clone freeze.** `defer()` freezes a shallow clone of the deferred map. The caller's reference stays mutable, but post-call mutations cannot smuggle entries past the reserved-key / thenable validation pass. An eagerly-rejected promise gets a defensive no-op `.catch(() => {})` attached so a synchronous rejection doesn't trip `process.on("unhandledRejection")` before `injectDeferredScripts` attaches its real `.then`.
275
+
276
+ ## Streaming SSR pipeline (`/server` subpath)
277
+
278
+ Pair `defer()`-returning loaders with `injectDeferredScripts` on the server to interleave the React stream with `<script>__rrDefer__(...)</script>` settle tags as each promise resolves:
279
+
280
+ ```typescript
281
+ import { renderToReadableStream } from "react-dom/server";
282
+ import {
283
+ getDeferBootstrapScript,
284
+ injectDeferredScripts,
285
+ } from "@real-router/ssr-data-plugin/server";
286
+
287
+ const reactStream = await renderToReadableStream(<App />);
288
+ const deferred =
289
+ (state.context as { ssrDataDeferred?: Record<string, Promise<unknown>> })
290
+ .ssrDataDeferred ?? {};
291
+
292
+ // Wrap the React stream — settle scripts interleave in resolution order.
293
+ const stream = injectDeferredScripts(reactStream, deferred, {
294
+ bootstrap: false, // emit bootstrap separately for cleaner React hydration
295
+ });
296
+
297
+ // Embed the bootstrap once in <head>:
298
+ const bootstrap = `<script>${getDeferBootstrapScript()}</script>`;
299
+ ```
300
+
301
+ ### `InjectDeferredScriptsOptions`
302
+
303
+ | Option | Type | Default | Purpose |
304
+ | ---------------- | ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
305
+ | `serialize` | `Serializer` = `(v) => string` | `JSON.stringify` | Custom value serializer. Pass `devalue.stringify` / `superjson.stringify` for Date / Map / Set / BigInt payloads. Output must `JSON.parse` cleanly on the client. |
306
+ | `serializeError` | `(error: unknown) => string` | `JSON.stringify({ name, message })` | Custom error serializer. Output lands in `__rrDeferError__("key", json)`. The bootstrap reconstructs an `Error` with `{ name, message }`. |
307
+ | `bootstrap` | `boolean` | `true` | When `true`, prepends `<script>${getDeferBootstrapScript()}</script>` to the stream. Set `false` to embed the bootstrap separately in `<head>` (cleaner React hydration). |
308
+
309
+ `Serializer` is exported from `@real-router/ssr-data-plugin/server` so application code (e.g. wrappers around `devalue` / `superjson`) can type-annotate its custom serializer.
310
+
311
+ See [`examples/web/react/ssr-examples/ssr-streaming/`](../../examples/web/react/ssr-examples/ssr-streaming) for the full pipeline and the [Streaming SSR wiki guide](https://github.com/greydragon888/real-router/wiki/Streaming-SSR) for design background.
92
312
 
93
313
  ## Cleanup
94
314
 
@@ -101,10 +321,19 @@ unsubscribe();
101
321
 
102
322
  In SSR, `router.dispose()` handles cleanup automatically.
103
323
 
324
+ ## Streaming SSR
325
+
326
+ Combine with React 19's `<Suspense>` + `use(promise)` for deferred sections that arrive after the shell. The loader resolves critical data; deferred fetches live inside Suspense components and stream in via `renderToReadableStream`. No router-specific wrapper API needed.
327
+
328
+ See [`examples/web/react/ssr-examples/ssr-streaming/`](../../examples/web/react/ssr-examples/ssr-streaming) for a complete working example, or the [Streaming SSR wiki guide](https://github.com/greydragon888/real-router/wiki/Streaming-SSR) for the design pattern.
329
+
104
330
  ## Documentation
105
331
 
106
332
  - [ARCHITECTURE.md](ARCHITECTURE.md) — Design decisions and data flow
107
- - [SSR Example](../../examples/ssr-react) — Full working example with React + Vite
333
+ - [SSR Example](../../examples/web/react/ssr-examples/ssr) — Full working example (classical, non-streaming)
334
+ - [SSR Mixed-mode Example](../../examples/web/react/ssr-examples/ssr-mixed) — Hybrid pipeline: full SSR + data-only + client-only on the same server, **plus the canonical `mutation → invalidate → reload` dogfooding** (Home page Refresh button) replicated across all six adapters with paired `happy path` + `in-flight defer` e2e scenarios
335
+ - [Streaming SSR Example](../../examples/web/react/ssr-examples/ssr-streaming) — React 19 native streaming with `<Suspense>` + `use(promise)`
336
+ - [Streaming SSR wiki guide](https://github.com/greydragon888/real-router/wiki/Streaming-SSR)
108
337
 
109
338
  ## Related Packages
110
339
 
@@ -0,0 +1,2 @@
1
+ const e=`__rrDeferRegistry__`,t=`__rrDefer__`,n=`__rrDeferError__`;function r(){return globalThis}function i(){let t=r(),n=t[e];return n===void 0&&(n=new Map,t[e]=n),n}function a(e){let t=i(),n=t.get(e);if(n===void 0){let r,i;n={promise:new Promise((e,t)=>{r=e,i=t}),resolve:r,reject:i},t.set(e,n)}return n.promise}function o(){return`(function(g){var R=g.${e};if(!R)R=g.${e}=new Map();function E(k){var e=R.get(k);if(!e){var rs,rj;var p=new Promise(function(r,j){rs=r;rj=j});e={promise:p,resolve:rs,reject:rj};R.set(k,e)}return e}g.${t}=function(k,j){E(k).resolve(JSON.parse(j))};g.${n}=function(k,j){var d=JSON.parse(j);var er=new Error(d&&d.message?d.message:"deferred error");if(d&&d.name)er.name=d.name;E(k).reject(er)}})(typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:self));`}const s=[[`<`,`\\u003c`],[`>`,`\\u003e`],[`&`,`\\u0026`],[String.fromCodePoint(8232),`\\u2028`],[String.fromCodePoint(8233),`\\u2029`]],c=Object.fromEntries(s),l=RegExp(`[${s.map(([e])=>e).join(``)}]`,`g`);function u(e){let t;try{t=JSON.stringify(e)}catch{t=void 0}return typeof t==`string`?t.replace(l,e=>c[e]??e):`null`}function d(e,r,i){return`<script>${i?n:t}(${u(e)},${u(r)})<\/script>`}Object.defineProperty(exports,`n`,{enumerable:!0,get:function(){return d}}),Object.defineProperty(exports,`r`,{enumerable:!0,get:function(){return o}}),Object.defineProperty(exports,`t`,{enumerable:!0,get:function(){return a}});
2
+ //# sourceMappingURL=deferRegistry-DiIRW23O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deferRegistry-DiIRW23O.js","names":[],"sources":["../../../../shared/ssr/deferRegistry.ts"],"sourcesContent":["/**\n * Client-side registry for deferred values streamed from the server.\n *\n * The contract spans three actors:\n *\n * 1. **Server stream injects `<script>__rrDefer__(\"key\", \"json\")</script>`\n * tags** as each loader-returned promise resolves. The bootstrap script\n * (also server-emitted) installs `__rrDefer__` and the registry on\n * `globalThis` before any settle script runs.\n *\n * 2. **Plugin start interceptor** (post-hydration scratchpad path) reads the\n * `<deferredKeysNamespace>` list from the hydrated state, then calls\n * `ensureRegistryPromise(key)` once per key to obtain the promise that\n * `useDeferred()` will return. This ensures a stable Promise reference\n * across the initial render and any inline-script settlements.\n *\n * 3. **Adapter `useDeferred(key)`** reads from `state.context.<deferredNamespace>`\n * which the plugin populated above. The returned Promise integrates with\n * React `use()`, Solid `<Await/>`, Svelte `{#await}`, etc.\n */\n\ninterface RegistryEntry {\n promise: Promise<unknown>;\n resolve: (value: unknown) => void;\n reject: (error: unknown) => void;\n}\n\nconst REGISTRY_GLOBAL_KEY = \"__rrDeferRegistry__\";\nconst SETTLE_FN_NAME = \"__rrDefer__\";\nconst REJECT_FN_NAME = \"__rrDeferError__\";\n\ninterface DeferGlobal {\n [REGISTRY_GLOBAL_KEY]?: Map<string, RegistryEntry>;\n [SETTLE_FN_NAME]?: (key: string, json: string) => void;\n [REJECT_FN_NAME]?: (key: string, json: string) => void;\n}\n\nfunction getGlobal(): DeferGlobal {\n return globalThis as unknown as DeferGlobal;\n}\n\nfunction getOrCreateRegistry(): Map<string, RegistryEntry> {\n const g = getGlobal();\n let registry = g[REGISTRY_GLOBAL_KEY];\n\n if (registry === undefined) {\n registry = new Map<string, RegistryEntry>();\n g[REGISTRY_GLOBAL_KEY] = registry;\n }\n\n return registry;\n}\n\n/**\n * Returns the registered Promise for `key`, creating a fresh pending entry on\n * first access. Stable across calls — `useDeferred` relies on Promise\n * reference identity for React `use()` to track resolution.\n */\nexport function ensureRegistryPromise(key: string): Promise<unknown> {\n const registry = getOrCreateRegistry();\n let entry = registry.get(key);\n\n if (entry === undefined) {\n let resolve!: (value: unknown) => void;\n let reject!: (error: unknown) => void;\n\n const promise = new Promise<unknown>((res, rej) => {\n resolve = res;\n reject = rej;\n });\n\n entry = { promise, resolve, reject };\n registry.set(key, entry);\n }\n\n return entry.promise;\n}\n\n/**\n * Returns the inline bootstrap script (no `<script>` wrapper). Embed in a\n * `<script>` tag emitted **once before any `__rrDefer__()` call lands** in\n * the response stream. Idempotent — re-installing is a no-op.\n *\n * The script source is kept terse (ES5-ish, no template literals, no\n * arrow functions) so it works without transpilation in legacy browsers and\n * stays under ~600 bytes uncompressed.\n */\nexport function getDeferBootstrapScript(): string {\n // The script idempotently installs __rrDefer__/__rrDeferError__ on `g`. If\n // the registry already exists (e.g. from a prior call to\n // ensureRegistryPromise on the client adapter), reuse it — only the settle\n // functions are (re)assigned. This handles the realistic ordering:\n // adapter creates the registry during hydration; the first settle script\n // arriving in the response stream installs the global functions.\n return (\n \"(function(g){\" +\n `var R=g.${REGISTRY_GLOBAL_KEY};` +\n `if(!R)R=g.${REGISTRY_GLOBAL_KEY}=new Map();` +\n \"function E(k){\" +\n \"var e=R.get(k);\" +\n \"if(!e){\" +\n \"var rs,rj;\" +\n \"var p=new Promise(function(r,j){rs=r;rj=j});\" +\n \"e={promise:p,resolve:rs,reject:rj};\" +\n \"R.set(k,e)\" +\n \"}\" +\n \"return e\" +\n \"}\" +\n `g.${SETTLE_FN_NAME}=function(k,j){E(k).resolve(JSON.parse(j))};` +\n `g.${REJECT_FN_NAME}=function(k,j){` +\n \"var d=JSON.parse(j);\" +\n 'var er=new Error(d&&d.message?d.message:\"deferred error\");' +\n \"if(d&&d.name)er.name=d.name;\" +\n \"E(k).reject(er)\" +\n \"}\" +\n \"})(typeof globalThis!=='undefined'?globalThis:\" +\n \"(typeof window!=='undefined'?window:self));\"\n );\n}\n\n// Single-pass replacement table for the chars escapeForScript must encode\n// as `\\uXXXX` to keep them out of the raw HTML parser. Five consecutive\n// `replace` / `split`+`join` passes used to walk the string for each\n// codepoint; the regex + lookup form does it in one pass — ~1.6× faster\n// on large payloads, indistinguishable on short keys (the common case).\n//\n// Roundtrip + HTML-safety properties are pinned by the\n// `escapeForScript: pure-function security invariants` PBT block in\n// `tests/property/ssr-data.properties.ts` (numRuns: 1000).\n//\n// Built at module init via `String.fromCodePoint(...)` so the source file\n// itself never contains raw U+2028 / U+2029 codepoints (which would\n// terminate string literals / regex literals at parse time on legacy\n// JS engines and even in modern TS parsers under some configs).\nconst ESCAPE_FOR_SCRIPT_PAIRS: readonly (readonly [string, string])[] = [\n [\"<\", \"\\\\u003c\"],\n [\">\", \"\\\\u003e\"],\n [\"&\", \"\\\\u0026\"],\n [String.fromCodePoint(0x20_28), \"\\\\u2028\"],\n [String.fromCodePoint(0x20_29), \"\\\\u2029\"],\n] as const;\nconst ESCAPE_FOR_SCRIPT_TABLE: Record<string, string> = Object.fromEntries(\n ESCAPE_FOR_SCRIPT_PAIRS,\n);\nconst ESCAPE_FOR_SCRIPT_REGEX = new RegExp(\n `[${ESCAPE_FOR_SCRIPT_PAIRS.map(([c]) => c).join(\"\")}]`,\n \"g\",\n);\n\n/**\n * Encode an arbitrary string as a **JS string literal** that is also safe to\n * embed inside a `<script>...</script>` body. Returns the literal **with**\n * surrounding quotes — drop it directly into a script template.\n *\n * Encoding via Unicode escapes (`\\uXXXX`) means:\n * - The raw HTML parser sees no `<`, `>`, U+2028, or U+2029 — so it cannot\n * terminate the script tag prematurely (`</script>`, `<!--`) or trigger\n * legacy JS line-terminator interpretation.\n * - The JS parser interprets `<`/`>`/`
`/`
` back to\n * their original chars, so the runtime string value is bit-identical to\n * the input.\n * - Crucially, the same encoding works for two consumer paths:\n * 1. **Plain JS literal** (e.g. the deferred KEY): the JS parser hands\n * back the original string directly.\n * 2. **JS literal containing JSON** (e.g. the deferred VALUE): the JS\n * parser hands back a string with `<` text inside (the leading\n * `\\\\` of `\\\\u003c` escaped to `\\`, then `u003c` is plain text), and\n * `JSON.parse` then unescapes `<` → `<`. Net round-trip is\n * identity.\n * Both decode paths land on the original string — so the same\n * `escapeForScript` works for both keys (parsed as JS literal) and values\n * (parsed as JS literal containing JSON).\n *\n * The `&` → `&` substitution defends against `<![CDATA[` / template\n * engine post-processing that might re-interpret HTML entities; it is not\n * strictly necessary for `<script>` body parsing but cheap and conservative.\n */\nexport function escapeForScript(value: string): string {\n // The TS contract is `value: string`, but a cast at a callsite or a\n // misbehaving custom serializer can still smuggle a non-string through.\n // Three failure modes JSON.stringify can have on non-strings:\n // - returns `undefined` (`stringify(undefined)`, `stringify(symbol)`,\n // `stringify(function)`),\n // - throws (`stringify(bigint)` → `TypeError`,\n // `stringify(circular)` → `TypeError`),\n // - returns `\"null\"` (already safe for our pipeline).\n // Catch both and emit the JSON `null` literal — the safest single-token\n // representation that JSON.parse will accept downstream.\n let json: string | undefined;\n\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n\n if (typeof json !== \"string\") {\n return \"null\";\n }\n\n return json.replace(\n ESCAPE_FOR_SCRIPT_REGEX,\n (c) => ESCAPE_FOR_SCRIPT_TABLE[c] ?? c,\n );\n}\n\n/**\n * Format a single settle script for one resolved promise.\n * Output: `<script>__rrDefer__(\"key\",\"jsonString\")</script>`. Both `key`\n * and `serializedValue` are user-controlled in the general case (route\n * params can flow into deferred-map keys; loader returns flow into values),\n * so both go through {@link escapeForScript}.\n */\nexport function formatSettleScript(\n key: string,\n serializedValue: string,\n isError: boolean,\n): string {\n const fn = isError ? REJECT_FN_NAME : SETTLE_FN_NAME;\n const safeKey = escapeForScript(key);\n const safeValue = escapeForScript(serializedValue);\n\n return `<script>${fn}(${safeKey},${safeValue})</script>`;\n}\n\n/** Test-only — clears the global registry. Not exported from index.ts. */\nexport function __resetRegistryForTests(): void {\n const g = getGlobal();\n delete g[REGISTRY_GLOBAL_KEY];\n delete g[SETTLE_FN_NAME];\n delete g[REJECT_FN_NAME];\n}\n"],"mappings":"AA2BA,MAAM,EAAsB,sBACtB,EAAiB,cACjB,EAAiB,mBAQvB,SAAS,GAAyB,CAChC,OAAO,UACT,CAEA,SAAS,GAAkD,CACzD,IAAM,EAAI,EAAU,EAChB,EAAW,EAAE,GAOjB,OALI,IAAa,IAAA,KACf,EAAW,IAAI,IACf,EAAE,GAAuB,GAGpB,CACT,CAOA,SAAgB,EAAsB,EAA+B,CACnE,IAAM,EAAW,EAAoB,EACjC,EAAQ,EAAS,IAAI,CAAG,EAE5B,GAAI,IAAU,IAAA,GAAW,CACvB,IAAI,EACA,EAOJ,EAAQ,CAAE,QAAA,IALU,SAAkB,EAAK,IAAQ,CACjD,EAAU,EACV,EAAS,CACX,CAEgB,EAAG,UAAS,QAAO,EACnC,EAAS,IAAI,EAAK,CAAK,CACzB,CAEA,OAAO,EAAM,OACf,CAWA,SAAgB,GAAkC,CAOhD,MACE,wBACW,EAAoB,aAClB,EAAoB,gKAW5B,EAAe,gDACf,EAAe,mOASxB,CAgBA,MAAM,EAAkE,CACtE,CAAC,IAAK,SAAS,EACf,CAAC,IAAK,SAAS,EACf,CAAC,IAAK,SAAS,EACf,CAAC,OAAO,cAAc,IAAO,EAAG,SAAS,EACzC,CAAC,OAAO,cAAc,IAAO,EAAG,SAAS,CAC3C,EACM,EAAkD,OAAO,YAC7D,CACF,EACM,EAA8B,OAClC,IAAI,EAAwB,KAAK,CAAC,KAAO,CAAC,EAAE,KAAK,EAAE,EAAE,GACrD,GACF,EAgCA,SAAgB,EAAgB,EAAuB,CAWrD,IAAI,EAEJ,GAAI,CACF,EAAO,KAAK,UAAU,CAAK,CAC7B,MAAQ,CACN,EAAO,IAAA,EACT,CAMA,OAJI,OAAO,GAAS,SAIb,EAAK,QACV,EACC,GAAM,EAAwB,IAAM,CACvC,EANS,MAOX,CASA,SAAgB,EACd,EACA,EACA,EACQ,CAKR,MAAO,WAJI,EAAU,EAAiB,EAIjB,GAHL,EAAgB,CAGF,EAAE,GAFd,EAAgB,CAES,EAAE,YAC/C"}
@@ -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;;;;;;;;;;;;AAKgD;AAOhD;;;cAZa,cAAA,SAAuB,KAAK;EAAA,SAI5B,MAAA;EAAA,SACA,MAAA;EAAA,SAJF,IAAA;cAGE,MAAA,UACA,MAAA;AAAA;AAAA,cAOA,cAAA,SAAuB,KAAK;EAAA,SAGlB,QAAA;EAAA,SAFZ,IAAA;cAEY,QAAA;AAAA;AAAA,cAMV,aAAA,SAAsB,KAAK;EAAA,SAI3B,KAAA;EAAA,SACA,EAAA;EAAA,SAJF,IAAA;cAGE,KAAA,UACA,EAAA;AAAA;;;;AAAU;AA6DvB;;;;;;;;;;;;;;;;;;;;;;;;;;AAKY;;;;;;;;;;;;;;;;;;;;;;;;iBALI,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{target;status;code=`LOADER_REDIRECT`;constructor(e,t=302){super(`Redirect to ${e}`),this.target=e,this.status=t,this.name=`LoaderRedirect`}},t=class extends Error{resource;code=`LOADER_NOT_FOUND`;constructor(e){super(`Resource not found: ${e}`),this.resource=e,this.name=`LoaderNotFound`}},n=class extends Error{route;ms;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,CAI7B,OACA,OAJX,KAAgB,kBAEhB,YACE,EACA,EAAyC,IACzC,CACA,MAAM,eAAe,GAAQ,EAHpB,KAAA,OAAA,EACA,KAAA,OAAA,EAGT,KAAK,KAAO,gBACd,CACF,EAEa,EAAb,cAAoC,KAAM,CAGnB,SAFrB,KAAgB,mBAEhB,YAAY,EAA2B,CACrC,MAAM,uBAAuB,GAAU,EADpB,KAAA,SAAA,EAEnB,KAAK,KAAO,gBACd,CACF,EAEa,EAAb,cAAmC,KAAM,CAI5B,MACA,GAJX,KAAgB,iBAEhB,YACE,EACA,EACA,CACA,MAAM,eAAe,EAAM,aAAa,EAAG,GAAG,EAHrC,KAAA,MAAA,EACA,KAAA,GAAA,EAGT,KAAK,KAAO,eACd,CACF,EAwDA,SAAgB,EACd,EACA,EACA,EACA,EACY,CACZ,IAAM,EAAW,GAAS,eAE1B,GAAI,GAAU,QAKZ,OAAO,QAAQ,OACb,EAAS,QACP,IAAI,aAAa,6BAA8B,YAAY,CAC/D,EAGF,IAAM,EAAW,IAAI,gBACf,EAAW,EACb,YAAY,IAAI,CAAC,EAAU,EAAS,MAAM,CAAC,EAC3C,EAAS,OAET,EACE,EAAiB,IAAI,SAAY,EAAG,IAAW,CACnD,EAAQ,eAAiB,CACvB,IAAM,EAAQ,IAAI,EAAc,EAAW,CAAE,EAC7C,EAAS,MAAM,CAAK,EACpB,EAAO,CAAK,CACd,EAAG,CAAE,CACP,CAAC,EAEK,GAAQ,SAAY,EAAO,CAAE,OAAQ,CAAS,CAAC,GAAG,EAAE,YAAc,CAClE,IAAU,IAAA,IAAW,aAAa,CAAK,CAC7C,CAAC,EAED,OAAO,QAAQ,KAAQ,CAAC,EAAM,CAAc,CAAC,CAC/C"}
@@ -0,0 +1,104 @@
1
+ import { DefaultDependencies, Params, PluginFactory, Router, State } from "@real-router/types";
2
+
3
+ //#region ../../shared/ssr/types.d.ts
4
+ type SsrMode = "full" | "data-only" | "client-only";
5
+ /**
6
+ * Resolves the SSR mode for a route per-navigation.
7
+ *
8
+ * Receives the resolved post-routing `state` (with `name`, `params`, `path`)
9
+ * and returns one of the allowed `SsrMode` values for the host plugin.
10
+ *
11
+ * The resolver is invoked **before** the plugin writes the mode marker to
12
+ * `state.context.<modeNamespace>`, so reading `state.context.ssrDataMode` /
13
+ * `state.context.ssrRscMode` here yields `undefined`. Branch on
14
+ * `state.params`, `state.path`, or `state.name` instead.
15
+ *
16
+ * Throwing from the resolver propagates through `start()` (standard
17
+ * navigation error pipeline) — no partial mode write occurs. Returning a
18
+ * value outside the host plugin's `allowedModes` rejects with a typed
19
+ * `TypeError` at runtime.
20
+ */
21
+ type SsrModeResolver<M extends SsrMode = SsrMode> = (state: State) => M;
22
+ type SsrModeConfig<M extends SsrMode = SsrMode> = M | boolean | SsrModeResolver<M>;
23
+ /**
24
+ * Optional context object passed to the loader. The `signal` field is the
25
+ * navigation's `AbortController.signal` when the plugin's `subscribeLeave`
26
+ * handler invokes the loader (#605 `invalidate()` → CSR refresh path);
27
+ * `undefined` from the `start` interceptor (SSR boot path — apps that need
28
+ * a request-scoped signal use `getDep("abortSignal")` injected via
29
+ * `cloneRouter(base, { abortSignal })`, see `createRequestScope` and
30
+ * `withTimeout({ upstreamSignal })` patterns).
31
+ *
32
+ * Loaders ignoring the second argument remain compatible (TypeScript
33
+ * contravariance).
34
+ */
35
+ interface SsrLoaderContext {
36
+ signal: AbortSignal;
37
+ }
38
+ type SsrLoaderFn<T> = (params: Params, context?: SsrLoaderContext) => Promise<T> | T;
39
+ type SsrLoaderFnFactory<T, Dependencies extends DefaultDependencies = DefaultDependencies> = (router: Router<Dependencies>, getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K]) => SsrLoaderFn<T>;
40
+ interface SsrRouteEntryObject<T, M extends SsrMode = SsrMode, Dependencies extends DefaultDependencies = DefaultDependencies> {
41
+ ssr?: SsrModeConfig<M>;
42
+ loader?: SsrLoaderFnFactory<T, Dependencies>;
43
+ }
44
+ type SsrRouteEntry<T, M extends SsrMode = SsrMode, Dependencies extends DefaultDependencies = DefaultDependencies> = SsrLoaderFnFactory<T, Dependencies> | SsrRouteEntryObject<T, M, Dependencies>;
45
+ //#endregion
46
+ //#region ../../shared/ssr/defer.d.ts
47
+ /**
48
+ * Marker symbol for `defer()` payloads. `Symbol.for` is used so the brand
49
+ * survives across multiple module instances (a real concern in monorepo setups
50
+ * with multiple `node_modules/@real-router/ssr-data-plugin` copies).
51
+ */
52
+ declare const DEFER_BRAND: unique symbol;
53
+ interface DeferredPayload<C, D extends Record<string, Promise<unknown>>> {
54
+ readonly critical: C;
55
+ readonly deferred: D;
56
+ readonly [DEFER_BRAND]: true;
57
+ }
58
+ /**
59
+ * Wraps a loader return value to declare a critical/deferred split.
60
+ *
61
+ * - `critical` resolves before HTML render (blocks the shell).
62
+ * - `deferred` is a record of named promises that the framework can stream
63
+ * independently — `<Suspense>`, `<Await/>`, `{#await}`, etc.
64
+ *
65
+ * The plugin writes `critical` to `state.context.<namespace>` (e.g. `data`)
66
+ * and the deferred promises to `state.context.<namespace>Deferred` (e.g.
67
+ * `ssrDataDeferred`). Adapter-side `useDeferred(key)` reads from the same
68
+ * shape and returns the matching promise for native framework awaiting.
69
+ *
70
+ * On the server: `state.context.ssrDataDeferred[key]` is the actual promise
71
+ * the loader produced. On the client (post-hydration): the plugin reconstructs
72
+ * promises from the global `__rrDeferRegistry__` that inline `__rrDefer__()`
73
+ * scripts populate as the server stream lands.
74
+ */
75
+ declare function defer<const C, const D extends Record<string, Promise<unknown>>>(options: {
76
+ readonly critical: C;
77
+ readonly deferred: D;
78
+ }): DeferredPayload<C, D>;
79
+ /** Type guard — `true` iff `value` is a payload returned by `defer()`.
80
+ *
81
+ * The brand check uses `Object.hasOwn(value, DEFER_BRAND)` rather than a
82
+ * plain property read so a prototype-chain inheritance bypass —
83
+ * `Object.create({ [DEFER_BRAND]: true })` — does not falsely tag an
84
+ * object as a deferred payload. The brand symbol is a `Symbol.for(...)`,
85
+ * so a brand-marked object inherited by accident from a foreign realm
86
+ * could otherwise sneak past `defer()`'s validation and land in
87
+ * `processLoaderResult`'s slow path with no `critical`/`deferred` fields.
88
+ */
89
+ declare function isDeferred(value: unknown): value is DeferredPayload<unknown, Record<string, Promise<unknown>>>;
90
+ //#endregion
91
+ //#region ../../shared/ssr/deferRegistry.d.ts
92
+ /**
93
+ * Returns the inline bootstrap script (no `<script>` wrapper). Embed in a
94
+ * `<script>` tag emitted **once before any `__rrDefer__()` call lands** in
95
+ * the response stream. Idempotent — re-installing is a no-op.
96
+ *
97
+ * The script source is kept terse (ES5-ish, no template literals, no
98
+ * arrow functions) so it works without transpilation in legacy browsers and
99
+ * stays under ~600 bytes uncompressed.
100
+ */
101
+ declare function getDeferBootstrapScript(): string;
102
+ //#endregion
103
+ export { SsrLoaderContext as a, SsrMode as c, isDeferred as i, SsrRouteEntry as l, DeferredPayload as n, SsrLoaderFn as o, defer as r, SsrLoaderFnFactory as s, getDeferBootstrapScript as t };
104
+ //# sourceMappingURL=index-CeNUv7rM.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-CeNUv7rM.d.ts","names":[],"sources":["../../../../shared/ssr/types.ts","../../../../shared/ssr/defer.ts","../../../../shared/ssr/deferRegistry.ts"],"mappings":";;;KAOY,OAAA;AAAZ;;;;AAAmB;AAwBnB;;;;;;;;;;;AAxBA,KAwBY,eAAA,WAA0B,OAAA,GAAU,OAAA,KAAY,KAAA,EAAO,KAAA,KAAU,CAAA;AAAA,KAEjE,aAAA,WAAwB,OAAA,GAAU,OAAA,IAC1C,CAAA,aAEA,eAAA,CAAgB,CAAA;;;;AAL0D;AAE9E;;;;;;;;UAiBiB,gBAAA;EACf,MAAA,EAAQ,WAAW;AAAA;AAAA,KAGT,WAAA,OACV,MAAA,EAAQ,MAAA,EACR,OAAA,GAAU,gBAAA,KACP,OAAA,CAAQ,CAAA,IAAK,CAAA;AAAA,KAEN,kBAAA,yBAEW,mBAAA,GAAsB,mBAAA,KAE3C,MAAA,EAAQ,MAAA,CAAO,YAAA,GACf,aAAA,mBAAgC,YAAA,EAAc,GAAA,EAAK,CAAA,KAAM,YAAA,CAAa,CAAA,MACnE,WAAA,CAAY,CAAA;AAAA,UAEA,mBAAA,cAEL,OAAA,GAAU,OAAA,uBACC,mBAAA,GAAsB,mBAAA;EAE3C,GAAA,GAAM,aAAA,CAAc,CAAA;EACpB,MAAA,GAAS,kBAAA,CAAmB,CAAA,EAAG,YAAA;AAAA;AAAA,KAGrB,aAAA,cAEA,OAAA,GAAU,OAAA,uBACC,mBAAA,GAAsB,mBAAA,IAEzC,kBAAA,CAAmB,CAAA,EAAG,YAAA,IACtB,mBAAA,CAAoB,CAAA,EAAG,CAAA,EAAG,YAAA;;;;;;AA3E9B;;cCFa,WAAA;AAAA,UAII,eAAA,cAEL,MAAA,SAAe,OAAA;EAAA,SAEhB,QAAA,EAAU,CAAA;EAAA,SACV,QAAA,EAAU,CAAA;EAAA,UACT,WAAA;AAAA;;;;;;;;;;;;;;ADgBkE;AAE9E;;;iBCEgB,KAAA,0BAEE,MAAA,SAAe,OAAA,WAAA,CAC/B,OAAA;EAAA,SAAoB,QAAA,EAAU,CAAA;EAAA,SAAY,QAAA,EAAU,CAAA;AAAA,IAAM,eAAA,CAC1D,CAAA,EACA,CAAA;;;;;;;;;;;iBA2Fc,UAAA,CACd,KAAA,YACC,KAAA,IAAS,eAAA,UAAyB,MAAA,SAAe,OAAA;;;;;;;;;;;;iBC9CpC,uBAAA,CAAA"}