@troyio/vite-file-router 0.3.2 → 0.4.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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
4
5
  import { format } from "prettier";
5
- import fs from "node:fs";
6
6
  // src/scanner.ts
7
7
  var DEFAULT_CONVENTIONS = {
8
8
  page: "page",
@@ -35,11 +35,17 @@ function getRouteParameter(name) {
35
35
  optional: false
36
36
  } : void 0;
37
37
  }
38
- function collectPageRoutePatterns(node, segments = [], root = true) {
38
+ function collectPageRoutes(node, segments = [], root = true) {
39
39
  const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
40
- const patterns = node.page ? [`/${routeSegments.join("/")}`] : [];
41
- for (const child of node.children) patterns.push(...collectPageRoutePatterns(child, routeSegments, false));
42
- return patterns;
40
+ const pages = node.page ? [{
41
+ pattern: `/${routeSegments.join("/")}`,
42
+ node
43
+ }] : [];
44
+ for (const child of node.children) pages.push(...collectPageRoutes(child, routeSegments, false));
45
+ return pages;
46
+ }
47
+ function collectPageRoutePatterns(node) {
48
+ return collectPageRoutes(node).map(({ pattern }) => pattern);
43
49
  }
44
50
  function scan(directory, conventions = DEFAULT_CONVENTIONS) {
45
51
  const node = {
@@ -133,15 +139,15 @@ function generateImport(file, projectRoot) {
133
139
  Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
134
140
  })`;
135
141
  }
136
- function generateRouteLazy(node, projectRoot, includeLayout, includeLoader) {
142
+ function generateRouteLazy(node, projectRoot, includeLayout, includeLoader, instrumentId) {
137
143
  const properties = [];
138
144
  if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
139
145
  if (includeLoader && node.loader) return `async () => {
140
146
  const loaderModule = await import(${JSON.stringify(getImportPath(node.loader, projectRoot))});
141
147
  return {
142
- loader: loaderModule.loader,
148
+ loader: ${instrumentId ? `instrumentRouteData("loader", ${JSON.stringify(instrumentId)}, loaderModule.loader)` : "loaderModule.loader"},
143
149
  ...(typeof loaderModule.shouldRevalidate === "function"
144
- ? { shouldRevalidate: loaderModule.shouldRevalidate }
150
+ ? { shouldRevalidate: ${instrumentId ? `instrumentRevalidate(${JSON.stringify(instrumentId)}, loaderModule.shouldRevalidate)` : "loaderModule.shouldRevalidate"} }
145
151
  : {}),
146
152
  ${properties.join(",\n ")}
147
153
  };
@@ -154,9 +160,143 @@ function generateLazyComponent(file, projectRoot) {
154
160
  function generateLazyLayout(file, projectRoot) {
155
161
  return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
156
162
  }
157
- function generateDataHandler(file, projectRoot) {
158
- return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
163
+ function generateDataHandler(file, projectRoot, instrumentId) {
164
+ const handler = `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
165
+ return instrumentId ? `instrumentRouteData("action", ${JSON.stringify(instrumentId)}, ${handler})` : handler;
166
+ }
167
+ function getInstrumentId(node, context) {
168
+ return context.devClient ? getRouteId(node.directory, context.projectRoot) : void 0;
169
+ }
170
+ function generatePreload(files, projectRoot, children = []) {
171
+ const imports = files.filter((file) => Boolean(file)).map((file) => `import(${JSON.stringify(getImportPath(file, projectRoot))})`);
172
+ const preload = {};
173
+ if (imports.length) preload.load = `() => Promise.all([${imports.join(", ")}])`;
174
+ if (children.length) preload.children = genArrayFromRaw(children);
175
+ return genObjectFromRaw(preload);
176
+ }
177
+ /**
178
+ * Dev-only client for the DevTools Routes panel. It reports the tab's current
179
+ * URL and every loader, action, and `shouldRevalidate` call over Vite's HMR
180
+ * channel. Handler results pass through unchanged; only a short text preview
181
+ * of returned data is sent. Production builds never include this code.
182
+ */
183
+ var DEVTOOLS_CLIENT = `const devtoolsHot =
184
+ typeof window === "undefined" ? undefined : import.meta.hot;
185
+ const devtoolsTabId = Math.random().toString(36).slice(2, 10);
186
+
187
+ function sendToDevtools(event, payload) {
188
+ try {
189
+ devtoolsHot?.send(event, { ...payload, tabId: devtoolsTabId });
190
+ } catch {}
191
+ }
192
+
193
+ function previewRouteData(value) {
194
+ try {
195
+ const text = JSON.stringify(value, (_key, item) =>
196
+ typeof item === "bigint" ? String(item) : item,
197
+ );
198
+ if (text === undefined) return String(value);
199
+ return text.length > 600 ? text.slice(0, 600) + "…" : text;
200
+ } catch {
201
+ return String(value);
202
+ }
203
+ }
204
+
205
+ function describeRouteResult(value, thrown) {
206
+ if (value instanceof Response) {
207
+ const location = value.headers.get("Location");
208
+ if (location && value.status >= 300 && value.status < 400) {
209
+ return { outcome: "redirect", status: value.status, location };
210
+ }
211
+ return { outcome: thrown ? "error" : "response", status: value.status };
212
+ }
213
+ if (thrown) {
214
+ return {
215
+ outcome: "error",
216
+ message: value instanceof Error ? value.message : previewRouteData(value),
217
+ };
218
+ }
219
+ return { outcome: "data", preview: previewRouteData(value) };
220
+ }
221
+
222
+ function instrumentRouteData(type, routeId, handler) {
223
+ if (!devtoolsHot || typeof handler !== "function") return handler;
224
+ return async (args) => {
225
+ const startedAt = Date.now();
226
+ const started = performance.now();
227
+ const request = args?.request;
228
+ const report = (result, thrown) =>
229
+ sendToDevtools("vite-file-router:data", {
230
+ type,
231
+ routeId,
232
+ url: request?.url,
233
+ method: request?.method,
234
+ startedAt,
235
+ duration: performance.now() - started,
236
+ ...describeRouteResult(result, thrown),
237
+ });
238
+ try {
239
+ const result = await handler(args);
240
+ report(result, false);
241
+ return result;
242
+ } catch (error) {
243
+ report(error, true);
244
+ throw error;
245
+ }
246
+ };
159
247
  }
248
+
249
+ function instrumentRevalidate(routeId, shouldRevalidate) {
250
+ if (!devtoolsHot) return shouldRevalidate;
251
+ return (args) => {
252
+ const decision = shouldRevalidate(args);
253
+ sendToDevtools("vite-file-router:data", {
254
+ type: "revalidate",
255
+ routeId,
256
+ url: args?.nextUrl?.href,
257
+ startedAt: Date.now(),
258
+ decision,
259
+ defaultShouldRevalidate: args?.defaultShouldRevalidate,
260
+ });
261
+ return decision;
262
+ };
263
+ }
264
+
265
+ if (devtoolsHot) {
266
+ let reportedUrl;
267
+ const currentUrl = () =>
268
+ window.location.hash.startsWith("#/")
269
+ ? window.location.hash.slice(1)
270
+ : window.location.pathname + window.location.search;
271
+ const reportLocation = () => {
272
+ const url = currentUrl();
273
+ if (url === reportedUrl) return;
274
+ reportedUrl = url;
275
+ sendToDevtools("vite-file-router:location", { url, title: document.title });
276
+ };
277
+ // React Router's browser router navigates with pushState and replaceState,
278
+ // which fire no event, so they're wrapped once per page.
279
+ if (!window.__viteFileRouterHistoryPatched) {
280
+ window.__viteFileRouterHistoryPatched = true;
281
+ for (const method of ["pushState", "replaceState"]) {
282
+ const original = history[method];
283
+ history[method] = function (...args) {
284
+ const result = original.apply(this, args);
285
+ window.dispatchEvent(new Event("vite-file-router:navigate"));
286
+ return result;
287
+ };
288
+ }
289
+ }
290
+ for (const event of ["vite-file-router:navigate", "popstate", "hashchange"]) {
291
+ window.addEventListener(event, reportLocation);
292
+ }
293
+ // Report again after the dev server restarts and the socket reconnects.
294
+ devtoolsHot.on("vite:ws:connect", () => {
295
+ reportedUrl = undefined;
296
+ reportLocation();
297
+ });
298
+ reportLocation();
299
+ }`;
160
300
  function getRouteId(directory, projectRoot) {
161
301
  return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
162
302
  }
@@ -170,7 +310,7 @@ function generateNode(node, root, context, inheritedBoundary) {
170
310
  context.hasErrorBoundaries = true;
171
311
  route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
172
312
  }
173
- if (node.action) route.action = generateDataHandler(node.action, context.projectRoot);
313
+ if (node.action) route.action = generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context));
174
314
  let loadingComponent;
175
315
  if (node.loading) {
176
316
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -183,21 +323,27 @@ function generateNode(node, root, context, inheritedBoundary) {
183
323
  } else if (node.layout) {
184
324
  if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
185
325
  }
186
- if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
326
+ if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader), node.loader ? getInstrumentId(node, context) : void 0);
187
327
  const children = [];
328
+ const preloadChildren = [];
188
329
  if (node.page) {
189
330
  const page = hasBoundary ? {
190
331
  index: "true",
191
332
  Component: generateLazyComponent(node.page, context.projectRoot),
192
- ...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
333
+ ...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
193
334
  } : {
194
335
  index: "true",
195
336
  lazy: generateImport(node.page, context.projectRoot),
196
- ...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
337
+ ...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
197
338
  };
198
339
  children.push(genObjectFromRaw(page));
340
+ preloadChildren.push(generatePreload([node.page], context.projectRoot));
341
+ }
342
+ for (const child of node.children) {
343
+ const generated = generateNode(child, false, context, hasBoundary);
344
+ children.push(generated.route);
345
+ preloadChildren.push(generated.preload);
199
346
  }
200
- for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
201
347
  if (node.notFound) {
202
348
  const notFoundRoute = hasBoundary ? {
203
349
  path: JSON.stringify("*"),
@@ -207,11 +353,15 @@ function generateNode(node, root, context, inheritedBoundary) {
207
353
  lazy: generateImport(node.notFound, context.projectRoot)
208
354
  };
209
355
  children.push(genObjectFromRaw(notFoundRoute));
356
+ preloadChildren.push(generatePreload([node.notFound], context.projectRoot));
210
357
  }
211
358
  if (children.length) route.children = genArrayFromRaw(children);
212
- return genObjectFromRaw(route);
359
+ return {
360
+ route: genObjectFromRaw(route),
361
+ preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
362
+ };
213
363
  }
214
- function generate(node, root = false, projectRoot = process.cwd()) {
364
+ function generate(node, root = false, projectRoot = process.cwd(), options = {}) {
215
365
  validateRouteGroupNotFound(node, [], root);
216
366
  const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
217
367
  if (conflicts.length) throw new RouteConflictError(conflicts);
@@ -221,9 +371,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
221
371
  nextLoadingId: 0,
222
372
  hasBoundaries: false,
223
373
  hasLayouts: false,
224
- hasErrorBoundaries: false
374
+ hasErrorBoundaries: false,
375
+ devClient: Boolean(options.devClient)
225
376
  };
226
- const routes = generateNode(node, root, context, false);
377
+ const { route: routes, preload: preloads } = generateNode(node, root, context, false);
227
378
  const imports = [...context.loadingImports];
228
379
  const helpers = [];
229
380
  if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
@@ -232,8 +383,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
232
383
  if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
233
384
  if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
234
385
  imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
235
- if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
236
386
  }
387
+ const routerImports = ["matchRoutes"];
388
+ if (context.hasBoundaries || context.hasLayouts) routerImports.push("Outlet");
389
+ imports.push(`import { ${routerImports.join(", ")} } from "react-router";`);
237
390
  if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
238
391
  return function RouteBoundary() {
239
392
  return createElement(
@@ -257,10 +410,275 @@ function generate(node, root = false, projectRoot = process.cwd()) {
257
410
  );
258
411
  };
259
412
  }`);
413
+ if (context.devClient) helpers.push(DEVTOOLS_CLIENT);
260
414
  return {
261
415
  imports,
262
416
  helpers,
263
- routes
417
+ routes,
418
+ preloads
419
+ };
420
+ }
421
+ // src/declarations.ts
422
+ var PACKAGE_NAME = "@troyio/vite-file-router";
423
+ function collectLoaders(node, loaders = []) {
424
+ if (node.loader) loaders.push(node);
425
+ for (const child of node.children) collectLoaders(child, loaders);
426
+ return loaders;
427
+ }
428
+ function getTypeImportPath(file, declarationFile) {
429
+ const relativePath = path.relative(path.dirname(declarationFile), file).split(path.sep).join("/").replace(/\.ts$/, "");
430
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
431
+ }
432
+ /**
433
+ * Builds a declaration file that registers the discovered page patterns and
434
+ * loader modules with the package's `Register` interface. The package's
435
+ * public types read from that interface, so `routePath()` and the typed hooks
436
+ * only accept routes that exist.
437
+ */
438
+ function createRouteDeclarations(tree, projectRoot, declarationFile) {
439
+ const routes = /* @__PURE__ */ new Map();
440
+ for (const { pattern, node } of collectPageRoutes(tree)) {
441
+ if (routes.has(pattern)) continue;
442
+ routes.set(pattern, node.loader ? getRouteId(node.directory, projectRoot) : null);
443
+ }
444
+ const routeLines = [...routes.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([pattern, loader]) => ` ${JSON.stringify(pattern)}: { loader: ${loader === null ? "null" : JSON.stringify(loader)} };`);
445
+ const loaderLines = collectLoaders(tree).map((node) => {
446
+ const id = getRouteId(node.directory, projectRoot);
447
+ const file = node.loader;
448
+ const moduleType = file.endsWith(".ts") ? `typeof import(${JSON.stringify(getTypeImportPath(file, declarationFile))})` : "{ loader: (...args: any[]) => any }";
449
+ return [id, ` ${JSON.stringify(id)}: ${moduleType};`];
450
+ }).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, line]) => line);
451
+ return [
452
+ `// Generated by ${PACKAGE_NAME}. Do not edit this file.`,
453
+ "// It is rewritten whenever route files are added, removed, or renamed.",
454
+ "",
455
+ "export {};",
456
+ "",
457
+ `declare module ${JSON.stringify(PACKAGE_NAME)} {`,
458
+ " interface Register {",
459
+ routeLines.length ? [
460
+ " routes: {",
461
+ ...routeLines,
462
+ " };"
463
+ ].join("\n") : " routes: {};",
464
+ loaderLines.length ? [
465
+ " loaders: {",
466
+ ...loaderLines,
467
+ " };"
468
+ ].join("\n") : " loaders: {};",
469
+ " }",
470
+ "}",
471
+ ""
472
+ ].join("\n");
473
+ }
474
+ // src/devtools.ts
475
+ var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
476
+ var HEARTBEAT_INTERVAL = 25e3;
477
+ /**
478
+ * Mirrors the generated React Router tree: each manifest node becomes a
479
+ * route with an index child for its page and a `*` child for its not-found
480
+ * page, so matching agrees with the runtime routes.
481
+ */
482
+ function toMatchableRoute(node) {
483
+ const children = [];
484
+ if (node.files.page) children.push({
485
+ index: true,
486
+ node,
487
+ kind: "page"
488
+ });
489
+ for (const child of node.children) children.push(toMatchableRoute(child));
490
+ if (node.files.notFound) children.push({
491
+ path: "*",
492
+ node,
493
+ kind: "notFound"
494
+ });
495
+ return {
496
+ ...node.segment === null ? {} : { path: node.segment },
497
+ node,
498
+ ...children.length ? { children } : {}
499
+ };
500
+ }
501
+ async function matchRouteManifest(manifest, url) {
502
+ const { matchRoutes } = await import("react-router");
503
+ const pathname = new URL(url, "http://vite-file-router").pathname;
504
+ const matches = matchRoutes([toMatchableRoute(manifest)], pathname);
505
+ if (!matches) return {
506
+ matched: false,
507
+ pathname
508
+ };
509
+ const leaf = matches[matches.length - 1].route;
510
+ const chain = matches.filter(({ route }) => !route.kind).map(({ route }) => route.node);
511
+ const nearest = (kind) => [...chain].reverse().find((node) => node.files[kind])?.files[kind];
512
+ const collect = (kind) => chain.flatMap((node) => node.files[kind] ? [node.files[kind]] : []);
513
+ return {
514
+ matched: true,
515
+ pathname,
516
+ params: matches[matches.length - 1].params,
517
+ chain: chain.map(({ fullPath, segment, routeGroup, files }) => ({
518
+ fullPath,
519
+ segment,
520
+ routeGroup,
521
+ files
522
+ })),
523
+ ...leaf.kind === "page" ? { page: leaf.node?.files.page } : {},
524
+ ...leaf.kind === "notFound" ? { notFound: leaf.node?.files.notFound } : {},
525
+ layouts: collect("layout"),
526
+ loaders: collect("loader"),
527
+ ...nearest("loading") ? { loading: nearest("loading") } : {},
528
+ ...nearest("error") ? { error: nearest("error") } : {}
529
+ };
530
+ }
531
+ function writeEvent(response, event, data) {
532
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
533
+ }
534
+ /** Pushes route state to open DevTools panels over Server-Sent Events. */
535
+ function createRouteEventStream(getState, getSnapshot = () => []) {
536
+ const clients = /* @__PURE__ */ new Set();
537
+ let pending;
538
+ function send(response) {
539
+ writeEvent(response, "routes", getState());
540
+ }
541
+ return {
542
+ clients,
543
+ connect(request, response) {
544
+ response.statusCode = 200;
545
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
546
+ response.setHeader("Cache-Control", "no-store");
547
+ response.setHeader("Connection", "keep-alive");
548
+ response.flushHeaders?.();
549
+ response.write("retry: 1000\n\n");
550
+ send(response);
551
+ for (const [event, data] of getSnapshot()) writeEvent(response, event, data);
552
+ clients.add(response);
553
+ const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), HEARTBEAT_INTERVAL);
554
+ heartbeat.unref?.();
555
+ request.on("close", () => {
556
+ clearInterval(heartbeat);
557
+ clients.delete(response);
558
+ });
559
+ },
560
+ /** Coalesces bursts of file events (such as a folder rename) into one push. */
561
+ notify() {
562
+ if (!clients.size) return;
563
+ clearTimeout(pending);
564
+ pending = setTimeout(() => {
565
+ pending = void 0;
566
+ for (const client of clients) send(client);
567
+ }, 50);
568
+ },
569
+ /** Sends a named event to every connected panel right away. */
570
+ broadcast(event, data) {
571
+ for (const client of clients) writeEvent(client, event, data);
572
+ },
573
+ close() {
574
+ clearTimeout(pending);
575
+ for (const client of clients) client.end();
576
+ clients.clear();
577
+ }
578
+ };
579
+ }
580
+ // src/devtools-app.ts
581
+ /** HMR events the dev-only client in `virtual:routes` sends to the server. */
582
+ var APP_LOCATION_EVENT = "vite-file-router:location";
583
+ var APP_DATA_EVENT = "vite-file-router:data";
584
+ var MAX_ACTIVITY = 200;
585
+ var MAX_TEXT = 600;
586
+ function text(value, limit = MAX_TEXT) {
587
+ if (typeof value !== "string") return void 0;
588
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
589
+ }
590
+ function finite(value) {
591
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
592
+ }
593
+ /**
594
+ * Validates an event from the browser. Anything a page sends over the HMR
595
+ * channel is untrusted, so only known fields of the expected types are kept.
596
+ */
597
+ function sanitizeDataEvent(input, id) {
598
+ if (!input || typeof input !== "object") return void 0;
599
+ const event = input;
600
+ const type = event.type;
601
+ if (type !== "loader" && type !== "action" && type !== "revalidate") return;
602
+ const routeId = text(event.routeId, 400);
603
+ const tabId = text(event.tabId, 100);
604
+ if (!routeId || !tabId) return void 0;
605
+ const outcome = event.outcome;
606
+ return {
607
+ id,
608
+ tabId,
609
+ type,
610
+ routeId,
611
+ url: text(event.url, 2e3),
612
+ method: text(event.method, 20),
613
+ startedAt: finite(event.startedAt) ?? Date.now(),
614
+ duration: finite(event.duration),
615
+ ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" ? { outcome } : {},
616
+ status: finite(event.status),
617
+ location: text(event.location, 2e3),
618
+ message: text(event.message),
619
+ preview: text(event.preview),
620
+ ...typeof event.decision === "boolean" ? { decision: event.decision } : {},
621
+ ...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {}
622
+ };
623
+ }
624
+ /**
625
+ * Tracks the app tabs connected to the dev server and their recent loader
626
+ * and action calls, and tells the panel when either changes.
627
+ */
628
+ function createAppTracker(options) {
629
+ const tabs = /* @__PURE__ */ new Map();
630
+ const latestReport = /* @__PURE__ */ new Map();
631
+ const activity = [];
632
+ let nextEventId = 1;
633
+ let nextReport = 1;
634
+ const list = () => [...tabs.values()].sort((a, b) => a.connectedAt - b.connectedAt);
635
+ const changed = () => options.onTabsChange(list());
636
+ return {
637
+ tabs: list,
638
+ activity: () => [...activity],
639
+ async updateLocation(client, input) {
640
+ if (!input || typeof input !== "object") return;
641
+ const payload = input;
642
+ const id = text(payload.tabId, 100);
643
+ const url = text(payload.url, 2e3);
644
+ if (!id || !url?.startsWith("/")) return;
645
+ const report = nextReport++;
646
+ latestReport.set(client, report);
647
+ const match = await matchRouteManifest(options.getManifest(), url);
648
+ if (latestReport.get(client) !== report) return;
649
+ const now = Date.now();
650
+ tabs.set(client, {
651
+ id,
652
+ url,
653
+ title: text(payload.title, 200) ?? "",
654
+ connectedAt: tabs.get(client)?.connectedAt ?? now,
655
+ updatedAt: now,
656
+ match
657
+ });
658
+ changed();
659
+ },
660
+ disconnect(client) {
661
+ latestReport.delete(client);
662
+ if (tabs.delete(client)) changed();
663
+ },
664
+ /** Re-matches every tab, for example after route files change. */
665
+ async rematch() {
666
+ if (!tabs.size) return;
667
+ const manifest = options.getManifest();
668
+ for (const [client, tab] of tabs) tabs.set(client, {
669
+ ...tab,
670
+ match: await matchRouteManifest(manifest, tab.url)
671
+ });
672
+ changed();
673
+ },
674
+ record(input) {
675
+ const event = sanitizeDataEvent(input, nextEventId);
676
+ if (!event) return;
677
+ nextEventId++;
678
+ activity.push(event);
679
+ if (activity.length > MAX_ACTIVITY) activity.shift();
680
+ options.onActivity(event);
681
+ }
264
682
  };
265
683
  }
