@rivetjs/router 0.1.0-alpha.0 → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/adapters/files.d.ts +13 -0
  2. package/adapters/files.js +58 -6
  3. package/adapters/node.d.ts +22 -5
  4. package/adapters/node.js +113 -67
  5. package/adapters/static.js +1 -1
  6. package/adapters/target-build.d.ts +5 -2
  7. package/adapters/vercel.d.ts +4 -2
  8. package/adapters/vercel.js +1 -1
  9. package/build/codegen.d.ts +13 -0
  10. package/build/codegen.js +152 -35
  11. package/build/dev-errors.d.ts +6 -2
  12. package/build/dev-errors.js +15 -4
  13. package/build/lazy-rows.d.ts +19 -0
  14. package/build/lazy-rows.js +59 -0
  15. package/build/migrate.d.ts +20 -4
  16. package/build/migrate.js +300 -61
  17. package/build/reach.d.ts +60 -0
  18. package/build/reach.js +302 -0
  19. package/build/routes.d.ts +9 -4
  20. package/build/routes.js +30 -13
  21. package/build/vite.d.ts +13 -0
  22. package/build/vite.js +165 -7
  23. package/cli/rivet.js +3 -2
  24. package/client/dev.js +17 -0
  25. package/client/index.d.ts +4 -0
  26. package/client/index.js +34 -0
  27. package/client/route-context.d.ts +1 -1
  28. package/index.d.ts +22 -25
  29. package/index.js +2 -1
  30. package/package.json +4 -4
  31. package/server/app.d.ts +7 -1
  32. package/server/app.js +38 -5
  33. package/server/deferred.d.ts +12 -0
  34. package/server/deferred.js +60 -0
  35. package/server/handler.d.ts +56 -12
  36. package/server/handler.js +46 -8
  37. package/server/index.d.ts +1 -1
  38. package/server/module.d.ts +7 -7
  39. package/server/module.js +52 -34
  40. package/server/render.d.ts +2 -0
  41. package/server/render.js +3 -0
  42. package/server/route-cache.d.ts +74 -4
  43. package/server/route-cache.js +77 -6
  44. package/server/shell-build.d.ts +3 -0
  45. package/server/shell-build.js +2 -0
  46. package/server/shell-cache.d.ts +8 -0
  47. package/server/shell-cache.js +836 -0
  48. package/shared/server-exports.d.ts +78 -0
  49. package/shared/server-exports.js +77 -0
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
+ }
package/build/routes.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface RouteModule {
7
7
  * handlers), or an endpoint-only plain `x.ts` exporting request handlers.
8
8
  */
9
9
  readonly server?: string;
10
- /** The page module itself declares server exports (`serverLoad`, `onGet`, `prerender`…). */
10
+ /** The page module itself declares server exports (`load(…)`, `onGet(…)` calls, `prerender`…). */
11
11
  readonly inline?: boolean;
12
12
  /** Page module with `export const live = true`: its `data` prop is an accessor revalidated in place. */
13
13
  readonly live?: boolean;
@@ -40,11 +40,16 @@ export interface RouteTable {
40
40
  }
41
41
  /**
42
42
  * Build the route table from file paths relative to the routes directory (posix separators).
43
- * `exports`: server export names per module file (`scanRoutes` reads them; pages/layouts with any are
44
- * `inline`, plain `.ts` files with request handlers are endpoint routes).
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.
45
46
  */
46
47
  export declare function routeTable(files: readonly string[], exports?: ReadonlyMap<string, readonly string[]>): RouteTable;
47
48
  /** Scan a routes directory and build its table (reads modules for their server exports). */
48
49
  export declare function scanRoutes(dir: string): Promise<RouteTable>;
49
- /** Server export names of route modules (pages, layouts, plain `.ts` files), by file; live pages also list `live`. */
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
+ */
50
55
  export declare function _serverExportsOf(dir: string, files: readonly string[]): Promise<Map<string, string[]>>;
