commit 0036079c75fd2fd546261a2e5e703af58bfb5436 Author: Endeavorance Date: Tue Jan 7 14:58:16 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..83301f0 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# socket-speak + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.1.42. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..c3da698 --- /dev/null +++ b/build.ts @@ -0,0 +1,8 @@ +import dts from "bun-plugin-dts"; + +await Bun.build({ + entrypoints: ["./src/index.ts"], + outdir: "./dist", + plugins: [dts()], + target: "browser", +}); diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..e8b49fb Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..db12c74 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "@endeavorance/socket-speak", + "description": "Lightweight reconnecting websocket interface", + "version": "0.0.1", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "bun run ./build.ts" + }, + "keywords": [], + "author": "Endeavorance (https://endeavorance.camp)", + "license": "CC BY-NC-SA 4.0", + "files": [ + "dist" + ], + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "bun-plugin-dts": "^0.2.3" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c1ce8f3 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,31 @@ +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.", + CATOSTROPHIC_CLOSE = "Catostrophic close code", + SOCKET_ERROR = "WebSocket error", + CONNECTION_REJECTED = "Connection rejected", +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..6acea1f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,17 @@ +import { ManagedSocketErrorType } from "./constants"; + +export class ManagedSocketError extends Error { + public originalError: Error | ErrorEvent; + public type: ManagedSocketErrorType = ManagedSocketErrorType.SOCKET_ERROR; + + constructor( + message: string, + originalError: Error | ErrorEvent, + type?: ManagedSocketErrorType, + ) { + super(message); + this.name = "ManagedSocketError"; + this.originalError = originalError; + this.type = type ?? this.type; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..246bf58 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,12 @@ +import { ManagedSocketError } from "./errors"; +import { SocketSpeak } from "./messages"; +import { SocketDisconnectCode, ManagedSocketErrorType } from "./constants"; +import { ManagedSocket } from "./managed-socket"; + +export { + SocketSpeak, + ManagedSocket, + ManagedSocketError, + SocketDisconnectCode, + ManagedSocketErrorType, +}; diff --git a/src/managed-socket.ts b/src/managed-socket.ts new file mode 100644 index 0000000..b67a46a --- /dev/null +++ b/src/managed-socket.ts @@ -0,0 +1,243 @@ +import { ManagedSocketErrorType, type SocketDisconnectCode } from "./constants"; +import { ManagedSocketError } from "./errors"; +import { SocketSpeak } 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 SocketCatostrophicErrorHandler = (error: ManagedSocketError) => void; + +export type ManagedSocketConnectionState = + | "connecting" + | "open" + | "closing" + | "closed" + | "reconnecting" + | "terminated"; + +export class ManagedSocket { + public readonly url: string; + public state: ManagedSocketConnectionState = "connecting"; + private ws: WebSocket; + + private onMessageHandler: SocketDataHandler | null = null; + private onErrorHandler: SocketErrorHandler | null = null; + private onOpenHandler: SocketOpenHandler | null = null; + private onCloseHandler: SocketCloseHandler | null = null; + private onCatostrophicErrorHandler: SocketCatostrophicErrorHandler | null = + null; + + private reconnectAttempts: number; + private maxReconnectDelay: number; + private heartbeatInterval: Timer | null; + private messageQueue: unknown[] = []; + private attemptReconnect = true; + + private debugMode = false; + + constructor(url: string) { + this.url = url; + this.ws = this.connect(); + this.reconnectAttempts = 0; + this.maxReconnectDelay = MAX_RECONNECT_DELAY; + this.heartbeatInterval = null; + } + + get connected() { + return this.ws.readyState === WebSocket.OPEN; + } + + get native() { + return this.ws; + } + + set debug(value: boolean) { + this.debugMode = value; + } + + debugMessage(...args: unknown[]) { + if (this.debugMode) { + const output = ["[ManagedSocketDebug]", ...args]; + console.log(...output); + } + } + + private sendQueuedMessages() { + // 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 = []; + 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 catostrophic, terminate the connection + if (SocketSpeak.isCatatrophicCloseCode(code)) { + this.handleCatostophicError(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; + } + + handleCatostophicError(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.CATOSTROPHIC_CLOSE, + ); + + if (this.onCatostrophicErrorHandler !== null) { + return this.onCatostrophicErrorHandler(socketError); + } + + throw socketError; + } + + onMessage(handler: SocketDataHandler) { + this.onMessageHandler = handler; + } + + onError(handler: (error: ManagedSocketError) => void) { + this.onErrorHandler = handler; + } + + onOpen(handler: () => void) { + this.onOpenHandler = handler; + } + + onClose(handler: () => void) { + this.onCloseHandler = handler; + } + + reconnect() { + this.attemptReconnect = true; + this.state = "reconnecting"; + const delay = Math.min( + 1000 * 2 ** this.reconnectAttempts, + this.maxReconnectDelay, + ); + this.debugMessage(`Attempting to reconnect in ${delay}ms`); + setTimeout(() => { + this.reconnectAttempts++; + this.connect(); + }, delay); + } + + close() { + this.attemptReconnect = false; + this.state = "closing"; + this.ws.close(); + } + + private startHeartbeat() { + this.heartbeatInterval = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + SocketSpeak.serialize(SocketSpeak.createHeartbeatMessage()), + ); + } + }, HEARTBEAT_INTERVAL); + } + + private stopHeartbeat() { + if (this.heartbeatInterval !== null) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + send(data: T) { + if (this.ws.readyState !== WebSocket.OPEN) { + this.debugMessage("WebSocket is not open. Queuing message."); + this.messageQueue.push(data); + return; + } + + this.ws.send(SocketSpeak.prepare(data)); + } +} diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..9f433b4 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,98 @@ +import { ManagedSocketErrorType, NON_RECONNECT_CODES } from "./constants"; +import { ManagedSocketError } from "./errors"; +import { + parseAnyScoketMessage, + type SerializedSocketMessage, + type SocketDataMessage, + type SocketHeartbeatMessage, + type SocketMessage, +} from "./types"; + +/** + * Creates a socket data message object with the given data. + * + * @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, + }; +} + +/** + * Creates a heartbeat message to be sent over a socket connection. + * + * @returns {SocketHeartbeatMessage} An object representing a heartbeat message with the current timestamp. + */ +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; +} + +/** + * Deserializes a JSON string into a `SocketMessage` object. + * + * @param data - The JSON string to be deserialized. + * @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 { + try { + const asObj = JSON.parse(data); + return parseAnyScoketMessage(asObj); + } catch (error) { + throw new ManagedSocketError( + "Failed to parse incoming message shape", + error instanceof Error ? error : new Error("Unknown error"), + ManagedSocketErrorType.INVALID_MESSAGE_SHAPE, + ); + } +} + +/** + * 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 isCatatrophicCloseCode(code: number) { + return (NON_RECONNECT_CODES as readonly number[]).includes(code); +} + +export const SocketSpeak = { + createDataMessage, + createHeartbeatMessage, + serialize, + deserialize, + isCatatrophicCloseCode, + prepare, +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ae19a3b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,115 @@ +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, + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}