@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 +34 -4
- package/lib/provider.d.ts.map +1 -1
- package/lib/websocket.d.ts +6 -23
- package/lib/websocket.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/assets.ts +1 -1
- package/src/provider.tsx +7 -1
- package/src/server.ts +2 -2
- package/src/websocket.ts +127 -453
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`)
|
|
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
|
}}
|
package/lib/provider.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/lib/websocket.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Built on top of react-native-tcp-socket
|
|
2
|
+
* WebSocket Server Implementation using nitro-http
|
|
4
3
|
*
|
|
5
|
-
* Supports:
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
package/lib/websocket.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA
|
|
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
|
+
"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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
* Built on top of react-native-tcp-socket
|
|
2
|
+
* WebSocket Server Implementation using nitro-http
|
|
4
3
|
*
|
|
5
|
-
* Supports:
|
|
6
|
-
*
|
|
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
|
|
10
|
-
import type {
|
|
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 {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
67
|
-
private clients: Map<string,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 =
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
74
|
+
// Store the client
|
|
75
|
+
this.clients.set(socketId, { id: socketId, ws });
|
|
237
76
|
|
|
238
|
-
|
|
239
|
-
|
|
77
|
+
// Emit connection event
|
|
78
|
+
this.emit("connection", socketId);
|
|
240
79
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
case OPCODE.TEXT: {
|
|
80
|
+
// Handle incoming messages
|
|
81
|
+
ws.onmessage = (event: { data: string | ArrayBuffer }) => {
|
|
244
82
|
try {
|
|
245
|
-
const
|
|
246
|
-
|
|
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 ${
|
|
91
|
+
`[WebSocket] Invalid JSON from ${socketId}, discarding:`,
|
|
251
92
|
error,
|
|
252
93
|
);
|
|
253
94
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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]
|
|
104
|
+
`[WebSocket] Client disconnected: ${socketId}`,
|
|
105
|
+
event.code,
|
|
106
|
+
event.reason,
|
|
295
107
|
);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
);
|
|
306
122
|
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
//
|
|
328
|
-
const
|
|
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
|
-
|
|
160
|
+
await client.ws.close(1000, "Server shutting down");
|
|
343
161
|
} catch (error) {
|
|
344
162
|
this.log(
|
|
345
|
-
"[WebSocket]
|
|
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.
|
|
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
|
|
362
|
-
if (
|
|
180
|
+
const client = this.clients.get(socketId);
|
|
181
|
+
if (client) {
|
|
363
182
|
try {
|
|
364
|
-
const
|
|
365
|
-
|
|
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
|
|
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
|
|
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
|
|
425
|
-
this.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
529
|
-
|
|
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
|
}
|