@rivetjs/router 0.0.0-stage → 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) 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 +7 -0
  20. package/adapters/files.js +103 -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 +163 -0
  26. package/adapters/node.js +940 -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 +92 -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 +33 -0
  38. package/adapters/vercel.js +45 -0
  39. package/build/codegen.d.ts +84 -0
  40. package/build/codegen.js +920 -0
  41. package/build/dev-errors.d.ts +19 -0
  42. package/build/dev-errors.js +236 -0
  43. package/build/migrate.d.ts +15 -0
  44. package/build/migrate.js +146 -0
  45. package/build/route-css.d.ts +54 -0
  46. package/build/route-css.js +216 -0
  47. package/build/routes.d.ts +50 -0
  48. package/build/routes.js +202 -0
  49. package/build/sync.d.ts +20 -0
  50. package/build/sync.js +16 -0
  51. package/build/vite.d.ts +195 -0
  52. package/build/vite.js +703 -0
  53. package/cli/rivet.d.ts +2 -0
  54. package/cli/rivet.js +100 -0
  55. package/client/active.d.ts +6 -0
  56. package/client/active.js +22 -0
  57. package/client/announce.d.ts +2 -0
  58. package/client/announce.js +21 -0
  59. package/client/dev.d.ts +41 -0
  60. package/client/dev.js +74 -0
  61. package/client/index.d.ts +152 -0
  62. package/client/index.js +680 -0
  63. package/client/layouts.d.ts +110 -0
  64. package/client/layouts.js +310 -0
  65. package/client/leave.d.ts +40 -0
  66. package/client/leave.js +75 -0
  67. package/client/link.d.ts +48 -0
  68. package/client/link.jsx +65 -0
  69. package/client/live.d.ts +57 -0
  70. package/client/live.js +170 -0
  71. package/client/navigate.d.ts +71 -0
  72. package/client/navigate.js +120 -0
  73. package/client/navigation.d.ts +28 -0
  74. package/client/navigation.js +14 -0
  75. package/client/params.d.ts +16 -0
  76. package/client/params.js +28 -0
  77. package/client/route-context.d.ts +32 -0
  78. package/client/route-context.js +21 -0
  79. package/client/scroll.d.ts +16 -0
  80. package/client/scroll.js +80 -0
  81. package/client/search.d.ts +8 -0
  82. package/client/search.js +17 -0
  83. package/client/tailwind.css +8 -0
  84. package/client/transition-helpers.d.ts +28 -0
  85. package/client/transition-helpers.js +63 -0
  86. package/client/transitions.css +90 -0
  87. package/client/transitions.d.ts +21 -0
  88. package/client/transitions.js +156 -0
  89. package/index.d.ts +138 -0
  90. package/index.js +7 -0
  91. package/package.json +156 -4
  92. package/server/app.d.ts +169 -0
  93. package/server/app.js +302 -0
  94. package/server/body.d.ts +20 -0
  95. package/server/body.js +8 -0
  96. package/server/early-hints.d.ts +34 -0
  97. package/server/early-hints.js +63 -0
  98. package/server/handler.d.ts +196 -0
  99. package/server/handler.js +623 -0
  100. package/server/index.d.ts +9 -0
  101. package/server/index.js +6 -0
  102. package/server/inline-styles.d.ts +21 -0
  103. package/server/inline-styles.js +28 -0
  104. package/server/module.d.ts +14 -0
  105. package/server/module.js +78 -0
  106. package/server/render.d.ts +94 -0
  107. package/server/render.js +489 -0
  108. package/server/route-cache.d.ts +175 -0
  109. package/server/route-cache.js +742 -0
  110. package/server/security.d.ts +18 -0
  111. package/server/security.js +47 -0
  112. package/server/strict-csp.d.ts +7 -0
  113. package/server/strict-csp.js +18 -0
  114. package/server-only.d.ts +1 -0
  115. package/server-only.js +3 -0
  116. package/shared/actions.d.ts +10 -0
  117. package/shared/actions.js +18 -0
  118. package/shared/base.d.ts +8 -0
  119. package/shared/base.js +20 -0
  120. package/shared/cookie.d.ts +7 -0
  121. package/shared/cookie.js +32 -0
  122. package/shared/csp-directives.d.ts +9 -0
  123. package/shared/csp-directives.js +85 -0
  124. package/shared/csp-hash.d.ts +39 -0
  125. package/shared/csp-hash.js +121 -0
  126. package/shared/csp.d.ts +26 -0
  127. package/shared/csp.js +13 -0
  128. package/shared/data.d.ts +175 -0
  129. package/shared/data.js +22 -0
  130. package/shared/errors.d.ts +31 -0
  131. package/shared/errors.js +51 -0
  132. package/shared/head.d.ts +57 -0
  133. package/shared/head.js +242 -0
  134. package/shared/nonce.d.ts +2 -0
  135. package/shared/nonce.js +23 -0
  136. package/shared/prefetch-fallback.min.d.ts +1 -0
  137. package/shared/prefetch-fallback.min.js +2 -0
  138. package/shared/prefetch.d.ts +54 -0
  139. package/shared/prefetch.js +74 -0
  140. package/shared/redirect.d.ts +6 -0
  141. package/shared/redirect.js +25 -0
  142. package/shared/slash.d.ts +8 -0
  143. package/shared/slash.js +24 -0
package/build/vite.js ADDED
@@ -0,0 +1,703 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // Vite plugin: scan routes, write `.rivet/` codegen, compile it with the Rivet plugin (resume mode
10
+ // for server/static apps), default the build inputs, and serve requests in dev.
11
+ import { lstat, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
12
+ import { dirname, join, relative, resolve, sep } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { rivet, _factsResolve, } from "@rivetjs/compiler/vite";
15
+ import { updateEditorManifest } from "@rivetjs/compiler/editor";
16
+ import { scanRoutes, _serverExportsOf } from "./routes.js";
17
+ import { generate } from "./codegen.js";
18
+ import { normalizeBase } from "../shared/base.js";
19
+ import { slashPath } from "../shared/slash.js";
20
+ import { DATA_PARAM } from "../shared/data.js";
21
+ import { compileRules } from "../server/route-cache.js";
22
+ /** Build-time helper for other Vite plugins (docs-kit example frames): one merged sheet per stylesheet list. */
23
+ export { mergeRouteCSS } from "./route-css.js";
24
+ import { bundleRouteCSS, devRouteCSS, INLINE_CSS, inlineCSS, mergeRouteCSS, ROUTE_CSS } from "./route-css.js";
25
+ import { devHandler, devReport, listenClientErrors } from "./dev-errors.js";
26
+ import { adapterOptions, targetBuildConfig, targetEntryPlugin } from "../adapters/target-build.js";
27
+ /** The router's own base-path module (its `BASE` reads Vite's `BASE_URL`). */
28
+ const BASE_MODULE = fileURLToPath(new URL("../shared/base.js", import.meta.url));
29
+ /** The router's trailing-slash module (its `TRAILING_SLASH` constant is rewritten when the option is set). */
30
+ const SLASH_MODULE = fileURLToPath(new URL("../shared/slash.js", import.meta.url));
31
+ const SLASH_DEFAULT = '"ignore" as TrailingSlash';
32
+ const posix = (path) => path.split(sep).join("/");
33
+ const EMPTY = "\0rivet-router-empty";
34
+ /** Write generated files whose contents changed; returns whether anything changed. */
35
+ async function sync(dir, files) {
36
+ await mkdir(dir, { recursive: true });
37
+ let changed = false;
38
+ for (const [name, contents] of Object.entries(files)) {
39
+ const path = join(dir, name);
40
+ const current = await readFile(path, "utf8").catch(() => null);
41
+ if (current === contents)
42
+ continue;
43
+ await writeFile(path, contents);
44
+ changed = true;
45
+ }
46
+ return changed;
47
+ }
48
+ const SOURCE = /\.[cm]?[jt]sx?$/;
49
+ const FEATURES = [
50
+ ["searchParams", /\b(?:useSearchParams|SearchParams)\b/],
51
+ ["navigate", /\b(?:navigate|invalidate|revalidate|setSearchParams)\b/],
52
+ ["params", /\buseParams\b/],
53
+ ["activeLinks", /<Link\b[^>]*?\sactive\b/],
54
+ ["prefetch", /\bprefetch\s*=\s*\{?\s*["'](?:document|prerender)["']/],
55
+ ];
56
+ /** Every `RouterUsage` flag (FEATURES + `forms`). */
57
+ const USAGE = [...FEATURES.map(([name]) => name), "forms"];
58
+ const none = () => ({ searchParams: false, navigate: false, forms: false, params: false, activeLinks: false, prefetch: false });
59
+ /** Features one source text uses: names in a file importing `@rivetjs/router` (`URLSearchParams` does not count). */
60
+ function featuresOf(text, into) {
61
+ // Forms (enhanced submits follow redirects / revalidate through the router): `@rivetjs/forms`, or ui's `Form`.
62
+ if (text.includes("@rivetjs/forms") || (text.includes("@rivetjs/ui") && /\bForm\b/.test(text)))
63
+ into.forms = into.navigate = true;
64
+ if (!text.includes("@rivetjs/router"))
65
+ return;
66
+ for (const [name, pattern] of FEATURES)
67
+ if (pattern.test(text))
68
+ into[name] = true;
69
+ }
70
+ /** Never sources: dot entries (`.rivet`, `.git`), `node_modules`, build output (`dist`, `dist-<lane>`, `dist.x`). */
71
+ const OUTPUT = /^(?:\.|node_modules$|dist(?:$|[-_.]))/;
72
+ async function scan(dir, into, seen) {
73
+ if (seen.has(dir))
74
+ return;
75
+ seen.add(dir);
76
+ // Concurrent builds may create or remove files mid-walk: a vanished entry is skipped (ENOENT).
77
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
78
+ const path = join(dir, entry.name);
79
+ if (entry.isDirectory()) {
80
+ if (!OUTPUT.test(entry.name))
81
+ await scan(path, into, seen);
82
+ }
83
+ else if (!entry.name.startsWith(".") && SOURCE.test(entry.name)) {
84
+ const text = await readFile(path, "utf8").catch((error) => {
85
+ if (error.code === "ENOENT" || error.code === "EISDIR")
86
+ return "";
87
+ throw error;
88
+ });
89
+ featuresOf(text, into);
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Which router features the project's sources use: every source under `root` (skipping
95
+ * `node_modules`, `dist` and dot directories) plus the sources of linked (workspace / `npm link`)
96
+ * dependencies of `root/package.json`, except `@rivetjs/*` packages. Text based: a name that appears
97
+ * in a file importing `@rivetjs/router` counts (false positives only cost bytes); published
98
+ * dependencies are not scanned (set `rivetRouter({ navigate, params, searchParams })`).
99
+ */
100
+ export async function routerUsage(root, options = {}) {
101
+ const usage = none(),
102
+ // `exclude`: absolute directories never walked (the Vite build `outDir`, the generated `.rivet/`).
103
+ seen = new Set((options.exclude ?? []).map((dir) => resolve(root, dir)).filter((dir) => dir !== resolve(root)));
104
+ await scan(root, usage, seen);
105
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8").catch(() => "{}"));
106
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
107
+ if (name.startsWith("@rivetjs/"))
108
+ continue;
109
+ for (let dir = root;; dir = dirname(dir)) {
110
+ const path = join(dir, "node_modules", name);
111
+ const link = await lstat(path).catch(() => null);
112
+ if (link?.isSymbolicLink())
113
+ await scan(await realpath(path), usage, seen);
114
+ if (link || dir === dirname(dir))
115
+ break;
116
+ }
117
+ }
118
+ return usage;
119
+ }
120
+ /** Regenerate `.rivet/` for a project root; returns the resume modules and whether files changed. */
121
+ export async function generateRoutes(root, options = {}) {
122
+ const routesDir = posix(options.routes ?? "src/routes");
123
+ const outDir = posix(options.outDir ?? ".rivet");
124
+ const table = await scanRoutes(resolve(root, routesDir));
125
+ const generated = generate(table, {
126
+ routesDir,
127
+ outDir,
128
+ document: posix(options.document ?? "src/document.ts"),
129
+ mode: options.mode ?? "server",
130
+ ...(options.head ? { head: options.head } : {}),
131
+ ...(options.transitions ? { transitions: options.transitions } : {}),
132
+ ...(options.announce === false ? { announce: false } : {}),
133
+ ...(options.base ? { base: options.base } : {}),
134
+ ...(options.searchParams ? { searchParams: true } : {}),
135
+ ...(options.navigate ? { navigate: true } : {}),
136
+ ...(options.forms ? { forms: true } : {}),
137
+ ...(options.params ? { params: true } : {}),
138
+ ...(options.activeLinks ? { activeLinks: true } : {}),
139
+ ...(options.prefetch ? { prefetch: options.prefetch } : {}),
140
+ ...(options.prefetchEagerness ? { prefetchEagerness: options.prefetchEagerness } : {}),
141
+ ...(options.prefetchPaths === false ? { prefetchPaths: false } : {}),
142
+ ...(options.trailingSlash && options.trailingSlash !== "ignore" ? { trailingSlash: options.trailingSlash } : {}),
143
+ ...(options.preload?.background !== undefined ? { preloadBackground: options.preload.background } : {}),
144
+ ...(options.routeRules && Object.keys(options.routeRules).length ? { routeRules: options.routeRules } : {}),
145
+ ...(options.clientRouter !== undefined ? { clientRouter: options.clientRouter } : {}),
146
+ });
147
+ const changed = await sync(resolve(root, outDir), generated.files);
148
+ // No router contexts: the compiler's default resume entry (drop a stale generated one).
149
+ const resumeEntry = "entry.resume.ts" in generated.files;
150
+ if (!resumeEntry)
151
+ await rm(resolve(root, outDir, "entry.resume.ts"), { force: true });
152
+ // Editors: route ids/patterns/files for links (go to route, unknown-route warnings). Best effort.
153
+ try {
154
+ const routes = table.routes.filter((route) => route.page).map((route) => ({ id: route.id, pattern: route.pattern, file: routesDir + "/" + route.page }));
155
+ updateEditorManifest(root, { routesDir, routes });
156
+ }
157
+ catch { }
158
+ return { table, resumeModules: generated.resumeModules, changed, resumeEntry };
159
+ }
160
+ /** Routes-relative modules that declare server exports (in-file pages/layouts, plain `.ts` endpoints). */
161
+ function serverFiles(table) {
162
+ const files = new Set();
163
+ for (const route of table.routes)
164
+ for (const module of [route, ...route.layouts]) {
165
+ if (module.inline && module.page)
166
+ files.add(module.page);
167
+ // Live pages (`export const live = true`) as `live:<page>`: toggling the flag regenerates the App.
168
+ if (module.live && module.page)
169
+ files.add("live:" + module.page);
170
+ if (module.server && !/\.server\.[cm]?[jt]s$/.test(module.server))
171
+ files.add(module.server);
172
+ }
173
+ return files;
174
+ }
175
+ /** Root-relative page/layout/special modules of a table: compiled with `serverExports`. */
176
+ function routeModules(table, routesDir) {
177
+ const files = new Set();
178
+ for (const route of table.routes)
179
+ for (const module of [route, ...route.layouts]) {
180
+ if (module.page)
181
+ files.add(module.page);
182
+ // Plain `.ts` endpoints too (T7): a page importing a constant from one gets its handlers split out.
183
+ if (module.server && !/\.server\.[cm]?[jt]s$/.test(module.server))
184
+ files.add(module.server);
185
+ }
186
+ return [...files].map((file) => posix(join(routesDir, file)));
187
+ }
188
+ /**
189
+ * File routes for Vite: the routing plugin plus the Rivet compiler configured for the generated app.
190
+ * `vite build` builds the browser side into `dist/client`; `vite build --ssr` the server entry into
191
+ * `dist/server` (both overridable through `build`). `vite` serves pages through the same handler.
192
+ * In `csr` mode the client build also writes `index.html` and there is no server build.
193
+ */
194
+ const STATIC_DATA = /^([^?#]*\/)_data\.json(?:[?#]|$)/;
195
+ /**
196
+ * Dev server, static mode: a `<page>/_data.json` request (the file the static adapter prerenders
197
+ * beside each page, `client/index.ts` `dataURL(url, "static")`) becomes that page's data request,
198
+ * as `adapters/static.ts` `prerender` renders it: the page path in its canonical form, no query.
199
+ */
200
+ function staticData(req, slash) {
201
+ const hit = STATIC_DATA.exec(req.url ?? "");
202
+ if (!hit || (req.method !== "GET" && req.method !== "HEAD"))
203
+ return;
204
+ const page = hit[1] === "/" ? "/" : hit[1].slice(0, -1);
205
+ req.url = slashPath(page, slash) + "?" + DATA_PARAM + "=data";
206
+ req.headers.accept = "application/json";
207
+ }
208
+ /** The router's Vite plugins: file routes, typed routes, server code split, data requests and build adapters. */
209
+ export function rivetRouter(options = {}) {
210
+ const routesDir = options.routes ?? "src/routes";
211
+ const outDir = options.outDir ?? ".rivet";
212
+ const styles = options.styles ?? [];
213
+ const mode = options.mode ?? "server";
214
+ const documentPath = options.document ?? "src/document.ts";
215
+ const codegen = {
216
+ routes: routesDir,
217
+ outDir,
218
+ document: documentPath,
219
+ mode,
220
+ transitions: options.transitions,
221
+ announce: options.announce,
222
+ ...(options.trailingSlash ? { trailingSlash: options.trailingSlash } : {}),
223
+ ...(options.prefetchEagerness ? { prefetchEagerness: options.prefetchEagerness } : {}),
224
+ ...(options.prefetchPaths === false ? { prefetchPaths: false } : {}),
225
+ ...(options.preload ? { preload: options.preload } : {}),
226
+ ...(options.routeRules ? { routeRules: options.routeRules } : {}),
227
+ ...(options.clientRouter !== undefined ? { clientRouter: options.clientRouter } : {}),
228
+ };
229
+ if (options.routeRules !== undefined) {
230
+ // Checked at config time (same checks as at server start, except the origin `isr` needs: a server option), and
231
+ // kept to JSON (the server entry inlines it).
232
+ compileRules(options.routeRules, true);
233
+ const json = (value) => typeof value === "string" ||
234
+ typeof value === "boolean" ||
235
+ (typeof value === "number" && Number.isFinite(value)) ||
236
+ (Array.isArray(value)
237
+ ? value.every(json)
238
+ : typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every(json));
239
+ if (!json(options.routeRules))
240
+ throw new Error("rivetRouter: routeRules must be JSON data (numbers, strings, booleans; use isr: true for no expiry)");
241
+ }
242
+ const background = options.preload?.background;
243
+ if (background !== undefined && !(Number.isFinite(background) && background >= 0))
244
+ throw new Error(`rivetRouter: preload.background must be a non-negative number of bytes (got ${JSON.stringify(background)})`);
245
+ if (options.prefetch !== undefined && typeof options.prefetch !== "boolean" && options.prefetch !== "document" && options.prefetch !== "prerender")
246
+ throw new Error(`rivetRouter: prefetch must be a boolean, "document" or "prerender" (got ${JSON.stringify(options.prefetch)})`);
247
+ if (options.prefetchEagerness !== undefined && !["conservative", "moderate", "eager"].includes(options.prefetchEagerness))
248
+ throw new Error(`rivetRouter: prefetchEagerness must be "conservative", "moderate" or "eager" (got ${JSON.stringify(options.prefetchEagerness)})`);
249
+ if (options.prefetchPaths !== undefined && typeof options.prefetchPaths !== "boolean")
250
+ throw new Error(`rivetRouter: prefetchPaths must be a boolean (got ${JSON.stringify(options.prefetchPaths)})`);
251
+ const inlineOption = options.inlineStylesheets;
252
+ if (inlineOption !== undefined &&
253
+ !(typeof inlineOption === "string"
254
+ ? ["auto", "always", "never"].includes(inlineOption)
255
+ : typeof inlineOption === "object" &&
256
+ inlineOption !== null &&
257
+ Object.keys(inlineOption).every((key) => key === "maxTotalBrotli") &&
258
+ (inlineOption.maxTotalBrotli === undefined || (Number.isSafeInteger(inlineOption.maxTotalBrotli) && inlineOption.maxTotalBrotli >= 0))))
259
+ throw new Error(`rivetRouter: inlineStylesheets must be "auto", "always", "never" or { maxTotalBrotli: <bytes> } (got ${JSON.stringify(inlineOption)})`);
260
+ const slash = options.trailingSlash ?? "ignore";
261
+ if (slash !== "ignore" && slash !== "never" && slash !== "always")
262
+ throw new Error(`rivetRouter: trailingSlash must be "ignore", "never" or "always" (got ${JSON.stringify(slash)})`);
263
+ // Filled in `config`, which runs before the compiler plugin reads it in `configResolved`.
264
+ // Page chunks prefetch on navigation intent, not at idle (the App chunk loads eagerly for clicks).
265
+ // The generated App prefetches the hovered route's page itself: page chunks are not chunk lazies (`lazyOwned`).
266
+ const resume = {
267
+ lazy: "intent",
268
+ ...options.resume,
269
+ lazyOwned: [...(options.resume?.lazyOwned ?? []), posix(join(outDir, "app.tsx"))],
270
+ modules: [...(options.resume?.modules ?? [])],
271
+ dev: options.dev?.resume !== false,
272
+ // Every app component module a route imports resumes without being listed in `resume.modules`.
273
+ appModules: options.resume?.appModules ?? true,
274
+ };
275
+ // The generated resume entry (when there is one) provides the router's contexts (navigation state,
276
+ // search params) to resumed regions.
277
+ const ownEntry = resume.entry;
278
+ // Dev resumed pages read it live (the compiler's dev manifest): kept in step on regeneration too.
279
+ const entryFor = (generated) => {
280
+ if (ownEntry)
281
+ resume.entry = ownEntry;
282
+ else if (generated)
283
+ resume.entry = posix(join(outDir, "entry.resume.ts"));
284
+ else
285
+ delete resume.entry;
286
+ };
287
+ let root = process.cwd();
288
+ let table = { routes: [] };
289
+ const devtools = options.devtools ?? true;
290
+ const css = options.css ? { css: options.css } : {};
291
+ // Filled in `config` and on route changes: in-file server exports leave the browser build.
292
+ const serverExports = { modules: [] };
293
+ let inFile = new Set(); // routes-relative files with server exports + `live:<page>` (dev change detection)
294
+ // Dev regenerations run one at a time (two writing `.rivet/` at once race); closing the server
295
+ // waits for the running one and drops queued ones.
296
+ let regenerations = Promise.resolve();
297
+ let closed = false;
298
+ const icons = options.staticIcons === undefined ? {} : { staticIcons: options.staticIcons };
299
+ const compilerOptions = mode === "csr" ? { devtools, serverExports, ...css, ...icons } : { resume, devtools, serverExports, ...css, ...icons };
300
+ const bridges = options.bridges?.length ? options.bridges : undefined;
301
+ if (bridges && mode === "csr")
302
+ throw new Error("rivetRouter: framework bridges need server or static mode (islands are server-rendered)");
303
+ const plugins = options.images || bridges
304
+ ? rivet({ ...compilerOptions, images: options.images, bridges })
305
+ : [rivet(compilerOptions)];
306
+ // With bridges the framework plugins come first: the compiler is the plugin named `vite-plugin-rivet`.
307
+ const compiler = plugins.find((plugin) => plugin.name === "vite-plugin-rivet");
308
+ const clientEntry = () => resolve(root, outDir, "entry.client.tsx");
309
+ const adapter = adapterOptions(options.adapter);
310
+ if (adapter && mode === "csr")
311
+ throw new Error("rivetRouter: CSR apps have no server build (adapter)");
312
+ // i18n client builds: the locale suffix of Vite's base (`/<locale>`), and the app base without it.
313
+ let suffix = "", appBase = "";
314
+ // i18n client builds: RegExp source of the app paths this build's locale serves (other locales' URLs load documents).
315
+ let localePaths = null;
316
+ // i18n dev server: every locale's path pattern (one client for all locales; see the base.ts transform).
317
+ let devLocalePaths = null;
318
+ // Z5: the client build's output (captured before Vite rewrites dynamic imports) and, per importing
319
+ // chunk, the files its static graph already loaded.
320
+ let clientBundle;
321
+ const loadedBy = new Map();
322
+ const preloadDeps = (file, deps, { hostId }) => {
323
+ if (!clientBundle)
324
+ return deps;
325
+ let loaded = loadedBy.get(hostId);
326
+ if (!loaded) {
327
+ loaded = new Set();
328
+ for (const stack = [hostId]; stack.length;) {
329
+ const next = stack.pop();
330
+ if (loaded.has(next))
331
+ continue;
332
+ loaded.add(next);
333
+ const chunk = clientBundle[next];
334
+ if (chunk?.type === "chunk")
335
+ stack.push(...chunk.imports);
336
+ }
337
+ loadedBy.set(hostId, loaded);
338
+ }
339
+ const rest = deps.filter((dep) => !loaded.has(dep));
340
+ // Only the imported chunk itself left: `import()` fetches it anyway (a preload list would repeat its URL).
341
+ return rest.length === 1 && rest[0] === file ? [] : rest;
342
+ };
343
+ const router = {
344
+ name: "vite-plugin-rivet-router",
345
+ enforce: "pre",
346
+ async config(config, env) {
347
+ root = resolve(config.root ?? process.cwd());
348
+ // `trailingSlash`: the server bundle must carry its own copy of the router (its policy constant
349
+ // is rewritten in this build), even where the package would otherwise be external.
350
+ if (slash !== "ignore") {
351
+ const kept = config.ssr?.noExternal;
352
+ if (kept !== true)
353
+ (config.ssr ??= {}).noExternal = [...(kept === undefined ? [] : Array.isArray(kept) ? kept : [kept]), "@rivetjs/router"];
354
+ }
355
+ const names = new Set();
356
+ let i18nLocale = null;
357
+ localePaths = null;
358
+ devLocalePaths = null;
359
+ const scan = (list) => {
360
+ if (Array.isArray(list))
361
+ list.forEach(scan);
362
+ else if (list && typeof list === "object" && "name" in list) {
363
+ const name = String(list.name);
364
+ names.add(name);
365
+ if (name === "vite-plugin-rivet-i18n") {
366
+ const api = list.api;
367
+ i18nLocale = api?.locale ?? null;
368
+ localePaths = api?.clientPaths ?? null;
369
+ if (env.command === "serve")
370
+ devLocalePaths = api?.devPaths ?? null;
371
+ }
372
+ }
373
+ };
374
+ scan(config.plugins);
375
+ // `@rivetjs/i18n/vite` client builds (one per locale, its config hook runs first) serve a
376
+ // locale's assets under `<base><locale>/`; routes and `BASE` keep the app base.
377
+ suffix = i18nLocale && env.command === "build" && !config.build?.ssr && !env.isSsrBuild ? "/" + i18nLocale : "";
378
+ // One base for assets (Vite) and routes (codegen).
379
+ if (options.base !== undefined)
380
+ config.base = normalizeBase(options.base) + suffix + "/";
381
+ appBase = normalizeBase(config.base);
382
+ if (suffix && appBase.endsWith(suffix))
383
+ appBase = appBase.slice(0, -suffix.length);
384
+ codegen.base = appBase;
385
+ // Kit's plugin (`kit()` from @rivetjs/kit/vite) turns component head on unless `head: "early"`.
386
+ const head = options.head ?? (names.has("vite-plugin-rivet-kit") ? "components" : "early");
387
+ if (head === "components" && mode !== "csr")
388
+ codegen.head = "components";
389
+ const usage = await routerUsage(root, { exclude: [config.build?.outDir ?? "dist", outDir] });
390
+ for (const name of USAGE)
391
+ codegen[name] = options[name] ?? usage[name];
392
+ const generated = await generateRoutes(root, codegen);
393
+ entryFor(generated.resumeEntry);
394
+ const { resumeModules } = generated;
395
+ table = generated.table;
396
+ serverExports.modules.splice(0, serverExports.modules.length, ...routeModules(table, routesDir));
397
+ inFile = serverFiles(table);
398
+ const extra = options.resume?.modules ?? [];
399
+ resume.modules.splice(0, resume.modules.length, ...new Set([...resumeModules, ...extra]));
400
+ const ssr = config.build?.ssr;
401
+ const input = config.build?.rollupOptions?.input;
402
+ if (ssr && mode === "csr")
403
+ throw new Error("rivetRouter: CSR apps have no server build");
404
+ const serverOut = config.build?.outDir ?? "dist/server";
405
+ if (ssr === true && adapter)
406
+ return targetBuildConfig(adapter, resolve(root, outDir, "entry.server.tsx"), serverOut);
407
+ if (ssr === true)
408
+ return { build: { ssr: resolve(root, outDir, "entry.server.tsx"), outDir: serverOut } };
409
+ if (ssr)
410
+ return;
411
+ // Keyed by file name, so stylesheets land flat in `assets/` (`styles-<hash>.css`).
412
+ const stylesInput = Object.fromEntries(styles.map((path) => [
413
+ posix(path)
414
+ .split("/")
415
+ .pop()
416
+ .replace(/\.[^.]+$/, ""),
417
+ resolve(root, path),
418
+ ]));
419
+ // Resumed pages have no application entry, only stylesheets (the compiler emits the resume
420
+ // runtime and symbol chunks itself). CSR apps start from the generated client entry.
421
+ const defaultInput = mode === "csr" ? { app: clientEntry(), ...stylesInput } : styles.length ? stylesInput : { "rivet-empty": EMPTY };
422
+ return {
423
+ // Pages come from the app handler (or the CSR shell), not an index.html fallback.
424
+ appType: config.appType ?? "custom",
425
+ build: {
426
+ outDir: config.build?.outDir ?? "dist/client",
427
+ manifest: config.build?.manifest ?? true,
428
+ // Z5: a dynamic import (route page, lazy component) preloads only what its importer has not
429
+ // loaded statically: the App chunk lists no shared runtime chunks per route (routes-500 −26 KB min).
430
+ ...(config.build?.modulePreload === undefined ? { modulePreload: { resolveDependencies: preloadDeps } } : {}),
431
+ ...(input === undefined ? { rollupOptions: { input: defaultInput } } : {}),
432
+ },
433
+ };
434
+ },
435
+ resolveId(id, importer, resolveOptions) {
436
+ if (id === EMPTY)
437
+ return id;
438
+ // `x.server.ts` modules hold secrets and server code: never part of a browser graph.
439
+ if (!importer || resolveOptions?.ssr || this.environment?.config?.consumer === "server")
440
+ return null;
441
+ // The compiler's resume grouping resolves every specifier its facts list (type-only imports too): not an import.
442
+ if (_factsResolve(id, importer))
443
+ return null;
444
+ if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(id.split("?")[0]) || id === "@rivetjs/router/server-only")
445
+ this.error({
446
+ message: `[rivet:router] ${id} is server-only and cannot be imported by browser code (imported from ${posix(importer)})` +
447
+ "\n fix: read it in a server export (`serverLoad`, `serverActions`, `onGet`, …) and pass the data to the page; type-only imports (`import type`) are fine" +
448
+ "\n docs: https://rivetjs.dev/guides/security/server-only-code",
449
+ id: importer,
450
+ plugin: "vite-plugin-rivet-router",
451
+ });
452
+ return null;
453
+ },
454
+ load: (id) => (id === EMPTY ? "export {};\n" : null),
455
+ // i18n client build: `BASE` is the app base, not the locale's asset base (`BASE_URL`). Only this
456
+ // build's copy of shared/base.ts changes, so other apps' bundles stay byte-identical.
457
+ // `trailingSlash`: the policy constant (client and server bundles); unset, the module stays as is.
458
+ transform(code, id, transformOptions) {
459
+ if (slash !== "ignore" && id.split("?")[0] === SLASH_MODULE) {
460
+ if (!code.includes(SLASH_DEFAULT))
461
+ this.error("rivetRouter: shared/slash.ts no longer declares TRAILING_SLASH as expected");
462
+ return { code: code.replace(SLASH_DEFAULT, JSON.stringify(slash) + " as TrailingSlash"), map: null };
463
+ }
464
+ // i18n dev: one client serves every locale, so `underBase` also requires the target's locale to be
465
+ // the page's (read once at load): another locale's URL loads its document, as in a build.
466
+ if (devLocalePaths && !transformOptions?.ssr && id.split("?")[0] === BASE_MODULE) {
467
+ const decl = "export function underBase(";
468
+ if (!code.includes(decl))
469
+ this.error("rivetRouter: shared/base.ts no longer declares underBase as expected");
470
+ return {
471
+ code: code.replace(decl, "function _inBase(") +
472
+ `\nconst _LOCALES = ${JSON.stringify(devLocalePaths)}.map((source) => new RegExp(source, "i"));\n` +
473
+ 'const _localeOf = (pathname, base) => { const path = base ? pathname.slice(base.length) || "/" : pathname; return _LOCALES.findIndex((re) => re.test(path)); };\n' +
474
+ 'const _PAGE = typeof location === "undefined" ? -1 : _localeOf(location.pathname, BASE);\n' +
475
+ "export function underBase(pathname, base = BASE) {\n" +
476
+ " return _inBase(pathname, base) && (_PAGE < 0 || _localeOf(pathname, base) === _PAGE);\n}\n",
477
+ map: null,
478
+ };
479
+ }
480
+ if (!suffix || id.split("?")[0] !== BASE_MODULE)
481
+ return null;
482
+ const read = /\(import\.meta(?: as [^)]*)?\)\.env\?\.BASE_URL|import\.meta\.env\?\.BASE_URL/;
483
+ if (!read.test(code))
484
+ this.error("rivetRouter: shared/base.ts no longer reads import.meta.env.BASE_URL as expected");
485
+ let out = code.replace(read, JSON.stringify(appBase + "/"));
486
+ // Another locale's URL is another client build (its inlined messages, its chunks): the
487
+ // browser loads its document; the client router and navigate() never switch locale in place.
488
+ if (localePaths) {
489
+ const decl = "export function underBase(";
490
+ if (!out.includes(decl))
491
+ this.error("rivetRouter: shared/base.ts no longer declares underBase as expected");
492
+ out =
493
+ out.replace(decl, "function _inBase(") +
494
+ `\nconst _LOCALE = new RegExp(${JSON.stringify(localePaths)}, "i");\n` +
495
+ "export function underBase(pathname, base = BASE) {\n" +
496
+ ' return _inBase(pathname, base) && _LOCALE.test(base ? pathname.slice(base.length) || "/" : pathname);\n}\n';
497
+ }
498
+ return { code: out, map: null };
499
+ },
500
+ async generateBundle(_options, bundle) {
501
+ for (const [name, output] of Object.entries(bundle))
502
+ if (output.type === "chunk" && output.facadeModuleId === EMPTY)
503
+ delete bundle[name];
504
+ if (this.environment?.config?.consumer === "server")
505
+ return;
506
+ clientBundle = bundle;
507
+ loadedBy.clear();
508
+ if (mode !== "csr") {
509
+ // Route modules' CSS (component stylesheets, css`` sheets), linked per route by the handler.
510
+ // Several stylesheets of one route are merged into one route sheet (`mergeRouteCSS`).
511
+ const base = this.environment?.config?.base ?? "/";
512
+ const { routes: css, assets } = mergeRouteCSS(bundle, bundleRouteCSS(bundle, table, resolve(root, routesDir), base), base);
513
+ for (const asset of assets)
514
+ this.emitFile({ type: "asset", ...asset });
515
+ if (Object.keys(css).length)
516
+ this.emitFile({ type: "asset", fileName: ROUTE_CSS, source: JSON.stringify(css, null, 2) + "\n" });
517
+ // Sheets the handler may write inline (`inlineStylesheets`; decided per page, `server/inline-styles.ts`).
518
+ const limit = this.environment?.config?.build.assetsInlineLimit;
519
+ const inline = inlineCSS(bundle, assets, base, options.inlineStylesheets ?? "auto", typeof limit === "number" ? limit : 4096);
520
+ if (inline)
521
+ this.emitFile({ type: "asset", fileName: INLINE_CSS, source: JSON.stringify(inline) + "\n" });
522
+ return;
523
+ }
524
+ const base = this.environment?.config?.base ?? "/";
525
+ const entry = Object.values(bundle).find((output) => output.type === "chunk" && output.facadeModuleId === clientEntry());
526
+ if (!entry || entry.type !== "chunk")
527
+ return;
528
+ const css = new Set(entry.viteMetadata?.importedCss ?? []);
529
+ const sources = new Set(styles.map((path) => posix(path)));
530
+ for (const output of Object.values(bundle))
531
+ if (output.type === "asset" && output.fileName.endsWith(".css") && output.originalFileNames.some((name) => sources.has(posix(name))))
532
+ css.add(output.fileName);
533
+ const { shellHTML } = await import("../adapters/csr.js");
534
+ const document = await loadDocument(resolve(root, documentPath));
535
+ const source = shellHTML(document, { script: base + entry.fileName, styles: [...css].map((file) => base + file) });
536
+ this.emitFile({ type: "asset", fileName: "index.html", source });
537
+ },
538
+ async closeServer() {
539
+ closed = true;
540
+ await regenerations;
541
+ },
542
+ configureServer(server) {
543
+ const routesPath = resolve(root, routesDir);
544
+ const regenerate = async (file) => (resolve(file).startsWith(routesPath + sep) ? refresh() : false);
545
+ closed = false;
546
+ const refresh = () => {
547
+ const run = regenerations.then(async () => {
548
+ if (closed)
549
+ return false;
550
+ const { changed, table: next, resumeEntry } = await generateRoutes(root, codegen);
551
+ entryFor(resumeEntry);
552
+ table = next;
553
+ serverExports.modules.splice(0, serverExports.modules.length, ...routeModules(table, routesDir));
554
+ inFile = serverFiles(table);
555
+ if (changed && !closed)
556
+ server.ws.send({ type: "full-reload" });
557
+ return changed;
558
+ });
559
+ regenerations = run.catch(() => { });
560
+ return run;
561
+ };
562
+ // A failed regeneration is logged (an unhandled rejection would end the dev server's process).
563
+ const logged = (fn) => (file) => void fn(file).catch((error) => server.config.logger.error("rivet router: " + (error instanceof Error ? (error.stack ?? error.message) : String(error))));
564
+ server.watcher.on("add", logged(regenerate));
565
+ server.watcher.on("unlink", logged(regenerate));
566
+ // A source starts using a router feature (search params, navigate(), useParams()): generate it
567
+ // from now on (regenerate + full reload). A dev server never turns one off again.
568
+ const features = async (file) => {
569
+ if (!SOURCE.test(file) || /(?:^|[\\/])(?:node_modules|dist(?:[-_.][^\\/]*)?|\.[^\\/]*)[\\/]/.test(relative(root, file)))
570
+ return false;
571
+ const found = none();
572
+ featuresOf(await readFile(file, "utf8").catch(() => ""), found);
573
+ const added = USAGE.filter((name) => found[name] && !codegen[name] && options[name] === undefined);
574
+ if (!added.length)
575
+ return false;
576
+ for (const name of added)
577
+ codegen[name] = true;
578
+ await refresh();
579
+ return true;
580
+ };
581
+ server.watcher.on("add", logged(features));
582
+ // An edit can add or remove in-file server exports: only then does the route table change
583
+ // (regenerated without touching other edits). Server-only edits (`*.server.ts`, in-file
584
+ // loaders) refetch the route data and re-mount the page (`rivet:data`).
585
+ server.watcher.on("change", logged(async (file) => {
586
+ if (await features(file))
587
+ return;
588
+ const path = posix(file);
589
+ if (/\.server\.[cm]?[jt]sx?$/.test(path))
590
+ return server.ws.send({ type: "custom", event: "rivet:data" });
591
+ if (!resolve(file).startsWith(routesPath + sep))
592
+ return;
593
+ const name = posix(relative(routesPath, resolve(file)));
594
+ const names = (await _serverExportsOf(routesPath, [name])).get(name) ?? [];
595
+ const has = names.some((n) => n !== "live");
596
+ if ((has !== inFile.has(name) || names.includes("live") !== inFile.has("live:" + name)) && (await regenerate(file)))
597
+ return;
598
+ if (has)
599
+ server.ws.send({ type: "custom", event: "rivet:data" });
600
+ }));
601
+ const devStyles = styles.map((path) => "/" + posix(path));
602
+ // i18n (`@rivetjs/i18n/vite`): dev pages render their locale's variant, and a non-default
603
+ // locale loads the dev entry with `?rivet-locale=` (its client modules compile for that locale).
604
+ const i18n = server.config.plugins.find((p) => p.name === "vite-plugin-rivet-i18n")?.api?.config;
605
+ const devEntry = server.config.base + posix(join(outDir, "entry.dev.tsx"));
606
+ const devMount = server.config.base + posix(join(outDir, "dev-mount.tsx"));
607
+ // Dev errors: server failures reach the overlay and a dev error page; browser ones are forwarded.
608
+ listenClientErrors(server);
609
+ // Dev SSR records like production (resume recorder, dev manifest): serialization errors surface.
610
+ const devResume = compiler.api.devResume;
611
+ const onError = devReport(server, () => table, routesPath, (id) => devResume.describe(id));
612
+ // Pages resume like production; opted out, or a non-default i18n locale (its client graph
613
+ // compiles per locale, chunks do not yet): client-render without the recorded state.
614
+ const devMode = (locale) => !resume.dev || (i18n && locale && locale !== i18n.defaultLocale) ? "check" : "resume";
615
+ const listeners = new WeakMap();
616
+ // After Vite's own middlewares (modules, assets): pages and data go through the app handler.
617
+ return () => {
618
+ server.middlewares.use(async (req, res, next) => {
619
+ try {
620
+ if (mode === "csr") {
621
+ if (req.method !== "GET" && req.method !== "HEAD")
622
+ return next();
623
+ const { shellHTML } = await import("../adapters/csr.js");
624
+ const document = (await server.ssrLoadModule("/" + posix(documentPath))).default;
625
+ const shell = shellHTML(document, { script: "/" + posix(join(outDir, "entry.client.tsx")), styles: devStyles });
626
+ const html = await server.transformIndexHtml(req.url ?? "/", shell);
627
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
628
+ res.end(req.method === "HEAD" ? undefined : html);
629
+ return;
630
+ }
631
+ // The entry re-exports createApp: handler and app load through one SSR graph (one runtime).
632
+ const entry = (await server.ssrLoadModule("/" + posix(join(outDir, "entry.server.tsx"))));
633
+ // One app per entry namespace: Vite hands out a new one once an edit invalidates the graph.
634
+ let listener = listeners.get(entry);
635
+ if (!listener) {
636
+ const { createListener } = await import("../adapters/node.js");
637
+ const handler = entry.createApp({
638
+ entry,
639
+ styles: devStyles,
640
+ routeStyles: (id) => devRouteCSS(server, table, resolve(root, routesDir), id, new Set(devStyles)),
641
+ dev: true,
642
+ // Read per page: the dev manifest grows as Vite compiles modules.
643
+ devResume: {
644
+ get manifest() {
645
+ return devResume.manifest();
646
+ },
647
+ mode: devMode,
648
+ },
649
+ // The DevTools agent (when on) runs first, before any framework module evaluates.
650
+ devScripts: (locale) => [
651
+ ...compiler.api.devtoolsScripts(),
652
+ server.config.base + "@vite/client",
653
+ // Resumed: the entry that switches to client rendering on a hot update; else the mount itself.
654
+ devMode(locale) === "resume"
655
+ ? devEntry
656
+ : i18n && locale && locale !== i18n.defaultLocale
657
+ ? devMount + "?rivet-locale=" + encodeURIComponent(locale)
658
+ : devMount,
659
+ ],
660
+ ...(i18n ? { i18n: { locales: i18n.locales } } : {}),
661
+ onError,
662
+ });
663
+ listener = createListener({ handler: devHandler(handler, server, () => table), compress: false }).listener;
664
+ listeners.set(entry, listener);
665
+ }
666
+ // Vite's base middleware strips the base from `req.url`: route patterns carry it.
667
+ const original = req.originalUrl;
668
+ if (codegen.base && original)
669
+ req.url = original;
670
+ // Static mode: client navigation reads prerendered `<page>/_data.json` files, which only the
671
+ // build writes. Dev answers them from the data endpoint, as the static adapter renders them.
672
+ if (mode === "static")
673
+ staticData(req, slash);
674
+ listener(req, res);
675
+ }
676
+ catch (error) {
677
+ // Never reject (connect does not await middlewares; an unhandled rejection ends the dev server).
678
+ try {
679
+ if (error instanceof Error)
680
+ server.ssrFixStacktrace(error);
681
+ }
682
+ catch {
683
+ /* Keep the original stack. */
684
+ }
685
+ next(error);
686
+ }
687
+ });
688
+ };
689
+ },
690
+ };
691
+ return [
692
+ router,
693
+ ...plugins,
694
+ ...(adapter ? [targetEntryPlugin(adapter, { serverEntry: () => resolve(root, outDir, "entry.server.tsx"), mode: () => mode })] : []),
695
+ ];
696
+ }
697
+ /** Import the document shell module at build time (Node strips its types). */
698
+ async function loadDocument(path) {
699
+ const module = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href, true)));
700
+ if (typeof module.default !== "function")
701
+ throw new Error(`${path}: default export must be a DocumentShell function`);
702
+ return module.default;
703
+ }