Skip to content

Commit ad4b60e

Browse files
committed
runtime: switch iframe sandbox transport to MessageChannel
1 parent e4af47e commit ad4b60e

4 files changed

Lines changed: 171 additions & 126 deletions

File tree

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,83 @@
11
export function buildIframeSandboxSrcdoc(channelLiteral: string): string {
22
return /* html */ `<!doctype html><html><body><script>
33
const CHANNEL = ${channelLiteral};
4-
window.addEventListener("message", async (event) => {
4+
window.addEventListener("message", (event) => {
55
const data = event.data;
6-
if (!data || data.channel !== CHANNEL) {
6+
if (!data || data.channel !== CHANNEL || data.type !== "init") {
77
return;
88
}
9-
const request = data.request || {};
9+
const port = event.ports && event.ports[0];
10+
if (!port) {
11+
return;
12+
}
13+
1014
const safeSend = (payload) => {
1115
try {
12-
parent.postMessage({ channel: CHANNEL, ...payload }, "*");
16+
port.postMessage({ type: "result", ...payload });
1317
return true;
1418
} catch (postError) {
1519
try {
1620
const postMessageError =
1721
postError && typeof postError === "object" && "message" in postError
1822
? String(postError.message)
1923
: String(postError);
20-
parent.postMessage(
21-
{
22-
channel: CHANNEL,
23-
ok: false,
24-
error: "Sandbox response is not serializable: " + postMessageError,
25-
},
26-
"*",
27-
);
24+
port.postMessage({
25+
type: "result",
26+
ok: false,
27+
error: "Sandbox response is not serializable: " + postMessageError,
28+
});
2829
} catch {
2930
// Ignore terminal postMessage failures.
3031
}
3132
return false;
3233
}
3334
};
34-
try {
35-
const moduleUrl = URL.createObjectURL(
36-
new Blob([String(request.code ?? "")], { type: "text/javascript" }),
37-
);
35+
36+
port.onmessage = async (portEvent) => {
37+
const envelope = portEvent.data;
38+
if (!envelope || envelope.type !== "execute") {
39+
return;
40+
}
41+
const request = envelope.request || {};
42+
3843
try {
39-
const namespace = await import(moduleUrl);
40-
const exportName =
41-
typeof request.exportName === "string" &&
42-
request.exportName.trim().length > 0
43-
? request.exportName.trim()
44-
: "default";
45-
const selected = namespace[exportName];
46-
if (selected === undefined) {
47-
throw new Error(
48-
'Runtime source export "' + exportName + '" is missing',
49-
);
44+
const moduleUrl = URL.createObjectURL(
45+
new Blob([String(request.code ?? "")], { type: "text/javascript" }),
46+
);
47+
try {
48+
const namespace = await import(moduleUrl);
49+
const exportName =
50+
typeof request.exportName === "string" &&
51+
request.exportName.trim().length > 0
52+
? request.exportName.trim()
53+
: "default";
54+
const selected = namespace[exportName];
55+
if (selected === undefined) {
56+
throw new Error(
57+
'Runtime source export "' + exportName + '" is missing',
58+
);
59+
}
60+
const output =
61+
typeof selected === "function"
62+
? await selected(request.runtimeInput ?? {})
63+
: selected;
64+
safeSend({ ok: true, output });
65+
} finally {
66+
URL.revokeObjectURL(moduleUrl);
5067
}
51-
const output =
52-
typeof selected === "function"
53-
? await selected(request.runtimeInput ?? {})
54-
: selected;
55-
safeSend({ ok: true, output });
56-
} finally {
57-
URL.revokeObjectURL(moduleUrl);
68+
} catch (error) {
69+
const message =
70+
error && typeof error === "object" && "message" in error
71+
? String(error.message)
72+
: String(error);
73+
safeSend({ ok: false, error: message });
5874
}
59-
} catch (error) {
60-
const message =
61-
error && typeof error === "object" && "message" in error
62-
? String(error.message)
63-
: String(error);
64-
safeSend({ ok: false, error: message });
75+
};
76+
77+
if (typeof port.start === "function") {
78+
port.start();
6579
}
66-
});
80+
port.postMessage({ type: "ready" });
81+
}, { once: true });
6782
</script></body></html>`;
6883
}

