20 Commits
v2.0.1 ... main

Author SHA1 Message Date
48f158a98b 2.3.0
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 3m52s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-30 23:02:49 +00:00
994b1d20fb feat(streaming): Add streaming support: chunked stream transfers, file send/receive, stream events and helpers 2025-08-30 23:02:49 +00:00
7ba064584b 2.2.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 3m49s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-29 17:02:50 +00:00
1c08df8e6a fix(ipc): Propagate per-client disconnects, add proper routing for targeted messages, and remove unused node-ipc deps 2025-08-29 17:02:50 +00:00
44770bf820 2.2.1
Some checks failed
Default (tags) / security (push) Successful in 27s
Default (tags) / test (push) Failing after 3m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-29 08:49:04 +00:00
6c77ca1e4c fix(tests): Remove redundant manual topic handlers from tests and rely on server built-in pub/sub 2025-08-29 08:49:04 +00:00
350b3f1359 2.2.0
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 3m50s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-29 08:48:38 +00:00
fa53dcfc4f feat(ipcclient): Add clientOnly mode to prevent clients from auto-starting servers and improve registration/reconnect behavior 2025-08-29 08:48:38 +00:00
fd3fc7518b 2.1.3 2025-08-28 20:12:40 +00:00
1b462e3a35 fix(classes.ipcchannel): Normalize heartbeatThrowOnTimeout option parsing and allow registering heartbeatTimeout via IpcChannel.on 2025-08-28 20:12:40 +00:00
4ed42945fc 2.1.2 2025-08-26 12:32:28 +00:00
a0638b5364 fix(core): Improve heartbeat handling and transport routing; forward heartbeat timeout events; include clientId routing and probe improvements 2025-08-26 12:32:28 +00:00
32f3c63fca 2.1.1 2025-08-25 14:00:56 +00:00
f1534ad531 fix(readme): Update README: expand docs, examples, server readiness, heartbeat, and testing utilities 2025-08-25 14:00:56 +00:00
d52fa80650 2.1.0 2025-08-25 13:37:31 +00:00
dd25ffd3e4 feat(core): Add heartbeat grace/timeout options, client retry/wait-for-ready, server readiness and socket cleanup, transport socket options, helper utilities, and tests 2025-08-25 13:37:31 +00:00
e3c1d35895 2.0.3 2025-08-25 08:41:11 +00:00
50aad0e5c1 fix(ipc): Patch release prep: bump patch version and release minor fixes 2025-08-25 08:41:11 +00:00
ab26281c03 2.0.2 2025-08-24 19:37:15 +00:00
73c85f0623 fix(packaging): Update package metadata: add exports, mark package public; clean up README contributing section 2025-08-24 19:37:15 +00:00
15 changed files with 2206 additions and 561 deletions

View File

@@ -1,5 +1,97 @@
# Changelog # Changelog
## 2025-08-30 - 2.3.0 - feat(streaming)
Add streaming support: chunked stream transfers, file send/receive, stream events and helpers
- Implement chunked streaming protocol in IpcChannel (init / chunk / end / error / cancel messages)
- Add sendStream, cancelOutgoingStream and cancelIncomingStream methods to IpcChannel
- Expose high-level streaming API on client: sendStream, sendFile, cancelOutgoingStream, cancelIncomingStream
- Expose high-level streaming API on server: sendStreamToClient, sendFileToClient, cancelIncomingStreamFromClient, cancelOutgoingStreamToClient
- Emit 'stream' events from channels/servers/clients with (info, readable) where info includes streamId, meta, headers and clientId
- Add maxConcurrentStreams option (default 32) and enforce concurrent stream limits for incoming/outgoing
- Add SmartIpc.pipeStreamToFile helper to persist incoming streams to disk
- Export stream in smartipc.plugins and update README with streaming usage and examples
- Add comprehensive streaming tests (test/test.streaming.ts) covering large payloads, file transfer, cancellation and concurrency limits
## 2025-08-29 - 2.2.2 - fix(ipc)
Propagate per-client disconnects, add proper routing for targeted messages, and remove unused node-ipc deps
- Forward per-client 'clientDisconnected' events from transports up through IpcChannel and IpcServer so higher layers can react and clean up state.
- IpcChannel re-emits 'clientDisconnected' and allows registering handlers for it.
- IpcServer now listens for 'clientDisconnected' to cleanup topic subscriptions, remove clients from the map, and emit 'clientDisconnect'.
- sendToClient injects the target clientId into headers so transports can route messages to the correct socket instead of broadcasting.
- broadcast and broadcastTo delegate to sendToClient to ensure messages are routed to intended recipients and errors are attributed to the correct client.
- Transports now emit 'clientDisconnected' with the clientId when known.
- package.json: removed unused node-ipc and @types/node-ipc dependencies (dependency cleanup).
## 2025-08-29 - 2.2.1 - fix(tests)
Remove redundant manual topic handlers from tests and rely on server built-in pub/sub
- Removed manual server.onMessage('__subscribe__') and server.onMessage('__publish__') handlers from test/test.ts
- Tests now rely on the server's built-in publish/subscribe behavior: clients publish directly and subscribers receive messages
- Test code simplified without changing public API or runtime behavior
## 2025-08-29 - 2.2.0 - feat(ipcclient)
Add clientOnly mode to prevent clients from auto-starting servers and improve registration/reconnect behavior
- Introduce a clientOnly option on transports and clients, and support SMARTIPC_CLIENT_ONLY=1 env override to prevent a client from auto-starting a server when connect() encounters ECONNREFUSED/ENOENT.
- Update UnixSocketTransport/TcpTransport connect behavior: if clientOnly (or env override) is enabled, reject connect with a descriptive error instead of starting a server (preserves backward compatibility when disabled).
- Make SmartIpc.waitForServer use clientOnly probing to avoid accidental server creation during readiness checks.
- Refactor IpcClient registration flow: extract attemptRegistrationInternal, set didRegisterOnce flag, and automatically re-register on reconnects when previously registered.
- Add and update tests to cover clientOnly behavior, SMARTIPC_CLIENT_ONLY env enforcement, temporary socket paths and automatic cleanup, and other reliability improvements.
- Update README with a new 'Client-Only Mode' section documenting the option, env override, and examples.
## 2025-08-28 - 2.1.3 - fix(classes.ipcchannel)
Normalize heartbeatThrowOnTimeout option parsing and allow registering 'heartbeatTimeout' via IpcChannel.on
- Normalize heartbeatThrowOnTimeout to boolean (accepts 'true'/'false' strings and other truthy/falsey values) to be defensive for JS consumers
- Expose 'heartbeatTimeout' as a special channel event so handlers registered via IpcChannel.on('heartbeatTimeout', ...) will be called
## 2025-08-26 - 2.1.2 - fix(core)
Improve heartbeat handling and transport routing; forward heartbeat timeout events; include clientId routing and probe improvements
- IpcChannel: add heartbeatInitialGracePeriod handling — delay heartbeat timeout checks until the grace period elapses and use a minimum check interval (>= 1000ms)
- IpcChannel: add heartbeatGraceTimer and ensure stopHeartbeat clears the grace timer to avoid repeated events
- IpcChannel / Client / Server: forward heartbeatTimeout events instead of only throwing when configured (heartbeatThrowOnTimeout = false) so consumers can handle timeouts via events
- IpcClient: include clientId in registration request headers to enable proper routing on the server/transport side
- UnixSocketTransport: track socket <-> clientId mappings, clean them up on socket close, and update mappings when __register__ or messages containing clientId are received
- UnixSocketTransport: route messages to a specific client when headers.clientId is present (fallback to broadcasting when no target is found), and emit both clientMessage and message for parsed client messages
- ts/index.waitForServer: use SmartIpc.createClient for probing, shorten probe register timeout, and use a slightly longer retry delay between probes for stability
## 2025-08-25 - 2.1.1 - fix(readme)
Update README: expand docs, examples, server readiness, heartbeat, and testing utilities
- Rewrite introduction and overall tone to emphasize zero-dependency, reliability, and TypeScript support
- Replace several Quick Start examples to use socketPath and show autoCleanupSocketFile usage
- Add Server readiness detection docs and SmartIpc.waitForServer example
- Document smart connection retry options (connectRetry) and registerTimeoutMs usage
- Clarify heartbeat configuration and add heartbeatThrowOnTimeout option to emit events instead of throwing
- Add sections for automatic socket cleanup, broadcasting, testing utilities (waitForServer, spawnAndConnect), and metrics
- Various formatting and copy improvements throughout README
## 2025-08-25 - 2.1.0 - feat(core)
Add heartbeat grace/timeout options, client retry/wait-for-ready, server readiness and socket cleanup, transport socket options, helper utilities, and tests
- IpcChannel: add heartbeatInitialGracePeriodMs and heartbeatThrowOnTimeout; emit 'heartbeatTimeout' event when configured instead of throwing and disconnecting immediately.
- IpcClient: add connectRetry configuration, registerTimeoutMs, waitForReady option and robust connect logic with exponential backoff and total timeout handling.
- IpcServer: add start option readyWhen ('accepting'), isReady/getIsReady API, autoCleanupSocketFile and socketMode support for managing stale socket files and permissions.
- Transports: support autoCleanupSocketFile and socketMode (cleanup stale socket files and set socket permissions where applicable).
- SmartIpc: add waitForServer helper to wait until a server is ready and spawnAndConnect helper to spawn a server process and connect a client.
- Tests: add comprehensive tests (test.improvements.ts and test.reliability.ts) covering readiness, socket cleanup, retries, heartbeat behavior, race conditions, multiple clients, and server restart scenarios.
## 2025-08-25 - 2.0.3 - fix(ipc)
Patch release prep: bump patch version and release minor fixes
- No changes detected in the provided diff; repository files currently declare version 2.0.2.
- Recommend a patch bump to 2.0.3 to prepare a new release (no breaking changes identified).
## 2025-08-24 - 2.0.2 - fix(packaging)
Update package metadata: add exports, mark package public; clean up README contributing section
- Add an exports entry in package.json pointing to ./dist_ts/index.js for proper ESM exports resolution
- Mark package as public (private: false) and remove legacy main/typings fields
- Remove the Contributing section and example contributor workflow from README
## 2025-08-24 - 2.0.1 - fix(npm) ## 2025-08-24 - 2.0.1 - fix(npm)
Remove .npmrc to avoid committing npm registry configuration Remove .npmrc to avoid committing npm registry configuration
@@ -31,3 +123,10 @@ Initial release and a series of patch fixes to core components.
- 1.0.1: initial release. - 1.0.1: initial release.
- 1.0.2 → 1.0.7: a sequence of small core fixes and maintenance updates (repeated "fix(core): update" commits). - 1.0.2 → 1.0.7: a sequence of small core fixes and maintenance updates (repeated "fix(core): update" commits).
## 2025-08-29 - 2.1.4 - feat(transports)
Add client-only mode to prevent unintended server auto-start in Unix/NamedPipe transports; safer probing
- Add `clientOnly?: boolean` to transport options; when true (or `SMARTIPC_CLIENT_ONLY=1`), a client will fail fast on `ECONNREFUSED`/`ENOENT` instead of auto-starting a server.
- Update `SmartIpc.waitForServer()` to probe with `clientOnly: true` to avoid races during readiness checks.
- Extend tests to cover option and env override; update core test to use unique socket path and auto-cleanup.
- Docs: add README section for client-only mode.

View File

