/** * Cloudflare Sandbox Runner * * Uses Worker Loader to run plugins in isolated V8 isolates. * Plugins communicate with the host via a BRIDGE service binding * that enforces capabilities and scopes operations. * * This module imports directly from cloudflare:workers to access * the LOADER binding and PluginBridge export. It's only loaded * when the user configures `sandboxRunner: "@emdash-cms/cloudflare/sandbox"`. * */ import { env, exports } from "cloudflare:workers"; import type { SandboxRunner, SandboxedPlugin, SandboxEmailSendCallback, SandboxOptions, SandboxRunnerFactory, SerializedRequest, PluginManifest, } from "emdash"; import { setEmailSendCallback } from "./bridge.js"; import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js"; import { generatePluginWrapper } from "./wrapper.js"; /** * Default resource limits for sandboxed plugins. * * cpuMs and subrequests are enforced by Worker Loader at the V8 isolate level. * wallTimeMs is enforced by the runner via Promise.race. * memoryMb is declared for API compatibility but NOT currently enforced — * Worker Loader doesn't expose a memory limit option. V8 isolates have a * platform-level memory ceiling (~128MB) but it's not configurable per-worker. */ const DEFAULT_LIMITS = { cpuMs: 50, memoryMb: 128, subrequests: 10, wallTimeMs: 30_000, } as const; export interface PluginBridgeProps { pluginId: string; pluginVersion: string; capabilities: string[]; allowedHosts: string[]; storageCollections: string[]; } /** * Get the Worker Loader binding from env */ function getLoader(): WorkerLoader | null { // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker Loader binding accessed from untyped env object return (env as Record).LOADER as WorkerLoader | null; } /** * Get the PluginBridge from exports (loopback binding) */ function getPluginBridge(): ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding) | null { // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- PluginBridge accessed from untyped cloudflare:workers exports return (exports as Record).PluginBridge as | ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding) | null; } /** * Resolved resource limits with defaults applied. */ interface ResolvedLimits { cpuMs: number; memoryMb: number; subrequests: number; wallTimeMs: number; } /** * Resolve resource limits by merging user-provided overrides with defaults. */ function resolveLimits(limits?: SandboxOptions["limits"]): ResolvedLimits { return { cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs, memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb, subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests, wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs, }; } /** * Cloudflare sandbox runner using Worker Loader. */ export class CloudflareSandboxRunner implements SandboxRunner { private plugins = new Map(); private options: SandboxOptions; private resolvedLimits: ResolvedLimits; private siteInfo?: { name: string; url: string; locale: string }; constructor(options: SandboxOptions) { this.options = options; this.resolvedLimits = resolveLimits(options.limits); this.siteInfo = options.siteInfo; // Wire email send callback if provided at construction time setEmailSendCallback(options.emailSend ?? null); } /** * Set the email send callback for sandboxed plugins. * Called after the EmailPipeline is created, since the pipeline * doesn't exist when the sandbox runner is constructed. */ setEmailSend(callback: SandboxEmailSendCallback | null): void { setEmailSendCallback(callback); } /** * Check if Worker Loader is available. */ isAvailable(): boolean { return !!getLoader() && !!getPluginBridge(); } /** * Load a sandboxed plugin. * * @param manifest - Plugin manifest with capabilities and storage declarations * @param code - The bundled plugin JavaScript code */ async load(manifest: PluginManifest, code: string): Promise { const pluginId = `${manifest.id}:${manifest.version}`; // Return cached plugin if available const existing = this.plugins.get(pluginId); if (existing) return existing; const loader = getLoader(); const pluginBridge = getPluginBridge(); if (!loader) { throw new Error( "Worker Loader not available. Add worker_loaders binding to wrangler config.", ); } if (!pluginBridge) { throw new Error( "PluginBridge not available. Export PluginBridge from your worker entrypoint.", ); } const plugin = new CloudflareSandboxedPlugin( manifest, code, loader, pluginBridge, this.resolvedLimits, this.siteInfo, ); this.plugins.set(pluginId, plugin); return plugin; } /** * Terminate all loaded plugins. */ async terminateAll(): Promise { for (const plugin of this.plugins.values()) { await plugin.terminate(); } this.plugins.clear(); } } /** * A plugin running in a Worker Loader isolate. * * IMPORTANT: Worker stubs and bridge bindings are tied to request context. * We must create fresh stubs for each invocation to avoid I/O isolation errors: * "Cannot perform I/O on behalf of a different request" */ class CloudflareSandboxedPlugin implements SandboxedPlugin { readonly id: string; readonly manifest: PluginManifest; private loader: WorkerLoader; private createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding; private code: string; private wrapperCode: string | null = null; private limits: ResolvedLimits; private siteInfo?: { name: string; url: string; locale: string }; constructor( manifest: PluginManifest, code: string, loader: WorkerLoader, createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding, limits: ResolvedLimits, siteInfo?: { name: string; url: string; locale: string }, ) { this.id = `${manifest.id}:${manifest.version}`; this.manifest = manifest; this.code = code; this.loader = loader; this.createBridge = createBridge; this.limits = limits; this.siteInfo = siteInfo; } /** * Create a fresh worker stub for the current request. * * Worker Loader stubs contain bindings (like BRIDGE) that are tied to the * request context in which they were created. Reusing stubs across requests * causes "Cannot perform I/O on behalf of a different request" errors. * * The Worker Loader internally caches the V8 isolate, so we only pay the * cost of creating the bridge binding and stub wrapper per request. */ private createWorker(): WorkerStub { // Cache the wrapper code (CPU-bound, no I/O context issues) if (!this.wrapperCode) { this.wrapperCode = generatePluginWrapper(this.manifest, { site: this.siteInfo, }); } // Create fresh bridge binding for THIS request const bridgeBinding = this.createBridge({ props: { pluginId: this.manifest.id, pluginVersion: this.manifest.version || "0.0.0", capabilities: this.manifest.capabilities || [], allowedHosts: this.manifest.allowedHosts || [], storageCollections: Object.keys(this.manifest.storage || {}), }, }); // Build Worker Loader limits from resolved resource limits const loaderLimits: WorkerLoaderLimits = { cpuMs: this.limits.cpuMs, subRequests: this.limits.subrequests, }; // Get a fresh stub with the new bridge binding. // Worker Loader caches the isolate but the stub/bindings are per-call. return this.loader.get(this.id, () => ({ compatibilityDate: "2025-01-01", mainModule: "plugin.js", modules: { "plugin.js": { js: this.wrapperCode! }, "sandbox-plugin.js": { js: this.code }, }, // Block direct network access - plugins must use ctx.http via bridge globalOutbound: null, // Enforce resource limits at the V8 isolate level limits: loaderLimits, env: { // Plugin metadata PLUGIN_ID: this.manifest.id, PLUGIN_VERSION: this.manifest.version || "0.0.0", // Bridge binding for all host operations BRIDGE: bridgeBinding, }, })); } /** * Run a function with wall-time enforcement. * * CPU limits and subrequest limits are enforced by the Worker Loader * at the V8 isolate level. Wall-time is enforced here because Worker * Loader doesn't expose a wall-time limit — a plugin could stall * indefinitely waiting on network I/O. */ private async withWallTimeLimit(operation: string, fn: () => Promise): Promise { const wallTimeMs = this.limits.wallTimeMs; let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { reject( new Error( `Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`, ), ); }, wallTimeMs); }); try { return await Promise.race([fn(), timeout]); } finally { if (timer !== undefined) clearTimeout(timer); } } /** * Invoke a hook in the sandboxed plugin. * * CPU and subrequest limits are enforced by Worker Loader. * Wall-time is enforced here. */ async invokeHook(hookName: string, event: unknown): Promise { return this.withWallTimeLimit(`hook:${hookName}`, () => { const worker = this.createWorker(); const entrypoint = worker.getEntrypoint("default"); return entrypoint.invokeHook(hookName, event); }); } /** * Invoke an API route in the sandboxed plugin. * * CPU and subrequest limits are enforced by Worker Loader. * Wall-time is enforced here. */ async invokeRoute( routeName: string, input: unknown, request: SerializedRequest, ): Promise { return this.withWallTimeLimit(`route:${routeName}`, () => { const worker = this.createWorker(); const entrypoint = worker.getEntrypoint("default"); return entrypoint.invokeRoute(routeName, input, request); }); } /** * Terminate the sandboxed plugin. */ async terminate(): Promise { // Worker Loader manages isolate lifecycle - nothing to do here this.wrapperCode = null; } } /** * The RPC interface exposed by the plugin wrapper. */ interface PluginEntrypoint { invokeHook(hookName: string, event: unknown): Promise; invokeRoute(routeName: string, input: unknown, request: SerializedRequest): Promise; } /** * Factory function for creating the Cloudflare sandbox runner. * * Matches the SandboxRunnerFactory signature. The LOADER and PluginBridge * are obtained internally from cloudflare:workers imports. */ export const createSandboxRunner: SandboxRunnerFactory = (options) => { return new CloudflareSandboxRunner(options); };