@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/README.md +664 -536
- package/devtools/app.js +619 -34
- package/devtools/index.html +6 -0
- package/devtools/style.css +456 -102
- package/dist/devtools-app.d.ts +40 -2
- package/dist/devtools.d.ts +25 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +523 -40
- package/dist/scanner.d.ts +2 -1
- package/package.json +1 -1
- package/types/index.d.ts +3 -0
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;
|
|
181
|
-
* of
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
274
|
+
return undefined;
|
|
202
275
|
}
|
|
203
276
|
}
|
|
204
277
|
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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([
|
|
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
|
|
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
|
-
|
|
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",
|
|
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 };
|