@troyio/vite-file-router 0.4.0 → 0.5.0

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
@@ -11,7 +11,8 @@ var DEFAULT_CONVENTIONS = {
11
11
  notFound: "not-found",
12
12
  error: "error",
13
13
  loader: "loader",
14
- action: "action"
14
+ action: "action",
15
+ middleware: "middleware"
15
16
  };
16
17
  function getRouteSegment(name) {
17
18
  if (name.startsWith("[...") && name.endsWith("]")) return "*";
@@ -68,7 +69,7 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
68
69
  const [, basename, extension] = match;
69
70
  const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
70
71
  if (!kind) continue;
71
- const isDataModule = kind === "loader" || kind === "action";
72
+ const isDataModule = kind === "loader" || kind === "action" || kind === "middleware";
72
73
  if (!(isDataModule ? ["js", "ts"] : ["jsx", "tsx"]).includes(extension)) continue;
73
74
  const preferredExtension = isDataModule ? "ts" : "tsx";
74
75
  if (!node[kind] || extension === preferredExtension) node[kind] = file;
@@ -139,15 +140,15 @@ function generateImport(file, projectRoot) {
139
140
  Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
140
141
  })`;
141
142
  }
142
- function generateRouteLazy(node, projectRoot, includeLayout, includeLoader) {
143
+ function generateRouteLazy(node, projectRoot, includeLayout, includeLoader, instrumentId) {
143
144
  const properties = [];
144
145
  if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
145
146
  if (includeLoader && node.loader) return `async () => {
146
147
  const loaderModule = await import(${JSON.stringify(getImportPath(node.loader, projectRoot))});
147
148
  return {
148
- loader: loaderModule.loader,
149
+ loader: ${instrumentId ? `instrumentRouteData("loader", ${JSON.stringify(instrumentId)}, loaderModule.loader)` : "loaderModule.loader"},
149
150
  ...(typeof loaderModule.shouldRevalidate === "function"
150
- ? { shouldRevalidate: loaderModule.shouldRevalidate }
151
+ ? { shouldRevalidate: ${instrumentId ? `instrumentRevalidate(${JSON.stringify(instrumentId)}, loaderModule.shouldRevalidate)` : "loaderModule.shouldRevalidate"} }
151
152
  : {}),
152
153
  ${properties.join(",\n ")}
153
154
  };
@@ -160,8 +161,12 @@ function generateLazyComponent(file, projectRoot) {
160
161
  function generateLazyLayout(file, projectRoot) {
161
162
  return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
162
163
  }
163
- function generateDataHandler(file, projectRoot) {
164
- return `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
164
+ function generateDataHandler(file, projectRoot, instrumentId) {
165
+ const handler = `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
166
+ return instrumentId ? `instrumentRouteData("action", ${JSON.stringify(instrumentId)}, ${handler})` : handler;
167
+ }
168
+ function getInstrumentId(node, context) {
169
+ return context.devClient ? getRouteId(node.directory, context.projectRoot) : void 0;
165
170
  }
166
171
  function generatePreload(files, projectRoot, children = []) {
167
172
  const imports = files.filter((file) => Boolean(file)).map((file) => `import(${JSON.stringify(getImportPath(file, projectRoot))})`);
@@ -170,6 +175,337 @@ function generatePreload(files, projectRoot, children = []) {
170
175
  if (children.length) preload.children = genArrayFromRaw(children);
171
176
  return genObjectFromRaw(preload);
172
177
  }
178
+ /**
179
+ * Dev-only client for the DevTools Routes panel. It reports the tab's current
180
+ * URL and every loader, action, and `shouldRevalidate` call over Vite's HMR
181
+ * channel. Handler results pass through unchanged; the panel gets a capped
182
+ * copy of params, submitted form fields, returned data, response details, and
183
+ * errors. Production builds never include this code.
184
+ */
185
+ var DEVTOOLS_CLIENT = `const devtoolsHot =
186
+ typeof window === "undefined" ? undefined : import.meta.hot;
187
+ const devtoolsTabId = Math.random().toString(36).slice(2, 10);
188
+
189
+ function sendToDevtools(event, payload) {
190
+ try {
191
+ devtoolsHot?.send(event, { ...payload, tabId: devtoolsTabId });
192
+ } catch {}
193
+ }
194
+
195
+ const DEVTOOLS_DATA_LIMIT = 8000;
196
+ const DEVTOOLS_TEXT_LIMIT = 4000;
197
+
198
+ function clipText(text, limit) {
199
+ return text.length > limit ? text.slice(0, limit) + "…" : text;
200
+ }
201
+
202
+ // Serializes loader and action results for the panel. Circular references,
203
+ // Maps, Sets, bigints, and functions don't break serialization.
204
+ function serializeRouteData(value, limit = DEVTOOLS_DATA_LIMIT) {
205
+ const stack = [];
206
+ let text;
207
+ try {
208
+ text = JSON.stringify(value, function (_key, item) {
209
+ if (stack.length > 0) {
210
+ const position = stack.indexOf(this);
211
+ if (position === -1) stack.push(this);
212
+ else stack.splice(position + 1);
213
+ if (item && typeof item === "object" && stack.includes(item)) {
214
+ return "[Circular]";
215
+ }
216
+ } else {
217
+ stack.push(item);
218
+ }
219
+ if (typeof item === "bigint") return item.toString() + "n";
220
+ if (typeof item === "function") {
221
+ return "[Function " + (item.name || "anonymous") + "]";
222
+ }
223
+ if (typeof item === "symbol") return item.toString();
224
+ if (item instanceof Map) {
225
+ return Object.fromEntries(
226
+ [...item].map(([key, entry]) => [String(key), entry]),
227
+ );
228
+ }
229
+ if (item instanceof Set) return [...item];
230
+ return item;
231
+ });
232
+ } catch {}
233
+ if (text === undefined) text = String(value);
234
+ return text.length > limit
235
+ ? { data: text.slice(0, limit), dataTruncated: true }
236
+ : { data: text };
237
+ }
238
+
239
+ function previewRouteData(value) {
240
+ return clipText(serializeRouteData(value, 601).data, 600);
241
+ }
242
+
243
+ function routeParams(params) {
244
+ if (!params || typeof params !== "object") return undefined;
245
+ const entries = Object.entries(params).filter(
246
+ ([, value]) => typeof value === "string",
247
+ );
248
+ return entries.length ? Object.fromEntries(entries) : undefined;
249
+ }
250
+
251
+ async function describeRequestBody(request) {
252
+ const type = request.headers.get("Content-Type") || "";
253
+ try {
254
+ if (
255
+ type.includes("multipart/form-data") ||
256
+ type.includes("application/x-www-form-urlencoded")
257
+ ) {
258
+ const form = await request.formData();
259
+ return {
260
+ kind: "form",
261
+ entries: [...form].slice(0, 50).map(([name, value]) => [
262
+ name,
263
+ typeof value === "string"
264
+ ? clipText(value, 500)
265
+ : "File " + value.name + " (" + value.size + " bytes)",
266
+ ]),
267
+ };
268
+ }
269
+ const text = await request.text();
270
+ return text
271
+ ? { kind: "text", contentType: type, text: clipText(text, DEVTOOLS_TEXT_LIMIT) }
272
+ : undefined;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function headerEntries(headers) {
279
+ try {
280
+ return [...new Headers(headers)].slice(0, 40);
281
+ } catch {
282
+ return undefined;
283
+ }
284
+ }
285
+
286
+ // Clones a Response synchronously, before React Router reads its body.
287
+ function describeResponse(value, thrown) {
288
+ const location = value.headers.get("Location");
289
+ const base = { status: value.status, headers: headerEntries(value.headers) };
290
+ if (location && value.status >= 300 && value.status < 400) {
291
+ return { ...base, outcome: "redirect", location };
292
+ }
293
+ const outcome = thrown ? "error" : "response";
294
+ if (value.bodyUsed || !value.body) return { ...base, outcome };
295
+ const copy = value.clone();
296
+ return copy
297
+ .text()
298
+ .then((text) => ({
299
+ ...base,
300
+ outcome,
301
+ ...(text ? { responseBody: clipText(text, DEVTOOLS_TEXT_LIMIT) } : {}),
302
+ }))
303
+ .catch(() => ({ ...base, outcome }));
304
+ }
305
+
306
+ function describeRouteResult(value, thrown) {
307
+ if (value instanceof Response) return describeResponse(value, thrown);
308
+ if (thrown) {
309
+ if (value instanceof Error) {
310
+ return {
311
+ outcome: "error",
312
+ errorName: value.name,
313
+ message: value.message,
314
+ stack: value.stack ? clipText(value.stack, DEVTOOLS_TEXT_LIMIT) : undefined,
315
+ };
316
+ }
317
+ return {
318
+ outcome: "error",
319
+ message: previewRouteData(value),
320
+ ...serializeRouteData(value),
321
+ };
322
+ }
323
+ // React Router's data() helper wraps data with a status and headers.
324
+ if (value && value.type === "DataWithResponseInit" && "data" in value) {
325
+ return {
326
+ outcome: "data",
327
+ status: value.init?.status,
328
+ headers: value.init?.headers ? headerEntries(value.init.headers) : undefined,
329
+ preview: previewRouteData(value.data),
330
+ ...serializeRouteData(value.data),
331
+ };
332
+ }
333
+ return {
334
+ outcome: "data",
335
+ preview: previewRouteData(value),
336
+ ...serializeRouteData(value),
337
+ };
338
+ }
339
+
340
+ function instrumentRouteData(type, routeId, handler) {
341
+ if (!devtoolsHot || typeof handler !== "function") return handler;
342
+ return async (args) => {
343
+ const startedAt = Date.now();
344
+ const started = performance.now();
345
+ const request = args?.request;
346
+ // Read the submitted body from a copy, so the handler can still read it.
347
+ const body =
348
+ request && request.body && request.method !== "GET" && request.method !== "HEAD"
349
+ ? describeRequestBody(request.clone())
350
+ : undefined;
351
+ const report = (result, thrown) => {
352
+ const duration = performance.now() - started;
353
+ const described = describeRouteResult(result, thrown);
354
+ Promise.all([described, body])
355
+ .then(([details, requestBody]) =>
356
+ sendToDevtools("vite-file-router:data", {
357
+ type,
358
+ routeId,
359
+ url: request?.url,
360
+ method: request?.method,
361
+ params: routeParams(args?.params),
362
+ body: requestBody,
363
+ startedAt,
364
+ duration,
365
+ ...details,
366
+ }),
367
+ )
368
+ .catch(() => {});
369
+ };
370
+ try {
371
+ const result = await handler(args);
372
+ report(result, false);
373
+ return result;
374
+ } catch (error) {
375
+ report(error, true);
376
+ throw error;
377
+ }
378
+ };
379
+ }
380
+
381
+ // Reports the middleware's own time, not counting the handlers next() runs.
382
+ function instrumentMiddleware(routeId, middleware) {
383
+ if (!devtoolsHot) return middleware;
384
+ return async (args, next) => {
385
+ const startedAt = Date.now();
386
+ const started = performance.now();
387
+ let downstream = 0;
388
+ let calledNext = false;
389
+ const timedNext = async () => {
390
+ calledNext = true;
391
+ const nextStarted = performance.now();
392
+ try {
393
+ return await next();
394
+ } finally {
395
+ downstream += performance.now() - nextStarted;
396
+ }
397
+ };
398
+ const report = (details) => {
399
+ const duration = performance.now() - started - downstream;
400
+ Promise.resolve(details)
401
+ .then((resolved) =>
402
+ sendToDevtools("vite-file-router:data", {
403
+ type: "middleware",
404
+ routeId,
405
+ url: args?.request?.url,
406
+ method: args?.request?.method,
407
+ params: routeParams(args?.params),
408
+ startedAt,
409
+ duration,
410
+ ...resolved,
411
+ }),
412
+ )
413
+ .catch(() => {});
414
+ };
415
+ try {
416
+ const result = await middleware(args, timedNext);
417
+ report(
418
+ result instanceof Response && !calledNext
419
+ ? describeRouteResult(result, false)
420
+ : { outcome: "next" },
421
+ );
422
+ return result;
423
+ } catch (error) {
424
+ report(describeRouteResult(error, true));
425
+ throw error;
426
+ }
427
+ };
428
+ }
429
+
430
+ function instrumentRevalidate(routeId, shouldRevalidate) {
431
+ if (!devtoolsHot) return shouldRevalidate;
432
+ return (args) => {
433
+ const decision = shouldRevalidate(args);
434
+ sendToDevtools("vite-file-router:data", {
435
+ type: "revalidate",
436
+ routeId,
437
+ url: args?.nextUrl?.href,
438
+ currentUrl: args?.currentUrl?.href,
439
+ params: routeParams(args?.nextParams),
440
+ formMethod: args?.formMethod,
441
+ actionStatus: args?.actionStatus,
442
+ startedAt: Date.now(),
443
+ decision,
444
+ defaultShouldRevalidate: args?.defaultShouldRevalidate,
445
+ });
446
+ return decision;
447
+ };
448
+ }
449
+
450
+ if (devtoolsHot) {
451
+ let reportedUrl;
452
+ const currentUrl = () =>
453
+ window.location.hash.startsWith("#/")
454
+ ? window.location.hash.slice(1)
455
+ : window.location.pathname + window.location.search;
456
+ const reportLocation = () => {
457
+ const url = currentUrl();
458
+ if (url === reportedUrl) return;
459
+ reportedUrl = url;
460
+ sendToDevtools("vite-file-router:location", { url, title: document.title });
461
+ };
462
+ // React Router's browser router navigates with pushState and replaceState,
463
+ // which fire no event, so they're wrapped once per page.
464
+ if (!window.__viteFileRouterHistoryPatched) {
465
+ window.__viteFileRouterHistoryPatched = true;
466
+ for (const method of ["pushState", "replaceState"]) {
467
+ const original = history[method];
468
+ history[method] = function (...args) {
469
+ const result = original.apply(this, args);
470
+ window.dispatchEvent(new Event("vite-file-router:navigate"));
471
+ return result;
472
+ };
473
+ }
474
+ }
475
+ for (const event of ["vite-file-router:navigate", "popstate", "hashchange"]) {
476
+ window.addEventListener(event, reportLocation);
477
+ }
478
+ // The panel's "Open in app" action. React Router's browser router follows
479
+ // popstate events, so pushing the URL and announcing it navigates without a
480
+ // reload. The state matches React Router's so Back and Forward keep working.
481
+ devtoolsHot.on("vite-file-router:navigate", (payload) => {
482
+ const url = payload?.url;
483
+ if (typeof url !== "string" || !url.startsWith("/") || url.startsWith("//")) {
484
+ return;
485
+ }
486
+ if (window.location.hash.startsWith("#/")) {
487
+ window.location.hash = url;
488
+ return;
489
+ }
490
+ const index = window.history.state?.idx;
491
+ window.history.pushState(
492
+ {
493
+ usr: null,
494
+ key: Math.random().toString(36).slice(2, 10),
495
+ idx: typeof index === "number" ? index + 1 : 0,
496
+ },
497
+ "",
498
+ url,
499
+ );
500
+ window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
501
+ });
502
+ // Report again after the dev server restarts and the socket reconnects.
503
+ devtoolsHot.on("vite:ws:connect", () => {
504
+ reportedUrl = undefined;
505
+ reportLocation();
506
+ });
507
+ reportLocation();
508
+ }`;
173
509
  function getRouteId(directory, projectRoot) {
174
510
  return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
175
511
  }
@@ -183,7 +519,13 @@ function generateNode(node, root, context, inheritedBoundary) {
183
519
  context.hasErrorBoundaries = true;
184
520
  route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
185
521
  }
186
- if (node.action) route.action = generateDataHandler(node.action, context.projectRoot);
522
+ if (node.action) route.action = generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context));
523
+ if (node.middleware) {
524
+ context.hasMiddleware = true;
525
+ const load = `lazyMiddleware(() => import(${JSON.stringify(getImportPath(node.middleware, context.projectRoot))}))`;
526
+ const instrumentId = getInstrumentId(node, context);
527
+ route.middleware = `[${instrumentId ? `instrumentMiddleware(${JSON.stringify(instrumentId)}, ${load})` : load}]`;
528
+ }
187
529
  let loadingComponent;
188
530
  if (node.loading) {
189
531
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -196,18 +538,18 @@ function generateNode(node, root, context, inheritedBoundary) {
196
538
  } else if (node.layout) {
197
539
  if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
198
540
  }
199
- if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
541
+ 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);
200
542
  const children = [];
201
543
  const preloadChildren = [];
202
544
  if (node.page) {
203
545
  const page = hasBoundary ? {
204
546
  index: "true",
205
547
  Component: generateLazyComponent(node.page, context.projectRoot),
206
- ...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
548
+ ...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
207
549
  } : {
208
550
  index: "true",
209
551
  lazy: generateImport(node.page, context.projectRoot),
210
- ...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
552
+ ...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
211
553
  };
212
554
  children.push(genObjectFromRaw(page));
213
555
  preloadChildren.push(generatePreload([node.page], context.projectRoot));
@@ -231,10 +573,14 @@ function generateNode(node, root, context, inheritedBoundary) {
231
573
  if (children.length) route.children = genArrayFromRaw(children);
232
574
  return {
233
575
  route: genObjectFromRaw(route),
234
- preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
576
+ preload: generatePreload([
577
+ node.layout,
578
+ node.loader,
579
+ node.middleware
580
+ ], context.projectRoot, preloadChildren)
235
581
  };
236
582
  }
237
- function generate(node, root = false, projectRoot = process.cwd()) {
583
+ function generate(node, root = false, projectRoot = process.cwd(), options = {}) {
238
584
  validateRouteGroupNotFound(node, [], root);
239
585
  const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
240
586
  if (conflicts.length) throw new RouteConflictError(conflicts);
@@ -244,7 +590,9 @@ function generate(node, root = false, projectRoot = process.cwd()) {
244
590
  nextLoadingId: 0,
245
591
  hasBoundaries: false,
246
592
  hasLayouts: false,
247
- hasErrorBoundaries: false
593
+ hasErrorBoundaries: false,
594
+ hasMiddleware: false,
595
+ devClient: Boolean(options.devClient)
248
596
  };
249
597
  const { route: routes, preload: preloads } = generateNode(node, root, context, false);
250
598
  const imports = [...context.loadingImports];
@@ -282,6 +630,32 @@ function generate(node, root = false, projectRoot = process.cwd()) {
282
630
  );
283
631
  };
284
632
  }`);
633
+ if (context.hasMiddleware) helpers.push(`/**
634
+ * Runs a middleware module's default or \`middleware\` export, which can be a
635
+ * function or an array of functions. Like React Router, it calls \`next()\`
636
+ * after a function that didn't call it.
637
+ */
638
+ function lazyMiddleware(load) {
639
+ let pending;
640
+ return async (args, next) => {
641
+ pending ??= load();
642
+ const module = await pending;
643
+ const exported = module.middleware ?? module.default;
644
+ const list = (Array.isArray(exported) ? exported : [exported]).filter(
645
+ (item) => typeof item === "function",
646
+ );
647
+ const run = async (index) => {
648
+ if (index >= list.length) return next();
649
+ let downstream;
650
+ const callNext = () => (downstream ??= run(index + 1));
651
+ const result = await list[index](args, callNext);
652
+ const nextResult = await callNext();
653
+ return result === undefined ? nextResult : result;
654
+ };
655
+ return run(0);
656
+ };
657
+ }`);
658
+ if (context.devClient) helpers.push(DEVTOOLS_CLIENT);
285
659
  return {
286
660
  imports,
287
661
  helpers,
@@ -342,6 +716,340 @@ function createRouteDeclarations(tree, projectRoot, declarationFile) {
342
716
  ""
343
717
  ].join("\n");
344
718
  }
719
+ // src/devtools.ts
720
+ var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
721
+ var HEARTBEAT_INTERVAL = 25e3;
722
+ var EXPORT_PATTERNS = (name) => [
723
+ new RegExp(`export\\s+(?:async\\s+)?function\\s*\\*?\\s*${name}\\b`),
724
+ new RegExp(`export\\s+(?:const|let|var)\\s+${name}\\b`),
725
+ new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`),
726
+ /export\s*\*\s*from/
727
+ ];
728
+ /** Whether module source code (probably) has a named export. */
729
+ function hasNamedExport(source, name) {
730
+ return EXPORT_PATTERNS(name).some((pattern) => pattern.test(source));
731
+ }
732
+ var MISSING_EXPORT_MESSAGES = {
733
+ loader: `This loader file doesn't export a function named "loader", so the route loads no data.`,
734
+ action: `This action file doesn't export a function named "action", so the route fails when a form is submitted.`,
735
+ middleware: "This middleware file has no default or \"middleware\" export, so no middleware runs for the route."
736
+ };
737
+ var HANDLER_NAMES = {
738
+ loader: "a loader",
739
+ action: "an action",
740
+ middleware: "middleware"
741
+ };
742
+ function listHandlers(kinds) {
743
+ const names = kinds.map((kind) => HANDLER_NAMES[kind]);
744
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
745
+ }
746
+ /**
747
+ * Finds routes that are missing a file they likely need: an error file for a
748
+ * loader, action, or middleware, a root not-found page, or the expected export
749
+ * of a data module. `readSource` reads a project-relative file.
750
+ */
751
+ function findRouteWarnings(manifest, readSource = () => void 0) {
752
+ const warnings = [];
753
+ if (!manifest.files.notFound) warnings.push({
754
+ kind: "no-not-found",
755
+ severity: manifest.files.error ? "info" : "warning",
756
+ key: "0",
757
+ fullPath: "/",
758
+ message: manifest.files.error ? "There's no root not-found page, so unknown URLs render the root error file with a 404 error." : "There's no root not-found page or error file, so unknown URLs show React Router's default error screen.",
759
+ files: manifest.files.error ? [manifest.files.error] : []
760
+ });
761
+ const visit = (node, key, hasError) => {
762
+ const covered = hasError || Boolean(node.files.error);
763
+ const handlers = [
764
+ "loader",
765
+ "action",
766
+ "middleware"
767
+ ].filter((kind) => node.files[kind]);
768
+ if (handlers.length && !covered) warnings.push({
769
+ kind: "no-error-boundary",
770
+ severity: "warning",
771
+ key,
772
+ fullPath: node.fullPath,
773
+ message: `This route has ${listHandlers(handlers)} but no error file at or above it, so errors it throws show React Router's default error screen.`,
774
+ files: handlers.map((kind) => node.files[kind])
775
+ });
776
+ for (const kind of handlers) {
777
+ const file = node.files[kind];
778
+ const source = readSource(file);
779
+ if (source === void 0) continue;
780
+ if (!(hasNamedExport(source, kind) || kind === "middleware" && /export\s+default\b/.test(source))) warnings.push({
781
+ kind: "missing-export",
782
+ severity: "warning",
783
+ key,
784
+ fullPath: node.fullPath,
785
+ message: MISSING_EXPORT_MESSAGES[kind],
786
+ files: [file]
787
+ });
788
+ }
789
+ node.children.forEach((child, index) => {
790
+ visit(child, `${key}.${index}`, covered);
791
+ });
792
+ };
793
+ visit(manifest, "0", false);
794
+ return warnings;
795
+ }
796
+ /**
797
+ * Mirrors the generated React Router tree: each manifest node becomes a
798
+ * route with an index child for its page and a `*` child for its not-found
799
+ * page, so matching agrees with the runtime routes.
800
+ */
801
+ function toMatchableRoute(node) {
802
+ const children = [];
803
+ if (node.files.page) children.push({
804
+ index: true,
805
+ node,
806
+ kind: "page"
807
+ });
808
+ for (const child of node.children) children.push(toMatchableRoute(child));
809
+ if (node.files.notFound) children.push({
810
+ path: "*",
811
+ node,
812
+ kind: "notFound"
813
+ });
814
+ return {
815
+ ...node.segment === null ? {} : { path: node.segment },
816
+ node,
817
+ ...children.length ? { children } : {}
818
+ };
819
+ }
820
+ async function matchRouteManifest(manifest, url) {
821
+ const { matchRoutes } = await import("react-router");
822
+ const pathname = new URL(url, "http://vite-file-router").pathname;
823
+ const matches = matchRoutes([toMatchableRoute(manifest)], pathname);
824
+ if (!matches) return {
825
+ matched: false,
826
+ pathname
827
+ };
828
+ const leaf = matches[matches.length - 1].route;
829
+ const chain = matches.filter(({ route }) => !route.kind).map(({ route }) => route.node);
830
+ const nearest = (kind) => [...chain].reverse().find((node) => node.files[kind])?.files[kind];
831
+ const collect = (kind) => chain.flatMap((node) => node.files[kind] ? [node.files[kind]] : []);
832
+ return {
833
+ matched: true,
834
+ pathname,
835
+ params: matches[matches.length - 1].params,
836
+ chain: chain.map(({ fullPath, segment, routeGroup, files }) => ({
837
+ fullPath,
838
+ segment,
839
+ routeGroup,
840
+ files
841
+ })),
842
+ ...leaf.kind === "page" ? { page: leaf.node?.files.page } : {},
843
+ ...leaf.kind === "notFound" ? { notFound: leaf.node?.files.notFound } : {},
844
+ layouts: collect("layout"),
845
+ loaders: collect("loader"),
846
+ middleware: collect("middleware"),
847
+ ...nearest("loading") ? { loading: nearest("loading") } : {},
848
+ ...nearest("error") ? { error: nearest("error") } : {}
849
+ };
850
+ }
851
+ function writeEvent(response, event, data) {
852
+ response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
853
+ }
854
+ /** Pushes route state to open DevTools panels over Server-Sent Events. */
855
+ function createRouteEventStream(getState, getSnapshot = () => []) {
856
+ const clients = /* @__PURE__ */ new Set();
857
+ let pending;
858
+ function send(response) {
859
+ writeEvent(response, "routes", getState());
860
+ }
861
+ return {
862
+ clients,
863
+ connect(request, response) {
864
+ response.statusCode = 200;
865
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
866
+ response.setHeader("Cache-Control", "no-store");
867
+ response.setHeader("Connection", "keep-alive");
868
+ response.flushHeaders?.();
869
+ response.write("retry: 1000\n\n");
870
+ send(response);
871
+ for (const [event, data] of getSnapshot()) writeEvent(response, event, data);
872
+ clients.add(response);
873
+ const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), HEARTBEAT_INTERVAL);
874
+ heartbeat.unref?.();
875
+ request.on("close", () => {
876
+ clearInterval(heartbeat);
877
+ clients.delete(response);
878
+ });
879
+ },
880
+ /** Coalesces bursts of file events (such as a folder rename) into one push. */
881
+ notify() {
882
+ if (!clients.size) return;
883
+ clearTimeout(pending);
884
+ pending = setTimeout(() => {
885
+ pending = void 0;
886
+ for (const client of clients) send(client);
887
+ }, 50);
888
+ },
889
+ /** Sends a named event to every connected panel right away. */
890
+ broadcast(event, data) {
891
+ for (const client of clients) writeEvent(client, event, data);
892
+ },
893
+ close() {
894
+ clearTimeout(pending);
895
+ for (const client of clients) client.end();
896
+ clients.clear();
897
+ }
898
+ };
899
+ }
900
+ // src/devtools-app.ts
901
+ /** HMR events the dev-only client in `virtual:routes` sends to the server. */
902
+ var APP_LOCATION_EVENT = "vite-file-router:location";
903
+ var APP_DATA_EVENT = "vite-file-router:data";
904
+ /** Sent from the server to the app: navigate one tab to a URL. */
905
+ var APP_NAVIGATE_EVENT = "vite-file-router:navigate";
906
+ /**
907
+ * Checks a URL the panel asks an app tab to open. Only same-origin paths are
908
+ * allowed, so the panel can't send the app to another site.
909
+ */
910
+ function isAppPath(url) {
911
+ return typeof url === "string" && url.length <= 2e3 && url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\") && ![...url].some((character) => character.charCodeAt(0) < 32);
912
+ }
913
+ var MAX_ACTIVITY = 200;
914
+ var MAX_TEXT = 600;
915
+ var MAX_DATA = 8e3;
916
+ var MAX_DETAIL = 4e3;
917
+ function text(value, limit = MAX_TEXT) {
918
+ if (typeof value !== "string") return void 0;
919
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
920
+ }
921
+ function finite(value) {
922
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
923
+ }
924
+ function pairs(value, max, limit) {
925
+ if (!Array.isArray(value)) return void 0;
926
+ return value.filter((entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "string").slice(0, max).map(([name, item]) => [text(name, 200), text(item, limit)]);
927
+ }
928
+ function stringRecord(value) {
929
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
930
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string").slice(0, 30).map(([name, item]) => [text(name, 100), text(item, 1e3)]);
931
+ return entries.length ? Object.fromEntries(entries) : void 0;
932
+ }
933
+ function requestBody(value) {
934
+ if (!value || typeof value !== "object") return void 0;
935
+ const body = value;
936
+ if (body.kind === "form") return {
937
+ kind: "form",
938
+ entries: pairs(body.entries, 50, 500) ?? []
939
+ };
940
+ const bodyText = text(body.text, MAX_DETAIL);
941
+ if (body.kind === "text" && bodyText !== void 0) return {
942
+ kind: "text",
943
+ contentType: text(body.contentType, 200),
944
+ text: bodyText
945
+ };
946
+ }
947
+ /**
948
+ * Validates an event from the browser. Anything a page sends over the HMR
949
+ * channel is untrusted, so only known fields of the expected types are kept.
950
+ */
951
+ function sanitizeDataEvent(input, id) {
952
+ if (!input || typeof input !== "object") return void 0;
953
+ const event = input;
954
+ const type = event.type;
955
+ if (type !== "loader" && type !== "action" && type !== "middleware" && type !== "revalidate") return;
956
+ const routeId = text(event.routeId, 400);
957
+ const tabId = text(event.tabId, 100);
958
+ if (!routeId || !tabId) return void 0;
959
+ const outcome = event.outcome;
960
+ return {
961
+ id,
962
+ tabId,
963
+ type,
964
+ routeId,
965
+ url: text(event.url, 2e3),
966
+ method: text(event.method, 20),
967
+ startedAt: finite(event.startedAt) ?? Date.now(),
968
+ duration: finite(event.duration),
969
+ ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" || outcome === "next" ? { outcome } : {},
970
+ status: finite(event.status),
971
+ location: text(event.location, 2e3),
972
+ message: text(event.message),
973
+ preview: text(event.preview),
974
+ ...typeof event.decision === "boolean" ? { decision: event.decision } : {},
975
+ ...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {},
976
+ params: stringRecord(event.params),
977
+ body: requestBody(event.body),
978
+ data: text(event.data, MAX_DATA),
979
+ ...event.dataTruncated === true ? { dataTruncated: true } : {},
980
+ headers: pairs(event.headers, 40, 1e3),
981
+ responseBody: text(event.responseBody, MAX_DETAIL),
982
+ errorName: text(event.errorName, 200),
983
+ stack: text(event.stack, MAX_DETAIL),
984
+ currentUrl: text(event.currentUrl, 2e3),
985
+ formMethod: text(event.formMethod, 20),
986
+ actionStatus: finite(event.actionStatus)
987
+ };
988
+ }
989
+ /**
990
+ * Tracks the app tabs connected to the dev server and their recent loader
991
+ * and action calls, and tells the panel when either changes.
992
+ */
993
+ function createAppTracker(options) {
994
+ const tabs = /* @__PURE__ */ new Map();
995
+ const latestReport = /* @__PURE__ */ new Map();
996
+ const activity = [];
997
+ let nextEventId = 1;
998
+ let nextReport = 1;
999
+ const list = () => [...tabs.values()].sort((a, b) => a.connectedAt - b.connectedAt);
1000
+ const changed = () => options.onTabsChange(list());
1001
+ return {
1002
+ tabs: list,
1003
+ activity: () => [...activity],
1004
+ async updateLocation(client, input) {
1005
+ if (!input || typeof input !== "object") return;
1006
+ const payload = input;
1007
+ const id = text(payload.tabId, 100);
1008
+ const url = text(payload.url, 2e3);
1009
+ if (!id || !url?.startsWith("/")) return;
1010
+ const report = nextReport++;
1011
+ latestReport.set(client, report);
1012
+ const match = await matchRouteManifest(options.getManifest(), url);
1013
+ if (latestReport.get(client) !== report) return;
1014
+ const now = Date.now();
1015
+ tabs.set(client, {
1016
+ id,
1017
+ url,
1018
+ title: text(payload.title, 200) ?? "",
1019
+ connectedAt: tabs.get(client)?.connectedAt ?? now,
1020
+ updatedAt: now,
1021
+ match
1022
+ });
1023
+ changed();
1024
+ },
1025
+ /** The HMR client for an app tab, to send it events such as navigation. */
1026
+ clientFor(tabId) {
1027
+ for (const [client, tab] of tabs) if (tab.id === tabId) return client;
1028
+ },
1029
+ disconnect(client) {
1030
+ latestReport.delete(client);
1031
+ if (tabs.delete(client)) changed();
1032
+ },
1033
+ /** Re-matches every tab, for example after route files change. */
1034
+ async rematch() {
1035
+ if (!tabs.size) return;
1036
+ const manifest = options.getManifest();
1037
+ for (const [client, tab] of tabs) tabs.set(client, {
1038
+ ...tab,
1039
+ match: await matchRouteManifest(manifest, tab.url)
1040
+ });
1041
+ changed();
1042
+ },
1043
+ record(input) {
1044
+ const event = sanitizeDataEvent(input, nextEventId);
1045
+ if (!event) return;
1046
+ nextEventId++;
1047
+ activity.push(event);
1048
+ if (activity.length > MAX_ACTIVITY) activity.shift();
1049
+ options.onActivity(event);
1050
+ }
1051
+ };
1052
+ }
345
1053
  // src/index.ts
346
1054
  var ROUTES_MODULE_ID = "virtual:routes";
347
1055
  var ROUTES_RESOLVED_ID = `\0${ROUTES_MODULE_ID}`;
@@ -349,13 +1057,22 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
349
1057
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
350
1058
  var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
351
1059
  var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
352
- var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
353
- var ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
1060
+ var WARNING_KINDS = [
1061
+ "no-error-boundary",
1062
+ "no-not-found",
1063
+ "missing-export"
1064
+ ];
354
1065
  var DEFAULT_DTS = "src/route-types.d.ts";
1066
+ var TYPESCRIPT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
1067
+ function resolveDeclarationFile(dts, projectRoot) {
1068
+ if (dts === false) return void 0;
1069
+ if (dts !== "auto") return path.resolve(projectRoot, dts);
1070
+ return TYPESCRIPT_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectRoot, file))) ? path.resolve(projectRoot, DEFAULT_DTS) : void 0;
1071
+ }
355
1072
  function resolveOptions(input) {
356
1073
  const options = typeof input === "string" ? { directory: input } : input;
357
1074
  if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
358
- for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts") throw new TypeError(`Unknown routes() option "${key}".`);
1075
+ for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts" && key !== "warnings") throw new TypeError(`Unknown routes() option "${key}".`);
359
1076
  const directory = options.directory ?? "src/pages";
360
1077
  if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
361
1078
  const conventions = { ...DEFAULT_CONVENTIONS };
@@ -367,13 +1084,51 @@ function resolveOptions(input) {
367
1084
  }
368
1085
  const names = Object.values(conventions);
369
1086
  if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
370
- const dts = options.dts ?? DEFAULT_DTS;
371
- if (dts !== false && (typeof dts !== "string" || dts.trim() === "" || !dts.endsWith(".d.ts"))) throw new TypeError("routes() dts must be false or a path ending in \".d.ts\".");
1087
+ const dts = options.dts ?? "auto";
1088
+ 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\".");
372
1089
  return {
373
1090
  directory,
374
1091
  conventions,
375
- dts
1092
+ dts,
1093
+ warnings: resolveWarnings(options.warnings)
1094
+ };
1095
+ }
1096
+ function resolveWarnings(input) {
1097
+ const message = "routes() warnings must be \"warn\", \"error\", false, or { level, ignore }.";
1098
+ if (input === void 0 || input === "warn") return {
1099
+ level: "warn",
1100
+ ignore: []
1101
+ };
1102
+ if (input === "error") return {
1103
+ level: "error",
1104
+ ignore: []
376
1105
  };
1106
+ if (input === false) return false;
1107
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new TypeError(message);
1108
+ for (const key of Object.keys(input)) if (key !== "level" && key !== "ignore") throw new TypeError(message);
1109
+ const level = input.level ?? "warn";
1110
+ if (level !== "warn" && level !== "error") throw new TypeError(message);
1111
+ const ignore = input.ignore ?? [];
1112
+ if (!Array.isArray(ignore) || ignore.some((kind) => !WARNING_KINDS.includes(kind))) throw new TypeError(`routes() warnings.ignore must list warning kinds: ${WARNING_KINDS.join(", ")}.`);
1113
+ return {
1114
+ level,
1115
+ ignore
1116
+ };
1117
+ }
1118
+ /** Formats route warnings for the terminal, grouped by route. */
1119
+ function formatRouteWarnings(warnings) {
1120
+ const byRoute = /* @__PURE__ */ new Map();
1121
+ for (const warning of warnings) byRoute.set(warning.fullPath, [...byRoute.get(warning.fullPath) ?? [], warning]);
1122
+ const lines = [`vite-file-router found ${warnings.length} route warning${warnings.length === 1 ? "" : "s"}:`];
1123
+ for (const [fullPath, list] of byRoute) {
1124
+ lines.push("", ` ${fullPath}`);
1125
+ for (const warning of list) {
1126
+ lines.push(` - ${warning.message} [${warning.kind}]`);
1127
+ for (const file of warning.files) lines.push(` ${file}`);
1128
+ }
1129
+ }
1130
+ lines.push("", "Ignore a kind with routes({ warnings: { ignore: [...] } }), or turn these off with routes({ warnings: false }).");
1131
+ return lines.join("\n");
377
1132
  }