packages/runtime/src/sandbox.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ async function executeSourceInIframeSandbox(
210210

211211
const channel = `renderify-runtime-source-${options.request.id}`;
212212
const channelLiteral = JSON.stringify(channel);
213+
const channelPair = new MessageChannel();
214+
const channelPort = channelPair.port1;
215+
const iframePort = channelPair.port2;
213216
iframe.srcdoc = buildIframeSandboxSrcdoc(channelLiteral);
214217

215218
document.body.appendChild(iframe);
@@ -227,7 +230,17 @@ async function executeSourceInIframeSandbox(
227230
if (options.signal && onAbort) {
228231
options.signal.removeEventListener("abort", onAbort);
229232
}
230-
window.removeEventListener("message", onMessage);
233+
channelPort.removeEventListener("message", onMessage);
234+
try {
235+
channelPort.close();
236+
} catch {
237+
// Ignore close failures.
238+
}
239+
try {
240+
iframePort.close();
241+
} catch {
242+
// Ignore close failures.
243+
}
231244
iframe.removeEventListener("load", onLoad);
232245
iframe.remove();
233246
};
@@ -238,14 +251,33 @@ async function executeSourceInIframeSandbox(
238251
}, options.timeoutMs);
239252

240253
const onMessage = (event: MessageEvent<unknown>) => {
241-
if (event.source !== iframe.contentWindow) {
254+
const data = event.data as
255+
| { type?: string; ok?: boolean; output?: unknown; error?: string }
256+
| undefined;
257+
if (!data || typeof data.type !== "string") {
242258
return;
243259
}
244260

245-
const data = event.data as
246-
| { channel?: string; ok?: boolean; output?: unknown; error?: string }
247-
| undefined;
248-
if (!data || data.channel !== channel) {
261+
if (data.type === "ready") {
262+
try {
263+
channelPort.postMessage({
264+
type: "execute",
265+
request: options.request,
266+
});
267+
} catch (error) {
268+
cleanup();
269+
reject(
270+
new Error(
271+
`Iframe sandbox failed to receive request: ${
272+
error instanceof Error ? error.message : String(error)
273+
}`,
274+
),
275+
);
276+
}
277+
return;
278+
}
279+
280+
if (data.type !== "result") {
249281
return;
250282
}
251283

@@ -267,10 +299,9 @@ async function executeSourceInIframeSandbox(
267299
if (!iframe.contentWindow) {
268300
throw new Error("Iframe sandbox contentWindow is unavailable");
269301
}
270-
iframe.contentWindow.postMessage(
271-
{ channel, request: options.request },
272-
"*",
273-
);
302+
iframe.contentWindow.postMessage({ channel, type: "init" }, "*", [
303+
iframePort,
304+
]);
274305
} catch (error) {
275306
cleanup();
276307
reject(
@@ -281,7 +312,8 @@ async function executeSourceInIframeSandbox(
281312
}
282313
};
283314

284-
window.addEventListener("message", onMessage);
315+
channelPort.addEventListener("message", onMessage);
316+
channelPort.start();
285317
iframe.addEventListener("load", onLoad, { once: true });
286318

287319
if (options.signal) {
@@ -486,6 +518,7 @@ function isIframeSandboxAvailable(): boolean {
486518
if (
487519
typeof document === "undefined" ||
488520
typeof window === "undefined" ||
521+
typeof MessageChannel !== "function" ||
489522
!hasRuntimeModuleBlobSupport()
490523
) {
491524
return false;

tests/runtime.test.ts

Lines changed: 56 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -349,62 +349,65 @@ function installBrowserIframeSandboxGlobals(): () => void {
349349
const previousWorker = Object.getOwnPropertyDescriptor(root, "Worker");
350350
const previousCreateObjectURL = urlStatics.createObjectURL;
351351
const previousRevokeObjectURL = urlStatics.revokeObjectURL;
352-
353-
const windowMessageHandlers = new Set<
354-
(event: MessageEvent<unknown>) => void
355-
>();
356-
357-
const mockWindow = {
358-
addEventListener(type: string, handler: EventListener): void {
359-
if (type === "message") {
360-
windowMessageHandlers.add(
361-
handler as (event: MessageEvent<unknown>) => void,
362-
);
363-
}
364-
},
365-
removeEventListener(type: string, handler: EventListener): void {
366-
if (type === "message") {
367-
windowMessageHandlers.delete(
368-
handler as (event: MessageEvent<unknown>) => void,
369-
);
370-
}
371-
},
372-
dispatchMessage(event: MessageEvent<unknown>): void {
373-
for (const handler of windowMessageHandlers) {
374-
handler(event);
375-
}
376-
},
377-
} as unknown as Window & {
378-
dispatchMessage(event: MessageEvent<unknown>): void;
379-
};
352+
const mockWindow = {} as Window;
380353

381354
class MockIframeElement {
382355
private readonly loadHandlers = new Set<EventListener>();
383356
readonly style: Record<string, string> = {};
384357
srcdoc = "";
385358
contentWindow: {
386-
postMessage: (payload: unknown, targetOrigin: string) => void;
359+
postMessage: (
360+
payload: unknown,
361+
targetOrigin: string,
362+
transfer?: unknown[],
363+
) => void;
387364
};
388365

389366
constructor() {
390367
const contentWindowRef = {
391-
postMessage: (payload: unknown) => {
392-
const requestPayload = payload as {
368+
postMessage: (
369+
payload: unknown,
370+
_targetOrigin: string,
371+
transfer?: unknown[],
372+
) => {
373+
const initPayload = payload as {
393374
channel?: string;
394-
request?: {
395-
runtimeInput?: {
396-
state?: {
397-
count?: number;
375+
type?: string;
376+
};
377+
const port = transfer?.[0] as
378+
| {
379+
postMessage: (value: unknown) => void;
380+
addEventListener?: (
381+
type: string,
382+
listener: EventListener,
383+
options?: AddEventListenerOptions,
384+
) => void;
385+
}
386+
| undefined;
387+
if (!port || initPayload.type !== "init") {
388+
return;
389+
}
390+
391+
port.addEventListener?.(
392+
"message",
393+
((event: MessageEvent<unknown>) => {
394+
const executePayload = event.data as {
395+
type?: string;
396+
request?: {
397+
runtimeInput?: {
398+
state?: {
399+
count?: number;
400+
};
401+
};
398402
};
399403
};
400-
};
401-
};
402-
const count = requestPayload.request?.runtimeInput?.state?.count ?? 0;
403-
queueMicrotask(() => {
404-
mockWindow.dispatchMessage({
405-
source: contentWindowRef,
406-
data: {
407-
channel: requestPayload.channel,
404+
if (executePayload.type !== "execute") {
405+
return;
406+
}
407+
const count =
408+
executePayload.request?.runtimeInput?.state?.count ?? 0;
409+
port.postMessage({
410+
type: "result",
408411
ok: true,
409412
output: {
410413
type: "element",
@@ -416,8 +419,16 @@ function installBrowserIframeSandboxGlobals(): () => void {
416419
},
417420
],
418421
},
419-
},
420-
} as MessageEvent<unknown>);
422+
});
423+
}) as EventListener,
424+
{ once: true },
425+
);
426+
427+
queueMicrotask(() => {
428+
port.postMessage({
429+
type: "ready",
430+
channel: initPayload.channel,
431+
});
421432
});
422433
},
423434
};

0 commit comments

Comments
 (0)