@rivetjs/router 0.0.0-stage → 0.1.0-alpha.0
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 +7 -0
- package/adapters/files.js +103 -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 +163 -0
- package/adapters/node.js +940 -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 +92 -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 +33 -0
- package/adapters/vercel.js +45 -0
- package/build/codegen.d.ts +84 -0
- package/build/codegen.js +920 -0
- package/build/dev-errors.d.ts +19 -0
- package/build/dev-errors.js +236 -0
- package/build/migrate.d.ts +15 -0
- package/build/migrate.js +146 -0
- package/build/route-css.d.ts +54 -0
- package/build/route-css.js +216 -0
- package/build/routes.d.ts +50 -0
- package/build/routes.js +202 -0
- package/build/sync.d.ts +20 -0
- package/build/sync.js +16 -0
- package/build/vite.d.ts +195 -0
- package/build/vite.js +703 -0
- package/cli/rivet.d.ts +2 -0
- package/cli/rivet.js +100 -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 +74 -0
- package/client/index.d.ts +152 -0
- package/client/index.js +680 -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 +138 -0
- package/index.js +7 -0
- package/package.json +156 -4
- package/server/app.d.ts +169 -0
- package/server/app.js +302 -0
- package/server/body.d.ts +20 -0
- package/server/body.js +8 -0
- package/server/early-hints.d.ts +34 -0
- package/server/early-hints.js +63 -0
- package/server/handler.d.ts +196 -0
- package/server/handler.js +623 -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 +78 -0
- package/server/render.d.ts +94 -0
- package/server/render.js +489 -0
- package/server/route-cache.d.ts +175 -0
- package/server/route-cache.js +742 -0
- package/server/security.d.ts +18 -0
- package/server/security.js +47 -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/slash.d.ts +8 -0
- package/shared/slash.js +24 -0
package/server/render.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
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
|
+
if (options.around && (wait = writer.write(options.around.end)))
|
|
437
|
+
await wait;
|
|
438
|
+
if ((wait = writer.write(parts.end)))
|
|
439
|
+
await wait;
|
|
440
|
+
flush();
|
|
441
|
+
ended = true;
|
|
442
|
+
out.close();
|
|
443
|
+
if (!responded)
|
|
444
|
+
open();
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
ended = true;
|
|
448
|
+
const abort = () => {
|
|
449
|
+
try {
|
|
450
|
+
out.fail(error);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
/* Already cancelled by the consumer. */
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
// No Response handed out yet: the caller answers (error page, status; or drops an aborted request).
|
|
457
|
+
if (!responded) {
|
|
458
|
+
failure = { error };
|
|
459
|
+
abort();
|
|
460
|
+
return failed?.(error);
|
|
461
|
+
}
|
|
462
|
+
if (options.signal?.aborted)
|
|
463
|
+
return abort();
|
|
464
|
+
const digest = options.onError?.(error) || undefined;
|
|
465
|
+
// Shell already out: end the document cleanly with the marker (no truncated, errored body).
|
|
466
|
+
// Nothing sent yet (`renderPage`) or the shell not rendered (held head): the body errors.
|
|
467
|
+
if (!parts || !sent)
|
|
468
|
+
return abort();
|
|
469
|
+
try {
|
|
470
|
+
void out.write(failureMarker(options.nonce, digest));
|
|
471
|
+
void out.write(parts.end);
|
|
472
|
+
flush();
|
|
473
|
+
out.close();
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
abort();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
// Ended: the hand-off callback is the writer's last tie to this scope (see `bodyStream`).
|
|
481
|
+
out.enqueued = ENDED;
|
|
482
|
+
}
|
|
483
|
+
})();
|
|
484
|
+
if (failure)
|
|
485
|
+
throw failure.error;
|
|
486
|
+
if (responded || queued || ended)
|
|
487
|
+
return respond();
|
|
488
|
+
return new Promise((resolve, reject) => ((opened = resolve), (failed = reject)));
|
|
489
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { AppHandler } from "./handler.js";
|
|
2
|
+
import type { CSPOptions } from "../shared/csp.js";
|
|
3
|
+
/** How a matched route's rendered pages are cached (`RouteRule.cache`). */
|
|
4
|
+
export interface CacheRule {
|
|
5
|
+
/** Seconds a stored page is served without rendering (fresh). */
|
|
6
|
+
readonly maxAge: number;
|
|
7
|
+
/**
|
|
8
|
+
* Seconds after `maxAge` the stored page is still served while one background render refreshes it
|
|
9
|
+
* (stale-while-revalidate); `true`: without limit. Default 0 (an expired page renders on the request).
|
|
10
|
+
*/
|
|
11
|
+
readonly swr?: number | true;
|
|
12
|
+
/** Names `revalidate({ tag })` drops these pages by. */
|
|
13
|
+
readonly tags?: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* Request headers whose values key separate copies (`accept-language`, …); case-insensitive; never `cookie` /
|
|
16
|
+
* `authorization`. Sent as `Vary` with every stored page. A response `Vary` naming other headers adds them to the key
|
|
17
|
+
* too; one naming `cookie` / `authorization` (the i18n guard's) is stored only when `varies` names a header.
|
|
18
|
+
*/
|
|
19
|
+
readonly varies?: readonly string[];
|
|
20
|
+
/**
|
|
21
|
+
* Query strings: `true` (default) keys a copy per query string (a new query string is stored on its second
|
|
22
|
+
* request), `false` ignores the query (one copy per path), a list keys only those parameters.
|
|
23
|
+
*/
|
|
24
|
+
readonly query?: boolean | readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* `true`: requests with a `Cookie` header are served from the cache too (cookies are not part of the key) — and every
|
|
27
|
+
* page of the rule renders without the request's `Cookie` / `Authorization` (anonymous: a loader never sees a session),
|
|
28
|
+
* so one visitor's page is never stored for everyone. Default: requests with cookies bypass the cache.
|
|
29
|
+
*/
|
|
30
|
+
readonly cookies?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** What shared caches / CDNs are told (`RouteRule.cdn`): `s-maxage` / `stale-while-revalidate` seconds. */
|
|
33
|
+
export interface CdnRule {
|
|
34
|
+
readonly maxAge: number;
|
|
35
|
+
readonly swr?: number | true;
|
|
36
|
+
}
|
|
37
|
+
/** What applies to the requests whose path matches a pattern. */
|
|
38
|
+
export interface RouteRule {
|
|
39
|
+
/** Response headers set on every response of the matched paths (pages, data requests, endpoints, 304s). */
|
|
40
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
41
|
+
/** Cache rendered pages; `false` turns off a less specific rule's cache. */
|
|
42
|
+
readonly cache?: CacheRule | false;
|
|
43
|
+
/** Shorthand: `swr: n` = `cache: { maxAge: n, swr: true }`. */
|
|
44
|
+
readonly swr?: number | true;
|
|
45
|
+
/**
|
|
46
|
+
* Shorthand: `isr: n` = `cache: { maxAge: n, swr: true }`; `isr: true` = stored until `revalidate()`. Needs a fixed
|
|
47
|
+
* `origin` (`routeCache.origin`, or the adapter's). CDNs get `s-maxage=0, must-revalidate` (`revalidate()` is
|
|
48
|
+
* process-local: it cannot purge a CDN) unless `cdn` says otherwise.
|
|
49
|
+
*/
|
|
50
|
+
readonly isr?: number | true;
|
|
51
|
+
/**
|
|
52
|
+
* `Cache-Control` for shared caches / CDNs of the stored pages (`s-maxage`, `stale-while-revalidate`). Default: the
|
|
53
|
+
* cache's own `maxAge` / `swr`, `isr` (and `maxAge: Infinity`) rules `s-maxage=0, must-revalidate`.
|
|
54
|
+
*/
|
|
55
|
+
readonly cdn?: CdnRule;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Rules by path pattern (pathname below the app's base): `/about` exact, `*` one segment (`/blog/*`), a trailing
|
|
59
|
+
* `/**` the path and everything below it (`/docs/**`, `/**`). Every matching rule applies, the less specific first
|
|
60
|
+
* (fewer literal characters): headers merge by name, the most specific `cache` (or shorthand) wins.
|
|
61
|
+
*/
|
|
62
|
+
export type RouteRules = Readonly<Record<string, RouteRule>>;
|
|
63
|
+
/** Bounds of the in-process page store (`createApp({ routeCache })`). */
|
|
64
|
+
export interface RouteCacheOptions {
|
|
65
|
+
/** Stored bytes — bodies, their br / gzip copies, keys, headers (default 32 MiB); a page over a quarter of it is never stored. */
|
|
66
|
+
readonly maxBytes?: number;
|
|
67
|
+
/** Stored pages (default 5000). */
|
|
68
|
+
readonly maxEntries?: number;
|
|
69
|
+
/**
|
|
70
|
+
* The public origin pages are stored for (`https://shop.example`); requests for another origin bypass the cache.
|
|
71
|
+
* Required by `isr` rules. Without it the request's origin (from `Host` / trusted forwarded headers) is part of the
|
|
72
|
+
* key: a forged `Host` renders and stores a copy of its own (never served to other hosts) — set it, or a fixed
|
|
73
|
+
* `origin` on the node / fetch adapter (`start()` / `createFetchHandler()` pass theirs on).
|
|
74
|
+
*/
|
|
75
|
+
readonly origin?: string;
|
|
76
|
+
}
|
|
77
|
+
/** A stored page (exported for the node adapter's fast path). */
|
|
78
|
+
export interface CachedPage {
|
|
79
|
+
readonly key: string;
|
|
80
|
+
/** Pathname below the base (decoded). */
|
|
81
|
+
readonly path: string;
|
|
82
|
+
readonly tags: readonly string[];
|
|
83
|
+
/** The stored body (a nonce-free document when the app's policy was converted to hashes). */
|
|
84
|
+
readonly body: Uint8Array;
|
|
85
|
+
/** Response headers (lower-case names): content type, cache-control, CSP, validators, rule headers. */
|
|
86
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
87
|
+
readonly etag: string;
|
|
88
|
+
/** `Last-Modified` (HTTP date) and its time (ms, second precision). */
|
|
89
|
+
readonly modified: string;
|
|
90
|
+
readonly modifiedAt: number;
|
|
91
|
+
/** Times (ms): stored, fresh until, served stale until. */
|
|
92
|
+
readonly created: number;
|
|
93
|
+
readonly fresh: number;
|
|
94
|
+
readonly stale: number;
|
|
95
|
+
/** CLOCK reference bit. */
|
|
96
|
+
used: boolean;
|
|
97
|
+
/** Bytes counted against `maxBytes` (body, key, headers, `charge`d copies). */
|
|
98
|
+
size: number;
|
|
99
|
+
/** Background refreshes that failed in a row, and when the next may start (ms; exponential backoff). */
|
|
100
|
+
failures: number;
|
|
101
|
+
retry: number;
|
|
102
|
+
/** Count `bytes` more against the store's `maxBytes` (an adapter's compressed copy of the body). */
|
|
103
|
+
charge(bytes: number): void;
|
|
104
|
+
}
|
|
105
|
+
/** Request header lookup (lower-case name). */
|
|
106
|
+
export type HeaderOf = (name: string) => string | null | undefined;
|
|
107
|
+
/** Fresh stored page for a GET / HEAD request, if any (node adapter fast path: before a `Request` exists). `origin`: the request URL's. */
|
|
108
|
+
export type CacheLookup = (method: string, origin: string, pathname: string, search: string, header: HeaderOf) => CachedPage | undefined;
|
|
109
|
+
/** Own property of a handler with route rules: the cache lookup. */
|
|
110
|
+
export declare const ROUTE_CACHE: unique symbol;
|
|
111
|
+
/** A handler with route rules. */
|
|
112
|
+
export type CachingHandler = AppHandler & {
|
|
113
|
+
readonly [ROUTE_CACHE]: CacheLookup;
|
|
114
|
+
};
|
|
115
|
+
interface Cache {
|
|
116
|
+
readonly maxAge: number;
|
|
117
|
+
readonly swr: number;
|
|
118
|
+
readonly tags: readonly string[];
|
|
119
|
+
readonly varies: readonly string[];
|
|
120
|
+
readonly query: boolean | readonly string[];
|
|
121
|
+
readonly cookies: boolean;
|
|
122
|
+
/** The `Cache-Control` stored pages answer with. */
|
|
123
|
+
readonly control: string;
|
|
124
|
+
/** Headers responses named in `Vary` (beyond `varies`): part of the key from then on. */
|
|
125
|
+
readonly learned: string[];
|
|
126
|
+
}
|
|
127
|
+
interface Resolved {
|
|
128
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
129
|
+
readonly cache: Cache | undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compile `rules` (throws `TypeError` on a malformed pattern or rule) into a pathname → rule lookup. `origin`: a fixed
|
|
133
|
+
* origin is configured (`isr` rules need one).
|
|
134
|
+
*/
|
|
135
|
+
export declare function compileRules(rules: RouteRules, origin?: boolean): (pathname: string) => Resolved | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* The router's view of a raw pathname: `match` — segments percent-decoded (a decoded `/` stays `%2F`: one segment),
|
|
138
|
+
* what rules match and keys use; `canonical` — the raw path is that path's one encoding (only those are stored, so
|
|
139
|
+
* `/%61dmin` neither escapes an `/admin/**` rule nor adds a copy of `/admin`). Malformed escapes: not canonical.
|
|
140
|
+
*/
|
|
141
|
+
export declare function normalPath(pathname: string): {
|
|
142
|
+
readonly match: string;
|
|
143
|
+
readonly canonical: boolean;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Drop stored pages (every app's in this process): by `path` (pathname, with or without the app's base; a trailing
|
|
147
|
+
* `/**` drops everything below it) and / or `tag` (`CacheRule.tags`). The next request renders; a render already
|
|
148
|
+
* running does not store its page. Returns how many pages were dropped here. Node `start({ workers })`: the other
|
|
149
|
+
* workers drop theirs too (relayed by the primary, asynchronously). Other servers and CDNs keep theirs until they
|
|
150
|
+
* expire.
|
|
151
|
+
*/
|
|
152
|
+
export declare function revalidate(target: {
|
|
153
|
+
readonly path?: string;
|
|
154
|
+
readonly tag?: string;
|
|
155
|
+
}): number;
|
|
156
|
+
/** `If-None-Match` (weak comparison) / `If-Modified-Since` (when no `If-None-Match`) hold for `page`. */
|
|
157
|
+
export declare function notModified(page: CachedPage, match: string | null | undefined, since: string | null | undefined): boolean;
|
|
158
|
+
/** Content-Security-Policy handling of stored pages (from `createApp` `csp`). */
|
|
159
|
+
export interface CachePolicy {
|
|
160
|
+
/** The app's policy for a nonce (`false`: none). A stored response whose policy differs (a route's own) is kept as rendered. */
|
|
161
|
+
readonly policy: (nonce: string) => string | false;
|
|
162
|
+
/**
|
|
163
|
+
* Convert the app's nonce policy to hashes of the page's nonce'd inline code (default / `CSPOptions` apps); `null`:
|
|
164
|
+
* no conversion (a `csp` function) — a page whose policy allows its nonce is then not stored (one nonce for every visitor).
|
|
165
|
+
*/
|
|
166
|
+
readonly hash: CSPOptions | null;
|
|
167
|
+
}
|
|
168
|
+
/** Own property the app sets on a request whose render reported a failure (`onError`: an error fallback was rendered). */
|
|
169
|
+
export declare const RENDER_FAILED: unique symbol;
|
|
170
|
+
/**
|
|
171
|
+
* Wrap the app's handler with route rules. `base`: the app's base path (stripped before matching). The result
|
|
172
|
+
* carries `ROUTE_CACHE` (node adapter fast path).
|
|
173
|
+
*/
|
|
174
|
+
export declare function withRouteRules(handler: AppHandler, rules: RouteRules, csp: CachePolicy, base?: string, options?: RouteCacheOptions): CachingHandler;
|
|
175
|
+
export {};
|