@couch-kit/host 1.3.0 → 1.4.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 CHANGED
@@ -26,10 +26,10 @@ Then install the required peer dependencies:
26
26
 
27
27
  ```bash
28
28
  npx expo install expo-file-system expo-network
29
- bun add react-native-tcp-socket
29
+ bun add react-native-tcp-socket react-native-nitro-modules
30
30
  ```
31
31
 
32
- > **Note:** This library requires Expo modules (`expo-file-system`, `expo-network`) and `react-native-tcp-socket` as peer dependencies. These must be installed in your consumer app. React Native's autolinking will handle native setup automatically.
32
+ > **Note:** This library requires Expo modules (`expo-file-system`, `expo-network`), `react-native-tcp-socket`, and `react-native-nitro-modules` as peer dependencies. These must be installed in your consumer app. React Native's autolinking will handle native setup automatically.
33
33
 
34
34
  ## Compatibility
35
35
 
@@ -45,8 +45,6 @@ bun add react-native-tcp-socket
45
45
 
46
46
  > **New Architecture:** This package supports React Native's New Architecture (Fabric/TurboModules) via React Native 0.83+.
47
47
 
48
- ## Usage
49
-
50
48
  ## API
51
49
 
52
50
  ### `<GameHostProvider config={...}>`
@@ -71,6 +69,36 @@ Returns:
71
69
  - `serverUrl`: HTTP URL phones should open (or `devServerUrl` in dev mode)
72
70
  - `serverError`: static server error (if startup fails)
73
71
 
72
+ ### `useExtractAssets(manifest)`
73
+
74
+ Extracts bundled web assets from the APK to a writable directory so the native HTTP server can serve them.
75
+
76
+ On Android, assets live inside the APK and cannot be served directly. This hook copies each file listed in the manifest from the APK to the device filesystem. On iOS, extraction is skipped since assets are accessible from the bundle directory.
77
+
78
+ ```tsx
79
+ import { useExtractAssets } from "@couch-kit/host";
80
+ import manifest from "./www/manifest.json";
81
+
82
+ function App() {
83
+ const { staticDir, loading, error } = useExtractAssets(manifest);
84
+
85
+ if (loading) return <Text>Extracting assets...</Text>;
86
+ if (error) return <Text>Error: {error}</Text>;
87
+
88
+ return (
89
+ <GameHostProvider config={{ staticDir, reducer, initialState }}>
90
+ ...
91
+ </GameHostProvider>
92
+ );
93
+ }
94
+ ```
95
+
96
+ | Property | Type | Description |
97
+ | ----------- | --------------------- | ------------------------------------------------- |
98
+ | `staticDir` | `string \| undefined` | Filesystem path to extracted assets, or undefined |
99
+ | `loading` | `boolean` | Whether extraction is in progress |
100
+ | `error` | `string \| null` | Error message if extraction failed |
101
+
74
102
  ## System Actions
75
103
 
76
104
  The host automatically dispatches internal system actions (`__PLAYER_JOINED__`, `__PLAYER_LEFT__`, `__PLAYER_RECONNECTED__`, `__PLAYER_REMOVED__`, `__HYDRATE__`) into `createGameReducer`, which handles them for you. **You do not need to handle these in your reducer.**
