// Copyright 2026 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.
import { type binary, errors, url, zod } from "@synnaxlabs/x";
import { z } from "zod";
import { EOF, StreamClosed } from "@/errors";
import { CONTENT_TYPE_HEADER_KEY, FREIGHTER_METADATA_PREFIX } from "@/http";
import { type Context, MiddlewareCollector } from "@/middleware";
import { type Stream, type StreamClient } from "@/stream";
const wsMessageZ = z.object({
type: z.enum(["data", "close", "open"]),
payload: z.unknown(),
error: z.optional(errors.payloadZ),
});
export type WebsocketMessage
= {
type: "data" | "close" | "open";
error?: errors.Payload;
payload?: P;
};
type ReceiveCallbacksQueue = Array<{
resolve: (msg: WebsocketMessage) => void;
reject: (reason: unknown) => void;
}>;
/** WebSocketStream is an implementation of Stream that is backed by a websocket. */
class WebSocketStream<
RQ extends z.ZodType,
RS extends z.ZodType = RQ,
> implements Stream {
private readonly codec: binary.Codec;
private readonly reqSchema: RQ;
private readonly resSchema: RS;
private readonly ws: WebSocket;
private serverClosed: Error | null;
private sendClosed: boolean;
private readonly receiveDataQueue: WebsocketMessage[] = [];
private readonly receiveCallbacksQueue: ReceiveCallbacksQueue = [];
private readonly resLabel: string;
constructor(
ws: WebSocket,
encoder: binary.Codec,
target: string,
reqSchema: RQ,
resSchema: RS,
) {
this.codec = encoder;
this.resLabel = `${target} response`;
this.reqSchema = reqSchema;
this.resSchema = resSchema;
this.ws = ws;
this.sendClosed = false;
this.serverClosed = null;
this.listenForMessages();
}
async receiveOpenAck(): Promise {
const msg = await this.receiveMsg();
if (msg.type === "open") return;
if (msg.error == null) throw new Error("Message error must be defined");
const err = errors.decode(msg.error);
throw err ?? new Error(`Unexpected open-ack message type: ${msg.type}`);
}
/** Implements the Stream protocol */
send(req: z.input | z.infer): void {
if (this.serverClosed != null) throw new EOF();
if (this.sendClosed) throw new StreamClosed();
this.ws.send(this.codec.encode({ type: "data", payload: req }));
}
/** Implements the Stream protocol */
async receive(): Promise> {
if (this.serverClosed != null) throw this.serverClosed;
const msg = await this.receiveMsg();
if (msg.type === "close") {
if (msg.error == null) throw new Error("Message error must be defined");
this.serverClosed = errors.decode(msg.error);
if (this.serverClosed == null) throw new Error("Message error must be defined");
throw this.serverClosed;
}
return zod.parse(this.resSchema, msg.payload, { label: this.resLabel });
}
/** Implements the Stream protocol */
received(): boolean {
return this.receiveDataQueue.length !== 0;
}
/** Implements the Stream protocol */
closeSend(): void {
if (this.sendClosed || this.serverClosed != null) return undefined;
const msg: WebsocketMessage = { type: "close" };
try {
this.ws.send(this.codec.encode(msg));
} finally {
this.sendClosed = true;
}
return undefined;
}
private async receiveMsg(): Promise {
const msg = this.receiveDataQueue.shift();
if (msg != null) return msg;
return await new Promise((resolve, reject) =>
this.receiveCallbacksQueue.push({ resolve, reject }),
);
}
private addMessage(msg: WebsocketMessage): void {
const callback = this.receiveCallbacksQueue.shift();
if (callback != null) callback.resolve(msg);
else this.receiveDataQueue.push(msg);
}
private listenForMessages(): void {
this.ws.onmessage = this.onMessage.bind(this);
this.ws.onclose = this.onClose.bind(this);
}
private onMessage(ev: MessageEvent): void {
this.addMessage(this.codec.decode(ev.data, wsMessageZ));
}
private onClose(ev: CloseEvent): void {
this.addMessage({
type: "close",
error: {
type: ev.code === CLOSE_NORMAL ? EOF.TYPE : StreamClosed.TYPE,
data: "",
},
});
}
}
const CLOSE_NORMAL = 1000;
/**
* WebSocketClient is an implementation of StreamClient that is backed by websockets.
*/
export class WebSocketClient extends MiddlewareCollector implements StreamClient {
baseUrl: url.URL;
encoder: binary.Codec;
secure: boolean;
static readonly MESSAGE_TYPE = "arraybuffer";
/**
* @param encoder - The encoder to use for encoding messages and decoding responses.
* @param baseEndpoint - A base url to use as a prefix for all requests.
*/
constructor(baseEndpoint: url.URL, encoder: binary.Codec, secure = false) {
super();
this.secure = secure;
this.baseUrl = baseEndpoint.replace({ protocol: secure ? "wss" : "ws" });
this.encoder = encoder;
}
withCodec(codec: binary.Codec): WebSocketClient {
const c = new WebSocketClient(this.baseUrl, codec, this.secure);
c.use(...this.middleware);
return c;
}
/** Implements the StreamClient interface. */
async stream(
target: string,
reqSchema: RQ,
resSchema: RS,
): Promise> {
let stream: Stream | undefined;
await this.executeMiddleware(
{ target, protocol: "websocket", params: {}, role: "client" },
async (ctx: Context): Promise => {
const ws = new WebSocket(this.buildURL(target, ctx));
const outCtx: Context = { ...ctx, params: {} };
ws.binaryType = WebSocketClient.MESSAGE_TYPE;
stream = await this.wrapSocket(ws, target, reqSchema, resSchema);
return outCtx;
},
);
return stream as Stream;
}
private buildURL(target: string, ctx: Context): string {
const qs = url.buildQueryString(
{
[CONTENT_TYPE_HEADER_KEY]: this.encoder.contentType,
...ctx.params,
},
FREIGHTER_METADATA_PREFIX,
);
return this.baseUrl.child(target).toString() + qs;
}
private async wrapSocket(
ws: WebSocket,
target: string,
reqSchema: RQ,
resSchema: RS,
): Promise> {
return await new Promise((resolve, reject) => {
ws.onopen = () => {
const oWs = new WebSocketStream(
ws,
this.encoder,
target,
reqSchema,
resSchema,
);
oWs
.receiveOpenAck()
.then(() => resolve(oWs))
.catch((err: unknown) => reject(errors.fromUnknown(err)));
};
ws.onerror = (ev: Event) => {
const ev_ = ev as ErrorEvent;
reject(new Error(ev_.message ?? "websocket error", { cause: ev_.error ?? ev }));
};
});
}
}