package/build/routes.js CHANGED
@@ -5,7 +5,8 @@ const PAGE = /\.(tsx|jsx)$/;
5
5
  const SERVER = /\.server\.(ts|js)$/;
6
6
  /** Plain `.ts`/`.js` route files: endpoint-only routes when they export request handlers. */
7
7
  const PLAIN = /\.(ts|js)$/;
8
- const HANDLER = /^on(Request|Get|Post|Put|Patch|Delete|Options)$/;
8
+ // Request handler kinds; `?` = a re-export from a server-only module (kind known at runtime only).
9
+ const HANDLER = /^(on(Request|Get|Post|Put|Patch|Delete|Options)|\?)$/;
9
10
  const IGNORED = /(^|\/)[.]|\.(test|spec)\.[^/]+$|\.d\.ts$/;
10
11
  const SPECIAL = new Set(["_layout", "_404", "_error"]);
11
12
  /** Parse one path segment of a route file name; null for a path-less `(group)`. */
@@ -43,8 +44,9 @@ const shape = (segments) => "/" + segments.map((s) => (s.kind === "static" ? s.t
43
44
  const pattern = (segments) => "/" + segments.map((s) => (s.kind === "static" ? encodeURIComponent(s.text) : (s.kind === "splat" ? "*" : ":") + s.name)).join("/");
44
45
  /**
45
46
  * Build the route table from file paths relative to the routes directory (posix separators).
46
- * `exports`: server export names per module file (`scanRoutes` reads them; pages/layouts with any are
47
- * `inline`, plain `.ts` files with request handlers are endpoint routes).
47
+ * `exports`: server export kinds per module file (`load`, `onGet`, … the wrapper names, `?` for a re-export
48
+ * from a server-only module, plus the flags `prerender` / `live`; `scanRoutes` reads them): pages/layouts
49
+ * with any are `inline`, plain `.ts` files with a request handler are endpoint routes.
48
50
  */
49
51
  export function routeTable(files, exports = new Map()) {
50
52
  const set = new Set(files.filter((file) => !IGNORED.test(file)));
@@ -177,7 +179,15 @@ export async function scanRoutes(dir) {
177
179
  const PRERENDER = /^\s*export\s+(?:const|let|var)\s+prerender\b/m;
178
180
  // `export const live = true`: a live page (reported as `live`, not a server export; universal, kept in the browser).
179
181
  const LIVE = /^\s*export\s+const\s+live\s*=\s*true\b/m;
180
- /** Server export names of route modules (pages, layouts, plain `.ts` files), by file; live pages also list `live`. */
182
+ // Cheap prefilter (no candidate, no parse): a wrapper import, a server-only re-export, or a former magic name (RV3010).
183
+ const CANDIDATE = /@rivetjs\/router["']|\.server\b|\b(server(Load|Guard|Actions|Entries|Headers)|on(Request|Get|Post|Put|Patch|Delete|Options))\b/;
184
+ // Server-split errors that change what the route table would be: untagged magic names, duplicate kinds.
185
+ const TABLE_ERRORS = /^(Untagged server export|Duplicate server export)/;
186
+ /**
187
+ * Server export kinds of route modules (pages, layouts, plain `.ts` files), by file (see `routeTable`);
188
+ * `prerender` and `live` pages are listed with their flag. Throws on a module whose exports misdeclare the
189
+ * route (RV3010 untagged magic name, duplicate kind): the file and the compiler's message.
190
+ */
181
191
  export async function _serverExportsOf(dir, files) {
182
192
  const { moduleFacts } = await import("@rivetjs/compiler");
183
193
  const out = new Map();
@@ -186,17 +196,24 @@ export async function _serverExportsOf(dir, files) {
186
196
  continue;
187
197
  const source = await readFile(join(dir, file), "utf8");
188
198
  const live = PAGE.test(file) && LIVE.test(source);
189
- // Cheap prefilter: no candidate export name, no parse.
190
- const candidates = /\b(server[A-Z]\w*|on(Request|Get|Post|Put|Patch|Delete|Options)|prerender)\b/.test(source);
191
- if (!candidates && !live)
199
+ const prerender = PAGE.test(file) && PRERENDER.test(source);
200
+ const candidates = CANDIDATE.test(source);
201
+ if (!candidates && !live && !prerender)
192
202
  continue;
193
- const names = candidates ? [...(moduleFacts(source, file, { serverExports: true }).serverExports ?? [])] : [];
194
- if (PAGE.test(file) && PRERENDER.test(source))
195
- names.push("prerender");
203
+ const kinds = [];
204
+ if (candidates) {
205
+ const facts = moduleFacts(source, file, { serverExports: true });
206
+ const error = facts.serverErrors?.find((e) => TABLE_ERRORS.test(e.message));
207
+ if (error)
208
+ throw new Error(`${join(dir, file)}${error.line === null ? "" : `:${error.line}:${error.column ?? 1}`}: ${error.message}`);
209
+ kinds.push(...(facts.serverKinds ?? []));
210
+ }
211
+ if (prerender)
212
+ kinds.push("prerender");
196
213
  if (live)
197
- names.push("live");
198
- if (names.length)
199
- out.set(file, names);
214
+ kinds.push("live");
215
+ if (kinds.length)
216
+ out.set(file, kinds);
200
217
  }
201
218
  return out;
202
219
  }
package/build/vite.d.ts CHANGED
@@ -9,6 +9,7 @@ export type { InlineStylesheets } from "./route-css.js";
9
9
  /** Build-time helper for other Vite plugins (docs-kit example frames): one merged sheet per stylesheet list. */
10
10
  export { mergeRouteCSS } from "./route-css.js";
11
11
  import { type InlineStylesheets } from "./route-css.js";
12
+ import { type ServerOnlyOption } from "./reach.js";
12
13
  import { type AdapterName, type AdapterOptions } from "../adapters/target-build.js";
13
14
  /** Options of the `rivetRouter()` Vite plugin. */
14
15
  export interface RivetRouterOptions {
@@ -156,6 +157,16 @@ export interface RivetRouterOptions {
156
157
  dev?: {
157
158
  resume?: boolean;
158
159
  };
160
+ /**
161
+ * Server-only modules besides `*.server.*` files and modules importing `@rivetjs/router/server-only`: module ids /
162
+ * specifiers or RegExps (virtual modules serving server data, e.g. `"virtual:docs/api"`; plugins declare theirs as
163
+ * `api.rivetServerOnly`, `rivet({ serverOnly })` too). Before each build / dev start the router walks every route's
164
+ * browser graph: a page route reaching one through its own module and plain helper modules is a document route (links
165
+ * load its document, its module leaves the client build); a component module, layout or error page reaching one fails
166
+ * the build (RV3011, with the import chain and the routes). `{ modules, reach: "warn" }`: components reaching one only
167
+ * warn, and the pages rendering them become document routes. Server / static modes (csr keeps the import error).
168
+ */
169
+ serverOnly?: ServerOnlyOption;
159
170
  }
160
171
  /** Router features generated code provides only when the app uses them (`routerUsage`). */
161
172
  export interface RouterUsage {
@@ -185,6 +196,8 @@ export declare function routerUsage(root: string, options?: {
185
196
  /** Regenerate `.rivet/` for a project root; returns the resume modules and whether files changed. */
186
197
  export declare function generateRoutes(root: string, options?: Pick<RivetRouterOptions, "routes" | "outDir" | "document" | "mode" | "transitions" | "announce" | "base" | "searchParams" | "navigate" | "forms" | "params" | "activeLinks" | "trailingSlash" | "prefetch" | "prefetchEagerness" | "prefetchPaths" | "preload" | "routeRules" | "clientRouter"> & {
187
198
  head?: "components" | undefined;
199
+ documents?: readonly string[];
200
+ derived?: boolean;
188
201
  }): Promise<{
189
202
  table: RouteTable;
190
203
  resumeModules: readonly string[];