@rivetjs/router 0.1.0-alpha.0 → 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.
Files changed (49) hide show
  1. package/adapters/files.d.ts +13 -0
  2. package/adapters/files.js +58 -6
  3. package/adapters/node.d.ts +22 -5
  4. package/adapters/node.js +113 -67
  5. package/adapters/static.js +1 -1
  6. package/adapters/target-build.d.ts +5 -2
  7. package/adapters/vercel.d.ts +4 -2
  8. package/adapters/vercel.js +1 -1
  9. package/build/codegen.d.ts +13 -0
  10. package/build/codegen.js +152 -35
  11. package/build/dev-errors.d.ts +6 -2
  12. package/build/dev-errors.js +15 -4
  13. package/build/lazy-rows.d.ts +19 -0
  14. package/build/lazy-rows.js +59 -0
  15. package/build/migrate.d.ts +20 -4
  16. package/build/migrate.js +300 -61
  17. package/build/reach.d.ts +60 -0
  18. package/build/reach.js +302 -0
  19. package/build/routes.d.ts +9 -4
  20. package/build/routes.js +30 -13
  21. package/build/vite.d.ts +13 -0
  22. package/build/vite.js +165 -7
  23. package/cli/rivet.js +3 -2
  24. package/client/dev.js +17 -0
  25. package/client/index.d.ts +4 -0
  26. package/client/index.js +34 -0
  27. package/client/route-context.d.ts +1 -1
  28. package/index.d.ts +22 -25
  29. package/index.js +2 -1
  30. package/package.json +4 -4
  31. package/server/app.d.ts +7 -1
  32. package/server/app.js +38 -5
  33. package/server/deferred.d.ts +12 -0
  34. package/server/deferred.js +60 -0
  35. package/server/handler.d.ts +56 -12
  36. package/server/handler.js +46 -8
  37. package/server/index.d.ts +1 -1
  38. package/server/module.d.ts +7 -7
  39. package/server/module.js +52 -34
  40. package/server/render.d.ts +2 -0
  41. package/server/render.js +3 -0
  42. package/server/route-cache.d.ts +74 -4
  43. package/server/route-cache.js +77 -6
  44. package/server/shell-build.d.ts +3 -0
  45. package/server/shell-build.js +2 -0
  46. package/server/shell-cache.d.ts +8 -0
  47. package/server/shell-cache.js +836 -0
  48. package/shared/server-exports.d.ts +78 -0
  49. package/shared/server-exports.js +77 -0
