@atolljs/node 0.1.3 → 0.1.5
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-EEME4TDI.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/redis.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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 { type SharedMemory, type SharedSpec } from '@atolljs/core';
|
|
23
|
+
/** The hash commands the adapter needs — ioredis and node-redis both fit. */
|
|
24
|
+
export interface RedisHashClient {
|
|
25
|
+
hset(key: string, field: string, value: string): Promise<unknown>;
|
|
26
|
+
hgetall(key: string): Promise<Record<string, string>>;
|
|
27
|
+
publish?(channel: string, message: string): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Normalized pub/sub surface — node-redis's `client.subscribe(ch, listener)`
|
|
31
|
+
* matches directly; wrap an ioredis connection with `ioRedisSubscriber`.
|
|
32
|
+
*/
|
|
33
|
+
export interface RedisSubscriber {
|
|
34
|
+
subscribe(channel: string, onMessage: (channel: string, message: string) => void): unknown;
|
|
35
|
+
unsubscribe?(channel: string): unknown;
|
|
36
|
+
}
|
|
37
|
+
/** Wrap an ioredis connection in the normalized subscriber surface. */
|
|
38
|
+
export declare const ioRedisSubscriber: (client: {
|
|
39
|
+
subscribe(channel: string): unknown;
|
|
40
|
+
unsubscribe?(channel: string): unknown;
|
|
41
|
+
on(event: 'message', cb: (channel: string, message: string) => void): unknown;
|
|
42
|
+
}) => RedisSubscriber;
|
|
43
|
+
export interface RedisMemoryAdapterOptions {
|
|
44
|
+
client: RedisHashClient;
|
|
45
|
+
/** Hash key root — the contract lives at `${key}:${name}`. Default 'atoll:mem'. */
|
|
46
|
+
key?: string;
|
|
47
|
+
/** Contract namespace under the key root. Default 'default'. */
|
|
48
|
+
name?: string;
|
|
49
|
+
/** Version-diff poll cadence — dirty fields flush at most this often. Default 100ms. */
|
|
50
|
+
syncIntervalMs?: number;
|
|
51
|
+
/** Restrict persistence to a subset of field paths. */
|
|
52
|
+
fields?: string[];
|
|
53
|
+
/**
|
|
54
|
+
* A second Redis connection subscribed to `${key}:${name}:ops` — enables
|
|
55
|
+
* cross-process replication. Each instance publishes its dirty fields;
|
|
56
|
+
* subscribers apply the bytes into their own buffer and bump the local
|
|
57
|
+
* version counter so local observers fire. Last write wins per field.
|
|
58
|
+
*/
|
|
59
|
+
subscriber?: RedisSubscriber;
|
|
60
|
+
/** Echo guard — defaults to pid + random. Override for deterministic tests. */
|
|
61
|
+
instanceId?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface RedisMemoryPersistence {
|
|
64
|
+
/** Resolves when initial restore + subscriber attach complete. */
|
|
65
|
+
readonly ready: Promise<void>;
|
|
66
|
+
/** Flush dirty fields now — also runs automatically on the poll interval. */
|
|
67
|
+
flush(): Promise<number>;
|
|
68
|
+
/** Final flush, unsubscribe, and stop the poll timer. */
|
|
69
|
+
stop(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Attach Redis persistence to a bound (or soon-bound) shared-memory contract.
|
|
73
|
+
* `ready` resolves once state has been restored INTO the local buffer —
|
|
74
|
+
* workers that bind afterwards see the restored state, and version bumps
|
|
75
|
+
* wake observers on this thread.
|
|
76
|
+
*/
|
|
77
|
+
export declare function persistSharedMemory<S extends SharedSpec>(memory: SharedMemory<S>, options: RedisMemoryAdapterOptions): RedisMemoryPersistence;
|
|
78
|
+
/**
|
|
79
|
+
* Pool-config form — the `persistence` option shape for `createNodePool` /
|
|
80
|
+
* `AtollModule.registerPool`. Nest's `app.useWebSocketAdapter()` analog:
|
|
81
|
+
*
|
|
82
|
+
* persistence: redisMemoryAdapter(redis, { name: 'incidents' })
|
|
83
|
+
*/
|
|
84
|
+
export declare function redisMemoryAdapter(client: RedisHashClient, options?: Omit<RedisMemoryAdapterOptions, 'client'>): (memory: SharedMemory) => RedisMemoryPersistence;
|
package/dist/redis.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
scoped
|
|
3
|
+
} from "./chunk-EEME4TDI.js";
|
|
4
|
+
|
|
5
|
+
// src/redis.ts
|
|
6
|
+
var log = scoped("redis-mem");
|
|
7
|
+
var ioRedisSubscriber = (client) => ({
|
|
8
|
+
subscribe: (channel, onMessage) => {
|
|
9
|
+
client.on("message", (c, m) => {
|
|
10
|
+
if (c === channel) onMessage(c, m);
|
|
11
|
+
});
|
|
12
|
+
return client.subscribe(channel);
|
|
13
|
+
},
|
|
14
|
+
unsubscribe: (channel) => client.unsubscribe?.(channel)
|
|
15
|
+
});
|
|
16
|
+
function persistSharedMemory(memory, options) {
|
|
17
|
+
const { client, subscriber } = options;
|
|
18
|
+
const key = `${options.key ?? "atoll:mem"}:${options.name ?? "default"}`;
|
|
19
|
+
const channel = `${key}:ops`;
|
|
20
|
+
const intervalMs = options.syncIntervalMs ?? 100;
|
|
21
|
+
const instanceId = options.instanceId ?? `${process.pid}:${Math.random().toString(36).slice(2, 8)}`;
|
|
22
|
+
let buffer;
|
|
23
|
+
let regions = [];
|
|
24
|
+
const lastSeen = /* @__PURE__ */ new Map();
|
|
25
|
+
let timer;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
let flushing = Promise.resolve(0);
|
|
28
|
+
const b64encode = (u8) => Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString("base64");
|
|
29
|
+
const b64decode = (s) => new Uint8Array(Buffer.from(s, "base64"));
|
|
30
|
+
const fieldRegion = (path) => regions.find((r) => r.path === path);
|
|
31
|
+
const applyRemote = (path, b64) => {
|
|
32
|
+
const region = fieldRegion(path);
|
|
33
|
+
if (!region) return;
|
|
34
|
+
const bytes = b64decode(b64);
|
|
35
|
+
if (bytes.byteLength !== region.byteLength) {
|
|
36
|
+
log.warn(`"${path}": remote field is ${bytes.byteLength}B but the contract reserves ${region.byteLength}B \u2014 skipped`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
new Uint8Array(buffer, region.byteOffset, region.byteLength).set(bytes);
|
|
40
|
+
if (region.version) {
|
|
41
|
+
Atomics.add(region.version.view, region.version.index, 1);
|
|
42
|
+
Atomics.notify(region.version.view, region.version.index);
|
|
43
|
+
lastSeen.set(path, Atomics.load(region.version.view, region.version.index));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const flush = async () => {
|
|
47
|
+
if (stopped) return 0;
|
|
48
|
+
let dirty = 0;
|
|
49
|
+
for (const region of regions) {
|
|
50
|
+
const current = region.version ? Atomics.load(region.version.view, region.version.index) : 0;
|
|
51
|
+
if (lastSeen.get(region.path) === current) continue;
|
|
52
|
+
lastSeen.set(region.path, current);
|
|
53
|
+
dirty++;
|
|
54
|
+
const b64 = b64encode(new Uint8Array(buffer, region.byteOffset, region.byteLength));
|
|
55
|
+
await client.hset(key, region.path, b64);
|
|
56
|
+
if (subscriber) {
|
|
57
|
+
await client.publish?.(
|
|
58
|
+
channel,
|
|
59
|
+
JSON.stringify({ src: instanceId, path: region.path, b64 })
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return dirty;
|
|
64
|
+
};
|
|
65
|
+
const restore = async () => {
|
|
66
|
+
const all = await client.hgetall(key) ?? {};
|
|
67
|
+
let restored = 0;
|
|
68
|
+
for (const [path, b64] of Object.entries(all)) {
|
|
69
|
+
applyRemote(path, b64);
|
|
70
|
+
restored++;
|
|
71
|
+
}
|
|
72
|
+
if (restored) log.info(`restored ${restored} field(s) from ${key}`);
|
|
73
|
+
};
|
|
74
|
+
const ready = new Promise((resolve, reject) => {
|
|
75
|
+
memory.onBound(() => {
|
|
76
|
+
buffer = memory.buffer;
|
|
77
|
+
regions = memory.fields().filter((f) => !options.fields || options.fields.includes(f.path)).map((f) => ({ ...f, version: memory.connector(f.path)._version }));
|
|
78
|
+
for (const r of regions) {
|
|
79
|
+
if (r.version) {
|
|
80
|
+
lastSeen.set(r.path, Atomics.load(r.version.view, r.version.index));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
(async () => {
|
|
84
|
+
await restore();
|
|
85
|
+
if (subscriber) {
|
|
86
|
+
await subscriber.subscribe(channel, (_ch, message) => {
|
|
87
|
+
try {
|
|
88
|
+
const msg = JSON.parse(message);
|
|
89
|
+
if (msg.src === instanceId || !msg.path || !msg.b64) return;
|
|
90
|
+
applyRemote(msg.path, msg.b64);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
log.warn("malformed replication message", e);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (stopped) return;
|
|
97
|
+
timer = setInterval(() => {
|
|
98
|
+
flushing = flushing.then(flush).catch((e) => {
|
|
99
|
+
log.warn("flush failed", e);
|
|
100
|
+
return 0;
|
|
101
|
+
});
|
|
102
|
+
}, intervalMs);
|
|
103
|
+
timer.unref?.();
|
|
104
|
+
})().then(resolve, reject);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
ready,
|
|
109
|
+
// Serialize manual flushes behind the interval's — concurrent hset on
|
|
110
|
+
// the same members is pointless churn.
|
|
111
|
+
flush: () => flushing = flushing.then(flush).catch(() => 0),
|
|
112
|
+
stop: async () => {
|
|
113
|
+
if (stopped) return;
|
|
114
|
+
stopped = true;
|
|
115
|
+
if (timer) clearInterval(timer);
|
|
116
|
+
await subscriber?.unsubscribe?.(channel);
|
|
117
|
+
await flushing.catch(() => 0);
|
|
118
|
+
stopped = false;
|
|
119
|
+
try {
|
|
120
|
+
await flush();
|
|
121
|
+
} catch (e) {
|
|
122
|
+
log.warn("final flush failed", e);
|
|
123
|
+
} finally {
|
|
124
|
+
stopped = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function redisMemoryAdapter(client, options = {}) {
|
|
130
|
+
return (memory) => persistSharedMemory(memory, { ...options, client });
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
ioRedisSubscriber,
|
|
134
|
+
persistSharedMemory,
|
|
135
|
+
redisMemoryAdapter
|
|
136
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Worker as NodeWorker } from 'node:worker_threads';
|
|
2
|
+
/**
|
|
3
|
+
* Main thread: wraps a worker factory so each spawned worker receives a
|
|
4
|
+
* shared buffer over its message channel. The buffer may be a thunk —
|
|
5
|
+
* evaluated per spawn, so respawned workers get it too. An unresolved
|
|
6
|
+
* buffer still posts (empty), so the worker fails loudly instead of hanging.
|
|
7
|
+
*/
|
|
8
|
+
export declare function withSharedBuffer<W extends Worker | NodeWorker>(spawn: () => W, buffer: SharedArrayBuffer | (() => SharedArrayBuffer | undefined)): () => W;
|
|
9
|
+
/**
|
|
10
|
+
* Worker side: resolve this worker's shared buffer and bind every defined
|
|
11
|
+
* contract to it (the same call the worker bootstrap makes on INIT_MEMORY).
|
|
12
|
+
* Checks `workerData.buffer` first — the manual channel — then waits for the
|
|
13
|
+
* message {@link withSharedBuffer} posts. Rejects on an empty buffer or after
|
|
14
|
+
* `timeoutMs` so misconfigurations surface instead of hanging.
|
|
15
|
+
*/
|
|
16
|
+
export declare function bindSharedBuffer(timeoutMs?: number): Promise<SharedArrayBuffer>;
|
package/dist/shim.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/shim.js
ADDED
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Worker as NodeWorker } from 'node:worker_threads';
|
|
2
|
+
export interface WorkerErrorEvent {
|
|
3
|
+
type: 'error';
|
|
4
|
+
message: string;
|
|
5
|
+
error?: unknown;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Adapts node:worker_threads.Worker to the DOM Worker surface that
|
|
9
|
+
* WorkerPool expects (`postMessage`, `terminate`, add/removeEventListener
|
|
10
|
+
* with `event.data`). Node's Worker is an EventEmitter — 'message' delivers
|
|
11
|
+
* the value directly, so handlers get wrapped and tracked by identity so
|
|
12
|
+
* removeEventListener can detach the exact listener the pool installed.
|
|
13
|
+
*
|
|
14
|
+
* 'error' listeners additionally observe a Node 'exit' with code ≠ 0 —
|
|
15
|
+
* surfaced as `{ type: 'error', message }` so the pool's crash/respawn
|
|
16
|
+
* path sees silent exits too. `exit(0)` is a clean shutdown, not an error.
|
|
17
|
+
*/
|
|
18
|
+
export declare class NodeWorkerAdapter {
|
|
19
|
+
private readonly worker;
|
|
20
|
+
private readonly listeners;
|
|
21
|
+
constructor(worker: NodeWorker);
|
|
22
|
+
postMessage(message: unknown, transferList?: unknown[]): void;
|
|
23
|
+
terminate(): void;
|
|
24
|
+
addEventListener(type: 'message', handler: (event: {
|
|
25
|
+
data: unknown;
|
|
26
|
+
}) => void): void;
|
|
27
|
+
addEventListener(type: 'error', handler: (event: WorkerErrorEvent) => void): void;
|
|
28
|
+
removeEventListener(type: 'message', handler: (event: {
|
|
29
|
+
data: unknown;
|
|
30
|
+
}) => void): void;
|
|
31
|
+
removeEventListener(type: 'error', handler: (event: WorkerErrorEvent) => void): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Wraps node:worker_threads.Worker creation. Pass a file/URL to spawn, or an
|
|
35
|
+
* already-constructed Worker to adapt — the latter enables the
|
|
36
|
+
* bundler-detectable form in pool configs:
|
|
37
|
+
*
|
|
38
|
+
* createWorker: () => createNodeWorker(
|
|
39
|
+
* new NodeWorker(new URL('./my.worker.ts', import.meta.url))
|
|
40
|
+
* )
|
|
41
|
+
*
|
|
42
|
+
* webpack compiles the referenced entry into its own chunk and rewrites the
|
|
43
|
+
* URL to the emitted file — the config points at the TS source, never at a
|
|
44
|
+
* dist filename.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createNodeWorker(source: string | URL | NodeWorker, options?: ConstructorParameters<typeof NodeWorker>[1]): Worker;
|
package/package.json
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/node",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Node runtime adapter — node:worker_threads pools, no framework",
|
|
5
5
|
"homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./http": {
|
|
13
|
+
"types": "./dist/http.d.ts",
|
|
14
|
+
"default": "./dist/http.js"
|
|
15
|
+
},
|
|
16
|
+
"./redis": {
|
|
17
|
+
"types": "./dist/redis.d.ts",
|
|
18
|
+
"default": "./dist/redis.js"
|
|
19
|
+
},
|
|
20
|
+
"./shim": {
|
|
21
|
+
"types": "./dist/shim.d.ts",
|
|
22
|
+
"default": "./dist/shim.js"
|
|
23
|
+
}
|
|
12
24
|
},
|
|
13
25
|
"files": [
|
|
14
|
-
"src"
|
|
26
|
+
"src",
|
|
27
|
+
"dist"
|
|
15
28
|
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node ../../scripts/build-lib.mjs"
|
|
31
|
+
},
|
|
16
32
|
"publishConfig": {
|
|
17
33
|
"access": "public",
|
|
18
34
|
"registry": "https://registry.npmjs.org"
|
|
19
35
|
},
|
|
20
36
|
"peerDependencies": {
|
|
21
|
-
"@atolljs/core": "0.1.
|
|
37
|
+
"@atolljs/core": "0.1.5"
|
|
22
38
|
},
|
|
23
39
|
"repository": {
|
|
24
40
|
"type": "git",
|