diff --git a/docs/index.html b/docs/index.html
index ca71057..b36aa28 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -400,7 +400,7 @@
✈ iframe-flight
Child iframe
-
@@ -487,7 +487,7 @@ ✈ iframe-flight
-
@@ -580,6 +580,8 @@ ✈ iframe-flight
✈ iframe-flight{ 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) {
@@ -777,37 +779,46 @@ ✈ iframe-flight
{ 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')
@@ -816,10 +827,10 @@ ✈ iframe-flight
{
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 ══ */
diff --git a/src/ArrowParentEmitter.ts b/src/ArrowParentEmitter.ts
index 2bd69a0..edccc72 100644
--- a/src/ArrowParentEmitter.ts
+++ b/src/ArrowParentEmitter.ts
@@ -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);
diff --git a/src/__tests__/ArrowParentEmitter.test.ts b/src/__tests__/ArrowParentEmitter.test.ts
index d987c4a..d8037b3 100644
--- a/src/__tests__/ArrowParentEmitter.test.ts
+++ b/src/__tests__/ArrowParentEmitter.test.ts
@@ -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', () => {
@@ -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);
@@ -57,7 +64,7 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType;
- 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 }),
@@ -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();
@@ -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') }));
});
@@ -111,7 +129,7 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe, { ackTimeout: 3000 });
const postMessage = iframe.contentWindow!.postMessage as ReturnType;
- 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' });
@@ -119,7 +137,7 @@ describe('ArrowParentEmitter', () => {
expect(sentMsg).toBeDefined();
expect(sentMsg!.format).toBe('json');
- dispatchMessage({
+ dispatchFromIframe(iframe, {
type: MessageType.DATA_RECEIVED,
messageId: sentMsg!.messageId,
success: true,
@@ -139,7 +157,7 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType;
- 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);
@@ -147,13 +165,13 @@ describe('ArrowParentEmitter', () => {
const sentMsg = postMessage.mock.calls.find(([msg]) => msg.type === MessageType.DATA_TRANSFER)?.[0] as Record | 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);
@@ -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();
@@ -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);
});
@@ -185,19 +203,19 @@ describe('ArrowParentEmitter', () => {
emitter = new ArrowParentEmitter(iframe);
const postMessage = iframe.contentWindow!.postMessage as ReturnType;
- 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;
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');
});