Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
23
packages/client/CHANGELOG.md
Normal file
23
packages/client/CHANGELOG.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.1] - 2026-09-05
|
||||
|
||||
## [0.85.0] - 2026-09-04
|
||||
|
||||
## [0.84.4] - 2026-08-28
|
||||
|
||||
## [0.84.3] - 2026-08-24
|
||||
|
||||
## [0.84.2] - 2026-08-14
|
||||
|
||||
## [0.84.1] - 2026-08-07
|
||||
|
||||
## [0.84.0] - 2026-08-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Replaced `SessionSummary` with durable `SessionMetadata` for `PiClient.listSessions()` and server snapshots; runtime state is available only from acquired session snapshots ([#7708](https://github.com/earendil-works/pi/pull/7708)).
|
||||
|
||||
### Added
|
||||
|
||||
- Added the experimental transport-neutral `PiClient` and multi-session `PiSessionHandle` APIs with structured `PiServerError` responses.
|
||||
72
packages/client/README.md
Normal file
72
packages/client/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @earendil-works/pi-client
|
||||
|
||||
Transport-neutral client for the experimental Pi service protocol.
|
||||
|
||||
```ts
|
||||
import { Client, type ByteTransportFactory } from "@earendil-works/pi-client";
|
||||
|
||||
const transportFactory: ByteTransportFactory = async (handlers) => {
|
||||
// Connect using WebSocket, Unix socket, or another ordered byte transport.
|
||||
return {
|
||||
async send(chunk) {
|
||||
// Deliver bytes in invocation order and honor backpressure.
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
};
|
||||
|
||||
const client = await Client.connect({
|
||||
serverId: "01234567-89ab-4def-8123-456789abcdef",
|
||||
transportFactory,
|
||||
});
|
||||
const result = await client.request(
|
||||
{ serverId: client.hello.serverId },
|
||||
{ serviceId: "example.service", member: "read", args: [] },
|
||||
);
|
||||
```
|
||||
|
||||
The client verifies that the physical endpoint reports the expected logical `serverId`. Server-wide requests carry that ID, and every Session request carries the full live target `{ serverId, sessionId, attachmentId }`. The combined durable address prevents cross-server or cross-session misrouting; the server-generated attachment ID rejects delayed frames after switching or reattaching.
|
||||
|
||||
Typed server and Session APIs are provided by Chord service bindings owned by the application. `createClientServiceTransport()` adapts a lazily resolved server or Session route to a Chord transport; `request()` and `subscribeService()` remain its low-level primitives. The client uses Chord's service-control parsers and per-subscription state decoder; `pi-protocol` only validates the routed envelope and strict-JSON boundary. A service subscription returns a complete provider snapshot; the binding installs it and then calls `start()` to release updates buffered during hydration. `Client` applies ordered out-of-band attachment changes but deliberately does not construct typed service proxies or interpret application contracts.
|
||||
|
||||
Application observation APIs such as the coding agent's `Transcript` are ordinary Chord services. The client does not interpret their snapshots or updates.
|
||||
|
||||
On disconnect or disposal, pending requests reject locally, but accepted work may still complete remotely before the attachment is released. The client clears its live attachment route. It never reconnects or replays requests automatically. After disconnection, call `reconnect()`, attach through the application's management service again, and explicitly repeat only operations known to be safe.
|
||||
|
||||
The experimental local coordinator only provides a stable endpoint and relays traffic. Replaceable server processes own Session and worker lifecycle outside the public client protocol.
|
||||
|
||||
Call transport handlers as follows:
|
||||
|
||||
- `handlers.onData(chunk)` for inbound bytes;
|
||||
- `handlers.onClose()` for an orderly terminal close;
|
||||
- `handlers.onError(error)` for transport failures.
|
||||
|
||||
A transport factory creates a fresh authenticated connection for each attempt. Requests are correlated by ID, and server failures are exposed as `ServerError`.
|
||||
|
||||
## Unix-domain sockets
|
||||
|
||||
Node.js and Bun consumers can use the separate Unix transport:
|
||||
|
||||
```ts
|
||||
import { Client } from "@earendil-works/pi-client";
|
||||
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
|
||||
|
||||
const client = new Client({
|
||||
serverId: "01234567-89ab-4def-8123-456789abcdef",
|
||||
transportFactory: createUnixTransportFactory({ path: "/tmp/pi.sock" }),
|
||||
});
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
Unix discovery scans an explicit physical-route directory, derives each expected server ID from its filename, and verifies it through the existing handshake:
|
||||
|
||||
```ts
|
||||
import { discoverUnixServers } from "@earendil-works/pi-client/unix";
|
||||
|
||||
const routes = await discoverUnixServers({ directory: "/run/user/1000/pi" });
|
||||
// [{ serverId: "...", path: "/run/user/1000/pi/<serverId>.sock" }]
|
||||
```
|
||||
|
||||
Malformed entries, non-sockets, stale or unresponsive endpoints, and server-ID mismatches are ignored. Discovery is read-only and probes at most 16 sockets concurrently. Unexpected filesystem and socket errors reject discovery. Pass `timeoutMs` to override the default probe timeout.
|
||||
|
||||
`ClientOptions.maxFrameLength` bounds protocol payloads. `maxPendingBytes` bounds queued Unix transport output. Configure matching limits on both peers.
|
||||
57
packages/client/package.json
Normal file
57
packages/client/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-client",
|
||||
"version": "0.85.1",
|
||||
"description": "Transport-neutral client for remote pi sessions over framed CBOR bytes",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./unix": {
|
||||
"types": "./dist/unix.d.ts",
|
||||
"import": "./dist/unix.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -p tsconfig.test.json",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"pi",
|
||||
"client",
|
||||
"protocol",
|
||||
"cbor",
|
||||
"binary"
|
||||
],
|
||||
"author": "Earendil Works",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/client"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.85.1",
|
||||
"@earendil-works/pi-protocol": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
479
packages/client/src/client.ts
Normal file
479
packages/client/src/client.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
type JsonValue,
|
||||
parseServiceCall,
|
||||
parseServiceCatalogue,
|
||||
parseWireServiceProviderUpdate,
|
||||
parseWireServiceSubscriptionSnapshot,
|
||||
type RemoteServiceTransport,
|
||||
type ServiceCall,
|
||||
type ServiceCatalogueEntry,
|
||||
type ServiceMode,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceStateDecoder,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
} from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AttachmentEnvelope,
|
||||
encodeClientMessage,
|
||||
isServerId,
|
||||
ProtocolValidationError,
|
||||
type ResponseEnvelope,
|
||||
type RpcTarget,
|
||||
type ServerHello,
|
||||
type ServiceEventEnvelope,
|
||||
type SessionTarget,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { Connection } from "./connection.ts";
|
||||
import { ClientDisposedError, DisconnectedError, ServerError, toError } from "./errors.ts";
|
||||
import { createPromiseResolvers } from "./promise.ts";
|
||||
import type {
|
||||
AttachmentChangeListener,
|
||||
ClientOptions,
|
||||
ConnectionState,
|
||||
ConnectionStateChange,
|
||||
ServiceSubscription,
|
||||
Unsubscribe,
|
||||
} from "./types.ts";
|
||||
|
||||
type ServiceResult = JsonValue | undefined;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve(result: ServiceResult): void;
|
||||
reject(error: Error): void;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
interface ActiveServiceListener {
|
||||
readonly target: RpcTarget;
|
||||
readonly listener: (update: ServiceProviderUpdate) => void | Promise<void>;
|
||||
readonly decoder: ServiceStateDecoder;
|
||||
readonly queuedWireUpdates: JsonValue[];
|
||||
readonly queued: ServiceProviderUpdate[];
|
||||
deliveryTail: Promise<void>;
|
||||
hydrated: boolean;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export class Client {
|
||||
readonly #options: ClientOptions;
|
||||
readonly #connection: Connection;
|
||||
readonly #pendingRequests = new Map<string, PendingRequest>();
|
||||
readonly #connectionStateListeners = new Set<(change: ConnectionStateChange) => void>();
|
||||
readonly #attachmentListeners = new Set<AttachmentChangeListener>();
|
||||
readonly #serviceListeners = new Map<string, ActiveServiceListener>();
|
||||
#requestSequence = 0;
|
||||
#serviceSubscriptionSequence = 0;
|
||||
#hello: ServerHello | undefined;
|
||||
#attachment: SessionTarget | undefined;
|
||||
#disposed = false;
|
||||
#disposePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
if (!isServerId(options.serverId)) {
|
||||
throw new TypeError("serverId must be a canonical lowercase UUIDv4");
|
||||
}
|
||||
this.#options = options;
|
||||
this.#connection = new Connection({
|
||||
transportFactory: options.transportFactory,
|
||||
serverId: options.serverId,
|
||||
maxFrameLength: options.maxFrameLength,
|
||||
onHandshake: (hello) => {
|
||||
this.#hello = hello;
|
||||
},
|
||||
onMessage: (message) => this.#handleMessage(message),
|
||||
onStateChange: (change) => this.#handleConnectionStateChange(change),
|
||||
});
|
||||
}
|
||||
|
||||
get disposed(): boolean {
|
||||
return this.#disposed;
|
||||
}
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this.#connection.state;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.#connection.state === "connected";
|
||||
}
|
||||
|
||||
get serverId(): string {
|
||||
return this.#options.serverId;
|
||||
}
|
||||
|
||||
get hello(): ServerHello | undefined {
|
||||
return this.#hello;
|
||||
}
|
||||
|
||||
get attachment(): SessionTarget | undefined {
|
||||
return this.#attachment;
|
||||
}
|
||||
|
||||
static async connect(options: ClientOptions): Promise<Client> {
|
||||
const client = new Client(options);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
connect(): Promise<ServerHello> {
|
||||
if (this.#disposed) return Promise.reject(new ClientDisposedError());
|
||||
this.#hello = undefined;
|
||||
return this.#connection.connect();
|
||||
}
|
||||
|
||||
reconnect(): Promise<ServerHello> {
|
||||
return this.connect();
|
||||
}
|
||||
|
||||
disconnect(reason = "Client disconnected"): void {
|
||||
this.#connection.disconnect(reason);
|
||||
}
|
||||
|
||||
onConnectionStateChange(listener: (change: ConnectionStateChange) => void): Unsubscribe {
|
||||
this.#assertNotDisposed();
|
||||
this.#connectionStateListeners.add(listener);
|
||||
return () => this.#connectionStateListeners.delete(listener);
|
||||
}
|
||||
|
||||
onAttachmentChange(listener: AttachmentChangeListener): Unsubscribe {
|
||||
this.#assertNotDisposed();
|
||||
this.#attachmentListeners.add(listener);
|
||||
return () => this.#attachmentListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Invoke one low-level protocol call against an explicit routed target. */
|
||||
request(target: RpcTarget, call: ServiceCall, signal?: AbortSignal): Promise<ServiceResult> {
|
||||
return this.#request(target, call, signal);
|
||||
}
|
||||
|
||||
async serviceCatalogue(target: RpcTarget, signal?: AbortSignal): Promise<readonly ServiceCatalogueEntry[]> {
|
||||
const result = await this.#request(target, createServiceCatalogueCall(), signal);
|
||||
try {
|
||||
return parseServiceCatalogue(result);
|
||||
} catch (error) {
|
||||
const validationError = new ProtocolValidationError(
|
||||
error instanceof Error ? error.message : "Invalid service catalogue",
|
||||
);
|
||||
this.#connection.fail(validationError);
|
||||
throw validationError;
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeService(
|
||||
target: RpcTarget,
|
||||
serviceId: string,
|
||||
mode: ServiceMode,
|
||||
listener: (update: ServiceProviderUpdate) => void | Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServiceSubscription> {
|
||||
const subscriptionId = `service-${++this.#serviceSubscriptionSequence}`;
|
||||
const active: ActiveServiceListener = {
|
||||
target,
|
||||
listener,
|
||||
decoder: createServiceStateDecoder(),
|
||||
queuedWireUpdates: [],
|
||||
queued: [],
|
||||
deliveryTail: Promise.resolve(),
|
||||
hydrated: false,
|
||||
ready: false,
|
||||
};
|
||||
this.#serviceListeners.set(subscriptionId, active);
|
||||
let snapshot: ServiceSubscriptionSnapshot;
|
||||
try {
|
||||
snapshot = await this.#request(
|
||||
target,
|
||||
createServiceSubscribeCall(subscriptionId, serviceId, mode),
|
||||
signal,
|
||||
(result) => {
|
||||
const decoded = active.decoder.decodeSnapshot(parseWireServiceSubscriptionSnapshot(result));
|
||||
active.hydrated = true;
|
||||
for (const update of active.queuedWireUpdates.splice(0)) {
|
||||
active.queued.push(active.decoder.decodeUpdate(parseWireServiceProviderUpdate(update)));
|
||||
}
|
||||
return decoded;
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
|
||||
throw error;
|
||||
}
|
||||
if (this.#serviceListeners.get(subscriptionId) !== active) throw new DisconnectedError();
|
||||
let disposed = false;
|
||||
return {
|
||||
id: subscriptionId,
|
||||
target,
|
||||
snapshot,
|
||||
start: () => {
|
||||
if (disposed || active.ready) return;
|
||||
active.ready = true;
|
||||
for (const update of active.queued.splice(0)) this.#deliverServiceUpdate(active, update);
|
||||
},
|
||||
dispose: async () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (this.#serviceListeners.get(subscriptionId) === active) this.#serviceListeners.delete(subscriptionId);
|
||||
try {
|
||||
if (this.connected && this.#targetIsCurrent(target)) {
|
||||
await this.#request(target, createServiceUnsubscribeCall(subscriptionId));
|
||||
}
|
||||
await active.deliveryTail;
|
||||
} finally {
|
||||
active.queuedWireUpdates.length = 0;
|
||||
active.queued.length = 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#request<T = ServiceResult>(
|
||||
target: RpcTarget,
|
||||
call: ServiceCall,
|
||||
signal?: AbortSignal,
|
||||
transform?: (result: ServiceResult) => T,
|
||||
): Promise<T> {
|
||||
if (this.#disposed) return Promise.reject(new ClientDisposedError());
|
||||
if (!this.connected) return Promise.reject(new DisconnectedError());
|
||||
if (signal?.aborted) return Promise.reject(abortError(signal));
|
||||
const id = `request-${++this.#requestSequence}`;
|
||||
const { promise, resolve, reject } = createPromiseResolvers<T>();
|
||||
let sent = false;
|
||||
let aborted = false;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const sendCancel = (): void => {
|
||||
if (!sent || !this.connected) return;
|
||||
try {
|
||||
this.#connection.send(
|
||||
encodeClientMessage({ type: "cancel", id, target }, { maxFrameLength: this.#connection.maxFrameLength }),
|
||||
);
|
||||
} catch (error) {
|
||||
this.#connection.fail(toError(error));
|
||||
}
|
||||
};
|
||||
if (signal !== undefined) {
|
||||
onAbort = () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
reject(abortError(signal));
|
||||
sendCancel();
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
this.#pendingRequests.set(id, {
|
||||
resolve: (result) => {
|
||||
try {
|
||||
resolve(transform === undefined ? (result as T) : transform(result));
|
||||
} catch (error) {
|
||||
const validationError = new ProtocolValidationError(
|
||||
error instanceof Error ? error.message : "Invalid service operation stream",
|
||||
);
|
||||
this.#connection.fail(validationError);
|
||||
reject(validationError);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
cleanup: () => {
|
||||
if (signal !== undefined && onAbort !== undefined) signal.removeEventListener("abort", onAbort);
|
||||
},
|
||||
});
|
||||
let frame: Uint8Array;
|
||||
try {
|
||||
frame = encodeClientMessage(
|
||||
{ type: "request", id, target, call: parseServiceCall(call) as unknown as JsonValue },
|
||||
{ maxFrameLength: this.#connection.maxFrameLength },
|
||||
);
|
||||
} catch (error) {
|
||||
this.#takePendingRequest(id)?.reject(toError(error));
|
||||
return promise;
|
||||
}
|
||||
this.#connection.send(frame);
|
||||
sent = true;
|
||||
if (aborted) sendCancel();
|
||||
return promise;
|
||||
}
|
||||
|
||||
#handleMessage(message: ResponseEnvelope | ServiceEventEnvelope | AttachmentEnvelope): void {
|
||||
if (message.type === "attachment") {
|
||||
if (message.attachment !== null && message.attachment.serverId !== this.#options.serverId) {
|
||||
this.#connection.fail(new ProtocolValidationError("Attachment update belongs to another server"));
|
||||
return;
|
||||
}
|
||||
this.#setAttachment(message.attachment ?? undefined);
|
||||
return;
|
||||
}
|
||||
if (message.type === "service_update") {
|
||||
const active = this.#serviceListeners.get(message.subscriptionId);
|
||||
if (active === undefined) return;
|
||||
if (!active.hydrated) {
|
||||
active.queuedWireUpdates.push(message.update);
|
||||
return;
|
||||
}
|
||||
let update: ServiceProviderUpdate;
|
||||
try {
|
||||
update = active.decoder.decodeUpdate(parseWireServiceProviderUpdate(message.update));
|
||||
} catch (error) {
|
||||
this.#connection.fail(
|
||||
new ProtocolValidationError(error instanceof Error ? error.message : "Invalid service operation stream"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (active.ready) this.#deliverServiceUpdate(active, update);
|
||||
else active.queued.push(update);
|
||||
return;
|
||||
}
|
||||
const pending = this.#takePendingRequest(message.id);
|
||||
if (!pending) {
|
||||
this.#connection.fail(new ProtocolValidationError("Response has no matching request"));
|
||||
return;
|
||||
}
|
||||
if (!message.ok) {
|
||||
pending.reject(new ServerError(message.error));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
|
||||
#handleConnectionStateChange(change: ConnectionStateChange): void {
|
||||
if (change.state === "disconnected") {
|
||||
this.#hello = undefined;
|
||||
this.#setAttachment(undefined);
|
||||
this.#rejectPendingRequests(change.error ?? new DisconnectedError());
|
||||
this.#serviceListeners.clear();
|
||||
}
|
||||
for (const listener of this.#connectionStateListeners) {
|
||||
try {
|
||||
listener(change);
|
||||
} catch (error) {
|
||||
this.#reportListenerError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#takePendingRequest(id: string): PendingRequest | undefined {
|
||||
const request = this.#pendingRequests.get(id);
|
||||
if (request) {
|
||||
this.#pendingRequests.delete(id);
|
||||
request.cleanup();
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
#rejectPendingRequests(error: Error): void {
|
||||
const requests = [...this.#pendingRequests.values()];
|
||||
this.#pendingRequests.clear();
|
||||
for (const request of requests) {
|
||||
request.cleanup();
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
if (this.#disposePromise) return this.#disposePromise;
|
||||
this.#disposed = true;
|
||||
this.#disposePromise = Promise.resolve();
|
||||
const error = new ClientDisposedError();
|
||||
this.#rejectPendingRequests(error);
|
||||
this.#connection.disconnect(error);
|
||||
this.#hello = undefined;
|
||||
this.#setAttachment(undefined);
|
||||
this.#connectionStateListeners.clear();
|
||||
this.#attachmentListeners.clear();
|
||||
this.#serviceListeners.clear();
|
||||
return this.#disposePromise;
|
||||
}
|
||||
|
||||
[Symbol.asyncDispose](): Promise<void> {
|
||||
return this.dispose();
|
||||
}
|
||||
|
||||
#setAttachment(attachment: SessionTarget | undefined): void {
|
||||
const previous = this.#attachment;
|
||||
if (
|
||||
previous?.serverId === attachment?.serverId &&
|
||||
previous?.sessionId === attachment?.sessionId &&
|
||||
previous?.attachmentId === attachment?.attachmentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.#attachment = attachment;
|
||||
for (const listener of this.#attachmentListeners) {
|
||||
try {
|
||||
listener(attachment);
|
||||
} catch (error) {
|
||||
this.#reportListenerError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#deliverServiceUpdate(active: ActiveServiceListener, update: ServiceProviderUpdate): void {
|
||||
active.deliveryTail = active.deliveryTail
|
||||
.then(() => active.listener(update))
|
||||
.catch((error: unknown) => this.#reportListenerError(error));
|
||||
}
|
||||
|
||||
#targetIsCurrent(target: RpcTarget): boolean {
|
||||
if (!("sessionId" in target)) return this.#hello?.serverId === target.serverId;
|
||||
const attachment = this.#attachment;
|
||||
return (
|
||||
attachment?.serverId === target.serverId &&
|
||||
attachment.sessionId === target.sessionId &&
|
||||
attachment.attachmentId === target.attachmentId
|
||||
);
|
||||
}
|
||||
|
||||
#assertNotDisposed(): void {
|
||||
if (this.#disposed) throw new ClientDisposedError();
|
||||
}
|
||||
|
||||
#reportListenerError(error: unknown): void {
|
||||
if (!this.#options.onListenerError) return;
|
||||
try {
|
||||
this.#options.onListenerError(toError(error));
|
||||
} catch {
|
||||
// Diagnostics cannot affect protocol or transport state.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapts a lazily resolved routed client target to a Chord service transport. */
|
||||
export function createClientServiceTransport(
|
||||
client: Client,
|
||||
getTarget: () => RpcTarget | undefined,
|
||||
): RemoteServiceTransport {
|
||||
const target = (): RpcTarget => {
|
||||
const resolved = getTarget();
|
||||
if (resolved === undefined) throw new Error("Remote service target is unavailable");
|
||||
return resolved;
|
||||
};
|
||||
return {
|
||||
invoke: async (call, context) => client.request(target(), call, context.abortSignal),
|
||||
async subscribe(serviceId, mode, listener, context) {
|
||||
const subscription = await client.subscribeService(
|
||||
target(),
|
||||
serviceId,
|
||||
mode,
|
||||
(update) => listener(update, BACKGROUND_CONTEXT),
|
||||
context.abortSignal,
|
||||
);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.start(),
|
||||
close: () => subscription.dispose(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const reason: unknown = signal.reason;
|
||||
return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
245
packages/client/src/connection.ts
Normal file
245
packages/client/src/connection.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
encodeClientMessage,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolValidationError,
|
||||
type ServerHello,
|
||||
type ServerMessage,
|
||||
ServerMessageDecoder,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { DisconnectedError, ServerError, toDisconnectedError, toError } from "./errors.ts";
|
||||
import { createPromiseResolvers, type PromiseResolvers } from "./promise.ts";
|
||||
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
|
||||
import type { ConnectionState, ConnectionStateChange } from "./types.ts";
|
||||
|
||||
const MAX_UINT32 = 0xffff_ffff;
|
||||
|
||||
type ActiveConnection = {
|
||||
id: number;
|
||||
decoder: ServerMessageDecoder;
|
||||
transport?: ByteTransport;
|
||||
};
|
||||
|
||||
type ConnectionLifecycle =
|
||||
| { state: "disconnected" }
|
||||
| ({ state: "connecting"; handshake: PromiseResolvers<ServerHello> } & ActiveConnection)
|
||||
| ({
|
||||
state: "connected";
|
||||
transport: ByteTransport;
|
||||
handshake: PromiseResolvers<ServerHello> | undefined;
|
||||
} & ActiveConnection);
|
||||
|
||||
interface ConnectionOptions {
|
||||
transportFactory: ByteTransportFactory;
|
||||
serverId: string;
|
||||
maxFrameLength?: number;
|
||||
onHandshake(hello: ServerHello): void;
|
||||
onMessage(message: Exclude<ServerMessage, { type: "hello" | "hello_error" }>): void;
|
||||
onStateChange(change: ConnectionStateChange): void;
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
readonly #options: ConnectionOptions;
|
||||
readonly #maxFrameLength: number;
|
||||
#lifecycle: ConnectionLifecycle = { state: "disconnected" };
|
||||
#sequence = 0;
|
||||
|
||||
constructor(options: ConnectionOptions) {
|
||||
this.#options = options;
|
||||
this.#maxFrameLength = options.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
if (
|
||||
!Number.isSafeInteger(this.#maxFrameLength) ||
|
||||
this.#maxFrameLength <= 0 ||
|
||||
this.#maxFrameLength > MAX_UINT32
|
||||
) {
|
||||
throw new TypeError(`Client maxFrameLength must be an integer between 1 and ${MAX_UINT32}`);
|
||||
}
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this.#lifecycle.state;
|
||||
}
|
||||
|
||||
get maxFrameLength(): number {
|
||||
return this.#maxFrameLength;
|
||||
}
|
||||
|
||||
connect(): Promise<ServerHello> {
|
||||
if (this.#lifecycle.state !== "disconnected") {
|
||||
return Promise.reject(new DisconnectedError(`Client is already ${this.#lifecycle.state}`));
|
||||
}
|
||||
const id = ++this.#sequence;
|
||||
const handshake = createPromiseResolvers<ServerHello>();
|
||||
this.#lifecycle = {
|
||||
state: "connecting",
|
||||
id,
|
||||
decoder: new ServerMessageDecoder({ maxFrameLength: this.#maxFrameLength }),
|
||||
handshake,
|
||||
};
|
||||
this.#options.onStateChange({ state: "connecting" });
|
||||
const handlers = {
|
||||
onData: (chunk) => this.#handleData(id, chunk),
|
||||
onClose: () => {
|
||||
if (this.#isCurrent(id)) this.#handleClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));
|
||||
},
|
||||
} satisfies ByteTransportHandlers;
|
||||
void this.#openTransport(id, handlers);
|
||||
return handshake.promise;
|
||||
}
|
||||
|
||||
disconnect(reason: string | Error = "Client disconnected"): void {
|
||||
if (this.#lifecycle.state === "disconnected") return;
|
||||
this.#failAndClose(typeof reason === "string" ? new DisconnectedError(reason) : reason);
|
||||
}
|
||||
|
||||
fail(error: Error): void {
|
||||
this.#failAndClose(error);
|
||||
}
|
||||
|
||||
send(frame: Uint8Array): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state !== "connected") throw new DisconnectedError();
|
||||
let sending: Promise<void>;
|
||||
try {
|
||||
sending = lifecycle.transport.send(frame);
|
||||
} catch (error) {
|
||||
this.#failAndClose(toDisconnectedError(error));
|
||||
return;
|
||||
}
|
||||
void sending.catch((error: unknown) => {
|
||||
const current = this.#lifecycle;
|
||||
if (current.state !== "disconnected" && current.transport === lifecycle.transport) {
|
||||
this.#failAndClose(toDisconnectedError(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #openTransport(id: number, handlers: ByteTransportHandlers): Promise<void> {
|
||||
let transport: ByteTransport;
|
||||
try {
|
||||
transport = await this.#options.transportFactory(handlers);
|
||||
} catch (error) {
|
||||
if (this.#isCurrent(id)) this.#fail(toDisconnectedError(error));
|
||||
return;
|
||||
}
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state !== "connecting" || lifecycle.id !== id) {
|
||||
transport.close();
|
||||
return;
|
||||
}
|
||||
this.#lifecycle = { ...lifecycle, transport };
|
||||
try {
|
||||
await transport.send(
|
||||
encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }, { maxFrameLength: this.#maxFrameLength }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.#isCurrent(id)) this.#failAndClose(toDisconnectedError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#handleData(id: number, chunk: Uint8Array): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state === "disconnected" || lifecycle.id !== id) return;
|
||||
if (lifecycle.state === "connecting" && !lifecycle.transport) {
|
||||
this.#failAndClose(new ProtocolValidationError("Received server data before the client hello was sent"));
|
||||
return;
|
||||
}
|
||||
let messages: ServerMessage[];
|
||||
try {
|
||||
messages = lifecycle.decoder.push(chunk);
|
||||
} catch (error) {
|
||||
this.#failAndClose(toError(error));
|
||||
return;
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (this.#lifecycle.state === "disconnected") return;
|
||||
this.#handleMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
#handleMessage(message: ServerMessage): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state === "connecting") {
|
||||
if (message.type === "hello_error") {
|
||||
this.#failAndClose(new ServerError(message.error));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "hello") {
|
||||
this.#failAndClose(new ProtocolValidationError("Expected server hello as first message"));
|
||||
return;
|
||||
}
|
||||
if (message.serverId !== this.#options.serverId) {
|
||||
this.#failAndClose(
|
||||
new ProtocolValidationError(
|
||||
`Connected server ${JSON.stringify(message.serverId)} does not match ${JSON.stringify(this.#options.serverId)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!lifecycle.transport) {
|
||||
this.#failAndClose(new ProtocolValidationError("Received server hello before the client hello was sent"));
|
||||
return;
|
||||
}
|
||||
const connected = {
|
||||
state: "connected",
|
||||
id: lifecycle.id,
|
||||
decoder: lifecycle.decoder,
|
||||
transport: lifecycle.transport,
|
||||
handshake: lifecycle.handshake,
|
||||
} satisfies Extract<ConnectionLifecycle, { state: "connected" }>;
|
||||
this.#lifecycle = connected;
|
||||
try {
|
||||
this.#options.onHandshake(message);
|
||||
} catch (error) {
|
||||
if (this.#lifecycle === connected) this.#failAndClose(toError(error));
|
||||
return;
|
||||
}
|
||||
if (this.#lifecycle !== connected) return;
|
||||
this.#options.onStateChange({ state: "connected" });
|
||||
if (this.#lifecycle !== connected) return;
|
||||
this.#lifecycle = { ...connected, handshake: undefined };
|
||||
lifecycle.handshake.resolve(message);
|
||||
return;
|
||||
}
|
||||
if (lifecycle.state !== "connected") return;
|
||||
if (message.type === "hello" || message.type === "hello_error") {
|
||||
this.#failAndClose(new ProtocolValidationError("Unexpected handshake message"));
|
||||
return;
|
||||
}
|
||||
this.#options.onMessage(message);
|
||||
}
|
||||
|
||||
#handleClose(): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state === "disconnected") return;
|
||||
let error: Error = new DisconnectedError("Byte transport closed");
|
||||
try {
|
||||
lifecycle.decoder.end();
|
||||
} catch (decoderError) {
|
||||
error = toError(decoderError);
|
||||
}
|
||||
this.#fail(error);
|
||||
}
|
||||
|
||||
#failAndClose(error: Error): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
const transport = lifecycle.state === "disconnected" ? undefined : lifecycle.transport;
|
||||
this.#fail(error);
|
||||
transport?.close();
|
||||
}
|
||||
|
||||
#fail(error: Error): void {
|
||||
const lifecycle = this.#lifecycle;
|
||||
if (lifecycle.state === "disconnected") return;
|
||||
this.#lifecycle = { state: "disconnected" };
|
||||
lifecycle.handshake?.reject(error);
|
||||
this.#options.onStateChange({ state: "disconnected", error });
|
||||
}
|
||||
|
||||
#isCurrent(id: number): boolean {
|
||||
return this.#lifecycle.state !== "disconnected" && this.#lifecycle.id === id;
|
||||
}
|
||||
}
|
||||
34
packages/client/src/errors.ts
Normal file
34
packages/client/src/errors.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ProtocolError, ProtocolErrorCode } from "@earendil-works/pi-protocol";
|
||||
|
||||
export class ServerError extends Error {
|
||||
readonly code: ProtocolErrorCode;
|
||||
|
||||
constructor(error: ProtocolError) {
|
||||
super(error.message);
|
||||
this.name = "ServerError";
|
||||
this.code = error.code;
|
||||
}
|
||||
}
|
||||
|
||||
export class DisconnectedError extends Error {
|
||||
constructor(message = "Client is disconnected", cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "DisconnectedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientDisposedError extends Error {
|
||||
constructor() {
|
||||
super("Client is disposed");
|
||||
this.name = "ClientDisposedError";
|
||||
}
|
||||
}
|
||||
|
||||
export function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function toDisconnectedError(error: unknown): DisconnectedError {
|
||||
const cause = toError(error);
|
||||
return cause instanceof DisconnectedError ? cause : new DisconnectedError(cause.message, cause);
|
||||
}
|
||||
12
packages/client/src/index.ts
Normal file
12
packages/client/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { Client, createClientServiceTransport } from "./client.ts";
|
||||
export { ClientDisposedError, DisconnectedError, ServerError } from "./errors.ts";
|
||||
export type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
|
||||
export type {
|
||||
AttachmentChangeListener,
|
||||
ClientOptions,
|
||||
ConnectionState,
|
||||
ConnectionStateChange,
|
||||
ListenerErrorHandler,
|
||||
ServiceSubscription,
|
||||
Unsubscribe,
|
||||
} from "./types.ts";
|
||||
16
packages/client/src/promise.ts
Normal file
16
packages/client/src/promise.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface PromiseResolvers<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T | PromiseLike<T>): void;
|
||||
reject(reason?: unknown): void;
|
||||
}
|
||||
|
||||
/** Remove in favor of `Promise.withResolvers()` when the repository's TypeScript lib baseline moves to ES2024. */
|
||||
export function createPromiseResolvers<T>(): PromiseResolvers<T> {
|
||||
let resolve!: PromiseResolvers<T>["resolve"];
|
||||
let reject!: PromiseResolvers<T>["reject"];
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
18
packages/client/src/transport.ts
Normal file
18
packages/client/src/transport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface ByteTransport {
|
||||
/** Sends one byte chunk. Calls must be delivered in invocation order. */
|
||||
send(chunk: Uint8Array): Promise<void>;
|
||||
/** Closes the transport. Implementations must make repeated calls harmless. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ByteTransportHandlers {
|
||||
/** Delivers an arbitrary inbound byte chunk. */
|
||||
onData(chunk: Uint8Array): void;
|
||||
/** Reports an orderly terminal close. */
|
||||
onClose(): void;
|
||||
/** Reports a terminal transport failure. */
|
||||
onError(error: Error): void;
|
||||
}
|
||||
|
||||
/** Creates a fresh connected, authenticated transport. Exactly one terminal handler is expected. */
|
||||
export type ByteTransportFactory = (handlers: ByteTransportHandlers) => ByteTransport | Promise<ByteTransport>;
|
||||
32
packages/client/src/types.ts
Normal file
32
packages/client/src/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ServiceSubscriptionSnapshot } from "@earendil-works/chord";
|
||||
import type { RpcTarget, SessionTarget } from "@earendil-works/pi-protocol";
|
||||
import type { ByteTransportFactory } from "./transport.ts";
|
||||
|
||||
export type ConnectionState = "disconnected" | "connecting" | "connected";
|
||||
|
||||
export interface ConnectionStateChange {
|
||||
state: ConnectionState;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
export type ListenerErrorHandler = (error: Error) => void;
|
||||
export type AttachmentChangeListener = (attachment: SessionTarget | undefined) => void;
|
||||
|
||||
export interface ServiceSubscription {
|
||||
readonly id: string;
|
||||
readonly target: RpcTarget;
|
||||
readonly snapshot: ServiceSubscriptionSnapshot;
|
||||
/** Begin ordered update delivery after the caller has installed the snapshot. */
|
||||
start(): void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
transportFactory: ByteTransportFactory;
|
||||
/** Logical server identity expected at the physical endpoint. */
|
||||
serverId: string;
|
||||
maxFrameLength?: number;
|
||||
/** Reports subscriber failures without allowing them to corrupt client state. */
|
||||
onListenerError?: ListenerErrorHandler;
|
||||
}
|
||||
299
packages/client/src/unix.ts
Normal file
299
packages/client/src/unix.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { createConnection, type Socket } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
isServerId,
|
||||
ProtocolValidationError,
|
||||
type ServerId,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { Client } from "./client.ts";
|
||||
import { DisconnectedError, ServerError } from "./errors.ts";
|
||||
import type { ByteTransport, ByteTransportFactory, ByteTransportHandlers } from "./transport.ts";
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 1_000;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
const UNIX_SOCKET_SUFFIX = ".sock";
|
||||
const MAX_CONCURRENT_DISCOVERY_PROBES = 16;
|
||||
|
||||
export interface UnixTransportOptions {
|
||||
path: string;
|
||||
maxPendingBytes?: number;
|
||||
}
|
||||
|
||||
export interface UnixServerRoute {
|
||||
serverId: ServerId;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DiscoverUnixServersOptions {
|
||||
/** Directory containing server-addressed Unix sockets. */
|
||||
directory: string;
|
||||
/** Maximum time for each connection and handshake. Defaults to 1,000 ms. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Discover reachable local servers by probing server-addressed Unix sockets. */
|
||||
export async function discoverUnixServers(options: DiscoverUnixServersOptions): Promise<UnixServerRoute[]> {
|
||||
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
|
||||
const directory = options.directory;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new TypeError(`Unix discovery timeoutMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`);
|
||||
}
|
||||
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch (error) {
|
||||
if (isErrorCode(error, "ENOENT")) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const candidates = names.flatMap((name): UnixServerRoute[] => {
|
||||
if (!name.endsWith(UNIX_SOCKET_SUFFIX)) return [];
|
||||
const serverId = name.slice(0, -UNIX_SOCKET_SUFFIX.length);
|
||||
return isServerId(serverId) ? [{ serverId, path: join(directory, name) }] : [];
|
||||
});
|
||||
const routes: UnixServerRoute[] = [];
|
||||
let nextIndex = 0;
|
||||
let failure: { error: unknown } | undefined;
|
||||
const workerCount = Math.min(MAX_CONCURRENT_DISCOVERY_PROBES, candidates.length);
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (!failure) {
|
||||
const candidate = candidates[nextIndex++];
|
||||
if (!candidate) return;
|
||||
try {
|
||||
try {
|
||||
if (!(await lstat(candidate.path)).isSocket()) continue;
|
||||
} catch (error) {
|
||||
// A socket can disappear between readdir and lstat during normal server shutdown.
|
||||
if (isErrorCode(error, "ENOENT")) continue;
|
||||
throw error;
|
||||
}
|
||||
const route = await probeUnixServer(candidate, timeoutMs);
|
||||
if (route) routes.push(route);
|
||||
} catch (error) {
|
||||
failure ??= { error };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (failure) throw failure.error;
|
||||
return routes.sort((left, right) => left.serverId.localeCompare(right.serverId));
|
||||
}
|
||||
|
||||
/** Creates fresh Unix-domain socket transports for Client connection attempts in Node-compatible runtimes. */
|
||||
export function createUnixTransportFactory(options: UnixTransportOptions): ByteTransportFactory {
|
||||
const maxPendingBytes = validateUnixTransportOptions(options);
|
||||
return (handlers) => connectUnixSocket(options.path, maxPendingBytes, handlers);
|
||||
}
|
||||
|
||||
function validateUnixTransportOptions(options: UnixTransportOptions): number {
|
||||
if (options.path.length === 0) throw new TypeError("Unix transport path must not be empty");
|
||||
const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_FRAME_LENGTH * 4;
|
||||
if (!Number.isSafeInteger(maxPendingBytes) || maxPendingBytes <= 0) {
|
||||
throw new TypeError("Unix transport maxPendingBytes must be a positive safe integer");
|
||||
}
|
||||
if (process.platform === "win32") throw new Error("Unix transport is not supported on Windows");
|
||||
return maxPendingBytes;
|
||||
}
|
||||
|
||||
function connectUnixSocket(
|
||||
path: string,
|
||||
maxPendingBytes: number,
|
||||
handlers: ByteTransportHandlers,
|
||||
onSocket?: (socket: Socket) => void,
|
||||
): Promise<ByteTransport> {
|
||||
return new Promise<ByteTransport>((resolve, reject) => {
|
||||
const socket = createConnection(path);
|
||||
onSocket?.(socket);
|
||||
let connected = false;
|
||||
let terminal = false;
|
||||
|
||||
const close = (): void => {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
socket.destroy();
|
||||
if (connected) handlers.onClose();
|
||||
else reject(new Error("Unix transport closed before connecting"));
|
||||
};
|
||||
|
||||
socket.once("connect", () => {
|
||||
if (terminal) return;
|
||||
connected = true;
|
||||
resolve(
|
||||
new UnixByteTransport(socket, maxPendingBytes, () => {
|
||||
terminal = true;
|
||||
}),
|
||||
);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
if (!terminal) handlers.onData(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
||||
});
|
||||
socket.once("end", close);
|
||||
socket.once("close", close);
|
||||
socket.once("error", (error) => {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
socket.destroy();
|
||||
if (connected) handlers.onError(error);
|
||||
else reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class UnixByteTransport implements ByteTransport {
|
||||
readonly #socket: Socket;
|
||||
readonly #maxPendingBytes: number;
|
||||
readonly #markLocalClose: () => void;
|
||||
#closed = false;
|
||||
#pendingBytes = 0;
|
||||
#writeTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(socket: Socket, maxPendingBytes: number, markLocalClose: () => void) {
|
||||
this.#socket = socket;
|
||||
this.#maxPendingBytes = maxPendingBytes;
|
||||
this.#markLocalClose = markLocalClose;
|
||||
}
|
||||
|
||||
send(chunk: Uint8Array): Promise<void> {
|
||||
if (!(chunk instanceof Uint8Array)) {
|
||||
return Promise.reject(new TypeError("Unix transport chunks must be Uint8Array"));
|
||||
}
|
||||
if (this.#closed) return Promise.reject(new Error("Unix transport is closed"));
|
||||
if (this.#pendingBytes + chunk.byteLength > this.#maxPendingBytes) {
|
||||
return Promise.reject(new Error("Unix transport exceeded its pending byte limit"));
|
||||
}
|
||||
this.#pendingBytes += chunk.byteLength;
|
||||
const bytes = chunk.slice();
|
||||
const write = this.#writeTail.then(() => this.#write(bytes));
|
||||
const tracked = write.finally(() => {
|
||||
this.#pendingBytes -= bytes.byteLength;
|
||||
});
|
||||
this.#writeTail = tracked.catch(() => {});
|
||||
return tracked;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#markLocalClose();
|
||||
this.#socket.destroy();
|
||||
}
|
||||
|
||||
#write(chunk: Uint8Array): Promise<void> {
|
||||
if (this.#closed || !this.#socket.writable) return Promise.reject(new Error("Unix transport is closed"));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let callbackComplete = false;
|
||||
let drainComplete = false;
|
||||
let requiresDrain: boolean | undefined;
|
||||
let settled = false;
|
||||
|
||||
const onDrain = (): void => {
|
||||
drainComplete = true;
|
||||
finish();
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
this.#socket.off("drain", onDrain);
|
||||
this.#socket.off("close", onClose);
|
||||
};
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const finish = (): void => {
|
||||
if (settled || !callbackComplete || requiresDrain === undefined) return;
|
||||
if (requiresDrain && !drainComplete) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onClose = (): void => fail(new Error("Unix transport closed during write"));
|
||||
|
||||
try {
|
||||
this.#socket.once("close", onClose);
|
||||
const accepted = this.#socket.write(chunk, (error) => {
|
||||
if (error) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
callbackComplete = true;
|
||||
finish();
|
||||
});
|
||||
requiresDrain = !accepted;
|
||||
if (requiresDrain) this.#socket.once("drain", onDrain);
|
||||
finish();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function probeUnixServer(route: UnixServerRoute, timeoutMs: number): Promise<UnixServerRoute | undefined> {
|
||||
const maxPendingBytes = validateUnixTransportOptions({ path: route.path });
|
||||
let socket: Socket | undefined;
|
||||
const client = new Client({
|
||||
serverId: route.serverId,
|
||||
transportFactory: (handlers) =>
|
||||
connectUnixSocket(route.path, maxPendingBytes, handlers, (created) => {
|
||||
socket = created;
|
||||
}),
|
||||
});
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
client.connect(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
socket?.destroy();
|
||||
reject(new UnixDiscoveryTimeoutError());
|
||||
}, timeoutMs);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
return route;
|
||||
} catch (error) {
|
||||
// Missing/refused sockets are stale or shutting down. Protocol failures mean
|
||||
// the endpoint is not the advertised server. Both are safe to omit.
|
||||
if (
|
||||
error instanceof UnixDiscoveryTimeoutError ||
|
||||
error instanceof ProtocolValidationError ||
|
||||
(error instanceof DisconnectedError && error.cause === undefined) ||
|
||||
(error instanceof ServerError && error.code === "version") ||
|
||||
isErrorCode(error, "ENOENT") ||
|
||||
isErrorCode(error, "ECONNREFUSED") ||
|
||||
isErrorCode(error, "ECONNRESET") ||
|
||||
isErrorCode(error, "EPIPE") ||
|
||||
isErrorCode(error, "ETIMEDOUT")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
await client.dispose();
|
||||
const activeSocket = socket;
|
||||
if (activeSocket && !activeSocket.destroyed) activeSocket.destroy();
|
||||
if (activeSocket && !activeSocket.closed) {
|
||||
await new Promise<void>((resolve) => activeSocket.once("close", resolve));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UnixDiscoveryTimeoutError extends Error {}
|
||||
|
||||
function isErrorCode(error: unknown, code: string): boolean {
|
||||
let current = error;
|
||||
const seen = new Set<unknown>();
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
if ("code" in current && current.code === code) return true;
|
||||
current = current.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
384
packages/client/test/client.test.ts
Normal file
384
packages/client/test/client.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
encodeCbor,
|
||||
encodeFrame,
|
||||
encodeServerMessage,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolValidationError,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
type ByteTransportFactory,
|
||||
Client,
|
||||
ClientDisposedError,
|
||||
createClientServiceTransport,
|
||||
DisconnectedError,
|
||||
} from "../src/index.ts";
|
||||
import { MemoryByteServer } from "./support.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const serverTarget = { serverId } as const;
|
||||
|
||||
async function connectClient(server: MemoryByteServer, expectedServerId = serverId): Promise<Client> {
|
||||
return Client.connect({ serverId: expectedServerId, transportFactory: (handlers) => server.connect(handlers) });
|
||||
}
|
||||
|
||||
async function attachClient(client: Client, server: MemoryByteServer, sessionId: string): Promise<void> {
|
||||
const expectedMessages = server.messages.length + 1;
|
||||
const attaching = client.request(serverTarget, {
|
||||
serviceId: "pi.session-management",
|
||||
member: "attach",
|
||||
args: [sessionId],
|
||||
});
|
||||
await server.waitForMessages(expectedMessages);
|
||||
const request = server.messages.at(-1);
|
||||
if (request?.type !== "request") throw new Error("Missing attach request");
|
||||
server.send({
|
||||
type: "attachment",
|
||||
attachment: { serverId, sessionId, attachmentId: `attachment-${sessionId}` },
|
||||
});
|
||||
server.send({ type: "response", id: request.id, ok: true, result: null });
|
||||
await attaching;
|
||||
}
|
||||
|
||||
test("requires a canonical UUIDv4 server identity", () => {
|
||||
expect(() => new Client({ serverId: "invalid-server", transportFactory: () => Promise.reject() })).toThrow(
|
||||
/serverId/,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Client service operations", () => {
|
||||
test("connects only to the expected logical server", async () => {
|
||||
const matching = new MemoryByteServer();
|
||||
const client = await connectClient(matching);
|
||||
expect(client.hello).toMatchObject({ serverId });
|
||||
await client.dispose();
|
||||
|
||||
const wrong = new MemoryByteServer("00000000-0000-4000-8000-000000000002");
|
||||
await expect(connectClient(wrong)).rejects.toBeInstanceOf(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("updates attachment state from out-of-band server routing", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const changes: Array<string | undefined> = [];
|
||||
client.onAttachmentChange((attachment) => changes.push(attachment?.sessionId));
|
||||
|
||||
await attachClient(client, server, "session-1");
|
||||
expect(client.attachment).toMatchObject({ sessionId: "session-1", attachmentId: "attachment-session-1" });
|
||||
expect(server.messages[1]).toMatchObject({
|
||||
type: "request",
|
||||
target: serverTarget,
|
||||
call: { serviceId: "pi.session-management", member: "attach", args: ["session-1"] },
|
||||
});
|
||||
|
||||
server.send({ type: "attachment", attachment: null });
|
||||
expect(client.attachment).toBeUndefined();
|
||||
expect(changes).toEqual(["session-1", undefined]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("buffers service updates until the subscription snapshot arrives", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
await attachClient(client, server, "session-1");
|
||||
const target = client.attachment!;
|
||||
const transport = createClientServiceTransport(client, () => client.attachment);
|
||||
const updates: Array<{ readonly type: string; readonly ops?: readonly unknown[] }> = [];
|
||||
const opening = transport.subscribe(
|
||||
"pi.models",
|
||||
"singleton",
|
||||
(update) => {
|
||||
updates.push(update);
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toMatchObject({
|
||||
type: "request",
|
||||
target,
|
||||
call: {
|
||||
serviceId: "$chord.service",
|
||||
member: "subscribe",
|
||||
args: ["service-1", "pi.models", "singleton"],
|
||||
},
|
||||
});
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: { type: "state", member: "state", sequence: 1, ops: [["s", ["revision"], 1]] },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(updates).toEqual([]);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-2",
|
||||
ok: true,
|
||||
result: {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [{ members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }] }],
|
||||
},
|
||||
});
|
||||
const subscription = await opening;
|
||||
expect(updates).toEqual([]);
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "closed-subscription",
|
||||
update: { type: "state", member: "state", sequence: 99, ops: [["s", 99, 99]] },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(client.connected).toBe(true);
|
||||
expect(subscription.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
]);
|
||||
subscription.activate();
|
||||
await vi.waitFor(() => expect(updates.map(({ type }) => type)).toEqual(["state"]));
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: { type: "state", member: "state", sequence: 2, ops: [["s", ["revision"], 2]] },
|
||||
});
|
||||
server.send({
|
||||
type: "service_update",
|
||||
subscriptionId: "service-1",
|
||||
update: {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 3,
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 3],
|
||||
],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(updates).toHaveLength(3));
|
||||
expect(updates[2]?.ops).toEqual([["s", ["revision"], 3]]);
|
||||
|
||||
const disposing = subscription.close(BACKGROUND_CONTEXT);
|
||||
await server.waitForMessages(4);
|
||||
expect(server.messages[3]).toMatchObject({
|
||||
call: { serviceId: "$chord.service", member: "unsubscribe", args: ["service-1"] },
|
||||
});
|
||||
server.send({ type: "response", id: "request-3", ok: true });
|
||||
await disposing;
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("correlates out-of-order generic service responses", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const first = client.request(serverTarget, { serviceId: "test", member: "first", args: [] });
|
||||
const second = client.request(serverTarget, { serviceId: "test", member: "second", args: [] });
|
||||
await server.waitForMessages(3);
|
||||
server.send({ type: "response", id: "request-2", ok: true, result: "second" });
|
||||
server.send({ type: "response", id: "request-1", ok: true, result: "first" });
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("exposes bounded server errors", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "missing", args: [] });
|
||||
await server.waitForMessages(2);
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: false,
|
||||
error: { code: "session_not_found", message: "Unknown session" },
|
||||
});
|
||||
await expect(pending).rejects.toMatchObject({ code: "session_not_found" });
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("does not send a pre-aborted untyped RPC request", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("already cancelled");
|
||||
controller.abort(reason);
|
||||
|
||||
await expect(
|
||||
client.request(serverTarget, { serviceId: "test", member: "noop", args: [] }, controller.signal),
|
||||
).rejects.toBe(reason);
|
||||
expect(server.messages).toHaveLength(1);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("cancels one untyped RPC request without disconnecting", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("stop this request");
|
||||
const pending = client.request(
|
||||
serverTarget,
|
||||
{ serviceId: "test", member: "mutate", args: [{ value: 42 }] },
|
||||
controller.signal,
|
||||
);
|
||||
await server.waitForMessages(2);
|
||||
expect(server.messages[1]).toMatchObject({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
target: serverTarget,
|
||||
call: { serviceId: "test", member: "mutate", args: [{ value: 42 }] },
|
||||
});
|
||||
|
||||
controller.abort(reason);
|
||||
await expect(pending).rejects.toBe(reason);
|
||||
await server.waitForMessages(3);
|
||||
expect(server.messages[2]).toEqual({ type: "cancel", id: "request-1", target: serverTarget });
|
||||
server.send({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: false,
|
||||
error: { code: "cancelled", message: "cancelled" },
|
||||
});
|
||||
expect(client.connected).toBe(true);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("rejects pending requests after disconnect or disposal", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
server.disconnect();
|
||||
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
|
||||
await client.dispose();
|
||||
await expect(
|
||||
client.request(serverTarget, { serviceId: "test", member: "disposed", args: [] }),
|
||||
).rejects.toBeInstanceOf(ClientDisposedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client connection lifecycle", () => {
|
||||
test("rejects server data delivered before the client hello is sent", async () => {
|
||||
let closeCount = 0;
|
||||
let sendCount = 0;
|
||||
const client = new Client({
|
||||
serverId,
|
||||
transportFactory: (handlers) => {
|
||||
handlers.onData(encodeServerMessage({ type: "hello", version: PROTOCOL_VERSION, serverId }));
|
||||
return {
|
||||
async send() {
|
||||
sendCount += 1;
|
||||
},
|
||||
close() {
|
||||
closeCount += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.connect()).rejects.toMatchObject({
|
||||
name: "ProtocolValidationError",
|
||||
message: "Received server data before the client hello was sent",
|
||||
});
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
expect(sendCount).toBe(0);
|
||||
expect(closeCount).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects typed handshake errors and closes the transport", async () => {
|
||||
let handlers: Parameters<ByteTransportFactory>[0];
|
||||
let closeCount = 0;
|
||||
const client = new Client({
|
||||
serverId,
|
||||
transportFactory: (createdHandlers) => {
|
||||
handlers = createdHandlers;
|
||||
return {
|
||||
async send() {
|
||||
handlers.onData(
|
||||
encodeServerMessage({
|
||||
type: "hello_error",
|
||||
error: { code: "version", message: "Unsupported protocol version" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
close() {
|
||||
closeCount += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.connect()).rejects.toMatchObject({
|
||||
name: "ServerError",
|
||||
code: "version",
|
||||
message: "Unsupported protocol version",
|
||||
});
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
expect(closeCount).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects pending requests and reconnects through a fresh transport", async () => {
|
||||
const first = new MemoryByteServer();
|
||||
const second = new MemoryByteServer();
|
||||
let connection = 0;
|
||||
const transportFactory: ByteTransportFactory = (handlers) =>
|
||||
(connection++ === 0 ? first : second).connect(handlers);
|
||||
const client = new Client({ serverId, transportFactory });
|
||||
const states: string[] = [];
|
||||
client.onConnectionStateChange(({ state }) => states.push(state));
|
||||
await client.connect();
|
||||
await attachClient(client, first, "session-1");
|
||||
const target = client.attachment;
|
||||
if (target === undefined) throw new Error("Missing attachment");
|
||||
const pending = client.request(target, { serviceId: "test.session", member: "run", args: [] });
|
||||
await first.waitForMessages(3);
|
||||
expect(first.messages[2]).toMatchObject({ call: { serviceId: "test.session", member: "run" } });
|
||||
first.disconnect();
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(DisconnectedError);
|
||||
await expect(client.reconnect()).resolves.toMatchObject({ serverId });
|
||||
expect(connection).toBe(2);
|
||||
expect(client.connected).toBe(true);
|
||||
expect(second.messages).toHaveLength(1);
|
||||
expect(states).toEqual(["connecting", "connected", "disconnected", "connecting", "connected"]);
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("reports transport failures without leaving requests pending", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
const pending = client.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
await server.waitForMessages(2);
|
||||
server.error(new Error("read failed"));
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: "DisconnectedError",
|
||||
message: "read failed",
|
||||
cause: expect.objectContaining({ message: "read failed" }),
|
||||
});
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
});
|
||||
|
||||
test("disconnects on invalid or truncated server framing", async () => {
|
||||
const invalidServer = new MemoryByteServer();
|
||||
const invalidClient = await connectClient(invalidServer);
|
||||
invalidServer.sendRaw(encodeFrame(encodeCbor({ type: "response", id: "unknown", ok: true, result: 1 })));
|
||||
expect(invalidClient.connectionState).toBe("disconnected");
|
||||
|
||||
const truncatedServer = new MemoryByteServer();
|
||||
const truncatedClient = await connectClient(truncatedServer);
|
||||
const pending = truncatedClient.request(serverTarget, { serviceId: "test", member: "pending", args: [] });
|
||||
await truncatedServer.waitForMessages(2);
|
||||
truncatedServer.sendRaw(new Uint8Array([0, 0, 0, 2, 1]));
|
||||
truncatedServer.disconnect();
|
||||
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: "ProtocolValidationError",
|
||||
message: expect.stringMatching(/truncated/i),
|
||||
});
|
||||
expect(truncatedClient.connectionState).toBe("disconnected");
|
||||
});
|
||||
|
||||
test("disconnects when a response has no matching request", async () => {
|
||||
const server = new MemoryByteServer();
|
||||
const client = await connectClient(server);
|
||||
server.send({ type: "response", id: "unknown-request", ok: true, result: [] });
|
||||
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
expect(server.clientCloseCount).toBe(1);
|
||||
});
|
||||
});
|
||||
9
packages/client/test/fixtures/stale-socket-server.mjs
vendored
Normal file
9
packages/client/test/fixtures/stale-socket-server.mjs
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const path = process.argv[2];
|
||||
if (!path) throw new Error("Socket path argument is required");
|
||||
|
||||
const server = createServer();
|
||||
server.listen(path, () => {
|
||||
if (process.send) process.send("listening");
|
||||
});
|
||||
84
packages/client/test/support.ts
Normal file
84
packages/client/test/support.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
encodeServerMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type ServerMessage,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
import type { ByteTransport, ByteTransportHandlers } from "../src/index.ts";
|
||||
|
||||
export class MemoryByteServer {
|
||||
readonly messages: ClientMessage[] = [];
|
||||
readonly serverId: string;
|
||||
clientCloseCount = 0;
|
||||
private handlers?: ByteTransportHandlers;
|
||||
private decoder = new ClientMessageDecoder();
|
||||
private readonly messageWaiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
constructor(serverId = "00000000-0000-4000-8000-000000000001") {
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
connect(handlers: ByteTransportHandlers): ByteTransport {
|
||||
this.handlers = handlers;
|
||||
this.decoder = new ClientMessageDecoder();
|
||||
let closed = false;
|
||||
return {
|
||||
send: async (chunk) => {
|
||||
for (const message of this.decoder.push(chunk)) {
|
||||
this.messages.push(message);
|
||||
this.resolveMessageWaiters();
|
||||
if (message.type === "hello") {
|
||||
this.send({
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
serverId: this.serverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
this.clientCloseCount += 1;
|
||||
if (this.handlers === handlers) this.handlers = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
waitForMessages(count: number): Promise<void> {
|
||||
if (this.messages.length >= count) return Promise.resolve();
|
||||
return new Promise((resolve) => this.messageWaiters.push({ count, resolve }));
|
||||
}
|
||||
|
||||
send(message: ServerMessage): void {
|
||||
if (!this.handlers) throw new Error("No client connection");
|
||||
this.handlers.onData(encodeServerMessage(message));
|
||||
}
|
||||
|
||||
sendRaw(chunk: Uint8Array): void {
|
||||
if (!this.handlers) throw new Error("No client connection");
|
||||
this.handlers.onData(chunk);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
const handlers = this.handlers;
|
||||
this.handlers = undefined;
|
||||
handlers?.onClose();
|
||||
}
|
||||
|
||||
error(error: Error): void {
|
||||
const handlers = this.handlers;
|
||||
this.handlers = undefined;
|
||||
handlers?.onError(error);
|
||||
}
|
||||
|
||||
private resolveMessageWaiters(): void {
|
||||
for (let index = this.messageWaiters.length - 1; index >= 0; index--) {
|
||||
const waiter = this.messageWaiters[index]!;
|
||||
if (this.messages.length < waiter.count) continue;
|
||||
this.messageWaiters.splice(index, 1);
|
||||
waiter.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
147
packages/client/test/unix-transport.test.ts
Normal file
147
packages/client/test/unix-transport.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { parseServiceCall } from "@earendil-works/chord";
|
||||
import { ClientMessageDecoder, encodeServerMessage, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { Client } from "../src/index.ts";
|
||||
import { createUnixTransportFactory } from "../src/unix.ts";
|
||||
|
||||
const serverId = "00000000-0000-4000-8000-000000000001";
|
||||
const tempDirectories = new Set<string>();
|
||||
const servers = new Set<Server>();
|
||||
const sockets = new Set<Socket>();
|
||||
|
||||
async function makeSocketPath(): Promise<string> {
|
||||
const directory = await mkdtemp(join("/tmp", "pi-client-transport-"));
|
||||
tempDirectories.add(directory);
|
||||
return join(directory, "pi.sock");
|
||||
}
|
||||
|
||||
async function listen(server: Server, path: string): Promise<void> {
|
||||
servers.add(server);
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sockets.clear();
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
servers.clear();
|
||||
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
tempDirectories.clear();
|
||||
});
|
||||
|
||||
test("rejects invalid Unix transport options", () => {
|
||||
expect(() => createUnixTransportFactory({ path: "" })).toThrow(/must not be empty/);
|
||||
expect(() => createUnixTransportFactory({ path: "/tmp/pi.sock", maxPendingBytes: 0 })).toThrow(/positive/);
|
||||
});
|
||||
|
||||
describe.runIf(process.platform !== "win32")("createUnixTransportFactory", () => {
|
||||
test("carries a complete Client handshake and request over a real Unix socket", async () => {
|
||||
const path = await makeSocketPath();
|
||||
const receivedMembers: string[] = [];
|
||||
const server = createServer((socket) => {
|
||||
const decoder = new ClientMessageDecoder();
|
||||
socket.on("data", (chunk) => {
|
||||
for (const message of decoder.push(chunk)) {
|
||||
if (message.type === "hello") {
|
||||
const frame = encodeServerMessage({
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
serverId,
|
||||
});
|
||||
for (const byte of frame) socket.write(Uint8Array.of(byte));
|
||||
continue;
|
||||
}
|
||||
if (message.type === "cancel") continue;
|
||||
const call = parseServiceCall(message.call);
|
||||
receivedMembers.push(`${call.serviceId}.${call.member}`);
|
||||
const frame = encodeServerMessage({
|
||||
type: "response",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: [],
|
||||
});
|
||||
const split = Math.floor(frame.byteLength / 2);
|
||||
socket.write(frame.subarray(0, split));
|
||||
socket.write(frame.subarray(split));
|
||||
}
|
||||
});
|
||||
});
|
||||
await listen(server, path);
|
||||
const client = new Client({ serverId, transportFactory: createUnixTransportFactory({ path }) });
|
||||
|
||||
try {
|
||||
await expect(client.connect()).resolves.toMatchObject({ serverId });
|
||||
await expect(
|
||||
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
|
||||
).resolves.toEqual([]);
|
||||
expect(receivedMembers).toEqual(["test.server.list"]);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports truncated final frames through Client", async () => {
|
||||
const path = await makeSocketPath();
|
||||
const server = createServer((socket) => {
|
||||
const decoder = new ClientMessageDecoder();
|
||||
socket.on("data", (chunk) => {
|
||||
for (const message of decoder.push(chunk)) {
|
||||
if (message.type === "hello") {
|
||||
socket.write(
|
||||
encodeServerMessage({
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
socket.end(new Uint8Array([0, 0, 0, 2, 1]));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
await listen(server, path);
|
||||
const client = new Client({ serverId, transportFactory: createUnixTransportFactory({ path }) });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await expect(
|
||||
client.request({ serverId }, { serviceId: "test.server", member: "list", args: [] }),
|
||||
).rejects.toMatchObject({
|
||||
name: "ProtocolValidationError",
|
||||
message: expect.stringMatching(/truncated/i),
|
||||
});
|
||||
expect(client.connectionState).toBe("disconnected");
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects connection attempts to missing sockets", async () => {
|
||||
const path = await makeSocketPath();
|
||||
await expect(
|
||||
createUnixTransportFactory({ path })({
|
||||
onData: () => {},
|
||||
onClose: () => {},
|
||||
onError: () => {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
184
packages/client/test/unix.test.ts
Normal file
184
packages/client/test/unix.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { type ChildProcess, fork } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Server, type Socket } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { Server as RuntimeServer } from "../../server/src/server.ts";
|
||||
import { createTestServerServices } from "../../server/src/testing/host.ts";
|
||||
import { createUnixListener } from "../../server/src/transports/unix/listener.ts";
|
||||
import { discoverUnixServers } from "../src/unix.ts";
|
||||
|
||||
const tempDirectories = new Set<string>();
|
||||
const servers = new Set<RuntimeServer>();
|
||||
const rawServers = new Set<Server>();
|
||||
const rawSockets = new Set<Socket>();
|
||||
const children = new Set<ChildProcess>();
|
||||
|
||||
async function makeDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join("/tmp", "pc-"));
|
||||
tempDirectories.add(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function serverId(value: number): string {
|
||||
return `00000000-0000-4000-8000-${value.toString(16).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
async function startServer(
|
||||
directory: string,
|
||||
fileServerId: string,
|
||||
reportedServerId = fileServerId,
|
||||
): Promise<RuntimeServer> {
|
||||
const path = join(directory, `${fileServerId}.sock`);
|
||||
const server = new RuntimeServer(
|
||||
{
|
||||
serverServices: createTestServerServices(),
|
||||
resolveSession: async () => Promise.reject(new Error("unused")),
|
||||
openSession: async () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
{ listeners: [createUnixListener({ path })], serverId: reportedServerId },
|
||||
);
|
||||
servers.add(server);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
async function startSilentSocket(
|
||||
path: string,
|
||||
connections?: { active: number; maximum: number; total: number },
|
||||
): Promise<void> {
|
||||
const server = createServer((socket) => {
|
||||
rawSockets.add(socket);
|
||||
if (connections) {
|
||||
connections.active += 1;
|
||||
connections.maximum = Math.max(connections.maximum, connections.active);
|
||||
connections.total += 1;
|
||||
}
|
||||
socket.once("close", () => {
|
||||
rawSockets.delete(socket);
|
||||
if (connections) connections.active -= 1;
|
||||
});
|
||||
});
|
||||
rawServers.add(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
await Promise.all([...children].map((child) => (child.exitCode === null ? once(child, "exit") : undefined)));
|
||||
children.clear();
|
||||
await Promise.all([...servers].map((server) => server.close()));
|
||||
servers.clear();
|
||||
for (const socket of rawSockets) socket.destroy();
|
||||
rawSockets.clear();
|
||||
await Promise.all(
|
||||
[...rawServers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
rawServers.clear();
|
||||
await Promise.all([...tempDirectories].map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
tempDirectories.clear();
|
||||
});
|
||||
|
||||
describe("discoverUnixServers", () => {
|
||||
test("returns no routes when the server directory is missing", async () => {
|
||||
const directory = join(await makeDirectory(), "missing");
|
||||
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("discovers reachable servers in server ID order", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const first = serverId(1);
|
||||
const second = serverId(2);
|
||||
await startServer(directory, second);
|
||||
await startServer(directory, first);
|
||||
|
||||
await expect(discoverUnixServers({ directory })).resolves.toEqual([
|
||||
{ serverId: first, path: join(directory, `${first}.sock`) },
|
||||
{ serverId: second, path: join(directory, `${second}.sock`) },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores malformed entries, non-sockets, and mismatched servers", async () => {
|
||||
const directory = await makeDirectory();
|
||||
await writeFile(join(directory, `${serverId(1)}.sock`), "not a socket");
|
||||
await writeFile(join(directory, "not-a-server.sock"), "ignored");
|
||||
await mkdir(join(directory, `${serverId(2)}.sock`));
|
||||
await startServer(directory, serverId(3), serverId(4));
|
||||
|
||||
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores stale sockets without deleting them", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const id = serverId(1);
|
||||
const path = join(directory, `${id}.sock`);
|
||||
const child = fork(new URL("fixtures/stale-socket-server.mjs", import.meta.url), [path], {
|
||||
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
||||
});
|
||||
children.add(child);
|
||||
await once(child, "message");
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit");
|
||||
children.delete(child);
|
||||
|
||||
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
|
||||
expect((await lstat(path)).isSocket()).toBe(true);
|
||||
});
|
||||
|
||||
test("times out an unresponsive socket without deleting it", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const id = serverId(1);
|
||||
const path = join(directory, `${id}.sock`);
|
||||
await startSilentSocket(path);
|
||||
|
||||
await expect(discoverUnixServers({ directory, timeoutMs: 20 })).resolves.toEqual([]);
|
||||
expect((await lstat(path)).isSocket()).toBe(true);
|
||||
});
|
||||
|
||||
test("limits concurrent probes to 16", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const connections = { active: 0, maximum: 0, total: 0 };
|
||||
for (let index = 1; index <= 20; index++) {
|
||||
await startSilentSocket(join(directory, `${serverId(index)}.sock`), connections);
|
||||
}
|
||||
|
||||
const discovery = discoverUnixServers({ directory, timeoutMs: 100 });
|
||||
await expect.poll(() => connections.active).toBe(16);
|
||||
expect(connections.maximum).toBe(16);
|
||||
await expect(discovery).resolves.toEqual([]);
|
||||
expect(connections.total).toBe(20);
|
||||
});
|
||||
|
||||
test("ignores an endpoint that closes before its handshake", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const id = serverId(1);
|
||||
const path = join(directory, `${id}.sock`);
|
||||
const server = createServer((socket) => socket.destroy());
|
||||
rawServers.add(server);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(path, resolve);
|
||||
});
|
||||
|
||||
await expect(discoverUnixServers({ directory })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("propagates unexpected filesystem errors", async () => {
|
||||
const directory = await makeDirectory();
|
||||
const file = join(directory, "not-a-directory");
|
||||
await writeFile(file, "content");
|
||||
|
||||
await expect(discoverUnixServers({ directory: file })).rejects.toMatchObject({ code: "ENOTDIR" });
|
||||
});
|
||||
});
|
||||
12
packages/client/tsconfig.build.json
Normal file
12
packages/client/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"@earendil-works/pi-protocol": ["../protocol/dist/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
17
packages/client/tsconfig.test.json
Normal file
17
packages/client/tsconfig.test.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "vitest"],
|
||||
"paths": {
|
||||
"@earendil-works/pi-agent-core": ["../agent/src/index.ts"],
|
||||
"@earendil-works/pi-ai": ["../ai/src/index.ts"],
|
||||
"@earendil-works/pi-ai/*": ["../ai/src/*.ts", "../ai/src/providers/*.ts"],
|
||||
"@earendil-works/pi-protocol": ["../protocol/src/index.ts"],
|
||||
"@earendil-works/pi-telemetry": ["../telemetry/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
17
packages/client/vitest.config.ts
Normal file
17
packages/client/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
},
|
||||
resolve: {
|
||||
conditions: ["source"],
|
||||
alias: {
|
||||
"@earendil-works/pi-protocol": fileURLToPath(new URL("../protocol/src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
ssr: { resolve: { conditions: ["source"] } },
|
||||
});
|
||||
Reference in New Issue
Block a user