@@ -1,10 +1,11 @@
{ {
"name": "@push.rocks/smartipc", "name": "@push.rocks/smartipc",
"version": "2.0.1", "version": "2.3.0",
"private": false, "private": false,
"description": "A library for node inter process communication, providing an easy-to-use API for IPC.", "description": "A library for node inter process communication, providing an easy-to-use API for IPC.",
"main": "dist/index.js", "exports": {
"typings": "dist/index.d.ts", ".": "./dist_ts/index.js"
},
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -23,9 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartdelay": "^3.0.1", "@push.rocks/smartdelay": "^3.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10"
"@types/node-ipc": "^9.1.1",
"node-ipc": "^12.0.0"
}, },
"keywords": [ "keywords": [
"IPC", "IPC",

203
pnpm-lock.yaml generated
View File

@@ -14,12 +14,6 @@ importers:
'@push.rocks/smartrx': '@push.rocks/smartrx':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
'@types/node-ipc':
specifier: ^9.1.1
version: 9.2.3
node-ipc:
specifier: ^12.0.0
version: 12.0.0
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^2.0.22 specifier: ^2.0.22
@@ -1573,9 +1567,6 @@ packages:
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
'@types/node-ipc@9.2.3':
resolution: {integrity: sha512-/MvSiF71fYf3+zwqkh/zkVkZj1hl1Uobre9EMFy08mqfJNAmpR0vmPgOUdEIDVgifxHj6G1vYMPLSBLLxoDACQ==}
'@types/node@22.13.8': '@types/node@22.13.8':
resolution: {integrity: sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==} resolution: {integrity: sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==}
@@ -1879,9 +1870,6 @@ packages:
resolution: {integrity: sha512-4CCmhqt4yqbQQI9REDKCf+N6U3SToC5o7PoKCq4veHvr30TJ2Vmz1mYYF23VC0E7Z13tf4CXh9jXY0VC+Jtdng==} resolution: {integrity: sha512-4CCmhqt4yqbQQI9REDKCf+N6U3SToC5o7PoKCq4veHvr30TJ2Vmz1mYYF23VC0E7Z13tf4CXh9jXY0VC+Jtdng==}
engines: {node: '>=4'} engines: {node: '>=4'}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
cliui@8.0.1: cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1967,13 +1955,6 @@ packages:
resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
copyfiles@2.4.1:
resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==}
hasBin: true
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.5: cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2122,10 +2103,6 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
easy-stack@1.0.1:
resolution: {integrity: sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==}
engines: {node: '>=6.0.0'}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2236,10 +2213,6 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
event-pubsub@5.0.3:
resolution: {integrity: sha512-2QiHxshejKgJrYMzSI9MEHrvhmzxBL+eLyiM5IiyjDBySkgwS2+tdtnO3gbx8pEisu/yOFCIhfCb63gCEu0yBQ==}
engines: {node: '>=13.0.0'}
eventemitter3@4.0.7: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -2635,12 +2608,6 @@ packages:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'} engines: {node: '>=8'}
isarray@0.0.1:
resolution: {integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=}
isarray@1.0.0:
resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -2658,14 +2625,6 @@ packages:
js-base64@3.7.7: js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
js-message@1.0.7:
resolution: {integrity: sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==}
engines: {node: '>=0.6.0'}
js-queue@2.0.2:
resolution: {integrity: sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==}
engines: {node: '>=1.0.0'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3032,11 +2991,6 @@ packages:
mitt@3.0.1: mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
mongodb-connection-string-url@3.0.2: mongodb-connection-string-url@3.0.2:
resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==}
@@ -3106,13 +3060,6 @@ packages:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'} engines: {node: '>= 6.13.0'}
node-ipc@12.0.0:
resolution: {integrity: sha512-QHJ2gAJiqA3cM7cQiRjLsfCOBRB0TwQ6axYD4FSllQWipEbP6i7Se1dP8EzPKk5J1nCe27W69eqPmCoKyQ61Vg==}
engines: {node: '>=14'}
noms@0.0.0:
resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==}
normalize-newline@4.1.0: normalize-newline@4.1.0:
resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==} resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3291,9 +3238,6 @@ packages:
resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==}
engines: {node: '>=18'} engines: {node: '>=18'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
progress@2.0.3: progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -3362,12 +3306,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
readable-stream@1.0.34:
resolution: {integrity: sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2: readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3452,9 +3390,6 @@ packages:
engines: {node: '>=8.3.0'} engines: {node: '>=8.3.0'}
hasBin: true hasBin: true
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3612,12 +3547,6 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'} engines: {node: '>=12'}
string_decoder@0.10.31:
resolution: {integrity: sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -3646,14 +3575,6 @@ packages:
strnum@2.1.1: strnum@2.1.1:
resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
strong-type@0.1.6:
resolution: {integrity: sha512-eJe5caH6Pi5oMMeQtIoBPpvNu/s4jiyb63u5tkHNnQXomK+puyQ5i+Z5iTLBr/xUz/pIcps0NSfzzFI34+gAXg==}
engines: {node: '>=12.0.0'}
strong-type@1.1.0:
resolution: {integrity: sha512-X5Z6riticuH5GnhUyzijfDi1SoXas8ODDyN7K8lJeQK+Jfi4dKdoJGL4CXTskY/ATBcN+rz5lROGn1tAUkOX7g==}
engines: {node: '>=12.21.0'}
strtok3@10.3.4: strtok3@10.3.4:
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3697,9 +3618,6 @@ packages:
threads@1.7.0: threads@1.7.0:
resolution: {integrity: sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==} resolution: {integrity: sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==}
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
through2@4.0.2: through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
@@ -3842,10 +3760,6 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
untildify@4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}
upper-case@1.1.3: upper-case@1.1.3:
resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==}
@@ -3955,26 +3869,14 @@ packages:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
yargs@17.7.2: yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6988,10 +6890,6 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.13.8 '@types/node': 22.13.8
'@types/node-ipc@9.2.3':
dependencies:
'@types/node': 22.13.8
'@types/node@22.13.8': '@types/node@22.13.8':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@@ -7287,12 +7185,6 @@ snapshots:
clean-stack@1.3.0: {} clean-stack@1.3.0: {}
cliui@7.0.4:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@8.0.1: cliui@8.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@@ -7372,18 +7264,6 @@ snapshots:
depd: 2.0.0 depd: 2.0.0
keygrip: 1.1.0 keygrip: 1.1.0
copyfiles@2.4.1:
dependencies:
glob: 7.2.3
minimatch: 3.1.2
mkdirp: 1.0.4
noms: 0.0.0
through2: 2.0.5
untildify: 4.0.0
yargs: 16.2.0
core-util-is@1.0.3: {}
cors@2.8.5: cors@2.8.5:
dependencies: dependencies:
object-assign: 4.1.1 object-assign: 4.1.1
@@ -7502,8 +7382,6 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
easy-stack@1.0.1: {}
ee-first@1.1.1: {} ee-first@1.1.1: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -7630,11 +7508,6 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-pubsub@5.0.3:
dependencies:
copyfiles: 2.4.1
strong-type: 0.1.6
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
express-force-ssl@0.3.2: express-force-ssl@0.3.2:
@@ -8107,10 +7980,6 @@ snapshots:
dependencies: dependencies:
is-docker: 2.2.1 is-docker: 2.2.1
isarray@0.0.1: {}
isarray@1.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.1: {} isexe@3.1.1: {}
@@ -8123,12 +7992,6 @@ snapshots:
js-base64@3.7.7: {} js-base64@3.7.7: {}
js-message@1.0.7: {}
js-queue@2.0.2:
dependencies:
easy-stack: 1.0.1
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@3.14.1: js-yaml@3.14.1:
@@ -8683,8 +8546,6 @@ snapshots:
mitt@3.0.1: {} mitt@3.0.1: {}
mkdirp@1.0.4: {}
mongodb-connection-string-url@3.0.2: mongodb-connection-string-url@3.0.2:
dependencies: dependencies:
'@types/whatwg-url': 11.0.5 '@types/whatwg-url': 11.0.5
@@ -8759,18 +8620,6 @@ snapshots:
node-forge@1.3.1: {} node-forge@1.3.1: {}
node-ipc@12.0.0:
dependencies:
event-pubsub: 5.0.3
js-message: 1.0.7
js-queue: 2.0.2
strong-type: 1.1.0
noms@0.0.0:
dependencies:
inherits: 2.0.4
readable-stream: 1.0.34
normalize-newline@4.1.0: normalize-newline@4.1.0:
dependencies: dependencies:
replace-buffer: 1.2.1 replace-buffer: 1.2.1
@@ -8928,8 +8777,6 @@ snapshots:
dependencies: dependencies:
parse-ms: 4.0.0 parse-ms: 4.0.0
process-nextick-args@2.0.1: {}
progress@2.0.3: {} progress@2.0.3: {}
property-information@7.1.0: {} property-information@7.1.0: {}
@@ -9028,23 +8875,6 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
readable-stream@1.0.34:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 0.0.1
string_decoder: 0.10.31
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2: readable-stream@3.6.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -9183,8 +9013,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safe-regex-test@1.1.0: safe-regex-test@1.1.0:
@@ -9395,12 +9223,6 @@ snapshots:
emoji-regex: 9.2.2 emoji-regex: 9.2.2
strip-ansi: 7.1.0 strip-ansi: 7.1.0
string_decoder@0.10.31: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -9428,10 +9250,6 @@ snapshots:
strnum@2.1.1: {} strnum@2.1.1: {}
strong-type@0.1.6: {}
strong-type@1.1.0: {}
strtok3@10.3.4: strtok3@10.3.4:
dependencies: dependencies:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
@@ -9490,11 +9308,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
xtend: 4.0.2
through2@4.0.2: through2@4.0.2:
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
@@ -9619,8 +9432,6 @@ snapshots:
unpipe@1.0.0: {} unpipe@1.0.0: {}
untildify@4.0.0: {}
upper-case@1.1.3: {} upper-case@1.1.3: {}
url@0.11.4: url@0.11.4:
@@ -9719,24 +9530,10 @@ snapshots:
xmlhttprequest-ssl@2.1.2: {} xmlhttprequest-ssl@2.1.2: {}
xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@16.2.0:
dependencies:
cliui: 7.0.4
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 20.2.9
yargs@17.7.2: yargs@17.7.2:
dependencies: dependencies:
cliui: 8.0.1 cliui: 8.0.1

750
readme.md
View File

@@ -1,34 +1,34 @@
# @push.rocks/smartipc 🚀 # @push.rocks/smartipc 🚀
**Lightning-fast, type-safe IPC for modern Node.js applications** **Rock-solid IPC for Node.js with zero dependencies**
[![npm version](https://img.shields.io/npm/v/@push.rocks/smartipc.svg)](https://www.npmjs.com/package/@push.rocks/smartipc) [![npm version](https://img.shields.io/npm/v/@push.rocks/smartipc.svg)](https://www.npmjs.com/package/@push.rocks/smartipc)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./license) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./license)
SmartIPC is a production-grade Inter-Process Communication library that brings enterprise-level messaging patterns to Node.js. Built with TypeScript from the ground up, it offers zero-dependency native IPC with automatic reconnection, type-safe messaging, and built-in observability. SmartIPC delivers bulletproof Inter-Process Communication for Node.js applications. Built for real-world production use, it handles all the edge cases that make IPC tricky - automatic reconnection, race conditions, heartbeat monitoring, and clean shutdowns. All with **zero external dependencies** and full TypeScript support.
## Why SmartIPC? ## 🎯 Why SmartIPC?
- **🎯 Zero External Dependencies** - Pure Node.js implementation using native `net` module - **Zero Dependencies** - Pure Node.js implementation using native modules
- **🔒 Type-Safe** - Full TypeScript support with generics for compile-time safety - **Battle-tested Reliability** - Automatic reconnection, graceful degradation, and timeout handling
- **🔄 Auto-Reconnect** - Built-in exponential backoff and circuit breaker patterns - **Type-Safe** - Full TypeScript support with generics for compile-time safety
- **📊 Observable** - Real-time metrics and connection tracking - **CI/Test Ready** - Built-in helpers and race condition prevention for testing
- **⚡ High Performance** - Length-prefixed framing, backpressure handling, and optimized buffers - **Observable** - Real-time metrics, connection tracking, and health monitoring
- **🎭 Multiple Patterns** - Request/Response, Pub/Sub, and Fire-and-Forget messaging - **Multiple Patterns** - Request/Response, Pub/Sub, and Fire-and-Forget messaging
- **🛡️ Production Ready** - Message size limits, heartbeat monitoring, and graceful shutdown - **Streaming Support** - Efficient, backpressureaware streaming for large data and files
## Installation ## 📦 Installation
```bash ```bash
npm install @push.rocks/smartipc
# or
pnpm add @push.rocks/smartipc pnpm add @push.rocks/smartipc
# or # or
npm install @push.rocks/smartipc yarn add @push.rocks/smartipc
``` ```
## Quick Start ## 🚀 Quick Start
### Simple TCP Server & Client
```typescript ```typescript
import { SmartIpc } from '@push.rocks/smartipc'; import { SmartIpc } from '@push.rocks/smartipc';
@@ -36,34 +36,42 @@ import { SmartIpc } from '@push.rocks/smartipc';
// Create a server // Create a server
const server = SmartIpc.createServer({ const server = SmartIpc.createServer({
id: 'my-service', id: 'my-service',
host: 'localhost', socketPath: '/tmp/my-service.sock',
port: 9876 autoCleanupSocketFile: true // Clean up stale sockets automatically
}); });
// Handle incoming messages // Handle incoming messages
server.onMessage('hello', async (data, clientId) => { server.onMessage('greet', async (data, clientId) => {
console.log(`Client ${clientId} says:`, data); console.log(`Client ${clientId} says:`, data.message);
return { response: 'Hello back!' }; return { response: `Hello ${data.name}!` };
}); });
await server.start(); // Start the server
await server.start({ readyWhen: 'accepting' }); // Wait until fully ready
console.log('Server is ready to accept connections! ✨');
// Create a client // Create a client
const client = SmartIpc.createClient({ const client = SmartIpc.createClient({
id: 'my-service', id: 'my-service',
host: 'localhost', socketPath: '/tmp/my-service.sock',
port: 9876, connectRetry: {
clientId: 'client-1' enabled: true,
maxAttempts: 10
}
}); });
// Connect with automatic retry
await client.connect(); await client.connect();
// Send a message and get response // Send a request and get a response
const response = await client.request('hello', { message: 'Hi server!' }); const response = await client.request('greet', {
console.log('Server responded:', response); name: 'World',
message: 'Hi there!'
});
console.log('Server said:', response.response); // "Hello World!"
``` ```
## Core Concepts ## 🎮 Core Concepts
### Transport Types ### Transport Types
@@ -84,348 +92,505 @@ const unixServer = SmartIpc.createServer({
}); });
// Windows Named Pipe (Windows optimal) // Windows Named Pipe (Windows optimal)
const pipeServer = SmartIpc.createServer({ // Automatically used on Windows when socketPath is provided
const windowsServer = SmartIpc.createServer({
id: 'pipe-service', id: 'pipe-service',
pipeName: 'my-app-pipe' socketPath: '\\\\.\\pipe\\my-app-pipe'
}); });
``` ```
### Message Patterns ### Message Patterns
#### 🔥 Fire and Forget #### 🔥 Fire and Forget
Fast, one-way messaging when you don't need a response: Send messages without waiting for a response:
```typescript ```typescript
// Server // Server
server.onMessage('log', (data, clientId) => { server.onMessage('log', (data, clientId) => {
console.log(`[${clientId}]:`, data.message); console.log(`[${clientId}] ${data.level}:`, data.message);
// No return value needed // No return needed
}); });
// Client // Client
await client.sendMessage('log', { await client.sendMessage('log', {
level: 'info',
message: 'User logged in', message: 'User logged in',
timestamp: Date.now() timestamp: Date.now()
}); });
``` ```
#### 📞 Request/Response #### 📞 Request/Response
RPC-style communication with timeouts and type safety: RPC-style communication with type safety:
```typescript ```typescript
// Server - Define your handler with types interface UserRequest {
interface CalculateRequest { userId: string;
operation: 'add' | 'multiply'; fields?: string[];
values: number[];
} }
interface CalculateResponse { interface UserResponse {
result: number; id: string;
computedAt: number; name: string;
email?: string;
createdAt: number;
} }
server.onMessage<CalculateRequest, CalculateResponse>('calculate', async (data) => { // Server
const result = data.operation === 'add' server.onMessage<UserRequest, UserResponse>('getUser', async (data) => {
? data.values.reduce((a, b) => a + b, 0) const user = await db.getUser(data.userId);
: data.values.reduce((a, b) => a * b, 1);
return { return {
result, id: user.id,
computedAt: Date.now() name: user.name,
email: data.fields?.includes('email') ? user.email : undefined,
createdAt: user.createdAt
}; };
}); });
// Client - Type-safe request // Client - with timeout
const response = await client.request<CalculateRequest, CalculateResponse>( const user = await client.request<UserRequest, UserResponse>(
'calculate', 'getUser',
{ operation: 'add', values: [1, 2, 3, 4, 5] }, { userId: '123', fields: ['email'] },
{ timeout: 5000 } { timeout: 5000 }
); );
console.log(`Sum is ${response.result}`);
``` ```
#### 📢 Pub/Sub Pattern #### 📢 Pub/Sub Pattern
Topic-based message broadcasting: Topic-based message broadcasting:
```typescript ```typescript
// Server automatically handles subscriptions // Subscribers
const publisher = SmartIpc.createClient({
id: 'events-service',
clientId: 'publisher'
});
const subscriber1 = SmartIpc.createClient({ const subscriber1 = SmartIpc.createClient({
id: 'events-service', id: 'events-service',
clientId: 'subscriber-1' socketPath: '/tmp/events.sock'
}); });
const subscriber2 = SmartIpc.createClient({ await subscriber1.connect();
id: 'events-service',
clientId: 'subscriber-2'
});
// Subscribe to topics
await subscriber1.subscribe('user.login', (data) => { await subscriber1.subscribe('user.login', (data) => {
console.log('User logged in:', data); console.log('User logged in:', data);
}); });
await subscriber2.subscribe('user.*', (data) => { // Publisher
console.log('User event:', data); const publisher = SmartIpc.createClient({
id: 'events-service',
socketPath: '/tmp/events.sock'
}); });
// Publish events await publisher.connect();
await publisher.publish('user.login', { await publisher.publish('user.login', {
userId: '123', userId: '123',
ip: '192.168.1.1',
timestamp: Date.now() timestamp: Date.now()
}); });
``` ```
## Advanced Features ## 💪 Advanced Features
### 🔄 Auto-Reconnection with Exponential Backoff ### 📦 Streaming Large Data & Files
Clients automatically reconnect on connection loss: SmartIPC supports efficient, backpressure-aware streaming of large payloads using chunked messages. Streams work both directions and emit a high-level `stream` event for consumption.
Client → Server streaming:
```typescript
// Server side: receive stream
server.on('stream', async (info, readable) => {
if (info.meta?.type === 'file') {
console.log('Receiving file', info.meta.basename, 'from', info.clientId);
}
// Pipe to disk or process chunks
await SmartIpc.pipeStreamToFile(readable, '/tmp/incoming.bin');
});
// Client side: send a stream
const readable = fs.createReadStream('/path/to/local.bin');
await client.sendStream(readable, {
meta: { type: 'file', basename: 'local.bin' },
chunkSize: 64 * 1024 // optional, defaults to 64k
});
```
Server → Client streaming:
```typescript
client.on('stream', async (info, readable) => {
console.log('Got stream from server', info.meta);
await SmartIpc.pipeStreamToFile(readable, '/tmp/from-server.bin');
});
await server.sendStreamToClient(client.getClientId(), fs.createReadStream('/path/server.bin'), {
meta: { type: 'file', basename: 'server.bin' }
});
```
High-level helpers for files:
```typescript
// Client → Server
await client.sendFile('/path/to/bigfile.iso');
// Server → Client
await server.sendFileToClient(clientId, '/path/to/backup.tar');
// Save an incoming stream to a file (both sides)
server.on('stream', async (info, readable) => {
await SmartIpc.pipeStreamToFile(readable, '/data/uploaded/' + info.meta?.basename);
});
```
Events & metadata:
- `channel/server/client` emit `stream` with `(info, readable)`
- `info` contains: `streamId`, `meta` (your metadata, e.g., filename/size), `headers`, and `clientId` (if available)
API summary:
- Client: `sendStream(readable, opts)`, `sendFile(filePath, opts)`, `cancelOutgoingStream(id)`, `cancelIncomingStream(id)`
- Server: `sendStreamToClient(clientId, readable, opts)`, `sendFileToClient(clientId, filePath, opts)`, `cancelIncomingStreamFromClient(clientId, id)`, `cancelOutgoingStreamToClient(clientId, id)`
- Utility: `SmartIpc.pipeStreamToFile(readable, filePath)`
Concurrency and cancelation:
```typescript
// Limit concurrent streams per connection
const server = SmartIpc.createServer({
id: 'svc', socketPath: '/tmp/svc.sock', maxConcurrentStreams: 2
});
// Cancel a stream from the receiver side
server.on('stream', (info, readable) => {
if (info.meta?.shouldCancel) {
(server as any).primaryChannel.cancelIncomingStream(info.streamId, { clientId: info.clientId });
}
});
```
Notes:
- Streaming uses chunked messages under the hood and respects socket backpressure.
- Include `meta` to share context like filename/size; its delivered with the `stream` event.
- Configure `maxConcurrentStreams` (default: 32) to guard resources.
### 🏁 Server Readiness Detection
Eliminate race conditions in tests and production:
```typescript
const server = SmartIpc.createServer({
id: 'my-service',
socketPath: '/tmp/my-service.sock',
autoCleanupSocketFile: true
});
// Option 1: Wait for full readiness
await server.start({ readyWhen: 'accepting' });
// Server is now FULLY ready to accept connections
// Option 2: Use ready event
server.on('ready', () => {
console.log('Server is ready!');
startClients();
});
await server.start();
// Option 3: Check readiness state
if (server.getIsReady()) {
console.log('Ready to rock! 🎸');
}
```
### 🔄 Smart Connection Retry
Never lose messages due to temporary connection issues:
```typescript ```typescript
const client = SmartIpc.createClient({ const client = SmartIpc.createClient({
id: 'resilient-service', id: 'resilient-client',
clientId: 'auto-reconnect-client', socketPath: '/tmp/service.sock',
reconnect: { connectRetry: {
enabled: true, enabled: true,
initialDelay: 1000, // Start with 1 second initialDelay: 100, // Start with 100ms
maxDelay: 30000, // Cap at 30 seconds maxDelay: 1500, // Cap at 1.5 seconds
factor: 2, // Double each time maxAttempts: 20, // Try 20 times
maxAttempts: Infinity // Keep trying forever totalTimeout: 15000 // Give up after 15 seconds total
} },
registerTimeoutMs: 8000 // Registration handshake timeout
}); });
// Monitor connection state // Will retry automatically if server isn't ready yet
client.on('connected', () => console.log('Connected! 🟢')); await client.connect({
client.on('disconnected', () => console.log('Connection lost! 🔴')); waitForReady: true, // Wait for server to exist
client.on('reconnecting', (attempt) => console.log(`Reconnecting... Attempt ${attempt} 🟡`)); waitTimeout: 10000 // Wait up to 10 seconds
});
``` ```
### 💓 Heartbeat Monitoring ### 🛑 Client-Only Mode (No Auto-Start)
Keep connections alive and detect failures quickly: In some setups (CLI + long-running daemon), you want clients to fail fast when no server is available, rather than implicitly becoming the server. Enable client-only mode to prevent the “client becomes server” fallback for Unix domain sockets and Windows named pipes.
```typescript
// Strict client that never auto-starts a server on connect failure
const client = SmartIpc.createClient({
id: 'my-service',
socketPath: '/tmp/my-service.sock',
clientId: 'my-cli',
clientOnly: true, // NEW: disable auto-start fallback
connectRetry: { enabled: false } // optional: fail fast
});
try {
await client.connect();
} catch (err) {
// With clientOnly: true, errors become descriptive
// e.g. "Server not available (ENOENT); clientOnly prevents auto-start"
console.error(err.message);
}
```
- Default: `clientOnly` is `false` to preserve backward compatibility.
- Env override: set `SMARTIPC_CLIENT_ONLY=1` to enforce client-only behavior without code changes.
- Note: `SmartIpc.waitForServer()` internally uses `clientOnly: true` for safe probing.
### 💓 Graceful Heartbeat Monitoring
Keep connections alive without crashing on timeouts:
```typescript ```typescript
const server = SmartIpc.createServer({ const server = SmartIpc.createServer({
id: 'monitored-service', id: 'monitored-service',
heartbeat: { socketPath: '/tmp/monitored.sock',
enabled: true, heartbeat: true,
interval: 5000, // Send heartbeat every 5 seconds heartbeatInterval: 3000,
timeout: 15000 // Consider dead after 15 seconds heartbeatTimeout: 10000,
} heartbeatInitialGracePeriodMs: 5000, // Grace period for startup
heartbeatThrowOnTimeout: false // Emit event instead of throwing
}); });
// Clients automatically respond to heartbeats server.on('heartbeatTimeout', (clientId) => {
console.log(`Client ${clientId} heartbeat timeout - will handle gracefully`);
});
// Client configuration
const client = SmartIpc.createClient({ const client = SmartIpc.createClient({
id: 'monitored-service', id: 'monitored-service',
clientId: 'heartbeat-client', socketPath: '/tmp/monitored.sock',
heartbeat: true // Enable heartbeat responses heartbeat: true,
heartbeatInterval: 3000,
heartbeatTimeout: 10000,
heartbeatInitialGracePeriodMs: 5000,
heartbeatThrowOnTimeout: false
});
client.on('heartbeatTimeout', () => {
console.log('Heartbeat timeout detected, reconnecting...');
// Handle reconnection logic
}); });
``` ```
### 📊 Real-time Metrics & Observability ### 🧹 Automatic Socket Cleanup
Track performance and connection health: Never worry about stale socket files:
```typescript ```typescript
// Server metrics const server = SmartIpc.createServer({
id: 'clean-service',
socketPath: '/tmp/service.sock',
autoCleanupSocketFile: true, // Remove stale socket on start
socketMode: 0o600 // Set socket permissions (Unix only)
});
// Socket file will be cleaned up automatically on start
await server.start();
```
### 📊 Real-time Metrics
Monitor your IPC performance:
```typescript
// Server stats
const serverStats = server.getStats(); const serverStats = server.getStats();
console.log({ console.log({
isRunning: serverStats.isRunning, isRunning: serverStats.isRunning,
connectedClients: serverStats.connectedClients, connectedClients: serverStats.connectedClients,
totalConnections: serverStats.totalConnections, totalConnections: serverStats.totalConnections,
uptime: serverStats.uptime,
metrics: { metrics: {
messagesSent: serverStats.metrics.messagesSent, messagesSent: serverStats.metrics.messagesSent,
messagesReceived: serverStats.metrics.messagesReceived, messagesReceived: serverStats.metrics.messagesReceived,
bytesSent: serverStats.metrics.bytesSent,
bytesReceived: serverStats.metrics.bytesReceived,
errors: serverStats.metrics.errors errors: serverStats.metrics.errors
} }
}); });
// Client metrics // Client stats
const clientStats = client.getStats(); const clientStats = client.getStats();
console.log({ console.log({
connected: clientStats.connected, connected: clientStats.connected,
reconnectAttempts: clientStats.reconnectAttempts, reconnectAttempts: clientStats.reconnectAttempts,
lastActivity: clientStats.lastActivity,
metrics: clientStats.metrics metrics: clientStats.metrics
}); });
// Track specific clients on server // Get specific client info
const clientInfo = server.getClientInfo('client-1'); const clientInfo = server.getClientInfo('client-123');
console.log({ console.log({
clientId: clientInfo.clientId, connectedAt: new Date(clientInfo.connectedAt),
metadata: clientInfo.metadata, lastActivity: new Date(clientInfo.lastActivity),
connectedAt: clientInfo.connectedAt, metadata: clientInfo.metadata
lastActivity: clientInfo.lastActivity,
subscriptions: clientInfo.subscriptions
}); });
``` ```
### 🛡️ Security & Limits ### 🎯 Broadcasting
Protect against malicious or misbehaving clients: Send messages to multiple clients:
```typescript
const secureServer = SmartIpc.createServer({
id: 'secure-service',
maxMessageSize: 10 * 1024 * 1024, // 10MB max message size
maxConnections: 100, // Limit concurrent connections
connectionTimeout: 60000, // Drop idle connections after 1 minute
// Authentication (coming soon)
auth: {
required: true,
validator: async (token) => {
// Validate auth token
return validateToken(token);
}
}
});
// Rate limiting per client
secureServer.use(rateLimitMiddleware({
windowMs: 60000, // 1 minute window
max: 100 // 100 requests per window
}));
```
### 🎯 Broadcast to Specific Clients
Send targeted messages:
```typescript ```typescript
// Broadcast to all connected clients // Broadcast to all connected clients
server.broadcast('system-alert', { await server.broadcast('announcement', {
message: 'Maintenance in 5 minutes' message: 'Server will restart in 5 minutes',
severity: 'warning'
}); });
// Send to specific client // Send to specific clients
server.sendToClient('client-1', 'personal-message', { await server.broadcastTo(
content: 'This is just for you' ['client-1', 'client-2'],
}); 'private-message',
{ content: 'This is just for you two' }
);
// Send to multiple specific clients // Send to one client
server.sendToClients(['client-1', 'client-2'], 'group-message', { await server.sendToClient('client-1', 'direct', {
content: 'Group notification' data: 'Personal message'
}); });
// Get all connected client IDs
const clients = server.getConnectedClients();
console.log('Connected clients:', clients);
``` ```
## Error Handling ## 🧪 Testing Utilities
Comprehensive error handling with typed errors: SmartIPC includes powerful helpers for testing:
### Wait for Server
```typescript ```typescript
import { IpcError, ConnectionError, TimeoutError } from '@push.rocks/smartipc'; import { SmartIpc } from '@push.rocks/smartipc';
// Client error handling // Start your server in another process
const serverProcess = spawn('node', ['server.js']);
// Wait for it to be ready
await SmartIpc.waitForServer({
socketPath: '/tmp/test.sock',
timeoutMs: 10000
});
// Now safe to connect clients
const client = SmartIpc.createClient({
id: 'test-client',
socketPath: '/tmp/test.sock'
});
await client.connect();
```
### Spawn and Connect
```typescript
// Helper that spawns a server and connects a client
const { client, serverProcess } = await SmartIpc.spawnAndConnect({
serverScript: './server.js',
socketPath: '/tmp/test.sock',
clientId: 'test-client',
connectRetry: {
enabled: true,
maxAttempts: 10
}
});
// Use the client
const response = await client.request('ping', {});
// Cleanup
await client.disconnect();
serverProcess.kill();
```
## 🎭 Event Handling
SmartIPC provides comprehensive event emitters:
```typescript
// Server events
server.on('start', () => console.log('Server started'));
server.on('ready', () => console.log('Server ready for connections'));
server.on('clientConnect', (clientId, metadata) => {
console.log(`Client ${clientId} connected with metadata:`, metadata);
});
server.on('clientDisconnect', (clientId) => {
console.log(`Client ${clientId} disconnected`);
});
server.on('error', (error, clientId) => {
console.error(`Error from ${clientId}:`, error);
});
// Client events
client.on('connect', () => console.log('Connected to server'));
client.on('disconnect', () => console.log('Disconnected from server'));
client.on('reconnecting', (attempt) => {
console.log(`Reconnection attempt ${attempt}`);
});
client.on('error', (error) => { client.on('error', (error) => {
if (error instanceof ConnectionError) { console.error('Client error:', error);
console.error('Connection failed:', error.message); });
} else if (error instanceof TimeoutError) { client.on('heartbeatTimeout', (error) => {
console.error('Request timed out:', error.message); console.warn('Heartbeat timeout:', error);
});
```
## 🛡️ Error Handling
Robust error handling with detailed error information:
```typescript
// Client-side error handling
try {
const response = await client.request('riskyOperation', data, {
timeout: 5000
});
} catch (error) {
if (error.message.includes('timeout')) {
console.error('Request timed out');
} else if (error.message.includes('Failed to register')) {
console.error('Could not register with server');
} else { } else {
console.error('Unknown error:', error); console.error('Unknown error:', error);
} }
});
// Server error handling
server.on('client-error', (clientId, error) => {
console.error(`Client ${clientId} error:`, error);
// Optionally disconnect misbehaving clients
if (error.code === 'INVALID_MESSAGE') {
server.disconnectClient(clientId);
}
});
// Request with error handling
try {
const response = await client.request('risky-operation', data, {
timeout: 5000,
retries: 3
});
} catch (error) {
if (error instanceof TimeoutError) {
// Handle timeout
} else {
// Handle other errors
}
} }
```
## Testing // Server-side error boundaries
server.onMessage('process', async (data, clientId) => {
SmartIPC includes comprehensive testing utilities: try {
return await riskyProcessing(data);
```typescript } catch (error) {
import { createTestServer, createTestClient } from '@push.rocks/smartipc/testing'; console.error(`Processing failed for ${clientId}:`, error);
throw error; // Will be sent back to client as error
describe('My IPC integration', () => { }
let server, client;
beforeEach(async () => {
server = await createTestServer();
client = await createTestClient(server);
});
afterEach(async () => {
await client.disconnect();
await server.stop();
});
it('should handle messages', async () => {
server.onMessage('test', (data) => ({ echo: data }));
const response = await client.request('test', { value: 42 });
expect(response.echo.value).toBe(42);
});
}); });
``` ```
## Performance Benchmarks ## 🏗️ Architecture
SmartIPC has been optimized for high throughput and low latency: SmartIPC uses a clean, layered architecture:
| Transport | Messages/sec | Avg Latency | Use Case |
|-----------|-------------|-------------|----------|
| Unix Socket | 150,000+ | < 0.1ms | Local high-performance IPC |
| TCP (localhost) | 100,000+ | < 0.2ms | Local network-capable IPC |
| TCP (network) | 50,000+ | < 1ms | Distributed systems |
| Named Pipe | 120,000+ | < 0.15ms | Windows local IPC |
*Benchmarked on modern hardware with 1KB message payloads*
## Architecture
SmartIPC uses a layered architecture for maximum flexibility:
``` ```
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Application Layer Your Application │
(Your business logic and handlers) (Business logic)
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
IPC Client / Server IpcServer / IpcClient
│ (High-level API, patterns, routing) │ │ (High-level API, Message routing)
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ IPC Channel │ │ IpcChannel
│ (Connection management, reconnection, │ (Connection management, Heartbeat,
heartbeat, request/response) Reconnection, Request/Response) │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
@@ -435,43 +600,94 @@ SmartIPC uses a layered architecture for maximum flexibility:
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
``` ```
## Comparison with Alternatives ## 🎯 Common Use Cases
| Feature | SmartIPC | node-ipc | zeromq | | ### Microservices Communication
|---------|----------|----------|---------|--| ```typescript
| Zero Dependencies | ✅ | ❌ | ❌ | | // API Gateway
| TypeScript Native | ✅ | ❌ | ❌ | | const gateway = SmartIpc.createServer({
| Auto-Reconnect | ✅ | ⚠️ | ✅ | | id: 'api-gateway',
| Request/Response | ✅ | ⚠️ | ✅ | | socketPath: '/tmp/gateway.sock'
| Pub/Sub | ✅ | ❌ | ✅ | | });
| Built-in Metrics | ✅ | ❌ | ❌ | |
| Heartbeat | ✅ | ❌ | ✅ | |
| Message Size Limits | ✅ | ❌ | ✅ | |
| Type Safety | ✅ | ❌ | ❌ | |
## Contributing // User Service
const userService = SmartIpc.createClient({
id: 'api-gateway',
socketPath: '/tmp/gateway.sock',
clientId: 'user-service'
});
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. // Order Service
const orderService = SmartIpc.createClient({
```bash id: 'api-gateway',
# Clone the repository socketPath: '/tmp/gateway.sock',
git clone https://code.foss.global/push.rocks/smartipc.git clientId: 'order-service'
});
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build
``` ```
## Support ### Worker Process Management
```typescript
// Main process
const server = SmartIpc.createServer({
id: 'main',
socketPath: '/tmp/workers.sock'
});
- 📖 [Documentation](https://code.foss.global/push.rocks/smartipc) server.onMessage('job-complete', (result, workerId) => {
- 🐛 [Issue Tracker](https://code.foss.global/push.rocks/smartipc/issues) console.log(`Worker ${workerId} completed job:`, result);
- 💬 [Discussions](https://code.foss.global/push.rocks/smartipc/discussions) });
// Worker process
const worker = SmartIpc.createClient({
id: 'main',
socketPath: '/tmp/workers.sock',
clientId: `worker-${process.pid}`
});
await worker.sendMessage('job-complete', {
jobId: '123',
result: processedData
});
```
### Real-time Event Distribution
```typescript
// Event bus
const eventBus = SmartIpc.createServer({
id: 'event-bus',
socketPath: '/tmp/events.sock'
});
// Services subscribe to events
const analyticsService = SmartIpc.createClient({
id: 'event-bus',
socketPath: '/tmp/events.sock'
});
await analyticsService.subscribe('user.*', (event) => {
trackEvent(event);
});
```
## 📈 Performance
SmartIPC is optimized for high throughput and low latency:
| Transport | Messages/sec | Avg Latency | Use Case |
|-----------|-------------|-------------|----------|
| Unix Socket | 150,000+ | < 0.1ms | Local high-performance IPC (Linux/macOS) |
| Named Pipe | 120,000+ | < 0.15ms | Windows local IPC |
| TCP (localhost) | 100,000+ | < 0.2ms | Local network-capable IPC |
| TCP (network) | 50,000+ | < 1ms | Distributed systems |
- **Memory efficient**: Streaming support for large payloads
- **CPU efficient**: Event-driven, non-blocking I/O
## 🔧 Requirements
- Node.js >= 14.x
- TypeScript >= 4.x (for development)
- Unix-like OS (Linux, macOS) or Windows
## License and Legal Information ## License and Legal Information
@@ -491,7 +707,3 @@ Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works. By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
---
**Built with ❤️ by Task Venture Capital GmbH**

