npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@capacities/courier

v1.0.1

Published

Type-safe RPC for Web Workers over MessagePort

Downloads

306

Readme

@capacities/courier

Type-safe RPC for Web Workers over MessagePort.

Courier gives you a type-safe RPC layer between a host and one or more workers. You define the message contract once in TypeScript; emit, on and bind stay fully typed on both sides.

Built at Capacities and used in our own codebase.

Why Courier?

  • End-to-end TypeScript - define your contract once; emit, on and handler arguments/returns are checked on host and every worker.
  • Direct worker-to-worker calls - the host builds a full mesh of MessageChannels, so any participant can call any other without relaying through the main thread.
  • Production-ready protocol - initialization handshake, optional heartbeat, per-event timeouts and disconnect handling are built in.
  • Request middleware - intercept lets you gate, log, or reject incoming RPCs before they hit your handlers.
  • Chrome DevTools profiling - isProfiling records round-trip measurements with handler vs messaging breakdown. See Profiling.

With three participants, a worker can call another worker directly:

type Definition = {
  main: {
    onSaved: (id: string) => void
  }
  processing: {
    ingest: (raw: string) => { id: string; title: string; tokens: string[] }
  }
  database: {
    save: (record: { id: string; title: string; tokens: string[] }) => void
    get: (id: string) => { id: string; title: string; tokens: string[] } | null
  }
}

// Inside the processing worker - no need to bounce through main
const ipc = await courier.worker<Definition, 'processing'>('processing', self)

ipc.on('ingest', async (raw) => {
  const record = {
    id: crypto.randomUUID(),
    title: raw.split('\n')[0] ?? '',
    tokens: raw.toLowerCase().split(/\s+/),
  }

  await ipc.emit('database', 'save', record)
  await ipc.emit('main', 'onSaved', record.id)

  return record
})

Comparison

| | Courier | Comlink | threads.js | Raw postMessage | | ----------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------- | --------------------- | | Model | Named participants, shared contract | Proxy over an exposed object | Thread pool for parallel tasks | Ad-hoc messages | | API | emit(target, event, …) / on(event, handler) | Comlink.wrap(endpoint).method() | pool.queue(fn) | Custom protocol | | Typing | ✅ One Definition types every participant's emit/on | ✅ Remote<T> from wrap<T>()¹ | ✅ via spawn<T>()¹ | ❌ | | Worker-to-worker | ✅ Mesh wired by courier.host | ⚠️ Works over MessagePort; channels are DIY | ❌ Not the focus | ⚠️ DIY | | Multi-worker setup | ✅ courier.host connects all ports | ⚠️ One wrap() per endpoint | ✅ Worker pool | ⚠️ DIY | | Handshake & heartbeat | ✅ Built-in | ❌ | ❌ | ❌ | | RPC timeouts & disconnect | ✅ Built-in | ❌ | ❌ | ❌ | | Per-RPC DevTools tracks | ✅ Optional isProfiling (handler vs messaging split)² | ❌ No built-in instrumentation | ❌ No built-in instrumentation | ❌ | | Transferables / callbacks | 🔜 Planned | ✅ Comlink.transfer, Comlink.proxy | ✅ Via Comlink | ⚠️ Manual |

¹ Comlink and threads.js both provide real generic typing (Remote<T>, typed spawn). The gap vs Courier is mostly shape: Courier's single Definition schema types named participants and cross-worker routes together; Comlink/threads.js type the exposed API surface, with occasional as unknown as at the edges.

² Chrome can profile any worker natively (chrome://inspect). This row is about library-level performance.measure() tracks per RPC, not whether profiling is possible at all.

✅ Built-in · ⚠️ Possible, not provided · ❌ Not provided · 🔜 Planned

Install

npm install @capacities/courier

Quick start

Define a definition - a map of participant names to their exposed handlers:

type Definition = {
  main: {
    onResult: (value: number) => void
  }
  worker: {
    compute: (a: number, b: number) => number
  }
}

Host

import { courier } from '@capacities/courier'

const ipc = courier.host<Definition, 'main'>('main', {
  worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
})

ipc.on('onResult', (value) => {
  console.log('result:', value)
})

const sum = await ipc.emit('worker', 'compute', 2, 3)
console.log(sum) // 5

Worker

import { courier } from '@capacities/courier'

const ipc = await courier.worker<Definition, 'worker'>('worker', self)

ipc.on('compute', (a, b) => a + b)

await ipc.emit('main', 'onResult', 42)

Courier handles the handshake: the host spins up MessageChannels between all participants, transfers ports, waits for initialization, then signals readiness.

API

courier.host(self, workers, options?)

Creates the host-side IPC interface. workers maps each worker name to a factory that returns a Worker (or mock).

Returns an Courier.IPC instance synchronously. Workers are terminated when close() is called.

courier.worker(self, scope, options?)

Bootstraps a worker. Pass self (the global WorkerGlobalScope in a real worker).

Returns a Promise<Courier.IPC> that resolves once the host signals readiness.

courier.ipc({ self, ports, ready }, options)

Low-level setup when you manage MessagePorts yourself.

courier.mock(self, callback, options?)

Creates an in-memory host/worker pair for tests - no real Worker thread needed.

import { courier } from '@capacities/courier'

const mock = courier.mock<Definition, 'worker'>('worker', (ipc) => {
  ipc.on('compute', (a, b) => a + b)
})

const host = courier.host<Definition, 'main'>('main', {
  worker: () => mock,
})

await host.emit('worker', 'compute', 3, 9) // 12

IPC interface

Each side exposes:

| Method | Description | | ------------------------------ | --------------------------------------------------------------------------- | | emit(target, event, ...args) | Call a handler on another participant. Returns a Promise with the result. | | on(event, callback) | Register a handler for incoming requests. Returns an unsubscribe function. | | once(event, callback) | Same as on, but runs at most once. | | bind(handlers) | Register multiple handlers at once. | | close() | Tear down listeners and ports. | | self | This participant's name. |

Handlers may be sync or async - return values and thrown errors are propagated back to the caller.

Options

Pass partial options to override defaults from Courier.DEFAULTS (host, worker and low-level ipc each accept a slightly different subset):

courier.host(
  'main',
  { worker: () => new Worker('./worker.js') },
  {
    timeouts: {
      // (Host only) Max time to wait for every worker to finish the initialize handshake.
      initialization: 5_000,

      // Max time an `intercept` callback may run before the request is rejected.
      intercept: 5_000,

      // Max time an `emit` may take before its promise rejects.
      // Use a number for one global limit, or an object for per-target/per-event overrides.
      events: Infinity,
    },

    // Optional liveness checks. Sends periodic pings; marks a target dead after too many misses.
    heartbeat: {
      interval: 1_000, // How often to ping each target (ms).
      timeout: 500, // How long to wait for a pong before counting a miss (ms).
      threshold: 3, // Consecutive misses before `onDead` runs and the target is closed.
      isPaused: () => document.hidden, // When true, pauses probes and resets miss counters.
      targets: ['worker'], // Which peers to monitor. Defaults to all connected targets.
      onDead: (target) => console.warn(`${target} is unresponsive`),
    },

    // Middleware invoked on incoming requests before they reach your handler.
    // Call `proceed()` to continue or `reject(reason)` to fail the request.
    // Set to `null` (default) to disable.
    intercept: (event, parameters, { proceed, reject }) => {
      proceed()
    },

    // Called when a participant disconnects or is closed (including after heartbeat failure).
    onClose: (target) => console.warn(`${target} disconnected`),

    // Log internal warnings (handshake failures, port errors, heartbeat issues, etc.).
    isVerbose: false,

    // (Host only) Record `performance.measure()` entries for Chrome DevTools. See Profiling below.
    isProfiling: false,
  }
)

Worker options are the same except there is no timeouts.initialization and isProfiling / isVerbose are inherited from the host during handshake rather than set on the worker directly.

bind(handlers, { isOnce: true }) accepts a listen option to auto-unsubscribe after the first matching request.

Profiling

Courier has built-in support for the Performance API and Chrome DevTools custom tracks. Enable it on the host - the setting is forwarded to every worker during handshake:

const ipc = courier.host<Definition, 'main'>(
  'main',
  { worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) },
  { isProfiling: true }
)

Every emit then records a performance.measure() entry that shows up in the Chrome Performance panel.

Typical workflow:

  1. Set isProfiling: true in development (keep it off in production).
  2. Open Chrome DevTools → Performance.
  3. Record a session while your app runs worker RPCs.
  4. Look for the Courier track group - each bar is one typed emit, hover for the computation/communication breakdown.

Requirements

  • Browser - relies on Worker, MessageChannel and MessagePort
  • ESM - this package ships as ES modules only

Development

pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm build

License

MIT © Capacities Labs GmbH