@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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/adapters/assemble.d.ts +32 -0
  4. package/adapters/assemble.js +32 -0
  5. package/adapters/build.d.ts +24 -0
  6. package/adapters/build.js +43 -0
  7. package/adapters/bun.d.ts +18 -0
  8. package/adapters/bun.js +44 -0
  9. package/adapters/cloudflare.d.ts +5 -0
  10. package/adapters/cloudflare.js +8 -0
  11. package/adapters/cluster.d.ts +7 -0
  12. package/adapters/cluster.js +130 -0
  13. package/adapters/csr.d.ts +12 -0
  14. package/adapters/csr.js +11 -0
  15. package/adapters/deno.d.ts +13 -0
  16. package/adapters/deno.js +35 -0
  17. package/adapters/fetch.d.ts +55 -0
  18. package/adapters/fetch.js +72 -0
  19. package/adapters/files.d.ts +20 -0
  20. package/adapters/files.js +155 -0
  21. package/adapters/idle-memory.d.ts +23 -0
  22. package/adapters/idle-memory.js +91 -0
  23. package/adapters/netlify.d.ts +13 -0
  24. package/adapters/netlify.js +15 -0
  25. package/adapters/node.d.ts +180 -0
  26. package/adapters/node.js +986 -0
  27. package/adapters/pack.d.ts +39 -0
  28. package/adapters/pack.js +360 -0
  29. package/adapters/server-target.d.ts +17 -0
  30. package/adapters/server-target.js +8 -0
  31. package/adapters/static.d.ts +84 -0
  32. package/adapters/static.js +231 -0
  33. package/adapters/target-build.d.ts +95 -0
  34. package/adapters/target-build.js +118 -0
  35. package/adapters/vercel-edge.d.ts +7 -0
  36. package/adapters/vercel-edge.js +9 -0
  37. package/adapters/vercel.d.ts +35 -0
  38. package/adapters/vercel.js +45 -0
  39. package/build/codegen.d.ts +97 -0
  40. package/build/codegen.js +1037 -0
  41. package/build/dev-errors.d.ts +23 -0
  42. package/build/dev-errors.js +247 -0
  43. package/build/lazy-rows.d.ts +19 -0
  44. package/build/lazy-rows.js +59 -0
  45. package/build/migrate.d.ts +31 -0
  46. package/build/migrate.js +385 -0
  47. package/build/reach.d.ts +60 -0
  48. package/build/reach.js +302 -0
  49. package/build/route-css.d.ts +54 -0
  50. package/build/route-css.js +216 -0
  51. package/build/routes.d.ts +55 -0
  52. package/build/routes.js +219 -0
  53. package/build/sync.d.ts +20 -0
  54. package/build/sync.js +16 -0
  55. package/build/vite.d.ts +208 -0
  56. package/build/vite.js +861 -0
  57. package/cli/rivet.d.ts +2 -0
  58. package/cli/rivet.js +101 -0
  59. package/client/active.d.ts +6 -0
  60. package/client/active.js +22 -0
  61. package/client/announce.d.ts +2 -0
  62. package/client/announce.js +21 -0
  63. package/client/dev.d.ts +41 -0
  64. package/client/dev.js +91 -0
  65. package/client/index.d.ts +156 -0
  66. package/client/index.js +714 -0
  67. package/client/layouts.d.ts +110 -0
  68. package/client/layouts.js +310 -0
  69. package/client/leave.d.ts +40 -0
  70. package/client/leave.js +75 -0
  71. package/client/link.d.ts +48 -0
  72. package/client/link.jsx +65 -0
  73. package/client/live.d.ts +57 -0
  74. package/client/live.js +170 -0
  75. package/client/navigate.d.ts +71 -0
  76. package/client/navigate.js +120 -0
  77. package/client/navigation.d.ts +28 -0
  78. package/client/navigation.js +14 -0
  79. package/client/params.d.ts +16 -0
  80. package/client/params.js +28 -0
  81. package/client/route-context.d.ts +32 -0
  82. package/client/route-context.js +21 -0
  83. package/client/scroll.d.ts +16 -0
  84. package/client/scroll.js +80 -0
  85. package/client/search.d.ts +8 -0
  86. package/client/search.js +17 -0
  87. package/client/tailwind.css +8 -0
  88. package/client/transition-helpers.d.ts +28 -0
  89. package/client/transition-helpers.js +63 -0
  90. package/client/transitions.css +90 -0
  91. package/client/transitions.d.ts +21 -0
  92. package/client/transitions.js +156 -0
  93. package/index.d.ts +135 -0
  94. package/index.js +8 -0
  95. package/package.json +156 -4
  96. package/server/app.d.ts +175 -0
  97. package/server/app.js +335 -0
  98. package/server/body.d.ts +20 -0
  99. package/server/body.js +8 -0
  100. package/server/deferred.d.ts +12 -0
  101. package/server/deferred.js +60 -0
  102. package/server/early-hints.d.ts +34 -0
  103. package/server/early-hints.js +63 -0
  104. package/server/handler.d.ts +240 -0
  105. package/server/handler.js +661 -0
  106. package/server/index.d.ts +9 -0
  107. package/server/index.js +6 -0
  108. package/server/inline-styles.d.ts +21 -0
  109. package/server/inline-styles.js +28 -0
  110. package/server/module.d.ts +14 -0
  111. package/server/module.js +96 -0
  112. package/server/render.d.ts +96 -0
  113. package/server/render.js +492 -0
  114. package/server/route-cache.d.ts +245 -0
  115. package/server/route-cache.js +813 -0
  116. package/server/security.d.ts +18 -0
  117. package/server/security.js +47 -0
  118. package/server/shell-build.d.ts +3 -0
  119. package/server/shell-build.js +2 -0
  120. package/server/shell-cache.d.ts +8 -0
  121. package/server/shell-cache.js +836 -0
  122. package/server/strict-csp.d.ts +7 -0
  123. package/server/strict-csp.js +18 -0
  124. package/server-only.d.ts +1 -0
  125. package/server-only.js +3 -0
  126. package/shared/actions.d.ts +10 -0
  127. package/shared/actions.js +18 -0
  128. package/shared/base.d.ts +8 -0
  129. package/shared/base.js +20 -0
  130. package/shared/cookie.d.ts +7 -0
  131. package/shared/cookie.js +32 -0
  132. package/shared/csp-directives.d.ts +9 -0
  133. package/shared/csp-directives.js +85 -0
  134. package/shared/csp-hash.d.ts +39 -0
  135. package/shared/csp-hash.js +121 -0
  136. package/shared/csp.d.ts +26 -0
  137. package/shared/csp.js +13 -0
  138. package/shared/data.d.ts +175 -0
  139. package/shared/data.js +22 -0
  140. package/shared/errors.d.ts +31 -0
  141. package/shared/errors.js +51 -0
  142. package/shared/head.d.ts +57 -0
  143. package/shared/head.js +242 -0
  144. package/shared/nonce.d.ts +2 -0
  145. package/shared/nonce.js +23 -0
  146. package/shared/prefetch-fallback.min.d.ts +1 -0
  147. package/shared/prefetch-fallback.min.js +2 -0
  148. package/shared/prefetch.d.ts +54 -0
  149. package/shared/prefetch.js +74 -0
  150. package/shared/redirect.d.ts +6 -0
  151. package/shared/redirect.js +25 -0
  152. package/shared/server-exports.d.ts +78 -0
  153. package/shared/server-exports.js +77 -0
  154. package/shared/slash.d.ts +8 -0
  155. package/shared/slash.js +24 -0