266
684
  // src/index.ts
@@ -270,12 +688,17 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
270
688
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
271
689
  var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
272
690
  var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
273
- var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
274
- var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
691
+ var DEFAULT_DTS = "src/route-types.d.ts";
692
+ var TYPESCRIPT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
693
+ function resolveDeclarationFile(dts, projectRoot) {
694
+ if (dts === false) return void 0;
695
+ if (dts !== "auto") return path.resolve(projectRoot, dts);
696
+ return TYPESCRIPT_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectRoot, file))) ? path.resolve(projectRoot, DEFAULT_DTS) : void 0;
697
+ }
275
698
  function resolveOptions(input) {
276
699
  const options = typeof input === "string" ? { directory: input } : input;
277
700
  if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
278
- for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions") throw new TypeError(`Unknown routes() option "${key}".`);
701
+ for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts") throw new TypeError(`Unknown routes() option "${key}".`);
279
702
  const directory = options.directory ?? "src/pages";
280
703
  if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
281
704
  const conventions = { ...DEFAULT_CONVENTIONS };
@@ -287,9 +710,12 @@ function resolveOptions(input) {
287
710
  }
288
711
  const names = Object.values(conventions);
289
712
  if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
713
+ const dts = options.dts ?? "auto";
714
+ if (dts !== false && dts !== "auto" && (typeof dts !== "string" || dts.trim() === "" || !dts.endsWith(".d.ts"))) throw new TypeError("routes() dts must be \"auto\", false, or a path ending in \".d.ts\".");
290
715
  return {
291
716
  directory,
292
- conventions
717
+ conventions,
718
+ dts
293
719
  };
294
720
  }
