@dormer/node-runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@dormer/node-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Dormer Node.js side: host channel, router, streams, lifecycle plumbing",
5
+ "type": "module",
6
+ "license": "Apache-2.0 OR MIT",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test test/*.test.ts"
12
+ },
13
+ "dependencies": {
14
+ "@dormer/ipc": "0.1.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/veerareddyvishal144/dormer.git",
22
+ "directory": "runtime/node-runtime"
23
+ }
24
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,392 @@
1
+ // Frame channels: the host channel (inherited descriptor, HELLO/WELCOME, heartbeat, shutdown) and
2
+ // the shared request/response/stream machinery that worker links reuse over MessagePorts.
3
+ import { EventEmitter } from "node:events";
4
+ import fs from "node:fs";
5
+ import net from "node:net";
6
+ import {
7
+ FrameDecoder, FrameType, PROTO_VERSION, encodeFrame, msgpackFrame, binFrame, makeFrame, creditFrame, parseCredit,
8
+ unpack, type Frame, type Hello, type Welcome, type Req, type Res, type Err, type Evt, type StreamEnd,
9
+ } from "@dormer/ipc";
10
+
11
+ export interface IncomingRequest { route: number; id: number; m: string; a: unknown[]; signal: AbortSignal }
12
+ export interface IncomingEvent { route: number; ch: string; data: unknown }
13
+
14
+ /** Stable wire shape of a DormerError (`JSON.stringify(err)`), see `@dormer/types` `DormerErrorJSON`. */
15
+ export interface DormerErrorJSON { name: "DormerError"; code: string; message: string; data?: unknown; retry: boolean }
16
+
17
+ export class DormerError extends Error {
18
+ code: string; data?: unknown; retry: boolean;
19
+ constructor(code: string, message: string, data?: unknown, retry = false) {
20
+ super(message); this.name = "DormerError"; this.code = code; this.data = data; this.retry = retry;
21
+ }
22
+ toJSON(): DormerErrorJSON {
23
+ const j: DormerErrorJSON = { name: "DormerError", code: this.code, message: this.message, retry: this.retry };
24
+ if (this.data !== undefined) j.data = this.data;
25
+ return j;
26
+ }
27
+ /** Rebuild from `toJSON()` output or an ERR payload (`{c, msg, data, retry}`). */
28
+ static from(v: unknown): DormerError {
29
+ if (v instanceof DormerError) return v;
30
+ const o = (v ?? {}) as Partial<DormerErrorJSON> & Partial<Err>;
31
+ return new DormerError(o.code ?? o.c ?? "EINTERNAL", o.message ?? o.msg ?? "error", o.data, !!o.retry);
32
+ }
33
+ }
34
+
35
+ /** Minimal sink an `OutStream` needs: send frames and forget the stream when it ends. */
36
+ export interface FrameSink { send(frame: Frame): void; streams: Map<number, OutStream> }
37
+
38
+ /** Outgoing stream with credit-based flow control (PROTOCOL.md §5). */
39
+ export class OutStream {
40
+ private credit = 0;
41
+ private waiters: Array<() => void> = [];
42
+ private ended = false;
43
+ private aborted = false;
44
+ private readonly ch: FrameSink;
45
+ readonly route: number;
46
+ readonly id: number;
47
+ constructor(ch: FrameSink, route: number, id: number) { this.ch = ch; this.route = route; this.id = id; }
48
+ grant(n: number) { this.credit += n; const w = this.waiters.splice(0); for (const f of w) f(); }
49
+ abort() { this.aborted = true; const w = this.waiters.splice(0); for (const f of w) f(); }
50
+ get isAborted() { return this.aborted; }
51
+ async write(chunk: Uint8Array): Promise<void> {
52
+ let off = 0;
53
+ while (off < chunk.byteLength) {
54
+ if (this.aborted) throw new DormerError("ECANCELED", "stream cancelled by peer");
55
+ if (this.credit <= 0) { await new Promise<void>((r) => this.waiters.push(r)); continue; }
56
+ const n = Math.min(this.credit, chunk.byteLength - off, 1024 * 1024);
57
+ this.ch.send(binFrame(FrameType.StreamChunk, this.route, this.id, chunk.subarray(off, off + n)));
58
+ this.credit -= n; off += n;
59
+ }
60
+ }
61
+ end(err?: { c: string; msg: string }) {
62
+ if (this.ended) return; this.ended = true;
63
+ const payload: StreamEnd = err ? { ok: false, c: err.c, msg: err.msg } : { ok: true };
64
+ this.ch.send(msgpackFrame(FrameType.StreamEnd, this.route, this.id, payload));
65
+ this.ch.streams.delete(this.id);
66
+ }
67
+ }
68
+
69
+ /** Default credit window granted by a stream consumer (PROTOCOL.md §5). */
70
+ export const STREAM_WINDOW = 1024 * 1024;
71
+
72
+ /** Consumer side of a stream: an async iterable of chunks that grants credit as it drains. */
73
+ export class InStream implements AsyncIterable<Uint8Array> {
74
+ private queue: Uint8Array[] = [];
75
+ private waiters: Array<(v: IteratorResult<Uint8Array>) => void> = [];
76
+ private done: { ok: boolean; c?: string; msg?: string } | null = null;
77
+ private granted = 0;
78
+ private consumed = 0;
79
+ private readonly ch: FrameSink;
80
+ readonly route: number;
81
+ readonly id: number;
82
+ constructor(ch: FrameSink, route: number, id: number) { this.ch = ch; this.route = route; this.id = id; this.grant(STREAM_WINDOW); }
83
+ private grant(n: number) { this.granted += n; this.ch.send(creditFrame(this.route, this.id, n)); }
84
+ /** @internal */
85
+ push(chunk: Uint8Array) {
86
+ this.consumed += chunk.byteLength;
87
+ // keep roughly one window ahead of the producer
88
+ if (this.granted - this.consumed < STREAM_WINDOW / 2) this.grant(STREAM_WINDOW);
89
+ const w = this.waiters.shift();
90
+ if (w) w({ value: chunk, done: false }); else this.queue.push(chunk);
91
+ }
92
+ /** @internal */
93
+ end(e: { ok: boolean; c?: string; msg?: string }) {
94
+ this.done = e;
95
+ const ws = this.waiters.splice(0);
96
+ for (const w of ws) this.settle(w);
97
+ }
98
+ private settle(w: (v: IteratorResult<Uint8Array>) => void) {
99
+ const d = this.done!;
100
+ if (d.ok) w({ value: undefined as unknown as Uint8Array, done: true });
101
+ else w(Promise.reject(new DormerError(d.c ?? "EINTERNAL", d.msg ?? "stream failed")) as unknown as IteratorResult<Uint8Array>);
102
+ }
103
+ cancel() { if (!this.done) { this.done = { ok: false, c: "ECANCELED", msg: "cancelled by consumer" }; this.ch.send(makeFrame(FrameType.Cancel, this.route, this.id)); const ws = this.waiters.splice(0); for (const w of ws) this.settle(w); } }
104
+ [Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
105
+ return {
106
+ next: () => {
107
+ const q = this.queue.shift();
108
+ if (q) return Promise.resolve({ value: q, done: false });
109
+ if (this.done) return new Promise<IteratorResult<Uint8Array>>((res, rej) => this.settle((v) => (v instanceof Promise ? (v as Promise<never>).catch(rej) : res(v))));
110
+ return new Promise<IteratorResult<Uint8Array>>((res, rej) => this.waiters.push((v) => (v instanceof Promise ? (v as Promise<never>).catch(rej) : res(v))));
111
+ },
112
+ return: () => { this.cancel(); return Promise.resolve({ value: undefined as unknown as Uint8Array, done: true }); },
113
+ };
114
+ }
115
+ }
116
+
117
+ /** Request/response/event/stream machinery over any frame transport. Subclasses implement `write`. */
118
+ export abstract class FrameChannel extends EventEmitter implements FrameSink {
119
+ protected decoder = new FrameDecoder();
120
+ private nextId = 2; // even: Node-initiated
121
+ private nextStreamId = 2;
122
+ private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
123
+ private inflight = new Map<string, AbortController>(); // `${route}:${id}` of requests we are handling
124
+ readonly streams = new Map<number, OutStream>();
125
+ readonly inStreams = new Map<number, InStream>();
126
+ closed = false;
127
+
128
+ protected abstract write(bytes: Uint8Array): void;
129
+
130
+ send(frame: Frame): void {
131
+ if (this.closed) return;
132
+ this.write(encodeFrame(frame));
133
+ }
134
+
135
+ /** Request to the peer (`host.*` methods on the host channel, any method on a worker link). */
136
+ call<T = unknown>(m: string, a: unknown[] = [], route = 0, signal?: AbortSignal): Promise<T> {
137
+ const id = this.nextId; this.nextId = (this.nextId + 2) >>> 0 || 2;
138
+ const req: Req = { a, m };
139
+ return new Promise<T>((resolve, reject) => {
140
+ if (this.closed) return reject(new DormerError("ECONNRESET", "channel closed"));
141
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
142
+ if (signal) {
143
+ if (signal.aborted) { this.pending.delete(id); return reject(new DormerError("ECANCELED", "aborted")); }
144
+ signal.addEventListener("abort", () => { if (this.pending.has(id)) this.send(makeFrame(FrameType.Cancel, route, id)); }, { once: true });
145
+ }
146
+ this.send(msgpackFrame(FrameType.Req, route, id, req));
147
+ });
148
+ }
149
+
150
+ /** Request a stream from the peer; resolves once the peer has opened it. */
151
+ async callStream(m: string, a: unknown[] = [], route = 0, signal?: AbortSignal): Promise<InStream> {
152
+ const r = await this.call<{ __dormerStream?: number }>(m, a, route, signal);
153
+ if (!r || typeof r.__dormerStream !== "number") throw new DormerError("EINVAL", `'${m}' is not a stream handler`);
154
+ const s = this.inStreams.get(r.__dormerStream) ?? this.openInStream(route, r.__dormerStream);
155
+ signal?.addEventListener("abort", () => s.cancel(), { once: true });
156
+ return s;
157
+ }
158
+ private openInStream(route: number, id: number): InStream { const s = new InStream(this, route, id); this.inStreams.set(id, s); return s; }
159
+
160
+ reply(route: number, id: number, r: unknown): void {
161
+ this.inflight.delete(`${route}:${id}`);
162
+ const res: Res = { r };
163
+ this.send(msgpackFrame(FrameType.Res, route, id, res));
164
+ }
165
+ replyError(route: number, id: number, c: string, msg: string, data?: unknown, retry = false): void {
166
+ this.inflight.delete(`${route}:${id}`);
167
+ const err: Err = { c, msg, retry, ...(data !== undefined ? { data } : {}) };
168
+ this.send(msgpackFrame(FrameType.Err, route, id, err));
169
+ }
170
+ event(route: number, ch: string, data: unknown): void {
171
+ const evt: Evt = { ch, data };
172
+ this.send(msgpackFrame(FrameType.Evt, route, 0, evt));
173
+ }
174
+ openStream(route: number, meta: Record<string, unknown>): OutStream {
175
+ const id = this.nextStreamId; this.nextStreamId = (this.nextStreamId + 2) >>> 0 || 2;
176
+ const s = new OutStream(this, route, id);
177
+ this.streams.set(id, s);
178
+ this.send(msgpackFrame(FrameType.StreamOpen, route, id, { meta }));
179
+ return s;
180
+ }
181
+
182
+ protected onData(d: Uint8Array) {
183
+ let frames: Frame[];
184
+ try { frames = this.decoder.push(d); }
185
+ catch (e) { this.emit("error", e); this.destroy(); return; }
186
+ for (const f of frames) this.onFrame(f);
187
+ }
188
+
189
+ protected abstract destroy(): void;
190
+
191
+ protected onFrame(f: Frame) {
192
+ switch (f.type) {
193
+ case FrameType.Req: {
194
+ const req = unpack<Req>(f.payload);
195
+ const ac = new AbortController();
196
+ this.inflight.set(`${f.route}:${f.id}`, ac);
197
+ const ir: IncomingRequest = { route: f.route, id: f.id, m: req.m, a: req.a ?? [], signal: ac.signal };
198
+ this.emit("request", ir);
199
+ break;
200
+ }
201
+ case FrameType.Res: { const p = this.pending.get(f.id); if (p) { this.pending.delete(f.id); p.resolve(unpack<Res>(f.payload).r); } break; }
202
+ case FrameType.Err: {
203
+ const p = this.pending.get(f.id);
204
+ if (p) { this.pending.delete(f.id); const e = unpack<Err>(f.payload); p.reject(new DormerError(e.c, e.msg, e.data, e.retry)); }
205
+ break;
206
+ }
207
+ case FrameType.Evt: { const e = unpack<Evt>(f.payload); this.emit("event", { route: f.route, ch: e.ch, data: e.data } satisfies IncomingEvent); break; }
208
+ case FrameType.Credit: { this.streams.get(f.id)?.grant(parseCredit(f.payload)); break; }
209
+ case FrameType.StreamOpen: { if (!this.inStreams.has(f.id)) this.openInStream(f.route, f.id); break; }
210
+ case FrameType.StreamChunk: { this.inStreams.get(f.id)?.push(f.payload); break; }
211
+ case FrameType.StreamEnd: { const s = this.inStreams.get(f.id); if (s) { this.inStreams.delete(f.id); s.end(unpack<StreamEnd>(f.payload)); } break; }
212
+ case FrameType.Cancel: {
213
+ const s = this.streams.get(f.id);
214
+ if (s) { s.abort(); break; }
215
+ this.inflight.get(`${f.route}:${f.id}`)?.abort();
216
+ break;
217
+ }
218
+ case FrameType.Ping: this.send(makeFrame(FrameType.Pong, f.route, f.id, f.payload)); break;
219
+ case FrameType.Pong: this.emit("pong", f); break;
220
+ default: this.emit("frame", f);
221
+ }
222
+ }
223
+
224
+ protected failAll(e: Error) {
225
+ for (const p of this.pending.values()) p.reject(e);
226
+ this.pending.clear();
227
+ for (const s of this.streams.values()) s.abort();
228
+ for (const s of this.inStreams.values()) s.end({ ok: false, c: (e as DormerError).code ?? "ECONNRESET", msg: e.message });
229
+ this.inStreams.clear();
230
+ }
231
+ }
232
+
233
+ export interface HostChannelOptions {
234
+ /** PING interval in ms (default 15 000; 0 disables). */
235
+ heartbeatMs?: number;
236
+ /** Missed PONGs before the channel is considered dead (default 2). */
237
+ maxMissedPongs?: number;
238
+ /** Development mode: never close on missed PONGs (a paused debugger must not kill the app). */
239
+ dev?: boolean;
240
+ /** Per-launch token to send in HELLO (named-endpoint fallback, ADR-0003 §3). */
241
+ token?: string;
242
+ }
243
+
244
+ /** True when the process was launched by a Dormer host (the host always sets one of these). */
245
+ export function hasHostEnv(): boolean { return process.env.DORMER_CHANNEL_FD !== undefined || process.env.DORMER_CHANNEL_PIPE !== undefined; }
246
+
247
+ /**
248
+ * Host channel over the inherited descriptor (fd 3) or, on the Windows fallback, a named pipe.
249
+ * May be created *deferred* (no socket yet): frames sent before `attach()` are buffered, so the
250
+ * router and app wiring can exist before the process decides whether a host is present.
251
+ */
252
+ export class HostChannel extends FrameChannel {
253
+ private sock?: net.Socket;
254
+ private buffered: Uint8Array[] = [];
255
+ welcome?: Welcome;
256
+ private opts: Required<Omit<HostChannelOptions, "token">> & { token?: string };
257
+ private heartbeat?: NodeJS.Timeout;
258
+ private missedPongs = 0;
259
+ private pingSeq = 0;
260
+
261
+ /** Adopt the channel the host created (see `attachFromEnv`). */
262
+ static fromEnv(opts: HostChannelOptions = {}): HostChannel {
263
+ const ch = new HostChannel(undefined, opts);
264
+ ch.attachFromEnv();
265
+ return ch;
266
+ }
267
+
268
+ constructor(socket?: net.Socket, opts: HostChannelOptions = {}) {
269
+ super();
270
+ this.opts = { heartbeatMs: opts.heartbeatMs ?? 15_000, maxMissedPongs: opts.maxMissedPongs ?? 2, dev: opts.dev ?? process.env.DORMER_DEV === "1", token: opts.token };
271
+ if (socket) this.attach(socket);
272
+ }
273
+
274
+ /** The underlying socket; throws when the channel is still deferred. */
275
+ get socket(): net.Socket {
276
+ if (!this.sock) throw new DormerError("ENOTCONN", "host channel is not attached");
277
+ return this.sock;
278
+ }
279
+ get attached(): boolean { return this.sock !== undefined; }
280
+
281
+ /** Bind a connected socket and flush frames sent while deferred. Emits `attach`. */
282
+ attach(socket: net.Socket): void {
283
+ if (this.sock) throw new DormerError("EINVAL", "host channel already attached");
284
+ this.sock = socket;
285
+ socket.on("data", (d: Buffer) => this.onData(d));
286
+ socket.on("error", (e) => { this.closed = true; this.emit("error", e); });
287
+ socket.on("close", () => { this.closed = true; this.stopHeartbeat(); this.failAll(new DormerError("ECONNRESET", "host channel closed")); this.emit("close"); });
288
+ for (const b of this.buffered.splice(0)) socket.write(b);
289
+ this.emit("attach");
290
+ }
291
+
292
+ /**
293
+ * Attach to the channel the host created: `DORMER_CHANNEL_FD` or, when
294
+ * `DORMER_CHANNEL_PIPE=stdin`, a pipe name and token read from stdin (Windows fallback).
295
+ * Never guesses a descriptor: test runners also hand children an fd 3 (node --test's IPC pipe).
296
+ */
297
+ attachFromEnv(): void {
298
+ this.opts.dev = process.env.DORMER_DEV === "1" ? true : this.opts.dev;
299
+ if (process.env.DORMER_CHANNEL_PIPE === "stdin") {
300
+ const [name, token] = readStdinLines(2);
301
+ if (!name) throw new DormerError("EINVAL", "DORMER_CHANNEL_PIPE=stdin but no pipe name on stdin");
302
+ this.opts.token = token;
303
+ this.attach(net.connect(name));
304
+ return;
305
+ }
306
+ if (process.env.DORMER_CHANNEL_FD === undefined) throw new DormerError("ENOTSUP", "not running inside a Dormer host (DORMER_CHANNEL_FD is not set)");
307
+ const fd = Number(process.env.DORMER_CHANNEL_FD);
308
+ if (!Number.isInteger(fd) || fd < 0) throw new DormerError("EINVAL", `invalid DORMER_CHANNEL_FD '${process.env.DORMER_CHANNEL_FD}'`);
309
+ this.attach(new net.Socket({ fd, readable: true, writable: true }));
310
+ }
311
+
312
+ /** Detach the socket (listeners removed) so another channel can adopt it. Used by test injection. */
313
+ release(): net.Socket {
314
+ const s = this.socket;
315
+ s.removeAllListeners("data"); s.removeAllListeners("error"); s.removeAllListeners("close");
316
+ this.sock = undefined; this.stopHeartbeat();
317
+ return s;
318
+ }
319
+
320
+ protected write(bytes: Uint8Array): void { if (this.sock) this.sock.write(bytes); else this.buffered.push(bytes); }
321
+ protected destroy(): void { if (this.sock) this.sock.destroy(); else { this.closed = true; this.emit("close"); } }
322
+
323
+ async handshake(caps: string[] = ["bin", "stream"], token = this.opts.token): Promise<Welcome> {
324
+ const hello: Hello = { caps, min: PROTO_VERSION, proto: PROTO_VERSION, ...(token ? { token } : {}) };
325
+ this.send(msgpackFrame(FrameType.Hello, 0, 0, hello));
326
+ return new Promise((resolve, reject) => {
327
+ const t = setTimeout(() => reject(new DormerError("ETIMEDOUT", "no WELCOME from host")), 10_000);
328
+ this.once("welcome", (w: Welcome) => { clearTimeout(t); resolve(w); });
329
+ });
330
+ }
331
+
332
+ /** Heartbeat (PROTOCOL.md §2 PING/PONG): starts after WELCOME; `unref`'d so it never keeps the process alive. */
333
+ startHeartbeat(): void {
334
+ this.stopHeartbeat();
335
+ if (this.opts.heartbeatMs <= 0) return;
336
+ this.heartbeat = setInterval(() => {
337
+ if (this.closed) return this.stopHeartbeat();
338
+ if (this.missedPongs >= this.opts.maxMissedPongs) {
339
+ this.emit("heartbeat-lost", this.missedPongs);
340
+ // Destroy without an error payload: the "close" handler fails pending calls with
341
+ // ECONNRESET, and an unhandled "error" event would otherwise throw in apps without a listener.
342
+ if (!this.opts.dev) { console.error(`[dormer] host missed ${this.missedPongs} heartbeats; closing channel`); this.sock?.destroy(); return; }
343
+ }
344
+ this.missedPongs++;
345
+ const payload = new Uint8Array(4); new DataView(payload.buffer).setUint32(0, ++this.pingSeq >>> 0, true);
346
+ this.send(makeFrame(FrameType.Ping, 0, 0, payload));
347
+ }, this.opts.heartbeatMs);
348
+ this.heartbeat.unref();
349
+ }
350
+ stopHeartbeat(): void { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = undefined; } }
351
+
352
+ /** Flush pending writes and exit. Used for `host:node.shutdown` and by `app.exit`. */
353
+ flushAndExit(code: number, graceMs = 500): void {
354
+ this.stopHeartbeat();
355
+ const bye = () => process.exit(code);
356
+ const t = setTimeout(bye, graceMs);
357
+ t.unref();
358
+ if (!this.sock || this.sock.destroyed || this.sock.writableEnded) return bye();
359
+ this.sock.end(() => { clearTimeout(t); bye(); });
360
+ }
361
+
362
+ protected onFrame(f: Frame) {
363
+ switch (f.type) {
364
+ case FrameType.Welcome: this.welcome = unpack<Welcome>(f.payload); this.startHeartbeat(); this.emit("welcome", this.welcome); return;
365
+ case FrameType.Pong: this.missedPongs = 0; break;
366
+ case FrameType.Evt: {
367
+ const e = unpack<Evt>(f.payload);
368
+ if (e.ch === "host:node.shutdown") {
369
+ const code = Number((Array.isArray(e.data) ? (e.data[0] as { code?: number })?.code : (e.data as { code?: number })?.code) ?? 0);
370
+ // Application code (e.g. `app`) may take over by listening for "shutdown"; otherwise exit now.
371
+ if (this.listenerCount("shutdown") > 0) this.emit("shutdown", code); else this.flushAndExit(code);
372
+ return;
373
+ }
374
+ break;
375
+ }
376
+ }
377
+ super.onFrame(f);
378
+ }
379
+ }
380
+
381
+ function readStdinLines(n: number): string[] {
382
+ const lines: string[] = []; let buf = ""; const chunk = Buffer.alloc(256);
383
+ while (lines.length < n) {
384
+ let got = 0;
385
+ try { got = fs.readSync(0, chunk, 0, chunk.length, null); } catch (e) { if ((e as NodeJS.ErrnoException).code === "EAGAIN") continue; throw e; }
386
+ if (got === 0) break;
387
+ buf += chunk.subarray(0, got).toString("utf8");
388
+ let i: number;
389
+ while ((i = buf.indexOf("\n")) >= 0 && lines.length < n) { lines.push(buf.slice(0, i).replace(/\r$/, "")); buf = buf.slice(i + 1); }
390
+ }
391
+ return lines;
392
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { HostChannel, FrameChannel, hasHostEnv, OutStream, InStream, DormerError, STREAM_WINDOW, type FrameSink, type HostChannelOptions, type DormerErrorJSON, type IncomingRequest, type IncomingEvent } from "./channel.ts";
2
+ export { Router, toDormerError, type CallContext, type Handler, type StreamHandler, type StreamSource, type EventHandler } from "./router.ts";
3
+ export { PortChannel, WorkerLink, parent as workerParent, linkWorker, linkPort, type PortLike } from "./worker.ts";
package/src/router.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Method router: maps REQ frames to handlers, serialises results, drives pull-based streams.
2
+ // Works over any `FrameChannel` (the host channel or a worker MessagePort link).
3
+ import type { Readable } from "node:stream";
4
+ import { FrameChannel, DormerError, type IncomingRequest, type IncomingEvent } from "./channel.ts";
5
+
6
+ export interface CallContext { route: number; signal: AbortSignal; method: string }
7
+ export type Handler = (ctx: CallContext, ...args: any[]) => unknown;
8
+ export type StreamSource = AsyncIterable<Uint8Array> | Iterable<Uint8Array> | Readable;
9
+ export type StreamHandler = (ctx: CallContext, ...args: any[]) => StreamSource | Promise<StreamSource>;
10
+ export type EventHandler = (ctx: { route: number; channel: string }, ...args: any[]) => void;
11
+
12
+ const DEV = process.env.DORMER_DEV === "1";
13
+
14
+ export class Router {
15
+ private handlers = new Map<string, Handler>();
16
+ private streamHandlers = new Map<string, StreamHandler>();
17
+ private eventHandlers = new Map<string, Set<EventHandler>>();
18
+
19
+ readonly channel: FrameChannel;
20
+ constructor(channel: FrameChannel) {
21
+ this.channel = channel;
22
+ channel.on("request", (r: IncomingRequest) => void this.dispatch(r));
23
+ channel.on("event", (e: IncomingEvent) => this.dispatchEvent(e));
24
+ }
25
+
26
+ handle(method: string, fn: Handler): void {
27
+ if (this.handlers.has(method) || this.streamHandlers.has(method)) throw new Error(`Attempted to register a second handler for '${method}'`);
28
+ this.handlers.set(method, fn);
29
+ }
30
+ handleStream(method: string, fn: StreamHandler): void {
31
+ if (this.handlers.has(method) || this.streamHandlers.has(method)) throw new Error(`Attempted to register a second handler for '${method}'`);
32
+ this.streamHandlers.set(method, fn);
33
+ }
34
+ removeHandler(method: string): void { this.handlers.delete(method); this.streamHandlers.delete(method); }
35
+ on(channel: string, fn: EventHandler): () => void {
36
+ let set = this.eventHandlers.get(channel);
37
+ if (!set) { set = new Set(); this.eventHandlers.set(channel, set); }
38
+ set.add(fn);
39
+ return () => set!.delete(fn);
40
+ }
41
+
42
+ private dispatchEvent(e: IncomingEvent) {
43
+ const set = this.eventHandlers.get(e.ch);
44
+ if (!set) return;
45
+ const args = Array.isArray(e.data) ? e.data : [e.data];
46
+ for (const fn of set) { try { fn({ route: e.route, channel: e.ch }, ...args); } catch (err) { console.error(`[dormer] event handler for '${e.ch}' threw`, err); } }
47
+ }
48
+
49
+ private async dispatch(r: IncomingRequest) {
50
+ const ctx: CallContext = { route: r.route, signal: r.signal, method: r.m };
51
+ const h = this.handlers.get(r.m);
52
+ if (h) {
53
+ try {
54
+ const value = await h(ctx, ...r.a);
55
+ if (r.signal.aborted) return this.channel.replyError(r.route, r.id, "ECANCELED", "request cancelled");
56
+ this.channel.reply(r.route, r.id, value === undefined ? null : value);
57
+ } catch (err) { this.replyWithError(r, err); }
58
+ return;
59
+ }
60
+ const sh = this.streamHandlers.get(r.m);
61
+ if (sh) {
62
+ let source: StreamSource;
63
+ try { source = await sh(ctx, ...r.a); } catch (err) { return this.replyWithError(r, err); }
64
+ const out = this.channel.openStream(r.route, { method: r.m });
65
+ this.channel.reply(r.route, r.id, { __dormerStream: out.id });
66
+ void this.pump(source, out, r.signal);
67
+ return;
68
+ }
69
+ this.channel.replyError(r.route, r.id, "ENOTSUP", `No handler registered for '${r.m}'`);
70
+ }
71
+
72
+ private async pump(source: StreamSource, out: ReturnType<FrameChannel["openStream"]>, signal: AbortSignal) {
73
+ try {
74
+ for await (const chunk of source as AsyncIterable<Uint8Array | string>) {
75
+ if (signal.aborted || out.isAborted) break;
76
+ const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
77
+ await out.write(bytes);
78
+ }
79
+ out.end(out.isAborted ? { c: "ECANCELED", msg: "stream cancelled" } : undefined);
80
+ } catch (err) {
81
+ const e = toDormerError(err);
82
+ out.end({ c: e.code, msg: e.message });
83
+ if (typeof (source as Readable).destroy === "function") (source as Readable).destroy();
84
+ }
85
+ }
86
+
87
+ private replyWithError(r: IncomingRequest, err: unknown) {
88
+ const e = toDormerError(err);
89
+ const data = DEV ? { stack: (err as Error)?.stack, ...(e.data !== undefined ? { detail: e.data } : {}) } : e.data;
90
+ this.channel.replyError(r.route, r.id, e.code, e.message, data, e.retry);
91
+ }
92
+ }
93
+
94
+ /** Node `ERR_*` codes → protocol codes (PROTOCOL.md §4). Anything else keeps a stable `data.nodeCode`. */
95
+ const NODE_CODE_MAP: Record<string, string> = {
96
+ ERR_INVALID_ARG_TYPE: "EINVAL", ERR_INVALID_ARG_VALUE: "EINVAL", ERR_OUT_OF_RANGE: "EINVAL", ERR_INVALID_URL: "EINVAL",
97
+ ERR_MISSING_ARGS: "EINVAL", ERR_INVALID_FILE_URL_PATH: "EINVAL", ERR_INVALID_URL_SCHEME: "EINVAL", ERR_INVALID_STATE: "EINVAL",
98
+ ERR_ACCESS_DENIED: "EPERM",
99
+ ERR_UNSUPPORTED_ESM_URL_SCHEME: "ENOTSUP", ERR_FEATURE_UNAVAILABLE_ON_PLATFORM: "ENOTSUP", ERR_METHOD_NOT_IMPLEMENTED: "ENOTSUP", ERR_UNKNOWN_ENCODING: "ENOTSUP",
100
+ ERR_STREAM_PREMATURE_CLOSE: "ECONNRESET", ERR_STREAM_DESTROYED: "ECONNRESET", ERR_SOCKET_CLOSED: "ECONNRESET", ERR_STREAM_WRITE_AFTER_END: "ECONNRESET",
101
+ ERR_CHILD_PROCESS_STDIO_MAXBUFFER: "E2BIG", ERR_FS_FILE_TOO_LARGE: "E2BIG", ERR_STRING_TOO_LONG: "E2BIG",
102
+ ABORT_ERR: "ECANCELED",
103
+ };
104
+ const RETRYABLE = new Set(["EAGAIN", "EBUSY", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EMFILE", "ENFILE"]);
105
+ /** Node `SystemError` codes we forward unchanged (POSIX names, PROTOCOL.md §4 stable set). */
106
+ const SYSTEM_CODE = /^E[A-Z0-9]{2,15}$/;
107
+
108
+ /**
109
+ * Normalise anything thrown by a handler into a `DormerError` with a stable code:
110
+ * `DormerError` as is; `AbortError` → `ECANCELED`; Node `SystemError` (`ENOENT`, `EACCES`, ...)
111
+ * keep their code with `{syscall, path, errno}` in `data`; Node `ERR_*` codes map through
112
+ * `NODE_CODE_MAP` (else `EINTERNAL`) and keep the original in `data.nodeCode`; `TypeError` /
113
+ * `RangeError` / `SyntaxError` without a code → `EINVAL`; everything else `EINTERNAL`.
114
+ */
115
+ export function toDormerError(err: unknown): DormerError {
116
+ if (err instanceof DormerError) return err;
117
+ if (err && typeof err === "object") {
118
+ const e = err as { code?: unknown; message?: unknown; name?: unknown; syscall?: unknown; path?: unknown; errno?: unknown; dest?: unknown };
119
+ const msg = typeof e.message === "string" ? e.message : String(err);
120
+ const code = typeof e.code === "string" ? e.code : undefined;
121
+ if (e.name === "AbortError" || code === "ABORT_ERR") return new DormerError("ECANCELED", msg);
122
+ if (code && SYSTEM_CODE.test(code)) {
123
+ const data: Record<string, unknown> = {};
124
+ if (typeof e.syscall === "string") data.syscall = e.syscall;
125
+ if (typeof e.path === "string") data.path = e.path;
126
+ if (typeof e.dest === "string") data.dest = e.dest;
127
+ if (typeof e.errno === "number") data.errno = e.errno;
128
+ return new DormerError(code, msg, Object.keys(data).length ? data : undefined, RETRYABLE.has(code));
129
+ }
130
+ if (code && code.startsWith("ERR_")) return new DormerError(NODE_CODE_MAP[code] ?? "EINTERNAL", msg, { nodeCode: code });
131
+ if (code) return new DormerError(code, msg);
132
+ if (e.name === "TypeError" || e.name === "RangeError" || e.name === "SyntaxError") return new DormerError("EINVAL", msg, { nodeCode: e.name });
133
+ return new DormerError("EINTERNAL", msg);
134
+ }
135
+ return new DormerError("EINTERNAL", String(err));
136
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Worker links: the Dormer frame protocol over a `worker_threads` MessagePort, so a handler can
2
+ // move between the main backend and a worker without API change (ARCHITECTURE.md §4.4).
3
+ // Frames travel as whole `Uint8Array` messages (transferred, not copied).
4
+ import { isMainThread, parentPort, type MessagePort, type Worker } from "node:worker_threads";
5
+ import { FrameChannel, DormerError } from "./channel.ts";
6
+ import { Router, type Handler, type StreamHandler, type EventHandler } from "./router.ts";
7
+ import type { Frame } from "@dormer/ipc";
8
+
9
+ /** Anything with `postMessage` and `message` events: `MessagePort`, `Worker`, `parentPort`. */
10
+ export interface PortLike {
11
+ postMessage(value: unknown, transferList?: readonly unknown[]): void;
12
+ on(event: "message", listener: (value: unknown) => void): unknown;
13
+ on(event: "close", listener: () => void): unknown;
14
+ on(event: "error", listener: (err: Error) => void): unknown;
15
+ on(event: "exit", listener: (code: number) => void): unknown;
16
+ off?(event: string, listener: (...args: any[]) => void): unknown;
17
+ close?(): void;
18
+ unref?(): void;
19
+ ref?(): void;
20
+ }
21
+
22
+ /** A `FrameChannel` over a MessagePort-like object. */
23
+ export class PortChannel extends FrameChannel {
24
+ readonly port: PortLike;
25
+ constructor(port: PortLike) {
26
+ super();
27
+ this.port = port;
28
+ port.on("message", (m: unknown) => {
29
+ if (m instanceof Uint8Array) this.onData(m);
30
+ else if (m instanceof ArrayBuffer) this.onData(new Uint8Array(m));
31
+ });
32
+ const gone = () => { if (!this.closed) { this.closed = true; this.failAll(new DormerError("ECONNRESET", "worker link closed")); this.emit("close"); } };
33
+ try { port.on("close", gone); } catch {}
34
+ try { port.on("exit", gone); } catch {}
35
+ try { port.on("error", (e: Error) => this.emit("error", e)); } catch {}
36
+ }
37
+ protected write(bytes: Uint8Array): void {
38
+ // encodeFrame returns a fresh buffer, so transferring it is safe.
39
+ this.port.postMessage(bytes, [bytes.buffer]);
40
+ }
41
+ protected destroy(): void { this.close(); }
42
+ close(): void {
43
+ if (this.closed) return;
44
+ this.closed = true;
45
+ this.failAll(new DormerError("ECONNRESET", "worker link closed"));
46
+ try { this.port.close?.(); } catch {}
47
+ this.emit("close");
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Two-way link: `call`/`callStream`/`send` towards the peer, `handle`/`handleStream`/`on` for
53
+ * requests the peer sends us. The same object shape is used on both sides of a worker.
54
+ */
55
+ export class WorkerLink {
56
+ readonly channel: PortChannel;
57
+ readonly router: Router;
58
+ constructor(port: PortLike) {
59
+ this.channel = new PortChannel(port);
60
+ this.router = new Router(this.channel);
61
+ }
62
+ /** Invoke `method` on the peer. `opts.signal` cancels (the peer sees `ctx.signal` abort). */
63
+ call<T = unknown>(method: string, ...args: unknown[]): Promise<T> { return this.channel.call<T>(method, args, 0); }
64
+ callWith<T = unknown>(opts: { signal?: AbortSignal }, method: string, ...args: unknown[]): Promise<T> { return this.channel.call<T>(method, args, 0, opts.signal); }
65
+ /** Consume a stream handler on the peer as an async iterable of `Uint8Array` chunks. */
66
+ stream(method: string, ...args: unknown[]): Promise<AsyncIterable<Uint8Array> & { cancel(): void }> { return this.channel.callStream(method, args, 0); }
67
+ /** Fire-and-forget event to the peer (`link.on(channel, fn)` on the other side). */
68
+ send(channel: string, ...args: unknown[]): void { this.channel.event(0, channel, args); }
69
+ handle(method: string, fn: (ctx: { signal: AbortSignal; method: string }, ...args: any[]) => unknown): void { this.router.handle(method, fn as Handler); }
70
+ handleStream(method: string, fn: StreamHandler): void { this.router.handleStream(method, fn); }
71
+ removeHandler(method: string): void { this.router.removeHandler(method); }
72
+ on(channel: string, fn: (...args: any[]) => void): () => void { return this.router.on(channel, ((_ctx, ...a) => fn(...a)) as EventHandler); }
73
+ onClose(fn: () => void): void { this.channel.on("close", fn); }
74
+ close(): void { this.channel.close(); }
75
+ }
76
+
77
+ let parentLink: WorkerLink | undefined;
78
+ /** Inside a worker: the link back to the thread that created us (throws on the main thread). */
79
+ export function parent(): WorkerLink {
80
+ if (isMainThread || !parentPort) throw new DormerError("EINVAL", "WorkerLink.parent() is only available inside a worker thread");
81
+ return parentLink ??= new WorkerLink(parentPort as unknown as PortLike);
82
+ }
83
+
84
+ /** Wrap an existing `Worker` (main side). */
85
+ export function linkWorker(worker: Worker): WorkerLink { return new WorkerLink(worker as unknown as PortLike); }
86
+ /** Wrap one end of a `MessageChannel` (same-thread tests, or ports handed to workers). */
87
+ export function linkPort(port: MessagePort): WorkerLink { return new WorkerLink(port as unknown as PortLike); }
88
+
89
+ export type { Frame };
@@ -0,0 +1,115 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import net from "node:net";
4
+ import { FrameDecoder, FrameType, encodeFrame, msgpackFrame, unpack, makeFrame, creditFrame, type Frame } from "@dormer/ipc";
5
+ import { HostChannel, Router } from "../src/index.ts";
6
+
7
+ // A fake host on the other end of a socketpair-like duplex (two connected TCP sockets on loopback,
8
+ // used only inside this test process).
9
+ async function pair(): Promise<[net.Socket, net.Socket]> {
10
+ const server = net.createServer();
11
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
12
+ const port = (server.address() as net.AddressInfo).port;
13
+ const [a, b] = await Promise.all([
14
+ new Promise<net.Socket>((r) => server.once("connection", r)),
15
+ new Promise<net.Socket>((r) => { const s = net.connect(port, "127.0.0.1", () => r(s)); }),
16
+ ]);
17
+ server.close();
18
+ return [a, b];
19
+ }
20
+
21
+ class FakeHost {
22
+ dec = new FrameDecoder(); frames: Frame[] = []; waiters: Array<(f: Frame) => void> = []; sock: net.Socket;
23
+ constructor(sock: net.Socket) { this.sock = sock; sock.on("data", (d) => { for (const f of this.dec.push(d)) { const w = this.waiters.shift(); w ? w(f) : this.frames.push(f); } }); }
24
+ next(): Promise<Frame> { const f = this.frames.shift(); return f ? Promise.resolve(f) : new Promise((r) => this.waiters.push(r)); }
25
+ send(f: Frame) { this.sock.write(encodeFrame(f)); }
26
+ }
27
+
28
+ test("handshake, request dispatch, errors, events", async () => {
29
+ const [hostSock, nodeSock] = await pair();
30
+ const host = new FakeHost(hostSock);
31
+ const ch = new HostChannel(nodeSock);
32
+ const router = new Router(ch);
33
+ router.handle("ipc:add", (_ctx, a: number, b: number) => a + b);
34
+ router.handle("ipc:fail", () => { const e = new Error("nope") as Error & { code: string }; e.code = "EFAIL"; throw e; });
35
+ const seen: unknown[] = [];
36
+ router.on("send:log", (_ctx, ...args) => seen.push(args));
37
+
38
+ const hp = ch.handshake(["bin", "stream"]);
39
+ const hello = await host.next();
40
+ assert.equal(hello.type, FrameType.Hello);
41
+ assert.deepEqual(unpack(hello.payload), { caps: ["bin", "stream"], min: 1, proto: 1 });
42
+ host.send(msgpackFrame(FrameType.Welcome, 0, 0, { caps: ["bin", "stream"], proto: 1, route: 0 }));
43
+ assert.equal((await hp).route, 0);
44
+
45
+ host.send(msgpackFrame(FrameType.Req, 7, 1, { m: "ipc:add", a: [2, 3] }));
46
+ const res = await host.next();
47
+ assert.deepEqual([res.type, res.route, res.id, unpack(res.payload)], [FrameType.Res, 7, 1, { r: 5 }]);
48
+
49
+ host.send(msgpackFrame(FrameType.Req, 7, 3, { m: "ipc:fail", a: [] }));
50
+ const err = await host.next();
51
+ assert.equal(err.type, FrameType.Err);
52
+ assert.equal((unpack(err.payload) as { c: string }).c, "EFAIL");
53
+
54
+ host.send(msgpackFrame(FrameType.Req, 7, 5, { m: "ipc:missing", a: [] }));
55
+ assert.equal((unpack((await host.next()).payload) as { c: string }).c, "ENOTSUP");
56
+
57
+ host.send(msgpackFrame(FrameType.Evt, 7, 0, { ch: "send:log", data: ["hello", 42] }));
58
+ await new Promise((r) => setTimeout(r, 10));
59
+ assert.deepEqual(seen, [["hello", 42]]);
60
+
61
+ // Node -> host call
62
+ const p = ch.call("host.window.create", [{ width: 1 }]);
63
+ const req = await host.next();
64
+ assert.equal(req.type, FrameType.Req);
65
+ host.send(msgpackFrame(FrameType.Res, 0, req.id, { r: { route: 9 } }));
66
+ assert.deepEqual(await p, { route: 9 });
67
+
68
+ hostSock.destroy(); nodeSock.destroy();
69
+ });
70
+
71
+ test("pull-based stream with credit", async () => {
72
+ const [hostSock, nodeSock] = await pair();
73
+ const host = new FakeHost(hostSock);
74
+ const ch = new HostChannel(nodeSock);
75
+ const router = new Router(ch);
76
+ router.handleStream("ipc:bytes", async function* (_ctx, n: number) {
77
+ for (let i = 0; i < n; i++) yield new Uint8Array(1000).fill(i);
78
+ });
79
+ host.send(msgpackFrame(FrameType.Req, 2, 11, { m: "ipc:bytes", a: [3] }));
80
+ const open = await host.next();
81
+ assert.equal(open.type, FrameType.StreamOpen);
82
+ const res = await host.next();
83
+ const streamId = (unpack(res.payload) as { r: { __dormerStream: number } }).r.__dormerStream;
84
+ assert.equal(streamId, open.id);
85
+ // no credit yet: nothing should arrive
86
+ await new Promise((r) => setTimeout(r, 20));
87
+ assert.equal(host.frames.length, 0);
88
+ host.send(creditFrame(2, streamId, 1500));
89
+ const c1 = await host.next(); assert.equal(c1.type, FrameType.StreamChunk); assert.equal(c1.payload.byteLength, 1000);
90
+ const c2 = await host.next(); assert.equal(c2.payload.byteLength, 500); // split by credit
91
+ host.send(creditFrame(2, streamId, 10_000));
92
+ const c3 = await host.next(); assert.equal(c3.payload.byteLength, 500);
93
+ const c4 = await host.next(); assert.equal(c4.payload.byteLength, 1000);
94
+ const end = await host.next(); assert.equal(end.type, FrameType.StreamEnd); assert.deepEqual(unpack(end.payload), { ok: true });
95
+ hostSock.destroy(); nodeSock.destroy();
96
+ });
97
+
98
+ test("ping/pong and cancel of in-flight request", async () => {
99
+ const [hostSock, nodeSock] = await pair();
100
+ const host = new FakeHost(hostSock);
101
+ const ch = new HostChannel(nodeSock);
102
+ const router = new Router(ch);
103
+ let sawAbort = false;
104
+ router.handle("ipc:slow", (ctx) => new Promise((resolve) => { ctx.signal.addEventListener("abort", () => { sawAbort = true; resolve("late"); }); }));
105
+ host.send(makeFrame(FrameType.Ping, 0, 0, new Uint8Array([1, 2])));
106
+ const pong = await host.next(); assert.equal(pong.type, FrameType.Pong); assert.deepEqual([...pong.payload], [1, 2]);
107
+ host.send(msgpackFrame(FrameType.Req, 4, 21, { m: "ipc:slow", a: [] }));
108
+ await new Promise((r) => setTimeout(r, 10));
109
+ host.send(makeFrame(FrameType.Cancel, 4, 21));
110
+ const err = await host.next();
111
+ assert.equal(err.type, FrameType.Err);
112
+ assert.equal((unpack(err.payload) as { c: string }).c, "ECANCELED");
113
+ assert.equal(sawAbort, true);
114
+ hostSock.destroy(); nodeSock.destroy();
115
+ });
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { toDormerError, DormerError } from "../src/index.ts";
4
+
5
+ test("error code mapping is stable", () => {
6
+ const sys = Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT", errno: -2, syscall: "open", path: "/nope" });
7
+ const e1 = toDormerError(sys);
8
+ assert.equal(e1.code, "ENOENT"); assert.deepEqual(e1.data, { syscall: "open", path: "/nope", errno: -2 }); assert.equal(e1.retry, false);
9
+ assert.equal(toDormerError(Object.assign(new Error("busy"), { code: "EBUSY" })).retry, true);
10
+ const e2 = toDormerError(Object.assign(new TypeError("bad arg"), { code: "ERR_INVALID_ARG_TYPE" }));
11
+ assert.equal(e2.code, "EINVAL"); assert.deepEqual(e2.data, { nodeCode: "ERR_INVALID_ARG_TYPE" });
12
+ assert.equal(toDormerError(Object.assign(new Error("denied"), { code: "ERR_ACCESS_DENIED" })).code, "EPERM");
13
+ assert.equal(toDormerError(Object.assign(new Error("weird"), { code: "ERR_SOMETHING_NEW" })).code, "EINTERNAL");
14
+ const abort = new Error("aborted"); abort.name = "AbortError";
15
+ assert.equal(toDormerError(abort).code, "ECANCELED");
16
+ assert.equal(toDormerError(new RangeError("x")).code, "EINVAL");
17
+ assert.equal(toDormerError(new Error("plain")).code, "EINTERNAL");
18
+ assert.equal(toDormerError("string").code, "EINTERNAL");
19
+ assert.equal(toDormerError(Object.assign(new Error("app"), { code: "MY_APP_CODE" })).code, "MY_APP_CODE");
20
+ });
21
+
22
+ test("DormerError serialises and revives", () => {
23
+ const e = new DormerError("EPERM", "no", { why: 1 }, true);
24
+ const j = JSON.parse(JSON.stringify(e));
25
+ assert.deepEqual(j, { name: "DormerError", code: "EPERM", message: "no", data: { why: 1 }, retry: true });
26
+ const back = DormerError.from(j);
27
+ assert.ok(back instanceof DormerError); assert.equal(back.code, "EPERM"); assert.equal(back.retry, true);
28
+ assert.equal(DormerError.from({ c: "ENOENT", msg: "m", retry: false }).code, "ENOENT");
29
+ assert.equal(JSON.stringify(new DormerError("X", "y")), '{"name":"DormerError","code":"X","message":"y","retry":false}');
30
+ });
@@ -0,0 +1,6 @@
1
+ import { workerParent } from "../../src/index.ts";
2
+ const link = workerParent();
3
+ link.handle("echo", (_ctx, ...args: unknown[]) => JSON.stringify(args));
4
+ link.handle("askMain", () => link.call("main.time"));
5
+ link.handle("double", (_ctx, b: Uint8Array) => new Uint8Array(b.map((v) => v * 2)));
6
+ link.handleStream("count", async function* (_ctx, n: number) { for (let i = 0; i < n; i++) yield new Uint8Array(10).fill(i); });
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import net from "node:net";
4
+ import { FrameDecoder, FrameType, encodeFrame, msgpackFrame, makeFrame, unpack, type Frame } from "@dormer/ipc";
5
+ import { HostChannel } from "../src/index.ts";
6
+
7
+ async function pair(): Promise<[net.Socket, net.Socket]> {
8
+ const server = net.createServer();
9
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
10
+ const port = (server.address() as net.AddressInfo).port;
11
+ const [a, b] = await Promise.all([
12
+ new Promise<net.Socket>((r) => server.once("connection", r)),
13
+ new Promise<net.Socket>((r) => { const s = net.connect(port, "127.0.0.1", () => r(s)); }),
14
+ ]);
15
+ server.close();
16
+ return [a, b];
17
+ }
18
+ class FakeHost {
19
+ dec = new FrameDecoder(); frames: Frame[] = []; waiters: Array<(f: Frame) => void> = []; sock: net.Socket;
20
+ constructor(sock: net.Socket) { this.sock = sock; sock.on("data", (d) => { for (const f of this.dec.push(d)) { const w = this.waiters.shift(); w ? w(f) : this.frames.push(f); } }); }
21
+ next(): Promise<Frame> { const f = this.frames.shift(); return f ? Promise.resolve(f) : new Promise((r) => this.waiters.push(r)); }
22
+ send(f: Frame) { this.sock.write(encodeFrame(f)); }
23
+ }
24
+
25
+ test("heartbeat pings after WELCOME, closes after missed pongs (non-dev), survives in dev", async () => {
26
+ for (const dev of [false, true]) {
27
+ const [hostSock, nodeSock] = await pair();
28
+ const host = new FakeHost(hostSock);
29
+ const ch = new HostChannel(nodeSock, { heartbeatMs: 10, maxMissedPongs: 2, dev });
30
+ let lost = 0; ch.on("heartbeat-lost", () => lost++);
31
+ const closed = new Promise<boolean>((r) => { ch.on("close", () => r(true)); setTimeout(() => r(false), 150); });
32
+ const hp = ch.handshake();
33
+ assert.equal((await host.next()).type, FrameType.Hello);
34
+ host.send(msgpackFrame(FrameType.Welcome, 0, 0, { caps: [], proto: 1, route: 0 }));
35
+ await hp;
36
+ const p1 = await host.next(); assert.equal(p1.type, FrameType.Ping);
37
+ host.send(makeFrame(FrameType.Pong, 0, 0, p1.payload)); // answered: counter resets
38
+ const p2 = await host.next(); assert.equal(p2.type, FrameType.Ping);
39
+ const p3 = await host.next(); assert.equal(p3.type, FrameType.Ping); // two unanswered
40
+ assert.equal(await closed, !dev, dev ? "dev mode must not close" : "non-dev closes the channel");
41
+ assert.ok(lost >= 1);
42
+ ch.stopHeartbeat();
43
+ hostSock.destroy(); nodeSock.destroy();
44
+ }
45
+ });
46
+
47
+ test("host:node.shutdown emits shutdown when handled, else flushes and exits", async () => {
48
+ const [hostSock, nodeSock] = await pair();
49
+ const host = new FakeHost(hostSock);
50
+ const ch = new HostChannel(nodeSock, { heartbeatMs: 0 });
51
+ const codes: number[] = [];
52
+ ch.on("shutdown", (code: number) => codes.push(code));
53
+ host.send(msgpackFrame(FrameType.Evt, 0, 0, { ch: "host:node.shutdown", data: [{ code: 3 }] }));
54
+ await new Promise((r) => setTimeout(r, 20));
55
+ assert.deepEqual(codes, [3]);
56
+
57
+ const realExit = process.exit; const exits: number[] = [];
58
+ (process as any).exit = (c: number) => { exits.push(c); };
59
+ const ch2 = new HostChannel(nodeSock, { heartbeatMs: 0 }); // second listener set, no shutdown handler
60
+ ch.removeAllListeners("shutdown");
61
+ host.send(msgpackFrame(FrameType.Evt, 0, 0, { ch: "host:node.shutdown", data: [{ code: 4 }] }));
62
+ await new Promise((r) => setTimeout(r, 50));
63
+ (process as any).exit = realExit;
64
+ assert.ok(exits.includes(4), `expected exit(4), got ${exits}`);
65
+ void ch2; void unpack;
66
+ hostSock.destroy(); nodeSock.destroy();
67
+ });
@@ -0,0 +1,63 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MessageChannel, Worker } from "node:worker_threads";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { linkPort, linkWorker, DormerError } from "../src/index.ts";
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+
10
+ test("worker link over a MessageChannel: call, cancel, events, streams, errors", async () => {
11
+ const { port1, port2 } = new MessageChannel();
12
+ const a = linkPort(port1), b = linkPort(port2);
13
+ b.handle("add", (_ctx, x: number, y: number) => x + y);
14
+ b.handle("bytes", (_ctx, buf: Uint8Array) => new Uint8Array(buf.map((v) => v * 2)));
15
+ b.handle("fail", () => { throw Object.assign(new Error("missing"), { code: "ENOENT", syscall: "open", path: "/x" }); });
16
+ let sawAbort = false;
17
+ b.handle("slow", (ctx) => new Promise((resolve) => ctx.signal.addEventListener("abort", () => { sawAbort = true; resolve("late"); })));
18
+ b.handleStream("chunks", async function* (_ctx, n: number) { for (let i = 0; i < n; i++) yield new Uint8Array(70_000).fill(i); });
19
+ const seen: unknown[] = [];
20
+ b.on("log", (...args) => seen.push(args));
21
+
22
+ assert.equal(await a.call("add", 2, 3), 5);
23
+ const out = await a.call<Uint8Array>("bytes", new Uint8Array([1, 2, 3]));
24
+ assert.deepEqual([...out], [2, 4, 6]);
25
+ await assert.rejects(a.call("fail"), (e: DormerError) => e.code === "ENOENT" && (e.data as { syscall: string }).syscall === "open");
26
+ await assert.rejects(a.call("nope"), (e: DormerError) => e.code === "ENOTSUP");
27
+
28
+ const ac = new AbortController(); setTimeout(() => ac.abort(), 5);
29
+ await assert.rejects(a.callWith({ signal: ac.signal }, "slow"), (e: DormerError) => e.code === "ECANCELED");
30
+ assert.equal(sawAbort, true);
31
+
32
+ a.send("log", "hello", 42);
33
+ await new Promise((r) => setTimeout(r, 10));
34
+ assert.deepEqual(seen, [["hello", 42]]);
35
+
36
+ let total = 0, chunks = 0;
37
+ for await (const c of await a.stream("chunks", 20)) { total += c.byteLength; chunks++; }
38
+ assert.equal(total, 20 * 70_000);
39
+ assert.ok(chunks >= 20);
40
+
41
+ // consumer cancels a stream midway: the producer sees abort and ends
42
+ const s = await a.stream("chunks", 1000);
43
+ const it = s[Symbol.asyncIterator]();
44
+ await it.next();
45
+ s.cancel();
46
+ await new Promise((r) => setTimeout(r, 20));
47
+ assert.ok(b.channel.streams.size === 0, "producer stream removed after cancel");
48
+
49
+ a.close(); b.close();
50
+ });
51
+
52
+ test("real worker thread speaks the protocol both ways", async () => {
53
+ const worker = new Worker(join(here, "fixtures", "echo-worker.ts"));
54
+ const link = linkWorker(worker);
55
+ link.handle("main.time", () => 12345);
56
+ assert.equal(await link.call("echo", "x", 1), JSON.stringify(["x", 1]));
57
+ assert.equal(await link.call("askMain"), 12345);
58
+ const buf = await link.call<Uint8Array>("double", new Uint8Array([5, 6]));
59
+ assert.deepEqual([...buf], [10, 12]);
60
+ let n = 0; for await (const c of await link.stream("count", 3)) n += c.byteLength;
61
+ assert.equal(n, 3 * 10);
62
+ await worker.terminate();
63
+ });
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.