@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
package/build/reach.js ADDED
@@ -0,0 +1,302 @@
1
+ // Server reach (SERVER-REACH, spec docs/design/specs/2026-10-20-server-reach-design.md): which server-only modules each
2
+ // route's browser graph reaches, before the build. A page route that reaches one through its own module and plain
3
+ // helper modules becomes a document route (links load its document; its module leaves the client build); a component
4
+ // module on the way (shared, client-rendered on every client navigation to the routes using it), a layout or an error
5
+ // page reaching one is RV3011. Build-time only (Node): sources are read, compiled / type-stripped and parsed here.
6
+ import { readFileSync, statSync } from "node:fs";
7
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
8
+ import { parseSync, transformWithOxc } from "vite";
9
+ import { compile } from "@rivetjs/compiler";
10
+ import { docsUrl } from "@rivetjs/compiler/codes";
11
+ import { isServerOnly, SERVER_ONLY_MARKER } from "@rivetjs/compiler/vite";
12
+ export const RV_REACH = "RV3011";
13
+ const SCANNED = /\.[cm]?[jt]sx?$/;
14
+ const COMPONENT = /\.[jt]sx$/;
15
+ // Browser imports per module, by mtime (dev re-runs read only changed files).
16
+ const edgeCache = new Map();
17
+ function namesOf(entries) {
18
+ return entries.map(({ importName }) => (importName.kind === "Name" ? importName.name : importName.kind === "Default" ? "default" : "*"));
19
+ }
20
+ /** Browser imports of one module: route modules after the server split, `.tsx` as the compiler emits them, `.ts` type-stripped. */
21
+ async function edgesOf(file, route, config) {
22
+ let mtime;
23
+ try {
24
+ mtime = statSync(file).mtimeMs;
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const cached = edgeCache.get(file);
30
+ if (cached && cached.mtime === mtime && cached.route === route)
31
+ return cached.edges;
32
+ const source = readFileSync(file, "utf8");
33
+ let code;
34
+ if (route || COMPONENT.test(file)) {
35
+ const result = compile(source, { target: "dom", filename: file, ...(route ? { serverExports: true } : {}) });
36
+ // A module that does not compile fails the real build with its own diagnostic; scan its imports as written.
37
+ if (!result.diagnostics.length)
38
+ code = result.code;
39
+ }
40
+ if (code === undefined && /\.[cm]?[jt]sx?$/.test(file) && !/\.[cm]?js$/.test(file))
41
+ code = (await transformWithOxc(source, file, { jsx: "preserve" }, undefined, config).catch(() => ({ code: source }))).code;
42
+ const parsed = parseSync(file.replace(/\.[^./\\]+$/, ".jsx"), code ?? source);
43
+ const edges = [];
44
+ const text = code ?? source;
45
+ for (const item of parsed.module.staticImports) {
46
+ // Type-only entries survive only where stripping failed: never browser imports.
47
+ const entries = item.entries.filter((entry) => !entry.isType);
48
+ if (item.entries.length && !entries.length)
49
+ continue;
50
+ edges.push({ spec: item.moduleRequest.value, names: namesOf(entries) });
51
+ }
52
+ for (const item of parsed.module.staticExports)
53
+ for (const entry of item.entries)
54
+ if (entry.moduleRequest && !entry.isType)
55
+ edges.push({ spec: entry.moduleRequest.value, names: entry.importName.kind === "Name" ? [entry.importName.name] : ["*"] });
56
+ for (const item of parsed.module.dynamicImports) {
57
+ const literal = /^\s*(["'`])([^"'`$]*)\1\s*$/.exec(text.slice(item.moduleRequest.start, item.moduleRequest.end));
58
+ if (literal)
59
+ edges.push({ spec: literal[2], names: ["*"] });
60
+ }
61
+ edgeCache.set(file, { mtime, route, edges });
62
+ return edges;
63
+ }
64
+ /** Display path: root-relative inside the root, else `<package name>/<path in the package>`. */
65
+ function displayer(root) {
66
+ const packages = new Map();
67
+ const packageOf = (dir) => {
68
+ for (let at = dir;; at = dirname(at)) {
69
+ let name = packages.get(at);
70
+ if (name === undefined) {
71
+ try {
72
+ name = JSON.parse(readFileSync(join(at, "package.json"), "utf8")).name ?? null;
73
+ }
74
+ catch {
75
+ name = null;
76
+ }
77
+ packages.set(at, name);
78
+ }
79
+ if (name)
80
+ return { dir: at, name };
81
+ if (dirname(at) === at)
82
+ return null;
83
+ }
84
+ };
85
+ return (id) => {
86
+ if (!isAbsolute(id))
87
+ return id;
88
+ const rel = relative(root, id);
89
+ if (!rel.startsWith("..") && !isAbsolute(rel))
90
+ return rel.split(sep).join("/");
91
+ const pkg = packageOf(dirname(id));
92
+ return pkg ? pkg.name + "/" + relative(pkg.dir, id).split(sep).join("/") : id;
93
+ };
94
+ }
95
+ const list = (routes) => (routes.length <= 5 ? routes.join(", ") : routes.slice(0, 5).join(", ") + ` and ${routes.length - 5} more`);
96
+ /** Walk every route's browser graph and classify the routes (see the module comment). */
97
+ export async function analyzeReach(options) {
98
+ const { root, routesDir, table, serverOnly, routeModules } = options;
99
+ const show = displayer(root);
100
+ const abs = (file) => join(routesDir, file);
101
+ const graph = new Map();
102
+ const resolved = new Map();
103
+ const resolveCached = async (spec, importer) => {
104
+ const key = importer + "\0" + spec;
105
+ if (!resolved.has(key))
106
+ resolved.set(key, await options.resolve(spec, importer).catch(() => undefined));
107
+ return resolved.get(key);
108
+ };
109
+ const scannable = (id) => isAbsolute(id) && !id.includes("?") && !id.includes("\0") && SCANNED.test(id) && !id.split(sep).includes("node_modules");
110
+ const visit = async (seeds) => {
111
+ const queue = seeds.filter((seed) => !graph.has(seed));
112
+ for (const seed of queue)
113
+ graph.set(seed, []);
114
+ while (queue.length) {
115
+ const file = queue.shift();
116
+ const targets = [];
117
+ for (const edge of await edgesOf(file, routeModules.has(file), options.config)) {
118
+ const id = await resolveCached(edge.spec, file);
119
+ const clean = id?.split("?")[0];
120
+ if (isServerOnly(serverOnly, edge.spec, clean)) {
121
+ targets.push({ key: id === undefined || id.startsWith("\0") ? edge.spec : clean, serverOnly: true, spec: edge.spec, names: edge.names });
122
+ continue;
123
+ }
124
+ if (!id || !scannable(id))
125
+ continue;
126
+ targets.push({ key: id, serverOnly: false, spec: edge.spec, names: edge.names });
127
+ if (!graph.has(id))
128
+ (graph.set(id, []), queue.push(id));
129
+ }
130
+ graph.set(file, targets);
131
+ }
132
+ };
133
+ const pages = [...new Set(table.routes.flatMap((route) => (route.page ? [route.page] : [])))];
134
+ const layouts = [...new Set(table.routes.flatMap((route) => route.layouts.flatMap((layout) => (layout.page ? [layout.page] : []))))];
135
+ const specials = [
136
+ ...new Set([
137
+ table.notFound,
138
+ table.error,
139
+ ...(table.notFounds ?? []).map((entry) => entry.page),
140
+ ...table.routes.flatMap((route) => [route.notFound, route.error]),
141
+ ].filter((file) => !!file)),
142
+ ];
143
+ await visit([...pages, ...layouts, ...specials].map(abs));
144
+ // Fixpoints (monotone booleans over a graph with cycles): reaches a server-only module; reaches one through a component.
145
+ const reaches = new Set(), viaComponent = new Set();
146
+ for (let changed = true; changed;) {
147
+ changed = false;
148
+ for (const [file, targets] of graph) {
149
+ if (!reaches.has(file) && targets.some((t) => t.serverOnly || reaches.has(t.key)))
150
+ (reaches.add(file), (changed = true));
151
+ if (!viaComponent.has(file) && targets.some((t) => !t.serverOnly && ((COMPONENT.test(t.key) && reaches.has(t.key)) || viaComponent.has(t.key))))
152
+ (viaComponent.add(file), (changed = true));
153
+ }
154
+ }
155
+ // Shortest display chain from `from` to a server-only module; `component`: through a component module (not `from`).
156
+ const chainFrom = (from, component) => {
157
+ const seen = new Set([from + "\0" + !component]);
158
+ const queue = [{ file: from, passed: !component, back: null }];
159
+ while (queue.length) {
160
+ const state = queue.shift();
161
+ for (const target of graph.get(state.file) ?? []) {
162
+ if (target.serverOnly) {
163
+ if (!state.passed)
164
+ continue;
165
+ const files = [];
166
+ for (let at = state; at; at = at.back)
167
+ files.unshift(at.file);
168
+ // Component chains start at the first component (the page and its helpers are the routes' own).
169
+ const first = component ? files.findIndex((file, i) => i > 0 && COMPONENT.test(file)) : 0;
170
+ return [...files.slice(Math.max(first, 0)).map(show), target.key.startsWith("/") ? show(target.key) : target.key];
171
+ }
172
+ const passed = state.passed || COMPONENT.test(target.key);
173
+ const key = target.key + "\0" + passed;
174
+ if (seen.has(key) || !reaches.has(target.key))
175
+ continue;
176
+ seen.add(key);
177
+ queue.push({ file: target.key, passed, back: state });
178
+ }
179
+ }
180
+ return [show(from)];
181
+ };
182
+ const reachMode = options.reach ?? "error";
183
+ const documents = new Map();
184
+ const problems = [];
185
+ const byChain = new Map();
186
+ const patternsOf = (page) => table.routes.filter((route) => route.page === page).map((route) => route.pattern);
187
+ for (const page of pages) {
188
+ const file = abs(page);
189
+ if (!reaches.has(file))
190
+ continue;
191
+ const targets = graph.get(file) ?? [];
192
+ // `import "@rivetjs/router/server-only"` in the page module: the page itself is server-rendered only.
193
+ const declared = targets.some((t) => t.serverOnly && t.spec === SERVER_ONLY_MARKER);
194
+ const component = !declared && targets.some((t) => !t.serverOnly && ((COMPONENT.test(t.key) && reaches.has(t.key)) || viaComponent.has(t.key)));
195
+ if (component) {
196
+ const chain = chainFrom(file, true);
197
+ const key = chain.join("\0");
198
+ let group = byChain.get(key);
199
+ if (!group)
200
+ byChain.set(key, (group = { chain, routes: new Set(), files: new Set() }));
201
+ for (const pattern of patternsOf(page))
202
+ group.routes.add(pattern);
203
+ group.files.add(page);
204
+ if (reachMode === "error")
205
+ continue;
206
+ }
207
+ documents.set(page, chainFrom(file, false));
208
+ }
209
+ const docs = "\n docs: " + docsUrl(RV_REACH);
210
+ for (const { chain, routes, files } of byChain.values()) {
211
+ const severity = reachMode === "warn" ? "warning" : "error";
212
+ const what = routes.size === 1 ? "client-navigated page route" : "client-navigated page routes";
213
+ problems.push({
214
+ severity,
215
+ kind: "component",
216
+ chain,
217
+ routes: [...routes],
218
+ files: [...files],
219
+ message: `[rivet:router] ${RV_REACH} ${chain.join(" → ")} (server-only), rendered by ${list([...routes])} (${what})` +
220
+ (severity === "warning" ? "; rendered as document routes (links load their documents)" : "") +
221
+ "\n fix: pass the data to the component as props (inlined at build time, or read in the page's `load(…)`), keep server-only modules out of components, or make the page a document route (`import \"" +
222
+ SERVER_ONLY_MARKER +
223
+ '"` in the page module); `rivetRouter({ serverOnly: { reach: "warn" } })` turns this into a warning (those pages render as document routes)' +
224
+ docs,
225
+ });
226
+ }
227
+ for (const [kind, files] of [
228
+ ["layout", layouts],
229
+ ["special", specials],
230
+ ])
231
+ for (const page of files) {
232
+ const file = abs(page);
233
+ if (!reaches.has(file))
234
+ continue;
235
+ const chain = chainFrom(file, false);
236
+ const folder = dirname(page) === "." ? "" : "/" + dirname(page).split(sep).join("/");
237
+ const routes = kind === "layout"
238
+ ? table.routes.filter((route) => route.page && route.layouts.some((layout) => layout.page === page)).map((route) => route.pattern)
239
+ : table.routes.filter((route) => route.notFound === page || route.error === page).map((route) => route.pattern);
240
+ problems.push({
241
+ severity: "error",
242
+ kind,
243
+ chain,
244
+ routes,
245
+ files: [page],
246
+ message: `[rivet:router] ${RV_REACH} ${chain.join(" → ")} (server-only), rendered by ${kind === "layout" ? `the layout of every page under ${folder || "/"}` : "an error / not-found page"} (client-rendered on navigation and resume)` +
247
+ "\n fix: read the data in the layout's `load(…)` (or a server-only helper called from it) and render it from props; layouts and error pages cannot be document routes" +
248
+ docs,
249
+ });
250
+ }
251
+ // Browser graphs: modules of client-rendered routes (non-document pages, layouts, error pages) vs. document pages only.
252
+ const closure = (seeds) => {
253
+ const out = new Set();
254
+ for (const stack = [...seeds]; stack.length;) {
255
+ const file = stack.pop();
256
+ if (out.has(file))
257
+ continue;
258
+ out.add(file);
259
+ for (const target of graph.get(file) ?? [])
260
+ if (!target.serverOnly)
261
+ stack.push(target.key);
262
+ }
263
+ return out;
264
+ };
265
+ const navigable = closure([...pages.filter((page) => !documents.has(page)), ...layouts, ...specials].map(abs));
266
+ const documentOnly = new Set([...closure([...documents.keys()].map(abs))].filter((file) => !navigable.has(file)));
267
+ const targets = new Map(), names = new Map(), chains = new Map();
268
+ for (const [file, edges] of graph)
269
+ for (const target of edges) {
270
+ if (!target.serverOnly)
271
+ continue;
272
+ targets.set(file + "\0" + target.spec, target.key);
273
+ let set = names.get(target.key);
274
+ if (!set)
275
+ names.set(target.key, (set = new Set()));
276
+ for (const name of target.names)
277
+ set.add(name);
278
+ }
279
+ // Stand-in messages: the first document page's chain to each server-only module it reaches.
280
+ for (const page of documents.keys()) {
281
+ const file = abs(page);
282
+ for (const reached of closure([file]))
283
+ for (const target of graph.get(reached) ?? [])
284
+ if (target.serverOnly && !chains.has(target.key) && documentOnly.has(reached)) {
285
+ const chain = chainFrom(file, false);
286
+ chains.set(target.key, chain[chain.length - 1] === (target.key.startsWith("/") ? show(target.key) : target.key) ? chain.slice(0, -1) : [show(file), show(reached)]);
287
+ }
288
+ }
289
+ return { documents, problems, documentOnly, targets, names, chains };
290
+ }
291
+ /** Normalize `rivetRouter({ serverOnly })`. */
292
+ export function serverOnlyOption(option) {
293
+ if (option === undefined)
294
+ return { modules: [], reach: "error" };
295
+ const modules = Array.isArray(option) ? option : (option.modules ?? []);
296
+ const reach = Array.isArray(option) ? "error" : (option.reach ?? "error");
297
+ if (!modules.every((pattern) => typeof pattern === "string" || pattern instanceof RegExp))
298
+ throw new Error("rivetRouter: serverOnly modules must be module ids (strings) or RegExps");
299
+ if (reach !== "error" && reach !== "warn")
300
+ throw new Error(`rivetRouter: serverOnly.reach must be "error" or "warn" (got ${JSON.stringify(reach)})`);
301
+ return { modules, reach };
302
+ }
@@ -0,0 +1,54 @@
1
+ import type { InlineStyleManifest } from "../server/inline-styles.js";
2
+ import type { Rollup, ViteDevServer } from "vite";
3
+ import type { RouteTable } from "./routes.js";
4
+ /** Client build asset: route id → stylesheet URLs (written only when some route has CSS). */
5
+ export declare const ROUTE_CSS = ".vite/rivet-route-css.json";
6
+ /** Stylesheet URLs per route id from a client bundle (empty when no route module imports CSS). */
7
+ export declare function bundleRouteCSS(bundle: Rollup.OutputBundle, table: RouteTable, routesDir: string, base: string): Record<string, string[]>;
8
+ /**
9
+ * One stylesheet per route: a route whose modules import several stylesheets (per-component CSS, `@rivetjs/ui`)
10
+ * links them as one merged asset (`assets/route-<hash>.css`, the files joined in link order; routes with the same
11
+ * list share it), so a page has one render-blocking route sheet next to the global styles. Lists of one file stay.
12
+ * Returns the merged assets to emit and the route map pointing at them. `prefix` names the asset (docs-kit example
13
+ * frames: `frame`).
14
+ */
15
+ export declare function mergeRouteCSS(bundle: Rollup.OutputBundle, css: Record<string, string[]>, base: string, prefix?: string): {
16
+ routes: Record<string, string[]>;
17
+ assets: {
18
+ fileName: string;
19
+ source: string;
20
+ }[];
21
+ };
22
+ /** Client build asset: the stylesheets the handler may write inline (`InlineStyleManifest`; written only when some are). */
23
+ export declare const INLINE_CSS = ".vite/rivet-inline-css.json";
24
+ /**
25
+ * `rivetRouter({ inlineStylesheets })` (Astro's `build.inlineStylesheets` model, decided per page on compressed bytes):
26
+ * `"auto"` (default) / `{ maxTotalBrotli }`, `"always"`, `"never"`.
27
+ */
28
+ export type InlineStylesheets = "auto" | "always" | "never" | {
29
+ readonly maxTotalBrotli?: number;
30
+ };
31
+ /** `auto`: all of a page's sheets inline when their brotli sizes add up to at most this. */
32
+ export declare const MAX_TOTAL_BROTLI = 6144;
33
+ /**
34
+ * The inline stylesheet manifest (`.vite/rivet-inline-css.json`, `server/inline-styles.ts` decides per page), or
35
+ * `undefined` when no sheet qualifies (`"never"`, nothing listed). Listed: every client CSS asset and merged route sheet
36
+ * (keys = the URLs the handler links, `base` + file name) without relative `url()` / `@import` references (they resolve
37
+ * against the sheet's URL) that could ever go inline — `"always"`: all; `"auto"`: brotli size ≤ `maxTotalBrotli` (the
38
+ * per-page total) or raw size ≤ `limit` (Vite `build.assetsInlineLimit`, the per-sheet fallback). Brotli at quality 11.
39
+ */
40
+ export declare function inlineCSS(bundle: Rollup.OutputBundle, extra: readonly {
41
+ fileName: string;
42
+ source: string;
43
+ }[], base: string, mode: InlineStylesheets, limit: number): InlineStyleManifest | undefined;
44
+ /**
45
+ * Drops repeated top-level `@property` / `@keyframes` blocks and `@layer a, b;` statements that are byte-identical to an
46
+ * earlier one (each per-component stylesheet carries the ones it uses; a repeat changes nothing). Strings and comments
47
+ * are skipped; anything else is kept as is.
48
+ */
49
+ export declare function dedupeTopLevel(css: string): string;
50
+ /**
51
+ * Dev: stylesheets in the SSR module graph of a route's modules (loaded first), as `?direct` links
52
+ * (no flash before the client graph injects its `<style>`s; `entry.dev.tsx` then removes them).
53
+ */
54
+ export declare function devRouteCSS(server: ViteDevServer, table: RouteTable, routesDir: string, id: string, exclude: ReadonlySet<string>): Promise<string[]>;
@@ -0,0 +1,216 @@
1
+ // Per-route stylesheets: the CSS a route's layouts and page can render (their chunks' static
2
+ // import closure), outer → inner, so SSR links exactly the route's CSS in the head.
3
+ import { createHash } from "node:crypto";
4
+ import { brotliCompressSync, constants } from "node:zlib";
5
+ import { resolve } from "node:path";
6
+ /** Client build asset: route id → stylesheet URLs (written only when some route has CSS). */
7
+ export const ROUTE_CSS = ".vite/rivet-route-css.json";
8
+ /** An app entry (router `styles`, CSR app), not a compiler resume chunk. */
9
+ const isStylesEntry = (chunk) => chunk.isEntry && !chunk.facadeModuleId?.includes("rivet-resume-chunk:");
10
+ const routeFiles = (table, routesDir, id) => {
11
+ const route = table.routes.find((r) => r.id === id);
12
+ if (!route?.page)
13
+ return [];
14
+ return [...route.layouts.flatMap((l) => (l.page ? [l.page] : [])), route.page].map((file) => resolve(routesDir, file));
15
+ };
16
+ /** Stylesheet URLs per route id from a client bundle (empty when no route module imports CSS). */
17
+ export function bundleRouteCSS(bundle, table, routesDir, base) {
18
+ const chunks = Object.values(bundle).filter((o) => o.type === "chunk");
19
+ const byFile = new Map(chunks.map((c) => [c.fileName, c]));
20
+ const owner = new Map();
21
+ for (const chunk of chunks)
22
+ for (const id of chunk.moduleIds)
23
+ owner.set(id.split("?")[0], chunk);
24
+ // Entry CSS (router `styles`) is linked on every page already. Resume chunks are entries too (compiler inputs,
25
+ // `\0rivet-resume-chunk:`) but their CSS is a component's: it stays per route.
26
+ const global = new Set(chunks.filter(isStylesEntry).flatMap((c) => [...(c.viteMetadata?.importedCss ?? [])]));
27
+ const out = {};
28
+ for (const id of new Set(table.routes.map((r) => r.id))) {
29
+ const css = [];
30
+ const seen = new Set();
31
+ // Post-order: a chunk's static imports' CSS precedes its own.
32
+ const visit = (chunk) => {
33
+ if (seen.has(chunk.fileName))
34
+ return;
35
+ seen.add(chunk.fileName);
36
+ for (const file of chunk.imports) {
37
+ const imported = byFile.get(file);
38
+ if (imported)
39
+ visit(imported);
40
+ }
41
+ for (const file of chunk.viteMetadata?.importedCss ?? [])
42
+ if (!global.has(file) && !css.includes(base + file))
43
+ css.push(base + file);
44
+ };
45
+ for (const file of routeFiles(table, routesDir, id)) {
46
+ const chunk = owner.get(file);
47
+ if (chunk)
48
+ visit(chunk);
49
+ }
50
+ if (css.length)
51
+ out[id] = css;
52
+ }
53
+ return out;
54
+ }
55
+ /**
56
+ * One stylesheet per route: a route whose modules import several stylesheets (per-component CSS, `@rivetjs/ui`)
57
+ * links them as one merged asset (`assets/route-<hash>.css`, the files joined in link order; routes with the same
58
+ * list share it), so a page has one render-blocking route sheet next to the global styles. Lists of one file stay.
59
+ * Returns the merged assets to emit and the route map pointing at them. `prefix` names the asset (docs-kit example
60
+ * frames: `frame`).
61
+ */
62
+ export function mergeRouteCSS(bundle, css, base, prefix = "route") {
63
+ const assets = new Map();
64
+ const routes = {};
65
+ for (const [id, list] of Object.entries(css)) {
66
+ const sources = list.map((href) => bundle[href.slice(base.length)]);
67
+ if (list.length < 2 || sources.some((asset) => asset?.type !== "asset")) {
68
+ routes[id] = list;
69
+ continue;
70
+ }
71
+ const source = dedupeTopLevel(sources.map((asset) => unmarked(String(asset.source))).join("\n"));
72
+ const fileName = `assets/${prefix}-${createHash("sha256").update(source).digest("base64url").slice(0, 8)}.css`;
73
+ assets.set(fileName, source + "\n");
74
+ routes[id] = [base + fileName];
75
+ }
76
+ return { routes, assets: [...assets].map(([fileName, source]) => ({ fileName, source })) };
77
+ }
78
+ // Vite's CSS chunk marker (`/*$vite$:1*/`): still in the bundle's CSS assets during `generateBundle` (Vite drops it when
79
+ // it writes its own assets, not from sources copied elsewhere).
80
+ const unmarked = (css) => css.replace(/\/\*\$vite\$:\d+\*\/\n?/g, "").trim();
81
+ /** Client build asset: the stylesheets the handler may write inline (`InlineStyleManifest`; written only when some are). */
82
+ export const INLINE_CSS = ".vite/rivet-inline-css.json";
83
+ /** `auto`: all of a page's sheets inline when their brotli sizes add up to at most this. */
84
+ export const MAX_TOTAL_BROTLI = 6144;
85
+ // A `url()` / `@import` the document would resolve against its own URL instead of the sheet's: `./` / `../` / bare
86
+ // paths (Vite writes base-absolute URLs unless `base` is relative). Site-absolute, scheme, protocol-relative, `data:`
87
+ // and fragment-only references resolve the same from both.
88
+ const RELATIVE_REFERENCE = /(?:url\(\s*|@import\s+)(["']?)(?![a-z][\w+.-]*:|\/|#|["')\s])/i;
89
+ /**
90
+ * The inline stylesheet manifest (`.vite/rivet-inline-css.json`, `server/inline-styles.ts` decides per page), or
91
+ * `undefined` when no sheet qualifies (`"never"`, nothing listed). Listed: every client CSS asset and merged route sheet
92
+ * (keys = the URLs the handler links, `base` + file name) without relative `url()` / `@import` references (they resolve
93
+ * against the sheet's URL) that could ever go inline — `"always"`: all; `"auto"`: brotli size ≤ `maxTotalBrotli` (the
94
+ * per-page total) or raw size ≤ `limit` (Vite `build.assetsInlineLimit`, the per-sheet fallback). Brotli at quality 11.
95
+ */
96
+ export function inlineCSS(bundle, extra, base, mode, limit) {
97
+ if (mode === "never")
98
+ return undefined;
99
+ const always = mode === "always";
100
+ const maxTotalBrotli = typeof mode === "object" ? (mode.maxTotalBrotli ?? MAX_TOTAL_BROTLI) : MAX_TOTAL_BROTLI;
101
+ const sheets = {};
102
+ const all = [
103
+ ...Object.values(bundle).flatMap((o) => (o.type === "asset" && o.fileName.endsWith(".css") ? [{ fileName: o.fileName, source: String(o.source) }] : [])),
104
+ ...extra,
105
+ ];
106
+ for (const { fileName, source } of all) {
107
+ const css = unmarked(source);
108
+ if (!css || RELATIVE_REFERENCE.test(css))
109
+ continue;
110
+ const bytes = Buffer.byteLength(css);
111
+ const br = brotliCompressSync(css, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: bytes } }).length;
112
+ if (always || br <= maxTotalBrotli || bytes <= limit)
113
+ sheets[base + fileName] = { css, bytes, br };
114
+ }
115
+ return Object.keys(sheets).length ? { mode: always ? "always" : "auto", maxTotalBrotli, limit, sheets } : undefined;
116
+ }
117
+ /**
118
+ * Drops repeated top-level `@property` / `@keyframes` blocks and `@layer a, b;` statements that are byte-identical to an
119
+ * earlier one (each per-component stylesheet carries the ones it uses; a repeat changes nothing). Strings and comments
120
+ * are skipped; anything else is kept as is.
121
+ */
122
+ export function dedupeTopLevel(css) {
123
+ const seen = new Set();
124
+ let out = "";
125
+ let depth = 0;
126
+ for (let i = 0; i < css.length;) {
127
+ const ch = css[i];
128
+ if (ch === '"' || ch === "'") {
129
+ let j = i + 1;
130
+ while (j < css.length && css[j] !== ch)
131
+ j += css[j] === "\\" ? 2 : 1;
132
+ out += css.slice(i, j + 1);
133
+ i = j + 1;
134
+ }
135
+ else if (ch === "/" && css[i + 1] === "*") {
136
+ const j = css.indexOf("*/", i + 2);
137
+ const end = j < 0 ? css.length : j + 2;
138
+ out += css.slice(i, end);
139
+ i = end;
140
+ }
141
+ else if (depth === 0 && ch === "@" && /^@(property|keyframes|-webkit-keyframes|layer)\b/.test(css.slice(i, i + 20))) {
142
+ const end = blockEnd(css, i);
143
+ const block = css.slice(i, end);
144
+ const isStatement = block.endsWith(";");
145
+ if (!(/^@layer/.test(block) && !isStatement) && seen.has(block)) {
146
+ i = end;
147
+ continue;
148
+ }
149
+ if (!/^@layer/.test(block) || isStatement)
150
+ seen.add(block);
151
+ if (/^@layer/.test(block) && !isStatement) {
152
+ // A layer block: keep its head, scan its body (nested rules are not deduplicated).
153
+ out += ch;
154
+ i++;
155
+ continue;
156
+ }
157
+ out += block;
158
+ i = end;
159
+ }
160
+ else {
161
+ if (ch === "{")
162
+ depth++;
163
+ else if (ch === "}")
164
+ depth--;
165
+ out += ch;
166
+ i++;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ /** End (exclusive) of the at-rule starting at `at`: after its `;` (statement) or its balanced `{…}` block. */
172
+ function blockEnd(css, at) {
173
+ let depth = 0;
174
+ for (let i = at; i < css.length; i++) {
175
+ const ch = css[i];
176
+ if (ch === '"' || ch === "'") {
177
+ for (i++; i < css.length && css[i] !== ch; i += css[i] === "\\" ? 2 : 1)
178
+ ;
179
+ }
180
+ else if (ch === ";" && depth === 0)
181
+ return i + 1;
182
+ else if (ch === "{")
183
+ depth++;
184
+ else if (ch === "}" && --depth === 0)
185
+ return i + 1;
186
+ }
187
+ return css.length;
188
+ }
189
+ const CSS_REQUEST = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
190
+ const NOT_LINKED = /[?&](?:inline|url|raw)\b/;
191
+ /**
192
+ * Dev: stylesheets in the SSR module graph of a route's modules (loaded first), as `?direct` links
193
+ * (no flash before the client graph injects its `<style>`s; `entry.dev.tsx` then removes them).
194
+ */
195
+ export async function devRouteCSS(server, table, routesDir, id, exclude) {
196
+ const environment = server.environments.ssr;
197
+ const css = [];
198
+ const seen = new Set();
199
+ const visit = (node) => {
200
+ if (!node?.id || seen.has(node.id))
201
+ return;
202
+ seen.add(node.id);
203
+ for (const imported of node.importedModules)
204
+ visit(imported);
205
+ if (CSS_REQUEST.test(node.url) && !NOT_LINKED.test(node.url) && !exclude.has(node.url.split("?")[0])) {
206
+ const href = node.url + (node.url.includes("?") ? "&" : "?") + "direct";
207
+ if (!css.includes(href))
208
+ css.push(href);
209
+ }
210
+ };
211
+ for (const file of routeFiles(table, routesDir, id)) {
212
+ await server.ssrLoadModule(file);
213
+ visit(environment.moduleGraph.getModuleById(file));
214
+ }
215
+ return css;
216
+ }
@@ -0,0 +1,55 @@
1
+ /** A layout or special page found along a route's folder chain. */
2
+ export interface RouteModule {
3
+ /** Universal module (component), relative to the routes directory, posix separators. */
4
+ readonly page?: string;
5
+ /**
6
+ * Server-only module: a sibling `*.server.ts` (guard, load, entries, actions, headers, request
7
+ * handlers), or an endpoint-only plain `x.ts` exporting request handlers.
8
+ */
9
+ readonly server?: string;
10
+ /** The page module itself declares server exports (`load(…)`, `onGet(…)` calls, `prerender`…). */
11
+ readonly inline?: boolean;
12
+ /** Page module with `export const live = true`: its `data` prop is an accessor revalidated in place. */
13
+ readonly live?: boolean;
14
+ }
15
+ /** One matchable route. Optional segments expand into several entries sharing an `id`. */
16
+ export interface RouteEntry extends RouteModule {
17
+ /** Stable id: the file path without extension, e.g. `blog/[slug]`. */
18
+ readonly id: string;
19
+ /** Router pattern for `compileRoutes`, e.g. `/blog/:slug`, `/docs/*rest`. */
20
+ readonly pattern: string;
21
+ /** Parameter names in pattern order. */
22
+ readonly params: readonly string[];
23
+ /** Layouts from the outermost folder inward. */
24
+ readonly layouts: readonly RouteModule[];
25
+ /** Nearest `_404` / `_error` page modules. */
26
+ readonly notFound?: string;
27
+ readonly error?: string;
28
+ }
29
+ export interface RouteTable {
30
+ /** Ordered: the first matching entry wins. */
31
+ readonly routes: readonly RouteEntry[];
32
+ /** Root-level `_404` / `_error` for URLs that match no route. */
33
+ readonly notFound?: string;
34
+ readonly error?: string;
35
+ /** `_404` pages under static folders for unmatched URLs, longest URL prefix first (root excluded). */
36
+ readonly notFounds?: readonly {
37
+ readonly prefix: string;
38
+ readonly page: string;
39
+ }[];
40
+ }
41
+ /**
42
+ * Build the route table from file paths relative to the routes directory (posix separators).
43
+ * `exports`: server export kinds per module file (`load`, `onGet`, … the wrapper names, `?` for a re-export
44
+ * from a server-only module, plus the flags `prerender` / `live`; `scanRoutes` reads them): pages/layouts
45
+ * with any are `inline`, plain `.ts` files with a request handler are endpoint routes.
46
+ */
47
+ export declare function routeTable(files: readonly string[], exports?: ReadonlyMap<string, readonly string[]>): RouteTable;
48
+ /** Scan a routes directory and build its table (reads modules for their server exports). */
49
+ export declare function scanRoutes(dir: string): Promise<RouteTable>;
50
+ /**
51
+ * Server export kinds of route modules (pages, layouts, plain `.ts` files), by file (see `routeTable`);
52
+ * `prerender` and `live` pages are listed with their flag. Throws on a module whose exports misdeclare the
53
+ * route (RV3010 untagged magic name, duplicate kind): the file and the compiler's message.
54
+ */
55
+ export declare function _serverExportsOf(dir: string, files: readonly string[]): Promise<Map<string, string[]>>;