@real-router/ssr-data-plugin 0.4.3 → 0.4.5

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/src/server.ts DELETED
@@ -1,319 +0,0 @@
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 DELETED
@@ -1,42 +0,0 @@
1
- import type {
2
- SsrLoaderFn,
3
- SsrLoaderFnFactory,
4
- SsrMode,
5
- SsrRouteEntry,
6
- } from "./shared-ssr";
7
- import type { DefaultDependencies } from "@real-router/types";
8
-
9
- export type DataLoaderFn = SsrLoaderFn<unknown>;
10
-
11
- /**
12
- * Factory function for creating data loaders.
13
- * Receives the router instance and a dependency getter (same pattern as GuardFnFactory).
14
- * Factory runs once when the plugin starts; the returned loader is cached.
15
- *
16
- * @template Dependencies - Router dependency map for typed `getDependency()` access.
17
- * Defaults to `DefaultDependencies`. Pass your app's dependency interface for
18
- * type-safe DI: `DataLoaderFnFactory<AppDependencies>`.
19
- */
20
- export type DataLoaderFnFactory<
21
- Dependencies extends DefaultDependencies = DefaultDependencies,
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>;
37
-
38
- export type DataLoaderFactoryMap<
39
- Dependencies extends DefaultDependencies = DefaultDependencies,
40
- > = Record<string, DataRouteEntry<Dependencies>>;
41
-
42
- export { type SsrLoaderContext, type SsrMode } from "./shared-ssr";