Type-safe bidirectional communication library for isolated JavaScript execution contexts.
tschannel provides a robust, type-safe messaging system for communication between isolated JavaScript environments. Built with TypeScript, it offers full type safety for request/response patterns with a clean, intuitive API.
Working with iframes, service workers, Chrome extensions, and other isolated JavaScript contexts often involves dealing with cumbersome addEventListener patterns and untyped message passing. tschannel solves this by providing:
- Full Type Safety: TypeScript-first design with complete type inference for messages and responses
- Bidirectional Communication: Both sides can send requests and handle responses
- Transport Agnostic: Core logic separated from transport layer via
IChannelinterface - Flexible Message Direction: Define unidirectional (
main→worker,worker→main) or bidirectional messages - Namespace-based Organization: Group related messages into logical namespaces
- Middleware Support: Intercept and transform messages at any stage
- Promise-based API: Modern async/await friendly interface
- Iframes and parent windows (via
postMessage) - Web Workers and main threads (in progress)
- Service Workers and clients (in progress)
- Chrome Extensions (background/content scripts) (in progress)
- Electron (main/renderer processes) (in progress)
- Any custom transport (via
IChannelimplementation)
# Using pnpm
pnpm add @tschannel/core @tschannel/iframe-channel
# Using npm
npm install @tschannel/core @tschannel/iframe-channel
# Using yarn
yarn add @tschannel/core @tschannel/iframe-channel- @tschannel/core - Core library with Bridge, NamespaceBuilder, and types
- @tschannel/iframe-channel - Channel implementation for iframe communication
- @tschannel/pubsub-channel - In-memory channel for testing and same-context communication
Create a namespace with typed messages:
import { NamespaceBuilder } from '@tschannel/core';
type TUser = {
id: string;
name: string;
email: string;
};
// Define message namespace with different directions
// Generic format: .message<TRequest, TResponse>()('messageName')
// - TRequest: Type of data sent with the message
// - TResponse: Type of data returned in response
const myNamespace = new NamespaceBuilder('myApp')
// Main → Worker messages (parent sends to child)
.mainToWorkerMessage<string, string>()('greet')
.mainToWorkerMessage<{ userId: string }, TUser>()('getUserData')
// Worker → Main messages (child sends to parent)
.workerToMainMessage<string, void>()('notify')
// Bidirectional messages (either side can send)
.bidirectionalMessage<string, string>()('echo')
.build();
export const messages = myNamespace.message;
export const send = myNamespace.send;import { Bridge } from '@tschannel/core';
import { IframeChannel } from '@tschannel/iframe-channel';
import { myNamespace, messages, send } from './namespace';
// Get iframe element
const iframe = document.getElementById('myFrame') as HTMLIFrameElement;
// Create channel
const channel = new IframeChannel({
side: 'main',
iframe: iframe,
targetOrigin: 'https://child-domain.com', // Specify for security
});
// Create bridge
const bridge = new Bridge(myNamespace, channel);
// Setup message handlers
bridge.listen(messages.notify, async (message: string) => {
console.log('Notification from iframe:', message);
});
bridge.listen(messages.echo, async (text: string) => {
return `Parent echoes: ${text}`;
});
// Send messages to iframe
const greeting = await bridge.dispatch(send.greet('Hello from parent!'));
console.log(greeting); // Response from iframe
const user = await bridge.dispatch(send.getUserData({ userId: '123' }));
console.log(user); // { id: '123', name: 'John', email: 'john@example.com' }import { Bridge } from '@tschannel/core';
import { IframeChannel } from '@tschannel/iframe-channel';
import { myNamespace, messages, send } from './namespace';
// Create channel (automatically uses window.parent)
const channel = new IframeChannel({
side: 'worker',
targetOrigin: 'https://parent-domain.com',
});
// Create bridge
const bridge = new Bridge(myNamespace, channel);
// Setup message handlers
bridge.listen(messages.greet, async (greeting: string) => {
console.log('Greeting from parent:', greeting);
return 'Hello from iframe!';
});
bridge.listen(messages.getUserData, async ({ userId }: { userId: string }) => {
// Fetch user data
return {
id: userId,
name: 'John',
email: 'john@example.com',
};
});
bridge.listen(messages.echo, async (text: string) => {
return `Iframe echoes: ${text}`;
});
// Send notification to parent
await bridge.dispatch(send.notify('Iframe is ready!'));The NamespaceBuilder creates a type-safe schema for all messages in your communication:
const namespace = new NamespaceBuilder('namespace-name')
.mainToWorkerMessage<RequestType, ResponseType>()('messageName')
.workerToMainMessage<RequestType, ResponseType>()('anotherMessage')
.bidirectionalMessage<RequestType, ResponseType>()('twoWayMessage')
.build();Message Directions:
mainToWorkerMessage- Can only be sent from main side to worker sideworkerToMainMessage- Can only be sent from worker side to main sidebidirectionalMessage- Can be sent from either side
Channels implement the transport layer and must implement the IChannel interface:
interface IChannel<TSide extends 'main' | 'worker'> {
readonly side: TSide;
initialize(): Promise<void> | void;
send(message: TInternalMessage): void;
onMessage(handler: (message: TInternalMessage) => void): void;
isReady(): boolean;
destroy(): void;
}Available Channels:
IframeChannel- For iframe communication via postMessagePubSubChannel- For in-memory same-context communication (testing)
The Bridge class manages message sending, receiving, and routing:
const bridge = new Bridge(namespace, channel, {
timeout: 10000, // Request timeout in ms (default: 10000)
retries: 3, // Retry attempts (default: 3)
});
// Send messages
const response = await bridge.dispatch(send.messageName(payload));
// Handle incoming messages
bridge.listen(messages.messageName, async (payload) => {
// Process and return response
return responseData;
});
// Add middleware
bridge.use({
onBeforeSend: (namespace, messageName, data) => {
console.log('Sending:', messageName, data);
return data;
},
onAfterReceive: (namespace, messageName, response) => {
console.log('Received:', messageName, response);
return response;
},
});Intercept and transform messages at different lifecycle stages:
bridge.use({
// Before sending a request
onBeforeSend: (namespace, messageName, request) => {
console.log(`Sending ${messageName}:`, request);
return request; // Can transform data
},
// After receiving a response
onAfterReceive: (namespace, messageName, response) => {
console.log(`Received response for ${messageName}:`, response);
return response; // Can transform data
},
// Before handling an incoming request
onBeforeHandle: (namespace, messageName, request) => {
console.log(`Handling ${messageName}:`, request);
return request; // Can transform data
},
// After processing and before sending response
onAfterHandle: (namespace, messageName, response) => {
console.log(`Responding to ${messageName}:`, response);
return response; // Can transform data
},
// On any error
onError: (namespace, messageName, error) => {
console.error(`Error in ${messageName}:`, error);
},
});Implement custom serialization for complex data types:
import { TSerializer } from '@tschannel/core';
const customSerializer: TSerializer = {
serialize: (message) => {
// Custom serialization logic
return JSON.stringify(message);
},
deserialize: (data) => {
// Custom deserialization logic
return JSON.parse(data as string);
},
};
const channel = new IframeChannel({
side: 'main',
iframe: iframeElement,
serializer: customSerializer,
});Implement the IChannel interface for any transport mechanism:
import { IChannel, TInternalMessage } from '@tschannel/core';
class WebSocketChannel implements IChannel<'main'> {
readonly side = 'main';
private socket: WebSocket;
private handler?: (message: TInternalMessage) => void;
private ready = false;
constructor(private url: string) {
this.socket = new WebSocket(url);
}
async initialize(): Promise<void> {
return new Promise((resolve) => {
this.socket.onopen = () => {
this.ready = true;
resolve();
};
this.socket.onmessage = (event) => {
if (this.handler) {
const message = JSON.parse(event.data);
this.handler(message);
}
};
});
}
send(message: TInternalMessage): void {
this.socket.send(JSON.stringify(message));
}
onMessage(handler: (message: TInternalMessage) => void): void {
this.handler = handler;
}
isReady(): boolean {
return this.ready;
}
destroy(): void {
this.socket.close();
this.handler = undefined;
this.ready = false;
}
}// shared/namespace.ts
export const extensionNamespace = new NamespaceBuilder('myExtension')
.mainToWorkerMessage<{ url: string }, { title: string }>()('getPageInfo')
.workerToMainMessage<{ text: string }, void>()('showNotification')
.build();
// background.ts (main)
const channel = new ChromeRuntimeChannel({ side: 'main' });
const bridge = new Bridge(extensionNamespace, channel);
bridge.listen(messages.showNotification, async ({ text }) => {
chrome.notifications.create({ message: text });
});
// content-script.ts (worker)
const channel = new ChromeRuntimeChannel({ side: 'worker' });
const bridge = new Bridge(extensionNamespace, channel);
bridge.listen(messages.getPageInfo, async ({ url }) => {
return { title: document.title };
});
await bridge.dispatch(send.showNotification({ text: 'Page loaded!' }));// Implement ServiceWorkerChannel
class ServiceWorkerChannel implements IChannel<'main'> {
// ... implementation
}
// In main thread
const channel = new ServiceWorkerChannel({ side: 'main' });
const bridge = new Bridge(namespace, channel);
// In service worker
const channel = new ServiceWorkerChannel({ side: 'worker' });
const bridge = new Bridge(namespace, channel);Builder for creating type-safe message namespaces.
Methods:
mainToWorkerMessage<TReq, TRes>()('name')- Define main→worker messageworkerToMainMessage<TReq, TRes>()('name')- Define worker→main messagebidirectionalMessage<TReq, TRes>()('name')- Define bidirectional messagebuild()- Build the namespace
Main class for managing bidirectional communication.
Constructor:
new Bridge(namespace, channel, config?)Methods:
dispatch(message)- Send a message and wait for responselisten(messageType, handler)- Register handler for incoming messagesuse(middleware)- Add middlewareisReady()- Check if bridge is readydestroy()- Clean up resources
Interface for implementing custom transport channels.
Required Methods:
initialize()- Setup channelsend(message)- Send message through transportonMessage(handler)- Subscribe to incoming messagesisReady()- Check ready statedestroy()- Clean up
Channel implementation for iframe communication.
Constructor:
new IframeChannel({
side: 'main' | 'worker',
iframe?: HTMLIFrameElement, // Required for 'main' side
targetOrigin?: string, // Security: specify allowed origin
expectedOrigin?: string, // Security: validate incoming origin
serializer?: TSerializer,
})In-memory channel for same-context communication.
Constructor:
new PubSubChannel({
side: 'main' | 'worker',
eventBus?: EventTarget, // Optional custom EventTarget
serializer?: TSerializer,
})tschannel is built with TypeScript and provides full type safety:
// Types are automatically inferred
const namespace = new NamespaceBuilder('app')
.mainToWorkerMessage<{ id: number }, { name: string }>()('getUser')
.build();
const bridge = new Bridge(namespace, channel);
// ✅ Type-safe: payload is { id: number }, response is { name: string }
const user = await bridge.dispatch(send.getUser({ id: 123 }));
console.log(user.name); // string
// ❌ TypeScript error: wrong payload type
await bridge.dispatch(send.getUser({ id: 'wrong' }));
// ✅ Type-safe handler
bridge.listen(messages.getUser, async (payload) => {
// payload is { id: number }
return { name: 'John' }; // Must return { name: string }
});
// ❌ TypeScript error: wrong return type
bridge.listen(messages.getUser, async (payload) => {
return { id: 123 }; // Type error!
});tschannel includes comprehensive tests using Vitest:
# Run all tests
pnpm test
# Run tests for specific package
pnpm --filter @tschannel/core test
pnpm --filter @tschannel/iframe-channel test
# Watch mode
pnpm test:watchtschannel/
├── packages/
│ ├── core/ # Core library
│ ├── iframe-channel/ # Iframe channel implementation
│ └── pubsub-channel/ # PubSub channel implementation
├── apps/
│ └── dev-app/ # Demo application (SolidJS)
├── configs/ # Shared configurations
└── .changeset/ # Changesets for versioning
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Build specific package
pnpm --filter @tschannel/core build
# Run demo app
pnpm --filter dev-app dev
# Run linter
pnpm lint
# Type check
pnpm typecheck
# Format code
pnpm format- Fork the repository
- Create a feature branch
- Make your changes
- Run tests and linters:
pnpm test && pnpm lint && pnpm typecheck - Create a changeset:
pnpm changeset - Submit a pull request
For detailed contribution guidelines, see CONTRIBUTING.md.
- Node.js: >= 18
- TypeScript: >= 5.0
- Module format: ESM only
- Target: ES5 (transpiled output)
tschannel works in all modern browsers that support:
- ES6 Promises
- EventTarget API
- postMessage API (for iframe communication)
MIT License - see LICENSE file for details.
Developed by Vladimir Sannikov
Built with ❤️ using TypeScript