239 lines
8.0 KiB
TypeScript
239 lines
8.0 KiB
TypeScript
/**
|
|
* 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, "* /");
|
|
}
|