first commit
This commit is contained in:
238
packages/cloudflare/src/sandbox/wrapper.ts
Normal file
238
packages/cloudflare/src/sandbox/wrapper.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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, "* /");
|
||||
}
|
||||
Reference in New Issue
Block a user