@troyio/vite-file-router 0.4.1 → 0.6.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,9 @@ 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",
16
+ hydrateFallback: "hydrate-fallback"
15
17
  };
16
18
  function getRouteSegment(name) {
17
19
  if (name.startsWith("[...") && name.endsWith("]")) return "*";
@@ -68,7 +70,7 @@ function scan(directory, conventions = DEFAULT_CONVENTIONS) {
68
70
  const [, basename, extension] = match;
69
71
  const kind = Object.keys(conventions).find((candidate) => conventions[candidate] === basename);
70
72
  if (!kind) continue;
71
- const isDataModule = kind === "loader" || kind === "action";
73
+ const isDataModule = kind === "loader" || kind === "action" || kind === "middleware";
72
74
  if (!(isDataModule ? ["js", "ts"] : ["jsx", "tsx"]).includes(extension)) continue;
73
75
  const preferredExtension = isDataModule ? "ts" : "tsx";
74
76
  if (!node[kind] || extension === preferredExtension) node[kind] = file;
@@ -177,8 +179,9 @@ function generatePreload(files, projectRoot, children = []) {
177
179
  /**
178
180
  * Dev-only client for the DevTools Routes panel. It reports the tab's current
179
181
  * 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
+ * channel. Handler results pass through unchanged; the panel gets a capped
183
+ * copy of params, submitted form fields, returned data, response details, and
184
+ * errors. Production builds never include this code.
182
185
  */
183
186
  var DEVTOOLS_CLIENT = `const devtoolsHot =
184
187
  typeof window === "undefined" ? undefined : import.meta.hot;
@@ -190,33 +193,149 @@ function sendToDevtools(event, payload) {
190
193
  } catch {}
191
194
  }
192
195
 
196
+ const DEVTOOLS_DATA_LIMIT = 8000;
197
+ const DEVTOOLS_TEXT_LIMIT = 4000;
198
+
199
+ function clipText(text, limit) {
200
+ return text.length > limit ? text.slice(0, limit) + "…" : text;
201
+ }
202
+
203
+ // Serializes loader and action results for the panel. Circular references,
204
+ // Maps, Sets, bigints, and functions don't break serialization.
205
+ function serializeRouteData(value, limit = DEVTOOLS_DATA_LIMIT) {
206
+ const stack = [];
207
+ let text;
208
+ try {
209
+ text = JSON.stringify(value, function (_key, item) {
210
+ if (stack.length > 0) {
211
+ const position = stack.indexOf(this);
212
+ if (position === -1) stack.push(this);
213
+ else stack.splice(position + 1);
214
+ if (item && typeof item === "object" && stack.includes(item)) {
215
+ return "[Circular]";
216
+ }
217
+ } else {
218
+ stack.push(item);
219
+ }
220
+ if (typeof item === "bigint") return item.toString() + "n";
221
+ if (typeof item === "function") {
222
+ return "[Function " + (item.name || "anonymous") + "]";
223
+ }
224
+ if (typeof item === "symbol") return item.toString();
225
+ if (item instanceof Map) {
226
+ return Object.fromEntries(
227
+ [...item].map(([key, entry]) => [String(key), entry]),
228
+ );
229
+ }
230
+ if (item instanceof Set) return [...item];
231
+ return item;
232
+ });
233
+ } catch {}
234
+ if (text === undefined) text = String(value);
235
+ return text.length > limit
236
+ ? { data: text.slice(0, limit), dataTruncated: true }
237
+ : { data: text };
238
+ }
239
+
193
240
  function previewRouteData(value) {
241
+ return clipText(serializeRouteData(value, 601).data, 600);
242
+ }
243
+
244
+ function routeParams(params) {
245
+ if (!params || typeof params !== "object") return undefined;
246
+ const entries = Object.entries(params).filter(
247
+ ([, value]) => typeof value === "string",
248
+ );
249
+ return entries.length ? Object.fromEntries(entries) : undefined;
250
+ }
251
+
252
+ async function describeRequestBody(request) {
253
+ const type = request.headers.get("Content-Type") || "";
194
254
  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;
255
+ if (
256
+ type.includes("multipart/form-data") ||
257
+ type.includes("application/x-www-form-urlencoded")
258
+ ) {
259
+ const form = await request.formData();
260
+ return {
261
+ kind: "form",
262
+ entries: [...form].slice(0, 50).map(([name, value]) => [
263
+ name,
264
+ typeof value === "string"
265
+ ? clipText(value, 500)
266
+ : "File " + value.name + " (" + value.size + " bytes)",
267
+ ]),
268
+ };
269
+ }
270
+ const text = await request.text();
271
+ return text
272
+ ? { kind: "text", contentType: type, text: clipText(text, DEVTOOLS_TEXT_LIMIT) }
273
+ : undefined;
200
274
  } catch {
201
- return String(value);
275
+ return undefined;
202
276
  }
203
277
  }
204
278
 
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 };
279
+ function headerEntries(headers) {
280
+ try {
281
+ return [...new Headers(headers)].slice(0, 40);
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }
286
+
287
+ // Clones a Response synchronously, before React Router reads its body.
288
+ function describeResponse(value, thrown) {
289
+ const location = value.headers.get("Location");
290
+ const base = { status: value.status, headers: headerEntries(value.headers) };
291
+ if (location && value.status >= 300 && value.status < 400) {
292
+ return { ...base, outcome: "redirect", location };
212
293
  }
294
+ const outcome = thrown ? "error" : "response";
295
+ if (value.bodyUsed || !value.body) return { ...base, outcome };
296
+ const copy = value.clone();
297
+ return copy
298
+ .text()
299
+ .then((text) => ({
300
+ ...base,
301
+ outcome,
302
+ ...(text ? { responseBody: clipText(text, DEVTOOLS_TEXT_LIMIT) } : {}),
303
+ }))
304
+ .catch(() => ({ ...base, outcome }));
305
+ }
306
+
307
+ function describeRouteResult(value, thrown) {
308
+ if (value instanceof Response) return describeResponse(value, thrown);
213
309
  if (thrown) {
310
+ if (value instanceof Error) {
311
+ return {
312
+ outcome: "error",
313
+ errorName: value.name,
314
+ message: value.message,
315
+ stack: value.stack ? clipText(value.stack, DEVTOOLS_TEXT_LIMIT) : undefined,
316
+ };
317
+ }
214
318
  return {
215
319
  outcome: "error",
216
- message: value instanceof Error ? value.message : previewRouteData(value),
320
+ message: previewRouteData(value),
321
+ ...serializeRouteData(value),
217
322
  };
218
323
  }
219
- return { outcome: "data", preview: previewRouteData(value) };
324
+ // React Router's data() helper wraps data with a status and headers.
325
+ if (value && value.type === "DataWithResponseInit" && "data" in value) {
326
+ return {
327
+ outcome: "data",
328
+ status: value.init?.status,
329
+ headers: value.init?.headers ? headerEntries(value.init.headers) : undefined,
330
+ preview: previewRouteData(value.data),
331
+ ...serializeRouteData(value.data),
332
+ };
333
+ }
334
+ return {
335
+ outcome: "data",
336
+ preview: previewRouteData(value),
337
+ ...serializeRouteData(value),
338
+ };
220
339
  }
221
340
 
222
341
  function instrumentRouteData(type, routeId, handler) {
@@ -225,16 +344,30 @@ function instrumentRouteData(type, routeId, handler) {
225
344
  const startedAt = Date.now();
226
345
  const started = performance.now();
227
346
  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
- });
347
+ // Read the submitted body from a copy, so the handler can still read it.
348
+ const body =
349
+ request && request.body && request.method !== "GET" && request.method !== "HEAD"
350
+ ? describeRequestBody(request.clone())
351
+ : undefined;
352
+ const report = (result, thrown) => {
353
+ const duration = performance.now() - started;
354
+ const described = describeRouteResult(result, thrown);
355
+ Promise.all([described, body])
356
+ .then(([details, requestBody]) =>
357
+ sendToDevtools("vite-file-router:data", {
358
+ type,
359
+ routeId,
360
+ url: request?.url,
361
+ method: request?.method,
362
+ params: routeParams(args?.params),
363
+ body: requestBody,
364
+ startedAt,
365
+ duration,
366
+ ...details,
367
+ }),
368
+ )
369
+ .catch(() => {});
370
+ };
238
371
  try {
239
372
  const result = await handler(args);
240
373
  report(result, false);
@@ -246,6 +379,55 @@ function instrumentRouteData(type, routeId, handler) {
246
379
  };
247
380
  }
248
381
 
382
+ // Reports the middleware's own time, not counting the handlers next() runs.
383
+ function instrumentMiddleware(routeId, middleware) {
384
+ if (!devtoolsHot) return middleware;
385
+ return async (args, next) => {
386
+ const startedAt = Date.now();
387
+ const started = performance.now();
388
+ let downstream = 0;
389
+ let calledNext = false;
390
+ const timedNext = async () => {
391
+ calledNext = true;
392
+ const nextStarted = performance.now();
393
+ try {
394
+ return await next();
395
+ } finally {
396
+ downstream += performance.now() - nextStarted;
397
+ }
398
+ };
399
+ const report = (details) => {
400
+ const duration = performance.now() - started - downstream;
401
+ Promise.resolve(details)
402
+ .then((resolved) =>
403
+ sendToDevtools("vite-file-router:data", {
404
+ type: "middleware",
405
+ routeId,
406
+ url: args?.request?.url,
407
+ method: args?.request?.method,
408
+ params: routeParams(args?.params),
409
+ startedAt,
410
+ duration,
411
+ ...resolved,
412
+ }),
413
+ )
414
+ .catch(() => {});
415
+ };
416
+ try {
417
+ const result = await middleware(args, timedNext);
418
+ report(
419
+ result instanceof Response && !calledNext
420
+ ? describeRouteResult(result, false)
421
+ : { outcome: "next" },
422
+ );
423
+ return result;
424
+ } catch (error) {
425
+ report(describeRouteResult(error, true));
426
+ throw error;
427
+ }
428
+ };
429
+ }
430
+
249
431
  function instrumentRevalidate(routeId, shouldRevalidate) {
250
432
  if (!devtoolsHot) return shouldRevalidate;
251
433
  return (args) => {
@@ -254,6 +436,10 @@ function instrumentRevalidate(routeId, shouldRevalidate) {
254
436
  type: "revalidate",
255
437
  routeId,
256
438
  url: args?.nextUrl?.href,
439
+ currentUrl: args?.currentUrl?.href,
440
+ params: routeParams(args?.nextParams),
441
+ formMethod: args?.formMethod,
442
+ actionStatus: args?.actionStatus,
257
443
  startedAt: Date.now(),
258
444
  decision,
259
445
  defaultShouldRevalidate: args?.defaultShouldRevalidate,
@@ -290,6 +476,30 @@ if (devtoolsHot) {
290
476
  for (const event of ["vite-file-router:navigate", "popstate", "hashchange"]) {
291
477
  window.addEventListener(event, reportLocation);
292
478
  }
479
+ // The panel's "Open in app" action. React Router's browser router follows
480
+ // popstate events, so pushing the URL and announcing it navigates without a
481
+ // reload. The state matches React Router's so Back and Forward keep working.
482
+ devtoolsHot.on("vite-file-router:navigate", (payload) => {
483
+ const url = payload?.url;
484
+ if (typeof url !== "string" || !url.startsWith("/") || url.startsWith("//")) {
485
+ return;
486
+ }
487
+ if (window.location.hash.startsWith("#/")) {
488
+ window.location.hash = url;
489
+ return;
490
+ }
491
+ const index = window.history.state?.idx;
492
+ window.history.pushState(
493
+ {
494
+ usr: null,
495
+ key: Math.random().toString(36).slice(2, 10),
496
+ idx: typeof index === "number" ? index + 1 : 0,
497
+ },
498
+ "",
499
+ url,
500
+ );
501
+ window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
502
+ });
293
503
  // Report again after the dev server restarts and the socket reconnects.
294
504
  devtoolsHot.on("vite:ws:connect", () => {
295
505
  reportedUrl = undefined;
@@ -311,6 +521,17 @@ function generateNode(node, root, context, inheritedBoundary) {
311
521
  route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
312
522
  }
313
523
  if (node.action) route.action = generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context));
524
+ if (node.middleware) {
525
+ context.hasMiddleware = true;
526
+ const load = `lazyMiddleware(() => import(${JSON.stringify(getImportPath(node.middleware, context.projectRoot))}))`;
527
+ const instrumentId = getInstrumentId(node, context);
528
+ route.middleware = `[${instrumentId ? `instrumentMiddleware(${JSON.stringify(instrumentId)}, ${load})` : load}]`;
529
+ }
530
+ if (node.hydrateFallback) {
531
+ const fallback = genSafeVariableName(`HydrateFallback${context.nextLoadingId++}`);
532
+ context.loadingImports.push(`import ${fallback} from ${JSON.stringify(getImportPath(node.hydrateFallback, context.projectRoot))};`);
533
+ route.HydrateFallback = fallback;
534
+ }
314
535
  let loadingComponent;
315
536
  if (node.loading) {
316
537
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -358,7 +579,11 @@ function generateNode(node, root, context, inheritedBoundary) {
358
579
  if (children.length) route.children = genArrayFromRaw(children);
359
580
  return {
360
581
  route: genObjectFromRaw(route),
361
- preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
582
+ preload: generatePreload([
583
+ node.layout,
584
+ node.loader,
585
+ node.middleware
586
+ ], context.projectRoot, preloadChildren)
362
587
  };
363
588
  }
364
589
  function generate(node, root = false, projectRoot = process.cwd(), options = {}) {
@@ -372,6 +597,7 @@ function generate(node, root = false, projectRoot = process.cwd(), options = {})
372
597
  hasBoundaries: false,
373
598
  hasLayouts: false,
374
599
  hasErrorBoundaries: false,
600
+ hasMiddleware: false,
375
601
  devClient: Boolean(options.devClient)
376
602
  };
377
603
  const { route: routes, preload: preloads } = generateNode(node, root, context, false);
@@ -409,6 +635,31 @@ function generate(node, root = false, projectRoot = process.cwd(), options = {})
409
635
  createElement(Component),
410
636
  );
411
637
  };
638
+ }`);
639
+ if (context.hasMiddleware) helpers.push(`/**
640
+ * Runs a middleware module's default or \`middleware\` export, which can be a
641
+ * function or an array of functions. Like React Router, it calls \`next()\`
642
+ * after a function that didn't call it.
643
+ */
644
+ function lazyMiddleware(load) {
645
+ let pending;
646
+ return async (args, next) => {
647
+ pending ??= load();
648
+ const module = await pending;
649
+ const exported = module.middleware ?? module.default;
650
+ const list = (Array.isArray(exported) ? exported : [exported]).filter(
651
+ (item) => typeof item === "function",
652
+ );
653
+ const run = async (index) => {
654
+ if (index >= list.length) return next();
655
+ let downstream;
656
+ const callNext = () => (downstream ??= run(index + 1));
657
+ const result = await list[index](args, callNext);
658
+ const nextResult = await callNext();
659
+ return result === undefined ? nextResult : result;
660
+ };
661
+ return run(0);
662
+ };
412
663
  }`);
413
664
  if (context.devClient) helpers.push(DEVTOOLS_CLIENT);
414
665
  return {
@@ -474,6 +725,80 @@ function createRouteDeclarations(tree, projectRoot, declarationFile) {
474
725
  // src/devtools.ts
475
726
  var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
476
727
  var HEARTBEAT_INTERVAL = 25e3;
728
+ var EXPORT_PATTERNS = (name) => [
729
+ new RegExp(`export\\s+(?:async\\s+)?function\\s*\\*?\\s*${name}\\b`),
730
+ new RegExp(`export\\s+(?:const|let|var)\\s+${name}\\b`),
731
+ new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`),
732
+ /export\s*\*\s*from/
733
+ ];
734
+ /** Whether module source code (probably) has a named export. */
735
+ function hasNamedExport(source, name) {
736
+ return EXPORT_PATTERNS(name).some((pattern) => pattern.test(source));
737
+ }
738
+ var MISSING_EXPORT_MESSAGES = {
739
+ loader: `This loader file doesn't export a function named "loader", so the route loads no data.`,
740
+ action: `This action file doesn't export a function named "action", so the route fails when a form is submitted.`,
741
+ middleware: "This middleware file has no default or \"middleware\" export, so no middleware runs for the route."
742
+ };
743
+ var HANDLER_NAMES = {
744
+ loader: "a loader",
745
+ action: "an action",
746
+ middleware: "middleware"
747
+ };
748
+ function listHandlers(kinds) {
749
+ const names = kinds.map((kind) => HANDLER_NAMES[kind]);
750
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
751
+ }
752
+ /**
753
+ * Finds routes that are missing a file they likely need: an error file for a
754
+ * loader, action, or middleware, a root not-found page, or the expected export
755
+ * of a data module. `readSource` reads a project-relative file.
756
+ */
757
+ function findRouteWarnings(manifest, readSource = () => void 0) {
758
+ const warnings = [];
759
+ if (!manifest.files.notFound) warnings.push({
760
+ kind: "no-not-found",
761
+ severity: manifest.files.error ? "info" : "warning",
762
+ key: "0",
763
+ fullPath: "/",
764
+ 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.",
765
+ files: manifest.files.error ? [manifest.files.error] : []
766
+ });
767
+ const visit = (node, key, hasError) => {
768
+ const covered = hasError || Boolean(node.files.error);
769
+ const handlers = [
770
+ "loader",
771
+ "action",
772
+ "middleware"
773
+ ].filter((kind) => node.files[kind]);
774
+ if (handlers.length && !covered) warnings.push({
775
+ kind: "no-error-boundary",
776
+ severity: "warning",
777
+ key,
778
+ fullPath: node.fullPath,
779
+ 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.`,
780
+ files: handlers.map((kind) => node.files[kind])
781
+ });
782
+ for (const kind of handlers) {
783
+ const file = node.files[kind];
784
+ const source = readSource(file);
785
+ if (source === void 0) continue;
786
+ if (!(hasNamedExport(source, kind) || kind === "middleware" && /export\s+default\b/.test(source))) warnings.push({
787
+ kind: "missing-export",
788
+ severity: "warning",
789
+ key,
790
+ fullPath: node.fullPath,
791
+ message: MISSING_EXPORT_MESSAGES[kind],
792
+ files: [file]
793
+ });
794
+ }
795
+ node.children.forEach((child, index) => {
796
+ visit(child, `${key}.${index}`, covered);
797
+ });
798
+ };
799
+ visit(manifest, "0", false);
800
+ return warnings;
801
+ }
477
802
  /**
478
803
  * Mirrors the generated React Router tree: each manifest node becomes a
479
804
  * route with an index child for its page and a `*` child for its not-found
@@ -524,7 +849,9 @@ async function matchRouteManifest(manifest, url) {
524
849
  ...leaf.kind === "notFound" ? { notFound: leaf.node?.files.notFound } : {},
525
850
  layouts: collect("layout"),
526
851
  loaders: collect("loader"),
852
+ middleware: collect("middleware"),
527
853
  ...nearest("loading") ? { loading: nearest("loading") } : {},
854
+ ...nearest("hydrateFallback") ? { hydrateFallback: nearest("hydrateFallback") } : {},
528
855
  ...nearest("error") ? { error: nearest("error") } : {}
529
856
  };
530
857
  }
@@ -581,8 +908,19 @@ function createRouteEventStream(getState, getSnapshot = () => []) {
581
908
  /** HMR events the dev-only client in `virtual:routes` sends to the server. */
582
909
  var APP_LOCATION_EVENT = "vite-file-router:location";
583
910
  var APP_DATA_EVENT = "vite-file-router:data";
911
+ /** Sent from the server to the app: navigate one tab to a URL. */
912
+ var APP_NAVIGATE_EVENT = "vite-file-router:navigate";
913
+ /**
914
+ * Checks a URL the panel asks an app tab to open. Only same-origin paths are
915
+ * allowed, so the panel can't send the app to another site.
916
+ */
917
+ function isAppPath(url) {
918
+ return typeof url === "string" && url.length <= 2e3 && url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\") && ![...url].some((character) => character.charCodeAt(0) < 32);
919
+ }
584
920
  var MAX_ACTIVITY = 200;
585
921
  var MAX_TEXT = 600;
922
+ var MAX_DATA = 8e3;
923
+ var MAX_DETAIL = 4e3;
586
924
  function text(value, limit = MAX_TEXT) {
587
925
  if (typeof value !== "string") return void 0;
588
926
  return value.length > limit ? `${value.slice(0, limit)}…` : value;
@@ -590,6 +928,29 @@ function text(value, limit = MAX_TEXT) {
590
928
  function finite(value) {
591
929
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
592
930
  }
931
+ function pairs(value, max, limit) {
932
+ if (!Array.isArray(value)) return void 0;
933
+ 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)]);
934
+ }
935
+ function stringRecord(value) {
936
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
937
+ const entries = Object.entries(value).filter((entry) => typeof entry[1] === "string").slice(0, 30).map(([name, item]) => [text(name, 100), text(item, 1e3)]);
938
+ return entries.length ? Object.fromEntries(entries) : void 0;
939
+ }
940
+ function requestBody(value) {
941
+ if (!value || typeof value !== "object") return void 0;
942
+ const body = value;
943
+ if (body.kind === "form") return {
944
+ kind: "form",
945
+ entries: pairs(body.entries, 50, 500) ?? []
946
+ };
947
+ const bodyText = text(body.text, MAX_DETAIL);
948
+ if (body.kind === "text" && bodyText !== void 0) return {
949
+ kind: "text",
950
+ contentType: text(body.contentType, 200),
951
+ text: bodyText
952
+ };
953
+ }
593
954
  /**
594
955
  * Validates an event from the browser. Anything a page sends over the HMR
595
956
  * channel is untrusted, so only known fields of the expected types are kept.
@@ -598,7 +959,7 @@ function sanitizeDataEvent(input, id) {
598
959
  if (!input || typeof input !== "object") return void 0;
599
960
  const event = input;
600
961
  const type = event.type;
601
- if (type !== "loader" && type !== "action" && type !== "revalidate") return;
962
+ if (type !== "loader" && type !== "action" && type !== "middleware" && type !== "revalidate") return;
602
963
  const routeId = text(event.routeId, 400);
603
964
  const tabId = text(event.tabId, 100);
604
965
  if (!routeId || !tabId) return void 0;
@@ -612,13 +973,24 @@ function sanitizeDataEvent(input, id) {
612
973
  method: text(event.method, 20),
613
974
  startedAt: finite(event.startedAt) ?? Date.now(),
614
975
  duration: finite(event.duration),
615
- ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" ? { outcome } : {},
976
+ ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" || outcome === "next" ? { outcome } : {},
616
977
  status: finite(event.status),
617
978
  location: text(event.location, 2e3),
618
979
  message: text(event.message),
619
980
  preview: text(event.preview),
620
981
  ...typeof event.decision === "boolean" ? { decision: event.decision } : {},
621
- ...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {}
982
+ ...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {},
983
+ params: stringRecord(event.params),
984
+ body: requestBody(event.body),
985
+ data: text(event.data, MAX_DATA),
986
+ ...event.dataTruncated === true ? { dataTruncated: true } : {},
987
+ headers: pairs(event.headers, 40, 1e3),
988
+ responseBody: text(event.responseBody, MAX_DETAIL),
989
+ errorName: text(event.errorName, 200),
990
+ stack: text(event.stack, MAX_DETAIL),
991
+ currentUrl: text(event.currentUrl, 2e3),
992
+ formMethod: text(event.formMethod, 20),
993
+ actionStatus: finite(event.actionStatus)
622
994
  };
623
995
  }
624
996
  /**
@@ -657,6 +1029,10 @@ function createAppTracker(options) {
657
1029
  });
658
1030
  changed();
659
1031
  },
1032
+ /** The HMR client for an app tab, to send it events such as navigation. */
1033
+ clientFor(tabId) {
1034
+ for (const [client, tab] of tabs) if (tab.id === tabId) return client;
1035
+ },
660
1036
  disconnect(client) {
661
1037
  latestReport.delete(client);
662
1038
  if (tabs.delete(client)) changed();
@@ -688,6 +1064,11 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
688
1064
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
689
1065
  var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
690
1066
  var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
1067
+ var WARNING_KINDS = [
1068
+ "no-error-boundary",
1069
+ "no-not-found",
1070
+ "missing-export"
1071
+ ];
691
1072
  var DEFAULT_DTS = "src/route-types.d.ts";
692
1073
  var TYPESCRIPT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
693
1074
  function resolveDeclarationFile(dts, projectRoot) {
@@ -698,7 +1079,7 @@ function resolveDeclarationFile(dts, projectRoot) {
698
1079
  function resolveOptions(input) {
699
1080
  const options = typeof input === "string" ? { directory: input } : input;
700
1081
  if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
701
- for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts") throw new TypeError(`Unknown routes() option "${key}".`);
1082
+ for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts" && key !== "warnings") throw new TypeError(`Unknown routes() option "${key}".`);
702
1083
  const directory = options.directory ?? "src/pages";
703
1084
  if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
704
1085
  const conventions = { ...DEFAULT_CONVENTIONS };
@@ -715,21 +1096,80 @@ function resolveOptions(input) {
715
1096
  return {
716
1097
  directory,
717
1098
  conventions,
718
- dts
1099
+ dts,
1100
+ warnings: resolveWarnings(options.warnings)
1101
+ };
1102
+ }
1103
+ function resolveWarnings(input) {
1104
+ const message = "routes() warnings must be \"warn\", \"error\", false, or { level, ignore }.";
1105
+ if (input === void 0 || input === "warn") return {
1106
+ level: "warn",
1107
+ ignore: []
1108
+ };
1109
+ if (input === "error") return {
1110
+ level: "error",
1111
+ ignore: []
1112
+ };
1113
+ if (input === false) return false;
1114
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new TypeError(message);
1115
+ for (const key of Object.keys(input)) if (key !== "level" && key !== "ignore") throw new TypeError(message);
1116
+ const level = input.level ?? "warn";
1117
+ if (level !== "warn" && level !== "error") throw new TypeError(message);
1118
+ const ignore = input.ignore ?? [];
1119
+ if (!Array.isArray(ignore) || ignore.some((kind) => !WARNING_KINDS.includes(kind))) throw new TypeError(`routes() warnings.ignore must list warning kinds: ${WARNING_KINDS.join(", ")}.`);
1120
+ return {
1121
+ level,
1122
+ ignore
719
1123
  };
720
1124
  }
1125
+ /** Formats route warnings for the terminal, grouped by route. */
1126
+ function formatRouteWarnings(warnings) {
1127
+ const byRoute = /* @__PURE__ */ new Map();
1128
+ for (const warning of warnings) byRoute.set(warning.fullPath, [...byRoute.get(warning.fullPath) ?? [], warning]);
1129
+ const lines = [`vite-file-router found ${warnings.length} route warning${warnings.length === 1 ? "" : "s"}:`];
1130
+ for (const [fullPath, list] of byRoute) {
1131
+ lines.push("", ` ${fullPath}`);
1132
+ for (const warning of list) {
1133
+ lines.push(` - ${warning.message} [${warning.kind}]`);
1134
+ for (const file of warning.files) lines.push(` ${file}`);
1135
+ }
1136
+ }
1137
+ lines.push("", "Ignore a kind with routes({ warnings: { ignore: [...] } }), or turn these off with routes({ warnings: false }).");
1138
+ return lines.join("\n");
1139
+ }
721
1140
  function routes(input = "src/pages") {
722
1141
  const options = resolveOptions(input);
723
1142
  let projectRoot = "";
724
1143
  let root = "";
725
1144
  let dev = false;
726
1145
  let declarationFile;
1146
+ function readSource(file) {
1147
+ try {
1148
+ return fs.readFileSync(path.resolve(projectRoot, file), "utf8");
1149
+ } catch {
1150
+ return;
1151
+ }
1152
+ }
1153
+ let reportedWarnings;
1154
+ function reportBuildWarnings(context) {
1155
+ if (dev || !options.warnings) return;
1156
+ const { level, ignore } = options.warnings;
1157
+ const warnings = findRouteWarnings(createRouteManifest(scan(root, options.conventions), projectRoot), readSource).filter((warning) => warning.severity === "warning" && !ignore.includes(warning.kind));
1158
+ if (!warnings.length) return;
1159
+ const message = formatRouteWarnings(warnings);
1160
+ if (level === "error") context.error(message);
1161
+ if (message === reportedWarnings) return;
1162
+ reportedWarnings = message;
1163
+ context.warn(message);
1164
+ }
727
1165
  function getInspectorState() {
728
1166
  const tree = scan(root, options.conventions);
1167
+ const manifest = createRouteManifest(tree, projectRoot);
729
1168
  return {
730
1169
  projectRoot,
731
1170
  directory: path.relative(projectRoot, root).split(path.sep).join("/"),
732
- manifest: createRouteManifest(tree, projectRoot),
1171
+ manifest,
1172
+ warnings: findRouteWarnings(manifest, readSource),
733
1173
  conflicts: findPageRouteConflicts(tree).map(({ routePath, files }) => ({
734
1174
  routePath,
735
1175
  files: files.map((file) => path.relative(projectRoot, file).split(path.sep).join("/"))
@@ -742,6 +1182,44 @@ function routes(input = "src/pages") {
742
1182
  onActivity: (event) => routeEvents.broadcast("activity", event)
743
1183
  });
744
1184
  const routeEvents = createRouteEventStream(getInspectorState, () => [["apps", appTracker.tabs()], ["activity-snapshot", appTracker.activity()]]);
1185
+ /**
1186
+ * Every module the plugin generates, for the DevTools Generated code page.
1187
+ * A module that fails to generate (for example on a route conflict)
1188
+ * reports its error instead of failing the others.
1189
+ */
1190
+ async function getGeneratedCode() {
1191
+ const capture = async (module, create) => {
1192
+ try {
1193
+ return {
1194
+ ...module,
1195
+ code: await create()
1196
+ };
1197
+ } catch (error) {
1198
+ return {
1199
+ ...module,
1200
+ error: error instanceof Error ? error.message : String(error)
1201
+ };
1202
+ }
1203
+ };
1204
+ const typesFile = declarationFile ?? path.resolve(projectRoot, DEFAULT_DTS);
1205
+ return Promise.all([
1206
+ capture({
1207
+ id: ROUTES_MODULE_ID,
1208
+ variant: "dev"
1209
+ }, () => execute(true)),
1210
+ capture({
1211
+ id: ROUTES_MODULE_ID,
1212
+ variant: "build"
1213
+ }, () => execute(false)),
1214
+ capture({ id: MANIFEST_MODULE_ID }, executeManifest),
1215
+ capture({ id: ROUTE_HELPERS_MODULE_ID }, executeRouteHelpers),
1216
+ capture({
1217
+ id: "route-types",
1218
+ file: path.relative(projectRoot, typesFile).split(path.sep).join("/"),
1219
+ written: Boolean(declarationFile)
1220
+ }, () => createRouteDeclarations(scan(root, options.conventions), projectRoot, typesFile))
1221
+ ]);
1222
+ }
745
1223
  function writeDeclarations() {
746
1224
  if (!declarationFile) return;
747
1225
  const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
@@ -758,12 +1236,13 @@ function routes(input = "src/pages") {
758
1236
  return leftSource.localeCompare(rightSource);
759
1237
  });
760
1238
  }
761
- async function execute() {
1239
+ /** `devClient` adds the DevTools client, which only the dev server serves. */
1240
+ async function execute(devClient = dev) {
762
1241
  const tree = scan(root, options.conventions);
763
1242
  const conflicts = findPageRouteConflicts(tree);
764
1243
  if (conflicts.length) {
765
1244
  const error = new RouteConflictError(conflicts);
766
- if (!dev) throw error;
1245
+ if (!devClient) throw error;
767
1246
  const code = `
768
1247
  import { createElement } from "react";
769
1248
 
@@ -787,7 +1266,7 @@ function routes(input = "src/pages") {
787
1266
  `;
788
1267
  return format(code, { parser: "typescript" });
789
1268
  }
790
- const generated = generate(tree, true, projectRoot, { devClient: dev });
1269
+ const generated = generate(tree, true, projectRoot, { devClient });
791
1270
  const sections = [
792
1271
  sortImports(generated.imports).join("\n"),
793
1272
  generated.helpers.join("\n"),
@@ -925,6 +1404,13 @@ function routes(input = "src/pages") {
925
1404
  },
926
1405
  buildStart() {
927
1406
  writeDeclarations();
1407
+ reportBuildWarnings({
1408
+ warn: (message) => typeof this?.warn === "function" ? this.warn(message) : console.warn(message),
1409
+ error: (message) => {
1410
+ if (typeof this?.error === "function") this.error(message);
1411
+ throw new Error(message);
1412
+ }
1413
+ });
928
1414
  },
929
1415
  resolveId: (id) => {
930
1416
  if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
@@ -946,6 +1432,43 @@ function routes(input = "src/pages") {
946
1432
  type: "iframe",
947
1433
  url: ROUTE_INSPECTOR_PATH
948
1434
  });
1435
+ const fail = (res, status, message) => {
1436
+ res.statusCode = status;
1437
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
1438
+ res.end(message);
1439
+ };
1440
+ const navigateApp = (req, res) => {
1441
+ if (!req.headers["content-type"]?.startsWith("application/json")) {
1442
+ fail(res, 415, "Expected a JSON body.");
1443
+ return;
1444
+ }
1445
+ let body = "";
1446
+ req.setEncoding?.("utf8");
1447
+ req.on("data", (chunk) => {
1448
+ body += chunk;
1449
+ if (body.length > 8192) req.destroy();
1450
+ });
1451
+ req.on("end", () => {
1452
+ let payload;
1453
+ try {
1454
+ payload = JSON.parse(body);
1455
+ } catch {
1456
+ fail(res, 400, "Invalid JSON.");
1457
+ return;
1458
+ }
1459
+ if (!isAppPath(payload?.url)) {
1460
+ fail(res, 400, "The \"url\" must be a path starting with \"/\".");
1461
+ return;
1462
+ }
1463
+ const client = appTracker.clientFor(String(payload.tabId));
1464
+ if (!client) {
1465
+ fail(res, 404, "That app tab is no longer connected.");
1466
+ return;
1467
+ }
1468
+ client.send(APP_NAVIGATE_EVENT, { url: payload.url });
1469
+ sendJson(res, { sent: true });
1470
+ });
1471
+ };
949
1472
  const sendJson = (res, body) => {
950
1473
  res.statusCode = 200;
951
1474
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -957,17 +1480,28 @@ function routes(input = "src/pages") {
957
1480
  if (![
958
1481
  "/__vite-file-router-data",
959
1482
  "/__vite-file-router-events",
960
- "/__vite-file-router-match"
1483
+ "/__vite-file-router-match",
1484
+ "/__vite-file-router-navigate",
1485
+ "/__vite-file-router-code"
961
1486
  ].includes(url.pathname)) {
962
1487
  next();
963
1488
  return;
964
1489
  }
965
- if (req.method !== "GET") {
1490
+ const method = url.pathname === "/__vite-file-router-navigate" ? "POST" : "GET";
1491
+ if (req.method !== method) {
966
1492
  res.statusCode = 405;
967
- res.setHeader("Allow", "GET");
1493
+ res.setHeader("Allow", method);
968
1494
  res.end("Method Not Allowed");
969
1495
  return;
970
1496
  }
1497
+ if (url.pathname === "/__vite-file-router-code") {
1498
+ getGeneratedCode().then((modules) => sendJson(res, { modules }), next);
1499
+ return;
1500
+ }
1501
+ if (url.pathname === "/__vite-file-router-navigate") {
1502
+ navigateApp(req, res);
1503
+ return;
1504
+ }
971
1505
  if (url.pathname === "/__vite-file-router-data") {
972
1506
  sendJson(res, getInspectorState());
973
1507
  return;
@@ -1020,4 +1554,4 @@ function routes(input = "src/pages") {
1020
1554
  }
1021
1555
  };
1022
1556
  }
1023
- export { routes };
1557
+ export { formatRouteWarnings, routes };