@troyio/vite-file-router 0.4.1 → 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;
@@ -177,8 +178,9 @@ function generatePreload(files, projectRoot, children = []) {
177
178
  /**
178
179
  * Dev-only client for the DevTools Routes panel. It reports the tab's current
179
180
  * 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.
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.
182
184
  */
183
185
  var DEVTOOLS_CLIENT = `const devtoolsHot =
184
186
  typeof window === "undefined" ? undefined : import.meta.hot;
@@ -190,33 +192,149 @@ function sendToDevtools(event, payload) {
190
192
  } catch {}
191
193
  }
192
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
+
193
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") || "";
194
253
  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;
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;
200
273
  } catch {
201
- return String(value);
274
+ return undefined;
202
275
  }
203
276
  }
204
277
 
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 };
278
+ function headerEntries(headers) {
279
+ try {
280
+ return [...new Headers(headers)].slice(0, 40);
281
+ } catch {
282
+ return undefined;
212
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);
213
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
+ }
214
317
  return {
215
318
  outcome: "error",
216
- message: value instanceof Error ? value.message : previewRouteData(value),
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),
217
331
  };
218
332
  }
219
- return { outcome: "data", preview: previewRouteData(value) };
333
+ return {
334
+ outcome: "data",
335
+ preview: previewRouteData(value),
336
+ ...serializeRouteData(value),
337
+ };
220
338
  }
221
339
 
222
340
  function instrumentRouteData(type, routeId, handler) {
@@ -225,16 +343,30 @@ function instrumentRouteData(type, routeId, handler) {
225
343
  const startedAt = Date.now();
226
344
  const started = performance.now();
227
345
  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
- });
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
+ };
238
370
  try {
239
371
  const result = await handler(args);
240
372
  report(result, false);
@@ -246,6 +378,55 @@ function instrumentRouteData(type, routeId, handler) {
246
378
  };
247
379
  }
248
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
+
249
430
  function instrumentRevalidate(routeId, shouldRevalidate) {
250
431
  if (!devtoolsHot) return shouldRevalidate;
251
432
  return (args) => {
@@ -254,6 +435,10 @@ function instrumentRevalidate(routeId, shouldRevalidate) {
254
435
  type: "revalidate",
255
436
  routeId,
256
437
  url: args?.nextUrl?.href,
438
+ currentUrl: args?.currentUrl?.href,
439
+ params: routeParams(args?.nextParams),
440
+ formMethod: args?.formMethod,
441
+ actionStatus: args?.actionStatus,
257
442
  startedAt: Date.now(),
258
443
  decision,
259
444
  defaultShouldRevalidate: args?.defaultShouldRevalidate,
@@ -290,6 +475,30 @@ if (devtoolsHot) {
290
475
  for (const event of ["vite-file-router:navigate", "popstate", "hashchange"]) {
291
476
  window.addEventListener(event, reportLocation);
292
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
+ });
293
502
  // Report again after the dev server restarts and the socket reconnects.
294
503
  devtoolsHot.on("vite:ws:connect", () => {
295
504
  reportedUrl = undefined;
@@ -311,6 +520,12 @@ function generateNode(node, root, context, inheritedBoundary) {
311
520
  route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
312
521
  }
313
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
+ }
314
529
  let loadingComponent;
315
530
  if (node.loading) {
316
531
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -358,7 +573,11 @@ function generateNode(node, root, context, inheritedBoundary) {
358
573
  if (children.length) route.children = genArrayFromRaw(children);
359
574
  return {
360
575
  route: genObjectFromRaw(route),
361
- 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)
362
581
  };
363
582
  }
364
583
  function generate(node, root = false, projectRoot = process.cwd(), options = {}) {
@@ -372,6 +591,7 @@ function generate(node, root = false, projectRoot = process.cwd(), options = {})
372
591
  hasBoundaries: false,
373
592
  hasLayouts: false,
374
593
  hasErrorBoundaries: false,
594
+ hasMiddleware: false,
375
595
  devClient: Boolean(options.devClient)
376
596
  };
377
597
  const { route: routes, preload: preloads } = generateNode(node, root, context, false);
@@ -409,6 +629,31 @@ function generate(node, root = false, projectRoot = process.cwd(), options = {})
409
629
  createElement(Component),
410
630
  );
411
631
  };
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
+ };
412
657
  }`);
