@couch-kit/host 1.3.1 → 1.5.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/lib/provider.d.ts +2 -0
- 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/provider.tsx +185 -66
- package/src/websocket.ts +127 -453
- package/lib/buffer-utils.d.ts +0 -27
- package/lib/buffer-utils.d.ts.map +0 -1
- package/src/buffer-utils.ts +0 -53
- package/src/declarations.d.ts +0 -25
package/lib/provider.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
9
9
|
devServerUrl?: string;
|
|
10
10
|
staticDir?: string;
|
|
11
11
|
debug?: boolean;
|
|
12
|
+
/** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
|
|
13
|
+
disconnectTimeout?: number;
|
|
12
14
|
/** Called when a player successfully joins. */
|
|
13
15
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
14
16
|
/** Called when a player disconnects. */
|
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,
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAUL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAQzB,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,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+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,qBAmaA;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.5.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.
|
|
58
|
+
"@couch-kit/core": "0.6.0",
|
|
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/provider.tsx
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
InternalActionTypes,
|
|
15
15
|
DEFAULT_HTTP_PORT,
|
|
16
16
|
DEFAULT_WS_PORT_OFFSET,
|
|
17
|
+
DEFAULT_DISCONNECT_TIMEOUT,
|
|
17
18
|
createGameReducer,
|
|
18
19
|
derivePlayerId,
|
|
20
|
+
derivePlayerIdLegacy,
|
|
19
21
|
isValidSecret,
|
|
20
22
|
type IGameState,
|
|
21
23
|
type IAction,
|
|
@@ -23,6 +25,12 @@ import {
|
|
|
23
25
|
type ClientMessage,
|
|
24
26
|
} from "@couch-kit/core";
|
|
25
27
|
|
|
28
|
+
/** Maximum actions per rate-limit window. */
|
|
29
|
+
const RATE_LIMIT_MAX = 60;
|
|
30
|
+
|
|
31
|
+
/** Rate-limit window duration (ms). */
|
|
32
|
+
const RATE_LIMIT_WINDOW = 1000;
|
|
33
|
+
|
|
26
34
|
export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
27
35
|
initialState: S;
|
|
28
36
|
reducer: (state: S, action: A) => S;
|
|
@@ -32,6 +40,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
32
40
|
devServerUrl?: string;
|
|
33
41
|
staticDir?: string; // Override the default www directory path (required on Android)
|
|
34
42
|
debug?: boolean;
|
|
43
|
+
/** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
|
|
44
|
+
disconnectTimeout?: number;
|
|
35
45
|
/** Called when a player successfully joins. */
|
|
36
46
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
37
47
|
/** Called when a player disconnects. */
|
|
@@ -125,23 +135,36 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
125
135
|
stateRef.current = state;
|
|
126
136
|
}, [state]);
|
|
127
137
|
|
|
128
|
-
// Send WELCOME messages after state has settled (post-render).
|
|
138
|
+
// Send WELCOME/RECONNECTED messages after state has settled (post-render).
|
|
129
139
|
// This guarantees the joining player is included in the state snapshot.
|
|
130
140
|
useEffect(() => {
|
|
131
141
|
if (pendingWelcome.current.size === 0) return;
|
|
132
142
|
if (!wsServer.current) return;
|
|
133
143
|
|
|
134
144
|
const server = wsServer.current;
|
|
135
|
-
for (const [
|
|
145
|
+
for (const [
|
|
146
|
+
socketId,
|
|
147
|
+
{ playerId, isReconnect },
|
|
148
|
+
] of pendingWelcome.current) {
|
|
136
149
|
welcomedClients.current.add(socketId);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
if (isReconnect) {
|
|
151
|
+
server.send(socketId, {
|
|
152
|
+
type: MessageTypes.RECONNECTED,
|
|
153
|
+
payload: {
|
|
154
|
+
playerId,
|
|
155
|
+
state,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
server.send(socketId, {
|
|
160
|
+
type: MessageTypes.WELCOME,
|
|
161
|
+
payload: {
|
|
162
|
+
playerId,
|
|
163
|
+
state,
|
|
164
|
+
serverTime: Date.now(),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
145
168
|
}
|
|
146
169
|
pendingWelcome.current.clear();
|
|
147
170
|
}, [state]);
|
|
@@ -178,14 +201,36 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
178
201
|
// Track socket IDs that have received their WELCOME message
|
|
179
202
|
const welcomedClients = useRef<Set<string>>(new Set());
|
|
180
203
|
|
|
181
|
-
// Track socket IDs that need a WELCOME message after state settles
|
|
182
|
-
const pendingWelcome = useRef<
|
|
204
|
+
// Track socket IDs that need a WELCOME/RECONNECTED message after state settles
|
|
205
|
+
const pendingWelcome = useRef<
|
|
206
|
+
Map<string, { playerId: string; isReconnect: boolean }>
|
|
207
|
+
>(new Map());
|
|
208
|
+
|
|
209
|
+
// Cache: socketId -> playerId (avoids async derivation in hot paths)
|
|
210
|
+
const socketIdToPlayerId = useRef<Map<string, string>>(new Map());
|
|
211
|
+
|
|
212
|
+
// Track which players have finished loading assets
|
|
213
|
+
const assetsLoaded = useRef<Map<string, boolean>>(new Map());
|
|
214
|
+
|
|
215
|
+
// Queue of actions dispatched since last broadcast (for STATE_UPDATE.action)
|
|
216
|
+
const actionQueue = useRef<unknown[]>([]);
|
|
217
|
+
|
|
218
|
+
// Rate limiting: track action count per socket
|
|
219
|
+
const rateLimits = useRef<
|
|
220
|
+
Map<string, { count: number; windowStart: number }>
|
|
221
|
+
>(new Map());
|
|
183
222
|
|
|
184
223
|
useEffect(() => {
|
|
185
224
|
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
186
225
|
const server = new GameWebSocketServer({ port, debug: config.debug });
|
|
187
226
|
|
|
188
|
-
server
|
|
227
|
+
// Start the WebSocket server asynchronously
|
|
228
|
+
server.start().catch((error) => {
|
|
229
|
+
if (configRef.current.debug) {
|
|
230
|
+
console.error("[GameHost] Failed to start WebSocket server:", error);
|
|
231
|
+
}
|
|
232
|
+
configRef.current.onError?.(error);
|
|
233
|
+
});
|
|
189
234
|
wsServer.current = server;
|
|
190
235
|
|
|
191
236
|
server.on("listening", (p) => {
|
|
@@ -234,39 +279,69 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
234
279
|
return;
|
|
235
280
|
}
|
|
236
281
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
282
|
+
// Async dual-derivation: try SHA-256 first, fall back to legacy
|
|
283
|
+
derivePlayerId(secret).then((hashedId) => {
|
|
284
|
+
let playerId = hashedId;
|
|
285
|
+
|
|
286
|
+
// Migration: if a player exists under the legacy ID, use that instead
|
|
287
|
+
const legacyId = derivePlayerIdLegacy(secret);
|
|
288
|
+
if (
|
|
289
|
+
!stateRef.current.players[playerId] &&
|
|
290
|
+
stateRef.current.players[legacyId]
|
|
291
|
+
) {
|
|
292
|
+
playerId = legacyId;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Cache the resolved playerId for this socket
|
|
296
|
+
socketIdToPlayerId.current.set(socketId, playerId);
|
|
297
|
+
|
|
298
|
+
// Update session maps
|
|
299
|
+
sessions.current.set(secret, socketId);
|
|
300
|
+
reverseMap.current.set(socketId, secret);
|
|
301
|
+
|
|
302
|
+
// Cancel any pending cleanup timer for this player
|
|
303
|
+
const existingTimer = cleanupTimers.current.get(playerId);
|
|
304
|
+
if (existingTimer) {
|
|
305
|
+
clearTimeout(existingTimer);
|
|
306
|
+
cleanupTimers.current.delete(playerId);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check if this is a returning player
|
|
310
|
+
const existingPlayer = stateRef.current.players[playerId];
|
|
311
|
+
if (existingPlayer) {
|
|
312
|
+
// Reconnection — restore existing player
|
|
313
|
+
dispatch({
|
|
314
|
+
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
315
|
+
payload: { playerId },
|
|
316
|
+
} as InternalAction<S>);
|
|
317
|
+
|
|
318
|
+
// Reset assets loaded status on reconnect
|
|
319
|
+
assetsLoaded.current.set(playerId, false);
|
|
320
|
+
|
|
321
|
+
// Queue RECONNECTED message
|
|
322
|
+
pendingWelcome.current.set(socketId, {
|
|
323
|
+
playerId,
|
|
324
|
+
isReconnect: true,
|
|
325
|
+
});
|
|
326
|
+
} else {
|
|
327
|
+
// New player
|
|
328
|
+
dispatch({
|
|
329
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
330
|
+
payload: { id: playerId, ...payload },
|
|
331
|
+
} as InternalAction<S>);
|
|
332
|
+
|
|
333
|
+
// Initialize assets loaded status
|
|
334
|
+
assetsLoaded.current.set(playerId, false);
|
|
335
|
+
|
|
336
|
+
// Queue WELCOME message
|
|
337
|
+
pendingWelcome.current.set(socketId, {
|
|
338
|
+
playerId,
|
|
339
|
+
isReconnect: false,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
configRef.current.onPlayerJoined?.(playerId, payload.name);
|
|
344
|
+
});
|
|
270
345
|
break;
|
|
271
346
|
}
|
|
272
347
|
|
|
@@ -296,12 +371,32 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
296
371
|
});
|
|
297
372
|
return;
|
|
298
373
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
374
|
+
|
|
375
|
+
// Rate limiting
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
let rateInfo = rateLimits.current.get(socketId);
|
|
378
|
+
if (!rateInfo || now - rateInfo.windowStart > RATE_LIMIT_WINDOW) {
|
|
379
|
+
rateInfo = { count: 0, windowStart: now };
|
|
380
|
+
rateLimits.current.set(socketId, rateInfo);
|
|
381
|
+
}
|
|
382
|
+
rateInfo.count++;
|
|
383
|
+
if (rateInfo.count > RATE_LIMIT_MAX) {
|
|
384
|
+
if (configRef.current.debug)
|
|
385
|
+
console.warn(`[GameHost] Rate limited ${socketId}`);
|
|
386
|
+
server.send(socketId, {
|
|
387
|
+
type: MessageTypes.ERROR,
|
|
388
|
+
payload: {
|
|
389
|
+
code: "RATE_LIMITED",
|
|
390
|
+
message: "Too many actions, slow down",
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Use cached playerId (populated at JOIN time)
|
|
397
|
+
const resolvedPlayerId = socketIdToPlayerId.current.get(socketId);
|
|
304
398
|
dispatch({ ...actionPayload, playerId: resolvedPlayerId });
|
|
399
|
+
actionQueue.current.push(actionPayload);
|
|
305
400
|
break;
|
|
306
401
|
}
|
|
307
402
|
|
|
@@ -315,6 +410,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
315
410
|
},
|
|
316
411
|
});
|
|
317
412
|
break;
|
|
413
|
+
|
|
414
|
+
case MessageTypes.ASSETS_LOADED: {
|
|
415
|
+
const loadedPlayerId = socketIdToPlayerId.current.get(socketId);
|
|
416
|
+
if (loadedPlayerId) {
|
|
417
|
+
assetsLoaded.current.set(loadedPlayerId, true);
|
|
418
|
+
if (configRef.current.debug)
|
|
419
|
+
console.log(`[GameHost] Assets loaded for ${loadedPlayerId}`);
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
318
423
|
}
|
|
319
424
|
});
|
|
320
425
|
|
|
@@ -324,13 +429,21 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
324
429
|
|
|
325
430
|
welcomedClients.current.delete(socketId);
|
|
326
431
|
|
|
327
|
-
//
|
|
432
|
+
// Use cached playerId
|
|
433
|
+
const playerId = socketIdToPlayerId.current.get(socketId);
|
|
434
|
+
socketIdToPlayerId.current.delete(socketId);
|
|
435
|
+
|
|
436
|
+
// Clean up reverse map
|
|
328
437
|
const secret = reverseMap.current.get(socketId);
|
|
329
438
|
reverseMap.current.delete(socketId);
|
|
330
439
|
|
|
331
|
-
|
|
440
|
+
// Clean up rate limits
|
|
441
|
+
rateLimits.current.delete(socketId);
|
|
442
|
+
|
|
443
|
+
if (!playerId || !secret) return; // Unknown socket, nothing to do
|
|
332
444
|
|
|
333
|
-
|
|
445
|
+
// Clean up assets loaded tracking
|
|
446
|
+
assetsLoaded.current.delete(playerId);
|
|
334
447
|
|
|
335
448
|
// RACE GUARD: Only mark as left if this socket is still the active one for this secret
|
|
336
449
|
if (sessions.current.get(secret) !== socketId) {
|
|
@@ -346,18 +459,17 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
346
459
|
|
|
347
460
|
configRef.current.onPlayerLeft?.(playerId);
|
|
348
461
|
|
|
349
|
-
// Start stale player cleanup timer
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
);
|
|
462
|
+
// Start stale player cleanup timer
|
|
463
|
+
const timeout =
|
|
464
|
+
configRef.current.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT;
|
|
465
|
+
const timer = setTimeout(() => {
|
|
466
|
+
cleanupTimers.current.delete(playerId);
|
|
467
|
+
sessions.current.delete(secret);
|
|
468
|
+
dispatch({
|
|
469
|
+
type: InternalActionTypes.PLAYER_REMOVED,
|
|
470
|
+
payload: { playerId },
|
|
471
|
+
} as InternalAction<S>);
|
|
472
|
+
}, timeout);
|
|
361
473
|
cleanupTimers.current.set(playerId, timer);
|
|
362
474
|
});
|
|
363
475
|
|
|
@@ -383,11 +495,18 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
383
495
|
|
|
384
496
|
const broadcastState = useCallback(() => {
|
|
385
497
|
if (wsServer.current) {
|
|
498
|
+
const actions = actionQueue.current;
|
|
499
|
+
actionQueue.current = [];
|
|
386
500
|
wsServer.current.broadcast({
|
|
387
501
|
type: MessageTypes.STATE_UPDATE,
|
|
388
502
|
payload: {
|
|
389
503
|
newState: stateRef.current,
|
|
390
504
|
timestamp: Date.now(),
|
|
505
|
+
...(actions.length === 1
|
|
506
|
+
? { action: actions[0] }
|
|
507
|
+
: actions.length > 1
|
|
508
|
+
? { action: actions }
|
|
509
|
+
: {}),
|
|
391
510
|
},
|
|
392
511
|
});
|
|
393
512
|
}
|
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
|
}
|
package/lib/buffer-utils.d.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Buffer management utilities for WebSocket per-client receive buffers.
|
|
3
|
-
* Extracted into a standalone module so they can be tested without
|
|
4
|
-
* react-native dependencies.
|
|
5
|
-
*/
|
|
6
|
-
import { Buffer } from "buffer";
|
|
7
|
-
import type { TcpSocketInstance } from "./declarations";
|
|
8
|
-
export interface ManagedSocket {
|
|
9
|
-
socket: TcpSocketInstance;
|
|
10
|
-
id: string;
|
|
11
|
-
isHandshakeComplete: boolean;
|
|
12
|
-
buffer: Buffer;
|
|
13
|
-
/** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
|
|
14
|
-
bufferLength: number;
|
|
15
|
-
lastPong: number;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Append data to a managed socket's buffer, growing capacity geometrically
|
|
19
|
-
* to avoid re-allocation on every TCP data event.
|
|
20
|
-
*/
|
|
21
|
-
export declare function appendToBuffer(managed: ManagedSocket, data: Buffer): void;
|
|
22
|
-
/**
|
|
23
|
-
* Compact the buffer by discarding consumed bytes from the front.
|
|
24
|
-
* If all data has been consumed, reset the length to 0 without re-allocating.
|
|
25
|
-
*/
|
|
26
|
-
export declare function compactBuffer(managed: ManagedSocket, consumed: number): void;
|
|
27
|
-
//# sourceMappingURL=buffer-utils.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"buffer-utils.d.ts","sourceRoot":"","sources":["../src/buffer-utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAGxD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB,EAAE,OAAO,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAazE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAS5E"}
|
package/src/buffer-utils.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Buffer management utilities for WebSocket per-client receive buffers.
|
|
3
|
-
* Extracted into a standalone module so they can be tested without
|
|
4
|
-
* react-native dependencies.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { Buffer } from "buffer";
|
|
8
|
-
import type { TcpSocketInstance } from "./declarations";
|
|
9
|
-
|
|
10
|
-
// Internal type for a TCP socket with our added management properties.
|
|
11
|
-
export interface ManagedSocket {
|
|
12
|
-
socket: TcpSocketInstance;
|
|
13
|
-
id: string;
|
|
14
|
-
isHandshakeComplete: boolean;
|
|
15
|
-
buffer: Buffer;
|
|
16
|
-
/** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
|
|
17
|
-
bufferLength: number;
|
|
18
|
-
lastPong: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Append data to a managed socket's buffer, growing capacity geometrically
|
|
23
|
-
* to avoid re-allocation on every TCP data event.
|
|
24
|
-
*/
|
|
25
|
-
export function appendToBuffer(managed: ManagedSocket, data: Buffer): void {
|
|
26
|
-
const needed = managed.bufferLength + data.length;
|
|
27
|
-
|
|
28
|
-
if (needed > managed.buffer.length) {
|
|
29
|
-
// Grow by at least 2x or to fit the new data, whichever is larger
|
|
30
|
-
const newCapacity = Math.max(managed.buffer.length * 2, needed);
|
|
31
|
-
const grown = Buffer.alloc(newCapacity);
|
|
32
|
-
managed.buffer.copy(grown, 0, 0, managed.bufferLength);
|
|
33
|
-
managed.buffer = grown;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
data.copy(managed.buffer, managed.bufferLength);
|
|
37
|
-
managed.bufferLength = needed;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Compact the buffer by discarding consumed bytes from the front.
|
|
42
|
-
* If all data has been consumed, reset the length to 0 without re-allocating.
|
|
43
|
-
*/
|
|
44
|
-
export function compactBuffer(managed: ManagedSocket, consumed: number): void {
|
|
45
|
-
const remaining = managed.bufferLength - consumed;
|
|
46
|
-
if (remaining <= 0) {
|
|
47
|
-
managed.bufferLength = 0;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
// Shift remaining bytes to the front of the existing buffer
|
|
51
|
-
managed.buffer.copy(managed.buffer, 0, consumed, managed.bufferLength);
|
|
52
|
-
managed.bufferLength = remaining;
|
|
53
|
-
}
|
package/src/declarations.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal type definition for the raw TCP socket provided by react-native-tcp-socket.
|
|
3
|
-
* Covers only the API surface used by GameWebSocketServer.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { Buffer } from "buffer";
|
|
7
|
-
|
|
8
|
-
export interface TcpSocketInstance {
|
|
9
|
-
write(data: string | Buffer): void;
|
|
10
|
-
destroy(): void;
|
|
11
|
-
on(event: "data", callback: (data: Buffer | string) => void): this;
|
|
12
|
-
on(event: "error", callback: (error: Error) => void): this;
|
|
13
|
-
on(event: "close", callback: (hadError: boolean) => void): this;
|
|
14
|
-
address():
|
|
15
|
-
| { address: string; family: string; port: number }
|
|
16
|
-
| Record<string, never>;
|
|
17
|
-
readonly destroyed: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
declare module "react-native-nitro-http-server" {
|
|
21
|
-
export class StaticServer {
|
|
22
|
-
start(port: number, path: string, host?: string): Promise<void>;
|
|
23
|
-
stop(): void;
|
|
24
|
-
}
|
|
25
|
-
}
|