Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 40 additions & 29 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1
<div class="px-4 py-3 bg-zinc-50 border-b border-zinc-200">
<span class="text-sm font-semibold text-zinc-800">Child iframe</span>
</div>
<iframe id="qs-frame" src="./child-simple.html" sandbox="allow-scripts"
<iframe id="qs-frame" src="about:blank" sandbox="allow-scripts"
class="block w-full h-[200px] bg-zinc-50 border-0"></iframe>
</div>
</div>
Expand Down Expand Up @@ -487,7 +487,7 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1

<!-- Chart iframe -->
<div class="bg-white border border-zinc-200 rounded-lg overflow-hidden mb-4">
<iframe id="chart-frame" src="./child-chart.html" sandbox="allow-scripts"
<iframe id="chart-frame" src="about:blank" sandbox="allow-scripts"
class="block w-full h-[460px] border-0"></iframe>
</div>

Expand Down Expand Up @@ -580,6 +580,8 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1
a.classList.toggle('text-zinc-500', !active);
a.classList.toggle('border-transparent', !active);
});
if (page === 'simple') initQsEmitter();
if (page === 'chart') initChartEmitter();
}
window.addEventListener('hashchange', route);
route();
Expand Down Expand Up @@ -766,7 +768,7 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1
q('btn-resume-alpha').addEventListener('click',()=>{ q('frame-alpha').contentWindow.postMessage({__iframeFlight:'resume'},'*'); q('btn-resume-alpha').disabled=true; appendLog('parent-log','▶ resume → child-alpha','info'); });
q('btn-resume-beta').addEventListener('click',()=>{ q('frame-beta').contentWindow.postMessage({__iframeFlight:'resume'},'*'); q('btn-resume-beta').disabled=true; appendLog('parent-log','▶ resume → child-beta','info'); });

/* ══ CHART PAGE EMITTER ══ */
/* ══ CHART PAGE — lazy init ══ */
const CHART_PRODUCTS = ['Aurora','Beacon','Catalyst','Dynamo','Ember','Forge','Helios','Iris','Jetstream','Kronos','Lumina','Meridian','Nexus','Orbit','Prism'];

function makeChartData(n) {
Expand All @@ -777,37 +779,46 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1
return tableToIPC(tableFromArrays({ product: products, revenue, growth, units }));
}

const chartRowsSlider = q('chart-rows');
const chartRowsVal = q('chart-rows-val');
chartRowsSlider.addEventListener('input', () => { chartRowsVal.textContent = chartRowsSlider.value; });

let chartEmitter = new ArrowParentEmitter(q('chart-frame'), { handshakeTimeout:10000, ackTimeout:6000, allowedOrigins:['*'] });
chartEmitter
.onStateChange((next) => {
if (next === State.READY) {
q('chart-send').disabled = false;
doChartSend();
}
})
.onError(err => { q('chart-meta').textContent = `❌ ${err.message}`; });
q('chart-rows').addEventListener('input', () => { q('chart-rows-val').textContent = q('chart-rows').value; });

let chartEmitter = null;
function initChartEmitter() {
if (chartEmitter) return;
q('chart-frame').src = './child-chart.html';
chartEmitter = new ArrowParentEmitter(q('chart-frame'), { handshakeTimeout:12000, ackTimeout:8000, allowedOrigins:['*'] });
chartEmitter
.onStateChange(next => {
if (next === State.READY) { q('chart-send').disabled = false; doChartSend(); }
})
.onError(err => { q('chart-meta').textContent = `❌ ${err.message}`; });
}

function doChartSend() {
const buf = makeChartData(parseInt(chartRowsSlider.value, 10));
const buf = makeChartData(parseInt(q('chart-rows').value, 10));
const t0 = performance.now();
chartEmitter.send(buf).then(ack => {
const rtt = Math.round(performance.now() - t0);
q('chart-meta').textContent = `${ack.rows} rows · ${ack.cols} cols · ${ack.processingTime}ms transfer · ${rtt}ms RTT`;
q('chart-meta').textContent = `${ack.rows} rows · ${ack.cols} cols · ${ack.processingTime}ms · RTT ${Math.round(performance.now()-t0)}ms`;
}).catch(err => { q('chart-meta').textContent = `❌ ${err.message}`; });
}

q('chart-send').addEventListener('click', doChartSend);

