This commit is contained in:
Endeavorance 2025-06-28 08:37:34 -04:00
parent 466fa26cfd
commit b6b4ceb4ae
8 changed files with 65 additions and 68 deletions

View file

@ -1,21 +1,17 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": ["dist", ".next", "public", "*.d.ts", "*.json"]
"includes": ["src/**/*.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
"indentStyle": "space"
},
"linter": {
"enabled": true,
@ -27,5 +23,13 @@
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

BIN
bun.lockb

Binary file not shown.

View file

@ -1,11 +1,12 @@
{
"name": "@endeavorance/socket",
"description": "Self-managed reconnecting WebSocket wrapper",
"version": "0.1.0",
"version": "0.2.0",
"exports": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "bun run ./build.ts"
"build": "bun run ./build.ts",
"fmt": "bunx --bun biome check --fix"
},
"keywords": [],
"author": "Endeavorance <hello@endeavorance.camp> (https://endeavorance.camp)",
@ -15,7 +16,7 @@
],
"type": "module",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@biomejs/biome": "^2.0.6",
"@types/bun": "latest",
"bun-plugin-dts": "^0.2.3"
},

View file

@ -7,3 +7,5 @@ export class SocketError extends Error {
this.originalError = originalError;
}
}
export class UnpackError extends SocketError {}

View file

@ -1,5 +1,13 @@
import { SocketError } from "./errors";
import { SocketError, UnpackError } from "./errors";
import { type DataMessage, Heartbeat, pack, unpack } from "./messages";
import { ManagedSocket } from "./socket";
import { wrap, unwrap, deserialize } from "./messages";
export { ManagedSocket, SocketError, wrap, unwrap, deserialize };
export {
ManagedSocket,
SocketError,
UnpackError,
pack,
unpack,
type DataMessage,
Heartbeat,
};

View file

@ -1,61 +1,48 @@
import { SocketError } from "./errors";
import { UnpackError } from "./errors";
export type SocketHeartbeatMessage = ["heartbeat", number];
export type SocketDataMessage<T = unknown> = ["data", T];
export const Heartbeat = Symbol("Heartbeat");
/**
* 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 wrap(data: unknown): SocketDataMessage {
return ["data", data];
export interface DataMessage {
_: unknown;
}
export function pack(data: unknown): string {
return JSON.stringify({ _: data }) as string;
}
/**
* Extracts the data from a SocketDataMessage.
*
* @param message - The socket data message to extract data from.
* @returns The data contained in the message.
*/
function unwrap<T = unknown>(message: SocketDataMessage<T>): T {
return message[1];
}
/**
* Deserializes a JSON string into a `SocketMessage` object.
* Returns the data in the message or the Heartbeat symbol
*
* @param data - The JSON string to be deserialized.
* @returns The deserialized `SocketMessage` object.
* @returns The deserialized data or the Heartbeat symbol
* @throws {ManagedSocketError} If the JSON string cannot be parsed or if the message shape is invalid.
*/
function deserialize<T>(
data: string,
): SocketDataMessage<T> | SocketHeartbeatMessage {
export function unpack(data: string): { data: unknown } | typeof Heartbeat {
try {
const parsedData = JSON.parse(data);
// If the parsed data is a number, it's a heartbeat
if (typeof parsedData === "number") {
return ["heartbeat", parsedData];
return Heartbeat;
}
// Otherwise, the shape should be an object with a _ property holding the data
if (
typeof parsedData === "object" &&
parsedData !== null &&
Array.isArray(parsedData) &&
parsedData.length === 2 &&
parsedData[0] === "data"
"_" in parsedData &&
typeof parsedData._ !== "undefined"
) {
const data = parsedData[1] as T;
return ["data", data];
return { data: parsedData._ };
}
throw new SocketError("Failed to parse internal message shape");
throw new UnpackError("Unpack Error: Invalid message shape");
} catch (error) {
throw new SocketError(
"Failed to parse incoming message shape: Failed to deserialize",
);
if (error instanceof Error) {
throw new UnpackError("Unpack Error: Failed to deserialize", error);
}
// Raise unknown exceptions
throw error;
}
}
export { wrap, unwrap, deserialize };

View file

@ -1,9 +1,7 @@
import { SocketError } from "./errors";
import { deserialize, wrap } from "./messages";
import { pack, unpack } from "./messages";
const ONE_SECOND = 1000;
const HEARTBEAT_INTERVAL = 30 * ONE_SECOND;
const MAX_RECONNECT_DELAY = 30 * ONE_SECOND;
const THIRTY_SECONDS = 30 * 1000;
type SocketDataHandler = (data: unknown) => void | Promise<void>;
type SocketErrorHandler = (error: SocketError) => void;
@ -50,8 +48,8 @@ export class ManagedSocket {
this.socket = this.connect();
setInterval(() => {
this.heartbeat();
}, HEARTBEAT_INTERVAL);
this.sendHeartbeat();
}, THIRTY_SECONDS);
window.addEventListener("offline", () => {
this._offline = true;
@ -74,7 +72,7 @@ export class ManagedSocket {
});
}
private heartbeat() {
private sendHeartbeat() {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(Date.now()));
}
@ -104,10 +102,7 @@ export class ManagedSocket {
return;
}
const delay = Math.min(
ONE_SECOND * 2 ** this._reconnectAttempts,
MAX_RECONNECT_DELAY,
);
const delay = Math.min(2000 ** this._reconnectAttempts, THIRTY_SECONDS);
this.debugMessage(`Attempting to reconnect in ${delay}ms`);
setTimeout(() => {
this._reconnectAttempts++;
@ -159,9 +154,9 @@ export class ManagedSocket {
});
newSocket.addEventListener("message", (messageEvent) => {
const [type, data] = deserialize(messageEvent.data);
const data = unpack(messageEvent.data);
if (type === "heartbeat") {
if (data === null) {
return;
}
@ -189,13 +184,13 @@ export class ManagedSocket {
this.socket.close();
}
send<T>(data: T) {
send(data: unknown) {
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)));
this.socket.send(pack(data));
}
}

View file

@ -25,5 +25,5 @@
"noPropertyAccessFromIndexSignature": false
},
"include": ["src"],
"exclude": ["src/test"],
"exclude": ["src/test"]
}