@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 +97 -14
- package/package.json +10 -3
- package/src/http.ts +569 -0
- package/src/index.ts +7 -6
- package/src/redis.ts +252 -0
- package/src/sharedBuffer.ts +76 -0
- package/src/worker.ts +6 -2
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
|
|
5
|
-
surface they expect.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|