/* ══ QUICK START PAGE EMITTER ══ */
let qsEmitter = new ArrowParentEmitter(q('qs-frame'), { handshakeTimeout:8000, ackTimeout:5000, allowedOrigins:['*'] });
qsEmitter.onStateChange((next,prev) => {
setDot('qs-dot', null, next);
if (next===State.READY) { q('qs-send').disabled=false; appendLog('qs-log','✅ Ready — click send()','ok'); }
}).onError(err => appendLog('qs-log',`❌ ${err.message}`,'err'));
/* ══ QUICK START PAGE — lazy init ══ */
let qsEmitter = null;
function initQsEmitter() {
if (qsEmitter) return;
q('qs-frame').src = './child-simple.html';
qsEmitter = new ArrowParentEmitter(q('qs-frame'), { handshakeTimeout:10000, ackTimeout:6000, allowedOrigins:['*'] });
qsEmitter
.onStateChange((next, prev) => {
setDot('qs-dot', null, next);
if (next === State.READY && prev !== State.SENDING) {
q('qs-send').disabled = false;
appendLog('qs-log', '✅ Ready — click send()', 'ok');
}
})
.onError(err => appendLog('qs-log', `❌ ${err.message}`, 'err'));
}

window.addEventListener('message', e => {
if (e.data?.__iframeFlight==='log' && e.data.sourceId==='quickstart')
Expand All @@ -816,10 +827,10 @@ <h1 class="text-xl font-bold text-zinc-900 tracking-tight">✈ iframe-flight</h1

q('qs-send').addEventListener('click', () => {
const a = makeArrowBuf();
appendLog('qs-log','→ send()…');
qsEmitter.send(a.buf,{format:'auto',schema:a.schema}).then(ack => {
appendLog('qs-log',`✅ rows=${ack.rows} cols=${ack.cols} zeroCopy=${ack.isZeroCopy}`,'ok');
}).catch(err => appendLog('qs-log',`❌ ${err.message}`,'err'));
appendLog('qs-log', '→ send()…');
qsEmitter.send(a.buf, {format:'auto', schema:a.schema}).then(ack => {
appendLog('qs-log', `✅ rows=${ack.rows} cols=${ack.cols} zeroCopy=${ack.isZeroCopy}`, 'ok');
}).catch(err => appendLog('qs-log', `❌ ${err.message}`, 'err'));
});

/* ══ TABS ══ */
Expand Down
6 changes: 6 additions & 0 deletions src/ArrowParentEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export class ArrowParentEmitter {
return;
}

// Reject messages not originating from our specific iframe.
// Without this check every ArrowParentEmitter on the page would
// accept the first CHILD_READY it sees, regardless of which iframe
// sent it, causing phantom READY transitions and ACK timeouts.
if (event.source !== this.iframe.contentWindow) return;

if (type === MessageType.CHILD_READY) {
if (this.handshakeTimeout) {
clearTimeout(this.handshakeTimeout);
Expand Down
52 changes: 35 additions & 17 deletions src/__tests__/ArrowParentEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ function makeIframe(): HTMLIFrameElement {
return iframe;
}

function dispatchMessage(data: unknown, origin = '*') {
window.dispatchEvent(new MessageEvent('message', { data, origin }));
function dispatchMessage(data: unknown, origin = '*', source?: unknown) {
const init: MessageEventInit = { data, origin };
if (source !== undefined) init.source = source as MessageEventSource;
window.dispatchEvent(new MessageEvent('message', init));
}

/** Dispatch a message as if it originated from this iframe's contentWindow. */
function dispatchFromIframe(iframe: HTMLIFrameElement, data: unknown, origin = '*') {
dispatchMessage(data, origin, iframe.contentWindow);
}

describe('ArrowParentEmitter', () => {
Expand Down Expand Up @@ -46,7 +53,7 @@ describe('ArrowParentEmitter', () => {
const readyCb = vi.fn();
emitter = new ArrowParentEmitter(iframe).onReady(readyCb);

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

expect(emitter.getState()).toBe(State.READY);
expect(emitter.isReady()).toBe(true);
Expand All @@ -57,7 +64,7 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType<typeof vi.fn>;

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.PARENT_ACK }),
Expand All @@ -79,7 +86,7 @@ describe('ArrowParentEmitter', () => {
const errorCb = vi.fn();
emitter = new ArrowParentEmitter(iframe, { handshakeTimeout: 1000 }).onError(errorCb);

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
vi.advanceTimersByTime(2000);

expect(errorCb).not.toHaveBeenCalled();
Expand All @@ -89,15 +96,26 @@ describe('ArrowParentEmitter', () => {
const readyCb = vi.fn();
emitter = new ArrowParentEmitter(iframe, { allowedOrigins: ['https://trusted.com'] }).onReady(readyCb);

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION }, 'https://evil.com');
dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION }, 'https://evil.com', iframe.contentWindow);
expect(readyCb).not.toHaveBeenCalled();
});

it('ignores CHILD_READY from a different iframe', () => {
const readyCb = vi.fn();
emitter = new ArrowParentEmitter(iframe).onReady(readyCb);

const otherIframe = makeIframe();
dispatchFromIframe(otherIframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

expect(readyCb).not.toHaveBeenCalled();
expect(emitter.getState()).toBe(State.CONNECTING);
});

it('fires error on protocol version mismatch', () => {
const errorCb = vi.fn();
emitter = new ArrowParentEmitter(iframe).onError(errorCb);

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: '99.0.0', messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: '99.0.0', messageId: 'x', timestamp: Date.now(), source: 'child' });

expect(errorCb).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('version mismatch') }));
});
Expand All @@ -111,15 +129,15 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe, { ackTimeout: 3000 });
const postMessage = iframe.contentWindow!.postMessage as ReturnType<typeof vi.fn>;

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

