Skip to content

Commit 6be7263

Browse files
fix(cloudflare): route sandbox runtime calls over invocation RPC (#4394)
* fix(cloudflare): route sandbox runtime calls over invocation RPC Route runtime-owned sandbox operations back to the originating runner using an invocation-scoped RpcTarget, preserving callback identity, async-local DB context, content validation/hooks/translation links, write fencing and invocation settlement. Added runner and real workerd/D1 regressions. Verified root build, package typechecks, format, clean quick lint, 128 sandbox tests and two Worker RPC/D1 tests; independent review findings addressed. EmDash-Bot-Case: 1198246700-issue-6017545075 EmDash-Bot-Candidate: cand-1-9818720259 * fix(cloudflare): route sandbox runtime calls over invocation RPC Address review regression by wiring createPluginTestHost content creation through a database-backed handler, preserving slug/status and translation links. Add transport regression and deterministic after-hook guard coverage; document invocation lifetime and callback timing caveats. Verified root build/typecheck, clean quick lint/format, 128 sandbox tests, complete plugin-test suite, 13 settlement tests and two real Worker RPC/D1 tests. Independent follow-up review found all findings addressed or reasonably answered. EmDash-Bot-Case: 1198246700-issue-6017545075 EmDash-Bot-Candidate: cand-2-c272bbab53 * fix(cloudflare): route sandbox runtime calls over invocation RPC Expose host.waitForDeferredTasks() through the runtime test host using a built core internal export, replace the private source import, verify after-hook effects without polling, cover disposed-host calls, and document the supported wait in the guide/changeset. Root build/typecheck, clean quick lint/format, docs build, full plugin-test suite and 17 deferred-task/settlement tests pass. Independent review findings are addressed or explicitly documented. EmDash-Bot-Case: 1198246700-issue-6017545075 EmDash-Bot-Candidate: cand-3-d5a9b2bd40 --------- Co-authored-by: emdash-autofix[bot] <emdash-autofix[bot]@users.noreply.github.com>
1 parent a16ab2c commit 6be7263

16 files changed

Lines changed: 1166 additions & 27 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"emdash": patch
3+
"@emdash-cms/cloudflare": patch
4+
"@emdash-cms/plugin-test": patch
5+
---
6+
7+
Fixes sandboxed plugin `ctx.taxonomies` mutations (`createTerm()`, `addEntryTerms()`, and `removeEntryTerms()`), `ctx.content.create()`, versioned content actions (`getVersioned()`, `publish()`, `unpublish()`, `schedule()`, `unschedule()`, `getTrashedVersioned()`, and `restore()`), and `ctx.comments.setStatus()` on Cloudflare when requests run across multiple Worker isolates. Content creation consistently uses runtime validation and hooks and preserves `translationOf` links instead of falling back to a plain database insert.
8+
9+
Runner-created invocations without a content-create callback now receive `Content creation is not configured` rather than a database insert that bypasses runtime behavior. `createPluginTestHost()` supplies a database-backed callback with content-handler validation so transport-level tests can continue creating content and linked translations without starting the full EmDash runtime. It preserves the transport host's support for `data.slug` and `data.status`.
10+
11+
Await these operation promises before returning from a plugin route or hook. Their RPC connection now lasts only for that invocation; unlike the previous loopback binding, it cannot support work that outlives the invocation.
12+
13+
Publication after-hooks remain deferred. Same-entry actions attempted while an after-hook is running can return `CONTENT_ACTION_REENTRANT`; faster callback routing can expose this overlap in tests that assumed hooks had already finished. In runtime-host tests, call `await host.waitForDeferredTasks()` after an action before inspecting its after-hook effects or starting another action on the same entry. This method waits for queued deferred runtime work, including work those tasks enqueue; it does not run future scheduled tasks or wait for arbitrary unawaited plugin operations.

‎docs/src/content/docs/plugins/creating-plugins/testing.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ The runtime host groups its API by boundary:
165165
- `http` queues external responses and captures the requests that a plugin sends through `ctx.http.fetch()`.
166166
- `restart()` replaces the runtime and isolate while retaining D1, plugin storage, media storage, and plugin state.
167167

168+
Call `await host.waitForDeferredTasks()` after an action to finish deferred runtime work, such as `content:afterPublish` hooks, before inspecting its effects or starting another action on the same entry. Same-entry actions can otherwise fail with `CONTENT_ACTION_REENTRANT` while an after-hook is running.
169+
170+
The method also waits for deferred work queued by those tasks. It does not run future scheduled tasks or wait for arbitrary unawaited plugin promises. Continue to await plugin operations before returning from a route or hook.
171+
168172
Call `dispose()` after each test. Disposal terminates the isolate and resets all bindings, so a later test cannot observe the previous host's database or media.
169173

170174
Use the settings action and raw inspector to prove that a generated settings save reaches the production handler and does not persist plaintext:

‎packages/cloudflare/src/sandbox/runner.ts‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
setTaxonomyWriteCallback,
4646
setHttpFetchCallback,
4747
} from "./bridge.js";
48+
import { PluginRuntimeBridge } from "./runtime-bridge.js";
4849
import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js";
4950
import { generatePluginWrapper } from "./wrapper.js";
5051

