@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.
- package/README.md +237 -8
- 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 +75 -5
- 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 +75 -5
- 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 +7 -2
- 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 -4
package/src/server.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { formatSettleScript, getDeferBootstrapScript } from "./shared-ssr";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serialiser for deferred values. Output is JSON the client `JSON.parse`s.
|
|
5
|
+
*
|
|
6
|
+
* The `string | undefined` return matches `JSON.stringify`'s actual runtime
|
|
7
|
+
* behaviour — `JSON.stringify(undefined)`, `JSON.stringify(() => 1)`,
|
|
8
|
+
* `JSON.stringify(Symbol())` all return `undefined`. The wire-format
|
|
9
|
+
* normalises that to `"null"` so consumers always receive valid JSON.
|
|
10
|
+
*/
|
|
11
|
+
export type Serializer = (value: unknown) => string | undefined;
|
|
12
|
+
|
|
13
|
+
export interface InjectDeferredScriptsOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Serializer for deferred values. Default `JSON.stringify`. Pass
|
|
16
|
+
* `devalue.stringify` / `superjson.stringify` if your deferred payload
|
|
17
|
+
* contains Date / Map / Set / RegExp / BigInt — output must still be a
|
|
18
|
+
* JSON string the client `JSON.parse`s.
|
|
19
|
+
*/
|
|
20
|
+
serialize?: Serializer;
|
|
21
|
+
/**
|
|
22
|
+
* Serializer for the rejected-promise payload. Default `JSON.stringify` of
|
|
23
|
+
* `{ name, message }`. Override to surface custom error fields to the
|
|
24
|
+
* client.
|
|
25
|
+
*/
|
|
26
|
+
serializeError?: (error: unknown) => string;
|
|
27
|
+
/**
|
|
28
|
+
* If `false`, do not emit the bootstrap `<script>`. Use when you embed the
|
|
29
|
+
* bootstrap statically in `index.html` (one fewer per-request byte cost).
|
|
30
|
+
* Default `true` — bootstrap lands once at the start of the deferred
|
|
31
|
+
* stream when at least one promise is queued.
|
|
32
|
+
*/
|
|
33
|
+
bootstrap?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_ERROR_SERIALIZER = (error: unknown): string => {
|
|
37
|
+
if (error instanceof Error) {
|
|
38
|
+
return JSON.stringify({ name: error.name, message: error.message });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return JSON.stringify({ message: String(error) });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Panic fallback used when both `serialize` and `serializeError` throw.
|
|
45
|
+
// Without this, `<Await>` consumers stay suspended forever — better to
|
|
46
|
+
// surface a generic rejection than to hang the boundary indefinitely.
|
|
47
|
+
const PANIC_ERROR_JSON =
|
|
48
|
+
'{"name":"Error","message":"deferred serialization failed"}';
|
|
49
|
+
|
|
50
|
+
function safeSerializeError(
|
|
51
|
+
serializeError: (error: unknown) => string,
|
|
52
|
+
error: unknown,
|
|
53
|
+
): string {
|
|
54
|
+
try {
|
|
55
|
+
return serializeError(error);
|
|
56
|
+
} catch {
|
|
57
|
+
return PANIC_ERROR_JSON;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the settle-promise array that emits `<script>__rrDefer__(...)</script>`
|
|
63
|
+
* (or `__rrDeferError__`) chunks via `safeEnqueue` as each deferred promise
|
|
64
|
+
* settles.
|
|
65
|
+
*
|
|
66
|
+
* Extracted from `injectDeferredScripts` so the streaming wrapper's `start`
|
|
67
|
+
* callback reads top-down without a 35-line inline `entries.map` body.
|
|
68
|
+
*
|
|
69
|
+
* `Promise.resolve(thenable)` adopts the thenable's state under the standard
|
|
70
|
+
* Promise machinery. This buys two safety properties:
|
|
71
|
+
* 1. A duck-typed thenable whose `.then(...)` throws synchronously
|
|
72
|
+
* (`defer()` only validates `typeof .then === "function"`, not that the
|
|
73
|
+
* implementation behaves) is converted into a rejection instead of
|
|
74
|
+
* escaping `entries.map` and crashing the stream's `start` callback.
|
|
75
|
+
* 2. Native promises pass through unchanged — `Promise.resolve(p)` returns
|
|
76
|
+
* `p` for native promises (per spec).
|
|
77
|
+
*/
|
|
78
|
+
function buildSettlePromises(
|
|
79
|
+
entries: [string, Promise<unknown>][],
|
|
80
|
+
encoder: TextEncoder,
|
|
81
|
+
serialize: Serializer,
|
|
82
|
+
serializeError: (error: unknown) => string,
|
|
83
|
+
safeEnqueue: (chunk: Uint8Array) => void,
|
|
84
|
+
): Promise<void>[] {
|
|
85
|
+
return entries.map(([key, promise]) =>
|
|
86
|
+
Promise.resolve(promise).then(
|
|
87
|
+
(value) => {
|
|
88
|
+
try {
|
|
89
|
+
// Mirror serializeState's `?? "null"` fallback (#606): a serializer
|
|
90
|
+
// that returns undefined for unsupported inputs becomes `null` on
|
|
91
|
+
// the wire instead of a confusing TypeError crash inside
|
|
92
|
+
// escapeForScript. Throws (e.g. BigInt without a custom serializer,
|
|
93
|
+
// circular refs) still route to the error-settle path below.
|
|
94
|
+
const json = serialize(value) ?? "null";
|
|
95
|
+
|
|
96
|
+
safeEnqueue(encoder.encode(formatSettleScript(key, json, false)));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const errJson = safeSerializeError(serializeError, error);
|
|
99
|
+
|
|
100
|
+
safeEnqueue(encoder.encode(formatSettleScript(key, errJson, true)));
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
(error: unknown) => {
|
|
104
|
+
const errJson = safeSerializeError(serializeError, error);
|
|
105
|
+
|
|
106
|
+
safeEnqueue(encoder.encode(formatSettleScript(key, errJson, true)));
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Emit the bootstrap `<script>` once, gated on `(includeBootstrap, entries)`.
|
|
114
|
+
* Lifted out of the `start` callback so the streaming wrapper reads
|
|
115
|
+
* top-down: bootstrap → upstream forwarding → settle awaits → close.
|
|
116
|
+
*
|
|
117
|
+
* Returns nothing — side-effect call into `safeEnqueue`. Kept as a function
|
|
118
|
+
* (instead of inlining a 4-line `if`) so the reader can ignore the bootstrap
|
|
119
|
+
* concern entirely when scanning `injectDeferredScripts`.
|
|
120
|
+
*/
|
|
121
|
+
function bootstrapForwarder(
|
|
122
|
+
includeBootstrap: boolean,
|
|
123
|
+
entryCount: number,
|
|
124
|
+
encoder: TextEncoder,
|
|
125
|
+
safeEnqueue: (chunk: Uint8Array) => void,
|
|
126
|
+
): void {
|
|
127
|
+
if (includeBootstrap && entryCount > 0) {
|
|
128
|
+
safeEnqueue(
|
|
129
|
+
encoder.encode(`<script>${getDeferBootstrapScript()}</script>`),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pump every chunk from the upstream HTML reader into `safeEnqueue` until
|
|
136
|
+
* the reader is exhausted (`done: true`) or the consumer cancels the
|
|
137
|
+
* downstream stream (`cancelledRef.value === true`).
|
|
138
|
+
*
|
|
139
|
+
* Resolves to `{ error: null }` on success; `{ error: unknown }` on a
|
|
140
|
+
* reader-level throw that the caller propagates via `controller.error(...)`.
|
|
141
|
+
* Splitting the loop out of `start` keeps the upstream-reader +
|
|
142
|
+
* settle-promise orchestration linear in the caller. The error is passed
|
|
143
|
+
* through verbatim — matches the previous `controller.error(error)` shape
|
|
144
|
+
* where non-Error throws stayed non-Error.
|
|
145
|
+
*/
|
|
146
|
+
async function htmlForwarder(
|
|
147
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
148
|
+
cancelledRef: { value: boolean },
|
|
149
|
+
safeEnqueue: (chunk: Uint8Array) => void,
|
|
150
|
+
): Promise<{ error: unknown }> {
|
|
151
|
+
try {
|
|
152
|
+
while (!cancelledRef.value) {
|
|
153
|
+
const { done, value } = await reader.read();
|
|
154
|
+
|
|
155
|
+
if (done) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
safeEnqueue(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { error: null };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { error };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Wraps an HTML `ReadableStream` (e.g. from React's `renderToReadableStream`)
|
|
170
|
+
* with inline `<script>__rrDefer__("key", json)</script>` chunks emitted as
|
|
171
|
+
* each promise in `deferred` resolves.
|
|
172
|
+
*
|
|
173
|
+
* The combined stream forwards every byte of the underlying HTML stream and
|
|
174
|
+
* interleaves settle scripts in **resolution order** (not declaration order)
|
|
175
|
+
* — the first promise to settle is the first script to land. This matches
|
|
176
|
+
* the order client `useDeferred` observers will resolve in.
|
|
177
|
+
*
|
|
178
|
+
* Closing semantics: the returned stream stays open until **both** the HTML
|
|
179
|
+
* stream is exhausted AND every deferred promise has settled. If the HTML
|
|
180
|
+
* stream errors, that error propagates and outstanding settle promises are
|
|
181
|
+
* abandoned (no controller.enqueue race).
|
|
182
|
+
*/
|
|
183
|
+
export function injectDeferredScripts(
|
|
184
|
+
htmlStream: ReadableStream<Uint8Array>,
|
|
185
|
+
deferred: Record<string, Promise<unknown>>,
|
|
186
|
+
options: InjectDeferredScriptsOptions = {},
|
|
187
|
+
): ReadableStream<Uint8Array> {
|
|
188
|
+
const encoder = new TextEncoder();
|
|
189
|
+
const serialize = options.serialize ?? JSON.stringify;
|
|
190
|
+
const serializeError = options.serializeError ?? DEFAULT_ERROR_SERIALIZER;
|
|
191
|
+
const includeBootstrap = options.bootstrap !== false;
|
|
192
|
+
const entries = Object.entries(deferred);
|
|
193
|
+
|
|
194
|
+
// Tracked outside the `start` callback so the `cancel` callback can reach
|
|
195
|
+
// the active reader and release its lock + propagate cancellation upstream
|
|
196
|
+
// when the consumer aborts mid-stream (e.g. client disconnect). The
|
|
197
|
+
// cancelled flag travels via a `{ value }` wrapper so `htmlForwarder` reads
|
|
198
|
+
// it through the same reference after each loop turn.
|
|
199
|
+
let upstreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
200
|
+
const cancelledRef = { value: false };
|
|
201
|
+
|
|
202
|
+
return new ReadableStream<Uint8Array>({
|
|
203
|
+
async start(controller) {
|
|
204
|
+
// Mutated from within `safeEnqueue` (catch branch); TS narrowing
|
|
205
|
+
// can't track the cross-closure write, so widen to `boolean`.
|
|
206
|
+
let closed = false;
|
|
207
|
+
|
|
208
|
+
const safeEnqueue = (chunk: Uint8Array): void => {
|
|
209
|
+
if (closed) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
controller.enqueue(chunk);
|
|
215
|
+
} catch {
|
|
216
|
+
closed = true;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
bootstrapForwarder(
|
|
221
|
+
includeBootstrap,
|
|
222
|
+
entries.length,
|
|
223
|
+
encoder,
|
|
224
|
+
safeEnqueue,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const settlePromises = buildSettlePromises(
|
|
228
|
+
entries,
|
|
229
|
+
encoder,
|
|
230
|
+
serialize,
|
|
231
|
+
serializeError,
|
|
232
|
+
safeEnqueue,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
upstreamReader = htmlStream.getReader();
|
|
236
|
+
|
|
237
|
+
const forwardResult = await htmlForwarder(
|
|
238
|
+
upstreamReader,
|
|
239
|
+
cancelledRef,
|
|
240
|
+
safeEnqueue,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (forwardResult.error !== null) {
|
|
244
|
+
closed = true;
|
|
245
|
+
controller.error(forwardResult.error);
|
|
246
|
+
upstreamReader.releaseLock();
|
|
247
|
+
upstreamReader = null;
|
|
248
|
+
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The cancel handler may have already nulled `upstreamReader` while
|
|
253
|
+
// the in-flight `read()` resolved as `{done:true}`. ESLint sees the
|
|
254
|
+
// local control-flow type as `ReadableStreamDefaultReader<…>` (because
|
|
255
|
+
// assignment + try/catch don't narrow to "possibly null after a yield
|
|
256
|
+
// point"), so the optional chain is flagged as unnecessary even though
|
|
257
|
+
// the cross-closure mutation makes it required.
|
|
258
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
259
|
+
upstreamReader?.releaseLock();
|
|
260
|
+
upstreamReader = null;
|
|
261
|
+
|
|
262
|
+
await Promise.allSettled(settlePromises);
|
|
263
|
+
|
|
264
|
+
// Two race conditions need this guard:
|
|
265
|
+
// - `closed === true` — `safeEnqueue` caught `controller.enqueue()`'s
|
|
266
|
+
// throw on a cancelled controller; closing again would re-throw
|
|
267
|
+
// "Invalid state: Controller is already closed".
|
|
268
|
+
// - `cancelledRef.value === true` — consumer called `reader.cancel()`;
|
|
269
|
+
// the stream is already in the closed state per WHATWG, so
|
|
270
|
+
// `controller.close()` would throw the same error. The throw is
|
|
271
|
+
// normally swallowed by ReadableStream's start-callback rejection
|
|
272
|
+
// handling (cancelled streams suppress start errors), but skipping
|
|
273
|
+
// the call is cleaner and avoids spurious unhandled-rejection
|
|
274
|
+
// warnings under stricter runtimes.
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
276
|
+
if (!closed && !cancelledRef.value) {
|
|
277
|
+
controller.close();
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
async cancel(reason) {
|
|
281
|
+
// Propagate consumer cancellation upstream so the underlying React
|
|
282
|
+
// (or other) HTML stream stops producing chunks. Without this, the
|
|
283
|
+
// upstream renderer keeps running until its internal AbortSignal
|
|
284
|
+
// fires (or never), wasting work after the client disconnected.
|
|
285
|
+
cancelledRef.value = true;
|
|
286
|
+
|
|
287
|
+
// The else-branch of the null check is a defensive fallback for the
|
|
288
|
+
// cancel-before-start race. WHATWG runs start() synchronously during
|
|
289
|
+
// ReadableStream construction, so `upstreamReader` is set before any
|
|
290
|
+
// consumer code can call cancel. Reachable only if `getReader()` threw.
|
|
291
|
+
/* v8 ignore start -- @preserve: defensive null-check, see above */
|
|
292
|
+
|
|
293
|
+
if (upstreamReader === null) {
|
|
294
|
+
try {
|
|
295
|
+
await htmlStream.cancel(reason);
|
|
296
|
+
} catch {
|
|
297
|
+
// best-effort
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
/* v8 ignore stop */
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await upstreamReader.cancel(reason);
|
|
306
|
+
} catch {
|
|
307
|
+
// upstream cancel rejected — best-effort, swallow
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
upstreamReader.releaseLock();
|
|
311
|
+
} catch {
|
|
312
|
+
// already released
|
|
313
|
+
}
|
|
314
|
+
upstreamReader = null;
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { getDeferBootstrapScript } from "./shared-ssr";
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
SsrLoaderFn,
|
|
3
|
+
SsrLoaderFnFactory,
|
|
4
|
+
SsrMode,
|
|
5
|
+
SsrRouteEntry,
|
|
6
|
+
} from "./shared-ssr";
|
|
7
|
+
import type { DefaultDependencies } from "@real-router/types";
|
|
2
8
|
|
|
3
|
-
export type DataLoaderFn =
|
|
9
|
+
export type DataLoaderFn = SsrLoaderFn<unknown>;
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* Factory function for creating data loaders.
|
|
@@ -13,11 +19,24 @@ export type DataLoaderFn = (params: Params) => Promise<unknown>;
|
|
|
13
19
|
*/
|
|
14
20
|
export type DataLoaderFnFactory<
|
|
15
21
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
16
|
-
> =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
22
|
+
> = SsrLoaderFnFactory<unknown, Dependencies>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-route entry: either a loader factory (short form) or
|
|
26
|
+
* `{ ssr?, loader? }` object form. Mode defaults to "full".
|
|
27
|
+
*
|
|
28
|
+
* - `ssr: "full"` (default) — server runs the loader, mode marker `state.context.ssrDataMode = "full"`.
|
|
29
|
+
* - `ssr: "data-only"` — server runs the loader, mode marker `"data-only"`. App may render shell-only HTML.
|
|
30
|
+
* - `ssr: "client-only"` (or `false`) — loader is skipped on every `start()`. App handles client-side fetching.
|
|
31
|
+
* - `ssr: true` — alias for "full".
|
|
32
|
+
* - `ssr: (state) => SsrMode` — resolved per-navigation, **before** the mode is written to context.
|
|
33
|
+
*/
|
|
34
|
+
export type DataRouteEntry<
|
|
35
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
36
|
+
> = SsrRouteEntry<unknown, SsrMode, Dependencies>;
|
|
20
37
|
|
|
21
38
|
export type DataLoaderFactoryMap<
|
|
22
39
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
23
|
-
> = Record<string,
|
|
40
|
+
> = Record<string, DataRouteEntry<Dependencies>>;
|
|
41
|
+
|
|
42
|
+
export { type SsrLoaderContext, type SsrMode } from "./shared-ssr";
|
package/src/validation.ts
DELETED