Initial Commit
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user