@reckona/mreact-server 0.0.81 → 0.0.83

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/sink.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { HtmlSink } from "@reckona/mreact-shared/compiler-contract";
2
+
3
+ export interface StringHtmlSink extends HtmlSink {
4
+ bufferStrategy(): StringSinkBufferStrategy;
5
+ drain(): Promise<void>;
6
+ toString(): string;
7
+ }
8
+
9
+ export type StringSinkBufferStrategy = "concat" | "array-join";
10
+
11
+ export interface StringSinkOptions {
12
+ strategy?: StringSinkBufferStrategy | "auto";
13
+ arrayJoinThreshold?: number;
14
+ }
15
+
16
+ export type StreamRender = (sink: HtmlSink) => void | PromiseLike<void>;
17
+
18
+ export function createStringSink(options: StringSinkOptions = {}): StringHtmlSink {
19
+ // Default to "concat" - V8 rope flattening yields 2-6x throughput over
20
+ // `Array#join("")` across all measured fixture sizes (see
21
+ // docs/benchmarks/2026-05-12-server-sink-strategy.md). "array-join" stays
22
+ // available as opt-in for scenarios that need lower peak memory.
23
+ const requestedStrategy = options.strategy ?? "concat";
24
+ const arrayJoinThreshold = options.arrayJoinThreshold ?? 256;
25
+ const deferredTasks: PromiseLike<void>[] = [];
26
+ let strategy: StringSinkBufferStrategy = requestedStrategy === "auto"
27
+ ? "concat"
28
+ : requestedStrategy;
29
+ let writeCount = 0;
30
+ let text = "";
31
+ const chunks: string[] = [];
32
+
33
+ const switchConcatToArrayJoin = () => {
34
+ if (strategy !== "concat") {
35
+ return;
36
+ }
37
+
38
+ if (text !== "") {
39
+ chunks.push(text);
40
+ text = "";
41
+ }
42
+ strategy = "array-join";
43
+ };
44
+
45
+ return {
46
+ append(chunk) {
47
+ writeCount += 1;
48
+
49
+ if (requestedStrategy === "auto" && strategy === "concat" && writeCount > arrayJoinThreshold) {
50
+ switchConcatToArrayJoin();
51
+ }
52
+
53
+ if (strategy === "concat") {
54
+ text += chunk;
55
+ return;
56
+ }
57
+
58
+ chunks.push(chunk);
59
+ },
60
+ bufferStrategy() {
61
+ return strategy;
62
+ },
63
+ defer(task) {
64
+ deferredTasks.push(task);
65
+ },
66
+ async drain() {
67
+ await Promise.all(deferredTasks);
68
+ },
69
+ toString() {
70
+ if (strategy === "concat") {
71
+ return text;
72
+ }
73
+
74
+ return chunks.join("");
75
+ },
76
+ };
77
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { createStreamingBufferSink } from "./buffer-sink.js";
2
+ import type { StreamRender } from "./sink.js";
3
+
4
+ export interface RenderToReadableStreamOptions {
5
+ logAbortedDeferredErrors?: boolean;
6
+ }
7
+
8
+ const streamQueuedChunkSoftLimitBytes = 1024 * 1024;
9
+
10
+ export function renderToReadableStream(
11
+ render: StreamRender,
12
+ options: RenderToReadableStreamOptions = {},
13
+ ): ReadableStream<Uint8Array> {
14
+ // Issue 084: append calls go into a coalescing Node Buffer sink. The
15
+ // previous implementation called `controller.enqueue(encoder.encode(chunk))`
16
+ // per `sink.append` - one TextEncoder allocation + one WHATWG queue trip
17
+ // per call. Now we emit one chunk per flush boundary:
18
+ // 1. After the sync portion of `render` returns - the "shell"
19
+ // pre-flush. Done synchronously so it lands before any deferred
20
+ // task body fires in a microtask.
21
+ // 2. Whenever the accumulated buffer crosses the flushThreshold
22
+ // mid-render (e.g. a single very large list rendering).
23
+ // 3. Each `sink.append` made during the deferred phase flushes
24
+ // immediately - gives each OOB fragment its own HTTP chunk so
25
+ // the browser can swap it in as soon as it arrives.
26
+ // 4. End of stream - any tail bytes.
27
+ const abortController = new AbortController();
28
+ const queuedChunks: Uint8Array[] = [];
29
+ let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
30
+ let cancelled = false;
31
+ let complete = false;
32
+ let queuedBytes = 0;
33
+ let warnedQueuedBytes = false;
34
+ let backpressurePromise: Promise<void> | undefined;
35
+ let resolveBackpressure: (() => void) | undefined;
36
+
37
+ const enqueueOrQueue = (buffer: Uint8Array) => {
38
+ if (cancelled || abortController.signal.aborted) {
39
+ return;
40
+ }
41
+
42
+ const controller = controllerRef;
43
+ if (controller === undefined) {
44
+ queueChunk(buffer);
45
+ return;
46
+ }
47
+
48
+ if (queuedChunks.length === 0 && (controller.desiredSize ?? 0) > 0) {
49
+ controller.enqueue(buffer);
50
+ resolveBackpressureIfReady();
51
+ return;
52
+ }
53
+
54
+ queueChunk(buffer);
55
+ };
56
+ const drainQueuedChunks = (controller: ReadableStreamDefaultController<Uint8Array>) => {
57
+ while (!cancelled && queuedChunks.length > 0 && (controller.desiredSize ?? 0) > 0) {
58
+ const chunk = queuedChunks.shift();
59
+ if (chunk !== undefined) {
60
+ queuedBytes -= chunk.byteLength;
61
+ controller.enqueue(chunk);
62
+ }
63
+ }
64
+
65
+ if (!cancelled && complete && queuedChunks.length === 0) {
66
+ controller.close();
67
+ }
68
+
69
+ resolveBackpressureIfReady();
70
+ };
71
+
72
+ return new ReadableStream<Uint8Array>({
73
+ start(controller) {
74
+ controllerRef = controller;
75
+ const sink = createStreamingBufferSink({
76
+ onFlush(buffer) {
77
+ enqueueOrQueue(buffer);
78
+ },
79
+ });
80
+ const deferredTasks: PromiseLike<void>[] = [];
81
+ let inDeferredPhase = false;
82
+ let renderResult: void | PromiseLike<void>;
83
+
84
+ try {
85
+ renderResult = render({
86
+ append(chunk) {
87
+ if (abortController.signal.aborted) {
88
+ return;
89
+ }
90
+ sink.append(chunk);
91
+ if (inDeferredPhase) {
92
+ // OOB pattern: each deferred task ends with exactly one
93
+ // `sink.append("<template ...>...")`. Flushing here
94
+ // promotes that single append to its own chunk so the
95
+ // browser's MutationObserver can apply it without
96
+ // waiting for other deferred fragments.
97
+ sink.flush();
98
+ }
99
+ },
100
+ backpressure() {
101
+ return waitForBackpressure();
102
+ },
103
+ defer(task) {
104
+ deferredTasks.push(ignoreAfterAbort(task, abortController.signal, options));
105
+ },
106
+ signal: abortController.signal,
107
+ });
108
+ } catch (error) {
109
+ if (abortController.signal.aborted) {
110
+ return;
111
+ }
112
+ controller.error(error);
113
+ return;
114
+ }
115
+
116
+ // Shell pre-flush - synchronous, BEFORE we yield to microtasks.
117
+ // If we awaited render first the deferred tasks' bodies would
118
+ // already have appended their bytes to the same buffer and we
119
+ // would emit one merged chunk.
120
+ sink.flush();
121
+
122
+ void continueAfterShell();
123
+
124
+ async function continueAfterShell(): Promise<void> {
125
+ try {
126
+ if (renderResult !== undefined && renderResult !== null) {
127
+ await raceAbort(renderResult, abortController.signal);
128
+ // Async render may have written more before its tail returned.
129
+ // That tail is also "shell" - flush it before entering the
130
+ // deferred phase.
131
+ sink.flush();
132
+ }
133
+
134
+ inDeferredPhase = true;
135
+ await raceAbort(Promise.all(deferredTasks), abortController.signal);
136
+ // Tail flush in case the render closure (or a deferred task)
137
+ // somehow left bytes in the buffer past the per-append flushes.
138
+ sink.flush();
139
+ complete = true;
140
+ drainQueuedChunks(controller);
141
+ } catch (error) {
142
+ if (abortController.signal.aborted) {
143
+ return;
144
+ }
145
+ controller.error(error);
146
+ }
147
+ }
148
+ },
149
+ pull(controller) {
150
+ drainQueuedChunks(controller);
151
+ resolveBackpressureAfterPull();
152
+ },
153
+ cancel(reason) {
154
+ cancelled = true;
155
+ queuedChunks.length = 0;
156
+ queuedBytes = 0;
157
+ abortController.abort(reason);
158
+ resolveBackpressureIfReady();
159
+ },
160
+ });
161
+
162
+ function hasBackpressure(): boolean {
163
+ if (cancelled || abortController.signal.aborted) {
164
+ return false;
165
+ }
166
+
167
+ if (queuedChunks.length > 0) {
168
+ return true;
169
+ }
170
+
171
+ const controller = controllerRef;
172
+ return controller !== undefined && (controller.desiredSize ?? 0) <= 0;
173
+ }
174
+
175
+ function waitForBackpressure(): Promise<void> {
176
+ if (!hasBackpressure()) {
177
+ return Promise.resolve();
178
+ }
179
+
180
+ if (backpressurePromise === undefined) {
181
+ backpressurePromise = new Promise<void>((resolve) => {
182
+ resolveBackpressure = resolve;
183
+ });
184
+ }
185
+
186
+ return backpressurePromise;
187
+ }
188
+
189
+ function resolveBackpressureIfReady(): void {
190
+ if (resolveBackpressure === undefined || hasBackpressure()) {
191
+ return;
192
+ }
193
+
194
+ resolveBackpressureWaiter();
195
+ }
196
+
197
+ function resolveBackpressureAfterPull(): void {
198
+ if (
199
+ resolveBackpressure === undefined ||
200
+ queuedChunks.length > 0 ||
201
+ cancelled ||
202
+ abortController.signal.aborted
203
+ ) {
204
+ return;
205
+ }
206
+
207
+ resolveBackpressureWaiter();
208
+ }
209
+
210
+ function resolveBackpressureWaiter(): void {
211
+ const resolve = resolveBackpressure;
212
+ if (resolve === undefined) {
213
+ return;
214
+ }
215
+
216
+ backpressurePromise = undefined;
217
+ resolveBackpressure = undefined;
218
+ resolve();
219
+ }
220
+
221
+ function queueChunk(buffer: Uint8Array): void {
222
+ queuedChunks.push(buffer);
223
+ queuedBytes += buffer.byteLength;
224
+
225
+ if (
226
+ !warnedQueuedBytes &&
227
+ queuedBytes > streamQueuedChunkSoftLimitBytes &&
228
+ shouldWarnAboutQueuedStreamBytes()
229
+ ) {
230
+ warnedQueuedBytes = true;
231
+ console.warn(
232
+ `[mreact] renderToReadableStream queued ${queuedBytes} bytes because the downstream reader is slower than the renderer.`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
238
+ function shouldWarnAboutQueuedStreamBytes(): boolean {
239
+ return (
240
+ typeof process !== "undefined" &&
241
+ process.env !== undefined &&
242
+ process.env.NODE_ENV !== "production"
243
+ );
244
+ }
245
+
246
+ async function raceAbort<T>(task: PromiseLike<T>, signal: AbortSignal): Promise<T | undefined> {
247
+ if (signal.aborted) {
248
+ return undefined;
249
+ }
250
+
251
+ return Promise.race([
252
+ task,
253
+ new Promise<undefined>((resolve) => {
254
+ signal.addEventListener("abort", () => resolve(undefined), { once: true });
255
+ }),
256
+ ]);
257
+ }
258
+
259
+ function ignoreAfterAbort(
260
+ task: PromiseLike<void>,
261
+ signal: AbortSignal,
262
+ options: RenderToReadableStreamOptions,
263
+ ): Promise<void> {
264
+ return Promise.resolve(task).catch((error) => {
265
+ if (!signal.aborted) {
266
+ throw error;
267
+ }
268
+
269
+ if (options.logAbortedDeferredErrors === true && process.env.NODE_ENV !== "production") {
270
+ console.warn("[mreact] ignored deferred task error after abort:", error);
271
+ }
272
+ });
273
+ }