molvqingtai

molvqingtai

JavaScript Developer and TypeScript Gymnast.

Comctx: A better JS context communication library than Comlink

Comlink performs excellently in Web Worker communication, which is its primary design goal. However, when you want to use it in other environments, you will find that adapting it is exceptionally difficult.

I developed Comctx to solve this problem. It retains the concise API of Comlink but simplifies environment adaptation through the adapter pattern.

What Problems Does It Specifically Solve#

Comlink is primarily designed for Web Worker, and while it can theoretically adapt to other environments, it is very challenging in practice.

For example, in browser extensions, communication between the Content Script and Background Script can only occur through the chrome.runtime API. If you want to use Comlink, you have to find a way to wrap this API into the form of a MessagePort, which is a complex process that requires rewriting the adapter code of Comlink issue(438).

Similar issues exist in Electron and certain restricted environments. Each time you encounter a new environment, you have to perform a complex adaptation process.

The idea behind Comctx is simple:

  • No specific communication method is restricted
  • Provides an adapter interface that lets you specify how to send and receive messages
  • Handles the remaining RPC logic for you

This way, the same service code can be reused across various environments.

See Where It Can Be Used#

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 Computational Tasks#

// Image processing service
class ImageProcessor {
  async processImage(imageData, filters) {
    // Complex image processing algorithm
    return processedData
  }
  
  async onProgress(callback) {
    // Processing 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 a 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 calls 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 the 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 application 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)
    })
  }
}

// Each micro-frontend application can use the same authentication service
const auth = inject(new MicroFrontendAdapter())
const user = await auth.getCurrentUser()

These examples show that regardless of the underlying communication mechanism used, your business code remains the same. This is the benefit of the adapter pattern.

In addition to solving environment limitations, Comctx also makes several optimizations in other areas:

Smaller package size Thanks to the extremely minimalist design of the core code, Comctx is only 1KB+, while Comlink is 4KB.

Automatic handling of Transferable Objects When you transfer large objects like ArrayBuffer and ImageData, Comctx can automatically extract them for transfer. Comlink requires you to handle this manually.

Better connection management Comctx has built-in heartbeat detection, which can automatically wait for the remote service to be ready. This solves the common timing issues in Comlink—sometimes when you call a method, the other side is not ready to receive messages.

Type safety TypeScript support is as good as that of Comlink, with all necessary type inference available.

Differences in Design Philosophy#

The design philosophies of Comlink and Comctx are fundamentally different:

Comlink's approach

// Directly wrap the entire worker
const api = Comlink.wrap(worker)
await api.someMethod()

This method is straightforward, but the problem is that it hardcodes the communication mechanism. The Worker object must support MessagePort, which does not work in other environments.

Comctx's approach

// First define the service
const [provide, inject] = defineProxy(() => new Service())

// Server side: publish the service
provide(adapter)

// Client side: use the 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 used. This achieves separation of communication methods and business logic.

Additionally, Comctx has a heartbeat detection mechanism to ensure the connection is alive. This resolves common connection timing issues in Comlink.

Conclusion#

The original intention of developing Comctx is simple: to make RPC communication no longer limited by the environment.

If you are only using it in a Web Worker, Comlink is sufficient. However, if your project involves browser extensions, iframes, Electron, or other custom communication scenarios, Comctx will be a better choice.

It not only solves the environment adaptation problem but also improves package size, performance, and reliability. Most importantly, the API design maintains the simplicity of Comlink, with almost zero learning cost.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.