@atolljs/node 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,17 +1,100 @@
1
1
  # @atolljs/node
2
2
 
3
3
  `node:worker_threads` runtime adapter for `@atolljs/core` — lets `WorkerPool`
4
- and `connectWorker` run on Node's Worker (an EventEmitter) instead of the DOM
5
- surface they expect.
6
-
7
- - `createNodePool({ worker, sharedMemory?, tasks?, … })` — `WorkerPool` with the
8
- adapter baked in. The `worker:` factory may return a `node:worker_threads.Worker`
9
- directly, so `new Worker(new URL('./x.worker.ts', import.meta.url))` stays
10
- bundler-detectable.
11
- - `createNodeWorker(nodeWorker)` / `NodeWorkerAdapter` — wraps a Node Worker for
12
- `connectWorker` or a hand-built `WorkerPool`.
13
- - `@atolljs/node/shim` — worker-side entry shim; imports first and binds
14
- `self = parentPort` before `defineWorker`'s bootstrap evaluates.
15
-
16
- `SharedArrayBuffer` works in Node with no headers — cross-origin isolation is a
17
- browser-only requirement.
4
+ and `connectWorker` run on Node's `Worker` (an `EventEmitter`) instead of the
5
+ DOM surface they expect. No framework required.
6
+
7
+ **[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)**
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @atolljs/core @atolljs/node
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ // pool.ts — a WorkerPool backed by node:worker_threads
19
+ import { Worker } from 'node:worker_threads';
20
+ import { createNodePool } from '@atolljs/node';
21
+ import { counterMemory } from './counter.memory';
22
+
23
+ export const pool = createNodePool({
24
+ // The factory may return a node:worker_threads.Worker directly — the
25
+ // adapter wraps it, so new Worker(new URL(...)) stays bundler-detectable.
26
+ worker: () => new Worker(new URL('./counter.worker.js', import.meta.url)),
27
+ sharedMemory: counterMemory,
28
+ poolSize: 4,
29
+ });
30
+ ```
31
+
32
+ ```ts
33
+ // counter.worker.ts — the worker entry; the shim binds self = parentPort
34
+ // before defineWorker's bootstrap evaluates, so import it first.
35
+ import '@atolljs/node/shim';
36
+ import { defineWorker } from '@atolljs/core';
37
+ import { counterMemory } from './counter.memory';
38
+
39
+ export const counterWorker = defineWorker({
40
+ sharedMemory: counterMemory,
41
+ methods: {
42
+ increment(delta: number) {
43
+ const next = counterMemory.count.read() + delta;
44
+ counterMemory.count.write(next);
45
+ return next;
46
+ },
47
+ },
48
+ });
49
+ ```
50
+
51
+ ## API
52
+
53
+ - `createNodePool({ worker, sharedMemory?, tasks?, … })` — a `WorkerPool` with
54
+ the adapter baked in. `worker` accepts a path/URL or a factory returning
55
+ either a Node or DOM-style `Worker`.
56
+ - `createNodeWorker(nodeWorker)` / `NodeWorkerAdapter` — wrap a Node `Worker`
57
+ (or worker file path) for `connectWorker` or a hand-built `WorkerPool`.
58
+ - `@atolljs/node/shim` — worker-side entry shim; import first so `self =
59
+ parentPort` is bound before `defineWorker`'s bootstrap evaluates.
60
+ - `withSharedBuffer` + `bindSharedBuffer` — share one contract buffer across
61
+ pools. Wrap a worker factory with `withSharedBuffer(spawn, bufferOrThunk)`
62
+ so every spawn is fed `pool.sharedBuffer` (thunk evaluated per spawn —
63
+ respawns included); the worker entry `await`s `bindSharedBuffer()` to bind
64
+ all contracts. For message-only pools whose workers read another pool's
65
+ memory.
66
+ - `@atolljs/node/http` — two offload topologies:
67
+ - `createHttpCluster` (Node ≥ 26): the main thread accepts TCP
68
+ connections with `pauseOnConnect` and transfers each `net.Socket` to a
69
+ pool worker, where `serveHttp` feeds it into an `http.Server` owned by
70
+ the worker — parsing, routing, and serialization all off-thread.
71
+ - `routeHttpGateway` (any Node): path-level ownership — the main thread
72
+ parses HTTP once and proxies matched prefixes to worker-owned internal
73
+ listeners (`serveHttp(app, { listen: 0 })`). Pin `/api/a/*` to worker A,
74
+ `/api/b/*` to worker B, serve the rest on main. WebSocket upgrades match
75
+ the same prefixes and are tunneled end-to-end; for embedding into a
76
+ host framework, `workerHttpPorts` + `proxyToWorker` (+`proxyUpgradeToWorker`
77
+ on the server's `upgrade` event) expose the same machinery as mountable
78
+ middleware (see `examples/nestjs` housed API).
79
+ See `examples/http-offload` and `docs/frameworks/node.md`.
80
+ - `@atolljs/node/redis` — shared-memory persistence via Redis
81
+ (`persistSharedMemory` / `redisMemoryAdapter`): field regions mirror to a
82
+ Redis hash on a version-diff flush, restore into the buffer at boot, and
83
+ optionally replicate across processes over a pub/sub channel. Pass it as
84
+ `persistence` on `createNodePool` (or `AtollModule.registerPool`) — the
85
+ pool attaches it after bind and calls `stop()` on `terminate()`.
86
+
87
+ ## Notes
88
+
89
+ - `SharedArrayBuffer` works in Node with no headers — cross-origin isolation
90
+ is a browser-only requirement.
91
+ - Building a NestJS app? [`@atolljs/nestjs`](../nestjs) wraps this adapter in
92
+ DI providers and decorator-based method offload.
93
+
94
+ ## Documentation
95
+
96
+ - [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)
97
+ - [Node backends — Express, Fastify, Hono, Koa](https://jwhenry3.github.io/atolljs/consumer/#/fw-node)
98
+ - [Worker HTTP servers — clustering & gateway](https://jwhenry3.github.io/atolljs/consumer/#/fw-node/clustering)
99
+ - [Worker pool & tasks](https://jwhenry3.github.io/atolljs/consumer/#/tasks)
100
+ - [NestJS guide](https://jwhenry3.github.io/atolljs/consumer/#/fw-nestjs)
package/package.json CHANGED
@@ -1,24 +1,31 @@
1
1
  {
2
2
  "name": "@atolljs/node",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Node runtime adapter — node:worker_threads pools, no framework",
5
+ "homepage": "https://jwhenry3.github.io/atolljs/consumer/#/quickstart",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": "./src/index.ts",
9
+ "./http": "./src/http.ts",
10
+ "./redis": "./src/redis.ts",
8
11
  "./shim": "./src/shim.ts"
9
12
  },
10
13
  "files": [
11
14
  "src"
12
15
  ],
13
16
  "publishConfig": {
14
- "access": "public"
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org"
15
19
  },
16
20
  "peerDependencies": {
17
- "@atolljs/core": "0.1.0"
21
+ "@atolljs/core": "0.1.1"
18
22
  },
19
23
  "repository": {
20
24
  "type": "git",
21
25
  "url": "https://github.com/jwhenry3/atolljs",
22
26
  "directory": "packages/node"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/jwhenry3/atolljs/issues"
23
30
  }
24
31
  }
package/src/http.ts ADDED
@@ -0,0 +1,569 @@
1
+ // HTTP clustering for plain-Node servers — the worker_threads answer to the
2
+ // cluster module. The main thread accepts TCP connections with
3
+ // pauseOnConnect (no bytes consumed) and transfers each net.Socket to a
4
+ // pool worker via postMessage. The worker feeds it into its own
5
+ // http.Server, so request parsing, route handling, and response
6
+ // serialization ALL run off the API thread — the main thread only moves a
7
+ // handle. Works alongside task dispatch: the same workers still answer
8
+ // EXECUTE_TASK; sockets just queue behind whatever a worker is doing.
9
+ import {
10
+ createServer as createHttpServer,
11
+ request as httpRequest,
12
+ Server as HttpServer,
13
+ type IncomingMessage,
14
+ type RequestListener,
15
+ type ServerResponse,
16
+ } from 'node:http';
17
+ import {
18
+ connect as netConnect,
19
+ createServer as createNetServer,
20
+ type Server as NetServer,
21
+ type Socket,
22
+ } from 'node:net';
23
+ import { isMainThread, parentPort } from 'node:worker_threads';
24
+
25
+ /** Wire id for transferred sockets — distinct from the pool's task protocol. */
26
+ const HTTP_CONNECTION = 'HTTP_CONNECTION';
27
+ /** Wire id a worker uses to announce an internally-listening HTTP port. */
28
+ export const HTTP_PORT = 'HTTP_PORT';
29
+ /** Wire id a tracker sends to re-request an HTTP_PORT announcement. */
30
+ const HTTP_PORT_QUERY = 'HTTP_PORT_QUERY';
31
+
32
+ /**
33
+ * net.Socket/net.Server handles only became transferable across
34
+ * worker_threads in Node.js 26 — on older runtimes postMessage throws
35
+ * DataCloneError ("Found invalid value in transferList"). Gate at setup so
36
+ * the failure is loud instead of dropping connections.
37
+ */
38
+ const NODE_MAJOR = Number(process.versions.node.split('.')[0]);
39
+ export const SOCKET_TRANSFER_SUPPORTED = NODE_MAJOR >= 26;
40
+
41
+ function assertSocketTransferSupported(): void {
42
+ if (!SOCKET_TRANSFER_SUPPORTED) {
43
+ throw new Error(
44
+ `serveHttp (without { listen }) requires Node.js >= 26 for net.Socket transfer ` +
45
+ `across worker_threads (found ${process.versions.node}).`,
46
+ );
47
+ }
48
+ }
49
+
50
+ /** Anything that exposes its workers — WorkerPool is the usual source. */
51
+ export interface WorkerSource {
52
+ readonly workers: readonly Worker[];
53
+ }
54
+
55
+ export interface HttpClusterOptions {
56
+ /** The pool whose workers serve connections (respawns picked up on read). */
57
+ pool: WorkerSource;
58
+ /** Port to listen on. Ignored when `server` is provided. */
59
+ port?: number;
60
+ /** Listen host. Ignored when `server` is provided. */
61
+ host?: string;
62
+ /**
63
+ * Bring your own listener — MUST be created with `pauseOnConnect: true`
64
+ * (e.g. `net.createServer({ pauseOnConnect: true })`) or the main thread
65
+ * may consume request bytes before the socket is transferred.
66
+ */
67
+ server?: NetServer;
68
+ /**
69
+ * Connection → worker selection. Defaults to round-robin. Receives the
70
+ * fresh `pool.workers` snapshot each call, so respawns participate.
71
+ * Returning undefined destroys the socket. {@link stickyByAddress} is a
72
+ * ready-made sticky implementation for multi-connection session flows.
73
+ */
74
+ route?: (socket: Socket, workers: readonly Worker[]) => Worker | undefined;
75
+ /** Called once the server is listening. */
76
+ onListen?: (server: NetServer) => void;
77
+ }
78
+
79
+ export interface HttpCluster {
80
+ /** The listening TCP server (the user's `server` when provided). */
81
+ server: NetServer;
82
+ /** Stop accepting; in-flight transferred connections stay with workers. */
83
+ close(): Promise<void>;
84
+ }
85
+
86
+ /**
87
+ * Main thread: cluster a pool behind one listener — accept connections and
88
+ * hand each socket to a pool worker. Pairs with the worker-side
89
+ * {@link serveHttp}:
90
+ *
91
+ * const pool = createNodePool({ worker: () => new Worker(new URL('./api.worker.js', import.meta.url)), sharedMemory, poolSize: 'auto' });
92
+ * const cluster = createHttpCluster({ pool, port: 8080 }); // null below Node 26
93
+ *
94
+ * Sockets transfer BEFORE any bytes are read — the worker's http.Server does
95
+ * all parsing. HTTPS termination is not supported here (the TLS handshake
96
+ * would run on the accept thread); terminate TLS upstream or serve plain
97
+ * HTTP behind a proxy.
98
+ *
99
+ * Returns `null` on runtimes without socket transfer (Node < 26 — see
100
+ * {@link SOCKET_TRANSFER_SUPPORTED}) after logging a one-line notice, so
101
+ * callers can write the cluster unconditionally and degrade to gateway-only
102
+ * behavior without wrapping the call in capability checks.
103
+ */
104
+ export function createHttpCluster(options: HttpClusterOptions): HttpCluster | null {
105
+ if (!SOCKET_TRANSFER_SUPPORTED) {
106
+ console.warn(
107
+ `[atoll] createHttpCluster skipped — net.Socket transfer requires Node.js >= 26 ` +
108
+ `(found ${process.versions.node})`,
109
+ );
110
+ return null;
111
+ }
112
+ let cursor = 0;
113
+ const route =
114
+ options.route ??
115
+ ((_: Socket, workers: readonly Worker[]) =>
116
+ workers.length === 0 ? undefined : workers[cursor++ % workers.length]);
117
+
118
+ const server = options.server ?? createNetServer({ pauseOnConnect: true });
119
+ server.on('connection', (socket: Socket) => {
120
+ const worker = route(socket, options.pool.workers);
121
+ if (!worker) {
122
+ socket.destroy();
123
+ return;
124
+ }
125
+ try {
126
+ worker.postMessage({ type: HTTP_CONNECTION, socket }, [
127
+ socket as unknown as Transferable,
128
+ ]);
129
+ } catch (err) {
130
+ socket.destroy();
131
+ server.emit('error', err);
132
+ }
133
+ });
134
+ if (!options.server) {
135
+ server.listen(options.port ?? 8080, options.host, () => options.onListen?.(server));
136
+ } else {
137
+ options.onListen?.(server);
138
+ }
139
+ return {
140
+ server,
141
+ close: () =>
142
+ new Promise<void>((resolve, reject) =>
143
+ server.close((err) => (err ? reject(err) : resolve())),
144
+ ),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * FNV-1a — tiny string hash for rendezvous routing. Only hashes connection
150
+ * metadata (client IPs), never user data, so a non-crypto hash is fine.
151
+ */
152
+ const fnv1a = (s: string): number => {
153
+ let h = 0x811c9dc5;
154
+ for (let i = 0; i < s.length; i++) {
155
+ h ^= s.charCodeAt(i);
156
+ h = Math.imul(h, 0x01000193);
157
+ }
158
+ return h >>> 0;
159
+ };
160
+
161
+ /**
162
+ * Sticky connection routing for {@link createHttpCluster}: every connection
163
+ * from the same client address lands on the same worker — rendezvous
164
+ * (highest-random-weight) hashing over `key + slot`, so removing a worker
165
+ * only remaps ITS clients instead of reshuffling the whole pool.
166
+ *
167
+ * A transferred socket pins its whole life to one worker, so a bare
168
+ * WebSocket connection never needs this — stickiness matters for
169
+ * multi-connection session flows: socket.io's polling→upgrade sequence,
170
+ * or an HTTP request that must precede/follow a WS connection on the same
171
+ * worker. The acceptor can't read headers or cookies, so client address
172
+ * (nginx ip_hash style) is the strongest key available — supply `key` to
173
+ * mix in more (e.g. localPort for multi-listener setups).
174
+ *
175
+ * createHttpCluster({ pool, port: 8080, route: stickyByAddress() });
176
+ */
177
+ export function stickyByAddress(
178
+ key: (socket: Socket) => string = (s) => s.remoteAddress ?? '',
179
+ ): (socket: Socket, workers: readonly Worker[]) => Worker | undefined {
180
+ return (socket, workers) => {
181
+ if (workers.length === 0) return undefined;
182
+ const k = key(socket);
183
+ let best: Worker | undefined;
184
+ let bestScore = -1;
185
+ workers.forEach((w, i) => {
186
+ const score = fnv1a(`${k}#${i}`);
187
+ if (score > bestScore) {
188
+ bestScore = score;
189
+ best = w;
190
+ }
191
+ });
192
+ return best;
193
+ };
194
+ }
195
+
196
+ export interface ServeHttpOptions {
197
+ /**
198
+ * Listen on an internal TCP port and announce it to the parent thread via
199
+ * a `{ type: HTTP_PORT, port }` message — for the gateway topology where
200
+ * the main thread parses HTTP and proxies to worker-owned listeners
201
+ * (works on any Node version, unlike socket transfer). `0` = ephemeral.
202
+ * The server still accepts transferred sockets either way — `listen` only
203
+ * adds the internal gateway port.
204
+ */
205
+ listen?: number;
206
+ /** Listen host; defaults to '127.0.0.1' so worker ports stay internal. */
207
+ host?: string;
208
+ }
209
+
210
+ /**
211
+ * Worker side: serve HTTP with an http.Server owned by this worker. Call it
212
+ * once in the worker entry, after '@atolljs/node/shim':
213
+ *
214
+ * import '@atolljs/node/shim';
215
+ * import { serveHttp } from '@atolljs/node/http';
216
+ * import express from 'express';
217
+ *
218
+ * const app = express(); // any (req, res) handler works —
219
+ * app.get('/api', (_q, r) => …); // express/koa .callback()/fastify.server
220
+ * serveHttp(app); // sockets transferred by createHttpCluster
221
+ * serveHttp(app, { listen: 0 }); // internal listener for routeHttpGateway
222
+ *
223
+ * Accepts a RequestListener (wrapped in a new http.Server), an existing
224
+ * http.Server (e.g. `fastify().server` after ready()), or
225
+ * `{ handler }` / `{ server }`. A no-op on the main thread.
226
+ */
227
+ export function serveHttp(
228
+ target: RequestListener | HttpServer | { handler?: RequestListener; server?: HttpServer },
229
+ options?: ServeHttpOptions,
230
+ ): HttpServer {
231
+ const server =
232
+ target instanceof HttpServer
233
+ ? target
234
+ : typeof target === 'function'
235
+ ? createHttpServer(target)
236
+ : target.server ?? createHttpServer(target.handler);
237
+
238
+ if (isMainThread || !parentPort) return server;
239
+ if (options?.listen === undefined) assertSocketTransferSupported();
240
+
241
+ let announcedPort: number | undefined;
242
+ parentPort.on('message', (msg: { type?: string; socket?: Socket }) => {
243
+ if (msg?.type === HTTP_CONNECTION && msg.socket) {
244
+ // Feed the accepted socket to this worker's HTTP stack — its stream is
245
+ // still paused (pauseOnConnect), so resume after the parser attaches.
246
+ server.emit('connection', msg.socket);
247
+ msg.socket.resume();
248
+ } else if (msg?.type === HTTP_PORT_QUERY && announcedPort !== undefined) {
249
+ parentPort!.postMessage({ type: HTTP_PORT, port: announcedPort });
250
+ }
251
+ });
252
+
253
+ if (options?.listen !== undefined) {
254
+ server.listen(options.listen, options.host ?? '127.0.0.1', () => {
255
+ const addr = server.address();
256
+ announcedPort = typeof addr === 'object' && addr ? addr.port : 0;
257
+ parentPort!.postMessage({ type: HTTP_PORT, port: announcedPort });
258
+ });
259
+ }
260
+ return server;
261
+ }
262
+
263
+ /* ── worker port tracking — shared by gateway and proxy middleware ─────── */
264
+
265
+ export interface WorkerHttpPorts {
266
+ /** Announced internal listener ports, keyed by worker instance (live map). */
267
+ readonly ports: ReadonlyMap<Worker, number>;
268
+ /**
269
+ * Attach to any new workers in `source.workers` and re-query ones that
270
+ * haven't announced — call it before resolving routes so respawns (and
271
+ * announcements that raced the initial attach) are picked up.
272
+ */
273
+ refresh(): void;
274
+ /** Detach all message listeners. */
275
+ dispose(): void;
276
+ }
277
+
278
+ /**
279
+ * Collect HTTP_PORT announcements from a pool's workers — for the
280
+ * gateway/proxy topology where workers listen on internal TCP ports.
281
+ * Announcements are a handshake, not a broadcast: workers that haven't
282
+ * announced get an HTTP_PORT_QUERY so trackers attached late still learn
283
+ * their ports.
284
+ */
285
+ export function workerHttpPorts(source: WorkerSource): WorkerHttpPorts {
286
+ const ports = new Map<Worker, number>();
287
+ const listeners = new Map<Worker, (e: MessageEvent) => void>();
288
+ const refresh = () => {
289
+ for (const w of source.workers) {
290
+ if (!listeners.has(w)) {
291
+ const listener = (e: MessageEvent) => {
292
+ const d = e.data as { type?: string; port?: number } | undefined;
293
+ if (d?.type === HTTP_PORT && typeof d.port === 'number') ports.set(w, d.port);
294
+ };
295
+ listeners.set(w, listener);
296
+ w.addEventListener('message', listener);
297
+ }
298
+ if (!ports.has(w)) {
299
+ try {
300
+ w.postMessage({ type: HTTP_PORT_QUERY });
301
+ } catch {
302
+ /* worker terminated between snapshot read and post — next refresh retries */
303
+ }
304
+ }
305
+ }
306
+ };
307
+ refresh();
308
+ return {
309
+ ports,
310
+ refresh,
311
+ dispose: () => {
312
+ for (const [w, l] of listeners) w.removeEventListener('message', l);
313
+ listeners.clear();
314
+ },
315
+ };
316
+ }
317
+
318
+ /** Forward one request to a worker-owned listener and pipe the reply back. */
319
+ const forwardToPort = (
320
+ req: IncomingMessage,
321
+ res: ServerResponse,
322
+ port: number,
323
+ path: string,
324
+ ): void => {
325
+ const upstream = httpRequest(
326
+ {
327
+ host: '127.0.0.1',
328
+ port,
329
+ method: req.method,
330
+ path: path.startsWith('/') ? path : `/${path}`,
331
+ headers: { ...req.headers, host: `127.0.0.1:${port}` },
332
+ },
333
+ (ures) => {
334
+ res.writeHead(ures.statusCode ?? 502, ures.headers);
335
+ ures.pipe(res);
336
+ },
337
+ );
338
+ upstream.on('error', () => {
339
+ if (!res.headersSent) res.writeHead(502);
340
+ res.end();
341
+ });
342
+ req.pipe(upstream);
343
+ };
344
+
345
+ export interface ProxyToWorkerOptions {
346
+ /** The pool whose workers announce internal HTTP ports. */
347
+ pool: WorkerSource;
348
+ /** Announcement tracker — from {@link workerHttpPorts} (refreshed per request). */
349
+ tracker: WorkerHttpPorts;
350
+ /** Which worker serves this route — a slot index into the live
351
+ * `pool.workers` snapshot, or a selector (e.g. round-robin). */
352
+ worker: number | ((workers: readonly Worker[]) => Worker | undefined);
353
+ /**
354
+ * Prefix prepended to `req.url` before forwarding. When mounted via
355
+ * `app.use('/prefix', mw)` Express has already stripped the mount — set
356
+ * `to: '/prefix'` to restore it for the worker's routes.
357
+ */
358
+ to?: string;
359
+ }
360
+
361
+ /**
362
+ * A mountable RequestListener that proxies to one pool worker's internal
363
+ * listener — the piece that lets a host framework (Nest, Express, …) house
364
+ * part of its API inside workers without giving up its own server:
365
+ *
366
+ * const tracker = workerHttpPorts(pool);
367
+ * app.use('/api/housed', proxyToWorker({
368
+ * pool, tracker, to: '/api/housed',
369
+ * worker: (w) => w[i++ % w.length], // or 0 to pin a slot
370
+ * }));
371
+ */
372
+ export function proxyToWorker(options: ProxyToWorkerOptions): RequestListener {
373
+ return (req, res) => {
374
+ const port = resolveWorkerPort(options);
375
+ if (port === undefined) {
376
+ res.writeHead(503, { 'content-type': 'application/json' });
377
+ res.end(JSON.stringify({ error: 'worker not listening yet' }));
378
+ return;
379
+ }
380
+ forwardToPort(req, res, port, (options.to ?? '') + (req.url ?? '/'));
381
+ };
382
+ }
383
+
384
+ /** Resolve the target worker's announced port for proxy/cluster options. */
385
+ const resolveWorkerPort = (options: ProxyToWorkerOptions): number | undefined => {
386
+ options.tracker.refresh();
387
+ const workers = options.pool.workers;
388
+ const worker =
389
+ typeof options.worker === 'number' ? workers[options.worker] : options.worker(workers);
390
+ return worker ? options.tracker.ports.get(worker) : undefined;
391
+ };
392
+
393
+ /**
394
+ * Tunnel an HTTP Upgrade (WebSocket) handshake to a worker-owned listener:
395
+ * replay the request line + headers verbatim against the worker's internal
396
+ * port (rawHeaders preserves order, case, and duplicates), then splice the
397
+ * two sockets into a raw bidirectional pipe. `head` is the first packet of
398
+ * the upgraded stream the parser already consumed — it MUST be written after
399
+ * the headers or the peer's first frames are lost.
400
+ */
401
+ const tunnelUpgradeToPort = (
402
+ req: IncomingMessage,
403
+ socket: Socket,
404
+ head: Buffer,
405
+ port: number,
406
+ path: string,
407
+ ): void => {
408
+ const upstream = netConnect({ host: '127.0.0.1', port }, () => {
409
+ const lines = [`${req.method} ${path} HTTP/${req.httpVersion}`];
410
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
411
+ const key = req.rawHeaders[i];
412
+ const value = key.toLowerCase() === 'host' ? `127.0.0.1:${port}` : req.rawHeaders[i + 1];
413
+ lines.push(`${key}: ${value}`);
414
+ }
415
+ upstream.write(lines.join('\r\n') + '\r\n\r\n');
416
+ if (head?.length) upstream.write(head);
417
+ socket.pipe(upstream).pipe(socket);
418
+ });
419
+ upstream.on('error', () => socket.destroy());
420
+ socket.on('error', () => upstream.destroy());
421
+ };
422
+
423
+ /**
424
+ * The upgrade-side sibling of {@link proxyToWorker}: middleware never sees
425
+ * WebSocket handshakes (Node emits 'upgrade' on the server, bypassing
426
+ * request listeners), so attach the returned listener to your server:
427
+ *
428
+ * const tracker = workerHttpPorts(pool);
429
+ * app.use('/api/housed', proxyToWorker({ pool, tracker, to: '/api/housed', worker }));
430
+ * app.getHttpServer().on('upgrade',
431
+ * proxyUpgradeToWorker({ pool, tracker, worker })); // full URL — `to` not needed
432
+ *
433
+ * Unlike the middleware, an 'upgrade' listener receives the ORIGINAL url
434
+ * (nothing stripped it), so `to` is a rewrite prefix rather than a mount
435
+ * restore — usually omit it.
436
+ */
437
+ export function proxyUpgradeToWorker(
438
+ options: ProxyToWorkerOptions,
439
+ ): (req: IncomingMessage, socket: Socket, head: Buffer) => void {
440
+ return (req, socket, head) => {
441
+ const port = resolveWorkerPort(options);
442
+ if (port === undefined) {
443
+ socket.destroy();
444
+ return;
445
+ }
446
+ tunnelUpgradeToPort(req, socket, head, port, (options.to ?? '') + (req.url ?? '/'));
447
+ };
448
+ }
449
+
450
+ /* ── gateway: path-level routing on the main thread ───────────────────────
451
+ Clustering routes per-CONNECTION — it can't split a listener by URL
452
+ path (the accepting thread never reads request bytes). To pin routes to
453
+ worker A vs worker B vs the main thread, the main thread must parse HTTP
454
+ and forward — this gateway does exactly that: workers listen on internal
455
+ 127.0.0.1 ports (serveHttp({ listen })), and matched prefixes proxy to the
456
+ chosen pool worker; unmatched requests run in the main-thread handler.
457
+ Works on any Node version. */
458
+
459
+ export interface GatewayRoute {
460
+ /** URL prefix that owns this route, e.g. '/api/a/'. */
461
+ prefix: string;
462
+ /** Which pool worker serves the prefix — a slot index into the live
463
+ * `pool.workers` snapshot, or a selector. Respawns are picked up. */
464
+ worker: number | ((workers: readonly Worker[]) => Worker | undefined);
465
+ /** Rewrite target prefix — the worker sees `to + url.slice(prefix.length)`.
466
+ * Defaults to '/', so `/api/a/x` arrives at the worker as `/x`. */
467
+ to?: string;
468
+ }
469
+
470
+ export interface RouteHttpGatewayOptions {
471
+ /** The pool whose workers announce internal HTTP ports. */
472
+ pool: WorkerSource;
473
+ port?: number;
474
+ host?: string;
475
+ /** BYO http.Server (the gateway attaches its request handler). */
476
+ server?: HttpServer;
477
+ routes?: GatewayRoute[];
478
+ /** Everything unmatched runs on the main thread; defaults to 404. */
479
+ handler?: RequestListener;
480
+ /**
481
+ * Fallback for unmatched Upgrade (WebSocket) handshakes — e.g. attach your
482
+ * own ws server's handler to keep WS routes on the main thread. When
483
+ * omitted the socket is destroyed, unless another 'upgrade' listener is
484
+ * registered on the server (then it's left to that listener).
485
+ */
486
+ onUpgrade?: (req: IncomingMessage, socket: Socket, head: Buffer) => void;
487
+ onListen?: (server: HttpServer) => void;
488
+ }
489
+
490
+ export interface HttpGateway {
491
+ server: HttpServer;
492
+ /** Announced internal listener ports, keyed by worker instance (live map). */
493
+ ports: ReadonlyMap<Worker, number>;
494
+ close(): Promise<void>;
495
+ }
496
+
497
+ /** Main thread: parse HTTP once and proxy matched prefixes to worker-owned
498
+ * listeners — the mixed topology: some routes in worker A, some in worker
499
+ * B, the rest served right here. Upgrade (WebSocket) handshakes match the
500
+ * same prefix table and are TUNNELED — the handshake is replayed to the
501
+ * worker's listener and the two sockets spliced, so frames flow end-to-end
502
+ * without the main thread touching them again. */
503
+ export function routeHttpGateway(options: RouteHttpGatewayOptions): HttpGateway {
504
+ const tracker = workerHttpPorts(options.pool);
505
+ const fallback: RequestListener =
506
+ options.handler ?? ((_req, res) => res.writeHead(404).end());
507
+ const server = options.server ?? createHttpServer();
508
+
509
+ const resolveRoute = (url: string) => {
510
+ tracker.refresh(); // respawned workers re-announce on the next request
511
+ const workers = options.pool.workers;
512
+ const route = options.routes?.find((r) => url.startsWith(r.prefix));
513
+ const worker = route
514
+ ? typeof route.worker === 'number'
515
+ ? workers[route.worker]
516
+ : route.worker(workers)
517
+ : undefined;
518
+ return { route, port: worker ? tracker.ports.get(worker) : undefined };
519
+ };
520
+
521
+ server.on('request', (req: IncomingMessage, res: ServerResponse) => {
522
+ const url = req.url ?? '/';
523
+ const { route, port } = resolveRoute(url);
524
+ if (!route) {
525
+ fallback(req, res);
526
+ return;
527
+ }
528
+ if (port === undefined) {
529
+ res.writeHead(503, { 'content-type': 'application/json' });
530
+ res.end(JSON.stringify({ error: 'worker not listening yet' }));
531
+ return;
532
+ }
533
+ forwardToPort(req, res, port, (route.to ?? '/') + url.slice(route.prefix.length));
534
+ });
535
+
536
+ // Upgrade handshakes bypass 'request' entirely — match the same prefix
537
+ // table and splice the client socket onto a fresh connection to the
538
+ // worker's listener. After the handshake the main thread is out of the
539
+ // data path: frames tunnel socket↔socket.
540
+ server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
541
+ const url = req.url ?? '/';
542
+ const { route, port } = resolveRoute(url);
543
+ if (!route || port === undefined) {
544
+ if (options.onUpgrade) {
545
+ options.onUpgrade(req, socket, head);
546
+ } else if (server.listenerCount('upgrade') <= 1) {
547
+ // No fallback and nobody else claimed it — preserve Node's
548
+ // no-listener behavior (destroy) instead of leaking the socket.
549
+ socket.destroy();
550
+ }
551
+ return;
552
+ }
553
+ tunnelUpgradeToPort(req, socket, head, port, (route.to ?? '/') + url.slice(route.prefix.length));
554
+ });
555
+
556
+ if (!options.server) {
557
+ server.listen(options.port ?? 8080, options.host, () => options.onListen?.(server));
558
+ } else {
559
+ options.onListen?.(server);
560
+ }
561
+ return {
562
+ server,
563
+ ports: tracker.ports,
564
+ close: () =>
565
+ new Promise<void>((resolve, reject) =>
566
+ server.close((err) => (err ? reject(err) : resolve())),
567
+ ),
568
+ };
569
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
- // Node runtime adapter for the Atoll — node:worker_threads pools without a
2
- // framework. Worker entries must import '@atolljs/node/shim' first.
3
- export { NodeWorkerAdapter, createNodeWorker } from './worker';
4
- export type { WorkerErrorEvent } from './worker';
5
- export { createNodePool } from './pool';
6
- export type { NodePoolConfig } from './pool';
1
+ // Node runtime adapter for the Atoll — node:worker_threads pools without a
2
+ // framework. Worker entries must import '@atolljs/node/shim' first.
3
+ export { NodeWorkerAdapter, createNodeWorker } from './worker';
4
+ export type { WorkerErrorEvent } from './worker';
5
+ export { createNodePool } from './pool';
6
+ export type { NodePoolConfig } from './pool';
7
+ export { bindSharedBuffer, withSharedBuffer } from './sharedBuffer';
package/src/redis.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Redis persistence for shared-memory contracts — the shared-memory analog
3
+ * of NestJS's Redis WebSocket adapter. The SharedArrayBuffer stays the
4
+ * synchronous source of truth on this thread (the contract's read/write
5
+ * surface can't be async); the adapter mirrors field regions to a Redis hash
6
+ * so state survives restarts, and optionally replicates writes across
7
+ * processes through a pub/sub channel.
8
+ *
9
+ * Layout in Redis:
10
+ * {key}:{name} hash — member = field path, value = base64 bytes
11
+ * {key}:{name}:ops channel — { src, path, b64 } per dirty field
12
+ *
13
+ * Change detection is the version-counter block every write already bumps
14
+ * (Atomics), polled on `syncIntervalMs` — no instrumentation of the hot
15
+ * write path. Remote applies bump the local version too, so `observe()`/
16
+ * waitAsync watchers fire exactly as they do for local writes.
17
+ *
18
+ * The client surface is intentionally minimal (base64 strings, hset/hgetall)
19
+ * — ioredis and node-redis both satisfy it directly; `publish` is only used
20
+ * when replication is enabled.
21
+ */
22
+ import { scoped, type SharedMemory, type SharedSpec } from '@atolljs/core';
23
+
24
+ const log = scoped('redis-mem');
25
+
26
+ /** The hash commands the adapter needs — ioredis and node-redis both fit. */
27
+ export interface RedisHashClient {
28
+ hset(key: string, field: string, value: string): Promise<unknown>;
29
+ hgetall(key: string): Promise<Record<string, string>>;
30
+ publish?(channel: string, message: string): Promise<unknown>;
31
+ }
32
+
33
+ /**
34
+ * Normalized pub/sub surface — node-redis's `client.subscribe(ch, listener)`
35
+ * matches directly; wrap an ioredis connection with `ioRedisSubscriber`.
36
+ */
37
+ export interface RedisSubscriber {
38
+ subscribe(
39
+ channel: string,
40
+ onMessage: (channel: string, message: string) => void,
41
+ ): unknown;
42
+ unsubscribe?(channel: string): unknown;
43
+ }
44
+
45
+ /** Wrap an ioredis connection in the normalized subscriber surface. */
46
+ export const ioRedisSubscriber = (client: {
47
+ subscribe(channel: string): unknown;
48
+ unsubscribe?(channel: string): unknown;
49
+ on(event: 'message', cb: (channel: string, message: string) => void): unknown;
50
+ }): RedisSubscriber => ({
51
+ subscribe: (channel, onMessage) => {
52
+ client.on('message', (c, m) => {
53
+ if (c === channel) onMessage(c, m);
54
+ });
55
+ return client.subscribe(channel);
56
+ },
57
+ unsubscribe: (channel) => client.unsubscribe?.(channel),
58
+ });
59
+
60
+ export interface RedisMemoryAdapterOptions {
61
+ client: RedisHashClient;
62
+ /** Hash key root — the contract lives at `${key}:${name}`. Default 'atoll:mem'. */
63
+ key?: string;
64
+ /** Contract namespace under the key root. Default 'default'. */
65
+ name?: string;
66
+ /** Version-diff poll cadence — dirty fields flush at most this often. Default 100ms. */
67
+ syncIntervalMs?: number;
68
+ /** Restrict persistence to a subset of field paths. */
69
+ fields?: string[];
70
+ /**
71
+ * A second Redis connection subscribed to `${key}:${name}:ops` — enables
72
+ * cross-process replication. Each instance publishes its dirty fields;
73
+ * subscribers apply the bytes into their own buffer and bump the local
74
+ * version counter so local observers fire. Last write wins per field.
75
+ */
76
+ subscriber?: RedisSubscriber;
77
+ /** Echo guard — defaults to pid + random. Override for deterministic tests. */
78
+ instanceId?: string;
79
+ }
80
+
81
+ export interface RedisMemoryPersistence {
82
+ /** Resolves when initial restore + subscriber attach complete. */
83
+ readonly ready: Promise<void>;
84
+ /** Flush dirty fields now — also runs automatically on the poll interval. */
85
+ flush(): Promise<number>;
86
+ /** Final flush, unsubscribe, and stop the poll timer. */
87
+ stop(): Promise<void>;
88
+ }
89
+
90
+ interface FieldRegion {
91
+ path: string;
92
+ byteOffset: number;
93
+ byteLength: number;
94
+ version?: { view: Int32Array; index: number };
95
+ }
96
+
97
+ /**
98
+ * Attach Redis persistence to a bound (or soon-bound) shared-memory contract.
99
+ * `ready` resolves once state has been restored INTO the local buffer —
100
+ * workers that bind afterwards see the restored state, and version bumps
101
+ * wake observers on this thread.
102
+ */
103
+ export function persistSharedMemory<S extends SharedSpec>(
104
+ memory: SharedMemory<S>,
105
+ options: RedisMemoryAdapterOptions,
106
+ ): RedisMemoryPersistence {
107
+ const { client, subscriber } = options;
108
+ const key = `${options.key ?? 'atoll:mem'}:${options.name ?? 'default'}`;
109
+ const channel = `${key}:ops`;
110
+ const intervalMs = options.syncIntervalMs ?? 100;
111
+ const instanceId =
112
+ options.instanceId ?? `${process.pid}:${Math.random().toString(36).slice(2, 8)}`;
113
+
114
+ let buffer: SharedArrayBuffer;
115
+ let regions: FieldRegion[] = [];
116
+ const lastSeen = new Map<string, number>();
117
+ let timer: ReturnType<typeof setInterval> | undefined;
118
+ let stopped = false;
119
+ let flushing = Promise.resolve(0);
120
+
121
+ const b64encode = (u8: Uint8Array): string =>
122
+ Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString('base64');
123
+ const b64decode = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'base64'));
124
+
125
+ const fieldRegion = (path: string): FieldRegion | undefined =>
126
+ regions.find((r) => r.path === path);
127
+
128
+ /**
129
+ * Write bytes into the field's region and bump its local version counter —
130
+ * the same signal a connector write produces, so waitAsync observers and
131
+ * our own diff both see it. Marks lastSeen so the flush doesn't echo the
132
+ * applied value straight back to Redis (no publish ping-pong).
133
+ */
134
+ const applyRemote = (path: string, b64: string): void => {
135
+ const region = fieldRegion(path);
136
+ if (!region) return;
137
+ const bytes = b64decode(b64);
138
+ if (bytes.byteLength !== region.byteLength) {
139
+ log.warn(`"${path}": remote field is ${bytes.byteLength}B but the contract reserves ${region.byteLength}B — skipped`);
140
+ return;
141
+ }
142
+ new Uint8Array(buffer, region.byteOffset, region.byteLength).set(bytes);
143
+ if (region.version) {
144
+ Atomics.add(region.version.view, region.version.index, 1);
145
+ Atomics.notify(region.version.view, region.version.index);
146
+ lastSeen.set(path, Atomics.load(region.version.view, region.version.index));
147
+ }
148
+ };
149
+
150
+ /** Flush fields whose version counter moved since the last flush. */
151
+ const flush = async (): Promise<number> => {
152
+ if (stopped) return 0;
153
+ let dirty = 0;
154
+ for (const region of regions) {
155
+ const current = region.version
156
+ ? Atomics.load(region.version.view, region.version.index)
157
+ : 0;
158
+ if (lastSeen.get(region.path) === current) continue;
159
+ lastSeen.set(region.path, current);
160
+ dirty++;
161
+ const b64 = b64encode(new Uint8Array(buffer, region.byteOffset, region.byteLength));
162
+ await client.hset(key, region.path, b64);
163
+ if (subscriber) {
164
+ await client.publish?.(
165
+ channel,
166
+ JSON.stringify({ src: instanceId, path: region.path, b64 }),
167
+ );
168
+ }
169
+ }
170
+ return dirty;
171
+ };
172
+
173
+ const restore = async (): Promise<void> => {
174
+ const all = (await client.hgetall(key)) ?? {};
175
+ let restored = 0;
176
+ for (const [path, b64] of Object.entries(all)) {
177
+ applyRemote(path, b64);
178
+ restored++;
179
+ }
180
+ if (restored) log.info(`restored ${restored} field(s) from ${key}`);
181
+ };
182
+
183
+ const ready = new Promise<void>((resolve, reject) => {
184
+ memory.onBound(() => {
185
+ buffer = memory.buffer;
186
+ regions = memory
187
+ .fields()
188
+ .filter((f) => !options.fields || options.fields.includes(f.path))
189
+ .map((f) => ({ ...f, version: memory.connector(f.path)._version }));
190
+ for (const r of regions) {
191
+ if (r.version) {
192
+ lastSeen.set(r.path, Atomics.load(r.version.view, r.version.index));
193
+ }
194
+ }
195
+ (async () => {
196
+ await restore();
197
+ if (subscriber) {
198
+ await subscriber.subscribe(channel, (_ch, message) => {
199
+ try {
200
+ const msg = JSON.parse(message) as { src?: string; path?: string; b64?: string };
201
+ if (msg.src === instanceId || !msg.path || !msg.b64) return;
202
+ applyRemote(msg.path, msg.b64);
203
+ } catch (e) {
204
+ log.warn('malformed replication message', e);
205
+ }
206
+ });
207
+ }
208
+ timer = setInterval(() => {
209
+ flushing = flushing.then(flush).catch((e) => {
210
+ log.warn('flush failed', e);
211
+ return 0;
212
+ });
213
+ }, intervalMs);
214
+ timer.unref?.();
215
+ })().then(resolve, reject);
216
+ });
217
+ });
218
+
219
+ return {
220
+ ready,
221
+ // Serialize manual flushes behind the interval's — concurrent hset on
222
+ // the same members is pointless churn.
223
+ flush: () => (flushing = flushing.then(flush).catch(() => 0)),
224
+ stop: async () => {
225
+ if (stopped) return;
226
+ stopped = true;
227
+ if (timer) clearInterval(timer);
228
+ await subscriber?.unsubscribe?.(channel);
229
+ await flushing.catch(() => 0);
230
+ // One last flush — durability on shutdown — bypass the stopped guard.
231
+ stopped = false;
232
+ try {
233
+ await flush();
234
+ } finally {
235
+ stopped = true;
236
+ }
237
+ },
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Pool-config form — the `persistence` option shape for `createNodePool` /
243
+ * `AtollModule.registerPool`. Nest's `app.useWebSocketAdapter()` analog:
244
+ *
245
+ * persistence: redisMemoryAdapter(redis, { name: 'incidents' })
246
+ */
247
+ export function redisMemoryAdapter(
248
+ client: RedisHashClient,
249
+ options: Omit<RedisMemoryAdapterOptions, 'client'> = {},
250
+ ): (memory: SharedMemory) => RedisMemoryPersistence {
251
+ return (memory) => persistSharedMemory(memory, { ...options, client });
252
+ }
@@ -0,0 +1,76 @@
1
+ // Hand an existing shared buffer to workers that aren't bound through a
2
+ // pool's INIT_MEMORY handshake — e.g. a second, message-only pool of HTTP
3
+ // workers that should read another pool's contract buffer. A pool's
4
+ // sharedMemory config creates a NEW WebAssembly.Memory, so sharing requires
5
+ // passing the buffer explicitly; this module packages both halves.
6
+ //
7
+ // // main — wraps a worker factory; bundler detection still sees the
8
+ // // literal `new Worker(new URL(...))` inside the thunk:
9
+ // worker: withSharedBuffer(
10
+ // () => new Worker(new URL('./housed.worker.ts', import.meta.url)),
11
+ // () => getAtollPool('incidents')?.sharedBuffer, // per spawn — respawns included
12
+ // ),
13
+ //
14
+ // // worker entry — resolves once the buffer lands, binds all contracts:
15
+ // await bindSharedBuffer();
16
+ import { isMainThread, parentPort, workerData } from 'node:worker_threads';
17
+ import type { Worker as NodeWorker } from 'node:worker_threads';
18
+ import { bindSharedMemories } from '@atolljs/core';
19
+
20
+ /** Wire id for the share-buffer message — distinct from pool protocol types. */
21
+ const SHARED_BUFFER = 'SHARED_BUFFER';
22
+
23
+ /**
24
+ * Main thread: wraps a worker factory so each spawned worker receives a
25
+ * shared buffer over its message channel. The buffer may be a thunk —
26
+ * evaluated per spawn, so respawned workers get it too. An unresolved
27
+ * buffer still posts (empty), so the worker fails loudly instead of hanging.
28
+ */
29
+ export function withSharedBuffer<W extends Worker | NodeWorker>(
30
+ spawn: () => W,
31
+ buffer: SharedArrayBuffer | (() => SharedArrayBuffer | undefined),
32
+ ): () => W {
33
+ return () => {
34
+ const worker = spawn();
35
+ const shared = typeof buffer === 'function' ? buffer() : buffer;
36
+ // Messages posted before the worker subscribes are queued by Node —
37
+ // the listener in bindSharedBuffer sees it as soon as the entry runs.
38
+ worker.postMessage({ type: SHARED_BUFFER, buffer: shared });
39
+ return worker;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Worker side: resolve this worker's shared buffer and bind every defined
45
+ * contract to it (the same call the worker bootstrap makes on INIT_MEMORY).
46
+ * Checks `workerData.buffer` first — the manual channel — then waits for the
47
+ * message {@link withSharedBuffer} posts. Rejects on an empty buffer or after
48
+ * `timeoutMs` so misconfigurations surface instead of hanging.
49
+ */
50
+ export function bindSharedBuffer(timeoutMs = 10_000): Promise<SharedArrayBuffer> {
51
+ const fromWorkerData = (workerData as { buffer?: SharedArrayBuffer } | null)?.buffer;
52
+ if (fromWorkerData) {
53
+ bindSharedMemories(fromWorkerData);
54
+ return Promise.resolve(fromWorkerData);
55
+ }
56
+ if (isMainThread || !parentPort) {
57
+ return Promise.reject(new Error('bindSharedBuffer() must be called inside a worker'));
58
+ }
59
+ const port = parentPort;
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(
62
+ () => reject(new Error(`bindSharedBuffer(): no shared buffer arrived within ${timeoutMs}ms`)),
63
+ timeoutMs,
64
+ );
65
+ port.on('message', (msg: { type?: string; buffer?: SharedArrayBuffer }) => {
66
+ if (msg?.type !== SHARED_BUFFER) return;
67
+ clearTimeout(timer);
68
+ if (!msg.buffer) {
69
+ reject(new Error('bindSharedBuffer(): the producing side sent an empty buffer'));
70
+ return;
71
+ }
72
+ bindSharedMemories(msg.buffer);
73
+ resolve(msg.buffer);
74
+ });
75
+ });
76
+ }
package/src/worker.ts CHANGED
@@ -24,8 +24,12 @@ export class NodeWorkerAdapter {
24
24
 
25
25
  constructor(private readonly worker: NodeWorker) {}
26
26
 
27
- postMessage(message: unknown): void {
28
- this.worker.postMessage(message);
27
+ postMessage(message: unknown, transferList?: unknown[]): void {
28
+ if (transferList === undefined) {
29
+ this.worker.postMessage(message);
30
+ } else {
31
+ this.worker.postMessage(message, transferList as never);
32
+ }
29
33
  }
30
34
 
31
35
  terminate(): void {