/** * Plugin Wrapper Generator * * Generates the code that wraps a plugin to run in a Worker Loader isolate. * The wrapper: * - Imports plugin hooks and routes from a separate module ("sandbox-plugin.js") * - Creates plugin context that proxies to BRIDGE service binding * - Exposes hooks and routes via RPC through WorkerEntrypoint * * Plugin code runs in its own module scope, isolated from the wrapper template. * */ import type { PluginManifest } from "emdash"; const TRAILING_SLASH_RE = /\/$/; const NEWLINE_RE = /[\n\r]/g; const COMMENT_CLOSE_RE = /\*\//g; /** * Options for wrapper generation * * **Known limitation:** `site` info is baked into the generated wrapper code * at load time. If site settings change (e.g., admin updates site name/URL), * sandboxed plugins will see stale values until the worker restarts. * Trusted-mode plugins always read fresh values from the database. */ export interface WrapperOptions { /** Site info to inject into the context (no RPC needed) */ site?: { name: string; url: string; locale: string }; } export function generatePluginWrapper(manifest: PluginManifest, options?: WrapperOptions): string { const storageCollections = Object.keys(manifest.storage || {}); const site = options?.site ?? { name: "", url: "", locale: "en" }; const hasReadUsers = manifest.capabilities.includes("read:users"); const hasEmailSend = manifest.capabilities.includes("email:send"); return ` // ============================================================================= // Sandboxed Plugin Wrapper // Generated by @emdashcms/cloudflare // Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)} // ============================================================================= import { WorkerEntrypoint } from "cloudflare:workers"; // Plugin code lives in a separate module for scope isolation import pluginModule from "sandbox-plugin.js"; // Extract hooks and routes from the plugin module const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {}; const routes = pluginModule?.routes || pluginModule?.default?.routes || {}; // ----------------------------------------------------------------------------- // Context Factory - creates ctx that proxies to BRIDGE // ----------------------------------------------------------------------------- function createContext(env) { const bridge = env.BRIDGE; const storageCollections = ${JSON.stringify(storageCollections)}; // KV - proxies to bridge.kvGet/Set/Delete/List const kv = { get: (key) => bridge.kvGet(key), set: (key, value) => bridge.kvSet(key, value), delete: (key) => bridge.kvDelete(key), list: (prefix) => bridge.kvList(prefix) }; // Storage collection factory function createStorageCollection(collectionName) { return { get: (id) => bridge.storageGet(collectionName, id), put: (id, data) => bridge.storagePut(collectionName, id, data), delete: (id) => bridge.storageDelete(collectionName, id), exists: async (id) => (await bridge.storageGet(collectionName, id)) !== null, query: (opts) => bridge.storageQuery(collectionName, opts), count: (where) => bridge.storageCount(collectionName, where), getMany: (ids) => bridge.storageGetMany(collectionName, ids), putMany: (items) => bridge.storagePutMany(collectionName, items), deleteMany: (ids) => bridge.storageDeleteMany(collectionName, ids) }; } // Storage proxy that creates collections on access const storage = new Proxy({}, { get(_, collectionName) { if (typeof collectionName !== "string") return undefined; return createStorageCollection(collectionName); } }); // Content access - proxies to bridge (capability enforced by bridge) const content = { get: (collection, id) => bridge.contentGet(collection, id), list: (collection, opts) => bridge.contentList(collection, opts), create: (collection, data) => bridge.contentCreate(collection, data), update: (collection, id, data) => bridge.contentUpdate(collection, id, data), delete: (collection, id) => bridge.contentDelete(collection, id) }; // Media access - proxies to bridge (capability enforced by bridge) const media = { get: (id) => bridge.mediaGet(id), list: (opts) => bridge.mediaList(opts), upload: (filename, contentType, bytes) => bridge.mediaUpload(filename, contentType, bytes), getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload(filename, contentType, bytes) instead."); }, delete: (id) => bridge.mediaDelete(id) }; // HTTP access - proxies to bridge (capability + host enforced by bridge) const http = { fetch: async (url, init) => { const result = await bridge.httpFetch(url, init); // Bridge returns serialized response, reconstruct Response-like object return { status: result.status, ok: result.status >= 200 && result.status < 300, headers: new Headers(result.headers), text: async () => result.text, json: async () => JSON.parse(result.text) }; } }; // Logger - proxies to bridge const log = { debug: (msg, data) => bridge.log("debug", msg, data), info: (msg, data) => bridge.log("info", msg, data), warn: (msg, data) => bridge.log("warn", msg, data), error: (msg, data) => bridge.log("error", msg, data) }; // Site info - injected at wrapper generation time, no RPC needed const site = ${JSON.stringify(site)}; // URL helper - generates absolute URLs from paths const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))}; function url(path) { if (!path.startsWith("/")) { throw new Error('URL path must start with "/", got: "' + path + '"'); } if (path.startsWith("//")) { throw new Error('URL path must not be protocol-relative, got: "' + path + '"'); } return siteBaseUrl + path; } // User access - proxies to bridge (capability enforced by bridge) const users = ${hasReadUsers} ? { get: (id) => bridge.userGet(id), getByEmail: (email) => bridge.userGetByEmail(email), list: (opts) => bridge.userList(opts) } : undefined; // Email access - proxies to bridge (capability enforced by bridge) const email = ${hasEmailSend} ? { send: (message) => bridge.emailSend(message) } : undefined; return { plugin: { id: env.PLUGIN_ID, version: env.PLUGIN_VERSION }, storage, kv, content, media, http, log, site, url, users, email }; } // ----------------------------------------------------------------------------- // Worker Entrypoint (RPC interface) // ----------------------------------------------------------------------------- export default class PluginEntrypoint extends WorkerEntrypoint { async invokeHook(hookName, event) { const ctx = createContext(this.env); // Find the hook handler const hookDef = hooks[hookName]; if (!hookDef) { // No handler for this hook - that's ok, return undefined return undefined; } // Get the handler (might be wrapped in config object) const handler = typeof hookDef === "function" ? hookDef : hookDef.handler; if (typeof handler !== "function") { throw new Error(\`Hook \${hookName} handler is not a function\`); } // Execute the hook return handler(event, ctx); } async invokeRoute(routeName, input, serializedRequest) { const ctx = createContext(this.env); // Find the route handler const route = routes[routeName]; if (!route) { throw new Error(\`Route not found: \${routeName}\`); } // Get handler (might be direct function or object with handler) const handler = typeof route === "function" ? route : route.handler; if (typeof handler !== "function") { throw new Error(\`Route \${routeName} handler is not a function\`); } // Execute the route handler with input, request metadata, and context return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx); } } `; } /** * Sanitize a string for inclusion in a JavaScript comment. * Prevents comment injection via manifest.id or manifest.version containing * newlines or comment-closing sequences. */ function sanitizeComment(s: string): string { return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /"); }