feat: Sprint 8 — TypeScript SDK with WASM crypto and browser demo
- WASM crypto bundle (175KB): 13 wasm_bindgen functions wrapping quicproquo-core's Ed25519 identity, X25519+ML-KEM-768 hybrid KEM, safety numbers, sealed sender, and message padding - @quicproquo/client TypeScript SDK: QpqClient class with connect, health, resolveUser, createChannel, send/sendWithTTL, receive, deleteAccount; WebSocket transport with request/response correlation and reconnection; ergonomic crypto.ts wrapper over WASM functions - Browser demo: vanilla HTML page with interactive crypto operations (identity gen, safety numbers, sign/verify, hybrid encrypt/decrypt, sealed sender, padding) and chat UI for server connectivity - Offline mode: crypto operations work without server connection TypeScript strict mode, 0 errors. WASM bundle size optimized (lto + opt-level=s).
This commit is contained in:
258
sdks/typescript/src/client.ts
Normal file
258
sdks/typescript/src/client.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* High-level quicproquo client.
|
||||
*
|
||||
* Combines the WASM crypto layer with a WebSocket transport to provide
|
||||
* a type-safe API for interacting with a quicproquo server.
|
||||
*
|
||||
* The crypto helpers work standalone (no server connection required).
|
||||
* Server RPC methods require a WebSocket bridge proxy since the native
|
||||
* server speaks Cap'n Proto over QUIC/TCP + Noise_XX.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConnectOptions,
|
||||
Message,
|
||||
ChannelResult,
|
||||
Identity,
|
||||
HybridKeypair,
|
||||
TransportEvent,
|
||||
} from "./types.js";
|
||||
import type { Transport } from "./transport.js";
|
||||
import { WebSocketTransport } from "./transport.js";
|
||||
import * as crypto from "./crypto.js";
|
||||
|
||||
export class QpqClient {
|
||||
private transport: Transport | null;
|
||||
private token: Uint8Array | null = null;
|
||||
|
||||
private constructor(transport: Transport | null) {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a quicproquo server via WebSocket bridge.
|
||||
*
|
||||
* NOTE: The server must expose a WebSocket-to-capnp bridge endpoint.
|
||||
* See the project documentation for bridge proxy setup.
|
||||
*/
|
||||
static async connect(opts: ConnectOptions): Promise<QpqClient> {
|
||||
await crypto.initCrypto();
|
||||
const transport = await WebSocketTransport.connect(opts);
|
||||
return new QpqClient(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client for offline/crypto-only usage (no server connection).
|
||||
* All crypto helper methods will work; RPC methods will throw.
|
||||
*/
|
||||
static async offline(): Promise<QpqClient> {
|
||||
await crypto.initCrypto();
|
||||
return new QpqClient(null);
|
||||
}
|
||||
|
||||
/** Register a handler for transport-level events. */
|
||||
on(handler: (event: TransportEvent) => void): void {
|
||||
if (this.transport) {
|
||||
this.transport.on(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the transport is connected. */
|
||||
get connected(): boolean {
|
||||
return this.transport?.connected ?? false;
|
||||
}
|
||||
|
||||
/** Set a session token for authenticated RPC calls. */
|
||||
setSessionToken(token: Uint8Array): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RPC methods (require server connection via WebSocket bridge)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Health check. */
|
||||
async health(): Promise<string> {
|
||||
const resp = await this.rpc("health", {});
|
||||
return resp.result as string;
|
||||
}
|
||||
|
||||
/** Resolve a username to its public key. */
|
||||
async resolveUser(username: string): Promise<Uint8Array> {
|
||||
const resp = await this.rpc("resolveUser", { username });
|
||||
return base64ToBytes(resp.result as string);
|
||||
}
|
||||
|
||||
/** Create or join a channel with a peer. */
|
||||
async createChannel(peerKey: Uint8Array): Promise<ChannelResult> {
|
||||
const resp = await this.rpc("createChannel", {
|
||||
peerKey: bytesToBase64(peerKey),
|
||||
});
|
||||
const r = resp.result as { channelId: string; wasNew: boolean };
|
||||
return {
|
||||
channelId: base64ToBytes(r.channelId),
|
||||
wasNew: r.wasNew,
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a payload to a recipient. Returns the message sequence number. */
|
||||
async send(recipientKey: Uint8Array, payload: Uint8Array): Promise<number> {
|
||||
const resp = await this.rpc("send", {
|
||||
recipientKey: bytesToBase64(recipientKey),
|
||||
payload: bytesToBase64(payload),
|
||||
});
|
||||
return resp.result as number;
|
||||
}
|
||||
|
||||
/** Send a payload with a time-to-live in seconds. */
|
||||
async sendWithTTL(
|
||||
recipientKey: Uint8Array,
|
||||
payload: Uint8Array,
|
||||
ttlSecs: number,
|
||||
): Promise<number> {
|
||||
const resp = await this.rpc("send", {
|
||||
recipientKey: bytesToBase64(recipientKey),
|
||||
payload: bytesToBase64(payload),
|
||||
ttlSecs,
|
||||
});
|
||||
return resp.result as number;
|
||||
}
|
||||
|
||||
/** Receive pending messages for a recipient key. */
|
||||
async receive(recipientKey: Uint8Array): Promise<Message[]> {
|
||||
const resp = await this.rpc("receive", {
|
||||
recipientKey: bytesToBase64(recipientKey),
|
||||
});
|
||||
const raw = resp.result as Array<{ seq: number; data: string }>;
|
||||
return raw.map((m) => ({
|
||||
seq: m.seq,
|
||||
data: base64ToBytes(m.data),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete the authenticated account. */
|
||||
async deleteAccount(): Promise<void> {
|
||||
await this.rpc("deleteAccount", {});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Crypto helpers (work offline, delegate to WASM)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Generate a fresh Ed25519 identity. */
|
||||
generateIdentity(): Identity {
|
||||
return crypto.generateIdentity();
|
||||
}
|
||||
|
||||
/** Derive public key from an Ed25519 seed. */
|
||||
identityPublicKey(seed: Uint8Array): Uint8Array {
|
||||
return crypto.identityPublicKey(seed);
|
||||
}
|
||||
|
||||
/** Sign a message with an Ed25519 seed. */
|
||||
sign(seed: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
return crypto.sign(seed, message);
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature. */
|
||||
verify(
|
||||
publicKey: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): boolean {
|
||||
return crypto.verify(publicKey, message, signature);
|
||||
}
|
||||
|
||||
/** Generate a hybrid (X25519 + ML-KEM-768) keypair. */
|
||||
hybridGenerateKeypair(): HybridKeypair {
|
||||
return crypto.hybridGenerateKeypair();
|
||||
}
|
||||
|
||||
/** Encrypt to a hybrid public key. */
|
||||
hybridEncrypt(publicKey: Uint8Array, plaintext: Uint8Array): Uint8Array {
|
||||
return crypto.hybridEncrypt(publicKey, plaintext);
|
||||
}
|
||||
|
||||
/** Decrypt a hybrid envelope. */
|
||||
hybridDecrypt(keypairBlob: Uint8Array, envelope: Uint8Array): Uint8Array {
|
||||
return crypto.hybridDecrypt(keypairBlob, envelope);
|
||||
}
|
||||
|
||||
/** Compute a 60-digit safety number from two public keys. */
|
||||
computeSafetyNumber(keyA: Uint8Array, keyB: Uint8Array): string {
|
||||
return crypto.computeSafetyNumber(keyA, keyB);
|
||||
}
|
||||
|
||||
/** Seal a payload in a sealed sender envelope. */
|
||||
seal(seed: Uint8Array, payload: Uint8Array): Uint8Array {
|
||||
return crypto.seal(seed, payload);
|
||||
}
|
||||
|
||||
/** Unseal a sealed sender envelope. */
|
||||
unseal(envelope: Uint8Array): Uint8Array {
|
||||
return crypto.unseal(envelope);
|
||||
}
|
||||
|
||||
/** Pad a message to a fixed bucket size. */
|
||||
padMessage(data: Uint8Array): Uint8Array {
|
||||
return crypto.padMessage(data);
|
||||
}
|
||||
|
||||
/** Remove padding from a message. */
|
||||
unpadMessage(data: Uint8Array): Uint8Array {
|
||||
return crypto.unpadMessage(data);
|
||||
}
|
||||
|
||||
/** Gracefully close the connection. */
|
||||
close(): void {
|
||||
if (this.transport) {
|
||||
this.transport.close();
|
||||
this.transport = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async rpc(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<{ ok: true; result: unknown }> {
|
||||
if (!this.transport) {
|
||||
throw new Error(
|
||||
`Cannot call RPC method "${method}": no server connection. ` +
|
||||
"Use QpqClient.connect() instead of QpqClient.offline().",
|
||||
);
|
||||
}
|
||||
if (this.token) {
|
||||
params.token = bytesToBase64(this.token);
|
||||
}
|
||||
const resp = await this.transport.call(method, params);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`RPC error in ${method}: ${resp.error ?? "unknown"}`);
|
||||
}
|
||||
return resp as { ok: true; result: unknown };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 helpers (browser-compatible, no Node.js Buffer dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
135
sdks/typescript/src/crypto.ts
Normal file
135
sdks/typescript/src/crypto.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Ergonomic TypeScript wrapper around the qpq WASM crypto primitives.
|
||||
*
|
||||
* Call `initCrypto()` once before using any other function.
|
||||
*/
|
||||
|
||||
import init, * as wasm from "../pkg/qpq_wasm_crypto.js";
|
||||
import type { Identity, HybridKeypair } from "./types.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/** Initialize the WASM module. Safe to call multiple times. */
|
||||
export async function initCrypto(): Promise<void> {
|
||||
if (!initialized) {
|
||||
await init();
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the WASM module has been initialized. */
|
||||
export function isCryptoReady(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a fresh Ed25519 identity (32-byte seed + 32-byte public key). */
|
||||
export function generateIdentity(): Identity {
|
||||
const seed = wasm.generate_identity();
|
||||
const publicKey = wasm.identity_public_key(seed);
|
||||
return { seed, publicKey };
|
||||
}
|
||||
|
||||
/** Derive the 32-byte Ed25519 public key from a 32-byte seed. */
|
||||
export function identityPublicKey(seed: Uint8Array): Uint8Array {
|
||||
return wasm.identity_public_key(seed);
|
||||
}
|
||||
|
||||
/** Sign a message with an Ed25519 seed. Returns the 64-byte signature. */
|
||||
export function sign(seed: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
return wasm.sign(seed, message);
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature. Returns true if valid. */
|
||||
export function verify(
|
||||
publicKey: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): boolean {
|
||||
return wasm.verify(publicKey, message, signature);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hybrid KEM (X25519 + ML-KEM-768)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generate a hybrid keypair. Private: 2432 bytes, public: 1216 bytes. */
|
||||
export function hybridGenerateKeypair(): HybridKeypair {
|
||||
const blob = wasm.hybrid_generate_keypair();
|
||||
const publicKey = wasm.hybrid_public_key(blob);
|
||||
return { blob, publicKey };
|
||||
}
|
||||
|
||||
/** Extract the 1216-byte public key from a hybrid keypair blob. */
|
||||
export function hybridPublicKey(keypairBlob: Uint8Array): Uint8Array {
|
||||
return wasm.hybrid_public_key(keypairBlob);
|
||||
}
|
||||
|
||||
/** Encrypt plaintext to a hybrid public key. */
|
||||
export function hybridEncrypt(
|
||||
publicKey: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
): Uint8Array {
|
||||
return wasm.hybrid_encrypt(publicKey, plaintext);
|
||||
}
|
||||
|
||||
/** Decrypt a hybrid envelope using the keypair blob. */
|
||||
export function hybridDecrypt(
|
||||
keypairBlob: Uint8Array,
|
||||
envelope: Uint8Array,
|
||||
): Uint8Array {
|
||||
return wasm.hybrid_decrypt(keypairBlob, envelope);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safety number
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute a 60-digit safety number from two 32-byte Ed25519 public keys.
|
||||
* Returns 12 space-separated 5-digit groups.
|
||||
* Symmetric: computeSafetyNumber(a, b) === computeSafetyNumber(b, a).
|
||||
*/
|
||||
export function computeSafetyNumber(
|
||||
keyA: Uint8Array,
|
||||
keyB: Uint8Array,
|
||||
): string {
|
||||
return wasm.compute_safety_number(keyA, keyB);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sealed sender
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap a payload in a sealed sender envelope.
|
||||
* (magic || sender public key || Ed25519 signature || payload)
|
||||
*/
|
||||
export function seal(seed: Uint8Array, payload: Uint8Array): Uint8Array {
|
||||
return wasm.seal(seed, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unseal a sealed sender envelope.
|
||||
* Returns sender_public_key(32) || inner_payload.
|
||||
*/
|
||||
export function unseal(envelope: Uint8Array): Uint8Array {
|
||||
return wasm.unseal(envelope);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message padding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Pad a message to a fixed bucket size (256, 1024, 4096, or 16384 bytes). */
|
||||
export function padMessage(data: Uint8Array): Uint8Array {
|
||||
return wasm.pad_message(data);
|
||||
}
|
||||
|
||||
/** Remove padding and recover the original message. */
|
||||
export function unpadMessage(data: Uint8Array): Uint8Array {
|
||||
return wasm.unpad_message(data);
|
||||
}
|
||||
46
sdks/typescript/src/index.ts
Normal file
46
sdks/typescript/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @quicproquo/client -- TypeScript SDK for quicproquo E2E encrypted messenger.
|
||||
*
|
||||
* The SDK provides:
|
||||
* - WASM-powered crypto (Ed25519, hybrid X25519+ML-KEM-768, sealed sender)
|
||||
* - A high-level QpqClient class for server RPC via WebSocket bridge
|
||||
* - Standalone crypto operations that work without a server connection
|
||||
*/
|
||||
|
||||
// Client
|
||||
export { QpqClient } from "./client.js";
|
||||
|
||||
// Transport
|
||||
export { WebSocketTransport } from "./transport.js";
|
||||
export type { Transport } from "./transport.js";
|
||||
|
||||
// Crypto (direct access for advanced usage)
|
||||
export {
|
||||
initCrypto,
|
||||
isCryptoReady,
|
||||
generateIdentity,
|
||||
identityPublicKey,
|
||||
sign,
|
||||
verify,
|
||||
hybridGenerateKeypair,
|
||||
hybridPublicKey,
|
||||
hybridEncrypt,
|
||||
hybridDecrypt,
|
||||
computeSafetyNumber,
|
||||
seal,
|
||||
unseal,
|
||||
padMessage,
|
||||
unpadMessage,
|
||||
} from "./crypto.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ConnectOptions,
|
||||
Message,
|
||||
ChannelResult,
|
||||
Identity,
|
||||
HybridKeypair,
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
TransportEvent,
|
||||
} from "./types.js";
|
||||
206
sdks/typescript/src/transport.ts
Normal file
206
sdks/typescript/src/transport.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Transport abstraction for communicating with a quicproquo server.
|
||||
*
|
||||
* The native qpq server speaks Cap'n Proto RPC over QUIC/TCP + Noise_XX.
|
||||
* Browsers cannot open raw TCP sockets, so this SDK assumes a WebSocket
|
||||
* bridge proxy that translates JSON-over-WebSocket to capnp RPC.
|
||||
*
|
||||
* The transport layer handles:
|
||||
* - Binary/text message framing over WebSocket
|
||||
* - Request/response correlation via incrementing IDs
|
||||
* - Automatic reconnection with exponential back-off
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConnectOptions,
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
TransportEvent,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Transport {
|
||||
/** Send an RPC request and wait for the correlated response. */
|
||||
call(method: string, params: Record<string, unknown>): Promise<RpcResponse>;
|
||||
/** Register a listener for transport-level events. */
|
||||
on(handler: (event: TransportEvent) => void): void;
|
||||
/** True when the underlying connection is open. */
|
||||
readonly connected: boolean;
|
||||
/** Gracefully close the transport. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocketTransport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT = 5_000;
|
||||
const DEFAULT_REQUEST_TIMEOUT = 10_000;
|
||||
const MAX_RECONNECT_DELAY = 30_000;
|
||||
|
||||
interface PendingCall {
|
||||
resolve: (r: RpcResponse) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class WebSocketTransport implements Transport {
|
||||
private ws: WebSocket | null = null;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, PendingCall>();
|
||||
private handlers: Array<(event: TransportEvent) => void> = [];
|
||||
private _connected = false;
|
||||
private reconnectDelay = 1_000;
|
||||
private shouldReconnect = true;
|
||||
private readonly opts: ConnectOptions;
|
||||
|
||||
private constructor(opts: ConnectOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
/** Create and connect a WebSocketTransport. Resolves when the socket opens. */
|
||||
static connect(opts: ConnectOptions): Promise<WebSocketTransport> {
|
||||
const transport = new WebSocketTransport(opts);
|
||||
return transport.doConnect();
|
||||
}
|
||||
|
||||
on(handler: (event: TransportEvent) => void): void {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
async call(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<RpcResponse> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error("Transport is not connected");
|
||||
}
|
||||
|
||||
const id = this.nextId++;
|
||||
const request: RpcRequest = { id, method, params };
|
||||
|
||||
return new Promise<RpcResponse>((resolve, reject) => {
|
||||
const timeout = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`RPC timeout for ${method} (id=${id})`));
|
||||
}, timeout);
|
||||
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws!.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.shouldReconnect = false;
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, "client closing");
|
||||
this.ws = null;
|
||||
}
|
||||
this.rejectAll("Transport closed");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private doConnect(): Promise<WebSocketTransport> {
|
||||
return new Promise<WebSocketTransport>((resolve, reject) => {
|
||||
const timeout = this.opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`));
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
const ws = new WebSocket(this.opts.addr);
|
||||
ws.binaryType = "arraybuffer";
|
||||
this.ws = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
this._connected = true;
|
||||
this.reconnectDelay = 1_000;
|
||||
this.emit({ type: "open" });
|
||||
resolve(this);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
clearTimeout(timer);
|
||||
this._connected = false;
|
||||
this.emit({ type: "close", code: ev.code, reason: ev.reason });
|
||||
this.rejectAll(`WebSocket closed: ${ev.code} ${ev.reason}`);
|
||||
this.maybeReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
const err = new Error("WebSocket error");
|
||||
this.emit({ type: "error", error: err });
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
this.handleMessage(ev.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
// Binary frames are forwarded as-is
|
||||
if (raw instanceof ArrayBuffer) {
|
||||
this.emit({ type: "message", data: new Uint8Array(raw) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Text frames are parsed as JSON RPC responses
|
||||
if (typeof raw === "string") {
|
||||
let resp: RpcResponse;
|
||||
try {
|
||||
resp = JSON.parse(raw) as RpcResponse;
|
||||
} catch {
|
||||
return; // ignore malformed
|
||||
}
|
||||
const pending = this.pending.get(resp.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(resp.id);
|
||||
pending.resolve(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const h of this.handlers) {
|
||||
h(event);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAll(reason: string): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(reason));
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeReconnect(): void {
|
||||
if (!this.shouldReconnect) return;
|
||||
const delay = Math.min(this.reconnectDelay, MAX_RECONNECT_DELAY);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
setTimeout(() => {
|
||||
if (this.shouldReconnect) {
|
||||
this.doConnect().catch(() => {
|
||||
/* reconnect attempt failed, will retry via close handler */
|
||||
});
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
58
sdks/typescript/src/types.ts
Normal file
58
sdks/typescript/src/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** Options for connecting to a quicproquo server. */
|
||||
export interface ConnectOptions {
|
||||
/** WebSocket URL, e.g. ws://host:port or wss://host:port */
|
||||
addr: string;
|
||||
/** Skip TLS certificate verification (development only). */
|
||||
insecureSkipVerify?: boolean;
|
||||
/** Timeout in milliseconds for the initial connection. Default: 5000. */
|
||||
connectTimeoutMs?: number;
|
||||
/** Timeout in milliseconds for RPC request/response. Default: 10000. */
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A message received from the server. */
|
||||
export interface Message {
|
||||
seq: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/** Result of creating or joining a channel. */
|
||||
export interface ChannelResult {
|
||||
channelId: Uint8Array;
|
||||
wasNew: boolean;
|
||||
}
|
||||
|
||||
/** An Ed25519 identity (seed + derived public key). */
|
||||
export interface Identity {
|
||||
seed: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** A hybrid (X25519 + ML-KEM-768) keypair. */
|
||||
export interface HybridKeypair {
|
||||
/** Full keypair blob: private(2432) || public(1216). */
|
||||
blob: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** Internal RPC request envelope. */
|
||||
export interface RpcRequest {
|
||||
id: number;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Internal RPC response envelope. */
|
||||
export interface RpcResponse {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Events emitted by the transport layer. */
|
||||
export type TransportEvent =
|
||||
| { type: "open" }
|
||||
| { type: "close"; code: number; reason: string }
|
||||
| { type: "error"; error: Error }
|
||||
| { type: "message"; data: Uint8Array };
|
||||
Reference in New Issue
Block a user