@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.145

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.
@@ -0,0 +1,167 @@
1
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
+
3
+ /**
4
+ * Eager Flight-payload injector for the PPR resume path.
5
+ *
6
+ * rsc-html-stream's injectRSCPayload starts forwarding Flight chunks only from
7
+ * inside its first transform() callback — i.e. AFTER the first HTML chunk flows.
8
+ * That policy exists for the normal document path (a <script> must not precede
9
+ * the doctype). On a PPR shell HIT it parks the ENTIRE hydration payload: the
10
+ * resumed fizz render emits its first chunk only when the first hole's data
11
+ * resolves (live loaders — measured ~1.5s on SFCC-backed pages), while the
12
+ * Flight root row is ready within ~30ms of the tail render starting. The client
13
+ * cannot call hydrateRoot until that root row arrives, so the lazy start held
14
+ * hydration hostage to the slowest loader for no structural reason: the stored
15
+ * prelude (a complete document through </body></html>) is already on the wire
16
+ * before the tail, so every tail byte is foster-parented and a Flight <script>
17
+ * is valid as the FIRST tail byte.
18
+ *
19
+ * This injector starts pumping Flight chunks immediately in start(). Ordering
20
+ * safety is kept by serializing ALL writes through one promise chain: fizz
21
+ * chunks buffered within a tick flush as one atomic task (same batching idea as
22
+ * the stock injector — never inject between two partial HTML chunks), and each
23
+ * Flight script is its own task, so scripts land only between batches. The
24
+ * trailer is stripped from passing HTML and re-appended once, after both
25
+ * streams complete — identical to the stock contract.
26
+ *
27
+ * RESUME/DATA-VARIANT ONLY. The normal document path must keep the stock
28
+ * injector: there the first bytes are the document head, and an eager script
29
+ * would precede the doctype.
30
+ */
31
+
32
+ const encoder = new TextEncoder();
33
+ const TRAILER = "</body></html>";
34
+
35
+ // Escape closing script tags and HTML comments in JS content (ported from
36
+ // rsc-html-stream/server; escapes the "s" instead of the slash so a regexp
37
+ // literal like `0</script/` stays valid JS).
38
+ function escapeScript(script: string): string {
39
+ return script.replace(/<!--/g, "<\\!--").replace(/<\/(script)/gi, "</\\$1");
40
+ }
41
+
42
+ function writeScript(
43
+ controller: TransformStreamDefaultController<Uint8Array>,
44
+ jsExpr: string,
45
+ nonce: string | undefined,
46
+ ): void {
47
+ controller.enqueue(
48
+ encoder.encode(
49
+ `<script${nonce ? ` nonce="${nonce}"` : ""}>${escapeScript(
50
+ `(self.__FLIGHT_DATA||=[]).push(${jsExpr})`,
51
+ )}</script>`,
52
+ ),
53
+ );
54
+ }
55
+
56
+ export function injectRSCPayloadEager(
57
+ rscStream: ReadableStream<Uint8Array>,
58
+ options?: { nonce?: string },
59
+ ): TransformStream<Uint8Array, Uint8Array> {
60
+ const nonce = options?.nonce;
61
+ const htmlDecoder = new TextDecoder();
62
+ const t0 = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
63
+ let loggedFirstFlight = false;
64
+ let loggedFirstHtml = false;
65
+
66
+ // All output goes through this chain: one task per Flight script, one task
67
+ // per buffered-HTML batch. A script can therefore never split a batch.
68
+ let queue: Promise<void> = Promise.resolve();
69
+ const enqueueTask = (fn: () => void): Promise<void> => {
70
+ queue = queue.then(fn);
71
+ return queue;
72
+ };
73
+
74
+ let buffered: Uint8Array[] = [];
75
+ let timeout: ReturnType<typeof setTimeout> | null = null;
76
+ let rscDone: Promise<void> = Promise.resolve();
77
+
78
+ function flushBufferedHTML(
79
+ controller: TransformStreamDefaultController<Uint8Array>,
80
+ ): void {
81
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
82
+ loggedFirstHtml = true;
83
+ console.log(
84
+ `[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms`,
85
+ );
86
+ }
87
+ for (const chunk of buffered) {
88
+ let buf = htmlDecoder.decode(chunk, { stream: true });
89
+ if (buf.endsWith(TRAILER)) buf = buf.slice(0, -TRAILER.length);
90
+ controller.enqueue(encoder.encode(buf));
91
+ }
92
+ const remaining = htmlDecoder.decode();
93
+ if (remaining.length) {
94
+ const out = remaining.endsWith(TRAILER)
95
+ ? remaining.slice(0, -TRAILER.length)
96
+ : remaining;
97
+ controller.enqueue(encoder.encode(out));
98
+ }
99
+ buffered.length = 0;
100
+ timeout = null;
101
+ }
102
+
103
+ async function pumpRSC(
104
+ controller: TransformStreamDefaultController<Uint8Array>,
105
+ ): Promise<void> {
106
+ const rscDecoder = new TextDecoder("utf-8", { fatal: true });
107
+ const reader = rscStream.getReader();
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ // String when the chunk is valid unicode, base64 round-trip otherwise —
112
+ // same fallback the stock injector uses.
113
+ let jsExpr: string;
114
+ try {
115
+ jsExpr = JSON.stringify(rscDecoder.decode(value, { stream: true }));
116
+ } catch {
117
+ const base64 = JSON.stringify(
118
+ btoa(String.fromCodePoint(...(value as Uint8Array))),
119
+ );
120
+ jsExpr = `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`;
121
+ }
122
+ await enqueueTask(() => {
123
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
124
+ loggedFirstFlight = true;
125
+ console.log(
126
+ `[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms`,
127
+ );
128
+ }
129
+ writeScript(controller, jsExpr, nonce);
130
+ });
131
+ }
132
+ const remaining = rscDecoder.decode();
133
+ if (remaining.length) {
134
+ await enqueueTask(() =>
135
+ writeScript(controller, JSON.stringify(remaining), nonce),
136
+ );
137
+ }
138
+ }
139
+
140
+ return new TransformStream<Uint8Array, Uint8Array>({
141
+ start(controller) {
142
+ // The eager part: pump Flight immediately, before any HTML arrives.
143
+ rscDone = pumpRSC(controller).catch((err) => {
144
+ try {
145
+ controller.error(err);
146
+ } catch {
147
+ // Stream already errored/closed; nothing to signal.
148
+ }
149
+ });
150
+ },
151
+ transform(chunk, controller) {
152
+ buffered.push(chunk);
153
+ if (timeout) return;
154
+ // Batch same-tick fizz chunks so a Flight script cannot land between two
155
+ // partial HTML chunks of one logical write (stock injector's invariant).
156
+ timeout = setTimeout(() => {
157
+ void enqueueTask(() => flushBufferedHTML(controller));
158
+ }, 0);
159
+ },
160
+ async flush(controller) {
161
+ await rscDone;
162
+ if (timeout) clearTimeout(timeout);
163
+ await enqueueTask(() => flushBufferedHTML(controller));
164
+ controller.enqueue(encoder.encode(TRAILER));
165
+ },
166
+ });
167
+ }
package/src/vite/index.ts CHANGED
@@ -8,6 +8,13 @@
8
8
 
9
9
  export { rango } from "./rango.js";
10
10
  export { poke } from "./plugins/refresh-cmd.js";
11
+ // The built-in clientChunks strategy, exported so a custom `clientChunks`
12
+ // function can OVERLAY it (route a few modules to a dedicated chunk, delegate
13
+ // the rest) instead of replacing the whole route/marker grouping. Without this
14
+ // a consumer override silently loses app-fallback/route splitting for the
15
+ // entire app. Note: called without a ClientChunkContext the fallbackRefs-based
16
+ // `app-fallback` split is inactive — discovery wires it only for the built-in.
17
+ export { directoryClientChunks } from "./utils/client-chunks.js";
11
18
 
12
19
  export type {
13
20
  RangoNodeOptions,