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'); });