@@ -0,0 +1,836 @@
1
+ import { DIRECT_BODY } from "./body.js";
2
+ import { noncedSources } from "../shared/csp-hash.js";
3
+ import { DATA_PARAM } from "../shared/data.js";
4
+ import { deferredHandle } from "./deferred.js";
5
+ const SHELL = Symbol.for("rivet.shell");
6
+ const RENDER_FAILED = Symbol.for("rivet.renderFailed");
7
+ /** Test hook (`globalThis[Symbol.for("rivet.shellTest")] = { holes: n }`): the next `n` hits write a wrong holes `h`. */
8
+ const TEST = Symbol.for("rivet.shellTest");
9
+ const DOCS = "https://rivetjs.severyn.pro/guides/routing/headers-and-caching#partial-prerendering-shell-cache";
10
+ /** The root state script: the start of the shell's tail (`render.ts` `ROOT_STATE`). */
11
+ const ROOT_STATE = '<script type="rivet/state" data-region="0"';
12
+ const END_SCRIPT = "</script>";
13
+ const NONCE_LEAD = ' nonce="';
14
+ /** `locals` members a shell loader may read: the key's partition (locale variant, direction). */
15
+ const PARTITION = new Set(["locale", "dir"]);
16
+ /** Event members a shell loader may read without a note (request-independent for the key). */
17
+ const FREE = new Set(["params", "route", "isDataRequest", "dev", "signal", "depends"]);
18
+ /** Request headers a re-fill never forwards (`route-cache.ts` `VISITOR`). */
19
+ const VISITOR = /^(?:cookie|authorization|forwarded|x-forwarded-.+|x-real-ip|true-client-ip|x-client-ip|cf-.+|fastly-client-ip)$/;
20
+ /** Rejected fills per route before it stops attempting (until `revalidate()`). */
21
+ const STICKY = 3;
22
+ const KIND = "shell";
23
+ const encoder = new TextEncoder();
24
+ const NodeBuffer = globalThis.Buffer;
25
+ const latin1Decoder = NodeBuffer ? null : new TextDecoder("latin1");
26
+ const latin1 = (bytes) => NodeBuffer ? NodeBuffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).latin1Slice() : latin1Decoder.decode(bytes);
27
+ const utf8 = new TextDecoder();
28
+ const attrOf = (nonce) => (nonce ? NONCE_LEAD + nonce + '"' : "");
29
+ const clean = (nonce) => (nonce === undefined ? undefined : nonce.replace(/[^\w+/=-]/g, "") || undefined);
30
+ /** FNV-1a (32 bit) over the bare prefix and the fingerprint text, as 8 hex digits. */
31
+ function fnv(bytes, text) {
32
+ let hash = 0x811c9dc5;
33
+ for (let i = 0; i < bytes.length; i++)
34
+ hash = Math.imul(hash ^ bytes[i], 0x01000193);
35
+ for (let i = 0; i < text.length; i++)
36
+ hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
37
+ return (hash >>> 0).toString(16).padStart(8, "0");
38
+ }
39
+ /** The shell's `h` (after the prefix; part of the stored shell). */
40
+ const shellScript = (nonce, h) => `<script${attrOf(nonce)}>self.$rvh="${h}"</script>`;
41
+ /** Before the first hole: holes rendered against another shell (another `h`) reload the page once. */
42
+ const holesScript = (nonce, h) => `<script${attrOf(nonce)}>(function(h,k){try{if(self.$rvh!==h){if(!sessionStorage.getItem(k)){sessionStorage.setItem(k,"1");stop();location.reload()}}else if(sessionStorage.getItem(k))sessionStorage.removeItem(k)}catch(e){}})("${h}","rivet:shell")</script>`;
43
+ /** A replay that did not reproduce the stored shell: no holes; the page reports it and reloads once (a full render). */
44
+ const mismatchScript = (nonce) => `<script${attrOf(nonce)}>document.dispatchEvent(new CustomEvent("rivet:resume-error",{detail:{phase:"render",error:"Shell mismatch"}}));(function(k){try{if(!sessionStorage.getItem(k)){sessionStorage.setItem(k,"1");stop();location.reload()}}catch(e){}})("rivet:shell")</script>`;
45
+ // Node / Bun / Deno: native memcmp over the views (`Buffer#compare` takes any Uint8Array); elsewhere a byte loop. Measured
46
+ // 2026-10-21 (editorial shell lane): the JS loop over a 12 KB prefix was ~5 % of a hit's CPU.
47
+ const nativeCompare = globalThis.Buffer?.prototype.compare;
48
+ /** The first index `i < take` where `view[i] !== prefix[from + i]`, or -1. */
49
+ function differs(view, prefix, from, take) {
50
+ if (nativeCompare && nativeCompare.call(view, prefix, from, from + take, 0, take) === 0)
51
+ return -1;
52
+ for (let i = 0; i < take; i++)
53
+ if (view[i] !== prefix[from + i])
54
+ return i;
55
+ return -1;
56
+ }
57
+ /**
58
+ * Where the shell ends in `text` (latin1 view of the document so far): after the root state tail — the state script,
59
+ * the loader script and (with pending boundaries, `data-stream`) the stream add-on. -1: not yet (or never).
60
+ */
61
+ export function shellCut(text) {
62
+ const at = text.indexOf(ROOT_STATE);
63
+ if (at < 0)
64
+ return -1;
65
+ const open = text.indexOf(">", at);
66
+ if (open < 0)
67
+ return -1;
68
+ let scripts = text.slice(at, open).includes(' data-stream="') ? 3 : 2;
69
+ for (let from = open;;) {
70
+ const end = text.indexOf(END_SCRIPT, from);
71
+ if (end < 0)
72
+ return -1;
73
+ from = end + END_SCRIPT.length;
74
+ if (--scripts === 0)
75
+ return from;
76
+ }
77
+ }
78
+ /** The outgoing body of a composed / captured document: parks chunks until a consumer (web stream or direct sink). */
79
+ class Relay {
80
+ mode = 0;
81
+ parked = [];
82
+ sink = null;
83
+ controller = null;
84
+ wanted = false;
85
+ ended = false;
86
+ failure = null;
87
+ stream = new ReadableStream({
88
+ start: (controller) => void (this.controller = controller),
89
+ pull: () => this.pull(),
90
+ cancel: () => this.cancel(),
91
+ }, { highWaterMark: 0 });
92
+ /** A chunk (a view the source may reuse once `done` ran). Returns the sink's wait. */
93
+ write(view, done) {
94
+ if (this.ended)
95
+ return void done();
96
+ if (this.mode === 2)
97
+ return this.sink.write(view, done);
98
+ if (this.mode === 1 && this.wanted && !this.parked.length) {
99
+ this.wanted = false;
100
+ this.controller.enqueue(view.slice());
101
+ done();
102
+ return undefined;
103
+ }
104
+ this.parked.push([view, done]);
105
+ return undefined;
106
+ }
107
+ text(text) {
108
+ return text ? this.write(encoder.encode(text), noop) : undefined;
109
+ }
110
+ pull() {
111
+ if (this.mode === 2)
112
+ return;
113
+ this.mode = 1;
114
+ if (this.parked.length) {
115
+ for (const [view, done] of this.parked)
116
+ (this.controller.enqueue(view.slice()), done());
117
+ this.parked = [];
118
+ }
119
+ else if (!this.ended && !this.failure) {
120
+ this.wanted = true;
121
+ return;
122
+ }
123
+ if (this.failure)
124
+ this.controller.error(this.failure.error);
125
+ else if (this.ended)
126
+ this.controller.close();
127
+ }
128
+ attach(sink) {
129
+ if (this.mode !== 0)
130
+ return false;
131
+ this.mode = 2;
132
+ this.sink = sink;
133
+ const parked = this.parked;
134
+ this.parked = [];
135
+ try {
136
+ for (const [view, done] of parked)
137
+ sink.write(view, done);
138
+ }
139
+ catch {
140
+ return true;
141
+ }
142
+ if (this.failure)
143
+ sink.error(this.failure.error);
144
+ else if (this.ended)
145
+ sink.end();
146
+ return true;
147
+ }
148
+ cancel() {
149
+ for (const [, done] of this.parked)
150
+ done();
151
+ this.parked = [];
152
+ this.ended = true;
153
+ this.mode = 1;
154
+ }
155
+ end() {
156
+ if (this.ended)
157
+ return;
158
+ this.ended = true;
159
+ if (this.mode === 2)
160
+ this.sink.end();
161
+ else if (this.mode === 1 && !this.parked.length)
162
+ this.controller.close();
163
+ }
164
+ error(error) {
165
+ if (this.ended)
166
+ return;
167
+ this.failure = { error };
168
+ if (this.mode === 2)
169
+ ((this.ended = true), this.sink.error(error));
170
+ else if (this.mode === 1)
171
+ ((this.ended = true), this.controller.error(error));
172
+ }
173
+ response(headers) {
174
+ const response = new Response(this.stream, { status: 200, headers });
175
+ Object.defineProperty(response, DIRECT_BODY, { value: ((sink) => this.attach(sink)) });
176
+ return response;
177
+ }
178
+ }
179
+ const noop = () => { };
180
+ /** Read a page body: `chunk(view, done)` per chunk (its wait applies backpressure), then `end` / `fail`. */
181
+ function readBody(response, chunk, end, fail) {
182
+ const direct = response[DIRECT_BODY];
183
+ if (direct && !response.body?.locked)
184
+ try {
185
+ if (direct({ write: chunk, end, error: fail }))
186
+ return;
187
+ }
188
+ catch {
189
+ // `chunk` threw on a parked chunk (the consumer stopped): the render ends at its next write.
190
+ return;
191
+ }
192
+ if (!response.body)
193
+ return end();
194
+ const reader = response.body.getReader();
195
+ void (async () => {
196
+ try {
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done)
200
+ return end();
201
+ const wait = chunk(value, noop);
202
+ if (wait)
203
+ await wait;
204
+ }
205
+ }
206
+ catch (error) {
207
+ fail(error);
208
+ }
209
+ })();
210
+ }
211
+ /** Query admission: the URL's query is exactly the key's (unkeyed parameters render without the shell). */
212
+ function keyedQuery(cache, search) {
213
+ if (!search || search === "?")
214
+ return true;
215
+ if (cache.query === true)
216
+ return true;
217
+ if (cache.query === false)
218
+ return false;
219
+ const params = new URLSearchParams(search);
220
+ for (const name of params.keys())
221
+ if (!cache.query.includes(name))
222
+ return false;
223
+ const kept = new URLSearchParams();
224
+ for (const name of cache.query)
225
+ for (const value of params.getAll(name))
226
+ kept.append(name, value);
227
+ return search === "?" + kept;
228
+ }
229
+ /** `URLSearchParams` that notes named reads of parameters outside the rule's key (`get` / `getAll` / `has`). */
230
+ class TrackedParams extends URLSearchParams {
231
+ get(name) {
232
+ this.check(name);
233
+ return super.get(name);
234
+ }
235
+ getAll(name) {
236
+ this.check(name);
237
+ return super.getAll(name);
238
+ }
239
+ has(name, value) {
240
+ this.check(name);
241
+ return value === undefined ? super.has(name) : super.has(name, value);
242
+ }
243
+ check(name) {
244
+ if (this.keyed !== true && !this.keyed.includes(String(name)))
245
+ this.note("url.searchParams." + name);
246
+ }
247
+ }
248
+ /** A copy of `url` whose `searchParams` note named reads outside the key (`query` true: the URL as is). */
249
+ function trackedURL(url, cache, note) {
250
+ if (cache.query === true)
251
+ return url;
252
+ const copy = new URL(url.href);
253
+ const params = new TrackedParams(copy.search);
254
+ params.note = note;
255
+ params.keyed = cache.query === false ? [] : cache.query;
256
+ Object.defineProperty(copy, "searchParams", { value: params, enumerable: false });
257
+ return copy;
258
+ }
259
+ /** One shell rule request: a hit (composed response), a fill (tracked render, stored at the cut) or nothing. */
260
+ class Run {
261
+ constructor(shells, request, cache, resolved, base, path, search, nonce, refill) {
262
+ this.search = search;
263
+ this.phase = null;
264
+ this.link = null;
265
+ this.composer = undefined;
266
+ this.host = shells.host;
267
+ this.shells = shells;
268
+ this.request = request;
269
+ this.cache = cache;
270
+ this.resolved = resolved;
271
+ this.base = base;
272
+ this.path = path;
273
+ this.nonce = clean(nonce);
274
+ this.refill = refill;
275
+ this.resolve = null;
276
+ this.mode = null;
277
+ this.entry = null;
278
+ this.state = "";
279
+ this.key = "";
280
+ this.route = null;
281
+ this.reads = new Set();
282
+ this.extra = [];
283
+ this.data = null;
284
+ this.byURL = [];
285
+ this.deps = undefined;
286
+ this.cut = false;
287
+ this.gate = null;
288
+ this.open = null;
289
+ this.filling = null;
290
+ this.dev = shells.host.dev;
291
+ }
292
+ /** The handler, after guards + `headers()`. */
293
+ begin(route, event, headers) {
294
+ const locals = event.locals;
295
+ const key = this.base + "\0" + String(locals.locale ?? "") + "\0" + String(locals.dir ?? "") + "\0" + KIND;
296
+ if (this.dev)
297
+ return this.fill(route, key);
298
+ const store = this.host.store;
299
+ const now = Date.now();
300
+ const entry = this.refill ? undefined : store.get(key);
301
+ if (entry?.kind === KIND) {
302
+ // Stored with nonce slots (or without): a request with the other kind renders as usual.
303
+ if (entry.nonced !== (this.nonce !== undefined))
304
+ return undefined;
305
+ if (now < entry.stale) {
306
+ if (now >= entry.fresh && !this.shells.fills.has(key) && now >= entry.retry)
307
+ this.shells.refresh(this, entry);
308
+ return this.hit(entry, headers, now < entry.fresh ? "SHELL-HIT" : "SHELL-STALE");
309
+ }
310
+ if (now >= entry.stale)
311
+ store.delete(key);
312
+ }
313
+ if ((store.sticky.get(route.id) ?? 0) >= STICKY || this.shells.fills.has(key))
314
+ return undefined;
315
+ if (!this.refill && this.host.secondMiss(this.cache, this.search, (name) => this.request.headers.get(name)) && !this.host.doorkeeper.has(key)) {
316
+ if (this.host.doorkeeper.size >= 10_000)
317
+ this.host.doorkeeper.clear();
318
+ this.host.doorkeeper.add(key);
319
+ return undefined;
320
+ }
321
+ return this.fill(route, key);
322
+ }
323
+ hit(entry, live, state) {
324
+ this.mode = "hit";
325
+ this.entry = entry;
326
+ entry.used = true;
327
+ const headers = new Headers();
328
+ for (const [name, value] of entry.extra)
329
+ headers.set(name, value);
330
+ live.forEach((value, name) => name !== "set-cookie" && headers.set(name, value));
331
+ for (const cookie of live.getSetCookie())
332
+ headers.append("set-cookie", cookie);
333
+ headers.set("content-type", "text/html; charset=utf-8");
334
+ const policy = this.nonce !== undefined ? this.host.csp.policy(this.nonce) : false;
335
+ if (policy && !headers.has("content-security-policy"))
336
+ headers.set("content-security-policy", policy);
337
+ headers.set("cache-control", this.cache.control);
338
+ if (this.cache.varies.length)
339
+ headers.set("vary", this.cache.varies.join(", "));
340
+ headers.set("x-rivet-cache", state);
341
+ const composer = new Composer(this, entry);
342
+ this.composer = composer;
343
+ this.resolve(this.host.withHeaders(composer.relay.response(headers), this.resolved.headers));
344
+ return { data: entry.data, byURL: entry.byURL, ...(entry.deps ? { deps: entry.deps } : {}) };
345
+ }
346
+ fill(route, key) {
347
+ this.mode = "fill";
348
+ this.key = key;
349
+ this.route = route;
350
+ if (!this.dev) {
351
+ this.shells.fills.add(key);
352
+ this.filling = { path: this.path, tags: this.cache.tags, void: false };
353
+ this.host.store.filling.add(this.filling);
354
+ }
355
+ // Deferred handles deliver only once the shell is out: a shell that waits for one in order (a component rendering
356
+ // the handle, `Async` without a fallback, `Suspense` + `resource`) would freeze one visitor's data into the shell.
357
+ this.gate = new Promise((done) => (this.open = done));
358
+ const label = (index) => (index < route.layouts.length ? `layout "${route.keys?.[index] ?? index}"` : `page "${route.id}"`) + " loader";
359
+ const phase = (this.phase = {
360
+ event: (view, index) => this.track(view, label(index)),
361
+ loaded: (data, byURL, deps) => ((this.data = data), (this.byURL = byURL), (this.deps = deps)),
362
+ handle: (promise, dev) => this.handle(promise, dev),
363
+ url: trackedURL(new URL(this.request.url), this.cache, (what) => this.note("render reads " + what)),
364
+ });
365
+ return phase;
366
+ }
367
+ note(what) {
368
+ this.reads.add(what);
369
+ }
370
+ /** A shell loader's event view: request input outside the key is noted (allow-list: unknown members count as reads). */
371
+ track(view, who) {
372
+ const note = (what) => this.note(who + " reads " + what);
373
+ const varies = this.cache.varies;
374
+ let url, cookies, locals, request;
375
+ return new Proxy(view, {
376
+ get: (target, key) => {
377
+ if (typeof key === "symbol" || FREE.has(key))
378
+ return Reflect.get(target, key, target);
379
+ switch (key) {
380
+ case "url":
381
+ return (url ??= trackedURL(Reflect.get(target, key, target), this.cache, note));
382
+ case "setHeader":
383
+ return (name, value) => (this.extra.push([name.toLowerCase(), value]), target.setHeader(name, value));
384
+ case "setHeaders":
385
+ return (values) => {
386
+ target.setHeaders(values);
387
+ for (const name in values)
388
+ this.extra.push([name.toLowerCase(), values[name]]);
389
+ };
390
+ case "cookies": {
391
+ const jar = target.cookies;
392
+ return (cookies ??= {
393
+ get: (name) => (note("cookies." + name), jar.get(name)),
394
+ getAll: () => (note("cookies"), jar.getAll()),
395
+ set: (name, value, options) => (note("cookies.set"), jar.set(name, value, options)),
396
+ delete: (name, options) => (note("cookies.delete"), jar.delete(name, options)),
397
+ });
398
+ }
399
+ case "locals":
400
+ return (locals ??= new Proxy(target.locals, {
401
+ get: (object, name) => (typeof name === "string" && !PARTITION.has(name) && note("locals." + name), Reflect.get(object, name)),
402
+ has: (object, name) => (typeof name === "string" && !PARTITION.has(name) && note("locals." + name), Reflect.has(object, name)),
403
+ ownKeys: (object) => (note("locals"), Reflect.ownKeys(object)),
404
+ }));
405
+ case "request": {
406
+ const raw = Reflect.get(target, key, target);
407
+ return (request ??= trackedRequest(raw, varies, note));
408
+ }
409
+ default:
410
+ note(key === "platform" ? "platform (client address)" : "event." + key);
411
+ return Reflect.get(target, key, target);
412
+ }
413
+ },
414
+ });
415
+ }
416
+ /** A deferred slot's handle in a fill: gated until the cut; settling before the shell is out = consumed in order. */
417
+ handle(promise, dev) {
418
+ const gated = this.gate.then((error) => (error ? Promise.reject(error) : promise));
419
+ gated.catch(noop);
420
+ const settled = () => setTimeout(() => {
421
+ if (this.cut || !this.open)
422
+ return;
423
+ // Dev check mode (`devResume`): nothing streams, deferred data waits in place by design.
424
+ if (this.phase?.plain)
425
+ return this.release();
426
+ this.note("the shell waits for deferred loader data (rendered outside an `<Async fallback>` boundary)");
427
+ const error = dev
428
+ ? Object.assign(new TypeError("Deferred loader data rendered in the shell of a `shell` route: render it through `<Async load={() => data} fallback={…}>` so it streams as a hole (" +
429
+ DOCS +
430
+ ")"), { name: "DeferredDataError" })
431
+ : undefined;
432
+ this.release(error);
433
+ }, 0);
434
+ promise.then(settled, settled);
435
+ return deferredHandle(gated, dev);
436
+ }
437
+ release(error) {
438
+ const open = this.open;
439
+ this.open = null;
440
+ open?.(error);
441
+ }
442
+ /** The handler's answer: hit → the replay (the composed response is out); fill → captured; else as rendered. */
443
+ answer(handle) {
444
+ return new Promise((resolve, reject) => {
445
+ this.resolve = resolve;
446
+ handle().then((response) => {
447
+ if (this.composer)
448
+ this.composer.replay(response);
449
+ else if (this.mode === "fill")
450
+ this.capture(response).then(resolve, reject);
451
+ else
452
+ resolve(this.host.withHeaders(response, this.resolved.headers));
453
+ }, (error) => {
454
+ this.release();
455
+ this.done();
456
+ if (this.composer)
457
+ this.composer.fail(error);
458
+ else
459
+ reject(error);
460
+ });
461
+ });
462
+ }
463
+ done() {
464
+ if (this.filling)
465
+ this.host.store.filling.delete(this.filling);
466
+ this.filling = null;
467
+ if (this.mode === "fill" && !this.dev)
468
+ this.shells.fills.delete(this.key);
469
+ }
470
+ /** Fill: hold the document until the cut, decide, store; answer with the bytes (and the `h` scripts when stored). */
471
+ capture(response) {
472
+ const type = response.headers.get("content-type") ?? "";
473
+ if (response.status !== 200 || !/^text\/html\b/i.test(type) || !response.body) {
474
+ this.release();
475
+ this.done();
476
+ this.reject([`status ${response.status}`]);
477
+ return Promise.resolve(this.host.withHeaders(response, this.resolved.headers));
478
+ }
479
+ return new Promise((resolve) => {
480
+ const relay = new Relay();
481
+ const held = [];
482
+ let text = "";
483
+ let answered = false;
484
+ this.link = response.headers.get("link");
485
+ const answer = (state) => {
486
+ answered = true;
487
+ const headers = new Headers(response.headers);
488
+ headers.set("cache-control", this.cache.control);
489
+ if (state)
490
+ headers.set("x-rivet-cache", state.replace(/[^\x20-\x7e]/g, "?"));
491
+ resolve(this.host.withHeaders(relay.response(headers), this.resolved.headers));
492
+ };
493
+ const flushHeld = (insert, at) => {
494
+ let offset = 0;
495
+ for (const chunk of held) {
496
+ const end = offset + chunk.length;
497
+ if (insert && at >= offset && at < end) {
498
+ if (at > offset)
499
+ relay.write(chunk.subarray(0, at - offset), noop);
500
+ relay.text(insert);
501
+ relay.write(chunk.subarray(at - offset), noop);
502
+ insert = "";
503
+ }
504
+ else
505
+ relay.write(chunk, noop);
506
+ offset = end;
507
+ }
508
+ if (insert)
509
+ relay.text(insert);
510
+ held.length = 0;
511
+ };
512
+ const dev = this.dev;
513
+ const reason = () => (this.reads.size ? "SHELL-BYPASS " + [...this.reads][0] : null);
514
+ readBody(response, (view, done) => {
515
+ if (this.cut)
516
+ return relay.write(view, done);
517
+ // Before the cut: held (copied: the source reuses its slabs) until the shell is complete.
518
+ const copy = view.slice();
519
+ done();
520
+ held.push(copy);
521
+ text += latin1(copy);
522
+ const at = shellCut(text);
523
+ if (at < 0)
524
+ return undefined;
525
+ this.cut = true;
526
+ this.release();
527
+ const prefix = new Uint8Array(at);
528
+ let offset = 0;
529
+ for (const chunk of held) {
530
+ const take = Math.min(chunk.length, at - offset);
531
+ if (take <= 0)
532
+ break;
533
+ prefix.set(chunk.subarray(0, take), offset);
534
+ offset += take;
535
+ }
536
+ const h = this.decide(prefix, text.slice(0, at));
537
+ this.done();
538
+ flushHeld(h ? shellScript(this.nonce, h) + holesScript(this.nonce, h) : "", at);
539
+ // Dev probe (never stored): the header names the first reason the shell would not be stored.
540
+ answer(dev ? reason() : h ? (this.refill ? "SHELL-REFILL" : "SHELL-MISS") : null);
541
+ return undefined;
542
+ }, () => {
543
+ if (!this.cut) {
544
+ this.release();
545
+ this.done();
546
+ if (dev)
547
+ this.decide(new Uint8Array(0), "");
548
+ else
549
+ this.reject(["no resume state tail (the page has no resumable state, or failed before its shell ended)"]);
550
+ flushHeld("", -1);
551
+ if (!answered)
552
+ answer(dev ? reason() : null);
553
+ }
554
+ relay.end();
555
+ }, (error) => {
556
+ this.release();
557
+ this.done();
558
+ if (!answered) {
559
+ flushHeld("", -1);
560
+ answer(null);
561
+ }
562
+ relay.error(error);
563
+ });
564
+ });
565
+ }
566
+ /** At the cut: store the shell (returns its `h`) or say why not. */
567
+ decide(prefix, text) {
568
+ const why = [...this.reads];
569
+ if (this.dev) {
570
+ if (why.length)
571
+ this.shells.log(this.route.id, why);
572
+ return null;
573
+ }
574
+ if (text.includes('<script type="rivet/boundaries"'))
575
+ why.push("the derived resume model is not supported by the shell cache yet");
576
+ if (this.request[RENDER_FAILED] === true)
577
+ why.push("the shell render reported a failure");
578
+ if (this.filling?.void)
579
+ why.push("revalidate() ran during the fill");
580
+ if (this.data === null)
581
+ why.push("no loader phase");
582
+ let bare = prefix;
583
+ const slots = [];
584
+ const nonce = this.nonce;
585
+ if (nonce) {
586
+ const found = noncedSources(text, nonce, true, (from, to) => utf8.decode(prefix.subarray(from, to)));
587
+ if (!found)
588
+ why.push("its CSP nonce appears outside the renderer's script / style tags");
589
+ else if (found.attrs.length) {
590
+ const cut = NONCE_LEAD.length + nonce.length + 1;
591
+ bare = new Uint8Array(prefix.length - found.attrs.length * cut);
592
+ let from = 0, to = 0;
593
+ for (const at of found.attrs) {
594
+ bare.set(prefix.subarray(from, at), to);
595
+ to += at - from;
596
+ slots.push(to);
597
+ from = at + cut;
598
+ }
599
+ bare.set(prefix.subarray(from), to);
600
+ }
601
+ }
602
+ if (why.length) {
603
+ this.reject(why);
604
+ return null;
605
+ }
606
+ const route = this.route;
607
+ const h = fnv(bare, this.host.build + "\0" + route.id + "\0" + this.key);
608
+ let estimate = 0;
609
+ try {
610
+ estimate = JSON.stringify(this.data)?.length ?? 0;
611
+ }
612
+ catch {
613
+ estimate = 1024;
614
+ }
615
+ const now = Date.now();
616
+ const store = this.host.store;
617
+ const entry = {
618
+ kind: KIND,
619
+ key: this.key,
620
+ path: this.path,
621
+ tags: this.cache.tags,
622
+ body: bare,
623
+ headers: this.extra,
624
+ etag: "",
625
+ modified: "",
626
+ modifiedAt: 0,
627
+ created: now,
628
+ fresh: now + this.cache.maxAge * 1000,
629
+ stale: now + (this.cache.maxAge + this.cache.swr) * 1000,
630
+ used: false,
631
+ size: bare.length + this.key.length * 2 + estimate,
632
+ failures: 0,
633
+ retry: 0,
634
+ charge: (bytes) => store.grow(entry, bytes),
635
+ bare,
636
+ slots,
637
+ nonced: nonce !== undefined,
638
+ h,
639
+ route: route.id,
640
+ data: this.data,
641
+ byURL: this.byURL,
642
+ deps: this.deps,
643
+ extra: this.link !== null && !this.extra.some(([name]) => name === "link") ? [...this.extra, ["link", this.link]] : this.extra,
644
+ };
645
+ store.set(entry);
646
+ store.sticky.delete(route.id);
647
+ return h;
648
+ }
649
+ reject(why) {
650
+ if (this.dev || !this.route)
651
+ return void (this.route && why.length && this.shells.log(this.route.id, why));
652
+ const store = this.host.store;
653
+ store.sticky.set(this.route.id, (store.sticky.get(this.route.id) ?? 0) + 1);
654
+ if (this.refill)
655
+ this.refill.retry = Date.now() + Math.min(1000 * 2 ** this.refill.failures++, 300_000);
656
+ }
657
+ }
658
+ /** A request's headers as a shell loader sees them: named reads outside `varies` and any listing are noted. */
659
+ function trackedRequest(request, varies, note) {
660
+ const headers = request.headers;
661
+ const named = (name) => {
662
+ const lower = String(name).toLowerCase();
663
+ if (!varies.includes(lower))
664
+ note("request.headers." + lower);
665
+ };
666
+ const trackedHeaders = new Proxy(headers, {
667
+ get: (target, key) => {
668
+ if (key === "get")
669
+ return (name) => (named(name), target.get(name));
670
+ if (key === "has")
671
+ return (name) => (named(name), target.has(name));
672
+ const value = Reflect.get(target, key, target);
673
+ if (typeof value === "function") {
674
+ note("request.headers");
675
+ return value.bind(target);
676
+ }
677
+ return value;
678
+ },
679
+ });
680
+ return new Proxy(request, {
681
+ get: (target, key) => {
682
+ if (key === "headers")
683
+ return trackedHeaders;
684
+ if (typeof key === "string" && key !== "url" && key !== "method" && key !== "signal")
685
+ note("request." + key);
686
+ const value = Reflect.get(target, key, target);
687
+ return typeof value === "function" ? value.bind(target) : value;
688
+ },
689
+ });
690
+ }
691
+ /** A hit: the stored prefix with the live nonce (out at once), then the replay's holes once its prefix matched. */
692
+ class Composer {
693
+ constructor(run, entry) {
694
+ this.run = run;
695
+ this.entry = entry;
696
+ this.relay = new Relay();
697
+ const nonce = run.nonce;
698
+ let prefix = entry.bare;
699
+ if (nonce && entry.slots.length) {
700
+ const attr = encoder.encode(attrOf(nonce));
701
+ prefix = new Uint8Array(entry.bare.length + entry.slots.length * attr.length);
702
+ let from = 0, to = 0;
703
+ for (const at of entry.slots) {
704
+ prefix.set(entry.bare.subarray(from, at), to);
705
+ to += at - from;
706
+ prefix.set(attr, to);
707
+ to += attr.length;
708
+ from = at;
709
+ }
710
+ prefix.set(entry.bare.subarray(from), to);
711
+ }
712
+ this.prefix = prefix;
713
+ this.relay.write(prefix, noop);
714
+ this.relay.text(shellScript(nonce, entry.h));
715
+ }
716
+ /** The replay's document: compared with the stored prefix (never sent), the rest streams after the holes script. */
717
+ replay(response) {
718
+ const run = this.run, relay = this.relay, prefix = this.prefix;
719
+ run.done();
720
+ if (response.status !== 200 || !response.body) {
721
+ void response.body?.cancel().catch(noop);
722
+ return this.mismatch(`status ${response.status}`);
723
+ }
724
+ let matched = 0;
725
+ let broken = false;
726
+ readBody(response, (view, done) => {
727
+ if (broken) {
728
+ done();
729
+ throw new TypeError("shell mismatch");
730
+ }
731
+ if (matched === prefix.length)
732
+ return relay.write(view, done);
733
+ const take = Math.min(view.length, prefix.length - matched);
734
+ const at = differs(view, prefix, matched, take);
735
+ if (at >= 0) {
736
+ broken = true;
737
+ done();
738
+ this.mismatch("the replay's shell differs from the stored one (byte " + (matched + at) + ")");
739
+ throw new TypeError("shell mismatch");
740
+ }
741
+ matched += take;
742
+ if (matched < prefix.length)
743
+ return void done();
744
+ const test = globalThis[TEST];
745
+ const h = test?.holes ? (test.holes--, this.entry.h + "-") : this.entry.h;
746
+ relay.text(holesScript(run.nonce, h));
747
+ if (take === view.length)
748
+ return void done();
749
+ return relay.write(view.subarray(take), done);
750
+ }, () => {
751
+ if (!broken && matched < prefix.length)
752
+ return this.mismatch("the replay ended before its shell did");
753
+ relay.end();
754
+ }, (error) => {
755
+ if (broken)
756
+ return;
757
+ if (matched < prefix.length)
758
+ return this.mismatch(error instanceof Error ? error.message : String(error));
759
+ relay.error(error);
760
+ });
761
+ }
762
+ /** Never holes against a mismatched shell: drop the entry, stop filling the route, the page reloads once. */
763
+ mismatch(why) {
764
+ const run = this.run, store = run.host.store;
765
+ if (store.get(this.entry.key) === this.entry)
766
+ store.delete(this.entry.key);
767
+ store.sticky.set(this.entry.route, STICKY);
768
+ run.shells.log(this.entry.route, ["shell mismatch on a hit: " + why], true);
769
+ this.relay.text(mismatchScript(run.nonce));
770
+ this.relay.end();
771
+ }
772
+ fail(error) {
773
+ this.mismatch(error instanceof Error ? error.message : String(error));
774
+ }
775
+ }
776
+ /** The shell cache of one `withRouteRules` store. */
777
+ class Shells {
778
+ fills = new Set();
779
+ logged = new Set();
780
+ constructor(host) {
781
+ this.host = host;
782
+ }
783
+ /** Why a route's shell is not stored: dev logs every new reason once per route; a mismatch always warns (bounded). */
784
+ log(route, why, always = false) {
785
+ if (!this.host.dev && !always)
786
+ return;
787
+ const key = route + "\0" + why.join("\0");
788
+ if (this.logged.has(key) || this.logged.size >= 256)
789
+ return;
790
+ this.logged.add(key);
791
+ console.warn(`[rivet] routeRules shell: route "${route}" not stored: ${why.join("; ")} (${DOCS})`);
792
+ }
793
+ /** Stale entry: one background re-fill as a detached, anonymous request (no credentials, no visitor headers). */
794
+ refresh(from, entry) {
795
+ const sent = new Headers();
796
+ from.request.headers.forEach((value, name) => {
797
+ if (!VISITOR.test(name) || from.cache.varies.includes(name))
798
+ sent.set(name, value);
799
+ });
800
+ const detached = new Request(from.request.url, { headers: sent });
801
+ const run = new Run(this, detached, from.cache, from.resolved, from.base, from.path, from.search, from.nonce !== undefined ? freshNonce() : undefined, entry);
802
+ detached[SHELL] = run;
803
+ const context = run.nonce !== undefined ? { nonce: run.nonce } : {};
804
+ run
805
+ .answer(() => this.host.handler(detached, context))
806
+ .then((response) => {
807
+ if (response.headers.get("x-rivet-cache") !== "SHELL-REFILL")
808
+ entry.retry = Date.now() + Math.min(1000 * 2 ** entry.failures++, 300_000);
809
+ return response.body?.cancel();
810
+ })
811
+ .catch(() => void (entry.retry = Date.now() + Math.min(1000 * 2 ** entry.failures++, 300_000)));
812
+ }
813
+ }
814
+ const freshNonce = () => {
815
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
816
+ let text = "";
817
+ for (const byte of bytes)
818
+ text += String.fromCharCode(byte);
819
+ return btoa(text);
820
+ };
821
+ /** The route rules' shell entry point (`withRouteRules`): undefined = render without the shell. */
822
+ export function createShells(host) {
823
+ const shells = new Shells(host);
824
+ return (request, context, resolved, origin, path, search) => {
825
+ const cache = resolved.shell;
826
+ if (request.method !== "GET" || search.includes(DATA_PARAM) || (host.fixed !== undefined && origin !== host.fixed))
827
+ return undefined;
828
+ // Unkeyed query parameters: the page may depend on them, so it renders as usual (no shell, no fill).
829
+ if (!keyedQuery(cache, search))
830
+ return undefined;
831
+ const header = (name) => request.headers.get(name);
832
+ const run = new Run(shells, request, cache, resolved, host.keyOf(cache, origin, path, search, header), path, search, context?.nonce, null);
833
+ request[SHELL] = run;
834
+ return run.answer(() => host.handler(request, context));
835
+ };
836
+ }