Initial commit
This commit is contained in:
commit
0036079c75
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
|
@ -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
|
15
README.md
Normal file
15
README.md
Normal file
|
@ -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.
|
8
build.ts
Normal file
8
build.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import dts from "bun-plugin-dts";
|
||||||
|
|
||||||
|
await Bun.build({
|
||||||
|
entrypoints: ["./src/index.ts"],
|
||||||
|
outdir: "./dist",
|
||||||
|
plugins: [dts()],
|
||||||
|
target: "browser",
|
||||||
|
});
|
24
package.json
Normal file
24
package.json
Normal file
|
@ -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 <hello@endeavorance.camp> (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"
|
||||||
|
}
|
||||||
|
}
|
31
src/constants.ts
Normal file
31
src/constants.ts
Normal file
|
@ -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",
|
||||||
|
}
|
17
src/errors.ts
Normal file
17
src/errors.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
12
src/index.ts
Normal file
12
src/index.ts
Normal file
|
@ -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,
|
||||||
|
};
|
243
src/managed-socket.ts
Normal file
243
src/managed-socket.ts
Normal file
|
@ -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<void>;
|
||||||
|
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<T>(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));
|
||||||
|
}
|
||||||
|
}
|
98
src/messages.ts
Normal file
98
src/messages.ts
Normal file
|
@ -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<T>(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,
|
||||||
|
};
|
115
src/types.ts
Normal file
115
src/types.ts
Normal file
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue