@inachess/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @inachess/sdk
2
+
3
+ JavaScript/TypeScript client SDK for the **inachess-engine** WebSocket protocol.
4
+ Framework-agnostic — runs in the browser and in Node. It owns the wire protocol
5
+ (join, moves, latency pings, reconnect) so your app never hand-rolls socket
6
+ messages.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @inachess/sdk
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { EngineClient } from "@inachess/sdk";
18
+
19
+ const client = new EngineClient({ url: "wss://engine.example.com/ws" });
20
+
21
+ client.on("gameState", (s) => render(s.fen, s.turn, s.you));
22
+ client.on("move", (m) => render(m.fen, m.turn));
23
+ client.on("gameOver", (g) => console.log(g.result, g.reason));
24
+ client.on("error", (e) => console.warn(e.message));
25
+
26
+ // Join with a single-use ticket minted by the platform.
27
+ client.connect({ ticket });
28
+
29
+ client.move("e2e4"); // UCI everywhere
30
+ client.resign();
31
+ ```
32
+
33
+ The client auto-answers the engine's latency pings and auto-reconnects with the
34
+ engine's rotating reconnect token after a drop. Connect modes:
35
+
36
+ ```ts
37
+ client.connect({ ticket }); // first join
38
+ client.connect({ reconnect: token }); // resume after a drop
39
+ client.connect({ spectate: token }); // read-only viewer
40
+ ```
41
+
42
+ Helpers for rendering are exported too: `parseBoard(fen)`, `fenTurn(fen)`,
43
+ `isPromotion(board, from, to)`.
44
+
45
+ ## Develop
46
+
47
+ ```bash
48
+ npm install
49
+ npm run build # tsup → dist (ESM + CJS + .d.ts)
50
+ npm test # node --test
51
+ ```
52
+
53
+ ## License
54
+
55
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ EngineClient: () => EngineClient,
24
+ FILES: () => FILES,
25
+ fenTurn: () => fenTurn,
26
+ isPromotion: () => isPromotion,
27
+ parseBoard: () => parseBoard
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/client.ts
32
+ var OPEN = 1;
33
+ var EngineClient = class {
34
+ url;
35
+ WS;
36
+ autoReconnect;
37
+ baseDelay;
38
+ maxDelay;
39
+ ws = null;
40
+ mode = null;
41
+ closedByUser = false;
42
+ attempts = 0;
43
+ timer = null;
44
+ _reconnectToken = null;
45
+ _seat = null;
46
+ listeners = /* @__PURE__ */ new Map();
47
+ constructor(opts) {
48
+ this.url = opts.url;
49
+ const WS = opts.WebSocket ?? globalThis.WebSocket;
50
+ if (!WS) throw new Error("no WebSocket implementation available; pass options.WebSocket");
51
+ this.WS = WS;
52
+ this.autoReconnect = opts.autoReconnect ?? true;
53
+ this.baseDelay = opts.reconnectDelayMs ?? 500;
54
+ this.maxDelay = opts.maxReconnectDelayMs ?? 8e3;
55
+ }
56
+ /** The current rotating reconnect token, or null (spectators never get one). */
57
+ get reconnectToken() {
58
+ return this._reconnectToken;
59
+ }
60
+ /** The recipient's seat: "white" | "black" | "spectator" | null (before join). */
61
+ get seat() {
62
+ return this._seat;
63
+ }
64
+ on(event, handler) {
65
+ let set = this.listeners.get(event);
66
+ if (!set) {
67
+ set = /* @__PURE__ */ new Set();
68
+ this.listeners.set(event, set);
69
+ }
70
+ set.add(handler);
71
+ return this;
72
+ }
73
+ off(event, handler) {
74
+ this.listeners.get(event)?.delete(handler);
75
+ return this;
76
+ }
77
+ /** Open the socket with the given credentials. */
78
+ connect(mode) {
79
+ this.closedByUser = false;
80
+ this.mode = mode;
81
+ this.attempts = 0;
82
+ this.dial();
83
+ }
84
+ /** Submit a move (UCI). No-op if the socket is not open. */
85
+ move(uci) {
86
+ this.send({ type: "move", uci });
87
+ }
88
+ /** Resign the game. */
89
+ resign() {
90
+ this.send({ type: "resign" });
91
+ }
92
+ /** Close the socket and stop any reconnection. */
93
+ close() {
94
+ this.closedByUser = true;
95
+ if (this.timer) clearTimeout(this.timer);
96
+ this.ws?.close(1e3, "");
97
+ this.ws = null;
98
+ }
99
+ dial() {
100
+ if (!this.mode) return;
101
+ const ws = new this.WS(this.url + "?" + queryFor(this.mode));
102
+ this.ws = ws;
103
+ ws.onopen = () => {
104
+ this.attempts = 0;
105
+ this.emit("open", void 0);
106
+ };
107
+ ws.onmessage = (ev) => this.handleMessage(ev.data);
108
+ ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);
109
+ ws.onerror = () => {
110
+ };
111
+ }
112
+ handleMessage(data) {
113
+ let msg;
114
+ try {
115
+ msg = JSON.parse(typeof data === "string" ? data : String(data));
116
+ } catch {
117
+ return;
118
+ }
119
+ this.emit("message", msg);
120
+ switch (msg.type) {
121
+ case "game_state":
122
+ if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;
123
+ this._seat = msg.you;
124
+ this.emit("gameState", msg);
125
+ break;
126
+ case "move":
127
+ this.emit("move", msg);
128
+ break;
129
+ case "game_over":
130
+ this.emit("gameOver", msg);
131
+ break;
132
+ case "error":
133
+ this.emit("error", msg);
134
+ break;
135
+ case "ping":
136
+ this.send({ type: "pong", t: msg.t });
137
+ break;
138
+ }
139
+ }
140
+ handleClose(code, reason) {
141
+ this.ws = null;
142
+ const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;
143
+ this.emit("close", { code, reason, willReconnect });
144
+ if (willReconnect) this.scheduleReconnect();
145
+ }
146
+ scheduleReconnect() {
147
+ this.attempts += 1;
148
+ const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);
149
+ this.emit("reconnecting", { attempt: this.attempts });
150
+ this.timer = setTimeout(() => {
151
+ if (this.closedByUser || this._reconnectToken === null) return;
152
+ this.mode = { reconnect: this._reconnectToken };
153
+ this.dial();
154
+ }, delay);
155
+ }
156
+ send(obj) {
157
+ if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));
158
+ }
159
+ emit(event, payload) {
160
+ this.listeners.get(event)?.forEach((h) => h(payload));
161
+ }
162
+ };
163
+ function queryFor(mode) {
164
+ if ("ticket" in mode) return "ticket=" + encodeURIComponent(mode.ticket);
165
+ if ("reconnect" in mode) return "reconnect=" + encodeURIComponent(mode.reconnect);
166
+ return "spectate=" + encodeURIComponent(mode.spectate);
167
+ }
168
+
169
+ // src/fen.ts
170
+ var FILES = ["a", "b", "c", "d", "e", "f", "g", "h"];
171
+ function parseBoard(fen) {
172
+ const rows = fen.split(" ")[0].split("/");
173
+ const out = {};
174
+ for (let i = 0; i < 8; i++) {
175
+ const rank = 8 - i;
176
+ let file = 0;
177
+ for (const ch of rows[i] ?? "") {
178
+ if (ch >= "1" && ch <= "8") {
179
+ file += Number(ch);
180
+ } else {
181
+ out[FILES[file] + rank] = ch;
182
+ file += 1;
183
+ }
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+ function fenTurn(fen) {
189
+ return fen.split(" ")[1] === "b" ? "black" : "white";
190
+ }
191
+ function isPromotion(board, from, to) {
192
+ const p = board[from];
193
+ return p === "P" && to[1] === "8" || p === "p" && to[1] === "1";
194
+ }
195
+ // Annotate the CommonJS export names for ESM import in node:
196
+ 0 && (module.exports = {
197
+ EngineClient,
198
+ FILES,
199
+ fenTurn,
200
+ isPromotion,
201
+ parseBoard
202
+ });
203
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/client.ts","../src/fen.ts"],"sourcesContent":["export { EngineClient } from \"./client.js\";\nexport type {\n ConnectMode,\n EngineClientOptions,\n EngineClientEvents,\n} from \"./client.js\";\nexport type {\n Color,\n Seat,\n GameStatus,\n GameResult,\n ServerMessage,\n ClientMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n PingMessage,\n} from \"./protocol.js\";\nexport { parseBoard, fenTurn, isPromotion, FILES } from \"./fen.js\";\nexport type { PieceChar } from \"./fen.js\";\n","import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n this.closedByUser = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n this.attempts = 0;\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,156 @@
1
+ type Color = "white" | "black";
2
+ type Seat = Color | "spectator";
3
+ type GameStatus = "active" | "finished";
4
+ type GameResult = "white_win" | "black_win" | "draw";
5
+ /** Full snapshot sent on join / rejoin / spectate. */
6
+ interface GameStateMessage {
7
+ type: "game_state";
8
+ fen: string;
9
+ turn: Color;
10
+ you: Seat;
11
+ moves: string[];
12
+ status: GameStatus;
13
+ whiteMs: number;
14
+ blackMs: number;
15
+ /** Rotating single-use reconnect token (absent for spectators). */
16
+ reconnectToken?: string;
17
+ }
18
+ /** Broadcast after a validated move. */
19
+ interface MoveMessage {
20
+ type: "move";
21
+ uci: string;
22
+ by: Color;
23
+ fen: string;
24
+ turn: Color;
25
+ whiteMs: number;
26
+ blackMs: number;
27
+ }
28
+ /** Broadcast when the game ends. */
29
+ interface GameOverMessage {
30
+ type: "game_over";
31
+ result: GameResult;
32
+ reason: string;
33
+ }
34
+ /** Sent to a single client on a rejected command. */
35
+ interface ErrorMessage {
36
+ type: "error";
37
+ message: string;
38
+ }
39
+ /** Server latency probe; the client must echo `t` back in a pong. */
40
+ interface PingMessage {
41
+ type: "ping";
42
+ t: number;
43
+ }
44
+ type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage;
45
+ interface MoveIntent {
46
+ type: "move";
47
+ uci: string;
48
+ }
49
+ interface ResignIntent {
50
+ type: "resign";
51
+ }
52
+ interface PongIntent {
53
+ type: "pong";
54
+ t: number;
55
+ }
56
+ type ClientMessage = MoveIntent | ResignIntent | PongIntent;
57
+
58
+ /** How to authenticate the socket to a room. */
59
+ type ConnectMode = {
60
+ ticket: string;
61
+ } | {
62
+ reconnect: string;
63
+ } | {
64
+ spectate: string;
65
+ };
66
+ /** Events emitted by EngineClient. */
67
+ interface EngineClientEvents {
68
+ open: void;
69
+ close: {
70
+ code: number;
71
+ reason: string;
72
+ willReconnect: boolean;
73
+ };
74
+ reconnecting: {
75
+ attempt: number;
76
+ };
77
+ gameState: GameStateMessage;
78
+ move: MoveMessage;
79
+ gameOver: GameOverMessage;
80
+ error: ErrorMessage;
81
+ message: ServerMessage;
82
+ }
83
+ interface EngineClientOptions {
84
+ /** Base socket URL, e.g. "wss://engine.example.com/ws". */
85
+ url: string;
86
+ /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */
87
+ WebSocket?: unknown;
88
+ /** Auto-reconnect with the rotating token after an unexpected drop (default true). */
89
+ autoReconnect?: boolean;
90
+ reconnectDelayMs?: number;
91
+ maxReconnectDelayMs?: number;
92
+ }
93
+ type Listener<T> = (payload: T) => void;
94
+ /**
95
+ * EngineClient wraps one game socket to the inachess-engine. It handles the wire
96
+ * protocol end to end: parses server messages, auto-answers latency pings, tracks
97
+ * the rotating reconnect token, and (by default) transparently reconnects after a
98
+ * drop. Framework-agnostic — the platform routes a player to the play view with a
99
+ * ticket, then hands that ticket to this client.
100
+ *
101
+ * ```ts
102
+ * const c = new EngineClient({ url: "wss://engine/ws" });
103
+ * c.on("gameState", s => render(s));
104
+ * c.on("move", m => render(m));
105
+ * c.connect({ ticket });
106
+ * c.move("e2e4");
107
+ * ```
108
+ */
109
+ declare class EngineClient {
110
+ private readonly url;
111
+ private readonly WS;
112
+ private readonly autoReconnect;
113
+ private readonly baseDelay;
114
+ private readonly maxDelay;
115
+ private ws;
116
+ private mode;
117
+ private closedByUser;
118
+ private attempts;
119
+ private timer;
120
+ private _reconnectToken;
121
+ private _seat;
122
+ private listeners;
123
+ constructor(opts: EngineClientOptions);
124
+ /** The current rotating reconnect token, or null (spectators never get one). */
125
+ get reconnectToken(): string | null;
126
+ /** The recipient's seat: "white" | "black" | "spectator" | null (before join). */
127
+ get seat(): Seat | null;
128
+ on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this;
129
+ off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this;
130
+ /** Open the socket with the given credentials. */
131
+ connect(mode: ConnectMode): void;
132
+ /** Submit a move (UCI). No-op if the socket is not open. */
133
+ move(uci: string): void;
134
+ /** Resign the game. */
135
+ resign(): void;
136
+ /** Close the socket and stop any reconnection. */
137
+ close(): void;
138
+ private dial;
139
+ private handleMessage;
140
+ private handleClose;
141
+ private scheduleReconnect;
142
+ private send;
143
+ private emit;
144
+ }
145
+
146
+ type PieceChar = "K" | "Q" | "R" | "B" | "N" | "P" | "k" | "q" | "r" | "b" | "n" | "p";
147
+ declare const FILES: readonly ["a", "b", "c", "d", "e", "f", "g", "h"];
148
+ /** parseBoard turns a FEN into a map of square ("e4") → piece char. */
149
+ declare function parseBoard(fen: string): Record<string, PieceChar>;
150
+ /** fenTurn returns the side to move encoded in a FEN. */
151
+ declare function fenTurn(fen: string): Color;
152
+ /** isPromotion reports whether moving the piece on `from` to `to` is a pawn
153
+ * reaching the last rank — the UI appends a promotion piece (default queen). */
154
+ declare function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean;
155
+
156
+ export { type ClientMessage, type Color, type ConnectMode, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
@@ -0,0 +1,156 @@
1
+ type Color = "white" | "black";
2
+ type Seat = Color | "spectator";
3
+ type GameStatus = "active" | "finished";
4
+ type GameResult = "white_win" | "black_win" | "draw";
5
+ /** Full snapshot sent on join / rejoin / spectate. */
6
+ interface GameStateMessage {
7
+ type: "game_state";
8
+ fen: string;
9
+ turn: Color;
10
+ you: Seat;
11
+ moves: string[];
12
+ status: GameStatus;
13
+ whiteMs: number;
14
+ blackMs: number;
15
+ /** Rotating single-use reconnect token (absent for spectators). */
16
+ reconnectToken?: string;
17
+ }
18
+ /** Broadcast after a validated move. */
19
+ interface MoveMessage {
20
+ type: "move";
21
+ uci: string;
22
+ by: Color;
23
+ fen: string;
24
+ turn: Color;
25
+ whiteMs: number;
26
+ blackMs: number;
27
+ }
28
+ /** Broadcast when the game ends. */
29
+ interface GameOverMessage {
30
+ type: "game_over";
31
+ result: GameResult;
32
+ reason: string;
33
+ }
34
+ /** Sent to a single client on a rejected command. */
35
+ interface ErrorMessage {
36
+ type: "error";
37
+ message: string;
38
+ }
39
+ /** Server latency probe; the client must echo `t` back in a pong. */
40
+ interface PingMessage {
41
+ type: "ping";
42
+ t: number;
43
+ }
44
+ type ServerMessage = GameStateMessage | MoveMessage | GameOverMessage | ErrorMessage | PingMessage;
45
+ interface MoveIntent {
46
+ type: "move";
47
+ uci: string;
48
+ }
49
+ interface ResignIntent {
50
+ type: "resign";
51
+ }
52
+ interface PongIntent {
53
+ type: "pong";
54
+ t: number;
55
+ }
56
+ type ClientMessage = MoveIntent | ResignIntent | PongIntent;
57
+
58
+ /** How to authenticate the socket to a room. */
59
+ type ConnectMode = {
60
+ ticket: string;
61
+ } | {
62
+ reconnect: string;
63
+ } | {
64
+ spectate: string;
65
+ };
66
+ /** Events emitted by EngineClient. */
67
+ interface EngineClientEvents {
68
+ open: void;
69
+ close: {
70
+ code: number;
71
+ reason: string;
72
+ willReconnect: boolean;
73
+ };
74
+ reconnecting: {
75
+ attempt: number;
76
+ };
77
+ gameState: GameStateMessage;
78
+ move: MoveMessage;
79
+ gameOver: GameOverMessage;
80
+ error: ErrorMessage;
81
+ message: ServerMessage;
82
+ }
83
+ interface EngineClientOptions {
84
+ /** Base socket URL, e.g. "wss://engine.example.com/ws". */
85
+ url: string;
86
+ /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */
87
+ WebSocket?: unknown;
88
+ /** Auto-reconnect with the rotating token after an unexpected drop (default true). */
89
+ autoReconnect?: boolean;
90
+ reconnectDelayMs?: number;
91
+ maxReconnectDelayMs?: number;
92
+ }
93
+ type Listener<T> = (payload: T) => void;
94
+ /**
95
+ * EngineClient wraps one game socket to the inachess-engine. It handles the wire
96
+ * protocol end to end: parses server messages, auto-answers latency pings, tracks
97
+ * the rotating reconnect token, and (by default) transparently reconnects after a
98
+ * drop. Framework-agnostic — the platform routes a player to the play view with a
99
+ * ticket, then hands that ticket to this client.
100
+ *
101
+ * ```ts
102
+ * const c = new EngineClient({ url: "wss://engine/ws" });
103
+ * c.on("gameState", s => render(s));
104
+ * c.on("move", m => render(m));
105
+ * c.connect({ ticket });
106
+ * c.move("e2e4");
107
+ * ```
108
+ */
109
+ declare class EngineClient {
110
+ private readonly url;
111
+ private readonly WS;
112
+ private readonly autoReconnect;
113
+ private readonly baseDelay;
114
+ private readonly maxDelay;
115
+ private ws;
116
+ private mode;
117
+ private closedByUser;
118
+ private attempts;
119
+ private timer;
120
+ private _reconnectToken;
121
+ private _seat;
122
+ private listeners;
123
+ constructor(opts: EngineClientOptions);
124
+ /** The current rotating reconnect token, or null (spectators never get one). */
125
+ get reconnectToken(): string | null;
126
+ /** The recipient's seat: "white" | "black" | "spectator" | null (before join). */
127
+ get seat(): Seat | null;
128
+ on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this;
129
+ off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this;
130
+ /** Open the socket with the given credentials. */
131
+ connect(mode: ConnectMode): void;
132
+ /** Submit a move (UCI). No-op if the socket is not open. */
133
+ move(uci: string): void;
134
+ /** Resign the game. */
135
+ resign(): void;
136
+ /** Close the socket and stop any reconnection. */
137
+ close(): void;
138
+ private dial;
139
+ private handleMessage;
140
+ private handleClose;
141
+ private scheduleReconnect;
142
+ private send;
143
+ private emit;
144
+ }
145
+
146
+ type PieceChar = "K" | "Q" | "R" | "B" | "N" | "P" | "k" | "q" | "r" | "b" | "n" | "p";
147
+ declare const FILES: readonly ["a", "b", "c", "d", "e", "f", "g", "h"];
148
+ /** parseBoard turns a FEN into a map of square ("e4") → piece char. */
149
+ declare function parseBoard(fen: string): Record<string, PieceChar>;
150
+ /** fenTurn returns the side to move encoded in a FEN. */
151
+ declare function fenTurn(fen: string): Color;
152
+ /** isPromotion reports whether moving the piece on `from` to `to` is a pawn
153
+ * reaching the last rank — the UI appends a promotion piece (default queen). */
154
+ declare function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean;
155
+
156
+ export { type ClientMessage, type Color, type ConnectMode, EngineClient, type EngineClientEvents, type EngineClientOptions, type ErrorMessage, FILES, type GameOverMessage, type GameResult, type GameStateMessage, type GameStatus, type MoveMessage, type PieceChar, type PingMessage, type Seat, type ServerMessage, fenTurn, isPromotion, parseBoard };
package/dist/index.js ADDED
@@ -0,0 +1,172 @@
1
+ // src/client.ts
2
+ var OPEN = 1;
3
+ var EngineClient = class {
4
+ url;
5
+ WS;
6
+ autoReconnect;
7
+ baseDelay;
8
+ maxDelay;
9
+ ws = null;
10
+ mode = null;
11
+ closedByUser = false;
12
+ attempts = 0;
13
+ timer = null;
14
+ _reconnectToken = null;
15
+ _seat = null;
16
+ listeners = /* @__PURE__ */ new Map();
17
+ constructor(opts) {
18
+ this.url = opts.url;
19
+ const WS = opts.WebSocket ?? globalThis.WebSocket;
20
+ if (!WS) throw new Error("no WebSocket implementation available; pass options.WebSocket");
21
+ this.WS = WS;
22
+ this.autoReconnect = opts.autoReconnect ?? true;
23
+ this.baseDelay = opts.reconnectDelayMs ?? 500;
24
+ this.maxDelay = opts.maxReconnectDelayMs ?? 8e3;
25
+ }
26
+ /** The current rotating reconnect token, or null (spectators never get one). */
27
+ get reconnectToken() {
28
+ return this._reconnectToken;
29
+ }
30
+ /** The recipient's seat: "white" | "black" | "spectator" | null (before join). */
31
+ get seat() {
32
+ return this._seat;
33
+ }
34
+ on(event, handler) {
35
+ let set = this.listeners.get(event);
36
+ if (!set) {
37
+ set = /* @__PURE__ */ new Set();
38
+ this.listeners.set(event, set);
39
+ }
40
+ set.add(handler);
41
+ return this;
42
+ }
43
+ off(event, handler) {
44
+ this.listeners.get(event)?.delete(handler);
45
+ return this;
46
+ }
47
+ /** Open the socket with the given credentials. */
48
+ connect(mode) {
49
+ this.closedByUser = false;
50
+ this.mode = mode;
51
+ this.attempts = 0;
52
+ this.dial();
53
+ }
54
+ /** Submit a move (UCI). No-op if the socket is not open. */
55
+ move(uci) {
56
+ this.send({ type: "move", uci });
57
+ }
58
+ /** Resign the game. */
59
+ resign() {
60
+ this.send({ type: "resign" });
61
+ }
62
+ /** Close the socket and stop any reconnection. */
63
+ close() {
64
+ this.closedByUser = true;
65
+ if (this.timer) clearTimeout(this.timer);
66
+ this.ws?.close(1e3, "");
67
+ this.ws = null;
68
+ }
69
+ dial() {
70
+ if (!this.mode) return;
71
+ const ws = new this.WS(this.url + "?" + queryFor(this.mode));
72
+ this.ws = ws;
73
+ ws.onopen = () => {
74
+ this.attempts = 0;
75
+ this.emit("open", void 0);
76
+ };
77
+ ws.onmessage = (ev) => this.handleMessage(ev.data);
78
+ ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);
79
+ ws.onerror = () => {
80
+ };
81
+ }
82
+ handleMessage(data) {
83
+ let msg;
84
+ try {
85
+ msg = JSON.parse(typeof data === "string" ? data : String(data));
86
+ } catch {
87
+ return;
88
+ }
89
+ this.emit("message", msg);
90
+ switch (msg.type) {
91
+ case "game_state":
92
+ if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;
93
+ this._seat = msg.you;
94
+ this.emit("gameState", msg);
95
+ break;
96
+ case "move":
97
+ this.emit("move", msg);
98
+ break;
99
+ case "game_over":
100
+ this.emit("gameOver", msg);
101
+ break;
102
+ case "error":
103
+ this.emit("error", msg);
104
+ break;
105
+ case "ping":
106
+ this.send({ type: "pong", t: msg.t });
107
+ break;
108
+ }
109
+ }
110
+ handleClose(code, reason) {
111
+ this.ws = null;
112
+ const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;
113
+ this.emit("close", { code, reason, willReconnect });
114
+ if (willReconnect) this.scheduleReconnect();
115
+ }
116
+ scheduleReconnect() {
117
+ this.attempts += 1;
118
+ const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);
119
+ this.emit("reconnecting", { attempt: this.attempts });
120
+ this.timer = setTimeout(() => {
121
+ if (this.closedByUser || this._reconnectToken === null) return;
122
+ this.mode = { reconnect: this._reconnectToken };
123
+ this.dial();
124
+ }, delay);
125
+ }
126
+ send(obj) {
127
+ if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));
128
+ }
129
+ emit(event, payload) {
130
+ this.listeners.get(event)?.forEach((h) => h(payload));
131
+ }
132
+ };
133
+ function queryFor(mode) {
134
+ if ("ticket" in mode) return "ticket=" + encodeURIComponent(mode.ticket);
135
+ if ("reconnect" in mode) return "reconnect=" + encodeURIComponent(mode.reconnect);
136
+ return "spectate=" + encodeURIComponent(mode.spectate);
137
+ }
138
+
139
+ // src/fen.ts
140
+ var FILES = ["a", "b", "c", "d", "e", "f", "g", "h"];
141
+ function parseBoard(fen) {
142
+ const rows = fen.split(" ")[0].split("/");
143
+ const out = {};
144
+ for (let i = 0; i < 8; i++) {
145
+ const rank = 8 - i;
146
+ let file = 0;
147
+ for (const ch of rows[i] ?? "") {
148
+ if (ch >= "1" && ch <= "8") {
149
+ file += Number(ch);
150
+ } else {
151
+ out[FILES[file] + rank] = ch;
152
+ file += 1;
153
+ }
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+ function fenTurn(fen) {
159
+ return fen.split(" ")[1] === "b" ? "black" : "white";
160
+ }
161
+ function isPromotion(board, from, to) {
162
+ const p = board[from];
163
+ return p === "P" && to[1] === "8" || p === "p" && to[1] === "1";
164
+ }
165
+ export {
166
+ EngineClient,
167
+ FILES,
168
+ fenTurn,
169
+ isPromotion,
170
+ parseBoard
171
+ };
172
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client.ts","../src/fen.ts"],"sourcesContent":["import type {\n ServerMessage,\n GameStateMessage,\n MoveMessage,\n GameOverMessage,\n ErrorMessage,\n Seat,\n} from \"./protocol.js\";\n\n/** How to authenticate the socket to a room. */\nexport type ConnectMode =\n | { ticket: string }\n | { reconnect: string }\n | { spectate: string };\n\n/** Events emitted by EngineClient. */\nexport interface EngineClientEvents {\n open: void;\n close: { code: number; reason: string; willReconnect: boolean };\n reconnecting: { attempt: number };\n gameState: GameStateMessage;\n move: MoveMessage;\n gameOver: GameOverMessage;\n error: ErrorMessage;\n message: ServerMessage;\n}\n\nexport interface EngineClientOptions {\n /** Base socket URL, e.g. \"wss://engine.example.com/ws\". */\n url: string;\n /** WebSocket implementation; defaults to the global (browser). Pass `ws` in Node. */\n WebSocket?: unknown;\n /** Auto-reconnect with the rotating token after an unexpected drop (default true). */\n autoReconnect?: boolean;\n reconnectDelayMs?: number;\n maxReconnectDelayMs?: number;\n}\n\ntype Listener<T> = (payload: T) => void;\ntype WSCtor = new (url: string) => WebSocketLike;\ninterface WebSocketLike {\n readyState: number;\n send(data: string): void;\n close(code?: number, reason?: string): void;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: { code: number; reason: string }) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n}\n\nconst OPEN = 1;\n\n/**\n * EngineClient wraps one game socket to the inachess-engine. It handles the wire\n * protocol end to end: parses server messages, auto-answers latency pings, tracks\n * the rotating reconnect token, and (by default) transparently reconnects after a\n * drop. Framework-agnostic — the platform routes a player to the play view with a\n * ticket, then hands that ticket to this client.\n *\n * ```ts\n * const c = new EngineClient({ url: \"wss://engine/ws\" });\n * c.on(\"gameState\", s => render(s));\n * c.on(\"move\", m => render(m));\n * c.connect({ ticket });\n * c.move(\"e2e4\");\n * ```\n */\nexport class EngineClient {\n private readonly url: string;\n private readonly WS: WSCtor;\n private readonly autoReconnect: boolean;\n private readonly baseDelay: number;\n private readonly maxDelay: number;\n\n private ws: WebSocketLike | null = null;\n private mode: ConnectMode | null = null;\n private closedByUser = false;\n private attempts = 0;\n private timer: ReturnType<typeof setTimeout> | null = null;\n\n private _reconnectToken: string | null = null;\n private _seat: Seat | null = null;\n private listeners = new Map<keyof EngineClientEvents, Set<Listener<unknown>>>();\n\n constructor(opts: EngineClientOptions) {\n this.url = opts.url;\n const WS = (opts.WebSocket ?? (globalThis as { WebSocket?: unknown }).WebSocket) as WSCtor | undefined;\n if (!WS) throw new Error(\"no WebSocket implementation available; pass options.WebSocket\");\n this.WS = WS;\n this.autoReconnect = opts.autoReconnect ?? true;\n this.baseDelay = opts.reconnectDelayMs ?? 500;\n this.maxDelay = opts.maxReconnectDelayMs ?? 8000;\n }\n\n /** The current rotating reconnect token, or null (spectators never get one). */\n get reconnectToken(): string | null {\n return this._reconnectToken;\n }\n\n /** The recipient's seat: \"white\" | \"black\" | \"spectator\" | null (before join). */\n get seat(): Seat | null {\n return this._seat;\n }\n\n on<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n let set = this.listeners.get(event);\n if (!set) {\n set = new Set();\n this.listeners.set(event, set);\n }\n set.add(handler as Listener<unknown>);\n return this;\n }\n\n off<K extends keyof EngineClientEvents>(event: K, handler: Listener<EngineClientEvents[K]>): this {\n this.listeners.get(event)?.delete(handler as Listener<unknown>);\n return this;\n }\n\n /** Open the socket with the given credentials. */\n connect(mode: ConnectMode): void {\n this.closedByUser = false;\n this.mode = mode;\n this.attempts = 0;\n this.dial();\n }\n\n /** Submit a move (UCI). No-op if the socket is not open. */\n move(uci: string): void {\n this.send({ type: \"move\", uci });\n }\n\n /** Resign the game. */\n resign(): void {\n this.send({ type: \"resign\" });\n }\n\n /** Close the socket and stop any reconnection. */\n close(): void {\n this.closedByUser = true;\n if (this.timer) clearTimeout(this.timer);\n this.ws?.close(1000, \"\");\n this.ws = null;\n }\n\n private dial(): void {\n if (!this.mode) return;\n const ws = new this.WS(this.url + \"?\" + queryFor(this.mode));\n this.ws = ws;\n ws.onopen = () => {\n this.attempts = 0;\n this.emit(\"open\", undefined);\n };\n ws.onmessage = (ev) => this.handleMessage(ev.data);\n ws.onclose = (ev) => this.handleClose(ev.code, ev.reason);\n ws.onerror = () => {}; // a close event always follows\n }\n\n private handleMessage(data: unknown): void {\n let msg: ServerMessage;\n try {\n msg = JSON.parse(typeof data === \"string\" ? data : String(data)) as ServerMessage;\n } catch {\n return;\n }\n this.emit(\"message\", msg);\n switch (msg.type) {\n case \"game_state\":\n if (msg.reconnectToken) this._reconnectToken = msg.reconnectToken;\n this._seat = msg.you;\n this.emit(\"gameState\", msg);\n break;\n case \"move\":\n this.emit(\"move\", msg);\n break;\n case \"game_over\":\n this.emit(\"gameOver\", msg);\n break;\n case \"error\":\n this.emit(\"error\", msg);\n break;\n case \"ping\":\n this.send({ type: \"pong\", t: msg.t });\n break;\n }\n }\n\n private handleClose(code: number, reason: string): void {\n this.ws = null;\n const willReconnect = !this.closedByUser && this.autoReconnect && this._reconnectToken !== null;\n this.emit(\"close\", { code, reason, willReconnect });\n if (willReconnect) this.scheduleReconnect();\n }\n\n private scheduleReconnect(): void {\n this.attempts += 1;\n const delay = Math.min(this.baseDelay * 2 ** (this.attempts - 1), this.maxDelay);\n this.emit(\"reconnecting\", { attempt: this.attempts });\n this.timer = setTimeout(() => {\n if (this.closedByUser || this._reconnectToken === null) return;\n this.mode = { reconnect: this._reconnectToken };\n this.dial();\n }, delay);\n }\n\n private send(obj: unknown): void {\n if (this.ws && this.ws.readyState === OPEN) this.ws.send(JSON.stringify(obj));\n }\n\n private emit<K extends keyof EngineClientEvents>(event: K, payload: EngineClientEvents[K]): void {\n this.listeners.get(event)?.forEach((h) => h(payload));\n }\n}\n\nfunction queryFor(mode: ConnectMode): string {\n if (\"ticket\" in mode) return \"ticket=\" + encodeURIComponent(mode.ticket);\n if (\"reconnect\" in mode) return \"reconnect=\" + encodeURIComponent(mode.reconnect);\n return \"spectate=\" + encodeURIComponent(mode.spectate);\n}\n","import type { Color } from \"./protocol.js\";\n\nexport type PieceChar = \"K\" | \"Q\" | \"R\" | \"B\" | \"N\" | \"P\" | \"k\" | \"q\" | \"r\" | \"b\" | \"n\" | \"p\";\n\nexport const FILES = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"] as const;\n\n/** parseBoard turns a FEN into a map of square (\"e4\") → piece char. */\nexport function parseBoard(fen: string): Record<string, PieceChar> {\n const rows = fen.split(\" \")[0].split(\"/\");\n const out: Record<string, PieceChar> = {};\n for (let i = 0; i < 8; i++) {\n const rank = 8 - i;\n let file = 0;\n for (const ch of rows[i] ?? \"\") {\n if (ch >= \"1\" && ch <= \"8\") {\n file += Number(ch);\n } else {\n out[FILES[file] + rank] = ch as PieceChar;\n file += 1;\n }\n }\n }\n return out;\n}\n\n/** fenTurn returns the side to move encoded in a FEN. */\nexport function fenTurn(fen: string): Color {\n return fen.split(\" \")[1] === \"b\" ? \"black\" : \"white\";\n}\n\n/** isPromotion reports whether moving the piece on `from` to `to` is a pawn\n * reaching the last rank — the UI appends a promotion piece (default queen). */\nexport function isPromotion(board: Record<string, PieceChar>, from: string, to: string): boolean {\n const p = board[from];\n return (p === \"P\" && to[1] === \"8\") || (p === \"p\" && to[1] === \"1\");\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@inachess/sdk",
3
+ "version": "0.1.0",
4
+ "description": "JavaScript/TypeScript client SDK for the inachess-engine socket protocol.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": ["dist"],
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "test": "node --test",
20
+ "prepublishOnly": "npm run build && npm test"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": ["chess", "inachess", "websocket", "sdk"],
26
+ "license": "MIT",
27
+ "devDependencies": {
28
+ "tsup": "^8.3.5",
29
+ "typescript": "^5.6.3",
30
+ "ws": "^8.18.0"
31
+ }
32
+ }