From 0036079c75fd2fd546261a2e5e703af58bfb5436 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Tue, 7 Jan 2025 14:58:16 -0500 Subject: [PATCH] Initial commit --- .gitignore | 175 ++++++++++++++++++++++++++++++ README.md | 15 +++ build.ts | 8 ++ bun.lockb | Bin 0 -> 10556 bytes package.json | 24 +++++ src/constants.ts | 31 ++++++ src/errors.ts | 17 +++ src/index.ts | 12 +++ src/managed-socket.ts | 243 ++++++++++++++++++++++++++++++++++++++++++ src/messages.ts | 98 +++++++++++++++++ src/types.ts | 115 ++++++++++++++++++++ tsconfig.json | 27 +++++ 12 files changed, 765 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.ts create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/constants.ts create mode 100644 src/errors.ts create mode 100644 src/index.ts create mode 100644 src/managed-socket.ts create mode 100644 src/messages.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..e8b49fb48a11ef4909259031e2b1048a9a73bd33 GIT binary patch literal 10556 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 literal 0 HcmV?d00001 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 + } +}