Skip to content

Commit 9d76ad4

Browse files
authored
Add ready handshake to framecast (#44)
1 parent dcaa9b4 commit 9d76ad4

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

.changeset/add-ready-handshake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ciolabs/framecast': minor
3+
---
4+
5+
Add `signalReady()` and `waitForReady()` methods for built-in iframe ready handshake support. This removes the need for consumers to build their own ready handshake pattern when using srcdoc iframes.

packages/framecast/src/index.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,138 @@ describe('Framecast', () => {
473473
});
474474
});
475475

476+
describe('ready handshake', () => {
477+
it('signalReady registers function handler and broadcasts', () => {
478+
framecast.signalReady();
479+
480+
// Should broadcast the ready message
481+
const calls = mockTargetWindow.postMessage.mock.calls;
482+
const readyBroadcast = calls.find(call => {
483+
const message = superjson.parse(call[0]) as any;
484+
return message.type === 'broadcast' && message.data?.type === '__framecast_ready';
485+
});
486+
487+
expect(readyBroadcast).toBeDefined();
488+
});
489+
490+
it('signalReady responds to __framecast_ready function calls', async () => {
491+
framecast.signalReady();
492+
493+
// Simulate a ready check function call
494+
simulateMessage('function:__framecast_ready', { id: 'ready-check-1', args: [] });
495+
496+
// Should respond with functionResult
497+
await vi.waitFor(() => {
498+
const calls = mockTargetWindow.postMessage.mock.calls;
499+
const resultCall = calls.find(call => {
500+
const message = superjson.parse(call[0]) as any;
501+
return message.type === 'functionResult' && message.id === 'ready-check-1' && message.result === true;
502+
});
503+
expect(resultCall).toBeDefined();
504+
});
505+
});
506+
507+
it('waitForReady resolves when it receives a ready broadcast', async () => {
508+
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 1000 });
509+
510+
// Simulate the ready broadcast from the iframe
511+
simulateMessage('broadcast', { data: { type: '__framecast_ready' } });
512+
513+
await expect(readyPromise).resolves.toBeUndefined();
514+
});
515+
516+
it('waitForReady resolves when __framecast_ready function call succeeds', async () => {
517+
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 1000 });
518+
519+
// Get the function call that was sent (poll)
520+
await vi.waitFor(() => {
521+
const calls = mockTargetWindow.postMessage.mock.calls;
522+
const readyCall = calls.find(call => {
523+
const message = superjson.parse(call[0]) as any;
524+
return message.type === 'function:__framecast_ready';
525+
});
526+
expect(readyCall).toBeDefined();
527+
});
528+
529+
// Get the call ID and simulate a successful response
530+
const calls = mockTargetWindow.postMessage.mock.calls;
531+
const readyCall = calls.find(call => {
532+
const message = superjson.parse(call[0]) as any;
533+
return message.type === 'function:__framecast_ready';
534+
})!;
535+
const sentMessage = superjson.parse(readyCall[0]) as any;
536+
537+
simulateMessage('functionResult', { id: sentMessage.id, result: true });
538+
539+
await expect(readyPromise).resolves.toBeUndefined();
540+
});
541+
542+
it('waitForReady times out if no ready signal', async () => {
543+
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 100 });
544+
545+
await expect(readyPromise).rejects.toThrow('waitForReady timed out after 100ms');
546+
});
547+
548+
it('waitForReady does not timeout when timeout is 0', async () => {
549+
const readyPromise = framecast.waitForReady({ interval: 10, timeout: 0 });
550+
551+
// Simulate ready after a short delay
552+
setTimeout(() => {
553+
simulateMessage('broadcast', { data: { type: '__framecast_ready' } });
554+
}, 50);
555+
556+
await expect(readyPromise).resolves.toBeUndefined();
557+
});
558+
559+
it('full handshake: signalReady + waitForReady', async () => {
560+
// Create a second framecast pair simulating parent <-> iframe
561+
const iframeMessageHandlers = new Map<string, Function>();
562+
const mockIframeWindow: MockWindow = {
563+
postMessage: vi.fn(),
564+
addEventListener: vi.fn((type: string, handler: Function) => {
565+
iframeMessageHandlers.set(type, handler);
566+
}),
567+
removeEventListener: vi.fn(),
568+
setTimeout: global.setTimeout,
569+
clearTimeout: global.clearTimeout,
570+
};
571+
572+
// Parent framecast targets iframe window
573+
const parentFramecast = new Framecast(mockIframeWindow as any, {
574+
self: mockSelfWindow as any,
575+
functionTimeoutMs: 1000,
576+
});
577+
578+
// Iframe framecast targets parent (mockSelfWindow)
579+
const iframeFramecast = new Framecast(mockSelfWindow as any, {
580+
self: mockIframeWindow as any,
581+
functionTimeoutMs: 1000,
582+
});
583+
584+
// Wire up message forwarding: when parent posts to iframe, deliver it
585+
mockIframeWindow.postMessage.mockImplementation((data: string, _origin: string) => {
586+
const handler = iframeMessageHandlers.get('message');
587+
handler?.({ data, origin: '*' });
588+
});
589+
590+
// Wire up message forwarding: when iframe posts to parent, deliver it
591+
mockSelfWindow.postMessage.mockImplementation((data: string, _origin: string) => {
592+
const handler = messageHandlers.get('message');
593+
handler?.({ data, origin: '*' });
594+
});
595+
596+
// Parent waits for ready
597+
const readyPromise = parentFramecast.waitForReady({ interval: 10, timeout: 1000 });
598+
599+
// Iframe signals ready after a short delay
600+
setTimeout(() => {
601+
iframeFramecast.signalReady();
602+
}, 50);
603+
604+
await expect(readyPromise).resolves.toBeUndefined();
605+
});
606+
});
607+
476608
describe('state management', () => {
477609
it('creates state atoms with initial values', () => {
478610
const initialValue = { count: 0 };

packages/framecast/src/index.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,86 @@ export class Framecast {
216216
};
217217
}
218218

219+
/**
220+
* Signals that this side of the framecast is ready to receive messages.
221+
* Call this on the iframe side after setting up all listeners.
222+
*
223+
* Registers a `__framecast_ready` function handler so the parent can
224+
* poll for readiness, and proactively broadcasts a ready message.
225+
*/
226+
signalReady(): void {
227+
this.on('function:__framecast_ready', async () => {
228+
return true;
229+
});
230+
231+
this.broadcast({ type: '__framecast_ready' });
232+
}
233+
234+
/**
235+
* Waits for the other side of the framecast to signal readiness.
236+
* Call this on the parent side before sending messages to the iframe.
237+
*
238+
* Polls by calling the `__framecast_ready` function at a regular interval
239+
* and also listens for a proactive ready broadcast from the iframe.
240+
* Resolves when either succeeds.
241+
*
242+
* @param options.interval Polling interval in ms (default: 50)
243+
* @param options.timeout Timeout in ms (default: 10000). Set to 0 to wait indefinitely.
244+
*/
245+
waitForReady(options?: { interval?: number; timeout?: number }): Promise<void> {
246+
const interval = options?.interval ?? 50;
247+
const timeout = options?.timeout ?? 10_000;
248+
249+
return new Promise<void>((resolve, reject) => {
250+
let resolved = false;
251+
const off = this.off.bind(this);
252+
253+
const poll = () => {
254+
if (resolved) return;
255+
this.call('__framecast_ready')
256+
.then(() => {
257+
if (!resolved) {
258+
cleanup();
259+
resolve();
260+
}
261+
})
262+
.catch(() => {
263+
// Expected when the iframe hasn't set up its listener yet.
264+
// The poll will retry on the next interval.
265+
});
266+
};
267+
268+
const broadcastListener = (message: any) => {
269+
if (resolved) return;
270+
if (message && typeof message === 'object' && message.type === '__framecast_ready') {
271+
cleanup();
272+
resolve();
273+
}
274+
};
275+
276+
// Start polling and listening
277+
this.on('broadcast', broadcastListener);
278+
poll();
279+
const pollTimer = setInterval(poll, interval);
280+
const timeoutTimer =
281+
timeout > 0
282+
? setTimeout(() => {
283+
if (!resolved) {
284+
cleanup();
285+
reject(new Error(`waitForReady timed out after ${timeout}ms`));
286+
}
287+
}, timeout)
288+
: undefined;
289+
290+
function cleanup() {
291+
resolved = true;
292+
clearInterval(pollTimer);
293+
if (timeoutTimer) clearTimeout(timeoutTimer);
294+
off('broadcast', broadcastListener);
295+
}
296+
});
297+
}
298+
219299
/**
220300
* Evaluates the given function in the context of the target window
221301
* and returns the result.

0 commit comments

Comments
 (0)