@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
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { DIRECT_BODY } from "./body.js";
|
|
2
|
+
import { hashPolicy, noncedSources } from "../shared/csp-hash.js";
|
|
3
|
+
import { DATA_PARAM } from "../shared/data.js";
|
|
4
|
+
/** Own property of a handler with route rules: the cache lookup. */
|
|
5
|
+
export const ROUTE_CACHE = Symbol.for("rivet.routeCache");
|
|
6
|
+
const RULE_KEYS = new Set(["headers", "cache", "swr", "isr", "cdn", "shell"]);
|
|
7
|
+
const SHELL_KEYS = new Set(["maxAge", "swr", "tags", "varies", "query"]);
|
|
8
|
+
const CREDENTIALS = new Set(["cookie", "authorization"]);
|
|
9
|
+
// Request headers a fill / refresh never forwards (unless the rule's `varies` names them): credentials and what
|
|
10
|
+
// identifies the visitor behind a proxy — a stored page must not depend on the visitor whose miss rendered it.
|
|
11
|
+
const VISITOR = /^(?:cookie|authorization|forwarded|x-forwarded-.+|x-real-ip|true-client-ip|x-client-ip|cf-.+|fastly-client-ip)$/;
|
|
12
|
+
const CACHE_KEYS = new Set(["maxAge", "swr", "tags", "varies", "query", "cookies"]);
|
|
13
|
+
const YEAR = 31536000;
|
|
14
|
+
const seconds = (value, what) => {
|
|
15
|
+
if (typeof value !== "number" || !(value >= 0))
|
|
16
|
+
throw new TypeError(`routeRules: ${what} must be a number of seconds ≥ 0 (got ${String(value)})`);
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
const strings = (value, what) => {
|
|
20
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item))
|
|
21
|
+
throw new TypeError(`routeRules: ${what} must be a list of names`);
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
/** Check and normalise one rule's cache (shorthands → `CacheRule`). */
|
|
25
|
+
function cacheOf(rule, pattern, origin) {
|
|
26
|
+
const where = `"${pattern}"`;
|
|
27
|
+
const given = [rule.cache !== undefined, rule.swr !== undefined, rule.isr !== undefined].filter(Boolean).length;
|
|
28
|
+
if (given > 1)
|
|
29
|
+
throw new TypeError(`routeRules ${where}: give one of cache, swr, isr`);
|
|
30
|
+
let cache;
|
|
31
|
+
if (rule.cdn !== undefined && (given === 0 || rule.cache === false))
|
|
32
|
+
throw new TypeError(`routeRules ${where}: cdn needs a cache (cache, swr or isr)`);
|
|
33
|
+
if (rule.cache === false)
|
|
34
|
+
return false;
|
|
35
|
+
if (rule.cache !== undefined) {
|
|
36
|
+
if (typeof rule.cache !== "object" || rule.cache === null)
|
|
37
|
+
throw new TypeError(`routeRules ${where}: cache must be an object or false`);
|
|
38
|
+
for (const key of Object.keys(rule.cache))
|
|
39
|
+
if (!CACHE_KEYS.has(key))
|
|
40
|
+
throw new TypeError(`routeRules ${where}: unknown cache option "${key}"`);
|
|
41
|
+
cache = rule.cache;
|
|
42
|
+
}
|
|
43
|
+
else if (rule.swr !== undefined || rule.isr !== undefined) {
|
|
44
|
+
const value = (rule.swr ?? rule.isr);
|
|
45
|
+
cache = { maxAge: value === true ? (rule.isr !== undefined ? Infinity : 0) : value, swr: true };
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
return undefined;
|
|
49
|
+
const maxAge = seconds(cache.maxAge, `${pattern} maxAge`);
|
|
50
|
+
const swr = cache.swr === true ? Infinity : cache.swr === undefined ? 0 : seconds(cache.swr, `${pattern} swr`);
|
|
51
|
+
const query = cache.query ?? true;
|
|
52
|
+
if (typeof query !== "boolean")
|
|
53
|
+
strings(query, `${pattern} query`);
|
|
54
|
+
const s = (n) => (n === Infinity ? YEAR : Math.floor(n));
|
|
55
|
+
const varies = cache.varies ? strings(cache.varies, `${pattern} varies`).map((name) => name.toLowerCase()) : [];
|
|
56
|
+
if (varies.some((name) => CREDENTIALS.has(name)))
|
|
57
|
+
throw new TypeError(`routeRules ${where}: varies cannot name cookie / authorization (one copy per visitor)`);
|
|
58
|
+
// Stored until revalidate(): never keyed by a client-sent Host.
|
|
59
|
+
const isr = rule.isr !== undefined || maxAge === Infinity;
|
|
60
|
+
if (isr && !origin)
|
|
61
|
+
throw new TypeError(`routeRules ${where}: isr needs a fixed origin (createApp({ routeCache: { origin } }) or the adapter's \`origin\`)`);
|
|
62
|
+
let cdn = "";
|
|
63
|
+
if (rule.cdn !== undefined) {
|
|
64
|
+
if (typeof rule.cdn !== "object" || rule.cdn === null)
|
|
65
|
+
throw new TypeError(`routeRules ${where}: cdn must be { maxAge, swr? }`);
|
|
66
|
+
for (const key of Object.keys(rule.cdn))
|
|
67
|
+
if (key !== "maxAge" && key !== "swr")
|
|
68
|
+
throw new TypeError(`routeRules ${where}: unknown cdn option "${key}"`);
|
|
69
|
+
const max = seconds(rule.cdn.maxAge, `${pattern} cdn.maxAge`);
|
|
70
|
+
const stale = rule.cdn.swr === true ? Infinity : rule.cdn.swr === undefined ? 0 : seconds(rule.cdn.swr, `${pattern} cdn.swr`);
|
|
71
|
+
cdn = `s-maxage=${s(max)}` + (stale ? `, stale-while-revalidate=${s(stale)}` : "");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
maxAge,
|
|
75
|
+
swr,
|
|
76
|
+
tags: cache.tags ? strings(cache.tags, `${pattern} tags`) : [],
|
|
77
|
+
varies,
|
|
78
|
+
query: typeof query === "boolean" ? query : [...query].sort(),
|
|
79
|
+
cookies: cache.cookies === true,
|
|
80
|
+
// Browsers revalidate every time (304 when unchanged); shared caches / CDNs follow the rule — except ISR pages,
|
|
81
|
+
// which only this process can drop (`revalidate()`): CDNs revalidate them every time unless `cdn` opts in.
|
|
82
|
+
control: "public, max-age=0, " + (cdn || (isr ? "s-maxage=0, must-revalidate" : `s-maxage=${s(maxAge)}` + (swr ? `, stale-while-revalidate=${s(swr)}` : ""))),
|
|
83
|
+
learned: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Check and normalise one rule's `shell` (defaults: the rule's own cache times, else stored until `revalidate()`). */
|
|
87
|
+
function shellOf(rule, pattern, origin, cache) {
|
|
88
|
+
const where = `"${pattern}"`;
|
|
89
|
+
const given = rule.shell;
|
|
90
|
+
if (given === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (given === false)
|
|
93
|
+
return false;
|
|
94
|
+
if (given !== true && (typeof given !== "object" || given === null))
|
|
95
|
+
throw new TypeError(`routeRules ${where}: shell must be true, false or { maxAge, swr, tags, varies, query }`);
|
|
96
|
+
if (rule.cdn !== undefined)
|
|
97
|
+
throw new TypeError(`routeRules ${where}: shell cannot take cdn (shell documents carry per-visitor holes: private, no-cache)`);
|
|
98
|
+
const own = given === true ? {} : given;
|
|
99
|
+
for (const key of Object.keys(own))
|
|
100
|
+
if (!SHELL_KEYS.has(key))
|
|
101
|
+
throw new TypeError(`routeRules ${where}: unknown shell option "${key}"`);
|
|
102
|
+
const base = cache || undefined;
|
|
103
|
+
const maxAge = own.maxAge ?? base?.maxAge ?? Infinity;
|
|
104
|
+
const swr = own.swr ?? (base ? (base.swr === Infinity ? true : base.swr) : undefined);
|
|
105
|
+
const normal = cacheOf({
|
|
106
|
+
cache: {
|
|
107
|
+
maxAge,
|
|
108
|
+
...(swr !== undefined ? { swr } : {}),
|
|
109
|
+
...((own.tags ?? base?.tags)?.length ? { tags: own.tags ?? base.tags } : {}),
|
|
110
|
+
...((own.varies ?? base?.varies)?.length ? { varies: own.varies ?? base.varies } : {}),
|
|
111
|
+
...(own.query !== undefined ? { query: own.query } : base ? { query: base.query } : {}),
|
|
112
|
+
},
|
|
113
|
+
}, pattern + " shell", origin);
|
|
114
|
+
// Documents with live holes: never shared-cacheable, revalidated by the browser every time.
|
|
115
|
+
return normal ? { ...normal, control: "private, no-cache" } : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Compile `rules` (throws `TypeError` on a malformed pattern or rule) into a pathname → rule lookup. `origin`: a fixed
|
|
119
|
+
* origin is configured (`isr` rules need one).
|
|
120
|
+
*/
|
|
121
|
+
export function compileRules(rules, origin = false) {
|
|
122
|
+
const compiled = [];
|
|
123
|
+
const caches = new Map();
|
|
124
|
+
const shells = new Map();
|
|
125
|
+
for (const [pattern, rule] of Object.entries(rules)) {
|
|
126
|
+
const segments = pattern.split("/");
|
|
127
|
+
if (!pattern.startsWith("/") ||
|
|
128
|
+
segments.some((segment, at) => segment.includes("*") && segment !== "*" && (segment !== "**" || at !== segments.length - 1)))
|
|
129
|
+
throw new TypeError(`routeRules: pattern "${pattern}" must start with "/"; "*" is a whole segment, "/**" only at the end`);
|
|
130
|
+
if (typeof rule !== "object" || rule === null)
|
|
131
|
+
throw new TypeError(`routeRules "${pattern}": the rule must be an object`);
|
|
132
|
+
for (const key of Object.keys(rule))
|
|
133
|
+
if (!RULE_KEYS.has(key))
|
|
134
|
+
throw new TypeError(`routeRules "${pattern}": unknown option "${key}"`);
|
|
135
|
+
if (rule.headers !== undefined)
|
|
136
|
+
for (const [name, value] of Object.entries(rule.headers))
|
|
137
|
+
if (!/^[!#$%&'*+.^`|~\w-]+$/.test(name) || typeof value !== "string" || /[\r\n\0]/.test(value))
|
|
138
|
+
throw new TypeError(`routeRules "${pattern}": invalid header ${JSON.stringify(name)}`);
|
|
139
|
+
const own = cacheOf(rule, pattern, origin);
|
|
140
|
+
caches.set(rule, own);
|
|
141
|
+
if (rule.shell !== undefined)
|
|
142
|
+
shells.set(rule, shellOf(rule, pattern, origin, own));
|
|
143
|
+
const any = pattern.endsWith("/**");
|
|
144
|
+
const body = any ? pattern.slice(0, -3) : pattern;
|
|
145
|
+
const source = body
|
|
146
|
+
.split("/")
|
|
147
|
+
.map((segment) => (segment === "*" ? "[^/]+" : segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&")))
|
|
148
|
+
.join("/");
|
|
149
|
+
compiled.push({ test: new RegExp(`^${source}${any ? "(?:/.*)?" : "/?"}$`), weight: body.replace(/\*/g, "").length, rule });
|
|
150
|
+
}
|
|
151
|
+
// Less specific first: later (more specific) headers / cache win.
|
|
152
|
+
compiled.sort((a, b) => a.weight - b.weight);
|
|
153
|
+
const memo = new Map();
|
|
154
|
+
return (pathname) => {
|
|
155
|
+
let hit = memo.get(pathname);
|
|
156
|
+
if (hit === undefined) {
|
|
157
|
+
let headers;
|
|
158
|
+
let cache;
|
|
159
|
+
let shell;
|
|
160
|
+
let matched = false;
|
|
161
|
+
for (const { test, rule } of compiled) {
|
|
162
|
+
if (!test.test(pathname))
|
|
163
|
+
continue;
|
|
164
|
+
matched = true;
|
|
165
|
+
if (rule.headers)
|
|
166
|
+
for (const [name, value] of Object.entries(rule.headers))
|
|
167
|
+
(headers ??= new Map()).set(name.toLowerCase(), value);
|
|
168
|
+
const own = caches.get(rule);
|
|
169
|
+
if (own !== undefined)
|
|
170
|
+
cache = own;
|
|
171
|
+
const shelled = shells.get(rule);
|
|
172
|
+
if (shelled !== undefined)
|
|
173
|
+
shell = shelled;
|
|
174
|
+
}
|
|
175
|
+
// A shell rule wins for documents: no whole-page copy of them (their holes are per visitor).
|
|
176
|
+
hit = matched
|
|
177
|
+
? shell
|
|
178
|
+
? { headers: headers ? [...headers] : [], cache: undefined, shell }
|
|
179
|
+
: { headers: headers ? [...headers] : [], cache: cache || undefined }
|
|
180
|
+
: null;
|
|
181
|
+
// Bounded: request paths are client-controlled.
|
|
182
|
+
if (memo.size >= 2048)
|
|
183
|
+
memo.clear();
|
|
184
|
+
memo.set(pathname, hit);
|
|
185
|
+
}
|
|
186
|
+
return hit ?? undefined;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Bounded page store, CLOCK eviction (a hit sets one bit). Shell entries (`shell-cache.ts`) live here too (key `…\0shell`). */
|
|
190
|
+
class Store {
|
|
191
|
+
pages = new Map();
|
|
192
|
+
/** Shell cache: rejected fills per route id (3 in a row → no more attempts until a `revalidate()`). */
|
|
193
|
+
sticky = new Map();
|
|
194
|
+
bytes = 0;
|
|
195
|
+
/** Fills running now (`path`, `tags`): a `revalidate()` matching one voids it (it answers but does not store). */
|
|
196
|
+
filling = new Set();
|
|
197
|
+
maxBytes;
|
|
198
|
+
maxEntries;
|
|
199
|
+
base;
|
|
200
|
+
constructor(maxBytes, maxEntries, base) {
|
|
201
|
+
this.maxBytes = maxBytes;
|
|
202
|
+
this.maxEntries = maxEntries;
|
|
203
|
+
this.base = base;
|
|
204
|
+
}
|
|
205
|
+
get(key) {
|
|
206
|
+
return this.pages.get(key);
|
|
207
|
+
}
|
|
208
|
+
set(page) {
|
|
209
|
+
this.delete(page.key);
|
|
210
|
+
if (page.size > this.maxBytes / 4)
|
|
211
|
+
return;
|
|
212
|
+
this.pages.set(page.key, page);
|
|
213
|
+
this.bytes += page.size;
|
|
214
|
+
this.evict(page);
|
|
215
|
+
}
|
|
216
|
+
/** `page` (stored) grew by `bytes`. */
|
|
217
|
+
grow(page, bytes) {
|
|
218
|
+
if (this.pages.get(page.key) !== page)
|
|
219
|
+
return;
|
|
220
|
+
page.size += bytes;
|
|
221
|
+
this.bytes += bytes;
|
|
222
|
+
this.evict(page);
|
|
223
|
+
}
|
|
224
|
+
evict(keep) {
|
|
225
|
+
for (const [key, old] of this.pages) {
|
|
226
|
+
if (this.bytes <= this.maxBytes && this.pages.size <= this.maxEntries)
|
|
227
|
+
break;
|
|
228
|
+
if (old === keep)
|
|
229
|
+
continue;
|
|
230
|
+
this.pages.delete(key);
|
|
231
|
+
if (old.used) {
|
|
232
|
+
old.used = false;
|
|
233
|
+
this.pages.set(key, old);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
this.bytes -= old.size;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
delete(key) {
|
|
240
|
+
const page = this.pages.get(key);
|
|
241
|
+
if (!page)
|
|
242
|
+
return false;
|
|
243
|
+
this.pages.delete(key);
|
|
244
|
+
this.bytes -= page.size;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const stores = new Set();
|
|
249
|
+
// Path segments: kept as sent when printable ASCII outside the WHATWG path percent-encode set (and `%`, `/`);
|
|
250
|
+
// everything else percent-encoded (upper-case hex) — the form browsers send and the router's links use.
|
|
251
|
+
const LITERAL = /^[!$&'()*+,\-.0-9:;=@A-Z[\]^_a-z|~\\]$/;
|
|
252
|
+
const encodeSegment = (segment) => {
|
|
253
|
+
let out = "";
|
|
254
|
+
for (const char of segment)
|
|
255
|
+
out += LITERAL.test(char) ? char : encodeURIComponent(char);
|
|
256
|
+
return out;
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* The router's view of a raw pathname: `match` — segments percent-decoded (a decoded `/` stays `%2F`: one segment),
|
|
260
|
+
* what rules match and keys use; `canonical` — the raw path is that path's one encoding (only those are stored, so
|
|
261
|
+
* `/%61dmin` neither escapes an `/admin/**` rule nor adds a copy of `/admin`). Malformed escapes: not canonical.
|
|
262
|
+
*/
|
|
263
|
+
export function normalPath(pathname) {
|
|
264
|
+
if (!pathname.includes("%"))
|
|
265
|
+
return { match: pathname, canonical: true };
|
|
266
|
+
const segments = pathname.split("/");
|
|
267
|
+
let match = "";
|
|
268
|
+
let canonical = true;
|
|
269
|
+
for (let at = 0; at < segments.length; at++) {
|
|
270
|
+
const raw = segments[at];
|
|
271
|
+
let decoded;
|
|
272
|
+
try {
|
|
273
|
+
decoded = raw.includes("%") ? decodeURIComponent(raw) : raw;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return { match: pathname, canonical: false };
|
|
277
|
+
}
|
|
278
|
+
if (canonical && encodeSegment(decoded) !== raw)
|
|
279
|
+
canonical = false;
|
|
280
|
+
match += (at ? "/" : "") + decoded.replaceAll("/", "%2F");
|
|
281
|
+
}
|
|
282
|
+
return { match, canonical };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Drop stored pages (every app's in this process): by `path` (pathname, with or without the app's base; a trailing
|
|
286
|
+
* `/**` drops everything below it) and / or `tag` (`CacheRule.tags`). The next request renders; a render already
|
|
287
|
+
* running does not store its page. Returns how many pages were dropped here. Node `start({ workers })`: the other
|
|
288
|
+
* workers drop theirs too (relayed by the primary, asynchronously). Other servers and CDNs keep theirs until they
|
|
289
|
+
* expire.
|
|
290
|
+
*/
|
|
291
|
+
export function revalidate(target) {
|
|
292
|
+
const dropped = dropLocal(target);
|
|
293
|
+
globalThis[BROADCAST]?.({ ...target });
|
|
294
|
+
return dropped;
|
|
295
|
+
}
|
|
296
|
+
// `adapters/cluster.ts`: a worker relays `revalidate()` (BROADCAST, set there) and drops what other workers revalidate (LOCAL).
|
|
297
|
+
const BROADCAST = Symbol.for("rivet.revalidate.broadcast");
|
|
298
|
+
globalThis[Symbol.for("rivet.revalidate.local")] = (target) => dropLocal(target);
|
|
299
|
+
function dropLocal(target) {
|
|
300
|
+
let dropped = 0;
|
|
301
|
+
for (const ref of stores) {
|
|
302
|
+
const store = ref.deref();
|
|
303
|
+
if (!store) {
|
|
304
|
+
stores.delete(ref);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
let path = target.path === undefined ? undefined : normalPath(target.path).match;
|
|
308
|
+
if (path !== undefined && store.base && (path === store.base || path.startsWith(store.base + "/")))
|
|
309
|
+
path = path.slice(store.base.length) || "/";
|
|
310
|
+
const below = path?.endsWith("/**") ? path.slice(0, -3) : undefined;
|
|
311
|
+
const matches = (page) => (path === undefined ||
|
|
312
|
+
(below !== undefined ? page.path === (below || "/") || page.path.startsWith(below + "/") : page.path === path || page.path === path + "/")) &&
|
|
313
|
+
(target.tag === undefined || page.tags.includes(target.tag));
|
|
314
|
+
// Renders already running for a matching page: their result predates the change.
|
|
315
|
+
for (const fill of store.filling)
|
|
316
|
+
if (matches(fill))
|
|
317
|
+
fill.void = true;
|
|
318
|
+
store.sticky.clear();
|
|
319
|
+
for (const page of [...store.pages.values()])
|
|
320
|
+
if (matches(page) && store.delete(page.key))
|
|
321
|
+
dropped++;
|
|
322
|
+
}
|
|
323
|
+
return dropped;
|
|
324
|
+
}
|
|
325
|
+
/** `If-None-Match` (weak comparison) / `If-Modified-Since` (when no `If-None-Match`) hold for `page`. */
|
|
326
|
+
export function notModified(page, match, since) {
|
|
327
|
+
if (match) {
|
|
328
|
+
if (match.trim() === "*")
|
|
329
|
+
return true;
|
|
330
|
+
const own = page.etag.slice(2);
|
|
331
|
+
for (const tag of match.split(","))
|
|
332
|
+
if (tag.trim().replace(/^W\//, "") === own)
|
|
333
|
+
return true;
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
if (!since)
|
|
337
|
+
return false;
|
|
338
|
+
const date = Date.parse(since);
|
|
339
|
+
return date === date && page.modifiedAt <= date;
|
|
340
|
+
}
|
|
341
|
+
/** Headers a 304 repeats (RFC 9110 15.4.5). */
|
|
342
|
+
const KEPT_304 = new Set(["cache-control", "etag", "last-modified", "vary", "expires", "content-location"]);
|
|
343
|
+
/** Own property the app sets on a request whose render reported a failure (`onError`: an error fallback was rendered). */
|
|
344
|
+
export const RENDER_FAILED = Symbol.for("rivet.renderFailed");
|
|
345
|
+
const encoder = new TextEncoder();
|
|
346
|
+
const base64 = (bytes) => {
|
|
347
|
+
let text = "";
|
|
348
|
+
for (const byte of bytes)
|
|
349
|
+
text += String.fromCharCode(byte);
|
|
350
|
+
return btoa(text);
|
|
351
|
+
};
|
|
352
|
+
const native = globalThis.process?.getBuiltinModule?.("node:crypto");
|
|
353
|
+
const digest = async (algorithm, data) => native
|
|
354
|
+
? native
|
|
355
|
+
.createHash(algorithm === "SHA-1" ? "sha1" : "sha256")
|
|
356
|
+
.update(data)
|
|
357
|
+
.digest("base64")
|
|
358
|
+
: base64(new Uint8Array(await crypto.subtle.digest(algorithm, typeof data === "string" ? encoder.encode(data) : data)));
|
|
359
|
+
const NodeBuffer = globalThis.Buffer;
|
|
360
|
+
const latin1Decoder = NodeBuffer ? null : new TextDecoder("latin1");
|
|
361
|
+
const latin1 = (bytes) => NodeBuffer ? NodeBuffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).latin1Slice() : latin1Decoder.decode(bytes);
|
|
362
|
+
const utf8 = new TextDecoder();
|
|
363
|
+
/** `[origin, pathname, search]` of an absolute request URL (`search` with its `?`, or ""). */
|
|
364
|
+
function split(url) {
|
|
365
|
+
const start = url.indexOf("/", url.indexOf("//") + 2);
|
|
366
|
+
if (start < 0)
|
|
367
|
+
return [url, "/", ""];
|
|
368
|
+
const origin = url.slice(0, start);
|
|
369
|
+
const query = url.indexOf("?", start);
|
|
370
|
+
const end = url.indexOf("#", start);
|
|
371
|
+
const stop = end < 0 ? url.length : end;
|
|
372
|
+
return query < 0 || query > stop ? [origin, url.slice(start, stop), ""] : [origin, url.slice(start, query), url.slice(query, stop)];
|
|
373
|
+
}
|
|
374
|
+
/** Lower-case names of a `Vary` value. */
|
|
375
|
+
const varyNames = (value) => value
|
|
376
|
+
? value
|
|
377
|
+
.split(",")
|
|
378
|
+
.map((name) => name.trim().toLowerCase())
|
|
379
|
+
.filter(Boolean)
|
|
380
|
+
: [];
|
|
381
|
+
/** The whole body of a rendered page: from the renderer's slabs (`DIRECT_BODY`) when it has them, else its stream. */
|
|
382
|
+
function bodyOf(response) {
|
|
383
|
+
const direct = response[DIRECT_BODY];
|
|
384
|
+
if (!direct || response.body?.locked)
|
|
385
|
+
return response.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
const chunks = [];
|
|
388
|
+
const releases = [];
|
|
389
|
+
let size = 0;
|
|
390
|
+
const attached = direct({
|
|
391
|
+
// Views into pooled slabs: held (not released) until the end, then copied once.
|
|
392
|
+
write(chunk, done) {
|
|
393
|
+
chunks.push(chunk);
|
|
394
|
+
releases.push(done);
|
|
395
|
+
size += chunk.byteLength;
|
|
396
|
+
return undefined;
|
|
397
|
+
},
|
|
398
|
+
end() {
|
|
399
|
+
const body = new Uint8Array(size);
|
|
400
|
+
let at = 0;
|
|
401
|
+
for (const chunk of chunks)
|
|
402
|
+
body.set(chunk, (at += chunk.byteLength) - chunk.byteLength);
|
|
403
|
+
for (const done of releases)
|
|
404
|
+
done();
|
|
405
|
+
resolve(body);
|
|
406
|
+
},
|
|
407
|
+
error(error) {
|
|
408
|
+
for (const done of releases)
|
|
409
|
+
done();
|
|
410
|
+
reject(error);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
if (!attached)
|
|
414
|
+
response.arrayBuffer().then((buffer) => resolve(new Uint8Array(buffer)), reject);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
/** A Response of known bytes that the node adapter writes without reading a stream (`DIRECT_BODY`). */
|
|
418
|
+
function bytesResponse(body, init) {
|
|
419
|
+
const response = new Response(body, init);
|
|
420
|
+
if (body)
|
|
421
|
+
Object.defineProperty(response, DIRECT_BODY, {
|
|
422
|
+
value: ((sink) => {
|
|
423
|
+
try {
|
|
424
|
+
sink.write(body, () => { });
|
|
425
|
+
sink.end();
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// The sink closed (client gone).
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
return response;
|
|
434
|
+
}
|
|
435
|
+
// Pages not stored for a reason worth a warning (a nonce outside the renderer's tags): once per path, bounded.
|
|
436
|
+
const warned = new Set();
|
|
437
|
+
/** How long a key whose page varies by credentials renders without a fill attempt. */
|
|
438
|
+
const PERSONAL_MS = 10_000;
|
|
439
|
+
const warn = (path, why) => {
|
|
440
|
+
if (warned.size >= 256 || warned.has(path))
|
|
441
|
+
return;
|
|
442
|
+
warned.add(path);
|
|
443
|
+
console.warn(`[rivet] routeRules: ${path} not cached: ${why}`);
|
|
444
|
+
};
|
|
445
|
+
/** Refresh backoff after `failures` failed background renders: 1 s doubling to 5 min. */
|
|
446
|
+
const backoff = (failures) => Math.min(1000 * 2 ** (failures - 1), 300_000);
|
|
447
|
+
/**
|
|
448
|
+
* Wrap the app's handler with route rules. `base`: the app's base path (stripped before matching). The result
|
|
449
|
+
* carries `ROUTE_CACHE` (node adapter fast path). `shells`: the shell cache module (`createApp` loads it only for
|
|
450
|
+
* rules with `shell`); `internal.dev`: shell rules only probe (never store; reasons logged), `internal.build`: build id.
|
|
451
|
+
*/
|
|
452
|
+
export function withRouteRules(handler, rules, csp, base = "", options = {}, shells, internal = {}) {
|
|
453
|
+
const fixed = options.origin === undefined ? undefined : new URL(options.origin).origin;
|
|
454
|
+
const resolve = compileRules(rules, fixed !== undefined);
|
|
455
|
+
const store = new Store(options.maxBytes ?? 32 * 1024 * 1024, options.maxEntries ?? 5000, base);
|
|
456
|
+
stores.add(new WeakRef(store));
|
|
457
|
+
// Keys of client-chosen input are stored on their second miss (`secondMiss`: attacker-chosen query strings,
|
|
458
|
+
// hosts, header values never evict pages).
|
|
459
|
+
const doorkeeper = new Set();
|
|
460
|
+
const flights = new Map();
|
|
461
|
+
// Keys whose page the credentials choose (`cookies: true` + `Vary: cookie`): never stored, so for a while requests
|
|
462
|
+
// render once, as themselves, instead of an anonymous fill first (two renders per cookie request).
|
|
463
|
+
const personal = new Map();
|
|
464
|
+
/**
|
|
465
|
+
* Keys made of client-chosen input are stored on their second miss: a query string, an origin without a fixed
|
|
466
|
+
* `origin` (any `Host`), a value of a keyed header (`varies`, learned Vary names). Others on the first.
|
|
467
|
+
*/
|
|
468
|
+
const secondMiss = (cache, search, header) => {
|
|
469
|
+
if (search || fixed === undefined)
|
|
470
|
+
return true;
|
|
471
|
+
for (const name of cache.varies)
|
|
472
|
+
if (header(name))
|
|
473
|
+
return true;
|
|
474
|
+
for (const name of cache.learned)
|
|
475
|
+
if (header(name))
|
|
476
|
+
return true;
|
|
477
|
+
return false;
|
|
478
|
+
};
|
|
479
|
+
const below = (pathname) => !base ? pathname : pathname === base ? "/" : pathname.startsWith(base + "/") ? pathname.slice(base.length) : null;
|
|
480
|
+
const keyOf = (cache, origin, path, search, header) => {
|
|
481
|
+
let query = "";
|
|
482
|
+
if (search && cache.query !== false) {
|
|
483
|
+
if (cache.query === true)
|
|
484
|
+
query = search;
|
|
485
|
+
else {
|
|
486
|
+
const params = new URLSearchParams(search);
|
|
487
|
+
const kept = new URLSearchParams();
|
|
488
|
+
for (const name of cache.query)
|
|
489
|
+
for (const value of params.getAll(name))
|
|
490
|
+
kept.append(name, value);
|
|
491
|
+
query = kept.size ? "?" + kept : "";
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
let key = origin + path + "\0" + query;
|
|
495
|
+
for (const name of cache.varies)
|
|
496
|
+
key += "\0" + (header(name) ?? "");
|
|
497
|
+
for (const name of cache.learned)
|
|
498
|
+
key += "\0" + (header(name) ?? "");
|
|
499
|
+
return key;
|
|
500
|
+
};
|
|
501
|
+
/** The rule's cache for a request, or undefined when it bypasses the cache. */
|
|
502
|
+
const cacheFor = (resolved, method, origin, canonical, search, header) => {
|
|
503
|
+
const cache = resolved?.cache;
|
|
504
|
+
if (!cache || !canonical || (method !== "GET" && method !== "HEAD") || (fixed !== undefined && origin !== fixed))
|
|
505
|
+
return undefined;
|
|
506
|
+
// Data requests (client navigation JSON), credentials.
|
|
507
|
+
if (search && search.includes(DATA_PARAM))
|
|
508
|
+
return undefined;
|
|
509
|
+
if (header("authorization") != null || (!cache.cookies && header("cookie") != null))
|
|
510
|
+
return undefined;
|
|
511
|
+
return cache;
|
|
512
|
+
};
|
|
513
|
+
const lookup = (method, origin, pathname, search, header) => {
|
|
514
|
+
const raw = below(pathname);
|
|
515
|
+
if (raw === null)
|
|
516
|
+
return undefined;
|
|
517
|
+
const { match, canonical } = normalPath(raw);
|
|
518
|
+
const cache = cacheFor(resolve(match), method, origin, canonical, search, header);
|
|
519
|
+
if (!cache)
|
|
520
|
+
return undefined;
|
|
521
|
+
const page = store.get(keyOf(cache, origin, match, search, header));
|
|
522
|
+
if (!page || Date.now() >= page.fresh)
|
|
523
|
+
return undefined;
|
|
524
|
+
page.used = true;
|
|
525
|
+
return page;
|
|
526
|
+
};
|
|
527
|
+
const withHeaders = (response, headers) => {
|
|
528
|
+
if (!headers.length)
|
|
529
|
+
return response;
|
|
530
|
+
try {
|
|
531
|
+
for (const [name, value] of headers)
|
|
532
|
+
response.headers.set(name, value);
|
|
533
|
+
return response;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// Immutable headers (a fetched / redirect Response): copy.
|
|
537
|
+
const copy = new Response(response.body, response);
|
|
538
|
+
for (const [name, value] of headers)
|
|
539
|
+
copy.headers.set(name, value);
|
|
540
|
+
return copy;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const shellServe = shells?.createShells({
|
|
544
|
+
handler,
|
|
545
|
+
store,
|
|
546
|
+
csp,
|
|
547
|
+
fixed,
|
|
548
|
+
dev: internal.dev === true,
|
|
549
|
+
build: internal.build ?? "",
|
|
550
|
+
keyOf,
|
|
551
|
+
secondMiss,
|
|
552
|
+
doorkeeper,
|
|
553
|
+
withHeaders,
|
|
554
|
+
});
|
|
555
|
+
const age = (page, now) => String(Math.max(0, Math.floor((now - page.created) / 1000)));
|
|
556
|
+
/** Serve a stored page (`state`: HIT / STALE), 304 when the request's validators hold. */
|
|
557
|
+
const serve = (page, request, state) => {
|
|
558
|
+
page.used = true;
|
|
559
|
+
const headers = new Headers();
|
|
560
|
+
const unchanged = notModified(page, request.headers.get("if-none-match"), request.headers.get("if-modified-since"));
|
|
561
|
+
for (const [name, value] of page.headers)
|
|
562
|
+
if (!unchanged || KEPT_304.has(name))
|
|
563
|
+
headers.set(name, value);
|
|
564
|
+
headers.set("age", age(page, Date.now()));
|
|
565
|
+
headers.set("x-rivet-cache", state);
|
|
566
|
+
if (unchanged)
|
|
567
|
+
return new Response(null, { status: 304, headers });
|
|
568
|
+
return bytesResponse(request.method === "HEAD" ? null : page.body, { status: 200, headers });
|
|
569
|
+
};
|
|
570
|
+
/**
|
|
571
|
+
* Render for the store: a request of its own (a client going away does not abort it), without the visitor's
|
|
572
|
+
* `Cookie` / `Authorization` (stored pages are anonymous). Not stored: non-200, non-HTML, `Set-Cookie`, private /
|
|
573
|
+
* no-store, a `Vary` the key cannot follow, a reported render failure (error fallback), a nonce the hash policy
|
|
574
|
+
* cannot replace, a `revalidate()` since the render started.
|
|
575
|
+
*/
|
|
576
|
+
async function fill(key, origin, path, search, cache, resolved, request, context) {
|
|
577
|
+
const filling = { path, tags: cache.tags, void: false };
|
|
578
|
+
store.filling.add(filling);
|
|
579
|
+
// Anonymous, visitor-neutral render: no credentials, no client address / per-visitor proxy headers (unless keyed).
|
|
580
|
+
const sent = new Headers();
|
|
581
|
+
request.headers.forEach((value, name) => {
|
|
582
|
+
if (!VISITOR.test(name) || cache.varies.includes(name))
|
|
583
|
+
sent.set(name, value);
|
|
584
|
+
});
|
|
585
|
+
const detached = new Request(request.url, { headers: sent });
|
|
586
|
+
let response;
|
|
587
|
+
try {
|
|
588
|
+
const { address: _address, ...neutral } = context ?? {};
|
|
589
|
+
response = await handler(detached, neutral);
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
store.filling.delete(filling);
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
const type = response.headers.get("content-type") ?? "";
|
|
596
|
+
const control = response.headers.get("cache-control") ?? "";
|
|
597
|
+
const vary = varyNames(response.headers.get("vary"));
|
|
598
|
+
if (response.status !== 200 ||
|
|
599
|
+
!response.body ||
|
|
600
|
+
!/^text\/html\b/i.test(type) ||
|
|
601
|
+
response.headers.has("set-cookie") ||
|
|
602
|
+
/\b(?:private|no-store)\b/i.test(control) ||
|
|
603
|
+
vary.includes("*") ||
|
|
604
|
+
// Chosen by credentials (the i18n guard's `Vary: cookie`): stored only when the rule keys the other inputs, and
|
|
605
|
+
// never when cookie requests share the store (`cookies: true`: their cookie may choose another page).
|
|
606
|
+
(vary.some((name) => CREDENTIALS.has(name)) && (!cache.varies.length || cache.cookies))) {
|
|
607
|
+
store.filling.delete(filling);
|
|
608
|
+
if (vary.some((name) => CREDENTIALS.has(name))) {
|
|
609
|
+
if (personal.size >= 10_000)
|
|
610
|
+
personal.clear();
|
|
611
|
+
personal.set(key, Date.now() + PERSONAL_MS);
|
|
612
|
+
}
|
|
613
|
+
// Not stored, so not shared: a visitor whose credentials the anonymous render left out (`cookies: true`) gets
|
|
614
|
+
// a render of their own request (their cookie may choose the locale, the session, …).
|
|
615
|
+
if (request.headers.has("cookie") || request.headers.has("authorization")) {
|
|
616
|
+
void response.body?.cancel().catch(() => { });
|
|
617
|
+
response = await handler(request, context);
|
|
618
|
+
}
|
|
619
|
+
return { page: null, response: withHeaders(response, resolved.headers) };
|
|
620
|
+
}
|
|
621
|
+
// The whole page (a stream failure rejects: a miss answers 500).
|
|
622
|
+
let rendered;
|
|
623
|
+
try {
|
|
624
|
+
rendered = await bodyOf(response);
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
store.filling.delete(filling);
|
|
628
|
+
}
|
|
629
|
+
const nonce = context?.nonce?.replace(/[^\w+/=-]/g, "");
|
|
630
|
+
const own = response.headers.get("content-security-policy");
|
|
631
|
+
let body = rendered;
|
|
632
|
+
let policy = own;
|
|
633
|
+
if (nonce) {
|
|
634
|
+
if (csp.hash && own !== null && own === csp.policy(context.nonce)) {
|
|
635
|
+
// The app's nonce policy → hashes of the elements carrying this render's nonce (never injected markup).
|
|
636
|
+
const text = latin1(rendered);
|
|
637
|
+
const found = noncedSources(text, nonce, !!csp.hash.directives, (from, to) => utf8.decode(rendered.subarray(from, to)));
|
|
638
|
+
if (!found) {
|
|
639
|
+
warn(path, "its CSP nonce appears outside the renderer's script / style tags");
|
|
640
|
+
body = null;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
const sha = async ([from, to]) => `'sha256-${await digest("SHA-256", rendered.subarray(from, to))}'`;
|
|
644
|
+
policy = hashPolicy(await Promise.all(found.scripts.map(sha)), found.origins, await Promise.all(found.styles.map(sha)), csp.hash, false);
|
|
645
|
+
// The stored body without its ` nonce="…"` attributes (byte ranges cut out, no re-encoding).
|
|
646
|
+
const cut = nonce.length + 9;
|
|
647
|
+
if (found.attrs.length) {
|
|
648
|
+
body = new Uint8Array(rendered.byteLength - found.attrs.length * cut);
|
|
649
|
+
let from = 0;
|
|
650
|
+
let to = 0;
|
|
651
|
+
for (const at of found.attrs) {
|
|
652
|
+
body.set(rendered.subarray(from, at), to);
|
|
653
|
+
to += at - from;
|
|
654
|
+
from = at + cut;
|
|
655
|
+
}
|
|
656
|
+
body.set(rendered.subarray(from), to);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
else if (own !== null && own.includes(`'nonce-${nonce}'`)) {
|
|
661
|
+
// A policy that trusts this render's nonce (a `csp` function, a route's own): a stored copy would hand it to everyone.
|
|
662
|
+
body = null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const stored = body !== null && detached[RENDER_FAILED] !== true && !filling.void;
|
|
666
|
+
const now = Date.now();
|
|
667
|
+
const modifiedAt = Math.floor(now / 1000) * 1000;
|
|
668
|
+
const headers = [];
|
|
669
|
+
response.headers.forEach((value, name) => {
|
|
670
|
+
if (name === "content-length" || name === "content-security-policy" || name === "age" || name === "vary")
|
|
671
|
+
return;
|
|
672
|
+
if (!stored || (name !== "cache-control" && name !== "etag" && name !== "last-modified"))
|
|
673
|
+
headers.push([name, value]);
|
|
674
|
+
});
|
|
675
|
+
// Vary: the response's names and the rule's (CDNs keep one copy per value too).
|
|
676
|
+
const varies = [...new Set([...vary, ...cache.varies])];
|
|
677
|
+
if (varies.length)
|
|
678
|
+
headers.push(["vary", varies.join(", ")]);
|
|
679
|
+
const etag = stored ? `W/"${(await digest("SHA-1", body)).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")}"` : "";
|
|
680
|
+
if (stored)
|
|
681
|
+
headers.push(["cache-control", cache.control], ["etag", etag], ["last-modified", new Date(modifiedAt).toUTCString()]);
|
|
682
|
+
for (const header of resolved.headers) {
|
|
683
|
+
const at = headers.findIndex(([name]) => name === header[0]);
|
|
684
|
+
if (at >= 0)
|
|
685
|
+
headers.splice(at, 1);
|
|
686
|
+
headers.push([header[0], header[1]]);
|
|
687
|
+
}
|
|
688
|
+
// This request's copy: its own nonce (and policy).
|
|
689
|
+
const out = new Headers(headers);
|
|
690
|
+
if (own !== null)
|
|
691
|
+
out.set("content-security-policy", own);
|
|
692
|
+
if (stored) {
|
|
693
|
+
out.set("age", "0");
|
|
694
|
+
out.set("x-rivet-cache", "MISS");
|
|
695
|
+
}
|
|
696
|
+
const answer = bytesResponse(request.method === "HEAD" ? null : rendered, { status: 200, headers: out });
|
|
697
|
+
if (!stored)
|
|
698
|
+
return { page: null, response: answer };
|
|
699
|
+
// Response Vary names beyond the rule's: keyed from now on (this page under its full key).
|
|
700
|
+
let storedKey = key;
|
|
701
|
+
const extra = vary.filter((name) => name !== "accept-encoding" && !CREDENTIALS.has(name) && !cache.varies.includes(name) && !cache.learned.includes(name));
|
|
702
|
+
if (extra.length) {
|
|
703
|
+
cache.learned.push(...extra);
|
|
704
|
+
storedKey = keyOf(cache, origin, path, search, (name) => request.headers.get(name));
|
|
705
|
+
}
|
|
706
|
+
const pageHeaders = policy === null ? headers : [...headers, ["content-security-policy", policy]];
|
|
707
|
+
let size = body.byteLength + storedKey.length * 2;
|
|
708
|
+
for (const [name, value] of pageHeaders)
|
|
709
|
+
size += name.length + value.length;
|
|
710
|
+
const page = {
|
|
711
|
+
key: storedKey,
|
|
712
|
+
path,
|
|
713
|
+
tags: cache.tags,
|
|
714
|
+
body: body,
|
|
715
|
+
headers: pageHeaders,
|
|
716
|
+
etag,
|
|
717
|
+
modified: new Date(modifiedAt).toUTCString(),
|
|
718
|
+
modifiedAt,
|
|
719
|
+
created: now,
|
|
720
|
+
fresh: now + cache.maxAge * 1000,
|
|
721
|
+
stale: now + (cache.maxAge + cache.swr) * 1000,
|
|
722
|
+
used: false,
|
|
723
|
+
size,
|
|
724
|
+
failures: 0,
|
|
725
|
+
retry: 0,
|
|
726
|
+
charge: (bytes) => store.grow(page, bytes),
|
|
727
|
+
};
|
|
728
|
+
store.set(page);
|
|
729
|
+
return { page, response: answer };
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* One render per key at a time: the filler's promise, whose page concurrent misses are served. `stale`: the stored
|
|
733
|
+
* page a background refresh replaces — a 404 / 410 drops it, any other failure keeps it and backs off.
|
|
734
|
+
*/
|
|
735
|
+
const start = (key, origin, path, search, cache, resolved, request, context, stale) => {
|
|
736
|
+
const run = fill(key, origin, path, search, cache, resolved, request, context);
|
|
737
|
+
// A page that is gone (404 / 410), moved (3xx) or now personal (Set-Cookie): drop it; else keep it and back off.
|
|
738
|
+
const failed = (status, cookie) => {
|
|
739
|
+
if (!stale || store.get(key) !== stale)
|
|
740
|
+
return;
|
|
741
|
+
if (status === 404 || status === 410 || (status >= 300 && status < 400) || cookie)
|
|
742
|
+
store.delete(key);
|
|
743
|
+
else
|
|
744
|
+
stale.retry = Date.now() + backoff(++stale.failures);
|
|
745
|
+
};
|
|
746
|
+
const flight = run.then((result) => {
|
|
747
|
+
if (!result.page)
|
|
748
|
+
failed(result.response.status, result.response.headers.has("set-cookie"));
|
|
749
|
+
return result.page;
|
|
750
|
+
}, () => (failed(500, false), null));
|
|
751
|
+
flights.set(key, flight);
|
|
752
|
+
void flight.then(() => flights.delete(key));
|
|
753
|
+
return run;
|
|
754
|
+
};
|
|
755
|
+
const wrapped = async (request, context) => {
|
|
756
|
+
const [origin, pathname, search] = split(request.url);
|
|
757
|
+
const raw = below(pathname);
|
|
758
|
+
const { match: path, canonical } = raw === null ? { match: "", canonical: false } : normalPath(raw);
|
|
759
|
+
const resolved = raw === null ? undefined : resolve(path);
|
|
760
|
+
if (!resolved)
|
|
761
|
+
return handler(request, context);
|
|
762
|
+
// Seeded `locals` (`context.locals`): the page depends on input that is not in the URL, so it is neither read
|
|
763
|
+
// from nor written to the store (rule headers still apply).
|
|
764
|
+
if (context?.locals)
|
|
765
|
+
return withHeaders(await handler(request, context), resolved.headers);
|
|
766
|
+
// Shell rules (documents): stored shell + live holes (`shell-cache.ts`); undefined = render as usual.
|
|
767
|
+
if (resolved.shell && shellServe && canonical) {
|
|
768
|
+
const served = shellServe(request, context, resolved, origin, path, search);
|
|
769
|
+
if (served)
|
|
770
|
+
return served;
|
|
771
|
+
}
|
|
772
|
+
const header = (name) => request.headers.get(name);
|
|
773
|
+
const cache = cacheFor(resolved, request.method, origin, canonical, search, header);
|
|
774
|
+
if (!cache)
|
|
775
|
+
return withHeaders(await handler(request, context), resolved.headers);
|
|
776
|
+
const key = keyOf(cache, origin, path, search, header);
|
|
777
|
+
const page = store.get(key);
|
|
778
|
+
const now = Date.now();
|
|
779
|
+
if (page) {
|
|
780
|
+
if (now < page.fresh)
|
|
781
|
+
return serve(page, request, "HIT");
|
|
782
|
+
if (now < page.stale) {
|
|
783
|
+
if (!flights.has(key) && now >= page.retry)
|
|
784
|
+
start(key, origin, path, search, cache, resolved, request, context, page).catch(() => { });
|
|
785
|
+
return serve(page, request, "STALE");
|
|
786
|
+
}
|
|
787
|
+
store.delete(key);
|
|
788
|
+
}
|
|
789
|
+
const until = personal.get(key);
|
|
790
|
+
if (until !== undefined) {
|
|
791
|
+
if (now < until)
|
|
792
|
+
return withHeaders(await handler(request, context), resolved.headers);
|
|
793
|
+
personal.delete(key);
|
|
794
|
+
}
|
|
795
|
+
const flight = flights.get(key);
|
|
796
|
+
let admit = false;
|
|
797
|
+
if (flight) {
|
|
798
|
+
const filled = await flight;
|
|
799
|
+
if (filled)
|
|
800
|
+
return serve(filled, request, "HIT");
|
|
801
|
+
}
|
|
802
|
+
else if (request.method === "GET" && (!(admit = secondMiss(cache, search, header)) || doorkeeper.has(key))) {
|
|
803
|
+
return (await start(key, origin, path, search, cache, resolved, request, context)).response;
|
|
804
|
+
}
|
|
805
|
+
else if (admit) {
|
|
806
|
+
if (doorkeeper.size >= 10_000)
|
|
807
|
+
doorkeeper.clear();
|
|
808
|
+
doorkeeper.add(key);
|
|
809
|
+
}
|
|
810
|
+
return withHeaders(await handler(request, context), resolved.headers);
|
|
811
|
+
};
|
|
812
|
+
return Object.assign(wrapped, { [ROUTE_CACHE]: lookup });
|
|
813
|
+
}
|