feat: add @claudemesh/sdk standalone client library

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:53:46 +01:00
parent 5563f90733
commit 7e102a235b
7 changed files with 905 additions and 0 deletions

564
packages/sdk/src/client.ts Normal file
View 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
View 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;
}
}

View 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
View 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;
}