@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.
- package/adapters/files.d.ts +13 -0
- package/adapters/files.js +58 -6
- package/adapters/node.d.ts +22 -5
- package/adapters/node.js +113 -67
- package/adapters/static.js +1 -1
- package/adapters/target-build.d.ts +5 -2
- package/adapters/vercel.d.ts +4 -2
- package/adapters/vercel.js +1 -1
- package/build/codegen.d.ts +13 -0
- package/build/codegen.js +152 -35
- package/build/dev-errors.d.ts +6 -2
- package/build/dev-errors.js +15 -4
- package/build/lazy-rows.d.ts +19 -0
- package/build/lazy-rows.js +59 -0
- package/build/migrate.d.ts +20 -4
- package/build/migrate.js +300 -61
- package/build/reach.d.ts +60 -0
- package/build/reach.js +302 -0
- package/build/routes.d.ts +9 -4
- package/build/routes.js +30 -13
- package/build/vite.d.ts +13 -0
- package/build/vite.js +165 -7
- package/cli/rivet.js +3 -2
- package/client/dev.js +17 -0
- package/client/index.d.ts +4 -0
- package/client/index.js +34 -0
- package/client/route-context.d.ts +1 -1
- package/index.d.ts +22 -25
- package/index.js +2 -1
- package/package.json +4 -4
- package/server/app.d.ts +7 -1
- package/server/app.js +38 -5
- package/server/deferred.d.ts +12 -0
- package/server/deferred.js +60 -0
- package/server/handler.d.ts +56 -12
- package/server/handler.js +46 -8
- package/server/index.d.ts +1 -1
- package/server/module.d.ts +7 -7
- package/server/module.js +52 -34
- package/server/render.d.ts +2 -0
- package/server/render.js +3 -0
- package/server/route-cache.d.ts +74 -4
- package/server/route-cache.js +77 -6
- package/server/shell-build.d.ts +3 -0
- package/server/shell-build.js +2 -0
- package/server/shell-cache.d.ts +8 -0
- package/server/shell-cache.js +836 -0
- package/shared/server-exports.d.ts +78 -0
- 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 (`
|
|
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
|
|
44
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
const candidates =
|
|
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
|
|
194
|
-
if (
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
if (
|
|
199
|
-
out.set(file,
|
|
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[];
|