295
721
  function routes(input = "src/pages") {
@@ -297,6 +723,32 @@ function routes(input = "src/pages") {
297
723
  let projectRoot = "";
298
724
  let root = "";
299
725
  let dev = false;
726
+ let declarationFile;
727
+ function getInspectorState() {
728
+ const tree = scan(root, options.conventions);
729
+ return {
730
+ projectRoot,
731
+ directory: path.relative(projectRoot, root).split(path.sep).join("/"),
732
+ manifest: createRouteManifest(tree, projectRoot),
733
+ conflicts: findPageRouteConflicts(tree).map(({ routePath, files }) => ({
734
+ routePath,
735
+ files: files.map((file) => path.relative(projectRoot, file).split(path.sep).join("/"))
736
+ }))
737
+ };
738
+ }
739
+ const appTracker = createAppTracker({
740
+ getManifest: () => createRouteManifest(scan(root, options.conventions), projectRoot),
741
+ onTabsChange: (tabs) => routeEvents.broadcast("apps", tabs),
742
+ onActivity: (event) => routeEvents.broadcast("activity", event)
743
+ });
744
+ const routeEvents = createRouteEventStream(getInspectorState, () => [["apps", appTracker.tabs()], ["activity-snapshot", appTracker.activity()]]);
745
+ function writeDeclarations() {
746
+ if (!declarationFile) return;
747
+ const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
748
+ if (fs.existsSync(declarationFile) && fs.readFileSync(declarationFile, "utf8") === content) return;
749
+ fs.mkdirSync(path.dirname(declarationFile), { recursive: true });
750
+ fs.writeFileSync(declarationFile, content);
751
+ }
300
752
  function sortImports(imports) {
301
753
  return imports.sort((left, right) => {
302
754
  const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
@@ -328,14 +780,41 @@ function routes(input = "src/pages") {
328
780
  }
329
781
 
330
782
  export const routes = [{ path: "*", Component: RouteConflictPage }];
783
+
784
+ export async function preloadRoute() {
785
+ return false;
786
+ }
331
787
  `;
332
788
  return format(code, { parser: "typescript" });
333
789
  }
334
- const generated = generate(tree, true, projectRoot);
790
+ const generated = generate(tree, true, projectRoot, { devClient: dev });
335
791
  const sections = [
336
792
  sortImports(generated.imports).join("\n"),
337
793
  generated.helpers.join("\n"),
338
- `export const routes = ${genArrayFromRaw([generated.routes])};`
794
+ `export const routes = ${genArrayFromRaw([generated.routes])};`,
795
+ `const preloadTree = ${genArrayFromRaw([generated.preloads])};
796
+ const routePreloads = new WeakMap();
797
+
798
+ function registerPreloads(routeList, preloadList) {
799
+ routeList.forEach((route, index) => {
800
+ const preload = preloadList[index];
801
+ if (preload?.load) routePreloads.set(route, preload.load);
802
+ if (route.children) {
803
+ registerPreloads(route.children, preload?.children ?? []);
804
+ }
805
+ });
806
+ }
807
+
808
+ registerPreloads(routes, preloadTree);
809
+
810
+ export async function preloadRoute(to, basename) {
811
+ const matches = matchRoutes(routes, to, basename);
812
+ if (!matches) return false;
813
+ await Promise.all(
814
+ matches.map(({ route }) => routePreloads.get(route)?.()),
815
+ );
816
+ return true;
817
+ }`
339
818
  ].filter(Boolean);
340
819
  return format(sections.join("\n\n"), { parser: "typescript" });
341
820
  }
@@ -346,8 +825,35 @@ function routes(input = "src/pages") {
346
825
  async function executeRouteHelpers() {
347
826
  const tree = scan(root, options.conventions);
348
827
  const patterns = [...new Set(collectPageRoutePatterns(tree))].sort();
828
+ const routeLoaderIds = {};
829
+ for (const { pattern, node } of collectPageRoutes(tree)) if (node.loader && !Object.hasOwn(routeLoaderIds, pattern)) routeLoaderIds[pattern] = getRouteId(node.directory, projectRoot);
830
+ const collectLoaderIds = (node) => {
831
+ if (node.loader) {
832
+ const id = getRouteId(node.directory, projectRoot);
833
+ routeLoaderIds[id] = id;
834
+ }
835
+ node.children.forEach(collectLoaderIds);
836
+ };
837
+ collectLoaderIds(tree);
349
838
  const code = `
839
+ import { useParams, useRouteLoaderData } from "react-router";
840
+
350
841
  export const routePatterns = ${JSON.stringify(patterns)};
842
+ const routeLoaderIds = ${JSON.stringify(routeLoaderIds)};
843
+
844
+ export function useRouteParams(pattern) {
845
+ if (!routePatterns.includes(pattern)) {
846
+ throw new TypeError(\`Unknown route pattern "\${pattern}".\`);
847
+ }
848
+ return useParams();
849
+ }
850
+
851
+ export function useRouteData(key) {
852
+ if (!Object.hasOwn(routeLoaderIds, key)) {
853
+ throw new TypeError(\`Route "\${key}" has no loader.\`);
854
+ }
855
+ return useRouteLoaderData(routeLoaderIds[key]);
856
+ }
351
857
 
352
858
  export function routePath(pattern, ...args) {
353
859
  if (!routePatterns.includes(pattern)) {
@@ -415,6 +921,10 @@ function routes(input = "src/pages") {
415
921
  projectRoot = config.root;
416
922
  root = path.resolve(projectRoot, options.directory);
417
923
  dev = config.command === "serve";
924
+ declarationFile = resolveDeclarationFile(options.dts, projectRoot);
925
+ },
926
+ buildStart() {
927
+ writeDeclarations();
418
928
  },
419
929
  resolveId: (id) => {
420
930
  if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
@@ -436,8 +946,19 @@ function routes(input = "src/pages") {
436
946
  type: "iframe",
437
947
  url: ROUTE_INSPECTOR_PATH
438
948
  });
949
+ const sendJson = (res, body) => {
950
+ res.statusCode = 200;
951
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
952
+ res.setHeader("Cache-Control", "no-store");
953
+ res.end(JSON.stringify(body));
954
+ };
439
955
  ctx.viteServer.middlewares.use((req, res, next) => {
440
- if (new URL(req.url ?? "/", "http://vite-file-router").pathname !== ROUTE_INSPECTOR_DATA_PATH) {
956
+ const url = new URL(req.url ?? "/", "http://vite-file-router");
957
+ if (![
958
+ "/__vite-file-router-data",
959
+ "/__vite-file-router-events",
960
+ "/__vite-file-router-match"
961
+ ].includes(url.pathname)) {
441
962
  next();
442
963
  return;
443
964
  }
@@ -447,11 +968,21 @@ function routes(input = "src/pages") {
447
968
  res.end("Method Not Allowed");
448
969
  return;
449
970
  }
450
- const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
451
- res.statusCode = 200;
452
- res.setHeader("Content-Type", "application/json; charset=utf-8");
453
- res.setHeader("Cache-Control", "no-store");
454
- res.end(JSON.stringify(manifest));
971
+ if (url.pathname === "/__vite-file-router-data") {
972
+ sendJson(res, getInspectorState());
973
+ return;
974
+ }
975
+ if (url.pathname === "/__vite-file-router-events") {
976
+ routeEvents.connect(req, res);
977
+ return;
978
+ }
979
+ const target = url.searchParams.get("url");
980
+ if (!target) {
981
+ res.statusCode = 400;
982
+ res.end("Missing \"url\" query parameter.");
983
+ return;
984
+ }
985
+ matchRouteManifest(createRouteManifest(scan(root, options.conventions), projectRoot), target).then((result) => sendJson(res, result), next);
455
986
  });
456
987
  } },
457
988
  configureServer: (server) => {
@@ -475,8 +1006,17 @@ function routes(input = "src/pages") {
475
1006
  const module = server.moduleGraph.getModuleById(id);
476
1007
  if (module) server.moduleGraph.invalidateModule(module);
477
1008
  }
1009
+ writeDeclarations();
1010
+ routeEvents.notify();
1011
+ appTracker.rematch();
478
1012
  server.ws.send({ type: "full-reload" });
479
1013
  });
1014
+ server.httpServer?.on("close", routeEvents.close);
1015
+ server.ws.on(APP_LOCATION_EVENT, (data, client) => appTracker.updateLocation(client, data).catch(() => {}));
1016
+ server.ws.on(APP_DATA_EVENT, (data) => appTracker.record(data));
1017
+ server.ws.on("vite:client:disconnect", (_data, client) => {
1018
+ appTracker.disconnect(client);
1019
+ });
480
1020
  }
481
1021
  };
482
1022
  }