IframeBridgeTransport
IframeBridgeTransport enables multiplayer communication between sandboxed iframes through a parent window relay. Perfect for building browser-based IDEs, code playgrounds, and any scenario where game instances run in isolated iframe contexts.
When to Use
✅ Perfect for:
- Browser-based game IDEs (like martini-kit IDE)
- Code sandboxes with live multiplayer preview
- Testing across multiple sandboxed iframe environments
- Isolating untrusted user code while enabling multiplayer
❌ Not suitable for:
- Production games (use P2P or server-based transports)
- Same-page multiplayer (use LocalTransport instead)
- Cross-domain iframes (security restrictions apply)
Architecture
┌──────────────────────────────────────────────┐
│ Parent Window (IDE/App) │
│ ┌────────────────────────────────────────┐ │
│ │ IframeBridgeRelay (Message Hub) │ │
│ └────────────────────────────────────────┘ │
│ ▲ ▼ │
│ postMessage postMessage │
│ │ │ │
│ ┌────────┴────────┐ ┌────────┴────────┐ │
│ │ <iframe> (Host) │ │ <iframe>(Client)│ │
│ │ IframeBridge │ │ IframeBridge │ │
│ │ Transport │ │ Transport │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────┘ Message Flow
- Send: iframe →
window.parent.postMessage()→ Relay - Route: Relay receives message, identifies target iframe(s)
- Deliver: Relay →
iframe.contentWindow.postMessage()→ Target iframe(s) - Receive: Target iframe’s message listener → handlers invoked
Installation
pnpm add @martini-kit/transport-iframe-bridge
# or
npm install @martini-kit/transport-iframe-bridge API Reference
IframeBridgeTransport (Iframe Side)
class IframeBridgeTransport implements Transport {
constructor(config: IframeBridgeConfig);
}
interface IframeBridgeConfig {
roomId: string; // Unique room identifier
playerId?: string; // Optional custom player ID
isHost: boolean; // Whether this instance is the host
} IframeBridgeRelay (Parent Side)
class IframeBridgeRelay {
constructor();
// Manual registration (optional)
registerIframe(
playerId: string,
roomId: string,
iframe: HTMLIFrameElement,
isHost: boolean
): void;
// Query methods
getPeers(): PeerInfo[];
getPeersInRoomById(roomId: string): PeerInfo[];
// Cleanup
destroy(): void;
}
interface PeerInfo {
playerId: string;
roomId: string;
iframe: HTMLIFrameElement;
isHost: boolean;
} Transport Methods
All standard Transport interface methods:
// Messaging
send(message: WireMessage, targetId?: string): void;
onMessage(handler: (message: WireMessage, senderId: string) => void): () => void;
// Peer lifecycle
onPeerJoin(handler: (peerId: string) => void): () => void;
onPeerLeave(handler: (peerId: string) => void): () => void;
onHostDisconnect(handler: () => void): () => void;
getPeerIds(): string[];
// Identity
getPlayerId(): string;
isHost(): boolean;
// Cleanup
disconnect(): void; Quick Start
Parent Window Setup
import { IframeBridgeRelay } from '@martini-kit/transport-iframe-bridge';
// Create relay in parent window
const relay = new IframeBridgeRelay();
// Create iframes
const hostIframe = document.createElement('iframe');
hostIframe.src = '/game.html?role=host';
document.body.appendChild(hostIframe);
const clientIframe = document.createElement('iframe');
clientIframe.src = '/game.html?role=client';
document.body.appendChild(clientIframe);
// Relay automatically discovers and coordinates iframes
// (Iframes register themselves via postMessage)
// Cleanup when done
window.addEventListener('beforeunload', () => {
relay.destroy();
}); Iframe (Game) Setup
// game.html - runs in iframe
import { IframeBridgeTransport } from '@martini-kit/transport-iframe-bridge';
import { GameRuntime } from '@martini-kit/core';
import { game } from './my-game';
// Determine role from URL
const params = new URLSearchParams(window.location.search);
const isHost = params.get('role') === 'host';
// Create transport
const transport = new IframeBridgeTransport({
roomId: 'my-game-room',
isHost
});
// Create runtime
const runtime = new GameRuntime(game, transport, {
isHost,
playerIds: [transport.getPlayerId()]
});
// Start the game
// The transport automatically registers with parent relay Complete Example
Parent Window (IDE)
<!DOCTYPE html>
<html>
<head>
<title>Multiplayer IDE</title>
</head>
<body>
<h1>Multiplayer Game IDE</h1>
<div style="display: flex; gap: 1rem;">
<div>
<h2>Host</h2>
<iframe id="host" src="/game.html?role=host" width="400" height="300"></iframe>
</div>
<div>
<h2>Client</h2>
<iframe id="client" src="/game.html?role=client" width="400" height="300"></iframe>
</div>
</div>
<script type="module">
import { IframeBridgeRelay } from '@martini-kit/transport-iframe-bridge';
// Create relay to coordinate iframes
const relay = new IframeBridgeRelay();
// Optional: Monitor peer connections
setInterval(() => {
const peers = relay.getPeers();
console.log('Connected peers:', peers.length);
peers.forEach(p => {
console.log(` - ${p.playerId} (${p.isHost ? 'host' : 'client'})`);
});
}, 2000);
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
relay.destroy();
});
</script>
</body>
</html> Game Code (Runs in Iframe)
// game.html
import { IframeBridgeTransport } from '@martini-kit/transport-iframe-bridge';
import { defineGame, GameRuntime } from '@martini-kit/core';
// Define game
const game = defineGame({
setup: () => ({
players: {}
}),
actions: {
move: {
apply(state, context, input: { x: number; y: number }) {
const player = state.players[context.targetId];
if (player) {
player.x = input.x;
player.y = input.y;
}
}
}
},
onPlayerJoin(state, playerId) {
state.players[playerId] = { x: 100, y: 100 };
},
onPlayerLeave(state, playerId) {
delete state.players[playerId];
}
});
// Get role from URL
const params = new URLSearchParams(window.location.search);
const isHost = params.get('role') === 'host';
// Create transport
const transport = new IframeBridgeTransport({
roomId: 'my-game',
isHost
});
// Wait for connection
transport.metrics?.onConnectionChange((state) => {
console.log('Connection state:', state);
if (state === 'connected') {
console.log('Connected! Peers:', transport.getPeerIds());
}
});
// Create runtime
const runtime = new GameRuntime(game, transport, {
isHost,
playerIds: [transport.getPlayerId()]
});
// Listen for state changes
runtime.onChange((state) => {
console.log('State updated:', state);
renderGame(state);
});
// Submit actions
document.addEventListener('click', (e) => {
runtime.submitAction('move', { x: e.clientX, y: e.clientY });
});
function renderGame(state) {
// Render game visuals
console.log('Players:', Object.keys(state.players).length);
} Advanced Usage
Manual Iframe Registration
If you need explicit control over registration:
// Parent window
const relay = new IframeBridgeRelay();
const hostIframe = document.getElementById('host-iframe') as HTMLIFrameElement;
const clientIframe = document.getElementById('client-iframe') as HTMLIFrameElement;
// Manually register iframes
relay.registerIframe('host-player', 'my-room', hostIframe, true);
relay.registerIframe('client-player', 'my-room', clientIframe, false); Multiple Rooms
The relay supports multiple isolated rooms:
// Parent window with multiple games
const relay = new IframeBridgeRelay();
// Room 1: Fire & Ice game
const fireHost = document.getElementById('fire-host') as HTMLIFrameElement;
const fireClient = document.getElementById('fire-client') as HTMLIFrameElement;
// Room 2: Paddle Battle game
const paddleHost = document.getElementById('paddle-host') as HTMLIFrameElement;
const paddleClient = document.getElementById('paddle-client') as HTMLIFrameElement;
// Each iframe registers with its own roomId
// Room 1 peers: roomId = 'fire-ice'
// Room 2 peers: roomId = 'paddle-battle'
// Rooms are isolated - messages don't cross over Monitoring Connections
// Parent window
const relay = new IframeBridgeRelay();
// Check peers in a specific room
setInterval(() => {
const peers = relay.getPeersInRoomById('my-game-room');
console.log(`Room 'my-game-room' has ${peers.length} peers:`);
peers.forEach(peer => {
console.log(` - ${peer.playerId} (${peer.isHost ? 'HOST' : 'client'})`);
});
}, 1000); Message Protocol
IframeBridgeTransport uses a custom message protocol via window.postMessage:
interface BridgeMessage {
type: 'BRIDGE_REGISTER' | 'BRIDGE_SEND' | 'BRIDGE_DELIVER' |
'BRIDGE_PEER_JOIN' | 'BRIDGE_PEER_LEAVE' | 'BRIDGE_HOST_DISCONNECT';
roomId: string;
playerId: string;
payload?: {
message?: WireMessage;
targetId?: string;
peerId?: string;
wasHost?: boolean;
};
} Message Types
BRIDGE_REGISTER: Iframe registers with relayBRIDGE_SEND: Iframe sends message to peer(s)BRIDGE_DELIVER: Relay delivers message to iframeBRIDGE_PEER_JOIN: Relay notifies about peer joiningBRIDGE_PEER_LEAVE: Relay notifies about peer leavingBRIDGE_HOST_DISCONNECT: Relay notifies clients that host left
Metrics
IframeBridgeTransport provides full metrics support:
const transport = new IframeBridgeTransport({ roomId: 'test', isHost: true });
// Connection state
console.log(transport.metrics.getConnectionState());
// "connecting" → "connected" when relay responds
// Listen for connection changes
transport.metrics.onConnectionChange((state) => {
if (state === 'connected') {
console.log('Connected to relay!');
}
});
// Message stats
const stats = transport.metrics.getMessageStats();
console.log('Sent:', stats.sent);
console.log('Received:', stats.received);
console.log('Errors:', stats.errors);
// Peer count
console.log('Peers:', transport.metrics.getPeerCount()); Testing
Unit Tests
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { IframeBridgeRelay } from '@martini-kit/transport-iframe-bridge';
describe('IframeBridgeRelay', () => {
let relay: IframeBridgeRelay;
beforeEach(() => {
relay = new IframeBridgeRelay();
});
afterEach(() => {
relay.destroy();
});
it('should track registered peers', () => {
const iframe1 = document.createElement('iframe');
const iframe2 = document.createElement('iframe');
relay.registerIframe('p1', 'room1', iframe1, true);
relay.registerIframe('p2', 'room1', iframe2, false);
const peers = relay.getPeersInRoomById('room1');
expect(peers).toHaveLength(2);
expect(peers.find(p => p.playerId === 'p1')?.isHost).toBe(true);
expect(peers.find(p => p.playerId === 'p2')?.isHost).toBe(false);
});
it('should isolate different rooms', () => {
const iframe1 = document.createElement('iframe');
const iframe2 = document.createElement('iframe');
relay.registerIframe('p1', 'room1', iframe1, true);
relay.registerIframe('p2', 'room2', iframe2, true);
expect(relay.getPeersInRoomById('room1')).toHaveLength(1);
expect(relay.getPeersInRoomById('room2')).toHaveLength(1);
});
}); Performance Characteristics
- Latency: ~1-5ms (postMessage overhead)
- Bandwidth: Same as network transport (messages are serialized)
- Peer Limit: Browser-dependent (typically 10-20 iframes max)
- Message Size Limit: Browser-dependent (~1MB typical)
- Connection State: “connecting” → “connected” on relay response
Security Considerations
Cross-Origin Restrictions
IframeBridgeTransport uses postMessage with targetOrigin: '*' by default. For production use, restrict to specific origins:
// In IframeBridgeRelay.ts (modify source)
iframe.contentWindow.postMessage(message, 'https://your-domain.com');
// In IframeBridgeTransport.ts (modify source)
window.parent.postMessage(bridgeMessage, 'https://your-domain.com'); Sandboxing Iframes
Use iframe sandbox attribute to restrict capabilities:
<iframe
src="/game.html"
sandbox="allow-scripts allow-same-origin"
></iframe> Recommended sandbox flags:
allow-scripts- Required for JavaScript executionallow-same-origin- Required for postMessage (use with caution)
Security trade-offs:
allow-same-originallows iframe to access parent (needed for postMessage)- Avoid
allow-same-originif iframe loads untrusted code - Use separate domains for untrusted content
Limitations
- Same-domain only (by default): Iframes must be same-origin unless CORS is configured
- Parent dependency: Requires parent window to run
IframeBridgeRelay - No persistence: Messages are not persisted
- Dev/Testing focus: Not optimized for production games (use P2P or server transports)
Debugging
Enable Logging
// In iframe
const transport = new IframeBridgeTransport({ roomId: 'test', isHost: true });
transport.metrics?.onConnectionChange((state) => {
console.log('[IframeBridge] Connection:', state);
});
transport.onPeerJoin((peerId) => {
console.log('[IframeBridge] Peer joined:', peerId);
});
transport.onMessage((msg, senderId) => {
console.log('[IframeBridge] Message from', senderId, msg);
}); Common Issues
Problem: Iframe not connecting to relay Solution: Ensure parent window has created IframeBridgeRelay before iframe loads
// ❌ Wrong - relay created after iframes load
setTimeout(() => {
const relay = new IframeBridgeRelay(); // Too late!
}, 1000);
// ✅ Correct - relay created before iframes
const relay = new IframeBridgeRelay();
const iframe = document.createElement('iframe');
iframe.src = '/game.html'; Problem: Messages not delivering Solution: Check that both iframes use the same roomId
// ❌ Wrong - different room IDs
// Host iframe
const hostTransport = new IframeBridgeTransport({ roomId: 'room-1', isHost: true });
// Client iframe
const clientTransport = new IframeBridgeTransport({ roomId: 'room-2', isHost: false });
// ✅ Correct - same room ID
const roomId = 'my-game';
const hostTransport = new IframeBridgeTransport({ roomId, isHost: true });
const clientTransport = new IframeBridgeTransport({ roomId, isHost: false }); Problem: Connection state stuck on “connecting” Solution: Check browser console for postMessage errors
Cleanup
Always destroy the relay when done:
// Parent window
const relay = new IframeBridgeRelay();
// When page unloads or component unmounts
window.addEventListener('beforeunload', () => {
relay.destroy();
});
// Or in framework cleanup (React, Vue, Svelte, etc.)
onDestroy(() => {
relay.destroy();
}); Iframes should also disconnect:
// In iframe
const transport = new IframeBridgeTransport({ roomId: 'test', isHost: true });
window.addEventListener('beforeunload', () => {
transport.disconnect();
}); See Also
- Transports Overview
- LocalTransport - For same-page multiplayer
- TrysteroTransport - For P2P production games
- Custom Transports - Build your own