chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { WorkerdSandboxRunner, createSandboxRunner } from "./sandbox/index.js";
@@ -0,0 +1,146 @@
/**
* Backing Service HTTP Handler
*
* Runs in the Node process for production workerd deployments.
* Receives HTTP requests from plugin workers running in workerd isolates.
* Each request is authenticated via a per-plugin auth token.
*
* This is a thin wrapper around createBridgeHandler that adds:
* - Auth token validation (extracting claims from the HMAC token)
* - Node http.IncomingMessage -> Request conversion
* - Response -> http.ServerResponse conversion
*
* The actual bridge logic (dispatch, capability enforcement, DB queries)
* lives in bridge-handler.ts and is shared with the dev runner.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { createBridgeHandler } from "./bridge-handler.js";
import type { WorkerdSandboxRunner } from "./runner.js";
export interface BackingServiceHandler {
handler: (req: IncomingMessage, res: ServerResponse) => void;
removePlugin: (pluginId: string) => void;
}
/** Error carrying an HTTP status code, used to surface request-level failures. */
class HttpError extends Error {
constructor(
message: string,
readonly statusCode: number,
) {
super(message);
}
}
/**
* Create an HTTP request handler for the backing service.
*/
export function createBackingServiceHandler(runner: WorkerdSandboxRunner): BackingServiceHandler {
// Cache bridge handlers per pluginId to avoid re-creation
const handlerCache = new Map<string, (request: Request) => Promise<Response>>();
const handler = async (req: IncomingMessage, res: ServerResponse) => {
try {
// Parse auth token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing or invalid authorization" }));
return;
}
const token = authHeader.slice(7);
const claims = runner.validateToken(token);
if (!claims) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid auth token" }));
return;
}
// Get or create bridge handler for this plugin
const cacheKey = claims.pluginId;
let bridgeHandler = handlerCache.get(cacheKey);
if (!bridgeHandler) {
bridgeHandler = createBridgeHandler({
pluginId: claims.pluginId,
version: claims.version,
capabilities: claims.capabilities,
allowedHosts: claims.allowedHosts,
storageCollections: claims.storageCollections,
storageConfig: runner.getPluginStorageConfig(claims.pluginId, claims.version),
db: runner.db,
emailSend: () => runner.emailSend,
storage: runner.mediaStorage,
});
handlerCache.set(cacheKey, bridgeHandler);
}
// Convert Node request to web Request
const body = await readBody(req);
const url = `http://bridge${req.url || "/"}`;
const webRequest = new Request(url, {
method: req.method || "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// Dispatch through the shared bridge handler
const webResponse = await bridgeHandler(webRequest);
const responseBody = await webResponse.text();
res.writeHead(webResponse.status, { "Content-Type": "application/json" });
res.end(responseBody);
} catch (error) {
const statusCode = error instanceof HttpError ? error.statusCode : 500;
const message = error instanceof Error ? error.message : "Internal error";
res.writeHead(statusCode, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
};
return {
handler,
removePlugin(pluginId: string) {
handlerCache.delete(pluginId);
},
};
}
const MAX_BRIDGE_BODY_BYTES = 10 * 1024 * 1024;
function isJsonObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
// IncomingMessage is a Node Readable; async-iteration yields chunks typed
// as `any`. Validate each chunk is a Buffer (the runtime guarantee for
// non-object-mode streams) so we can compose them safely.
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of req) {
if (!Buffer.isBuffer(chunk)) {
throw new HttpError("Request body has unexpected chunk type", 400);
}
totalBytes += chunk.length;
if (totalBytes > MAX_BRIDGE_BODY_BYTES) {
throw new HttpError("Request body too large", 413);
}
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString();
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid JSON";
throw new HttpError(`Invalid JSON: ${message}`, 400);
}
if (!isJsonObject(parsed)) {
throw new HttpError("Request body must be a JSON object", 400);
}
return parsed;
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
/**
* Cap'n Proto Config Generator for workerd
*
* Generates workerd configuration from plugin manifests.
* Each plugin becomes a nanoservice with:
* - Its own listening socket (for hook/route invocation from Node)
* - An external service definition for the Node backing service
* - globalOutbound set to the backing service (all fetch() calls route
* through the backing service, which enforces capability checks)
*/
import type { PluginManifest } from "emdash";
/** For string values in capnp config (service/socket names) */
const SAFE_NAME_RE = /[^a-z0-9_-]/gi;
const NON_ALNUM_RE = /[^a-z0-9]+/i;
/** Convert a plugin ID to a camelCase capnp identifier.
* capnp requires camelCase for const declarations (no underscores or hyphens). */
function toCapnpId(pluginId: string): string {
const parts = pluginId.split(NON_ALNUM_RE).filter(Boolean);
return parts
.map((p, i) =>
i === 0 ? p.toLowerCase() : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(),
)
.join("");
}
interface LoadedPlugin {
manifest: PluginManifest;
code: string;
port: number;
token: string;
}
interface CapnpOptions {
plugins: Map<string, LoadedPlugin>;
backingServiceAddress: string;
configDir: string;
/** Filename (relative to configDir) of the shared "emdash" shim module. */
emdashShimFile: string;
}
/**
* Generate a workerd capnp configuration file.
*
* Each plugin gets its own worker (nanoservice) with:
* - A listener socket on its assigned port
* - Modules for wrapper + plugin code
* - globalOutbound pointing to the backing service external server
* (all outbound fetch() goes through the backing service for
* capability enforcement, SSRF protection, and host allowlist checks)
*
* KNOWN LIMITATION on resource limits:
* Standalone workerd does NOT support per-worker cpuMs/memoryMb/subrequests
* limits — those are Cloudflare platform features, not workerd capnp options.
* The only limit we enforce on the Node path is wallTimeMs, which is wrapped
* via Promise.race in WorkerdSandboxedPlugin.invokeHook/invokeRoute.
* For true CPU/memory isolation, deploy on Cloudflare Workers.
*/
export function generateCapnpConfig(options: CapnpOptions): string {
const { plugins, backingServiceAddress, emdashShimFile } = options;
const lines: string[] = [
`# Auto-generated workerd configuration for EmDash plugin sandbox`,
`# Generated at: ${new Date().toISOString()}`,
`# Plugins: ${plugins.size}`,
``,
`using Workerd = import "/workerd/workerd.capnp";`,
``,
`const config :Workerd.Config = (`,
` services = [`,
// External service: the Node backing service
` (name = "emdash-backing", external = (address = "${backingServiceAddress}")),`,
];
// Add a service + socket for each plugin
const socketEntries: string[] = [];
for (const [pluginId, plugin] of plugins) {
const constId = toCapnpId(pluginId);
const safeName = pluginId.replace(SAFE_NAME_RE, "_");
lines.push(` (name = "plugin-${safeName}", worker = .plugin${constId}),`);
socketEntries.push(
` (name = "socket-${safeName}", address = "127.0.0.1:${plugin.port}", service = "plugin-${safeName}"),`,
);
}
lines.push(` ],`);
// Socket definitions
lines.push(` sockets = [`);
for (const socket of socketEntries) {
lines.push(socket);
}
lines.push(` ],`);
lines.push(`);`);
lines.push(``);
// Worker definitions for each plugin
for (const [pluginId] of plugins) {
const constId = toCapnpId(pluginId);
const safeName = pluginId.replace(SAFE_NAME_RE, "_");
const wrapperFile = `${safeName}-wrapper.js`;
const pluginFile = `${safeName}-plugin.js`;
lines.push(`const plugin${constId} :Workerd.Worker = (`);
lines.push(` modules = [`);
lines.push(` (name = "worker.js", esModule = embed "${wrapperFile}"),`);
lines.push(` (name = "sandbox-plugin.js", esModule = embed "${pluginFile}"),`);
lines.push(` (name = "emdash", esModule = embed "${emdashShimFile}"),`);
lines.push(` ],`);
lines.push(` compatibilityDate = "2025-01-01",`);
lines.push(` compatibilityFlags = ["nodejs_compat"],`);
// globalOutbound routes ALL outbound fetch() calls from the plugin
// through the backing service. This is intentional security posture:
//
// - Bridge calls (e.g., fetch("http://bridge/content/get")) are
// dispatched normally by the backing service path router.
// - Direct fetch() calls to arbitrary URLs (e.g., fetch("https://evil.com"))
// also arrive at the backing service. They will NOT match any known
// bridge method and will return 500 "Unknown bridge method".
//
// In other words: plugins cannot reach the internet by calling plain
// fetch(). They must use ctx.http.fetch(), which goes through the
// http/fetch bridge handler, which enforces network:fetch capability
// and the allowedHosts allowlist.
lines.push(` globalOutbound = "emdash-backing",`);
// Note: workerd capnp config does not support per-worker cpu/memory
// limits. Wall-time is enforced in WorkerdSandboxedPlugin via
// Promise.race. See generateCapnpConfig docstring above.
lines.push(`);`);
lines.push(``);
}
return lines.join("\n");
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Miniflare Dev Runner
*
* Uses miniflare for plugin sandboxing during development.
* Provides the same SandboxRunner interface as WorkerdSandboxRunner
* but uses miniflare's serviceBindings-as-functions pattern instead
* of raw workerd + capnp + HTTP backing service.
*
* Advantages over raw workerd in dev:
* - No HTTP backing service needed (bridge calls are Node functions)
* - No capnp config generation
* - No child process management
* - Faster startup
*/
import { randomBytes } from "node:crypto";
import { createRequire } from "node:module";
import type {
SandboxRunner,
SandboxedPluginInstance,
SandboxEmailSendCallback,
SandboxOptions,
SerializedRequest,
} from "emdash";
const DEFAULT_WALL_TIME_MS = 30_000;
import type { PluginManifest } from "emdash";
import { createBridgeHandler } from "./bridge-handler.js";
import { generatePluginWrapper } from "./wrapper.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
const SAFE_ID_RE = /[^a-z0-9_-]/gi;
/**
* Stub for the "emdash" module that sandbox-entry plugins import to get
* `definePlugin`. The marketplace bundler inlines this via an alias, but
* statically-loaded sandboxed plugins (from `sandboxed: [...]`) embed
* their `dist/sandbox-entry.mjs` as-is, which still has the bare import.
* Providing the module here keeps that path working without rebuilding
* every plugin. Mirrors `EMDASH_SHIM` in @emdash-cms/cloudflare.
*/
const EMDASH_SHIM = "export const definePlugin = (d) => d;\n";
/**
* Miniflare-based sandbox runner for development.
*/
export class MiniflareDevRunner implements SandboxRunner {
private options: SandboxOptions;
private siteInfo?: { name: string; url: string; locale: string };
private emailSendCallback: SandboxEmailSendCallback | null = null;
/** Miniflare instance (lazily created) */
private mf: InstanceType<typeof import("miniflare").Miniflare> | null = null;
/** Loaded plugins */
private plugins = new Map<string, { manifest: PluginManifest; code: string }>();
/** Whether miniflare is running */
private running = false;
/**
* Per-startup token sent on every hook/route invocation. Plugins reject
* requests without this token. In dev mode the plugin worker is only
* reachable through miniflare's dispatchFetch, but we still wire the
* token for consistency with production and so the wrapper template
* is identical in both modes.
*/
private devInvokeToken: string;
constructor(options: SandboxOptions) {
this.options = options;
this.siteInfo = options.siteInfo;
this.emailSendCallback = options.emailSend ?? null;
this.devInvokeToken = randomBytes(32).toString("hex");
}
get wallTimeMs(): number {
return this.options.limits?.wallTimeMs ?? DEFAULT_WALL_TIME_MS;
}
/** Get the per-startup invoke token (sent on hook/route requests to plugins) */
get invokeAuthToken() {
return this.devInvokeToken;
}
isAvailable(): boolean {
try {
const esmRequire = createRequire(import.meta.url);
esmRequire.resolve("miniflare");
return true;
} catch {
return false;
}
}
isHealthy(): boolean {
return this.running;
}
setEmailSend(callback: SandboxEmailSendCallback | null): void {
this.emailSendCallback = callback;
}
async load(manifest: PluginManifest, code: string): Promise<SandboxedPluginInstance> {
const pluginId = `${manifest.id}:${manifest.version}`;
this.plugins.set(pluginId, { manifest, code });
// Rebuild miniflare with all plugins
await this.rebuild();
return new MiniflareDevPlugin(pluginId, manifest, this);
}
async terminateAll(): Promise<void> {
if (this.mf) {
await this.mf.dispose();
this.mf = null;
}
this.plugins.clear();
this.running = false;
}
/**
* Unload a single plugin and rebuild miniflare without it.
* Called from MiniflareDevPlugin.terminate() so marketplace
* update/uninstall flows actually drop the old plugin from
* the dev sandbox instead of leaving stale entries.
*/
async unloadPlugin(pluginId: string): Promise<void> {
if (this.plugins.delete(pluginId)) {
await this.rebuild();
}
}
/**
* Rebuild miniflare with current plugin configuration.
* Called on each plugin load/unload.
*/
private async rebuild(): Promise<void> {
if (this.mf) {
await this.mf.dispose();
this.mf = null;
}
if (this.plugins.size === 0) {
this.running = false;
return;
}
const { Miniflare } = await import("miniflare");
// Build worker configs with outboundService to intercept bridge calls.
// The wrapper code does fetch("http://bridge/method", ...).
// outboundService intercepts all outbound fetches and routes bridge
// calls to the Node handler function.
const workerConfigs = [];
for (const [pluginId, { manifest, code }] of this.plugins) {
const bridgeHandler = createBridgeHandler({
pluginId: manifest.id,
version: manifest.version || "0.0.0",
capabilities: manifest.capabilities || [],
allowedHosts: manifest.allowedHosts || [],
storageCollections: Object.keys(manifest.storage || {}),
storageConfig: manifest.storage,
db: this.options.db,
emailSend: () => this.emailSendCallback,
storage: this.options.mediaStorage,
});
const wrapperCode = generatePluginWrapper(manifest, {
site: this.siteInfo,
backingServiceUrl: "http://bridge",
authToken: "dev-mode",
invokeToken: this.devInvokeToken,
});
// outboundService intercepts all fetch() calls from this worker.
// Calls to http://bridge/... go to the Node bridge handler.
// Other calls pass through for network:fetch.
workerConfigs.push({
name: pluginId.replace(SAFE_ID_RE, "_"),
// The wrapper imports "sandbox-plugin.js", so we provide both
// the wrapper as the main module and the plugin code as a
// named module that the wrapper can import.
modulesRoot: "/",
modules: [
{ type: "ESModule" as const, path: "worker.js", contents: wrapperCode },
{ type: "ESModule" as const, path: "sandbox-plugin.js", contents: code },
{ type: "ESModule" as const, path: "emdash", contents: EMDASH_SHIM },
],
outboundService: async (request: Request) => {
const url = new URL(request.url);
// Only allow bridge calls. Any other outbound fetch is blocked
// to enforce that all network access goes through ctx.http.fetch
// (which routes via the bridge with capability + host validation).
// Without this, plugins could bypass network:fetch / allowedHosts
// by calling plain fetch() directly.
if (url.hostname === "bridge") {
return bridgeHandler(request);
}
return new Response(
`Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:fetch capability).`,
{ status: 403 },
);
},
});
}
this.mf = new Miniflare({ workers: workerConfigs });
this.running = true;
}
/**
* Dispatch a fetch to a specific plugin worker in miniflare.
*
* Miniflare's `worker.fetch` uses undici's Request/Response/RequestInit
* types, which are structurally compatible with the platform globals but
* declared as distinct nominal types. Callers only consume status/ok and
* the body via text()/json(), so we widen at this boundary.
*/
async dispatchToPlugin(pluginId: string, url: string, init?: RequestInit): Promise<Response> {
if (!this.mf) {
throw new Error(`Miniflare not running, cannot dispatch to ${pluginId}`);
}
const workerName = pluginId.replace(SAFE_ID_RE, "_");
const worker = await this.mf.getWorker(workerName);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- miniflare's Response_2 / RequestInit_2 are structurally compatible with the global types we use here. See JSDoc above.
return worker.fetch(url, init as never) as unknown as Response;
}
}
/**
* A plugin running in a miniflare dev isolate.
*/
class MiniflareDevPlugin implements SandboxedPluginInstance {
readonly id: string;
private manifest: PluginManifest;
private runner: MiniflareDevRunner;
constructor(id: string, manifest: PluginManifest, runner: MiniflareDevRunner) {
this.id = id;
this.manifest = manifest;
this.runner = runner;
}
async invokeHook(hookName: string, event: unknown): Promise<unknown> {
if (!this.runner.isHealthy()) {
throw new Error(`Dev sandbox unavailable for ${this.id}`);
}
return this.withWallTimeLimit(`hook:${hookName}`, async () => {
const res = await this.runner.dispatchToPlugin(this.id, `http://plugin/hook/${hookName}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.runner.invokeAuthToken}`,
},
body: JSON.stringify({ event }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Plugin ${this.id} hook ${hookName} failed: ${text}`);
}
const result: unknown = await res.json();
if (!isRecord(result)) {
throw new Error(`Plugin ${this.id} hook ${hookName} returned a non-object response`);
}
return result.value;
});
}
async invokeRoute(
routeName: string,
input: unknown,
request: SerializedRequest,
): Promise<unknown> {
if (!this.runner.isHealthy()) {
throw new Error(`Dev sandbox unavailable for ${this.id}`);
}
return this.withWallTimeLimit(`route:${routeName}`, async () => {
const res = await this.runner.dispatchToPlugin(this.id, `http://plugin/route/${routeName}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.runner.invokeAuthToken}`,
},
body: JSON.stringify({ input, request }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`);
}
return res.json();
});
}
private async withWallTimeLimit<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const wallTimeMs = this.runner.wallTimeMs;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, 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);
}
}
async terminate(): Promise<void> {
// Drop this plugin from the runner so marketplace update/uninstall
// actually removes it from the dev sandbox.
await this.runner.unloadPlugin(this.id);
}
}
+3
View File
@@ -0,0 +1,3 @@
export { WorkerdSandboxRunner, createSandboxRunner } from "./runner.js";
export { MiniflareDevRunner } from "./dev-runner.js";
export { createBridgeHandler } from "./bridge-handler.js";
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
/**
* Plugin Wrapper Generator for workerd
*
* Generates the code that wraps a plugin to run in a workerd isolate.
* Unlike the Cloudflare wrapper which uses RPC via service bindings,
* this wrapper uses HTTP fetch to call the Node backing service.
*
* The wrapper:
* - Imports plugin hooks and routes from "sandbox-plugin.js"
* - Creates plugin context that proxies operations via HTTP to the backing service
* - Exposes an HTTP fetch handler for hook/route invocation
*/
import type { PluginManifest } from "emdash";
const TRAILING_SLASH_RE = /\/$/;
const NEWLINE_RE = /[\n\r]/g;
const COMMENT_CLOSE_RE = /\*\//g;
export interface WrapperOptions {
site?: { name: string; url: string; locale: string };
/** URL of the Node backing service (e.g., http://127.0.0.1:18787) */
backingServiceUrl: string;
/** Auth token the plugin sends on outbound bridge calls to Node */
authToken: string;
/**
* Auth token the Node runner must send on inbound hook/route invocations.
* Prevents same-host attackers from invoking plugin hooks directly via
* the per-plugin TCP listener (which is exposed on 127.0.0.1).
*/
invokeToken: string;
}
export function generatePluginWrapper(manifest: PluginManifest, options: WrapperOptions): string {
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 (workerd)
// Generated by @emdash-cms/sandbox-workerd
// Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)}
// =============================================================================
import pluginModule from "sandbox-plugin.js";
const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {};
const routes = pluginModule?.routes || pluginModule?.default?.routes || {};
const BACKING_URL = ${JSON.stringify(options.backingServiceUrl)};
const AUTH_TOKEN = ${JSON.stringify(options.authToken)};
const INVOKE_TOKEN = ${JSON.stringify(options.invokeToken)};
// -----------------------------------------------------------------------------
// Bridge - HTTP calls to Node backing service
// -----------------------------------------------------------------------------
async function bridgeCall(method, body) {
const res = await fetch(BACKING_URL + "/" + method, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + AUTH_TOKEN,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error("Bridge call " + method + " failed: " + text);
}
const data = await res.json();
return data.result;
}
// -----------------------------------------------------------------------------
// Context Factory
// -----------------------------------------------------------------------------
function createContext() {
const kv = {
get: (key) => bridgeCall("kv/get", { key }),
set: (key, value) => bridgeCall("kv/set", { key, value }),
delete: (key) => bridgeCall("kv/delete", { key }),
list: (prefix) => bridgeCall("kv/list", { prefix }),
};
function createStorageCollection(collectionName) {
return {
get: (id) => bridgeCall("storage/get", { collection: collectionName, id }),
put: (id, data) => bridgeCall("storage/put", { collection: collectionName, id, data }),
delete: (id) => bridgeCall("storage/delete", { collection: collectionName, id }),
exists: async (id) => (await bridgeCall("storage/get", { collection: collectionName, id })) !== null,
query: (opts) => bridgeCall("storage/query", { collection: collectionName, ...opts }),
count: (where) => bridgeCall("storage/count", { collection: collectionName, where }),
getMany: async (ids) => {
// Bridge returns a list of [id, data] pairs (not a plain object)
// so special IDs like "__proto__" survive transport. Convert
// back to Map to match StorageCollection.getMany() contract.
const entries = await bridgeCall("storage/getMany", { collection: collectionName, ids });
return new Map(entries || []);
},
putMany: (items) => bridgeCall("storage/putMany", { collection: collectionName, items }),
deleteMany: (ids) => bridgeCall("storage/deleteMany", { collection: collectionName, ids }),
};
}
const storage = new Proxy({}, {
get(_, collectionName) {
if (typeof collectionName !== "string") return undefined;
return createStorageCollection(collectionName);
}
});
const content = {
get: (collection, id) => bridgeCall("content/get", { collection, id }),
list: (collection, opts) => bridgeCall("content/list", { collection, ...opts }),
create: (collection, data) => bridgeCall("content/create", { collection, data }),
update: (collection, id, data) => bridgeCall("content/update", { collection, id, data }),
delete: (collection, id) => bridgeCall("content/delete", { collection, id }),
createMany: (collection, items) => bridgeCall("content/createMany", { collection, items }),
updateMany: (collection, items) => bridgeCall("content/updateMany", { collection, items }),
deleteMany: (collection, ids) => bridgeCall("content/deleteMany", { collection, ids }),
};
// Taxonomy access (read-only) - capability enforced by the bridge
const taxonomies = {
getAll: (opts) => bridgeCall("taxonomy/list", { ...opts }),
getTerms: (taxonomy, opts) => bridgeCall("taxonomy/terms", { taxonomy, ...opts }),
getEntryTerms: (collection, entryId, opts) => bridgeCall("taxonomy/entryTerms", { collection, entryId, ...opts }),
};
const media = {
get: (id) => bridgeCall("media/get", { id }),
list: (opts) => bridgeCall("media/list", opts || {}),
upload: (filename, contentType, bytes) => {
// Convert any binary input into a Uint8Array view pointing at the
// SAME underlying bytes (not reinterpreted). For ArrayBufferView
// inputs (Uint16Array, Int32Array, DataView, etc.) we must use
// the view's buffer + byteOffset + byteLength so we don't
// reinterpret element-typed values as bytes and corrupt the file.
let view;
if (bytes instanceof Uint8Array) {
view = bytes;
} else if (bytes instanceof ArrayBuffer) {
view = new Uint8Array(bytes);
} else if (ArrayBuffer.isView(bytes)) {
view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
} else {
throw new TypeError("media.upload: bytes must be ArrayBuffer or ArrayBufferView");
}
let binary = "";
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
return bridgeCall("media/upload", {
filename,
contentType,
bytes: btoa(binary),
encoding: "base64",
});
},
getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload() instead."); },
delete: (id) => bridgeCall("media/delete", { id }),
};
// Marshal a RequestInit into a JSON-safe shape so headers, body, and other
// fields survive transport over the bridge. The bridge handler reverses
// this in unmarshalRequestInit().
async function marshalRequestInit(init) {
if (!init) return undefined;
const out = {};
if (init.method) out.method = init.method;
if (init.redirect) out.redirect = init.redirect;
// Headers: serialize as a list of [name, value] pairs so multi-value
// headers (Set-Cookie etc.) survive round-trip. A plain object would
// collapse duplicate names.
if (init.headers) {
const headers = [];
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => { headers.push([k, v]); });
} else if (Array.isArray(init.headers)) {
for (const [k, v] of init.headers) headers.push([k, v]);
} else {
for (const [k, v] of Object.entries(init.headers)) {
headers.push([k, v]);
}
}
out.headers = headers;
}
// Helper: convert a Uint8Array view to base64, preserving offset/length
function viewToBase64(view) {
let binary = "";
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
return btoa(binary);
}
// Helper: get a Uint8Array view from any binary input, respecting
// the original byteOffset and byteLength so we don't serialize the
// entire backing buffer for views like Uint8Array.subarray().
function toBytes(input) {
if (input instanceof Uint8Array) return input;
if (input instanceof ArrayBuffer) return new Uint8Array(input);
if (ArrayBuffer.isView(input)) {
// DataView, Int8Array, Float32Array, etc. — preserve the window
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
}
// Should never reach here: callers gate with ArrayBuffer/isView checks.
// Throw loudly so unexpected body types surface as errors instead of
// silently dropping data.
throw new TypeError("toBytes: unsupported binary input type");
}
// Body: convert to base64 to preserve binary, or pass strings through
if (init.body !== undefined && init.body !== null) {
if (typeof init.body === "string") {
out.bodyType = "string";
out.body = init.body;
} else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
out.bodyType = "base64";
out.body = viewToBase64(toBytes(init.body));
} else if (typeof Blob !== "undefined" && init.body instanceof Blob) {
// Blob/File (without going through FormData): read bytes and
// preserve content type if not already set
const bytes = new Uint8Array(await init.body.arrayBuffer());
out.bodyType = "base64";
out.body = viewToBase64(bytes);
if (init.body.type) {
if (!Array.isArray(out.headers)) out.headers = [];
const hasContentType = out.headers.some(([k]) => k.toLowerCase() === "content-type");
if (!hasContentType) {
out.headers.push(["content-type", init.body.type]);
}
}
} else if (init.body instanceof FormData) {
// FormData: serialize entries as { name, value, filename? }
const parts = [];
for (const [k, v] of init.body.entries()) {
if (typeof v === "string") {
parts.push({ name: k, value: v });
} else {
// File/Blob: read as base64
const bytes = new Uint8Array(await v.arrayBuffer());
parts.push({
name: k,
value: viewToBase64(bytes),
filename: v.name,
type: v.type,
isBlob: true,
});
}
}
out.bodyType = "formdata";
out.body = parts;
} else if (init.body instanceof URLSearchParams) {
out.bodyType = "string";
out.body = init.body.toString();
if (!Array.isArray(out.headers)) out.headers = [];
const hasContentType = out.headers.some(([k]) => k.toLowerCase() === "content-type");
if (!hasContentType) {
out.headers.push(["content-type", "application/x-www-form-urlencoded"]);
}
} else {
// Fall back to JSON for plain objects
out.bodyType = "string";
out.body = JSON.stringify(init.body);
}
}
return out;
}
const http = {
fetch: async (url, init) => {
const marshaledInit = await marshalRequestInit(init);
const result = await bridgeCall("http/fetch", { url, init: marshaledInit });
// Decode base64 body back to bytes to preserve binary content
// (images, audio, etc.) so arrayBuffer()/blob() work correctly.
const binaryString = atob(result.bodyBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Response(bytes, {
status: result.status,
statusText: result.statusText,
headers: result.headers,
});
}
};
const log = {
debug: (msg, data) => bridgeCall("log", { level: "debug", msg, data }),
info: (msg, data) => bridgeCall("log", { level: "info", msg, data }),
warn: (msg, data) => bridgeCall("log", { level: "warn", msg, data }),
error: (msg, data) => bridgeCall("log", { level: "error", msg, data }),
};
const site = ${JSON.stringify(site)};
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;
}
const users = ${hasReadUsers} ? {
get: (id) => bridgeCall("users/get", { id }),
getByEmail: (email) => bridgeCall("users/getByEmail", { email }),
list: (opts) => bridgeCall("users/list", opts || {}),
} : undefined;
const email = ${hasEmailSend} ? {
send: (message) => bridgeCall("email/send", { message }),
} : undefined;
return {
plugin: {
id: ${JSON.stringify(manifest.id)},
version: ${JSON.stringify(manifest.version || "0.0.0")},
},
storage,
kv,
content,
taxonomies,
media,
http,
log,
site,
url,
users,
email,
};
}
// -----------------------------------------------------------------------------
// HTTP Handler (replaces WorkerEntrypoint for workerd-on-Node)
// -----------------------------------------------------------------------------
// Constant-time string comparison. workerd doesn't expose
// crypto.timingSafeEqual, so XOR char codes manually. Always processes
// the full length of the longer input to avoid early-exit timing leaks.
function constantTimeEqual(a, b) {
let result = a.length === b.length ? 0 : 1;
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const ac = i < a.length ? a.charCodeAt(i) : 0;
const bc = i < b.length ? b.charCodeAt(i) : 0;
result |= ac ^ bc;
}
return result === 0;
}
export default {
async fetch(request) {
const url = new URL(request.url);
// Authenticate the caller. The plugin's TCP listener is exposed on
// 127.0.0.1, so any local process could otherwise invoke hooks/routes
// directly. Only the Node runner has the per-startup invoke token.
// Use constant-time comparison: workerd doesn't expose timingSafeEqual,
// so we XOR character codes manually. Same length always required.
const authHeader = request.headers.get("authorization") || "";
const expected = "Bearer " + INVOKE_TOKEN;
if (!constantTimeEqual(authHeader, expected)) {
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname === "/__ready") {
return new Response("ok", { status: 200 });
}
// Hook invocation: POST /hook/{hookName}
if (url.pathname.startsWith("/hook/")) {
const hookName = url.pathname.slice(6); // Remove "/hook/"
const { event } = await request.json();
const ctx = createContext();
const hookDef = hooks[hookName];
if (!hookDef) {
return Response.json({ value: undefined });
}
const handler = typeof hookDef === "function" ? hookDef : hookDef.handler;
if (typeof handler !== "function") {
return new Response("Hook " + hookName + " handler is not a function", { status: 500 });
}
try {
const result = await handler(event, ctx);
return Response.json({ value: result });
} catch (err) {
return new Response(err.message || "Hook error", { status: 500 });
}
}
// Route invocation: POST /route/{routeName}
if (url.pathname.startsWith("/route/")) {
const routeName = url.pathname.slice(7); // Remove "/route/"
const { input, request: serializedRequest } = await request.json();
const ctx = createContext();
const route = routes[routeName];
if (!route) {
return new Response("Route not found: " + routeName, { status: 404 });
}
const handler = typeof route === "function" ? route : route.handler;
if (typeof handler !== "function") {
return new Response("Route " + routeName + " handler is not a function", { status: 500 });
}
try {
const result = await handler(
{ input, request: serializedRequest, requestMeta: serializedRequest?.meta },
ctx,
);
return Response.json(result);
} catch (err) {
return new Response(err.message || "Route error", { status: 500 });
}
}
return new Response("Not found", { status: 404 });
}
};
`;
}
function sanitizeComment(s: string): string {
return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /");
}