@troyio/vite-file-router 0.3.2 → 0.4.1
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 +154 -6
- package/devtools/app.js +961 -143
- package/devtools/index.html +147 -33
- package/devtools/style.css +1136 -231
- package/dist/declarations.d.ts +9 -0
- package/dist/devtools-app.d.ts +59 -0
- package/dist/devtools.d.ts +50 -0
- package/dist/generator.d.ts +14 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +573 -33
- package/dist/scanner.d.ts +6 -1
- package/package.json +1 -1
- package/types/index.d.ts +143 -60
- package/types/virtual-routes.d.ts +46 -19
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { genArrayFromRaw, genObjectFromRaw, genSafeVariableName } from "knitwork";
|
|
4
5
|
import { format } from "prettier";
|
|
5
|
-
import fs from "node:fs";
|
|
6
6
|
// src/scanner.ts
|
|
7
7
|
var DEFAULT_CONVENTIONS = {
|
|
8
8
|
page: "page",
|
|
@@ -35,11 +35,17 @@ function getRouteParameter(name) {
|
|
|
35
35
|
optional: false
|
|
36
36
|
} : void 0;
|
|
37
37
|
}
|
|
38
|
-
function
|
|
38
|
+
function collectPageRoutes(node, segments = [], root = true) {
|
|
39
39
|
const routeSegments = root || node.routeGroup ? segments : [...segments, getRouteSegment(node.name)];
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const pages = node.page ? [{
|
|
41
|
+
pattern: `/${routeSegments.join("/")}`,
|
|
42
|
+
node
|
|
43
|
+
}] : [];
|
|
44
|
+
for (const child of node.children) pages.push(...collectPageRoutes(child, routeSegments, false));
|
|
45
|
+
return pages;
|
|
46
|
+
}
|
|
47
|
+
function collectPageRoutePatterns(node) {
|
|
48
|
+
return collectPageRoutes(node).map(({ pattern }) => pattern);
|
|
43
49
|
}
|
|
44
50
|
function scan(directory, conventions = DEFAULT_CONVENTIONS) {
|
|
45
51
|
const node = {
|
|
@@ -133,15 +139,15 @@ function generateImport(file, projectRoot) {
|
|
|
133
139
|
Component: (await import(${JSON.stringify(getImportPath(file, projectRoot))})).default
|
|
134
140
|
})`;
|
|
135
141
|
}
|
|
136
|
-
function generateRouteLazy(node, projectRoot, includeLayout, includeLoader) {
|
|
142
|
+
function generateRouteLazy(node, projectRoot, includeLayout, includeLoader, instrumentId) {
|
|
137
143
|
const properties = [];
|
|
138
144
|
if (includeLayout && node.layout) properties.push(`Component: withOutlet((await import(${JSON.stringify(getImportPath(node.layout, projectRoot))})).default)`);
|
|
139
145
|
if (includeLoader && node.loader) return `async () => {
|
|
140
146
|
const loaderModule = await import(${JSON.stringify(getImportPath(node.loader, projectRoot))});
|
|
141
147
|
return {
|
|
142
|
-
loader: loaderModule.loader,
|
|
148
|
+
loader: ${instrumentId ? `instrumentRouteData("loader", ${JSON.stringify(instrumentId)}, loaderModule.loader)` : "loaderModule.loader"},
|
|
143
149
|
...(typeof loaderModule.shouldRevalidate === "function"
|
|
144
|
-
? { shouldRevalidate: loaderModule.shouldRevalidate }
|
|
150
|
+
? { shouldRevalidate: ${instrumentId ? `instrumentRevalidate(${JSON.stringify(instrumentId)}, loaderModule.shouldRevalidate)` : "loaderModule.shouldRevalidate"} }
|
|
145
151
|
: {}),
|
|
146
152
|
${properties.join(",\n ")}
|
|
147
153
|
};
|
|
@@ -154,9 +160,143 @@ function generateLazyComponent(file, projectRoot) {
|
|
|
154
160
|
function generateLazyLayout(file, projectRoot) {
|
|
155
161
|
return `lazy(() => import(${JSON.stringify(getImportPath(file, projectRoot))}).then(({ default: Layout }) => ({ default: withOutlet(Layout) })))`;
|
|
156
162
|
}
|
|
157
|
-
function generateDataHandler(file, projectRoot) {
|
|
158
|
-
|
|
163
|
+
function generateDataHandler(file, projectRoot, instrumentId) {
|
|
164
|
+
const handler = `async (args) => (await import(${JSON.stringify(getImportPath(file, projectRoot))})).action(args)`;
|
|
165
|
+
return instrumentId ? `instrumentRouteData("action", ${JSON.stringify(instrumentId)}, ${handler})` : handler;
|
|
166
|
+
}
|
|
167
|
+
function getInstrumentId(node, context) {
|
|
168
|
+
return context.devClient ? getRouteId(node.directory, context.projectRoot) : void 0;
|
|
169
|
+
}
|
|
170
|
+
function generatePreload(files, projectRoot, children = []) {
|
|
171
|
+
const imports = files.filter((file) => Boolean(file)).map((file) => `import(${JSON.stringify(getImportPath(file, projectRoot))})`);
|
|
172
|
+
const preload = {};
|
|
173
|
+
if (imports.length) preload.load = `() => Promise.all([${imports.join(", ")}])`;
|
|
174
|
+
if (children.length) preload.children = genArrayFromRaw(children);
|
|
175
|
+
return genObjectFromRaw(preload);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Dev-only client for the DevTools Routes panel. It reports the tab's current
|
|
179
|
+
* URL and every loader, action, and `shouldRevalidate` call over Vite's HMR
|
|
180
|
+
* channel. Handler results pass through unchanged; only a short text preview
|
|
181
|
+
* of returned data is sent. Production builds never include this code.
|
|
182
|
+
*/
|
|
183
|
+
var DEVTOOLS_CLIENT = `const devtoolsHot =
|
|
184
|
+
typeof window === "undefined" ? undefined : import.meta.hot;
|
|
185
|
+
const devtoolsTabId = Math.random().toString(36).slice(2, 10);
|
|
186
|
+
|
|
187
|
+
function sendToDevtools(event, payload) {
|
|
188
|
+
try {
|
|
189
|
+
devtoolsHot?.send(event, { ...payload, tabId: devtoolsTabId });
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function previewRouteData(value) {
|
|
194
|
+
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;
|
|
200
|
+
} catch {
|
|
201
|
+
return String(value);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
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 };
|
|
212
|
+
}
|
|
213
|
+
if (thrown) {
|
|
214
|
+
return {
|
|
215
|
+
outcome: "error",
|
|
216
|
+
message: value instanceof Error ? value.message : previewRouteData(value),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { outcome: "data", preview: previewRouteData(value) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function instrumentRouteData(type, routeId, handler) {
|
|
223
|
+
if (!devtoolsHot || typeof handler !== "function") return handler;
|
|
224
|
+
return async (args) => {
|
|
225
|
+
const startedAt = Date.now();
|
|
226
|
+
const started = performance.now();
|
|
227
|
+
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
|
+
});
|
|
238
|
+
try {
|
|
239
|
+
const result = await handler(args);
|
|
240
|
+
report(result, false);
|
|
241
|
+
return result;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
report(error, true);
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
159
247
|
}
|
|
248
|
+
|
|
249
|
+
function instrumentRevalidate(routeId, shouldRevalidate) {
|
|
250
|
+
if (!devtoolsHot) return shouldRevalidate;
|
|
251
|
+
return (args) => {
|
|
252
|
+
const decision = shouldRevalidate(args);
|
|
253
|
+
sendToDevtools("vite-file-router:data", {
|
|
254
|
+
type: "revalidate",
|
|
255
|
+
routeId,
|
|
256
|
+
url: args?.nextUrl?.href,
|
|
257
|
+
startedAt: Date.now(),
|
|
258
|
+
decision,
|
|
259
|
+
defaultShouldRevalidate: args?.defaultShouldRevalidate,
|
|
260
|
+
});
|
|
261
|
+
return decision;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (devtoolsHot) {
|
|
266
|
+
let reportedUrl;
|
|
267
|
+
const currentUrl = () =>
|
|
268
|
+
window.location.hash.startsWith("#/")
|
|
269
|
+
? window.location.hash.slice(1)
|
|
270
|
+
: window.location.pathname + window.location.search;
|
|
271
|
+
const reportLocation = () => {
|
|
272
|
+
const url = currentUrl();
|
|
273
|
+
if (url === reportedUrl) return;
|
|
274
|
+
reportedUrl = url;
|
|
275
|
+
sendToDevtools("vite-file-router:location", { url, title: document.title });
|
|
276
|
+
};
|
|
277
|
+
// React Router's browser router navigates with pushState and replaceState,
|
|
278
|
+
// which fire no event, so they're wrapped once per page.
|
|
279
|
+
if (!window.__viteFileRouterHistoryPatched) {
|
|
280
|
+
window.__viteFileRouterHistoryPatched = true;
|
|
281
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
282
|
+
const original = history[method];
|
|
283
|
+
history[method] = function (...args) {
|
|
284
|
+
const result = original.apply(this, args);
|
|
285
|
+
window.dispatchEvent(new Event("vite-file-router:navigate"));
|
|
286
|
+
return result;
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const event of ["vite-file-router:navigate", "popstate", "hashchange"]) {
|
|
291
|
+
window.addEventListener(event, reportLocation);
|
|
292
|
+
}
|
|
293
|
+
// Report again after the dev server restarts and the socket reconnects.
|
|
294
|
+
devtoolsHot.on("vite:ws:connect", () => {
|
|
295
|
+
reportedUrl = undefined;
|
|
296
|
+
reportLocation();
|
|
297
|
+
});
|
|
298
|
+
reportLocation();
|
|
299
|
+
}`;
|
|
160
300
|
function getRouteId(directory, projectRoot) {
|
|
161
301
|
return `file-route:${path.relative(projectRoot, directory).split(path.sep).join("/") || "."}`;
|
|
162
302
|
}
|
|
@@ -170,7 +310,7 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
170
310
|
context.hasErrorBoundaries = true;
|
|
171
311
|
route.ErrorBoundary = `createErrorBoundary(${generateLazyComponent(node.error, context.projectRoot)})`;
|
|
172
312
|
}
|
|
173
|
-
if (node.action) route.action = generateDataHandler(node.action, context.projectRoot);
|
|
313
|
+
if (node.action) route.action = generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context));
|
|
174
314
|
let loadingComponent;
|
|
175
315
|
if (node.loading) {
|
|
176
316
|
loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
|
|
@@ -183,21 +323,27 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
183
323
|
} else if (node.layout) {
|
|
184
324
|
if (hasBoundary) route.Component = generateLazyLayout(node.layout, context.projectRoot);
|
|
185
325
|
}
|
|
186
|
-
if (node.layout && !hasBoundary || node.loader) route.lazy = generateRouteLazy(node, context.projectRoot, Boolean(node.layout && !hasBoundary), Boolean(node.loader));
|
|
326
|
+
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);
|
|
187
327
|
const children = [];
|
|
328
|
+
const preloadChildren = [];
|
|
188
329
|
if (node.page) {
|
|
189
330
|
const page = hasBoundary ? {
|
|
190
331
|
index: "true",
|
|
191
332
|
Component: generateLazyComponent(node.page, context.projectRoot),
|
|
192
|
-
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
333
|
+
...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
|
|
193
334
|
} : {
|
|
194
335
|
index: "true",
|
|
195
336
|
lazy: generateImport(node.page, context.projectRoot),
|
|
196
|
-
...node.action ? { action: generateDataHandler(node.action, context.projectRoot) } : {}
|
|
337
|
+
...node.action ? { action: generateDataHandler(node.action, context.projectRoot, getInstrumentId(node, context)) } : {}
|
|
197
338
|
};
|
|
198
339
|
children.push(genObjectFromRaw(page));
|
|
340
|
+
preloadChildren.push(generatePreload([node.page], context.projectRoot));
|
|
341
|
+
}
|
|
342
|
+
for (const child of node.children) {
|
|
343
|
+
const generated = generateNode(child, false, context, hasBoundary);
|
|
344
|
+
children.push(generated.route);
|
|
345
|
+
preloadChildren.push(generated.preload);
|
|
199
346
|
}
|
|
200
|
-
for (const child of node.children) children.push(generateNode(child, false, context, hasBoundary));
|
|
201
347
|
if (node.notFound) {
|
|
202
348
|
const notFoundRoute = hasBoundary ? {
|
|
203
349
|
path: JSON.stringify("*"),
|
|
@@ -207,11 +353,15 @@ function generateNode(node, root, context, inheritedBoundary) {
|
|
|
207
353
|
lazy: generateImport(node.notFound, context.projectRoot)
|
|
208
354
|
};
|
|
209
355
|
children.push(genObjectFromRaw(notFoundRoute));
|
|
356
|
+
preloadChildren.push(generatePreload([node.notFound], context.projectRoot));
|
|
210
357
|
}
|
|
211
358
|
if (children.length) route.children = genArrayFromRaw(children);
|
|
212
|
-
return
|
|
359
|
+
return {
|
|
360
|
+
route: genObjectFromRaw(route),
|
|
361
|
+
preload: generatePreload([node.layout, node.loader], context.projectRoot, preloadChildren)
|
|
362
|
+
};
|
|
213
363
|
}
|
|
214
|
-
function generate(node, root = false, projectRoot = process.cwd()) {
|
|
364
|
+
function generate(node, root = false, projectRoot = process.cwd(), options = {}) {
|
|
215
365
|
validateRouteGroupNotFound(node, [], root);
|
|
216
366
|
const conflicts = findPageRouteConflicts(node, /* @__PURE__ */ new Map(), [], root);
|
|
217
367
|
if (conflicts.length) throw new RouteConflictError(conflicts);
|
|
@@ -221,9 +371,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
221
371
|
nextLoadingId: 0,
|
|
222
372
|
hasBoundaries: false,
|
|
223
373
|
hasLayouts: false,
|
|
224
|
-
hasErrorBoundaries: false
|
|
374
|
+
hasErrorBoundaries: false,
|
|
375
|
+
devClient: Boolean(options.devClient)
|
|
225
376
|
};
|
|
226
|
-
const routes = generateNode(node, root, context, false);
|
|
377
|
+
const { route: routes, preload: preloads } = generateNode(node, root, context, false);
|
|
227
378
|
const imports = [...context.loadingImports];
|
|
228
379
|
const helpers = [];
|
|
229
380
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) {
|
|
@@ -232,8 +383,10 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
232
383
|
if (context.hasBoundaries || context.hasLayouts || context.hasErrorBoundaries) reactImports.push("lazy");
|
|
233
384
|
if (context.hasBoundaries || context.hasErrorBoundaries) reactImports.push("Suspense");
|
|
234
385
|
imports.unshift(`import { ${reactImports.join(", ")} } from "react";`);
|
|
235
|
-
if (context.hasBoundaries || context.hasLayouts) imports.push(`import { Outlet } from "react-router";`);
|
|
236
386
|
}
|
|
387
|
+
const routerImports = ["matchRoutes"];
|
|
388
|
+
if (context.hasBoundaries || context.hasLayouts) routerImports.push("Outlet");
|
|
389
|
+
imports.push(`import { ${routerImports.join(", ")} } from "react-router";`);
|
|
237
390
|
if (context.hasBoundaries) helpers.push(`function createBoundary(Fallback, Component) {
|
|
238
391
|
return function RouteBoundary() {
|
|
239
392
|
return createElement(
|
|
@@ -257,10 +410,275 @@ function generate(node, root = false, projectRoot = process.cwd()) {
|
|
|
257
410
|
);
|
|
258
411
|
};
|
|
259
412
|
}`);
|
|
413
|
+
if (context.devClient) helpers.push(DEVTOOLS_CLIENT);
|
|
260
414
|
return {
|
|
261
415
|
imports,
|
|
262
416
|
helpers,
|
|
263
|
-
routes
|
|
417
|
+
routes,
|
|
418
|
+
preloads
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// src/declarations.ts
|
|
422
|
+
var PACKAGE_NAME = "@troyio/vite-file-router";
|
|
423
|
+
function collectLoaders(node, loaders = []) {
|
|
424
|
+
if (node.loader) loaders.push(node);
|
|
425
|
+
for (const child of node.children) collectLoaders(child, loaders);
|
|
426
|
+
return loaders;
|
|
427
|
+
}
|
|
428
|
+
function getTypeImportPath(file, declarationFile) {
|
|
429
|
+
const relativePath = path.relative(path.dirname(declarationFile), file).split(path.sep).join("/").replace(/\.ts$/, "");
|
|
430
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Builds a declaration file that registers the discovered page patterns and
|
|
434
|
+
* loader modules with the package's `Register` interface. The package's
|
|
435
|
+
* public types read from that interface, so `routePath()` and the typed hooks
|
|
436
|
+
* only accept routes that exist.
|
|
437
|
+
*/
|
|
438
|
+
function createRouteDeclarations(tree, projectRoot, declarationFile) {
|
|
439
|
+
const routes = /* @__PURE__ */ new Map();
|
|
440
|
+
for (const { pattern, node } of collectPageRoutes(tree)) {
|
|
441
|
+
if (routes.has(pattern)) continue;
|
|
442
|
+
routes.set(pattern, node.loader ? getRouteId(node.directory, projectRoot) : null);
|
|
443
|
+
}
|
|
444
|
+
const routeLines = [...routes.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([pattern, loader]) => ` ${JSON.stringify(pattern)}: { loader: ${loader === null ? "null" : JSON.stringify(loader)} };`);
|
|
445
|
+
const loaderLines = collectLoaders(tree).map((node) => {
|
|
446
|
+
const id = getRouteId(node.directory, projectRoot);
|
|
447
|
+
const file = node.loader;
|
|
448
|
+
const moduleType = file.endsWith(".ts") ? `typeof import(${JSON.stringify(getTypeImportPath(file, declarationFile))})` : "{ loader: (...args: any[]) => any }";
|
|
449
|
+
return [id, ` ${JSON.stringify(id)}: ${moduleType};`];
|
|
450
|
+
}).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, line]) => line);
|
|
451
|
+
return [
|
|
452
|
+
`// Generated by ${PACKAGE_NAME}. Do not edit this file.`,
|
|
453
|
+
"// It is rewritten whenever route files are added, removed, or renamed.",
|
|
454
|
+
"",
|
|
455
|
+
"export {};",
|
|
456
|
+
"",
|
|
457
|
+
`declare module ${JSON.stringify(PACKAGE_NAME)} {`,
|
|
458
|
+
" interface Register {",
|
|
459
|
+
routeLines.length ? [
|
|
460
|
+
" routes: {",
|
|
461
|
+
...routeLines,
|
|
462
|
+
" };"
|
|
463
|
+
].join("\n") : " routes: {};",
|
|
464
|
+
loaderLines.length ? [
|
|
465
|
+
" loaders: {",
|
|
466
|
+
...loaderLines,
|
|
467
|
+
" };"
|
|
468
|
+
].join("\n") : " loaders: {};",
|
|
469
|
+
" }",
|
|
470
|
+
"}",
|
|
471
|
+
""
|
|
472
|
+
].join("\n");
|
|
473
|
+
}
|
|
474
|
+
// src/devtools.ts
|
|
475
|
+
var ROUTE_INSPECTOR_PATH = "/__vite-file-router/";
|
|
476
|
+
var HEARTBEAT_INTERVAL = 25e3;
|
|
477
|
+
/**
|
|
478
|
+
* Mirrors the generated React Router tree: each manifest node becomes a
|
|
479
|
+
* route with an index child for its page and a `*` child for its not-found
|
|
480
|
+
* page, so matching agrees with the runtime routes.
|
|
481
|
+
*/
|
|
482
|
+
function toMatchableRoute(node) {
|
|
483
|
+
const children = [];
|
|
484
|
+
if (node.files.page) children.push({
|
|
485
|
+
index: true,
|
|
486
|
+
node,
|
|
487
|
+
kind: "page"
|
|
488
|
+
});
|
|
489
|
+
for (const child of node.children) children.push(toMatchableRoute(child));
|
|
490
|
+
if (node.files.notFound) children.push({
|
|
491
|
+
path: "*",
|
|
492
|
+
node,
|
|
493
|
+
kind: "notFound"
|
|
494
|
+
});
|
|
495
|
+
return {
|
|
496
|
+
...node.segment === null ? {} : { path: node.segment },
|
|
497
|
+
node,
|
|
498
|
+
...children.length ? { children } : {}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async function matchRouteManifest(manifest, url) {
|
|
502
|
+
const { matchRoutes } = await import("react-router");
|
|
503
|
+
const pathname = new URL(url, "http://vite-file-router").pathname;
|
|
504
|
+
const matches = matchRoutes([toMatchableRoute(manifest)], pathname);
|
|
505
|
+
if (!matches) return {
|
|
506
|
+
matched: false,
|
|
507
|
+
pathname
|
|
508
|
+
};
|
|
509
|
+
const leaf = matches[matches.length - 1].route;
|
|
510
|
+
const chain = matches.filter(({ route }) => !route.kind).map(({ route }) => route.node);
|
|
511
|
+
const nearest = (kind) => [...chain].reverse().find((node) => node.files[kind])?.files[kind];
|
|
512
|
+
const collect = (kind) => chain.flatMap((node) => node.files[kind] ? [node.files[kind]] : []);
|
|
513
|
+
return {
|
|
514
|
+
matched: true,
|
|
515
|
+
pathname,
|
|
516
|
+
params: matches[matches.length - 1].params,
|
|
517
|
+
chain: chain.map(({ fullPath, segment, routeGroup, files }) => ({
|
|
518
|
+
fullPath,
|
|
519
|
+
segment,
|
|
520
|
+
routeGroup,
|
|
521
|
+
files
|
|
522
|
+
})),
|
|
523
|
+
...leaf.kind === "page" ? { page: leaf.node?.files.page } : {},
|
|
524
|
+
...leaf.kind === "notFound" ? { notFound: leaf.node?.files.notFound } : {},
|
|
525
|
+
layouts: collect("layout"),
|
|
526
|
+
loaders: collect("loader"),
|
|
527
|
+
...nearest("loading") ? { loading: nearest("loading") } : {},
|
|
528
|
+
...nearest("error") ? { error: nearest("error") } : {}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function writeEvent(response, event, data) {
|
|
532
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
533
|
+
}
|
|
534
|
+
/** Pushes route state to open DevTools panels over Server-Sent Events. */
|
|
535
|
+
function createRouteEventStream(getState, getSnapshot = () => []) {
|
|
536
|
+
const clients = /* @__PURE__ */ new Set();
|
|
537
|
+
let pending;
|
|
538
|
+
function send(response) {
|
|
539
|
+
writeEvent(response, "routes", getState());
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
clients,
|
|
543
|
+
connect(request, response) {
|
|
544
|
+
response.statusCode = 200;
|
|
545
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
546
|
+
response.setHeader("Cache-Control", "no-store");
|
|
547
|
+
response.setHeader("Connection", "keep-alive");
|
|
548
|
+
response.flushHeaders?.();
|
|
549
|
+
response.write("retry: 1000\n\n");
|
|
550
|
+
send(response);
|
|
551
|
+
for (const [event, data] of getSnapshot()) writeEvent(response, event, data);
|
|
552
|
+
clients.add(response);
|
|
553
|
+
const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), HEARTBEAT_INTERVAL);
|
|
554
|
+
heartbeat.unref?.();
|
|
555
|
+
request.on("close", () => {
|
|
556
|
+
clearInterval(heartbeat);
|
|
557
|
+
clients.delete(response);
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
/** Coalesces bursts of file events (such as a folder rename) into one push. */
|
|
561
|
+
notify() {
|
|
562
|
+
if (!clients.size) return;
|
|
563
|
+
clearTimeout(pending);
|
|
564
|
+
pending = setTimeout(() => {
|
|
565
|
+
pending = void 0;
|
|
566
|
+
for (const client of clients) send(client);
|
|
567
|
+
}, 50);
|
|
568
|
+
},
|
|
569
|
+
/** Sends a named event to every connected panel right away. */
|
|
570
|
+
broadcast(event, data) {
|
|
571
|
+
for (const client of clients) writeEvent(client, event, data);
|
|
572
|
+
},
|
|
573
|
+
close() {
|
|
574
|
+
clearTimeout(pending);
|
|
575
|
+
for (const client of clients) client.end();
|
|
576
|
+
clients.clear();
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// src/devtools-app.ts
|
|
581
|
+
/** HMR events the dev-only client in `virtual:routes` sends to the server. */
|
|
582
|
+
var APP_LOCATION_EVENT = "vite-file-router:location";
|
|
583
|
+
var APP_DATA_EVENT = "vite-file-router:data";
|
|
584
|
+
var MAX_ACTIVITY = 200;
|
|
585
|
+
var MAX_TEXT = 600;
|
|
586
|
+
function text(value, limit = MAX_TEXT) {
|
|
587
|
+
if (typeof value !== "string") return void 0;
|
|
588
|
+
return value.length > limit ? `${value.slice(0, limit)}…` : value;
|
|
589
|
+
}
|
|
590
|
+
function finite(value) {
|
|
591
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Validates an event from the browser. Anything a page sends over the HMR
|
|
595
|
+
* channel is untrusted, so only known fields of the expected types are kept.
|
|
596
|
+
*/
|
|
597
|
+
function sanitizeDataEvent(input, id) {
|
|
598
|
+
if (!input || typeof input !== "object") return void 0;
|
|
599
|
+
const event = input;
|
|
600
|
+
const type = event.type;
|
|
601
|
+
if (type !== "loader" && type !== "action" && type !== "revalidate") return;
|
|
602
|
+
const routeId = text(event.routeId, 400);
|
|
603
|
+
const tabId = text(event.tabId, 100);
|
|
604
|
+
if (!routeId || !tabId) return void 0;
|
|
605
|
+
const outcome = event.outcome;
|
|
606
|
+
return {
|
|
607
|
+
id,
|
|
608
|
+
tabId,
|
|
609
|
+
type,
|
|
610
|
+
routeId,
|
|
611
|
+
url: text(event.url, 2e3),
|
|
612
|
+
method: text(event.method, 20),
|
|
613
|
+
startedAt: finite(event.startedAt) ?? Date.now(),
|
|
614
|
+
duration: finite(event.duration),
|
|
615
|
+
...outcome === "data" || outcome === "response" || outcome === "redirect" || outcome === "error" ? { outcome } : {},
|
|
616
|
+
status: finite(event.status),
|
|
617
|
+
location: text(event.location, 2e3),
|
|
618
|
+
message: text(event.message),
|
|
619
|
+
preview: text(event.preview),
|
|
620
|
+
...typeof event.decision === "boolean" ? { decision: event.decision } : {},
|
|
621
|
+
...typeof event.defaultShouldRevalidate === "boolean" ? { defaultShouldRevalidate: event.defaultShouldRevalidate } : {}
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Tracks the app tabs connected to the dev server and their recent loader
|
|
626
|
+
* and action calls, and tells the panel when either changes.
|
|
627
|
+
*/
|
|
628
|
+
function createAppTracker(options) {
|
|
629
|
+
const tabs = /* @__PURE__ */ new Map();
|
|
630
|
+
const latestReport = /* @__PURE__ */ new Map();
|
|
631
|
+
const activity = [];
|
|
632
|
+
let nextEventId = 1;
|
|
633
|
+
let nextReport = 1;
|
|
634
|
+
const list = () => [...tabs.values()].sort((a, b) => a.connectedAt - b.connectedAt);
|
|
635
|
+
const changed = () => options.onTabsChange(list());
|
|
636
|
+
return {
|
|
637
|
+
tabs: list,
|
|
638
|
+
activity: () => [...activity],
|
|
639
|
+
async updateLocation(client, input) {
|
|
640
|
+
if (!input || typeof input !== "object") return;
|
|
641
|
+
const payload = input;
|
|
642
|
+
const id = text(payload.tabId, 100);
|
|
643
|
+
const url = text(payload.url, 2e3);
|
|
644
|
+
if (!id || !url?.startsWith("/")) return;
|
|
645
|
+
const report = nextReport++;
|
|
646
|
+
latestReport.set(client, report);
|
|
647
|
+
const match = await matchRouteManifest(options.getManifest(), url);
|
|
648
|
+
if (latestReport.get(client) !== report) return;
|
|
649
|
+
const now = Date.now();
|
|
650
|
+
tabs.set(client, {
|
|
651
|
+
id,
|
|
652
|
+
url,
|
|
653
|
+
title: text(payload.title, 200) ?? "",
|
|
654
|
+
connectedAt: tabs.get(client)?.connectedAt ?? now,
|
|
655
|
+
updatedAt: now,
|
|
656
|
+
match
|
|
657
|
+
});
|
|
658
|
+
changed();
|
|
659
|
+
},
|
|
660
|
+
disconnect(client) {
|
|
661
|
+
latestReport.delete(client);
|
|
662
|
+
if (tabs.delete(client)) changed();
|
|
663
|
+
},
|
|
664
|
+
/** Re-matches every tab, for example after route files change. */
|
|
665
|
+
async rematch() {
|
|
666
|
+
if (!tabs.size) return;
|
|
667
|
+
const manifest = options.getManifest();
|
|
668
|
+
for (const [client, tab] of tabs) tabs.set(client, {
|
|
669
|
+
...tab,
|
|
670
|
+
match: await matchRouteManifest(manifest, tab.url)
|
|
671
|
+
});
|
|
672
|
+
changed();
|
|
673
|
+
},
|
|
674
|
+
record(input) {
|
|
675
|
+
const event = sanitizeDataEvent(input, nextEventId);
|
|
676
|
+
if (!event) return;
|
|
677
|
+
nextEventId++;
|
|
678
|
+
activity.push(event);
|
|
679
|
+
if (activity.length > MAX_ACTIVITY) activity.shift();
|
|
680
|
+
options.onActivity(event);
|
|
681
|
+
}
|
|
264
682
|
};
|
|
265
683
|
}
|
|
266
684
|
// src/index.ts
|
|
@@ -270,12 +688,17 @@ var MANIFEST_MODULE_ID = "virtual:route-manifest";
|
|
|
270
688
|
var MANIFEST_RESOLVED_ID = `\0${MANIFEST_MODULE_ID}`;
|
|
271
689
|
var ROUTE_HELPERS_MODULE_ID = "virtual:route-helpers";
|
|
272
690
|
var ROUTE_HELPERS_RESOLVED_ID = `\0${ROUTE_HELPERS_MODULE_ID}`;
|
|
273
|
-
var
|
|
274
|
-
var
|
|
691
|
+
var DEFAULT_DTS = "src/route-types.d.ts";
|
|
692
|
+
var TYPESCRIPT_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
|
|
693
|
+
function resolveDeclarationFile(dts, projectRoot) {
|
|
694
|
+
if (dts === false) return void 0;
|
|
695
|
+
if (dts !== "auto") return path.resolve(projectRoot, dts);
|
|
696
|
+
return TYPESCRIPT_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectRoot, file))) ? path.resolve(projectRoot, DEFAULT_DTS) : void 0;
|
|
697
|
+
}
|
|
275
698
|
function resolveOptions(input) {
|
|
276
699
|
const options = typeof input === "string" ? { directory: input } : input;
|
|
277
700
|
if (!options || typeof options !== "object" || Array.isArray(options)) throw new TypeError("routes() expects a directory string or options object.");
|
|
278
|
-
for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions") throw new TypeError(`Unknown routes() option "${key}".`);
|
|
701
|
+
for (const key of Object.keys(options)) if (key !== "directory" && key !== "conventions" && key !== "dts") throw new TypeError(`Unknown routes() option "${key}".`);
|
|
279
702
|
const directory = options.directory ?? "src/pages";
|
|
280
703
|
if (typeof directory !== "string" || directory.trim() === "") throw new TypeError("routes() directory must be a non-empty path string.");
|
|
281
704
|
const conventions = { ...DEFAULT_CONVENTIONS };
|
|
@@ -287,9 +710,12 @@ function resolveOptions(input) {
|
|
|
287
710
|
}
|
|
288
711
|
const names = Object.values(conventions);
|
|
289
712
|
if (new Set(names).size !== names.length) throw new TypeError("Route file convention names must be unique.");
|
|
713
|
+
const dts = options.dts ?? "auto";
|
|
714
|
+
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\".");
|
|
290
715
|
return {
|
|
291
716
|
directory,
|
|
292
|
-
conventions
|
|
717
|
+
conventions,
|
|
718
|
+
dts
|
|
293
719
|
};
|
|
294
720
|
}
|
|
295
721
|
function routes(input = "src/pages") {
|
|
@@ -297,6 +723,32 @@ function routes(input = "src/pages") {
|
|
|
297
723
|
let projectRoot = "";
|
|
298
724
|
let root = "";
|
|
299
725
|
let dev = false;
|
|
726
|
+
let declarationFile;
|
|
727
|
+
function getInspectorState() {
|
|
728
|
+
const tree = scan(root, options.conventions);
|
|
729
|
+
return {
|
|
730
|
+
projectRoot,
|
|
731
|
+
directory: path.relative(projectRoot, root).split(path.sep).join("/"),
|
|
732
|
+
manifest: createRouteManifest(tree, projectRoot),
|
|
733
|
+
conflicts: findPageRouteConflicts(tree).map(({ routePath, files }) => ({
|
|
734
|
+
routePath,
|
|
735
|
+
files: files.map((file) => path.relative(projectRoot, file).split(path.sep).join("/"))
|
|
736
|
+
}))
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const appTracker = createAppTracker({
|
|
740
|
+
getManifest: () => createRouteManifest(scan(root, options.conventions), projectRoot),
|
|
741
|
+
onTabsChange: (tabs) => routeEvents.broadcast("apps", tabs),
|
|
742
|
+
onActivity: (event) => routeEvents.broadcast("activity", event)
|
|
743
|
+
});
|
|
744
|
+
const routeEvents = createRouteEventStream(getInspectorState, () => [["apps", appTracker.tabs()], ["activity-snapshot", appTracker.activity()]]);
|
|
745
|
+
function writeDeclarations() {
|
|
746
|
+
if (!declarationFile) return;
|
|
747
|
+
const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
|
|
748
|
+
if (fs.existsSync(declarationFile) && fs.readFileSync(declarationFile, "utf8") === content) return;
|
|
749
|
+
fs.mkdirSync(path.dirname(declarationFile), { recursive: true });
|
|
750
|
+
fs.writeFileSync(declarationFile, content);
|
|
751
|
+
}
|
|
300
752
|
function sortImports(imports) {
|
|
301
753
|
return imports.sort((left, right) => {
|
|
302
754
|
const leftSource = left.match(/from ["'](.+?)["']/)?.[1] ?? left;
|
|
@@ -328,14 +780,41 @@ function routes(input = "src/pages") {
|
|
|
328
780
|
}
|
|
329
781
|
|
|
330
782
|
export const routes = [{ path: "*", Component: RouteConflictPage }];
|
|
783
|
+
|
|
784
|
+
export async function preloadRoute() {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
331
787
|
`;
|
|
332
788
|
return format(code, { parser: "typescript" });
|
|
333
789
|
}
|
|
334
|
-
const generated = generate(tree, true, projectRoot);
|
|
790
|
+
const generated = generate(tree, true, projectRoot, { devClient: dev });
|
|
335
791
|
const sections = [
|
|
336
792
|
sortImports(generated.imports).join("\n"),
|
|
337
793
|
generated.helpers.join("\n"),
|
|
338
|
-
`export const routes = ${genArrayFromRaw([generated.routes])}
|
|
794
|
+
`export const routes = ${genArrayFromRaw([generated.routes])};`,
|
|
795
|
+
`const preloadTree = ${genArrayFromRaw([generated.preloads])};
|
|
796
|
+
const routePreloads = new WeakMap();
|
|
797
|
+
|
|
798
|
+
function registerPreloads(routeList, preloadList) {
|
|
799
|
+
routeList.forEach((route, index) => {
|
|
800
|
+
const preload = preloadList[index];
|
|
801
|
+
if (preload?.load) routePreloads.set(route, preload.load);
|
|
802
|
+
if (route.children) {
|
|
803
|
+
registerPreloads(route.children, preload?.children ?? []);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
registerPreloads(routes, preloadTree);
|
|
809
|
+
|
|
810
|
+
export async function preloadRoute(to, basename) {
|
|
811
|
+
const matches = matchRoutes(routes, to, basename);
|
|
812
|
+
if (!matches) return false;
|
|
813
|
+
await Promise.all(
|
|
814
|
+
matches.map(({ route }) => routePreloads.get(route)?.()),
|
|
815
|
+
);
|
|
816
|
+
return true;
|
|
817
|
+
}`
|
|
339
818
|
].filter(Boolean);
|
|
340
819
|
return format(sections.join("\n\n"), { parser: "typescript" });
|
|
341
820
|
}
|
|
@@ -346,8 +825,35 @@ function routes(input = "src/pages") {
|
|
|
346
825
|
async function executeRouteHelpers() {
|
|
347
826
|
const tree = scan(root, options.conventions);
|
|
348
827
|
const patterns = [...new Set(collectPageRoutePatterns(tree))].sort();
|
|
828
|
+
const routeLoaderIds = {};
|
|
829
|
+
for (const { pattern, node } of collectPageRoutes(tree)) if (node.loader && !Object.hasOwn(routeLoaderIds, pattern)) routeLoaderIds[pattern] = getRouteId(node.directory, projectRoot);
|
|
830
|
+
const collectLoaderIds = (node) => {
|
|
831
|
+
if (node.loader) {
|
|
832
|
+
const id = getRouteId(node.directory, projectRoot);
|
|
833
|
+
routeLoaderIds[id] = id;
|
|
834
|
+
}
|
|
835
|
+
node.children.forEach(collectLoaderIds);
|
|
836
|
+
};
|
|
837
|
+
collectLoaderIds(tree);
|
|
349
838
|
const code = `
|
|
839
|
+
import { useParams, useRouteLoaderData } from "react-router";
|
|
840
|
+
|
|
350
841
|
export const routePatterns = ${JSON.stringify(patterns)};
|
|
842
|
+
const routeLoaderIds = ${JSON.stringify(routeLoaderIds)};
|
|
843
|
+
|
|
844
|
+
export function useRouteParams(pattern) {
|
|
845
|
+
if (!routePatterns.includes(pattern)) {
|
|
846
|
+
throw new TypeError(\`Unknown route pattern "\${pattern}".\`);
|
|
847
|
+
}
|
|
848
|
+
return useParams();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function useRouteData(key) {
|
|
852
|
+
if (!Object.hasOwn(routeLoaderIds, key)) {
|
|
853
|
+
throw new TypeError(\`Route "\${key}" has no loader.\`);
|
|
854
|
+
}
|
|
855
|
+
return useRouteLoaderData(routeLoaderIds[key]);
|
|
856
|
+
}
|
|
351
857
|
|
|
352
858
|
export function routePath(pattern, ...args) {
|
|
353
859
|
if (!routePatterns.includes(pattern)) {
|
|
@@ -415,6 +921,10 @@ function routes(input = "src/pages") {
|
|
|
415
921
|
projectRoot = config.root;
|
|
416
922
|
root = path.resolve(projectRoot, options.directory);
|
|
417
923
|
dev = config.command === "serve";
|
|
924
|
+
declarationFile = resolveDeclarationFile(options.dts, projectRoot);
|
|
925
|
+
},
|
|
926
|
+
buildStart() {
|
|
927
|
+
writeDeclarations();
|
|
418
928
|
},
|
|
419
929
|
resolveId: (id) => {
|
|
420
930
|
if (id === ROUTES_MODULE_ID) return ROUTES_RESOLVED_ID;
|
|
@@ -436,8 +946,19 @@ function routes(input = "src/pages") {
|
|
|
436
946
|
type: "iframe",
|
|
437
947
|
url: ROUTE_INSPECTOR_PATH
|
|
438
948
|
});
|
|
949
|
+
const sendJson = (res, body) => {
|
|
950
|
+
res.statusCode = 200;
|
|
951
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
952
|
+
res.setHeader("Cache-Control", "no-store");
|
|
953
|
+
res.end(JSON.stringify(body));
|
|
954
|
+
};
|
|
439
955
|
ctx.viteServer.middlewares.use((req, res, next) => {
|
|
440
|
-
|
|
956
|
+
const url = new URL(req.url ?? "/", "http://vite-file-router");
|
|
957
|
+
if (![
|
|
958
|
+
"/__vite-file-router-data",
|
|
959
|
+
"/__vite-file-router-events",
|
|
960
|
+
"/__vite-file-router-match"
|
|
961
|
+
].includes(url.pathname)) {
|
|
441
962
|
next();
|
|
442
963
|
return;
|
|
443
964
|
}
|
|
@@ -447,11 +968,21 @@ function routes(input = "src/pages") {
|
|
|
447
968
|
res.end("Method Not Allowed");
|
|
448
969
|
return;
|
|
449
970
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
971
|
+
if (url.pathname === "/__vite-file-router-data") {
|
|
972
|
+
sendJson(res, getInspectorState());
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (url.pathname === "/__vite-file-router-events") {
|
|
976
|
+
routeEvents.connect(req, res);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const target = url.searchParams.get("url");
|
|
980
|
+
if (!target) {
|
|
981
|
+
res.statusCode = 400;
|
|
982
|
+
res.end("Missing \"url\" query parameter.");
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
matchRouteManifest(createRouteManifest(scan(root, options.conventions), projectRoot), target).then((result) => sendJson(res, result), next);
|
|
455
986
|
});
|
|
456
987
|
} },
|
|
457
988
|
configureServer: (server) => {
|
|
@@ -475,8 +1006,17 @@ function routes(input = "src/pages") {
|
|
|
475
1006
|
const module = server.moduleGraph.getModuleById(id);
|
|
476
1007
|
if (module) server.moduleGraph.invalidateModule(module);
|
|
477
1008
|
}
|
|
1009
|
+
writeDeclarations();
|
|
1010
|
+
routeEvents.notify();
|
|
1011
|
+
appTracker.rematch();
|
|
478
1012
|
server.ws.send({ type: "full-reload" });
|
|
479
1013
|
});
|
|
1014
|
+
server.httpServer?.on("close", routeEvents.close);
|
|
1015
|
+
server.ws.on(APP_LOCATION_EVENT, (data, client) => appTracker.updateLocation(client, data).catch(() => {}));
|
|
1016
|
+
server.ws.on(APP_DATA_EVENT, (data) => appTracker.record(data));
|
|
1017
|
+
server.ws.on("vite:client:disconnect", (_data, client) => {
|
|
1018
|
+
appTracker.disconnect(client);
|
|
1019
|
+
});
|
|
480
1020
|
}
|
|
481
1021
|
};
|
|
482
1022
|
}
|