@atolljs/node 0.1.4 → 0.1.6
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/dist/chunk-QD4C3PWH.js +697 -0
- package/dist/http.d.ts +212 -0
- package/dist/http.js +261 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +127 -0
- package/dist/pool.d.ts +32 -0
- package/dist/redis.d.ts +84 -0
- package/dist/redis.js +136 -0
- package/dist/sharedBuffer.d.ts +16 -0
- package/dist/shim.d.ts +1 -0
- package/dist/shim.js +5 -0
- package/dist/worker.d.ts +46 -0
- package/package.json +23 -7
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Server as HttpServer, type IncomingMessage, type RequestListener } from 'node:http';
|
|
2
|
+
import { type Server as NetServer, type Socket } from 'node:net';
|
|
3
|
+
/** Wire id a worker uses to announce an internally-listening HTTP port. */
|
|
4
|
+
export declare const HTTP_PORT = "HTTP_PORT";
|
|
5
|
+
export declare const SOCKET_TRANSFER_SUPPORTED: boolean;
|
|
6
|
+
/** Anything that exposes its workers — WorkerPool is the usual source. */
|
|
7
|
+
export interface WorkerSource {
|
|
8
|
+
readonly workers: readonly Worker[];
|
|
9
|
+
}
|
|
10
|
+
export interface HttpClusterOptions {
|
|
11
|
+
/** The pool whose workers serve connections (respawns picked up on read). */
|
|
12
|
+
pool: WorkerSource;
|
|
13
|
+
/** Port to listen on. Ignored when `server` is provided. */
|
|
14
|
+
port?: number;
|
|
15
|
+
/** Listen host. Ignored when `server` is provided. */
|
|
16
|
+
host?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Bring your own listener — MUST be created with `pauseOnConnect: true`
|
|
19
|
+
* (e.g. `net.createServer({ pauseOnConnect: true })`) or the main thread
|
|
20
|
+
* may consume request bytes before the socket is transferred.
|
|
21
|
+
*/
|
|
22
|
+
server?: NetServer;
|
|
23
|
+
/**
|
|
24
|
+
* Connection → worker selection. Defaults to round-robin. Receives the
|
|
25
|
+
* fresh `pool.workers` snapshot each call, so respawns participate.
|
|
26
|
+
* Returning undefined destroys the socket. {@link stickyByAddress} is a
|
|
27
|
+
* ready-made sticky implementation for multi-connection session flows.
|
|
28
|
+
*/
|
|
29
|
+
route?: (socket: Socket, workers: readonly Worker[]) => Worker | undefined;
|
|
30
|
+
/** Called once the server is listening. */
|
|
31
|
+
onListen?: (server: NetServer) => void;
|
|
32
|
+
}
|
|
33
|
+
export interface HttpCluster {
|
|
34
|
+
/** The listening TCP server (the user's `server` when provided). */
|
|
35
|
+
server: NetServer;
|
|
36
|
+
/** Stop accepting; in-flight transferred connections stay with workers. */
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Main thread: cluster a pool behind one listener — accept connections and
|
|
41
|
+
* hand each socket to a pool worker. Pairs with the worker-side
|
|
42
|
+
* {@link serveHttp}:
|
|
43
|
+
*
|
|
44
|
+
* const pool = createNodePool({ worker: () => new Worker(new URL('./api.worker.js', import.meta.url)), sharedMemory, poolSize: 'auto' });
|
|
45
|
+
* const cluster = createHttpCluster({ pool, port: 8080 }); // null below Node 26
|
|
46
|
+
*
|
|
47
|
+
* Sockets transfer BEFORE any bytes are read — the worker's http.Server does
|
|
48
|
+
* all parsing. HTTPS termination is not supported here (the TLS handshake
|
|
49
|
+
* would run on the accept thread); terminate TLS upstream or serve plain
|
|
50
|
+
* HTTP behind a proxy.
|
|
51
|
+
*
|
|
52
|
+
* Returns `null` on runtimes without socket transfer (Node < 26 — see
|
|
53
|
+
* {@link SOCKET_TRANSFER_SUPPORTED}) after logging a one-line notice, so
|
|
54
|
+
* callers can write the cluster unconditionally and degrade to gateway-only
|
|
55
|
+
* behavior without wrapping the call in capability checks.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createHttpCluster(options: HttpClusterOptions): HttpCluster | null;
|
|
58
|
+
/**
|
|
59
|
+
* Sticky connection routing for {@link createHttpCluster}: every connection
|
|
60
|
+
* from the same client address lands on the same worker — rendezvous
|
|
61
|
+
* (highest-random-weight) hashing over `key + slot`, so removing a worker
|
|
62
|
+
* only remaps ITS clients instead of reshuffling the whole pool.
|
|
63
|
+
*
|
|
64
|
+
* A transferred socket pins its whole life to one worker, so a bare
|
|
65
|
+
* WebSocket connection never needs this — stickiness matters for
|
|
66
|
+
* multi-connection session flows: socket.io's polling→upgrade sequence,
|
|
67
|
+
* or an HTTP request that must precede/follow a WS connection on the same
|
|
68
|
+
* worker. The acceptor can't read headers or cookies, so client address
|
|
69
|
+
* (nginx ip_hash style) is the strongest key available — supply `key` to
|
|
70
|
+
* mix in more (e.g. localPort for multi-listener setups).
|
|
71
|
+
*
|
|
72
|
+
* createHttpCluster({ pool, port: 8080, route: stickyByAddress() });
|
|
73
|
+
*/
|
|
74
|
+
export declare function stickyByAddress(key?: (socket: Socket) => string): (socket: Socket, workers: readonly Worker[]) => Worker | undefined;
|
|
75
|
+
export interface ServeHttpOptions {
|
|
76
|
+
/**
|
|
77
|
+
* Listen on an internal TCP port and announce it to the parent thread via
|
|
78
|
+
* a `{ type: HTTP_PORT, port }` message — for the gateway topology where
|
|
79
|
+
* the main thread parses HTTP and proxies to worker-owned listeners
|
|
80
|
+
* (works on any Node version, unlike socket transfer). `0` = ephemeral.
|
|
81
|
+
* The server still accepts transferred sockets either way — `listen` only
|
|
82
|
+
* adds the internal gateway port.
|
|
83
|
+
*/
|
|
84
|
+
listen?: number;
|
|
85
|
+
/** Listen host; defaults to '127.0.0.1' so worker ports stay internal. */
|
|
86
|
+
host?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Worker side: serve HTTP with an http.Server owned by this worker. Call it
|
|
90
|
+
* once in the worker entry, after '@atolljs/node/shim':
|
|
91
|
+
*
|
|
92
|
+
* import '@atolljs/node/shim';
|
|
93
|
+
* import { serveHttp } from '@atolljs/node/http';
|
|
94
|
+
* import express from 'express';
|
|
95
|
+
*
|
|
96
|
+
* const app = express(); // any (req, res) handler works —
|
|
97
|
+
* app.get('/api', (_q, r) => …); // express/koa .callback()/fastify.server
|
|
98
|
+
* serveHttp(app); // sockets transferred by createHttpCluster
|
|
99
|
+
* serveHttp(app, { listen: 0 }); // internal listener for routeHttpGateway
|
|
100
|
+
*
|
|
101
|
+
* Accepts a RequestListener (wrapped in a new http.Server), an existing
|
|
102
|
+
* http.Server (e.g. `fastify().server` after ready()), or
|
|
103
|
+
* `{ handler }` / `{ server }`. A no-op on the main thread.
|
|
104
|
+
*/
|
|
105
|
+
export declare function serveHttp(target: RequestListener | HttpServer | {
|
|
106
|
+
handler?: RequestListener;
|
|
107
|
+
server?: HttpServer;
|
|
108
|
+
}, options?: ServeHttpOptions): HttpServer;
|
|
109
|
+
export interface WorkerHttpPorts {
|
|
110
|
+
/** Announced internal listener ports, keyed by worker instance (live map). */
|
|
111
|
+
readonly ports: ReadonlyMap<Worker, number>;
|
|
112
|
+
/**
|
|
113
|
+
* Attach to any new workers in `source.workers` and re-query ones that
|
|
114
|
+
* haven't announced — call it before resolving routes so respawns (and
|
|
115
|
+
* announcements that raced the initial attach) are picked up.
|
|
116
|
+
*/
|
|
117
|
+
refresh(): void;
|
|
118
|
+
/** Detach all message listeners. */
|
|
119
|
+
dispose(): void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Collect HTTP_PORT announcements from a pool's workers — for the
|
|
123
|
+
* gateway/proxy topology where workers listen on internal TCP ports.
|
|
124
|
+
* Announcements are a handshake, not a broadcast: workers that haven't
|
|
125
|
+
* announced get an HTTP_PORT_QUERY so trackers attached late still learn
|
|
126
|
+
* their ports.
|
|
127
|
+
*/
|
|
128
|
+
export declare function workerHttpPorts(source: WorkerSource): WorkerHttpPorts;
|
|
129
|
+
export interface ProxyToWorkerOptions {
|
|
130
|
+
/** The pool whose workers announce internal HTTP ports. */
|
|
131
|
+
pool: WorkerSource;
|
|
132
|
+
/** Announcement tracker — from {@link workerHttpPorts} (refreshed per request). */
|
|
133
|
+
tracker: WorkerHttpPorts;
|
|
134
|
+
/** Which worker serves this route — a slot index into the live
|
|
135
|
+
* `pool.workers` snapshot, or a selector (e.g. round-robin). */
|
|
136
|
+
worker: number | ((workers: readonly Worker[]) => Worker | undefined);
|
|
137
|
+
/**
|
|
138
|
+
* Prefix prepended to `req.url` before forwarding. When mounted via
|
|
139
|
+
* `app.use('/prefix', mw)` Express has already stripped the mount — set
|
|
140
|
+
* `to: '/prefix'` to restore it for the worker's routes.
|
|
141
|
+
*/
|
|
142
|
+
to?: string;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A mountable RequestListener that proxies to one pool worker's internal
|
|
146
|
+
* listener — the piece that lets a host framework (Nest, Express, …) house
|
|
147
|
+
* part of its API inside workers without giving up its own server:
|
|
148
|
+
*
|
|
149
|
+
* const tracker = workerHttpPorts(pool);
|
|
150
|
+
* app.use('/api/housed', proxyToWorker({
|
|
151
|
+
* pool, tracker, to: '/api/housed',
|
|
152
|
+
* worker: (w) => w[i++ % w.length], // or 0 to pin a slot
|
|
153
|
+
* }));
|
|
154
|
+
*/
|
|
155
|
+
export declare function proxyToWorker(options: ProxyToWorkerOptions): RequestListener;
|
|
156
|
+
/**
|
|
157
|
+
* The upgrade-side sibling of {@link proxyToWorker}: middleware never sees
|
|
158
|
+
* WebSocket handshakes (Node emits 'upgrade' on the server, bypassing
|
|
159
|
+
* request listeners), so attach the returned listener to your server:
|
|
160
|
+
*
|
|
161
|
+
* const tracker = workerHttpPorts(pool);
|
|
162
|
+
* app.use('/api/housed', proxyToWorker({ pool, tracker, to: '/api/housed', worker }));
|
|
163
|
+
* app.getHttpServer().on('upgrade',
|
|
164
|
+
* proxyUpgradeToWorker({ pool, tracker, worker })); // full URL — `to` not needed
|
|
165
|
+
*
|
|
166
|
+
* Unlike the middleware, an 'upgrade' listener receives the ORIGINAL url
|
|
167
|
+
* (nothing stripped it), so `to` is a rewrite prefix rather than a mount
|
|
168
|
+
* restore — usually omit it.
|
|
169
|
+
*/
|
|
170
|
+
export declare function proxyUpgradeToWorker(options: ProxyToWorkerOptions): (req: IncomingMessage, socket: Socket, head: Buffer) => void;
|
|
171
|
+
export interface GatewayRoute {
|
|
172
|
+
/** URL prefix that owns this route, e.g. '/api/a/'. */
|
|
173
|
+
prefix: string;
|
|
174
|
+
/** Which pool worker serves the prefix — a slot index into the live
|
|
175
|
+
* `pool.workers` snapshot, or a selector. Respawns are picked up. */
|
|
176
|
+
worker: number | ((workers: readonly Worker[]) => Worker | undefined);
|
|
177
|
+
/** Rewrite target prefix — the worker sees `to + url.slice(prefix.length)`.
|
|
178
|
+
* Defaults to '/', so `/api/a/x` arrives at the worker as `/x`. */
|
|
179
|
+
to?: string;
|
|
180
|
+
}
|
|
181
|
+
export interface RouteHttpGatewayOptions {
|
|
182
|
+
/** The pool whose workers announce internal HTTP ports. */
|
|
183
|
+
pool: WorkerSource;
|
|
184
|
+
port?: number;
|
|
185
|
+
host?: string;
|
|
186
|
+
/** BYO http.Server (the gateway attaches its request handler). */
|
|
187
|
+
server?: HttpServer;
|
|
188
|
+
routes?: GatewayRoute[];
|
|
189
|
+
/** Everything unmatched runs on the main thread; defaults to 404. */
|
|
190
|
+
handler?: RequestListener;
|
|
191
|
+
/**
|
|
192
|
+
* Fallback for unmatched Upgrade (WebSocket) handshakes — e.g. attach your
|
|
193
|
+
* own ws server's handler to keep WS routes on the main thread. When
|
|
194
|
+
* omitted the socket is destroyed, unless another 'upgrade' listener is
|
|
195
|
+
* registered on the server (then it's left to that listener).
|
|
196
|
+
*/
|
|
197
|
+
onUpgrade?: (req: IncomingMessage, socket: Socket, head: Buffer) => void;
|
|
198
|
+
onListen?: (server: HttpServer) => void;
|
|
199
|
+
}
|
|
200
|
+
export interface HttpGateway {
|
|
201
|
+
server: HttpServer;
|
|
202
|
+
/** Announced internal listener ports, keyed by worker instance (live map). */
|
|
203
|
+
ports: ReadonlyMap<Worker, number>;
|
|
204
|
+
close(): Promise<void>;
|
|
205
|
+
}
|
|
206
|
+
/** Main thread: parse HTTP once and proxy matched prefixes to worker-owned
|
|
207
|
+
* listeners — the mixed topology: some routes in worker A, some in worker
|
|
208
|
+
* B, the rest served right here. Upgrade (WebSocket) handshakes match the
|
|
209
|
+
* same prefix table and are TUNNELED — the handshake is replayed to the
|
|
210
|
+
* worker's listener and the two sockets spliced, so frames flow end-to-end
|
|
211
|
+
* without the main thread touching them again. */
|
|
212
|
+
export declare function routeHttpGateway(options: RouteHttpGatewayOptions): HttpGateway;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// src/http.ts
|
|
2
|
+
import {
|
|
3
|
+
createServer as createHttpServer,
|
|
4
|
+
request as httpRequest,
|
|
5
|
+
Server as HttpServer
|
|
6
|
+
} from "node:http";
|
|
7
|
+
import {
|
|
8
|
+
connect as netConnect,
|
|
9
|
+
createServer as createNetServer
|
|
10
|
+
} from "node:net";
|
|
11
|
+
import { isMainThread, parentPort } from "node:worker_threads";
|
|
12
|
+
var HTTP_CONNECTION = "HTTP_CONNECTION";
|
|
13
|
+
var HTTP_PORT = "HTTP_PORT";
|
|
14
|
+
var HTTP_PORT_QUERY = "HTTP_PORT_QUERY";
|
|
15
|
+
var NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
16
|
+
var SOCKET_TRANSFER_SUPPORTED = NODE_MAJOR >= 26;
|
|
17
|
+
function assertSocketTransferSupported() {
|
|
18
|
+
if (!SOCKET_TRANSFER_SUPPORTED) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`serveHttp (without { listen }) requires Node.js >= 26 for net.Socket transfer across worker_threads (found ${process.versions.node}).`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function createHttpCluster(options) {
|
|
25
|
+
if (!SOCKET_TRANSFER_SUPPORTED) {
|
|
26
|
+
console.warn(
|
|
27
|
+
`[atoll] createHttpCluster skipped \u2014 net.Socket transfer requires Node.js >= 26 (found ${process.versions.node})`
|
|
28
|
+
);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
const route = options.route ?? ((_, workers) => workers.length === 0 ? void 0 : workers[cursor++ % workers.length]);
|
|
33
|
+
const server = options.server ?? createNetServer({ pauseOnConnect: true });
|
|
34
|
+
server.on("connection", (socket) => {
|
|
35
|
+
const worker = route(socket, options.pool.workers);
|
|
36
|
+
if (!worker) {
|
|
37
|
+
socket.destroy();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
worker.postMessage({ type: HTTP_CONNECTION, socket }, [
|
|
42
|
+
socket
|
|
43
|
+
]);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
socket.destroy();
|
|
46
|
+
server.emit("error", err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (!options.server) {
|
|
50
|
+
server.listen(options.port ?? 8080, options.host, () => options.onListen?.(server));
|
|
51
|
+
} else {
|
|
52
|
+
options.onListen?.(server);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
server,
|
|
56
|
+
close: () => new Promise(
|
|
57
|
+
(resolve, reject) => server.close((err) => err ? reject(err) : resolve())
|
|
58
|
+
)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
var fnv1a = (s) => {
|
|
62
|
+
let h = 2166136261;
|
|
63
|
+
for (let i = 0; i < s.length; i++) {
|
|
64
|
+
h ^= s.charCodeAt(i);
|
|
65
|
+
h = Math.imul(h, 16777619);
|
|
66
|
+
}
|
|
67
|
+
return h >>> 0;
|
|
68
|
+
};
|
|
69
|
+
function stickyByAddress(key = (s) => s.remoteAddress ?? "") {
|
|
70
|
+
return (socket, workers) => {
|
|
71
|
+
if (workers.length === 0) return void 0;
|
|
72
|
+
const k = key(socket);
|
|
73
|
+
let best;
|
|
74
|
+
let bestScore = -1;
|
|
75
|
+
workers.forEach((w, i) => {
|
|
76
|
+
const score = fnv1a(`${k}#${i}`);
|
|
77
|
+
if (score > bestScore) {
|
|
78
|
+
bestScore = score;
|
|
79
|
+
best = w;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return best;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function serveHttp(target, options) {
|
|
86
|
+
const server = target instanceof HttpServer ? target : typeof target === "function" ? createHttpServer(target) : target.server ?? createHttpServer(target.handler);
|
|
87
|
+
if (isMainThread || !parentPort) return server;
|
|
88
|
+
if (options?.listen === void 0) assertSocketTransferSupported();
|
|
89
|
+
let announcedPort;
|
|
90
|
+
parentPort.on("message", (msg) => {
|
|
91
|
+
if (msg?.type === HTTP_CONNECTION && msg.socket) {
|
|
92
|
+
server.emit("connection", msg.socket);
|
|
93
|
+
msg.socket.resume();
|
|
94
|
+
} else if (msg?.type === HTTP_PORT_QUERY && announcedPort !== void 0) {
|
|
95
|
+
parentPort.postMessage({ type: HTTP_PORT, port: announcedPort });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (options?.listen !== void 0) {
|
|
99
|
+
server.listen(options.listen, options.host ?? "127.0.0.1", () => {
|
|
100
|
+
const addr = server.address();
|
|
101
|
+
announcedPort = typeof addr === "object" && addr ? addr.port : 0;
|
|
102
|
+
parentPort.postMessage({ type: HTTP_PORT, port: announcedPort });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return server;
|
|
106
|
+
}
|
|
107
|
+
function workerHttpPorts(source) {
|
|
108
|
+
const ports = /* @__PURE__ */ new Map();
|
|
109
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
110
|
+
const refresh = () => {
|
|
111
|
+
for (const w of source.workers) {
|
|
112
|
+
if (!listeners.has(w)) {
|
|
113
|
+
const listener = (e) => {
|
|
114
|
+
const d = e.data;
|
|
115
|
+
if (d?.type === HTTP_PORT && typeof d.port === "number") ports.set(w, d.port);
|
|
116
|
+
};
|
|
117
|
+
listeners.set(w, listener);
|
|
118
|
+
w.addEventListener("message", listener);
|
|
119
|
+
}
|
|
120
|
+
if (!ports.has(w)) {
|
|
121
|
+
try {
|
|
122
|
+
w.postMessage({ type: HTTP_PORT_QUERY });
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
refresh();
|
|
129
|
+
return {
|
|
130
|
+
ports,
|
|
131
|
+
refresh,
|
|
132
|
+
dispose: () => {
|
|
133
|
+
for (const [w, l] of listeners) w.removeEventListener("message", l);
|
|
134
|
+
listeners.clear();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
var forwardToPort = (req, res, port, path) => {
|
|
139
|
+
const upstream = httpRequest(
|
|
140
|
+
{
|
|
141
|
+
host: "127.0.0.1",
|
|
142
|
+
port,
|
|
143
|
+
method: req.method,
|
|
144
|
+
path: path.startsWith("/") ? path : `/${path}`,
|
|
145
|
+
headers: { ...req.headers, host: `127.0.0.1:${port}` }
|
|
146
|
+
},
|
|
147
|
+
(ures) => {
|
|
148
|
+
res.writeHead(ures.statusCode ?? 502, ures.headers);
|
|
149
|
+
ures.pipe(res);
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
upstream.on("error", () => {
|
|
153
|
+
if (!res.headersSent) res.writeHead(502);
|
|
154
|
+
res.end();
|
|
155
|
+
});
|
|
156
|
+
req.pipe(upstream);
|
|
157
|
+
};
|
|
158
|
+
function proxyToWorker(options) {
|
|
159
|
+
return (req, res) => {
|
|
160
|
+
const port = resolveWorkerPort(options);
|
|
161
|
+
if (port === void 0) {
|
|
162
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
163
|
+
res.end(JSON.stringify({ error: "worker not listening yet" }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
forwardToPort(req, res, port, (options.to ?? "") + (req.url ?? "/"));
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
var resolveWorkerPort = (options) => {
|
|
170
|
+
options.tracker.refresh();
|
|
171
|
+
const workers = options.pool.workers;
|
|
172
|
+
const worker = typeof options.worker === "number" ? workers[options.worker] : options.worker(workers);
|
|
173
|
+
return worker ? options.tracker.ports.get(worker) : void 0;
|
|
174
|
+
};
|
|
175
|
+
var tunnelUpgradeToPort = (req, socket, head, port, path) => {
|
|
176
|
+
const upstream = netConnect({ host: "127.0.0.1", port }, () => {
|
|
177
|
+
const lines = [`${req.method} ${path} HTTP/${req.httpVersion}`];
|
|
178
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
179
|
+
const key = req.rawHeaders[i];
|
|
180
|
+
const value = key.toLowerCase() === "host" ? `127.0.0.1:${port}` : req.rawHeaders[i + 1];
|
|
181
|
+
lines.push(`${key}: ${value}`);
|
|
182
|
+
}
|
|
183
|
+
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
184
|
+
if (head?.length) upstream.write(head);
|
|
185
|
+
socket.pipe(upstream).pipe(socket);
|
|
186
|
+
});
|
|
187
|
+
upstream.on("error", () => socket.destroy());
|
|
188
|
+
socket.on("error", () => upstream.destroy());
|
|
189
|
+
};
|
|
190
|
+
function proxyUpgradeToWorker(options) {
|
|
191
|
+
return (req, socket, head) => {
|
|
192
|
+
const port = resolveWorkerPort(options);
|
|
193
|
+
if (port === void 0) {
|
|
194
|
+
socket.destroy();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
tunnelUpgradeToPort(req, socket, head, port, (options.to ?? "") + (req.url ?? "/"));
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function routeHttpGateway(options) {
|
|
201
|
+
const tracker = workerHttpPorts(options.pool);
|
|
202
|
+
const fallback = options.handler ?? ((_req, res) => res.writeHead(404).end());
|
|
203
|
+
const server = options.server ?? createHttpServer();
|
|
204
|
+
const resolveRoute = (url) => {
|
|
205
|
+
tracker.refresh();
|
|
206
|
+
const workers = options.pool.workers;
|
|
207
|
+
const route = options.routes?.find((r) => url.startsWith(r.prefix));
|
|
208
|
+
const worker = route ? typeof route.worker === "number" ? workers[route.worker] : route.worker(workers) : void 0;
|
|
209
|
+
return { route, port: worker ? tracker.ports.get(worker) : void 0 };
|
|
210
|
+
};
|
|
211
|
+
server.on("request", (req, res) => {
|
|
212
|
+
const url = req.url ?? "/";
|
|
213
|
+
const { route, port } = resolveRoute(url);
|
|
214
|
+
if (!route) {
|
|
215
|
+
fallback(req, res);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (port === void 0) {
|
|
219
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ error: "worker not listening yet" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
forwardToPort(req, res, port, (route.to ?? "/") + url.slice(route.prefix.length));
|
|
224
|
+
});
|
|
225
|
+
server.on("upgrade", (req, socket, head) => {
|
|
226
|
+
const url = req.url ?? "/";
|
|
227
|
+
const { route, port } = resolveRoute(url);
|
|
228
|
+
if (!route || port === void 0) {
|
|
229
|
+
if (options.onUpgrade) {
|
|
230
|
+
options.onUpgrade(req, socket, head);
|
|
231
|
+
} else if (server.listenerCount("upgrade") <= 1) {
|
|
232
|
+
socket.destroy();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
tunnelUpgradeToPort(req, socket, head, port, (route.to ?? "/") + url.slice(route.prefix.length));
|
|
237
|
+
});
|
|
238
|
+
if (!options.server) {
|
|
239
|
+
server.listen(options.port ?? 8080, options.host, () => options.onListen?.(server));
|
|
240
|
+
} else {
|
|
241
|
+
options.onListen?.(server);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
server,
|
|
245
|
+
ports: tracker.ports,
|
|
246
|
+
close: () => new Promise(
|
|
247
|
+
(resolve, reject) => server.close((err) => err ? reject(err) : resolve())
|
|
248
|
+
)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export {
|
|
252
|
+
HTTP_PORT,
|
|
253
|
+
SOCKET_TRANSFER_SUPPORTED,
|
|
254
|
+
createHttpCluster,
|
|
255
|
+
proxyToWorker,
|
|
256
|
+
proxyUpgradeToWorker,
|
|
257
|
+
routeHttpGateway,
|
|
258
|
+
serveHttp,
|
|
259
|
+
stickyByAddress,
|
|
260
|
+
workerHttpPorts
|
|
261
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { NodeWorkerAdapter, createNodeWorker } from './worker.js';
|
|
2
|
+
export type { WorkerErrorEvent } from './worker.js';
|
|
3
|
+
export { createNodePool } from './pool.js';
|
|
4
|
+
export type { NodePoolConfig } from './pool.js';
|
|
5
|
+
export { bindSharedBuffer, withSharedBuffer } from './sharedBuffer.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WorkerPool,
|
|
3
|
+
bindSharedMemories
|
|
4
|
+
} from "./chunk-QD4C3PWH.js";
|
|
5
|
+
|
|
6
|
+
// src/worker.ts
|
|
7
|
+
import { Worker as NodeWorker } from "node:worker_threads";
|
|
8
|
+
var NodeWorkerAdapter = class {
|
|
9
|
+
constructor(worker) {
|
|
10
|
+
this.worker = worker;
|
|
11
|
+
}
|
|
12
|
+
worker;
|
|
13
|
+
listeners = /* @__PURE__ */ new Map();
|
|
14
|
+
postMessage(message, transferList) {
|
|
15
|
+
if (transferList === void 0) {
|
|
16
|
+
this.worker.postMessage(message);
|
|
17
|
+
} else {
|
|
18
|
+
this.worker.postMessage(message, transferList);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
terminate() {
|
|
22
|
+
void this.worker.terminate();
|
|
23
|
+
}
|
|
24
|
+
addEventListener(type, handler) {
|
|
25
|
+
let map = this.listeners.get(handler);
|
|
26
|
+
if (!map) this.listeners.set(handler, map = /* @__PURE__ */ new Map());
|
|
27
|
+
if (type === "message") {
|
|
28
|
+
const wrapped = (data) => handler({ data });
|
|
29
|
+
map.set("message", wrapped);
|
|
30
|
+
this.worker.on("message", wrapped);
|
|
31
|
+
} else {
|
|
32
|
+
const onError = (error) => handler({ type: "error", message: error.message, error });
|
|
33
|
+
const onExit = (code) => {
|
|
34
|
+
if (code !== 0) {
|
|
35
|
+
handler({ type: "error", message: `worker exited with code ${code}` });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
map.set("error", onError);
|
|
39
|
+
map.set("exit", onExit);
|
|
40
|
+
this.worker.on("error", onError);
|
|
41
|
+
this.worker.on("exit", onExit);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
removeEventListener(type, handler) {
|
|
45
|
+
const map = this.listeners.get(handler);
|
|
46
|
+
if (!map) return;
|
|
47
|
+
const events = type === "message" ? ["message"] : ["error", "exit"];
|
|
48
|
+
for (const ev of events) {
|
|
49
|
+
const wrapped = map.get(ev);
|
|
50
|
+
if (wrapped) {
|
|
51
|
+
this.worker.off(ev, wrapped);
|
|
52
|
+
map.delete(ev);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (map.size === 0) this.listeners.delete(handler);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
function createNodeWorker(source, options) {
|
|
59
|
+
const worker = source instanceof NodeWorker ? source : new NodeWorker(source, options);
|
|
60
|
+
return new NodeWorkerAdapter(worker);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/pool.ts
|
|
64
|
+
import { Worker as NodeWorker2 } from "node:worker_threads";
|
|
65
|
+
var toWorker = (w) => w instanceof NodeWorker2 ? createNodeWorker(w) : w;
|
|
66
|
+
function createNodePool(config) {
|
|
67
|
+
const { workerFile, worker, createWorker, ...rest } = config;
|
|
68
|
+
const file = workerFile ?? (typeof worker === "function" ? void 0 : worker);
|
|
69
|
+
const factory = createWorker ?? (typeof worker === "function" ? () => toWorker(worker()) : void 0);
|
|
70
|
+
return new WorkerPool({
|
|
71
|
+
...rest,
|
|
72
|
+
createWorker: factory ?? (() => {
|
|
73
|
+
if (!file) {
|
|
74
|
+
throw new Error("createNodePool requires worker, workerFile or createWorker.");
|
|
75
|
+
}
|
|
76
|
+
return createNodeWorker(file);
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/sharedBuffer.ts
|
|
82
|
+
import { isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
83
|
+
var SHARED_BUFFER = "SHARED_BUFFER";
|
|
84
|
+
function withSharedBuffer(spawn, buffer) {
|
|
85
|
+
return () => {
|
|
86
|
+
const worker = spawn();
|
|
87
|
+
const shared = typeof buffer === "function" ? buffer() : buffer;
|
|
88
|
+
worker.postMessage({ type: SHARED_BUFFER, buffer: shared });
|
|
89
|
+
return worker;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function bindSharedBuffer(timeoutMs = 1e4) {
|
|
93
|
+
const fromWorkerData = workerData?.buffer;
|
|
94
|
+
if (fromWorkerData) {
|
|
95
|
+
bindSharedMemories(fromWorkerData);
|
|
96
|
+
return Promise.resolve(fromWorkerData);
|
|
97
|
+
}
|
|
98
|
+
if (isMainThread || !parentPort) {
|
|
99
|
+
return Promise.reject(new Error("bindSharedBuffer() must be called inside a worker"));
|
|
100
|
+
}
|
|
101
|
+
const port = parentPort;
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const onMessage = (msg) => {
|
|
104
|
+
if (msg?.type !== SHARED_BUFFER) return;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
port.off("message", onMessage);
|
|
107
|
+
if (!msg.buffer) {
|
|
108
|
+
reject(new Error("bindSharedBuffer(): the producing side sent an empty buffer"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
bindSharedMemories(msg.buffer);
|
|
112
|
+
resolve(msg.buffer);
|
|
113
|
+
};
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
port.off("message", onMessage);
|
|
116
|
+
reject(new Error(`bindSharedBuffer(): no shared buffer arrived within ${timeoutMs}ms`));
|
|
117
|
+
}, timeoutMs);
|
|
118
|
+
port.on("message", onMessage);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
NodeWorkerAdapter,
|
|
123
|
+
bindSharedBuffer,
|
|
124
|
+
createNodePool,
|
|
125
|
+
createNodeWorker,
|
|
126
|
+
withSharedBuffer
|
|
127
|
+
};
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Worker as NodeWorker } from 'node:worker_threads';
|
|
2
|
+
import { WorkerPool, type SharedSpec, type TaskMap, type WorkerPoolConfig } from '@atolljs/core';
|
|
3
|
+
/**
|
|
4
|
+
* WorkerPoolConfig for Node: workerUrl is replaced by a worker spec — a
|
|
5
|
+
* bundled file path, a `new Worker(new URL('./x.worker.ts', import.meta.url))`
|
|
6
|
+
* factory (webpack/esbuild detect the literal and emit the worker chunk), or
|
|
7
|
+
* an explicit createWorker for full control.
|
|
8
|
+
*/
|
|
9
|
+
export interface NodePoolConfig<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> extends Omit<WorkerPoolConfig<S, T>, 'workerUrl' | 'createWorker'> {
|
|
10
|
+
/** Bundled worker entry (e.g. dist/incidents.worker.js). */
|
|
11
|
+
workerFile?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Worker spec — the preferred form. A path/URL behaves like workerFile; a
|
|
14
|
+
* factory may return a node:worker_threads Worker (auto-adapted) or a
|
|
15
|
+
* DOM-style Worker (used as-is):
|
|
16
|
+
*
|
|
17
|
+
* worker: () => new Worker(new URL('./x.worker.ts', import.meta.url))
|
|
18
|
+
*/
|
|
19
|
+
worker?: string | URL | (() => Worker | NodeWorker);
|
|
20
|
+
/** Worker factory; defaults to the node:worker_threads adapter. */
|
|
21
|
+
createWorker?: () => Worker;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates a WorkerPool backed by node:worker_threads. Identical to
|
|
25
|
+
* `new WorkerPool` except workers default to `new NodeWorker(workerFile)`
|
|
26
|
+
* wrapped in the EventTarget adapter — usable from any Node program, no
|
|
27
|
+
* framework required:
|
|
28
|
+
*
|
|
29
|
+
* const pool = createNodePool({ workerFile, sharedMemory, tasks });
|
|
30
|
+
* const pool = createNodePool({ worker: () => new Worker(new URL('./w.worker.ts', import.meta.url)), ... });
|
|
31
|
+
*/
|
|
32
|
+
export declare function createNodePool<S extends SharedSpec, T extends TaskMap>(config: NodePoolConfig<S, T>): WorkerPool<S, T>;
|