diff --git a/apps/docs/content/docs/dev/plugins/meta.json b/apps/docs/content/docs/dev/plugins/meta.json index 7921fa120..b742f97e3 100644 --- a/apps/docs/content/docs/dev/plugins/meta.json +++ b/apps/docs/content/docs/dev/plugins/meta.json @@ -6,6 +6,7 @@ "pages": [ "create", "layouts-and-pages", + "route-manifest", "admin-page", "dashboard-widgets", "breadcrumbs", diff --git a/apps/docs/content/docs/dev/plugins/route-manifest.mdx b/apps/docs/content/docs/dev/plugins/route-manifest.mdx new file mode 100644 index 000000000..f35f70d05 --- /dev/null +++ b/apps/docs/content/docs/dev/plugins/route-manifest.mdx @@ -0,0 +1,240 @@ +--- +title: Route Manifest +description: Declare a plugin's public pages as data, so any VitNode app can serve them - not just Next.js ones. +--- + +Plugins have always shipped pages as Next.js route files, and VitNode copies them +into every app that installs the plugin. That works beautifully - right up until +the app isn't Next.js. + +The **route manifest** is the other way to say the same thing: a plugin declares +*what* pages it has and *where* they live, as plain data. The application decides +how to serve them. + + + This is new in Stage 5 and is a **parallel** path. Your `src/routes/main` + folder still works exactly as it did - nothing has been removed, and you don't + have to migrate anything today. + + +## Declaring a route + +Create `src/routes/manifest.ts` in your plugin and export a `routes` array: + +```ts title="plugins/blog/src/routes/manifest.ts" +import type { PluginRouteDefinition } from "@vitnode/core/routing"; + +export const routes: PluginRouteDefinition[] = [ + { + entry: "routes/post-page", // [!code highlight] + id: "post", + path: "/blog/:slug", // [!code highlight] + }, +]; +``` + +Four fields, and only one of them is optional: + +| Field | What it is | +| ------- | --------------------------------------------------------------------------------------------------- | +| `id` | Stable name for the page, unique inside your plugin. Name it after the page, not the URL. | +| `path` | The public URL, in VitNode's canonical spelling (see below). | +| `entry` | Package export subpath of the module that renders it - `"routes/post-page"`, no file extension. | +| `area` | Optional. `"main"` (the default) is the only area for now; the AdminCP keeps its own routing. | + +Then hand the same array to `buildPlugin`, so an app that registers your plugin +the usual way declares the same routes: + +```tsx title="plugins/blog/src/config.tsx" +import { buildPlugin } from "@vitnode/core/lib/plugin"; + +import { routes } from "./routes/manifest"; // [!code highlight] + +export const blogPlugin = () => + buildPlugin({ + pluginId: "@vitnode/blog", + routes, // [!code highlight] + }); +``` + +One list, read by both paths - so the two can never disagree. + +## Writing paths + +VitNode has its own spelling, and it is neither framework's: + +| Shape | VitNode | Next.js | TanStack Router | +| --------------- | -------------------------- | ------------------------ | ----------------------- | +| Static | `/blog` | `/blog` | `/blog` | +| Dynamic segment | `/blog/:slug` | `/blog/[slug]` | `/blog/$slug` | +| Nested | `/blog/:slug/comments` | `/blog/[slug]/comments` | `/blog/$slug/comments` | + +Write the VitNode one. If you paste `[slug]` or `$slug` in by muscle memory, the +error tells you so by name and hands you the right spelling - it doesn't just say +"invalid path". + +Catch-all (`/blog/*`), optional (`/blog/:slug?`) and repeating (`/blog/:slug+`) +segments are **not** supported yet. They're rejected on purpose rather than +half-guessed at. + +### Static segments are lowercase + +`/blog/post` is valid; `/Blog/Post` is not. Routers match URLs +case-insensitively, so `/Blog` and `/blog` are one page to a browser but two +different strings to VitNode's collision check - and a collision it can't see is +the one thing this whole layer exists to prevent. + +VitNode rejects the uppercase spelling instead of quietly lowercasing it, because +your public URL silently changing is worse than a build error that tells you what +to write: + +```txt +Plugin route "post" from @vitnode/blog has an invalid path: "/Blog/post" is not a +valid path: "Blog" has uppercase letters - VitNode route paths are lowercase, +because a router matches them case-insensitively and "/Blog" and "/blog" would be +one URL. Write "blog" instead. +``` + +Parameter names are variable names, not URL text, so `:postId` stays exactly as +camelCase as you like. + +## Building the manifest + +An application turns every plugin's declarations into one ordered list: + +```ts +import { buildPluginRouteManifest } from "@vitnode/core/routing"; + +const manifest = buildPluginRouteManifest(vitNodeConfig.plugins); +``` + +Each entry comes back validated, normalised and already parsed: + +```ts +{ + area: "main", + entry: "routes/post-page", + id: "@vitnode/blog:post", + path: "/blog/:slug", + pluginId: "@vitnode/blog", + routeId: "post", + segments: [ + { kind: "static", value: "blog" }, + { kind: "param", name: "slug" }, + ], +} +``` + +The order is decided by the paths, never by the order plugins were registered: a +static segment comes before a parameter at the same depth, so `/blog/new` always +wins over `/blog/:slug` no matter who loaded first. + +Need a framework-shaped path? The conversions are pure functions over +`segments`, so there's no second parser to disagree with the first: + +```ts +import { toNextRoutePath, toTanStackRoutePath } from "@vitnode/core/routing"; + +toNextRoutePath(route.segments); // "/blog/[slug]" +toTanStackRoutePath(route.segments); // "/blog/$slug" +``` + +## When two plugins want the same URL + +They can't have it, and VitNode won't pick for you: + +```txt +Plugin route path collision on "/hello" (main): @vitnode/example already owns +"/hello" as "@vitnode/example:hello", and @vitnode/blog declares "/hello" as +"@vitnode/blog:greeting". Two plugins cannot serve the same path - rename one +of them. +``` + +`/blog/:slug` and `/blog/:postId` collide too - different spelling, same URLs. +Duplicate ids, malformed paths and entries an app could never import all fail the +same way: loudly, at build time, naming the plugin. + +The same rule applies against the **application's own** pages. If the app serves +`/users/$id` from its own route files and your plugin declares `/users/:userId`, +that's one URL claimed twice and it fails - the parameter names differ, the URLs +don't. `/users/new` beside `/users/:id` is fine, because a router can tell a +static segment from a dynamic one. + +## How an app serves them + +The manifest says *what* exists. Serving it is the application's job, and the +TanStack Start app in `apps/web` is the first one to do it. Two files are +generated for it at build time, and neither of them is a page: + +```txt +src/plugin-route-manifest.gen.ts what routes exist, as the manifest above +src/plugin-routes.gen.ts how each route's module is imported +``` + +Both come from the plugins listed in `src/vitnode.config.ts` and the +`routes/manifest.ts` each of them ships. A plugin that is installed but not +configured contributes nothing - no directory is ever scanned. + +**Your page is not copied anywhere.** It stays in your plugin, compiled in your +own `dist`, and the app holds one generated line per route: + +```ts title="src/plugin-routes.gen.ts" +export const pluginRouteModules = { + "@vitnode/blog:post": () => import("@vitnode/blog/routes/post-page"), +}; +``` + +That line is a literal `import()`, which is the whole reason it is generated +rather than assembled at runtime: the bundler can follow it, so your page gets +its own chunk and stays out of the app's initial download until somebody visits +it. Nothing in the browser ever asks which plugins are installed. + +The app then joins the two by route id and registers each one on its **existing** +route tree - the same tree its own pages are in. There is no second router and no +separate route tree for plugins. + +### What you get for free + +Everything the app's own pages get, because your page is in the same route tree: + +- **Locale prefixes.** One route, every language. `/blog/hello` and + `/pl/blog/hello` are the same route, and your manifest never mentions a + language - the app strips the prefix before matching and writes it back into + every link it builds. +- **Client-side navigation.** During the Next.js -> TanStack migration, links ask + the route tree whether the app can render a destination. Register a route and + the answer changes to yes. There is no list of migrated routes to update. +- **Lazy loading**, per the chunk above. + +### Writing the page + +A route module exports a component as its default export, and that is the entire +contract: + +```tsx title="plugins/blog/src/routes/post-page.tsx" +const PostPage = () =>
Hello from the blog plugin
; + +export default PostPage; +``` + + + Keep these modules framework-neutral. Your plugin can be installed into a + Next.js app *and* a TanStack Start app at the same time, so anything from + `next/*`, `next-intl` or a router pins the page to one of them. Plain JSX and + shared VitNode components are pinned to neither. + + +## Learn More + + + + + diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 000000000..63c05df0d --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,6 @@ +# Generated, and rewritten on every build. Formatting them is churn at best: +# whatever Prettier changes is gone the next time the generator runs, and a +# reflow would make the output depend on how long a plugin's name happens to be. +src/routeTree.gen.ts +src/plugin-routes.gen.ts +src/plugin-route-manifest.gen.ts diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index e2c4cbd83..eebfd770a 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -18,6 +18,8 @@ export default [ ".tanstack/**", "dist/**", "src/routeTree.gen.ts", + "src/plugin-routes.gen.ts", + "src/plugin-route-manifest.gen.ts", "prettier.config.js", ], }, diff --git a/apps/web/package.json b/apps/web/package.json index 86755d359..35c85dd6f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "@vitejs/plugin-react": "^6.0.1", "@vitnode/config": "workspace:*", "eslint": "^10.7.0", + "jiti": "^2.7.0", "jsdom": "^29.1.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", diff --git a/apps/web/src/components/migration-link.tsx b/apps/web/src/components/migration-link.tsx index 16850bb30..30b483080 100644 --- a/apps/web/src/components/migration-link.tsx +++ b/apps/web/src/components/migration-link.tsx @@ -9,8 +9,9 @@ import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app' /** * Linking to a VitNode page while half of VitNode still runs on Next.js. * - * This app owns three routes today - `/`, `/discover` and the `/api/*` mount - - * and search results point at all of the ones it does not: `/blog/post-30`, + * This app owns four routes today - `/`, `/discover`, the `/api/*` mount and the + * `@vitnode/example` plugin's `/example` - and search results point at all of the + * ones it does not: `/blog/post-30`, * `/files/...`, `/admin/...`, whatever a plugin indexed. Handing every * internal-looking path to `` routes those into *this* router, which has * nothing to match them with, so a perfectly good blog post becomes a TanStack @@ -24,8 +25,10 @@ import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app' * * This is deliberately not a cross-framework navigation system, and there is no * hand-maintained table of migrated routes - the route tree *is* the table. When - * `/blog` is migrated it appears in the generated tree, `isTanStackOwnedPath` - * starts answering `true` for it, and nothing here changes. + * `/blog` is migrated it appears in the route tree, `isTanStackOwnedPath` starts + * answering `true` for it, and nothing here changes. Stage 5 is the proof: a + * plugin declared `/example`, `lib/plugin-routes.ts` mounted it on the same tree, + * and this file was not touched. */ /** @@ -55,7 +58,8 @@ const isApiRouteId = (routeId: string): boolean => * An unmatched path resolves to the root route alone, so "something below the * root matched" is the test. That also means a root-level catch-all route would * make every path look owned; there is none today, and - * `migration-link.test.tsx` fails loudly if one appears. + * `src/tests/plugin-routes.test.ts` fails loudly if one appears - it asserts that + * `/blog/post-30` is still somebody else's. */ export const isTanStackOwnedPath = ( router: AnyRouter, diff --git a/apps/web/src/lib/plugin-routes.ts b/apps/web/src/lib/plugin-routes.ts new file mode 100644 index 000000000..d5585092c --- /dev/null +++ b/apps/web/src/lib/plugin-routes.ts @@ -0,0 +1,287 @@ +import type { AnyRoute } from '@tanstack/react-router' +import type { + PluginRouteModuleLoader, + PluginRouteModuleRegistry, +} from '@vitnode/core/framework/plugin-routes' +import type { PluginRoute } from '@vitnode/core/routing' + +import { + createRoute, + joinPaths, + lazyRouteComponent, +} from '@tanstack/react-router' +import { + routeMatchKey, + routeMatchKeyFromTanStackPath, + toTanStackRoutePath, +} from '@vitnode/core/routing' + +/** + * Plugin pages, in this app's route tree. + * + * Three inputs, and the whole point of the design is that each one answers + * exactly one question: + * + * plugin-route-manifest.gen.ts what routes exist, at which VitNode path + * plugin-routes.gen.ts how each route's module is imported + * this module how that becomes a TanStack route + * + * Both generated files are written by `vitnode-plugin-routes.ts` from the + * plugins listed in `src/vitnode.config.ts` and the route manifest each of those + * plugins ships. Neither of them mentions a router, and no plugin page is copied + * into `src/routes` - the component stays compiled in the plugin's own `dist` and + * arrives here as a lazy import the bundler resolved at build time. + * + * What is deliberately *not* here: a locale. `/example` and `/pl/example` are the + * same route, because the router's rewrite strips the prefix before matching and + * writes it back into every link (`lib/i18n/client.ts`). A plugin declares the + * logical path and Stage 3 owns the public one, so there is nothing to prefix. + */ + +/** + * The pathless route every plugin page is mounted under. + * + * Pathless, so it contributes no URL segment: a plugin route at `/example` is + * served at `/example`, not at `/_plugins/example`. It earns its place by making + * the composition below **idempotent** - the plugin subtree is one child of the + * root, identifiable by this id, so re-running the composition replaces it + * instead of appending a second copy of every route. That is not a theoretical + * concern: in dev, Vite re-evaluates this module without re-evaluating + * `routeTree.gen.ts`, and the root route it mutates is the same object. + * + * It also gives the whole plugin subtree one name in the router devtools, and + * one place for a future stage to hang something every plugin page needs. + */ +export const PLUGIN_ROUTES_ROUTE_ID = '_plugins' + +/** + * What a plugin route module must export. + * + * A default export, because that is how every VitNode plugin page already + * exports itself and it is the one name a generated registry can rely on without + * being told. + */ +interface PluginRouteModule { + default: React.FunctionComponent +} + +/** One plugin route, paired with the loader that will fetch its component. */ +export interface PluginRouteSpec { + load: PluginRouteModuleLoader + /** {@link PluginRoute.path} in TanStack's spelling: `/blog/$slug`. */ + path: string + route: PluginRoute +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +/** + * A route's declared `path` and `id`, whichever of the two it has. + * + * `RouteOptions` is a union - a route declares a `path` *or* an `id`, never both + * - so neither field can be read off it directly even though every route object + * carries one of them. Both are optional here for exactly that reason. + */ +const declaredOptions = (route: AnyRoute): { id?: string; path?: string } => + route.options + +/** + * Checks that a loaded plugin route module actually exports a component. + * + * The registry's loaders are typed `() => Promise` on purpose - what a + * module is expected to export is not the registry's contract - so this is where + * `unknown` becomes something React can render, and it is checked rather than + * asserted. A plugin that exports the wrong thing gets a message naming itself; + * without this the failure is React's "type is invalid" from inside a lazy + * component, three frames away from the plugin that caused it. + */ +export const assertPluginRouteModule = ( + module: unknown, + routeId: string, +): PluginRouteModule => { + if (isRecord(module) && typeof module.default === 'function') { + return module as unknown as PluginRouteModule + } + + throw new Error( + `[VitNode plugin routes] The module for plugin route "${routeId}" does not export a component as its default export. A plugin route module is \`export default MyPage\`.`, + ) +} + +/** + * The manifest and the registry, joined by route id. + * + * Both generated files key on the manifest layer's own `:`, + * so the join needs no translation - and checking it in **both** directions is + * the point of doing it here rather than inline. They are written by one build + * from one read of one manifest, so a route in one and not the other means the + * two files are out of step, which is a stale generated file somebody committed + * or a half-finished build. Either way it fails now, naming the route, instead of + * becoming a 404 for a page that is definitely installed. + */ +export const pluginRouteSpecs = ( + manifest: readonly PluginRoute[], + registry: PluginRouteModuleRegistry, +): PluginRouteSpec[] => { + const specs = manifest.map((route) => { + const load = registry[route.id] + + if (!load) { + throw new Error( + `[VitNode plugin routes] Plugin route "${route.id}" is in the route manifest but has no module in the registry. Regenerate \`src/plugin-routes.gen.ts\` - the two generated files are out of step.`, + ) + } + + return { load, path: toTanStackRoutePath(route.segments), route } + }) + + const claimed = new Set(manifest.map((route) => route.id)) + const orphans = Object.keys(registry).filter((key) => !claimed.has(key)) + + if (orphans.length > 0) { + throw new Error( + `[VitNode plugin routes] The registry has modules for routes that are not in the route manifest: ${orphans.join(', ')}. Regenerate \`src/plugin-route-manifest.gen.ts\` - the two generated files are out of step.`, + ) + } + + return specs +} + +/** + * Every URL this app's own route files already claim. + * + * Walked rather than read off the route tree's types, because a route's + * `fullPath` is only computed once the router initialises it and this runs + * before that. + * + * **Every route that declares a path claims one**, children or not. A TanStack + * route can be a page *and* a layout - `discover.tsx` with a `discover/` folder + * beside it renders at `/discover` and wraps everything under it - so treating a + * route with children as "just a layout" would quietly hand `/discover` to a + * plugin. A pathless route claims nothing, which is what pathless means: it + * contributes no segment and answers no URL, only its children do. + * + * The plugin subtree is skipped, so this stays the app's own answer no matter how + * many times the composition has run. + */ +export const fileRoutePaths = (routeTree: AnyRoute): string[] => { + const walk = (route: AnyRoute, prefix: string): string[] => { + const { id, path } = declaredOptions(route) + + if (id === PLUGIN_ROUTES_ROUTE_ID) return [] + + const declaresPath = typeof path === 'string' && path.length > 0 + const here = declaresPath ? joinPaths([prefix, path]) : prefix + const children: AnyRoute[] = route.children ?? [] + + return [ + ...(declaresPath ? [here] : []), + ...children.flatMap((child) => walk(child, here)), + ] + } + + return (routeTree.children ?? []).flatMap((child: AnyRoute) => + walk(child, '/'), + ) +} + +/** + * Refuses a plugin route that would answer a URL this app already answers. + * + * `buildPluginRouteManifest` already rejects two *plugins* claiming one URL and + * cannot see this case: it never knows which application it is being built for. + * Without this the app would hold two routes matching one pathname and let the + * router's own ranking pick, which is the "last route wins" outcome the manifest + * layer exists to make impossible. + * + * Compared by **match key, not by text**, and that is the whole substance of this + * function. `/users/$id` and `/users/:userId` are the same URL space spelled two + * ways in two syntaxes, and a string comparison sees two different strings: + * + * app /users/$id -> /users/: ┐ collide + * plugin /users/:userId -> /users/: ┘ + * + * app /users/new -> /users/new ┐ do not collide - a router tells + * plugin /users/:id -> /users/: ┘ static from dynamic + * + * Both sides go through the routing package's one key space: the plugin through + * `routeMatchKey` over its parsed segments, the app through + * `routeMatchKeyFromTanStackPath` over the string its router holds. Same rule as + * plugin-vs-plugin, because it is the same function. + * + * The first application path to claim a key is the one named in the error - the + * app's route files cannot collide with each other, so which one it is only + * affects the message. + */ +const assertNoAppCollision = ( + specs: PluginRouteSpec[], + appPaths: string[], +): void => { + const claimed = new Map() + + for (const appPath of appPaths) { + const key = routeMatchKeyFromTanStackPath(appPath) + + if (!claimed.has(key)) claimed.set(key, appPath) + } + + for (const spec of specs) { + const conflict = claimed.get(routeMatchKey(spec.route.segments)) + + if (conflict === undefined) continue + + throw new Error( + `[VitNode plugin routes] Plugin route "${spec.route.id}" claims "${spec.route.path}", which conflicts with application route "${conflict}". Both match the same URLs, and this app will not let a router's ordering decide which one answers - rename the plugin's route.`, + ) + } +} + +/** + * Mounts the plugin routes on a route tree, and hands the same tree back. + * + * `addChildren` **replaces** a route's children and mutates the route in place, + * so the plugin subtree is rebuilt from the root's current children with any + * previous copy of itself removed. Calling this twice on one tree is therefore + * the same as calling it once, which is what makes it safe in a dev server that + * re-evaluates this module while `routeTree.gen.ts` stays cached. + * + * The route's component is a `lazyRouteComponent` over the registry's loader, + * which is the supported way to code-split a code-based route: the plugin's page + * gets its own Rollup chunk, stays out of the initial bundle, and the router + * awaits `component.preload()` before it renders the match - so SSR and + * hydration both have the module in hand rather than suspending on it. + */ +export const withPluginRoutes = ( + routeTree: TRouteTree, + specs: PluginRouteSpec[], +): TRouteTree => { + if (specs.length === 0) return routeTree + + assertNoAppCollision(specs, fileRoutePaths(routeTree)) + + const container = createRoute({ + getParentRoute: () => routeTree, + id: PLUGIN_ROUTES_ROUTE_ID, + }) + + container.addChildren( + specs.map((spec) => + createRoute({ + component: lazyRouteComponent(async () => + assertPluginRouteModule(await spec.load(), spec.route.id), + ), + getParentRoute: () => container, + path: spec.path, + }), + ), + ) + + const siblings: AnyRoute[] = (routeTree.children ?? []).filter( + (child: AnyRoute) => declaredOptions(child).id !== PLUGIN_ROUTES_ROUTE_ID, + ) + + routeTree.addChildren([...siblings, container]) + + return routeTree +} diff --git a/apps/web/src/plugin-route-manifest.gen.ts b/apps/web/src/plugin-route-manifest.gen.ts new file mode 100644 index 000000000..f008b73a5 --- /dev/null +++ b/apps/web/src/plugin-route-manifest.gen.ts @@ -0,0 +1,40 @@ +/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the `vitnode:plugin-routes` Vite plugin on every +// `vite dev` and `vite build`, from the plugins configured in +// `src/vitnode.config.ts` and the route manifest each of those plugins ships. +// +// This is the *what*: which routes exist, and at which canonical VitNode path. +// Its sibling `plugin-routes.gen.ts` is the *how*: one lazy import per route +// module. Neither knows what a router is. +// +// Same plugin configuration in, same bytes out: the routes are sorted by path. + +import type { PluginRoute } from '@vitnode/core/routing' + +/** + * Every route this app's configured plugins contribute, already validated. + * + * `buildPluginRouteManifest` produced this list while the app was being built, + * which is the whole reason it is a literal here: a path that cannot be parsed, + * an entry that cannot be imported and two plugins claiming one URL have all + * already failed the build by the time this file exists. Nothing reads it to + * find out whether the routes are valid - only to find out what they are. + * + * Deliberately not `as const`: a route's `segments` is a mutable array on + * `PluginRoute`, and a frozen tuple would not satisfy it. `satisfies` alone + * still checks every field and still narrows `area` and `kind` to their unions. + */ +export const pluginRouteManifest = [ + { + area: 'main', + entry: 'routes/example-page', + id: '@vitnode/example:example-page', + path: '/example', + pluginId: '@vitnode/example', + routeId: 'example-page', + segments: [{ kind: 'static', value: 'example' }], + }, +] satisfies readonly PluginRoute[] diff --git a/apps/web/src/plugin-routes.gen.ts b/apps/web/src/plugin-routes.gen.ts new file mode 100644 index 000000000..a1abf9443 --- /dev/null +++ b/apps/web/src/plugin-routes.gen.ts @@ -0,0 +1,54 @@ +/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the `vitnode:plugin-routes` Vite plugin on every +// `vite dev` and `vite build`, from two inputs and nothing else: the plugins +// configured in `src/vitnode.config.ts`, and the route manifest each of those +// plugins ships. Nothing here is discovered at runtime - the browser is handed +// this module, never a filesystem. +// +// Same plugin configuration in, same bytes out: the entries are sorted by key. + +import type { + PluginRouteModuleRegistry, + ResolvedPluginRouteModule, +} from '@vitnode/core/framework/plugin-routes' + +/** + * Every configured plugin's route modules, keyed by `:`. + * + * The specifiers below are literal, which is the whole point of generating this + * file: Vite resolves them at build time and Rollup gives each module its own + * chunk, fetched when a loader is first called. No route component is in the + * initial bundle, and none of them is reached through a computed string - the + * browser never asks what is installed. + * + * `satisfies` rather than a type annotation, deliberately: it checks the shape + * while keeping both the literal keys and each module's real export types, so a + * consumer's `await load()` is typed by the module it loaded. + */ +export const pluginRouteModules = { + '@vitnode/example:example-page': () => import('@vitnode/example/routes/example-page'), +} satisfies PluginRouteModuleRegistry + +/** Every key in {@link pluginRouteModules}, as a union. */ +export type PluginRouteKey = keyof typeof pluginRouteModules + +/** + * The same route modules as plain data, in the same order. + * + * An object literal carries nothing beyond its keys, so the plugin id, the route + * id and the specifier are repeated here for whatever builds the router: it can + * walk this array, join each entry to its plugin's own route manifest by + * `pluginId` and `routeId`, and look the loader up by `key`. + */ +export const pluginRouteEntries = [ + { + entry: 'routes/example-page', + key: '@vitnode/example:example-page', + pluginId: '@vitnode/example', + routeId: 'example-page', + specifier: '@vitnode/example/routes/example-page', + }, +] as const satisfies readonly ResolvedPluginRouteModule[] diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 06da98f3b..27157af6b 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -5,7 +5,27 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query import { createVitNodeQueryClient } from '@vitnode/core/lib/query-client' import { createLocaleRewrite } from './lib/i18n/client' -import { routeTree } from './routeTree.gen' +import { pluginRouteSpecs, withPluginRoutes } from './lib/plugin-routes' +import { pluginRouteManifest } from './plugin-route-manifest.gen' +import { pluginRouteModules } from './plugin-routes.gen' +import { routeTree as fileRouteTree } from './routeTree.gen' + +/** + * One route tree: this app's route files, plus the pages its plugins declare. + * + * At module scope rather than inside `getRouter`, because `getRouter` runs once + * per server request and mounting the plugin routes mutates the route tree - the + * generated tree is a module singleton. `withPluginRoutes` is idempotent anyway; + * doing it once is simply where it belongs. + * + * The plugin half comes from two generated files and is joined by route id. No + * plugin page is copied into `src/routes`, no route path is written by hand, and + * nothing here knows which plugins are installed - see `lib/plugin-routes.ts`. + */ +const routeTree = withPluginRoutes( + fileRouteTree, + pluginRouteSpecs(pluginRouteManifest, pluginRouteModules), +) /** * The app's router, and the QueryClient it owns. diff --git a/apps/web/src/tests/plugin-routes.test.ts b/apps/web/src/tests/plugin-routes.test.ts new file mode 100644 index 000000000..e8c77fef0 --- /dev/null +++ b/apps/web/src/tests/plugin-routes.test.ts @@ -0,0 +1,395 @@ +import type { AnyRoute } from '@tanstack/react-router' +import type { PluginRouteModuleRegistry } from '@vitnode/core/framework/plugin-routes' +import type { PluginRoute } from '@vitnode/core/routing' + +import { createRootRoute, createRoute } from '@tanstack/react-router' +import { describe, expect, it } from 'vitest' + +import { isTanStackOwnedPath } from '#/components/migration-link' +import { + assertPluginRouteModule, + fileRoutePaths, + PLUGIN_ROUTES_ROUTE_ID, + pluginRouteSpecs, + withPluginRoutes, +} from '#/lib/plugin-routes' +import { pluginRouteManifest } from '#/plugin-route-manifest.gen' +import { pluginRouteModules } from '#/plugin-routes.gen' +import { getRouter } from '#/router' + +/** + * Plugin routes, from a plugin's declaration to a URL this app answers. + * + * Everything here is route *data*: what the two generated files say, what the + * composition builds out of them, and what the resulting route tree claims to + * own. Nothing renders - whether the plugin's page produces the right HTML is + * the plugin's own business, and a component test would only assert that + * `lazyRouteComponent` works. + */ + +const route = (overrides: Partial = {}): PluginRoute => ({ + area: 'main', + entry: 'routes/page', + id: 'plugin:page', + path: '/page', + pluginId: 'plugin', + routeId: 'page', + segments: [{ kind: 'static', value: 'page' }], + ...overrides, +}) + +const registryOf = (...keys: string[]): PluginRouteModuleRegistry => + Object.fromEntries( + keys.map((key) => [ + key, + async () => Promise.resolve({ default: () => null }), + ]), + ) + +describe('pluginRouteSpecs', () => { + it('pairs each route with its module and converts the path for TanStack', () => { + const specs = pluginRouteSpecs( + [ + route({ + id: 'plugin:article', + path: '/blog/:slug', + routeId: 'article', + segments: [ + { kind: 'static', value: 'blog' }, + { kind: 'param', name: 'slug' }, + ], + }), + ], + registryOf('plugin:article'), + ) + + expect(specs).toHaveLength(1) + // `:slug` in the manifest, `$slug` in the router. Neither spelling is the + // other's, which is the reason the conversion is a function and not a regex + // written twice. + expect(specs[0].path).toBe('/blog/$slug') + expect(specs[0].route.path).toBe('/blog/:slug') + }) + + it('leaves an app with no plugin routes with nothing to register', () => { + expect(pluginRouteSpecs([], {})).toEqual([]) + }) + + it('rejects a manifest route the registry has no module for', () => { + expect(() => pluginRouteSpecs([route()], {})).toThrow(/plugin:page/) + }) + + it('rejects a registry module no manifest route claims', () => { + expect(() => pluginRouteSpecs([], registryOf('plugin:page'))).toThrow( + /plugin:page/, + ) + }) +}) + +describe('assertPluginRouteModule', () => { + it('accepts a module with a component as its default export', () => { + const module = { default: () => null } + + expect(assertPluginRouteModule(module, 'plugin:page')).toBe(module) + }) + + it.each([ + ['no default export', {}], + ['a default export that is not a component', { default: 'page' }], + ['nothing at all', null], + ])('rejects a module with %s', (_label, module) => { + expect(() => assertPluginRouteModule(module, 'plugin:page')).toThrow( + /plugin:page/, + ) + }) +}) + +describe('withPluginRoutes', () => { + const appTree = () => { + const root = createRootRoute() + + return root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/' }), + createRoute({ getParentRoute: () => root, path: '/discover' }), + ]) + } + + const pluginChildren = (tree: ReturnType) => + (tree.children ?? []) + .filter( + (child) => + (child.options as { id?: string }).id === PLUGIN_ROUTES_ROUTE_ID, + ) + .flatMap((container) => container.children ?? []) + .map((child) => (child.options as { path?: string }).path) + + it('mounts one route per plugin route, under the plugin container', () => { + const tree = withPluginRoutes( + appTree(), + pluginRouteSpecs( + [route({ path: '/example' })], + registryOf('plugin:page'), + ), + ) + + expect(pluginChildren(tree)).toEqual(['/page']) + expect(fileRoutePaths(tree)).toEqual(['/', '/discover']) + }) + + it('leaves the app route tree alone when no plugin declares a route', () => { + const tree = withPluginRoutes(appTree(), []) + + expect(pluginChildren(tree)).toEqual([]) + expect(tree.children).toHaveLength(2) + }) + + /** + * The property that keeps a dev server honest. Vite re-evaluates the module + * that composes the tree without re-evaluating `routeTree.gen.ts`, so the + * composition runs more than once against the same root route object - and + * `addChildren` replaces rather than appends only because the plugin subtree is + * one identifiable child. + */ + it('replaces the plugin subtree rather than appending a second copy', () => { + const tree = appTree() + const specs = pluginRouteSpecs([route()], registryOf('plugin:page')) + + withPluginRoutes(tree, specs) + withPluginRoutes(tree, specs) + + expect(pluginChildren(tree)).toEqual(['/page']) + expect(tree.children).toHaveLength(3) + }) + + /** + * The manifest layer rejects two plugins claiming one URL and cannot see this + * case - it does not know which application it is being built for. + */ + it('refuses a plugin route that would shadow one of the app’s own pages', () => { + expect(() => + withPluginRoutes( + appTree(), + pluginRouteSpecs( + [ + route({ + path: '/discover', + segments: [{ kind: 'static', value: 'discover' }], + }), + ], + registryOf('plugin:page'), + ), + ), + ).toThrow(/discover/) + }) +}) + +/** + * Plugin-vs-application collisions, compared by the URLs a route matches rather + * than by the text of its path. + * + * The two sides are written in different syntaxes and name their parameters + * independently, so `/users/$id` and `/users/:userId` are the same route spelled + * two ways - and a string comparison sees two different strings. + */ +describe('plugin ↔ application collisions', () => { + const treeWith = (...paths: string[]) => { + const root = createRootRoute() + + return root.addChildren( + paths.map((path) => createRoute({ getParentRoute: () => root, path })), + ) + } + + const mount = ( + tree: AnyRoute, + path: string, + segments: PluginRoute['segments'], + ) => + withPluginRoutes( + tree, + pluginRouteSpecs([route({ path, segments })], registryOf('plugin:page')), + ) + + const param = (name: string) => ({ kind: 'param' as const, name }) + const staticSegment = (value: string) => ({ kind: 'static' as const, value }) + + it.each([ + ['/users/$id', '/users/:userId', [staticSegment('users'), param('userId')]], + [ + '/blog/$slug/comments', + '/blog/:postId/comments', + [staticSegment('blog'), param('postId'), staticSegment('comments')], + ], + ['/discover', '/discover', [staticSegment('discover')]], + ] as const)( + 'refuses app %s against plugin %s', + (appPath, pluginPath, segments) => { + expect(() => mount(treeWith(appPath), pluginPath, [...segments])).toThrow( + /conflicts with application route/, + ) + }, + ) + + it.each([ + ['/users/new', '/users/:id', [staticSegment('users'), param('id')]], + [ + '/users/$id', + '/users/new', + [staticSegment('users'), staticSegment('new')], + ], + ['/discover', '/example', [staticSegment('example')]], + ] as const)( + 'allows app %s beside plugin %s', + (appPath, pluginPath, segments) => { + expect(() => + mount(treeWith(appPath), pluginPath, [...segments]), + ).not.toThrow() + }, + ) + + it('names the plugin route, its canonical path and the app route it hit', () => { + let message = '' + + try { + mount(treeWith('/users/$id'), '/users/:userId', [ + staticSegment('users'), + param('userId'), + ]) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + expect(message).toContain('plugin:page') + expect(message).toContain('/users/:userId') + expect(message).toContain('/users/$id') + }) +}) + +/** + * What the application is understood to already claim. + * + * A TanStack route can be a page *and* a layout at once, so "has children" does + * not mean "claims no URL" - and a pathless route claims nothing by definition. + */ +describe('fileRoutePaths', () => { + it('includes a route that has both a path and children', () => { + const root = createRootRoute() + const blog = createRoute({ getParentRoute: () => root, path: '/blog' }) + + blog.addChildren([ + createRoute({ getParentRoute: () => blog, path: '/' }), + createRoute({ getParentRoute: () => blog, path: '/$slug' }), + ]) + + expect(fileRoutePaths(root.addChildren([blog]))).toEqual([ + '/blog', + '/blog/', + '/blog/$slug', + ]) + }) + + it('does not let a pathless layout claim a URL', () => { + const root = createRootRoute() + const layout = createRoute({ getParentRoute: () => root, id: '_shell' }) + + layout.addChildren([ + createRoute({ getParentRoute: () => layout, path: '/settings' }), + ]) + + expect(fileRoutePaths(root.addChildren([layout]))).toEqual(['/settings']) + }) + + it('excludes the plugin container and everything under it', () => { + const tree = withPluginRoutes( + (() => { + const root = createRootRoute() + + return root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/discover' }), + ]) + })(), + pluginRouteSpecs([route()], registryOf('plugin:page')), + ) + + expect(fileRoutePaths(tree)).toEqual(['/discover']) + }) + + /** + * The regression the leaf-only walk allowed: a parent route that is also a + * page could be claimed by a plugin. + */ + it('protects a parent route that is also a page', () => { + const root = createRootRoute() + const blog = createRoute({ getParentRoute: () => root, path: '/blog' }) + + blog.addChildren([ + createRoute({ getParentRoute: () => blog, path: '/$slug' }), + ]) + + expect(() => + withPluginRoutes( + root.addChildren([blog]), + pluginRouteSpecs( + [ + route({ + path: '/blog', + segments: [{ kind: 'static', value: 'blog' }], + }), + ], + registryOf('plugin:page'), + ), + ), + ).toThrow(/conflicts with application route/) + }) +}) + +describe("the app's real route tree", () => { + /** + * The exit criterion, asserted against what the app actually ships rather than + * a fixture: the prototype plugin route is in the one route tree, and it got + * there from the two generated files. + */ + it('serves the example plugin’s page', () => { + expect(pluginRouteManifest.map((route) => route.path)).toContain('/example') + expect(Object.keys(pluginRouteModules)).toContain( + '@vitnode/example:example-page', + ) + + const router = getRouter() + + expect( + router.matchRoutes('/example', undefined).map((match) => match.routeId), + ).toContain(`/${PLUGIN_ROUTES_ROUTE_ID}/example`) + }) + + /** + * `MigrationLink` asks the route tree what this app owns and there is no + * hand-written list of migrated routes - so registering a plugin route is all + * it takes for the link to become a client-side navigation. Asserted through + * the same helper the component calls, which is a plain function over a router. + */ + it.each([ + ['/example', true], + ['/pl/example', true], + ['/example?from=search#top', true], + ['/discover', true], + ['/blog/post-30', false], + ['/api/core/members', false], + ])('answers %s as owned: %s', (href, owned) => { + expect(isTanStackOwnedPath(getRouter(), href)).toBe(owned) + }) + + /** + * A plugin route must not be reachable at a URL nobody declared. `/pl/example` + * works because Stage 3's rewrite strips the prefix before matching; an unknown + * prefix reaches the route tree intact and matches nothing. + */ + it('does not invent locale-prefixed routes of its own', () => { + expect(isTanStackOwnedPath(getRouter(), '/xx/example')).toBe(false) + expect( + fileRoutePaths(getRouter().routeTree).some((path) => + path.includes('/pl/'), + ), + ).toBe(false) + }) +}) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e4e3a5631..6a4a24248 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,7 @@ import { nitro } from 'nitro/vite' import { defineConfig } from 'vite' import { vitNodeEnv } from './vitnode-env' +import { vitNodePluginRoutes } from './vitnode-plugin-routes' const config = defineConfig({ resolve: { tsconfigPaths: true }, @@ -39,6 +40,7 @@ const config = defineConfig({ }, plugins: [ vitNodeEnv(), + vitNodePluginRoutes(), devtools(), nitro({ rollupConfig: { external: [/^@sentry\//] } }), tailwindcss(), diff --git a/apps/web/vitnode-plugin-routes.ts b/apps/web/vitnode-plugin-routes.ts new file mode 100644 index 000000000..5e680797b --- /dev/null +++ b/apps/web/vitnode-plugin-routes.ts @@ -0,0 +1,321 @@ +import type { ResolvedPluginRouteModule } from '@vitnode/core/framework/plugin-routes' +import type { PluginRouteDefinition } from '@vitnode/core/routing' +import type { Plugin } from 'vite' + +import { + generatePluginRouteManifestSource, + generatePluginRouteRegistrySource, + pluginIdsFromLoadedConfig, + resolvePluginRouteModules, + routeDeclarationsFromManifest, +} from '@vitnode/core/framework/plugin-routes' +import { buildPluginRouteManifest } from '@vitnode/core/routing' +import { createJiti } from 'jiti' +import { existsSync, statSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, relative } from 'node:path' +import { pathToFileURL } from 'node:url' + +const APP_ROOT = import.meta.dirname + +/** The configured plugin list, and the only place it is read from. */ +const CONFIG_PATH = join(APP_ROOT, 'src', 'vitnode.config.ts') + +/** + * The two generated files. Both committed, and both rewritten only when they + * change. + * + * Split because they answer different questions and are read by different + * things. The manifest says *what* routes exist and at which canonical VitNode + * path - framework-neutral data, which is what makes it worth generating once + * and reading from any router. The registry says *how* each route's module is + * imported, as one literal `import()` per route. `src/lib/plugin-routes.ts` + * joins them by route id and is the only place that knows about TanStack. + */ +const MANIFEST_OUTPUT_PATH = join( + APP_ROOT, + 'src', + 'plugin-route-manifest.gen.ts', +) +const REGISTRY_OUTPUT_PATH = join(APP_ROOT, 'src', 'plugin-routes.gen.ts') + +/** + * Where a plugin declares its routes, as a package export subpath. + * + * A plugin that does not export it - `@vitnode/blog` today - simply contributes + * no routes. That is not an error: most plugins are AdminCP content types and + * ship no pages at all, and a missing manifest has to mean "none" rather than + * failing the build of every app that installs one. + */ +const MANIFEST_SUBPATH = 'routes/manifest' + +const ERROR_PREFIX = '[VitNode plugin routes]' + +/** + * Resolution as this app would do it, honouring each package's `exports`. + * + * `createRequire` rather than `import.meta.resolve`, and the difference matters: + * every VitNode plugin maps `"./*"` to `"./dist/src/*.js"`, and + * `import.meta.resolve` answers a *pattern* match without ever touching the + * disk - it happily returns a URL for `@vitnode/example/routes/nope`. The CJS + * resolver stats the file, so a wrong entry is caught here instead of becoming a + * 404 in a browser. `existsSync` is checked anyway, because being wrong about + * this is the failure mode this whole step exists to prevent. + */ +const requireFromApp = createRequire(join(APP_ROOT, 'package.json')) + +const resolvePackageFile = (specifier: string): null | string => { + try { + const file = requireFromApp.resolve(specifier) + + return existsSync(file) ? file : null + } catch { + return null + } +} + +/** + * The plugins this app is configured with, in configuration order. + * + * `jiti` because `vitnode.config.ts` is TypeScript that imports other TypeScript + * without extensions, which Node's own type stripping will not load. It is the + * same loader `vitnode`'s own CLI scripts use to read this file. + * + * A plugin that is installed but not listed here is not consulted, and nothing + * ever enumerates `node_modules` - so disabling a plugin removes its routes from + * the bundle by construction rather than by a filter somebody has to remember. + */ +const readConfiguredPluginIds = async (): Promise => { + const jiti = createJiti(import.meta.url, { interopDefault: true }) + + return pluginIdsFromLoadedConfig( + await jiti.import(CONFIG_PATH), + relative(APP_ROOT, CONFIG_PATH), + ) +} + +/** + * One plugin's route declarations, loaded from its compiled manifest. + * + * The manifest is plain data by contract, so this is a normal `import()` of the + * plugin's build output in Node - no React, no router and no app code is + * evaluated to find out which routes exist. + * + * Loaded once and read twice, by the two layers that each own their own half of + * what a route is. `routeDeclarationsFromManifest` takes the `{ id, entry }` the + * registry generator needs and rejects anything else; `definitions` is the same + * array handed on untouched for `buildPluginRouteManifest`, which validates + * every field it reads - the path, the area, the entry, the ids - and is the + * only thing that decides whether a route is legal. Two readers rather than one + * shared narrowed shape, so neither layer has to know what the other requires. + * + * ## Why the URL carries an mtime + * + * Node's ESM loader caches modules by URL, permanently and with no eviction. The + * dev server therefore had a watcher that worked and a regeneration that could + * not: edit a plugin's manifest, the watcher fires, `regenerate()` runs, and + * `import()` of the same URL hands back the module Node parsed minutes ago - so + * the generated files were rewritten from stale declarations, or more often not + * rewritten at all because the bytes had not changed. + * + * A version taken off the file itself is the smallest thing that fixes it and + * keeps every property that matters: it changes when the file changes, so an + * untouched manifest keeps its cache entry across regenerations rather than + * leaking a new one, and it is read off disk rather than invented, so two builds + * of the same tree ask for the same URL. It never reaches the generated output - + * only `id`, `entry`, `path` and `area` are read from what comes back - so the + * generated bytes stay a function of the declarations alone, and the browser + * never sees any of this. + * + * Size as well as mtime, because mtime alone is only as fine-grained as the + * filesystem underneath: APFS and ext4 report sub-millisecond, but a Docker + * bind mount can round to the second, and two rebuilds inside one second is an + * ordinary thing for a watcher to cause. Two versions of a route manifest that + * share a timestamp *and* a byte length would still collide; that is a much + * narrower hole than the one this closes, and shutting it completely would mean + * hashing the file on every pass. + */ +const readPluginRoutes = async (pluginId: string) => { + const specifier = `${pluginId}/${MANIFEST_SUBPATH}` + const file = resolvePackageFile(specifier) + + if (file === null) return { declarations: [], definitions: [], watch: null } + + const { mtimeMs, size } = statSync(file) + const url = pathToFileURL(file) + url.searchParams.set('v', `${size}-${mtimeMs}`) + + const loaded = await import(url.href) + const declarations = routeDeclarationsFromManifest(loaded, specifier) + + return { + declarations, + // Safe by the line above: it threw unless `routes` is an array of records + // with a string `id` and `entry`. Everything past that - and there is no + // `path` in a declaration - is `buildPluginRouteManifest`'s to check, which + // it does defensively, from `unknown`. + definitions: (loaded as { routes: PluginRouteDefinition[] }).routes, + watch: file, + } +} + +/** + * Fails the build for a route module the app cannot import. + * + * The alternative is a generated `import()` of a specifier that does not + * resolve, which Vite reports from inside the module graph long after anyone can + * tell which plugin caused it - or worse, in the browser. + */ +const assertImportable = (module: ResolvedPluginRouteModule): void => { + if (resolvePackageFile(module.specifier) !== null) return + + throw new Error( + `${ERROR_PREFIX} "${module.key}" declares the entry "${module.entry}", which cannot be imported as "${module.specifier}". Check that ${module.pluginId} exports "./${module.entry}" and that its build output is up to date.`, + ) +} + +/** + * Everything the generated files are built from, discovered at build time only. + * + * `Promise.all` over the configured ids keeps the result independent of which + * manifest happens to load first, and both generators sort on top of that - so + * the bytes depend on the configuration and nothing else. + * + * This is also where a plugin route stops being able to fail quietly, and the + * order matters. `resolvePluginRouteModules` rejects an id or an entry that + * cannot be written into an import; `assertImportable` rejects an entry that + * does not resolve to a real file; `buildPluginRouteManifest` rejects a path it + * cannot parse and - the one no other layer can see - **two configured plugins + * claiming the same URL**, naming both sides. All three throw out of Vite's + * `config` hook, so `vite dev` and `vite build` stop rather than starting an app + * whose route table depends on which plugin was registered first. + */ +const discover = async () => { + const pluginIds = await readConfiguredPluginIds() + const loaded = await Promise.all( + pluginIds.map(async (pluginId) => ({ + pluginId, + ...(await readPluginRoutes(pluginId)), + })), + ) + + const modules = resolvePluginRouteModules( + loaded.map(({ declarations, pluginId }) => ({ + pluginId, + routes: declarations, + })), + ) + modules.forEach(assertImportable) + + const manifest = buildPluginRouteManifest( + loaded.map(({ definitions, pluginId }) => ({ + pluginId, + routes: definitions, + })), + ) + + return { + manifest: generatePluginRouteManifestSource(manifest), + registry: generatePluginRouteRegistrySource(modules), + watch: loaded.flatMap(({ watch }) => watch ?? []), + } +} + +/** + * Writes a generated file, and only if it changed. + * + * The write-if-changed is load bearing, not an optimisation: these files live in + * `src/`, so rewriting identical bytes on every dev-server event would trip + * Vite's watcher and reload the page in a loop - the same trap two route + * generators writing `routeTree.gen.ts` fall into. + */ +const writeIfChanged = async (path: string, source: string): Promise => { + const current = existsSync(path) ? await readFile(path, 'utf8') : null + + if (current !== source) await writeFile(path, source, 'utf8') +} + +/** Both generated files, from one discovery pass. */ +const writeGenerated = async (): Promise => { + const { manifest, registry, watch } = await discover() + + await Promise.all([ + writeIfChanged(MANIFEST_OUTPUT_PATH, manifest), + writeIfChanged(REGISTRY_OUTPUT_PATH, registry), + ]) + + return watch +} + +/** + * Build-time discovery of the route modules this app's plugins ship. + * + * The boundary this plugin exists to draw: + * + * - **Here, at build time.** Read the configured plugins, load their route + * manifests from `node_modules`, check every entry resolves to a real file, + * validate every route and reject two plugins claiming one URL, then write + * `src/plugin-route-manifest.gen.ts` and `src/plugin-routes.gen.ts`. + * - **In the browser.** Import those two files. They contain literal data and + * literal `import()` calls and nothing else - no `node:fs`, no package + * resolution, no validation to repeat and no specifier built from a variable, + * and so nothing a bundler cannot follow. + * + * Nothing is copied. The plugin's page stays in the plugin, compiled in its own + * `dist`, and the app holds one generated line of registration per route. + */ +export const vitNodePluginRoutes = (): Plugin => ({ + config: async () => { + await writeGenerated() + }, + /** + * Regenerates while the dev server runs, so editing a plugin's manifest is + * enough. Adding or removing a plugin in `vitnode.config.ts` is picked up too; + * a manifest that did not exist when the server started is not, because there + * is no file to watch yet - restart for that, exactly as installing a plugin + * already requires. + */ + configureServer: (server) => { + let watched = new Set() + + /** + * The tail of the regeneration chain. + * + * Regeneration is asynchronous - it resolves several manifests and writes two + * files - and the watcher can fire twice before the first pass finishes. Run + * concurrently, two passes interleave and the *older* one can perform the last + * write, leaving generated files that describe a manifest that no longer + * exists until something else happens to touch it. + * + * Chaining rather than a queue: each pass waits for the previous one, so the + * last event to arrive is the last to write. A pass re-reads everything from + * disk when it starts, so a run queued behind three others simply sees the + * final state - no coalescing needed, and nothing to keep in sync. + */ + let chain: Promise = Promise.resolve() + + const regenerate = (): void => { + chain = chain.then(async () => { + try { + watched = new Set(await writeGenerated()) + server.watcher.add([...watched]) + } catch (error) { + server.config.logger.error(String(error)) + } + }) + } + + const onChange = (file: string) => { + if (file !== CONFIG_PATH && !watched.has(file)) return + + regenerate() + } + + server.watcher.add(CONFIG_PATH) + regenerate() + server.watcher.on('change', onChange) + server.watcher.on('unlink', onChange) + }, + name: 'vitnode:plugin-routes', +}) diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index cabf416d5..27495b522 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -105,11 +105,21 @@ "types": "./dist/src/views/admin/views/content/form/index.d.ts", "default": "./dist/src/views/admin/views/content/form/index.js" }, + "./routing": { + "import": "./dist/src/routing/index.js", + "types": "./dist/src/routing/index.d.ts", + "default": "./dist/src/routing/index.js" + }, "./api/config": { "import": "./dist/src/api/config.js", "types": "./dist/src/api/config.d.ts", "default": "./dist/src/api/config.js" }, + "./framework/plugin-routes": { + "import": "./dist/src/framework/plugin-routes/index.js", + "types": "./dist/src/framework/plugin-routes/index.d.ts", + "default": "./dist/src/framework/plugin-routes/index.js" + }, "./*": { "import": "./dist/src/*.js", "types": "./dist/src/*.d.ts", diff --git a/packages/vitnode/src/framework/plugin-routes/generate.test.ts b/packages/vitnode/src/framework/plugin-routes/generate.test.ts new file mode 100644 index 000000000..8a0e79e7c --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/generate.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { generatePluginRouteRegistrySource } from "./generate.js"; +import { resolvePluginRouteModules } from "./resolve.js"; + +const generate = ( + sources: { pluginId: string; routes: { entry: string; id: string }[] }[], +): string => + generatePluginRouteRegistrySource(resolvePluginRouteModules(sources)); + +const EXAMPLE = [ + { + pluginId: "@vitnode/example", + routes: [{ entry: "routes/example-page", id: "example-page" }], + }, +]; + +describe("generatePluginRouteRegistrySource", () => { + it("emits the registry for one plugin route", () => { + expect(generate(EXAMPLE)).toMatchInlineSnapshot(` + "/* eslint-disable */ + + // This file is generated by VitNode. Do not edit it, and do not format it. + // + // It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every + // \`vite dev\` and \`vite build\`, from two inputs and nothing else: the plugins + // configured in \`src/vitnode.config.ts\`, and the route manifest each of those + // plugins ships. Nothing here is discovered at runtime - the browser is handed + // this module, never a filesystem. + // + // Same plugin configuration in, same bytes out: the entries are sorted by key. + + import type { + PluginRouteModuleRegistry, + ResolvedPluginRouteModule, + } from '@vitnode/core/framework/plugin-routes' + + /** + * Every configured plugin's route modules, keyed by \`:\`. + * + * The specifiers below are literal, which is the whole point of generating this + * file: Vite resolves them at build time and Rollup gives each module its own + * chunk, fetched when a loader is first called. No route component is in the + * initial bundle, and none of them is reached through a computed string - the + * browser never asks what is installed. + * + * \`satisfies\` rather than a type annotation, deliberately: it checks the shape + * while keeping both the literal keys and each module's real export types, so a + * consumer's \`await load()\` is typed by the module it loaded. + */ + export const pluginRouteModules = { + '@vitnode/example:example-page': () => import('@vitnode/example/routes/example-page'), + } satisfies PluginRouteModuleRegistry + + /** Every key in {@link pluginRouteModules}, as a union. */ + export type PluginRouteKey = keyof typeof pluginRouteModules + + /** + * The same route modules as plain data, in the same order. + * + * An object literal carries nothing beyond its keys, so the plugin id, the route + * id and the specifier are repeated here for whatever builds the router: it can + * walk this array, join each entry to its plugin's own route manifest by + * \`pluginId\` and \`routeId\`, and look the loader up by \`key\`. + */ + export const pluginRouteEntries = [ + { + entry: 'routes/example-page', + key: '@vitnode/example:example-page', + pluginId: '@vitnode/example', + routeId: 'example-page', + specifier: '@vitnode/example/routes/example-page', + }, + ] as const satisfies readonly ResolvedPluginRouteModule[] + " + `); + }); + + it("emits empty literals for an app whose plugins ship no routes", () => { + const source = generate([ + { pluginId: "@vitnode/blog", routes: [] }, + { pluginId: "@vitnode/example", routes: [] }, + ]); + + expect(source).toContain( + "export const pluginRouteModules = {} satisfies PluginRouteModuleRegistry", + ); + expect(source).toContain( + "export const pluginRouteEntries = [] as const satisfies readonly ResolvedPluginRouteModule[]", + ); + }); + + it("is byte-for-byte stable across calls", () => { + expect(generate(EXAMPLE)).toBe(generate(EXAMPLE)); + }); + + it("does not depend on the order the plugins were configured in", () => { + const a = { + pluginId: "@vitnode/blog", + routes: [{ entry: "routes/a", id: "a" }], + }; + const b = { + pluginId: "@vitnode/example", + routes: [{ entry: "routes/b", id: "b" }], + }; + + expect(generate([a, b])).toBe(generate([b, a])); + }); + + it("does not depend on the order one plugin declared its routes in", () => { + const routes = [ + { entry: "routes/alpha", id: "alpha" }, + { entry: "routes/zebra", id: "zebra" }, + ]; + + expect(generate([{ pluginId: "@vitnode/example", routes }])).toBe( + generate([ + { pluginId: "@vitnode/example", routes: [...routes].reverse() }, + ]), + ); + }); + + it("emits one lazy import per route and nothing eager", () => { + const source = generate([ + { + pluginId: "@vitnode/example", + routes: [ + { entry: "routes/one", id: "one" }, + { entry: "routes/two", id: "two" }, + ], + }, + ]); + + // Every route import is `() => import('...')`. The one static import in the + // file is type-only, so it is erased and nothing is pulled in eagerly. + expect(source.match(/=> import\('/g)).toHaveLength(2); + expect(source.match(/^import.*$/gm)).toEqual(["import type {"]); + }); + + it("sorts and de-duplicates its own input, not just the resolver's output", () => { + const modules = [ + { + entry: "routes/b", + key: "@vitnode/example:b", + pluginId: "@vitnode/example", + routeId: "b", + specifier: "@vitnode/example/routes/b", + }, + { + entry: "routes/a", + key: "@vitnode/example:a", + pluginId: "@vitnode/example", + routeId: "a", + specifier: "@vitnode/example/routes/a", + }, + ]; + + expect(generatePluginRouteRegistrySource(modules)).toBe( + generatePluginRouteRegistrySource([...modules].reverse()), + ); + expect(() => + generatePluginRouteRegistrySource([modules[0], modules[0]]), + ).toThrow(/same registry key/); + }); + + it("escapes a specifier that somehow reached it unvalidated", () => { + expect( + generatePluginRouteRegistrySource([ + { + entry: "routes/x", + key: "@vitnode/example:x", + pluginId: "@vitnode/example", + routeId: "x", + specifier: "@vitnode/example/routes/x'); evil(('", + }, + ]), + ).toContain("import('@vitnode/example/routes/x\\'); evil((\\'')"); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-routes/generate.ts b/packages/vitnode/src/framework/plugin-routes/generate.ts new file mode 100644 index 000000000..6a98a4407 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/generate.ts @@ -0,0 +1,114 @@ +import type { ResolvedPluginRouteModule } from "./types.js"; + +import { sortAndAssertUnique, toSingleQuotedLiteral } from "./resolve.js"; + +/** Where the generated file imports its types from. */ +const TYPES_SPECIFIER = "@vitnode/core/framework/plugin-routes"; + +/** + * The generated file's header. + * + * `eslint-disable` and the "do not format" line are not decoration: the file is + * rewritten on every build, so anything a linter or a formatter changes in it is + * lost, and a formatter that reflows one entry would make the output depend on + * how long a plugin's name happens to be. It is excluded from both in the app + * that receives it, and this says so at the top for whoever opens it anyway. + */ +const HEADER = `/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every +// \`vite dev\` and \`vite build\`, from two inputs and nothing else: the plugins +// configured in \`src/vitnode.config.ts\`, and the route manifest each of those +// plugins ships. Nothing here is discovered at runtime - the browser is handed +// this module, never a filesystem. +// +// Same plugin configuration in, same bytes out: the entries are sorted by key. + +import type { + PluginRouteModuleRegistry, + ResolvedPluginRouteModule, +} from '${TYPES_SPECIFIER}' + +/** + * Every configured plugin's route modules, keyed by \`:\`. + * + * The specifiers below are literal, which is the whole point of generating this + * file: Vite resolves them at build time and Rollup gives each module its own + * chunk, fetched when a loader is first called. No route component is in the + * initial bundle, and none of them is reached through a computed string - the + * browser never asks what is installed. + * + * \`satisfies\` rather than a type annotation, deliberately: it checks the shape + * while keeping both the literal keys and each module's real export types, so a + * consumer's \`await load()\` is typed by the module it loaded. + */ +export const pluginRouteModules = `; + +const MIDDLE = ` satisfies PluginRouteModuleRegistry + +/** Every key in {@link pluginRouteModules}, as a union. */ +export type PluginRouteKey = keyof typeof pluginRouteModules + +/** + * The same route modules as plain data, in the same order. + * + * An object literal carries nothing beyond its keys, so the plugin id, the route + * id and the specifier are repeated here for whatever builds the router: it can + * walk this array, join each entry to its plugin's own route manifest by + * \`pluginId\` and \`routeId\`, and look the loader up by \`key\`. + */ +export const pluginRouteEntries = `; + +const FOOTER = ` as const satisfies readonly ResolvedPluginRouteModule[] +`; + +const registryLiteral = (modules: ResolvedPluginRouteModule[]): string => { + if (modules.length === 0) return "{}"; + + const entries = modules.map( + module => + ` ${toSingleQuotedLiteral(module.key)}: () => import(${toSingleQuotedLiteral(module.specifier)}),`, + ); + + return `{\n${entries.join("\n")}\n}`; +}; + +const entriesLiteral = (modules: ResolvedPluginRouteModule[]): string => { + if (modules.length === 0) return "[]"; + + const entries = modules.map(module => + [ + " {", + ` entry: ${toSingleQuotedLiteral(module.entry)},`, + ` key: ${toSingleQuotedLiteral(module.key)},`, + ` pluginId: ${toSingleQuotedLiteral(module.pluginId)},`, + ` routeId: ${toSingleQuotedLiteral(module.routeId)},`, + ` specifier: ${toSingleQuotedLiteral(module.specifier)},`, + " },", + ].join("\n"), + ); + + return `[\n${entries.join("\n")}\n]`; +}; + +/** + * The source of an app's plugin route registry. + * + * A string rather than a file, so the one part of this that has to be exactly + * reproducible is a pure function of its input and can be asserted byte for + * byte. Writing it - and deciding whether it changed - belongs to the build tool + * that has a filesystem. + * + * Sorted and de-duplicated again on the way in: the resolver already does both, + * and doing it here as well is what makes "same configuration, same bytes" a + * property of this function rather than a promise about how it is called. + */ +export const generatePluginRouteRegistrySource = ( + modules: ResolvedPluginRouteModule[], +): string => { + const sorted = sortAndAssertUnique(modules); + + return `${HEADER}${registryLiteral(sorted)}${MIDDLE}${entriesLiteral(sorted)}${FOOTER}`; +}; diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts new file mode 100644 index 000000000..993e8448c --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/index.ts @@ -0,0 +1,48 @@ +export { generatePluginRouteRegistrySource } from "./generate.js"; +export { generatePluginRouteManifestSource } from "./manifest-source.js"; + +export { + assertPluginId, + pluginIdsFromLoadedConfig, + resolvePluginRouteModules, + routeDeclarationsFromManifest, + sortAndAssertUnique, + toSingleQuotedLiteral, +} from "./resolve.js"; +/** + * Build-time discovery of the route modules an app's configured plugins ship. + * + * Everything here is pure: plain data in, validated data or a source string out. + * There is no `node:fs`, no package resolution and no framework - a plugin route + * is a package export subpath and a key, and turning those into imports a + * bundler can follow is the whole job. The build tool that owns the filesystem + * (`apps/web/vitnode-plugin-routes.ts`) loads the app config and the plugin + * manifests, checks that each entry really resolves, and writes what + * `generatePluginRouteRegistrySource` returns. + * + * What a route *means* - its URL, its loader, its metadata, its permissions - + * is not decided here. That is the plugin route manifest's contract - + * `@vitnode/core/routing` - and the registry generator deliberately reads only + * `id` and `entry` off a `PluginRouteDefinition`, so the manifest can grow + * without it changing. The one thing the two layers do share is the identifier: + * a route's registry key is the manifest's own `pluginRouteId`, so a loader is + * registered under exactly the id the manifest gave the route. + * + * There are therefore two generators here, and the split is the point: + * + * - `generatePluginRouteRegistrySource` - **how** each route's implementation is + * imported. One literal `import()` per route. + * - `generatePluginRouteManifestSource` - **what** routes exist, as the + * framework-neutral manifest `@vitnode/core/routing` validated, frozen into + * the app at build time so nothing has to be validated again at runtime. + * + * How a route is *registered* is the third thing, and belongs to whichever + * router the app happens to run. + */ +export type { + PluginRouteEntryDeclaration, + PluginRouteEntrySource, + PluginRouteModuleLoader, + PluginRouteModuleRegistry, + ResolvedPluginRouteModule, +} from "./types.js"; diff --git a/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts b/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts new file mode 100644 index 000000000..f7b85dc8d --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/manifest-source.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { buildPluginRouteManifest } from "../../routing/manifest"; +import { generatePluginRouteManifestSource } from "./manifest-source"; + +const manifestOf = ( + ...sources: { pluginId: string; routes: { entry: string; path: string }[] }[] +) => + buildPluginRouteManifest( + sources.map(source => ({ + pluginId: source.pluginId, + routes: source.routes.map((route, index) => ({ + entry: route.entry, + id: `route-${index}`, + path: route.path, + })), + })), + ); + +describe("generatePluginRouteManifestSource", () => { + it("emits an empty manifest an app can still import", () => { + const source = generatePluginRouteManifestSource([]); + + expect(source).toContain("export const pluginRouteManifest = []"); + expect(source).toContain("satisfies readonly PluginRoute[]"); + expect(source).toContain("import type { PluginRoute } from"); + }); + + it("emits every field of a route, and its parsed segments", () => { + const source = generatePluginRouteManifestSource( + manifestOf({ + pluginId: "@vitnode/example", + routes: [{ entry: "routes/article", path: "/example/:slug" }], + }), + ); + + expect(source).toContain("area: 'main',"); + expect(source).toContain("entry: 'routes/article',"); + expect(source).toContain("id: '@vitnode/example:route-0',"); + expect(source).toContain("path: '/example/:slug',"); + expect(source).toContain("pluginId: '@vitnode/example',"); + expect(source).toContain("routeId: 'route-0',"); + expect(source).toContain( + "segments: [{ kind: 'static', value: 'example' }, { kind: 'param', name: 'slug' }],", + ); + }); + + it("emits a root route as an empty segment list", () => { + const source = generatePluginRouteManifestSource( + manifestOf({ + pluginId: "landing", + routes: [{ entry: "routes/home", path: "/" }], + }), + ); + + expect(source).toContain("path: '/',"); + expect(source).toContain("segments: [],"); + }); + + /** + * The property the whole "generate a file into `src/`" approach rests on: the + * bytes depend on the configuration and nothing else. If they depended on the + * order plugins happen to load in, the file would churn between developers and + * the dev server would reload in a loop on every restart. + */ + it("produces the same bytes whatever order the plugins are read in", () => { + const a = { pluginId: "a-plugin", routes: [{ entry: "r/a", path: "/a" }] }; + const b = { pluginId: "b-plugin", routes: [{ entry: "r/b", path: "/b" }] }; + + expect(generatePluginRouteManifestSource(manifestOf(a, b))).toBe( + generatePluginRouteManifestSource(manifestOf(b, a)), + ); + }); + + it("sorts routes even when handed a manifest out of order", () => { + const [a, b] = manifestOf({ + pluginId: "plugin", + routes: [ + { entry: "r/z", path: "/z" }, + { entry: "r/a", path: "/a" }, + ], + }); + + expect(generatePluginRouteManifestSource([b, a])).toBe( + generatePluginRouteManifestSource([a, b]), + ); + expect( + generatePluginRouteManifestSource([b, a]).indexOf("path: '/a',"), + ).toBeLessThan( + generatePluginRouteManifestSource([b, a]).indexOf("path: '/z',"), + ); + }); + + /** + * The file is written into an app's `src/`, so nothing that reaches it may be + * able to close a string literal. Every value has already been matched against + * a pattern that cannot contain a quote - this asserts the generator does not + * rely on that being true forever. + */ + it("escapes what it writes", () => { + const source = generatePluginRouteManifestSource([ + { + area: "main", + entry: "routes/x", + id: "p:x", + path: "/x", + pluginId: "p", + routeId: "x", + segments: [{ kind: "static", value: "it's" }], + }, + ]); + + expect(source).toContain("{ kind: 'static', value: 'it\\'s' }"); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-routes/manifest-source.ts b/packages/vitnode/src/framework/plugin-routes/manifest-source.ts new file mode 100644 index 000000000..900ab72e3 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/manifest-source.ts @@ -0,0 +1,93 @@ +import type { + PluginRoute, + PluginRouteManifest, + PluginRouteSegment, +} from "../../routing/types.js"; + +import { comparePluginRoutes } from "../../routing/manifest.js"; +import { toSingleQuotedLiteral } from "./resolve.js"; + +/** Where the generated file imports its type from. */ +const TYPES_SPECIFIER = "@vitnode/core/routing"; + +/** + * The generated file's header. + * + * Same rules as its sibling registry: rewritten on every build, so anything a + * linter or a formatter changes in it is lost, and a reflow would make the + * output depend on how long a plugin's name happens to be. + */ +const HEADER = `/* eslint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every +// \`vite dev\` and \`vite build\`, from the plugins configured in +// \`src/vitnode.config.ts\` and the route manifest each of those plugins ships. +// +// This is the *what*: which routes exist, and at which canonical VitNode path. +// Its sibling \`plugin-routes.gen.ts\` is the *how*: one lazy import per route +// module. Neither knows what a router is. +// +// Same plugin configuration in, same bytes out: the routes are sorted by path. + +import type { PluginRoute } from '${TYPES_SPECIFIER}' + +/** + * Every route this app's configured plugins contribute, already validated. + * + * \`buildPluginRouteManifest\` produced this list while the app was being built, + * which is the whole reason it is a literal here: a path that cannot be parsed, + * an entry that cannot be imported and two plugins claiming one URL have all + * already failed the build by the time this file exists. Nothing reads it to + * find out whether the routes are valid - only to find out what they are. + * + * Deliberately not \`as const\`: a route's \`segments\` is a mutable array on + * \`PluginRoute\`, and a frozen tuple would not satisfy it. \`satisfies\` alone + * still checks every field and still narrows \`area\` and \`kind\` to their unions. + */ +export const pluginRouteManifest = `; + +const FOOTER = ` satisfies readonly PluginRoute[] +`; + +const segmentLiteral = (segment: PluginRouteSegment): string => + segment.kind === "param" + ? `{ kind: 'param', name: ${toSingleQuotedLiteral(segment.name)} }` + : `{ kind: 'static', value: ${toSingleQuotedLiteral(segment.value)} }`; + +const routeLiteral = (route: PluginRoute): string => + [ + " {", + ` area: ${toSingleQuotedLiteral(route.area)},`, + ` entry: ${toSingleQuotedLiteral(route.entry)},`, + ` id: ${toSingleQuotedLiteral(route.id)},`, + ` path: ${toSingleQuotedLiteral(route.path)},`, + ` pluginId: ${toSingleQuotedLiteral(route.pluginId)},`, + ` routeId: ${toSingleQuotedLiteral(route.routeId)},`, + ` segments: [${route.segments.map(segmentLiteral).join(", ")}],`, + " },", + ].join("\n"); + +const manifestLiteral = (manifest: PluginRouteManifest): string => { + if (manifest.length === 0) return "[]"; + + return `[\n${manifest.map(routeLiteral).join("\n")}\n]`; +}; + +/** + * The source of an app's plugin route manifest. + * + * A string rather than a file, for the same reason as + * {@link generatePluginRouteRegistrySource}: the part that has to be exactly + * reproducible is a pure function of its input and can be asserted byte for + * byte, and writing it belongs to the build tool that has a filesystem. + * + * Sorted again on the way in with the manifest layer's own comparator, so "same + * configuration, same bytes" is a property of this function rather than a + * promise about how it is called. + */ +export const generatePluginRouteManifestSource = ( + manifest: PluginRouteManifest, +): string => + `${HEADER}${manifestLiteral([...manifest].sort(comparePluginRoutes))}${FOOTER}`; diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.test.ts b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts new file mode 100644 index 000000000..fac2aa4f1 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/resolve.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginRouteEntrySource } from "./types.js"; + +import { pluginRouteId } from "../../routing/manifest.js"; +import { + pluginIdsFromLoadedConfig, + resolvePluginRouteModules, + routeDeclarationsFromManifest, + sortAndAssertUnique, + toSingleQuotedLiteral, +} from "./resolve.js"; + +const source = ( + pluginId: string, + ...routes: { entry: string; id: string }[] +): PluginRouteEntrySource => ({ pluginId, routes }); + +describe("resolvePluginRouteModules", () => { + it("pairs each declaration with the specifier it will be imported by", () => { + expect( + resolvePluginRouteModules([ + source("@vitnode/example", { + entry: "routes/example-page", + id: "example-page", + }), + ]), + ).toEqual([ + { + entry: "routes/example-page", + key: "@vitnode/example:example-page", + pluginId: "@vitnode/example", + routeId: "example-page", + specifier: "@vitnode/example/routes/example-page", + }, + ]); + }); + + it("orders by key, not by the order the plugins were configured in", () => { + const forwards = resolvePluginRouteModules([ + source("@vitnode/example", { entry: "routes/b", id: "b" }), + source("@vitnode/blog", { entry: "routes/a", id: "a" }), + ]); + const backwards = resolvePluginRouteModules([ + source("@vitnode/blog", { entry: "routes/a", id: "a" }), + source("@vitnode/example", { entry: "routes/b", id: "b" }), + ]); + + expect(forwards.map(module => module.key)).toEqual([ + "@vitnode/blog:a", + "@vitnode/example:b", + ]); + expect(backwards).toEqual(forwards); + }); + + it("orders one plugin's own routes by key too", () => { + expect( + resolvePluginRouteModules([ + source( + "@vitnode/example", + { entry: "routes/zebra", id: "zebra" }, + { entry: "routes/alpha", id: "alpha" }, + { entry: "routes/nested/leaf", id: "nested/leaf" }, + ), + ]).map(module => module.routeId), + ).toEqual(["alpha", "nested/leaf", "zebra"]); + }); + + it("returns nothing for a plugin that declares no routes", () => { + expect(resolvePluginRouteModules([source("@vitnode/blog")])).toEqual([]); + expect(resolvePluginRouteModules([{ pluginId: "@vitnode/blog" }])).toEqual( + [], + ); + }); + + it("rejects two declarations claiming one key", () => { + expect(() => + resolvePluginRouteModules([ + source( + "@vitnode/example", + { entry: "routes/one", id: "duplicate" }, + { entry: "routes/two", id: "duplicate" }, + ), + ]), + ).toThrow(/same registry key: "@vitnode\/example:duplicate"/); + }); + + it("rejects the same key contributed by two configured entries", () => { + expect(() => + resolvePluginRouteModules([ + source("@vitnode/example", { entry: "routes/one", id: "page" }), + source("@vitnode/example", { entry: "routes/one", id: "page" }), + ]), + ).toThrow(/same registry key/); + }); + + it("allows two route ids pointing at one module", () => { + expect( + resolvePluginRouteModules([ + source( + "@vitnode/example", + { entry: "routes/shared", id: "first" }, + { entry: "routes/shared", id: "second" }, + ), + ]).map(module => module.specifier), + ).toEqual([ + "@vitnode/example/routes/shared", + "@vitnode/example/routes/shared", + ]); + }); + + it.each([ + ["../../../etc/passwd", "a parent-directory traversal"], + ["routes/../../secret", "a traversal in the middle"], + ["/routes/page", "an absolute path"], + ["./routes/page", "a relative-looking path"], + ["routes\\page", "a backslash"], + ["routes/page'", "a quote"], + ["routes/\npage", "a newline"], + ["routes/", "a trailing separator"], + ["", "an empty entry"], + [" routes/page", "padding"], + ])("rejects the entry %j - %s", entry => { + expect(() => + resolvePluginRouteModules([ + source("@vitnode/example", { entry, id: "x" }), + ]), + ).toThrow(/\[VitNode plugin routes\]/); + }); + + it.each([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"])( + "rejects an entry ending in %s, because the export map adds the extension", + extension => { + expect(() => + resolvePluginRouteModules([ + source("@vitnode/example", { + entry: `routes/page${extension}`, + id: "page", + }), + ]), + ).toThrow(/file extension/); + }, + ); + + it.each(["../evil", "@scope", "has space", "quote'", "back\\slash", ""])( + "rejects the plugin id %j", + pluginId => { + expect(() => + resolvePluginRouteModules([ + source(pluginId, { entry: "routes/page", id: "page" }), + ]), + ).toThrow(/not a package name/); + }, + ); + + it.each(["@vitnode/example", "my-plugin", "@scope/nested.plugin_1"])( + "accepts the plugin id %j", + pluginId => { + expect( + resolvePluginRouteModules([ + source(pluginId, { entry: "routes/page", id: "page" }), + ])[0].specifier, + ).toBe(`${pluginId}/routes/page`); + }, + ); + + it.each(["with:colon", "with space", "/leading", "-leading", ""])( + "rejects the route id %j", + id => { + expect(() => + resolvePluginRouteModules([ + source("@vitnode/example", { entry: "routes/page", id }), + ]), + ).toThrow(/\[VitNode plugin routes\]/); + }, + ); +}); + +describe("the registry key", () => { + it("is the manifest layer's own route id, not a second copy of the rule", () => { + expect( + resolvePluginRouteModules([ + source("@vitnode/example", { + entry: "routes/example-page", + id: "example-page", + }), + ])[0].key, + ).toBe(pluginRouteId("@vitnode/example", "example-page")); + }); +}); + +describe("sortAndAssertUnique", () => { + it("compares code units rather than using the machine's collation", () => { + const modules = ["b", "A", "_", "a", "B"].map(key => ({ + entry: "routes/x", + key, + pluginId: "@vitnode/example", + routeId: key, + specifier: "@vitnode/example/routes/x", + })); + + expect(sortAndAssertUnique(modules).map(module => module.key)).toEqual([ + "A", + "B", + "_", + "a", + "b", + ]); + }); + + it("does not mutate its argument", () => { + const modules = ["b", "a"].map(key => ({ + entry: "routes/x", + key, + pluginId: "@vitnode/example", + routeId: key, + specifier: "@vitnode/example/routes/x", + })); + + sortAndAssertUnique(modules); + + expect(modules.map(module => module.key)).toEqual(["b", "a"]); + }); +}); + +describe("toSingleQuotedLiteral", () => { + it.each([ + ["@vitnode/example/routes/page", "'@vitnode/example/routes/page'"], + ["it's", "'it\\'s'"], + ["back\\slash", "'back\\\\slash'"], + ["line\nbreak", "'line\\nbreak'"], + ["carriage\rreturn", "'carriage\\rreturn'"], + ["'); rm -rf /; ('", "'\\'); rm -rf /; (\\''"], + ])("escapes %j", (value, expected) => { + expect(toSingleQuotedLiteral(value)).toBe(expected); + }); + + it("produces a literal that evaluates back to the original", () => { + for (const value of ["a'b", "a\\b", "a\\'b", "a\nb"]) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + expect(new Function(`return ${toSingleQuotedLiteral(value)}`)()).toBe( + value, + ); + } + }); +}); + +describe("pluginIdsFromLoadedConfig", () => { + it("reads the configured plugin ids in the configured order", () => { + expect( + pluginIdsFromLoadedConfig( + { + vitNodeConfig: { + plugins: [ + { pluginId: "@vitnode/blog" }, + { pluginId: "@vitnode/example" }, + ], + }, + }, + "src/vitnode.config.ts", + ), + ).toEqual(["@vitnode/blog", "@vitnode/example"]); + }); + + it("accepts an app with no plugins", () => { + expect( + pluginIdsFromLoadedConfig( + { vitNodeConfig: { plugins: [] } }, + "src/vitnode.config.ts", + ), + ).toEqual([]); + }); + + it.each([ + [undefined, /does not export/], + [{}, /does not export/], + [{ vitNodeConfig: null }, /does not export/], + [{ vitNodeConfig: {} }, /is not an array/], + [{ vitNodeConfig: { plugins: "blog" } }, /is not an array/], + [{ vitNodeConfig: { plugins: [{}] } }, /has no string `pluginId`/], + [{ vitNodeConfig: { plugins: [null] } }, /has no string `pluginId`/], + [{ vitNodeConfig: { plugins: [{ pluginId: 1 }] } }, /has no string/], + ])("rejects %j", (loaded, message) => { + expect(() => + pluginIdsFromLoadedConfig(loaded, "src/vitnode.config.ts"), + ).toThrow(message); + }); + + it("names the offending index", () => { + expect(() => + pluginIdsFromLoadedConfig( + { vitNodeConfig: { plugins: [{ pluginId: "@vitnode/blog" }, {}] } }, + "src/vitnode.config.ts", + ), + ).toThrow(/plugins\[1\]/); + }); + + it("rejects a configured id that is not a package name", () => { + expect(() => + pluginIdsFromLoadedConfig( + { vitNodeConfig: { plugins: [{ pluginId: "../evil" }] } }, + "src/vitnode.config.ts", + ), + ).toThrow(/not a package name/); + }); +}); + +describe("routeDeclarationsFromManifest", () => { + it("reads only the id and the entry, ignoring everything else", () => { + expect( + routeDeclarationsFromManifest( + { + routes: [ + { + entry: "routes/example-page", + id: "example-page", + path: "/example", + permissions: ["staff"], + }, + ], + }, + "@vitnode/example/routes/manifest", + ), + ).toEqual([{ entry: "routes/example-page", id: "example-page" }]); + }); + + it("accepts a manifest declaring no routes", () => { + expect( + routeDeclarationsFromManifest({ routes: [] }, "@x/y/routes/manifest"), + ).toEqual([]); + }); + + it.each([ + [undefined, /does not export `routes`/], + [{}, /does not export `routes`/], + [{ routes: {} }, /is not an array/], + [{ routes: [{ id: "a" }] }, /is not a `\{ id: string, entry: string \}`/], + [{ routes: [{ entry: "routes/a" }] }, /is not a/], + [{ routes: ["routes/a"] }, /is not a/], + ])("rejects %j", (loaded, message) => { + expect(() => + routeDeclarationsFromManifest(loaded, "@x/y/routes/manifest"), + ).toThrow(message); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-routes/resolve.ts b/packages/vitnode/src/framework/plugin-routes/resolve.ts new file mode 100644 index 000000000..6762ecc82 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/resolve.ts @@ -0,0 +1,261 @@ +import type { + PluginRouteEntryDeclaration, + PluginRouteEntrySource, + ResolvedPluginRouteModule, +} from "./types.js"; + +import { pluginRouteId } from "../../routing/manifest.js"; + +/** + * What every error from this module is prefixed with. + * + * These are build-time failures - a plugin declared a route the app cannot + * import - and they surface in a Vite config hook, where the stack is all + * bundler internals. The prefix is what makes the message findable. + */ +const ERROR_PREFIX = "[VitNode plugin routes]"; + +/** + * A plugin id, which in VitNode is also the package name the route module is + * imported from - `@vitnode/example`, `my-plugin`. + * + * Matched rather than trusted because it is concatenated into an import + * specifier that is then written into a source file. Everything npm allows in a + * name is allowed here; nothing else is, which rules out quotes, whitespace, + * newlines, backslashes and `..` in one go. + */ +const PLUGIN_ID_PATTERN = + /^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** One path segment: no dots-only segments, so `.` and `..` cannot appear. */ +const SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** + * An extension on an entry, which is always a mistake. + * + * A plugin's export map maps subpaths to build output - `"./*"` to + * `"./dist/src/*.js"` - so the subpath is extensionless. `"routes/page.tsx"` + * would resolve to `dist/src/routes/page.tsx.js` and fail with a message about a + * file nobody wrote, so it is worth naming here instead. + */ +const ENTRY_EXTENSION_PATTERN = /\.[cm]?[jt]sx?$/; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const assertSegmentedPath = ({ + label, + source, + value, +}: { + label: string; + source: string; + value: string; +}): void => { + if (value === "" || value !== value.trim()) { + throw new Error( + `${ERROR_PREFIX} ${source} declares a route with ${label} ${JSON.stringify(value)}, which is empty or padded with whitespace.`, + ); + } + + const segments = value.split("/"); + const invalid = segments.filter(segment => !SEGMENT_PATTERN.test(segment)); + + if (invalid.length > 0) { + throw new Error( + `${ERROR_PREFIX} ${source} declares a route with ${label} ${JSON.stringify(value)}. Use "/"-separated segments of letters, digits, ".", "_" and "-" - a leading "/", a "." or ".." segment, a backslash or a quote is never valid, because this is written into a generated import.`, + ); + } +}; + +/** Validates a plugin id and returns it unchanged, so it can be used inline. */ +export const assertPluginId = (pluginId: string, source: string): string => { + if (!PLUGIN_ID_PATTERN.test(pluginId)) { + throw new Error( + `${ERROR_PREFIX} ${source} declares the plugin id ${JSON.stringify(pluginId)}, which is not a package name. A route module is imported from the plugin's package, so the id has to be one.`, + ); + } + + return pluginId; +}; + +/** + * Turns a JavaScript string into a single-quoted TypeScript literal. + * + * Every value that reaches this has already been matched against a pattern that + * cannot contain a quote, a backslash or a newline, so in practice it escapes + * nothing. It exists anyway, and is tested directly: this is a code generator, + * and a code generator that concatenates unescaped strings is one refactor away + * from writing whatever a plugin's `package.json` says into an app's source. + */ +export const toSingleQuotedLiteral = (value: string): string => + `'${value + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r")}'`; + +/** + * Reads the configured plugin ids out of an already-loaded `vitnode.config.ts`. + * + * The app's config is the source of truth for which plugins exist, so it is also + * the source of truth for whose routes get bundled: a plugin that is installed + * but not listed here contributes nothing, and no `node_modules` scan can + * accidentally put it back. + * + * Pure, and separate from the loading, so the narrowing every generated build + * depends on is testable without a module loader. `source` only ever appears in + * error messages - it is the config's path, which the caller knows and this + * does not. + */ +export const pluginIdsFromLoadedConfig = ( + loaded: unknown, + source: string, +): string[] => { + if (!isRecord(loaded) || !isRecord(loaded.vitNodeConfig)) { + throw new Error( + `${ERROR_PREFIX} ${source} does not export \`vitNodeConfig\`. It has to, because the configured plugins are what the route registry is generated from.`, + ); + } + + const { plugins } = loaded.vitNodeConfig; + + if (!Array.isArray(plugins)) { + throw new Error( + `${ERROR_PREFIX} \`vitNodeConfig.plugins\` in ${source} is not an array.`, + ); + } + + return plugins.map((plugin: unknown, index) => { + if (!isRecord(plugin) || typeof plugin.pluginId !== "string") { + throw new Error( + `${ERROR_PREFIX} \`vitNodeConfig.plugins[${index}]\` in ${source} has no string \`pluginId\`.`, + ); + } + + return assertPluginId(plugin.pluginId, source); + }); +}; + +/** + * Reads the route declarations out of an already-loaded plugin route manifest. + * + * Pure for the same reason as {@link pluginIdsFromLoadedConfig}, and equally + * strict: a manifest that exports the wrong shape has to fail here, with the + * specifier in the message, rather than three steps later as a generated file + * that will not compile. + */ +export const routeDeclarationsFromManifest = ( + loaded: unknown, + source: string, +): PluginRouteEntryDeclaration[] => { + if (!isRecord(loaded) || !("routes" in loaded)) { + throw new Error( + `${ERROR_PREFIX} ${source} does not export \`routes\`. A plugin route manifest exports an array of \`{ id, entry }\`.`, + ); + } + + const { routes } = loaded; + + if (!Array.isArray(routes)) { + throw new Error(`${ERROR_PREFIX} \`routes\` in ${source} is not an array.`); + } + + return routes.map((route: unknown, index) => { + if ( + !isRecord(route) || + typeof route.id !== "string" || + typeof route.entry !== "string" + ) { + throw new Error( + `${ERROR_PREFIX} \`routes[${index}]\` in ${source} is not a \`{ id: string, entry: string }\` record.`, + ); + } + + return { entry: route.entry, id: route.id }; + }); +}; + +/** + * Every configured plugin's declarations, validated and put in a fixed order. + * + * Two guarantees, and both are the reason this is a separate step rather than + * something the generator does inline: + * + * - **Deterministic.** The result is sorted by key with a code-unit comparison, + * not `localeCompare`, so it does not depend on the machine's locale, on the + * order the plugins were configured in, or on the order any directory was read + * in. The same configuration produces the same bytes. + * - **Loud.** A malformed id, an entry with a `..` segment or an extension, and + * two declarations claiming one key all throw here - at build time, naming the + * plugin - rather than turning into a generated import that fails somewhere in + * a browser. + * + * It stops at what can be decided from the declarations alone. Whether + * `/` actually resolves to a file is a filesystem question, and + * the caller that owns the filesystem asks it, using `specifier`. + */ +export const resolvePluginRouteModules = ( + sources: readonly PluginRouteEntrySource[], +): ResolvedPluginRouteModule[] => { + const modules: ResolvedPluginRouteModule[] = []; + + for (const source of sources) { + const pluginId = assertPluginId(source.pluginId, "vitnode.config.ts"); + + for (const route of source.routes ?? []) { + assertSegmentedPath({ label: "id", source: pluginId, value: route.id }); + assertSegmentedPath({ + label: "entry", + source: pluginId, + value: route.entry, + }); + + if (ENTRY_EXTENSION_PATTERN.test(route.entry)) { + throw new Error( + `${ERROR_PREFIX} ${pluginId} declares the entry ${JSON.stringify(route.entry)} with a file extension. An entry is a package export subpath, and a plugin's export map adds the extension - drop it.`, + ); + } + + modules.push({ + entry: route.entry, + key: pluginRouteId(pluginId, route.id), + pluginId, + routeId: route.id, + specifier: `${pluginId}/${route.entry}`, + }); + } + } + + return sortAndAssertUnique(modules); +}; + +/** + * Sorts by key and rejects duplicates. + * + * Also applied by the generator, so a caller cannot hand it an unsorted list and + * get a file whose bytes depend on argument order. + */ +export const sortAndAssertUnique = ( + modules: ResolvedPluginRouteModule[], +): ResolvedPluginRouteModule[] => { + const sorted = [...modules].sort((a, b) => { + if (a.key === b.key) return 0; + + return a.key < b.key ? -1 : 1; + }); + + const duplicates = sorted + .filter( + (module, index) => index > 0 && module.key === sorted[index - 1].key, + ) + .map(module => module.key); + + if (duplicates.length > 0) { + throw new Error( + `${ERROR_PREFIX} Two route declarations claim the same registry key: ${[...new Set(duplicates)].map(key => JSON.stringify(key)).join(", ")}. A key is \`:\`, so give the routes different ids.`, + ); + } + + return sorted; +}; diff --git a/packages/vitnode/src/framework/plugin-routes/types.ts b/packages/vitnode/src/framework/plugin-routes/types.ts new file mode 100644 index 000000000..ef742e4bf --- /dev/null +++ b/packages/vitnode/src/framework/plugin-routes/types.ts @@ -0,0 +1,64 @@ +import type { PluginRouteDefinition } from "../../routing/types.js"; + +/** + * The two fields of a {@link PluginRouteDefinition} the build actually reads. + * + * `Pick`, not a re-declaration: the plugin route manifest owns what a route is, + * and this layer only has to know which module to import and what to call it. + * Deriving the type means a rename there is a compile error here rather than two + * definitions that agree until somebody edits one. + * + * `entry` is a *package export subpath* - `"routes/example-page"`, imported as + * `"@vitnode/example/routes/example-page"` - rather than a file path, so a plugin + * can move its implementation inside `dist` without breaking every app that + * installed it. Everything else on a definition (`path`, `area`, and whatever + * the manifest grows) is carried past this layer untouched. + */ +export type PluginRouteEntryDeclaration = Pick< + PluginRouteDefinition, + "entry" | "id" +>; + +/** + * One configured plugin, and the route entries it declares. + * + * Structurally satisfied by the manifest layer's own `PluginRouteSource`, so an + * app reads each plugin's route list once and hands the same array to both. + */ +export interface PluginRouteEntrySource { + pluginId: string; + routes?: readonly PluginRouteEntryDeclaration[]; +} + +/** + * A declaration once it has been validated and paired with the import specifier + * the generated registry will contain. + * + * `key` is the manifest layer's own `:` route id - built by + * its `pluginRouteId`, not by a second copy of the same rule - so a route's + * module loader is registered under exactly the id the manifest gave it, and + * neither side has to translate. + */ +export interface ResolvedPluginRouteModule { + entry: string; + key: string; + pluginId: string; + routeId: string; + specifier: string; +} + +/** + * A lazy import of one plugin route module. + * + * `unknown`, not a route type: the registry's job is to hand back the module, and + * what a module is expected to export is not this layer's contract. The + * generated file uses `satisfies` against {@link PluginRouteModuleRegistry}, so + * each loader keeps the real `typeof import("...")` of its own module and a + * consumer gets those exports typed without this type having to name them. + */ +export type PluginRouteModuleLoader = () => Promise; + +/** Every plugin route module of an app, keyed by {@link ResolvedPluginRouteModule.key}. */ +export type PluginRouteModuleRegistry = Readonly< + Record +>; diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 1e7d736cf..496f2ed55 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -5,6 +5,7 @@ import type { ContentSelect, ContentSystemField, } from "../content/types"; +import type { PluginRouteDefinition } from "../routing/types"; import type { ItemNavAdmin } from "../views/admin/layouts/sidebar/nav/item"; import type { LocaleMessagesMap } from "./i18n/types"; @@ -182,6 +183,19 @@ export interface BuildPluginReturn

{ contentTypes?: ContentTypeFrontendRegistration[]; messages?: LocaleMessagesMap; pluginId: P; + /** + * Public pages this plugin contributes, declared rather than shipped as a + * framework's route files. + * + * Additive and optional: a plugin with a `src/routes/` tree keeps working + * exactly as it did, because that tree is still copied into every Next.js app + * by `scripts/prepare-plugins-files.ts`. This is the parallel path - the one an + * application that is not Next.js can read - and `buildPluginRouteManifest` + * turns every plugin's list into the application's route manifest. + * + * Nothing in this package renders them yet. See `src/routing/`. + */ + routes?: PluginRouteDefinition[]; } export function buildPlugin

( diff --git a/packages/vitnode/src/routing/boundaries.test.ts b/packages/vitnode/src/routing/boundaries.test.ts new file mode 100644 index 000000000..ccacd101d --- /dev/null +++ b/packages/vitnode/src/routing/boundaries.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const filesUnder = (directory: string): string[] => { + const entries: string[] = []; + + for (const name of readdirSync(directory)) { + const path = join(directory, name); + + if (statSync(path).isDirectory()) { + entries.push(...filesUnder(path)); + continue; + } + + if (/\.tsx?$/.test(name)) entries.push(path); + } + + return entries; +}; + +const importsFrom = (path: string): string[] => + [ + ...readFileSync(path, "utf8").matchAll( + /from\s+"([^"]+)"|import\s+"([^"]+)"/g, + ), + ] + .map(match => match[1] ?? match[2]) + .filter((specifier): specifier is string => Boolean(specifier)); + +/** + * The rule this layer exists to keep. + * + * A plugin route manifest is VitNode configuration data. It is read while a + * Next.js app builds, while a TanStack Start app builds, and by a plain + * `vitest` process with no framework loaded at all - so a single import of + * `next/*` or `@tanstack/*` here does not fail in review, it fails for whoever + * is not using that framework. + * + * Stated as "imports nothing but its own files" rather than as a list of banned + * packages, because a list is something somebody has to remember to extend. + */ +describe("the routing layer is framework-neutral", () => { + const files = filesUnder(here).filter(path => !/\.test\.tsx?$/.test(path)); + + it("has files to check", () => { + // Every assertion below is vacuously true against an empty list. + expect(files.length).toBeGreaterThan(3); + }); + + it("imports nothing but its own modules", () => { + const offenders = files.flatMap(path => + importsFrom(path) + .filter(specifier => !specifier.startsWith(".")) + .map(specifier => `${relative(here, path)} -> ${specifier}`), + ); + + expect(offenders).toEqual([]); + }); + + it.each([ + "@tanstack/react-router", + "@tanstack/react-start", + "next", + "next-intl", + "react", + "server-only", + ])("never imports %s", forbidden => { + // Redundant with the rule above by construction, and worth writing anyway: + // this is the list a failure should name, and these are the packages a + // future contributor will actually be tempted to reach for. + const offenders = files.filter(path => + importsFrom(path).some( + specifier => + specifier === forbidden || specifier.startsWith(`${forbidden}/`), + ), + ); + + expect(offenders.map(path => relative(here, path))).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/routing/errors.ts b/packages/vitnode/src/routing/errors.ts new file mode 100644 index 000000000..a7866878f --- /dev/null +++ b/packages/vitnode/src/routing/errors.ts @@ -0,0 +1,46 @@ +export type PluginRouteErrorCode = + | "duplicate-id" + | "duplicate-path" + | "invalid-area" + | "invalid-entry" + | "invalid-id" + | "invalid-path" + | "invalid-plugin-id" + | "malformed-route"; + +export interface PluginRouteErrorDetails { + code: PluginRouteErrorCode; + /** The route that already owned the id or the path, on a collision. */ + conflictsWith?: { pluginId: string; routeId: string }; + path?: string; + pluginId: string; + routeId?: string; +} + +/** + * A plugin route that cannot be part of a manifest. + * + * Thrown rather than collected, and thrown on the first problem: a manifest with + * two plugins claiming `/blog` has no correct interpretation, and picking one is + * how an install silently serves the wrong page for a release. The structured + * fields are here so a build tool can render the failure its own way without + * parsing the message. + */ +export class PluginRouteError extends Error { + constructor(message: string, details: PluginRouteErrorDetails) { + super(message); + + this.name = "PluginRouteError"; + this.code = details.code; + this.conflictsWith = details.conflictsWith; + this.path = details.path; + this.pluginId = details.pluginId; + this.routeId = details.routeId; + } + + readonly code: PluginRouteErrorCode; + readonly conflictsWith?: { pluginId: string; routeId: string }; + readonly path?: string; + readonly pluginId: string; + readonly routeId?: string; +} diff --git a/packages/vitnode/src/routing/index.ts b/packages/vitnode/src/routing/index.ts new file mode 100644 index 000000000..6812a4c28 --- /dev/null +++ b/packages/vitnode/src/routing/index.ts @@ -0,0 +1,41 @@ +/** + * Plugin routing, as VitNode data. + * + * A plugin says "I have a page called `hello`, it lives at `/example/hello`, and + * this module renders it". This layer turns every such declaration in an + * application into one validated, deterministically ordered manifest, and stops + * there - it renders nothing, resolves no modules and imports nothing + * framework-shaped. + * + * That boundary is the whole point. The route trees plugins ship today are + * *Next.js* route trees, copied file by file into an app's `src/app` by + * `scripts/prepare-plugins-files.ts`, which is why a VitNode plugin currently + * cannot contribute a page to an application that is not Next.js. Nothing here + * replaces that yet: it is the parallel path, and both are live. + */ +export type { PluginRouteErrorCode, PluginRouteErrorDetails } from "./errors"; +export { PluginRouteError } from "./errors"; +export { + buildPluginRouteManifest, + comparePluginRoutes, + pluginRouteId, +} from "./manifest"; + +export type { ParseRoutePathResult } from "./path"; +export { + formatRoutePath, + parseRoutePath, + routeMatchKey, + routeMatchKeyFromTanStackPath, + toNextRoutePath, + toTanStackRoutePath, +} from "./path"; +export type { + PluginRoute, + PluginRouteArea, + PluginRouteDefinition, + PluginRouteManifest, + PluginRouteSegment, + PluginRouteSource, +} from "./types"; +export { PLUGIN_ROUTE_AREAS, PLUGIN_ROUTE_ID_SEPARATOR } from "./types"; diff --git a/packages/vitnode/src/routing/manifest.test.ts b/packages/vitnode/src/routing/manifest.test.ts new file mode 100644 index 000000000..b0171b11d --- /dev/null +++ b/packages/vitnode/src/routing/manifest.test.ts @@ -0,0 +1,357 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { BuildPluginReturn } from "../lib/plugin"; +import type { PluginRouteDefinition, PluginRouteSource } from "./types"; + +import { PluginRouteError } from "./errors"; +import { + buildPluginRouteManifest, + comparePluginRoutes, + pluginRouteId, +} from "./manifest"; + +const route = (id: string, path: string): PluginRouteDefinition => ({ + entry: `routes/${id}`, + id, + path, +}); + +const example = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({ + pluginId: "@vitnode/example", + routes, +}); + +const blog = (...routes: PluginRouteDefinition[]): PluginRouteSource => ({ + pluginId: "@vitnode/blog", + routes, +}); + +/** The error a call threw, typed - `expect().toThrow` only sees the message. */ +const thrownBy = (build: () => unknown): PluginRouteError => { + try { + build(); + } catch (error) { + if (error instanceof PluginRouteError) return error; + throw error; + } + + throw new Error("expected a PluginRouteError"); +}; + +describe("route ids", () => { + it("namespaces a route id by its plugin", () => { + // The same key `framework/plugin-routes` registers the module loader under, + // so a manifest entry addresses its own module with no translation step. + expect(pluginRouteId("@vitnode/example", "hello")).toBe( + "@vitnode/example:hello", + ); + }); + + it("lets two plugins use the same local id", () => { + const manifest = buildPluginRouteManifest([ + example(route("index", "/example")), + blog(route("index", "/blog")), + ]); + + expect(manifest.map(entry => entry.id)).toEqual([ + "@vitnode/blog:index", + "@vitnode/example:index", + ]); + }); +}); + +describe("the seam with the generated module registry", () => { + it("takes an app's configured plugin list exactly as it is", () => { + // The call an application makes: `buildPluginRouteManifest(config.plugins)`. + // `BuildPluginReturn` is not imported by the routing layer - it reaches the + // AdminCP nav and the Content Engine, and through them React - so the two + // types meet structurally or not at all. This is where that is checked. + const plugins: BuildPluginReturn[] = [ + { + pluginId: "@vitnode/example", + routes: [route("hello", "/example/hello")], + }, + { pluginId: "@vitnode/blog" }, + ]; + + expect(buildPluginRouteManifest(plugins).map(entry => entry.id)).toEqual([ + "@vitnode/example:hello", + ]); + }); + + it("declares the two fields the registry reads, and no more", () => { + // `framework/plugin-routes` takes `id` and `entry` off these same records + // and generates a lazy import for each. A definition is assignable to that + // shape by construction, which is what lets one list in a plugin's + // `routes/manifest.ts` serve both layers. + const declaration: { entry: string; id: string } = route("hello", "/x"); + + expect(declaration).toMatchObject({ entry: "routes/hello", id: "hello" }); + }); + + it("addresses a module by the key that registry is keyed on", () => { + const [route] = buildPluginRouteManifest([ + example({ entry: "routes/hello", id: "hello", path: "/example/hello" }), + ]); + + expect(route.id).toBe(`${route.pluginId}:${route.routeId}`); + expect(route.entry).toBe("routes/hello"); + }); +}); + +describe("normalising a declaration", () => { + it("fills in the defaults a plugin left out", () => { + const [route] = buildPluginRouteManifest([ + example({ entry: "routes/x", id: "x", path: "/example/x/" }), + ]); + + expect(route).toEqual({ + area: "main", + entry: "routes/x", + id: "@vitnode/example:x", + path: "/example/x", + pluginId: "@vitnode/example", + routeId: "x", + segments: [ + { kind: "static", value: "example" }, + { kind: "static", value: "x" }, + ], + }); + }); + + it("keeps an explicit area", () => { + const [route] = buildPluginRouteManifest([ + example({ + area: "main", + entry: "routes/hello", + id: "hello", + path: "/example/hello", + }), + ]); + + expect(route.area).toBe("main"); + }); + + it("is an empty manifest when nothing declares a route", () => { + expect( + buildPluginRouteManifest([ + { pluginId: "@vitnode/example" }, + { pluginId: "@vitnode/blog", routes: [] }, + ]), + ).toEqual([]); + }); +}); + +/** + * The property the whole manifest rests on: two installs with the same plugins + * in a different order resolve the same URLs to the same pages. + */ +describe("ordering is decided by the paths, not by the registration order", () => { + const routes = [ + example( + route("slug", "/example/:slug"), + route("new", "/example/new"), + route("index", "/example"), + ), + blog(route("post", "/blog/:postId/comments"), route("index", "/blog")), + ]; + + it("puts static segments before parameters at the same depth", () => { + expect(buildPluginRouteManifest(routes).map(entry => entry.path)).toEqual([ + "/blog", + "/blog/:postId/comments", + "/example", + "/example/new", + "/example/:slug", + ]); + }); + + it("gives the same order whichever plugin registered first", () => { + const forwards = buildPluginRouteManifest(routes); + const backwards = buildPluginRouteManifest([...routes].reverse()); + + expect(backwards.map(entry => entry.id)).toEqual( + forwards.map(entry => entry.id), + ); + }); + + it("gives the same order whichever route a plugin declared first", () => { + const declared = buildPluginRouteManifest([ + example(route("index", "/example"), route("slug", "/example/:slug")), + ]); + const reversed = buildPluginRouteManifest([ + example(route("slug", "/example/:slug"), route("index", "/example")), + ]); + + expect(reversed.map(entry => entry.path)).toEqual( + declared.map(entry => entry.path), + ); + }); + + it("is a total order, so a sort of a manifest is a no-op", () => { + const manifest = buildPluginRouteManifest(routes); + + expect([...manifest].sort(comparePluginRoutes)).toEqual(manifest); + }); +}); + +describe("collisions are errors, never resolutions", () => { + it("names both plugins when two claim the same path", () => { + const error = thrownBy(() => + buildPluginRouteManifest([ + example(route("hello", "/hello")), + blog(route("greeting", "/hello")), + ]), + ); + + expect(error.code).toBe("duplicate-path"); + expect(error.path).toBe("/hello"); + expect(error.pluginId).toBe("@vitnode/blog"); + expect(error.conflictsWith).toEqual({ + pluginId: "@vitnode/example", + routeId: "@vitnode/example:hello", + }); + expect(error.message).toContain("/hello"); + expect(error.message).toContain("@vitnode/example"); + expect(error.message).toContain("@vitnode/blog"); + }); + + it("catches a collision that only normalisation reveals", () => { + expect(() => + buildPluginRouteManifest([ + example(route("a", "/hello")), + blog(route("b", "/hello/")), + ]), + ).toThrow(PluginRouteError); + }); + + it("treats two paths that differ only by a parameter name as one path", () => { + // `/example/:slug` and `/example/:id` match exactly the same URLs. + const error = thrownBy(() => + buildPluginRouteManifest([ + example(route("a", "/example/:slug")), + blog(route("b", "/example/:id")), + ]), + ); + + expect(error.code).toBe("duplicate-path"); + // Both spellings, because neither plugin author wrote the other's. + expect(error.message).toContain("/example/:slug"); + expect(error.message).toContain("/example/:id"); + }); + + it("rejects one plugin declaring the same id twice", () => { + const error = thrownBy(() => + buildPluginRouteManifest([ + example(route("hello", "/a"), route("hello", "/b")), + ]), + ); + + expect(error.code).toBe("duplicate-id"); + expect(error.message).toContain("@vitnode/example:hello"); + }); +}); + +describe("malformed declarations", () => { + const build = (source: unknown) => + buildPluginRouteManifest([source] as PluginRouteSource[]); + + it("rejects an empty plugin id", () => { + for (const pluginId of ["", " ", undefined]) { + expect(thrownBy(() => build({ pluginId, routes: [] })).code).toBe( + "invalid-plugin-id", + ); + } + }); + + it("rejects a route that is not an object", () => { + expect( + thrownBy(() => build({ pluginId: "@vitnode/example", routes: ["/x"] })) + .code, + ).toBe("malformed-route"); + }); + + it("rejects a missing or unusable id", () => { + for (const id of [ + undefined, + "", + "with space", + "-leading-dash", + "../escape", + ]) { + expect( + thrownBy(() => + build({ + pluginId: "@vitnode/example", + routes: [{ entry: "routes/x", id, path: "/x" }], + }), + ).code, + ).toBe("invalid-id"); + } + }); + + it("rejects a missing or malformed path", () => { + for (const path of [undefined, "", "x", "/x/[id]"]) { + const error = thrownBy(() => + build({ + pluginId: "@vitnode/example", + routes: [{ entry: "routes/x", id: "x", path }], + }), + ); + + expect(error.code).toBe("invalid-path"); + expect(error.routeId).toBe("x"); + } + }); + + /** + * A path a router would match case-insensitively but this layer would compare + * as two different strings. Rejected here rather than lowercased, and the + * failure names the plugin - see `path.test.ts` for the rule itself. + */ + it("rejects an uppercase path, naming the plugin", () => { + const error = thrownBy(() => + build({ + pluginId: "@vitnode/example", + routes: [{ entry: "routes/x", id: "x", path: "/Example" }], + }), + ); + + expect(error.code).toBe("invalid-path"); + expect(error.pluginId).toBe("@vitnode/example"); + expect(error.message).toContain('Write "example"'); + }); + + it("rejects an entry an application could never import", () => { + for (const entry of [ + undefined, + "", + "/routes/x", + "routes/../../secret", + "routes/x.tsx", + "routes/x'\\n", + ]) { + expect( + thrownBy(() => + build({ + pluginId: "@vitnode/example", + routes: [{ entry, id: "x", path: "/x" }], + }), + ).code, + ).toBe("invalid-entry"); + } + }); + + it("rejects an unknown area", () => { + const error = thrownBy(() => + build({ + pluginId: "@vitnode/example", + routes: [{ area: "admin", entry: "routes/x", id: "x", path: "/x" }], + }), + ); + + expect(error.code).toBe("invalid-area"); + expect(error.message).toContain("main"); + }); +}); diff --git a/packages/vitnode/src/routing/manifest.ts b/packages/vitnode/src/routing/manifest.ts new file mode 100644 index 000000000..69c8b1a22 --- /dev/null +++ b/packages/vitnode/src/routing/manifest.ts @@ -0,0 +1,259 @@ +import type { + PluginRoute, + PluginRouteDefinition, + PluginRouteManifest, + PluginRouteSegment, + PluginRouteSource, +} from "./types"; + +import { PluginRouteError } from "./errors"; +import { parseRoutePath, routeMatchKey } from "./path"; +import { PLUGIN_ROUTE_AREAS, PLUGIN_ROUTE_ID_SEPARATOR } from "./types"; + +/** + * A `/`-separated identifier, and nothing that could escape a string literal. + * + * The same rule `framework/plugin-routes` applies to an id and to an entry, for + * a reason this layer does not share - it writes both into a generated import. + * They are stated identically anyway: an id this layer accepts and that one + * rejects would be a route that validates and then fails the build. + */ +const SEGMENTED = + /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/; + +/** An entry is a package export subpath, and export maps add the extension. */ +const ENTRY_EXTENSION = /\.[cm]?[jt]sx?$/; + +/** + * A route's globally unique id. + * + * Namespaced by the plugin so two plugins can both call their landing page + * `"index"` - which they will - without either having to know the other exists. + */ +export const pluginRouteId = (pluginId: string, routeId: string): string => + `${pluginId}${PLUGIN_ROUTE_ID_SEPARATOR}${routeId}`; + +/** + * The order routes are declared in must not decide which one wins. + * + * Compared segment by segment: a static segment sorts before a parameter at the + * same depth, so `/blog/new` precedes `/blog/:slug` no matter who registered + * first; equal kinds compare by their text, and a shorter path precedes a longer + * one that starts the same way. Comparison is by code unit rather than + * `localeCompare`, because a route table that reorders itself on a machine with + * a different locale is a bug that only reproduces on someone else's laptop. + * + * The id breaks the remaining tie, and ids are unique, so the order is total. + */ +const compareSegments = ( + a: PluginRouteSegment[], + b: PluginRouteSegment[], +): number => { + const shared = Math.min(a.length, b.length); + + for (let index = 0; index < shared; index += 1) { + const left = a[index]; + const right = b[index]; + + if (left.kind !== right.kind) { + return left.kind === "static" ? -1 : 1; + } + + const leftText = left.kind === "static" ? left.value : left.name; + const rightText = right.kind === "static" ? right.value : right.name; + + if (leftText !== rightText) { + return leftText < rightText ? -1 : 1; + } + } + + return a.length - b.length; +}; + +export const comparePluginRoutes = (a: PluginRoute, b: PluginRoute): number => { + const bySegments = compareSegments(a.segments, b.segments); + + if (bySegments !== 0) return bySegments; + if (a.id === b.id) return 0; + + return a.id < b.id ? -1 : 1; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const readEntry = ( + entry: string | undefined, + pluginId: string, + routeId: string, +): string => { + const fail = (reason: string): never => { + throw new PluginRouteError( + `Plugin route "${routeId}" from ${pluginId} has an invalid entry ${JSON.stringify(entry)}: ${reason}.`, + { code: "invalid-entry", pluginId, routeId }, + ); + }; + + if (typeof entry !== "string" || !SEGMENTED.test(entry)) { + return fail( + 'expected a package export subpath such as "routes/example-page" - "/"-separated segments of letters, digits, ".", "_" and "-", with no leading slash and no ".." segment', + ); + } + + if (ENTRY_EXTENSION.test(entry)) { + return fail( + "an entry is a package export subpath and the plugin's export map adds the extension - drop it", + ); + } + + return entry; +}; + +const readDefinition = ( + definition: unknown, + pluginId: string, + index: number, +): PluginRoute => { + if (!isRecord(definition)) { + throw new PluginRouteError( + `Plugin ${pluginId} declared a route at index ${index} that is not an object.`, + { code: "malformed-route", pluginId }, + ); + } + + // The only cast in the module. `routes` is typed, but a plugin is JavaScript + // by the time it is registered and its config is written by hand, so the + // fields are read defensively and the types are re-established here. + const { area, entry, id, path } = + definition as Partial; + + if (typeof id !== "string" || !SEGMENTED.test(id)) { + throw new PluginRouteError( + `Plugin ${pluginId} declared a route at index ${index} with an invalid id ${JSON.stringify(id)} - use letters, digits, ".", "-" and "_".`, + { code: "invalid-id", pluginId }, + ); + } + + if (area !== undefined && !PLUGIN_ROUTE_AREAS.includes(area)) { + throw new PluginRouteError( + `Plugin route "${id}" from ${pluginId} declares the unknown area ${JSON.stringify(area)}. Known areas: ${PLUGIN_ROUTE_AREAS.join(", ")}.`, + { code: "invalid-area", pluginId, routeId: id }, + ); + } + + if (typeof path !== "string") { + throw new PluginRouteError( + `Plugin route "${id}" from ${pluginId} declares no path (got ${JSON.stringify(path)}).`, + { code: "invalid-path", pluginId, routeId: id }, + ); + } + + const parsed = parseRoutePath(path); + + if (!parsed.ok) { + throw new PluginRouteError( + `Plugin route "${id}" from ${pluginId} has an invalid path: ${parsed.reason}.`, + { code: "invalid-path", path, pluginId, routeId: id }, + ); + } + + return { + area: area ?? "main", + entry: readEntry(entry, pluginId, id), + id: pluginRouteId(pluginId, id), + path: parsed.path, + pluginId, + routeId: id, + segments: parsed.segments, + }; +}; + +/** + * Every plugin route in an application, validated and deterministically ordered. + * + * Pure, and total in the only sense that matters: it either returns a manifest + * no framework can misread, or it throws a {@link PluginRouteError} naming the + * plugin, the route and - on a collision - both sides of it. There is no third + * outcome where a route is quietly dropped, because a page that silently stops + * existing is the failure mode this whole function is for. + * + * Registration order affects nothing but which plugin an error message calls + * "first". + */ +export const buildPluginRouteManifest = ( + sources: PluginRouteSource[], +): PluginRouteManifest => { + const routes: PluginRoute[] = []; + const byId = new Map(); + const byPath = new Map(); + + for (const source of sources) { + const pluginId = isRecord(source) ? source.pluginId : undefined; + + if (typeof pluginId !== "string" || !/^\S+$/.test(pluginId)) { + throw new PluginRouteError( + `A plugin registered routes without a plugin id (got ${JSON.stringify(pluginId)}).`, + { code: "invalid-plugin-id", pluginId: "" }, + ); + } + + const declared = (source.routes ?? []) as unknown[]; + + if (!Array.isArray(declared)) { + throw new PluginRouteError( + `Plugin ${pluginId} declared \`routes\` that is not an array.`, + { code: "malformed-route", pluginId }, + ); + } + + declared.forEach((definition, index) => { + const route = readDefinition(definition, pluginId, index); + const existingById = byId.get(route.id); + + if (existingById) { + throw new PluginRouteError( + `Duplicate plugin route id "${route.id}": declared twice by ${pluginId}.`, + { + code: "duplicate-id", + conflictsWith: { + pluginId: existingById.pluginId, + routeId: existingById.id, + }, + path: route.path, + pluginId, + routeId: route.id, + }, + ); + } + + // Keyed on the URLs the route matches rather than on its text, so + // `/blog/:slug` and `/blog/:postId` collide - they are one route spelled + // twice. Area-scoped, because the same pathname under two different + // layouts would be two different URLs; only one area exists today. + const pathKey = `${route.area} ${routeMatchKey(route.segments)}`; + const existingByPath = byPath.get(pathKey); + + if (existingByPath) { + throw new PluginRouteError( + `Plugin route path collision on "${route.path}" (${route.area}): ${existingByPath.pluginId} already owns "${existingByPath.path}" as "${existingByPath.id}", and ${pluginId} declares "${route.path}" as "${route.id}". Two plugins cannot serve the same path - rename one of them.`, + { + code: "duplicate-path", + conflictsWith: { + pluginId: existingByPath.pluginId, + routeId: existingByPath.id, + }, + path: route.path, + pluginId, + routeId: route.id, + }, + ); + } + + byId.set(route.id, route); + byPath.set(pathKey, route); + routes.push(route); + }); + } + + return routes.sort(comparePluginRoutes); +}; diff --git a/packages/vitnode/src/routing/path.test.ts b/packages/vitnode/src/routing/path.test.ts new file mode 100644 index 000000000..5d365f64d --- /dev/null +++ b/packages/vitnode/src/routing/path.test.ts @@ -0,0 +1,332 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + formatRoutePath, + parseRoutePath, + routeMatchKey, + routeMatchKeyFromTanStackPath, + toNextRoutePath, + toTanStackRoutePath, +} from "./path"; + +const parse = (path: string) => { + const result = parseRoutePath(path); + + if (!result.ok) throw new Error(result.reason); + + return result; +}; + +const reason = (path: string): string => { + const result = parseRoutePath(path); + + if (result.ok) throw new Error(`"${path}" was accepted`); + + return result.reason; +}; + +describe("the shapes a VitNode route path represents", () => { + it("reads a static route", () => { + expect(parse("/example").segments).toEqual([ + { kind: "static", value: "example" }, + ]); + }); + + it("reads a nested static route", () => { + expect(parse("/example/hello/there").segments).toEqual([ + { kind: "static", value: "example" }, + { kind: "static", value: "hello" }, + { kind: "static", value: "there" }, + ]); + }); + + it("reads a dynamic segment", () => { + expect(parse("/example/:slug").segments).toEqual([ + { kind: "static", value: "example" }, + { kind: "param", name: "slug" }, + ]); + }); + + it("reads a dynamic segment nested under another", () => { + expect(parse("/example/:categoryId/posts/:postId").segments).toEqual([ + { kind: "static", value: "example" }, + { kind: "param", name: "categoryId" }, + { kind: "static", value: "posts" }, + { kind: "param", name: "postId" }, + ]); + }); + + it("reads the root as no segments at all", () => { + expect(parse("/")).toEqual({ ok: true, path: "/", segments: [] }); + }); + + it("keeps dots, dashes and underscores in a static segment", () => { + expect(parse("/example/robots.txt/a-b_c").segments).toEqual([ + { kind: "static", value: "example" }, + { kind: "static", value: "robots.txt" }, + { kind: "static", value: "a-b_c" }, + ]); + }); +}); + +describe("normalisation", () => { + it("drops a trailing slash", () => { + expect(parse("/example/hello/").path).toBe("/example/hello"); + }); + + it("round-trips a path through its segments", () => { + for (const path of ["/", "/example", "/example/:slug/comments"]) { + expect(formatRoutePath(parse(path).segments)).toBe(path); + } + }); + + it("reports the normalised path, not the one it was given", () => { + expect(parse("/example/:slug/").path).toBe("/example/:slug"); + }); +}); + +describe("paths a plugin may not declare", () => { + it("needs a leading slash", () => { + expect(reason("example")).toContain('must start with "/"'); + }); + + it("rejects an empty path", () => { + expect(reason("")).toContain("non-empty string"); + }); + + it("rejects an empty segment", () => { + expect(reason("/example//hello")).toContain("empty segment"); + expect(reason("//")).toContain("empty segment"); + }); + + it("rejects a query string, a hash and whitespace", () => { + expect(reason("/example?page=2")).toContain("query string"); + expect(reason("/example#top")).toContain("hash"); + expect(reason("/example/hello world")).toContain("whitespace"); + }); + + it("rejects the same parameter twice", () => { + expect(reason("/example/:id/nested/:id")).toContain('declares ":id" twice'); + }); + + it("rejects a parameter that is not an identifier", () => { + expect(reason("/example/:1st")).toContain("not a valid parameter name"); + expect(reason("/example/:")).toContain("not a valid parameter name"); + }); +}); + +/** + * One canonical spelling, because a router only has one. + * + * The routers that consume this manifest match paths case-insensitively, so + * `/Example` and `/example` are one URL to a browser and two strings to + * `routeMatchKey` - a collision the validation could not see. Rejected rather + * than lowercased: a plugin's public URL must not change behind its author's + * back, and a build error is how they find out. + */ +describe("static segments are lowercase", () => { + it("accepts a lowercase path", () => { + expect(parse("/example").path).toBe("/example"); + expect(parse("/blog/post").path).toBe("/blog/post"); + expect(parse("/example/:slug").path).toBe("/example/:slug"); + }); + + it("rejects an uppercase segment, and says what to write instead", () => { + expect(reason("/Example")).toContain("uppercase letters"); + expect(reason("/Example")).toContain('Write "example"'); + expect(reason("/BLOG/post")).toContain('Write "blog"'); + expect(reason("/blog/My-Post")).toContain('Write "my-post"'); + }); + + /** + * A parameter's name never reaches a URL - it is a variable name - so the + * identifier rules it already had are the right ones. + */ + it("still allows camelCase parameter names", () => { + expect(parse("/blog/:postId").segments).toEqual([ + { kind: "static", value: "blog" }, + { kind: "param", name: "postId" }, + ]); + }); + + it("keeps naming the framework syntaxes ahead of the case rule", () => { + // `[Slug]` and `$Slug` are uppercase *and* the wrong syntax. The syntax is + // the useful thing to say. + expect(reason("/example/[Slug]")).toContain("Next.js filesystem syntax"); + expect(reason("/example/$Slug")).toContain("TanStack Router syntax"); + }); +}); + +/** + * The two syntaxes this representation exists to be independent of. + * + * A plugin author coming from either framework writes the one they know, so the + * failure has to name the syntax and hand back the VitNode spelling rather than + * saying "invalid path". + */ +describe("framework syntax is rejected by name", () => { + it("rejects Next.js filesystem syntax", () => { + expect(reason("/example/[slug]")).toContain("Next.js filesystem syntax"); + expect(reason("/example/[slug]")).toContain('write ":slug"'); + }); + + it("rejects TanStack Router syntax", () => { + expect(reason("/example/$slug")).toContain("TanStack Router syntax"); + expect(reason("/example/$slug")).toContain('write ":slug"'); + }); +}); + +/** + * Deferred on purpose - inventoried in the Stage 5 notes rather than guessed at. + * + * Core ships two catch-alls today (`admin/content/[...slug]` and the + * `@breadcrumb` slots), both of which are AdminCP or parallel-route machinery + * that this stage does not cover. Accepting `/x/*` here would mean deciding what + * it means before anything needs it. + */ +describe("route shapes this prototype defers", () => { + it("rejects a catch-all", () => { + expect(reason("/example/*")).toContain("catch-all"); + expect(reason("/example/[...slug]")).toContain("Next.js filesystem syntax"); + }); + + it("rejects an optional segment", () => { + expect(reason("/example/:slug?")).toContain("optional segment"); + }); + + it("rejects a repeating segment", () => { + expect(reason("/example/:slug*")).toContain("repeating segment"); + expect(reason("/example/:slug+")).toContain("repeating segment"); + }); +}); + +describe("conversions to the frameworks that consume the manifest", () => { + it.each([ + ["/", "/", "/"], + ["/example", "/example", "/example"], + ["/example/:slug", "/example/[slug]", "/example/$slug"], + [ + "/example/:categoryId/posts/:postId", + "/example/[categoryId]/posts/[postId]", + "/example/$categoryId/posts/$postId", + ], + ])("converts %s", (path, next, tanstack) => { + const { segments } = parse(path); + + expect(toNextRoutePath(segments)).toBe(next); + expect(toTanStackRoutePath(segments)).toBe(tanstack); + }); + + it("is a pure function of the segments, not of the string it came from", () => { + // The conversions never see a path, so there is no second parser to + // disagree with the first one. + expect(toTanStackRoutePath(parse("/example/:slug/").segments)).toBe( + "/example/$slug", + ); + }); +}); + +describe("the URLs a path matches", () => { + it("is the path itself when nothing is dynamic", () => { + expect(routeMatchKey(parse("/example/hello").segments)).toBe( + "/example/hello", + ); + expect(routeMatchKey(parse("/").segments)).toBe("/"); + }); + + it("does not depend on what a parameter is called", () => { + // The two paths a plugin author writes when they have not read the other + // plugin's routes. They match the same URLs, so the manifest has to see + // them as one. + expect(routeMatchKey(parse("/example/:slug").segments)).toBe( + routeMatchKey(parse("/example/:postId").segments), + ); + }); + + it("keeps a parameter distinct from a static segment", () => { + expect(routeMatchKey(parse("/example/:slug").segments)).not.toBe( + routeMatchKey(parse("/example/slug").segments), + ); + }); + + it("keeps depth distinct", () => { + expect(routeMatchKey(parse("/example/:a/:b").segments)).not.toBe( + routeMatchKey(parse("/example/:a").segments), + ); + }); +}); + +/** + * The same key space, entered from a path an application's router already holds. + * + * This is what lets plugin-vs-application collisions be the same question as + * plugin-vs-plugin instead of a second rule that agrees until somebody edits one. + * `$id` is read as input syntax; nothing here imports a router. + */ +describe("the URLs a TanStack path matches", () => { + const key = routeMatchKeyFromTanStackPath; + + it("agrees with the canonical key for the same route", () => { + expect(key("/example/hello")).toBe( + routeMatchKey(parse("/example/hello").segments), + ); + expect(key("/blog/$slug")).toBe( + routeMatchKey(parse("/blog/:slug").segments), + ); + expect(key("/")).toBe(routeMatchKey(parse("/").segments)); + }); + + /** + * The case the old exact-string comparison missed: two syntaxes, two parameter + * names, one URL space. + */ + it("does not depend on what a parameter is called", () => { + expect(key("/users/$id")).toBe(key("/users/$userId")); + expect(key("/users/$id")).toBe( + routeMatchKey(parse("/users/:userId").segments), + ); + expect(key("/blog/$slug/comments")).toBe( + routeMatchKey(parse("/blog/:postId/comments").segments), + ); + }); + + it("keeps a static segment distinct from a parameter", () => { + expect(key("/users/new")).not.toBe(key("/users/$id")); + expect(key("/users/new")).not.toBe( + routeMatchKey(parse("/users/:id").segments), + ); + }); + + /** + * An index route under a layout joins to `/blog/`, which is the same URL as + * `/blog` - so a plugin claiming `/blog` has to collide with it. + */ + it("treats one trailing slash as formatting", () => { + expect(key("/discover/")).toBe(key("/discover")); + expect(key("/discover/")).toBe(routeMatchKey(parse("/discover").segments)); + expect(key("/")).toBe("/"); + }); + + /** + * A splat swallows every remaining segment and a parameter swallows one, so + * they are not the same URL space and must not share a key. No canonical + * VitNode path can produce this marker - catch-alls are rejected - so a plugin + * route can never collide with an application splat by key. + */ + it("keeps a splat distinct from a parameter", () => { + expect(key("/api/$")).toBe("/api/**"); + expect(key("/api/$")).not.toBe(key("/api/$id")); + expect(key("/api/$")).not.toBe(routeMatchKey(parse("/api/:id").segments)); + }); + + /** + * An application's own route files are not held to the plugin lowercase rule, + * and a router would match `/Users` and `/users` as one URL either way. + */ + it("compares an application path case-insensitively", () => { + expect(key("/Users/$id")).toBe( + routeMatchKey(parse("/users/:userId").segments), + ); + }); +}); diff --git a/packages/vitnode/src/routing/path.ts b/packages/vitnode/src/routing/path.ts new file mode 100644 index 000000000..b80a9ab29 --- /dev/null +++ b/packages/vitnode/src/routing/path.ts @@ -0,0 +1,279 @@ +import type { PluginRouteSegment } from "./types"; + +/** + * A static segment: a literal piece of URL, and lowercase. + * + * Percent-encoding, spaces and uppercase are all left out. A plugin author who + * needs one of the first two in a public URL has a naming problem, not a routing + * problem, and a route table full of `%20` is nobody's idea of a good time. + * + * Uppercase is excluded for a sharper reason: the routers that consume this + * manifest match paths **case-insensitively**, so `/Example` and `/example` + * answer the same URL. Accepting both would mean two manifest paths that + * `routeMatchKey` calls different and a browser calls identical - a collision the + * validation could not see. One canonical spelling removes the question. + */ +const STATIC_SEGMENT = /^[a-z0-9][a-z0-9._-]*$/; + +/** A parameter name, i.e. a JavaScript-ish identifier - it becomes one. */ +const PARAM_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** Next.js filesystem syntax: `[id]`, `[...slug]`, `[[...slug]]`. */ +const NEXT_SEGMENT = /^\[.*\]$/; + +export type ParseRoutePathResult = + | { ok: false; reason: string } + | { ok: true; path: string; segments: PluginRouteSegment[] }; + +const parseSegment = ( + raw: string, +): { reason: string } | { segment: PluginRouteSegment } => { + if (raw.length === 0) { + return { reason: "it has an empty segment" }; + } + + if (NEXT_SEGMENT.test(raw)) { + const name = raw.replace(/^\[+\.{0,3}|\]+$/g, ""); + + return { + reason: `"${raw}" is Next.js filesystem syntax - write ":${name || "name"}" instead`, + }; + } + + if (raw.startsWith("$")) { + return { + reason: `"${raw}" is TanStack Router syntax - write ":${raw.slice(1) || "name"}" instead`, + }; + } + + if (raw === "*" || raw === "**") { + return { + reason: `"${raw}" is a catch-all segment, which VitNode route paths do not represent yet`, + }; + } + + if (raw.startsWith(":")) { + const name = raw.slice(1); + + if (name.endsWith("?")) { + return { + reason: `"${raw}" is an optional segment, which VitNode route paths do not represent yet`, + }; + } + + if (name.endsWith("*") || name.endsWith("+")) { + return { + reason: `"${raw}" is a repeating segment, which VitNode route paths do not represent yet`, + }; + } + + if (!PARAM_NAME.test(name)) { + return { + reason: `":${name}" is not a valid parameter name - use letters, digits and underscores, starting with a letter`, + }; + } + + return { segment: { kind: "param", name } }; + } + + if (raw.includes("?")) { + return { + reason: `"${raw}" looks like a query string, which is not part of a route path`, + }; + } + + // Named before the general rule, and never lowercased silently: a plugin's + // public URL changing behind its author's back is worse than a build error + // that says exactly what to write. + if (/[A-Z]/.test(raw)) { + return { + reason: `"${raw}" has uppercase letters - VitNode route paths are lowercase, because a router matches them case-insensitively and "/${raw}" and "/${raw.toLowerCase()}" would be one URL. Write "${raw.toLowerCase()}" instead`, + }; + } + + if (!STATIC_SEGMENT.test(raw)) { + return { + reason: `"${raw}" is not a valid path segment - use lowercase letters, digits, "-", "_" and "."`, + }; + } + + return { segment: { kind: "static", value: raw } }; +}; + +/** + * Reads a canonical VitNode route path. + * + * The one fallible function in this module, and the only place a path string is + * ever interpreted. Everything else takes segments, which cannot be malformed, + * so no caller has to remember to handle an error twice. + * + * Returns a result rather than throwing: the manifest builder wants to attach + * the plugin and the route id to the failure, and an exception thrown from here + * would not know either. + */ +export const parseRoutePath = (path: string): ParseRoutePathResult => { + if (typeof path !== "string" || path.length === 0) { + return { ok: false, reason: "a route path must be a non-empty string" }; + } + + if (!path.startsWith("/")) { + return { ok: false, reason: `"${path}" must start with "/"` }; + } + + if (/[#\s]/.test(path)) { + return { + ok: false, + reason: `"${path}" must not contain whitespace or a hash`, + }; + } + + if (path === "/") { + return { ok: true, path: "/", segments: [] }; + } + + // One trailing slash is a formatting difference, not a different route. + const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; + const segments: PluginRouteSegment[] = []; + const params = new Set(); + + for (const raw of trimmed.slice(1).split("/")) { + const parsed = parseSegment(raw); + + if ("reason" in parsed) { + return { + ok: false, + reason: `"${path}" is not a valid path: ${parsed.reason}`, + }; + } + + if (parsed.segment.kind === "param") { + if (params.has(parsed.segment.name)) { + return { + ok: false, + reason: `"${path}" declares ":${parsed.segment.name}" twice`, + }; + } + + params.add(parsed.segment.name); + } + + segments.push(parsed.segment); + } + + return { ok: true, path: formatRoutePath(segments), segments }; +}; + +/** Segments back to their canonical VitNode path. */ +export function formatRoutePath(segments: PluginRouteSegment[]): string { + if (segments.length === 0) return "/"; + + return `/${segments + .map(segment => + segment.kind === "param" ? `:${segment.name}` : segment.value, + ) + .join("/")}`; +} + +/** + * Segments to Next.js filesystem syntax, `/blog/[slug]`. + * + * Here rather than in the Next.js layer because it is the same three lines as + * its TanStack twin, and keeping the pair together is what stops the two + * conversions from drifting into two different ideas of what a path is. + */ +export const toNextRoutePath = (segments: PluginRouteSegment[]): string => { + if (segments.length === 0) return "/"; + + return `/${segments + .map(segment => + segment.kind === "param" ? `[${segment.name}]` : segment.value, + ) + .join("/")}`; +}; + +/** Segments to TanStack Router syntax, `/blog/$slug`. */ +export const toTanStackRoutePath = (segments: PluginRouteSegment[]): string => { + if (segments.length === 0) return "/"; + + return `/${segments + .map(segment => + segment.kind === "param" ? `$${segment.name}` : segment.value, + ) + .join("/")}`; +}; + +/** + * The set of URLs a path matches, as a comparable string. + * + * `/blog/:slug` and `/blog/:postId` are two spellings of one route: they match + * exactly the same URLs, and an application that accepted both would answer + * `/blog/hello` differently depending on which plugin loaded first. Collapsing + * every parameter to `:` is what turns that into a collision the manifest can + * refuse rather than a race it silently resolves. + */ +export const routeMatchKey = (segments: PluginRouteSegment[]): string => { + if (segments.length === 0) return "/"; + + return `/${segments + .map(segment => (segment.kind === "param" ? ":" : segment.value)) + .join("/")}`; +}; + +/** + * A splat, in a {@link routeMatchKeyFromTanStackPath} key. + * + * Deliberately not `:`. A splat swallows every remaining segment and a parameter + * swallows exactly one, so `/api/$` and `/api/:id` do *not* match the same URLs - + * `/api/a/b` reaches only the first. Giving them one key would break the single + * promise this whole key space makes: equal keys mean equal sets of URLs. No + * canonical VitNode path can produce this marker, because `parseRoutePath` + * rejects catch-alls outright, so a plugin route can never collide with an + * application's splat by key. + */ +const MATCH_KEY_SPLAT = "**"; + +/** + * {@link routeMatchKey}, for a path already written in TanStack Router syntax. + * + * The second entrance to one key space, and the reason plugin-vs-plugin and + * plugin-vs-application collisions are the same question asked twice rather than + * two rules that agree until somebody edits one. A plugin route arrives as parsed + * segments and goes through `routeMatchKey`; an application's own route arrives + * as the string its router already holds - `/users/$id` - and comes through here. + * Both land on `/users/:`, so they compare. + * + * /users/$id -> /users/: + * /users/$userId -> /users/: (a parameter's name is not part of a URL) + * /users/new -> /users/new (a router tells static from dynamic) + * /blog/$slug/x -> /blog/:/x + * /discover/ -> /discover (an index route under a layout) + * /api/$ -> /api/** (see MATCH_KEY_SPLAT) + * + * Framework-neutral despite the name: `$id` is treated as *input syntax*, the + * same way `toTanStackRoutePath` treats it as output syntax. Nothing here imports + * a router, and nothing here may - see `boundaries.test.ts`. + */ +export const routeMatchKeyFromTanStackPath = (path: string): string => { + // A route may declare `/`, and a layout's index child joins to `/blog/` - + // which is the same URL as `/blog`. One trailing slash is formatting. + const trimmed = + path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; + + if (trimmed === "" || trimmed === "/") return "/"; + + return `/${trimmed + .replace(/^\//, "") + .split("/") + .filter(segment => segment.length > 0) + .map(segment => { + if (segment === "$") return MATCH_KEY_SPLAT; + if (segment.startsWith("$")) return ":"; + + // Lowercased because a router matches case-insensitively, so an + // application route at `/Users` and a plugin route at `/users` are one + // URL. Plugin paths are already lowercase by construction - `parseRoutePath` + // refuses anything else - and an app's own route files are not. + return segment.toLowerCase(); + }) + .join("/")}`; +}; diff --git a/packages/vitnode/src/routing/types.ts b/packages/vitnode/src/routing/types.ts new file mode 100644 index 000000000..ec8a3d777 --- /dev/null +++ b/packages/vitnode/src/routing/types.ts @@ -0,0 +1,107 @@ +/** + * Where a plugin route mounts in the application. + * + * One member, on purpose. Stage 5 is about public pages: the AdminCP has its own + * layout, its own staff permissions and its own breadcrumbs, and none of that is + * decided here. Adding `"admin"` later is a one-line change plus whatever + * interprets it - which is exactly the point of keeping the list here rather than + * letting every route invent its own string. + */ +export type PluginRouteArea = "main"; + +/** Every area a route may declare. */ +export const PLUGIN_ROUTE_AREAS: PluginRouteArea[] = ["main"]; + +/** + * Separates a plugin id from a route id. Not legal inside either half. + * + * The same separator `framework/plugin-routes` keys its generated module + * registry by, so a manifest entry's `id` *is* the key that registry is looked + * up with. Two layers, one identifier, and nothing has to translate between + * them. + */ +export const PLUGIN_ROUTE_ID_SEPARATOR = ":"; + +/** One parsed segment of a canonical VitNode route path. */ +export type PluginRouteSegment = + { kind: "param"; name: string } | { kind: "static"; value: string }; + +/** + * A page route contributed by a plugin, as the plugin declares it. + * + * Deliberately four fields, two of which are the two `framework/plugin-routes` + * already reads - so one list in a plugin's `routes/manifest.ts` serves both: + * the build tool takes `id` and `entry` and generates a lazy import, and this + * layer takes `path` and `area` and decides what URL that import answers. + * + * Everything else a page needs - its data, its metadata, its cache policy, who + * may see it - is either the component's business or a question this prototype + * has not earned an answer to yet. + */ +export interface PluginRouteDefinition { + /** Defaults to `"main"`. */ + area?: PluginRouteArea; + /** + * Package export subpath of the module that renders this route, e.g. + * `"routes/example-page"`, imported as + * `"@vitnode/example/routes/example-page"`. + * + * A subpath rather than a full specifier, because the plugin id is already on + * the record; a subpath rather than a file path, so a plugin can move the + * implementation inside its `dist` without breaking every app that installs + * it; extensionless, because the plugin's export map adds the extension. + */ + entry: string; + /** + * Stable identifier, unique within the plugin. It survives a path change - + * that is what makes it worth having - so name it after the page, not the URL. + */ + id: string; + /** + * Canonical VitNode path: `/blog`, `/blog/:slug`, `/blog/:slug/comments`. + * + * Neither Next's `[slug]` nor TanStack's `$slug`. See `./path` for the + * conversions, and for the shapes this prototype rejects rather than guesses + * at. + */ + path: string; +} + +/** One route in a built manifest: validated, normalised and parsed. */ +export interface PluginRoute { + area: PluginRouteArea; + entry: string; + /** + * Globally unique, `":"` - and the key + * `framework/plugin-routes` registers the route's module loader under. + */ + id: string; + /** Canonical path, normalised (no trailing slash). */ + path: string; + pluginId: string; + /** The plugin-local half of {@link PluginRoute.id}, as declared. */ + routeId: string; + /** `path`, already parsed - so nothing downstream has to parse it again. */ + segments: PluginRouteSegment[]; +} + +/** + * Every plugin route in an application, deterministically ordered. + * + * A plain array rather than a wrapper object: it is a list of routes, and a + * wrapper would only be somewhere to put the fields this stage was asked not to + * invent. + */ +export type PluginRouteManifest = PluginRoute[]; + +/** + * The part of a registered plugin the manifest reads. + * + * Structural, not `BuildPluginReturn`: that type reaches the AdminCP nav and the + * Content Engine, which reach React and Next, and this module has to stay + * loadable anywhere. A `BuildPluginReturn` satisfies this shape as it is. + */ +export interface PluginRouteSource { + pluginId: string; + routes?: PluginRouteDefinition[]; +} diff --git a/packages/vitnode/src/views/search/search-feed-content.test.tsx b/packages/vitnode/src/views/search/search-feed-content.test.tsx index c2b2f3901..679ff6d2d 100644 --- a/packages/vitnode/src/views/search/search-feed-content.test.tsx +++ b/packages/vitnode/src/views/search/search-feed-content.test.tsx @@ -23,12 +23,10 @@ vi.mock("@/lib/fetcher-client", () => ({ fetcherClient: (...args: unknown[]) => fetcherClient(...args), })); -const { classifySearchFeedHref, SearchFeedContent } = await import( - "./search-feed-content" -); -const { searchFeedQueryKey, searchFeedQueryOptions } = await import( - "./search-feed-query" -); +const { classifySearchFeedHref, SearchFeedContent } = + await import("./search-feed-content"); +const { searchFeedQueryKey, searchFeedQueryOptions } = + await import("./search-feed-query"); const messages = { core: { @@ -408,6 +406,7 @@ describe("the loading state", () => { it("renders skeletons until the first page arrives", async () => { let resolvePage: (value: { json: () => Promise; + ok: boolean; }) => void = () => undefined; fetcherClient.mockReturnValue( @@ -422,7 +421,10 @@ describe("the loading state", () => { container.querySelectorAll('[data-slot="skeleton"]').length, ).toBeGreaterThan(0); - resolvePage({ ok: true, json: async () => Promise.resolve(page([item()])) }); + resolvePage({ + ok: true, + json: async () => Promise.resolve(page([item()])), + }); expect(await screen.findByText("First post")).toBeDefined(); }); }); diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx index 1c3809dce..3c05e9fc1 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/example/src/config.tsx @@ -6,6 +6,7 @@ import { articleContentType } from "@/content/article"; import { categoryContentType } from "@/content/category"; import messages from "./locales"; +import { routes } from "./routes/manifest"; /** * Registering the content types is the whole frontend integration: the AdminCP @@ -15,6 +16,9 @@ export const examplePlugin = () => buildPlugin({ pluginId: "@vitnode/example", messages, + // Stage 5: the same list `routes/manifest.ts` hands the build tool, so a + // route is declared once whichever path an app reads it through. + routes, contentTypes: [ contentTypeAdmin({ definition: articleContentType, diff --git a/plugins/example/src/routes/example-page.tsx b/plugins/example/src/routes/example-page.tsx new file mode 100644 index 000000000..6807bf51a --- /dev/null +++ b/plugins/example/src/routes/example-page.tsx @@ -0,0 +1,31 @@ +/** + * The page `routes/manifest.ts` declares, and the first plugin route module a + * VitNode app bundles rather than copies. + * + * Zero imports, which is the point rather than an accident. It is compiled into + * this package's `dist` and imported by the app as + * `@vitnode/example/routes/example-page`, so it has to be renderable by whatever + * framework the app happens to use - and today those are Next.js and TanStack + * Start at the same time. Anything from `next/*`, `next-intl` or a router would + * pin it to one of them; a component that only needs JSX is pinned to neither. + * + * It exports a default component because that is how every VitNode plugin page + * already exports itself, and because a default export is the one name a + * generated registry can rely on without being told. + */ +const ExamplePage = () => ( +

+

+ Example plugin route +

+ +

+ This page lives in @vitnode/example and is served by the app + that installed it. It was never copied into the app's source: the app + generated a literal import for it from the plugin's route manifest, + and the bundler put it in its own chunk. +

+
+); + +export default ExamplePage; diff --git a/plugins/example/src/routes/manifest.ts b/plugins/example/src/routes/manifest.ts new file mode 100644 index 000000000..8b28bc9d1 --- /dev/null +++ b/plugins/example/src/routes/manifest.ts @@ -0,0 +1,37 @@ +import type { PluginRouteDefinition } from "@vitnode/core/routing"; + +/** + * The routes this plugin contributes to whatever app installs it. + * + * Plain data, and framework-neutral by construction: an `entry` is a *package + * export subpath*, so `"routes/example-page"` is imported as + * `"@vitnode/example/routes/example-page"` and resolves through this package's + * export map to its build output. Nothing here imports a router, and nothing + * here imports a page - so an app can read this list at build time, in Node, + * without pulling a single React component into the process. + * + * That is what lets the app generate literal `import()` calls for these modules + * instead of building specifiers at runtime: the ids and entries are known before + * the bundler runs, so Rollup gives each page its own lazily fetched chunk and + * the browser never has to ask which plugins are installed. + * + * Route *semantics* - the URL a route is served at, its area, its loader, its + * metadata, its permissions - belong on these records too, and are owned by the + * plugin route manifest contract (`@vitnode/core/routing`) rather than by the + * two fields the build reads. `path` is the first of them: `/example` in the + * canonical VitNode spelling, which is neither Next's `[id]` nor TanStack's + * `$id`, and `area` defaults to `"main"`. The registry generator reads `id` and + * `entry` and ignores the rest, so this list can keep growing without the build + * changing. + * + * `config.tsx` hands this same array to `buildPlugin({ routes })`, so a Next.js + * app that registers the plugin the usual way declares exactly the same routes - + * one list, read by both paths. + */ +export const routes: PluginRouteDefinition[] = [ + { + entry: "routes/example-page", + id: "example-page", + path: "/example", + }, +]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd6ff12ab..74f2360e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,6 +383,9 @@ importers: eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) + jiti: + specifier: ^2.7.0 + version: 2.7.0 jsdom: specifier: ^29.1.1 version: 29.1.1