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": { "vcs": {
"enabled": false, "enabled": false,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": false
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"ignore": ["dist", ".next", "public", "*.d.ts", "*.json"] "includes": ["src/**/*.ts"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentStyle": "space", "indentStyle": "space"
"indentWidth": 2
},
"organizeImports": {
"enabled": true
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
@ -27,5 +23,13 @@
"formatter": { "formatter": {
"quoteStyle": "double" "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", "name": "@endeavorance/socket",
"description": "Self-managed reconnecting WebSocket wrapper", "description": "Self-managed reconnecting WebSocket wrapper",
"version": "0.1.0", "version": "0.2.0",
"exports": "./dist/index.js", "exports": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"build": "bun run ./build.ts" "build": "bun run ./build.ts",
"fmt": "bunx --bun biome check --fix"
}, },
"keywords": [], "keywords": [],
"author": "Endeavorance <hello@endeavorance.camp> (https://endeavorance.camp)", "author": "Endeavorance <hello@endeavorance.camp> (https://endeavorance.camp)",
@ -15,7 +16,7 @@
], ],
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^2.0.6",
"@types/bun": "latest", "@types/bun": "latest",
"bun-plugin-dts": "^0.2.3" "bun-plugin-dts": "^0.2.3"
}, },

View file

@ -7,3 +7,5 @@ export class SocketError extends Error {
this.originalError = originalError; 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 { 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 const Heartbeat = Symbol("Heartbeat");
export type SocketDataMessage<T = unknown> = ["data", T];
/** export interface DataMessage {
* Creates a socket data message object with the given data. _: unknown;
* }
* @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. export function pack(data: unknown): string {
*/ return JSON.stringify({ _: data }) as string;
function wrap(data: unknown): SocketDataMessage {
return ["data", data];
} }
/** /**
* Extracts the data from a SocketDataMessage. * Returns the data in the message or the Heartbeat symbol
*
* @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.
* *
* @param data - The JSON string to be deserialized. * @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. * @throws {ManagedSocketError} If the JSON string cannot be parsed or if the message shape is invalid.
*/ */
function deserialize<T>( export function unpack(data: string): { data: unknown } | typeof Heartbeat {
data: string,
): SocketDataMessage<T> | SocketHeartbeatMessage {
try { try {
const parsedData = JSON.parse(data); const parsedData = JSON.parse(data);
// If the parsed data is a number, it's a heartbeat
if (typeof parsedData === "number") { if (typeof parsedData === "number") {
return ["heartbeat", parsedData]; return Heartbeat;
} }
// Otherwise, the shape should be an object with a _ property holding the data
if ( if (
typeof parsedData === "object" &&
parsedData !== null && parsedData !== null &&
Array.isArray(parsedData) && "_" in parsedData &&
parsedData.length === 2 && typeof parsedData._ !== "undefined"
parsedData[0] === "data"
) { ) {
const data = parsedData[1] as T; return { data: parsedData._ };
return ["data", data];
} }
throw new SocketError("Failed to parse internal message shape"); throw new UnpackError("Unpack Error: Invalid message shape");
} catch (error) { } catch (error) {
throw new SocketError( if (error instanceof Error) {
"Failed to parse incoming message shape: Failed to deserialize", 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 { SocketError } from "./errors";
import { deserialize, wrap } from "./messages"; import { pack, unpack } from "./messages";
const ONE_SECOND = 1000; const THIRTY_SECONDS = 30 * 1000;
const HEARTBEAT_INTERVAL = 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: SocketError) => void; type SocketErrorHandler = (error: SocketError) => void;
@ -50,8 +48,8 @@ export class ManagedSocket {
this.socket = this.connect(); this.socket = this.connect();
setInterval(() => { setInterval(() => {
this.heartbeat(); this.sendHeartbeat();
}, HEARTBEAT_INTERVAL); }, THIRTY_SECONDS);
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
this._offline = true; this._offline = true;
@ -74,7 +72,7 @@ export class ManagedSocket {
}); });
} }
private heartbeat() { private sendHeartbeat() {
if (this.socket?.readyState === WebSocket.OPEN) { if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(Date.now())); this.socket.send(JSON.stringify(Date.now()));
} }
@ -104,10 +102,7 @@ export class ManagedSocket {
return; return;
} }
const delay = Math.min( const delay = Math.min(2000 ** this._reconnectAttempts, THIRTY_SECONDS);
ONE_SECOND * 2 ** this._reconnectAttempts,
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++;
@ -159,9 +154,9 @@ export class ManagedSocket {
}); });
newSocket.addEventListener("message", (messageEvent) => { newSocket.addEventListener("message", (messageEvent) => {
const [type, data] = deserialize(messageEvent.data); const data = unpack(messageEvent.data);
if (type === "heartbeat") { if (data === null) {
return; return;
} }
@ -189,13 +184,13 @@ export class ManagedSocket {
this.socket.close(); this.socket.close();
} }
send<T>(data: T) { send(data: unknown) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.debugMessage("WebSocket is not open. Queuing message."); this.debugMessage("WebSocket is not open. Queuing message.");
this._messageQueue.push(data); this._messageQueue.push(data);
return; return;
} }
this.socket.send(JSON.stringify(wrap(data))); this.socket.send(pack(data));
} }
} }

View file

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