/** * Shared path resolution for `/x/*` user routes. * * Both the request dispatcher (`user-route-dispatcher.ts`) and the CLI * discovery surface (`user-routes-cli.ts`) resolve `/x/` paths to on-disk * handler files through this module, so what the CLI lists can never drift * from what the dispatcher actually serves. * * Three locations back the surface: * - `/routes/` — workspace routes, served at `/x/`. * - `/plugins//routes/` — an installed plugin's * routes, served in that plugin's reserved namespace at * `/x/plugins//`. * - `plugins/defaults//routes/` (the app source tree) — a * first-party default plugin's routes, served in the same * `/x/plugins//` namespace, where `` is the plugin's * `default-…` manifest name (e.g. `default-platform-hosted`) — the same name * its `.disabled` sentinel is keyed by. Default plugins live in the binary's * source, not the workspace, so their routes resolve from the source tree. * An installed plugin of the same name takes precedence (it can override a * default). */ import { existsSync, readdirSync, statSync } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; import { getDefaultPluginRouteRoots, getDefaultPluginRoutesDir, } from "../../plugins/defaults/main.js"; import { isPluginDisabled } from "../../plugins/disabled-state.js"; import { getWorkspacePluginsDir, getWorkspaceRoutesDir, } from "../../util/platform.js"; /** Supported file extensions for handler modules (`.js` preferred over `.ts`). */ export const HANDLER_EXTENSIONS = [".ts", ".js"] as const; /** Directory basename reserved for co-located tests — never a route segment. */ const TEST_DIR_NAME = "__tests__"; /** * True when a path under a routes directory is test machinery rather than a * servable handler: any `__tests__` segment, or a `*.test.ts` / `*.test.js` * filename. Discovery and dispatch both exclude these so a co-located test * file is never dynamically imported into the daemon — a test file's * top-level `mock.module(...)` calls are process-global and would replace * production modules (DB, platform paths) in the live process. */ export function isRouteTestPath(relPath: string): boolean { const segments = relPath.split(sep); return ( segments.includes(TEST_DIR_NAME) || /\.test\.(ts|js)$/.test(segments.at(-1) ?? "") ); } /** Path segment reserved for plugin-namespaced routes under `/x/`. */ export const PLUGIN_ROUTE_SEGMENT = "plugins"; export interface RouteLocation { /** Absolute directory the handler file is resolved under. */ routesDir: string; /** Path within `routesDir`, relative and without the `/x/` prefix. */ subPath: string; } /** * The manifest name of the plugin whose namespace an `/x/` route path falls in, * or undefined for a workspace route. * * The dispatcher marks that plugin as in context for the handler's execution, * so host APIs a route handler reaches (`resolveCredential`, * `indexDocument`) scope to the owning plugin exactly as they do inside its * hooks and tools. */ export function pluginNameForRoutePath(routePath: string): string | undefined { const segments = routePath.split("/"); if (segments[0] !== PLUGIN_ROUTE_SEGMENT) { return undefined; } return segments[1] || undefined; } /** * Resolve an `/x/` route path to the base directory + sub-path the handler file * is looked up under. * * `plugins//` resolves against that plugin's own `routes/` * directory (`` may be empty, mapping to the namespace's `index` * handler): an installed plugin's `/plugins//routes/` when * it ships one, otherwise a first-party default plugin's * `plugins/defaults//routes/` in the source tree. Everything else * resolves against the workspace `routes/` directory. Returns `null` (caller * 404s) when: * * - the path is a malformed plugin path (`plugins` with no name segment), so it * never falls back to a workspace route — the `plugins/` prefix is reserved * for plugin routes; or * - the named plugin is disabled (`.disabled` sentinel present), so a disabled * plugin serves no routes even though its files remain on disk. */ export function resolveRouteLocation(routePath: string): RouteLocation | null { const segments = routePath.split("/"); if (segments[0] === PLUGIN_ROUTE_SEGMENT) { const pluginName = pluginNameForRoutePath(routePath); if (!pluginName) { return null; } // A default plugin's namespace IS its `default-…` manifest name, so the // single `.disabled` sentinel key (`/plugins//.disabled`) // covers installed and default plugins alike. if (isPluginDisabled(pluginName)) { return null; } return { routesDir: resolvePluginRoutesDir(pluginName), subPath: segments.slice(2).join("/"), }; } return { routesDir: getWorkspaceRoutesDir(), subPath: routePath }; } /** * Resolve the `routes/` directory that backs a plugin's `/x/plugins//` * namespace. An installed plugin that ships a `routes/` directory takes * precedence (so it can override a same-named default); otherwise a default * plugin's source `routes/` directory is used (resolved by manifest name). * Falls back to the (missing) workspace path when the name matches neither, so * {@link resolveHandlerFile} reports a 404. */ function resolvePluginRoutesDir(pluginName: string): string { const workspaceRoutesDir = join( getWorkspacePluginsDir(), pluginName, "routes", ); if (existsSync(workspaceRoutesDir)) { return workspaceRoutesDir; } const defaultRoutesDir = getDefaultPluginRoutesDir(pluginName); if (defaultRoutesDir && existsSync(defaultRoutesDir)) { return defaultRoutesDir; } return workspaceRoutesDir; } /** * Resolve a sub-path within `routesDir` to a handler file on disk. * * Checks for direct file matches first (`.ts`, `.js`), then falls * back to index files (`/index.ts`, `/index.js`). Returns the * absolute path to the handler file, or `null` if not found. Rejects any path * that escapes `routesDir` (traversal backstop) and any test path * ({@link isRouteTestPath}), so test files are never served or imported. */ export function resolveHandlerFile( routesDir: string, subPath: string, ): string | null { const base = resolve(routesDir); const resolved = resolve(join(routesDir, subPath)); if (!resolved.startsWith(base)) { return null; } for (const ext of HANDLER_EXTENSIONS) { const candidate = `${resolved}${ext}`; if (existsSync(candidate) && !isRouteTestPath(relative(base, candidate))) { return candidate; } } for (const ext of HANDLER_EXTENSIONS) { const candidate = join(resolved, `index${ext}`); if (existsSync(candidate) && !isRouteTestPath(relative(base, candidate))) { return candidate; } } return null; } /** * True when a workspace `/x/` collides with the reserved plugin prefix * (`plugins` or `plugins/…`). Such a file lives under `/routes/` * but is shadowed by the plugin namespace and never served, so discovery must * exclude it — {@link resolveRouteLocation} routes the same path to a plugin * directory instead. */ export function isReservedWorkspaceRoutePath(routePath: string): boolean { return ( routePath === PLUGIN_ROUTE_SEGMENT || routePath.startsWith(`${PLUGIN_ROUTE_SEGMENT}/`) ); } /** * Enumerate enabled plugins that ship a `routes/` directory, for route * discovery. Mirrors {@link resolveRouteLocation}'s plugin resolution — same * directories, same disabled-sentinel gate, same installed-over-default * precedence — so discovery and dispatch agree on which plugin routes exist. * * Default plugins are seeded first (lower precedence); an installed plugin of * the same name that ships routes overrides its entry. */ export function listPluginRouteRoots(): { pluginName: string; routesDir: string; }[] { const byName = new Map(); for (const { pluginName, routesDir } of getDefaultPluginRouteRoots()) { if (!isPluginDisabled(pluginName)) { byName.set(pluginName, routesDir); } } const pluginsDir = getWorkspacePluginsDir(); if (existsSync(pluginsDir)) { for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) { if (!entry.isDirectory() || isPluginDisabled(entry.name)) { continue; } const routesDir = join(pluginsDir, entry.name, "routes"); if (existsSync(routesDir) && statSync(routesDir).isDirectory()) { byName.set(entry.name, routesDir); } } } return [...byName].map(([pluginName, routesDir]) => ({ pluginName, routesDir, })); }