@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98
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/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
- package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
- package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
- package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +55 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -62
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +6 -4
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +8 -18
- package/dist/plugins/dev-404-page.d.ts.map +1 -1
- package/dist/routing/index.d.ts +5 -3
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts +1 -10
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +37 -8
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/types.d.ts +63 -23
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +51 -0
- package/dist/routing/walkers.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/dev-holding-server.d.ts +4 -2
- package/dist/server/dev-holding-server.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +212 -0
- package/dist/server/html-injector-core.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +59 -59
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/internal.js +710 -563
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +46 -49
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +88 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -0
- package/dist/server/pipeline-phases.d.ts +97 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +53 -32
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +117 -0
- package/dist/server/port-resolution.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +20 -47
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +16 -11
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/utils/directive-parser.d.ts +0 -45
- package/dist/utils/directive-parser.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/adapters/nitro.ts +55 -5
- package/src/cli.ts +0 -0
- package/src/index.ts +84 -31
- package/src/plugins/build-report.ts +13 -22
- package/src/plugins/dev-404-page.ts +15 -41
- package/src/plugins/routing.ts +14 -12
- package/src/routing/codegen.ts +1 -1
- package/src/routing/convention-lint.ts +4 -4
- package/src/routing/index.ts +5 -3
- package/src/routing/interception.ts +1 -1
- package/src/routing/link-codegen.ts +25 -13
- package/src/routing/scanner.ts +17 -93
- package/src/routing/segment-classify.ts +107 -8
- package/src/routing/status-file-lint.ts +3 -3
- package/src/routing/types.ts +63 -23
- package/src/routing/walkers.ts +90 -0
- package/src/server/action-handler.ts +6 -0
- package/src/server/deny-renderer.ts +5 -5
- package/src/server/dev-holding-server.ts +4 -2
- package/src/server/fallback-error.ts +1 -1
- package/src/server/html-injector-core.ts +403 -0
- package/src/server/html-injectors.ts +158 -297
- package/src/server/node-stream-transforms.ts +108 -248
- package/src/server/pipeline-helpers.ts +180 -0
- package/src/server/pipeline-phases.ts +591 -0
- package/src/server/pipeline.ts +76 -539
- package/src/server/port-resolution.ts +215 -0
- package/src/server/route-element-builder.ts +1 -1
- package/src/server/route-matcher.ts +28 -60
- package/src/server/rsc-entry/api-handler.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +52 -98
- package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/slot-resolver.ts +1 -1
- package/src/server/status-code-resolver.ts +112 -128
- package/src/server/tree-builder.ts +6 -4
- package/src/utils/directive-parser.ts +0 -392
- package/LICENSE +0 -8
- package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
- package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
- package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
- package/dist/server/manifest-status-resolver.d.ts +0 -58
- package/dist/server/manifest-status-resolver.d.ts.map +0 -1
- package/src/server/manifest-status-resolver.ts +0 -215
|
@@ -1,17 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML stream injectors —
|
|
2
|
+
* HTML stream injectors — Web `TransformStream` wrappers around the
|
|
3
|
+
* pure helpers in `html-injector-core.ts`.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* Used on edge runtimes (Cloudflare Workers, Deno, browser SSR) where
|
|
6
|
+
* Web Streams are the native shape. The Node.js / Bun adapters live in
|
|
7
|
+
* `node-stream-transforms.ts` and wrap the same core in Node `Transform`s.
|
|
8
|
+
* Both wrappers must stay byte-for-byte equivalent — the core enforces
|
|
9
|
+
* that. If you fix a streaming bug here, you almost certainly need to
|
|
10
|
+
* fix it (or its cause) in the core, not in the Node wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Also exports the `buildClientScripts` helper used by RSC entry to
|
|
13
|
+
* resolve bootstrap script URLs from the build manifest. That helper
|
|
14
|
+
* is unrelated to streaming but lives here for historical reasons (it
|
|
15
|
+
* shares no code with the core and was previously co-located with the
|
|
16
|
+
* Web stream transforms).
|
|
6
17
|
*
|
|
7
18
|
* Design docs: 02-rendering-pipeline.md, 18-build-system.md §"Entry Files"
|
|
8
19
|
*/
|
|
9
20
|
|
|
21
|
+
import {
|
|
22
|
+
BufferAggregator,
|
|
23
|
+
FlightInjectorCore,
|
|
24
|
+
createInjectorState,
|
|
25
|
+
createSuffixState,
|
|
26
|
+
encodeUtf8,
|
|
27
|
+
injectorFlush,
|
|
28
|
+
processInjectorChunk,
|
|
29
|
+
processSuffixChunk,
|
|
30
|
+
suffixFlush,
|
|
31
|
+
} from './html-injector-core.js';
|
|
32
|
+
import { flightChunkScript } from './flight-scripts.js';
|
|
33
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
34
|
+
|
|
10
35
|
// ─── Buffered Transform ──────────────────────────────────────────────────────
|
|
11
36
|
|
|
12
|
-
/**
|
|
13
|
-
* Options for the buffered transform stream.
|
|
14
|
-
*/
|
|
15
37
|
export interface BufferedTransformOptions {
|
|
16
38
|
/**
|
|
17
39
|
* Flush synchronously once the buffer reaches this many bytes.
|
|
@@ -24,85 +46,55 @@ export interface BufferedTransformOptions {
|
|
|
24
46
|
/**
|
|
25
47
|
* Buffer incoming chunks and coalesce them within a single event loop tick.
|
|
26
48
|
*
|
|
27
|
-
* React Fizz may emit multiple micro-chunks within a single flush
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* This transform collects all chunks that arrive in the same tick and
|
|
33
|
-
* emits them as a single concatenated chunk on the next `setImmediate`.
|
|
34
|
-
* This ensures each output chunk represents a coherent HTML fragment
|
|
35
|
-
* from a single Fizz flush — safe for downstream script injection at
|
|
36
|
-
* chunk boundaries.
|
|
49
|
+
* React Fizz may emit multiple micro-chunks within a single flush. Without
|
|
50
|
+
* coalescing, downstream transforms (especially flight injection) could
|
|
51
|
+
* see chunk boundaries in the middle of HTML tags. This transform collects
|
|
52
|
+
* everything that arrives in the same tick and emits it as one concatenated
|
|
53
|
+
* chunk on the next `setImmediate` — a single-shot schedule, not a poll.
|
|
37
54
|
*
|
|
38
|
-
*
|
|
39
|
-
* cycle — no recursive scheduling, no busy-wait. See design/02 §"No Polling".
|
|
40
|
-
*
|
|
41
|
-
* Inspired by Next.js `createBufferedTransformStream`.
|
|
55
|
+
* See design/02 §"No Polling" and §"Scripts at Chunk Boundaries Only".
|
|
42
56
|
*/
|
|
43
57
|
export function createBufferedTransformStream(
|
|
44
58
|
options: BufferedTransformOptions = {}
|
|
45
59
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
let bufferedChunks: Uint8Array[] = [];
|
|
49
|
-
let bufferByteLength = 0;
|
|
60
|
+
const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
|
|
50
61
|
let pendingFlush: Promise<void> | undefined;
|
|
51
62
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// Concatenate all buffered chunks into a single output chunk
|
|
56
|
-
const merged = new Uint8Array(bufferByteLength);
|
|
57
|
-
let offset = 0;
|
|
58
|
-
for (const chunk of bufferedChunks) {
|
|
59
|
-
merged.set(chunk, offset);
|
|
60
|
-
offset += chunk.byteLength;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
bufferedChunks = [];
|
|
64
|
-
bufferByteLength = 0;
|
|
65
|
-
|
|
63
|
+
const drainTo = (controller: TransformStreamDefaultController<Uint8Array>) => {
|
|
64
|
+
const merged = buffer.drain();
|
|
65
|
+
if (!merged) return;
|
|
66
66
|
try {
|
|
67
67
|
controller.enqueue(merged);
|
|
68
68
|
} catch {
|
|
69
|
-
// Controller may be errored (e.g
|
|
69
|
+
// Controller may be errored (e.g. cancelled) — ignore.
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
const scheduleFlush = (controller: TransformStreamDefaultController<Uint8Array>) => {
|
|
74
|
-
if (pendingFlush) return;
|
|
75
|
-
|
|
76
|
-
// Single-shot setImmediate — fires once at the end of the current
|
|
77
|
-
// event loop iteration (check phase), then the promise resolves.
|
|
78
|
-
// NOT a recursive loop — no CPU spin risk.
|
|
79
|
-
pendingFlush = new Promise<void>((resolve) => {
|
|
80
|
-
setImmediate(() => {
|
|
81
|
-
try {
|
|
82
|
-
flush(controller);
|
|
83
|
-
} finally {
|
|
84
|
-
pendingFlush = undefined;
|
|
85
|
-
resolve();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
};
|
|
90
|
-
|
|
91
73
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
92
74
|
transform(chunk, controller) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
flush(controller);
|
|
99
|
-
} else {
|
|
100
|
-
// Schedule a deferred flush for end of this tick
|
|
101
|
-
scheduleFlush(controller);
|
|
75
|
+
const overCap = buffer.append(chunk);
|
|
76
|
+
if (overCap) {
|
|
77
|
+
// Buffer too large to hold — flush synchronously now.
|
|
78
|
+
drainTo(controller);
|
|
79
|
+
return;
|
|
102
80
|
}
|
|
81
|
+
if (pendingFlush) return;
|
|
82
|
+
// Single-shot setImmediate — fires once at the end of this tick,
|
|
83
|
+
// then the promise resolves. NOT a recursive loop. See design/02
|
|
84
|
+
// §"No Polling".
|
|
85
|
+
pendingFlush = new Promise<void>((resolve) => {
|
|
86
|
+
setImmediate(() => {
|
|
87
|
+
try {
|
|
88
|
+
drainTo(controller);
|
|
89
|
+
} finally {
|
|
90
|
+
pendingFlush = undefined;
|
|
91
|
+
resolve();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
103
95
|
},
|
|
104
96
|
flush() {
|
|
105
|
-
// Wait for any pending scheduled flush to complete
|
|
97
|
+
// Wait for any pending scheduled flush to complete.
|
|
106
98
|
return pendingFlush;
|
|
107
99
|
},
|
|
108
100
|
});
|
|
@@ -110,84 +102,47 @@ export function createBufferedTransformStream(
|
|
|
110
102
|
|
|
111
103
|
// ─── Move Suffix Transform ───────────────────────────────────────────────────
|
|
112
104
|
|
|
113
|
-
const SUFFIX = '</body></html>';
|
|
114
|
-
|
|
115
105
|
/**
|
|
116
106
|
* Move `</body></html>` to the end of the stream.
|
|
117
107
|
*
|
|
118
|
-
* React's renderToReadableStream emits
|
|
119
|
-
* shell chunk. Content
|
|
120
|
-
* RSC
|
|
121
|
-
* HTML
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* is found, it's appended anyway to ensure well-formed HTML.
|
|
108
|
+
* React's renderToReadableStream emits the closing tags as part of the
|
|
109
|
+
* shell chunk. Content arriving after the shell (Suspense resolutions,
|
|
110
|
+
* RSC scripts) would otherwise appear after `</html>` and produce invalid
|
|
111
|
+
* HTML. This transform strips the suffix on first sight and re-emits it
|
|
112
|
+
* from `flush()` — after every other byte has passed through. If the
|
|
113
|
+
* suffix never appeared in the input, it is appended anyway so output
|
|
114
|
+
* is well-formed.
|
|
126
115
|
*
|
|
127
116
|
* Equivalent to Next.js's `createMoveSuffixStream`.
|
|
128
117
|
*/
|
|
129
118
|
export function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array> {
|
|
130
|
-
const
|
|
131
|
-
const suffixBytes = encoder.encode(SUFFIX);
|
|
132
|
-
let foundSuffix = false;
|
|
119
|
+
const state = createSuffixState();
|
|
133
120
|
|
|
134
121
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
135
122
|
transform(chunk, controller) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Search for the suffix in this chunk
|
|
142
|
-
const text = new TextDecoder().decode(chunk, { stream: true });
|
|
143
|
-
const idx = text.indexOf(SUFFIX);
|
|
144
|
-
if (idx === -1) {
|
|
123
|
+
const result = processSuffixChunk(state, chunk);
|
|
124
|
+
if (result.kind === 'passthrough' || result.kind === 'noSuffix') {
|
|
145
125
|
controller.enqueue(chunk);
|
|
146
126
|
return;
|
|
147
127
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// If the entire chunk is exactly the suffix, skip it
|
|
152
|
-
if (chunk.byteLength === suffixBytes.byteLength) return;
|
|
153
|
-
|
|
154
|
-
// Emit content before the suffix
|
|
155
|
-
const before = text.slice(0, idx);
|
|
156
|
-
const after = text.slice(idx + SUFFIX.length);
|
|
157
|
-
if (before) controller.enqueue(encoder.encode(before));
|
|
158
|
-
// Emit content after the suffix (shouldn't normally exist,
|
|
159
|
-
// but handle it for robustness)
|
|
160
|
-
if (after) controller.enqueue(encoder.encode(after));
|
|
128
|
+
if (result.before) controller.enqueue(result.before);
|
|
129
|
+
if (result.after) controller.enqueue(result.after);
|
|
161
130
|
},
|
|
162
131
|
flush(controller) {
|
|
163
|
-
|
|
164
|
-
// find it in the input (ensures well-formed HTML).
|
|
165
|
-
controller.enqueue(suffixBytes);
|
|
132
|
+
controller.enqueue(suffixFlush(state));
|
|
166
133
|
},
|
|
167
134
|
});
|
|
168
135
|
}
|
|
169
136
|
|
|
170
|
-
// ─── Injection
|
|
171
|
-
|
|
172
|
-
import { createMachine } from '../utils/state-machine.js';
|
|
173
|
-
import { flightChunkScript } from './flight-scripts.js';
|
|
174
|
-
import {
|
|
175
|
-
flightInjectionTransitions,
|
|
176
|
-
isHtmlDone,
|
|
177
|
-
isPullDone,
|
|
178
|
-
type FlightInjectionState,
|
|
179
|
-
type FlightInjectionEvent,
|
|
180
|
-
} from './flight-injection-state.js';
|
|
181
|
-
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
137
|
+
// ─── Tag Injection ───────────────────────────────────────────────────────────
|
|
182
138
|
|
|
183
139
|
/**
|
|
184
|
-
* Inject
|
|
140
|
+
* Inject `content` immediately before (or after) `targetTag` in the stream.
|
|
185
141
|
*
|
|
186
|
-
* Streams chunks through immediately, keeping only a small trailing
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* back waiting for the closing tag.
|
|
142
|
+
* Streams chunks through immediately, keeping only a small trailing buffer
|
|
143
|
+
* (length of target tag minus one) so a tag split across chunks is still
|
|
144
|
+
* detected. Suspense streaming chunks are never held back waiting for the
|
|
145
|
+
* closing tag.
|
|
191
146
|
*/
|
|
192
147
|
function createInjector(
|
|
193
148
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -196,57 +151,29 @@ function createInjector(
|
|
|
196
151
|
position: 'before' | 'after' = 'before'
|
|
197
152
|
): ReadableStream<Uint8Array> {
|
|
198
153
|
if (!content) return stream;
|
|
199
|
-
|
|
200
|
-
const decoder = new TextDecoder();
|
|
201
|
-
const encoder = new TextEncoder();
|
|
202
|
-
let injected = false;
|
|
203
|
-
// Keep a trailing buffer just large enough that the target tag
|
|
204
|
-
// can't be split across the boundary without us seeing it.
|
|
205
|
-
let tail = '';
|
|
206
|
-
const tailLen = targetTag.length - 1;
|
|
154
|
+
const state = createInjectorState({ content, targetTag, position });
|
|
207
155
|
|
|
208
156
|
return stream.pipeThrough(
|
|
209
157
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
210
158
|
transform(chunk, controller) {
|
|
211
|
-
|
|
159
|
+
const result = processInjectorChunk(state, chunk);
|
|
160
|
+
if (result.kind === 'passthrough') {
|
|
212
161
|
controller.enqueue(chunk);
|
|
213
162
|
return;
|
|
214
163
|
}
|
|
215
|
-
|
|
216
|
-
// Combine the trailing buffer with the new chunk
|
|
217
|
-
const text = tail + decoder.decode(chunk, { stream: true });
|
|
218
|
-
const tagIndex = text.indexOf(targetTag);
|
|
219
|
-
|
|
220
|
-
if (tagIndex !== -1) {
|
|
221
|
-
const splitPoint = position === 'before' ? tagIndex : tagIndex + targetTag.length;
|
|
222
|
-
const before = text.slice(0, splitPoint);
|
|
223
|
-
const after = text.slice(splitPoint);
|
|
224
|
-
controller.enqueue(encoder.encode(before + content + after));
|
|
225
|
-
injected = true;
|
|
226
|
-
tail = '';
|
|
227
|
-
} else {
|
|
228
|
-
// Flush everything except the last tailLen chars (which might
|
|
229
|
-
// be the start of the target tag split across chunks).
|
|
230
|
-
const safeEnd = Math.max(0, text.length - tailLen);
|
|
231
|
-
if (safeEnd > 0) {
|
|
232
|
-
controller.enqueue(encoder.encode(text.slice(0, safeEnd)));
|
|
233
|
-
}
|
|
234
|
-
tail = text.slice(safeEnd);
|
|
235
|
-
}
|
|
164
|
+
if (result.bytes) controller.enqueue(result.bytes);
|
|
236
165
|
},
|
|
237
166
|
flush(controller) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
167
|
+
const leftover = injectorFlush(state);
|
|
168
|
+
if (leftover) controller.enqueue(leftover);
|
|
241
169
|
},
|
|
242
170
|
})
|
|
243
171
|
);
|
|
244
172
|
}
|
|
245
173
|
|
|
246
174
|
/**
|
|
247
|
-
* Inject metadata elements before
|
|
248
|
-
*
|
|
249
|
-
* If no </head> is found, the buffer is emitted as-is.
|
|
175
|
+
* Inject metadata elements before `</head>` in the HTML stream.
|
|
176
|
+
* If no `</head>` is found, the buffer is emitted as-is.
|
|
250
177
|
*/
|
|
251
178
|
export function injectHead(
|
|
252
179
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -256,10 +183,9 @@ export function injectHead(
|
|
|
256
183
|
}
|
|
257
184
|
|
|
258
185
|
/**
|
|
259
|
-
* Inject client bootstrap scripts before
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* If no </body> is found, the buffer is emitted as-is.
|
|
186
|
+
* Inject client bootstrap scripts before `</body>` in the HTML stream.
|
|
187
|
+
* Returns the stream unchanged if `scriptsHtml` is empty (client JS
|
|
188
|
+
* disabled mode). If no `</body>` is found, the buffer is emitted as-is.
|
|
263
189
|
*/
|
|
264
190
|
export function injectScripts(
|
|
265
191
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -268,30 +194,27 @@ export function injectScripts(
|
|
|
268
194
|
return createInjector(stream, scriptsHtml, '</body>');
|
|
269
195
|
}
|
|
270
196
|
|
|
197
|
+
// ─── RSC Flight Injection ────────────────────────────────────────────────────
|
|
198
|
+
|
|
271
199
|
/**
|
|
272
200
|
* Transform an RSC Flight stream into a stream of inline `<script>` tags.
|
|
273
201
|
*
|
|
274
202
|
* Uses a **pull-based** ReadableStream — the consumer (the injection
|
|
275
203
|
* transform) drives reads from the RSC stream on demand. No background
|
|
276
|
-
* reader, no shared mutable arrays, no race conditions.
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* flightInitScript() — see flight-scripts.ts.
|
|
204
|
+
* reader, no shared mutable arrays, no race conditions. Each RSC chunk
|
|
205
|
+
* becomes `<script>self.__timber_f.push([1,"data"])</script>`. The
|
|
206
|
+
* init script (which creates `__timber_f`) is in `<head>` via
|
|
207
|
+
* `flightInitScript()` — see flight-scripts.ts.
|
|
281
208
|
*/
|
|
282
209
|
export function createInlinedRscStream(
|
|
283
210
|
rscStream: ReadableStream<Uint8Array>,
|
|
284
211
|
renderTimeoutMs?: number
|
|
285
212
|
): ReadableStream<Uint8Array> {
|
|
286
|
-
const encoder = new TextEncoder();
|
|
287
213
|
const rscReader = rscStream.getReader();
|
|
288
214
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
289
215
|
const timeoutMs = renderTimeoutMs ?? 30_000;
|
|
290
216
|
|
|
291
217
|
return new ReadableStream<Uint8Array>({
|
|
292
|
-
// No bootstrap signal here — the init script is in <head> via
|
|
293
|
-
// flightInitScript() (see flight-scripts.ts). This ensures the
|
|
294
|
-
// __timber_f array exists before any chunk scripts execute.
|
|
295
218
|
async pull(controller) {
|
|
296
219
|
try {
|
|
297
220
|
const readPromise = rscReader.read();
|
|
@@ -305,7 +228,7 @@ export function createInlinedRscStream(
|
|
|
305
228
|
}
|
|
306
229
|
if (value) {
|
|
307
230
|
const decoded = decoder.decode(value, { stream: true });
|
|
308
|
-
controller.enqueue(
|
|
231
|
+
controller.enqueue(encodeUtf8(flightChunkScript(decoded)));
|
|
309
232
|
}
|
|
310
233
|
} catch (error) {
|
|
311
234
|
if (error instanceof RenderTimeoutError) {
|
|
@@ -318,105 +241,50 @@ export function createInlinedRscStream(
|
|
|
318
241
|
}
|
|
319
242
|
|
|
320
243
|
/**
|
|
321
|
-
* Merge RSC
|
|
322
|
-
* between HTML chunks at safe
|
|
244
|
+
* Merge a raw RSC byte stream into the HTML stream, wrapping each RSC
|
|
245
|
+
* chunk in a `<script>` and injecting it between HTML chunks at safe
|
|
246
|
+
* boundaries. Suffix stripping is handled upstream by
|
|
247
|
+
* `createMoveSuffixStream` — this transform only interleaves.
|
|
323
248
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
249
|
+
* The state machine, pending queue, script wrapping (`flightChunkScript`),
|
|
250
|
+
* and pull loop all live in `FlightInjectorCore`. This wrapper only
|
|
251
|
+
* shuffles bytes between the core and the Web `TransformStream` controller.
|
|
327
252
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* injection (TIM-527).
|
|
253
|
+
* **Input must be the raw RSC stream, not the pre-scripted output of
|
|
254
|
+
* `createInlinedRscStream`.** The core wraps each chunk itself; passing
|
|
255
|
+
* an already-wrapped stream would double-wrap and break hydration.
|
|
332
256
|
*
|
|
333
|
-
* State machine phases:
|
|
334
|
-
* init → streaming → flushing → done
|
|
335
|
-
* (any) → error
|
|
257
|
+
* State machine phases: init → streaming → flushing → done; (any) → error.
|
|
336
258
|
*
|
|
337
|
-
* Inspired by Next.js createFlightDataInjectionTransformStream
|
|
259
|
+
* Inspired by Next.js `createFlightDataInjectionTransformStream`. See
|
|
260
|
+
* design/02 §"Scripts at Chunk Boundaries Only".
|
|
338
261
|
*/
|
|
339
262
|
function createFlightInjectionTransform(
|
|
340
|
-
|
|
263
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
341
264
|
renderTimeoutMs?: number
|
|
342
265
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
347
|
-
initial: { phase: 'init' },
|
|
348
|
-
transitions: flightInjectionTransitions,
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
let pullPromise: Promise<void> | null = null;
|
|
352
|
-
|
|
353
|
-
// RSC script chunks waiting to be drained at a safe boundary.
|
|
354
|
-
// pullLoop buffers here; transform(), flush(), and pullLoop's
|
|
355
|
-
// post-yield drain all consume from this buffer.
|
|
356
|
-
const pending: Uint8Array[] = [];
|
|
357
|
-
|
|
358
|
-
// Controller reference — set on first transform() call so pullLoop
|
|
359
|
-
// can drain pending scripts between HTML chunks (after yielding).
|
|
266
|
+
const core = new FlightInjectorCore(rscStream, renderTimeoutMs);
|
|
267
|
+
// Controller reference — set on first transform() call so the pull
|
|
268
|
+
// loop can drain pending scripts between HTML chunks (after yielding).
|
|
360
269
|
let _controller: TransformStreamDefaultController<Uint8Array> | null = null;
|
|
361
270
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
for (;;) {
|
|
369
|
-
// Guard each RSC read with a timeout.
|
|
370
|
-
// See design/02 §"Streaming Constraints".
|
|
371
|
-
const readPromise = rscReader.read();
|
|
372
|
-
const { done, value } =
|
|
373
|
-
timeoutMs > 0
|
|
374
|
-
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
375
|
-
: await readPromise;
|
|
376
|
-
if (done) {
|
|
377
|
-
machine.send({ type: 'PULL_DONE' });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
pending.push(value);
|
|
381
|
-
// Yield between reads so HTML chunks get priority.
|
|
382
|
-
// Once flush() fires, drain without yielding.
|
|
383
|
-
if (!isHtmlDone(machine.state)) {
|
|
384
|
-
await new Promise<void>((r) => setImmediate(r));
|
|
385
|
-
// After yielding, drain pending scripts. This ensures RSC
|
|
386
|
-
// flight data reaches the client at shell-flush time — without
|
|
387
|
-
// this, hydration blocks waiting for data stuck in pending[].
|
|
388
|
-
// Safe because the upstream buffered transform (TIM-528)
|
|
389
|
-
// guarantees chunks end at tag boundaries.
|
|
390
|
-
if (pending.length > 0 && _controller) {
|
|
391
|
-
drainPending(_controller);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
} catch (err) {
|
|
396
|
-
if (err instanceof RenderTimeoutError) {
|
|
397
|
-
rscReader.cancel(err).catch(() => {});
|
|
398
|
-
}
|
|
399
|
-
machine.send({ type: 'PULL_ERROR', error: err });
|
|
400
|
-
}
|
|
401
|
-
}
|
|
271
|
+
const drain = (controller: TransformStreamDefaultController<Uint8Array>): void => {
|
|
272
|
+
while (core.pending.length > 0) controller.enqueue(core.pending.shift()!);
|
|
273
|
+
const err = core.terminalError;
|
|
274
|
+
if (err !== null) controller.error(err);
|
|
275
|
+
};
|
|
402
276
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
controller.enqueue(pending.shift()!);
|
|
407
|
-
}
|
|
408
|
-
if (machine.state.phase === 'error') {
|
|
409
|
-
controller.error(machine.state.error);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
277
|
+
const onYieldDrain = (): void => {
|
|
278
|
+
if (_controller) drain(_controller);
|
|
279
|
+
};
|
|
412
280
|
|
|
413
281
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
414
282
|
transform(chunk, controller) {
|
|
415
283
|
_controller = controller;
|
|
416
284
|
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
285
|
+
if (core.isInit) {
|
|
286
|
+
core.notifyFirstChunk();
|
|
287
|
+
core.ensurePullLoop(onYieldDrain);
|
|
420
288
|
}
|
|
421
289
|
|
|
422
290
|
// Emit the HTML chunk, then drain any buffered RSC scripts.
|
|
@@ -424,24 +292,19 @@ function createFlightInjectionTransform(
|
|
|
424
292
|
// The buffered transform upstream (TIM-528) ensures coherent chunks.
|
|
425
293
|
// Suffix stripping is upstream via createMoveSuffixStream (TIM-530).
|
|
426
294
|
controller.enqueue(chunk);
|
|
427
|
-
if (pending.length > 0)
|
|
295
|
+
if (core.pending.length > 0) drain(controller);
|
|
428
296
|
},
|
|
429
297
|
flush(controller) {
|
|
430
298
|
// All HTML chunks emitted. Pull loop stops yielding.
|
|
431
|
-
|
|
299
|
+
core.notifyHtmlDone();
|
|
432
300
|
|
|
433
|
-
const finish = () =>
|
|
434
|
-
drainPending(controller);
|
|
435
|
-
};
|
|
301
|
+
const finish = () => drain(controller);
|
|
436
302
|
|
|
437
|
-
if (isPullDone
|
|
303
|
+
if (core.isPullDone) {
|
|
438
304
|
finish();
|
|
439
305
|
return;
|
|
440
306
|
}
|
|
441
|
-
|
|
442
|
-
pullPromise = pullLoop();
|
|
443
|
-
}
|
|
444
|
-
return pullPromise.then(finish);
|
|
307
|
+
return core.ensurePullLoop(onYieldDrain).then(finish);
|
|
445
308
|
},
|
|
446
309
|
});
|
|
447
310
|
}
|
|
@@ -449,19 +312,23 @@ function createFlightInjectionTransform(
|
|
|
449
312
|
/**
|
|
450
313
|
* Progressively inline RSC Flight payload chunks into the HTML stream.
|
|
451
314
|
*
|
|
452
|
-
* Architecture (
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
315
|
+
* Architecture (2 TransformStream pipeline):
|
|
316
|
+
* HTML stream → flightInjection → moveSuffix → output
|
|
317
|
+
*
|
|
318
|
+
* 1. flightInjection wraps each RSC chunk in a `<script>` and interleaves
|
|
319
|
+
* them between HTML chunks.
|
|
320
|
+
* 2. moveSuffix strips `</body></html>` and re-emits at end so injected
|
|
321
|
+
* scripts appear before the closing tags.
|
|
456
322
|
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
323
|
+
* Wrapping happens inside `FlightInjectorCore` (via `flightChunkScript`).
|
|
324
|
+
* The raw RSC stream is passed straight to `createFlightInjectionTransform`
|
|
325
|
+
* — do not pre-wrap with `createInlinedRscStream` (that helper still
|
|
326
|
+
* exists for callers that want a standalone scripted stream).
|
|
460
327
|
*
|
|
461
328
|
* The client reads these script tags via `self.__timber_f` and feeds
|
|
462
|
-
* them to `createFromReadableStream` for progressive hydration.
|
|
463
|
-
*
|
|
464
|
-
*
|
|
329
|
+
* them to `createFromReadableStream` for progressive hydration. Stream
|
|
330
|
+
* completion is signaled by the `DOMContentLoaded` event on the client
|
|
331
|
+
* side — no custom done flag needed.
|
|
465
332
|
*/
|
|
466
333
|
export function injectRscPayload(
|
|
467
334
|
htmlStream: ReadableStream<Uint8Array>,
|
|
@@ -469,23 +336,17 @@ export function injectRscPayload(
|
|
|
469
336
|
renderTimeoutMs?: number
|
|
470
337
|
): ReadableStream<Uint8Array> {
|
|
471
338
|
if (!rscStream) return htmlStream;
|
|
472
|
-
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// Pipeline: flightInjection → moveSuffix
|
|
477
|
-
//
|
|
478
|
-
// 1. flightInjection interleaves RSC scripts between HTML chunks
|
|
479
|
-
// 2. moveSuffix strips </body></html> and re-emits at end
|
|
480
|
-
//
|
|
481
|
-
// The flight injector is upstream and interleaves scripts between
|
|
482
|
-
// HTML chunks. The moveSuffix transform then ensures </body></html>
|
|
483
|
-
// appears after all injected scripts, producing well-formed HTML.
|
|
339
|
+
// Pass the raw RSC stream directly to the flight injection transform.
|
|
340
|
+
// The transform's core (FlightInjectorCore) wraps each chunk in a
|
|
341
|
+
// <script> via flightChunkScript itself — do NOT pre-wrap with
|
|
342
|
+
// createInlinedRscStream here, that would double-wrap.
|
|
484
343
|
return htmlStream
|
|
485
|
-
.pipeThrough(createFlightInjectionTransform(
|
|
344
|
+
.pipeThrough(createFlightInjectionTransform(rscStream, renderTimeoutMs))
|
|
486
345
|
.pipeThrough(createMoveSuffixStream());
|
|
487
346
|
}
|
|
488
347
|
|
|
348
|
+
// ─── Client Bootstrap Script Resolution ──────────────────────────────────────
|
|
349
|
+
|
|
489
350
|
/**
|
|
490
351
|
* Client bootstrap configuration returned by buildClientScripts.
|
|
491
352
|
*
|
|
@@ -526,19 +387,19 @@ function findManifestEntryArray(
|
|
|
526
387
|
/**
|
|
527
388
|
* Build client bootstrap configuration based on runtime config.
|
|
528
389
|
*
|
|
529
|
-
* Returns empty strings when client JavaScript is disabled,
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
390
|
+
* Returns empty strings when client JavaScript is disabled, which produces
|
|
391
|
+
* zero-JS output. When `enableHMRInDev` is true and running in dev mode,
|
|
392
|
+
* injects only the Vite HMR client (no app bootstrap) so hot reloading
|
|
393
|
+
* works during development.
|
|
533
394
|
*
|
|
534
395
|
* In production, uses hashed chunk URLs from the build manifest.
|
|
535
396
|
*
|
|
536
397
|
* The bootstrap uses dynamic `import()` inside a regular (non-module)
|
|
537
398
|
* inline script so it executes immediately during HTML parsing. This
|
|
538
|
-
* is critical for streaming: `<script type="module">` is deferred
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
399
|
+
* is critical for streaming: `<script type="module">` is deferred until
|
|
400
|
+
* the document finishes parsing, which blocks hydration behind Suspense
|
|
401
|
+
* boundaries. Dynamic `import()` starts module loading and execution as
|
|
402
|
+
* soon as the shell HTML is parsed.
|
|
542
403
|
*/
|
|
543
404
|
export function buildClientScripts(runtimeConfig: {
|
|
544
405
|
output: string;
|