// @effect-diagnostics nodeBuiltinImport:off - Measures the real Node HTTP and WebSocket transports. import * as NodeHttp from "node:http"; import * as NodeZlib from "node:zlib"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import { WsRpcGroup } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; export class TransferHttpRequestError extends Schema.TaggedError()( "TransferHttpRequestError", { url: Schema.String, cause: Schema.Defect(), }, ) {} export interface HttpTransferMeasurement { readonly status: number; readonly contentEncoding: string | null; readonly encodedBody: Uint8Array; readonly encodedBodyBytes: number; readonly decodedBody: Uint8Array; readonly decodedBodyBytes: number; /** HTTP response bytes read from the socket, including status line and headers. */ readonly wireBytes: number; } export const measureHttpGet = Effect.fn("TransferBudget.measureHttpGet")(function* (input: { readonly url: string; readonly headers?: Readonly>; }) { return yield* Effect.tryPromise({ try: () => new Promise((resolve, reject) => { let socketBytesBeforeResponse = 0; const request = NodeHttp.get( input.url, { agent: false, headers: { "accept-encoding": "gzip", connection: "close", ...input.headers, }, }, (response) => { const chunks: Buffer[] = []; response.on("data", (chunk: Buffer) => chunks.push(chunk)); response.once("error", reject); response.once("end", () => { try { const encodedBody = Buffer.concat(chunks); const header = response.headers["content-encoding"]; const contentEncoding = Array.isArray(header) ? (header[0] ?? null) : (header ?? null); const decodedBody = contentEncoding === "gzip" ? NodeZlib.gunzipSync(encodedBody) : encodedBody; resolve({ status: response.statusCode ?? 0, contentEncoding, encodedBody, encodedBodyBytes: encodedBody.byteLength, decodedBody, decodedBodyBytes: decodedBody.byteLength, wireBytes: Math.max(0, response.socket.bytesRead - socketBytesBeforeResponse), }); } catch (cause) { reject(cause); } }); }, ); request.once("socket", (socket) => { socketBytesBeforeResponse = socket.bytesRead; }); request.once("error", reject); request.setTimeout(10_000, () => { request.destroy(new Error(`Timed out reading ${input.url}`)); }); }), catch: (cause) => new TransferHttpRequestError({ url: input.url, cause }), }); }); export interface WebSocketTransferTotals { readonly wireBytes: number; readonly decodedBytes: number; readonly messages: number; } export interface WebSocketTransferRecorder { readonly connect: ( url: string, protocols: string | string[] | undefined, cookie: string, ) => globalThis.WebSocket; readonly totals: () => WebSocketTransferTotals; readonly negotiatedExtensions: () => string; /** Resolves once the upgrade completes, so totals taken after it exclude the upgrade response. */ readonly awaitOpen: Effect.Effect; } interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket { readonly _socket?: { readonly bytesRead: number; }; } function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { if (Array.isArray(data)) { return data.reduce((total, chunk) => total + chunk.byteLength, 0); } return data.byteLength; } function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { let socket: NodeWebSocketWithTransport | null = null; // Held separately from the WebSocket so wire totals survive a close, which // is when a reconnect measurement reads them. let transport: NodeWebSocketWithTransport["_socket"] | null = null; let decodedBytes = 0; let messages = 0; let resolveOpen: () => void = () => {}; const opened = new Promise((resolve) => { resolveOpen = resolve; }); return { connect: (url, protocols, cookie) => { const nextSocket = new NodeSocket.NodeWS.WebSocket(url, protocols, { headers: { cookie }, perMessageDeflate: true, }) as NodeWebSocketWithTransport; socket = nextSocket; nextSocket.once("open", () => { transport = nextSocket._socket ?? null; resolveOpen(); }); nextSocket.on("message", (data) => { const bytes = rawDataBytes(data); decodedBytes += bytes; messages += 1; }); return nextSocket as unknown as globalThis.WebSocket; }, totals: () => ({ wireBytes: transport?.bytesRead ?? socket?._socket?.bytesRead ?? 0, decodedBytes, messages, }), negotiatedExtensions: () => socket?.extensions ?? "", awaitOpen: Effect.promise(() => opened).pipe( Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.die(new Error("Timed out waiting for the WebSocket to open")), }), ), }; } export function transferDelta( start: WebSocketTransferTotals, end: WebSocketTransferTotals, ): WebSocketTransferTotals { return { wireBytes: Math.max(0, end.wireBytes - start.wireBytes), decodedBytes: Math.max(0, end.decodedBytes - start.decodedBytes), messages: Math.max(0, end.messages - start.messages), }; } function countingWsRpcProtocolLayer(input: { readonly url: string; readonly cookie: string; readonly recorder: WebSocketTransferRecorder; }) { const webSocketConstructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => input.recorder.connect(url, protocols, input.cookie), ); return RpcClient.layerProtocolSocket().pipe( Layer.provide( Socket.layerWebSocket(input.url, { openTimeout: "10 seconds" }).pipe( Layer.provide(webSocketConstructorLayer), ), ), Layer.provide(RpcSerialization.layerJson), ); } const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); export type CountingWsRpcClient = Effect.Success; export interface MeasuredWsClient { readonly client: CountingWsRpcClient; readonly recorder: WebSocketTransferRecorder; /** Fork subscription consumers here so they stop before the socket closes. */ readonly scope: Scope.Scope; /** Closes the socket now. The enclosing scope closes it otherwise. */ readonly close: Effect.Effect; } /** * Opens one WebSocket RPC client on a child of the current scope. Several * clients can share one test scope and still disconnect independently, which * a reconnect measurement needs. */ export const openMeasuredWsClient = Effect.fn("TransferBudget.openMeasuredWsClient")( function* (input: { readonly url: string; readonly cookie: string }) { const recorder = makeWebSocketTransferRecorder(); const parent = yield* Effect.scope; const scope = yield* Scope.fork(parent); const protocol = yield* Layer.buildWithScope( countingWsRpcProtocolLayer({ url: input.url, cookie: input.cookie, recorder }), scope, ); const client = yield* makeCountingWsRpcClient.pipe( Effect.provide(protocol), Scope.provide(scope), ); yield* recorder.awaitOpen; return { client, recorder, scope, close: Scope.close(scope, Exit.void), } satisfies MeasuredWsClient; }, );