Quick Start
A calculator where math operations run in a Web Worker off the main thread. The main thread calls worker commands with full type safety, events broadcast results, and private commands stay local.
Step 1: Define Command Schemas
Section titled “Step 1: Define Command Schemas”First, define the schemas for your commands. These provide both TypeScript types and runtime validation. Use defineIds to create type-safe command ID constants:
import * as v from 'valibot'import type { CommandSchemaMap, InferCommandSchemaMapType } from '@coralstack/cmd-ipc'import { defineIds } from '@coralstack/cmd-ipc'
export const CalcCommandSchema = { 'calc.add': { description: 'Adds two numbers together', request: v.object({ a: v.number(), b: v.number() }), response: v.object({ result: v.number() }), }, 'calc.multiply': { description: 'Multiplies two numbers together', request: v.object({ a: v.number(), b: v.number() }), response: v.object({ result: v.number() }), }, // Private command — underscore prefix means it won't be shared with other processes '_main.log': { description: 'Internal logging command (private to main process)', request: v.object({ level: v.picklist(['debug', 'info', 'warn', 'error']), message: v.string(), }), },} as const satisfies CommandSchemaMap
export const CommandIDs = defineIds(CalcCommandSchema)
type Commands = InferCommandSchemaMapType<typeof CalcCommandSchema>export type CommandRequest<K extends keyof Commands> = Commands[K]['request']export type CommandResponse<K extends keyof Commands> = Promise<Commands[K]['response']>Step 2: Define Event Schemas
Section titled “Step 2: Define Event Schemas”Events are fire-and-forget broadcasts to all connected processes.
import * as v from 'valibot'import type { EventSchemaMap } from '@coralstack/cmd-ipc'import { defineIds } from '@coralstack/cmd-ipc'
export const CalcEventSchema = { 'worker.ready': v.object({ workerId: v.string(), commandCount: v.number(), }), 'computation.complete': v.object({ commandId: v.string(), result: v.number(), durationMs: v.number(), }),} as const satisfies EventSchemaMap
export const EventIDs = defineIds(CalcEventSchema)Step 3: Create Command Handlers
Section titled “Step 3: Create Command Handlers”Use the @Command decorator. The decorator only takes the command ID — descriptions live in the schema:
import { Command } from '@coralstack/cmd-ipc'import type { CommandRequest, CommandResponse } from '../ipc/command-schema'import { CommandIDs } from '../ipc/command-schema'
export class CalcService { @Command(CommandIDs.CALC_ADD) async add({ a, b }: CommandRequest<typeof CommandIDs.CALC_ADD>): CommandResponse<typeof CommandIDs.CALC_ADD> { return { result: a + b } }
@Command(CommandIDs.CALC_MULTIPLY) async multiply({ a, b }: CommandRequest<typeof CommandIDs.CALC_MULTIPLY>): CommandResponse<typeof CommandIDs.CALC_MULTIPLY> { return { result: a * b } }}Step 4: Set Up the Worker
Section titled “Step 4: Set Up the Worker”Create a Web Worker that registers the command handlers. The worker waits for the main thread to send a MessagePort via postMessage:
import 'reflect-metadata'import { CommandRegistry, MessagePortChannel, registerCommands } from '@coralstack/cmd-ipc'import { CalcCommandSchema, CommandIDs } from '../ipc/command-schema'import { CalcEventSchema, EventIDs } from '../ipc/event-schema'import { CalcService } from '../services/calc-service'
const registry = new CommandRegistry({ id: 'calc-worker', schemas: { commands: CalcCommandSchema, events: CalcEventSchema },})
registerCommands([new CalcService()], registry)
self.onmessage = (event: MessageEvent) => { if (event.data.type === 'init' && event.ports[0]) { const port = event.ports[0] const channel = new MessagePortChannel('main', port) registry.registerChannel(channel) channel.start()
registry.emitEvent(EventIDs.WORKER_READY, { workerId: 'calc-worker', commandCount: 2, }) }}Step 5: Connect from Main Thread
Section titled “Step 5: Connect from Main Thread”Create a MessageChannel and pass one port to the worker, keeping the other for the main thread’s registry:
import 'reflect-metadata'import { CommandRegistry, MessagePortChannel } from '@coralstack/cmd-ipc'import { CalcCommandSchema, CommandIDs } from './ipc/command-schema'import { CalcEventSchema, EventIDs } from './ipc/event-schema'
async function main() { const worker = new Worker( new URL('./workers/calc.worker.ts', import.meta.url), { type: 'module' } )
const channel = new MessageChannel()
const registry = new CommandRegistry({ id: 'main', schemas: { commands: CalcCommandSchema, events: CalcEventSchema }, })
registry.registerCommand(CommandIDs._MAIN_LOG, async ({ level, message }) => { console[level](`[Main] ${message}`) })
registry.onEvent(EventIDs.WORKER_READY, ({ workerId, commandCount }) => { console.log(`Worker ${workerId} ready with ${commandCount} commands`) })
const workerChannel = new MessagePortChannel('calc-worker', channel.port1) await registry.registerChannel(workerChannel)
worker.postMessage({ type: 'init' }, [channel.port2])
await new Promise<void>((resolve) => { registry.onEvent(EventIDs.WORKER_READY, () => resolve()) })
const sum = await registry.executeCommand(CommandIDs.CALC_ADD, { a: 5, b: 3 }) console.log('5 + 3 =', sum.result) // 8
const product = await registry.executeCommand(CommandIDs.CALC_MULTIPLY, { a: 4, b: 7 }) console.log('4 × 7 =', product.result) // 28
await registry.executeCommand(CommandIDs._MAIN_LOG, { level: 'info', message: 'Calculations complete!', })}
main()How It Works
Section titled “How It Works”- Schema Definition: Valibot schemas define the contract for each command and event.
- Handler Registration:
@Commanddecorators bind methods to command IDs;registerCommands([...], registry)installs them. - Channel Setup:
MessageChannel+MessagePortChannelprovides the pipe between the main thread and the worker. - Event Broadcasting: Events propagate to all connected registries.
- Command Execution:
executeCommandroutes to the right handler automatically.
Key Points
Section titled “Key Points”- Schemas define the contract — the wire format is byte-identical across TypeScript and Rust.
- Handlers are registered where the code should run (worker).
- Private commands (prefixed with
_) stay local to the current process. - Events broadcast to all connected processes without expecting a response.