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:
Installation
Install comctx in your project:
pnpm add comctxnpm install comctxyarn add comctxbun add comctxCreate the shared service
The real example keeps the service definition in service/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
Counterservice - exports both
provideCounterandinjectCounter
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.
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:
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:
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:
main.tscreates a moduleWorkerinjectCounter()returns a proxy object- button clicks call
counter.increment()orcounter.decrement() - Comctx turns those calls into messages
- the worker receives them through
WorkerAdapter - the real
Counterinstance updates its internal value onChangecallbacks update both the worker log and the main-page UI
Finish
A working setup should satisfy all of these:
main.tsandworker.tsuse the samenamespace- 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
onChangereach 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: