Offload functions to worker threads with shared memory primitives for Node.js.
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

TypeScript 99.9%
Other 0.1%
59 1 8

Clone this repository

https://tangled.org/divy.zone/moroutine https://tangled.org/did:plc:l3rouwludahu3ui3bt66mfvj/moroutine
git@tangled.org:divy.zone/moroutine git@tangled.org:did:plc:l3rouwludahu3ui3bt66mfvj/moroutine

For self-hosted knots, clone URLs may differ based on your setup.

Download tar.gz
README.md

moroutine#

Offload functions to worker threads with shared memory primitives for Node.js.

Quick Start#

// is-prime.ts
import { mo } from 'moroutine';

export const isPrime = mo(import.meta, (n: number): boolean => {
  if (n < 2) return false;
  for (let i = 2; i * i <= n; i++) {
    if (n % i === 0) return false;
  }
  return true;
});
// main.ts
import { workers } from 'moroutine';
import { isPrime } from './is-prime.ts';

using run = workers(4);
const results = await run([isPrime(999_999_937), isPrime(1_000_000_007)]);
console.log(results); // [true, true]

Define a function with mo() in its own module, then import and run it on a worker pool. Moroutine modules must be side-effect free — workers import them to find the registered functions.

Core API#

mo(import.meta, fn)#

Wraps a function so it runs on a worker thread. The function must be defined at module scope (not dynamically).

// math.ts
import { mo } from 'moroutine';

export const add = mo(import.meta, (a: number, b: number): number => {
  return a + b;
});

workers(size)#

Creates a pool of worker threads. Returns a Runner that dispatches tasks with round-robin scheduling. Disposable via using or [Symbol.dispose]().

import { workers } from 'moroutine';
import { add } from './math.ts';

{
  using run = workers(2);

  const result = await run(add(3, 4));       // single task
  const [a, b] = await run([add(1, 2), add(3, 4)]); // batch
}

Dedicated Workers#

Awaiting a task directly (without a pool) runs it on a dedicated worker thread, one per moroutine function.

const result = await add(3, 4); // runs on a dedicated worker for `add`

Shared Memory#

Descriptors and shared()#

Shared-memory types are created with descriptor functions or the shared() allocator.

import { shared, int32, bool, mutex, string, bytes } from 'moroutine';

Primitives#

const counter = int32();          // standalone Int32
const flag = bool();              // standalone Bool
const big = int64();              // standalone Int64 (bigint)

Atomics#

Atomic variants use Atomics.* for thread-safe operations without a lock.

const counter = int32atomic();
counter.add(1);                   // atomic increment, returns previous value
counter.load();                   // atomic read

Full atomic operations: load, store, add, sub, and, or, xor, exchange, compareExchange.

Structs#

Plain objects in shared() create structs backed by a single SharedArrayBuffer.

const point = shared({ x: int32, y: int32 });

point.load();                     // { x: 0, y: 0 }
point.store({ x: 10, y: 20 });
point.fields.x.store(10);        // direct field access

Structs nest:

const rect = shared({
  pos: { x: int32, y: int32 },
  size: { w: int32, h: int32 },
});

Tuples#

Arrays in shared() create fixed-length tuples.

const pair = shared([int32, bool]);
pair.load();                      // [0, false]
pair.store([42, true]);
pair.get(0).store(99);

Bytes and Strings#

const buf = bytes(32);            // fixed 32-byte buffer
buf.store(new Uint8Array(32));    // exact length required
buf.load();                       // Readonly<Uint8Array> view
buf.view[0] = 0xff;              // direct mutable access

const name = string(64);          // UTF-8, max 64 bytes
name.store('hello');
name.load();                      // 'hello'

Value Shorthand#

Primitive values in schemas infer their type.

shared(0)                         // Int32 initialized to 0
shared(true)                      // Bool initialized to true
shared(0n)                        // Int64 initialized to 0n
shared({ x: 10, y: 20 })         // struct with Int32 fields

Locks#

Mutex#

const mu = mutex();

using guard = await mu.lock();
// exclusive access
// auto-unlocks when guard is disposed

// or manually:
await mu.lock();
mu.unlock();

RwLock#

const rw = rwlock();

using guard = await rw.readLock();   // multiple readers OK
using guard = await rw.writeLock();  // exclusive access

Using with Workers#

Shared-memory types pass through postMessage automatically. They're reconstructed on the worker side with the same shared backing memory.

// update-position.ts
import { mo } from 'moroutine';
import type { Mutex, SharedStruct, Int32 } from 'moroutine';

type Position = SharedStruct<{ x: Int32; y: Int32 }>;

export const updatePosition = mo(
  import.meta,
  async (mu: Mutex, pos: Position, dx: number, dy: number): Promise<void> => {
    using guard = await mu.lock();
    const current = pos.load();
    pos.store({ x: current.x + dx, y: current.y + dy });
  },
);
// main.ts
import { workers, shared, int32, mutex } from 'moroutine';
import { updatePosition } from './update-position.ts';

const mu = mutex();
const pos = shared({ x: int32, y: int32 });

{
  using run = workers(4);
  await run([
    updatePosition(mu, pos, 1, 0),
    updatePosition(mu, pos, 0, 1),
  ]);
}

console.log(pos.load()); // { x: 1, y: 1 }

Transfers#

Use transfer() for zero-copy movement of ArrayBuffer, TypedArray, MessagePort, or streams.

import { transfer } from 'moroutine';

const buf = new ArrayBuffer(1024);
await run(processData(transfer(buf)));
// buf is now detached (zero-length) — ownership moved to worker

Return values from workers are auto-transferred when possible.

Examples#

All examples require Node v24+ and can be run directly, e.g. node examples/primes/main.ts.