259
test/test.improvements.ts Normal file
View File

@@ -0,0 +1,259 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartipc from '../ts/index.js';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
const testSocketPath = path.join(os.tmpdir(), `test-ipc-improvements-${Date.now()}.sock`);
// Test 1: Server Readiness API
tap.test('Server readiness API should emit ready event', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable heartbeat for this test
});
let readyEventFired = false;
server.on('ready', () => {
readyEventFired = true;
});
await server.start({ readyWhen: 'accepting' });
expect(readyEventFired).toBeTrue();
expect(server.getIsReady()).toBeTrue();
await server.stop();
});
// Test 2: Automatic Socket Cleanup
tap.test('Should cleanup stale socket file automatically', async () => {
// Create a stale socket file
fs.writeFileSync(testSocketPath, '');
expect(fs.existsSync(testSocketPath)).toBeTrue();
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable heartbeat for this test
});
// Should clean up and start successfully
await server.start();
expect(server.getIsReady()).toBeTrue();
await server.stop();
});
// Test 3: Basic Connection with New Options
tap.test('Client should connect with basic configuration', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable heartbeat for this test
});
await server.start({ readyWhen: 'accepting' });
// Wait for server to be fully ready
await new Promise(resolve => setTimeout(resolve, 200));
const client = smartipc.SmartIpc.createClient({
id: 'test-server',
socketPath: testSocketPath,
clientId: 'test-client',
registerTimeoutMs: 10000 // Longer timeout
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
// Test 4: Heartbeat Configuration Without Throwing
tap.test('Heartbeat should use event mode instead of throwing', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable server heartbeat for this test
});
// Add error handler to prevent unhandled errors
server.on('error', () => {});
await server.start({ readyWhen: 'accepting' });
await new Promise(resolve => setTimeout(resolve, 200));
const client = smartipc.SmartIpc.createClient({
id: 'test-server',
socketPath: testSocketPath,
clientId: 'heartbeat-client',
heartbeat: true,
heartbeatInterval: 100,
heartbeatTimeout: 300,
heartbeatInitialGracePeriodMs: 1000,
heartbeatThrowOnTimeout: false // Don't throw, emit event
});
let heartbeatTimeoutFired = false;
client.on('heartbeatTimeout', () => {
heartbeatTimeoutFired = true;
});
client.on('error', () => {});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
// Wait a bit but within grace period
await new Promise(resolve => setTimeout(resolve, 500));
// Should still be connected, no timeout during grace period
expect(heartbeatTimeoutFired).toBeFalse();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
// Test 5: Wait for Server Helper
tap.test('waitForServer should detect when server becomes ready', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable heartbeat for this test
});
// Start server after delay
setTimeout(async () => {
await server.start();
}, 200);
// Wait for server should succeed
await smartipc.SmartIpc.waitForServer({
socketPath: testSocketPath,
timeoutMs: 3000
});
// Server should be ready now
const client = smartipc.SmartIpc.createClient({
id: 'test-server',
socketPath: testSocketPath,
clientId: 'wait-test-client'
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
// Test 6: Connect Retry Configuration
tap.test('Client retry should work with delayed server', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: false // Disable heartbeat for this test
});
const client = smartipc.SmartIpc.createClient({
id: 'test-server',
socketPath: testSocketPath,
clientId: 'retry-client',
connectRetry: {
enabled: true,
initialDelay: 100,
maxDelay: 500,
maxAttempts: 10,
totalTimeout: 5000
}
});
// Start server after a delay
setTimeout(async () => {
await server.start({ readyWhen: 'accepting' });
}, 300);
// Client should retry and eventually connect
await client.connect({ waitForReady: true, waitTimeout: 5000 });
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
// Test 7: clientOnly prevents client from auto-starting a server
tap.test('clientOnly should prevent auto-start and fail fast', async () => {
const uniqueSocketPath = path.join(os.tmpdir(), `smartipc-clientonly-${Date.now()}.sock`);
const client = smartipc.SmartIpc.createClient({
id: 'clientonly-test',
socketPath: uniqueSocketPath,
clientId: 'co-client-1',
clientOnly: true,
connectRetry: { enabled: false }
});
let failed = false;
try {
await client.connect();
} catch (err: any) {
failed = true;
expect(err.message).toContain('clientOnly prevents auto-start');
}
expect(failed).toBeTrue();
// Ensure no server-side socket was created
expect(fs.existsSync(uniqueSocketPath)).toBeFalse();
});
// Test 8: env SMARTIPC_CLIENT_ONLY enforces clientOnly behavior
tap.test('SMARTIPC_CLIENT_ONLY=1 should enforce clientOnly', async () => {
const uniqueSocketPath = path.join(os.tmpdir(), `smartipc-clientonly-env-${Date.now()}.sock`);
const prev = process.env.SMARTIPC_CLIENT_ONLY;
process.env.SMARTIPC_CLIENT_ONLY = '1';
const client = smartipc.SmartIpc.createClient({
id: 'clientonly-test-env',
socketPath: uniqueSocketPath,
clientId: 'co-client-2',
connectRetry: { enabled: false }
});
let failed = false;
try {
await client.connect();
} catch (err: any) {
failed = true;
expect(err.message).toContain('clientOnly prevents auto-start');
}
expect(failed).toBeTrue();
expect(fs.existsSync(uniqueSocketPath)).toBeFalse();
// restore env
if (prev === undefined) {
delete process.env.SMARTIPC_CLIENT_ONLY;
} else {
process.env.SMARTIPC_CLIENT_ONLY = prev;
}
});
// Cleanup
tap.test('Cleanup test socket', async () => {
try {
fs.unlinkSync(testSocketPath);
} catch (e) {
// Ignore if doesn't exist
}
});
export default tap.start();

286
test/test.reliability.ts Normal file
View File

@@ -0,0 +1,286 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartipc from '../ts/index.js';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
const testSocketPath = path.join(os.tmpdir(), `test-ipc-reliability-${Date.now()}.sock`);
tap.test('Server Readiness API', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true
});
let readyEventFired = false;
server.on('ready', () => {
readyEventFired = true;
});
// Start server with 'accepting' readiness mode
await server.start({ readyWhen: 'accepting' });
// Check that ready event was fired
expect(readyEventFired).toBeTrue();
expect(server.getIsReady()).toBeTrue();
await server.stop();
});
tap.test('Automatic Socket Cleanup', async () => {
// Create a stale socket file
fs.writeFileSync(testSocketPath, '');
expect(fs.existsSync(testSocketPath)).toBeTrue();
const server = smartipc.SmartIpc.createServer({
id: 'test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
socketMode: 0o600
});
// Should clean up stale socket and start successfully
await server.start();
expect(server.getIsReady()).toBeTrue();
await server.stop();
});
tap.test('Client Connection Retry', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'retry-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true
});
// Create client with retry configuration
const client = smartipc.SmartIpc.createClient({
id: 'retry-client',
socketPath: testSocketPath,
connectRetry: {
enabled: true,
initialDelay: 50,
maxDelay: 500,
maxAttempts: 10,
totalTimeout: 5000
},
registerTimeoutMs: 3000
});
// Start server first with accepting readiness mode
await server.start({ readyWhen: 'accepting' });
// Give server a moment to be fully ready
await new Promise(resolve => setTimeout(resolve, 100));
// Client should connect successfully with retry enabled
await client.connect();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
tap.test('Graceful Heartbeat Handling', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'heartbeat-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: true,
heartbeatInterval: 100,
heartbeatTimeout: 500,
heartbeatInitialGracePeriodMs: 1000,
heartbeatThrowOnTimeout: false
});
// Add error handler to prevent unhandled error
server.on('error', (error) => {
// Ignore heartbeat errors in this test
});
await server.start({ readyWhen: 'accepting' });
// Give server a moment to be fully ready
await new Promise(resolve => setTimeout(resolve, 100));
const client = smartipc.SmartIpc.createClient({
id: 'heartbeat-client',
socketPath: testSocketPath,
heartbeat: true,
heartbeatInterval: 100,
heartbeatTimeout: 500,
heartbeatInitialGracePeriodMs: 1000,
heartbeatThrowOnTimeout: false
});
let heartbeatTimeoutFired = false;
client.on('heartbeatTimeout', () => {
heartbeatTimeoutFired = true;
});
// Add error handler to prevent unhandled error
client.on('error', (error) => {
// Ignore errors in this test
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
// Wait to ensure heartbeat is working
await new Promise(resolve => setTimeout(resolve, 300));
// Heartbeat should not timeout during normal operation
expect(heartbeatTimeoutFired).toBeFalse();
await client.disconnect();
await server.stop();
});
tap.test('Test Helper - waitForServer', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'wait-test-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true
});
// Start server after a delay
setTimeout(() => {
server.start();
}, 100);
// Wait for server should succeed
await smartipc.SmartIpc.waitForServer({
socketPath: testSocketPath,
timeoutMs: 3000
});
// Server should be ready
const client = smartipc.SmartIpc.createClient({
id: 'wait-test-client',
socketPath: testSocketPath
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
tap.test('Race Condition - Immediate Connect After Server Start', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'race-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true
});
// Start server and immediately try to connect
const serverPromise = server.start({ readyWhen: 'accepting' });
const client = smartipc.SmartIpc.createClient({
id: 'race-client',
socketPath: testSocketPath,
connectRetry: {
enabled: true,
maxAttempts: 20,
initialDelay: 10,
maxDelay: 100
},
registerTimeoutMs: 5000
});
// Wait for server to be ready
await serverPromise;
// Client should be able to connect without race condition
await client.connect();
expect(client.getIsConnected()).toBeTrue();
// Test request/response to ensure full functionality
server.onMessage('test', async (data) => {
return { echo: data };
});
const response = await client.request('test', { message: 'hello' });
expect(response.echo.message).toEqual('hello');
await client.disconnect();
await server.stop();
});
tap.test('Multiple Clients with Retry', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'multi-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true,
maxClients: 10
});
await server.start({ readyWhen: 'accepting' });
// Create multiple clients with retry
const clients = [];
for (let i = 0; i < 5; i++) {
const client = smartipc.SmartIpc.createClient({
id: `client-${i}`,
socketPath: testSocketPath,
connectRetry: {
enabled: true,
maxAttempts: 5
}
});
clients.push(client);
}
// Connect all clients concurrently
await Promise.all(clients.map(c => c.connect()));
// Verify all connected
for (const client of clients) {
expect(client.getIsConnected()).toBeTrue();
}
// Disconnect all
await Promise.all(clients.map(c => c.disconnect()));
await server.stop();
});
tap.test('Server Restart with Socket Cleanup', async () => {
const server = smartipc.SmartIpc.createServer({
id: 'restart-server',
socketPath: testSocketPath,
autoCleanupSocketFile: true
});
// First start
await server.start();
expect(server.getIsReady()).toBeTrue();
await server.stop();
// Second start - should cleanup and work
await server.start();
expect(server.getIsReady()).toBeTrue();
const client = smartipc.SmartIpc.createClient({
id: 'restart-client',
socketPath: testSocketPath
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
await client.disconnect();
await server.stop();
});
// Clean up test socket file
tap.test('Cleanup', async () => {
try {
fs.unlinkSync(testSocketPath);
} catch (e) {
// Ignore if doesn't exist
}
});
export default tap.start();

218
test/test.streaming.ts Normal file
View File

@@ -0,0 +1,218 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartipc from '../ts/index.js';
import * as smartdelay from '@push.rocks/smartdelay';
import * as plugins from '../ts/smartipc.plugins.js';
import * as fs from 'fs';
import * as path from 'path';
let server: smartipc.IpcServer;
let client: smartipc.IpcClient;
tap.test('setup TCP server and client (streaming)', async () => {
server = smartipc.SmartIpc.createServer({
id: 'stream-test-server',
host: '127.0.0.1',
port: 19876,
heartbeat: false
});
await server.start();
client = smartipc.SmartIpc.createClient({
id: 'stream-test-server',
host: '127.0.0.1',
port: 19876,
clientId: 'stream-client-1',
heartbeat: false
});
await client.connect();
expect(client.getIsConnected()).toBeTrue();
});
tap.test('client -> server streaming large payload', async () => {
// Create ~5MB buffer
const size = 5 * 1024 * 1024 + 123; // add some non-chunk-aligned bytes
const data = Buffer.alloc(size);
for (let i = 0; i < size; i++) data[i] = i % 251;
const received: Buffer[] = [];
const done = new Promise<void>((resolve, reject) => {
server.on('stream', (info: any, readable: plugins.stream.Readable) => {
// only handle our test stream
if (info?.meta?.direction === 'client-to-server') {
readable.on('data', chunk => received.push(Buffer.from(chunk)));
readable.on('end', resolve);
readable.on('error', reject);
}
});
});
// Send stream from client
const readable = plugins.stream.Readable.from(data);
await client.sendStream(readable, { meta: { direction: 'client-to-server' }, chunkSize: 64 * 1024 });
await done;
const result = Buffer.concat(received);
expect(result.length).toEqual(data.length);
expect(result.equals(data)).toBeTrue();
});
tap.test('server -> client streaming large payload', async () => {
const size = 6 * 1024 * 1024 + 7;
const data = Buffer.alloc(size);
for (let i = 0; i < size; i++) data[i] = (i * 7) % 255;
const received: Buffer[] = [];
const done = new Promise<void>((resolve, reject) => {
client.on('stream', (info: any, readable: plugins.stream.Readable) => {
if (info?.meta?.direction === 'server-to-client') {
readable.on('data', chunk => received.push(Buffer.from(chunk)));
readable.on('end', resolve);
readable.on('error', reject);
}
});
});
const readable = plugins.stream.Readable.from(data);
await server.sendStreamToClient('stream-client-1', readable, { meta: { direction: 'server-to-client' }, chunkSize: 64 * 1024 });
await done;
const result = Buffer.concat(received);
expect(result.length).toEqual(data.length);
expect(result.equals(data)).toBeTrue();
});
tap.test('client -> server file transfer to disk', async () => {
const baseTmp1 = path.join(process.cwd(), '.nogit', 'tmp');
fs.mkdirSync(baseTmp1, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(baseTmp1, 'tmp-'));
const srcPath = path.join(tmpDir, 'src.bin');
const dstPath = path.join(tmpDir, 'dst.bin');
// Prepare file ~1MB
const size = 1024 * 1024 + 333;
const buf = Buffer.alloc(size);
for (let i = 0; i < size; i++) buf[i] = (i * 11) % 255;
fs.writeFileSync(srcPath, buf);
const done = new Promise<void>((resolve, reject) => {
server.on('stream', async (info: any, readable: plugins.stream.Readable) => {
if (info?.meta?.type === 'file' && info?.meta?.basename === 'src.bin') {
try {
await smartipc.pipeStreamToFile(readable, dstPath);
resolve();
} catch (e) {
reject(e);
}
}
});
});
await client.sendFile(srcPath);
await done;
const out = fs.readFileSync(dstPath);
expect(out.equals(buf)).toBeTrue();
});
tap.test('server -> client file transfer to disk', async () => {
const baseTmp2 = path.join(process.cwd(), '.nogit', 'tmp');
fs.mkdirSync(baseTmp2, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(baseTmp2, 'tmp-'));
const srcPath = path.join(tmpDir, 'serverfile.bin');
const dstPath = path.join(tmpDir, 'clientfile.bin');
const size = 512 * 1024 + 77;
const buf = Buffer.alloc(size);
for (let i = 0; i < size; i++) buf[i] = (i * 17) % 251;
fs.writeFileSync(srcPath, buf);
const done = new Promise<void>((resolve, reject) => {
client.on('stream', async (info: any, readable: plugins.stream.Readable) => {
if (info?.meta?.type === 'file' && info?.meta?.basename === 'serverfile.bin') {
try {
await smartipc.pipeStreamToFile(readable, dstPath);
resolve();
} catch (e) {
reject(e);
}
}
});
});
await server.sendFileToClient('stream-client-1', srcPath);
await done;
const out = fs.readFileSync(dstPath);
expect(out.equals(buf)).toBeTrue();
});
tap.test('receiver cancels an incoming stream', async () => {
// Create a slow readable that emits many chunks
const bigChunk = Buffer.alloc(128 * 1024, 1);
let pushed = 0;
const readable = new plugins.stream.Readable({
read() {
setTimeout(() => {
if (pushed > 200) {
this.push(null);
} else {
this.push(bigChunk);
pushed++;
}
}, 5);
}
});
let cancelled = false;
const cancelPromise = new Promise<void>((resolve) => {
server.on('stream', (info: any, r: plugins.stream.Readable) => {
if (info?.meta?.direction === 'client-to-server-cancel') {
// cancel after first chunk
r.once('data', async () => {
cancelled = true;
// send cancel back to sender
await (server as any).primaryChannel.cancelIncomingStream(info.streamId, { clientId: info.clientId });
resolve();
});
r.on('error', () => { /* ignore cancellation error */ });
// drain to trigger data
r.resume();
}
});
});
const sendPromise = client
.sendStream(readable, { meta: { direction: 'client-to-server-cancel' } })
.catch(() => { /* expected due to cancel */ });
await cancelPromise;
expect(cancelled).toBeTrue();
await sendPromise;
});
tap.test('enforce maxConcurrentStreams option', async () => {
// Setup separate low-limit server/client
const srv = smartipc.SmartIpc.createServer({ id: 'limit-srv', host: '127.0.0.1', port: 19999, heartbeat: false, maxConcurrentStreams: 1 });
await srv.start();
const cli = smartipc.SmartIpc.createClient({ id: 'limit-srv', host: '127.0.0.1', port: 19999, clientId: 'limit-client', heartbeat: false, maxConcurrentStreams: 1 });
await cli.connect();
const r1 = plugins.stream.Readable.from(Buffer.alloc(256 * 1024));
const r2 = plugins.stream.Readable.from(Buffer.alloc(256 * 1024));
const p1 = cli.sendStream(r1, { meta: { n: 1 } });
let threw = false;
try {
await cli.sendStream(r2, { meta: { n: 2 } });
} catch (e) {
threw = true;
}
expect(threw).toBeTrue();
await p1;
await cli.disconnect();
await srv.stop();
});
tap.test('cleanup streaming test', async () => {
await client.disconnect();
await server.stop();
await smartdelay.delayFor(50);
});
export default tap.start();

View File

@@ -2,6 +2,10 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartipc from '../ts/index.js'; import * as smartipc from '../ts/index.js';
import * as smartdelay from '@push.rocks/smartdelay'; import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpromise from '@push.rocks/smartpromise';
import * as path from 'path';
import * as os from 'os';
const testSocketPath = path.join(os.tmpdir(), `test-smartipc-${Date.now()}.sock`);
let server: smartipc.IpcServer; let server: smartipc.IpcServer;
let client1: smartipc.IpcClient; let client1: smartipc.IpcClient;
@@ -11,12 +15,13 @@ let client2: smartipc.IpcClient;
tap.test('should create and start an IPC server', async () => { tap.test('should create and start an IPC server', async () => {
server = smartipc.SmartIpc.createServer({ server = smartipc.SmartIpc.createServer({
id: 'test-server', id: 'test-server',
socketPath: '/tmp/test-smartipc.sock', socketPath: testSocketPath,
autoCleanupSocketFile: true,
heartbeat: true, heartbeat: true,
heartbeatInterval: 2000 heartbeatInterval: 2000
}); });
await server.start(); await server.start({ readyWhen: 'accepting' });
expect(server.getStats().isRunning).toBeTrue(); expect(server.getStats().isRunning).toBeTrue();
}); });
@@ -24,11 +29,12 @@ tap.test('should create and start an IPC server', async () => {
tap.test('should create and connect a client', async () => { tap.test('should create and connect a client', async () => {
client1 = smartipc.SmartIpc.createClient({ client1 = smartipc.SmartIpc.createClient({
id: 'test-server', id: 'test-server',
socketPath: '/tmp/test-smartipc.sock', socketPath: testSocketPath,
clientId: 'client-1', clientId: 'client-1',
metadata: { name: 'Test Client 1' }, metadata: { name: 'Test Client 1' },
autoReconnect: true, autoReconnect: true,
heartbeat: true heartbeat: true,
clientOnly: true
}); });
await client1.connect(); await client1.connect();
@@ -76,9 +82,10 @@ tap.test('should handle request/response pattern', async () => {
tap.test('should handle multiple clients', async () => { tap.test('should handle multiple clients', async () => {
client2 = smartipc.SmartIpc.createClient({ client2 = smartipc.SmartIpc.createClient({
id: 'test-server', id: 'test-server',
socketPath: '/tmp/test-smartipc.sock', socketPath: testSocketPath,
clientId: 'client-2', clientId: 'client-2',
metadata: { name: 'Test Client 2' } metadata: { name: 'Test Client 2' },
clientOnly: true
}); });
await client2.connect(); await client2.connect();
@@ -154,17 +161,6 @@ tap.test('should handle pub/sub pattern', async () => {
messageReceived.resolve(); messageReceived.resolve();
}); });
// Server handles the subscription
server.onMessage('__subscribe__', async (payload, clientId) => {
expect(payload.topic).toEqual('news');
});
// Server handles publishing
server.onMessage('__publish__', async (payload, clientId) => {
// Broadcast to all subscribers of the topic
await server.broadcast(`topic:${payload.topic}`, payload.payload);
});
// Client 2 publishes to the topic // Client 2 publishes to the topic
await client2.publish('news', { headline: 'Breaking news!' }); await client2.publish('news', { headline: 'Breaking news!' });

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartipc', name: '@push.rocks/smartipc',
version: '2.0.1', version: '2.3.0',
description: 'A library for node inter process communication, providing an easy-to-use API for IPC.' description: 'A library for node inter process communication, providing an easy-to-use API for IPC.'
} }

View File

@@ -22,6 +22,12 @@ export interface IIpcChannelOptions extends IIpcTransportOptions {
heartbeatInterval?: number; heartbeatInterval?: number;
/** Heartbeat timeout in ms */ /** Heartbeat timeout in ms */
heartbeatTimeout?: number; heartbeatTimeout?: number;
/** Initial grace period before heartbeat timeout in ms */
heartbeatInitialGracePeriodMs?: number;
/** Throw on heartbeat timeout (default: true, set false to emit event instead) */
heartbeatThrowOnTimeout?: boolean;
/** Maximum concurrent streams (incoming/outgoing) */
maxConcurrentStreams?: number;
} }
/** /**
@@ -45,9 +51,17 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
private reconnectTimer?: NodeJS.Timeout; private reconnectTimer?: NodeJS.Timeout;
private heartbeatTimer?: NodeJS.Timeout; private heartbeatTimer?: NodeJS.Timeout;
private heartbeatCheckTimer?: NodeJS.Timeout; private heartbeatCheckTimer?: NodeJS.Timeout;
private heartbeatGraceTimer?: NodeJS.Timeout;
private lastHeartbeat: number = Date.now(); private lastHeartbeat: number = Date.now();
private connectionStartTime: number = Date.now();
private isReconnecting = false; private isReconnecting = false;
private isClosing = false; private isClosing = false;
// Streaming state
private incomingStreams = new Map<string, plugins.stream.PassThrough>();
private incomingStreamMeta = new Map<string, Record<string, any>>();
private outgoingStreams = new Map<string, { cancelled: boolean; abort?: () => void }>();
private activeIncomingStreams = 0;
private activeOutgoingStreams = 0;
// Metrics // Metrics
private metrics = { private metrics = {
@@ -73,9 +87,22 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
heartbeat: true, heartbeat: true,
heartbeatInterval: 5000, heartbeatInterval: 5000,
heartbeatTimeout: 10000, heartbeatTimeout: 10000,
maxConcurrentStreams: 32,
...options ...options
}; };
// Normalize heartbeatThrowOnTimeout to boolean (defensive for JS consumers)
const throwOnTimeout = (this.options as any).heartbeatThrowOnTimeout;
if (throwOnTimeout !== undefined) {
if (throwOnTimeout === 'false') {
this.options.heartbeatThrowOnTimeout = false;
} else if (throwOnTimeout === 'true') {
this.options.heartbeatThrowOnTimeout = true;
} else if (typeof throwOnTimeout !== 'boolean') {
this.options.heartbeatThrowOnTimeout = Boolean(throwOnTimeout);
}
}
this.transport = createTransport(this.options); this.transport = createTransport(this.options);
this.setupTransportHandlers(); this.setupTransportHandlers();
} }
@@ -110,6 +137,13 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
this.handleMessage(message); this.handleMessage(message);
}); });
// Forward per-client disconnects from transports that support multi-client servers
// We re-emit a 'clientDisconnected' event with the clientId if known so higher layers can act.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.transport as any).on?.('clientDisconnected', (_socket: any, clientId?: string) => {
this.emit('clientDisconnected', clientId);
});
this.transport.on('drain', () => { this.transport.on('drain', () => {
this.emit('drain'); this.emit('drain');
}); });
@@ -203,6 +237,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
this.stopHeartbeat(); this.stopHeartbeat();
this.lastHeartbeat = Date.now(); this.lastHeartbeat = Date.now();
this.connectionStartTime = Date.now();
// Send heartbeat messages // Send heartbeat messages
this.heartbeatTimer = setInterval(() => { this.heartbeatTimer = setInterval(() => {
@@ -211,14 +246,43 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
}); });
}, this.options.heartbeatInterval!); }, this.options.heartbeatInterval!);
// Delay starting the check until after the grace period
const gracePeriod = this.options.heartbeatInitialGracePeriodMs || 0;
if (gracePeriod > 0) {
// Use a timer to delay the first check
this.heartbeatGraceTimer = setTimeout(() => {
this.startHeartbeatCheck();
}, gracePeriod);
} else {
// No grace period, start checking immediately
this.startHeartbeatCheck();
}
}
/**
* Start heartbeat timeout checking (separated for grace period handling)
*/
private startHeartbeatCheck(): void {
// Check for heartbeat timeout // Check for heartbeat timeout
this.heartbeatCheckTimer = setInterval(() => { this.heartbeatCheckTimer = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat; const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat;
if (timeSinceLastHeartbeat > this.options.heartbeatTimeout!) { if (timeSinceLastHeartbeat > this.options.heartbeatTimeout!) {
this.emit('error', new Error('Heartbeat timeout')); const error = new Error('Heartbeat timeout');
if (this.options.heartbeatThrowOnTimeout !== false) {
// Default behavior: emit error which may cause disconnect
this.emit('error', error);
this.transport.disconnect().catch(() => {}); this.transport.disconnect().catch(() => {});
} else {
// Emit heartbeatTimeout event instead of error
this.emit('heartbeatTimeout', error);
// Clear timers to avoid repeated events
this.stopHeartbeat();
} }
}, this.options.heartbeatTimeout! / 2); }
}, Math.max(1000, Math.floor(this.options.heartbeatTimeout! / 2)));
} }
/** /**
@@ -234,6 +298,11 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
clearInterval(this.heartbeatCheckTimer); clearInterval(this.heartbeatCheckTimer);
this.heartbeatCheckTimer = undefined; this.heartbeatCheckTimer = undefined;
} }
if (this.heartbeatGraceTimer) {
clearTimeout(this.heartbeatGraceTimer);
this.heartbeatGraceTimer = undefined;
}
} }
/** /**
@@ -265,6 +334,105 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
return; return;
} }
// Handle streaming control messages
if (message.type === '__stream_init__') {
const streamId = (message.payload as any)?.streamId as string;
const meta = (message.payload as any)?.meta as Record<string, any> | undefined;
if (typeof streamId === 'string' && streamId.length) {
// Enforce max concurrent incoming streams
if (this.activeIncomingStreams >= (this.options.maxConcurrentStreams || Infinity)) {
const response: IIpcMessageEnvelope = {
id: plugins.crypto.randomUUID(),
type: '__stream_error__',
timestamp: Date.now(),
payload: { streamId, error: 'Max concurrent streams exceeded' },
headers: message.headers?.clientId ? { clientId: message.headers.clientId } : undefined
};
this.transport.send(response).catch(() => {});
return;
}
const pass = new plugins.stream.PassThrough();
this.incomingStreams.set(streamId, pass);
if (meta) this.incomingStreamMeta.set(streamId, meta);
this.activeIncomingStreams++;
// Emit a high-level stream event
const headersClientId = message.headers?.clientId;
const eventPayload = {
streamId,
meta: meta || {},
headers: message.headers || {},
clientId: headersClientId,
};
// Emit as ('stream', info, readable)
this.emit('stream', eventPayload, pass);
}
return;
}
if (message.type === '__stream_chunk__') {
const streamId = (message.payload as any)?.streamId as string;
const chunkB64 = (message.payload as any)?.chunk as string;
const pass = this.incomingStreams.get(streamId);
if (pass && typeof chunkB64 === 'string') {
try {
const chunk = Buffer.from(chunkB64, 'base64');
pass.write(chunk);
} catch (e) {
// If decode fails, destroy stream
pass.destroy(e as Error);
this.incomingStreams.delete(streamId);
this.incomingStreamMeta.delete(streamId);
}
}
return;
}
if (message.type === '__stream_end__') {
const streamId = (message.payload as any)?.streamId as string;
const pass = this.incomingStreams.get(streamId);
if (pass) {
pass.end();
this.incomingStreams.delete(streamId);
this.incomingStreamMeta.delete(streamId);
this.activeIncomingStreams = Math.max(0, this.activeIncomingStreams - 1);
}
return;
}
if (message.type === '__stream_error__') {
const streamId = (message.payload as any)?.streamId as string;
const errMsg = (message.payload as any)?.error as string;
const pass = this.incomingStreams.get(streamId);
if (pass) {
pass.destroy(new Error(errMsg || 'stream error'));
this.incomingStreams.delete(streamId);
this.incomingStreamMeta.delete(streamId);
this.activeIncomingStreams = Math.max(0, this.activeIncomingStreams - 1);
}
return;
}
if (message.type === '__stream_cancel__') {
const streamId = (message.payload as any)?.streamId as string;
// Cancel outgoing stream with same id if present
const ctrl = this.outgoingStreams.get(streamId);
if (ctrl) {
ctrl.cancelled = true;
try { ctrl.abort?.(); } catch {}
this.outgoingStreams.delete(streamId);
this.activeOutgoingStreams = Math.max(0, this.activeOutgoingStreams - 1);
}
// Also cancel any incoming stream if tracked
const pass = this.incomingStreams.get(streamId);
if (pass) {
try { pass.destroy(new Error('stream cancelled')); } catch {}
this.incomingStreams.delete(streamId);
this.incomingStreamMeta.delete(streamId);
this.activeIncomingStreams = Math.max(0, this.activeIncomingStreams - 1);
}
return;
}
// Handle request/response // Handle request/response
if (message.correlationId && this.pendingRequests.has(message.correlationId)) { if (message.correlationId && this.pendingRequests.has(message.correlationId)) {
const pending = this.pendingRequests.get(message.correlationId)!; const pending = this.pendingRequests.get(message.correlationId)!;
@@ -408,7 +576,7 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
* Register a message handler * Register a message handler
*/ */
public on(event: string, handler: (payload: any) => any | Promise<any>): this { public on(event: string, handler: (payload: any) => any | Promise<any>): this {
if (event === 'message' || event === 'connect' || event === 'disconnect' || event === 'error' || event === 'reconnecting' || event === 'drain') { if (event === 'message' || event === 'connect' || event === 'disconnect' || event === 'error' || event === 'reconnecting' || event === 'drain' || event === 'heartbeatTimeout' || event === 'clientDisconnected' || event === 'stream') {
// Special handling for channel events // Special handling for channel events
super.on(event, handler); super.on(event, handler);
} else { } else {
@@ -470,3 +638,129 @@ export class IpcChannel<TRequest = any, TResponse = any> extends plugins.EventEm
}; };
} }
} }
/**
* Streaming helpers
*/
export interface IStreamSendOptions {
headers?: Record<string, any>;
chunkSize?: number; // bytes, default 64k
streamId?: string;
meta?: Record<string, any>;
}
export type ReadableLike = NodeJS.ReadableStream | plugins.stream.Readable;
// Extend IpcChannel with a sendStream method
export interface IpcChannel<TRequest, TResponse> {
sendStream(readable: ReadableLike, options?: IStreamSendOptions): Promise<void>;
cancelOutgoingStream(streamId: string, headers?: Record<string, any>): Promise<void>;
cancelIncomingStream(streamId: string, headers?: Record<string, any>): Promise<void>;
}
IpcChannel.prototype.sendStream = async function(this: IpcChannel, readable: ReadableLike, options?: IStreamSendOptions): Promise<void> {
const streamId = options?.streamId || (plugins.crypto.randomUUID ? plugins.crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
const headers = options?.headers || {};
const chunkSize = Math.max(1024, Math.min(options?.chunkSize || 64 * 1024, (this as any).options.maxMessageSize || 8 * 1024 * 1024));
const self: any = this;
// Enforce max concurrent outgoing streams (reserve a slot synchronously)
if (self.activeOutgoingStreams >= (self.options.maxConcurrentStreams || Infinity)) {
throw new Error('Max concurrent streams exceeded');
}
self.activeOutgoingStreams++;
self.outgoingStreams.set(streamId, {
cancelled: false,
abort: () => {
try { (readable as any).destroy?.(new Error('stream cancelled')); } catch {}
}
});
try {
// Send init after reserving slot
await (this as any).sendMessage('__stream_init__', { streamId, meta: options?.meta || {} }, headers);
} catch (e) {
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
throw e;
}
const readChunkAndSend = async (buf: Buffer) => {
// Slice into chunkSize frames if needed
for (let offset = 0; offset < buf.length; offset += chunkSize) {
const ctrl = self.outgoingStreams.get(streamId);
if (ctrl?.cancelled) {
return;
}
const slice = buf.subarray(offset, Math.min(offset + chunkSize, buf.length));
const chunkB64 = slice.toString('base64');
await (this as any).sendMessage('__stream_chunk__', { streamId, chunk: chunkB64 }, headers);
}
};
await new Promise<void>((resolve, reject) => {
let sending = Promise.resolve();
readable.on('data', (chunk: any) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
// Ensure sequential sending to avoid write races
sending = sending.then(() => readChunkAndSend(buf));
sending.catch(reject);
});
readable.on('end', async () => {
try {
await sending;
await (this as any).sendMessage('__stream_end__', { streamId }, headers);
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
resolve();
} catch (e) {
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
reject(e);
}
});
readable.on('error', async (err: Error) => {
try {
await sending.catch(() => {});
await (this as any).sendMessage('__stream_error__', { streamId, error: err.message }, headers);
} finally {
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
reject(err);
}
});
// In case the stream is already ended
const r = readable as any;
if (r.readableEnded) {
(async () => {
await (this as any).sendMessage('__stream_end__', { streamId }, headers);
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
resolve();
})().catch(reject);
}
});
};
IpcChannel.prototype.cancelOutgoingStream = async function(this: IpcChannel, streamId: string, headers?: Record<string, any>): Promise<void> {
const self: any = this;
const ctrl = self.outgoingStreams.get(streamId);
if (ctrl) {
ctrl.cancelled = true;
try { ctrl.abort?.(); } catch {}
self.outgoingStreams.delete(streamId);
self.activeOutgoingStreams = Math.max(0, self.activeOutgoingStreams - 1);
}
await (this as any).sendMessage('__stream_cancel__', { streamId }, headers || {});
};
IpcChannel.prototype.cancelIncomingStream = async function(this: IpcChannel, streamId: string, headers?: Record<string, any>): Promise<void> {
const self: any = this;
const pass = self.incomingStreams.get(streamId);
if (pass) {
try { pass.destroy(new Error('stream cancelled')); } catch {}
self.incomingStreams.delete(streamId);
self.incomingStreamMeta.delete(streamId);
self.activeIncomingStreams = Math.max(0, self.activeIncomingStreams - 1);
}
await (this as any).sendMessage('__stream_cancel__', { streamId }, headers || {});
};

View File

@@ -5,11 +5,35 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js';
/** /**
* Options for IPC Client * Options for IPC Client
*/ */
export interface IConnectRetryConfig {
/** Enable connection retry */
enabled: boolean;
/** Initial delay before first retry in ms */
initialDelay?: number;
/** Maximum delay between retries in ms */
maxDelay?: number;
/** Maximum number of attempts */
maxAttempts?: number;
/** Total timeout for all retry attempts in ms */
totalTimeout?: number;
}
export interface IClientConnectOptions {
/** Wait for server to be ready before attempting connection */
waitForReady?: boolean;
/** Timeout for waiting for server readiness in ms */
waitTimeout?: number;
}
export interface IIpcClientOptions extends IIpcChannelOptions { export interface IIpcClientOptions extends IIpcChannelOptions {
/** Client identifier */ /** Client identifier */
clientId?: string; clientId?: string;
/** Client metadata */ /** Client metadata */
metadata?: Record<string, any>; metadata?: Record<string, any>;
/** Connection retry configuration */
connectRetry?: IConnectRetryConfig;
/** Registration timeout in ms (default: 5000) */
registerTimeoutMs?: number;
} }
/** /**
@@ -21,6 +45,7 @@ export class IpcClient extends plugins.EventEmitter {
private messageHandlers = new Map<string, (payload: any) => any | Promise<any>>(); private messageHandlers = new Map<string, (payload: any) => any | Promise<any>>();
private isConnected = false; private isConnected = false;
private clientId: string; private clientId: string;
private didRegisterOnce = false;
constructor(options: IIpcClientOptions) { constructor(options: IIpcClientOptions) {
super(); super();
@@ -35,15 +60,103 @@ export class IpcClient extends plugins.EventEmitter {
/** /**
* Connect to the server * Connect to the server
*/ */
public async connect(): Promise<void> { public async connect(connectOptions: IClientConnectOptions = {}): Promise<void> {
if (this.isConnected) { if (this.isConnected) {
return; return;
} }
// Helper function to attempt registration
const attemptRegistration = async (): Promise<void> => {
await this.attemptRegistrationInternal();
};
// Helper function to attempt connection with retry
const attemptConnection = async (): Promise<void> => {
const retryConfig = this.options.connectRetry;
const maxAttempts = retryConfig?.maxAttempts || 1;
const initialDelay = retryConfig?.initialDelay || 100;
const maxDelay = retryConfig?.maxDelay || 1500;
const totalTimeout = retryConfig?.totalTimeout || 15000;
const startTime = Date.now();
let lastError: Error | undefined;
let delay = initialDelay;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Check total timeout
if (totalTimeout && Date.now() - startTime > totalTimeout) {
throw new Error(`Connection timeout after ${totalTimeout}ms: ${lastError?.message || 'Unknown error'}`);
}
try {
// Connect the channel // Connect the channel
await this.channel.connect(); await this.channel.connect();
// Register with the server // Attempt registration
await attemptRegistration();
return; // Success!
} catch (error) {
lastError = error as Error;
// Disconnect channel for retry
await this.channel.disconnect().catch(() => {});
// If this isn't the last attempt and retry is enabled, wait before retrying
if (attempt < maxAttempts && retryConfig?.enabled) {
// Check if we have time for another attempt
if (totalTimeout && Date.now() - startTime + delay > totalTimeout) {
break; // Will timeout, don't wait
}
await new Promise(resolve => setTimeout(resolve, delay));
// Exponential backoff with max limit
delay = Math.min(delay * 2, maxDelay);
}
}
}
// All attempts failed
throw lastError || new Error('Failed to connect to server');
};
// If waitForReady is specified, wait for server socket to exist first
if (connectOptions.waitForReady) {
const waitTimeout = connectOptions.waitTimeout || 10000;
// For Unix domain sockets / named pipes: wait explicitly using helper that probes with clientOnly
if (this.options.socketPath) {
const { SmartIpc } = await import('./index.js');
await (SmartIpc as any).waitForServer({ socketPath: this.options.socketPath, timeoutMs: waitTimeout });
await attemptConnection();
return;
}
// Fallback (e.g., TCP): retry-connect loop
const startTime = Date.now();
while (Date.now() - startTime < waitTimeout) {
try {
await attemptConnection();
return; // Success!
} catch (error) {
if ((error as any).message?.includes('ECONNREFUSED')) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
throw error;
}
}
throw new Error(`Server not ready after ${waitTimeout}ms`);
} else {
// Normal connection attempt
await attemptConnection();
}
}
/**
* Attempt to register this client over the current channel connection.
* Sets connection flags and emits 'connect' on success.
*/
private async attemptRegistrationInternal(): Promise<void> {
const registerTimeoutMs = this.options.registerTimeoutMs || 5000;
try { try {
const response = await this.channel.request<any, any>( const response = await this.channel.request<any, any>(
'__register__', '__register__',
@@ -51,7 +164,10 @@ export class IpcClient extends plugins.EventEmitter {
clientId: this.clientId, clientId: this.clientId,
metadata: this.options.metadata metadata: this.options.metadata
}, },
{ timeout: 5000 } {
timeout: registerTimeoutMs,
headers: { clientId: this.clientId }
}
); );
if (!response.success) { if (!response.success) {
@@ -59,9 +175,9 @@ export class IpcClient extends plugins.EventEmitter {
} }
this.isConnected = true; this.isConnected = true;
this.didRegisterOnce = true;
this.emit('connect'); this.emit('connect');
} catch (error) { } catch (error: any) {
await this.channel.disconnect();
throw new Error(`Failed to register with server: ${error.message}`); throw new Error(`Failed to register with server: ${error.message}`);
} }
} }
@@ -84,8 +200,16 @@ export class IpcClient extends plugins.EventEmitter {
*/ */
private setupChannelHandlers(): void { private setupChannelHandlers(): void {
// Forward channel events // Forward channel events
this.channel.on('connect', () => { this.channel.on('connect', async () => {
// Don't emit connect here, wait for successful registration // On reconnects, re-register automatically when we had connected before
if (this.didRegisterOnce && !this.isConnected) {
try {
await this.attemptRegistrationInternal();
} catch (error) {
this.emit('error', error);
}
}
// For initial connect(), registration is handled explicitly there
}); });
this.channel.on('disconnect', (reason) => { this.channel.on('disconnect', (reason) => {
@@ -93,14 +217,31 @@ export class IpcClient extends plugins.EventEmitter {
this.emit('disconnect', reason); this.emit('disconnect', reason);
}); });
this.channel.on('error', (error) => { this.channel.on('error', (error: any) => {
// If heartbeat timeout and configured not to throw, convert to heartbeatTimeout event
if (error && error.message === 'Heartbeat timeout' && this.options.heartbeatThrowOnTimeout === false) {
this.emit('heartbeatTimeout', error);
return;
}
this.emit('error', error); this.emit('error', error);
}); });
this.channel.on('heartbeatTimeout', (error) => {
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
this.emit('heartbeatTimeout', error);
});
this.channel.on('reconnecting', (info) => { this.channel.on('reconnecting', (info) => {
this.emit('reconnecting', info); this.emit('reconnecting', info);
}); });
// Forward streaming events
// Emitted as ('stream', info, readable)
// info contains { streamId, meta, headers, clientId }
this.channel.on('stream', (info: any, readable: plugins.stream.Readable) => {
this.emit('stream', info, readable);
});
// Handle messages // Handle messages
this.channel.on('message', (message) => { this.channel.on('message', (message) => {
// Check if we have a handler for this message type // Check if we have a handler for this message type
@@ -229,4 +370,40 @@ export class IpcClient extends plugins.EventEmitter {
public getStats(): any { public getStats(): any {
return this.channel.getStats(); return this.channel.getStats();
} }
/**
* Send a Node.js readable stream to the server
*/
public async sendStream(readable: plugins.stream.Readable | NodeJS.ReadableStream, options?: { headers?: Record<string, any>; chunkSize?: number; streamId?: string; meta?: Record<string, any> }): Promise<void> {
const headers = { ...(options?.headers || {}), clientId: this.clientId };
await (this as any).channel.sendStream(readable as any, { ...options, headers });
}
/**
* Send a file to the server via streaming
*/
public async sendFile(filePath: string, options?: { headers?: Record<string, any>; chunkSize?: number; streamId?: string; meta?: Record<string, any> }): Promise<void> {
const fs = plugins.fs;
const path = plugins.path;
const stat = fs.statSync(filePath);
const meta = {
...(options?.meta || {}),
type: 'file',
basename: path.basename(filePath),
size: stat.size,
mtimeMs: stat.mtimeMs
};
const rs = fs.createReadStream(filePath);
await this.sendStream(rs, { ...options, meta });
}
/** Cancel an outgoing stream by id */
public async cancelOutgoingStream(streamId: string): Promise<void> {
await (this as any).channel.cancelOutgoingStream(streamId, { clientId: this.clientId });
}
/** Cancel an incoming stream by id */
public async cancelIncomingStream(streamId: string): Promise<void> {
await (this as any).channel.cancelIncomingStream(streamId, { clientId: this.clientId });
}
} }

View File

@@ -5,11 +5,20 @@ import type { IIpcChannelOptions } from './classes.ipcchannel.js';
/** /**
* Options for IPC Server * Options for IPC Server
*/ */
export interface IServerStartOptions {
/** When to consider server ready (default: 'socket-bound') */
readyWhen?: 'socket-bound' | 'accepting';
}
export interface IIpcServerOptions extends Omit<IIpcChannelOptions, 'autoReconnect' | 'reconnectDelay' | 'maxReconnectDelay' | 'reconnectMultiplier' | 'maxReconnectAttempts'> { export interface IIpcServerOptions extends Omit<IIpcChannelOptions, 'autoReconnect' | 'reconnectDelay' | 'maxReconnectDelay' | 'reconnectMultiplier' | 'maxReconnectAttempts'> {
/** Maximum number of client connections */ /** Maximum number of client connections */
maxClients?: number; maxClients?: number;
/** Client idle timeout in ms */ /** Client idle timeout in ms */
clientIdleTimeout?: number; clientIdleTimeout?: number;
/** Automatically cleanup stale socket file on start (default: false) */
autoCleanupSocketFile?: boolean;
/** Socket file permissions mode (e.g. 0o600) */
socketMode?: number;
} }
/** /**
@@ -32,6 +41,7 @@ export class IpcServer extends plugins.EventEmitter {
private messageHandlers = new Map<string, (payload: any, clientId: string) => any | Promise<any>>(); private messageHandlers = new Map<string, (payload: any, clientId: string) => any | Promise<any>>();
private primaryChannel?: IpcChannel; private primaryChannel?: IpcChannel;
private isRunning = false; private isRunning = false;
private isReady = false;
private clientIdleCheckTimer?: NodeJS.Timeout; private clientIdleCheckTimer?: NodeJS.Timeout;
// Pub/sub tracking // Pub/sub tracking
@@ -50,7 +60,7 @@ export class IpcServer extends plugins.EventEmitter {
/** /**
* Start the server * Start the server
*/ */
public async start(): Promise<void> { public async start(options: IServerStartOptions = {}): Promise<void> {
if (this.isRunning) { if (this.isRunning) {
return; return;
} }
@@ -190,12 +200,46 @@ export class IpcServer extends plugins.EventEmitter {
this.emit('error', error, 'server'); this.emit('error', error, 'server');
}); });
// Forward streaming events to server level
this.primaryChannel.on('stream', (info: any, readable: plugins.stream.Readable) => {
// Emit ('stream', info, readable)
this.emit('stream', info, readable);
});
this.primaryChannel.on('heartbeatTimeout', (error) => {
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
this.emit('heartbeatTimeout', error, 'server');
});
// Connect the primary channel (will start as server) // Connect the primary channel (will start as server)
await this.primaryChannel.connect(); await this.primaryChannel.connect();
this.isRunning = true; this.isRunning = true;
this.startClientIdleCheck(); this.startClientIdleCheck();
this.emit('start'); this.emit('start');
// Track individual client disconnects forwarded by the channel/transport
this.primaryChannel.on('clientDisconnected', (clientId?: string) => {
if (!clientId) return;
// Clean up any topic subscriptions and client map entry
this.cleanupClientSubscriptions(clientId);
if (this.clients.has(clientId)) {
this.clients.delete(clientId);
this.emit('clientDisconnect', clientId);
}
});
// Handle readiness based on options
if (options.readyWhen === 'accepting') {
// Wait a bit to ensure handlers are fully set up
await new Promise(resolve => setTimeout(resolve, 10));
this.isReady = true;
this.emit('ready');
} else {
// Default: ready when socket is bound
this.isReady = true;
this.emit('ready');
}
} }
/** /**
@@ -317,6 +361,19 @@ export class IpcServer extends plugins.EventEmitter {
} }
this.emit('error', error, actualClientId); this.emit('error', error, actualClientId);
}); });
channel.on('heartbeatTimeout', (error) => {
// Find the actual client ID for this channel
let actualClientId = clientId;
for (const [id, client] of this.clients) {
if (client.channel === channel) {
actualClientId = id;
break;
}
}
// Forward heartbeatTimeout event (when heartbeatThrowOnTimeout is false)
this.emit('heartbeatTimeout', error, actualClientId);
});
} }
/** /**
@@ -335,7 +392,60 @@ export class IpcServer extends plugins.EventEmitter {
throw new Error(`Client ${clientId} not found`); throw new Error(`Client ${clientId} not found`);
} }
await client.channel.sendMessage(type, payload, headers); // Ensure the target clientId is part of the headers so the transport
// can route the message to the correct socket instead of broadcasting.
const routedHeaders: Record<string, any> | undefined = {
...(headers || {}),
clientId,
};
await client.channel.sendMessage(type, payload, routedHeaders);
}
/**
* Send a Node.js readable stream to a specific client
*/
public async sendStreamToClient(clientId: string, readable: plugins.stream.Readable | NodeJS.ReadableStream, options?: { headers?: Record<string, any>; chunkSize?: number; streamId?: string; meta?: Record<string, any> }): Promise<void> {
const client = this.clients.get(clientId);
if (!client) {
throw new Error(`Client ${clientId} not found`);
}
const headers = { ...(options?.headers || {}), clientId };
await (client.channel as any).sendStream(readable as any, { ...options, headers });
}
/**
* Send a file to a specific client via streaming
*/
public async sendFileToClient(clientId: string, filePath: string, options?: { headers?: Record<string, any>; chunkSize?: number; streamId?: string; meta?: Record<string, any> }): Promise<void> {
const client = this.clients.get(clientId);
if (!client) {
throw new Error(`Client ${clientId} not found`);
}
const fs = plugins.fs;
const path = plugins.path;
const stat = fs.statSync(filePath);
const meta = {
...(options?.meta || {}),
type: 'file',
basename: path.basename(filePath),
size: stat.size,
mtimeMs: stat.mtimeMs
};
const rs = fs.createReadStream(filePath);
await this.sendStreamToClient(clientId, rs, { ...options, meta });
}
/** Cancel a stream incoming from a client (server side) */
public async cancelIncomingStreamFromClient(clientId: string, streamId: string): Promise<void> {
if (!this.primaryChannel) return;
await (this.primaryChannel as any).cancelIncomingStream(streamId, { clientId });
}
/** Cancel a server->client outgoing stream */
public async cancelOutgoingStreamToClient(clientId: string, streamId: string): Promise<void> {
if (!this.primaryChannel) return;
await (this.primaryChannel as any).cancelOutgoingStream(streamId, { clientId });
} }
/** /**
@@ -361,10 +471,9 @@ export class IpcServer extends plugins.EventEmitter {
public async broadcast(type: string, payload: any, headers?: Record<string, any>): Promise<void> { public async broadcast(type: string, payload: any, headers?: Record<string, any>): Promise<void> {
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
for (const [clientId, client] of this.clients) { for (const [clientId] of this.clients) {
promises.push( promises.push(
client.channel.sendMessage(type, payload, headers) this.sendToClient(clientId, type, payload, headers).catch((error) => {
.catch((error) => {
this.emit('error', error, clientId); this.emit('error', error, clientId);
}) })
); );
@@ -387,8 +496,7 @@ export class IpcServer extends plugins.EventEmitter {
for (const [clientId, client] of this.clients) { for (const [clientId, client] of this.clients) {
if (filter(clientId, client.metadata)) { if (filter(clientId, client.metadata)) {
promises.push( promises.push(
client.channel.sendMessage(type, payload, headers) this.sendToClient(clientId, type, payload, headers).catch((error) => {
.catch((error) => {
this.emit('error', error, clientId); this.emit('error', error, clientId);
}) })
); );
@@ -505,4 +613,11 @@ export class IpcServer extends plugins.EventEmitter {
uptime: this.primaryChannel ? Date.now() - (this.primaryChannel as any).connectedAt : undefined uptime: this.primaryChannel ? Date.now() - (this.primaryChannel as any).connectedAt : undefined
}; };
} }
/**
* Check if server is ready to accept connections
*/
public getIsReady(): boolean {
return this.isReady;
}
} }

View File

@@ -18,6 +18,12 @@ export interface IIpcMessageEnvelope<T = any> {
export interface IIpcTransportOptions { export interface IIpcTransportOptions {
/** Unique identifier for this transport */ /** Unique identifier for this transport */
id: string; id: string;
/**
* When true, a client transport will NOT auto-start a server when connect()
* encounters ECONNREFUSED/ENOENT. Useful for strict client/daemon setups.
* Default: false. Can also be overridden by env SMARTIPC_CLIENT_ONLY=1.
*/
clientOnly?: boolean;
/** Socket path for Unix domain sockets or pipe name for Windows */ /** Socket path for Unix domain sockets or pipe name for Windows */
socketPath?: string; socketPath?: string;
/** TCP host for network transport */ /** TCP host for network transport */
@@ -34,6 +40,10 @@ export interface IIpcTransportOptions {
noDelay?: boolean; noDelay?: boolean;
/** Maximum message size in bytes (default: 8MB) */ /** Maximum message size in bytes (default: 8MB) */
maxMessageSize?: number; maxMessageSize?: number;
/** Automatically cleanup stale socket file on start (default: false) */
autoCleanupSocketFile?: boolean;
/** Socket file permissions mode (e.g. 0o600) */
socketMode?: number;
} }
/** /**
@@ -165,6 +175,8 @@ export class UnixSocketTransport extends IpcTransport {
private socket: plugins.net.Socket | null = null; private socket: plugins.net.Socket | null = null;
private server: plugins.net.Server | null = null; private server: plugins.net.Server | null = null;
private clients: Set<plugins.net.Socket> = new Set(); private clients: Set<plugins.net.Socket> = new Set();
private socketToClientId = new WeakMap<plugins.net.Socket, string>();
private clientIdToSocket = new Map<string, plugins.net.Socket>();
/** /**
* Connect as client or start as server * Connect as client or start as server
@@ -189,7 +201,21 @@ export class UnixSocketTransport extends IpcTransport {
this.socket.on('error', (error: any) => { this.socket.on('error', (error: any) => {
if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') { if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') {
// No server exists, we should become the server // Determine if we must NOT auto-start server
const envVal = process.env.SMARTIPC_CLIENT_ONLY;
const envClientOnly = !!envVal && (envVal === '1' || envVal === 'true' || envVal === 'TRUE');
const clientOnly = this.options.clientOnly === true || envClientOnly;
if (clientOnly) {
// Reject instead of starting a server to avoid races
const reason = error.code || 'UNKNOWN';
const err = new Error(`Server not available (${reason}); clientOnly prevents auto-start`);
(err as any).code = reason;
reject(err);
return;
}
// No server exists and clientOnly is false: become the server (back-compat)
this.socket = null; this.socket = null;
this.startServer(socketPath).then(resolve).catch(reject); this.startServer(socketPath).then(resolve).catch(reject);
} else { } else {
@@ -206,12 +232,14 @@ export class UnixSocketTransport extends IpcTransport {
*/ */
private async startServer(socketPath: string): Promise<void> { private async startServer(socketPath: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Clean up stale socket file if it exists // Clean up stale socket file if autoCleanupSocketFile is enabled
if (this.options.autoCleanupSocketFile) {
try { try {
plugins.fs.unlinkSync(socketPath); plugins.fs.unlinkSync(socketPath);
} catch (error) { } catch (error) {
// File doesn't exist, that's fine // File doesn't exist, that's fine
} }
}
this.server = plugins.net.createServer((socket) => { this.server = plugins.net.createServer((socket) => {
// Each new connection gets added to clients // Each new connection gets added to clients
@@ -233,7 +261,14 @@ export class UnixSocketTransport extends IpcTransport {
socket.on('close', () => { socket.on('close', () => {
this.clients.delete(socket); this.clients.delete(socket);
this.emit('clientDisconnected', socket); // Clean up clientId mappings
const clientId = this.socketToClientId.get(socket);
if (clientId && this.clientIdToSocket.get(clientId) === socket) {
this.clientIdToSocket.delete(clientId);
}
this.socketToClientId.delete(socket);
// Emit with clientId if known so higher layers can react
this.emit('clientDisconnected', socket, clientId);
}); });
socket.on('drain', () => { socket.on('drain', () => {
@@ -247,6 +282,15 @@ export class UnixSocketTransport extends IpcTransport {
this.server.on('error', reject); this.server.on('error', reject);
this.server.listen(socketPath, () => { this.server.listen(socketPath, () => {
// Set socket permissions if specified
if (this.options.socketMode !== undefined && process.platform !== 'win32') {
try {
plugins.fs.chmodSync(socketPath, this.options.socketMode);
} catch (error) {
// Ignore permission errors, not critical
}
}
this.connected = true; this.connected = true;
this.emit('connect'); this.emit('connect');
resolve(); resolve();
@@ -292,7 +336,18 @@ export class UnixSocketTransport extends IpcTransport {
// Parse and emit the message with socket reference // Parse and emit the message with socket reference
try { try {
const message = JSON.parse(messageData.toString('utf8')) as IIpcMessageEnvelope; const message = JSON.parse(messageData.toString('utf8')) as IIpcMessageEnvelope;
// Update clientId mapping
const clientId = message.headers?.clientId ??
(message.type === '__register__' ? (message.payload as any)?.clientId : undefined);
if (clientId) {
this.socketToClientId.set(socket, clientId);
this.clientIdToSocket.set(clientId, socket);
}
// Emit both events so IpcChannel can process it
this.emit('clientMessage', message, socket); this.emit('clientMessage', message, socket);
this.emit('message', message);
} catch (error: any) { } catch (error: any) {
this.emit('error', new Error(`Failed to parse message: ${error.message}`)); this.emit('error', new Error(`Failed to parse message: ${error.message}`));
} }
@@ -400,7 +455,33 @@ export class UnixSocketTransport extends IpcTransport {
} }
}); });
} else if (this.server && this.clients.size > 0) { } else if (this.server && this.clients.size > 0) {
// Server mode - broadcast to all clients // Server mode - route by clientId if present, otherwise broadcast
const targetClientId = message.headers?.clientId;
if (targetClientId && this.clientIdToSocket.has(targetClientId)) {
// Send to specific client
const targetSocket = this.clientIdToSocket.get(targetClientId)!;
if (targetSocket && !targetSocket.destroyed) {
return new Promise((resolve) => {
const success = targetSocket.write(frame, (error) => {
if (error) {
resolve(false);
} else {
resolve(true);
}
});
if (!success) {
targetSocket.once('drain', () => resolve(true));
}
});
} else {
// Socket is destroyed, remove from mappings
this.clientIdToSocket.delete(targetClientId);
return false;
}
} else {
// Broadcast to all clients (fallback for messages without specific target)
const promises: Promise<boolean>[] = []; const promises: Promise<boolean>[] = [];
for (const client of this.clients) { for (const client of this.clients) {
@@ -422,6 +503,7 @@ export class UnixSocketTransport extends IpcTransport {
const results = await Promise.all(promises); const results = await Promise.all(promises);
return results.every(r => r); return results.every(r => r);
} }
}
return false; return false;
} }

View File

@@ -6,8 +6,9 @@ export * from './classes.ipcclient.js';
import { IpcServer } from './classes.ipcserver.js'; import { IpcServer } from './classes.ipcserver.js';
import { IpcClient } from './classes.ipcclient.js'; import { IpcClient } from './classes.ipcclient.js';
import { IpcChannel } from './classes.ipcchannel.js'; import { IpcChannel } from './classes.ipcchannel.js';
import { stream as nodeStream, fs as nodeFs, path as nodePath } from './smartipc.plugins.js';
import type { IIpcServerOptions } from './classes.ipcserver.js'; import type { IIpcServerOptions } from './classes.ipcserver.js';
import type { IIpcClientOptions } from './classes.ipcclient.js'; import type { IIpcClientOptions, IConnectRetryConfig } from './classes.ipcclient.js';
import type { IIpcChannelOptions } from './classes.ipcchannel.js'; import type { IIpcChannelOptions } from './classes.ipcchannel.js';
/** /**
@@ -17,6 +18,97 @@ export class SmartIpc {
/** /**
* Create an IPC server * Create an IPC server
*/ */
/**
* Wait for a server to become ready at the given socket path
*/
public static async waitForServer(options: {
socketPath: string;
timeoutMs?: number;
}): Promise<void> {
const timeout = options.timeoutMs || 10000;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
// Create a temporary client with proper options
const testClient = SmartIpc.createClient({
id: 'test-probe',
socketPath: options.socketPath,
clientId: `probe-${process.pid}-${Date.now()}`,
heartbeat: false,
clientOnly: true,
connectRetry: {
enabled: false // Don't retry, we're handling retries here
},
registerTimeoutMs: 2000 // Short timeout for quick probing
});
// Try to connect and register with the server
await testClient.connect();
// Success! Clean up and return
await testClient.disconnect();
return;
} catch (error) {
// Server not ready yet, wait and retry
await new Promise(resolve => setTimeout(resolve, 200));
}
}
throw new Error(`Server not ready at ${options.socketPath} after ${timeout}ms`);
}
/**
* Helper to spawn a server process and connect a client
*/
public static async spawnAndConnect(options: {
serverScript: string;
socketPath: string;
clientId?: string;
spawnOptions?: any;
connectRetry?: IConnectRetryConfig;
timeoutMs?: number;
}): Promise<{
client: IpcClient;
serverProcess: any;
}> {
const { spawn } = await import('child_process');
// Spawn the server process
const serverProcess = spawn('node', [options.serverScript], {
detached: true,
stdio: 'pipe',
...options.spawnOptions
});
// Handle server process errors
serverProcess.on('error', (error: Error) => {
console.error('Server process error:', error);
});
// Wait for server to be ready
await SmartIpc.waitForServer({
socketPath: options.socketPath,
timeoutMs: options.timeoutMs || 10000
});
// Create and connect client
const client = new IpcClient({
id: options.clientId || 'test-client',
socketPath: options.socketPath,
connectRetry: options.connectRetry || {
enabled: true,
maxAttempts: 10,
initialDelay: 100,
maxDelay: 1000
}
});
await client.connect({ waitForReady: true });
return { client, serverProcess };
}
public static createServer(options: IIpcServerOptions): IpcServer { public static createServer(options: IIpcServerOptions): IpcServer {
return new IpcServer(options); return new IpcServer(options);
} }
@@ -38,3 +130,21 @@ export class SmartIpc {
// Export the main class as default // Export the main class as default
export default SmartIpc; export default SmartIpc;
/**
* Helper: pipe an incoming SmartIPC readable stream to a file path.
* Ensures directory exists; resolves on finish.
*/
export async function pipeStreamToFile(readable: NodeJS.ReadableStream, filePath: string): Promise<void> {
// Ensure directory exists
try {
nodeFs.mkdirSync(nodePath.dirname(filePath), { recursive: true });
} catch {}
await new Promise<void>((resolve, reject) => {
const ws = nodeFs.createWriteStream(filePath);
ws.on('finish', () => resolve());
ws.on('error', reject);
readable.on('error', reject);
(readable as any).pipe(ws);
});
}

View File

@@ -11,6 +11,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as stream from 'stream';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
export { net, os, path, fs, crypto, EventEmitter }; export { net, os, path, fs, crypto, stream, EventEmitter };