@aztec/ipc-runtime 0.0.1-commit.4da4de7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/amd64-linux/ipc_runtime_napi.node +0 -0
- package/build/amd64-macos/ipc_runtime_napi.node +0 -0
- package/build/arm64-linux/ipc_runtime_napi.node +0 -0
- package/build/arm64-macos/ipc_runtime_napi.node +0 -0
- package/dest/index.d.ts +5 -0
- package/dest/index.js +4 -0
- package/dest/native_loader.d.ts +16 -0
- package/dest/native_loader.js +81 -0
- package/dest/shm_client.d.ts +65 -0
- package/dest/shm_client.js +93 -0
- package/dest/types.d.ts +12 -0
- package/dest/types.js +1 -0
- package/dest/uds_client.d.ts +37 -0
- package/dest/uds_client.js +120 -0
- package/dest/uds_server.d.ts +21 -0
- package/dest/uds_server.js +88 -0
- package/package.json +27 -0
- package/src/index.ts +16 -0
- package/src/native_loader.ts +97 -0
- package/src/shm_client.ts +148 -0
- package/src/types.ts +13 -0
- package/src/uds_client.ts +150 -0
- package/src/uds_server.ts +110 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dest/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { IpcClientAsync, IpcClientSync } from "./types.js";
|
|
2
|
+
export { UdsIpcClient, type UdsIpcClientConnectOptions } from "./uds_client.js";
|
|
3
|
+
export { UdsIpcServer, type IpcServerHandler } from "./uds_server.js";
|
|
4
|
+
export { NapiShmSyncClient, NapiShmAsyncClient, createNapiShmSyncClient, createNapiShmAsyncClient, type NapiMsgpackClientSync, type NapiMsgpackClientAsync, } from "./shm_client.js";
|
|
5
|
+
export { findIpcRuntimeNapi, loadIpcRuntimeNapi, type Platform, } from "./native_loader.js";
|
package/dest/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { UdsIpcClient } from "./uds_client.js";
|
|
2
|
+
export { UdsIpcServer } from "./uds_server.js";
|
|
3
|
+
export { NapiShmSyncClient, NapiShmAsyncClient, createNapiShmSyncClient, createNapiShmAsyncClient, } from "./shm_client.js";
|
|
4
|
+
export { findIpcRuntimeNapi, loadIpcRuntimeNapi, } from "./native_loader.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type Platform = "x86_64-linux" | "x86_64-darwin" | "aarch64-linux" | "aarch64-darwin";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the path of `ipc_runtime_napi.node` for the current platform.
|
|
4
|
+
* Returns null if either the platform is unsupported or the artifact is
|
|
5
|
+
* absent (typical reason: ipc-runtime/bootstrap.sh hasn't run yet).
|
|
6
|
+
*/
|
|
7
|
+
export declare function findIpcRuntimeNapi(customPath?: string): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Load `ipc_runtime_napi.node` and return its native exports
|
|
10
|
+
* (`MsgpackClient`, `MsgpackClientAsync`). Throws a descriptive error when
|
|
11
|
+
* the addon cannot be located or fails to dlopen.
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadIpcRuntimeNapi(customPath?: string): {
|
|
14
|
+
MsgpackClient: new (shmName: string, clientId?: number) => any;
|
|
15
|
+
MsgpackClientAsync: new (shmName: string, clientId?: number) => any;
|
|
16
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Locate the prebuilt ipc_runtime_napi.node addon shipped with this package.
|
|
2
|
+
//
|
|
3
|
+
// The addon is built by `ipc-runtime/bootstrap.sh` (CMake target
|
|
4
|
+
// `ipc_runtime_napi`) and copied into `build/<arch>-<os>/` next to this
|
|
5
|
+
// package's `package.json`. Resolution walks up from this file's URL to the
|
|
6
|
+
// first `package.json` adjacent to a `build/` directory — that's the
|
|
7
|
+
// package root in both `file:`-linked and published consumption.
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
const PLATFORM_TO_BUILD_DIR = {
|
|
13
|
+
"x86_64-linux": "amd64-linux",
|
|
14
|
+
"x86_64-darwin": "amd64-macos",
|
|
15
|
+
"aarch64-linux": "arm64-linux",
|
|
16
|
+
"aarch64-darwin": "arm64-macos",
|
|
17
|
+
};
|
|
18
|
+
function detectPlatform() {
|
|
19
|
+
const arch = process.arch;
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
if (arch === "x64" && platform === "linux")
|
|
22
|
+
return "x86_64-linux";
|
|
23
|
+
if (arch === "x64" && platform === "darwin")
|
|
24
|
+
return "x86_64-darwin";
|
|
25
|
+
if (arch === "arm64" && platform === "linux")
|
|
26
|
+
return "aarch64-linux";
|
|
27
|
+
if (arch === "arm64" && platform === "darwin")
|
|
28
|
+
return "aarch64-darwin";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function findPackageRoot() {
|
|
32
|
+
// `import.meta.url` after tsc compile points at the .js file under
|
|
33
|
+
// <pkg>/dest/...; climb until we find package.json with a sibling build/.
|
|
34
|
+
let currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const root = path.parse(currentDir).root;
|
|
36
|
+
while (currentDir !== root) {
|
|
37
|
+
const packageJsonPath = path.join(currentDir, "package.json");
|
|
38
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
39
|
+
const buildDir = path.join(currentDir, "build");
|
|
40
|
+
if (fs.existsSync(buildDir)) {
|
|
41
|
+
return currentDir;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
currentDir = path.dirname(currentDir);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the path of `ipc_runtime_napi.node` for the current platform.
|
|
50
|
+
* Returns null if either the platform is unsupported or the artifact is
|
|
51
|
+
* absent (typical reason: ipc-runtime/bootstrap.sh hasn't run yet).
|
|
52
|
+
*/
|
|
53
|
+
export function findIpcRuntimeNapi(customPath) {
|
|
54
|
+
if (customPath) {
|
|
55
|
+
return fs.existsSync(customPath) ? path.resolve(customPath) : null;
|
|
56
|
+
}
|
|
57
|
+
const platform = detectPlatform();
|
|
58
|
+
if (!platform)
|
|
59
|
+
return null;
|
|
60
|
+
const packageRoot = findPackageRoot();
|
|
61
|
+
if (!packageRoot)
|
|
62
|
+
return null;
|
|
63
|
+
const buildDir = PLATFORM_TO_BUILD_DIR[platform];
|
|
64
|
+
const candidate = path.join(packageRoot, "build", buildDir, "ipc_runtime_napi.node");
|
|
65
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Load `ipc_runtime_napi.node` and return its native exports
|
|
69
|
+
* (`MsgpackClient`, `MsgpackClientAsync`). Throws a descriptive error when
|
|
70
|
+
* the addon cannot be located or fails to dlopen.
|
|
71
|
+
*/
|
|
72
|
+
export function loadIpcRuntimeNapi(customPath) {
|
|
73
|
+
const addonPath = findIpcRuntimeNapi(customPath);
|
|
74
|
+
if (!addonPath) {
|
|
75
|
+
throw new Error("Could not locate ipc_runtime_napi.node. Build with `ipc-runtime/bootstrap.sh` " +
|
|
76
|
+
"or set the optional `customPath` argument to point at a prebuilt addon.");
|
|
77
|
+
}
|
|
78
|
+
// createRequire so this works in both ESM and CJS callers.
|
|
79
|
+
const require = createRequire(import.meta.url);
|
|
80
|
+
return require(addonPath);
|
|
81
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { IpcClientAsync, IpcClientSync } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Minimum surface a NAPI msgpack client must expose. Satisfied by the
|
|
4
|
+
* `MsgpackClient` / `MsgpackClientAsync` classes exported from this
|
|
5
|
+
* package's own `ipc_runtime_napi.node` addon (see ipc-runtime/cpp/napi/),
|
|
6
|
+
* which wraps the C++ ipc::IpcClient.
|
|
7
|
+
*
|
|
8
|
+
* The interface is exposed for tests / consumers that want to inject a
|
|
9
|
+
* mock or alternative implementation; the standard production path is the
|
|
10
|
+
* `createNapiShm{Sync,Async}Client` factories below, which load the
|
|
11
|
+
* prebuilt addon shipped in this package's `build/<arch>-<os>/` directory.
|
|
12
|
+
*
|
|
13
|
+
* Note on the async contract: `MsgpackClientAsync.call` is *fire and
|
|
14
|
+
* forget*. Responses arrive via `setResponseCallback` in FIFO order on a
|
|
15
|
+
* background-thread → main-thread bridge (Napi::ThreadSafeFunction).
|
|
16
|
+
* The TS wrapper below owns the request queue and matches responses.
|
|
17
|
+
*/
|
|
18
|
+
export interface NapiMsgpackClientSync {
|
|
19
|
+
call(input: Buffer): Buffer;
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
export interface NapiMsgpackClientAsync {
|
|
23
|
+
setResponseCallback(cb: (response: Buffer) => void): void;
|
|
24
|
+
call(input: Buffer): void;
|
|
25
|
+
acquire(): void;
|
|
26
|
+
release(): void;
|
|
27
|
+
}
|
|
28
|
+
/** Wraps a sync NAPI msgpack client behind the IpcClientSync interface. */
|
|
29
|
+
export declare class NapiShmSyncClient implements IpcClientSync {
|
|
30
|
+
private inner;
|
|
31
|
+
constructor(inner: NapiMsgpackClientSync);
|
|
32
|
+
call(input: Uint8Array): Uint8Array;
|
|
33
|
+
destroy(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wraps the fire-and-forget async NAPI msgpack client behind the
|
|
37
|
+
* `IpcClientAsync` interface. Owns a FIFO queue of pending calls; the C++
|
|
38
|
+
* background polling thread invokes `setResponseCallback` once per
|
|
39
|
+
* response, and this wrapper matches it to the next queued caller.
|
|
40
|
+
*
|
|
41
|
+
* `acquire` / `release` are reference-count hooks the NAPI exposes so the
|
|
42
|
+
* libuv loop is kept alive only while requests are outstanding — without
|
|
43
|
+
* them a `node script.js` would never exit naturally.
|
|
44
|
+
*/
|
|
45
|
+
export declare class NapiShmAsyncClient implements IpcClientAsync {
|
|
46
|
+
private inner;
|
|
47
|
+
private readonly pending;
|
|
48
|
+
constructor(inner: NapiMsgpackClientAsync);
|
|
49
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
50
|
+
destroy(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
export interface CreateNapiShmOptions {
|
|
53
|
+
/** MPSC client slot id (default 0). Distinct clients on the same shmName must use distinct slots. */
|
|
54
|
+
clientId?: number;
|
|
55
|
+
/** Override addon path lookup. Rarely needed; useful for tests / unusual deployments. */
|
|
56
|
+
customAddonPath?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Factories that load the bundled `ipc_runtime_napi.node` addon and
|
|
60
|
+
* construct an MPSC-SHM client wrapped behind the `IpcClient*` interface.
|
|
61
|
+
* Matches the transport used by `ipc::make_server` on the C++ side, so any
|
|
62
|
+
* server started via that helper accepts these clients directly.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createNapiShmSyncClient(shmName: string, options?: CreateNapiShmOptions): NapiShmSyncClient;
|
|
65
|
+
export declare function createNapiShmAsyncClient(shmName: string, options?: CreateNapiShmOptions): NapiShmAsyncClient;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { loadIpcRuntimeNapi } from "./native_loader.js";
|
|
2
|
+
/** Wraps a sync NAPI msgpack client behind the IpcClientSync interface. */
|
|
3
|
+
export class NapiShmSyncClient {
|
|
4
|
+
inner;
|
|
5
|
+
constructor(inner) {
|
|
6
|
+
this.inner = inner;
|
|
7
|
+
}
|
|
8
|
+
call(input) {
|
|
9
|
+
const buf = Buffer.isBuffer(input)
|
|
10
|
+
? input
|
|
11
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
12
|
+
const resp = this.inner.call(buf);
|
|
13
|
+
return new Uint8Array(resp.buffer, resp.byteOffset, resp.byteLength);
|
|
14
|
+
}
|
|
15
|
+
destroy() {
|
|
16
|
+
this.inner.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Wraps the fire-and-forget async NAPI msgpack client behind the
|
|
21
|
+
* `IpcClientAsync` interface. Owns a FIFO queue of pending calls; the C++
|
|
22
|
+
* background polling thread invokes `setResponseCallback` once per
|
|
23
|
+
* response, and this wrapper matches it to the next queued caller.
|
|
24
|
+
*
|
|
25
|
+
* `acquire` / `release` are reference-count hooks the NAPI exposes so the
|
|
26
|
+
* libuv loop is kept alive only while requests are outstanding — without
|
|
27
|
+
* them a `node script.js` would never exit naturally.
|
|
28
|
+
*/
|
|
29
|
+
export class NapiShmAsyncClient {
|
|
30
|
+
inner;
|
|
31
|
+
pending = [];
|
|
32
|
+
constructor(inner) {
|
|
33
|
+
this.inner = inner;
|
|
34
|
+
this.inner.setResponseCallback((response) => {
|
|
35
|
+
const cb = this.pending.shift();
|
|
36
|
+
if (cb) {
|
|
37
|
+
cb.resolve(new Uint8Array(response));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Unexpected — a response arrived but no caller is waiting.
|
|
41
|
+
// Drop it; there is no caller left to resolve.
|
|
42
|
+
}
|
|
43
|
+
if (this.pending.length === 0) {
|
|
44
|
+
this.inner.release();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
call(input) {
|
|
49
|
+
const buf = Buffer.isBuffer(input)
|
|
50
|
+
? input
|
|
51
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
if (this.pending.length === 0) {
|
|
54
|
+
this.inner.acquire();
|
|
55
|
+
}
|
|
56
|
+
this.pending.push({ resolve, reject });
|
|
57
|
+
try {
|
|
58
|
+
this.inner.call(buf);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Send failed — unwind the queue entry we just added.
|
|
62
|
+
this.pending.pop();
|
|
63
|
+
if (this.pending.length === 0) {
|
|
64
|
+
this.inner.release();
|
|
65
|
+
}
|
|
66
|
+
reject(err instanceof Error
|
|
67
|
+
? err
|
|
68
|
+
: new Error(`SHM async call failed: ${String(err)}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async destroy() {
|
|
73
|
+
// Reject anything still in flight.
|
|
74
|
+
while (this.pending.length > 0) {
|
|
75
|
+
const cb = this.pending.shift();
|
|
76
|
+
cb?.reject(new Error("ipc-runtime SHM client destroyed before response"));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Factories that load the bundled `ipc_runtime_napi.node` addon and
|
|
82
|
+
* construct an MPSC-SHM client wrapped behind the `IpcClient*` interface.
|
|
83
|
+
* Matches the transport used by `ipc::make_server` on the C++ side, so any
|
|
84
|
+
* server started via that helper accepts these clients directly.
|
|
85
|
+
*/
|
|
86
|
+
export function createNapiShmSyncClient(shmName, options = {}) {
|
|
87
|
+
const napi = loadIpcRuntimeNapi(options.customAddonPath);
|
|
88
|
+
return new NapiShmSyncClient(new napi.MsgpackClient(shmName, options.clientId ?? 0));
|
|
89
|
+
}
|
|
90
|
+
export function createNapiShmAsyncClient(shmName, options = {}) {
|
|
91
|
+
const napi = loadIpcRuntimeNapi(options.customAddonPath);
|
|
92
|
+
return new NapiShmAsyncClient(new napi.MsgpackClientAsync(shmName, options.clientId ?? 0));
|
|
93
|
+
}
|
package/dest/types.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal byte-in / byte-out interface that the ipc-codegen-emitted
|
|
3
|
+
* <Service>Api types consume. Both UDS and SHM transports satisfy this.
|
|
4
|
+
*/
|
|
5
|
+
export interface IpcClientAsync {
|
|
6
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
7
|
+
destroy(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export interface IpcClientSync {
|
|
10
|
+
call(input: Uint8Array): Uint8Array;
|
|
11
|
+
destroy(): void;
|
|
12
|
+
}
|
package/dest/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { IpcClientAsync } from "./types.js";
|
|
3
|
+
export interface UdsIpcClientConnectOptions {
|
|
4
|
+
/** Mark the socket as unref'd so it doesn't keep the Node event loop alive when idle. */
|
|
5
|
+
unref?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Retry budget (ms) for the initial connect when the server has bound the
|
|
8
|
+
* path but not yet called listen(). Set to 0 to fail immediately on
|
|
9
|
+
* ECONNREFUSED. Default 5000.
|
|
10
|
+
*/
|
|
11
|
+
connectTimeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Async IPC client over a Unix Domain Socket. Wire format matches the C++
|
|
15
|
+
* ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
|
|
16
|
+
* prefix followed by `length` bytes of msgpack payload, per direction.
|
|
17
|
+
*
|
|
18
|
+
* Supports pipelining: multiple concurrent `call()` invocations are queued
|
|
19
|
+
* FIFO and matched with responses in order. Pipelining keeps the server-side
|
|
20
|
+
* socket window full and matches the native client behaviour.
|
|
21
|
+
*/
|
|
22
|
+
export declare class UdsIpcClient implements IpcClientAsync {
|
|
23
|
+
private conn;
|
|
24
|
+
private buffer;
|
|
25
|
+
private pending;
|
|
26
|
+
private destroyed;
|
|
27
|
+
private constructor();
|
|
28
|
+
static connect(socketPath: string, opts?: UdsIpcClientConnectOptions): Promise<UdsIpcClient>;
|
|
29
|
+
/** Number of in-flight calls awaiting a response. */
|
|
30
|
+
get inflight(): number;
|
|
31
|
+
/** Underlying socket — exposed for ref/unref control (event-loop tuning). */
|
|
32
|
+
get socket(): net.Socket;
|
|
33
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
34
|
+
destroy(): Promise<void>;
|
|
35
|
+
private onData;
|
|
36
|
+
private failAll;
|
|
37
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* Async IPC client over a Unix Domain Socket. Wire format matches the C++
|
|
4
|
+
* ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
|
|
5
|
+
* prefix followed by `length` bytes of msgpack payload, per direction.
|
|
6
|
+
*
|
|
7
|
+
* Supports pipelining: multiple concurrent `call()` invocations are queued
|
|
8
|
+
* FIFO and matched with responses in order. Pipelining keeps the server-side
|
|
9
|
+
* socket window full and matches the native client behaviour.
|
|
10
|
+
*/
|
|
11
|
+
export class UdsIpcClient {
|
|
12
|
+
conn;
|
|
13
|
+
buffer = Buffer.alloc(0);
|
|
14
|
+
pending = [];
|
|
15
|
+
destroyed = false;
|
|
16
|
+
constructor(conn) {
|
|
17
|
+
this.conn = conn;
|
|
18
|
+
conn.on("data", (chunk) => this.onData(chunk));
|
|
19
|
+
conn.on("error", (err) => this.failAll(err));
|
|
20
|
+
conn.on("close", () => this.failAll(new Error("socket closed")));
|
|
21
|
+
}
|
|
22
|
+
static async connect(socketPath, opts) {
|
|
23
|
+
const conn = await connectWithRetry(socketPath, opts?.connectTimeoutMs ?? 5000);
|
|
24
|
+
conn.setNoDelay(true);
|
|
25
|
+
if (opts?.unref)
|
|
26
|
+
conn.unref();
|
|
27
|
+
return new UdsIpcClient(conn);
|
|
28
|
+
}
|
|
29
|
+
/** Number of in-flight calls awaiting a response. */
|
|
30
|
+
get inflight() {
|
|
31
|
+
return this.pending.length;
|
|
32
|
+
}
|
|
33
|
+
/** Underlying socket — exposed for ref/unref control (event-loop tuning). */
|
|
34
|
+
get socket() {
|
|
35
|
+
return this.conn;
|
|
36
|
+
}
|
|
37
|
+
async call(input) {
|
|
38
|
+
if (this.destroyed) {
|
|
39
|
+
throw new Error("UdsIpcClient: call() after destroy()");
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
this.pending.push({ resolve, reject });
|
|
43
|
+
const lenBuf = Buffer.allocUnsafe(4);
|
|
44
|
+
lenBuf.writeUInt32LE(input.length, 0);
|
|
45
|
+
this.conn.write(lenBuf);
|
|
46
|
+
this.conn.write(input);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async destroy() {
|
|
50
|
+
this.destroyed = true;
|
|
51
|
+
this.conn.removeAllListeners();
|
|
52
|
+
this.conn.destroy();
|
|
53
|
+
this.failAll(new Error("UdsIpcClient destroyed"));
|
|
54
|
+
}
|
|
55
|
+
onData(chunk) {
|
|
56
|
+
this.buffer =
|
|
57
|
+
this.buffer.length === 0
|
|
58
|
+
? Buffer.from(chunk)
|
|
59
|
+
: Buffer.concat([this.buffer, chunk]);
|
|
60
|
+
while (this.buffer.length >= 4) {
|
|
61
|
+
const len = this.buffer.readUInt32LE(0);
|
|
62
|
+
if (this.buffer.length < 4 + len)
|
|
63
|
+
return;
|
|
64
|
+
const payload = this.buffer.subarray(4, 4 + len);
|
|
65
|
+
this.buffer = this.buffer.subarray(4 + len);
|
|
66
|
+
const next = this.pending.shift();
|
|
67
|
+
if (next)
|
|
68
|
+
next.resolve(new Uint8Array(payload));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
failAll(err) {
|
|
72
|
+
const pending = this.pending;
|
|
73
|
+
this.pending = [];
|
|
74
|
+
for (const p of pending)
|
|
75
|
+
p.reject(err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Connect to `socketPath`, retrying on ECONNREFUSED until `timeoutMs`
|
|
80
|
+
* elapses. ECONNREFUSED happens in the narrow window between the server's
|
|
81
|
+
* bind() and listen(); other errors fail immediately.
|
|
82
|
+
*/
|
|
83
|
+
async function connectWithRetry(socketPath, timeoutMs) {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
let attempt = 0;
|
|
86
|
+
let lastErr;
|
|
87
|
+
while (true) {
|
|
88
|
+
try {
|
|
89
|
+
return await attemptConnect(socketPath);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
lastErr = err;
|
|
93
|
+
const code = err.code;
|
|
94
|
+
if (code !== "ECONNREFUSED" && code !== "ENOENT") {
|
|
95
|
+
throw new Error(`UdsIpcClient: connect failed: ${lastErr.message}`);
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() >= deadline) {
|
|
98
|
+
throw new Error(`UdsIpcClient: connect timed out: ${lastErr.message}`);
|
|
99
|
+
}
|
|
100
|
+
const delay = Math.min(50, 5 * 2 ** attempt++);
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function attemptConnect(socketPath) {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const conn = net.createConnection(socketPath);
|
|
108
|
+
const onError = (err) => {
|
|
109
|
+
conn.removeListener("connect", onConnect);
|
|
110
|
+
conn.destroy();
|
|
111
|
+
reject(err);
|
|
112
|
+
};
|
|
113
|
+
const onConnect = () => {
|
|
114
|
+
conn.removeListener("error", onError);
|
|
115
|
+
resolve(conn);
|
|
116
|
+
};
|
|
117
|
+
conn.once("connect", onConnect);
|
|
118
|
+
conn.once("error", onError);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler signature mirrors the C++ ipc::IpcServer::Handler: receive raw
|
|
3
|
+
* bytes, return raw bytes. msgpack decode/encode and command dispatch are
|
|
4
|
+
* the caller's responsibility (or the codegen's, when a generated dispatcher
|
|
5
|
+
* is wired in).
|
|
6
|
+
*/
|
|
7
|
+
export type IpcServerHandler = (clientId: number, request: Uint8Array) => Promise<Uint8Array> | Uint8Array;
|
|
8
|
+
/**
|
|
9
|
+
* UDS server with the same 4-byte-LE-length-prefix wire as UdsIpcClient and
|
|
10
|
+
* the C++ ipc::IpcServer socket transport. Accepts multiple concurrent
|
|
11
|
+
* connections; handler invocations are serialised per-connection.
|
|
12
|
+
*/
|
|
13
|
+
export declare class UdsIpcServer {
|
|
14
|
+
private socketPath;
|
|
15
|
+
private server;
|
|
16
|
+
private nextClientId;
|
|
17
|
+
private constructor();
|
|
18
|
+
static listen(socketPath: string, handler: IpcServerHandler): Promise<UdsIpcServer>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private handleConnection;
|
|
21
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* UDS server with the same 4-byte-LE-length-prefix wire as UdsIpcClient and
|
|
5
|
+
* the C++ ipc::IpcServer socket transport. Accepts multiple concurrent
|
|
6
|
+
* connections; handler invocations are serialised per-connection.
|
|
7
|
+
*/
|
|
8
|
+
export class UdsIpcServer {
|
|
9
|
+
socketPath;
|
|
10
|
+
server;
|
|
11
|
+
nextClientId = 0;
|
|
12
|
+
constructor(server, socketPath) {
|
|
13
|
+
this.socketPath = socketPath;
|
|
14
|
+
this.server = server;
|
|
15
|
+
}
|
|
16
|
+
static async listen(socketPath, handler) {
|
|
17
|
+
try {
|
|
18
|
+
fs.unlinkSync(socketPath);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* socket file may not exist; ignore */
|
|
22
|
+
}
|
|
23
|
+
const server = net.createServer();
|
|
24
|
+
const instance = new UdsIpcServer(server, socketPath);
|
|
25
|
+
server.on("connection", (conn) => instance.handleConnection(conn, handler));
|
|
26
|
+
await new Promise((resolve, reject) => {
|
|
27
|
+
const onError = (err) => {
|
|
28
|
+
server.removeListener("listening", onListening);
|
|
29
|
+
reject(err);
|
|
30
|
+
};
|
|
31
|
+
const onListening = () => {
|
|
32
|
+
server.removeListener("error", onError);
|
|
33
|
+
resolve();
|
|
34
|
+
};
|
|
35
|
+
server.once("error", onError);
|
|
36
|
+
server.once("listening", onListening);
|
|
37
|
+
server.listen(socketPath);
|
|
38
|
+
});
|
|
39
|
+
return instance;
|
|
40
|
+
}
|
|
41
|
+
async close() {
|
|
42
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
43
|
+
try {
|
|
44
|
+
fs.unlinkSync(this.socketPath);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* may already be gone */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
handleConnection(conn, handler) {
|
|
51
|
+
const clientId = this.nextClientId++;
|
|
52
|
+
let buffer = Buffer.alloc(0);
|
|
53
|
+
let chain = Promise.resolve();
|
|
54
|
+
conn.on("data", (chunk) => {
|
|
55
|
+
buffer =
|
|
56
|
+
buffer.length === 0
|
|
57
|
+
? Buffer.from(chunk)
|
|
58
|
+
: Buffer.concat([buffer, chunk]);
|
|
59
|
+
while (buffer.length >= 4) {
|
|
60
|
+
const len = buffer.readUInt32LE(0);
|
|
61
|
+
if (buffer.length < 4 + len)
|
|
62
|
+
break;
|
|
63
|
+
const payload = new Uint8Array(buffer.subarray(4, 4 + len));
|
|
64
|
+
buffer = buffer.subarray(4 + len);
|
|
65
|
+
const prev = chain;
|
|
66
|
+
chain = (async () => {
|
|
67
|
+
await prev;
|
|
68
|
+
try {
|
|
69
|
+
const resp = await handler(clientId, payload);
|
|
70
|
+
const lenBuf = Buffer.allocUnsafe(4);
|
|
71
|
+
lenBuf.writeUInt32LE(resp.length, 0);
|
|
72
|
+
conn.write(lenBuf);
|
|
73
|
+
conn.write(resp);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
conn.destroy(err);
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
void chain.catch(() => {
|
|
80
|
+
/* errors already handled by destroying the connection */
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
conn.on("error", () => {
|
|
85
|
+
/* swallowed — clients reconnect */
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aztec/ipc-runtime",
|
|
3
|
+
"packageManager": "yarn@4.13.0",
|
|
4
|
+
"version": "0.0.1-commit.4da4de7",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dest/index.js",
|
|
7
|
+
"types": "dest/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dest/index.d.ts",
|
|
11
|
+
"import": "./dest/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"clean": "rm -rf dest"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dest",
|
|
20
|
+
"src",
|
|
21
|
+
"build"
|
|
22
|
+
],
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22",
|
|
25
|
+
"typescript": "^5.6.3"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { IpcClientAsync, IpcClientSync } from "./types.js";
|
|
2
|
+
export { UdsIpcClient, type UdsIpcClientConnectOptions } from "./uds_client.js";
|
|
3
|
+
export { UdsIpcServer, type IpcServerHandler } from "./uds_server.js";
|
|
4
|
+
export {
|
|
5
|
+
NapiShmSyncClient,
|
|
6
|
+
NapiShmAsyncClient,
|
|
7
|
+
createNapiShmSyncClient,
|
|
8
|
+
createNapiShmAsyncClient,
|
|
9
|
+
type NapiMsgpackClientSync,
|
|
10
|
+
type NapiMsgpackClientAsync,
|
|
11
|
+
} from "./shm_client.js";
|
|
12
|
+
export {
|
|
13
|
+
findIpcRuntimeNapi,
|
|
14
|
+
loadIpcRuntimeNapi,
|
|
15
|
+
type Platform,
|
|
16
|
+
} from "./native_loader.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Locate the prebuilt ipc_runtime_napi.node addon shipped with this package.
|
|
2
|
+
//
|
|
3
|
+
// The addon is built by `ipc-runtime/bootstrap.sh` (CMake target
|
|
4
|
+
// `ipc_runtime_napi`) and copied into `build/<arch>-<os>/` next to this
|
|
5
|
+
// package's `package.json`. Resolution walks up from this file's URL to the
|
|
6
|
+
// first `package.json` adjacent to a `build/` directory — that's the
|
|
7
|
+
// package root in both `file:`-linked and published consumption.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
export type Platform =
|
|
15
|
+
| "x86_64-linux"
|
|
16
|
+
| "x86_64-darwin"
|
|
17
|
+
| "aarch64-linux"
|
|
18
|
+
| "aarch64-darwin";
|
|
19
|
+
|
|
20
|
+
const PLATFORM_TO_BUILD_DIR: Record<Platform, string> = {
|
|
21
|
+
"x86_64-linux": "amd64-linux",
|
|
22
|
+
"x86_64-darwin": "amd64-macos",
|
|
23
|
+
"aarch64-linux": "arm64-linux",
|
|
24
|
+
"aarch64-darwin": "arm64-macos",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function detectPlatform(): Platform | null {
|
|
28
|
+
const arch = process.arch;
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
if (arch === "x64" && platform === "linux") return "x86_64-linux";
|
|
31
|
+
if (arch === "x64" && platform === "darwin") return "x86_64-darwin";
|
|
32
|
+
if (arch === "arm64" && platform === "linux") return "aarch64-linux";
|
|
33
|
+
if (arch === "arm64" && platform === "darwin") return "aarch64-darwin";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findPackageRoot(): string | null {
|
|
38
|
+
// `import.meta.url` after tsc compile points at the .js file under
|
|
39
|
+
// <pkg>/dest/...; climb until we find package.json with a sibling build/.
|
|
40
|
+
let currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const root = path.parse(currentDir).root;
|
|
42
|
+
while (currentDir !== root) {
|
|
43
|
+
const packageJsonPath = path.join(currentDir, "package.json");
|
|
44
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
45
|
+
const buildDir = path.join(currentDir, "build");
|
|
46
|
+
if (fs.existsSync(buildDir)) {
|
|
47
|
+
return currentDir;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
currentDir = path.dirname(currentDir);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the path of `ipc_runtime_napi.node` for the current platform.
|
|
57
|
+
* Returns null if either the platform is unsupported or the artifact is
|
|
58
|
+
* absent (typical reason: ipc-runtime/bootstrap.sh hasn't run yet).
|
|
59
|
+
*/
|
|
60
|
+
export function findIpcRuntimeNapi(customPath?: string): string | null {
|
|
61
|
+
if (customPath) {
|
|
62
|
+
return fs.existsSync(customPath) ? path.resolve(customPath) : null;
|
|
63
|
+
}
|
|
64
|
+
const platform = detectPlatform();
|
|
65
|
+
if (!platform) return null;
|
|
66
|
+
const packageRoot = findPackageRoot();
|
|
67
|
+
if (!packageRoot) return null;
|
|
68
|
+
const buildDir = PLATFORM_TO_BUILD_DIR[platform];
|
|
69
|
+
const candidate = path.join(
|
|
70
|
+
packageRoot,
|
|
71
|
+
"build",
|
|
72
|
+
buildDir,
|
|
73
|
+
"ipc_runtime_napi.node",
|
|
74
|
+
);
|
|
75
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load `ipc_runtime_napi.node` and return its native exports
|
|
80
|
+
* (`MsgpackClient`, `MsgpackClientAsync`). Throws a descriptive error when
|
|
81
|
+
* the addon cannot be located or fails to dlopen.
|
|
82
|
+
*/
|
|
83
|
+
export function loadIpcRuntimeNapi(customPath?: string): {
|
|
84
|
+
MsgpackClient: new (shmName: string, clientId?: number) => any;
|
|
85
|
+
MsgpackClientAsync: new (shmName: string, clientId?: number) => any;
|
|
86
|
+
} {
|
|
87
|
+
const addonPath = findIpcRuntimeNapi(customPath);
|
|
88
|
+
if (!addonPath) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"Could not locate ipc_runtime_napi.node. Build with `ipc-runtime/bootstrap.sh` " +
|
|
91
|
+
"or set the optional `customPath` argument to point at a prebuilt addon.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// createRequire so this works in both ESM and CJS callers.
|
|
95
|
+
const require = createRequire(import.meta.url);
|
|
96
|
+
return require(addonPath);
|
|
97
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { loadIpcRuntimeNapi } from "./native_loader.js";
|
|
2
|
+
import { IpcClientAsync, IpcClientSync } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimum surface a NAPI msgpack client must expose. Satisfied by the
|
|
6
|
+
* `MsgpackClient` / `MsgpackClientAsync` classes exported from this
|
|
7
|
+
* package's own `ipc_runtime_napi.node` addon (see ipc-runtime/cpp/napi/),
|
|
8
|
+
* which wraps the C++ ipc::IpcClient.
|
|
9
|
+
*
|
|
10
|
+
* The interface is exposed for tests / consumers that want to inject a
|
|
11
|
+
* mock or alternative implementation; the standard production path is the
|
|
12
|
+
* `createNapiShm{Sync,Async}Client` factories below, which load the
|
|
13
|
+
* prebuilt addon shipped in this package's `build/<arch>-<os>/` directory.
|
|
14
|
+
*
|
|
15
|
+
* Note on the async contract: `MsgpackClientAsync.call` is *fire and
|
|
16
|
+
* forget*. Responses arrive via `setResponseCallback` in FIFO order on a
|
|
17
|
+
* background-thread → main-thread bridge (Napi::ThreadSafeFunction).
|
|
18
|
+
* The TS wrapper below owns the request queue and matches responses.
|
|
19
|
+
*/
|
|
20
|
+
export interface NapiMsgpackClientSync {
|
|
21
|
+
call(input: Buffer): Buffer;
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NapiMsgpackClientAsync {
|
|
26
|
+
setResponseCallback(cb: (response: Buffer) => void): void;
|
|
27
|
+
call(input: Buffer): void;
|
|
28
|
+
acquire(): void;
|
|
29
|
+
release(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Wraps a sync NAPI msgpack client behind the IpcClientSync interface. */
|
|
33
|
+
export class NapiShmSyncClient implements IpcClientSync {
|
|
34
|
+
constructor(private inner: NapiMsgpackClientSync) {}
|
|
35
|
+
|
|
36
|
+
call(input: Uint8Array): Uint8Array {
|
|
37
|
+
const buf = Buffer.isBuffer(input)
|
|
38
|
+
? input
|
|
39
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
40
|
+
const resp = this.inner.call(buf);
|
|
41
|
+
return new Uint8Array(resp.buffer, resp.byteOffset, resp.byteLength);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
destroy(): void {
|
|
45
|
+
this.inner.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PendingCallback {
|
|
50
|
+
resolve: (data: Uint8Array) => void;
|
|
51
|
+
reject: (error: Error) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wraps the fire-and-forget async NAPI msgpack client behind the
|
|
56
|
+
* `IpcClientAsync` interface. Owns a FIFO queue of pending calls; the C++
|
|
57
|
+
* background polling thread invokes `setResponseCallback` once per
|
|
58
|
+
* response, and this wrapper matches it to the next queued caller.
|
|
59
|
+
*
|
|
60
|
+
* `acquire` / `release` are reference-count hooks the NAPI exposes so the
|
|
61
|
+
* libuv loop is kept alive only while requests are outstanding — without
|
|
62
|
+
* them a `node script.js` would never exit naturally.
|
|
63
|
+
*/
|
|
64
|
+
export class NapiShmAsyncClient implements IpcClientAsync {
|
|
65
|
+
private readonly pending: PendingCallback[] = [];
|
|
66
|
+
|
|
67
|
+
constructor(private inner: NapiMsgpackClientAsync) {
|
|
68
|
+
this.inner.setResponseCallback((response: Buffer) => {
|
|
69
|
+
const cb = this.pending.shift();
|
|
70
|
+
if (cb) {
|
|
71
|
+
cb.resolve(new Uint8Array(response));
|
|
72
|
+
} else {
|
|
73
|
+
// Unexpected — a response arrived but no caller is waiting.
|
|
74
|
+
// Drop it; there is no caller left to resolve.
|
|
75
|
+
}
|
|
76
|
+
if (this.pending.length === 0) {
|
|
77
|
+
this.inner.release();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
call(input: Uint8Array): Promise<Uint8Array> {
|
|
83
|
+
const buf = Buffer.isBuffer(input)
|
|
84
|
+
? input
|
|
85
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
86
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
87
|
+
if (this.pending.length === 0) {
|
|
88
|
+
this.inner.acquire();
|
|
89
|
+
}
|
|
90
|
+
this.pending.push({ resolve, reject });
|
|
91
|
+
try {
|
|
92
|
+
this.inner.call(buf);
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
// Send failed — unwind the queue entry we just added.
|
|
95
|
+
this.pending.pop();
|
|
96
|
+
if (this.pending.length === 0) {
|
|
97
|
+
this.inner.release();
|
|
98
|
+
}
|
|
99
|
+
reject(
|
|
100
|
+
err instanceof Error
|
|
101
|
+
? err
|
|
102
|
+
: new Error(`SHM async call failed: ${String(err)}`),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async destroy(): Promise<void> {
|
|
109
|
+
// Reject anything still in flight.
|
|
110
|
+
while (this.pending.length > 0) {
|
|
111
|
+
const cb = this.pending.shift();
|
|
112
|
+
cb?.reject(new Error("ipc-runtime SHM client destroyed before response"));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CreateNapiShmOptions {
|
|
118
|
+
/** MPSC client slot id (default 0). Distinct clients on the same shmName must use distinct slots. */
|
|
119
|
+
clientId?: number;
|
|
120
|
+
/** Override addon path lookup. Rarely needed; useful for tests / unusual deployments. */
|
|
121
|
+
customAddonPath?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Factories that load the bundled `ipc_runtime_napi.node` addon and
|
|
126
|
+
* construct an MPSC-SHM client wrapped behind the `IpcClient*` interface.
|
|
127
|
+
* Matches the transport used by `ipc::make_server` on the C++ side, so any
|
|
128
|
+
* server started via that helper accepts these clients directly.
|
|
129
|
+
*/
|
|
130
|
+
export function createNapiShmSyncClient(
|
|
131
|
+
shmName: string,
|
|
132
|
+
options: CreateNapiShmOptions = {},
|
|
133
|
+
): NapiShmSyncClient {
|
|
134
|
+
const napi = loadIpcRuntimeNapi(options.customAddonPath);
|
|
135
|
+
return new NapiShmSyncClient(
|
|
136
|
+
new napi.MsgpackClient(shmName, options.clientId ?? 0),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createNapiShmAsyncClient(
|
|
141
|
+
shmName: string,
|
|
142
|
+
options: CreateNapiShmOptions = {},
|
|
143
|
+
): NapiShmAsyncClient {
|
|
144
|
+
const napi = loadIpcRuntimeNapi(options.customAddonPath);
|
|
145
|
+
return new NapiShmAsyncClient(
|
|
146
|
+
new napi.MsgpackClientAsync(shmName, options.clientId ?? 0),
|
|
147
|
+
);
|
|
148
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal byte-in / byte-out interface that the ipc-codegen-emitted
|
|
3
|
+
* <Service>Api types consume. Both UDS and SHM transports satisfy this.
|
|
4
|
+
*/
|
|
5
|
+
export interface IpcClientAsync {
|
|
6
|
+
call(input: Uint8Array): Promise<Uint8Array>;
|
|
7
|
+
destroy(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface IpcClientSync {
|
|
11
|
+
call(input: Uint8Array): Uint8Array;
|
|
12
|
+
destroy(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import { IpcClientAsync } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface PendingCall {
|
|
5
|
+
resolve: (resp: Uint8Array) => void;
|
|
6
|
+
reject: (err: Error) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UdsIpcClientConnectOptions {
|
|
10
|
+
/** Mark the socket as unref'd so it doesn't keep the Node event loop alive when idle. */
|
|
11
|
+
unref?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Retry budget (ms) for the initial connect when the server has bound the
|
|
14
|
+
* path but not yet called listen(). Set to 0 to fail immediately on
|
|
15
|
+
* ECONNREFUSED. Default 5000.
|
|
16
|
+
*/
|
|
17
|
+
connectTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Async IPC client over a Unix Domain Socket. Wire format matches the C++
|
|
22
|
+
* ipc::IpcServer/IpcClient socket transport: 4-byte little-endian length
|
|
23
|
+
* prefix followed by `length` bytes of msgpack payload, per direction.
|
|
24
|
+
*
|
|
25
|
+
* Supports pipelining: multiple concurrent `call()` invocations are queued
|
|
26
|
+
* FIFO and matched with responses in order. Pipelining keeps the server-side
|
|
27
|
+
* socket window full and matches the native client behaviour.
|
|
28
|
+
*/
|
|
29
|
+
export class UdsIpcClient implements IpcClientAsync {
|
|
30
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
31
|
+
private pending: PendingCall[] = [];
|
|
32
|
+
private destroyed = false;
|
|
33
|
+
|
|
34
|
+
private constructor(private conn: net.Socket) {
|
|
35
|
+
conn.on("data", (chunk) => this.onData(chunk));
|
|
36
|
+
conn.on("error", (err) => this.failAll(err));
|
|
37
|
+
conn.on("close", () => this.failAll(new Error("socket closed")));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static async connect(
|
|
41
|
+
socketPath: string,
|
|
42
|
+
opts?: UdsIpcClientConnectOptions,
|
|
43
|
+
): Promise<UdsIpcClient> {
|
|
44
|
+
const conn = await connectWithRetry(
|
|
45
|
+
socketPath,
|
|
46
|
+
opts?.connectTimeoutMs ?? 5000,
|
|
47
|
+
);
|
|
48
|
+
conn.setNoDelay(true);
|
|
49
|
+
if (opts?.unref) conn.unref();
|
|
50
|
+
return new UdsIpcClient(conn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Number of in-flight calls awaiting a response. */
|
|
54
|
+
get inflight(): number {
|
|
55
|
+
return this.pending.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Underlying socket — exposed for ref/unref control (event-loop tuning). */
|
|
59
|
+
get socket(): net.Socket {
|
|
60
|
+
return this.conn;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async call(input: Uint8Array): Promise<Uint8Array> {
|
|
64
|
+
if (this.destroyed) {
|
|
65
|
+
throw new Error("UdsIpcClient: call() after destroy()");
|
|
66
|
+
}
|
|
67
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
68
|
+
this.pending.push({ resolve, reject });
|
|
69
|
+
const lenBuf = Buffer.allocUnsafe(4);
|
|
70
|
+
lenBuf.writeUInt32LE(input.length, 0);
|
|
71
|
+
this.conn.write(lenBuf);
|
|
72
|
+
this.conn.write(input);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async destroy(): Promise<void> {
|
|
77
|
+
this.destroyed = true;
|
|
78
|
+
this.conn.removeAllListeners();
|
|
79
|
+
this.conn.destroy();
|
|
80
|
+
this.failAll(new Error("UdsIpcClient destroyed"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private onData(chunk: Buffer): void {
|
|
84
|
+
this.buffer =
|
|
85
|
+
this.buffer.length === 0
|
|
86
|
+
? Buffer.from(chunk)
|
|
87
|
+
: Buffer.concat([this.buffer, chunk]);
|
|
88
|
+
while (this.buffer.length >= 4) {
|
|
89
|
+
const len = this.buffer.readUInt32LE(0);
|
|
90
|
+
if (this.buffer.length < 4 + len) return;
|
|
91
|
+
const payload = this.buffer.subarray(4, 4 + len);
|
|
92
|
+
this.buffer = this.buffer.subarray(4 + len);
|
|
93
|
+
const next = this.pending.shift();
|
|
94
|
+
if (next) next.resolve(new Uint8Array(payload));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private failAll(err: Error): void {
|
|
99
|
+
const pending = this.pending;
|
|
100
|
+
this.pending = [];
|
|
101
|
+
for (const p of pending) p.reject(err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Connect to `socketPath`, retrying on ECONNREFUSED until `timeoutMs`
|
|
107
|
+
* elapses. ECONNREFUSED happens in the narrow window between the server's
|
|
108
|
+
* bind() and listen(); other errors fail immediately.
|
|
109
|
+
*/
|
|
110
|
+
async function connectWithRetry(
|
|
111
|
+
socketPath: string,
|
|
112
|
+
timeoutMs: number,
|
|
113
|
+
): Promise<net.Socket> {
|
|
114
|
+
const deadline = Date.now() + timeoutMs;
|
|
115
|
+
let attempt = 0;
|
|
116
|
+
let lastErr: Error | undefined;
|
|
117
|
+
while (true) {
|
|
118
|
+
try {
|
|
119
|
+
return await attemptConnect(socketPath);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
lastErr = err as Error;
|
|
122
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
123
|
+
if (code !== "ECONNREFUSED" && code !== "ENOENT") {
|
|
124
|
+
throw new Error(`UdsIpcClient: connect failed: ${lastErr.message}`);
|
|
125
|
+
}
|
|
126
|
+
if (Date.now() >= deadline) {
|
|
127
|
+
throw new Error(`UdsIpcClient: connect timed out: ${lastErr.message}`);
|
|
128
|
+
}
|
|
129
|
+
const delay = Math.min(50, 5 * 2 ** attempt++);
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function attemptConnect(socketPath: string): Promise<net.Socket> {
|
|
136
|
+
return new Promise<net.Socket>((resolve, reject) => {
|
|
137
|
+
const conn = net.createConnection(socketPath);
|
|
138
|
+
const onError = (err: Error) => {
|
|
139
|
+
conn.removeListener("connect", onConnect);
|
|
140
|
+
conn.destroy();
|
|
141
|
+
reject(err);
|
|
142
|
+
};
|
|
143
|
+
const onConnect = () => {
|
|
144
|
+
conn.removeListener("error", onError);
|
|
145
|
+
resolve(conn);
|
|
146
|
+
};
|
|
147
|
+
conn.once("connect", onConnect);
|
|
148
|
+
conn.once("error", onError);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Handler signature mirrors the C++ ipc::IpcServer::Handler: receive raw
|
|
6
|
+
* bytes, return raw bytes. msgpack decode/encode and command dispatch are
|
|
7
|
+
* the caller's responsibility (or the codegen's, when a generated dispatcher
|
|
8
|
+
* is wired in).
|
|
9
|
+
*/
|
|
10
|
+
export type IpcServerHandler = (
|
|
11
|
+
clientId: number,
|
|
12
|
+
request: Uint8Array,
|
|
13
|
+
) => Promise<Uint8Array> | Uint8Array;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* UDS server with the same 4-byte-LE-length-prefix wire as UdsIpcClient and
|
|
17
|
+
* the C++ ipc::IpcServer socket transport. Accepts multiple concurrent
|
|
18
|
+
* connections; handler invocations are serialised per-connection.
|
|
19
|
+
*/
|
|
20
|
+
export class UdsIpcServer {
|
|
21
|
+
private server: net.Server;
|
|
22
|
+
private nextClientId = 0;
|
|
23
|
+
|
|
24
|
+
private constructor(
|
|
25
|
+
server: net.Server,
|
|
26
|
+
private socketPath: string,
|
|
27
|
+
) {
|
|
28
|
+
this.server = server;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async listen(
|
|
32
|
+
socketPath: string,
|
|
33
|
+
handler: IpcServerHandler,
|
|
34
|
+
): Promise<UdsIpcServer> {
|
|
35
|
+
try {
|
|
36
|
+
fs.unlinkSync(socketPath);
|
|
37
|
+
} catch {
|
|
38
|
+
/* socket file may not exist; ignore */
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const server = net.createServer();
|
|
42
|
+
const instance = new UdsIpcServer(server, socketPath);
|
|
43
|
+
server.on("connection", (conn) => instance.handleConnection(conn, handler));
|
|
44
|
+
|
|
45
|
+
await new Promise<void>((resolve, reject) => {
|
|
46
|
+
const onError = (err: Error) => {
|
|
47
|
+
server.removeListener("listening", onListening);
|
|
48
|
+
reject(err);
|
|
49
|
+
};
|
|
50
|
+
const onListening = () => {
|
|
51
|
+
server.removeListener("error", onError);
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
server.once("error", onError);
|
|
55
|
+
server.once("listening", onListening);
|
|
56
|
+
server.listen(socketPath);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return instance;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async close(): Promise<void> {
|
|
63
|
+
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
64
|
+
try {
|
|
65
|
+
fs.unlinkSync(this.socketPath);
|
|
66
|
+
} catch {
|
|
67
|
+
/* may already be gone */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private handleConnection(conn: net.Socket, handler: IpcServerHandler): void {
|
|
72
|
+
const clientId = this.nextClientId++;
|
|
73
|
+
let buffer = Buffer.alloc(0);
|
|
74
|
+
let chain: Promise<void> = Promise.resolve();
|
|
75
|
+
|
|
76
|
+
conn.on("data", (chunk: Buffer) => {
|
|
77
|
+
buffer =
|
|
78
|
+
buffer.length === 0
|
|
79
|
+
? Buffer.from(chunk)
|
|
80
|
+
: Buffer.concat([buffer, chunk]);
|
|
81
|
+
while (buffer.length >= 4) {
|
|
82
|
+
const len = buffer.readUInt32LE(0);
|
|
83
|
+
if (buffer.length < 4 + len) break;
|
|
84
|
+
const payload = new Uint8Array(buffer.subarray(4, 4 + len));
|
|
85
|
+
buffer = buffer.subarray(4 + len);
|
|
86
|
+
|
|
87
|
+
const prev = chain;
|
|
88
|
+
chain = (async () => {
|
|
89
|
+
await prev;
|
|
90
|
+
try {
|
|
91
|
+
const resp = await handler(clientId, payload);
|
|
92
|
+
const lenBuf = Buffer.allocUnsafe(4);
|
|
93
|
+
lenBuf.writeUInt32LE(resp.length, 0);
|
|
94
|
+
conn.write(lenBuf);
|
|
95
|
+
conn.write(resp);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
conn.destroy(err as Error);
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
void chain.catch(() => {
|
|
101
|
+
/* errors already handled by destroying the connection */
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
conn.on("error", () => {
|
|
107
|
+
/* swallowed — clients reconnect */
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|