@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.
@@ -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
@@ -0,0 +1,5 @@
1
+ // src/shim.ts
2
+ import { isMainThread, parentPort } from "node:worker_threads";
3
+ if (!isMainThread && parentPort) {
4
+ globalThis.self = parentPort;
5
+ }
@@ -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",
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
- ".": "./src/index.ts",
9
- "./http": "./src/http.ts",
10
- "./redis": "./src/redis.ts",
11
- "./shim": "./src/shim.ts"
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.3"
37
+ "@atolljs/core": "0.1.5"
22
38
  },
23
39
  "repository": {
24
40
  "type": "git",