@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/migrate.js CHANGED
@@ -1,10 +1,31 @@
1
- // `rivet migrate server-exports`: rename the old sibling `*.server.ts` export names (`load`, `guard`,
2
- // `actions`, `entries`, `headers`, `GET`…) to the in-file names (`serverLoad`…, `onGet`…). Renames are
3
- // scope-aware (TypeScript language service): references in other modules (`typeof import(…).load`)
4
- // follow; unrelated locals of the same name do not.
1
+ // `rivet migrate server-exports`: rewrite magic route server export names to wrapper calls (spec
2
+ // docs/design/specs/2026-10-20-server-exports-design.md). In-file names (`serverLoad`, `serverGuard`,
3
+ // `serverActions`, `serverEntries`, `serverHeaders`, `onRequest`, `onGet`…) in every route module and the
4
+ // sibling `*.server.ts` names (`load`, `guard`, `actions`, `entries`, `headers`, `GET`…):
5
+ // export async function serverLoad(event) { … } → export const loader = load(async (event) => { … });
6
+ // export const onGet = (event) => … → export const GET = onGet((event) => …);
7
+ // with `import { load, onGet } from "@rivetjs/router"` added. Renames are scope-aware (TypeScript
8
+ // language service): references in other modules (`typeof import(…).serverLoad`, `PageProps<typeof
9
+ // serverLoad>`) follow; unrelated locals of the same name do not.
5
10
  import { readdir, readFile, writeFile } from "node:fs/promises";
6
11
  import { join, relative, resolve, sep } from "node:path";
