Skip to content

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.

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

First, define the schemas for your commands. These provide both TypeScript types and runtime validation. Use defineIds to create type-safe command ID constants:

src/ipc/command-schema.ts
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 signatures
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']>

Events are fire-and-forget broadcasts to all connected processes. Define them similarly to commands:

src/ipc/event-schema.ts
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)

Implement the command handlers using the @Command decorator. The decorator only takes the command ID—descriptions are defined in the schema:

src/services/calc-service.ts
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 }
}
}

Create a Web Worker that registers the command handlers. The worker waits for the main thread to send a MessagePort via postMessage:

src/workers/calc.worker.ts
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 schemas
const registry = new CommandRegistry({
id: 'calc-worker',
schemas: {
commands: CalcCommandSchema,
events: CalcEventSchema,
},
})
// Register command handlers
registerCommands([new CalcService()], registry)
// Wait for MessagePort from main thread
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()
// Emit event to notify main thread we're ready
registry.emitEvent(EventIDs.WORKER_READY, {
workerId: 'calc-worker',
commandCount: 2,
})
}
}

Create a MessageChannel and pass one port to the worker, keeping the other for the main thread’s registry:

src/main.ts
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()
  1. Schema Definition: Valibot schemas define the contract for each command and event
  2. ID Generation: defineIds creates type-safe constants from schema keys
  3. Handler Registration: @Command decorators bind methods to command IDs
  4. Channel Setup: MessageChannel provides the communication pipe between processes
  5. Event Broadcasting: Events propagate to all connected registries
  6. Command Execution: executeCommand routes to the right handler automatically
  • 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