import { WsRpcGroup } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelay, type ReconnectBackoffConfig, } from "./reconnectBackoff.ts"; export interface WsProtocolLifecycleHandlers { readonly getConnectionLabel?: () => string | null; readonly getVersionMismatchHint?: () => string | null; readonly isCloseIntentional?: () => boolean; readonly isActive?: () => boolean; readonly onAttempt?: (socketUrl: string) => void; readonly onOpen?: () => void; readonly onHeartbeatPing?: () => void; readonly onHeartbeatPong?: () => void; readonly onHeartbeatTimeout?: () => void; readonly onRequestStart?: (info: { readonly id: string; readonly tag: string; readonly stream: boolean; }) => void; readonly onRequestChunk?: (info: { readonly id: string; readonly tag: string; readonly chunkCount: number; }) => void; readonly onRequestExit?: (info: { readonly id: string; readonly tag: string; readonly stream: boolean; }) => void; readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; readonly onError?: (message: string) => void; readonly onClose?: ( details: { readonly code: number; readonly reason: string }, context: { readonly intentional: boolean }, ) => void; } export interface WsRpcProtocolRequestTelemetry { readonly onRequestSent?: (requestId: string, tag: string) => void; readonly onRequestAcknowledged?: (requestId: string) => void; readonly onClearTrackedRequests?: () => void; } export interface WsRpcProtocolOptions { /** Backoff configuration for reconnect retries. */ readonly backoff?: ReconnectBackoffConfig; /** * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. * Use for additive telemetry (connection state, clearing request trackers on disconnect). */ readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; } export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); type RpcClientFactory = typeof makeWsRpcProtocolClient; export type WsRpcProtocolClient = RpcClientFactory extends Effect.Effect ? Client : never; export type WsRpcProtocolSocketUrlProvider = string | (() => Promise); const decodeRpcPongMessage = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.TaggedStruct("Pong", {})), ); function formatSocketErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; } return String(error); } function resolveWsRpcSocketUrl(rawUrl: string): string { const resolved = new URL(rawUrl); if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); } resolved.pathname = "/ws"; return resolved.toString(); } type ResolvedLifecycleHandlers = Required< Pick< WsProtocolLifecycleHandlers, | "getConnectionLabel" | "getVersionMismatchHint" | "isCloseIntentional" | "isActive" | "onAttempt" | "onOpen" | "onHeartbeatPing" | "onHeartbeatPong" | "onHeartbeatTimeout" | "onError" | "onClose" > >; function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { return { onAttempt: () => undefined, onOpen: () => undefined, onHeartbeatPing: () => undefined, onHeartbeatPong: () => undefined, onHeartbeatTimeout: () => undefined, onError: () => undefined, onClose: () => undefined, getConnectionLabel: () => null, getVersionMismatchHint: () => null, isCloseIntentional: () => false, isActive: () => true, }; } function resolveLifecycleHandlers( handlers: WsProtocolLifecycleHandlers | undefined, telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, ): ResolvedLifecycleHandlers { const defaults = defaultLifecycleHandlers(); const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; const isCloseIntentional = handlers?.isCloseIntentional ?? telemetryLifecycle?.isCloseIntentional ?? defaults.isCloseIntentional; return { getConnectionLabel: () => handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, getVersionMismatchHint: () => handlers?.getVersionMismatchHint?.() ?? telemetryLifecycle?.getVersionMismatchHint?.() ?? null, isActive, isCloseIntentional, onAttempt: (socketUrl) => { if (!isActive()) { return; } telemetryLifecycle?.onAttempt?.(socketUrl); handlers?.onAttempt?.(socketUrl); }, onOpen: () => { if (!isActive()) { return; } telemetryLifecycle?.onOpen?.(); handlers?.onOpen?.(); }, onHeartbeatPing: () => { if (!isActive()) { return; } telemetryLifecycle?.onHeartbeatPing?.(); handlers?.onHeartbeatPing?.(); }, onHeartbeatPong: () => { if (!isActive()) { return; } telemetryLifecycle?.onHeartbeatPong?.(); handlers?.onHeartbeatPong?.(); }, onHeartbeatTimeout: () => { if (!isActive()) { return; } telemetryLifecycle?.onHeartbeatTimeout?.(); handlers?.onHeartbeatTimeout?.(); }, onError: (message) => { if (!isActive()) { return; } telemetryLifecycle?.onError?.(message); handlers?.onError?.(message); }, onClose: (details, context) => { if (!isActive()) { return; } telemetryLifecycle?.onClose?.(details, context); handlers?.onClose?.(details, context); }, }; } export function createWsRpcProtocolLayer( url: WsRpcProtocolSocketUrlProvider, handlers?: WsProtocolLifecycleHandlers, options?: WsRpcProtocolOptions, ) { const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; const requestTelemetry = options?.requestTelemetry; const resolvedUrl = typeof url === "function" ? Effect.promise(() => url()).pipe( Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), Effect.tapError((error) => Effect.sync(() => { lifecycle.onError(formatSocketErrorMessage(error)); }), ), Effect.orDie, ) : resolveWsRpcSocketUrl(url); const trackingWebSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, (socketUrl, protocols) => { lifecycle.onAttempt(socketUrl); const socket = new globalThis.WebSocket(socketUrl, protocols); socket.addEventListener( "open", () => { lifecycle.onOpen(); }, { once: true }, ); socket.addEventListener( "error", () => { lifecycle.onError("Unable to connect to the T3 server WebSocket."); }, { once: true }, ); socket.addEventListener("message", (event) => { if (Option.isSome(decodeRpcPongMessage(String(event.data)))) { lifecycle.onHeartbeatPong(); } }); socket.addEventListener( "close", (event) => { lifecycle.onClose( { code: event.code, reason: event.reason, }, { intentional: lifecycle.isCloseIntentional(), }, ); }, { once: true }, ); return socket; }, ); const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( Layer.provide(trackingWebSocketConstructorLayer), ); const baseSchedule = Option.match(backoff.maxRetries, { onNone: () => Schedule.forever, onSome: Schedule.recurs, }); const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => Effect.succeed( Option.match(getReconnectDelay(retryCount, backoff), { onNone: () => Duration.zero, onSome: (delay) => delay, }), ), ); const protocolLayer = Layer.effect( RpcClient.Protocol, Effect.map( RpcClient.makeProtocolSocket({ retryPolicy, retryTransientErrors: true, }), (protocol) => ({ ...protocol, run: (clientId, writeResponse) => protocol.run(clientId, (response) => { if (response._tag === "Chunk" || response._tag === "Exit") { requestTelemetry?.onRequestAcknowledged?.(response.requestId); } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { requestTelemetry?.onClearTrackedRequests?.(); } return writeResponse(response); }), send: (clientId, request, transferables) => { if (request._tag === "Request") { requestTelemetry?.onRequestSent?.(request.id, request.tag); if (lifecycle.isActive()) { handlers?.onRequestStart?.({ id: request.id, tag: request.tag, stream: false, }); } } return protocol.send(clientId, request, transferables); }, }), ), ); const connectionHooksLayer = Layer.succeed( RpcClient.ConnectionHooks, RpcClient.ConnectionHooks.of({ onConnect: Effect.void, onDisconnect: Effect.void, }), ); return protocolLayer.pipe( Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), ); }