Quick Start
This guide walks you through building a simple application with commands running in a Web Worker, events for cross-process communication, and private commands that stay local to a process.
What We’ll Build
Section titled “What We’ll Build”A calculator application where:
- Math operations run in a Web Worker (off the main thread)
- The main thread calls worker commands with full type safety
- Events broadcast computation results across processes
- Private commands handle internal logging without exposing to workers
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
// Type-safe command IDs (e.g., CommandIDs.CALC_ADD = 'calc.add')export const CommandIDs = defineIds(CalcCommandSchema)
// Type helpers for handler signaturestype 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. Define them similarly to commands:
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”Implement the command handlers using the @Command decorator. The decorator only takes the command ID—descriptions are defined 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'
// Create registry with schemasconst registry = new CommandRegistry({ id: 'calc-worker', schemas: { commands: CalcCommandSchema, events: CalcEventSchema, },})
// Register command handlersregisterCommands([new CalcService()], registry)
// Wait for MessagePort from main threadself.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()
// Emit event to notify main thread we're ready 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, registerCommands } from '@coralstack/cmd-ipc'import { CalcCommandSchema, CommandIDs } from './ipc/command-schema'import { CalcEventSchema, EventIDs } from './ipc/event-schema'
async function main() { // Create the worker const worker = new Worker( new URL('./workers/calc.worker.ts', import.meta.url), { type: 'module' } )
// Create MessageChannel - port1 for main, port2 for worker const channel = new MessageChannel()
// Create registry const registry = new CommandRegistry({ id: 'main', schemas: { commands: CalcCommandSchema, events: CalcEventSchema, }, })
// Register private command (underscore prefix = local only) registry.registerCommand(CommandIDs._MAIN_LOG, async ({ level, message }) => { console[level](`[Main] ${message}`) })
// Listen for events from the worker registry.onEvent(EventIDs.WORKER_READY, ({ workerId, commandCount }) => { console.log(`Worker ${workerId} ready with ${commandCount} commands`) })
registry.onEvent(EventIDs.COMPUTATION_COMPLETE, ({ commandId, result, durationMs }) => { console.log(`${commandId} completed: ${result} (${durationMs}ms)`) })
// Create channel to worker using our port const workerChannel = new MessagePortChannel('calc-worker', channel.port1) await registry.registerChannel(workerChannel)
// Send the other port to the worker worker.postMessage({ type: 'init' }, [channel.port2])
// Wait for worker to be ready await new Promise<void>((resolve) => { registry.onEvent(EventIDs.WORKER_READY, () => resolve()) })
// Execute commands with full type safety! 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
// Private command only runs locally 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
- ID Generation:
defineIdscreates type-safe constants from schema keys - Handler Registration:
@Commanddecorators bind methods to command IDs - Channel Setup: MessageChannel provides the communication pipe between processes
- Event Broadcasting: Events propagate to all connected registries
- Command Execution:
executeCommandroutes to the right handler automatically
Key Points
Section titled “Key Points”- Schemas are shared between processes for type safety
- Handlers are registered where the code should run (worker in this case)
- MessageChannel creates paired ports—one for each end of the connection
- Private commands (prefixed with
_) stay local to the current process - Events broadcast to all connected processes without expecting a response