413
658
  if (context.devClient) helpers.push(DEVTOOLS_CLIENT);
414
659
  return {
@@ -474,6 +719,80 @@ function createRouteDeclarations(tree, projectRoot, declarationFile) {
474
719
  // src/devtools.ts
475
720
  var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
476
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
+ }
477
796
  /**
478
797
  * Mirrors the generated React Router tree: each manifest node becomes a
479
798
  * route with an index child for its page and a `*` child for its not-found
@@ -524,6 +843,7 @@ async function matchRouteManifest(manifest, url) {
524
843
  ...leaf.kind === "notFound" ? { notFound: leaf.node?.files.notFound } : {},
525
844
  layouts: collect("layout"),
526
845
  loaders: collect("loader"),
846
+ middleware: collect("middleware"),
527
847
  ...nearest("loading") ? { loading: nearest("loading") } : {},
528
848
  ...nearest("error") ? { error: nearest("error") } : {}
529
849
  };
@@ -581,8 +901,19 @@ function createRouteEventStream(getState, getSnapshot = () => []) {
581
901
  /** HMR events the dev-only client in `virtual:routes` sends to the server. */
582
902
  var APP_LOCATION_EVENT = "vite-file-router:location";
583
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
+ }
584
913
  var MAX_ACTIVITY = 200;
585
914
  var MAX_TEXT = 600;
