procmesh-js 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shahzadhamza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,331 @@
1
+ # procmesh
2
+
3
+ Shared in-memory cache, pub/sub, RPC, and locks across **independent Node.js
4
+ processes on the same machine** โ€” no Redis, no external server, no native addons.
5
+
6
+ A tiny **broker process** holds the authoritative in-memory state and routes all
7
+ messaging over a local socket (Unix domain socket on Linux/macOS, named pipe on
8
+ Windows). The first client to connect auto-spawns the broker, so there's nothing to
9
+ run or manage. Every operation serializes through the one broker process, so the
10
+ cache, atomic ops, and locks are strongly consistent and correct by construction.
11
+
12
+ **What you get:**
13
+
14
+ - ๐Ÿ—ƒ๏ธ **Cache** โ€” shared key/value store with TTL and LRU eviction
15
+ - โž• **Atomic ops** โ€” `incr` / `decr` / `cas`, TTL-preserving, race-free
16
+ - ๐Ÿ“ฃ **Pub/Sub** โ€” channels with prefix wildcards and slow-consumer protection
17
+ - ๐Ÿ” **RPC** โ€” request/response across processes that doubles as a load-balanced worker pool
18
+ - ๐Ÿ”’ **Locks** โ€” TTL'd mutexes with fencing tokens, auto-released on crash
19
+ - ๐Ÿ“ฆ **Zero infra** โ€” no external server; only `lru-cache` required, no native deps
20
+
21
+ ```bash
22
+ npm install procmesh-js
23
+ ```
24
+
25
+ ## Contents
26
+
27
+ - [Quick start](#quick-start)
28
+ - [Core API](#core-api)
29
+ - [Cache](#cache) ยท [Atomic ops](#atomic-ops) ยท [Pub/Sub](#pubsub) ยท [RPC](#rpc-requestresponse-across-processes) ยท [Locks](#locks)
30
+ - [Advanced](#advanced)
31
+ - [Fencing tokens](#fencing-tokens-safe-under-ttl-overrun) ยท [Sharding](#sharding-scale-past-one-core) ยท [Persistence & crash survival](#persistence--crash-survival-ha)
32
+ - [Operations](#operations)
33
+ - [Running the broker explicitly](#running-the-broker-explicitly) ยท [Observability](#observability) ยท [Events](#events)
34
+ - [Configuration reference](#configuration-reference)
35
+ - [How it works](#how-it-works)
36
+ - [Limitations](#limitations-v1)
37
+ - [License](#license)
38
+
39
+ ## Quick start
40
+
41
+ ```js
42
+ const { createClient } = require('procmesh-js');
43
+
44
+ // In process A
45
+ const mesh = await createClient(); // auto-spawns a broker if needed
46
+ await mesh.set('user:1', { name: 'Ada' }, { ttl: 60_000 });
47
+
48
+ // In process B (a totally separate `node` invocation)
49
+ const mesh = await createClient();
50
+ console.log(await mesh.get('user:1')); // -> { name: 'Ada' }
51
+ ```
52
+
53
+ All processes that use the same `name` (default `'default'`) share one broker.
54
+
55
+ ## Core API
56
+
57
+ ### Cache
58
+ ```js
59
+ await mesh.set(key, value, { ttl }) // ttl in ms (optional)
60
+ await mesh.get(key) // -> value | undefined
61
+ await mesh.has(key) // -> boolean
62
+ await mesh.del(key) // -> boolean
63
+ await mesh.keys() // -> string[]
64
+ await mesh.clear()
65
+ await mesh.mget(['a', 'b']) // -> [valA, valB]
66
+ await mesh.mset({ a: 1, b: 2 }) // or [['a',1], ['b',2]]
67
+ ```
68
+
69
+ ### Atomic ops
70
+ ```js
71
+ await mesh.incr('count') // -> new value (starts at 0)
72
+ await mesh.decr('count', 2)
73
+ await mesh.cas('cfg', expected, next) // compare-and-set -> boolean
74
+ ```
75
+ `incr`/`decr`/`cas` are read-modify-write and **preserve any per-item TTL** on the key โ€” a counter
76
+ created with an expiry keeps counting down rather than becoming immortal on the next update.
77
+
78
+ ### Pub/Sub
79
+ ```js
80
+ const off = await mesh.subscribe('orders', (payload, channel) => { ... });
81
+ await mesh.publish('orders', { id: 7 }); // -> number of subscribers reached
82
+ await off(); // unsubscribe
83
+
84
+ // Wildcard (prefix) subscriptions โ€” a trailing * matches by prefix:
85
+ await mesh.subscribe('orders.*', (p, channel) => { /* orders.created, orders.shipped, ... */ });
86
+ ```
87
+ A slow subscriber can't make the broker run out of memory: pub/sub frames to a
88
+ backed-up consumer are dropped past the high-water mark (see `sendHighWaterMark`),
89
+ while replies/RPC stay reliable.
90
+
91
+ ### RPC (request/response across processes)
92
+ ```js
93
+ // worker process
94
+ await mesh.register('resize', async (w, h) => doResize(w, h));
95
+
96
+ // caller process
97
+ const result = await mesh.call('resize', [800, 600], { timeout: 5000 });
98
+ ```
99
+ **Worker pools:** if several processes `register` the *same* name, the broker
100
+ load-balances calls across them (least-busy first), so RPC doubles as a work queue.
101
+ A call in flight when its worker dies is failed with `EHANDLERGONE`; the pool keeps
102
+ serving from the remaining workers. A worker that *hangs* while staying connected can't leak broker
103
+ state either: the broker reaps the pending call shortly after the caller's `timeout` elapses, frees
104
+ the worker's in-flight slot (so dispatch isn't skewed), and โ€” for a caller without its own timeout โ€”
105
+ fails it with `ECALLTIMEOUT`.
106
+
107
+ ### Locks
108
+ ```js
109
+ // manual
110
+ const release = await mesh.lock('job:42', { wait: 10_000, ttl: 30_000 });
111
+ if (release) { try { /* critical section */ } finally { await release(); } }
112
+
113
+ // scoped โ€” acquires, runs, always releases
114
+ await mesh.withLock('job:42', async () => { /* critical section */ });
115
+ ```
116
+ `wait` is how long to block for the lock (0 = fail immediately, returning `null`).
117
+ `ttl` auto-releases the lock if the holder crashes, preventing deadlocks. A holder's
118
+ locks are also released automatically when it disconnects.
119
+
120
+ ## Advanced
121
+
122
+ ### Fencing tokens (safe under TTL overrun)
123
+
124
+ If a critical section runs longer than the lock's `ttl`, the lock auto-releases and another
125
+ client can acquire it โ€” the classic "two holders" hazard. Each grant therefore carries a
126
+ monotonically increasing **fencing token**, and procmesh-mediated writes can be *fenced*: a
127
+ write from a holder whose lock already expired is rejected with `EFENCED`.
128
+
129
+ ```js
130
+ // withLock hands you a fenced context โ€” set/cas/del here are guarded by this grant's token:
131
+ await mesh.withLock('account:42', async ({ token, set, cas }) => {
132
+ await set('account:42:balance', 100); // rejected with EFENCED if our lock has expired
133
+ });
134
+
135
+ // manual: the release closure carries the token
136
+ const release = await mesh.lock('account:42', { ttl: 30_000 });
137
+ await mesh.fencedSet('account:42', release.token, 'account:42:balance', 100);
138
+ await release();
139
+ ```
140
+
141
+ **Limitation:** procmesh can only *enforce* fencing for state kept in its own store. If your
142
+ critical section writes to an external resource (DB row, file, API), that resource must check
143
+ `token` itself โ€” procmesh gives you the token but cannot police a foreign system.
144
+
145
+ ### Sharding (scale past one core)
146
+
147
+ A single broker serializes everything on one event loop โ€” correct by construction, but a
148
+ one-core ceiling (~100k small ops/sec). To go faster, **shard** across N brokers (N cores)
149
+ with one option. The returned handle has the *identical* API, so nothing else in your code changes:
150
+
151
+ ```js
152
+ // dev: auto-spawn 4 shard brokers (named default#0 .. default#3)
153
+ const mesh = await createClient({ shards: 4 });
154
+
155
+ // prod: point at explicit, supervised brokers
156
+ const mesh = await createClient({
157
+ shards: ['/run/procmesh/0.sock', '/run/procmesh/1.sock', '/run/procmesh/2.sock'],
158
+ });
159
+
160
+ await mesh.set('user:1', { name: 'Ada' }); // same API, transparently routed
161
+ ```
162
+
163
+ **How work is routed.** Every key/name/channel hashes (FNV-1a, mod N) to exactly one broker:
164
+
165
+ | Primitive | Routes by | Scales across cores? |
166
+ |-----------------------------------|----------------|-----------------------------------------------|
167
+ | cache / atomic (`get`/`incr`/โ€ฆ) | the **key** | yes โ€” keyspace spread evenly |
168
+ | locks + fenced writes | the **lock key** | yes |
169
+ | RPC (`register`/`call`) | the **proc name** | yes, across *distinct* names (one busy name still pins to one broker) |
170
+ | pub/sub publish + exact subscribe | the **channel** | yes |
171
+ | pub/sub **wildcard** subscribe | **all shards** | the pattern occupies a slot on every broker |
172
+
173
+ Because each key lands on exactly one broker, atomic ops and locks stay correct by construction โ€”
174
+ exactly as with a single broker. A publish still hits exactly one broker (no duplicate delivery);
175
+ wildcard subscriptions register everywhere so they catch a publish on any shard.
176
+
177
+ **Invariant โ€” agree on N.** Every process joining the same mesh **must** use the same shard count
178
+ and the same per-shard naming/addresses, or a given key hashes to different brokers across processes
179
+ (writer/reader split-brain). Pin `shards` in shared config. (Numeric `shards` is incompatible with
180
+ `PROCMESH_SOCKET`, which would collapse every shard onto one socket โ€” that throws.)
181
+
182
+ **Lock/data colocation gotcha.** Inside `withLock(L, fn)`, `ctx.set(k, โ€ฆ)` writes to **`L`'s** shard
183
+ (fencing is per-broker), *not* `k`'s shard. So a fenced write may not be visible via `mesh.get(k)`
184
+ when `k` and `L` hash differently. Keep them colocated: use the same string for the lock and the
185
+ guarded key, or namespace guarded keys under the lock key.
186
+
187
+ **Events & stats.** The sharded handle emits `connect` once **all** shards are up and `disconnect`
188
+ once **all** are down, plus per-shard `shard-reconnect(i)` / `shard-disconnect(i)` (a forwarded
189
+ `error` carries `err.shard`). `mesh.stats()` returns summed counters plus a `shards: [...]` array for
190
+ per-shard drill-down; `cpuCoreFraction` is the **sum** across brokers (so > 1.0 is expected and good),
191
+ and `subscriptions` over-counts wildcards (present on every shard).
192
+
193
+ ### Persistence & crash survival (HA)
194
+
195
+ By default the broker is purely in-memory (zero-config). For production you can enable
196
+ **snapshot + append-only-log** persistence so cache and atomic counters survive a broker crash
197
+ or restart:
198
+
199
+ ```bash
200
+ npx procmesh serve --persist-dir /var/lib/procmesh # persistence on (fsync everysec)
201
+ # or: PROCMESH_PERSIST_DIR=/var/lib/procmesh npx procmesh serve
202
+ npx procmesh serve --no-persist # explicitly in-memory only
203
+ ```
204
+ ```js
205
+ createBroker({ persist: { dir: '/var/lib/procmesh', mode: 'everysec' } });
206
+ // mode: 'no' (OS-buffered) | 'everysec' (default, โ‰ค1s loss on power failure) | 'always' (sync, durable)
207
+ ```
208
+
209
+ What survives a restart and what doesn't โ€” **by design**:
210
+ - **Survives:** cache values, atomic counters (with correct remaining TTL), and the fencing
211
+ counter (so a stale pre-crash token is still rejected after restart).
212
+ - **Released:** **all locks.** A lock's owner is a live connection that dies with the broker;
213
+ treat a broker restart exactly as you treat a lock TTL expiry โ€” clients re-acquire on reconnect.
214
+ - **Rebuilt automatically:** subscriptions and RPC registrations (clients replay them on reconnect).
215
+
216
+ Run a **dedicated, supervised** broker (not auto-spawn) so it outlives your workers, with an
217
+ **explicit** persist dir (not the OS temp dir, which may clear on reboot):
218
+
219
+ ```ini
220
+ # systemd: /etc/systemd/system/procmesh.service
221
+ [Service]
222
+ ExecStart=/usr/bin/node /path/to/node_modules/procmesh-js/src/broker-bin.js
223
+ Environment=PROCMESH_BROKER_OPTS={"name":"default","idleTimeout":0,"persist":{"dir":"/var/lib/procmesh"}}
224
+ Restart=always
225
+ RestartSec=1
226
+ ```
227
+ (PM2: `pm2 start node_modules/procmesh-js/src/broker-bin.js`; Windows: wrap it with NSSM.)
228
+
229
+ ## Operations
230
+
231
+ ### Running the broker explicitly
232
+
233
+ Auto-spawn is convenient, but you can also run a long-lived broker (e.g. under
234
+ systemd or PM2) so it never idles out:
235
+
236
+ ```bash
237
+ npx procmesh serve # foreground broker
238
+ npx procmesh status # is a broker up?
239
+ npx procmesh stop # ask it to shut down
240
+ # all accept [--name <name>] [--socket <addr>] [--token <secret>]
241
+ ```
242
+
243
+ ### Observability
244
+
245
+ ```js
246
+ const s = await mesh.stats(); // { uptimeMs, connections, cacheSize, ops, dropped, reaped,
247
+ // locks, lockWaiters, pendingCalls, subscriptions, procs,
248
+ // memory, cpuCoreFraction }
249
+ ```
250
+ Or from the shell: `npx procmesh stats [--json]`. Counters are bumped on the hot path with a
251
+ single integer increment per op, so this is cheap to poll for a Prometheus exporter, etc.
252
+
253
+ ### Events
254
+
255
+ - **Client:** `connect`, `disconnect`, `reconnect`, `error`.
256
+ - **Broker:** `connect`/`disconnect` (connId), `drop` (`{ channel, connId }`),
257
+ `reap` (connId reaped), `stats` (snapshot, if `statsInterval` set), `persist-error` (err).
258
+
259
+ ## Configuration reference
260
+
261
+ ```js
262
+ await createClient({
263
+ name: 'default', // logical mesh; maps to a socket. Use distinct names to isolate meshes.
264
+ codec: 'json', // 'json' (default) | 'msgpack' (needs optional `msgpackr`) | custom { encode, decode }
265
+ // NOTE: a custom { encode, decode } codec can't be forwarded to an
266
+ // auto-spawned broker (functions can't cross `spawn`). Run the broker
267
+ // yourself with the same codec and pass autoSpawn:false (else it throws).
268
+ autoSpawn: true, // spawn a broker if none is running
269
+ reconnect: true, // auto-reconnect + replay subscriptions/registrations
270
+ callTimeout: 30_000, // default per-request timeout (ms)
271
+ pingInterval: 0, // ms; client-side keepalive. >0 = ping on this interval and reconnect if a
272
+ // PONG doesn't return in time (detects a dead broker on a half-open
273
+ // socket / when the broker heartbeat is off). 0 = rely on broker heartbeat.
274
+ token: undefined, // shared secret; if the broker requires one, must match (else EAUTH)
275
+ cache: { max: 10_000, ttl: 0, maxSize: 0 }, // broker cache bounds (used when this client spawns the broker)
276
+ shards: undefined, // scale past one core: a count N, or an array of broker addresses/names (see Sharding)
277
+ });
278
+ ```
279
+
280
+ Broker-side options (passed to `createBroker`, `procmesh serve`, or forwarded by an
281
+ auto-spawning client):
282
+ ```js
283
+ createBroker({
284
+ token: undefined, // require this shared secret on HELLO (omit = allow all, zero-config default)
285
+ callTimeout: 30_000, // ms; backstop deadline to reap an RPC call whose worker hangs while
286
+ // connected (frees pending state + worker inflight). Used when a
287
+ // caller doesn't send its own per-call timeout; otherwise caller's + grace.
288
+ heartbeatInterval: 30_000, // ms; broker pings idle conns and reaps unresponsive ones (3ร— interval)
289
+ sendHighWaterMark: 16<<20, // bytes; pub/sub frames dropped for a consumer buffered past this
290
+ sendHardLimit: 64<<20, // bytes; a consumer buffered past this is disconnected (slow-consumer protection)
291
+ idleTimeout: 0, // ms; auto-shutdown after the last client leaves (0 = never)
292
+ statsInterval: 0, // ms; if set, emit a 'stats' snapshot on this interval (0 = off)
293
+ cache: { max: 10_000, ttl: 0, maxSize: 0 },
294
+ persist: undefined, // crash-survival persistence โ€” see below (off by default)
295
+ });
296
+ ```
297
+
298
+ ## How it works
299
+
300
+ ```
301
+ node app-a node app-b node worker
302
+ \ | /
303
+ \ | /
304
+ [ procmesh broker process ]
305
+ Unix socket / Windows named pipe
306
+ cache ยท pub/sub ยท rpc router ยท lock manager
307
+ ```
308
+
309
+ - **Transport:** Node's built-in `net` only โ€” zero native dependencies.
310
+ - **Framing:** 4-byte length-prefixed frames; JSON payloads by default.
311
+ - **Eviction/TTL:** backed by [`lru-cache`](https://www.npmjs.com/package/lru-cache).
312
+ - **Robustness:** per-connection high-water-mark backpressure (slow consumers can't OOM the
313
+ broker), heartbeats that reap dead connections, worker-pool RPC with least-busy dispatch,
314
+ and optional shared-secret auth.
315
+
316
+ ## Limitations (v1)
317
+
318
+ - **Same machine only.** No cross-host networking (use Redis/NATS for that).
319
+ - **Single broker = single core.** All ops serialize through one event loop; that's what makes
320
+ cache/atomic/locks correct by construction, but it caps throughput at one core (โ‰ˆ100k small
321
+ ops/sec). To scale past it, **shard** across N brokers with `createClient({ shards: N })` โ€” see
322
+ [Sharding](#sharding-scale-past-one-core).
323
+ - **Persistence is opt-in** (see above). Without it, state is gone when the broker exits. Even
324
+ with it, locks are released on restart (they're connection-scoped), and pub/sub is at-most-once
325
+ (messages published during a disconnect are not buffered).
326
+ - Every cache op is a local IPC round-trip (~0.05โ€“0.2 ms). Fine for coordination;
327
+ not a substitute for a per-process hot cache in ultra-hot paths.
328
+
329
+ ## License
330
+
331
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "procmesh-js",
3
+ "version": "0.1.0",
4
+ "description": "Shared in-memory cache, pub/sub, RPC and locks across independent Node.js processes on one machine",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "procmesh": "src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/",
11
+ "prepublishOnly": "npm test"
12
+ },
13
+ "keywords": [
14
+ "cache",
15
+ "ipc",
16
+ "shared-memory",
17
+ "pubsub",
18
+ "rpc",
19
+ "lock",
20
+ "multiprocess",
21
+ "in-memory"
22
+ ],
23
+ "author": "Shahzad Hamza",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/shahzadhamza/procmesh-js.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/shahzadhamza/procmesh-js/issues"
30
+ },
31
+ "homepage": "https://github.com/shahzadhamza/procmesh-js#readme",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "dependencies": {
36
+ "lru-cache": "^11.0.0"
37
+ },
38
+ "optionalDependencies": {
39
+ "msgpackr": "^1.11.0"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "README.md"
44
+ ],
45
+ "license": "MIT"
46
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Standalone broker entrypoint. Spawned (detached) by clients on auto-start, and
5
+ // invoked by the CLI `serve` command. Configuration arrives as JSON via the
6
+ // PROCMESH_BROKER_OPTS environment variable.
7
+
8
+ const Broker = require('./broker');
9
+
10
+ let opts = {};
11
+ try {
12
+ opts = JSON.parse(process.env.PROCMESH_BROKER_OPTS || '{}');
13
+ } catch {
14
+ opts = {};
15
+ }
16
+
17
+ const broker = new Broker(opts);
18
+
19
+ broker
20
+ .start()
21
+ .then(() => {
22
+ // Signal readiness to a parent that used an IPC channel (not the detached case).
23
+ if (typeof process.send === 'function') process.send('ready');
24
+ if (process.env.PROCMESH_VERBOSE) {
25
+ // eslint-disable-next-line no-console
26
+ console.log(`procmesh broker listening on ${broker.address}`);
27
+ }
28
+ })
29
+ .catch((err) => {
30
+ // Lost the race to bind โ€” another broker already owns the socket. That's fine.
31
+ if (err && err.code === 'EADDRINUSE') process.exit(0);
32
+ // eslint-disable-next-line no-console
33
+ console.error('procmesh broker failed to start:', err && err.message);
34
+ process.exit(1);
35
+ });
36
+
37
+ function shutdown() {
38
+ broker.close().then(() => process.exit(0));
39
+ }
40
+ process.on('SIGINT', shutdown);
41
+ process.on('SIGTERM', shutdown);