7
- export const RENAMES = {
12
+ /** Wrapper and conventional export name per former magic name. */
13
+ export const WRAPPERS = {
14
+ serverLoad: { wrapper: "load", name: "loader" },
15
+ serverGuard: { wrapper: "guard", name: "access" },
16
+ serverActions: { wrapper: "actions", name: "forms" },
17
+ serverEntries: { wrapper: "entries", name: "paths" },
18
+ serverHeaders: { wrapper: "headers", name: "responseHeaders" },
19
+ onRequest: { wrapper: "onRequest", name: "middleware" },
20
+ onGet: { wrapper: "onGet", name: "GET" },
21
+ onPost: { wrapper: "onPost", name: "POST" },
22
+ onPut: { wrapper: "onPut", name: "PUT" },
23
+ onPatch: { wrapper: "onPatch", name: "PATCH" },
24
+ onDelete: { wrapper: "onDelete", name: "DELETE" },
25
+ onOptions: { wrapper: "onOptions", name: "OPTIONS" },
26
+ };
27
+ /** Former sibling `*.server.ts` names → the in-file name they meant. */
28
+ export const SIBLING = {
8
29
  load: "serverLoad",
9
30
  guard: "serverGuard",
10
31
  actions: "serverActions",
@@ -19,6 +40,7 @@ export const RENAMES = {
19
40
  const SOURCE = /\.(m|c)?[jt]sx?$/;
20
41
  const SERVER = /\.server\.(m|c)?[jt]s$/;
21
42
  const IGNORED = /(^|[/\\])(node_modules|dist|\.rivet|\.[^/\\]+)([/\\]|$)/;
43
+ const NOT_ROUTE = /\.(test|spec)\.[^/\\]+$|\.d\.ts$/;
22
44
  async function sources(dir) {
23
45
  const entries = await readdir(dir, { recursive: true, withFileTypes: true });
24
46
  return entries
@@ -27,8 +49,9 @@ async function sources(dir) {
27
49
  .filter((file) => !IGNORED.test(relative(dir, file)));
28
50
  }
29
51
  /**
30
- * Compute the migration for `*.server.ts` modules under `routesDir`; references are searched in every
31
- * source module under `projectDir`. Nothing is written (see `writeMigration`).
52
+ * Compute the migration for route modules under `routesDir` (pages, layouts, plain `.ts` endpoints,
53
+ * sibling `*.server.ts`); references are searched in every source module under `projectDir`. Nothing is
54
+ * written (see `writeMigration`).
32
55
  */
33
56
  export async function migrateServerExports(projectDir, routesDir) {
34
57
  const ts = (await import("typescript")).default;
@@ -37,7 +60,19 @@ export async function migrateServerExports(projectDir, routesDir) {
37
60
  for (const file of files)
38
61
  texts.set(file, await readFile(file, "utf8"));
39
62
  const routesRoot = resolve(projectDir, routesDir) + sep;
40
- const targets = files.filter((file) => file.startsWith(routesRoot) && SERVER.test(file));
63
+ const targets = files.filter((file) => file.startsWith(routesRoot) && !NOT_ROUTE.test(file));
64
+ return migrateTexts(ts, texts, targets, resolve(projectDir));
65
+ }
66
+ /** Migrate one module's text on its own (no other modules: references elsewhere are not renamed). */
67
+ export function migrateSource(ts, text, fileName) {
68
+ const file = resolve("/", fileName);
69
+ const report = migrateTexts(ts, new Map([[file, text]]), [file], "/");
70
+ return { text: report.changes.get(file) ?? text, renamed: report.renamed, skipped: report.skipped };
71
+ }
72
+ /** The migration over in-memory `texts` (absolute paths); `targets`: the route modules to rewrite. */
73
+ export function migrateTexts(ts, texts, targets, root) {
74
+ const current = new Map(texts);
75
+ const files = [...texts.keys()];
41
76
  const options = {
42
77
  allowJs: true,
43
78
  jsx: ts.JsxEmit.Preserve,
@@ -54,7 +89,7 @@ export async function migrateServerExports(projectDir, routesDir) {
54
89
  const text = texts.get(file) ?? ts.sys.readFile(file);
55
90
  return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
56
91
  },
57
- getCurrentDirectory: () => resolve(projectDir),
92
+ getCurrentDirectory: () => root,
58
93
  getCompilationSettings: () => options,
59
94
  getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
60
95
  fileExists: (file) => texts.has(file) || ts.sys.fileExists(file),
@@ -67,78 +102,282 @@ export async function migrateServerExports(projectDir, routesDir) {
67
102
  const edits = new Map();
68
103
  const renamed = [];
69
104
  const skipped = [];
105
+ const planned = new Map();
70
106
  const edit = (file, start, end, text) => {
71
107
  let list = edits.get(file);
72
108
  if (!list)
73
109
  edits.set(file, (list = []));
74
- if (!list.some((e) => e.start === start))
110
+ if (!list.some((e) => e.start === start && e.end === end))
75
111
  list.push({ start, end, text });
76
112
  };
113
+ const display = (file) => relative(root, file).split(sep).join("/");
114
+ // Phase 1: renames (project-wide, scope-aware) and the plan per target.
77
115
  for (const file of targets) {
78
- const program = service.getProgram();
79
- const source = program.getSourceFile(file);
80
- const name = relative(projectDir, file).split(sep).join("/");
81
- // Top-level names (declared or exported) decide collisions.
82
- const locals = new Set();
83
- const exported = [];
116
+ const source = service.getProgram().getSourceFile(file);
117
+ if (!source)
118
+ continue;
119
+ const sibling = SERVER.test(file);
120
+ const locals = topLevelNames(ts, source);
121
+ const plan = [];
84
122
  for (const statement of source.statements) {
85
- const isExport = ts.canHaveModifiers(statement) && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
86
- const names = [];
87
- if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name)
88
- names.push(statement.name);
89
- if (ts.isVariableStatement(statement))
123
+ const exported = ts.canHaveModifiers(statement) && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
124
+ const ids = [];
125
+ if (exported && ts.isFunctionDeclaration(statement) && statement.name && !statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword))
126
+ ids.push(statement.name);
127
+ if (exported && ts.isVariableStatement(statement))
90
128
  for (const d of statement.declarationList.declarations)
91
129
  if (ts.isIdentifier(d.name))
92
- names.push(d.name);
93
- for (const id of names) {
94
- locals.add(id.text);
95
- if (isExport && id.text in RENAMES)
96
- exported.push({ old: id.text, node: id });
130
+ ids.push(d.name);
131
+ const specifiers = ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause) ? statement.exportClause.elements : [];
132
+ for (const id of ids) {
133
+ const meant = magic(id.text, sibling);
134
+ if (!meant)
135
+ continue;
136
+ if (ts.isVariableStatement(statement) && statement.declarationList.declarations.length > 1) {
137
+ skipped.push(`${display(file)}: ${id.text} (declared with other variables: give it its own statement)`);
138
+ continue;
139
+ }
140
+ if (ts.isVariableStatement(statement) && !statement.declarationList.declarations[0].initializer) {
141
+ skipped.push(`${display(file)}: ${id.text} (no initializer)`);
142
+ continue;
143
+ }
144
+ const { wrapper, name } = WRAPPERS[meant];
145
+ const next = name === id.text ? name : free(name, locals);
146
+ if (!next) {
147
+ skipped.push(`${display(file)}: ${id.text} (\`${name}\` already exists)`);
148
+ continue;
149
+ }
150
+ locals.add(next);
151
+ const locations = service.findRenameLocations(file, id.getStart(source), false, false, { providePrefixAndSuffixTextForRename: true }) ?? [];
152
+ for (const location of locations)
153
+ edit(location.fileName, location.textSpan.start, location.textSpan.start + location.textSpan.length, (location.prefixText ?? "") + next + (location.suffixText ?? ""));
154
+ plan.push({ old: id.text, name: next, wrapper });
155
+ renamed.push(`${display(file)}: ${id.text} → ${next} = ${wrapper}(…)`);
97
156
  }
98
- if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause))
99
- for (const specifier of statement.exportClause.elements) {
100
- locals.add(specifier.name.text);
101
- if (specifier.name.text in RENAMES)
102
- exported.push({ old: specifier.name.text, node: specifier.name, specifier });
157
+ for (const specifier of specifiers) {
158
+ const meant = magic(specifier.name.text, sibling);
159
+ if (!meant)
160
+ continue;
161
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
162
+ skipped.push(`${display(file)}: ${specifier.name.text} (re-exported: wrap it where it is declared, then re-export it from a *.server.ts module)`);
163
+ continue;
164
+ }
165
+ if (!specifier.propertyName || magic(specifier.propertyName.text, sibling)) {
166
+ skipped.push(`${display(file)}: ${specifier.name.text} (\`export { ${specifier.name.text} }\`: rename the local, then wrap it)`);
167
+ continue;
103
168
  }
104
- if (ts.isImportDeclaration(statement) && statement.importClause) {
105
- const clause = statement.importClause;
106
- if (clause.name)
107
- locals.add(clause.name.text);
108
- const bindings = clause.namedBindings;
109
- if (bindings && ts.isNamedImports(bindings))
110
- for (const s of bindings.elements)
111
- locals.add(s.name.text);
112
- if (bindings && ts.isNamespaceImport(bindings))
113
- locals.add(bindings.name.text);
169
+ const { wrapper, name } = WRAPPERS[meant];
170
+ const next = name === specifier.name.text ? name : free(name, locals);
171
+ if (!next) {
172
+ skipped.push(`${display(file)}: ${specifier.name.text} (\`${name}\` already exists)`);
173
+ continue;
174
+ }
175
+ locals.add(next);
176
+ const locations = service.findRenameLocations(file, specifier.name.getStart(source), false, false, { providePrefixAndSuffixTextForRename: true }) ?? [];
177
+ for (const location of locations)
178
+ edit(location.fileName, location.textSpan.start, location.textSpan.start + location.textSpan.length, (location.prefixText ?? "") + next + (location.suffixText ?? ""));
179
+ plan.push({ old: specifier.name.text, name: next, wrapper });
180
+ renamed.push(`${display(file)}: ${specifier.name.text} → ${next} = ${wrapper}(${specifier.propertyName.text})`);
114
181
  }
115
182
  }
116
- for (const { old, node, specifier } of exported) {
117
- const next = RENAMES[old];
118
- if (locals.has(next)) {
119
- skipped.push(`${name}: ${old} (\`${next}\` already exists)`);
120
- continue;
183
+ if (plan.length)
184
+ planned.set(file, plan);
185
+ }
186
+ for (const [file, list] of edits)
187
+ current.set(file, applyEdits(current.get(file) ?? "", list));
188
+ // Phase 2 (per target, parser only): wrap the renamed exports, add the wrapper import.
189
+ for (const [file, plan] of planned)
190
+ current.set(file, wrapExports(ts, current.get(file), file, plan));
191
+ const changes = new Map();
192
+ for (const [file, text] of current)
193
+ if (text !== texts.get(file))
194
+ changes.set(file, text);
195
+ return { changes, renamed, skipped };
196
+ }
197
+ /** The in-file name an export name stands for (sibling modules: their former names too). */
198
+ function magic(name, sibling) {
199
+ if (Object.hasOwn(WRAPPERS, name))
200
+ return name;
201
+ return sibling && Object.hasOwn(SIBLING, name) ? SIBLING[name] : undefined;
202
+ }
203
+ function free(name, taken) {
204
+ for (const candidate of [name, name + "Export"])
205
+ if (!taken.has(candidate))
206
+ return candidate;
207
+ return undefined;
208
+ }
209
+ function topLevelNames(ts, source) {
210
+ const names = new Set();
211
+ for (const statement of source.statements) {
212
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name)
213
+ names.add(statement.name.text);
214
+ if (ts.isVariableStatement(statement))
215
+ for (const d of statement.declarationList.declarations)
216
+ collectBindings(ts, d.name, names);
217
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause))
218
+ for (const specifier of statement.exportClause.elements)
219
+ names.add(specifier.name.text);
220
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
221
+ const clause = statement.importClause;
222
+ if (clause.name)
223
+ names.add(clause.name.text);
224
+ const bindings = clause.namedBindings;
225
+ if (bindings && ts.isNamedImports(bindings))
226
+ for (const s of bindings.elements)
227
+ names.add(s.name.text);
228
+ if (bindings && ts.isNamespaceImport(bindings))
229
+ names.add(bindings.name.text);
230
+ }
231
+ if ((ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) && ts.isIdentifier(statement.name))
232
+ names.add(statement.name.text);
233
+ }
234
+ return names;
235
+ }
236
+ function collectBindings(ts, name, out) {
237
+ if (ts.isIdentifier(name))
238
+ out.add(name.text);
239
+ else
240
+ for (const element of name.elements)
241
+ if (!ts.isOmittedExpression(element))
242
+ collectBindings(ts, element.name, out);
243
+ }
244
+ function applyEdits(text, list) {
245
+ for (const e of [...list].sort((a, b) => b.start - a.start || b.end - a.end))
246
+ text = text.slice(0, e.start) + e.text + text.slice(e.end);
247
+ return text;
248
+ }
249
+ /** Whether a function body needs a function expression (`this`, `arguments`, `super`, `new.target`), not an arrow. */
250
+ function needsFunction(ts, node) {
251
+ let found = false;
252
+ const visit = (n) => {
253
+ if (found)
254
+ return;
255
+ if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword || ts.isMetaProperty(n) || (ts.isIdentifier(n) && n.text === "arguments")) {
256
+ found = true;
257
+ return;
258
+ }
259
+ // Own `this` / `arguments` of nested functions and classes do not count.
260
+ if (ts.isFunctionDeclaration(n) ||
261
+ ts.isFunctionExpression(n) ||
262
+ ts.isClassDeclaration(n) ||
263
+ ts.isClassExpression(n) ||
264
+ ts.isMethodDeclaration(n) ||
265
+ ts.isGetAccessorDeclaration(n) ||
266
+ ts.isSetAccessorDeclaration(n))
267
+ return;
268
+ ts.forEachChild(n, visit);
269
+ };
270
+ ts.forEachChild(node, visit);
271
+ return found;
272
+ }
273
+ function wrapExports(ts, text, file, plan) {
274
+ const kind = /\.tsx$/.test(file) ? ts.ScriptKind.TSX : /\.jsx$/.test(file) ? ts.ScriptKind.JSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
275
+ const typed = kind === ts.ScriptKind.TS || kind === ts.ScriptKind.TSX;
276
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kind);
277
+ const edits = [];
278
+ const slice = (node) => text.slice(node.getStart(source), node.getEnd());
279
+ const locals = topLevelNames(ts, source);
280
+ // Wrapper local names: an existing value import of `@rivetjs/router` (aliases kept), else added.
281
+ const imported = new Map();
282
+ let routerImport;
283
+ for (const statement of source.statements) {
284
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@rivetjs/router")
285
+ continue;
286
+ const clause = statement.importClause;
287
+ if (!clause || clause.isTypeOnly || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
288
+ continue;
289
+ routerImport ??= statement;
290
+ for (const s of clause.namedBindings.elements)
291
+ if (!s.isTypeOnly)
292
+ imported.set((s.propertyName ?? s.name).text, s.name.text);
293
+ }
294
+ const added = [];
295
+ const local = (wrapper) => {
296
+ const known = imported.get(wrapper);
297
+ if (known)
298
+ return known;
299
+ const name = locals.has(wrapper) ? "rivet" + wrapper[0].toUpperCase() + wrapper.slice(1) : wrapper;
300
+ imported.set(wrapper, name);
301
+ added.push(name === wrapper ? wrapper : `${wrapper} as ${name}`);
302
+ locals.add(name);
303
+ return name;
304
+ };
305
+ const appended = [];
306
+ for (const { name, wrapper } of plan) {
307
+ for (const statement of source.statements) {
308
+ if (ts.isVariableStatement(statement)) {
309
+ const d = statement.declarationList.declarations[0];
310
+ if (!d || !ts.isIdentifier(d.name) || d.name.text !== name || !d.initializer)
311
+ continue;
312
+ const call = local(wrapper);
313
+ let init = slice(d.initializer);
314
+ if (d.type && typed) {
315
+ init = `(${init}) satisfies ${slice(d.type)}`;
316
+ edits.push({ start: d.name.getEnd(), end: d.type.getEnd(), text: "" });
317
+ }
318
+ edits.push({ start: d.initializer.getStart(source), end: d.initializer.getEnd(), text: `${call}(${init})` });
319
+ if (!(statement.declarationList.flags & ts.NodeFlags.Const)) {
320
+ const keyword = statement.declarationList.getStart(source);
321
+ const word = /^(let|var)/.exec(text.slice(keyword))[1];
322
+ edits.push({ start: keyword, end: keyword + word.length, text: "const" });
323
+ }
121
324
  }
122
- if (specifier && specifier.propertyName) {
123
- // `export { x as load }`: only the exported name changes.
124
- edit(file, specifier.name.getStart(source), specifier.name.getEnd(), next);
325
+ else if (ts.isFunctionDeclaration(statement) && statement.name?.text === name && statement.body) {
326
+ const call = local(wrapper);
327
+ const isAsync = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
328
+ const params = text.slice(statement.parameters.pos - 1, statement.parameters.end + 1);
329
+ const returns = statement.type ? `: ${slice(statement.type)}` : "";
330
+ const typeParams = statement.typeParameters ? `<${statement.typeParameters.map(slice).join(", ")}>` : "";
331
+ const fn = statement.asteriskToken || needsFunction(ts, statement.body) || typeParams
332
+ ? `${isAsync ? "async " : ""}function${statement.asteriskToken ? "*" : ""} ${name}${typeParams}${params}${returns} ${slice(statement.body)}`
333
+ : `${isAsync ? "async " : ""}${params}${returns} => ${slice(statement.body)}`;
334
+ const start = statement.getStart(source);
335
+ edits.push({ start, end: statement.getEnd(), text: `export const ${name} = ${call}(${fn});` });
125
336
  }
126
- else {
127
- const locations = service.findRenameLocations(file, node.getStart(source), false, false, { providePrefixAndSuffixTextForRename: true }) ?? [];
128
- for (const location of locations)
129
- edit(location.fileName, location.textSpan.start, location.textSpan.start + location.textSpan.length, (location.prefixText ?? "") + next + (location.suffixText ?? ""));
337
+ else if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
338
+ const elements = statement.exportClause.elements;
339
+ const target = elements.find((s) => s.name.text === name && s.propertyName);
340
+ if (!target)
341
+ continue;
342
+ const call = local(wrapper);
343
+ const rest = elements.filter((s) => s !== target).map(slice);
344
+ const end = statement.getEnd() + (text[statement.getEnd()] === "\n" ? 1 : 0);
345
+ edits.push({ start: statement.getStart(source), end: rest.length ? statement.getEnd() : end, text: rest.length ? `export { ${rest.join(", ")} };` : "" });
346
+ appended.push(`export const ${name} = ${call}(${target.propertyName.text});`);
130
347
  }
131
- renamed.push(`${name}: ${old} → ${next}`);
132
348
  }
133
349
  }
134
- const changes = new Map();
135
- for (const [file, list] of edits) {
136
- let text = texts.get(file) ?? (await readFile(file, "utf8"));
137
- for (const e of list.sort((a, b) => b.start - a.start))
138
- text = text.slice(0, e.start) + e.text + text.slice(e.end);
139
- changes.set(file, text);
350
+ if (added.length) {
351
+ if (routerImport && ts.isNamedImports(routerImport.importClause.namedBindings)) {
352
+ const named = routerImport.importClause.namedBindings;
353
+ const last = named.elements[named.elements.length - 1];
354
+ if (last)
355
+ edits.push({ start: last.getEnd(), end: last.getEnd(), text: `, ${added.join(", ")}` });
356
+ else
357
+ edits.push({ start: named.getStart(source) + 1, end: named.getStart(source) + 1, text: ` ${added.join(", ")} ` });
358
+ }
359
+ else {
360
+ const imports = source.statements.filter(ts.isImportDeclaration);
361
+ const line = `import { ${added.join(", ")} } from "@rivetjs/router";`;
362
+ const after = imports[imports.length - 1];
363
+ if (after) {
364
+ // At the end of the last import's line (after a trailing comment, which stays with its import).
365
+ const eol = text.indexOf("\n", after.getEnd());
366
+ const at = eol === -1 ? text.length : eol;
367
+ edits.push({ start: at, end: at, text: "\n" + line });
368
+ }
369
+ else {
370
+ // After a leading comment block / directive prologue: before the first statement.
371
+ const first = source.statements[0];
372
+ const at = first ? first.getStart(source) : text.length;
373
+ edits.push({ start: at, end: at, text: line + "\n" });
374
+ }
375
+ }
140
376
  }
141
- return { changes, renamed, skipped };
377
+ let out = applyEdits(text, edits);
378
+ if (appended.length)
379
+ out = out.replace(/\s*$/, "\n") + appended.join("\n") + "\n";
380
+ return out;
142
381
  }
143
382
  export async function writeMigration(report) {
144
383
  for (const [file, text] of report.changes)
@@ -0,0 +1,60 @@
1
+ import { type ResolvedConfig } from "vite";
2
+ import { type ServerOnlyPattern } from "@rivetjs/compiler/vite";
3
+ import type { RouteTable } from "./routes.js";
4
+ /** Resolve a specifier for the browser graph: an absolute id (files: path, maybe `?query`), or undefined (virtual / unknown). */
5
+ export type ReachResolve = (specifier: string, importer: string) => Promise<string | undefined>;
6
+ /** `rivetRouter({ serverOnly })`. */
7
+ export type ServerOnlyOption = readonly ServerOnlyPattern[] | {
8
+ modules?: readonly ServerOnlyPattern[];
9
+ reach?: "error" | "warn";
10
+ };
11
+ export interface ReachOptions {
12
+ root: string;
13
+ /** Absolute routes directory. */
14
+ routesDir: string;
15
+ table: RouteTable;
16
+ resolve: ReachResolve;
17
+ /** Declared server-only modules (option + plugins); the marker and `*.server.*` always count. */
18
+ serverOnly: readonly ServerOnlyPattern[];
19
+ /** Components reaching a server-only module: build error (default) or warning (the page then renders as a document). */
20
+ reach?: "error" | "warn";
21
+ /** Absolute route module paths (compiled with `serverExports`: server exports and code only they use are no browser code). */
22
+ routeModules: ReadonlySet<string>;
23
+ /** Vite config (tsconfig options of the type stripping). */
24
+ config?: ResolvedConfig;
25
+ }
26
+ /** One RV3011 report. */
27
+ export interface ReachProblem {
28
+ readonly severity: "error" | "warning";
29
+ /** What reaches it: a component module of page routes, a layout, an error / not-found page. */
30
+ readonly kind: "component" | "layout" | "special";
31
+ /** Display chain from the first component (or the layout / page) to the server-only module. */
32
+ readonly chain: readonly string[];
33
+ /** Route patterns (component: the page routes rendering it; layout: the routes under it). */
34
+ readonly routes: readonly string[];
35
+ /** Routes-relative page / layout files involved. */
36
+ readonly files: readonly string[];
37
+ /** Terminal text: `[rivet:router] RV3011 …` + `fix:` / `docs:` lines. */
38
+ readonly message: string;
39
+ }
40
+ export interface Reach {
41
+ /** Document routes: routes-relative page file → display chain to the server-only module it reaches. */
42
+ readonly documents: ReadonlyMap<string, readonly string[]>;
43
+ readonly problems: readonly ReachProblem[];
44
+ /** Modules (absolute paths) only document pages reach: a server-only import from one gets the browser stand-in. */
45
+ readonly documentOnly: ReadonlySet<string>;
46
+ /** `importer \0 specifier` of each server-only import → its key (resolved id, else the specifier). */
47
+ readonly targets: ReadonlyMap<string, string>;
48
+ /** Server-only key → names imported from it (stand-in exports). */
49
+ readonly names: ReadonlyMap<string, ReadonlySet<string>>;
50
+ /** Server-only key → one display chain of importers (outermost first). */
51
+ readonly chains: ReadonlyMap<string, readonly string[]>;
52
+ }
53
+ export declare const RV_REACH = "RV3011";
54
+ /** Walk every route's browser graph and classify the routes (see the module comment). */
55
+ export declare function analyzeReach(options: ReachOptions): Promise<Reach>;
56
+ /** Normalize `rivetRouter({ serverOnly })`. */
57
+ export declare function serverOnlyOption(option: ServerOnlyOption | undefined): {
58
+ modules: readonly ServerOnlyPattern[];
59
+ reach: "error" | "warn";
60
+ };