feat: add @claudemesh/sdk standalone client library
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
100
packages/sdk/README.md
Normal file
100
packages/sdk/README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# @claudemesh/sdk
|
||||||
|
|
||||||
|
Lightweight TypeScript SDK for connecting any process to a claudemesh mesh. Handles WebSocket connections, ed25519 authentication, crypto_box encryption, and auto-reconnect.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @claudemesh/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MeshClient, generateKeyPair } from "@claudemesh/sdk";
|
||||||
|
|
||||||
|
const keys = generateKeyPair();
|
||||||
|
const client = new MeshClient({
|
||||||
|
brokerUrl: "wss://ic.claudemesh.com/ws",
|
||||||
|
meshId: "your-mesh-id",
|
||||||
|
memberId: "your-member-id",
|
||||||
|
pubkey: keys.publicKey,
|
||||||
|
secretKey: keys.secretKey,
|
||||||
|
displayName: "My Bot",
|
||||||
|
peerType: "connector",
|
||||||
|
channel: "custom",
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
client.on("message", (msg) => {
|
||||||
|
console.log(`From ${msg.senderPubkey}: ${msg.plaintext}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for peer events
|
||||||
|
client.on("peer_joined", (peer) => {
|
||||||
|
console.log(`${peer.displayName} joined`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("peer_left", (peer) => {
|
||||||
|
console.log(`${peer.displayName} left`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a message (by display name or pubkey)
|
||||||
|
await client.send("Alice", "Hello from SDK!");
|
||||||
|
|
||||||
|
// Broadcast to all peers
|
||||||
|
await client.broadcast("Hello everyone!");
|
||||||
|
|
||||||
|
// List connected peers
|
||||||
|
const peers = await client.listPeers();
|
||||||
|
|
||||||
|
// Shared state
|
||||||
|
await client.setState("build_status", "passing");
|
||||||
|
const value = await client.getState("build_status");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
client.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `generateKeyPair()`
|
||||||
|
|
||||||
|
Returns `Promise<{ publicKey: string; secretKey: string }>` -- an ed25519 keypair with hex-encoded keys.
|
||||||
|
|
||||||
|
### `new MeshClient(opts)`
|
||||||
|
|
||||||
|
| Option | Type | Required | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| `brokerUrl` | `string` | yes | WebSocket URL of the broker |
|
||||||
|
| `meshId` | `string` | yes | Mesh to join |
|
||||||
|
| `memberId` | `string` | yes | Your member ID within the mesh |
|
||||||
|
| `pubkey` | `string` | yes | Ed25519 public key (hex) |
|
||||||
|
| `secretKey` | `string` | yes | Ed25519 secret key (hex) |
|
||||||
|
| `displayName` | `string` | no | Name visible to other peers |
|
||||||
|
| `peerType` | `"ai" \| "human" \| "connector"` | no | Defaults to `"connector"` |
|
||||||
|
| `channel` | `string` | no | Channel identifier |
|
||||||
|
| `debug` | `boolean` | no | Log debug info to stderr |
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
- `connect(): Promise<void>` -- Open connection and authenticate
|
||||||
|
- `disconnect(): void` -- Close connection
|
||||||
|
- `send(to, message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to peer name, pubkey, `*`, or `@group`
|
||||||
|
- `broadcast(message, priority?): Promise<{ ok, messageId?, error? }>` -- Send to all peers
|
||||||
|
- `listPeers(): Promise<PeerInfo[]>` -- List connected peers
|
||||||
|
- `getState(key): Promise<string | null>` -- Read shared state
|
||||||
|
- `setState(key, value): Promise<void>` -- Write shared state
|
||||||
|
- `setSummary(summary): Promise<void>` -- Set session summary
|
||||||
|
- `setStatus(status): Promise<void>` -- Set status (`idle`, `working`, `dnd`)
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
- `"message"` -- Inbound message received
|
||||||
|
- `"connected"` -- WebSocket authenticated
|
||||||
|
- `"disconnected"` -- WebSocket closed
|
||||||
|
- `"peer_joined"` -- A peer connected to the mesh
|
||||||
|
- `"peer_left"` -- A peer disconnected
|
||||||
|
- `"state_change"` -- Shared state was updated by a peer
|
||||||
19
packages/sdk/package.json
Normal file
19
packages/sdk/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@claudemesh/sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "SDK for connecting any process to a claudemesh mesh",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers": "0.7.15",
|
||||||
|
"ws": "8.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "8.5.13",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
564
packages/sdk/src/client.ts
Normal file
564
packages/sdk/src/client.ts
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
/**
|
||||||
|
* MeshClient -- lightweight WebSocket client for connecting any process
|
||||||
|
* to a claudemesh mesh. Handles:
|
||||||
|
* - hello handshake + ack
|
||||||
|
* - send / ack / push message flow
|
||||||
|
* - auto-reconnect with exponential backoff
|
||||||
|
* - crypto_box encryption for direct messages
|
||||||
|
* - EventEmitter interface for messages, connection, and peer events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import {
|
||||||
|
signHello,
|
||||||
|
generateKeyPair,
|
||||||
|
encryptDirect,
|
||||||
|
decryptDirect,
|
||||||
|
isDirectTarget,
|
||||||
|
} from "./crypto.js";
|
||||||
|
import type {
|
||||||
|
MeshClientOptions,
|
||||||
|
PeerInfo,
|
||||||
|
InboundMessage,
|
||||||
|
Priority,
|
||||||
|
ConnStatus,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
interface PendingSend {
|
||||||
|
id: string;
|
||||||
|
targetSpec: string;
|
||||||
|
priority: Priority;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
resolve: (v: { ok: boolean; messageId?: string; error?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_QUEUED = 100;
|
||||||
|
const HELLO_ACK_TIMEOUT_MS = 5_000;
|
||||||
|
const BACKOFF_CAPS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
|
||||||
|
|
||||||
|
export interface MeshClientEvents {
|
||||||
|
message: [msg: InboundMessage];
|
||||||
|
connected: [];
|
||||||
|
disconnected: [];
|
||||||
|
peer_joined: [peer: PeerInfo];
|
||||||
|
peer_left: [peer: PeerInfo];
|
||||||
|
state_change: [change: { key: string; value: unknown; updatedBy: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshClient extends EventEmitter {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private _status: ConnStatus = "closed";
|
||||||
|
private pendingSends = new Map<string, PendingSend>();
|
||||||
|
private outbound: Array<() => void> = [];
|
||||||
|
private closed = false;
|
||||||
|
private reconnectAttempt = 0;
|
||||||
|
private helloTimer: NodeJS.Timeout | null = null;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Session keypair (generated on first connect, reused across reconnects)
|
||||||
|
private sessionPubkey: string | null = null;
|
||||||
|
private sessionSecretKey: string | null = null;
|
||||||
|
|
||||||
|
// Request-response resolvers
|
||||||
|
private listPeersResolvers = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (peers: PeerInfo[]) => void; timer: NodeJS.Timeout }
|
||||||
|
>();
|
||||||
|
private stateResolvers = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
resolve: (
|
||||||
|
result: {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
updatedBy: string;
|
||||||
|
updatedAt: string;
|
||||||
|
} | null,
|
||||||
|
) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(private opts: MeshClientOptions) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current connection status. */
|
||||||
|
get status(): ConnStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session public key hex (null before first connect). */
|
||||||
|
get pubkey(): string | null {
|
||||||
|
return this.sessionPubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the WebSocket, send hello, resolve when hello_ack received. */
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.closed) throw new Error("client is closed");
|
||||||
|
this._status = "connecting";
|
||||||
|
const ws = new WebSocket(this.opts.brokerUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const onOpen = async (): Promise<void> => {
|
||||||
|
this.debug("ws open -> generating session keypair + signing hello");
|
||||||
|
try {
|
||||||
|
if (!this.sessionPubkey) {
|
||||||
|
const sessionKP = await generateKeyPair();
|
||||||
|
this.sessionPubkey = sessionKP.publicKey;
|
||||||
|
this.sessionSecretKey = sessionKP.secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timestamp, signature } = await signHello(
|
||||||
|
this.opts.meshId,
|
||||||
|
this.opts.memberId,
|
||||||
|
this.opts.pubkey,
|
||||||
|
this.opts.secretKey,
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
meshId: this.opts.meshId,
|
||||||
|
memberId: this.opts.memberId,
|
||||||
|
pubkey: this.opts.pubkey,
|
||||||
|
sessionPubkey: this.sessionPubkey,
|
||||||
|
displayName: this.opts.displayName,
|
||||||
|
sessionId: `sdk-${process.pid}-${Date.now()}`,
|
||||||
|
pid: process.pid,
|
||||||
|
peerType: this.opts.peerType ?? "connector",
|
||||||
|
channel: this.opts.channel ?? "sdk",
|
||||||
|
timestamp,
|
||||||
|
signature,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`hello sign failed: ${e instanceof Error ? e.message : e}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.helloTimer = setTimeout(() => {
|
||||||
|
this.debug("hello_ack timeout");
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("hello_ack timeout"));
|
||||||
|
}, HELLO_ACK_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (raw: WebSocket.RawData): void => {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "hello_ack") {
|
||||||
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
|
this.helloTimer = null;
|
||||||
|
this._status = "open";
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.flushOutbound();
|
||||||
|
this.emit("connected");
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleServerMessage(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = (): void => {
|
||||||
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
|
this.helloTimer = null;
|
||||||
|
const wasOpen = this._status === "open" || this._status === "reconnecting";
|
||||||
|
this.ws = null;
|
||||||
|
if (!wasOpen && this._status === "connecting") {
|
||||||
|
reject(new Error("ws closed before hello_ack"));
|
||||||
|
}
|
||||||
|
if (!this.closed) {
|
||||||
|
this.emit("disconnected");
|
||||||
|
this.scheduleReconnect();
|
||||||
|
} else {
|
||||||
|
this._status = "closed";
|
||||||
|
this.emit("disconnected");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: Error): void => {
|
||||||
|
this.debug(`ws error: ${err.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on("open", onOpen);
|
||||||
|
ws.on("message", onMessage);
|
||||||
|
ws.on("close", onClose);
|
||||||
|
ws.on("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gracefully close the connection. */
|
||||||
|
disconnect(): void {
|
||||||
|
this.closed = true;
|
||||||
|
if (this.helloTimer) clearTimeout(this.helloTimer);
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._status = "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Messaging ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a peer. `to` can be:
|
||||||
|
* - A hex pubkey (64 chars) for encrypted direct message
|
||||||
|
* - A display name (resolved via listPeers)
|
||||||
|
* - "*" for broadcast
|
||||||
|
* - "@groupname" for group message
|
||||||
|
*/
|
||||||
|
async send(
|
||||||
|
to: string,
|
||||||
|
message: string,
|
||||||
|
priority: Priority = "next",
|
||||||
|
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||||
|
// Resolve display name to pubkey for direct encryption
|
||||||
|
let targetSpec = to;
|
||||||
|
if (!isDirectTarget(to) && to !== "*" && !to.startsWith("@") && !to.startsWith("#")) {
|
||||||
|
const peers = await this.listPeers();
|
||||||
|
const match = peers.find(
|
||||||
|
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
targetSpec = match.pubkey;
|
||||||
|
}
|
||||||
|
// If no match found, send as-is and let the broker resolve
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
let nonce: string;
|
||||||
|
let ciphertext: string;
|
||||||
|
|
||||||
|
if (isDirectTarget(targetSpec)) {
|
||||||
|
const env = await encryptDirect(
|
||||||
|
message,
|
||||||
|
targetSpec,
|
||||||
|
this.sessionSecretKey ?? this.opts.secretKey,
|
||||||
|
);
|
||||||
|
nonce = env.nonce;
|
||||||
|
ciphertext = env.ciphertext;
|
||||||
|
} else {
|
||||||
|
nonce = randomBytes(24).toString("base64");
|
||||||
|
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.pendingSends.size >= MAX_QUEUED) {
|
||||||
|
resolve({ ok: false, error: "outbound queue full" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingSends.set(id, {
|
||||||
|
id,
|
||||||
|
targetSpec,
|
||||||
|
priority,
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
const dispatch = (): void => {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "send",
|
||||||
|
id,
|
||||||
|
targetSpec,
|
||||||
|
priority,
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (this._status === "open") dispatch();
|
||||||
|
else {
|
||||||
|
if (this.outbound.length >= MAX_QUEUED) {
|
||||||
|
this.pendingSends.delete(id);
|
||||||
|
resolve({ ok: false, error: "outbound queue full" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.outbound.push(dispatch);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pendingSends.has(id)) {
|
||||||
|
this.pendingSends.delete(id);
|
||||||
|
resolve({ ok: false, error: "ack timeout" });
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a message to all peers in the mesh. */
|
||||||
|
async broadcast(
|
||||||
|
message: string,
|
||||||
|
priority: Priority = "next",
|
||||||
|
): Promise<{ ok: boolean; messageId?: string; error?: string }> {
|
||||||
|
return this.send("*", message, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Peers ---
|
||||||
|
|
||||||
|
/** Request the list of connected peers from the broker. */
|
||||||
|
async listPeers(): Promise<PeerInfo[]> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return [];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.listPeersResolvers.set(reqId, {
|
||||||
|
resolve,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
if (this.listPeersResolvers.delete(reqId)) resolve([]);
|
||||||
|
}, 5_000),
|
||||||
|
});
|
||||||
|
this.ws!.send(JSON.stringify({ type: "list_peers", _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
|
||||||
|
/** Read a shared state value. */
|
||||||
|
async getState(
|
||||||
|
key: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return null;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
this.stateResolvers.set(reqId, {
|
||||||
|
resolve: (result) => resolve(result ? String(result.value) : null),
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
if (this.stateResolvers.delete(reqId)) resolve(null);
|
||||||
|
}, 5_000),
|
||||||
|
});
|
||||||
|
this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a shared state value visible to all peers. */
|
||||||
|
async setState(key: string, value: string): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "set_state", key, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary / Status ---
|
||||||
|
|
||||||
|
/** Update this session's summary visible to other peers. */
|
||||||
|
async setSummary(summary: string): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "set_summary", summary }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Override connection status visible to peers. */
|
||||||
|
async setStatus(status: "idle" | "working" | "dnd"): Promise<void> {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
this.ws.send(JSON.stringify({ type: "set_status", status }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internals ---
|
||||||
|
|
||||||
|
private makeReqId(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushOutbound(): void {
|
||||||
|
const queued = this.outbound.slice();
|
||||||
|
this.outbound.length = 0;
|
||||||
|
for (const send of queued) send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
this._status = "reconnecting";
|
||||||
|
const delay =
|
||||||
|
BACKOFF_CAPS[Math.min(this.reconnectAttempt, BACKOFF_CAPS.length - 1)]!;
|
||||||
|
this.reconnectAttempt += 1;
|
||||||
|
this.debug(`reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.connect().catch((e) => {
|
||||||
|
this.debug(
|
||||||
|
`reconnect failed: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||||
|
const reqId = msg._reqId as string | undefined;
|
||||||
|
|
||||||
|
if (msg.type === "ack") {
|
||||||
|
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
||||||
|
if (pending) {
|
||||||
|
pending.resolve({
|
||||||
|
ok: true,
|
||||||
|
messageId: String(msg.messageId ?? ""),
|
||||||
|
});
|
||||||
|
this.pendingSends.delete(pending.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "peers_list") {
|
||||||
|
const peers = (msg.peers as PeerInfo[]) ?? [];
|
||||||
|
this.resolveFromMap(this.listPeersResolvers, reqId, peers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "push") {
|
||||||
|
void this.handlePush(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "state_result") {
|
||||||
|
if (msg.key) {
|
||||||
|
this.resolveFromMap(this.stateResolvers, reqId, {
|
||||||
|
key: String(msg.key),
|
||||||
|
value: msg.value,
|
||||||
|
updatedBy: String(msg.updatedBy ?? ""),
|
||||||
|
updatedAt: String(msg.updatedAt ?? ""),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resolveFromMap(this.stateResolvers, reqId, null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "state_change") {
|
||||||
|
this.emit("state_change", {
|
||||||
|
key: String(msg.key ?? ""),
|
||||||
|
value: msg.value,
|
||||||
|
updatedBy: String(msg.updatedBy ?? ""),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "error") {
|
||||||
|
this.debug(`broker error: ${msg.code} ${msg.message}`);
|
||||||
|
const id = msg.id ? String(msg.id) : null;
|
||||||
|
if (id) {
|
||||||
|
const pending = this.pendingSends.get(id);
|
||||||
|
if (pending) {
|
||||||
|
pending.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: `${msg.code}: ${msg.message}`,
|
||||||
|
});
|
||||||
|
this.pendingSends.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePush(msg: Record<string, unknown>): Promise<void> {
|
||||||
|
const nonce = String(msg.nonce ?? "");
|
||||||
|
const ciphertext = String(msg.ciphertext ?? "");
|
||||||
|
const senderPubkey = String(msg.senderPubkey ?? "");
|
||||||
|
|
||||||
|
const kind: InboundMessage["kind"] = senderPubkey ? "direct" : "unknown";
|
||||||
|
let plaintext: string | null = null;
|
||||||
|
|
||||||
|
// Try crypto_box decryption for direct messages
|
||||||
|
if (senderPubkey && nonce && ciphertext) {
|
||||||
|
plaintext = await decryptDirect(
|
||||||
|
{ nonce, ciphertext },
|
||||||
|
senderPubkey,
|
||||||
|
this.sessionSecretKey ?? this.opts.secretKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast/channel fallback: base64 UTF-8 decode
|
||||||
|
if (plaintext === null && ciphertext && !senderPubkey) {
|
||||||
|
try {
|
||||||
|
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||||
|
} catch {
|
||||||
|
plaintext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: try base64 decode even for direct (handles broadcasts
|
||||||
|
// and key mismatches gracefully)
|
||||||
|
if (plaintext === null && ciphertext) {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(ciphertext, "base64").toString("utf-8");
|
||||||
|
if (
|
||||||
|
/^[\x20-\x7E\s\u00A0-\uFFFF]*$/.test(decoded) &&
|
||||||
|
decoded.length > 0
|
||||||
|
) {
|
||||||
|
plaintext = decoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
plaintext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const push: InboundMessage = {
|
||||||
|
messageId: String(msg.messageId ?? ""),
|
||||||
|
meshId: String(msg.meshId ?? ""),
|
||||||
|
senderPubkey,
|
||||||
|
priority: (msg.priority as Priority) ?? "next",
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
createdAt: String(msg.createdAt ?? ""),
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
plaintext,
|
||||||
|
kind,
|
||||||
|
...(msg.subtype
|
||||||
|
? { subtype: msg.subtype as "reminder" | "system" }
|
||||||
|
: {}),
|
||||||
|
...(msg.event ? { event: String(msg.event) } : {}),
|
||||||
|
...(msg.eventData
|
||||||
|
? { eventData: msg.eventData as Record<string, unknown> }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit("message", push);
|
||||||
|
|
||||||
|
// Emit peer_joined / peer_left convenience events
|
||||||
|
if (push.event === "peer_joined" && push.eventData) {
|
||||||
|
this.emit("peer_joined", push.eventData as unknown as PeerInfo);
|
||||||
|
}
|
||||||
|
if (push.event === "peer_left" && push.eventData) {
|
||||||
|
this.emit("peer_left", push.eventData as unknown as PeerInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFromMap<T>(
|
||||||
|
map: Map<string, { resolve: (v: T) => void; timer: NodeJS.Timeout }>,
|
||||||
|
reqId: string | undefined,
|
||||||
|
value: T,
|
||||||
|
): boolean {
|
||||||
|
let entry = reqId ? map.get(reqId) : undefined;
|
||||||
|
if (!entry) {
|
||||||
|
// Fallback: oldest pending (FIFO, for brokers that don't echo _reqId)
|
||||||
|
const first = map.entries().next().value as
|
||||||
|
| [string, { resolve: (v: T) => void; timer: NodeJS.Timeout }]
|
||||||
|
| undefined;
|
||||||
|
if (first) {
|
||||||
|
entry = first[1];
|
||||||
|
map.delete(first[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.delete(reqId!);
|
||||||
|
}
|
||||||
|
if (entry) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.resolve(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private debug(msg: string): void {
|
||||||
|
if (this.opts.debug) console.error(`[claudemesh-sdk] ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
packages/sdk/src/crypto.ts
Normal file
136
packages/sdk/src/crypto.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Cryptographic primitives for the claudemesh SDK.
|
||||||
|
*
|
||||||
|
* Uses libsodium-wrappers for ed25519 keypair generation, hello signing,
|
||||||
|
* and crypto_box direct-message encryption. This matches the CLI's crypto
|
||||||
|
* implementation exactly, ensuring wire-level compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
|
if (!ready) {
|
||||||
|
await sodium.ready;
|
||||||
|
ready = true;
|
||||||
|
}
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ed25519 keypair with hex-encoded keys. */
|
||||||
|
export interface Ed25519Keypair {
|
||||||
|
/** 32-byte public key, hex-encoded. */
|
||||||
|
publicKey: string;
|
||||||
|
/** 64-byte secret key (seed || publicKey), hex-encoded. */
|
||||||
|
secretKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh ed25519 keypair for use as mesh identity. */
|
||||||
|
export async function generateKeyPair(): Promise<Ed25519Keypair> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const kp = s.crypto_sign_keypair();
|
||||||
|
return {
|
||||||
|
publicKey: s.to_hex(kp.publicKey),
|
||||||
|
secretKey: s.to_hex(kp.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a hello handshake message.
|
||||||
|
*
|
||||||
|
* Canonical bytes: `${meshId}|${memberId}|${pubkey}|${timestamp}`
|
||||||
|
* Must match the broker's `canonicalHello()` exactly.
|
||||||
|
*/
|
||||||
|
export async function signHello(
|
||||||
|
meshId: string,
|
||||||
|
memberId: string,
|
||||||
|
pubkey: string,
|
||||||
|
secretKeyHex: string,
|
||||||
|
): Promise<{ timestamp: number; signature: string }> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||||
|
const sig = s.crypto_sign_detached(
|
||||||
|
s.from_string(canonical),
|
||||||
|
s.from_hex(secretKeyHex),
|
||||||
|
);
|
||||||
|
return { timestamp, signature: s.to_hex(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypted envelope wire format. */
|
||||||
|
export interface Envelope {
|
||||||
|
nonce: string; // base64
|
||||||
|
ciphertext: string; // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
/** Check whether a targetSpec is a hex pubkey (direct message target). */
|
||||||
|
export function isDirectTarget(targetSpec: string): boolean {
|
||||||
|
return HEX_PUBKEY.test(targetSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext message for a single recipient using crypto_box.
|
||||||
|
*
|
||||||
|
* Ed25519 keys are converted to X25519 on the fly for Diffie-Hellman.
|
||||||
|
*/
|
||||||
|
export async function encryptDirect(
|
||||||
|
message: string,
|
||||||
|
recipientPubkeyHex: string,
|
||||||
|
senderSecretKeyHex: string,
|
||||||
|
): Promise<Envelope> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
s.from_hex(recipientPubkeyHex),
|
||||||
|
);
|
||||||
|
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
s.from_hex(senderSecretKeyHex),
|
||||||
|
);
|
||||||
|
const nonce = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||||
|
const ciphertext = s.crypto_box_easy(
|
||||||
|
s.from_string(message),
|
||||||
|
nonce,
|
||||||
|
recipientPub,
|
||||||
|
senderSec,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nonce: s.to_base64(nonce, s.base64_variants.ORIGINAL),
|
||||||
|
ciphertext: s.to_base64(ciphertext, s.base64_variants.ORIGINAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an inbound envelope from a known sender using crypto_box_open.
|
||||||
|
* Returns null if decryption fails.
|
||||||
|
*/
|
||||||
|
export async function decryptDirect(
|
||||||
|
envelope: Envelope,
|
||||||
|
senderPubkeyHex: string,
|
||||||
|
recipientSecretKeyHex: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
s.from_hex(senderPubkeyHex),
|
||||||
|
);
|
||||||
|
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
s.from_hex(recipientSecretKeyHex),
|
||||||
|
);
|
||||||
|
const nonce = s.from_base64(envelope.nonce, s.base64_variants.ORIGINAL);
|
||||||
|
const ciphertext = s.from_base64(
|
||||||
|
envelope.ciphertext,
|
||||||
|
s.base64_variants.ORIGINAL,
|
||||||
|
);
|
||||||
|
const plain = s.crypto_box_open_easy(
|
||||||
|
ciphertext,
|
||||||
|
nonce,
|
||||||
|
senderPub,
|
||||||
|
recipientSec,
|
||||||
|
);
|
||||||
|
return s.to_string(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/sdk/src/index.ts
Normal file
9
packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { MeshClient } from "./client.js";
|
||||||
|
export { generateKeyPair } from "./crypto.js";
|
||||||
|
export type {
|
||||||
|
PeerInfo,
|
||||||
|
InboundMessage,
|
||||||
|
Priority,
|
||||||
|
ConnStatus,
|
||||||
|
MeshClientOptions,
|
||||||
|
} from "./types.js";
|
||||||
64
packages/sdk/src/types.ts
Normal file
64
packages/sdk/src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/** Priority levels for message delivery. */
|
||||||
|
export type Priority = "now" | "next" | "low";
|
||||||
|
|
||||||
|
/** Connection status of the client. */
|
||||||
|
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
|
||||||
|
|
||||||
|
/** Information about a connected peer. */
|
||||||
|
export interface PeerInfo {
|
||||||
|
pubkey: string;
|
||||||
|
displayName: string;
|
||||||
|
status: string;
|
||||||
|
summary: string | null;
|
||||||
|
groups: Array<{ name: string; role?: string }>;
|
||||||
|
sessionId: string;
|
||||||
|
connectedAt: string;
|
||||||
|
cwd?: string;
|
||||||
|
peerType?: "ai" | "human" | "connector";
|
||||||
|
channel?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An inbound message received from the broker. */
|
||||||
|
export interface InboundMessage {
|
||||||
|
messageId: string;
|
||||||
|
meshId: string;
|
||||||
|
senderPubkey: string;
|
||||||
|
priority: Priority;
|
||||||
|
nonce: string;
|
||||||
|
ciphertext: string;
|
||||||
|
createdAt: string;
|
||||||
|
receivedAt: string;
|
||||||
|
/** Decrypted plaintext. null if decryption failed or broadcast. */
|
||||||
|
plaintext: string | null;
|
||||||
|
/** Message kind: "direct" (crypto_box), "broadcast", "channel", or "unknown". */
|
||||||
|
kind: "direct" | "broadcast" | "channel" | "unknown";
|
||||||
|
/** Optional semantic tag. */
|
||||||
|
subtype?: "reminder" | "system";
|
||||||
|
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||||
|
event?: string;
|
||||||
|
/** Structured payload for the event. */
|
||||||
|
eventData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for constructing a MeshClient. */
|
||||||
|
export interface MeshClientOptions {
|
||||||
|
/** WebSocket URL of the broker (e.g. "wss://ic.claudemesh.com/ws"). */
|
||||||
|
brokerUrl: string;
|
||||||
|
/** Mesh ID to join. */
|
||||||
|
meshId: string;
|
||||||
|
/** Member ID within the mesh. */
|
||||||
|
memberId: string;
|
||||||
|
/** Ed25519 public key (hex). Used for signing the hello handshake. */
|
||||||
|
pubkey: string;
|
||||||
|
/** Ed25519 secret key (hex). Used for signing and encryption. */
|
||||||
|
secretKey: string;
|
||||||
|
/** Display name visible to other peers. */
|
||||||
|
displayName?: string;
|
||||||
|
/** Peer type: "ai", "human", or "connector". Defaults to "connector". */
|
||||||
|
peerType?: "ai" | "human" | "connector";
|
||||||
|
/** Channel identifier (e.g. "claude-code", "custom"). */
|
||||||
|
channel?: string;
|
||||||
|
/** Enable debug logging to stderr. */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
13
packages/sdk/tsconfig.json
Normal file
13
packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user