Comctx

Web Worker

Build a Comctx connection between the main page and a Web Worker using the real file layout from this repository.

Example setup

This example is a browser project that uses a Web Worker.

It includes:

  • a main page entry
  • a worker entry
  • a shared service definition
  • worker-specific adapters

What this page covers

This page follows the real examples/web-worker source structure in this repository. The main page is the Injector, and the worker is the Provider.

Source

File layout

The real example is organized like this:

main.ts
worker.ts
adapter.ts
counter.ts
createElement.ts
style.css
vite.env.d.ts

Installation

Install comctx in your project:

pnpm add comctx
npm install comctx
yarn add comctx
bun add comctx

Create the shared service

The real example keeps the service definition in service/counter.ts:

counter.ts
counter.ts
import { defineProxy } from 'comctx'

export class Counter {
  private value: number
  constructor(initialValue: number = 0) {
    this.value = initialValue
  }
  async getValue() {
    return this.value
  }
  async onChange(callback: (value: number) => void) {
    let oldValue = this.value
    setInterval(() => {
      const newValue = this.value
      if (oldValue !== newValue) {
        callback(newValue)
        oldValue = newValue
      }
    })
  }
  async increment() {
    return ++this.value
  }
  async decrement() {
    return --this.value
  }
}

export const [provideCounter, injectCounter] = defineProxy(
  (initialValue: number = 0) => new Counter(initialValue),
  {
    namespace: '__web-worker-example__'
  },
)

This file does two things:

  • defines the real Counter service
  • exports both provideCounter and injectCounter

Real example detail

Unlike the simplified docs version, the real example supports an initialValue argument and exposes onChange for callback-based updates.

Create the adapters

The real example keeps both adapters in the same file: service/adapter.ts.

adapter.ts
adapter.ts
import { Adapter, SendMessage, OnMessage } from 'comctx'

declare const self: DedicatedWorkerGlobalScope

export class ProvideAdapter implements Adapter {
  sendMessage: SendMessage = (message) => {
    self.postMessage(message)
  }
  onMessage: OnMessage = (callback) => {
    const handler = (event: MessageEvent) => callback(event.data)
    self.addEventListener('message', handler)
    return () => self.removeEventListener('message', handler)
  }
}
adapter.ts
import { Adapter, SendMessage, OnMessage } from 'comctx'

export class InjectAdapter implements Adapter {
  worker: Worker
  constructor(path: string | URL) {
    this.worker = new Worker(path, { type: 'module' })
  }
  sendMessage: SendMessage = (message) => {
    this.worker.postMessage(message)
  }
  onMessage: OnMessage = (callback) => {
    const handler = (event: MessageEvent) => callback(event.data)
    this.worker.addEventListener('message', handler)
    return () => this.worker.removeEventListener('message', handler)
  }
}

The worker-side adapter:

  • sends messages with self.postMessage
  • listens on self

The main-page adapter:

  • creates the worker instance internally
  • sends messages through this.worker.postMessage
  • listens on the Worker instance

Real example detail

In this repository, the Injector adapter owns worker creation. That is different from the earlier abstract docs version where worker creation lived directly in main.ts.

Create the worker entry

The worker entry lives in worker.ts:

worker.ts
worker.ts
import { ProvideAdapter } from './service/adapter'
import { provideCounter } from './service/counter'

const counter = provideCounter(new ProvideAdapter())

counter.onChange((value) => {
  console.log('WebWorker Value:', value)
})

This is the Provider side. It owns the real implementation and handles incoming remote calls inside the worker.

Create the main page entry

The main page entry lives in main.ts:

main.ts
main.ts
import { name, description } from '../package.json'

import createElement from './utils/createElement'

import { InjectAdapter } from './service/adapter'
import { injectCounter } from './service/counter'
import './style.css'

void (async () => {
  const counter = injectCounter(
    new InjectAdapter(new URL('./worker.ts', import.meta.url)),
  )

  const initValue = await counter.getValue()

  document.querySelector<HTMLDivElement>('#app')!.appendChild(
    createElement(`
      <div>
        <h1>${name}</h1>
        <p>${description}</p>
        <div class="card">
          <button id="decrement" type="button">-</button>
          <div id="value">${initValue}</div>
          <button id="increment" type="button">+</button>
        </div>
        <div class="card">
          <h4 id="worker-value">WebWorker Value: ${initValue} </h4>
        </div>
      </div>
    `),
  )

  document
    .querySelector<HTMLButtonElement>('#decrement')!
    .addEventListener('click', async () => {
      await counter.decrement()
    })

  document
    .querySelector<HTMLButtonElement>('#increment')!
    .addEventListener('click', async () => {
      await counter.increment()
    })

  counter.onChange((value) => {
    document.querySelector<HTMLDivElement>('#value')!.textContent =
      value.toString()
    document.querySelector<HTMLDivElement>('#worker-value')!.textContent =
      `WebWorker Value: ${value}`
  })
})().catch(console.error)

The main page does not receive the real object.

It receives a proxy that:

  • turns method calls into messages
  • sends them through the worker adapter
  • waits for the worker to execute the real method
  • resolves the result asynchronously
  • supports callback-based updates through onChange

Understand the real flow

Once everything is connected, the real example works like this:

  1. main.ts creates an InjectAdapter with new URL('./worker.ts', import.meta.url)
  2. injectCounter() returns a proxy object
  3. button clicks call counter.increment() or counter.decrement()
  4. Comctx turns those calls into messages
  5. the worker receives them through ProvideAdapter
  6. the real Counter instance updates its internal value
  7. onChange callbacks update both the worker log and the main-page UI

Finish

A working setup should satisfy all of these:

  • main.ts and worker.ts use the same namespace
  • the worker entry initializes provideCounter(new ProvideAdapter())
  • the main page injects through new InjectAdapter(new URL('./worker.ts', import.meta.url))
  • both adapters forward messages correctly
  • callback updates from onChange reach the main page

Why this example matters

This example shows that Comctx is not limited to simple request/response calls. The real repository example also demonstrates callback-based updates across the worker boundary.

Next steps

After this page, continue with:

On this page