Comlink performs great for Web Worker communication, which is its main design goal. However, when you want to use it in other environments, you'll find the adaptation work extremely difficult.
I developed Comctx to solve this problem. It maintains Comlink's simple API while making environment adaptation easy through an adapter pattern.
What Problem Does It Solve
Comlink is primarily designed for Web Worker. While theoretically it can be adapted to other environments, the actual implementation is very difficult.
For example, in browser extensions, Content Script and Background Script can only communicate through the chrome.runtime
API. If you want to use Comlink, you need to somehow wrap this API into MessagePort format, which is complex and error-prone. You have to rewrite Comlink's adapter code issue(438).
Similar problems exist in Electron and certain restricted environments. Every time you encounter a new environment, you need to do a complex set of adaptation work.
Comctx's approach is simple:
- Don't restrict specific communication methods
- Provide an adapter interface that lets you tell it how to send and receive messages
- Handle all the RPC logic for you
This way, the same service code can be reused across various environments.
Where You Can Use It
1. Browser Extension Development
// Shared storage service
class StorageService {
async get(key) {
const result = await chrome.storage.local.get(key)
return result[key]
}
async set(key, value) {
await chrome.storage.local.set({ [key]: value })
}
async onChanged(callback) {
chrome.storage.onChanged.addListener(callback)
}
}
const [provideStorage, injectStorage] = defineProxy(() => new StorageService())
// Background Script (service provider)
class BackgroundAdapter {
sendMessage = (message) => chrome.runtime.sendMessage(message)
onMessage = (callback) => chrome.runtime.onMessage.addListener(callback)
}
provideStorage(new BackgroundAdapter())
// Content Script (service consumer)
const storage = injectStorage(new BackgroundAdapter())
await storage.set('userPrefs', { theme: 'dark' })
const prefs = await storage.get('userPrefs')
2. Web Worker Computation Tasks
// Image processing service
class ImageProcessor {
async processImage(imageData, filters) {
// Complex image processing algorithm
return processedData
}
async onProgress(callback) {
// Progress callback
}
}
const [provideProcessor, injectProcessor] = defineProxy(() => new ImageProcessor())
// Worker side
class WorkerAdapter {
sendMessage = (message) => postMessage(message)
onMessage = (callback) => addEventListener('message', event => callback(event.data))
}
provideProcessor(new WorkerAdapter())
// Main thread
const processor = injectProcessor(new WorkerAdapter())
// Progress callback
processor.onProgress(progress => updateUI(progress))
// Processing result
const result = await processor.processImage(imageData, filters)
3. iframe Cross-Domain Communication
// Payment service (running in secure iframe)
class PaymentService {
async processPayment(amount, cardInfo) {
// Secure payment processing logic
return paymentResult
}
async validateCard(cardNumber) {
return isValid
}
}
// Payment service inside iframe
class IframeAdapter {
sendMessage = (message) => parent.postMessage(message, '*')
onMessage = (callback) => addEventListener('message', event => callback(event.data))
}
provide(new IframeAdapter())
// Main page calling payment service
const payment = inject(new IframeAdapter())
const result = await payment.processPayment(100, cardInfo)
4. Electron Inter-Process Communication
// File operation service (providing file system access in main process)
class FileService {
async readFile(path) {
return fs.readFileSync(path, 'utf8')
}
async writeFile(path, content) {
fs.writeFileSync(path, content)
}
async watchFile(path, callback) {
fs.watchFile(path, callback)
}
}
// Main process
class MainProcessAdapter {
sendMessage = (message) => webContents.send('ipc-message', message)
onMessage = (callback) => ipcMain.on('ipc-message', (_, data) => callback(data))
}
provide(new MainProcessAdapter())
// Renderer process
class RendererAdapter {
sendMessage = (message) => ipcRenderer.send('ipc-message', message)
onMessage = (callback) => ipcRenderer.on('ipc-message', (_, data) => callback(data))
}
const fileService = inject(new RendererAdapter())
const content = await fileService.readFile('/path/to/file')
5. Micro-Frontend Architecture
// Shared user authentication service
class AuthService {
async login(credentials) { /* ... */ }
async logout() { /* ... */ }
async getCurrentUser() { /* ... */ }
async onAuthStateChange(callback) { /* ... */ }
}
// Main app provides authentication service
class MicroFrontendAdapter {
sendMessage = (message) => window.postMessage({ ...message, source: 'main-app' }, '*')
onMessage = (callback) => {
window.addEventListener('message', event => {
if (event.data.source === 'micro-app') callback(event.data)
})
}
}
// All micro-frontend apps can use the same authentication service
const auth = inject(new MicroFrontendAdapter())
const user = await auth.getCurrentUser()
Through these examples, you can see that regardless of the underlying communication mechanism, your business code remains the same. This is the benefit of the adapter pattern.
Improvements Over Comlink
Besides solving environment limitation issues, Comctx has made optimizations in other areas:
Smaller Bundle Size
Thanks to the minimalist design of the core code, Comctx is only 1KB+, while Comlink is 4KB+
Automatic Transferable Objects Handling
When you transfer large objects like ArrayBuffer and ImageData, Comctx can automatically extract them for transfer. Comlink requires manual handling.
Better Connection Management
Comctx has built-in heartbeat detection that can automatically wait for remote services to be ready. This solves the common timing issues in Comlink โ sometimes when you call a method, the other side isn't ready to receive messages.
Type Safety
TypeScript support is as good as Comlink, with all the type inference you expect.
Design Philosophy Differences
Comlink and Comctx have fundamentally different design approaches:
Comlink's Approach
// Directly wrap the entire worker
const api = Comlink.wrap(worker)
await api.someMethod()
This approach is straightforward, but the problem is it hardcodes the communication mechanism. The Worker object must support MessagePort, and it won't work in other environments.
Comctx's Approach
// First define the service
const [provide, inject] = defineProxy(() => new Service())
// Server side: publish service
provide(adapter)
// Client side: use service
const service = inject(adapter)
The key here is the adapter
. It tells Comctx how to send and receive messages without restricting the specific method. This achieves separation between communication methods and business logic.
Additionally, Comctx has a heartbeat detection mechanism to ensure the connection is alive. This solves the common connection timing issues in Comlink.
Summary
The motivation behind developing Comctx is simple: make RPC communication environment-agnostic.
If you're just using Web Worker, Comlink is sufficient. But if your project involves browser extensions, iframes, Electron, or other custom communication scenarios, Comctx would be a better choice.
It not only solves environment adaptation problems but also improves bundle size, performance, and reliability. Most importantly, the API design maintains Comlink's simplicity with almost zero learning curve.
Related Resources
- ๐ GitHub Repository - Complete source code and examples
- ๐ฆ NPM Package - Install and use immediately
- ๐ Online Documentation - Detailed usage guide
Top comments (0)