-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathrunner.ts
More file actions
396 lines (360 loc) · 11.7 KB
/
Copy pathrunner.ts
File metadata and controls
396 lines (360 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
/**
* Cloudflare Sandbox Runner
*
* Uses Worker Loader to run plugins in isolated V8 isolates.
* Plugins communicate with the host via a BRIDGE service binding
* that enforces capabilities and scopes operations.
*
* This module imports directly from cloudflare:workers to access
* the LOADER binding and PluginBridge export. It's only loaded
* when the user configures `sandboxRunner: "@emdash-cms/cloudflare/sandbox"`.
*
*/
import { env, exports } from "cloudflare:workers";
import {
createSandboxRouteError,
getSandboxRouteErrorEnvelope,
normalizeCapabilities,
type SandboxRunner,
type SandboxedPluginInstance,
type SandboxEmailSendCallback,
type SandboxOptions,
type SandboxRunnerFactory,
type SerializedRequest,
type PluginManifest,
} from "emdash";
import { setEmailSendCallback } from "./bridge.js";
import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js";
import { generatePluginWrapper } from "./wrapper.js";
/**
* Default resource limits for sandboxed plugins.
*
* cpuMs and subrequests are enforced by Worker Loader at the V8 isolate level.
* wallTimeMs is enforced by the runner via Promise.race.
* memoryMb is declared for API compatibility but NOT currently enforced —
* Worker Loader doesn't expose a memory limit option. V8 isolates have a
* platform-level memory ceiling (~128MB) but it's not configurable per-worker.
*/
const DEFAULT_LIMITS = {
cpuMs: 50,
memoryMb: 128,
subrequests: 10,
wallTimeMs: 30_000,
} as const;
export interface PluginBridgeProps {
pluginId: string;
pluginVersion: string;
capabilities: string[];
allowedHosts: string[];
storageCollections: string[];
storageConfig?: Record<
string,
{ indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
>;
}
/**
* Get the Worker Loader binding from env
*/
function getLoader(): WorkerLoader | null {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Worker Loader binding accessed from untyped env object
return (env as Record<string, unknown>).LOADER as WorkerLoader | null;
}
/**
* Get the PluginBridge from exports (loopback binding)
*/
function getPluginBridge(): ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding) | null {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- PluginBridge accessed from untyped cloudflare:workers exports
return (exports as Record<string, unknown>).PluginBridge as
| ((opts: { props: PluginBridgeProps }) => PluginBridgeBinding)
| null;
}
/**
* Resolved resource limits with defaults applied.
*/
interface ResolvedLimits {
cpuMs: number;
memoryMb: number;
subrequests: number;
wallTimeMs: number;
}
/**
* Resolve resource limits by merging user-provided overrides with defaults.
*/
function resolveLimits(limits?: SandboxOptions["limits"]): ResolvedLimits {
return {
cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs,
memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb,
subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests,
wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs,
};
}
/**
* Cloudflare sandbox runner using Worker Loader.
*/
export class CloudflareSandboxRunner implements SandboxRunner {
private plugins = new Map<string, CloudflareSandboxedPlugin>();
private options: SandboxOptions;
private resolvedLimits: ResolvedLimits;
private siteInfo?: {
name: string;
url: string;
locale: string;
trailingSlash?: "always" | "never" | "ignore";
};
constructor(options: SandboxOptions) {
this.options = options;
this.resolvedLimits = resolveLimits(options.limits);
this.siteInfo = options.siteInfo;
// Wire email send callback if provided at construction time
setEmailSendCallback(options.emailSend ?? null);
}
/**
* Set the email send callback for sandboxed plugins.
* Called after the EmailPipeline is created, since the pipeline
* doesn't exist when the sandbox runner is constructed.
*/
setEmailSend(callback: SandboxEmailSendCallback | null): void {
setEmailSendCallback(callback);
}
/**
* Check if Worker Loader is available.
*/
isAvailable(): boolean {
return !!getLoader() && !!getPluginBridge();
}
/**
* Worker Loader runs in-process, always healthy if available.
*/
isHealthy(): boolean {
return this.isAvailable();
}
/**
* Load a sandboxed plugin.
*
* @param manifest - Plugin manifest with capabilities and storage declarations
* @param code - The bundled plugin JavaScript code
*/
async load(manifest: PluginManifest, code: string): Promise<SandboxedPluginInstance> {
const pluginId = `${manifest.id}:${manifest.version}`;
// Return cached plugin if available
const existing = this.plugins.get(pluginId);
if (existing) return existing;
const loader = getLoader();
const pluginBridge = getPluginBridge();
if (!loader) {
throw new Error(
"Worker Loader not available. Add worker_loaders binding to wrangler config.",
);
}
if (!pluginBridge) {
throw new Error(
"PluginBridge not available. Export PluginBridge from your worker entrypoint.",
);
}
const plugin = new CloudflareSandboxedPlugin(
manifest,
code,
loader,
pluginBridge,
this.resolvedLimits,
this.siteInfo,
);
this.plugins.set(pluginId, plugin);
return plugin;
}
/**
* Terminate all loaded plugins.
*/
async terminateAll(): Promise<void> {
for (const plugin of this.plugins.values()) {
await plugin.terminate();
}
this.plugins.clear();
}
}
/**
* A plugin running in a Worker Loader isolate.
*
* IMPORTANT: Worker stubs and bridge bindings are tied to request context.
* We must create fresh stubs for each invocation to avoid I/O isolation errors:
* "Cannot perform I/O on behalf of a different request"
*/
class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
readonly id: string;
readonly manifest: PluginManifest;
private loader: WorkerLoader;
private createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding;
private code: string;
private wrapperCode: string | null = null;
private limits: ResolvedLimits;
private siteInfo?: {
name: string;
url: string;
locale: string;
trailingSlash?: "always" | "never" | "ignore";
};
constructor(
manifest: PluginManifest,
code: string,
loader: WorkerLoader,
createBridge: (opts: { props: PluginBridgeProps }) => PluginBridgeBinding,
limits: ResolvedLimits,
siteInfo?: {
name: string;
url: string;
locale: string;
trailingSlash?: "always" | "never" | "ignore";
},
) {
this.id = `${manifest.id}:${manifest.version}`;
this.manifest = manifest;
this.code = code;
this.loader = loader;
this.createBridge = createBridge;
this.limits = limits;
this.siteInfo = siteInfo;
}
/**
* Create a fresh worker stub for the current request.
*
* Worker Loader stubs contain bindings (like BRIDGE) that are tied to the
* request context in which they were created. Reusing stubs across requests
* causes "Cannot perform I/O on behalf of a different request" errors.
*
* The Worker Loader internally caches the V8 isolate, so we only pay the
* cost of creating the bridge binding and stub wrapper per request.
*/
private createWorker(): WorkerStub {
// Cache the wrapper code (CPU-bound, no I/O context issues)
if (!this.wrapperCode) {
this.wrapperCode = generatePluginWrapper(this.manifest, {
site: this.siteInfo,
});
}
// Create fresh bridge binding for THIS request.
//
// Capabilities are normalized to canonical names here so the bridge
// only ever sees the current vocabulary. Manifests installed before
// the rename (or sites still using the legacy alias layer) keep
// working — `normalizeCapabilities` rewrites legacy names like
// `read:content` → `content:read` and `network:fetch` → `network:request`.
const bridgeBinding = this.createBridge({
props: {
pluginId: this.manifest.id,
pluginVersion: this.manifest.version || "0.0.0",
capabilities: normalizeCapabilities(this.manifest.capabilities || []),
allowedHosts: this.manifest.allowedHosts || [],
storageCollections: Object.keys(this.manifest.storage || {}),
storageConfig: this.manifest.storage,
},
});
// Build Worker Loader limits from resolved resource limits
const loaderLimits: WorkerLoaderLimits = {
cpuMs: this.limits.cpuMs,
subRequests: this.limits.subrequests,
};
// Get a fresh stub with the new bridge binding.
// Worker Loader caches the isolate but the stub/bindings are per-call.
return this.loader.get(this.id, () => ({
compatibilityDate: "2026-04-01",
mainModule: "plugin.js",
modules: {
"plugin.js": { js: this.wrapperCode! },
"sandbox-plugin.js": { js: this.code },
},
// Block direct network access - plugins must use ctx.http via bridge
globalOutbound: null,
// Enforce resource limits at the V8 isolate level
limits: loaderLimits,
env: {
// Plugin metadata
PLUGIN_ID: this.manifest.id,
PLUGIN_VERSION: this.manifest.version || "0.0.0",
// Bridge binding for all host operations
BRIDGE: bridgeBinding,
},
}));
}
/**
* Run a function with wall-time enforcement.
*
* CPU limits and subrequest limits are enforced by the Worker Loader
* at the V8 isolate level. Wall-time is enforced here because Worker
* Loader doesn't expose a wall-time limit — a plugin could stall
* indefinitely waiting on network I/O.
*/
private async withWallTimeLimit<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const wallTimeMs = this.limits.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);
}
}
/**
* Invoke a hook in the sandboxed plugin.
*
* CPU and subrequest limits are enforced by Worker Loader.
* Wall-time is enforced here.
*/
async invokeHook(hookName: string, event: unknown): Promise<unknown> {
return this.withWallTimeLimit(`hook:${hookName}`, () => {
const worker = this.createWorker();
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
return entrypoint.invokeHook(hookName, event);
});
}
/**
* Invoke an API route in the sandboxed plugin.
*
* CPU and subrequest limits are enforced by Worker Loader.
* Wall-time is enforced here.
*/
async invokeRoute(
routeName: string,
input: unknown,
request: SerializedRequest,
): Promise<unknown> {
return this.withWallTimeLimit(`route:${routeName}`, async () => {
const worker = this.createWorker();
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
const result = await entrypoint.invokeRoute(routeName, input, request);
const envelope = getSandboxRouteErrorEnvelope(result);
if (envelope) throw createSandboxRouteError(envelope.error.code);
return result;
});
}
/**
* Terminate the sandboxed plugin.
*/
async terminate(): Promise<void> {
// Worker Loader manages isolate lifecycle - nothing to do here
this.wrapperCode = null;
}
}
/**
* The RPC interface exposed by the plugin wrapper.
*/
interface PluginEntrypoint {
invokeHook(hookName: string, event: unknown): Promise<unknown>;
invokeRoute(routeName: string, input: unknown, request: SerializedRequest): Promise<unknown>;
}
/**
* Factory function for creating the Cloudflare sandbox runner.
*
* Matches the SandboxRunnerFactory signature. The LOADER and PluginBridge
* are obtained internally from cloudflare:workers imports.
*/
export const createSandboxRunner: SandboxRunnerFactory = (options) => {
return new CloudflareSandboxRunner(options);
};