@@ -0,0 +1,661 @@
1
+ // Web-standard request handler: route match → guards → parallel loaders → render, plus the data
2
+ // endpoint client navigation uses (so guards always run on the server) and form actions.
3
+ import { compileRoutes } from "@rivetjs/runtime/router/server";
4
+ import { createEncoder, _builtinKinds } from "@rivetjs/runtime/resume-format";
5
+ import { HttpError, isHttpError } from "../shared/errors.js";
6
+ import { isActionFailure } from "../shared/actions.js";
7
+ import { DATA_PARAM, KEEP_PAGE, KEEP_PARAM, withoutRivet } from "../shared/data.js";
8
+ export { DATA_PARAM } from "../shared/data.js";
9
+ import { loadServer } from "./module.js";
10
+ import { crossSite, limitBody } from "./security.js";
11
+ import { ABSOLUTE, unsafeTarget } from "../shared/redirect.js";
12
+ import { slashPath, TRAILING_SLASH } from "../shared/slash.js";
13
+ import { parseCookies } from "../shared/cookie.js";
14
+ /** @internal Request property carrying a `ShellRequest` (`Symbol.for("rivet.shell")`). */
15
+ export const SHELL = Symbol.for("rivet.shell");
16
+ /** @internal Short random id tying a hidden 5xx message to its server log entry. */
17
+ export const _digest = () => {
18
+ const bytes = crypto.getRandomValues(new Uint8Array(5));
19
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
20
+ };
21
+ const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
22
+ const ON = { GET: "onGet", POST: "onPost", PUT: "onPut", PATCH: "onPatch", DELETE: "onDelete" };
23
+ /** `byURL` when no layout loader read the URL (shared: a server render always reports it). */
24
+ const NO_LAYOUTS = Object.freeze([]);
25
+ const TOKEN = /^[!#$%&'*+\-.^_`|~\w]+$/;
26
+ /** RFC 6265 path-value: no CTLs, no `;` (it would start another attribute). */
27
+ const PATH = /^\/[^\u0000-\u001f\u007f;]*$/;
28
+ /** Host name or IP literal, optional leading dot. */
29
+ const DOMAIN = /^\.?[a-z\d](?:[a-z\d-]*[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]*[a-z\d])?)*$/i;
30
+ const SAME_SITE = { lax: "Lax", strict: "Strict", none: "None" };
31
+ /** Browsers drop cookies whose name + value exceed 4096 bytes. */
32
+ const COOKIE_MAX = 4096;
33
+ function serializeCookie(name, value, options = {}) {
34
+ if (!TOKEN.test(name))
35
+ throw new TypeError("Invalid cookie name");
36
+ const path = options.path ?? "/";
37
+ if (!PATH.test(path))
38
+ throw new TypeError(`Invalid cookie path ${JSON.stringify(path)}`);
39
+ const encoded = encodeURIComponent(value);
40
+ if (name.length + encoded.length > COOKIE_MAX)
41
+ throw new TypeError(`Cookie "${name}" is larger than ${COOKIE_MAX} bytes (browsers drop it)`);
42
+ const secure = options.secure ?? true;
43
+ const sameSite = options.sameSite ?? "lax";
44
+ if (!Object.hasOwn(SAME_SITE, sameSite))
45
+ throw new TypeError(`Invalid cookie sameSite ${JSON.stringify(sameSite)}`);
46
+ if (sameSite === "none" && !secure)
47
+ throw new TypeError(`Cookie "${name}": sameSite "none" requires secure (browsers reject it)`);
48
+ if ((name.startsWith("__Secure-") || name.startsWith("__Host-")) && !secure)
49
+ throw new TypeError(`Cookie "${name}": the prefix requires secure`);
50
+ if (name.startsWith("__Host-") && (path !== "/" || options.domain))
51
+ throw new TypeError(`Cookie "${name}": __Host- requires path "/" and no domain`);
52
+ let out = name + "=" + encoded + "; Path=" + path;
53
+ if (options.domain) {
54
+ if (!DOMAIN.test(options.domain))
55
+ throw new TypeError(`Invalid cookie domain ${JSON.stringify(options.domain)}`);
56
+ out += "; Domain=" + options.domain;
57
+ }
58
+ if (options.maxAge !== undefined) {
59
+ if (!Number.isFinite(options.maxAge))
60
+ throw new TypeError(`Invalid cookie maxAge ${options.maxAge}`);
61
+ out += "; Max-Age=" + Math.floor(options.maxAge);
62
+ }
63
+ if (options.expires) {
64
+ if (!(options.expires instanceof Date) || Number.isNaN(options.expires.getTime()))
65
+ throw new TypeError("Invalid cookie expires date");
66
+ out += "; Expires=" + options.expires.toUTCString();
67
+ }
68
+ if (options.httpOnly ?? true)
69
+ out += "; HttpOnly";
70
+ if (secure)
71
+ out += "; Secure";
72
+ out += "; SameSite=" + SAME_SITE[sameSite];
73
+ return out;
74
+ }
75
+ /**
76
+ * A response that sets a cookie must not be stored by shared caches (it would hand the cookie to
77
+ * every visitor): `Cache-Control` gets `private`; `public` and `s-maxage` go, other directives stay.
78
+ */
79
+ function privateCache(headers) {
80
+ const kept = (headers.get("cache-control") ?? "")
81
+ .split(",")
82
+ .map((d) => d.trim())
83
+ .filter((d) => d && !/^(?:public|private|s-maxage)\b/i.test(d));
84
+ headers.set("cache-control", ["private", ...kept].join(", "));
85
+ }
86
+ /**
87
+ * Encode a value with the resume table (Date/Map/Set/BigInt/undefined/cycles survive). With built-in entries (RegExp /
88
+ * URLSearchParams / binary / errors) and a `builtins` chunk URL, `x` names it: the client imports it before decoding.
89
+ */
90
+ export function encodeData(value, builtins, origin) {
91
+ const encoder = createEncoder({ origin });
92
+ const root = encoder.ref(value);
93
+ return JSON.stringify(builtins && _builtinKinds(encoder.table) ? { t: encoder.table, r: root, x: builtins } : { t: encoder.table, r: root });
94
+ }
95
+ const isRedirect = (e) => e.location !== undefined;
96
+ /** A same-origin redirect target that is the page itself without a query (`url`: the action request's URL). */
97
+ function backHere(location, url) {
98
+ const target = new URL(location, url);
99
+ return target.origin === url.origin && target.pathname === url.pathname && !target.search && !target.hash;
100
+ }
101
+ /** A redirect from an action without an explicit status answers 303 (the browser follows with GET). */
102
+ const seeOther = (failure) => {
103
+ if (!isHttpError(failure) || !failure.defaultStatus)
104
+ return failure;
105
+ const out = new HttpError(303, failure.message, failure.location);
106
+ if (failure.external)
107
+ Object.defineProperty(out, "external", { value: true });
108
+ return out;
109
+ };
110
+ const NO_PLATFORM = Object.freeze({});
111
+ /** `load(fn, { defer: true })` marker (`shared/server-exports.ts` `DEFER`). */
112
+ const DEFER = Symbol.for("rivet.defer");
113
+ let deferredModule;
114
+ // `event.depends()` outside loaders.
115
+ const noDeps = () => { };
116
+ // Dev hint, once per layout: a layout loader that denies or redirects acts as access control, but
117
+ // client navigations that keep the layout's data (`_rivet_keep`, client-controlled) skip it.
118
+ const hinted = new Set();
119
+ function accessHint(route, index, failure) {
120
+ const layout = route.keys?.[index] ?? String(index);
121
+ if (isHttpError(failure) && (failure.status === 401 || failure.status === 403 || isRedirect(failure)) && !hinted.has(layout)) {
122
+ hinted.add(layout);
123
+ console.warn(`[rivet] The loader of layout "${layout}" ${isRedirect(failure) ? "redirected" : "answered " + failure.status}: loaders are not access control. ` +
124
+ "Client navigations that keep this layout's data skip its loader, so pages below it would load anyway. Check access in a `guard(…)` export (guards always run).");
125
+ }
126
+ throw failure;
127
+ }
128
+ /**
129
+ * One loader's view of the request event (`loaderEvent`): every member reads through to the event, `depends()`
130
+ * records this loader's keys and, for layouts (`byURL`), reading `url` / `request` notes that it depends on the
131
+ * URL. A class instead of `Object.create(event, descriptors)`: that built a dictionary-mode object with fresh
132
+ * closures per loader, per request (~0.6 µs + garbage each). Destructuring loaders read members once each.
133
+ */
134
+ class LoaderEvent {
135
+ #event;
136
+ #index;
137
+ #byURL;
138
+ #deps;
139
+ constructor(event, index, byURL, deps) {
140
+ this.#event = event;
141
+ this.#index = index;
142
+ this.#byURL = byURL;
143
+ this.#deps = deps;
144
+ }
145
+ get request() {
146
+ this.#byURL?.add(this.#index);
147
+ return this.#event.request;
148
+ }
149
+ get url() {
150
+ this.#byURL?.add(this.#index);
151
+ return this.#event.url;
152
+ }
153
+ // A getter (closure made when read): loaders destructure it (`({ depends }) => …`).
154
+ get depends() {
155
+ const deps = this.#deps, index = this.#index;
156
+ return (...keys) => {
157
+ const set = (deps[index] ??= new Set());
158
+ for (const key of keys)
159
+ set.add(String(key));
160
+ };
161
+ }
162
+ get params() {
163
+ return this.#event.params;
164
+ }
165
+ get locals() {
166
+ return this.#event.locals;
167
+ }
168
+ get signal() {
169
+ return this.#event.signal;
170
+ }
171
+ get cookies() {
172
+ return this.#event.cookies;
173
+ }
174
+ get dev() {
175
+ return this.#event.dev;
176
+ }
177
+ get route() {
178
+ return this.#event.route;
179
+ }
180
+ get isDataRequest() {
181
+ return this.#event.isDataRequest;
182
+ }
183
+ get platform() {
184
+ return this.#event.platform;
185
+ }
186
+ get setHeader() {
187
+ return this.#event.setHeader;
188
+ }
189
+ get setHeaders() {
190
+ return this.#event.setHeaders;
191
+ }
192
+ }
193
+ /** Create the app's request handler. */
194
+ export function createHandler(options) {
195
+ const { routes, render, dev = false } = options;
196
+ // `origin`: same-origin URLs in the data serialize relative (prerendered `_data.json`: no build-time origin).
197
+ const reply = (value, status, headers, origin) => json(value, status, headers, options.builtins?.(), origin);
198
+ const report = options.onError ?? ((error, _request, info) => console.error("[rivet] digest " + info?.digest + ":", error));
199
+ const match = compileRoutes(routes.map((route) => ({ path: route.pattern, render: () => route })));
200
+ // The root layout's server module (layout id `/`): unmatched URLs' error pages render inside it.
201
+ const rootLayout = routes.find((route) => route.keys?.[0] === "/");
202
+ return async (request, context = {}) => {
203
+ const url = new URL(request.url);
204
+ // `url.searchParams` only when the query names the router's params (document requests skip it);
205
+ // they are removed textually (re-serializing would turn `?flag&q=a%20b` into `?flag=&q=a+b`).
206
+ const data = url.search.includes(DATA_PARAM) && url.searchParams.get(DATA_PARAM) === "data";
207
+ // Layouts whose data the client still has (`_rivet_keep=id,id`): their loaders are skipped (guards still run).
208
+ const kept = data ? url.searchParams.get(KEEP_PARAM) : null;
209
+ if (data)
210
+ url.search = withoutRivet(url.search);
211
+ const headers = new Headers();
212
+ const nonce = context.nonce;
213
+ // Seeded by the caller (`context.locals`): a copy, so guards writing `locals` never reach the caller's object.
214
+ const seed = context.locals;
215
+ const locals = seed ? { ...seed } : {};
216
+ const found = match(url);
217
+ const route = found ? found.route.render(undefined, found) : undefined;
218
+ const method = request.method === "HEAD" ? "GET" : request.method;
219
+ // `?/save&next=/b`: the action name ends at the first `&`. Bad percent-encoding → 400 (after CSRF).
220
+ let action;
221
+ let malformed = false;
222
+ if (method === "POST" && url.search.startsWith("?/")) {
223
+ action = url.search.slice(2).split("&", 1)[0];
224
+ try {
225
+ action = decodeURIComponent(action);
226
+ }
227
+ catch {
228
+ malformed = true;
229
+ }
230
+ }
231
+ // Enhanced form submits (JSON) get redirects and failures as JSON, like data requests.
232
+ const wantsJSON = data || (action !== undefined && (request.headers.get("accept") ?? "").includes("application/json"));
233
+ // Error pages keep the outermost layouts whose guards passed (`guarded` modules) and whose loaders
234
+ // succeeded: loaders that already ran are reused, the others run now (guards passed for them).
235
+ let guarded = 0;
236
+ let modules = [];
237
+ const loads = [];
238
+ const errorLayouts = async () => {
239
+ const out = [];
240
+ for (let i = 0; i < Math.min(guarded, route ? route.layouts.length : 1); i++) {
241
+ try {
242
+ out.push(await (loads[i] ??= Promise.resolve(modules[i]?.load?.(event))));
243
+ }
244
+ catch {
245
+ break;
246
+ }
247
+ }
248
+ return out;
249
+ };
250
+ // Set by `event.cookies`: the response gets `privateCache` once its headers are final.
251
+ let setsCookie = false;
252
+ const fail = async (status, message) => {
253
+ if (setsCookie)
254
+ privateCache(headers);
255
+ if (wantsJSON)
256
+ return reply({ error: { status, message } }, status, headers, url.origin);
257
+ const layouts = options.renderError && guarded ? await errorLayouts() : [];
258
+ const input = { request, url, route, status, message, headers, nonce, locals, ...(layouts.length ? { layouts } : {}) };
259
+ if (options.renderError)
260
+ return options.renderError(input);
261
+ headers.set("content-type", "text/plain; charset=utf-8");
262
+ return new Response(message, { status, headers });
263
+ };
264
+ const body = { exceeded: false };
265
+ let phase = "handler";
266
+ const settle = async (failure) => {
267
+ if (body.exceeded)
268
+ return fail(413, "Payload too large");
269
+ // Absolute redirect targets must stay on this origin unless `redirect(url, { external: true })`.
270
+ if (isHttpError(failure) &&
271
+ isRedirect(failure) &&
272
+ !failure.external &&
273
+ ABSOLUTE.test(failure.location) &&
274
+ new URL(failure.location).origin !== url.origin)
275
+ failure = new Error(`redirect() to another origin (${failure.location}); pass { external: true } to allow it`);
276
+ if (isHttpError(failure)) {
277
+ if (isRedirect(failure)) {
278
+ if (setsCookie)
279
+ privateCache(headers);
280
+ if (wantsJSON)
281
+ return reply({ redirect: failure.location, status: failure.status }, 200, headers, url.origin);
282
+ headers.set("location", failure.location);
283
+ return new Response(null, { status: failure.status, headers });
284
+ }
285
+ if (failure.status < 500)
286
+ return fail(failure.status, failure.expose || dev ? failure.message : "Internal Server Error");
287
+ const digest = _digest();
288
+ report(failure, request, { digest, phase, route: route.id });
289
+ return fail(failure.status, failure.expose || dev ? failure.message : `Internal Server Error (digest ${digest})`);
290
+ }
291
+ if (request.signal.aborted)
292
+ throw failure;
293
+ const digest = _digest();
294
+ report(failure, request, { digest, phase, route: route.id });
295
+ return fail(500, dev && failure instanceof Error ? failure.message : `Internal Server Error (digest ${digest})`);
296
+ };
297
+ // Parsed on first cookie access (most requests never read one).
298
+ let jar;
299
+ const cookieJar = () => (jar ??= parseCookies(request.headers.get("cookie")));
300
+ const event = {
301
+ request,
302
+ url,
303
+ params: found?.params ?? {},
304
+ locals,
305
+ signal: request.signal,
306
+ dev,
307
+ cookies: {
308
+ get: (name) => cookieJar().get(name),
309
+ getAll: () => Array.from(cookieJar(), ([name, value]) => ({ name, value })),
310
+ set: (name, value, cookie) => {
311
+ cookieJar().set(name, value);
312
+ setsCookie = true;
313
+ headers.append("set-cookie", serializeCookie(name, value, cookie));
314
+ },
315
+ delete: (name, cookie) => {
316
+ cookieJar().delete(name);
317
+ setsCookie = true;
318
+ headers.append("set-cookie", serializeCookie(name, "", { ...cookie, maxAge: 0, expires: new Date(0) }));
319
+ },
320
+ },
321
+ setHeader: (name, value) => headers.set(name, value),
322
+ setHeaders: (values) => {
323
+ for (const name in values) {
324
+ if (name.toLowerCase() === "set-cookie")
325
+ throw new TypeError("setHeaders: use event.cookies.set for set-cookie");
326
+ headers.set(name, values[name]);
327
+ }
328
+ },
329
+ route: { id: route?.id ?? null },
330
+ isDataRequest: data,
331
+ platform: context.platform || context.address ? { ...(context.address ? { clientAddress: context.address } : {}), ...context.platform } : NO_PLATFORM,
332
+ depends: noDeps,
333
+ };
334
+ // Each loader gets its own view of the event: `depends()` records its keys (route data index);
335
+ // `track` (layouts) also notes reads of the URL or request (such data is not kept across URL changes).
336
+ const deps = [];
337
+ const byURL = new Set();
338
+ const loaderEvent = (i, track) => new LoaderEvent(event, i, track ? byURL : null, deps);
339
+ // Only when a loader called `depends()`: one entry per loader, undefined for the others.
340
+ const depsOf = () => (deps.length ? { deps: Array.from(modules, (_, i) => (deps[i] ? [...deps[i]] : undefined)) } : {});
341
+ // A Response from a request handler, guard, action or endpoint keeps what the request set: event
342
+ // cookies are appended, other event headers fill in what it lacks (its own win). Immutable headers
343
+ // (`Response.redirect()`, a proxied `fetch()`) are copied first.
344
+ const merge = (response) => {
345
+ const cookies = headers.getSetCookie();
346
+ let own = false;
347
+ headers.forEach((_, name) => (own ||= name !== "set-cookie"));
348
+ if (!cookies.length && !own)
349
+ return response;
350
+ const apply = (target) => {
351
+ headers.forEach((value, name) => name !== "set-cookie" && !target.has(name) && target.set(name, value));
352
+ for (const cookie of cookies)
353
+ target.append("set-cookie", cookie);
354
+ if (setsCookie)
355
+ privateCache(target);
356
+ };
357
+ try {
358
+ apply(response.headers);
359
+ return response;
360
+ }
361
+ catch {
362
+ const copy = new Response(response.body, response);
363
+ apply(copy.headers);
364
+ return copy;
365
+ }
366
+ };
367
+ // Data requests: a guard's or request handler's Response becomes the JSON the client follows
368
+ // (3xx → `{ redirect }`, anything else → `{ reload }`); enhanced actions get redirects as JSON.
369
+ // A target the client must not follow itself (`javascript:`, `//evil`, control characters): the
370
+ // client loads the document instead, where the browser applies the real response.
371
+ const early = (response) => {
372
+ if (!wantsJSON)
373
+ return merge(response);
374
+ for (const cookie of response.headers.getSetCookie())
375
+ headers.append("set-cookie", cookie);
376
+ if (!headers.has("cache-control"))
377
+ headers.set("cache-control", "no-store");
378
+ else if (setsCookie || headers.has("set-cookie"))
379
+ privateCache(headers);
380
+ const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
381
+ if (location !== null && !unsafeTarget(location) && (!ABSOLUTE.test(location) || /^https?:/i.test(location)))
382
+ return reply({ redirect: location, status: response.status }, 200, headers, url.origin);
383
+ return data || location !== null ? reply({ reload: true }, 200, headers, url.origin) : merge(response);
384
+ };
385
+ if (!found || !route) {
386
+ // The 404 page renders inside the root layout when its guard passes (its loader runs in `fail`).
387
+ if (rootLayout && options.renderError && !wantsJSON && method === "GET") {
388
+ try {
389
+ modules = [await loadServer(rootLayout.layouts[0])];
390
+ const verdict = await modules[0]?.guard?.(event);
391
+ if (verdict === undefined)
392
+ guarded = 1;
393
+ }
394
+ catch {
395
+ /* No layout around the 404 page. */
396
+ }
397
+ }
398
+ return fail(404, "Not found");
399
+ }
400
+ // `rivetRouter({ trailingSlash })` (build-time constant, folded away when unset): page GET/HEAD in
401
+ // the other form → 308 to the canonical path (query kept; data requests get `{ redirect }`), before
402
+ // any handler runs. `/`, endpoints and other methods are left alone.
403
+ if (TRAILING_SLASH !== "ignore" && method === "GET" && route.page) {
404
+ const canonical = slashPath(url.pathname, TRAILING_SLASH);
405
+ if (canonical !== url.pathname) {
406
+ headers.set("location", canonical + url.search);
407
+ return early(new Response(null, { status: 308, headers }));
408
+ }
409
+ }
410
+ if (!METHODS.has(method)) {
411
+ if (route.page)
412
+ headers.set("allow", "GET, HEAD, POST");
413
+ return fail(405, "Method not allowed");
414
+ }
415
+ try {
416
+ modules = await Promise.all([...route.layouts.map(loadServer), loadServer(route.server)]);
417
+ const own = modules[modules.length - 1];
418
+ const on = method === "OPTIONS" ? "onOptions" : ON[method];
419
+ // Unsafe methods: cross-site check before any handler, guard or action; then the body limit.
420
+ // OPTIONS (CORS preflight) is safe: no check, no body.
421
+ if (method !== "GET" && method !== "OPTIONS") {
422
+ if (own?.csrf !== false && crossSite(request, url.origin, dev))
423
+ throw new HttpError(403, "Cross-site request rejected");
424
+ request = limitBody(request, options.maxBody, body);
425
+ event.request = request;
426
+ }
427
+ if (malformed)
428
+ throw new HttpError(400, "Malformed action name");
429
+ // Page routes take GET, actions, and methods the route module handles (`onPost`…).
430
+ // `Allow`: page routes take GET/HEAD, POST (actions) and the methods the route module handles;
431
+ // endpoints what they export. 405 answers list those; OPTIONS answers add OPTIONS.
432
+ const allow = (options) => [
433
+ ...(route.page
434
+ ? ["GET, HEAD, POST", ...["PUT", "PATCH", "DELETE"].filter((m) => own?.[ON[m]])]
435
+ : Object.keys(ON).filter((m) => own?.[ON[m]]).map((m) => (m === "GET" ? "GET, HEAD" : m))),
436
+ ...(options ? ["OPTIONS"] : []),
437
+ ].join(", ");
438
+ if (route.page && method !== "GET" && method !== "OPTIONS" && action === undefined && !own?.[on]) {
439
+ headers.set("allow", allow(false));
440
+ return fail(405, "Method not allowed");
441
+ }
442
+ // Request handlers: outermost first, `onRequest` then `on<Method>`; an endpoint route's own
443
+ // `on<Method>` produces its response below. OPTIONS: `onRequest` then `onOptions` (own module too).
444
+ for (const module of modules) {
445
+ if (!module)
446
+ continue;
447
+ const first = module.onRequest;
448
+ if (first) {
449
+ const result = await first(event);
450
+ if (result instanceof Response)
451
+ return early(result);
452
+ if (isHttpError(result))
453
+ throw result;
454
+ }
455
+ const second = route.page || module !== own || method === "OPTIONS" ? module[on] : undefined;
456
+ if (second) {
457
+ const result = await second(event);
458
+ if (result instanceof Response)
459
+ return early(result);
460
+ if (isHttpError(result))
461
+ throw result;
462
+ }
463
+ }
464
+ // OPTIONS no handler answered: what the route allows (no guards, loaders or render).
465
+ if (method === "OPTIONS") {
466
+ headers.set("allow", allow(true));
467
+ return new Response(null, { status: 204, headers });
468
+ }
469
+ // Early hints: before guards and loaders (the slow part of data-backed pages); public asset URLs only.
470
+ if (options.earlyHints && method === "GET" && route.page && !wantsJSON && action === undefined)
471
+ options.earlyHints(route, request);
472
+ // Guards run outermost first and stop the chain on the first non-void result.
473
+ phase = "guard";
474
+ for (const module of modules) {
475
+ const guard = module?.guard;
476
+ if (guard) {
477
+ const verdict = await guard(event);
478
+ if (verdict instanceof Response)
479
+ return early(verdict);
480
+ if (isHttpError(verdict))
481
+ throw verdict;
482
+ }
483
+ guarded++;
484
+ }
485
+ phase = "handler";
486
+ for (const module of modules) {
487
+ const extra = module?.headers?.(event);
488
+ if (extra)
489
+ new Headers(extra).forEach((value, name) => headers.set(name, value));
490
+ }
491
+ // Route rule `shell` (document GET of a page): a hit sends the stored shell now; a fill tracks request reads.
492
+ const shell = route.page && action === undefined && !data ? request[SHELL]?.begin(route, event, headers) : undefined;
493
+ if (!route.page) {
494
+ const endpoint = own?.[on];
495
+ if (!endpoint) {
496
+ headers.set("allow", allow(false));
497
+ return fail(405, "Method not allowed");
498
+ }
499
+ const response = await endpoint(event);
500
+ if (body.exceeded)
501
+ return fail(413, "Payload too large");
502
+ if (!(response instanceof Response))
503
+ throw new Error(`${route.id}: ${on} must return a Response`);
504
+ return merge(response);
505
+ }
506
+ let performed;
507
+ let status = 200;
508
+ if (action !== undefined) {
509
+ phase = "action";
510
+ // Own function members only: `?/toString`, `?/constructor`, `?/__proto__` are unknown actions.
511
+ const actions = own?.actions;
512
+ const run = actions && Object.hasOwn(actions, action) ? actions[action] : undefined;
513
+ if (typeof run !== "function")
514
+ throw new HttpError(404, `Unknown action "${action}"`);
515
+ let result;
516
+ try {
517
+ result = await run(event);
518
+ }
519
+ catch (failure) {
520
+ result = failure;
521
+ if (!isHttpError(failure))
522
+ throw failure;
523
+ }
524
+ if (body.exceeded)
525
+ return fail(413, "Payload too large");
526
+ if (isHttpError(result)) {
527
+ const failure = seeOther(result);
528
+ // Single flight for post/redirect/get back to this page (`redirect()` to its path, no query): the
529
+ // enhanced submit gets the page's fresh loader data with the redirect (no second data request).
530
+ if (wantsJSON && request.headers.has("x-rivet-revalidate") && isRedirect(failure) && backHere(failure.location, url)) {
531
+ if (!headers.has("cache-control"))
532
+ headers.set("cache-control", "no-store");
533
+ if (setsCookie)
534
+ privateCache(headers);
535
+ url.search = "";
536
+ phase = "loader";
537
+ const fresh = await Promise.all(modules.map((module, i) => module?.load?.(loaderEvent(i, false))));
538
+ const head = await options.head?.(route, fresh, { params: found.params, url });
539
+ return reply({ redirect: failure.location, status: failure.status, data: fresh, ...(head ? { head } : {}), ...depsOf() }, 200, headers, url.origin);
540
+ }
541
+ throw failure;
542
+ }
543
+ if (result instanceof Response)
544
+ return early(result);
545
+ if (isActionFailure(result)) {
546
+ status = result.status;
547
+ performed = { name: action, result: result.data, status };
548
+ }
549
+ else
550
+ performed = { name: action, result };
551
+ url.search = "";
552
+ if (wantsJSON) {
553
+ if (!headers.has("cache-control"))
554
+ headers.set("cache-control", "no-store");
555
+ if (setsCookie)
556
+ privateCache(headers);
557
+ // Single flight: an enhanced form submit that succeeded gets the page's fresh loader data
558
+ // (the client revalidates from it). Failures changed nothing; other JSON posts (field checks,
559
+ // autosave drafts) do not ask for it.
560
+ if (status < 400 && request.headers.has("x-rivet-revalidate")) {
561
+ phase = "loader";
562
+ const fresh = await Promise.all(modules.map((module, i) => module?.load?.(loaderEvent(i, false))));
563
+ const head = await options.head?.(route, fresh, { params: found.params, url });
564
+ return reply({ action: performed, data: fresh, ...(head ? { head } : {}), ...depsOf() }, status, headers, url.origin);
565
+ }
566
+ return reply({ action: performed }, status, headers, url.origin);
567
+ }
568
+ }
569
+ const skip = new Set();
570
+ if (kept) {
571
+ const ids = new Set(kept.split(","));
572
+ const allowed = await options.keepable?.(route);
573
+ route.keys?.forEach((id, i) => ids.has(id) && allowed?.[i] !== false && skip.add(i));
574
+ // `.`: the page's own loader (same-URL `invalidate(key)` of layout keys only).
575
+ if (ids.has(KEEP_PAGE) && allowed?.[route.layouts.length] !== false)
576
+ skip.add(route.layouts.length);
577
+ }
578
+ // Layout loaders that read the URL (or request) cannot be kept across URL changes: the client learns which.
579
+ phase = "loader";
580
+ // Deferred loaders (`load(fn, { defer: true })`, document requests only; data requests await them like any loader):
581
+ // started now, not awaited; their slot is a `Deferred` handle (`deferred.ts`, imported on first use).
582
+ let lazy, promises, moved;
583
+ if (!data && modules.some((module) => module?.load && module.load[DEFER]))
584
+ lazy = await (deferredModule ??= import("./deferred.js"));
585
+ const loaded = await Promise.all(modules.map((module, i) => {
586
+ if (skip.has(i) || !module?.load)
587
+ return (loads[i] = undefined);
588
+ if (lazy && module.load[DEFER]) {
589
+ // Deferred loaders are the shell's holes: live on every request, never tracked.
590
+ const view = lazy.lateEvent(loaderEvent(i, i < route.layouts.length));
591
+ // Error pages await it like the other loaders (`errorLayouts`): they render after the failure, not streamed.
592
+ const started = ((promises ??= [])[i] = loads[i] = lazy.startDeferred(() => module.load(view)));
593
+ // A redirect after the head is out: a client redirect at the document's end (other origins need `external`).
594
+ started.catch((failure) => {
595
+ if (isHttpError(failure) &&
596
+ isRedirect(failure) &&
597
+ (failure.external || !ABSOLUTE.test(failure.location) || new URL(failure.location).origin === url.origin))
598
+ moved ??= failure.location;
599
+ });
600
+ // The handle is a thenable: set after `Promise.all`, which would await it.
601
+ return undefined;
602
+ }
603
+ // Shell hit: the stored shell's data (its loaders read no request input; the composed response is out).
604
+ if (shell?.data)
605
+ return (loads[i] = undefined), shell.data[i];
606
+ const view = loaderEvent(i, i < route.layouts.length);
607
+ const load = Promise.resolve(module.load(shell?.event ? shell.event(view, i) : view));
608
+ // Dev: a layout loader used as access control is skipped for kept layouts (`_rivet_keep`).
609
+ return (loads[i] = dev && i < route.layouts.length ? load.catch((failure) => accessHint(route, i, failure)) : load);
610
+ }));
611
+ shell?.loaded?.(loaded.slice(), [...byURL], depsOf().deps);
612
+ if (promises)
613
+ promises.forEach((promise, i) => (loaded[i] = shell?.handle ? shell.handle(promise, dev) : lazy.deferredHandle(promise, dev)));
614
+ const head = await options.head?.(route, loaded, { params: found.params, url });
615
+ if (data) {
616
+ if (!headers.has("cache-control"))
617
+ headers.set("cache-control", "private, no-store");
618
+ else if (setsCookie)
619
+ privateCache(headers);
620
+ return reply({
621
+ data: loaded,
622
+ status,
623
+ ...(head ? { head } : {}),
624
+ ...(skip.size ? { keep: [...skip] } : {}),
625
+ ...(byURL.size ? { byURL: [...byURL] } : {}),
626
+ ...depsOf(),
627
+ }, status, headers, url.origin);
628
+ }
629
+ if (!headers.has("cache-control"))
630
+ headers.set("cache-control", "no-cache");
631
+ if (setsCookie)
632
+ privateCache(headers);
633
+ phase = "render";
634
+ return await render({
635
+ request,
636
+ url,
637
+ route,
638
+ params: found.params,
639
+ data: loaded,
640
+ status,
641
+ headers,
642
+ nonce,
643
+ action: performed,
644
+ locals: event.locals,
645
+ head,
646
+ byURL: shell?.byURL ?? (byURL.size ? [...byURL] : NO_LAYOUTS),
647
+ ...(shell?.data ? (shell.deps ? { deps: shell.deps } : {}) : depsOf()),
648
+ ...(promises ? { deferred: true, moved: () => moved } : {}),
649
+ ...(shell ? { shell } : {}),
650
+ });
651
+ }
652
+ catch (failure) {
653
+ return settle(failure);
654
+ }
655
+ };
656
+ }
657
+ function json(value, status, headers, builtins, origin) {
658
+ headers.set("content-type", "application/json; charset=utf-8");
659
+ headers.set("x-content-type-options", "nosniff");
660
+ return new Response(encodeData(value, builtins, origin), { status, headers });
661
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./handler.js";
2
+ export type { CSPDirectives, CSPOptions } from "../shared/csp.js";
3
+ export type { TrailingSlash } from "../shared/slash.js";
4
+ export { strictCSP } from "./strict-csp.js";
5
+ export { createApp, mergeHeads, type AppConfig, type GeneratedEntry, type PageInput } from "./app.js";
6
+ export { renderPage, renderDocument, failureMarker, headTags, type DocumentShell, type DocumentInput, type DocumentParts, type RenderPageOptions, } from "./render.js";
7
+ export { serverExports, mergeServer, loadServer } from "./module.js";
8
+ export type { CacheRule, RouteCacheOptions, RouteRule, RouteRules, ShellRule } from "./route-cache.js";
9
+ export type { EarlyHintsOptions } from "./early-hints.js";