@real-router/ssr-data-plugin 0.3.3 → 0.4.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 +243 -13
- package/dist/cjs/deferRegistry-DiIRW23O.js +2 -0
- package/dist/cjs/deferRegistry-DiIRW23O.js.map +1 -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-CeNUv7rM.d.ts +104 -0
- package/dist/cjs/index-CeNUv7rM.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +76 -7
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/server.d.ts +53 -0
- package/dist/cjs/server.d.ts.map +1 -0
- package/dist/cjs/server.js +2 -0
- package/dist/cjs/server.js.map +1 -0
- package/dist/esm/deferRegistry-BV6amRWX.mjs +2 -0
- package/dist/esm/deferRegistry-BV6amRWX.mjs.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-B2jQWtUu.d.mts +104 -0
- package/dist/esm/index-B2jQWtUu.d.mts.map +1 -0
- package/dist/esm/index.d.mts +76 -7
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/server.d.mts +53 -0
- package/dist/esm/server.d.mts.map +1 -0
- package/dist/esm/server.mjs +2 -0
- package/dist/esm/server.mjs.map +1 -0
- package/package.json +30 -2
- package/src/errors.ts +6 -0
- package/src/factory.ts +16 -53
- package/src/getSsrDataMode.ts +29 -0
- package/src/index.ts +14 -0
- package/src/invalidate.ts +38 -0
- package/src/server.ts +319 -0
- package/src/types.ts +26 -7
- package/src/validation.ts +0 -25
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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,18 +321,28 @@ 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-
|
|
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
|
|
|
111
|
-
| Package
|
|
112
|
-
|
|
|
113
|
-
| [@real-router/core](https://www.npmjs.com/package/@real-router/core)
|
|
114
|
-
| [@real-router/
|
|
115
|
-
| [@real-router/
|
|
340
|
+
| Package | Description |
|
|
341
|
+
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
342
|
+
| [@real-router/core](https://www.npmjs.com/package/@real-router/core) | Core router (required peer dependency) |
|
|
343
|
+
| [@real-router/rsc-server-plugin](https://www.npmjs.com/package/@real-router/rsc-server-plugin) | Sibling plugin — same `start()` interceptor pattern but for `ReactNode` (RSC payload). Runs side-by-side on the same router with distinct namespaces (`data` vs `rsc`). |
|
|
344
|
+
| [@real-router/browser-plugin](https://www.npmjs.com/package/@real-router/browser-plugin) | Browser History API integration |
|
|
345
|
+
| [@real-router/react](https://www.npmjs.com/package/@real-router/react) | React bindings |
|
|
116
346
|
|
|
117
347
|
## License
|
|
118
348
|
|
|
@@ -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,WAGT,SAAS,GAAkD,CACzD,IAAM,EAAI,GAAW,CACjB,EAAW,EAAE,GAOjB,OALI,IAAa,IAAA,KACf,EAAW,IAAI,IACf,EAAE,GAAuB,GAGpB,EAQT,SAAgB,EAAsB,EAA+B,CACnE,IAAM,EAAW,GAAqB,CAClC,EAAQ,EAAS,IAAI,EAAI,CAE7B,GAAI,IAAU,IAAA,GAAW,CACvB,IAAI,EACA,EAOJ,EAAQ,CAAE,QAAA,IALU,SAAkB,EAAK,IAAQ,CACjD,EAAU,EACV,EAAS,GAGM,CAAE,UAAS,SAAQ,CACpC,EAAS,IAAI,EAAK,EAAM,CAG1B,OAAO,EAAM,QAYf,SAAgB,GAAkC,CAOhD,MACE,wBACW,EAAoB,aAClB,EAAoB,gKAW5B,EAAe,gDACf,EAAe,oOAyBxB,MAAM,EAAkE,CACtE,CAAC,IAAK,UAAU,CAChB,CAAC,IAAK,UAAU,CAChB,CAAC,IAAK,UAAU,CAChB,CAAC,OAAO,cAAc,KAAQ,CAAE,UAAU,CAC1C,CAAC,OAAO,cAAc,KAAQ,CAAE,UAAU,CAC3C,CACK,EAAkD,OAAO,YAC7D,EACD,CACK,EAA8B,OAClC,IAAI,EAAwB,KAAK,CAAC,KAAO,EAAE,CAAC,KAAK,GAAG,CAAC,GACrD,IACD,CAgCD,SAAgB,EAAgB,EAAuB,CAWrD,IAAI,EAEJ,GAAI,CACF,EAAO,KAAK,UAAU,EAAM,MACtB,CACN,EAAO,IAAA,GAOT,OAJI,OAAO,GAAS,SAIb,EAAK,QACV,EACC,GAAM,EAAwB,IAAM,EACtC,CANQ,OAgBX,SAAgB,EACd,EACA,EACA,EACQ,CAKR,MAAO,WAJI,EAAU,EAAiB,EAIjB,GAHL,EAAgB,EAGD,CAAC,GAFd,EAAgB,EAEU,CAAC"}
|
|
@@ -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"}
|
|
@@ -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;;;;;AAwBA;;;;;;;;;;;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;;;;;AAHpB;;;;;;;;UAiBiB,gBAAA;EACf,MAAA,EAAQ,WAAA;AAAA;AAAA,KAGE,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;;;;;;;;;;;;;;;ADkBZ;;;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"}
|