/** * Models bidirectional socket connections in Effect. * * A `Socket` exposes a pull-based `reader` and a scoped `writer`. Acquiring * the reader dials the connection and returns an `Effect` that yields batches * of incoming frames with end-to-end backpressure: nothing is read from the * transport until the consumer pulls. Every termination, including clean * closes, surfaces as a `SocketError`, so reconnecting is a plain * `Effect.retry` around the scoped consume loop. * * @stability unstable * @since 4.0.0 */ import type { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.ts" import type * as Cause from "../Cause.ts" import * as Channel from "../Channel.ts" import * as Context from "../Context.ts" import type * as Duration from "../Duration.ts" import * as Effect from "../Effect.ts" import * as Exit from "../Exit.ts" import { constVoid, dual, flow } from "../Function.ts" import * as Latch from "../Latch.ts" import * as Layer from "../Layer.ts" import * as Predicate from "../Predicate.ts" import * as Pull from "../Pull.ts" import type * as Redacted from "../Redacted.ts" import * as Schema from "../Schema.ts" import * as Scope from "../Scope.ts" import * as Stream from "../Stream.ts" /** * Runtime type identifier attached to `Socket` services. * * @stability unstable * @category type IDs * @since 4.0.0 */ export const TypeId = "~effect/socket/Socket" /** * Returns `true` when a value is a `Socket`. * * @stability unstable * @category guards * @since 4.0.0 */ export const isSocket = (u: unknown): u is Socket => Predicate.hasProperty(u, TypeId) /** * Service tag for bidirectional socket transports. * * **When to use** * * Use to access or provide the socket implementation used by programs that * read and write frames through the Effect environment. * * @stability unstable * @category services * @since 4.0.0 */ export const Socket: Context.Service = Context.Service("effect/socket/Socket") /** * Effect-based socket abstraction exposing a pull-based read side and a * scoped writer. * * **Details** * * Acquiring `reader` establishes the connection; the scope of the acquisition * owns the connection lifecycle. Its `pull` yields non-empty batches of incoming * frames and never completes via `Cause.Done`: every termination, clean close * included, fails with a `SocketError` wrapping the close reason. Code placed * between the acquisition and the first pull runs exactly once per * (re)connection, which makes handshakes plain code placement. * * Closing the acquisition scope must fail a pull that is currently suspended, * rather than leaving it blocked. Consumers such as `toChannel` rely on this * to shut a connection down without racing every pull against a separate * failure signal. * * The writer is detached from any single connection: acquiring it cannot * fail, and writes made while disconnected suspend until the next connection * is established. Releasing the writer scope half-closes the write side * where the transport supports it. * * **Example** (Consuming with automatic reconnect) * * ```ts skip-type-checking * Effect.gen(function*() { * const { pull } = yield* socket.reader * while (true) { * yield* handle(yield* pull) * } * }).pipe( * Effect.scoped, * Effect.retry({ schedule: Schedule.exponential(200) }) * ) * ``` * * @stability unstable * @category models * @since 4.0.0 */ export interface Socket { readonly [TypeId]: typeof TypeId readonly reader: Effect.Effect readonly writer: Effect.Effect } /** * The read side of a live `Socket` connection. * * **Details** * * `pull` reads the next non-empty batch. `upgrade` wraps this connection with * TLS when the transport supports it. Unsupported readers fail with a * `SocketUpgradeError`. The upgrade also fails with `SocketUpgradeError` when * the selected TLS role requires an identity but `key` and `cert` are not both * provided. Calling `upgrade()` without an options object uses the adapter's * client defaults. * * @stability unstable * @category models * @since 4.0.0 */ export interface Reader { readonly pull: Effect.Effect, SocketError> readonly upgrade: (options?: TlsUpgradeOptions) => Effect.Effect } /** * The write side of a `Socket`. * * **Details** * * `write` sends a single frame or a `CloseEvent`; `writeAll` sends a batch of * frames, allowing transports to coalesce them into a single flush. Both * apply the transport's native backpressure before succeeding. * * @stability unstable * @category models * @since 4.0.0 */ export interface Writer { readonly write: (chunk: Uint8Array | string | CloseEvent) => Effect.Effect readonly writeAll: (chunks: NonEmptyReadonlyArray) => Effect.Effect } /** * TLS credentials and handshake settings used to upgrade a live socket. * * **Details** * * `key` and `cert` are optional for client upgrades that do not present a * client certificate. Server upgrades require both, and providing only one is * invalid. Missing or incomplete credentials fail the upgrade with a * `SocketUpgradeError` when the adapter needs an identity. * * @stability unstable * @category models * @since 4.0.0 */ export interface TlsUpgradeOptions { /** * DNS hostname used for Server Name Indication (SNI) and certificate identity * verification during client upgrades. Server upgrades ignore this option. * When omitted, the adapter retains its existing hostname behavior. */ readonly servername?: string | undefined readonly key?: | Redacted.Redacted | ReadonlyArray> | undefined readonly cert?: string | Uint8Array | ReadonlyArray | undefined readonly ca?: string | Uint8Array | ReadonlyArray | undefined readonly passphrase?: Redacted.Redacted | undefined readonly alpnProtocols?: ReadonlyArray | undefined readonly requestCert?: boolean | undefined readonly rejectUnauthorized?: boolean | undefined } /** * Constructs a `Socket` from a reader acquisition and a scoped writer. * * **Details** * * The reader must fail a suspended pull when its acquisition scope closes; see * `Socket` for why. A reader that leaves a pull blocked forever will hang any * consumer that shuts the socket down by closing that scope. * * @stability unstable * @category constructors * @since 4.0.0 */ export const make = (options: { readonly reader: Socket["reader"] readonly writer: Socket["writer"] }): Socket => Socket.of({ [TypeId]: TypeId, reader: options.reader, writer: options.writer }) const encoder = new TextEncoder() /** * Acquires the socket's binary `pull`, encoding any string frames as UTF-8 * bytes. * * **Details** * * When a pulled batch contains no string frames it is returned as-is, so * transports that only emit bytes (TCP) pay no per-chunk cost. * * @stability unstable * @category combinators * @since 4.0.0 */ export const readerBytes = ( self: Socket ): Effect.Effect< Effect.Effect, SocketError>, SocketError, Scope.Scope > => Effect.map(self.reader, ({ pull }) => Effect.map(pull, (chunk) => { for (let i = 0; i < chunk.length; i++) { if (typeof chunk[i] === "string") { const out = new Array(chunk.length) as NonEmptyArray for (let j = 0; j < chunk.length; j++) { const item = chunk[j] out[j] = typeof item === "string" ? encoder.encode(item) : item } return out } } return chunk as NonEmptyReadonlyArray })) /** * Acquires the socket's string `pull`, decoding binary frames with the optional * text encoding. * * **Details** * * Each acquisition has its own streaming decoder. Characters may span binary * frames and pulls; each frame produces one string, possibly empty. A string * frame flushes incomplete characters as U+FFFD before its text. Close code 1000 * flushes remaining text once before propagating the close error; other failures * do not flush. * * **Gotchas** * * Binary WebSocket messages share decoder state: incomplete characters carry * into the next binary frame, and BOM handling resets only after a flush. Use * `readerBytes` and decode each frame separately for per-message decoding. * * @stability unstable * @category combinators * @since 4.0.0 */ export const readerString = ( self: Socket, encoding?: string | undefined ): Effect.Effect< Effect.Effect, SocketError>, SocketError, Scope.Scope > => Effect.map(self.reader, ({ pull }) => { const decoder = new TextDecoder(encoding) const streamOptions = { stream: true } let needsFlush = false return Effect.map(pull, (chunk) => { const out = new Array(chunk.length) for (let i = 0; i < chunk.length; i++) { const item = chunk[i] if (typeof item === "string") { out[i] = needsFlush ? decoder.decode() + item : item needsFlush = false } else { out[i] = decoder.decode(item, streamOptions) needsFlush = true } } return out as unknown as NonEmptyReadonlyArray }).pipe( Effect.catch((error) => { if (needsFlush && error.reason._tag === "SocketCloseError" && error.reason.code === 1000) { needsFlush = false const last = decoder.decode() if (last.length > 0) return Effect.succeed([last] as const) } return Effect.fail(error) }) ) }) const CloseEventTypeId = "~effect/socket/Socket/CloseEvent" /** * Represents a socket close event value carrying a close code and optional * reason. * * @stability unstable * @category models * @since 4.0.0 */ export class CloseEvent { /** * Marks this value as a socket close event for runtime guards. * * @stability unstable * @since 4.0.0 */ readonly [CloseEventTypeId]: typeof CloseEventTypeId readonly code: number readonly reason?: string | undefined constructor(code = 1000, reason?: string) { this[CloseEventTypeId] = CloseEventTypeId this.code = code this.reason = reason } /** * Formats the close code and optional reason for display. * * @stability unstable * @since 4.0.0 */ toString() { return this.reason ? `${this.code}: ${this.reason}` : `${this.code}` } } /** * Returns `true` when a value is a `CloseEvent`. * * @stability unstable * @category guards * @since 4.0.0 */ export const isCloseEvent = (u: unknown): u is CloseEvent => Predicate.hasProperty(u, CloseEventTypeId) /** * Type-level identifier used to mark `SocketError` values. * * @stability unstable * @category type IDs * @since 4.0.0 */ export type SocketErrorTypeId = "~effect/socket/Socket/SocketError" /** * Runtime type identifier attached to `SocketError` values. * * @stability unstable * @category type IDs * @since 4.0.0 */ export const SocketErrorTypeId: SocketErrorTypeId = "~effect/socket/Socket/SocketError" /** * Returns `true` when a value is a `SocketError`. * * @stability unstable * @category guards * @since 4.0.0 */ export const isSocketError = (u: unknown): u is SocketError => Predicate.hasProperty(u, SocketErrorTypeId) /** * Typed error for failures that occur while reading from a socket. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketReadError extends Schema.Error("effect/socket/Socket/SocketReadError")({ _tag: Schema.tag("SocketReadError"), cause: Schema.Defect() }) { /** * Default message used for socket read failures. * * @stability unstable * @since 4.0.0 */ override readonly message = `An error occurred during Read` } /** * Typed error for failures that occur while writing to a socket. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketWriteError extends Schema.Error("effect/socket/Socket/SocketWriteError")({ _tag: Schema.tag("SocketWriteError"), cause: Schema.Defect() }) { /** * Default message used for socket write failures. * * @stability unstable * @since 4.0.0 */ override readonly message = `An error occurred during Write` } /** * Typed error for failures that occur while opening a socket, including * unknown open failures and open timeouts. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketOpenError extends Schema.Error("effect/socket/Socket/SocketOpenError")({ _tag: Schema.tag("SocketOpenError"), kind: Schema.Literals(["Unknown", "Timeout"]), cause: Schema.Defect() }) { /** * Formats timeout and unknown open failures for display. * * @stability unstable * @since 4.0.0 */ override get message() { return this.kind === "Timeout" ? `timeout waiting for "open"` : `An error occurred during Open` } } /** * Typed error for an unsupported or failed in-place TLS upgrade. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketUpgradeError extends Schema.Error( "effect/socket/Socket/SocketUpgradeError" )({ _tag: Schema.tag("SocketUpgradeError"), cause: Schema.optional(Schema.Defect()) }) { /** * An upgrade implementation for transports that cannot wrap the connection * with TLS. * * @stability unstable * @since 4.0.0 */ static readonly unsupported: Reader["upgrade"] = () => Effect.fail(new SocketError({ reason: new SocketUpgradeError({}) })) override get message() { return this.cause === undefined ? `Socket does not support TLS upgrade` : `An error occurred during TLS upgrade` } } /** * Typed error for a socket close, carrying the close code and optional close * reason. * * **Details** * * Sockets never classify closes: any close, whatever the code, fails the * reader with a `SocketError` wrapping this reason. Consumers that treat a * close as normal catch it. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketCloseError extends Schema.Error("effect/socket/Socket/SocketCloseError")({ _tag: Schema.tag("SocketCloseError"), code: Schema.Int, closeReason: Schema.optional(Schema.String) }) { override get message() { if (this.closeReason) { return `${this.code}: ${this.closeReason}` } return `${this.code}` } } /** * Schema for all socket-specific error reasons. * * @stability unstable * @category errors * @since 4.0.0 */ export const SocketErrorReason = Schema.Union([ SocketReadError, SocketWriteError, SocketOpenError, SocketUpgradeError, SocketCloseError ]) /** * Union of socket-specific read, write, open, upgrade, and close error reasons. * * @stability unstable * @category errors * @since 4.0.0 */ export type SocketErrorReason = | SocketReadError | SocketWriteError | SocketOpenError | SocketUpgradeError | SocketCloseError /** * Tagged error that wraps socket read, write, open, upgrade, and close failures * while preserving the underlying reason. * * @stability unstable * @category errors * @since 4.0.0 */ export class SocketError extends Schema.TaggedError(SocketErrorTypeId)("SocketError", { _tag: Schema.tag("SocketError"), reason: SocketErrorReason }) { // @effect-diagnostics-next-line overriddenSchemaConstructor:off constructor(props: { readonly reason: SocketReadError | SocketWriteError | SocketOpenError | SocketUpgradeError | SocketCloseError }) { if ("cause" in props.reason) { super({ ...props, cause: props.reason.cause } as any) } else { super(props) } } /** * Marks this value as a socket error wrapper for runtime guards. * * @stability unstable * @since 4.0.0 */ readonly [SocketErrorTypeId]: SocketErrorTypeId = SocketErrorTypeId /** * Returns `true` when the value is a `SocketError`. * * @stability unstable * @since 4.0.0 */ static is(u: unknown): u is SocketError { return isSocketError(u) } override readonly message = this.reason.message } const closeError = (code: number, closeReason?: string | undefined) => new SocketError({ reason: new SocketCloseError({ code, closeReason }) }) const writeChunk = ( writer: Writer, chunk: NonEmptyReadonlyArray ): Effect.Effect => { for (let i = 0; i < chunk.length; i++) { if (isCloseEvent(chunk[i])) { return Effect.gen(function*() { for (let index = 0; index <= i; index++) { yield* writer.write(chunk[index]) } }) } } return writer.writeAll(chunk as NonEmptyReadonlyArray) } const toChannelWithReader = ( self: Socket, reader: Effect.Effect< Effect.Effect, SocketError>, SocketError, Scope.Scope > ): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > => Channel.fromTransform(Effect.fnUntraced(function*(upstream, scope) { const readScope = yield* Scope.fork(scope) const pull = yield* Scope.provide(reader, readScope) const writeScope = yield* Scope.fork(scope) const writer = yield* Scope.provide(self.writer, writeScope) let writeFailure: Cause.Cause | undefined yield* upstream.pipe( Effect.flatMap((chunk) => writeChunk(writer, chunk)), Effect.forever({ disableYield: true }), Effect.catchCauseFilter( Pull.filterNoDone, (cause) => Effect.suspend(() => { writeFailure = cause as Cause.Cause // closing the read scope fails a suspended pull, which is the // reader contract, so the read side needs no per-pull failure race return Scope.close(readScope, Exit.void) }) ), Effect.ensuring(Scope.close(writeScope, Exit.void)), Effect.forkIn(scope) ) // @effect-diagnostics-next-line returnEffectInGen:off return Effect.catchCause( Effect.suspend( (): Pull.Pull, SocketError | IE> => writeFailure !== undefined ? Effect.failCause(writeFailure) : pull ), (cause) => Effect.failCause( (writeFailure ?? cause) as Cause.Cause> ) ) })) /** * Converts a `Socket` into a bidirectional binary `Channel`, encoding * incoming string frames as UTF-8 bytes and writing outgoing frame batches to * the socket. * * **Details** * * The read side is the socket's pull, so the channel is backpressured * end-to-end. * * @stability unstable * @category combinators * @since 4.0.0 */ export const toChannel = ( self: Socket ): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > => toChannelWithReader(self, readerBytes(self)) /** * Converts a `Socket` into a bidirectional string `Channel`, decoding binary * frames with the optional text encoding. * * **Details** * * Uses `readerString` for decoding and close handling. * * @stability unstable * @category combinators * @since 4.0.0 */ export const toChannelString: { /** * Converts a `Socket` into a bidirectional string `Channel`, decoding binary * frames with the optional text encoding. * * **Details** * * Uses `readerString` for decoding and close handling. * * @stability unstable * @category combinators * @since 4.0.0 */ (encoding?: string | undefined): (self: Socket) => Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > /** * Converts a `Socket` into a bidirectional string `Channel`, decoding binary * frames with the optional text encoding. * * **Details** * * Uses `readerString` for decoding and close handling. * * @stability unstable * @category combinators * @since 4.0.0 */ (self: Socket, encoding?: string | undefined): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > } = dual((args) => isSocket(args[0]), ( self: Socket, encoding?: string | undefined ): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > => toChannelWithReader(self, readerString(self, encoding))) /** * Creates a `Socket` to binary `Channel` adapter with a fixed upstream error * type. * * @stability unstable * @category combinators * @since 4.0.0 */ export const toChannelWith = () => ( self: Socket ): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE > => toChannel(self) /** * Converts a `Socket` into a read-only binary `Stream` backed by the socket's * pull, so consumption is backpressured end-to-end. * * @stability unstable * @category combinators * @since 4.0.0 */ export const toStream = (self: Socket): Stream.Stream => Stream.fromChannel( Channel.fromTransform((_, scope) => Scope.provide(readerBytes(self), scope)) ) /** * Creates a binary socket `Channel` from the `Socket` service in the * environment. * * @stability unstable * @category constructors * @since 4.0.0 */ export const makeChannel = (): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE, unknown, Socket > => Channel.unwrap(Effect.map(Socket, toChannelWith())) /** * Event payload exposed by a WebSocket implementation. * * **Details** * * The socket adapter only reads `data`, `code`, and `reason`; implementations * may expose additional fields. * * @stability unstable * @category models * @since 4.0.0 */ export interface WebSocketEvent { readonly type?: string readonly data?: unknown readonly code?: number readonly reason?: string } /** * The subset of the WebSocket API required by `Socket`. * * **Details** * * This structural interface is intentionally independent of the DOM * `WebSocket` type. Node implementations such as `ws` expose the same * event-target methods, but are not assignable to `globalThis.WebSocket` * because their event payload types are runtime-specific. * * @stability unstable * @category models * @since 4.0.0 */ export interface WebSocketLike { readonly readyState: number addEventListener( type: "open" | "message" | "error" | "close", listener: (event: WebSocketEvent) => void, options?: { readonly once?: boolean } ): void removeEventListener( type: "open" | "message" | "error" | "close", listener: (event: WebSocketEvent) => void ): void close(code?: number, reason?: string): void send(data: string | Uint8Array): void } /** * Client options for Node and Bun WebSocket implementations, allowing * handshake headers and subprotocols to be supplied together. * * @stability unstable * @category models * @since 4.0.0 */ export interface WebSocketClientOptions { /** * Subprotocols to offer in the opening handshake. */ readonly protocols?: string | Array | undefined /** * Headers to include in the opening handshake. * * **Details** * * This is supported by Node and Bun WebSocket clients. Browser constructors * cannot set arbitrary handshake headers and must reject this option. */ readonly headers?: Readonly> | undefined } /** * Options accepted by a `WebSocketConstructor`. * * **Details** * * Browser-compatible constructors accept a protocol string or list. Node and * Bun constructors additionally accept `WebSocketClientOptions`. * * @stability unstable * @category models * @since 4.0.0 */ export type WebSocketConstructorOptions = string | Array | WebSocketClientOptions /** * Context service for the active `WebSocket` instance. * * @stability unstable * @category services * @since 4.0.0 */ export class WebSocket extends Context.Service()( "~effect/socket/Socket/WebSocket" ) {} /** * Context service for constructing `WebSocket` instances from a URL and * optional protocols or platform-specific options. * * @stability unstable * @category services * @since 4.0.0 */ export class WebSocketConstructor extends Context.Service< WebSocketConstructor, (url: string, options?: WebSocketConstructorOptions | undefined) => WebSocketLike >()("@effect/platform/Socket/WebSocketConstructor") {} /** * Layer that provides `WebSocketConstructor` using `globalThis.WebSocket`. * Rejects client options objects, including those with handshake headers. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerWebSocketConstructorGlobal: Layer.Layer = Layer.succeed(WebSocketConstructor)( (url, options) => { if (options !== undefined && typeof options !== "string" && !Array.isArray(options)) { throw new TypeError("WebSocket client options are not supported by the global WebSocket constructor") } return new globalThis.WebSocket(url, options) } ) /** * Creates a `Socket` backed by a `WebSocketConstructor`, dialing the * WebSocket for each reader acquisition. * Optional handshake headers require a Node or Bun constructor; without * headers, protocols are passed directly for browser compatibility. * * @stability unstable * @category constructors * @since 4.0.0 */ export const makeWebSocket = (url: string | Effect.Effect, options?: { readonly openTimeout?: Duration.Input | undefined readonly protocols?: string | Array | undefined readonly headers?: Readonly> | undefined readonly highWaterMark?: number | undefined }): Effect.Effect => WebSocketConstructor.use((makeWs) => fromWebSocket( Effect.acquireRelease( (typeof url === "string" ? Effect.succeed(url) : url).pipe( Effect.map((url) => makeWs( url, options?.headers === undefined ? options?.protocols : { protocols: options.protocols, headers: options.headers } ) ) ), (ws) => Effect.sync(() => ws.close(1000)) ), options ) ) interface Pausable { pause(): void resume(): void } const isPausable = (ws: WebSocketLike): ws is WebSocketLike & Pausable => "pause" in ws && typeof (ws as any).pause === "function" /** * Builds a `Socket` from a scoped WebSocket acquisition effect. * * **Details** * * Reader acquisition runs `acquire`, attaches event listeners, then waits for * the socket to open. Implementations exposing `pause`/`resume` (the `ws` * package) remain flowing until buffered frames reach the configured * `highWaterMark` (64 KiB by default). Draining the buffer resumes the * transport. Incoming frames that arrive in the same tick are coalesced into * one batch. Implementations without pause (browsers) buffer incoming frames, * optionally failing the socket with a `SocketReadError` when `highWaterMark` * bytes are exceeded (default unbounded). Message boundaries survive: each * pulled batch contains one element per frame. * * @stability unstable * @category constructors * @since 4.0.0 */ export const fromWebSocket = ( acquire: Effect.Effect, options?: { readonly openTimeout?: Duration.Input | undefined readonly highWaterMark?: number | undefined } | undefined ): Effect.Effect> => Effect.withFiber((fiber) => { let currentWS: WebSocketLike | undefined const latch = Latch.makeUnsafe(false) const acquireContext = fiber.context as Context.Context const reader: Socket["reader"] = Effect.gen(function*() { const scope = yield* Effect.scope const ws = yield* Scope.provide(acquire, scope) if ("binaryType" in ws) { ;(ws as { binaryType: string }).binaryType = "arraybuffer" } const pausable = isPausable(ws) const highWaterMark = options?.highWaterMark ?? (pausable ? defaultHighWaterMark : undefined) type ReadResume = ( effect: Effect.Effect, SocketError> ) => void let open = ws.readyState === 1 let paused = pausable && "isPaused" in ws && ws.isPaused === true let buffer: Array = [] let bufferSize = 0 let error: SocketError | undefined let waiter: ReadResume | undefined let openWaiter: | { readonly resume: (effect: Effect.Effect) => void readonly cleanup: () => void } | undefined function pauseWebSocket() { if (!pausable || paused) return ws.pause() paused = true } function resumeWebSocket() { if (!pausable || !paused) return ws.resume() paused = false } function takeBuffer(): NonEmptyReadonlyArray { const chunk = buffer buffer = [] bufferSize = 0 if (error === undefined) resumeWebSocket() return chunk as unknown as NonEmptyReadonlyArray } function deliver() { if (waiter === undefined || buffer.length === 0) return const resume = waiter waiter = undefined resume(Effect.succeed(takeBuffer())) } function push(data: Uint8Array | string) { buffer.push(data) if (highWaterMark !== undefined) { bufferSize += typeof data === "string" ? encoder.encode(data).byteLength : data.byteLength } if (waiter !== undefined) deliver() if (pausable && bufferSize >= highWaterMark!) { pauseWebSocket() } else if (!pausable && !waiter && highWaterMark !== undefined && bufferSize > highWaterMark) { fail( new SocketError({ reason: new SocketReadError({ cause: new Error(`Socket highWaterMark of ${highWaterMark} bytes exceeded`) }) }) ) } } function fail(err: SocketError) { if (error === undefined) error = err if (openWaiter !== undefined) { const { cleanup, resume } = openWaiter openWaiter = undefined cleanup() resume(Effect.fail(error)) } if (waiter !== undefined) { const resume = waiter waiter = undefined resume(buffer.length > 0 ? Effect.succeed(takeBuffer()) : Effect.fail(error)) } } function onMessage(event: WebSocketEvent) { const data = event.data as Uint8Array | ArrayBuffer | Blob | string if (typeof Blob !== "undefined" && data instanceof Blob) { data.arrayBuffer().then((buf) => push(new Uint8Array(buf)), (cause) => { fail(new SocketError({ reason: new SocketReadError({ cause }) })) }) return } push(data instanceof ArrayBuffer ? new Uint8Array(data) : data as Uint8Array | string) } function onError(event: WebSocketEvent) { fail( new SocketError({ reason: open ? new SocketReadError({ cause: event }) : new SocketOpenError({ kind: "Unknown", cause: event }) }) ) } function onClose(event: WebSocketEvent) { fail(closeError(typeof event.code === "number" ? event.code : 1006, event.reason)) } ws.addEventListener("message", onMessage) ws.addEventListener("error", onError, { once: true }) ws.addEventListener("close", onClose, { once: true }) yield* Scope.addFinalizer( scope, Effect.sync(() => { // resume a pull blocked in another fiber before detaching fail(closeError(1006)) ws.removeEventListener("message", onMessage) // Keep late errors handled when release aborts a pending ws handshake. ws.addEventListener("error", constVoid) ws.removeEventListener("error", onError) ws.removeEventListener("close", onClose) // let the close handshake proceed once nothing is pulling if (pausable) { try { resumeWebSocket() } catch { // the underlying stream may already be gone } } latch.closeUnsafe() currentWS = undefined }) ) if (ws.readyState >= 2) { // @effect-diagnostics-next-line unnecessaryFailYieldableError:off return yield* Effect.fail(error ?? closeError(1006)) } if (!open) { yield* Effect.callback((resume) => { const onOpen = () => { if (openWaiter?.resume !== resume) return openWaiter = undefined ws.removeEventListener("open", onOpen) resume(Effect.void) } const cleanup = () => ws.removeEventListener("open", onOpen) openWaiter = { resume, cleanup } ws.addEventListener("open", onOpen, { once: true }) return Effect.sync(() => { if (openWaiter?.resume === resume) openWaiter = undefined cleanup() }) }).pipe( Effect.timeoutOrElse({ duration: options?.openTimeout ?? 10000, orElse: () => Effect.fail( new SocketError({ reason: new SocketOpenError({ kind: "Timeout", cause: new Error("timeout waiting for \"open\"") }) }) ) }) ) open = true } if (error === undefined && bufferSize < highWaterMark!) resumeWebSocket() currentWS = ws latch.openUnsafe() return { pull: Effect.callback, SocketError>((resume) => { if (buffer.length > 0) return resume(Effect.succeed(takeBuffer())) if (error !== undefined) return resume(Effect.fail(error)) waiter = resume return Effect.sync(() => { if (waiter === resume) waiter = undefined }) }), upgrade: SocketUpgradeError.unsupported } }).pipe( Effect.updateContext((input: Context.Context) => Context.merge(acquireContext, input)) ) as Socket["reader"] const write = (chunk: Uint8Array | string | CloseEvent): Effect.Effect => Effect.suspend(() => { const ws = currentWS if (ws === undefined) return latch.whenOpen(write(chunk)) try { if (isCloseEvent(chunk)) { ws.close(chunk.code, chunk.reason) } else { ws.send(chunk as string | Uint8Array) } return Effect.void } catch (cause) { return Effect.fail(new SocketError({ reason: new SocketWriteError({ cause }) })) } }) const writeAll = ( chunks: NonEmptyReadonlyArray ): Effect.Effect => Effect.suspend(() => { const ws = currentWS if (ws === undefined) return latch.whenOpen(writeAll(chunks)) try { for (let i = 0; i < chunks.length; i++) { ws.send(chunks[i] as string | Uint8Array) } return Effect.void } catch (cause) { return Effect.fail(new SocketError({ reason: new SocketWriteError({ cause }) })) } }) const writer: Socket["writer"] = Effect.succeed({ write, writeAll }) return Effect.succeed(make({ reader, writer })) }) const defaultHighWaterMark = 64 * 1024 /** * Creates a binary `Channel` backed by a WebSocket URL, requiring a * `WebSocketConstructor` service. * Supports subprotocols and, with a Node or Bun constructor, handshake headers. * * @stability unstable * @category constructors * @since 4.0.0 */ export const makeWebSocketChannel = ( url: string, options?: { readonly openTimeout?: Duration.Input | undefined readonly protocols?: string | Array | undefined readonly headers?: Readonly> | undefined readonly highWaterMark?: number | undefined } ): Channel.Channel< NonEmptyReadonlyArray, SocketError | IE, void, NonEmptyReadonlyArray, IE, unknown, WebSocketConstructor > => Channel.unwrap( Effect.map(makeWebSocket(url, options), toChannelWith()) ) /** * Layer that provides a `Socket` service backed by a WebSocket URL or URL * effect. * Supports subprotocols and, with a Node or Bun constructor, handshake headers. * * @stability unstable * @category layers * @since 4.0.0 */ export const layerWebSocket: ( url: string | Effect.Effect, options?: { readonly openTimeout?: Duration.Input | undefined readonly protocols?: string | Array | undefined readonly headers?: Readonly> | undefined readonly highWaterMark?: number | undefined } | undefined ) => Layer.Layer = flow(makeWebSocket, Layer.effect(Socket)) /** * Readable and writable stream pair used to adapt transform-style streams into * a `Socket`. * * @stability unstable * @category models * @since 4.0.0 */ export interface InputTransformStream { readonly readable: ReadableStream | ReadableStream | ReadableStream readonly writable: WritableStream } /** * Builds a `Socket` from a scoped `InputTransformStream`, pulling incoming * chunks from the readable side and writing outgoing chunks to the writable * stream, encoding strings as UTF-8. * * @stability unstable * @category constructors * @since 4.0.0 */ export const fromTransformStream = ( acquire: Effect.Effect ): Effect.Effect> => Effect.withFiber((fiber) => { const latch = Latch.makeUnsafe(false) let currentStream: { readonly stream: InputTransformStream readonly fail: (error: SocketError) => void } | undefined const acquireContext = fiber.context as Context.Context const writers = new WeakMap>() const getWriter = (stream: InputTransformStream) => { let writer = writers.get(stream) if (!writer) { writer = stream.writable.getWriter() writers.set(stream, writer) } return writer } const reader: Socket["reader"] = Effect.gen(function*() { const scope = yield* Effect.scope const stream = yield* Scope.provide(acquire, scope) const readerHandle = (stream.readable as ReadableStream).getReader() let error: SocketError | undefined yield* Scope.addFinalizer( scope, Effect.suspend(() => { latch.closeUnsafe() currentStream = undefined return Effect.promise(() => readerHandle.cancel().catch(constVoid)) }) ) currentStream = { stream, fail(err) { if (error === undefined) error = err readerHandle.cancel().catch(constVoid) } } latch.openUnsafe() const read = Effect.tryPromise({ try: () => readerHandle.read(), catch: (cause) => error ?? new SocketError({ reason: new SocketReadError({ cause }) }) }) return { pull: Effect.suspend(() => { if (error !== undefined) return Effect.fail(error) return Effect.flatMap(read, ({ done, value }) => done ? Effect.fail(error ?? closeError(1000)) : Effect.succeed([value] as unknown as NonEmptyReadonlyArray)) }), upgrade: SocketUpgradeError.unsupported } }).pipe( Effect.updateContext((input: Context.Context) => Context.merge(acquireContext, input)) ) as Socket["reader"] const write = (chunk: Uint8Array | string | CloseEvent) => latch.whenOpen(Effect.suspend(() => { const current = currentStream! if (isCloseEvent(chunk)) { return Effect.sync(() => { current.fail(closeError(chunk.code, chunk.reason)) }) } return Effect.tryPromise({ try: () => getWriter(current.stream).write(typeof chunk === "string" ? encoder.encode(chunk) : chunk), catch: (cause) => new SocketError({ reason: new SocketWriteError({ cause }) }) }) })) const writeAll = (chunks: NonEmptyReadonlyArray) => latch.whenOpen(Effect.tryPromise({ try: async () => { const writer = getWriter(currentStream!.stream) for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i] await writer.write(typeof chunk === "string" ? encoder.encode(chunk) : chunk) } }, catch: (cause) => new SocketError({ reason: new SocketWriteError({ cause }) }) })) const writer: Socket["writer"] = Effect.acquireRelease( Effect.succeed({ write, writeAll }), () => Effect.promise(async () => { if (!currentStream) return await getWriter(currentStream.stream).close().catch(constVoid) }) ) return Effect.succeed(make({ reader, writer })) })