const sendPromise = emitter.sendJSON({ hello: 'world' });

const sentMsg = postMessage.mock.calls.find(([msg]) => msg.type === MessageType.DATA_TRANSFER)?.[0] as Record<string, unknown> | undefined;
expect(sentMsg).toBeDefined();
expect(sentMsg!.format).toBe('json');

dispatchMessage({
dispatchFromIframe(iframe, {
type: MessageType.DATA_RECEIVED,
messageId: sentMsg!.messageId,
success: true,
Expand All @@ -139,21 +157,21 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType<typeof vi.fn>;

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

const buf = new Uint8Array([1, 2, 3]);
const sendPromise = emitter.sendArrowCopy(buf);

const sentMsg = postMessage.mock.calls.find(([msg]) => msg.type === MessageType.DATA_TRANSFER)?.[0] as Record<string, unknown> | undefined;
expect(sentMsg!.format).toBe('arrow-copy');

dispatchMessage({ type: MessageType.DATA_RECEIVED, messageId: sentMsg!.messageId, success: true, format: 'arrow-copy', rows: 0, cols: 0, processingTime: 1, isZeroCopy: false });
dispatchFromIframe(iframe, { type: MessageType.DATA_RECEIVED, messageId: sentMsg!.messageId, success: true, format: 'arrow-copy', rows: 0, cols: 0, processingTime: 1, isZeroCopy: false });
await sendPromise;
});

it('ACK timeout rejects the send promise', async () => {
emitter = new ArrowParentEmitter(iframe, { ackTimeout: 500 });
dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

const sendPromise = emitter.sendJSON({ data: 'x' });
vi.advanceTimersByTime(600);
Expand All @@ -163,7 +181,7 @@ describe('ArrowParentEmitter', () => {

it('close rejects pending ACKs', async () => {
emitter = new ArrowParentEmitter(iframe);
dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

const sendPromise = emitter.sendJSON({ data: 'x' });
emitter.close();
Expand All @@ -176,7 +194,7 @@ describe('ArrowParentEmitter', () => {
const cb = vi.fn();
emitter = new ArrowParentEmitter(iframe).onStateChange(cb);

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

expect(cb).toHaveBeenCalledWith(State.READY, State.CONNECTING);
});
Expand All @@ -185,19 +203,19 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType<typeof vi.fn>;

dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

const sendPromise = emitter.send([{ id: 1 }]);
const sentMsg = postMessage.mock.calls.find(([msg]) => msg.type === MessageType.DATA_TRANSFER)?.[0] as Record<string, unknown>;
expect(sentMsg.format).toBe('json');

dispatchMessage({ type: MessageType.DATA_RECEIVED, messageId: sentMsg.messageId, success: true, format: 'json', rows: 1, cols: 1, processingTime: 1, isZeroCopy: false });
dispatchFromIframe(iframe, { type: MessageType.DATA_RECEIVED, messageId: sentMsg.messageId, success: true, format: 'json', rows: 1, cols: 1, processingTime: 1, isZeroCopy: false });
await sendPromise;
});

it('send() throws on unknown format', async () => {
emitter = new ArrowParentEmitter(iframe);
dispatchMessage({ type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });
dispatchFromIframe(iframe, { type: MessageType.CHILD_READY, protocolVersion: PROTOCOL_VERSION, messageId: 'x', timestamp: Date.now(), source: 'child' });

await expect(emitter.send({}, { format: 'xml' as never })).rejects.toThrow('Unknown format');
});
Expand Down
Loading