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.

feat: preserve error details across worker boundary

Errors thrown in moroutines now transfer via structured clone
instead of extracting message strings. This preserves stack
traces pointing to the actual source, built-in subclass identity
(TypeError, RangeError, etc.), and cause chains. Applies to
regular dispatch, pool dispatch, and streaming tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+91 -13
+7
.changeset/error-transfer.md
··· 1 + --- 2 + 'moroutine': minor 3 + --- 4 + 5 + Preserve error details across worker boundary 6 + 7 + Errors thrown in moroutines now transfer with `message`, `stack`, `cause`, and built-in subclass identity (`TypeError`, `RangeError`, etc.) preserved via structured clone. Previously only the message string was kept.
+5 -6
src/execute.ts
··· 16 16 const streamPortStack: MessagePort[][] = []; 17 17 18 18 export function setupWorker(worker: Worker): void { 19 - worker.on('message', (msg: { callId: number; value?: unknown; error?: string }) => { 19 + worker.on('message', (msg: { callId: number; value?: unknown; error?: Error }) => { 20 20 const call = pending.get(msg.callId); 21 21 if (!call) return; 22 22 pending.delete(msg.callId); 23 23 if (msg.error !== undefined) { 24 - call.reject(new Error(msg.error)); 24 + call.reject(msg.error); 25 25 } else { 26 26 call.resolve(deserializeArg(msg.value)); 27 27 } ··· 74 74 if (!cancelled) port.postMessage({ done: true }); 75 75 } catch (err) { 76 76 if (!cancelled) { 77 - const message = err instanceof Error ? err.message : String(err); 78 - port.postMessage({ done: true, error: message }); 77 + port.postMessage({ done: true, error: err instanceof Error ? err : new Error(String(err)) }); 79 78 } 80 79 } 81 80 try { ··· 174 173 let paused = false; 175 174 let waiting: (() => void) | null = null; 176 175 177 - port1.on('message', (msg: { value?: unknown; done?: boolean; error?: string }) => { 176 + port1.on('message', (msg: { value?: unknown; done?: boolean; error?: Error }) => { 178 177 if (msg.error) { 179 - error = new Error(msg.error); 178 + error = msg.error; 180 179 done = true; 181 180 port1.close(); 182 181 resolveDone!();
+4 -6
src/worker-entry.ts
··· 20 20 21 21 const HIGH_WATER = 16; 22 22 23 - port.on('message', (msg: { value?: unknown; done?: boolean; error?: string }) => { 23 + port.on('message', (msg: { value?: unknown; done?: boolean; error?: Error }) => { 24 24 if (msg.error) { 25 - error = new Error(msg.error); 25 + error = msg.error; 26 26 done = true; 27 27 if (waiting) { 28 28 waiting(); ··· 160 160 if (!cancelled) port.postMessage({ done: true }); 161 161 } catch (err) { 162 162 if (!cancelled) { 163 - const message = err instanceof Error ? err.message : String(err); 164 - port.postMessage({ done: true, error: message }); 163 + port.postMessage({ done: true, error: err instanceof Error ? err : new Error(String(err)) }); 165 164 } 166 165 } 167 166 try { ··· 177 176 collectTransferables(value, transferList); 178 177 parentPort!.postMessage({ callId, value: returnValue }, transferList); 179 178 } catch (err) { 180 - const message = err instanceof Error ? err.message : String(err); 181 - parentPort!.postMessage({ callId: msg.callId!, error: message }); 179 + parentPort!.postMessage({ callId: msg.callId!, error: err instanceof Error ? err : new Error(String(err)) }); 182 180 } 183 181 });
+60 -1
test/error.test.ts
··· 1 1 import { describe, it } from 'node:test'; 2 2 import assert from 'node:assert/strict'; 3 3 import { workers } from 'moroutine'; 4 - import { fail } from './fixtures/math.ts'; 4 + import { fail, failType, failCause } from './fixtures/math.ts'; 5 + import { failAfterType } from './fixtures/stream-gen.ts'; 5 6 6 7 describe('error handling', () => { 7 8 it('rejects with error from dedicated worker', async () => { ··· 15 16 await assert.rejects(() => run(fail('pool boom')), { 16 17 message: 'pool boom', 17 18 }); 19 + }); 20 + 21 + it('preserves stack trace pointing to worker source', async () => { 22 + await using run = workers(1); 23 + try { 24 + await run(fail('stack check')); 25 + assert.fail('should have thrown'); 26 + } catch (err) { 27 + assert.ok(err instanceof Error); 28 + assert.match(err.stack!, /fixtures\/math\.ts/); 29 + } 30 + }); 31 + 32 + it('preserves built-in error subclass identity', async () => { 33 + await using run = workers(1); 34 + try { 35 + await run(failType('type check')); 36 + assert.fail('should have thrown'); 37 + } catch (err) { 38 + assert.ok(err instanceof TypeError); 39 + assert.equal(err.message, 'type check'); 40 + } 41 + }); 42 + 43 + it('preserves error cause', async () => { 44 + await using run = workers(1); 45 + try { 46 + await run(failCause('with cause')); 47 + assert.fail('should have thrown'); 48 + } catch (err) { 49 + assert.ok(err instanceof Error); 50 + assert.ok(err.cause instanceof RangeError); 51 + assert.equal((err.cause as Error).message, 'root cause'); 52 + } 53 + }); 54 + 55 + it('preserves error details on dedicated worker', async () => { 56 + try { 57 + await failType('dedicated type'); 58 + assert.fail('should have thrown'); 59 + } catch (err) { 60 + assert.ok(err instanceof TypeError); 61 + assert.match((err as Error).stack!, /fixtures\/math\.ts/); 62 + } 63 + }); 64 + 65 + it('preserves error details on streaming task', async () => { 66 + await using run = workers(1); 67 + try { 68 + for await (const _ of run(failAfterType(2))) { 69 + // consume yields until error 70 + } 71 + assert.fail('should have thrown'); 72 + } catch (err) { 73 + assert.ok(err instanceof TypeError); 74 + assert.equal((err as Error).message, 'stream type error'); 75 + assert.match((err as Error).stack!, /fixtures\/stream-gen\.ts/); 76 + } 18 77 }); 19 78 });
+8
test/fixtures/math.ts
··· 5 5 export const fail = mo(import.meta, (msg: string) => { 6 6 throw new Error(msg); 7 7 }); 8 + 9 + export const failType = mo(import.meta, (msg: string) => { 10 + throw new TypeError(msg); 11 + }); 12 + 13 + export const failCause = mo(import.meta, (msg: string) => { 14 + throw new Error(msg, { cause: new RangeError('root cause') }); 15 + });
+7
test/fixtures/stream-gen.ts
··· 12 12 } 13 13 throw new Error('intentional error'); 14 14 }); 15 + 16 + export const failAfterType = mo(import.meta, async function* (n: number) { 17 + for (let i = 0; i < n; i++) { 18 + yield i; 19 + } 20 + throw new TypeError('stream type error'); 21 + });