378
1133
  function routes(input = "src/pages") {
379
1134
  const options = resolveOptions(input);
@@ -381,6 +1136,45 @@ function routes(input = "src/pages") {
381
1136
  let root = "";
382
1137
  let dev = false;
383
1138
  let declarationFile;
1139
+ function readSource(file) {
1140
+ try {
1141
+ return fs.readFileSync(path.resolve(projectRoot, file), "utf8");
1142
+ } catch {
1143
+ return;
1144
+ }
1145
+ }
1146
+ let reportedWarnings;
1147
+ function reportBuildWarnings(context) {
1148
+ if (dev || !options.warnings) return;
1149
+ const { level, ignore } = options.warnings;
1150
+ const warnings = findRouteWarnings(createRouteManifest(scan(root, options.conventions), projectRoot), readSource).filter((warning) => warning.severity === "warning" && !ignore.includes(warning.kind));
1151
+ if (!warnings.length) return;
1152
+ const message = formatRouteWarnings(warnings);
1153
+ if (level === "error") context.error(message);
1154
+ if (message === reportedWarnings) return;
1155
+ reportedWarnings = message;
1156
+ context.warn(message);
1157
+ }
1158
+ function getInspectorState() {
1159
+ const tree = scan(root, options.conventions);
1160
+ const manifest = createRouteManifest(tree, projectRoot);
1161
+ return {
1162
+ projectRoot,
1163
+ directory: path.relative(projectRoot, root).split(path.sep).join("/"),
1164
+ manifest,
1165
+ warnings: findRouteWarnings(manifest, readSource),
1166
+ conflicts: findPageRouteConflicts(tree).map(({ routePath, files }) => ({
1167
+ routePath,
1168
+ files: files.map((file) => path.relative(projectRoot, file).split(path.sep).join("/"))
1169
+ }))
1170
+ };
1171
+ }
1172
+ const appTracker = createAppTracker({
1173
+ getManifest: () => createRouteManifest(scan(root, options.conventions), projectRoot),
1174
+ onTabsChange: (tabs) => routeEvents.broadcast("apps", tabs),
1175
+ onActivity: (event) => routeEvents.broadcast("activity", event)
1176
+ });
1177
+ const routeEvents = createRouteEventStream(getInspectorState, () => [["apps", appTracker.tabs()], ["activity-snapshot", appTracker.activity()]]);
384
1178
  function writeDeclarations() {
385
1179
  if (!declarationFile) return;
386
1180
  const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
@@ -426,7 +1220,7 @@ function routes(input = "src/pages") {
426
1220
  `;
427
1221
  return format(code, { parser: "typescript" });
428
1222
  }
429
- const generated = generate(tree, true, projectRoot);
1223
+ const generated = generate(tree, true, projectRoot, { devClient: dev });
430
1224
  const sections = [
431
1225
  sortImports(generated.imports).join("\n"),
432
1226
  generated.helpers.join("\n"),
@@ -560,10 +1354,17 @@ function routes(input = "src/pages") {
560
1354
  projectRoot = config.root;
561
1355
  root = path.resolve(projectRoot, options.directory);
562
1356
  dev = config.command === "serve";
563
- declarationFile = options.dts === false ? void 0 : path.resolve(projectRoot, options.dts);
1357
+ declarationFile = resolveDeclarationFile(options.dts, projectRoot);
564
1358
  },
565
1359
  buildStart() {
566
1360
  writeDeclarations();
1361
+ reportBuildWarnings({
1362
+ warn: (message) => typeof this?.warn === "function" ? this.warn(message) : console.warn(message),
1363
+ error: (message) => {
1364
+ if (typeof this?.error === "function") this.error(message);
1365
+ throw new Error(message);
1366
+ }
1367
+ });
567
1368
  },
568
1369
  resolveId: (id) => {
569
1370
  if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
@@ -585,22 +1386,86 @@ function routes(input = "src/pages") {
585
1386
  type: "iframe",
586
1387
  url: ROUTE_INSPECTOR_PATH
587
1388
  });
1389
+ const fail = (res, status, message) => {
1390
+ res.statusCode = status;
1391
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
1392
+ res.end(message);
1393
+ };
1394
+ const navigateApp = (req, res) => {
1395
+ if (!req.headers["content-type"]?.startsWith("application/json")) {
1396
+ fail(res, 415, "Expected a JSON body.");
1397
+ return;
1398
+ }
1399
+ let body = "";
1400
+ req.setEncoding?.("utf8");
1401
+ req.on("data", (chunk) => {
1402
+ body += chunk;
1403
+ if (body.length > 8192) req.destroy();
1404
+ });
1405
+ req.on("end", () => {
1406
+ let payload;
1407
+ try {
1408
+ payload = JSON.parse(body);
1409
+ } catch {
1410
+ fail(res, 400, "Invalid JSON.");
1411
+ return;
1412
+ }
1413
+ if (!isAppPath(payload?.url)) {
1414
+ fail(res, 400, "The \"url\" must be a path starting with \"/\".");
1415
+ return;
1416
+ }
1417
+ const client = appTracker.clientFor(String(payload.tabId));
1418
+ if (!client) {
1419
+ fail(res, 404, "That app tab is no longer connected.");
1420
+ return;
1421
+ }
1422
+ client.send(APP_NAVIGATE_EVENT, { url: payload.url });
1423
+ sendJson(res, { sent: true });
1424
+ });
1425
+ };
1426
+ const sendJson = (res, body) => {
1427
+ res.statusCode = 200;
1428
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
1429
+ res.setHeader("Cache-Control", "no-store");
1430
+ res.end(JSON.stringify(body));
1431
+ };
588
1432
  ctx.viteServer.middlewares.use((req, res, next) => {
589
- if (new URL(req.url ?? "/", "http://vite-file-router").pathname !== ROUTE_INSPECTOR_DATA_PATH) {
1433
+ const url = new URL(req.url ?? "/", "http://vite-file-router");
1434
+ if (![
1435
+ "/__vite-file-router-data",
1436
+ "/__vite-file-router-events",
1437
+ "/__vite-file-router-match",
1438
+ "/__vite-file-router-navigate"
1439
+ ].includes(url.pathname)) {
590
1440
  next();
591
1441
  return;
592
1442
  }
593
- if (req.method !== "GET") {
1443
+ const method = url.pathname === "/__vite-file-router-navigate" ? "POST" : "GET";
1444
+ if (req.method !== method) {
594
1445
  res.statusCode = 405;
595
- res.setHeader("Allow", "GET");
1446
+ res.setHeader("Allow", method);
596
1447
  res.end("Method Not Allowed");
597
1448
  return;
598
1449
  }
599
- const manifest = createRouteManifest(scan(root, options.conventions), projectRoot);
600
- res.statusCode = 200;
601
- res.setHeader("Content-Type", "application/json; charset=utf-8");
602
- res.setHeader("Cache-Control", "no-store");
603
- res.end(JSON.stringify(manifest));
1450
+ if (url.pathname === "/__vite-file-router-navigate") {
1451
+ navigateApp(req, res);
1452
+ return;
1453
+ }
1454
+ if (url.pathname === "/__vite-file-router-data") {
1455
+ sendJson(res, getInspectorState());
1456
+ return;
1457
+ }
1458
+ if (url.pathname === "/__vite-file-router-events") {
1459
+ routeEvents.connect(req, res);
1460
+ return;
1461
+ }
1462
+ const target = url.searchParams.get("url");
1463
+ if (!target) {
1464
+ res.statusCode = 400;
1465
+ res.end("Missing \"url\" query parameter.");
1466
+ return;
1467
+ }
1468
+ matchRouteManifest(createRouteManifest(scan(root, options.conventions), projectRoot), target).then((result) => sendJson(res, result), next);
604
1469
  });
605
1470
  } },
606
1471
  configureServer: (server) => {
@@ -625,9 +1490,17 @@ function routes(input = "src/pages") {
625
1490
  if (module) server.moduleGraph.invalidateModule(module);
626
1491
  }
627
1492
  writeDeclarations();
1493
+ routeEvents.notify();
1494
+ appTracker.rematch();
628
1495
  server.ws.send({ type: "full-reload" });
629
1496
  });
1497
+ server.httpServer?.on("close", routeEvents.close);
1498
+ server.ws.on(APP_LOCATION_EVENT, (data, client) => appTracker.updateLocation(client, data).catch(() => {}));
1499
+ server.ws.on(APP_DATA_EVENT, (data) => appTracker.record(data));
1500
+ server.ws.on("vite:client:disconnect", (_data, client) => {
1501
+ appTracker.disconnect(client);
1502
+ });
630
1503
  }
631
1504
  };
632
1505
  }
633
- export { routes };
1506
+ export { formatRouteWarnings, routes };