diff --git a/package.json b/package.json index 84ea233..e3f49bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@endeavorance/socket", - "description": "Lightweight reconnecting websocket interface", - "version": "0.0.1", + "description": "Self-managed reconnecting WebSocket wrapper", + "version": "0.1.0", "exports": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { @@ -22,4 +22,4 @@ "peerDependencies": { "typescript": "^5.0.0" } -} \ No newline at end of file +} diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 1244f14..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -export enum SocketDisconnectCode { - CLOSE_NORMAL = 1000, - CLOSE_GOING_AWAY = 1001, - CLOSE_PROTOCOL_ERROR = 1002, - CLOSE_UNSUPPORTED = 1003, - CLOSE_NO_STATUS = 1005, - CLOSE_ABNORMAL = 1006, - CLOSE_TOO_LARGE = 1009, - CLOSE_EXTENSION_REQUIRED = 1010, - CLOSE_INTERNAL_ERROR = 1011, - CLOSE_SERVICE_RESTART = 1012, - CLOSE_TRY_AGAIN_LATER = 1013, - CLOSE_TLS_HANDSHAKE = 1015, -} - -export const NON_RECONNECT_CODES = [ - SocketDisconnectCode.CLOSE_NORMAL, - SocketDisconnectCode.CLOSE_GOING_AWAY, - SocketDisconnectCode.CLOSE_UNSUPPORTED, - SocketDisconnectCode.CLOSE_TOO_LARGE, - SocketDisconnectCode.CLOSE_EXTENSION_REQUIRED, - SocketDisconnectCode.CLOSE_TRY_AGAIN_LATER, - SocketDisconnectCode.CLOSE_TLS_HANDSHAKE, -] as const; - -export enum ManagedSocketErrorType { - INVALID_MESSAGE_SHAPE = "Invalid message shape.", - CATASTROPHIC_CLOSE = "Catastrophic close code", - SOCKET_ERROR = "WebSocket error", - CONNECTION_REJECTED = "Connection rejected", -} diff --git a/src/errors.ts b/src/errors.ts index cbaa694..50c4bc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,17 +1,9 @@ -import { ManagedSocketErrorType } from "./constants"; +export class SocketError extends Error { + name = "SocketError"; + public originalError?: Error | ErrorEvent; -export class ManagedSocketError extends Error { - public originalError: Error | ErrorEvent; - public type: ManagedSocketErrorType = ManagedSocketErrorType.SOCKET_ERROR; - - constructor( - message: string, - originalError: Error | ErrorEvent, - type?: ManagedSocketErrorType, - ) { + constructor(message: string, originalError?: Error | ErrorEvent) { super(message); - this.name = "ManagedSocketError"; this.originalError = originalError; - this.type = type ?? this.type; } } diff --git a/src/index.ts b/src/index.ts index a0ebfc5..2b7088d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,5 @@ -import { ManagedSocketErrorType, SocketDisconnectCode } from "./constants"; -import { ManagedSocketError } from "./errors"; +import { SocketError } from "./errors"; import { ManagedSocket } from "./managed-socket"; -import { SocketSpeak } from "./messages"; +import { wrap, unwrap, deserialize } from "./messages"; -export { - SocketSpeak, - ManagedSocket, - ManagedSocketError, - SocketDisconnectCode, - ManagedSocketErrorType, -}; +export { ManagedSocket, SocketError, wrap, unwrap, deserialize }; diff --git a/src/managed-socket.ts b/src/managed-socket.ts index 2e43c22..24dc5f0 100644 --- a/src/managed-socket.ts +++ b/src/managed-socket.ts @@ -1,68 +1,89 @@ -import { ManagedSocketErrorType, type SocketDisconnectCode } from "./constants"; -import { ManagedSocketError } from "./errors"; -import { SocketSpeak } from "./messages"; +import { SocketError } from "./errors"; +import { deserialize, wrap } from "./messages"; const ONE_SECOND = 1000; const HEARTBEAT_INTERVAL = 30 * ONE_SECOND; const MAX_RECONNECT_DELAY = 30 * ONE_SECOND; type SocketDataHandler = (data: unknown) => void | Promise; -type SocketErrorHandler = (error: ManagedSocketError) => void; -type SocketOpenHandler = () => void; -type SocketCloseHandler = () => void; -type SocketCatastrophicErrorHandler = (error: ManagedSocketError) => void; +type SocketErrorHandler = (error: SocketError) => void; +type SocketConnectedHandler = () => void; +type SocketDisconnectedHandler = ({ + wasClean, + code, + reason, +}: { + wasClean: boolean; + code: number; + reason: string; +}) => void; -export type ManagedSocketConnectionState = - | "connecting" - | "open" - | "closing" - | "closed" - | "reconnecting" - | "terminated"; +interface HandlerDefinitions { + onMessage?: SocketDataHandler; + onError?: SocketErrorHandler; + onConnected?: SocketConnectedHandler; + onDisconnected?: SocketDisconnectedHandler; +} export class ManagedSocket { public readonly url: string; - public state: ManagedSocketConnectionState = "connecting"; - private ws: WebSocket; + public socket: WebSocket; - private onMessageHandler: SocketDataHandler | null = null; - private onErrorHandler: SocketErrorHandler | null = null; - private onOpenHandler: SocketOpenHandler | null = null; - private onCloseHandler: SocketCloseHandler | null = null; - private onCatastrophicErrorHandler: SocketCatastrophicErrorHandler | null = - null; + public onMessage: SocketDataHandler = () => {}; + public onError: SocketErrorHandler = () => {}; + public onConnected: SocketConnectedHandler = () => {}; + public onDisconnected: SocketDisconnectedHandler = () => {}; - private reconnectAttempts: number; - private maxReconnectDelay: number; - private heartbeatInterval: Timer | null; - private messageQueue: unknown[] = []; - private attemptReconnect = true; + private _reconnectAttempts = 0; + private _messageQueue: unknown[] = []; + private _debugMode = false; + private _offline = false; - private debugMode = false; - - constructor(url: string) { + constructor(url: string, handlers: HandlerDefinitions = {}) { this.url = url; - this.ws = this.connect(); - this.reconnectAttempts = 0; - this.maxReconnectDelay = MAX_RECONNECT_DELAY; - this.heartbeatInterval = null; + + // Use provided handlers + this.onMessage = handlers.onMessage ?? this.onMessage; + this.onError = handlers.onError ?? this.onError; + this.onConnected = handlers.onConnected ?? this.onConnected; + this.onDisconnected = handlers.onDisconnected ?? this.onDisconnected; + + this.socket = this.connect(); + + setInterval(() => { + this.heartbeat(); + }, HEARTBEAT_INTERVAL); + + window.addEventListener("offline", () => { + this._offline = true; + + if (this.isConnected) { + this.debugMessage("Internet connection lost, closing socket..."); + this.close(); + } + }); + + window.addEventListener("online", () => { + this._offline = false; + + if (!this.isConnected) { + this.debugMessage( + "Internet connection restored, reconnecting socket...", + ); + this.attemptReconnection(); + } + }); } - get connected() { - return this.ws.readyState === WebSocket.OPEN; + private heartbeat() { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(Date.now())); + } } - get native() { - return this.ws; - } - - set debug(value: boolean) { - this.debugMode = value; - } - - debugMessage(...args: unknown[]) { - if (this.debugMode) { - const output = ["[ManagedSocketDebug]", ...args]; + private debugMessage(...args: unknown[]) { + if (this._debugMode) { + const output = ["[Socket]", ...args]; console.log(...output); } } @@ -71,177 +92,111 @@ export class ManagedSocket { // Make a copy of the queue and clear it first. // this way if the messages fail to send again, // they just get requeued - const messagesToSend = [...this.messageQueue]; - this.messageQueue = []; + const messagesToSend = [...this._messageQueue]; + this._messageQueue = []; for (const message of messagesToSend) { this.send(message); } } - private connect() { - this.ws = new WebSocket(this.url); - - this.ws.addEventListener("open", () => { - this.reconnectAttempts = 0; - - // Announce successful reconnects - if (this.state === "reconnecting") { - this.debugMessage("Reconnected to socket server"); - } - - this.onOpenHandler?.(); - this.state = "open"; - this.startHeartbeat(); - this.sendQueuedMessages(); - }); - - this.ws.addEventListener("close", (event) => { - const { code } = event; - this.debugMessage(code); - this.onCloseHandler?.(); - - // If the code received was catastrophic, terminate the connection - if (SocketSpeak.isCatastrophicCloseCode(code)) { - this.handleCatastrophicError(code); - } - - this.state = "closed"; - - if (this.attemptReconnect) { - this.debugMessage( - "Socket connection closed. Attempting reconnection...", - ); - this.stopHeartbeat(); - this.reconnect(); - } - }); - - this.ws.addEventListener("error", (error) => { - this.debugMessage("WebSocket error", error); - - const errorType = - this.state === "connecting" - ? ManagedSocketErrorType.CONNECTION_REJECTED - : ManagedSocketErrorType.SOCKET_ERROR; - - const socketError = new ManagedSocketError( - errorType, - new Error(error.currentTarget?.toString()), - errorType, - ); - - if (this.onErrorHandler !== null) { - return this.onErrorHandler(socketError); - } - - throw error; - }); - - this.ws.addEventListener("message", (event) => { - const message = SocketSpeak.deserialize(event.data.toString()); - - // Ignore heartbeats - if (message.type === "heartbeat") { - this.debugMessage("Received heartbeat"); - return; - } - - if (message.data === undefined || message.data === null) { - this.debugMessage("Received message with no data"); - return; - } - - this.debugMessage("Received message", message.data); - this.onMessageHandler?.(message.data); - }); - - return this.ws; - } - - private handleCatastrophicError(code: SocketDisconnectCode) { - this.state = "terminated"; - this.attemptReconnect = false; - this.debugMessage( - "Socket connection terminated due to non-reconnect close code", - ); - - const socketError = new ManagedSocketError( - "Socket connection terminated due to non-reconnect close code", - new Error(code.toString()), - ManagedSocketErrorType.CATASTROPHIC_CLOSE, - ); - - if (this.onCatastrophicErrorHandler !== null) { - return this.onCatastrophicErrorHandler(socketError); + private attemptReconnection() { + if (this._offline) { + this.debugMessage("Browser offline; skipping reconnection attempt"); + return; } - throw socketError; - } - - onMessage(handler: SocketDataHandler) { - this.onMessageHandler = handler; - } - - onError(handler: SocketErrorHandler) { - this.onErrorHandler = handler; - } - - onOpen(handler: SocketOpenHandler) { - this.onOpenHandler = handler; - } - - onClose(handler: SocketCloseHandler) { - this.onCloseHandler = handler; - } - - onCatastrophicError(handler: SocketCatastrophicErrorHandler) { - this.onCatastrophicErrorHandler = handler; - } - - reconnect() { - this.attemptReconnect = true; - this.state = "reconnecting"; const delay = Math.min( - 1000 * 2 ** this.reconnectAttempts, - this.maxReconnectDelay, + ONE_SECOND * 2 ** this._reconnectAttempts, + MAX_RECONNECT_DELAY, ); this.debugMessage(`Attempting to reconnect in ${delay}ms`); setTimeout(() => { - this.reconnectAttempts++; + this._reconnectAttempts++; this.connect(); }, delay); } - close() { - this.attemptReconnect = false; - this.state = "closing"; - this.ws.close(); - } + private connect() { + const newSocket = new WebSocket(this.url); - private startHeartbeat() { - this.heartbeatInterval = setInterval(() => { - if (this.ws.readyState === WebSocket.OPEN) { - this.ws.send( - SocketSpeak.serialize(SocketSpeak.createHeartbeatMessage()), - ); + // When the connection is established, run the handler + // and deliver any queued messages + newSocket.addEventListener("open", () => { + this._reconnectAttempts = 0; + this.onConnected(); + this.sendQueuedMessages(); + }); + + // When the connection closes, run the handler and possibly + // attempt a reconnection if there is still an internet connection + newSocket.addEventListener("close", ({ wasClean, code, reason }) => { + this.debugMessage("WebSocket closed", { + wasClean, + code, + reason, + }); + + this.onDisconnected({ + wasClean, + code, + reason, + }); + + if (!this._offline) { + this.debugMessage("Attempting automatic reconnection"); + return this.attemptReconnection(); } - }, HEARTBEAT_INTERVAL); + }); + + newSocket.addEventListener("error", (error) => { + this.debugMessage("WebSocket error", error); + + const socketError = new SocketError( + "Socket error", + new Error(error.currentTarget?.toString()), + ); + + this.onError(socketError); + }); + + newSocket.addEventListener("message", (messageEvent) => { + const [type, data] = deserialize(messageEvent.data); + + if (type === "heartbeat") { + return; + } + + this.debugMessage("Received message", data); + this.onMessage(data); + }); + + this.socket = newSocket; + return newSocket; } - private stopHeartbeat() { - if (this.heartbeatInterval !== null) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; + get isConnected() { + return this.socket?.readyState === WebSocket.OPEN; + } + + debug(debugModeEnabled: boolean) { + this._debugMode = debugModeEnabled; + } + + close() { + if (this.socket?.readyState !== WebSocket.OPEN) { + throw new SocketError("Cannot close a socket that is not open"); } + + this.socket.close(); } send(data: T) { - if (this.ws.readyState !== WebSocket.OPEN) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { this.debugMessage("WebSocket is not open. Queuing message."); - this.messageQueue.push(data); + this._messageQueue.push(data); return; } - this.ws.send(SocketSpeak.prepare(data)); + this.socket.send(JSON.stringify(wrap(data))); } } diff --git a/src/messages.ts b/src/messages.ts index 4bc6fcd..646aef5 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,12 +1,7 @@ -import { ManagedSocketErrorType, NON_RECONNECT_CODES } from "./constants"; -import { ManagedSocketError } from "./errors"; -import { - type SerializedSocketMessage, - type SocketDataMessage, - type SocketHeartbeatMessage, - type SocketMessage, - parseAnyScoketMessage, -} from "./types"; +import { SocketError } from "./errors"; + +export type SocketHeartbeatMessage = ["heartbeat", number]; +export type SocketDataMessage = ["data", T]; /** * Creates a socket data message object with the given data. @@ -14,33 +9,18 @@ import { * @param data - The data to be included in the message. Can be of any type. * @returns An object representing the socket data message with a type of "message" and the provided data. */ -function createDataMessage(data: unknown): SocketDataMessage { - return { - type: "message", - data, - }; +function wrap(data: unknown): SocketDataMessage { + return ["data", data]; } /** - * Creates a heartbeat message to be sent over a socket connection. + * Extracts the data from a SocketDataMessage. * - * @returns {SocketHeartbeatMessage} An object representing a heartbeat message with the current timestamp. + * @param message - The socket data message to extract data from. + * @returns The data contained in the message. */ -function createHeartbeatMessage(): SocketHeartbeatMessage { - return { - type: "heartbeat", - data: new Date().toISOString(), - }; -} - -/** - * Serializes a SocketMessage object into a JSON string. - * - * @param {SocketMessage} message - The message object to serialize. - * @returns {SerializedSocketMessage} The serialized JSON string representation of the message. - */ -function serialize(message: SocketMessage): SerializedSocketMessage { - return JSON.stringify(message) as SerializedSocketMessage; +function unwrap(message: SocketDataMessage): T { + return message[1]; } /** @@ -50,49 +30,32 @@ function serialize(message: SocketMessage): SerializedSocketMessage { * @returns The deserialized `SocketMessage` object. * @throws {ManagedSocketError} If the JSON string cannot be parsed or if the message shape is invalid. */ -function deserialize(data: string): SocketMessage { +function deserialize( + data: string, +): SocketDataMessage | SocketHeartbeatMessage { try { - const asObj = JSON.parse(data); - return parseAnyScoketMessage(asObj); + const parsedData = JSON.parse(data); + + if (typeof parsedData === "number") { + return ["heartbeat", parsedData]; + } + + if ( + parsedData !== null && + Array.isArray(parsedData) && + parsedData.length === 2 && + parsedData[0] === "data" + ) { + const data = parsedData[1] as T; + return ["data", data]; + } + + throw new SocketError("Failed to parse internal message shape"); } catch (error) { - throw new ManagedSocketError( - "Failed to parse incoming message shape", - error instanceof Error ? error : new Error("Unknown error"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, + throw new SocketError( + "Failed to parse incoming message shape: Failed to deserialize", ); } } -/** - * Creates and serializes a socket message with the given data. - * - * @remarks This is a convenience method to streamline making - * data messages. It's the equivalent of: - * `serialize(createDataMessage(data))` - * - * @template T - The type of the data to be prepared. - * @param {T} data - The data to be included in the message. - * @returns {SerializedSocketMessage} - The serialized socket message. - */ -function prepare(data: T): SerializedSocketMessage { - return serialize(createDataMessage(data)); -} - -/** - * Determines if the provided close code is considered catastrophic. - * - * @param code - The close code to check. - * @returns `true` if the code is in the list of non-reconnect codes, otherwise `false`. - */ -function isCatastrophicCloseCode(code: number) { - return (NON_RECONNECT_CODES as readonly number[]).includes(code); -} - -export const SocketSpeak = { - createDataMessage, - createHeartbeatMessage, - serialize, - deserialize, - isCatastrophicCloseCode, - prepare, -}; +export { wrap, unwrap, deserialize }; diff --git a/src/test/messages.test.ts b/src/test/messages.test.ts deleted file mode 100644 index be31bbe..0000000 --- a/src/test/messages.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { test, describe, expect } from "bun:test"; - -import { SocketSpeak } from "../messages"; -import { ManagedSocketError } from "../errors"; -import { NON_RECONNECT_CODES, SocketDisconnectCode } from "../constants"; - -describe("createDataMessage()", () => { - test("wraps the provided data in a message object", () => { - const data = { foo: "bar" }; - const message = SocketSpeak.createDataMessage(data); - expect(message).toEqual({ type: "message", data }); - }); -}); - -describe("createHeartbeatMessage()", () => { - test("creates a heartbeat message with the current timestamp", () => { - const message = SocketSpeak.createHeartbeatMessage(); - expect(message.type).toBe("heartbeat"); - expect(message.data).toBeTypeOf("string"); - }); -}); - -describe("serialize()", () => { - test("serializes a SocketMessage object into a JSON string", () => { - const message = SocketSpeak.createDataMessage({ foo: "bar" }); - const serialized = SocketSpeak.serialize(message) as string; - expect(serialized).toBe(JSON.stringify(message)); - }); -}); - -describe("deserialize()", () => { - test("deserializes a JSON string into a SocketMessage object", () => { - const message = SocketSpeak.createDataMessage({ foo: "bar" }); - const serialized = SocketSpeak.serialize(message); - const deserialized = SocketSpeak.deserialize(serialized); - expect(deserialized).toEqual(message); - }); - - test("throws an error if the JSON string cannot be parsed", () => { - const invalidData = "invalid"; - expect(() => SocketSpeak.deserialize(invalidData)).toThrow( - ManagedSocketError, - ); - }); - - test("throws an error if the message shape is invalid", () => { - const invalidData = JSON.stringify({ type: "invalid" }); - expect(() => SocketSpeak.deserialize(invalidData)).toThrow( - ManagedSocketError, - ); - }); -}); - -describe("prepare()", () => { - test("serializes and wraps the provided data in a message object", () => { - const data = { foo: "bar" }; - const message = SocketSpeak.prepare(data); - expect(message).toEqual(SocketSpeak.serialize({ type: "message", data })); - }); -}); - -describe("isCatatrophicCloseCode()", () => { - test("returns for codes in the catastrophic list", () => { - for (const code of NON_RECONNECT_CODES) { - expect(SocketSpeak.isCatastrophicCloseCode(code)).toBe(true); - } - }); - - test("returns false for other close codes", () => { - expect( - SocketSpeak.isCatastrophicCloseCode( - SocketDisconnectCode.CLOSE_SERVICE_RESTART, - ), - ).toBe(false); - }); -}); diff --git a/src/test/types.test.ts b/src/test/types.test.ts deleted file mode 100644 index c532458..0000000 --- a/src/test/types.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { test, describe, expect } from "bun:test"; - -import { parseAnyScoketMessage, parseMessageType } from "../types"; -import { ManagedSocketError } from "../errors"; - -describe("parseMessageType()", () => { - test("errors if the value is not a string", () => { - expect(() => parseMessageType(123)).toThrowError(ManagedSocketError); - }); - - test("errors if the value is a string but not a valid message type", () => { - expect(() => parseMessageType("invalid")).toThrowError(ManagedSocketError); - }); - - test("returns the message type when the value is a valid message type", () => { - expect(parseMessageType("heartbeat")).toBe("heartbeat"); - expect(parseMessageType("message")).toBe("message"); - }); -}); - -describe("parseAnyScoketMessage()", () => { - test("throws when the value is null", () => { - expect(() => parseAnyScoketMessage(null)).toThrowError(ManagedSocketError); - }); - - test("throws when the value is not an object", () => { - expect(() => parseAnyScoketMessage("string")).toThrowError( - ManagedSocketError, - ); - expect(() => parseAnyScoketMessage(() => {})).toThrowError( - ManagedSocketError, - ); - expect(() => parseAnyScoketMessage(false)).toThrowError(ManagedSocketError); - expect(() => parseAnyScoketMessage([])).toThrowError(ManagedSocketError); - expect(() => parseAnyScoketMessage(Symbol())).toThrowError( - ManagedSocketError, - ); - }); - - test("throws when the object does not have the correct shape", () => { - expect(() => parseAnyScoketMessage({})).toThrowError(ManagedSocketError); - expect(() => parseAnyScoketMessage({ type: "message" })).toThrowError( - ManagedSocketError, - ); - expect(() => parseAnyScoketMessage({ data: "data" })).toThrowError( - ManagedSocketError, - ); - }); - - test("throws when the message type is invalid", () => { - expect(() => - parseAnyScoketMessage({ type: "invalid", data: "data" }), - ).toThrowError(ManagedSocketError); - }); - - test("throws when the type is 'heartbeat' and the value is not a string", () => { - expect(() => - parseAnyScoketMessage({ type: "heartbeat", data: 123 }), - ).toThrowError(ManagedSocketError); - }); - - test("returns a valid message object when the type is 'heartbeat'", () => { - const dateStr = new Date().toISOString(); - const message = parseAnyScoketMessage({ - type: "heartbeat", - data: dateStr, - }); - const dataMessage = parseAnyScoketMessage({ type: "message", data: 123 }); - expect(message).toEqual({ type: "heartbeat", data: dateStr }); - expect(dataMessage).toEqual({ type: "message", data: 123 }); - }); -}); diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 3d6e93c..0000000 --- a/src/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ManagedSocketErrorType } from "./constants"; -import { ManagedSocketError } from "./errors"; - -declare const serializedSocketMessageBrand: unique symbol; - -/** - * Represents a serialized socket message. - * This type is a branded string, ensuring type safety for serialized socket messages. - */ -export type SerializedSocketMessage = string & { - [serializedSocketMessageBrand]: true; -}; - -/** - * Represents a heartbeat message sent over a socket connection. - * This message is used to indicate that the connection is still alive. - */ -export interface SocketHeartbeatMessage { - type: "heartbeat"; - data: string; -} - -/** - * Represents a message with data to be handled by the application - */ -export interface SocketDataMessage { - type: "message"; - data: unknown; -} - -/** - * Any socket message that can be sent or received. - */ -export type SocketMessage = SocketHeartbeatMessage | SocketDataMessage; - -const MESSAGE_TYPES = ["heartbeat", "message"] as const; -export type SocketMessageType = (typeof MESSAGE_TYPES)[number]; - -/** - * Parses the given value to determine if it is a valid `SocketMessageType`. - * - * @param value - The value to be parsed, expected to be of type `unknown`. - * @returns The parsed value as `SocketMessageType` if it is valid. - * @throws {ManagedSocketError} If the value is not a string or not included in `MESSAGE_TYPES`. - */ -export function parseMessageType(value: unknown): SocketMessageType { - if ( - typeof value !== "string" || - !(MESSAGE_TYPES as readonly string[]).includes(value) - ) { - throw new ManagedSocketError( - "Failed to parse incoming message shape", - new Error("Invalid message type"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, - ); - } - - return value as SocketMessageType; -} - -/** - * Parses an incoming socket message and returns a `SocketMessage` object. - * - * @param value - The incoming message to parse, expected to be an object. - * @returns A `SocketMessage` object containing the parsed message type and data. - * @throws {ManagedSocketError} If the incoming message is not a parseable message - */ -export function parseAnyScoketMessage(value: unknown): SocketMessage { - if (typeof value !== "object" || value === null) { - throw new ManagedSocketError( - "Failed to parse incoming message shape: value was not an object", - new Error("Invalid message shape"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, - ); - } - - if (!("type" in value) || !("data" in value)) { - throw new ManagedSocketError( - "Failed to parse incoming message shape: missing type or data", - new Error("Invalid message shape"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, - ); - } - - const { type: incomingType, data: incomingData } = value; - const type = parseMessageType(incomingType); - - if (type === "heartbeat") { - if (typeof incomingData !== "string") { - throw new ManagedSocketError( - "Failed to parse incoming message shape: heartbeat data was not a string", - new Error("Invalid message shape"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, - ); - } - - return { - type, - data: incomingData, - }; - } - - if (type === "message") { - return { - type, - data: incomingData, - }; - } - - throw new ManagedSocketError( - "Failed to parse incoming message shape", - new Error("Invalid message type"), - ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, - ); -}