/** * Plugin Routes v2 * * Handles plugin API route invocation with: * - Input validation via Zod schemas * - Route context creation * - Error handling * */ import { PluginContextFactory, type PluginContextFactoryOptions } from "./context.js"; import { extractRequestMeta } from "./request-meta.js"; import type { ResolvedPlugin, RouteContext, PluginRoute } from "./types.js"; /** * Body-reading methods on `Request`. EmDash parses the request body once before * the handler runs and exposes the result as `ctx.input`, leaving the underlying * stream consumed. Calling any of these on `ctx.request` would re-read a spent * stream and throw an opaque platform error ("Body is unusable: Body has already * been read") with no hint about `ctx.input` — so the guard replaces them with an * actionable message instead (#1293). */ const CONSUMED_BODY_METHODS = new Set(["json", "text", "arrayBuffer", "blob", "formData", "bytes"]); /** * Wrap the request handed to a plugin route handler so an accidental * `ctx.request.json()` (or `.text()`, `.formData()`, …) fails with a message * pointing at `ctx.input` rather than the runtime's cryptic "body already read" * error. Every non-body member passes through unchanged; function members are * bound to the underlying request so methods like `clone()` don't throw an * "Illegal invocation" when called on the proxy. */ function guardConsumedRequestBody(request: Request): Request { return new Proxy(request, { get(target, prop) { if (typeof prop === "string" && CONSUMED_BODY_METHODS.has(prop)) { return () => { throw new Error( `[emdash] ctx.request.${prop}() is not available inside a plugin route handler: ` + `EmDash has already parsed the request body and exposes it as ctx.input. ` + `Read ctx.input instead of ctx.request.${prop}().`, ); }; } const value = Reflect.get(target, prop, target); return typeof value === "function" ? value.bind(target) : value; }, }); } /** * Route metadata (public flag) without the handler. * Used by the catch-all route to decide auth before dispatch. */ export interface RouteMeta { public: boolean; } /** * Result from a route invocation */ export interface RouteResult { success: boolean; data?: T; error?: { code: string; message: string; details?: unknown; }; status: number; } /** * Route invocation options */ export interface InvokeRouteOptions { /** The original request */ request: Request; /** Request body (already parsed) */ body?: unknown; } /** * Route handler for a plugin */ export class PluginRouteHandler { private contextFactory: PluginContextFactory; private plugin: ResolvedPlugin; private trustedProxyHeaders: string[]; constructor(plugin: ResolvedPlugin, factoryOptions: PluginContextFactoryOptions) { this.plugin = plugin; this.contextFactory = new PluginContextFactory(factoryOptions); this.trustedProxyHeaders = factoryOptions.trustedProxyHeaders ?? []; } /** * Invoke a route by name */ async invoke(routeName: string, options: InvokeRouteOptions): Promise { const route = this.plugin.routes[routeName]; if (!route) { return { success: false, error: { code: "ROUTE_NOT_FOUND", message: `Route "${routeName}" not found in plugin "${this.plugin.id}"`, }, status: 404, }; } // Validate input if schema is provided let validatedInput: unknown; if (route.input) { const parseResult = route.input.safeParse(options.body); if (!parseResult.success) { return { success: false, error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: parseResult.error.format(), }, status: 400, }; } validatedInput = parseResult.data; } else { validatedInput = options.body; } // Create route context const baseContext = this.contextFactory.createContext(this.plugin); const routeContext: RouteContext = { ...baseContext, input: validatedInput, // The body is already parsed into `input`; guard `ctx.request`'s // body-reading methods so a re-read fails with an actionable message // (#1293). Metadata extraction uses the original request (headers only). request: guardConsumedRequestBody(options.request), requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders), }; // Execute handler try { const result = await route.handler(routeContext); return { success: true, data: result, status: 200, }; } catch (error) { // Handle known error types if (error instanceof PluginRouteError) { return { success: false, error: { code: error.code, message: error.message, details: error.details, }, status: error.status, }; } // Unknown error -- log internally, return generic message console.error(`[plugin:${this.plugin.id}] Route handler failed:`, error); return { success: false, error: { code: "INTERNAL_ERROR", message: "An internal error occurred", }, status: 500, }; } } /** * Get all route names */ getRouteNames(): string[] { return Object.keys(this.plugin.routes); } /** * Check if a route exists */ hasRoute(name: string): boolean { return name in this.plugin.routes; } /** * Get route metadata without invoking the handler. * Returns null if the route doesn't exist. */ getRouteMeta(name: string): RouteMeta | null { const route: PluginRoute | undefined = this.plugin.routes[name]; if (!route) return null; return { public: route.public === true }; } } /** * Error class for plugin routes * Allows plugins to return structured errors with specific HTTP status codes */ export class PluginRouteError extends Error { constructor( public code: string, message: string, public status: number = 400, public details?: unknown, ) { super(message); this.name = "PluginRouteError"; } /** * Create a bad request error (400) */ static badRequest(message: string, details?: unknown): PluginRouteError { return new PluginRouteError("BAD_REQUEST", message, 400, details); } /** * Create an unauthorized error (401) */ static unauthorized(message: string = "Unauthorized"): PluginRouteError { return new PluginRouteError("UNAUTHORIZED", message, 401); } /** * Create a forbidden error (403) */ static forbidden(message: string = "Forbidden"): PluginRouteError { return new PluginRouteError("FORBIDDEN", message, 403); } /** * Create a not found error (404) */ static notFound(message: string = "Not found"): PluginRouteError { return new PluginRouteError("NOT_FOUND", message, 404); } /** * Create a conflict error (409) */ static conflict(message: string, details?: unknown): PluginRouteError { return new PluginRouteError("CONFLICT", message, 409, details); } /** * Create an internal error (500) */ static internal(message: string = "Internal error"): PluginRouteError { return new PluginRouteError("INTERNAL_ERROR", message, 500); } } /** * Registry for all plugin route handlers */ export class PluginRouteRegistry { private handlers: Map = new Map(); constructor(private factoryOptions: PluginContextFactoryOptions) {} /** * Register a plugin's routes */ register(plugin: ResolvedPlugin): void { const handler = new PluginRouteHandler(plugin, this.factoryOptions); this.handlers.set(plugin.id, handler); } /** * Unregister a plugin's routes */ unregister(pluginId: string): void { this.handlers.delete(pluginId); } /** * Invoke a plugin route */ async invoke( pluginId: string, routeName: string, options: InvokeRouteOptions, ): Promise { const handler = this.handlers.get(pluginId); if (!handler) { return { success: false, error: { code: "PLUGIN_NOT_FOUND", message: `Plugin "${pluginId}" not found`, }, status: 404, }; } return handler.invoke(routeName, options); } /** * Get all registered plugin IDs */ getPluginIds(): string[] { return [...this.handlers.keys()]; } /** * Get routes for a plugin */ getRoutes(pluginId: string): string[] { return this.handlers.get(pluginId)?.getRouteNames() ?? []; } /** * Get route metadata for a specific plugin route. * Returns null if the plugin or route doesn't exist. */ getRouteMeta(pluginId: string, routeName: string): RouteMeta | null { const handler = this.handlers.get(pluginId); if (!handler) return null; return handler.getRouteMeta(routeName); } } /** * Create a route registry */ export function createRouteRegistry( factoryOptions: PluginContextFactoryOptions, ): PluginRouteRegistry { return new PluginRouteRegistry(factoryOptions); }