import type { VmSandboxHandle } from "@suaveplan/api-sandbox-vm"; import type { CapabilityRequirements, PipelineRoute, PipelineRouteOptions, PluginContext, PluginManifest, RequestContext, } from "@suaveplan/api-types"; import type { SerializedResponse } from "@suaveplan/api-types/sandbox-bridge"; import { SAND_VM_BRIDGE_CODES, SandboxVmBridgeError, } from "../error-codes/error-codes.js"; import { getSandboxVmBridgeInstruments } from "../instruments/instruments.js"; import { createSandboxLifecycleForwarder, type SandboxLifecycleForwarder, } from "../lifecycle/lifecycle.js"; import { marshalRequest } from "../marshal/marshal-request.js"; import { unmarshalResponse } from "../marshal/marshal-response.js"; import { type BridgeRouteRegistry, requireNonEmptyRegistry, type SandboxRouteHandler, } from "../route-discovery/route-discovery.js"; /** * `PluginModule`-shaped lifecycle bundle the host's lifecycle driver * consumes. Mirrors the relevant subset of * `@suaveplan/api-host`'s `PluginModule` so the bridge does not need to * import that package's type at the API boundary (avoiding a cyclic * import — `api-host` ships its own `PluginModule` shape from * `define-plugin`). */ export interface BridgedPluginModule< Caps extends CapabilityRequirements = CapabilityRequirements, > { readonly name: string; readonly version: string; readonly requires: Caps; readonly isolation: PluginManifest["isolation"]; readonly trustLevel: PluginManifest["trustLevel"]; readonly start: (ctx: PluginContext) => Promise; readonly stop: (ctx: PluginContext) => Promise; readonly uninstall: (ctx: PluginContext) => Promise; } /** * Result of {@link createSandboxedVmPluginModule}. `module` is the * lifecycle bundle (start/stop/uninstall delegate to the sandbox); * `routes` is the materialised `PipelineRoute[]` the host registers via * `host.installPlugin({ routes })`. `dispose` releases the underlying * sandbox — call it after `host.uninstallPlugin(...)` (or directly * during test teardown). */ export interface BridgedPlugin< Caps extends CapabilityRequirements = CapabilityRequirements, > { readonly module: BridgedPluginModule; readonly routes: ReadonlyArray; readonly dispose: () => void; /** Inspection — the forwarder behind `start` / `stop` / `dispose`. */ readonly lifecycle: SandboxLifecycleForwarder; } /** Options accepted by {@link createSandboxedVmPluginModule}. */ export interface CreateSandboxedVmPluginModuleOptions { readonly sandbox: VmSandboxHandle; readonly manifest: PluginManifest; readonly registry: BridgeRouteRegistry; /** Optional per-route option overrides keyed by `"METHOD /path"`. */ readonly routeOptions?: Readonly< Record >; /** Optional status-code overrides forwarded onto every route. */ readonly statusMap?: Readonly>; } const DEFAULT_ROUTE_OPTIONS: PipelineRouteOptions = Object.freeze({}); function routeOptionsFor( method: string, path: string, overrides: CreateSandboxedVmPluginModuleOptions["routeOptions"] ): PipelineRouteOptions { if (overrides === undefined) return DEFAULT_ROUTE_OPTIONS; const key = `${method} ${path}`; return overrides[key] ?? DEFAULT_ROUTE_OPTIONS; } function buildRouteHandler( pluginId: string, method: string, path: string, sandboxHandler: SandboxRouteHandler ): PipelineRoute["handler"] { const instruments = getSandboxVmBridgeInstruments(); return async (ctx: RequestContext): Promise => { const start = Date.now(); const serialized = await marshalRequest(ctx).catch( (err: unknown): never => { instruments.marshalFailuresTotal.add(1, { plugin: pluginId, direction: "request", // `marshalRequest` always throws a SandboxVmBridgeError — // direct code access avoids a `??` branch. code: (err as SandboxVmBridgeError).code, }); throw err; } ); instruments.requestsMarshalledTotal.add(1, { plugin: pluginId, route: path, method, }); let result: SerializedResponse; try { result = (await sandboxHandler(serialized)) as SerializedResponse; } catch (err) { instruments.handlerRejectionsTotal.add(1, { plugin: pluginId, route: path, }); throw new SandboxVmBridgeError( SAND_VM_BRIDGE_CODES.HANDLER_REJECTED, `sandboxed handler for ${method} ${path} threw`, { cause: err, context: { plugin: pluginId, route: path, method }, } ); } let response: Response; try { response = unmarshalResponse(result); } catch (err) { instruments.marshalFailuresTotal.add(1, { plugin: pluginId, direction: "response", // `unmarshalResponse` always throws a SandboxVmBridgeError. code: (err as SandboxVmBridgeError).code, }); throw err; } instruments.bridgeInvocationDurationMs.record(Date.now() - start, { plugin: pluginId, route: path, method, }); return response; }; } /** * Build a `BridgedPlugin` from a started `VmSandboxHandle`. The * sandbox MUST already have its `bridge.registerRoute` capability * wired and have `start()` called before this factory is invoked — * the factory itself drives the start through the lifecycle * forwarder so the bridge can capture sandbox-side throws as * `LIFECYCLE_FAILED`. * * If the sandbox registered zero routes during evaluation, the * factory rejects with `INVALID_PLUGIN_SURFACE`. */ export async function createSandboxedVmPluginModule< Caps extends CapabilityRequirements = CapabilityRequirements, >(options: CreateSandboxedVmPluginModuleOptions): Promise> { const { sandbox, manifest, registry, statusMap } = options; const forwarder = createSandboxLifecycleForwarder(sandbox); await forwarder.start(); const registrations = requireNonEmptyRegistry(registry, sandbox.pluginId); const routes: PipelineRoute[] = registrations.map((reg) => Object.freeze({ plugin: sandbox.pluginId, method: reg.method, path: reg.path, options: routeOptionsFor( reg.method, reg.path, options.routeOptions ), handler: buildRouteHandler( sandbox.pluginId, reg.method, reg.path, reg.handler ), params: Object.freeze({}), statusMap: Object.freeze({ ...(statusMap ?? {}) }), }) ); const requires = [] as unknown as Caps; const module: BridgedPluginModule = Object.freeze({ name: manifest.name, version: manifest.version, requires, isolation: manifest.isolation, trustLevel: manifest.trustLevel, async start(): Promise { // The sandbox has already been started by the factory; the // host's lifecycle hook is a no-op so re-invocation across a // hot-reload cycle is safe. await forwarder.start(); }, async stop(): Promise { // Stop is a logical signal to the plugin; the sandbox // remains evaluated until `dispose()` is called separately. // The bridge's `dispose()` (returned alongside the module) // is the authoritative teardown. }, async uninstall(): Promise { forwarder.dispose(); }, }); return Object.freeze({ module, routes: Object.freeze([...routes]), dispose: () => forwarder.dispose(), lifecycle: forwarder, }); }