227 lines
8.1 KiB
TypeScript
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,
|
|
});
|
|
}
|