Files
suaveplan/packages/api-stack/api/api-sandbox-vm-host-bridge/src/bridge/bridge.ts
2026-05-27 23:29:53 +01:00

227 lines
8.1 KiB
TypeScript

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<Caps>) => Promise<void>;
readonly stop: (ctx: PluginContext<Caps>) => Promise<void>;
readonly uninstall: (ctx: PluginContext<Caps>) => Promise<void>;
}
/**
* 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<Caps>;
readonly routes: ReadonlyArray<PipelineRoute>;
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<string, PipelineRouteOptions | undefined>
>;
/** Optional status-code overrides forwarded onto every route. */
readonly statusMap?: Readonly<Record<string, number>>;
}
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<unknown> => {
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<BridgedPlugin<Caps>> {
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<Caps> = Object.freeze({
name: manifest.name,
version: manifest.version,
requires,
isolation: manifest.isolation,
trustLevel: manifest.trustLevel,
async start(): Promise<void> {
// 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<void> {
// 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<void> {
forwarder.dispose();
},
});
return Object.freeze({
module,
routes: Object.freeze([...routes]),
dispose: () => forwarder.dispose(),
lifecycle: forwarder,
});
}