feat(puter.js): transferables in AppConnection.postMessage (#3776)

`postMessage(message, transfer)` (or `{ transfer }`) moves ArrayBuffers,
MessagePorts, streams and the rest to the target app instead of copying
them. The second argument is optional, so existing callers are unchanged.

The transfer list rides in the message body as well as the real transfer
list: structured clone's memory map keeps those objects identical to the
ones inside `contents`, which is how the desktop picks them out mid-relay
and keeps transferring them onward rather than leaving copies behind. Both
`messageToApp` paths carry it — the direct iframe relay and the connection
path that `launchApp()` between apps actually uses.

The desktop forwards the list as-is and lets postMessage judge it. A list
that arrived through the SDK was already validated by the browser on the
first hop, and validating again here would mean an allowlist of
transferable types that silently downgrades an unrecognised one to a copy.
A bad list only reaches us from an app that hand-wrote the envelope; that
throws without detaching anything, and is caught so it cannot escape
`ipc_listener` as an unhandled rejection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Toshit Chawda
2026-09-08 08:57:52 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 72889d72c8
commit 1d6d0da950
7 changed files with 290 additions and 24 deletions
+66 -2
View File
@@ -21,10 +21,12 @@ Listen to an event from the target app. Possible events are:
#### `off(eventName, handler)`
Remove an event listener added with `on(eventName, handler)`.
#### `postMessage(message)`
#### `postMessage(message, transfer)`
Send a message to the target app. Think of it as a more limited version of [`window.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). `message` can be anything that [`window.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) would accept for its `message` parameter.
If the target app is not using the SDK, or the connection is not open, then nothing will happen.
`transfer` is optional, and is an array of [transferable objects](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) — an `ArrayBuffer`, `MessagePort`, `ReadableStream`, `ImageBitmap`, and so on. Each one is *moved* to the target app instead of being copied, and becomes unusable in the sending app. Every object you list must also appear somewhere inside `message`, because that is where the target app reads it from. An options object, `postMessage(message, { transfer })`, is accepted as well.
If the target app is not using the SDK, or the connection is not open, then nothing will happen — and nothing is transferred.
#### `close()`
Attempt to close the target app. If you do not have permission to close it, or the target app is already closed, then nothing will happen.
@@ -156,3 +158,65 @@ In this example, a parent app (with the name `traffic-light`) launches three chi
</html>
```
### Transferring data instead of copying it
Large binary data — a decoded image, an audio buffer, a file you just read — is copied every time it crosses `postMessage()`. Listing it in `transfer` hands the memory over instead: nothing is copied, and the buffer is gone from the sending app afterwards.
This example sends a one-megabyte buffer to a child app, which sends it straight back after filling in a byte.
```html
<html>
<head>
<title>Parent app</title>
</head>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
const child = await puter.ui.launchApp('child');
child.on('message', msg => {
console.log('Got the buffer back:', msg.buffer.byteLength, 'bytes');
console.log('First byte:', new Uint8Array(msg.buffer)[0]);
});
const buffer = new ArrayBuffer(1024 * 1024);
// `buffer` is inside the message *and* in the transfer list.
child.postMessage({ buffer }, [buffer]);
// It now belongs to the child app, so there is nothing left here.
console.log(buffer.byteLength); // 0
</script>
</body>
</html>
<!------------------->
<html>
<head>
<title>Child app</title>
</head>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
const parent = puter.ui.parentApp();
parent.on('message', msg => {
const bytes = new Uint8Array(msg.buffer);
bytes[0] = 42;
// Send it back the same way, without copying it again.
parent.postMessage({ buffer: msg.buffer }, [msg.buffer]);
});
</script>
</body>
</html>
```
A `MessagePort` can be transferred the same way, which gives the two apps a direct channel that no longer goes through `AppConnection`:
```js
const channel = new MessageChannel();
channel.port1.onmessage = e => console.log('from the child:', e.data);
child.postMessage({ port: channel.port2 }, [channel.port2]);
```
+26 -15
View File
@@ -2050,30 +2050,41 @@ const ipc_listener = async (event, handled) => {
// messageToApp
//--------------------------------------------------------
else if ( event.data.msg === 'messageToApp' ) {
const { appInstanceID, targetAppInstanceID, targetAppOrigin, contents } = event.data;
const { appInstanceID, targetAppInstanceID, targetAppOrigin, contents, transfer } = event.data;
// TODO: Determine if we should allow the message
// TODO: Track message traffic between apps
const svc_ipc = globalThis.services.get('ipc');
// const svc_exec = globalThis.services()
const conn = svc_ipc.get_connection(targetAppInstanceID);
if ( conn ) {
conn.send(contents);
return;
}
// pass on the message
const target_iframe = window.iframe_for_app_instance(targetAppInstanceID);
if ( ! target_iframe ) {
const conn = svc_ipc.get_connection(targetAppInstanceID);
const target_iframe = conn ? null : window.iframe_for_app_instance(targetAppInstanceID);
if ( ! conn && ! target_iframe ) {
console.error('Failed to send message to non-existent app', event);
return;
}
target_iframe.contentWindow.postMessage({
msg: 'messageToApp',
appInstanceID,
targetAppInstanceID,
contents,
}, targetAppOrigin);
// The sender transferred these to us, so they have to keep moving: the
// target app wants the objects themselves, not copies stranded here.
// `transfer` is whatever the sending app put in the message and
// postMessage is the authority on what may be transferred, so a bad
// list throws (detaching nothing) — and `ipc_listener` has no outer
// catch to stop that escaping.
try {
if ( conn ) {
conn.send(contents, { transfer });
} else {
target_iframe.contentWindow.postMessage({
msg: 'messageToApp',
appInstanceID,
targetAppInstanceID,
contents,
transfer,
}, targetAppOrigin, transfer);
}
} catch ( e ) {
console.error('Failed to relay message between apps', e);
}
}
//--------------------------------------------------------
// closeApp
+3 -2
View File
@@ -162,15 +162,16 @@ export class PortalProcess extends Process {
}
}
send (channel, data, context) {
send (channel, data, { transfer = [] } = {}) {
const target = this.references.iframe.contentWindow;
target.postMessage({
msg: 'messageToApp',
appInstanceID: channel.returnAddress,
targetAppInstanceID: this.uuid,
contents: data,
transfer,
// }, new URL(this.references.iframe.src).origin);
}, '*');
}, '*', transfer);
}
async handle_connection (connection, args) {
+2 -2
View File
@@ -28,13 +28,13 @@ class InternalConnection {
this.reverse = reverse;
}
send (data) {
send (data, options) {
const svc_process = this.services.get('process');
const process = svc_process.get_by_uuid(this.target);
const channel = {
returnAddress: this.reverse,
};
process.send(channel, data);
process.send(channel, data, options);
}
}
+39 -3
View File
@@ -286,7 +286,7 @@ const PERMISSION_CHECK_TIMEOUT_MS = 2000;
* An interface for interacting with another app. Returned by the UI methods
* that launch or connect to one; it cannot be constructed directly.
*
* - `postMessage(message)` sends a message to the target app.
* - `postMessage(message, transfer)` sends a message to the target app.
* - `on('message', handler)` listens for messages from it.
* - `on('close', handler)` fires when it closes.
*
@@ -388,15 +388,48 @@ export class AppConnection extends EventListener {
return this.#usesSDK;
}
/**
* @overload
* @param {unknown} message
* @returns {void}
*/
/**
* @overload
* @param {unknown} message
* @param {Transferable[]} transfer
* @returns {void}
*/
/**
* @overload
* @param {unknown} message
* @param {{ transfer?: Transferable[] }} options
* @returns {void}
*/
/**
* Sends a message to the target app. Does nothing beyond a console
* warning if the target isn't using the SDK, or the connection has
* already closed.
*
* Objects listed in `transfer` move to the target app instead of being
* copied, and become unusable here. Each one must also appear somewhere
* inside `message`, which is where the target app reads it from.
*
* @param {unknown} message
* @param {Transferable[] | { transfer?: Transferable[] }} [transferOrOptions]
* @returns {void}
*/
postMessage (message) {
postMessage (message, transferOrOptions) {
const transfer = Array.isArray(transferOrOptions)
? transferOrOptions
: (transferOrOptions?.transfer ?? []);
if ( ! Array.isArray(transfer) ) {
throw {
message: 'transfer must be an array of transferable objects',
code: 'invalid_transfer_list',
};
}
if ( ! this.#isOpen ) {
console.warn('Trying to post message on a closed AppConnection');
return;
@@ -416,7 +449,10 @@ export class AppConnection extends EventListener {
// on the other side where the expected origin for the app is known.
targetAppOrigin: '*',
contents: message,
}, this.#puterOrigin);
// In the body as well as the transfer list: Puter relays this
// message onward, and needs to know what to keep transferring.
transfer,
}, this.#puterOrigin, transfer);
}
/**
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: AppConnection</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js AppConnection fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="send-transferable" disabled>Send a transferred buffer to a child app</button>
<div id="log" data-testid="log"></div>
<script>
// Point the SDK at the local Puter instance (env=web popup flow).
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
// Must be the real api subdomain: the SDK sends credentialed CORS
// requests, and only the api host answers with
// Access-Control-Allow-Credentials — on the GUI host they all fail.
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
// This fixture is both apps in the exchange: launched with no name, it
// launches a copy of itself, and the copy takes the child branch.
const TO_CHILD = [1, 2, 3, 4];
const TO_PARENT = [9, 8, 7, 6];
const PORT_GREETING = 'hello over the port';
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
const describe = (payload) => payload instanceof ArrayBuffer
? `ArrayBuffer[${[...new Uint8Array(payload)].join(',')}]`
: `not-an-ArrayBuffer:${typeof payload}`;
const bufferOf = (bytes) => new Uint8Array(bytes).buffer;
function runAsChild(parent) {
parent.on('message', msg => {
if (msg?.kind !== 'transfer') return;
// A transferred port is a live channel that no longer goes
// through Puter at all.
msg.port.postMessage(PORT_GREETING);
const reply = bufferOf(TO_PARENT);
parent.postMessage({
kind: 'result',
received: describe(msg.payload),
payload: reply,
}, [reply]);
});
// launchApp() resolves before this app has loaded, so the parent
// waits for this rather than posting into a window with no
// listener yet.
parent.postMessage({ kind: 'ready' });
}
function runAsParent() {
const button = document.getElementById('send-transferable');
button.addEventListener('click', async () => {
button.disabled = true;
try {
const child = await puter.ui.launchApp();
child.on('message', msg => {
if (msg?.kind === 'ready') {
const payload = bufferOf(TO_CHILD);
const channel = new MessageChannel();
channel.port1.onmessage = e => log(`port-message:${e.data}`);
child.postMessage(
{ kind: 'transfer', payload, port: channel.port2 },
[payload, channel.port2],
);
log(`sent:detached:${payload.byteLength === 0}`);
return;
}
if (msg?.kind !== 'result') return;
log(`child-received:${msg.received}`);
log(`parent-received:${describe(msg.payload)}`);
child.close();
});
} catch (e) {
log(`error:${e?.message ?? e}`);
}
});
button.disabled = false;
}
function ready() {
document.body.classList.add('ready');
const parent = puter.ui.parentApp();
statusEl.textContent = `Ready (env=${puter.env}, role=${parent ? 'child' : 'parent'})`;
if (parent) runAsChild(parent);
else runAsParent();
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
</script>
</body>
</html>
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js';
const APP_CONNECTION_FIXTURE_URL = FIXTURE_URL.replace(
'menubar-contextmenu.html',
'app-connection.html',
);
test.describe('AppConnection.postMessage transferables (env=app)', () => {
test('a transferred buffer reaches the other app and back', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: APP_CONNECTION_FIXTURE_URL });
try {
await gotoTestApp(page, appName);
// The parent's window is the first one open; the child it launches
// renders the same fixture in a second iframe.
const parentFrame = page.frameLocator('iframe.window-app-iframe').first();
await parentFrame.locator('#send-transferable').click();
const log = parentFrame.locator('#log');
// The desktop relays between the two apps, so a buffer that was
// transferred rather than copied only survives if every hop keeps
// transferring it.
await expect(log.locator('[data-entry="child-received:ArrayBuffer[1,2,3,4]"]'))
.toBeVisible({ timeout: 30_000 });
await expect(log.locator('[data-entry="parent-received:ArrayBuffer[9,8,7,6]"]'))
.toBeVisible();
// Transferred, not copied: the sender no longer holds the buffer.
await expect(log.locator('[data-entry="sent:detached:true"]')).toBeVisible();
// A port survives the relay as a working channel, not a dead copy.
await expect(log.locator('[data-entry="port-message:hello over the port"]'))
.toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
});