@@ -136,6 +137,9 @@ export class CloudflareSandboxRunner implements SandboxRunner {
136137
private plugins = new Map<string, CloudflareSandboxedPlugin>();
137138
private options: SandboxOptions;
138139
private resolvedLimits: ResolvedLimits;
140+
private contentCreate: SandboxContentCreateCallback | null = null;
141+
private contentActions: ContentActionCallbacks | null;
142+
private commentModerate: SandboxCommentModerateCallback | null;
139143
private readonly contentCreateRuntimeId = crypto.randomUUID();
140144
private readonly contentActionsRuntimeId = crypto.randomUUID();
141145
private readonly taxonomyWriteRuntimeId = crypto.randomUUID();
@@ -150,6 +154,8 @@ export class CloudflareSandboxRunner implements SandboxRunner {
150154

151155
constructor(options: SandboxOptions) {
152156
this.options = options;
157+
this.contentActions = options.contentActions ?? null;
158+
this.commentModerate = options.commentModerate ?? null;
153159
this.resolvedLimits = resolveLimits(options.limits);
154160
this.siteInfo = options.siteInfo;
155161
if (options.httpFetch) {
@@ -179,14 +185,17 @@ export class CloudflareSandboxRunner implements SandboxRunner {
179185
}
180186

181187
setCommentModerate(callback: SandboxCommentModerateCallback | null): void {
188+
this.commentModerate = callback;
182189
setCommentModerateCallback(callback);
183190
}
184191

185192
setContentCreate(callback: SandboxContentCreateCallback | null): void {
193+
this.contentCreate = callback;
186194
setContentCreateCallback(this.contentCreateRuntimeId, callback);
187195
}
188196

189197
setContentActions(callback: ContentActionCallbacks | null): void {
198+
this.contentActions = callback;
190199
setContentActionsCallback(this.contentActionsRuntimeId, callback);
191200
}
192201

@@ -262,6 +271,21 @@ export class CloudflareSandboxRunner implements SandboxRunner {
262271
this.taxonomyWriteRuntimeId,
263272
this.httpFetchKey,
264273
this.mediaStorageKey,
274+
// RPC parameters retain the originating isolate; loopback props only carry data.
275+
(invocationId) =>
276+
new PluginRuntimeBridge(
277+
manifest.id,
278+
normalizePluginCapabilities(manifest.capabilities || []),
279+
invocationId,
280+
{
281+
contentCreate: this.contentCreate,
282+
beforeContentWrite: this.options.beforeContentWrite,
283+
contentActions: this.contentActions,
284+
taxonomyWrite: this.options.taxonomyWrite,
285+
commentModerate: this.commentModerate,
286+
i18nConfig: getI18nConfig(),
287+
},
288+
),
265289
);
266290

267291
this.plugins.set(pluginId, plugin);
@@ -325,6 +349,7 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
325349
private taxonomyWriteRuntimeId?: string,
326350
private readonly httpFetchKey?: string,
327351
private readonly mediaStorageKey?: string,
352+
private readonly createRuntimeBridge?: (invocationId: string) => PluginRuntimeBridge,
328353
) {
329354
this.id = `${manifest.id}:${manifest.version}`;
330355
this.workerName = isolateKey ? `${this.id}:${isolateKey}` : this.id;
@@ -472,7 +497,12 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
472497
const invocation = Promise.resolve().then(() => {
473498
const worker = this.createWorker();
474499
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
475-
return entrypoint.invokeHook(hookName, event, invocationId);
500+
return entrypoint.invokeHook(
501+
hookName,
502+
event,
503+
invocationId,
504+
this.createRuntimeBridge?.(invocationId),
505+
);
476506
});
477507
return this.withWallTimeLimit(`hook:${hookName}`, invocationId, invocation);
478508
}
@@ -499,7 +529,13 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
499529
const invocation = (async () => {
500530
const worker = this.createWorker();
501531
const entrypoint = worker.getEntrypoint<PluginEntrypoint>("default");
502-
const routeResult = await entrypoint.invokeRoute(routeName, input, request, invocationId);
532+
const routeResult = await entrypoint.invokeRoute(
533+
routeName,
534+
input,
535+
request,
536+
invocationId,
537+
this.createRuntimeBridge?.(invocationId),
538+
);
503539
const envelope = getSandboxRouteErrorEnvelope(routeResult);
504540
if (envelope) throw createSandboxRouteError(envelope.error.code);
505541
return routeResult;
@@ -520,12 +556,18 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance {
520556
* The RPC interface exposed by the plugin wrapper.
521557
*/
522558
interface PluginEntrypoint {
523-
invokeHook(hookName: string, event: unknown, invocationId: string): Promise<unknown>;
559+
invokeHook(
560+
hookName: string,
561+
event: unknown,
562+
invocationId: string,
563+
runtimeBridge?: PluginRuntimeBridge,
564+
): Promise<unknown>;
524565
invokeRoute(
525566
routeName: string,
526567
input: unknown,
527568
request: SerializedRequest,
528569
invocationId: string,
570+
runtimeBridge?: PluginRuntimeBridge,
529571
): Promise<unknown>;
530572
}
531573

0 commit comments

Comments
 (0)