Skip to content

Commit efebc62

Browse files
authored
feat: proxy AbortSignal across iframe boundary for V3 plugin provider callbacks (#1276)
## PR Checklist - Required Checks - [x] Have you added type definitions? - [x] Have you tested your changes? - [x] Have you checked that it won't break any existing features? - [ ] If your PR uses models[^1], if true, check the following: - [ ] Have you checked if it works normally in all models? - [ ] Have you checked if it works normally in all web, local, and node hosted versions? If it doesn't, have you blocked it in those versions? - [x] If your PR is highly ai generated[^2], check the following: - [x] Have you understanded what the code does? - [x] Have you cleaned up any unnecessary or redundant code? - [x] Is is not a huge change? - We currently do not accept highly ai generated PRs that are large changes. [^1]: Modifies the behavior of prompting, requesting or handling responses from ai models. [^2]: Almost over 80% of the code is ai generated. ## Summary Follow-up to #1273. Instead of discarding `AbortSignal` (replacing with `null`), this PR proxies it across the iframe boundary using an ID-based message protocol, restoring abort/cancel functionality for V3 plugin providers. ## Related Issues - PR #1273 — Initial fix that sanitized `AbortSignal` to `null` to prevent `DataCloneError`. This PR builds on that foundation. ## Changes `AbortSignal` is not structured-cloneable (HTML spec), so it cannot be passed via `postMessage`. PR #1273 solved the crash by replacing it with `null`, but this meant plugins lost all abort/cancel functionality. This PR introduces a lightweight proxy protocol within factory.ts (single file, ~46 lines added): **Host side** (`SandboxHost.deserializeArgs`): - `AbortSignal` → serializable `AbortSignalRef` object `{ __type: 'ABORT_SIGNAL_REF', abortId, aborted }` - Attaches a one-shot `abort` event listener on the real signal that sends an `ABORT_SIGNAL` message to the guest iframe when triggered **Guest side** (`GUEST_BRIDGE_SCRIPT`): - New `abortControllers` registry (Map) - `INVOKE_CALLBACK` handler deserializes `AbortSignalRef` → local `AbortController`, stores in registry, returns `controller.signal` to the plugin callback - If `aborted: true` in ref, immediately calls `controller.abort()` - New `ABORT_SIGNAL` message handler: looks up controller by `abortId`, calls `.abort()`, removes from registry **Type additions**: - `'ABORT_SIGNAL'` added to `MsgType` union - `AbortSignalRef` interface added ### Data flow ``` Host (real AbortSignal) Guest (reconstructed AbortSignal) ─────────────────────── ──────────────────────────────── AbortSignal AbortController + .signal │ │ │ INVOKE_CALLBACK │ ├──→ { __type: 'ABORT_SIGNAL_REF', ──→ new AbortController() │ abortId: 'abc', abortControllers.set('abc', ctrl) │ aborted: false } return ctrl.signal to plugin │ │ │ (user clicks Stop) │ │ abort event fires │ ├──→ postMessage({ │ │ type: 'ABORT_SIGNAL', ────→ ctrl.abort() │ abortId: 'abc' abortControllers.delete('abc') │ }) │ ``` ## Impact **Backward compatible** — no breaking changes: - Plugins that **ignore** `abortSignal` (pass it as unused param or check for null): no behavior change. They received `null` before, now they receive a real `AbortSignal` that simply never fires if the host doesn't abort. - Plugins that **use** `abortSignal` (e.g., `signal: abortSignal` in fetch, or `.addEventListener('abort', ...)`): these now work correctly, whereas before they had no abort capability. - The `ABORT_SIGNAL` message type is ignored by older guest bridge code that doesn't recognize it, so forward compatibility is maintained. - No sandbox flags are modified (`allow-scripts allow-modals allow-downloads` only). - No host objects or references leak to the guest — only a plain `{ __type, abortId, aborted }` object crosses the boundary. ## Additional Notes - This change is scoped entirely to factory.ts — no other files are modified. - The protocol is unidirectional (host→guest only). Guest-to-host AbortSignal proxying (e.g., plugin passing `signal` in `nativeFetch` options) is not covered and would require a separate change. (It will probably be resolved by #1274) - The `aborted` field in `AbortSignalRef` handles the edge case where the signal is already aborted at serialization time. ---
2 parents 0ba78b2 + f6aa0a8 commit efebc62

1 file changed

Lines changed: 62 additions & 4 deletions

File tree

src/ts/plugins/apiV3/factory.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ type MsgType =
44
| 'INVOKE_CALLBACK'
55
| 'CALLBACK_RETURN'
66
| 'RESPONSE'
7-
| 'RELEASE_INSTANCE';
7+
| 'RELEASE_INSTANCE'
8+
| 'ABORT_SIGNAL';
89

910
interface RpcMessage {
1011
type: MsgType;
@@ -14,6 +15,7 @@ interface RpcMessage {
1415
args?: any[];
1516
result?: any;
1617
error?: string;
18+
abortId?: string;
1719
}
1820

1921
interface RemoteRef {
@@ -26,12 +28,19 @@ interface CallbackRef {
2628
id: string;
2729
}
2830

31+
interface AbortSignalRef {
32+
__type: 'ABORT_SIGNAL_REF';
33+
abortId: string;
34+
aborted: boolean;
35+
}
36+
2937

3038
const GUEST_BRIDGE_SCRIPT = `
3139
await (async function() {
3240
const pendingRequests = new Map();
3341
const callbackRegistry = new Map();
3442
const proxyRefRegistry = new Map();
43+
const abortControllers = new Map();
3544
3645
function serializeArg(arg) {
3746
if (typeof arg === 'function') {
@@ -150,17 +159,40 @@ await (async function() {
150159
send(response);
151160
}
152161
162+
else if (data.type === 'ABORT_SIGNAL' && data.abortId) {
163+
const controller = abortControllers.get(data.abortId);
164+
if (controller) {
165+
controller.abort();
166+
abortControllers.delete(data.abortId);
167+
}
168+
}
169+
153170
else if (data.type === 'INVOKE_CALLBACK' && data.id) {
154171
const fn = callbackRegistry.get(data.id);
155172
const response = { type: 'CALLBACK_RETURN', reqId: data.reqId };
173+
const usedAbortIds = [];
156174
157175
try {
158176
if (!fn) throw new Error("Callback not found or released");
159-
const result = await fn(...(data.args || []));
177+
const deserializedArgs = (data.args || []).map(function(a) {
178+
if (a && typeof a === 'object' && a.__type === 'ABORT_SIGNAL_REF') {
179+
const controller = new AbortController();
180+
abortControllers.set(a.abortId, controller);
181+
usedAbortIds.push(a.abortId);
182+
if (a.aborted) { controller.abort(); }
183+
return controller.signal;
184+
}
185+
return a;
186+
});
187+
const result = await fn(...deserializedArgs);
160188
response.result = result;
161189
} catch (e) {
162190
response.error = e.message || "Guest callback error";
163191
}
192+
// Clean up abort controllers after callback completes
193+
for (const id of usedAbortIds) {
194+
abortControllers.delete(id);
195+
}
164196
const transferables = collectTransferables(response);
165197
send(response, transferables);
166198
}
@@ -351,11 +383,37 @@ export class SandboxHost {
351383
const reqId = 'cb_req_' + Math.random().toString(36).substring(2);
352384
this.pendingCallbacks.set(reqId, { resolve, reject });
353385

386+
// AbortSignal cannot be structured-cloned for postMessage.
387+
// Convert to a serializable ref and forward abort events
388+
// via a separate ABORT_SIGNAL message.
389+
const sanitizedArgs = innerArgs.map(arg => {
390+
if (arg instanceof AbortSignal) {
391+
const abortId = 'abort_' + Math.random().toString(36).substring(2);
392+
const ref: AbortSignalRef = {
393+
__type: 'ABORT_SIGNAL_REF',
394+
abortId,
395+
aborted: arg.aborted
396+
};
397+
if (!arg.aborted) {
398+
arg.addEventListener('abort', () => {
399+
try {
400+
this.iframe.contentWindow?.postMessage({
401+
type: 'ABORT_SIGNAL',
402+
abortId
403+
} as RpcMessage, '*');
404+
} catch (_) { /* iframe already removed */ }
405+
}, { once: true });
406+
}
407+
return ref;
408+
}
409+
return arg;
410+
});
411+
354412
const message = {
355413
type: 'INVOKE_CALLBACK',
356414
id: cbRef.id,
357415
reqId,
358-
args: innerArgs
416+
args: sanitizedArgs
359417
};
360418
const transferables = this.collectTransferables(message);
361419
this.iframe.contentWindow?.postMessage(message, '*', transferables);
@@ -499,4 +557,4 @@ export class SandboxHost {
499557
this.instanceRegistry.clear();
500558
this.pendingCallbacks.clear();
501559
}
502-
}
560+
}

0 commit comments

Comments
 (0)