@rivetjs/router 0.0.0-stage → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +10 -2
- package/adapters/assemble.d.ts +32 -0
- package/adapters/assemble.js +32 -0
- package/adapters/build.d.ts +24 -0
- package/adapters/build.js +43 -0
- package/adapters/bun.d.ts +18 -0
- package/adapters/bun.js +44 -0
- package/adapters/cloudflare.d.ts +5 -0
- package/adapters/cloudflare.js +8 -0
- package/adapters/cluster.d.ts +7 -0
- package/adapters/cluster.js +130 -0
- package/adapters/csr.d.ts +12 -0
- package/adapters/csr.js +11 -0
- package/adapters/deno.d.ts +13 -0
- package/adapters/deno.js +35 -0
- package/adapters/fetch.d.ts +55 -0
- package/adapters/fetch.js +72 -0
- package/adapters/files.d.ts +20 -0
- package/adapters/files.js +155 -0
- package/adapters/idle-memory.d.ts +23 -0
- package/adapters/idle-memory.js +91 -0
- package/adapters/netlify.d.ts +13 -0
- package/adapters/netlify.js +15 -0
- package/adapters/node.d.ts +180 -0
- package/adapters/node.js +986 -0
- package/adapters/pack.d.ts +39 -0
- package/adapters/pack.js +360 -0
- package/adapters/server-target.d.ts +17 -0
- package/adapters/server-target.js +8 -0
- package/adapters/static.d.ts +84 -0
- package/adapters/static.js +231 -0
- package/adapters/target-build.d.ts +95 -0
- package/adapters/target-build.js +118 -0
- package/adapters/vercel-edge.d.ts +7 -0
- package/adapters/vercel-edge.js +9 -0
- package/adapters/vercel.d.ts +35 -0
- package/adapters/vercel.js +45 -0
- package/build/codegen.d.ts +97 -0
- package/build/codegen.js +1037 -0
- package/build/dev-errors.d.ts +23 -0
- package/build/dev-errors.js +247 -0
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +31 -0
- package/build/migrate.js +385 -0
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/route-css.d.ts +54 -0
- package/build/route-css.js +216 -0
- package/build/routes.d.ts +55 -0
- package/build/routes.js +219 -0
- package/build/sync.d.ts +20 -0
- package/build/sync.js +16 -0
- package/build/vite.d.ts +208 -0
- package/build/vite.js +861 -0
- package/cli/rivet.d.ts +2 -0
- package/cli/rivet.js +101 -0
- package/client/active.d.ts +6 -0
- package/client/active.js +22 -0
- package/client/announce.d.ts +2 -0
- package/client/announce.js +21 -0
- package/client/dev.d.ts +41 -0
- package/client/dev.js +91 -0
- package/client/index.d.ts +156 -0
- package/client/index.js +714 -0
- package/client/layouts.d.ts +110 -0
- package/client/layouts.js +310 -0
- package/client/leave.d.ts +40 -0
- package/client/leave.js +75 -0
- package/client/link.d.ts +48 -0
- package/client/link.jsx +65 -0
- package/client/live.d.ts +57 -0
- package/client/live.js +170 -0
- package/client/navigate.d.ts +71 -0
- package/client/navigate.js +120 -0
- package/client/navigation.d.ts +28 -0
- package/client/navigation.js +14 -0
- package/client/params.d.ts +16 -0
- package/client/params.js +28 -0
- package/client/route-context.d.ts +32 -0
- package/client/route-context.js +21 -0
- package/client/scroll.d.ts +16 -0
- package/client/scroll.js +80 -0
- package/client/search.d.ts +8 -0
- package/client/search.js +17 -0
- package/client/tailwind.css +8 -0
- package/client/transition-helpers.d.ts +28 -0
- package/client/transition-helpers.js +63 -0
- package/client/transitions.css +90 -0
- package/client/transitions.d.ts +21 -0
- package/client/transitions.js +156 -0
- package/index.d.ts +135 -0
- package/index.js +8 -0
- package/package.json +156 -4
- package/server/app.d.ts +175 -0
- package/server/app.js +335 -0
- package/server/body.d.ts +20 -0
- package/server/body.js +8 -0
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/early-hints.d.ts +34 -0
- package/server/early-hints.js +63 -0
- package/server/handler.d.ts +240 -0
- package/server/handler.js +661 -0
- package/server/index.d.ts +9 -0
- package/server/index.js +6 -0
- package/server/inline-styles.d.ts +21 -0
- package/server/inline-styles.js +28 -0
- package/server/module.d.ts +14 -0
- package/server/module.js +96 -0
- package/server/render.d.ts +96 -0
- package/server/render.js +492 -0
- package/server/route-cache.d.ts +245 -0
- package/server/route-cache.js +813 -0
- package/server/security.d.ts +18 -0
- package/server/security.js +47 -0
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/server/strict-csp.d.ts +7 -0
- package/server/strict-csp.js +18 -0
- package/server-only.d.ts +1 -0
- package/server-only.js +3 -0
- package/shared/actions.d.ts +10 -0
- package/shared/actions.js +18 -0
- package/shared/base.d.ts +8 -0
- package/shared/base.js +20 -0
- package/shared/cookie.d.ts +7 -0
- package/shared/cookie.js +32 -0
- package/shared/csp-directives.d.ts +9 -0
- package/shared/csp-directives.js +85 -0
- package/shared/csp-hash.d.ts +39 -0
- package/shared/csp-hash.js +121 -0
- package/shared/csp.d.ts +26 -0
- package/shared/csp.js +13 -0
- package/shared/data.d.ts +175 -0
- package/shared/data.js +22 -0
- package/shared/errors.d.ts +31 -0
- package/shared/errors.js +51 -0
- package/shared/head.d.ts +57 -0
- package/shared/head.js +242 -0
- package/shared/nonce.d.ts +2 -0
- package/shared/nonce.js +23 -0
- package/shared/prefetch-fallback.min.d.ts +1 -0
- package/shared/prefetch-fallback.min.js +2 -0
- package/shared/prefetch.d.ts +54 -0
- package/shared/prefetch.js +74 -0
- package/shared/redirect.d.ts +6 -0
- package/shared/redirect.js +25 -0
- package/shared/server-exports.d.ts +78 -0
- package/shared/server-exports.js +77 -0
- package/shared/slash.d.ts +8 -0
- package/shared/slash.js +24 -0
package/server/render.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Stream a page into a web Response: document start, resumable app, document end.
|
|
2
|
+
import { renderToWriter, SlabWriter } from "@rivetjs/runtime/server";
|
|
3
|
+
import { escapeText } from "@rivetjs/runtime/escape";
|
|
4
|
+
import { renderResumable } from "@rivetjs/runtime/server-resume";
|
|
5
|
+
import { withIdScope } from "@rivetjs/runtime/id";
|
|
6
|
+
import { DIRECT_BODY } from "./body.js";
|
|
7
|
+
export { headTags, htmlAttributes, bodyAttributes } from "../shared/head.js";
|
|
8
|
+
/** Coalesced write size (bytes): fewer, larger body chunks. */
|
|
9
|
+
const FLUSH_AT = 16 * 1024;
|
|
10
|
+
/** Bytes handed off but not consumed yet (no reader / sink, or a reader that has not asked) before the renderer waits. */
|
|
11
|
+
const QUEUE = 64 * 1024;
|
|
12
|
+
/** Byte slab the writes coalesce in; chunks are views of it (a slab is reused only after a direct sink released every view). */
|
|
13
|
+
const SLAB = 64 * 1024;
|
|
14
|
+
/** Strings up to this length are copied as ASCII by hand (cheaper than `encodeInto` + a view per write). */
|
|
15
|
+
const ASCII_LOOP = 64;
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
// Node / Bun: slabs are Buffers, so the renderer's text paths (`putText`, raw rows) and `write` encode with one
|
|
18
|
+
// native `utf8Write` call instead of a per-char loop / `encodeInto` + a subarray view (ssr-bench text grid,
|
|
19
|
+
// rivet-router lane: putText 24% of CPU on Uint8Array slabs). Other hosts (Deno, workers): plain Uint8Array.
|
|
20
|
+
const NodeBuffer = globalThis.Buffer;
|
|
21
|
+
const nativeSlabs = NodeBuffer !== undefined && typeof NodeBuffer.prototype.utf8Write === "function";
|
|
22
|
+
const allocate = (size) => (nativeSlabs ? NodeBuffer.allocUnsafeSlow(size) : new Uint8Array(size));
|
|
23
|
+
// Process-wide pool of SLAB-sized slabs, refilled only by direct sinks (a web stream consumer never
|
|
24
|
+
// says when a chunk's bytes are free): fresh ArrayBuffers per response add external-memory pressure.
|
|
25
|
+
const pool = [];
|
|
26
|
+
const POOL_LIMIT = 32;
|
|
27
|
+
const recycle = (slab) => {
|
|
28
|
+
if (slab.bytes.length !== SLAB || pool.length >= POOL_LIMIT)
|
|
29
|
+
return;
|
|
30
|
+
slab.retired = false;
|
|
31
|
+
pool.push(slab);
|
|
32
|
+
};
|
|
33
|
+
const retire = (slab) => {
|
|
34
|
+
slab.retired = true;
|
|
35
|
+
if (slab.pending === 0)
|
|
36
|
+
recycle(slab);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The body's byte writer (SlabWriter: writeText / writeBytes / writeInteger / raw-slab protocol shared with
|
|
40
|
+
* NodeStreamWriter). Writes coalesce in a slab and are handed off at FLUSH_AT bytes, on every write while
|
|
41
|
+
* the consumer is behind (`threshold` 0), or in a microtask (the renderer paused: a pending boundary, the
|
|
42
|
+
* end), so the shell before an await still leaves at once. The renderer hands over its pre-encoded template
|
|
43
|
+
* fragments and integers instead of strings to concatenate and encode per chunk.
|
|
44
|
+
*
|
|
45
|
+
* Consumer: none yet (chunks park, QUEUE bytes at most before the renderer waits), the web `ReadableStream`
|
|
46
|
+
* (high-water mark 0: `pull` = a read waits; parked chunks go out, later ones straight to a waiting read),
|
|
47
|
+
* or a direct sink (`attach`, node adapter: views of pooled slabs, released by the sink; no stream at all).
|
|
48
|
+
*/
|
|
49
|
+
class BodyWriter extends SlabWriter {
|
|
50
|
+
constructor(enqueued) {
|
|
51
|
+
super(FLUSH_AT, SLAB, false, undefined);
|
|
52
|
+
this.slab = null;
|
|
53
|
+
this.parked = [];
|
|
54
|
+
this.parkedSlabs = [];
|
|
55
|
+
this.parkedBytes = 0;
|
|
56
|
+
this.wanted = false;
|
|
57
|
+
this.mode = 0;
|
|
58
|
+
this.sink = null;
|
|
59
|
+
this.ended = false;
|
|
60
|
+
this.failure = null;
|
|
61
|
+
this.cancelled = false;
|
|
62
|
+
this.full = false;
|
|
63
|
+
this.waiting = null;
|
|
64
|
+
this.wake = null;
|
|
65
|
+
this.enqueued = enqueued;
|
|
66
|
+
this.later = () => {
|
|
67
|
+
try {
|
|
68
|
+
this.flush();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* Cancelled by the consumer: the next write fails and ends the render. */
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** The consumer caught up (or cancelled): writes coalesce again; a waiting renderer resumes. */
|
|
76
|
+
ready() {
|
|
77
|
+
this.full = false;
|
|
78
|
+
this.threshold = FLUSH_AT;
|
|
79
|
+
const resume = this.wake;
|
|
80
|
+
this.wake = this.waiting = null;
|
|
81
|
+
resume?.();
|
|
82
|
+
}
|
|
83
|
+
/** Stream `pull`: a read waits. Parked chunks go out at once; otherwise the next hand-off goes to it. */
|
|
84
|
+
pull() {
|
|
85
|
+
if (this.mode === 2)
|
|
86
|
+
return;
|
|
87
|
+
this.mode = 1;
|
|
88
|
+
if (this.parked.length) {
|
|
89
|
+
for (const chunk of this.parked)
|
|
90
|
+
this.controller.enqueue(chunk);
|
|
91
|
+
this.unpark();
|
|
92
|
+
}
|
|
93
|
+
else if (!this.failure && !this.ended) {
|
|
94
|
+
this.wanted = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (this.failure)
|
|
98
|
+
this.controller.error(this.failure.error);
|
|
99
|
+
else if (this.ended)
|
|
100
|
+
this.controller.close();
|
|
101
|
+
this.ready();
|
|
102
|
+
}
|
|
103
|
+
/** Direct consumer: every parked and later chunk goes to `sink`. False once the stream was read. */
|
|
104
|
+
attach(sink) {
|
|
105
|
+
if (this.mode !== 0 || this.cancelled)
|
|
106
|
+
return false;
|
|
107
|
+
this.mode = 2;
|
|
108
|
+
this.sink = sink;
|
|
109
|
+
const parked = this.parked, slabs = this.parkedSlabs;
|
|
110
|
+
this.unpark();
|
|
111
|
+
let wait;
|
|
112
|
+
for (let i = 0; i < parked.length; i++) {
|
|
113
|
+
const slab = slabs[i];
|
|
114
|
+
wait = sink.write(parked[i], () => --slab.pending === 0 && slab.retired && recycle(slab)) ?? wait;
|
|
115
|
+
}
|
|
116
|
+
if (this.failure)
|
|
117
|
+
sink.error(this.failure.error);
|
|
118
|
+
else if (this.ended)
|
|
119
|
+
(this.finish(), sink.end());
|
|
120
|
+
else if (wait) {
|
|
121
|
+
this.full = true;
|
|
122
|
+
this.threshold = 0;
|
|
123
|
+
this.waiting = wait.then(() => this.ready());
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
this.ready();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
unpark() {
|
|
130
|
+
this.parked = [];
|
|
131
|
+
this.parkedSlabs = [];
|
|
132
|
+
this.parkedBytes = 0;
|
|
133
|
+
}
|
|
134
|
+
/** The stream consumer cancelled. */
|
|
135
|
+
cancel() {
|
|
136
|
+
this.cancelled = true;
|
|
137
|
+
this.unpark();
|
|
138
|
+
this.ready();
|
|
139
|
+
}
|
|
140
|
+
/** The body is complete. */
|
|
141
|
+
close() {
|
|
142
|
+
this.ended = true;
|
|
143
|
+
if (this.mode === 2) {
|
|
144
|
+
this.finish();
|
|
145
|
+
this.sink.end();
|
|
146
|
+
}
|
|
147
|
+
else if (this.mode === 1 && !this.parked.length)
|
|
148
|
+
this.controller.close();
|
|
149
|
+
}
|
|
150
|
+
/** The render failed: the body errors (a consumer that comes later sees the error). */
|
|
151
|
+
fail(error) {
|
|
152
|
+
this.failure = { error };
|
|
153
|
+
this.unpark();
|
|
154
|
+
if (this.mode === 2) {
|
|
155
|
+
this.finish();
|
|
156
|
+
this.sink.error(error);
|
|
157
|
+
}
|
|
158
|
+
else if (this.mode === 1)
|
|
159
|
+
this.controller.error(error);
|
|
160
|
+
}
|
|
161
|
+
/** Direct sink: the current slab goes back to the pool once its views are released. */
|
|
162
|
+
finish() {
|
|
163
|
+
if (this.slab)
|
|
164
|
+
retire(this.slab);
|
|
165
|
+
this.slab = this.bytes = null;
|
|
166
|
+
}
|
|
167
|
+
schedule() {
|
|
168
|
+
this.scheduled = true;
|
|
169
|
+
queueMicrotask(this.later);
|
|
170
|
+
}
|
|
171
|
+
renew(size) {
|
|
172
|
+
if (this.slab)
|
|
173
|
+
retire(this.slab);
|
|
174
|
+
const slab = size <= SLAB ? (pool.pop() ?? { bytes: allocate(SLAB), pending: 0, retired: false }) : { bytes: allocate(size), pending: 0, retired: false };
|
|
175
|
+
this.slab = slab;
|
|
176
|
+
this.bytes = slab.bytes;
|
|
177
|
+
this.start = this.offset = 0;
|
|
178
|
+
}
|
|
179
|
+
flush() {
|
|
180
|
+
this.scheduled = false;
|
|
181
|
+
if (this.offset !== this.start) {
|
|
182
|
+
if (this.cancelled)
|
|
183
|
+
throw new TypeError("Response body cancelled");
|
|
184
|
+
const slab = this.slab;
|
|
185
|
+
const chunk = this.bytes.subarray(this.start, this.offset);
|
|
186
|
+
this.start = this.offset;
|
|
187
|
+
// The handed-off view keeps its bytes: later writes go past it, or into another slab.
|
|
188
|
+
slab.pending++;
|
|
189
|
+
if (this.bytes.length - this.offset < FLUSH_AT)
|
|
190
|
+
this.renew(0);
|
|
191
|
+
if (this.mode === 2) {
|
|
192
|
+
const wait = this.sink.write(chunk, () => --slab.pending === 0 && slab.retired && recycle(slab));
|
|
193
|
+
this.enqueued();
|
|
194
|
+
return wait;
|
|
195
|
+
}
|
|
196
|
+
if (this.wanted) {
|
|
197
|
+
this.wanted = false;
|
|
198
|
+
this.controller.enqueue(chunk);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
this.parked.push(chunk);
|
|
202
|
+
this.parkedSlabs.push(slab);
|
|
203
|
+
this.parkedBytes += chunk.length;
|
|
204
|
+
if (this.parkedBytes >= QUEUE) {
|
|
205
|
+
this.full = true;
|
|
206
|
+
this.threshold = 0;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.enqueued();
|
|
210
|
+
}
|
|
211
|
+
return this.full ? (this.waiting ??= new Promise((done) => (this.wake = done))) : undefined;
|
|
212
|
+
}
|
|
213
|
+
write(chunk) {
|
|
214
|
+
const length = chunk.length;
|
|
215
|
+
if (!length)
|
|
216
|
+
return;
|
|
217
|
+
let waiting;
|
|
218
|
+
if (this.bytes === null)
|
|
219
|
+
this.renew(0);
|
|
220
|
+
if (this.bytes.length - this.offset < length * 3) {
|
|
221
|
+
waiting = this.flush();
|
|
222
|
+
this.reserve(length * 3);
|
|
223
|
+
}
|
|
224
|
+
const slab = this.bytes;
|
|
225
|
+
// Explicit length: workerd's (nodejs_compat) Buffer#utf8Write throws ERR_OUT_OF_RANGE without it.
|
|
226
|
+
if (nativeSlabs)
|
|
227
|
+
this.offset += slab.utf8Write(chunk, this.offset, slab.length - this.offset);
|
|
228
|
+
else if (length <= ASCII_LOOP) {
|
|
229
|
+
// Short strings (escaped text, attribute values): plain ASCII copy, encoder only past it.
|
|
230
|
+
const offset = this.offset;
|
|
231
|
+
let i = 0;
|
|
232
|
+
for (; i < length; i++) {
|
|
233
|
+
const code = chunk.charCodeAt(i);
|
|
234
|
+
if (code >= 0x80)
|
|
235
|
+
break;
|
|
236
|
+
slab[offset + i] = code;
|
|
237
|
+
}
|
|
238
|
+
this.offset = offset + i;
|
|
239
|
+
if (i < length)
|
|
240
|
+
this.offset += encoder.encodeInto(i ? chunk.slice(i) : chunk, slab.subarray(this.offset)).written;
|
|
241
|
+
}
|
|
242
|
+
else
|
|
243
|
+
this.offset += encoder.encodeInto(chunk, slab.subarray(this.offset)).written;
|
|
244
|
+
if (this.offset - this.start >= this.threshold)
|
|
245
|
+
return this.flush() ?? waiting;
|
|
246
|
+
if (!this.scheduled)
|
|
247
|
+
this.schedule();
|
|
248
|
+
return waiting;
|
|
249
|
+
}
|
|
250
|
+
oversize(bytes) {
|
|
251
|
+
this.renew(bytes.length);
|
|
252
|
+
this.bytes.set(bytes);
|
|
253
|
+
this.offset = bytes.length;
|
|
254
|
+
return this.flush();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Root state script: the start of the recorder's tail (state, loader), the last thing it writes. */
|
|
258
|
+
const ROOT_STATE = '<script type="rivet/state" data-region="0"';
|
|
259
|
+
/** A writer that drops everything from the root state script on. */
|
|
260
|
+
const stateless = (writer) => {
|
|
261
|
+
let cut = false;
|
|
262
|
+
return {
|
|
263
|
+
write(chunk) {
|
|
264
|
+
if (cut)
|
|
265
|
+
return;
|
|
266
|
+
const at = chunk.indexOf(ROOT_STATE);
|
|
267
|
+
if (at < 0)
|
|
268
|
+
return writer.write(chunk);
|
|
269
|
+
cut = true;
|
|
270
|
+
return writer.write(chunk.slice(0, at));
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
/** `Promise.race([render, TICK])` yields PENDING when the render promise had not settled yet. */
|
|
275
|
+
const PENDING = Symbol("pending");
|
|
276
|
+
const TICK = Promise.resolve(PENDING);
|
|
277
|
+
/** Mid-stream failure marker: tells the page (and monitoring) the document is incomplete. */
|
|
278
|
+
export function failureMarker(nonce, digest) {
|
|
279
|
+
const attrs = nonce !== undefined ? ` nonce="${nonce.replace(/[^\w+/=-]/g, "")}"` : "";
|
|
280
|
+
const detail = JSON.stringify({
|
|
281
|
+
phase: "render",
|
|
282
|
+
...(digest ? { digest: digest.replace(/[^\w-]/g, "") } : {}),
|
|
283
|
+
error: "Render failed" + (digest ? ` (digest ${digest.replace(/[^\w-]/g, "")})` : ""),
|
|
284
|
+
});
|
|
285
|
+
return `<script${attrs}>document.dispatchEvent(new CustomEvent("rivet:resume-error",{detail:${detail}}))</script>`;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Render `factory` between the document parts as a streamed UTF-8 body. Writes respect the
|
|
289
|
+
* consumer's pace: the renderer waits while the stream's queue is full. Always a `Response`: a
|
|
290
|
+
* failure before any byte left errors the body (`renderDocument` rejects instead).
|
|
291
|
+
*/
|
|
292
|
+
export function renderPage(factory, input, options, init) {
|
|
293
|
+
return stream(factory, input, options, init, false);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* `renderPage`, but the `Response` is created once the first body bytes are ready (or the render
|
|
297
|
+
* ended): a failure before that throws / rejects, so the caller can answer with an error page and
|
|
298
|
+
* status instead of a 200 with a broken body. A synchronous page answers synchronously.
|
|
299
|
+
*/
|
|
300
|
+
export function renderDocument(factory, input, options, init) {
|
|
301
|
+
return stream(factory, input, options, init, true);
|
|
302
|
+
}
|
|
303
|
+
// The Response's body stream and direct hook close over the writer only, never over `stream`'s scope (one V8
|
|
304
|
+
// context for all its closures: factory, page data, recorder, the rendered graph). Node keeps a Response's web
|
|
305
|
+
// stream (and its source) alive through scavenges until the next mark-compact, unread or not, so whatever the
|
|
306
|
+
// source reaches outlives the request in the young generation and is promoted: resume /resume 1000 rows promoted
|
|
307
|
+
// ~550 KB per request (its whole render) into old space → a mark-compact every ~75 requests under a 512 MiB
|
|
308
|
+
// container. Through `out` only, the stream holds the writer (slabs released, sink) and nothing of the render.
|
|
309
|
+
const ENDED = () => { };
|
|
310
|
+
/** High-water mark 0: `pull` runs only while a read waits, so an unread body can still be attached. */
|
|
311
|
+
const bodyStream = (out) => new ReadableStream({
|
|
312
|
+
start(c) {
|
|
313
|
+
out.controller = c;
|
|
314
|
+
},
|
|
315
|
+
pull: () => out.pull(),
|
|
316
|
+
// Cancelled: the next hand-off throws and the render ends.
|
|
317
|
+
cancel: () => out.cancel(),
|
|
318
|
+
}, { highWaterMark: 0 });
|
|
319
|
+
const directBody = (out) => (sink) => out.attach(sink);
|
|
320
|
+
function stream(factory, input, options, init, deferred) {
|
|
321
|
+
const hold = options.hold;
|
|
322
|
+
let parts = hold ? null : options.document(input);
|
|
323
|
+
// Deferred: no Response until the renderer is really waiting (pending content, not a failure that
|
|
324
|
+
// is already settled) and bytes are queued, or the render ended; a failure before it rejects. Eager
|
|
325
|
+
// (`renderPage`): responded from the start. `queued`: bytes are in the stream, no Response handed out
|
|
326
|
+
// yet (still free to fail); `armed`: the renderer waits, the next queued bytes open the Response.
|
|
327
|
+
let responded = !deferred, sent = false, queued = false, armed = false, failure = null, opened = null, failed = null;
|
|
328
|
+
const respond = () => {
|
|
329
|
+
responded = true;
|
|
330
|
+
init.headers.set("content-type", "text/html; charset=utf-8");
|
|
331
|
+
const response = new Response(body, init);
|
|
332
|
+
// Host adapters (node) take the bytes straight from the writer (`server/body.ts`).
|
|
333
|
+
Object.defineProperty(response, DIRECT_BODY, { value: direct });
|
|
334
|
+
return response;
|
|
335
|
+
};
|
|
336
|
+
const out = new BodyWriter(() => {
|
|
337
|
+
sent = true;
|
|
338
|
+
if (!responded)
|
|
339
|
+
armed ? open() : (queued = true);
|
|
340
|
+
});
|
|
341
|
+
const flush = () => void out.flush();
|
|
342
|
+
const arm = () => {
|
|
343
|
+
armed = true;
|
|
344
|
+
if (out.offset > out.start)
|
|
345
|
+
flush();
|
|
346
|
+
else if (queued)
|
|
347
|
+
open();
|
|
348
|
+
};
|
|
349
|
+
const open = () => {
|
|
350
|
+
if (!opened)
|
|
351
|
+
return void (queued = true);
|
|
352
|
+
const done = opened;
|
|
353
|
+
opened = null;
|
|
354
|
+
done(respond());
|
|
355
|
+
};
|
|
356
|
+
const direct = directBody(out);
|
|
357
|
+
const body = bodyStream(out);
|
|
358
|
+
// Held: chunks queue until the shell is done (the marker after the app), then go out after the start.
|
|
359
|
+
// Still a byte writer while holding (RA5): pre-encoded fragments queue as they are (the renderer's
|
|
360
|
+
// cached, never-mutated bytes) and integers as strings, so a component-head page keeps the byte path.
|
|
361
|
+
let held = hold ? [] : null;
|
|
362
|
+
const writer = hold
|
|
363
|
+
? {
|
|
364
|
+
write(chunk) {
|
|
365
|
+
if (held)
|
|
366
|
+
return void held.push(chunk);
|
|
367
|
+
return out.write(chunk);
|
|
368
|
+
},
|
|
369
|
+
writeBytes(bytes) {
|
|
370
|
+
if (held)
|
|
371
|
+
return void held.push(bytes);
|
|
372
|
+
return out.writeBytes(bytes);
|
|
373
|
+
},
|
|
374
|
+
writeInteger(value) {
|
|
375
|
+
if (held)
|
|
376
|
+
return void held.push(String(value));
|
|
377
|
+
return out.writeInteger(value);
|
|
378
|
+
},
|
|
379
|
+
writeText(text) {
|
|
380
|
+
if (held)
|
|
381
|
+
return void held.push(escapeText(text));
|
|
382
|
+
return out.writeText(text);
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
: out;
|
|
386
|
+
const release = () => {
|
|
387
|
+
if (!held)
|
|
388
|
+
return null;
|
|
389
|
+
parts = options.document({ ...input, head: hold.head() });
|
|
390
|
+
const pending = held;
|
|
391
|
+
held = null;
|
|
392
|
+
// Synchronous enqueue: the following writes apply backpressure as usual.
|
|
393
|
+
void out.write(parts.start);
|
|
394
|
+
for (const chunk of pending)
|
|
395
|
+
void (typeof chunk === "string" ? out.write(chunk) : out.writeBytes(chunk));
|
|
396
|
+
return null;
|
|
397
|
+
};
|
|
398
|
+
const app = hold ? () => [factory(), release] : factory;
|
|
399
|
+
let ended = false;
|
|
400
|
+
void (async () => {
|
|
401
|
+
try {
|
|
402
|
+
// Await only real waits (backpressure, pending content): a bare `await` would yield to the
|
|
403
|
+
// coalescing microtask and split a synchronous page into several chunks.
|
|
404
|
+
let wait;
|
|
405
|
+
if (!hold && (wait = writer.write(parts.start)))
|
|
406
|
+
await wait;
|
|
407
|
+
if (options.around && (wait = writer.write(options.around.start)))
|
|
408
|
+
await wait;
|
|
409
|
+
const signal = options.signal;
|
|
410
|
+
const variant = options.variant;
|
|
411
|
+
// One id counter per render (`rv0`, `rv1`, …): the same page renders the same ids whatever the
|
|
412
|
+
// process rendered before (reproducible HTML / ETags, incremental prerender canaries). Around the
|
|
413
|
+
// render call, not inside `factory`: the renderer visits what the factory returns (and pending
|
|
414
|
+
// `Async` content later) under this owner, so every component sees the scope.
|
|
415
|
+
wait = withIdScope("rv", () => options.manifest
|
|
416
|
+
? renderResumable(app, options.discardState ? stateless(writer) : writer, {
|
|
417
|
+
manifest: options.manifest,
|
|
418
|
+
// Same-origin URLs in the state serialize relative (prerendered pages: no build-time origin).
|
|
419
|
+
origin: input.url.origin,
|
|
420
|
+
...(signal ? { signal } : {}),
|
|
421
|
+
...(options.nonce !== undefined ? { nonce: options.nonce } : {}),
|
|
422
|
+
...(options.stream && !options.discardState ? { stream: options.stream } : {}),
|
|
423
|
+
...(options.preload ? { preload: options.preload } : {}),
|
|
424
|
+
...(variant ? { variant } : {}),
|
|
425
|
+
})
|
|
426
|
+
: renderToWriter(app, writer, { ...(signal ? { signal } : {}), ...(variant ? { variant } : {}) }));
|
|
427
|
+
if (wait) {
|
|
428
|
+
// Deferred: a failure the renderer already settled (a component threw before any await; the
|
|
429
|
+
// streamed shell's rejection one tick later) must still reach the caller, so probe twice whether
|
|
430
|
+
// the render promise is really pending before opening the Response.
|
|
431
|
+
if (!responded && (await Promise.race([wait, TICK])) === PENDING && (await Promise.race([wait, TICK])) === PENDING)
|
|
432
|
+
arm();
|
|
433
|
+
await wait;
|
|
434
|
+
}
|
|
435
|
+
release();
|
|
436
|
+
const epilogue = options.epilogue?.();
|
|
437
|
+
if (epilogue && (wait = writer.write(epilogue)))
|
|
438
|
+
await wait;
|
|
439
|
+
if (options.around && (wait = writer.write(options.around.end)))
|
|
440
|
+
await wait;
|
|
441
|
+
if ((wait = writer.write(parts.end)))
|
|
442
|
+
await wait;
|
|
443
|
+
flush();
|
|
444
|
+
ended = true;
|
|
445
|
+
out.close();
|
|
446
|
+
if (!responded)
|
|
447
|
+
open();
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
ended = true;
|
|
451
|
+
const abort = () => {
|
|
452
|
+
try {
|
|
453
|
+
out.fail(error);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
/* Already cancelled by the consumer. */
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
// No Response handed out yet: the caller answers (error page, status; or drops an aborted request).
|
|
460
|
+
if (!responded) {
|
|
461
|
+
failure = { error };
|
|
462
|
+
abort();
|
|
463
|
+
return failed?.(error);
|
|
464
|
+
}
|
|
465
|
+
if (options.signal?.aborted)
|
|
466
|
+
return abort();
|
|
467
|
+
const digest = options.onError?.(error) || undefined;
|
|
468
|
+
// Shell already out: end the document cleanly with the marker (no truncated, errored body).
|
|
469
|
+
// Nothing sent yet (`renderPage`) or the shell not rendered (held head): the body errors.
|
|
470
|
+
if (!parts || !sent)
|
|
471
|
+
return abort();
|
|
472
|
+
try {
|
|
473
|
+
void out.write(failureMarker(options.nonce, digest));
|
|
474
|
+
void out.write(parts.end);
|
|
475
|
+
flush();
|
|
476
|
+
out.close();
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
abort();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
// Ended: the hand-off callback is the writer's last tie to this scope (see `bodyStream`).
|
|
484
|
+
out.enqueued = ENDED;
|
|
485
|
+
}
|
|
486
|
+
})();
|
|
487
|
+
if (failure)
|
|
488
|
+
throw failure.error;
|
|
489
|
+
if (responded || queued || ended)
|
|
490
|
+
return respond();
|
|
491
|
+
return new Promise((resolve, reject) => ((opened = resolve), (failed = reject)));
|
|
492
|
+
}
|