915
+ var MAX_DATA = 8e3;
916
+ var MAX_DETAIL = 4e3;
586
917
  function text(value, limit = MAX_TEXT) {
587
918
  if (typeof value !== "string") return void 0;
588
919
  return value.length > limit ? `${value.slice(0, limit)}…` : value;
@@ -590,6 +921,29 @@ function text(value, limit = MAX_TEXT) {
590
921
  function finite(value) {
591
922
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
592
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
+ }
593
947
  /**
594
948
  * Validates an event from the browser. Anything a page sends over the HMR
595
949
  * channel is untrusted, so only known fields of the expected types are kept.
@@ -598,7 +952,7 @@ function sanitizeDataEvent(input, id) {
598
952
  if (!input || typeof input !== "object") return void 0;
599
953
  const event = input;
600
954
  const type = event.type;
601
- if (type !== "loader" && type !== "action" && type !== "revalidate") return;
955
+ if (type !== "loader" && type !== "action" && type !== "middleware" && type !== "revalidate") return;
602
956
  const routeId = text(event.routeId, 400);
603
957
  const tabId = text(event.tabId, 100);
604
958
  if (!routeId || !tabId) return void 0;
@@ -612,13 +966,24 @@ function sanitizeDataEvent(input, id) {
612
966
  method: text(event.method, 20),
613
967
  startedAt: finite(event.startedAt) ?? Date.now(),
614
968
  duration: finite(event.duration),
615
- ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" ? { outcome } : {},
969
+ ...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" || outcome === "next" ? { outcome } : {},
616
970
  status: finite(event.status),
617
971
  location: text(event.location, 2e3),
618
972
  message: text(event.message),
619
973
  preview: text(event.preview),
620
974
  ...typeof event.decision === "boolean" ? { decision: event.decision } : {},
621
- ...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {}
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)
622
987
  };
623
988
  }
624
989
  /**
@@ -657,6 +1022,10 @@ function createAppTracker(options) {
657
1022
  });
658
1023
  changed();
659
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
+ },
660
1029
  disconnect(client) {
661
1030
  latestReport.delete(client);
662
1031
  if (tabs.delete(client)) changed();
@@ -688,6 +1057,11 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
688
1057
  var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
689
1058
  var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
690
1059
  var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
1060
+ var WARNING_KINDS = [
1061
+ "no-error-boundary",
1062
+ "no-not-found",
1063
+ "missing-export"
1064
+ ];
691
1065
  var DEFAULT_DTS = "src/route-types.d.ts";
692
1066
  var TYPESCRIPT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
693
1067
  function resolveDeclarationFile(dts, projectRoot) {
@@ -698,7 +1072,7 @@ function resolveDeclarationFile(dts, projectRoot) {
698
1072
  function resolveOptions(input) {
699
1073
  const options = typeof input === "string" ? { directory: input } : input;
700
1074
  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}".`);
1075
+ for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts" && key !== "warnings") throw new TypeError(`Unknown routes() option "${key}".`);
702
1076
  const directory = options.directory ?? "src/pages";
703
1077
  if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
704
1078
  const conventions = { ...DEFAULT_CONVENTIONS };
@@ -715,21 +1089,80 @@ function resolveOptions(input) {
715
1089
  return {
716
1090
  directory,
717
1091
  conventions,
718
- dts
1092
+ dts,
1093
+ warnings: resolveWarnings(options.warnings)
719
1094
  };
720
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: []
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");
1132
+ }
721
1133
  function routes(input = "src/pages") {
722
1134
  const options = resolveOptions(input);
723
1135
  let projectRoot = "";
724
1136
  let root = "";
725
1137
  let dev = false;
726
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
+ }
727
1158
  function getInspectorState() {
728
1159
  const tree = scan(root, options.conventions);
1160
+ const manifest = createRouteManifest(tree, projectRoot);
729
1161
  return {
730
1162
  projectRoot,
731
1163
  directory: path.relative(projectRoot, root).split(path.sep).join("/"),
732
- manifest: createRouteManifest(tree, projectRoot),
1164
+ manifest,
1165
+ warnings: findRouteWarnings(manifest, readSource),
733
1166
  conflicts: findPageRouteConflicts(tree).map(({ routePath, files }) => ({
734
1167
  routePath,
735
1168
  files: files.map((file) => path.relative(projectRoot, file).split(path.sep).join("/"))
@@ -925,6 +1358,13 @@ function routes(input = "src/pages") {
925
1358
  },
926
1359
  buildStart() {
927
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
+ });
928
1368
  },
929
1369
  resolveId: (id) => {
930
1370
  if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
@@ -946,6 +1386,43 @@ function routes(input = "src/pages") {
946
1386
  type: "iframe",
947
1387
  url: ROUTE_INSPECTOR_PATH
948
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
+ };
949
1426
  const sendJson = (res, body) => {
950
1427
  res.statusCode = 200;
951
1428
  res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -957,17 +1434,23 @@ function routes(input = "src/pages") {
957
1434
  if (![
958
1435
  "/__vite-file-router-data",
959
1436
  "/__vite-file-router-events",
960
- "/__vite-file-router-match"
1437
+ "/__vite-file-router-match",
1438
+ "/__vite-file-router-navigate"
961
1439
  ].includes(url.pathname)) {
962
1440
  next();
963
1441
  return;
964
1442
  }
965
- if (req.method !== "GET") {
1443
+ const method = url.pathname === "/__vite-file-router-navigate" ? "POST" : "GET";
1444
+ if (req.method !== method) {
966
1445
  res.statusCode = 405;
967
- res.setHeader("Allow", "GET");
1446
+ res.setHeader("Allow", method);
968
1447
  res.end("Method Not Allowed");
969
1448
  return;
970
1449
  }
1450
+ if (url.pathname === "/__vite-file-router-navigate") {
1451
+ navigateApp(req, res);
1452
+ return;
1453
+ }
971
1454
  if (url.pathname === "/__vite-file-router-data") {
972
1455
  sendJson(res, getInspectorState());
973
1456
  return;
@@ -1020,4 +1503,4 @@ function routes(input = "src/pages") {
1020
1503
  }
1021
1504
  };
1022
1505
  }
1023
- export { routes };
1506
+ export { formatRouteWarnings, routes };