@@ -161,6 +189,8 @@ To iterate on your web controller without rebuilding the Android app constantly:
161
189
  ```tsx
162
190
  <GameHostProvider
163
191
  config={{
192
+ reducer: gameReducer,
193
+ initialState,
164
194
  devMode: true,
165
195
  devServerUrl: 'http://192.168.1.50:5173' // Your laptop's IP
166
196
  }}
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAsTA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBA4TA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Lightweight WebSocket Server Implementation
3
- * Built on top of react-native-tcp-socket
2
+ * WebSocket Server Implementation using nitro-http
4
3
  *
5
- * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
- * server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
4
+ * Supports: bidirectional messaging, connection/disconnect events,
5
+ * and integration with react-native-nitro-http-server's WebSocket plugin.
7
6
  */
8
7
  import { EventEmitter } from "./event-emitter";
9
8
  export interface WebSocketConfig {
@@ -29,20 +28,10 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
29
28
  private clients;
30
29
  private port;
31
30
  private debug;
32
- private maxFrameSize;
33
- private keepaliveInterval;
34
- private keepaliveTimeout;
35
- private keepaliveTimer;
36
31
  constructor(config: WebSocketConfig);
37
32
  private log;
38
- start(): void;
39
- private startKeepalive;
40
- private processFrames;
41
- /**
42
- * Gracefully stop the server.
43
- * Sends close frames to all clients before destroying connections.
44
- */
45
- stop(): void;
33
+ start(): Promise<void>;
34
+ stop(): Promise<void>;
46
35
  /**
47
36
  * Send data to a specific client by socket ID.
48
37
  * Silently ignores unknown socket IDs and write errors.
@@ -50,16 +39,10 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
50
39
  send(socketId: string, data: unknown): void;
51
40
  /**
52
41
  * Broadcast data to all connected clients.
53
- * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
42
+ * Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
54
43
  */
55
44
  broadcast(data: unknown, excludeId?: string): void;
56
45
  /** Returns the number of currently connected clients. */
57
46
  get clientCount(): number;
58
- private handleHandshake;
59
- private generateAcceptKey;
60
- private decodeFrame;
61
- private encodeFrame;
62
- private encodeControlFrame;
63
- private buildFrame;
64
47
  }
65
48
  //# sourceMappingURL=websocket.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAY/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAwBF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA0D;IACxE,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,cAAc,CAA+C;gBAEzD,MAAM,EAAE,eAAe;IASnC,OAAO,CAAC,GAAG;IAMJ,KAAK;IAkFZ,OAAO,CAAC,cAAc;IAkCtB,OAAO,CAAC,aAAa;IA6GrB;;;OAGG;IACI,IAAI;IAqCX;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAgB3C;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM;IAclD,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;IAID,OAAO,CAAC,eAAe;IAkDvB,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,WAAW;IA4DnB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,UAAU;CA2BnB"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAG/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;gBAEX,MAAM,EAAE,eAAe;IASnC,OAAO,CAAC,GAAG;IAME,KAAK;IA6FL,IAAI;IAoBjB;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAsB3C;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM;IAoBlD,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "provenance": true
7
+ },
4
8
  "description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
5
9
  "license": "MIT",
6
10
  "repository": {
@@ -51,7 +55,7 @@
51
55
  "prepublishOnly": "bun run build"
52
56
  },
53
57
  "dependencies": {
54
- "@couch-kit/core": "0.5.0",
58
+ "@couch-kit/core": "0.5.1",
55
59
  "buffer": "^6.0.3",
56
60
  "js-sha1": "^0.7.0",
57
61
  "react-native-nitro-http-server": "^1.5.4"
@@ -69,7 +73,6 @@
69
73
  "react-native-nitro-modules": ">=0.33.0",
70
74
  "expo": ">=51.0.0",
71
75
  "expo-file-system": ">=17.0.0",
72
- "expo-network": ">=7.0.0",
73
- "react-native-tcp-socket": ">=6.0.0"
76
+ "expo-network": ">=7.0.0"
74
77
  }
75
78
  }
package/src/assets.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import * as FileSystem from "expo-file-system";
3
- import { Paths, Directory } from "expo-file-system/next";
3
+ import { Paths, Directory } from "expo-file-system";
4
4
  import { Platform } from "react-native";
5
5
  import { toErrorMessage } from "@couch-kit/core";
6
6
 
package/src/provider.tsx CHANGED
@@ -185,7 +185,13 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
185
185
  const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
186
186
  const server = new GameWebSocketServer({ port, debug: config.debug });
187
187
 
188
- server.start();
188
+ // Start the WebSocket server asynchronously
189
+ server.start().catch((error) => {
190
+ if (configRef.current.debug) {
191
+ console.error("[GameHost] Failed to start WebSocket server:", error);
192
+ }
193
+ configRef.current.onError?.(error);
194
+ });
189
195
  wsServer.current = server;
190
196
 
191
197
  server.on("listening", (p) => {
package/src/server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { StaticServer } from "react-native-nitro-http-server";
3
- import { Paths } from "expo-file-system/next";
3
+ import { Paths } from "expo-file-system";
4
4
  import { getBestIpAddress } from "./network";
5
5
  import { DEFAULT_HTTP_PORT, toErrorMessage } from "@couch-kit/core";
6
6
 
@@ -58,7 +58,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
58
58
  if (!bundleUri) {
59
59
  throw new Error(
60
60
  "No staticDir provided and Paths.bundle is unavailable. " +
61
- "On Android, you must pass staticDir from useExtractAssets.",
61
+ "On Android, you must pass staticDir from useExtractAssets.",
62
62
  );
63
63
  }
64
64
  path = `${bundleUri.replace(/^file:\/\//, "")}www`;
package/src/websocket.ts CHANGED
@@ -1,24 +1,17 @@
1
1
  /**
2
- * Lightweight WebSocket Server Implementation
3
- * Built on top of react-native-tcp-socket
2
+ * WebSocket Server Implementation using nitro-http
4
3
  *
5
- * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
- * server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
4
+ * Supports: bidirectional messaging, connection/disconnect events,
5
+ * and integration with react-native-nitro-http-server's WebSocket plugin.
7
6
  */
8
7
 
9
- import TcpSocket from "react-native-tcp-socket";
10
- import type { TcpSocketInstance } from "./declarations";
8
+ import { ConfigServer } from "react-native-nitro-http-server";
9
+ import type {
10
+ ServerWebSocket,
11
+ WebSocketConnectionRequest,
12
+ } from "react-native-nitro-http-server";
11
13
  import { EventEmitter } from "./event-emitter";
12
- import { Buffer } from "buffer";
13
- import { sha1 } from "js-sha1";
14
- import {
15
- generateId,
16
- MAX_FRAME_SIZE,
17
- KEEPALIVE_INTERVAL,
18
- KEEPALIVE_TIMEOUT,
19
- } from "@couch-kit/core";
20
- import { appendToBuffer, compactBuffer } from "./buffer-utils";
21
- import type { ManagedSocket } from "./buffer-utils";
14
+ import { generateId } from "@couch-kit/core";
22
15
 
23
16
  export interface WebSocketConfig {
24
17
  port: number;
@@ -40,45 +33,24 @@ export type WebSocketServerEvents = {
40
33
  error: [error: Error];
41
34
  };
42
35
 
43
- // WebSocket opcodes (RFC 6455 Section 5.2)
44
- const OPCODE = {
45
- CONTINUATION: 0x0,
46
- TEXT: 0x1,
47
- BINARY: 0x2,
48
- CLOSE: 0x8,
49
- PING: 0x9,
50
- PONG: 0xa,
51
- } as const;
52
-
53
- // Simple WebSocket Frame Parser/Builder
54
- const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
55
-
56
- /** Initial capacity for per-client receive buffers. */
57
- const INITIAL_BUFFER_CAPACITY = 4096;
58
-
59
- interface DecodedFrame {
60
- opcode: number;
61
- payload: Buffer;
62
- bytesConsumed: number;
36
+ interface WebSocketClient {
37
+ id: string;
38
+ ws: ServerWebSocket;
63
39
  }
64
40
 
65
41
  export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
66
- private server: ReturnType<typeof TcpSocket.createServer> | null = null;
67
- private clients: Map<string, ManagedSocket> = new Map();
42
+ private server: ConfigServer | null = null;
43
+ private clients: Map<string, WebSocketClient> = new Map();
68
44
  private port: number;
69
45
  private debug: boolean;
70
- private maxFrameSize: number;
71
- private keepaliveInterval: number;
72
- private keepaliveTimeout: number;
73
- private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
74
46
 
75
47
  constructor(config: WebSocketConfig) {
76
48
  super();
77
49
  this.port = config.port;
78
50
  this.debug = !!config.debug;
79
- this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
80
- this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
81
- this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
51
+ // Note: maxFrameSize, keepaliveInterval, and keepaliveTimeout are not directly
52
+ // configurable in nitro-http WebSocket plugin, but the underlying implementation
53
+ // handles these concerns automatically.
82
54
  }
83
55
 
84
56
  private log(...args: unknown[]) {
@@ -87,269 +59,116 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
87
59
  }
88
60
  }
89
61
 
90
- public start() {
62
+ public async start() {
91
63
  this.log(`[WebSocket] Starting server on port ${this.port}...`);
92
64
 
93
- this.server = TcpSocket.createServer((rawSocket: TcpSocketInstance) => {
94
- const addrInfo = rawSocket.address();
95
- const remoteAddr =
96
- addrInfo && "address" in addrInfo ? addrInfo.address : "unknown";
97
- this.log(`[WebSocket] New connection from ${remoteAddr}`);
98
-
99
- const managed: ManagedSocket = {
100
- socket: rawSocket,
101
- id: "",
102
- isHandshakeComplete: false,
103
- buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
104
- bufferLength: 0,
105
- lastPong: Date.now(),
106
- };
107
-
108
- rawSocket.on("data", (data: Buffer | string) => {
109
- this.log(
110
- `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
111
- );
112
- // Append new data using growing buffer strategy (avoids Buffer.concat per event)
113
- const incoming = typeof data === "string" ? Buffer.from(data) : data;
114
- appendToBuffer(managed, incoming);
115
-
116
- // Handshake not yet performed?
117
- if (!managed.isHandshakeComplete) {
118
- const header = managed.buffer.toString(
119
- "utf8",
120
- 0,
121
- managed.bufferLength,
122
- );
123
- const endOfHeader = header.indexOf("\r\n\r\n");
124
- if (endOfHeader !== -1) {
125
- this.handleHandshake(managed, header);
126
- // Compact buffer past the handshake header
127
- const headerByteLength = Buffer.byteLength(
128
- header.substring(0, endOfHeader + 4),
129
- "utf8",
130
- );
131
- compactBuffer(managed, headerByteLength);
132
- managed.isHandshakeComplete = true;
133
- // Fall through to process any remaining frames below
134
- } else {
135
- return;
136
- }
137
- }
138
-
139
- // Process all complete frames in the buffer
140
- this.processFrames(managed);
141
- });
142
-
143
- rawSocket.on("error", (error: Error) => {
144
- this.emit("error", error);
145
- });
146
-
147
- rawSocket.on("close", () => {
148
- if (managed.id) {
149
- this.clients.delete(managed.id);
150
- this.emit("disconnect", managed.id);
151
- }
152
- });
153
- });
154
-
155
- // Handle server-level errors (e.g., port already in use)
156
- this.server.on("error", (error: Error) => {
157
- this.log("[WebSocket] Server error:", error);
158
- this.emit("error", error);
159
- });
160
-
161
- this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
162
- this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
163
- this.emit("listening", this.port);
164
- });
165
-
166
- // Start keepalive pings if enabled
167
- if (this.keepaliveInterval > 0) {
168
- this.startKeepalive();
169
- }
170
- }
171
-
172
- private startKeepalive() {
173
- this.keepaliveTimer = setInterval(() => {
174
- const now = Date.now();
175
- const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
176
-
177
- for (const [id, managed] of this.clients) {
178
- // Check if previous keepalive timed out
179
- if (
180
- now - managed.lastPong >
181
- this.keepaliveInterval + this.keepaliveTimeout
182
- ) {
183
- this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
184
- try {
185
- managed.socket.destroy();
186
- } catch (error) {
187
- this.log(
188
- "[WebSocket] Socket already destroyed during keepalive cleanup:",
189
- error,
190
- );
191
- }
192
- this.clients.delete(id);
193
- this.emit("disconnect", id);
194
- continue;
195
- }
196
-
197
- try {
198
- managed.socket.write(pingFrame);
199
- } catch (error) {
200
- this.log("[WebSocket] Failed to send keepalive ping:", error);
201
- }
202
- }
203
- }, this.keepaliveInterval);
204
- }
205
-
206
- private processFrames(managed: ManagedSocket) {
207
- let offset = 0;
65
+ this.server = new ConfigServer();
208
66
 
209
- while (offset < managed.bufferLength) {
210
- // Create a view of the unconsumed portion for decoding
211
- const view = Buffer.from(
212
- managed.buffer.buffer,
213
- managed.buffer.byteOffset + offset,
214
- managed.bufferLength - offset,
215
- );
216
- let frame: DecodedFrame | null;
217
- try {
218
- frame = this.decodeFrame(view);
219
- } catch (error) {
220
- // Frame too large or malformed -- disconnect the client
221
- this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
222
- try {
223
- managed.socket.destroy();
224
- } catch (destroyError) {
225
- this.log(
226
- "[WebSocket] Socket already destroyed after frame error:",
227
- destroyError,
228
- );
229
- }
230
- return;
231
- }
67
+ // Register WebSocket handler for the /ws path
68
+ this.server.onWebSocket(
69
+ "/ws",
70
+ (ws: ServerWebSocket, request: WebSocketConnectionRequest) => {
71
+ const socketId = generateId();
72
+ this.log(`[WebSocket] New connection: ${socketId}`, request);
232
73
 
233
- if (!frame) {
234
- // Incomplete frame -- keep remaining bytes, wait for more data
235
- break;
236
- }
74
+ // Store the client
75
+ this.clients.set(socketId, { id: socketId, ws });
237
76
 
238
- // Advance past the consumed frame
239
- offset += frame.bytesConsumed;
77
+ // Emit connection event
78
+ this.emit("connection", socketId);
240
79
 
241
- // Handle frame by opcode
242
- switch (frame.opcode) {
243
- case OPCODE.TEXT: {
80
+ // Handle incoming messages
81
+ ws.onmessage = (event: { data: string | ArrayBuffer }) => {
244
82
  try {
245
- const message = JSON.parse(frame.payload.toString("utf8"));
246
- this.emit("message", managed.id, message);
83
+ const data =
84
+ typeof event.data === "string"
85
+ ? event.data
86
+ : new TextDecoder().decode(event.data);
87
+ const message = JSON.parse(data);
88
+ this.emit("message", socketId, message);
247
89
  } catch (error) {
248
- // Corrupt JSON in a complete frame -- discard this frame, continue processing
249
90
  this.log(
250
- `[WebSocket] Invalid JSON from ${managed.id}, discarding frame:`,
91
+ `[WebSocket] Invalid JSON from ${socketId}, discarding:`,
251
92
  error,
252
93
  );
253
94
  }
254
- break;
255
- }
256
-
257
- case OPCODE.CLOSE: {
258
- this.log(`[WebSocket] Close frame from ${managed.id}`);
259
- // Send close frame back (RFC 6455 Section 5.5.1)
260
- const closeFrame = Buffer.alloc(2);
261
- closeFrame[0] = 0x88; // FIN + Close opcode
262
- closeFrame[1] = 0x00; // No payload
263
- try {
264
- managed.socket.write(closeFrame);
265
- } catch (error) {
266
- this.log("[WebSocket] Failed to send close frame:", error);
267
- }
268
- managed.socket.destroy();
269
- break;
270
- }
271
-
272
- case OPCODE.PING: {
273
- this.log(`[WebSocket] Ping from ${managed.id}`);
274
- // Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
275
- const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
276
- try {
277
- managed.socket.write(pongFrame);
278
- } catch (error) {
279
- this.log("[WebSocket] Failed to send pong:", error);
280
- }
281
- break;
282
- }
283
-
284
- case OPCODE.PONG: {
285
- // Update last-seen pong time for keepalive tracking
286
- managed.lastPong = Date.now();
287
- this.log(`[WebSocket] Pong from ${managed.id}`);
288
- break;
289
- }
290
-
291
- case OPCODE.BINARY: {
292
- // Binary frames not supported -- log and discard
95
+ };
96
+
97
+ // Handle disconnect
98
+ ws.onclose = (event: {
99
+ code: number;
100
+ reason: string;
101
+ wasClean: boolean;
102
+ }) => {
293
103
  this.log(
294
- `[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
104
+ `[WebSocket] Client disconnected: ${socketId}`,
105
+ event.code,
106
+ event.reason,
295
107
  );
296
- break;
297
- }
298
-
299
- default: {
300
- this.log(
301
- `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
108
+ this.clients.delete(socketId);
109
+ this.emit("disconnect", socketId);
110
+ };
111
+
112
+ // Handle errors
113
+ ws.onerror = (event: { message: string }) => {
114
+ this.log(`[WebSocket] Error on ${socketId}:`, event.message);
115
+ this.emit(
116
+ "error",
117
+ new Error(`WebSocket error: ${event.message}`),
302
118
  );
303
- break;
304
- }
305
- }
119
+ };
120
+ },
121
+ );
306
122
 
307
- // If socket was destroyed (e.g., close frame), stop processing
308
- if (managed.socket.destroyed) break;
123
+ // Start the server with WebSocket plugin
124
+ try {
125
+ await this.server.start(
126
+ this.port,
127
+ async () => {
128
+ // Dummy HTTP handler - we're only using WebSocket functionality
129
+ return {
130
+ statusCode: 404,
131
+ body: "WebSocket server - use ws:// protocol",
132
+ };
133
+ },
134
+ {
135
+ mounts: [
136
+ {
137
+ type: "websocket",
138
+ path: "/ws",
139
+ },
140
+ ],
141
+ },
142
+ "0.0.0.0", // Bind to all interfaces
143
+ );
144
+ this.emit("listening", this.port);
145
+ this.log(`[WebSocket] Server listening on port ${this.port}`);
146
+ } catch (error) {
147
+ this.log("[WebSocket] Failed to start server:", error);
148
+ this.emit(
149
+ "error",
150
+ error instanceof Error ? error : new Error(String(error)),
151
+ );
309
152
  }
310
-
311
- // Compact buffer: shift unconsumed bytes to the front
312
- compactBuffer(managed, offset);
313
153
  }
314
154
 
315
- /**
316
- * Gracefully stop the server.
317
- * Sends close frames to all clients before destroying connections.
318
- */
319
- public stop() {
320
- // Stop keepalive timer
321
- if (this.keepaliveTimer) {
322
- clearInterval(this.keepaliveTimer);
323
- this.keepaliveTimer = null;
324
- }
325
-
155
+ public async stop() {
326
156
  if (this.server) {
327
- // Send close frames to all clients before destroying
328
- const closeFrame = Buffer.alloc(2);
329
- closeFrame[0] = 0x88; // FIN + Close opcode
330
- closeFrame[1] = 0x00; // No payload
331
-
332
- this.clients.forEach((managed) => {
333
- try {
334
- managed.socket.write(closeFrame);
335
- } catch (error) {
336
- this.log(
337
- "[WebSocket] Failed to send close frame during shutdown:",
338
- error,
339
- );
340
- }
157
+ // Close all client connections
158
+ for (const [, client] of this.clients) {
341
159
  try {
342
- managed.socket.destroy();
160
+ await client.ws.close(1000, "Server shutting down");
343
161
  } catch (error) {
344
162
  this.log(
345
- "[WebSocket] Socket already destroyed during shutdown:",
163
+ "[WebSocket] Failed to close connection during shutdown:",
346
164
  error,
347
165
  );
348
166
  }
349
- });
167
+ }
350
168
 
351
169
  this.clients.clear();
352
- this.server.close();
170
+ await this.server.stop();
171
+ this.server = null;
353
172
  }
354
173
  }
355
174
 
@@ -358,13 +177,19 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
358
177
  * Silently ignores unknown socket IDs and write errors.
359
178
  */
360
179
  public send(socketId: string, data: unknown) {
361
- const managed = this.clients.get(socketId);
362
- if (managed) {
180
+ const client = this.clients.get(socketId);
181
+ if (client) {
363
182
  try {
364
- const frame = this.encodeFrame(JSON.stringify(data));
365
- managed.socket.write(frame);
183
+ const message = JSON.stringify(data);
184
+ client.ws.send(message).catch((error: Error) => {
185
+ this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
186
+ this.emit(
187
+ "error",
188
+ error instanceof Error ? error : new Error(String(error)),
189
+ );
190
+ });
366
191
  } catch (error) {
367
- this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
192
+ this.log(`[WebSocket] Failed to serialize message:`, error);
368
193
  this.emit(
369
194
  "error",
370
195
  error instanceof Error ? error : new Error(String(error)),
@@ -375,181 +200,30 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
375
200
 
376
201
  /**
377
202
  * Broadcast data to all connected clients.
378
- * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
203
+ * Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
379
204
  */
380
205
  public broadcast(data: unknown, excludeId?: string) {
381
- const frame = this.encodeFrame(JSON.stringify(data));
382
- this.clients.forEach((managed, id) => {
383
- if (id !== excludeId) {
384
- try {
385
- managed.socket.write(frame);
386
- } catch (error) {
387
- this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
388
- // Don't abort -- continue sending to remaining clients
389
- }
390
- }
391
- });
392
- }
393
-
394
- /** Returns the number of currently connected clients. */
395
- public get clientCount(): number {
396
- return this.clients.size;
397
- }
398
-
399
- // --- Private Helpers ---
400
-
401
- private handleHandshake(managed: ManagedSocket, header: string) {
402
- this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
403
- const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
404
- if (!keyMatch) {
405
- console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
406
- managed.socket.destroy();
407
- return;
408
- }
409
-
410
- // Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
411
- const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
412
- if (versionMatch && versionMatch[1] !== "13") {
413
- console.error(
414
- `[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
415
- );
416
- managed.socket.destroy();
417
- return;
418
- }
419
-
420
- const key = keyMatch[1].trim();
421
- this.log("[WebSocket] Client Key:", key);
422
-
423
206
  try {
424
- const acceptKey = this.generateAcceptKey(key);
425
- this.log("[WebSocket] Generated Accept Key:", acceptKey);
426
-
427
- const response = [
428
- "HTTP/1.1 101 Switching Protocols",
429
- "Upgrade: websocket",
430
- "Connection: Upgrade",
431
- `Sec-WebSocket-Accept: ${acceptKey}`,
432
- "\r\n",
433
- ].join("\r\n");
434
-
435
- this.log(
436
- "[WebSocket] Sending Handshake Response:",
437
- JSON.stringify(response),
438
- );
439
- managed.socket.write(response);
440
-
441
- // Assign cryptographically random ID and store
442
- managed.id = generateId();
443
- this.clients.set(managed.id, managed);
444
- this.emit("connection", managed.id);
207
+ const message = JSON.stringify(data);
208
+ this.clients.forEach((client, id) => {
209
+ if (id !== excludeId) {
210
+ client.ws.send(message).catch((error: Error) => {
211
+ this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
212
+ // Don't abort -- continue sending to remaining clients
213
+ });
214
+ }
215
+ });
445
216
  } catch (error) {
446
- console.error("[WebSocket] Handshake error:", error);
447
- managed.socket.destroy();
448
- }
449
- }
450
-
451
- private generateAcceptKey(key: string): string {
452
- const input = key + GUID;
453
- const hash = sha1(input);
454
- this.log(`[WebSocket] SHA1 Input: ${input}`);
455
- this.log(`[WebSocket] SHA1 Hash (hex): ${hash}`);
456
- return Buffer.from(hash, "hex").toString("base64");
457
- }
458
-
459
- private decodeFrame(buffer: Buffer): DecodedFrame | null {
460
- // Need at least 2 bytes for the header
461
- if (buffer.length < 2) return null;
462
-
463
- const firstByte = buffer[0];
464
- const opcode = firstByte & 0x0f;
465
-
466
- const secondByte = buffer[1];
467
- const isMasked = (secondByte & 0x80) !== 0;
468
- let payloadLength = secondByte & 0x7f;
469
- let headerLength = 2;
470
-
471
- if (payloadLength === 126) {
472
- if (buffer.length < 4) return null; // Need 2 more bytes for extended length
473
- payloadLength = buffer.readUInt16BE(2);
474
- headerLength = 4;
475
- } else if (payloadLength === 127) {
476
- if (buffer.length < 10) return null; // Need 8 more bytes for extended length
477
- // Read 64-bit length. For safety, only use the lower 32 bits.
478
- const highBits = buffer.readUInt32BE(2);
479
- if (highBits > 0) {
480
- throw new Error("Frame payload too large (exceeds 4 GB)");
481
- }
482
- payloadLength = buffer.readUInt32BE(6);
483
- headerLength = 10;
484
- }
485
-
486
- // Enforce max frame size to prevent memory exhaustion attacks
487
- if (payloadLength > this.maxFrameSize) {
488
- throw new Error(
489
- `Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
490
- );
491
- }
492
-
493
- const maskLength = isMasked ? 4 : 0;
494
- const totalFrameLength = headerLength + maskLength + payloadLength;
495
-
496
- // Check if we have the complete frame
497
- if (buffer.length < totalFrameLength) return null;
498
-
499
- let payload: Buffer;
500
- if (isMasked) {
501
- const mask = buffer.subarray(headerLength, headerLength + 4);
502
- const maskedPayload = buffer.subarray(
503
- headerLength + 4,
504
- headerLength + 4 + payloadLength,
505
- );
506
- payload = Buffer.alloc(payloadLength);
507
- for (let i = 0; i < payloadLength; i++) {
508
- payload[i] = maskedPayload[i] ^ mask[i % 4];
509
- }
510
- } else {
511
- payload = Buffer.from(
512
- buffer.subarray(headerLength, headerLength + payloadLength),
217
+ this.log(`[WebSocket] Failed to serialize broadcast message:`, error);
218
+ this.emit(
219
+ "error",
220
+ error instanceof Error ? error : new Error(String(error)),
513
221
  );
514
222
  }
515
-
516
- return { opcode, payload, bytesConsumed: totalFrameLength };
517
- }
518
-
519
- private encodeFrame(data: string): Buffer {
520
- // Server -> Client frames are NOT masked (text frame)
521
- return this.buildFrame(OPCODE.TEXT, Buffer.from(data));
522
- }
523
-
524
- private encodeControlFrame(opcode: number, payload: Buffer): Buffer {
525
- return this.buildFrame(opcode, payload);
526
223
  }
527
224
 
528
- private buildFrame(opcode: number, payload: Buffer): Buffer {
529
- let headerLength = 2;
530
-
531
- if (payload.length > 65535) {
532
- headerLength = 10; // 2 header + 8 length
533
- } else if (payload.length > 125) {
534
- headerLength = 4; // 2 header + 2 length
535
- }
536
-
537
- const frame = Buffer.alloc(headerLength + payload.length);
538
- frame[0] = 0x80 | opcode; // FIN bit set + opcode
539
-
540
- if (payload.length > 65535) {
541
- frame[1] = 127;
542
- // Write 64-bit integer (max safe integer in JS is 2^53, so high 32 bits are 0)
543
- frame.writeUInt32BE(0, 2);
544
- frame.writeUInt32BE(payload.length, 6);
545
- } else if (payload.length > 125) {
546
- frame[1] = 126;
547
- frame.writeUInt16BE(payload.length, 2);
548
- } else {
549
- frame[1] = payload.length;
550
- }
551
-
552
- payload.copy(frame, headerLength);
553
- return frame;
225
+ /** Returns the number of currently connected clients. */
226
+ public get clientCount(): number {
227
+ return this.clients.size;
554
228
  }
555
229
  }