chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
479
third_party/pi/packages/client/src/client.ts
vendored
Normal file
479
third_party/pi/packages/client/src/client.ts
vendored
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
third_party/pi/packages/client/src/connection.ts
vendored
Normal file
245
third_party/pi/packages/client/src/connection.ts
vendored
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
third_party/pi/packages/client/src/errors.ts
vendored
Normal file
34
third_party/pi/packages/client/src/errors.ts
vendored
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
third_party/pi/packages/client/src/index.ts
vendored
Normal file
12
third_party/pi/packages/client/src/index.ts
vendored
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
third_party/pi/packages/client/src/promise.ts
vendored
Normal file
16
third_party/pi/packages/client/src/promise.ts
vendored
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
third_party/pi/packages/client/src/transport.ts
vendored
Normal file
18
third_party/pi/packages/client/src/transport.ts
vendored
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
third_party/pi/packages/client/src/types.ts
vendored
Normal file
32
third_party/pi/packages/client/src/types.ts
vendored
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
third_party/pi/packages/client/src/unix.ts
vendored
Normal file
299
third_party/pi/packages/client/src/unix.ts
vendored
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;
|
||||
}
|
||||
Reference in New Issue
Block a user