v0.1.0
This commit is contained in:
parent
c9f628ee2c
commit
4e3da87809
9 changed files with 191 additions and 582 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@endeavorance/socket",
|
"name": "@endeavorance/socket",
|
||||||
"description": "Lightweight reconnecting websocket interface",
|
"description": "Self-managed reconnecting WebSocket wrapper",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -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",
|
|
||||||
}
|
|
|
@ -1,17 +1,9 @@
|
||||||
import { ManagedSocketErrorType } from "./constants";
|
export class SocketError extends Error {
|
||||||
|
name = "SocketError";
|
||||||
|
public originalError?: Error | ErrorEvent;
|
||||||
|
|
||||||
export class ManagedSocketError extends Error {
|
constructor(message: string, originalError?: Error | ErrorEvent) {
|
||||||
public originalError: Error | ErrorEvent;
|
|
||||||
public type: ManagedSocketErrorType = ManagedSocketErrorType.SOCKET_ERROR;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
originalError: Error | ErrorEvent,
|
|
||||||
type?: ManagedSocketErrorType,
|
|
||||||
) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ManagedSocketError";
|
|
||||||
this.originalError = originalError;
|
this.originalError = originalError;
|
||||||
this.type = type ?? this.type;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
13
src/index.ts
13
src/index.ts
|
@ -1,12 +1,5 @@
|
||||||
import { ManagedSocketErrorType, SocketDisconnectCode } from "./constants";
|
import { SocketError } from "./errors";
|
||||||
import { ManagedSocketError } from "./errors";
|
|
||||||
import { ManagedSocket } from "./managed-socket";
|
import { ManagedSocket } from "./managed-socket";
|
||||||
import { SocketSpeak } from "./messages";
|
import { wrap, unwrap, deserialize } from "./messages";
|
||||||
|
|
||||||
export {
|
export { ManagedSocket, SocketError, wrap, unwrap, deserialize };
|
||||||
SocketSpeak,
|
|
||||||
ManagedSocket,
|
|
||||||
ManagedSocketError,
|
|
||||||
SocketDisconnectCode,
|
|
||||||
ManagedSocketErrorType,
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,68 +1,89 @@
|
||||||
import { ManagedSocketErrorType, type SocketDisconnectCode } from "./constants";
|
import { SocketError } from "./errors";
|
||||||
import { ManagedSocketError } from "./errors";
|
import { deserialize, wrap } from "./messages";
|
||||||
import { SocketSpeak } from "./messages";
|
|
||||||
|
|
||||||
const ONE_SECOND = 1000;
|
const ONE_SECOND = 1000;
|
||||||
const HEARTBEAT_INTERVAL = 30 * ONE_SECOND;
|
const HEARTBEAT_INTERVAL = 30 * ONE_SECOND;
|
||||||
const MAX_RECONNECT_DELAY = 30 * ONE_SECOND;
|
const MAX_RECONNECT_DELAY = 30 * ONE_SECOND;
|
||||||
|
|
||||||
type SocketDataHandler = (data: unknown) => void | Promise<void>;
|
type SocketDataHandler = (data: unknown) => void | Promise<void>;
|
||||||
type SocketErrorHandler = (error: ManagedSocketError) => void;
|
type SocketErrorHandler = (error: SocketError) => void;
|
||||||
type SocketOpenHandler = () => void;
|
type SocketConnectedHandler = () => void;
|
||||||
type SocketCloseHandler = () => void;
|
type SocketDisconnectedHandler = ({
|
||||||
type SocketCatastrophicErrorHandler = (error: ManagedSocketError) => void;
|
wasClean,
|
||||||
|
code,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
wasClean: boolean;
|
||||||
|
code: number;
|
||||||
|
reason: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
export type ManagedSocketConnectionState =
|
interface HandlerDefinitions {
|
||||||
| "connecting"
|
onMessage?: SocketDataHandler;
|
||||||
| "open"
|
onError?: SocketErrorHandler;
|
||||||
| "closing"
|
onConnected?: SocketConnectedHandler;
|
||||||
| "closed"
|
onDisconnected?: SocketDisconnectedHandler;
|
||||||
| "reconnecting"
|
}
|
||||||
| "terminated";
|
|
||||||
|
|
||||||
export class ManagedSocket {
|
export class ManagedSocket {
|
||||||
public readonly url: string;
|
public readonly url: string;
|
||||||
public state: ManagedSocketConnectionState = "connecting";
|
public socket: WebSocket;
|
||||||
private ws: WebSocket;
|
|
||||||
|
|
||||||
private onMessageHandler: SocketDataHandler | null = null;
|
public onMessage: SocketDataHandler = () => {};
|
||||||
private onErrorHandler: SocketErrorHandler | null = null;
|
public onError: SocketErrorHandler = () => {};
|
||||||
private onOpenHandler: SocketOpenHandler | null = null;
|
public onConnected: SocketConnectedHandler = () => {};
|
||||||
private onCloseHandler: SocketCloseHandler | null = null;
|
public onDisconnected: SocketDisconnectedHandler = () => {};
|
||||||
private onCatastrophicErrorHandler: SocketCatastrophicErrorHandler | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
private reconnectAttempts: number;
|
private _reconnectAttempts = 0;
|
||||||
private maxReconnectDelay: number;
|
private _messageQueue: unknown[] = [];
|
||||||
private heartbeatInterval: Timer | null;
|
private _debugMode = false;
|
||||||
private messageQueue: unknown[] = [];
|
private _offline = false;
|
||||||
private attemptReconnect = true;
|
|
||||||
|
|
||||||
private debugMode = false;
|
constructor(url: string, handlers: HandlerDefinitions = {}) {
|
||||||
|
|
||||||
constructor(url: string) {
|
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.ws = this.connect();
|
|
||||||
this.reconnectAttempts = 0;
|
// Use provided handlers
|
||||||
this.maxReconnectDelay = MAX_RECONNECT_DELAY;
|
this.onMessage = handlers.onMessage ?? this.onMessage;
|
||||||
this.heartbeatInterval = null;
|
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() {
|
private heartbeat() {
|
||||||
return this.ws.readyState === WebSocket.OPEN;
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
|
this.socket.send(JSON.stringify(Date.now()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get native() {
|
private debugMessage(...args: unknown[]) {
|
||||||
return this.ws;
|
if (this._debugMode) {
|
||||||
}
|
const output = ["[Socket]", ...args];
|
||||||
|
|
||||||
set debug(value: boolean) {
|
|
||||||
this.debugMode = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugMessage(...args: unknown[]) {
|
|
||||||
if (this.debugMode) {
|
|
||||||
const output = ["[ManagedSocketDebug]", ...args];
|
|
||||||
console.log(...output);
|
console.log(...output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,177 +92,111 @@ export class ManagedSocket {
|
||||||
// Make a copy of the queue and clear it first.
|
// Make a copy of the queue and clear it first.
|
||||||
// this way if the messages fail to send again,
|
// this way if the messages fail to send again,
|
||||||
// they just get requeued
|
// they just get requeued
|
||||||
const messagesToSend = [...this.messageQueue];
|
const messagesToSend = [...this._messageQueue];
|
||||||
this.messageQueue = [];
|
this._messageQueue = [];
|
||||||
for (const message of messagesToSend) {
|
for (const message of messagesToSend) {
|
||||||
this.send(message);
|
this.send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connect() {
|
private attemptReconnection() {
|
||||||
this.ws = new WebSocket(this.url);
|
if (this._offline) {
|
||||||
|
this.debugMessage("Browser offline; skipping reconnection attempt");
|
||||||
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;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
const delay = Math.min(
|
||||||
1000 * 2 ** this.reconnectAttempts,
|
ONE_SECOND * 2 ** this._reconnectAttempts,
|
||||||
this.maxReconnectDelay,
|
MAX_RECONNECT_DELAY,
|
||||||
);
|
);
|
||||||
this.debugMessage(`Attempting to reconnect in ${delay}ms`);
|
this.debugMessage(`Attempting to reconnect in ${delay}ms`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.reconnectAttempts++;
|
this._reconnectAttempts++;
|
||||||
this.connect();
|
this.connect();
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
private connect() {
|
||||||
this.attemptReconnect = false;
|
const newSocket = new WebSocket(this.url);
|
||||||
this.state = "closing";
|
|
||||||
this.ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startHeartbeat() {
|
// When the connection is established, run the handler
|
||||||
this.heartbeatInterval = setInterval(() => {
|
// and deliver any queued messages
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
newSocket.addEventListener("open", () => {
|
||||||
this.ws.send(
|
this._reconnectAttempts = 0;
|
||||||
SocketSpeak.serialize(SocketSpeak.createHeartbeatMessage()),
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.addEventListener("error", (error) => {
|
||||||
|
this.debugMessage("WebSocket error", error);
|
||||||
|
|
||||||
|
const socketError = new SocketError(
|
||||||
|
"Socket error",
|
||||||
|
new Error(error.currentTarget?.toString()),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}, HEARTBEAT_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopHeartbeat() {
|
this.onError(socketError);
|
||||||
if (this.heartbeatInterval !== null) {
|
});
|
||||||
clearInterval(this.heartbeatInterval);
|
|
||||||
this.heartbeatInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send<T>(data: T) {
|
newSocket.addEventListener("message", (messageEvent) => {
|
||||||
if (this.ws.readyState !== WebSocket.OPEN) {
|
const [type, data] = deserialize(messageEvent.data);
|
||||||
this.debugMessage("WebSocket is not open. Queuing message.");
|
|
||||||
this.messageQueue.push(data);
|
if (type === "heartbeat") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws.send(SocketSpeak.prepare(data));
|
this.debugMessage("Received message", data);
|
||||||
|
this.onMessage(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket = newSocket;
|
||||||
|
return newSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(data: T) {
|
||||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.debugMessage("WebSocket is not open. Queuing message.");
|
||||||
|
this._messageQueue.push(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify(wrap(data)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
105
src/messages.ts
105
src/messages.ts
|
@ -1,12 +1,7 @@
|
||||||
import { ManagedSocketErrorType, NON_RECONNECT_CODES } from "./constants";
|
import { SocketError } from "./errors";
|
||||||
import { ManagedSocketError } from "./errors";
|
|
||||||
import {
|
export type SocketHeartbeatMessage = ["heartbeat", number];
|
||||||
type SerializedSocketMessage,
|
export type SocketDataMessage<T = unknown> = ["data", T];
|
||||||
type SocketDataMessage,
|
|
||||||
type SocketHeartbeatMessage,
|
|
||||||
type SocketMessage,
|
|
||||||
parseAnyScoketMessage,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a socket data message object with the given data.
|
* 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.
|
* @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.
|
* @returns An object representing the socket data message with a type of "message" and the provided data.
|
||||||
*/
|
*/
|
||||||
function createDataMessage(data: unknown): SocketDataMessage {
|
function wrap(data: unknown): SocketDataMessage {
|
||||||
return {
|
return ["data", data];
|
||||||
type: "message",
|
|
||||||
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 {
|
function unwrap<T = unknown>(message: SocketDataMessage<T>): T {
|
||||||
return {
|
return message[1];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,49 +30,32 @@ function serialize(message: SocketMessage): SerializedSocketMessage {
|
||||||
* @returns The deserialized `SocketMessage` object.
|
* @returns The deserialized `SocketMessage` object.
|
||||||
* @throws {ManagedSocketError} If the JSON string cannot be parsed or if the message shape is invalid.
|
* @throws {ManagedSocketError} If the JSON string cannot be parsed or if the message shape is invalid.
|
||||||
*/
|
*/
|
||||||
function deserialize(data: string): SocketMessage {
|
function deserialize<T>(
|
||||||
|
data: string,
|
||||||
|
): SocketDataMessage<T> | SocketHeartbeatMessage {
|
||||||
try {
|
try {
|
||||||
const asObj = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
return parseAnyScoketMessage(asObj);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
throw new ManagedSocketError(
|
throw new SocketError(
|
||||||
"Failed to parse incoming message shape",
|
"Failed to parse incoming message shape: Failed to deserialize",
|
||||||
error instanceof Error ? error : new Error("Unknown error"),
|
|
||||||
ManagedSocketErrorType.INVALID_MESSAGE_SHAPE,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { wrap, unwrap, 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<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 isCatastrophicCloseCode(code: number) {
|
|
||||||
return (NON_RECONNECT_CODES as readonly number[]).includes(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SocketSpeak = {
|
|
||||||
createDataMessage,
|
|
||||||
createHeartbeatMessage,
|
|
||||||
serialize,
|
|
||||||
deserialize,
|
|
||||||
isCatastrophicCloseCode,
|
|
||||||
prepare,
|
|
||||||
};
|
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
115
src/types.ts
115
src/types.ts
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue