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