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__'
},
)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 keeps both adapters in the same file: service/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)
}
}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
Workerinstance
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:
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:
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:
main.tscreates anInjectAdapterwithnew URL('./worker.ts', import.meta.url)injectCounter()returns a proxy object- button clicks call
counter.increment()orcounter.decrement() - Comctx turns those calls into messages
- the worker receives them through
ProvideAdapter - 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 ProvideAdapter()) - the main page injects through
new InjectAdapter(new URL('./worker.ts', import.meta.url)) - both adapters forward messages correctly
- 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: