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__',
    debug: import.meta.env.DEV
  },
)

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 uses one worker adapter in service/adapter.ts.

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

export type WorkerEndpoint = Pick<Worker, 'postMessage' | 'addEventListener' | 'removeEventListener'>

export class WorkerAdapter implements Adapter {
  constructor(
    private worker: WorkerEndpoint,
    public name?: string,
  ) {}

  sendMessage: SendMessage = (message, transfer) => {
    this.worker.postMessage(message, transfer)
  }

  onMessage: OnMessage = (callback) => {
    const handler = (event: MessageEvent) => callback(event.data)
    this.worker.addEventListener('message', handler)
    return () => this.worker.removeEventListener('message', handler)
  }
}

The same adapter works on both sides because it only depends on the worker-compatible message endpoint. The worker entry passes self; the main page creates a Worker and passes that instance.

Create the worker entry

The worker entry lives in worker.ts:

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

const counter = provideCounter(new WorkerAdapter(self, 'web-worker-provider'))

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 { WorkerAdapter } from './service/adapter'
import { injectCounter } from './service/counter'
import './style.css'

void (async () => {
  const worker = new Worker(new URL('./worker.ts', import.meta.url), {
    type: 'module',
  })
  const counter = injectCounter(new WorkerAdapter(worker, 'web-worker-injector'))

  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 a module Worker
  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 WorkerAdapter
  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 WorkerAdapter(self, 'web-worker-provider'))
  • the main page injects through new WorkerAdapter(worker, 'web-worker-injector')
  • the adapter forwards messages correctly on both sides
  • 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