@reckona/mreact-server 0.0.65 → 0.0.67
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/package.json +5 -4
- package/src/buffer-sink.ts +239 -0
- package/src/flight.ts +2057 -0
- package/src/index.ts +1306 -0
- package/src/native-flight.ts +96 -0
- package/src/reorder.ts +30 -0
- package/src/url-safety.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reckona/mreact-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.67",
|
|
4
4
|
"description": "Server rendering, streaming, hydration, and Flight primitives for mreact.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"flight",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"dist/**/*.js",
|
|
26
26
|
"dist/**/*.js.map",
|
|
27
27
|
"dist/**/*.d.ts",
|
|
28
|
-
"dist/**/*.d.ts.map"
|
|
28
|
+
"dist/**/*.d.ts.map",
|
|
29
|
+
"src/**/*"
|
|
29
30
|
],
|
|
30
31
|
"type": "module",
|
|
31
32
|
"sideEffects": false,
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
"access": "public"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
|
-
"@reckona/mreact-compat": "0.0.
|
|
56
|
-
"@reckona/mreact-shared": "0.0.
|
|
56
|
+
"@reckona/mreact-compat": "0.0.67",
|
|
57
|
+
"@reckona/mreact-shared": "0.0.67"
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Node 専用 buffer sink — issue 050 の対応。
|
|
2
|
+
//
|
|
3
|
+
// `createStringSink` (cross-runtime, string accumulator) と並べて、
|
|
4
|
+
// Node の `Buffer` を直接 accumulate する sink を提供する。HTTP response
|
|
5
|
+
// body へ `response.write(buffer)` で直接渡すと string → UTF-8 encode の
|
|
6
|
+
// cost を 1 回にまとめられる利点がある。
|
|
7
|
+
//
|
|
8
|
+
// 注: Cloudflare Workers / Deno など `Buffer` を持たない runtime では
|
|
9
|
+
// import 時に runtime error になる。cross-runtime ポータビリティが必要な
|
|
10
|
+
// callsite は `@reckona/mreact-server` 本体の `createStringSink` を使う。
|
|
11
|
+
//
|
|
12
|
+
// tsconfig は `lib: ES2022 + DOM`, `types: []` なので `@types/node` に
|
|
13
|
+
// 依存しないよう、`Buffer` の最小型を本 file 内で declare する。
|
|
14
|
+
|
|
15
|
+
interface NodeBuffer extends Uint8Array {
|
|
16
|
+
toString(encoding?: string, start?: number, end?: number): string;
|
|
17
|
+
write(input: string, offset: number, encoding?: string): number;
|
|
18
|
+
copy(target: NodeBuffer, targetStart?: number, sourceStart?: number, sourceEnd?: number): number;
|
|
19
|
+
subarray(start?: number, end?: number): NodeBuffer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BufferConstructor {
|
|
23
|
+
from(input: string, encoding?: string): NodeBuffer;
|
|
24
|
+
from(input: ArrayBufferLike | ArrayLike<number>): NodeBuffer;
|
|
25
|
+
alloc(size: number): NodeBuffer;
|
|
26
|
+
allocUnsafe(size: number): NodeBuffer;
|
|
27
|
+
byteLength(input: string, encoding?: string): number;
|
|
28
|
+
isBuffer(input: unknown): input is NodeBuffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare const Buffer: BufferConstructor;
|
|
32
|
+
|
|
33
|
+
export interface BufferSink {
|
|
34
|
+
append(chunk: string | NodeBuffer): void;
|
|
35
|
+
toBuffer(): NodeBuffer;
|
|
36
|
+
toString(): string;
|
|
37
|
+
size(): number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BufferSinkOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Initial backing buffer size (UTF-8 bytes). The buffer grows
|
|
43
|
+
* geometrically as needed. Default: 8192.
|
|
44
|
+
*/
|
|
45
|
+
initialSize?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Growth multiplier when the backing buffer needs to expand.
|
|
48
|
+
* Default: 2.
|
|
49
|
+
*/
|
|
50
|
+
growthFactor?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a Node-only buffer sink with a single pre-allocated growing
|
|
55
|
+
* backing `Buffer`. UTF-8 encoding happens in-place at the current write
|
|
56
|
+
* offset, avoiding per-chunk Buffer allocation and a final concat.
|
|
57
|
+
*
|
|
58
|
+
* Compared to `Buffer.concat`-style implementations, this trades a small
|
|
59
|
+
* amount of headroom memory for ~5-10x throughput on small chunks
|
|
60
|
+
* (see docs/benchmarks/2026-05-12-server-sink-strategy.md).
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* A streaming-flavored buffer sink used by
|
|
64
|
+
* `renderToReadableStream`. Coalesces successive `append(chunk)` calls
|
|
65
|
+
* into a single byte buffer, then hands the buffer to a consumer
|
|
66
|
+
* callback either on demand (`flush()`) or automatically once the
|
|
67
|
+
* accumulated UTF-8 byte length crosses a threshold.
|
|
68
|
+
*
|
|
69
|
+
* The contract:
|
|
70
|
+
* - Each call to `flush()` (or an auto-flush triggered from within
|
|
71
|
+
* `append`) delivers **exactly one** non-empty byte buffer to the
|
|
72
|
+
* consumer. Empty buffers are never delivered — callers do not
|
|
73
|
+
* need to filter them out.
|
|
74
|
+
* - The delivered buffer is exclusively owned by the consumer; the
|
|
75
|
+
* sink will not mutate it afterwards. Internally we allocate a
|
|
76
|
+
* fresh backing buffer per epoch, so handing off a `subarray` view
|
|
77
|
+
* is safe with no copy.
|
|
78
|
+
*
|
|
79
|
+
* Issue 084: motivated by the streaming SSR throughput gap to
|
|
80
|
+
* marko-run (mreact was at 0.66x marko's ops/sec because the previous
|
|
81
|
+
* implementation paid a `TextEncoder.encode` + Web Streams
|
|
82
|
+
* `controller.enqueue` round-trip per `sink.append()` call).
|
|
83
|
+
*/
|
|
84
|
+
export interface StreamingBufferSink {
|
|
85
|
+
append(chunk: string): void;
|
|
86
|
+
flush(): void;
|
|
87
|
+
size(): number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface StreamingBufferSinkOptions {
|
|
91
|
+
/** UTF-8 byte threshold that triggers an automatic flush from inside
|
|
92
|
+
* `append`. Default 8 KiB (one common TCP segment payload). */
|
|
93
|
+
flushThreshold?: number;
|
|
94
|
+
/** Initial backing buffer size; same semantics as `BufferSinkOptions`. */
|
|
95
|
+
initialSize?: number;
|
|
96
|
+
/** Consumer hook — invoked at most once per `flush()` (manual or
|
|
97
|
+
* automatic), and only with a non-empty byte buffer. */
|
|
98
|
+
onFlush(buffer: Uint8Array): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createStreamingBufferSink(
|
|
102
|
+
options: StreamingBufferSinkOptions,
|
|
103
|
+
): StreamingBufferSink {
|
|
104
|
+
if (!hasNodeBuffer()) {
|
|
105
|
+
return createStreamingEncodedSink(options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const flushThreshold = options.flushThreshold ?? 8192;
|
|
109
|
+
const initialSize = options.initialSize ?? flushThreshold;
|
|
110
|
+
let inner = createBufferSink({ initialSize });
|
|
111
|
+
const emitAndReset = () => {
|
|
112
|
+
const buf = inner.toBuffer();
|
|
113
|
+
inner = createBufferSink({ initialSize });
|
|
114
|
+
options.onFlush(buf);
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
append(chunk) {
|
|
118
|
+
if (chunk === "") return;
|
|
119
|
+
inner.append(chunk);
|
|
120
|
+
if (inner.size() >= flushThreshold) {
|
|
121
|
+
emitAndReset();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
flush() {
|
|
125
|
+
if (inner.size() === 0) return;
|
|
126
|
+
emitAndReset();
|
|
127
|
+
},
|
|
128
|
+
size() {
|
|
129
|
+
return inner.size();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createStreamingEncodedSink(options: StreamingBufferSinkOptions): StreamingBufferSink {
|
|
135
|
+
const flushThreshold = options.flushThreshold ?? 8192;
|
|
136
|
+
const encoder = new TextEncoder();
|
|
137
|
+
let chunks: Uint8Array[] = [];
|
|
138
|
+
let byteLength = 0;
|
|
139
|
+
|
|
140
|
+
const emitAndReset = () => {
|
|
141
|
+
const output = concatUint8Arrays(chunks, byteLength);
|
|
142
|
+
chunks = [];
|
|
143
|
+
byteLength = 0;
|
|
144
|
+
options.onFlush(output);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
append(chunk) {
|
|
149
|
+
if (chunk === "") return;
|
|
150
|
+
|
|
151
|
+
const bytes = encoder.encode(chunk);
|
|
152
|
+
chunks.push(bytes);
|
|
153
|
+
byteLength += bytes.byteLength;
|
|
154
|
+
|
|
155
|
+
if (byteLength >= flushThreshold) {
|
|
156
|
+
emitAndReset();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
flush() {
|
|
160
|
+
if (byteLength === 0) return;
|
|
161
|
+
emitAndReset();
|
|
162
|
+
},
|
|
163
|
+
size() {
|
|
164
|
+
return byteLength;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function concatUint8Arrays(chunks: Uint8Array[], byteLength: number): Uint8Array {
|
|
170
|
+
if (chunks.length === 1) {
|
|
171
|
+
return chunks[0] ?? new Uint8Array();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const output = new Uint8Array(byteLength);
|
|
175
|
+
let offset = 0;
|
|
176
|
+
|
|
177
|
+
for (const chunk of chunks) {
|
|
178
|
+
output.set(chunk, offset);
|
|
179
|
+
offset += chunk.byteLength;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return output;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hasNodeBuffer(): boolean {
|
|
186
|
+
return typeof Buffer !== "undefined" && typeof Buffer.allocUnsafe === "function";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createBufferSink(options: BufferSinkOptions = {}): BufferSink {
|
|
190
|
+
const initialSize = options.initialSize ?? 8192;
|
|
191
|
+
const growthFactor = options.growthFactor ?? 2;
|
|
192
|
+
let buffer = Buffer.allocUnsafe(initialSize);
|
|
193
|
+
let offset = 0;
|
|
194
|
+
|
|
195
|
+
function ensure(requiredBytes: number): void {
|
|
196
|
+
const required = offset + requiredBytes;
|
|
197
|
+
|
|
198
|
+
if (required <= buffer.length) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let newCapacity = buffer.length === 0 ? initialSize : buffer.length;
|
|
203
|
+
|
|
204
|
+
while (newCapacity < required) {
|
|
205
|
+
newCapacity = Math.ceil(newCapacity * growthFactor);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const grown = Buffer.allocUnsafe(newCapacity);
|
|
209
|
+
buffer.copy(grown, 0, 0, offset);
|
|
210
|
+
buffer = grown;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
append(chunk) {
|
|
215
|
+
if (typeof chunk === "string") {
|
|
216
|
+
// Reserve worst-case byte length (4 bytes/char) up front so we
|
|
217
|
+
// can encode in a single `write()` call without a measurement
|
|
218
|
+
// pass; the actual encoded length is returned by `write()`.
|
|
219
|
+
const upperBound = chunk.length * 4;
|
|
220
|
+
ensure(upperBound);
|
|
221
|
+
offset += buffer.write(chunk, offset, "utf8");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
ensure(chunk.length);
|
|
226
|
+
chunk.copy(buffer, offset);
|
|
227
|
+
offset += chunk.length;
|
|
228
|
+
},
|
|
229
|
+
toBuffer() {
|
|
230
|
+
return buffer.subarray(0, offset);
|
|
231
|
+
},
|
|
232
|
+
toString() {
|
|
233
|
+
return buffer.toString("utf8", 0, offset);
|
|
234
|
+
},
|
|
235
|
+
size() {
|
|
236
|
+
return offset;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|