@couch-kit/host 0.3.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,147 @@
1
+ # @couch-kit/host
2
+
3
+ The server-side library for React Native TV applications. This package turns your TV app into a local game server.
4
+
5
+ > **Looking for a working example?** Check out the [Buzz](https://github.com/faluciano/buzz-tv-party-game) starter project for a complete host setup including asset extraction, QR code display, and player tracking.
6
+
7
+ ## Features
8
+
9
+ - **Dual-Port Architecture:**
10
+ - **Port 8080:** Static File Server (serves the web controller).
11
+ - **Port 8082:** WebSocket Game Server (handles real-time logic).
12
+ - **Session Recovery:** Tracks user secrets to support reconnection (handling page refreshes).
13
+ - **Large Message Support:** Capable of sending game states larger than 64KB (64-bit frame lengths).
14
+ - **Smart Network Discovery:** Uses the device IPv4 address for LAN URLs.
15
+ - **Game Loop:** Manages the canonical `IGameState` using a reducer.
16
+ - **Dev Mode:** Supports hot-reloading the web controller during development.
17
+ - **Debug Mode:** Optional logging for troubleshooting connection issues.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ bun add @couch-kit/host
23
+ ```
24
+
25
+ > **Note:** This library includes native dependencies (`react-native-tcp-socket`, `react-native-fs`, etc.). React Native's autolinking will handle the setup for Android. Ensure your `android/build.gradle` is configured correctly if you encounter build issues.
26
+
27
+ ## Usage
28
+
29
+ ## API
30
+
31
+ ### `<GameHostProvider config={...}>`
32
+
33
+ Config:
34
+
35
+ - `initialState`: initial host state
36
+ - `reducer`: `(state, action) => state` (shared reducer)
37
+ - `port?`: HTTP static server port (default `8080`)
38
+ - `wsPort?`: WebSocket game server port (default `8082`)
39
+ - `staticDir?`: absolute path to the directory of static files to serve. On Android, APK assets live inside a zip archive and cannot be served directly — use this to point to a writable filesystem path where you've extracted the `www/` assets at runtime. Defaults to `${RNFS.MainBundlePath}/www`.
40
+ - `devMode?`: if true, do not start the TV static file server; instead point phones at `devServerUrl`
41
+ - `devServerUrl?`: URL of your laptop dev server (e.g. `http://192.168.1.50:5173`)
42
+ - `debug?`: enable verbose logs
43
+
44
+ ### `useGameHost()`
45
+
46
+ Returns:
47
+
48
+ - `state`: canonical host state
49
+ - `dispatch(action)`: dispatches an action into your reducer
50
+ - `serverUrl`: HTTP URL phones should open (or `devServerUrl` in dev mode)
51
+ - `serverError`: static server error (if startup fails)
52
+
53
+ ## System Actions (Important)
54
+
55
+ The host will dispatch a few **system action types** into your reducer. Treat these as reserved:
56
+
57
+ - `PLAYER_JOINED`: payload `{ id: string, name: string, avatar?: string, secret?: string }`
58
+ - `PLAYER_LEFT`: payload `{ playerId: string }`
59
+
60
+ If you want to track players in `state.players`, handle these action types in your reducer. The `secret` field can be used to identify returning players.
61
+
62
+ ### 1. Configure the Provider
63
+
64
+ Wrap your root component (or the game screen) with `GameHostProvider`.
65
+
66
+ ```tsx
67
+ import { GameHostProvider } from "@couch-kit/host";
68
+ import { gameReducer, initialState } from "./shared/gameLogic";
69
+
70
+ export default function App() {
71
+ return (
72
+ <GameHostProvider
73
+ config={{
74
+ reducer: gameReducer,
75
+ initialState: initialState,
76
+ port: 8080, // Optional: HTTP port (default 8080)
77
+ wsPort: 8082, // Optional: WebSocket port (default 8082)
78
+ debug: true, // Optional: Enable detailed logs
79
+ }}
80
+ >
81
+ <GameScreen />
82
+ </GameHostProvider>
83
+ );
84
+ }
85
+ ```
86
+
87
+ ### 2. Access State & Actions
88
+
89
+ Use the `useGameHost` hook to access the game state, dispatch actions, and get the server URL (for QR codes).
90
+
91
+ ```tsx
92
+ import { useGameHost } from "@couch-kit/host";
93
+ import QRCode from "react-native-qrcode-svg";
94
+ import { View, Text, Button } from "react-native";
95
+
96
+ function GameScreen() {
97
+ const { state, dispatch, serverUrl } = useGameHost();
98
+
99
+ return (
100
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
101
+ {state.status === "lobby" && (
102
+ <>
103
+ <Text style={{ fontSize: 24, marginBottom: 20 }}>Join the Game!</Text>
104
+ {serverUrl && <QRCode value={serverUrl} size={200} />}
105
+ <Text style={{ marginTop: 20 }}>Scan to connect</Text>
106
+ <Text>Players Connected: {Object.keys(state.players).length}</Text>
107
+
108
+ <Button
109
+ title="Start Game"
110
+ onPress={() => dispatch({ type: "START_GAME" })}
111
+ />
112
+ </>
113
+ )}
114
+
115
+ {state.status === "playing" && (
116
+ <Text style={{ fontSize: 40 }}>Current Score: {state.score}</Text>
117
+ )}
118
+ </View>
119
+ );
120
+ }
121
+ ```
122
+
123
+ ## Development Mode
124
+
125
+ To iterate on your web controller without rebuilding the Android app constantly:
126
+
127
+ 1. Start your web project locally (`vite dev` usually runs on `localhost:5173`).
128
+ 2. Configure the Host to point to your laptop:
129
+
130
+ ```tsx
131
+ <GameHostProvider
132
+ config={{
133
+ devMode: true,
134
+ devServerUrl: 'http://192.168.1.50:5173' // Your laptop's IP
135
+ }}
136
+ >
137
+ ```
138
+
139
+ The TV will now tell phones to load the controller from your laptop.
140
+
141
+ Important: when the controller is served from the laptop, the client-side hook cannot infer the TV WebSocket host from `window.location.hostname`. In dev mode, pass `url: "ws://TV_IP:8082"` to `useGameClient()`.
142
+
143
+ ## Bundling / Assets
144
+
145
+ In production, the host serves static controller assets from `${RNFS.MainBundlePath}/www`.
146
+
147
+ The CLI `couch-kit bundle` copies your web build output into `android/app/src/main/assets/www` (default). Ensure your app packaging makes those assets available under the expected `www` folder.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@couch-kit/host",
3
+ "version": "0.3.0",
4
+ "main": "src/index.tsx",
5
+ "react-native": "src/index.tsx",
6
+ "source": "src/index.tsx",
7
+ "files": [
8
+ "src",
9
+ "lib",
10
+ "android",
11
+ "ios",
12
+ "cpp",
13
+ "*.podspec",
14
+ "!lib/typescript/example",
15
+ "!android/build",
16
+ "!ios/build",
17
+ "!**/__tests__",
18
+ "!**/__fixtures__",
19
+ "!**/__mocks__"
20
+ ],
21
+ "scripts": {
22
+ "test": "jest --passWithNoTests",
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
25
+ "clean": "del-cli lib"
26
+ },
27
+ "dependencies": {
28
+ "@couch-kit/core": "0.2.0",
29
+ "js-sha1": "^0.7.0",
30
+ "react-native-fs": "^2.20.0",
31
+ "react-native-network-info": "^5.2.1",
32
+ "react-native-nitro-http-server": "^1.5.4",
33
+ "react-native-nitro-modules": "^0.33.2",
34
+ "react-native-tcp-socket": "^6.0.6"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "~17.0.21",
38
+ "@types/react-native": "0.70.0",
39
+ "react": "18.2.0",
40
+ "react-native": "0.72.6",
41
+ "del-cli": "^5.1.0",
42
+ "jest": "^29.7.0"
43
+ },
44
+ "peerDependencies": {
45
+ "react": "*",
46
+ "react-native": "*"
47
+ }
48
+ }
@@ -0,0 +1,6 @@
1
+ declare module "react-native-nitro-http-server" {
2
+ export class StaticServer {
3
+ start(port: number, path: string, host?: string): Promise<void>;
4
+ stop(): void;
5
+ }
6
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Lightweight EventEmitter implementation for cross-platform compatibility.
3
+ * Works in browser, React Native, and Node.js environments.
4
+ */
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ type Listener = (...args: any[]) => void;
8
+
9
+ export class EventEmitter {
10
+ private listeners: Map<string, Listener[]> = new Map();
11
+
12
+ on(event: string, listener: Listener): this {
13
+ if (!this.listeners.has(event)) {
14
+ this.listeners.set(event, []);
15
+ }
16
+ this.listeners.get(event)!.push(listener);
17
+ return this;
18
+ }
19
+
20
+ once(event: string, listener: Listener): this {
21
+ const onceWrapper: Listener = (...args: any[]) => {
22
+ this.off(event, onceWrapper);
23
+ listener(...args);
24
+ };
25
+ return this.on(event, onceWrapper);
26
+ }
27
+
28
+ off(event: string, listener: Listener): this {
29
+ const listeners = this.listeners.get(event);
30
+ if (listeners) {
31
+ const index = listeners.indexOf(listener);
32
+ if (index !== -1) {
33
+ listeners.splice(index, 1);
34
+ }
35
+ }
36
+ return this;
37
+ }
38
+
39
+ emit(event: string, ...args: any[]): boolean {
40
+ const listeners = this.listeners.get(event);
41
+ if (!listeners || listeners.length === 0) {
42
+ return false;
43
+ }
44
+
45
+ // Create a copy to avoid issues if listeners are added/removed during emit
46
+ const listenersCopy = [...listeners];
47
+ for (const listener of listenersCopy) {
48
+ try {
49
+ listener(...args);
50
+ } catch (error) {
51
+ console.error(`Error in event listener for "${event}":`, error);
52
+ }
53
+ }
54
+ return true;
55
+ }
56
+
57
+ removeAllListeners(event?: string): this {
58
+ if (event) {
59
+ this.listeners.delete(event);
60
+ } else {
61
+ this.listeners.clear();
62
+ }
63
+ return this;
64
+ }
65
+
66
+ listenerCount(event: string): number {
67
+ return this.listeners.get(event)?.length ?? 0;
68
+ }
69
+ }
@@ -0,0 +1,20 @@
1
+ import { EventEmitter } from "./event-emitter";
2
+
3
+ // Since the EventEmitter type definition might not perfectly match the Node.js one in RN environment,
4
+ // we'll declare the interface we expect.
5
+ export interface GameWebSocketServer extends EventEmitter {
6
+ on(event: "connection", listener: (socketId: string) => void): this;
7
+ on(
8
+ event: "message",
9
+ listener: (socketId: string, message: unknown) => void,
10
+ ): this;
11
+ on(event: "disconnect", listener: (socketId: string) => void): this;
12
+ on(event: "listening", listener: (port: number) => void): this;
13
+ on(event: "error", listener: (error: Error) => void): this;
14
+
15
+ emit(event: "connection", socketId: string): boolean;
16
+ emit(event: "message", socketId: string, message: unknown): boolean;
17
+ emit(event: "disconnect", socketId: string): boolean;
18
+ emit(event: "listening", port: number): boolean;
19
+ emit(event: "error", error: Error): boolean;
20
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,4 @@
1
+ export * from './provider';
2
+ export * from './server';
3
+ export * from './websocket';
4
+ export * from './network';
package/src/network.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { NetworkInfo } from "react-native-network-info";
2
+
3
+ /**
4
+ * Smart IP Discovery
5
+ * Prioritizes interfaces that are likely to be the main network (WiFi/Ethernet)
6
+ * over internal/virtual interfaces.
7
+ */
8
+ export async function getBestIpAddress(): Promise<string | null> {
9
+ try {
10
+ // 1. Try to get the standard IP address (usually WiFi)
11
+ const ip = await NetworkInfo.getIPV4Address();
12
+
13
+ if (ip && ip !== "0.0.0.0" && ip !== "127.0.0.1") {
14
+ return ip;
15
+ }
16
+
17
+ // Fallback logic could go here (e.g., iterating interfaces if exposed by a native module)
18
+ // For now, react-native-network-info is the standard abstraction.
19
+
20
+ return null;
21
+ } catch (error) {
22
+ console.warn("[CouchKit] Failed to get IP address:", error);
23
+ return null;
24
+ }
25
+ }
@@ -0,0 +1,187 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useReducer,
6
+ useRef,
7
+ } from "react";
8
+ import { GameWebSocketServer } from "./websocket";
9
+ import { useStaticServer } from "./server";
10
+ import {
11
+ MessageTypes,
12
+ type IGameState,
13
+ type IAction,
14
+ type ClientMessage,
15
+ } from "@couch-kit/core";
16
+
17
+ interface GameHostConfig<S extends IGameState, A extends IAction> {
18
+ initialState: S;
19
+ reducer: (state: S, action: A) => S;
20
+ port?: number; // Static server port (default 8080)
21
+ wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
22
+ devMode?: boolean;
23
+ devServerUrl?: string;
24
+ staticDir?: string; // Override the default www directory path (required on Android)
25
+ debug?: boolean;
26
+ }
27
+
28
+ interface GameHostContextValue<S extends IGameState, A extends IAction> {
29
+ state: S;
30
+ dispatch: (action: A) => void;
31
+ serverUrl: string | null;
32
+ serverError: Error | null;
33
+ }
34
+
35
+ // Create Context with 'any' fallback because Context generics are tricky in React
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
38
+ null,
39
+ );
40
+
41
+ export function GameHostProvider<S extends IGameState, A extends IAction>({
42
+ children,
43
+ config,
44
+ }: {
45
+ children: React.ReactNode;
46
+ config: GameHostConfig<S, A>;
47
+ }) {
48
+ const [state, dispatch] = useReducer(config.reducer, config.initialState);
49
+
50
+ // Keep a ref to state so we can access it inside callbacks/effects that don't depend on it
51
+ const stateRef = useRef(state);
52
+ useEffect(() => {
53
+ stateRef.current = state;
54
+ }, [state]);
55
+
56
+ // 1. Start Static File Server (Port 8080)
57
+ const { url: serverUrl, error: serverError } = useStaticServer({
58
+ port: config.port || 8080,
59
+ devMode: config.devMode,
60
+ devServerUrl: config.devServerUrl,
61
+ staticDir: config.staticDir,
62
+ });
63
+
64
+ // 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
65
+ const wsServer = useRef<GameWebSocketServer | null>(null);
66
+
67
+ // Track active sessions: secret -> playerId
68
+ const sessions = useRef<Map<string, string>>(new Map());
69
+
70
+ useEffect(() => {
71
+ const httpPort = config.port || 8080;
72
+ const port = config.wsPort || httpPort + 2;
73
+ const server = new GameWebSocketServer({ port, debug: config.debug });
74
+
75
+ server.start();
76
+ wsServer.current = server;
77
+
78
+ server.on("listening", (p) => {
79
+ if (config.debug)
80
+ console.log(`[GameHost] WebSocket listening on port ${p}`);
81
+ });
82
+
83
+ server.on("connection", (socketId) => {
84
+ if (config.debug) console.log(`[GameHost] Client connected: ${socketId}`);
85
+ });
86
+
87
+ server.on("message", (socketId, message: ClientMessage) => {
88
+ if (config.debug)
89
+ console.log(`[GameHost] Msg from ${socketId}:`, message);
90
+
91
+ switch (message.type) {
92
+ case MessageTypes.JOIN: {
93
+ // Check for existing session
94
+ const { secret, ...payload } = message.payload;
95
+
96
+ if (secret) {
97
+ // Update the session map with the new socket ID for this secret
98
+ sessions.current.set(secret, socketId);
99
+ }
100
+
101
+ dispatch({
102
+ type: "PLAYER_JOINED",
103
+ payload: { id: socketId, secret, ...payload },
104
+ } as unknown as A);
105
+
106
+ server.send(socketId, {
107
+ type: MessageTypes.WELCOME,
108
+ payload: {
109
+ playerId: socketId,
110
+ state: stateRef.current,
111
+ serverTime: Date.now(),
112
+ },
113
+ });
114
+ break;
115
+ }
116
+
117
+ case MessageTypes.ACTION:
118
+ dispatch(message.payload as A);
119
+ break;
120
+
121
+ case MessageTypes.PING:
122
+ server.send(socketId, {
123
+ type: MessageTypes.PONG,
124
+ payload: {
125
+ id: message.payload.id,
126
+ origTimestamp: message.payload.timestamp,
127
+ serverTime: Date.now(),
128
+ },
129
+ });
130
+ break;
131
+ }
132
+ });
133
+
134
+ server.on("disconnect", (socketId) => {
135
+ if (config.debug)
136
+ console.log(`[GameHost] Client disconnected: ${socketId}`);
137
+
138
+ // We do NOT remove the session from the map here,
139
+ // allowing them to reconnect later with the same secret.
140
+
141
+ dispatch({
142
+ type: "PLAYER_LEFT",
143
+ payload: { playerId: socketId },
144
+ } as unknown as A);
145
+ });
146
+
147
+ return () => {
148
+ server.stop();
149
+ };
150
+ }, []); // Run once on mount
151
+
152
+ // 3. Broadcast State Updates
153
+ // Whenever React state changes, send it to all clients
154
+ useEffect(() => {
155
+ if (wsServer.current) {
156
+ // Optimization: In the future, send deltas or only send if changed significantly
157
+ wsServer.current.broadcast({
158
+ type: MessageTypes.STATE_UPDATE,
159
+ payload: {
160
+ newState: state,
161
+ timestamp: Date.now(),
162
+ },
163
+ });
164
+ }
165
+ }, [state]);
166
+
167
+ // Keep stateRef in sync inside this effect too just in case (redundant but safe)
168
+ useEffect(() => {
169
+ stateRef.current = state;
170
+ }, [state]);
171
+
172
+ return (
173
+ <GameHostContext.Provider
174
+ value={{ state, dispatch, serverUrl, serverError }}
175
+ >
176
+ {children}
177
+ </GameHostContext.Provider>
178
+ );
179
+ }
180
+
181
+ export function useGameHost<S extends IGameState, A extends IAction>() {
182
+ const context = useContext(GameHostContext);
183
+ if (!context) {
184
+ throw new Error("useGameHost must be used within a GameHostProvider");
185
+ }
186
+ return context as GameHostContextValue<S, A>;
187
+ }
package/src/server.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { useEffect, useState } from "react";
2
+ import { StaticServer } from "react-native-nitro-http-server";
3
+ import RNFS from "react-native-fs";
4
+ import { getBestIpAddress } from "./network";
5
+
6
+ interface CouchKitHostConfig {
7
+ port?: number;
8
+ devMode?: boolean;
9
+ devServerUrl?: string; // e.g. "http://localhost:5173"
10
+ staticDir?: string; // Override the default www directory path (required on Android)
11
+ }
12
+
13
+ export const useStaticServer = (config: CouchKitHostConfig) => {
14
+ const [url, setUrl] = useState<string | null>(null);
15
+ const [error, setError] = useState<Error | null>(null);
16
+
17
+ useEffect(() => {
18
+ let server: StaticServer | null = null;
19
+
20
+ const startServer = async () => {
21
+ // In Dev Mode, we don't start the static server.
22
+ // We just resolve the IP so the host knows where it is.
23
+ if (config.devMode && config.devServerUrl) {
24
+ const ip = await getBestIpAddress();
25
+ if (ip) {
26
+ // In dev mode, the URL is the laptop's dev server,
27
+ // but we might need the TV's IP for the WebSocket connection later.
28
+ setUrl(config.devServerUrl);
29
+ } else {
30
+ setError(new Error("Could not detect TV IP address"));
31
+ }
32
+ return;
33
+ }
34
+
35
+ // Production Mode: Serve assets from bundle
36
+ try {
37
+ // Use staticDir if provided (required on Android where MainBundlePath is undefined),
38
+ // otherwise fall back to iOS MainBundlePath
39
+ const path = config.staticDir || `${RNFS.MainBundlePath}/www`;
40
+ const port = config.port || 8080;
41
+
42
+ server = new StaticServer();
43
+
44
+ // Use '0.0.0.0' to bind to all interfaces (local network)
45
+ await server.start(port, path, "0.0.0.0");
46
+
47
+ // We prefer the actual IP over "localhost" returned by some libs
48
+ const ip = await getBestIpAddress();
49
+ if (ip) {
50
+ setUrl(`http://${ip}:${port}`);
51
+ } else {
52
+ // Fallback if we can't detect IP (though HttpServer doesn't return the URL directly like the old lib)
53
+ setUrl(`http://localhost:${port}`);
54
+ }
55
+ } catch (e) {
56
+ setError(e as Error);
57
+ }
58
+ };
59
+
60
+ startServer();
61
+
62
+ return () => {
63
+ if (server) {
64
+ server.stop();
65
+ }
66
+ };
67
+ }, [config.port, config.devMode, config.devServerUrl, config.staticDir]);
68
+
69
+ return { url, error };
70
+ };
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Lightweight WebSocket Server Implementation
3
+ * Built on top of react-native-tcp-socket
4
+ *
5
+ * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
+ * and robust buffer management per RFC 6455.
7
+ */
8
+
9
+ import TcpSocket from "react-native-tcp-socket";
10
+ import { EventEmitter } from "./event-emitter";
11
+ import { Buffer } from "buffer";
12
+ import { sha1 } from "js-sha1";
13
+
14
+ interface WebSocketConfig {
15
+ port: number;
16
+ debug?: boolean;
17
+ }
18
+
19
+ // WebSocket opcodes (RFC 6455 Section 5.2)
20
+ const OPCODE = {
21
+ CONTINUATION: 0x0,
22
+ TEXT: 0x1,
23
+ BINARY: 0x2,
24
+ CLOSE: 0x8,
25
+ PING: 0x9,
26
+ PONG: 0xa,
27
+ } as const;
28
+
29
+ // Simple WebSocket Frame Parser/Builder
30
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
31
+
32
+ interface DecodedFrame {
33
+ opcode: number;
34
+ payload: Buffer;
35
+ bytesConsumed: number;
36
+ }
37
+
38
+ export class GameWebSocketServer extends EventEmitter {
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ private server: any;
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ private clients: Map<string, any>;
43
+ private port: number;
44
+ private debug: boolean;
45
+
46
+ constructor(config: WebSocketConfig) {
47
+ super();
48
+ this.port = config.port;
49
+ this.debug = !!config.debug;
50
+ this.clients = new Map();
51
+ }
52
+
53
+ private log(...args: unknown[]) {
54
+ if (this.debug) {
55
+ console.log(...args);
56
+ }
57
+ }
58
+
59
+ private generateSocketId(): string {
60
+ // Generate a 21-character base36 ID for negligible collision probability
61
+ const a = Math.random().toString(36).substring(2, 15); // 13 chars
62
+ const b = Math.random().toString(36).substring(2, 10); // 8 chars
63
+ return a + b;
64
+ }
65
+
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ public start() {
68
+ this.log(`[WebSocket] Starting server on port ${this.port}...`);
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ this.server = TcpSocket.createServer((socket: any) => {
71
+ this.log(
72
+ `[WebSocket] New connection from ${socket.address?.()?.address}`,
73
+ );
74
+ let buffer: Buffer = Buffer.alloc(0);
75
+
76
+ socket.on("data", (data: Buffer | string) => {
77
+ this.log(
78
+ `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
79
+ );
80
+ // Concatenate new data
81
+ buffer = Buffer.concat([
82
+ buffer,
83
+ typeof data === "string" ? Buffer.from(data) : data,
84
+ ]);
85
+
86
+ // Handshake not yet performed?
87
+ if (!socket.isHandshakeComplete) {
88
+ const header = buffer.toString("utf8");
89
+ const endOfHeader = header.indexOf("\r\n\r\n");
90
+ if (endOfHeader !== -1) {
91
+ this.handleHandshake(socket, header);
92
+ // Retain any bytes after the handshake (could be the first WS frame)
93
+ const headerByteLength = Buffer.byteLength(
94
+ header.substring(0, endOfHeader + 4),
95
+ "utf8",
96
+ );
97
+ buffer = buffer.slice(headerByteLength);
98
+ socket.isHandshakeComplete = true;
99
+ // Fall through to process any remaining frames below
100
+ } else {
101
+ return;
102
+ }
103
+ }
104
+
105
+ // Process all complete frames in the buffer
106
+ this.processFrames(socket, buffer, (remaining) => {
107
+ buffer = remaining;
108
+ });
109
+ });
110
+
111
+ socket.on("error", (error: Error) => {
112
+ this.emit("error", error);
113
+ });
114
+
115
+ socket.on("close", () => {
116
+ if (socket.id) {
117
+ this.clients.delete(socket.id);
118
+ this.emit("disconnect", socket.id);
119
+ }
120
+ });
121
+ });
122
+
123
+ this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
124
+ this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
125
+ this.emit("listening", this.port);
126
+ });
127
+ }
128
+
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ private processFrames(
131
+ socket: any,
132
+ buffer: Buffer,
133
+ setBuffer: (b: Buffer) => void,
134
+ ) {
135
+ while (buffer.length > 0) {
136
+ const frame = this.decodeFrame(buffer);
137
+ if (!frame) {
138
+ // Incomplete frame -- keep buffer, wait for more data
139
+ break;
140
+ }
141
+
142
+ // Advance buffer past the consumed frame
143
+ buffer = buffer.slice(frame.bytesConsumed);
144
+
145
+ // Handle frame by opcode
146
+ switch (frame.opcode) {
147
+ case OPCODE.TEXT: {
148
+ try {
149
+ const message = JSON.parse(frame.payload.toString("utf8"));
150
+ this.emit("message", socket.id, message);
151
+ } catch (e) {
152
+ // Corrupt JSON in a complete frame -- discard this frame, continue processing
153
+ this.log(
154
+ `[WebSocket] Invalid JSON from ${socket.id}, discarding frame`,
155
+ );
156
+ }
157
+ break;
158
+ }
159
+
160
+ case OPCODE.CLOSE: {
161
+ this.log(`[WebSocket] Close frame from ${socket.id}`);
162
+ // Send close frame back (RFC 6455 Section 5.5.1)
163
+ const closeFrame = Buffer.alloc(2);
164
+ closeFrame[0] = 0x88; // FIN + Close opcode
165
+ closeFrame[1] = 0x00; // No payload
166
+ try {
167
+ socket.write(closeFrame);
168
+ } catch {
169
+ // Socket may already be closing
170
+ }
171
+ socket.destroy();
172
+ break;
173
+ }
174
+
175
+ case OPCODE.PING: {
176
+ this.log(`[WebSocket] Ping from ${socket.id}`);
177
+ // Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
178
+ const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
179
+ try {
180
+ socket.write(pongFrame);
181
+ } catch {
182
+ // Socket may be closing
183
+ }
184
+ break;
185
+ }
186
+
187
+ case OPCODE.PONG: {
188
+ // Unsolicited pong -- safe to ignore (RFC 6455 Section 5.5.3)
189
+ this.log(`[WebSocket] Pong from ${socket.id}`);
190
+ break;
191
+ }
192
+
193
+ case OPCODE.BINARY: {
194
+ // Binary frames not supported -- log and discard
195
+ this.log(
196
+ `[WebSocket] Binary frame from ${socket.id}, not supported -- discarding`,
197
+ );
198
+ break;
199
+ }
200
+
201
+ default: {
202
+ this.log(
203
+ `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${socket.id}, discarding`,
204
+ );
205
+ break;
206
+ }
207
+ }
208
+
209
+ // If socket was destroyed (e.g., close frame), stop processing
210
+ if (socket.destroyed) break;
211
+ }
212
+
213
+ setBuffer(buffer);
214
+ }
215
+
216
+ public stop() {
217
+ if (this.server) {
218
+ this.server.close();
219
+ this.clients.forEach((socket) => socket.destroy());
220
+ this.clients.clear();
221
+ }
222
+ }
223
+
224
+ public send(socketId: string, data: unknown) {
225
+ const socket = this.clients.get(socketId);
226
+ if (socket) {
227
+ const frame = this.encodeFrame(JSON.stringify(data));
228
+ socket.write(frame);
229
+ }
230
+ }
231
+
232
+ public broadcast(data: unknown, excludeId?: string) {
233
+ const frame = this.encodeFrame(JSON.stringify(data));
234
+ this.clients.forEach((socket, id) => {
235
+ if (id !== excludeId) {
236
+ socket.write(frame);
237
+ }
238
+ });
239
+ }
240
+
241
+ // --- Private Helpers ---
242
+
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
+ private handleHandshake(socket: any, header: string) {
245
+ this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
246
+ const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
247
+ if (!keyMatch) {
248
+ console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
249
+ socket.destroy();
250
+ return;
251
+ }
252
+
253
+ const key = keyMatch[1].trim();
254
+ this.log("[WebSocket] Client Key:", key);
255
+
256
+ try {
257
+ const acceptKey = this.generateAcceptKey(key);
258
+ this.log("[WebSocket] Generated Accept Key:", acceptKey);
259
+
260
+ const response = [
261
+ "HTTP/1.1 101 Switching Protocols",
262
+ "Upgrade: websocket",
263
+ "Connection: Upgrade",
264
+ `Sec-WebSocket-Accept: ${acceptKey}`,
265
+ "\r\n",
266
+ ].join("\r\n");
267
+
268
+ this.log(
269
+ "[WebSocket] Sending Handshake Response:",
270
+ JSON.stringify(response),
271
+ );
272
+ socket.write(response);
273
+
274
+ // Assign ID and store
275
+ socket.id = this.generateSocketId();
276
+ this.clients.set(socket.id, socket);
277
+ this.emit("connection", socket.id);
278
+ } catch (error) {
279
+ console.error("[WebSocket] Handshake error:", error);
280
+ socket.destroy();
281
+ }
282
+ }
283
+
284
+ private generateAcceptKey(key: string): string {
285
+ const input = key + GUID;
286
+ const hash = sha1(input);
287
+ this.log(`[WebSocket] SHA1 Input: ${input}`);
288
+ this.log(`[WebSocket] SHA1 Hash (hex): ${hash}`);
289
+ return Buffer.from(hash, "hex").toString("base64");
290
+ }
291
+
292
+ private decodeFrame(buffer: Buffer): DecodedFrame | null {
293
+ // Need at least 2 bytes for the header
294
+ if (buffer.length < 2) return null;
295
+
296
+ const firstByte = buffer[0];
297
+ const opcode = firstByte & 0x0f;
298
+
299
+ const secondByte = buffer[1];
300
+ const isMasked = (secondByte & 0x80) !== 0;
301
+ let payloadLength = secondByte & 0x7f;
302
+ let headerLength = 2;
303
+
304
+ if (payloadLength === 126) {
305
+ if (buffer.length < 4) return null; // Need 2 more bytes for extended length
306
+ payloadLength = buffer.readUInt16BE(2);
307
+ headerLength = 4;
308
+ } else if (payloadLength === 127) {
309
+ if (buffer.length < 10) return null; // Need 8 more bytes for extended length
310
+ // Read 64-bit length. For safety, only use the lower 32 bits.
311
+ const highBits = buffer.readUInt32BE(2);
312
+ if (highBits > 0) {
313
+ // Payload > 4GB -- reject
314
+ throw new Error("Frame payload too large");
315
+ }
316
+ payloadLength = buffer.readUInt32BE(6);
317
+ headerLength = 10;
318
+ }
319
+
320
+ const maskLength = isMasked ? 4 : 0;
321
+ const totalFrameLength = headerLength + maskLength + payloadLength;
322
+
323
+ // Check if we have the complete frame
324
+ if (buffer.length < totalFrameLength) return null;
325
+
326
+ let payload: Buffer;
327
+ if (isMasked) {
328
+ const mask = buffer.slice(headerLength, headerLength + 4);
329
+ const maskedPayload = buffer.slice(
330
+ headerLength + 4,
331
+ headerLength + 4 + payloadLength,
332
+ );
333
+ payload = Buffer.alloc(payloadLength);
334
+ for (let i = 0; i < payloadLength; i++) {
335
+ payload[i] = maskedPayload[i] ^ mask[i % 4];
336
+ }
337
+ } else {
338
+ payload = Buffer.from(
339
+ buffer.slice(headerLength, headerLength + payloadLength),
340
+ );
341
+ }
342
+
343
+ return { opcode, payload, bytesConsumed: totalFrameLength };
344
+ }
345
+
346
+ private encodeFrame(data: string): Buffer {
347
+ // Server -> Client frames are NOT masked (text frame)
348
+ return this.buildFrame(OPCODE.TEXT, Buffer.from(data));
349
+ }
350
+
351
+ private encodeControlFrame(opcode: number, payload: Buffer): Buffer {
352
+ return this.buildFrame(opcode, payload);
353
+ }
354
+
355
+ private buildFrame(opcode: number, payload: Buffer): Buffer {
356
+ let headerLength = 2;
357
+
358
+ if (payload.length > 65535) {
359
+ headerLength = 10; // 2 header + 8 length
360
+ } else if (payload.length > 125) {
361
+ headerLength = 4; // 2 header + 2 length
362
+ }
363
+
364
+ const frame = Buffer.alloc(headerLength + payload.length);
365
+ frame[0] = 0x80 | opcode; // FIN bit set + opcode
366
+
367
+ if (payload.length > 65535) {
368
+ frame[1] = 127;
369
+ // Write 64-bit integer (max safe integer in JS is 2^53, so high 32 bits are 0)
370
+ frame.writeUInt32BE(0, 2);
371
+ frame.writeUInt32BE(payload.length, 6);
372
+ } else if (payload.length > 125) {
373
+ frame[1] = 126;
374
+ frame.writeUInt16BE(payload.length, 2);
375
+ } else {
376
+ frame[1] = payload.length;
377
+ }
378
+
379
+ payload.copy(frame, headerLength);
380
+ return frame;
381
+ }
382
+ }