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
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:
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, "* /");
|
||||
}
|
||||
Reference in New Issue
Block a user