@couch-kit/host 0.3.0 → 0.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/package.json +31 -4
- package/src/event-emitter.ts +43 -16
- package/src/provider.tsx +173 -39
- package/src/server.ts +11 -5
- package/src/websocket.ts +286 -85
- package/src/events.d.ts +0 -20
package/package.json
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/faluciano/react-native-couch-kit.git",
|
|
9
|
+
"directory": "packages/host"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"android-tv",
|
|
15
|
+
"fire-tv",
|
|
16
|
+
"party-game",
|
|
17
|
+
"websocket",
|
|
18
|
+
"couch-kit",
|
|
19
|
+
"local-multiplayer",
|
|
20
|
+
"game-host"
|
|
21
|
+
],
|
|
4
22
|
"main": "src/index.tsx",
|
|
5
23
|
"react-native": "src/index.tsx",
|
|
6
24
|
"source": "src/index.tsx",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"react-native": "./src/index.tsx",
|
|
28
|
+
"types": "./src/index.tsx",
|
|
29
|
+
"default": "./src/index.tsx"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": false,
|
|
7
33
|
"files": [
|
|
8
34
|
"src",
|
|
9
35
|
"lib",
|
|
@@ -25,12 +51,12 @@
|
|
|
25
51
|
"clean": "del-cli lib"
|
|
26
52
|
},
|
|
27
53
|
"dependencies": {
|
|
28
|
-
"@couch-kit/core": "0.
|
|
54
|
+
"@couch-kit/core": "0.3.1",
|
|
55
|
+
"buffer": "^6.0.3",
|
|
29
56
|
"js-sha1": "^0.7.0",
|
|
30
57
|
"react-native-fs": "^2.20.0",
|
|
31
58
|
"react-native-network-info": "^5.2.1",
|
|
32
59
|
"react-native-nitro-http-server": "^1.5.4",
|
|
33
|
-
"react-native-nitro-modules": "^0.33.2",
|
|
34
60
|
"react-native-tcp-socket": "^6.0.6"
|
|
35
61
|
},
|
|
36
62
|
"devDependencies": {
|
|
@@ -43,6 +69,7 @@
|
|
|
43
69
|
},
|
|
44
70
|
"peerDependencies": {
|
|
45
71
|
"react": "*",
|
|
46
|
-
"react-native": "*"
|
|
72
|
+
"react-native": "*",
|
|
73
|
+
"react-native-nitro-modules": ">=0.33.0"
|
|
47
74
|
}
|
|
48
75
|
}
|
package/src/event-emitter.ts
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Lightweight EventEmitter implementation for cross-platform compatibility.
|
|
2
|
+
* Lightweight, type-safe EventEmitter implementation for cross-platform compatibility.
|
|
3
3
|
* Works in browser, React Native, and Node.js environments.
|
|
4
|
+
*
|
|
5
|
+
* @typeParam EventMap - A record mapping event names to their listener argument tuples.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* type MyEvents = {
|
|
10
|
+
* data: [payload: string];
|
|
11
|
+
* error: [err: Error];
|
|
12
|
+
* close: [];
|
|
13
|
+
* };
|
|
14
|
+
* const emitter = new EventEmitter<MyEvents>();
|
|
15
|
+
* emitter.on("data", (payload) => { ... }); // payload is typed as string
|
|
16
|
+
* ```
|
|
4
17
|
*/
|
|
5
18
|
|
|
6
|
-
|
|
7
|
-
|
|
19
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
|
+
export class EventEmitter<
|
|
21
|
+
EventMap extends Record<string, any[]> = Record<string, any[]>,
|
|
22
|
+
> {
|
|
23
|
+
private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
|
|
24
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
8
25
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
on<K extends string & keyof EventMap>(
|
|
27
|
+
event: K,
|
|
28
|
+
listener: (...args: EventMap[K]) => void,
|
|
29
|
+
): this {
|
|
13
30
|
if (!this.listeners.has(event)) {
|
|
14
31
|
this.listeners.set(event, []);
|
|
15
32
|
}
|
|
@@ -17,15 +34,22 @@ export class EventEmitter {
|
|
|
17
34
|
return this;
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
once
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
once<K extends string & keyof EventMap>(
|
|
38
|
+
event: K,
|
|
39
|
+
listener: (...args: EventMap[K]) => void,
|
|
40
|
+
): this {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
const onceWrapper = (...args: any[]) => {
|
|
43
|
+
this.off(event, onceWrapper as (...a: EventMap[K]) => void);
|
|
44
|
+
listener(...(args as EventMap[K]));
|
|
24
45
|
};
|
|
25
|
-
return this.on(event, onceWrapper);
|
|
46
|
+
return this.on(event, onceWrapper as (...a: EventMap[K]) => void);
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
off
|
|
49
|
+
off<K extends string & keyof EventMap>(
|
|
50
|
+
event: K,
|
|
51
|
+
listener: (...args: EventMap[K]) => void,
|
|
52
|
+
): this {
|
|
29
53
|
const listeners = this.listeners.get(event);
|
|
30
54
|
if (listeners) {
|
|
31
55
|
const index = listeners.indexOf(listener);
|
|
@@ -36,7 +60,10 @@ export class EventEmitter {
|
|
|
36
60
|
return this;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
|
-
emit
|
|
63
|
+
emit<K extends string & keyof EventMap>(
|
|
64
|
+
event: K,
|
|
65
|
+
...args: EventMap[K]
|
|
66
|
+
): boolean {
|
|
40
67
|
const listeners = this.listeners.get(event);
|
|
41
68
|
if (!listeners || listeners.length === 0) {
|
|
42
69
|
return false;
|
|
@@ -54,7 +81,7 @@ export class EventEmitter {
|
|
|
54
81
|
return true;
|
|
55
82
|
}
|
|
56
83
|
|
|
57
|
-
removeAllListeners(event?:
|
|
84
|
+
removeAllListeners<K extends string & keyof EventMap>(event?: K): this {
|
|
58
85
|
if (event) {
|
|
59
86
|
this.listeners.delete(event);
|
|
60
87
|
} else {
|
|
@@ -63,7 +90,7 @@ export class EventEmitter {
|
|
|
63
90
|
return this;
|
|
64
91
|
}
|
|
65
92
|
|
|
66
|
-
listenerCount(event:
|
|
93
|
+
listenerCount<K extends string & keyof EventMap>(event: K): number {
|
|
67
94
|
return this.listeners.get(event)?.length ?? 0;
|
|
68
95
|
}
|
|
69
96
|
}
|
package/src/provider.tsx
CHANGED
|
@@ -2,32 +2,44 @@ import React, {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
4
|
useEffect,
|
|
5
|
+
useMemo,
|
|
5
6
|
useReducer,
|
|
6
7
|
useRef,
|
|
8
|
+
useCallback,
|
|
7
9
|
} from "react";
|
|
8
10
|
import { GameWebSocketServer } from "./websocket";
|
|
9
11
|
import { useStaticServer } from "./server";
|
|
10
12
|
import {
|
|
11
13
|
MessageTypes,
|
|
14
|
+
InternalActionTypes,
|
|
15
|
+
DEFAULT_HTTP_PORT,
|
|
16
|
+
DEFAULT_WS_PORT_OFFSET,
|
|
12
17
|
type IGameState,
|
|
13
18
|
type IAction,
|
|
19
|
+
type InternalAction,
|
|
14
20
|
type ClientMessage,
|
|
15
21
|
} from "@couch-kit/core";
|
|
16
22
|
|
|
17
|
-
interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
23
|
+
export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
18
24
|
initialState: S;
|
|
19
|
-
reducer: (state: S, action: A) => S;
|
|
25
|
+
reducer: (state: S, action: A | InternalAction<S>) => S;
|
|
20
26
|
port?: number; // Static server port (default 8080)
|
|
21
27
|
wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
|
|
22
28
|
devMode?: boolean;
|
|
23
29
|
devServerUrl?: string;
|
|
24
30
|
staticDir?: string; // Override the default www directory path (required on Android)
|
|
25
31
|
debug?: boolean;
|
|
32
|
+
/** Called when a player successfully joins. */
|
|
33
|
+
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
34
|
+
/** Called when a player disconnects. */
|
|
35
|
+
onPlayerLeft?: (playerId: string) => void;
|
|
36
|
+
/** Called when a server error occurs. */
|
|
37
|
+
onError?: (error: Error) => void;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
29
41
|
state: S;
|
|
30
|
-
dispatch: (action: A) => void;
|
|
42
|
+
dispatch: (action: A | InternalAction<S>) => void;
|
|
31
43
|
serverUrl: string | null;
|
|
32
44
|
serverError: Error | null;
|
|
33
45
|
}
|
|
@@ -38,6 +50,42 @@ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
|
|
|
38
50
|
null,
|
|
39
51
|
);
|
|
40
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Validates that an incoming message has the expected shape.
|
|
55
|
+
* Returns true if the message is a valid ClientMessage, false otherwise.
|
|
56
|
+
*/
|
|
57
|
+
function isValidClientMessage(msg: unknown): msg is ClientMessage {
|
|
58
|
+
if (typeof msg !== "object" || msg === null) return false;
|
|
59
|
+
const m = msg as Record<string, unknown>;
|
|
60
|
+
if (typeof m.type !== "string") return false;
|
|
61
|
+
|
|
62
|
+
switch (m.type) {
|
|
63
|
+
case MessageTypes.JOIN:
|
|
64
|
+
return (
|
|
65
|
+
typeof m.payload === "object" &&
|
|
66
|
+
m.payload !== null &&
|
|
67
|
+
typeof (m.payload as Record<string, unknown>).name === "string"
|
|
68
|
+
);
|
|
69
|
+
case MessageTypes.ACTION:
|
|
70
|
+
return (
|
|
71
|
+
typeof m.payload === "object" &&
|
|
72
|
+
m.payload !== null &&
|
|
73
|
+
typeof (m.payload as Record<string, unknown>).type === "string"
|
|
74
|
+
);
|
|
75
|
+
case MessageTypes.PING:
|
|
76
|
+
return (
|
|
77
|
+
typeof m.payload === "object" &&
|
|
78
|
+
m.payload !== null &&
|
|
79
|
+
typeof (m.payload as Record<string, unknown>).id === "string" &&
|
|
80
|
+
typeof (m.payload as Record<string, unknown>).timestamp === "number"
|
|
81
|
+
);
|
|
82
|
+
case MessageTypes.ASSETS_LOADED:
|
|
83
|
+
return m.payload === true;
|
|
84
|
+
default:
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
42
90
|
children,
|
|
43
91
|
config,
|
|
@@ -53,9 +101,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
53
101
|
stateRef.current = state;
|
|
54
102
|
}, [state]);
|
|
55
103
|
|
|
56
|
-
//
|
|
104
|
+
// Keep refs for callback props to avoid stale closures
|
|
105
|
+
const configRef = useRef(config);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
configRef.current = config;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 1. Start Static File Server
|
|
111
|
+
const httpPort = config.port || DEFAULT_HTTP_PORT;
|
|
57
112
|
const { url: serverUrl, error: serverError } = useStaticServer({
|
|
58
|
-
port:
|
|
113
|
+
port: httpPort,
|
|
59
114
|
devMode: config.devMode,
|
|
60
115
|
devServerUrl: config.devServerUrl,
|
|
61
116
|
staticDir: config.staticDir,
|
|
@@ -67,30 +122,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
67
122
|
// Track active sessions: secret -> playerId
|
|
68
123
|
const sessions = useRef<Map<string, string>>(new Map());
|
|
69
124
|
|
|
125
|
+
// Track socket IDs that have received their WELCOME message
|
|
126
|
+
const welcomedClients = useRef<Set<string>>(new Set());
|
|
127
|
+
|
|
70
128
|
useEffect(() => {
|
|
71
|
-
const
|
|
72
|
-
const port = config.wsPort || httpPort + 2;
|
|
129
|
+
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
73
130
|
const server = new GameWebSocketServer({ port, debug: config.debug });
|
|
74
131
|
|
|
75
132
|
server.start();
|
|
76
133
|
wsServer.current = server;
|
|
77
134
|
|
|
78
135
|
server.on("listening", (p) => {
|
|
79
|
-
if (
|
|
136
|
+
if (configRef.current.debug)
|
|
80
137
|
console.log(`[GameHost] WebSocket listening on port ${p}`);
|
|
81
138
|
});
|
|
82
139
|
|
|
83
140
|
server.on("connection", (socketId) => {
|
|
84
|
-
if (
|
|
141
|
+
if (configRef.current.debug)
|
|
142
|
+
console.log(`[GameHost] Client connected: ${socketId}`);
|
|
85
143
|
});
|
|
86
144
|
|
|
87
|
-
server.on("message", (socketId,
|
|
88
|
-
|
|
145
|
+
server.on("message", (socketId, rawMessage) => {
|
|
146
|
+
// Validate message structure before processing
|
|
147
|
+
if (!isValidClientMessage(rawMessage)) {
|
|
148
|
+
if (configRef.current.debug)
|
|
149
|
+
console.warn(
|
|
150
|
+
`[GameHost] Invalid message from ${socketId}:`,
|
|
151
|
+
rawMessage,
|
|
152
|
+
);
|
|
153
|
+
server.send(socketId, {
|
|
154
|
+
type: MessageTypes.ERROR,
|
|
155
|
+
payload: { code: "INVALID_MESSAGE", message: "Malformed message" },
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const message = rawMessage;
|
|
161
|
+
|
|
162
|
+
if (configRef.current.debug)
|
|
89
163
|
console.log(`[GameHost] Msg from ${socketId}:`, message);
|
|
90
164
|
|
|
91
165
|
switch (message.type) {
|
|
92
166
|
case MessageTypes.JOIN: {
|
|
93
|
-
// Check for existing session
|
|
94
167
|
const { secret, ...payload } = message.payload;
|
|
95
168
|
|
|
96
169
|
if (secret) {
|
|
@@ -98,25 +171,58 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
98
171
|
sessions.current.set(secret, socketId);
|
|
99
172
|
}
|
|
100
173
|
|
|
174
|
+
// Dispatch the internal PLAYER_JOINED action
|
|
101
175
|
dispatch({
|
|
102
|
-
type:
|
|
103
|
-
payload: { id: socketId,
|
|
104
|
-
} as
|
|
176
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
177
|
+
payload: { id: socketId, ...payload },
|
|
178
|
+
} as InternalAction<S>);
|
|
105
179
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
// Use queueMicrotask to send WELCOME after the reducer has processed
|
|
181
|
+
// the PLAYER_JOINED action, so the client receives state that includes
|
|
182
|
+
// themselves in the players list.
|
|
183
|
+
queueMicrotask(() => {
|
|
184
|
+
welcomedClients.current.add(socketId);
|
|
185
|
+
server.send(socketId, {
|
|
186
|
+
type: MessageTypes.WELCOME,
|
|
187
|
+
payload: {
|
|
188
|
+
playerId: socketId,
|
|
189
|
+
state: stateRef.current,
|
|
190
|
+
serverTime: Date.now(),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
113
193
|
});
|
|
194
|
+
|
|
195
|
+
configRef.current.onPlayerJoined?.(socketId, payload.name);
|
|
114
196
|
break;
|
|
115
197
|
}
|
|
116
198
|
|
|
117
|
-
case MessageTypes.ACTION:
|
|
118
|
-
|
|
199
|
+
case MessageTypes.ACTION: {
|
|
200
|
+
// Only accept actions with a user-defined type string,
|
|
201
|
+
// reject internal action types to prevent injection.
|
|
202
|
+
const actionPayload = message.payload as A;
|
|
203
|
+
if (
|
|
204
|
+
actionPayload.type === InternalActionTypes.HYDRATE ||
|
|
205
|
+
actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
|
|
206
|
+
actionPayload.type === InternalActionTypes.PLAYER_LEFT
|
|
207
|
+
) {
|
|
208
|
+
if (configRef.current.debug)
|
|
209
|
+
console.warn(
|
|
210
|
+
`[GameHost] Rejected internal action from ${socketId}:`,
|
|
211
|
+
actionPayload.type,
|
|
212
|
+
);
|
|
213
|
+
server.send(socketId, {
|
|
214
|
+
type: MessageTypes.ERROR,
|
|
215
|
+
payload: {
|
|
216
|
+
code: "FORBIDDEN_ACTION",
|
|
217
|
+
message:
|
|
218
|
+
"Internal action types cannot be dispatched by clients",
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
dispatch(actionPayload);
|
|
119
224
|
break;
|
|
225
|
+
}
|
|
120
226
|
|
|
121
227
|
case MessageTypes.PING:
|
|
122
228
|
server.send(socketId, {
|
|
@@ -132,16 +238,26 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
132
238
|
});
|
|
133
239
|
|
|
134
240
|
server.on("disconnect", (socketId) => {
|
|
135
|
-
if (
|
|
241
|
+
if (configRef.current.debug)
|
|
136
242
|
console.log(`[GameHost] Client disconnected: ${socketId}`);
|
|
137
243
|
|
|
244
|
+
welcomedClients.current.delete(socketId);
|
|
245
|
+
|
|
138
246
|
// We do NOT remove the session from the map here,
|
|
139
247
|
// allowing them to reconnect later with the same secret.
|
|
140
248
|
|
|
141
249
|
dispatch({
|
|
142
|
-
type:
|
|
250
|
+
type: InternalActionTypes.PLAYER_LEFT,
|
|
143
251
|
payload: { playerId: socketId },
|
|
144
|
-
} as
|
|
252
|
+
} as InternalAction<S>);
|
|
253
|
+
|
|
254
|
+
configRef.current.onPlayerLeft?.(socketId);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
server.on("error", (error) => {
|
|
258
|
+
if (configRef.current.debug)
|
|
259
|
+
console.error(`[GameHost] Server error:`, error);
|
|
260
|
+
configRef.current.onError?.(error);
|
|
145
261
|
});
|
|
146
262
|
|
|
147
263
|
return () => {
|
|
@@ -149,30 +265,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
149
265
|
};
|
|
150
266
|
}, []); // Run once on mount
|
|
151
267
|
|
|
152
|
-
// 3.
|
|
153
|
-
//
|
|
154
|
-
|
|
268
|
+
// 3. Throttled State Broadcasts (~30fps)
|
|
269
|
+
// Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
|
|
270
|
+
// reducing serialization overhead and network traffic for fast-updating games.
|
|
271
|
+
const broadcastPending = useRef(false);
|
|
272
|
+
const broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
273
|
+
|
|
274
|
+
const broadcastState = useCallback(() => {
|
|
155
275
|
if (wsServer.current) {
|
|
156
|
-
// Optimization: In the future, send deltas or only send if changed significantly
|
|
157
276
|
wsServer.current.broadcast({
|
|
158
277
|
type: MessageTypes.STATE_UPDATE,
|
|
159
278
|
payload: {
|
|
160
|
-
newState:
|
|
279
|
+
newState: stateRef.current,
|
|
161
280
|
timestamp: Date.now(),
|
|
162
281
|
},
|
|
163
282
|
});
|
|
164
283
|
}
|
|
165
|
-
|
|
284
|
+
broadcastPending.current = false;
|
|
285
|
+
}, []);
|
|
166
286
|
|
|
167
|
-
// Keep stateRef in sync inside this effect too just in case (redundant but safe)
|
|
168
287
|
useEffect(() => {
|
|
169
|
-
|
|
170
|
-
|
|
288
|
+
if (!broadcastPending.current) {
|
|
289
|
+
broadcastPending.current = true;
|
|
290
|
+
broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return () => {
|
|
294
|
+
if (broadcastTimer.current) {
|
|
295
|
+
clearTimeout(broadcastTimer.current);
|
|
296
|
+
broadcastTimer.current = null;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}, [state, broadcastState]);
|
|
300
|
+
|
|
301
|
+
// Memoize context value to prevent unnecessary re-renders of consumers
|
|
302
|
+
// that only use stable references like dispatch
|
|
303
|
+
const contextValue = useMemo(
|
|
304
|
+
() => ({ state, dispatch, serverUrl, serverError }),
|
|
305
|
+
[state, serverUrl, serverError],
|
|
306
|
+
);
|
|
171
307
|
|
|
172
308
|
return (
|
|
173
|
-
<GameHostContext.Provider
|
|
174
|
-
value={{ state, dispatch, serverUrl, serverError }}
|
|
175
|
-
>
|
|
309
|
+
<GameHostContext.Provider value={contextValue}>
|
|
176
310
|
{children}
|
|
177
311
|
</GameHostContext.Provider>
|
|
178
312
|
);
|
package/src/server.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import { StaticServer } from "react-native-nitro-http-server";
|
|
3
3
|
import RNFS from "react-native-fs";
|
|
4
4
|
import { getBestIpAddress } from "./network";
|
|
5
|
+
import { DEFAULT_HTTP_PORT, toErrorMessage } from "@couch-kit/core";
|
|
5
6
|
|
|
6
|
-
interface CouchKitHostConfig {
|
|
7
|
+
export interface CouchKitHostConfig {
|
|
7
8
|
port?: number;
|
|
8
9
|
devMode?: boolean;
|
|
9
10
|
devServerUrl?: string; // e.g. "http://localhost:5173"
|
|
@@ -13,9 +14,11 @@ interface CouchKitHostConfig {
|
|
|
13
14
|
export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
14
15
|
const [url, setUrl] = useState<string | null>(null);
|
|
15
16
|
const [error, setError] = useState<Error | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
let server: StaticServer | null = null;
|
|
21
|
+
setLoading(true);
|
|
19
22
|
|
|
20
23
|
const startServer = async () => {
|
|
21
24
|
// In Dev Mode, we don't start the static server.
|
|
@@ -29,6 +32,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
|
29
32
|
} else {
|
|
30
33
|
setError(new Error("Could not detect TV IP address"));
|
|
31
34
|
}
|
|
35
|
+
setLoading(false);
|
|
32
36
|
return;
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -37,7 +41,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
|
37
41
|
// Use staticDir if provided (required on Android where MainBundlePath is undefined),
|
|
38
42
|
// otherwise fall back to iOS MainBundlePath
|
|
39
43
|
const path = config.staticDir || `${RNFS.MainBundlePath}/www`;
|
|
40
|
-
const port = config.port ||
|
|
44
|
+
const port = config.port || DEFAULT_HTTP_PORT;
|
|
41
45
|
|
|
42
46
|
server = new StaticServer();
|
|
43
47
|
|
|
@@ -49,11 +53,13 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
|
49
53
|
if (ip) {
|
|
50
54
|
setUrl(`http://${ip}:${port}`);
|
|
51
55
|
} else {
|
|
52
|
-
// Fallback if we can't detect IP
|
|
56
|
+
// Fallback if we can't detect IP
|
|
53
57
|
setUrl(`http://localhost:${port}`);
|
|
54
58
|
}
|
|
55
59
|
} catch (e) {
|
|
56
|
-
setError(
|
|
60
|
+
setError(new Error(toErrorMessage(e)));
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false);
|
|
57
63
|
}
|
|
58
64
|
};
|
|
59
65
|
|
|
@@ -66,5 +72,5 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
|
66
72
|
};
|
|
67
73
|
}, [config.port, config.devMode, config.devServerUrl, config.staticDir]);
|
|
68
74
|
|
|
69
|
-
return { url, error };
|
|
75
|
+
return { url, error, loading };
|
|
70
76
|
};
|
package/src/websocket.ts
CHANGED
|
@@ -3,19 +3,40 @@
|
|
|
3
3
|
* Built on top of react-native-tcp-socket
|
|
4
4
|
*
|
|
5
5
|
* Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
|
|
6
|
-
* and robust buffer management per RFC 6455.
|
|
6
|
+
* server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import TcpSocket from "react-native-tcp-socket";
|
|
10
10
|
import { EventEmitter } from "./event-emitter";
|
|
11
11
|
import { Buffer } from "buffer";
|
|
12
12
|
import { sha1 } from "js-sha1";
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
generateId,
|
|
15
|
+
MAX_FRAME_SIZE,
|
|
16
|
+
KEEPALIVE_INTERVAL,
|
|
17
|
+
KEEPALIVE_TIMEOUT,
|
|
18
|
+
} from "@couch-kit/core";
|
|
19
|
+
|
|
20
|
+
export interface WebSocketConfig {
|
|
15
21
|
port: number;
|
|
16
22
|
debug?: boolean;
|
|
23
|
+
/** Maximum allowed frame payload size in bytes (default: 1 MB). */
|
|
24
|
+
maxFrameSize?: number;
|
|
25
|
+
/** Interval (ms) between server-side keepalive pings (default: 30s). 0 disables. */
|
|
26
|
+
keepaliveInterval?: number;
|
|
27
|
+
/** Timeout (ms) to wait for a pong after a keepalive ping (default: 10s). */
|
|
28
|
+
keepaliveTimeout?: number;
|
|
17
29
|
}
|
|
18
30
|
|
|
31
|
+
/** Event map for type-safe event emission. */
|
|
32
|
+
export type WebSocketServerEvents = {
|
|
33
|
+
connection: [socketId: string];
|
|
34
|
+
message: [socketId: string, message: unknown];
|
|
35
|
+
disconnect: [socketId: string];
|
|
36
|
+
listening: [port: number];
|
|
37
|
+
error: [error: Error];
|
|
38
|
+
};
|
|
39
|
+
|
|
19
40
|
// WebSocket opcodes (RFC 6455 Section 5.2)
|
|
20
41
|
const OPCODE = {
|
|
21
42
|
CONTINUATION: 0x0,
|
|
@@ -29,25 +50,79 @@ const OPCODE = {
|
|
|
29
50
|
// Simple WebSocket Frame Parser/Builder
|
|
30
51
|
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
31
52
|
|
|
53
|
+
/** Initial capacity for per-client receive buffers. */
|
|
54
|
+
const INITIAL_BUFFER_CAPACITY = 4096;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append data to a managed socket's buffer, growing capacity geometrically
|
|
58
|
+
* to avoid re-allocation on every TCP data event.
|
|
59
|
+
*/
|
|
60
|
+
function appendToBuffer(managed: ManagedSocket, data: Buffer): void {
|
|
61
|
+
const needed = managed.bufferLength + data.length;
|
|
62
|
+
|
|
63
|
+
if (needed > managed.buffer.length) {
|
|
64
|
+
// Grow by at least 2x or to fit the new data, whichever is larger
|
|
65
|
+
const newCapacity = Math.max(managed.buffer.length * 2, needed);
|
|
66
|
+
const grown = Buffer.alloc(newCapacity);
|
|
67
|
+
managed.buffer.copy(grown, 0, 0, managed.bufferLength);
|
|
68
|
+
managed.buffer = grown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
data.copy(managed.buffer, managed.bufferLength);
|
|
72
|
+
managed.bufferLength = needed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compact the buffer by discarding consumed bytes from the front.
|
|
77
|
+
* If all data has been consumed, reset the length to 0 without re-allocating.
|
|
78
|
+
*/
|
|
79
|
+
function compactBuffer(managed: ManagedSocket, consumed: number): void {
|
|
80
|
+
const remaining = managed.bufferLength - consumed;
|
|
81
|
+
if (remaining <= 0) {
|
|
82
|
+
managed.bufferLength = 0;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Shift remaining bytes to the front of the existing buffer
|
|
86
|
+
managed.buffer.copy(managed.buffer, 0, consumed, managed.bufferLength);
|
|
87
|
+
managed.bufferLength = remaining;
|
|
88
|
+
}
|
|
89
|
+
|
|
32
90
|
interface DecodedFrame {
|
|
33
91
|
opcode: number;
|
|
34
92
|
payload: Buffer;
|
|
35
93
|
bytesConsumed: number;
|
|
36
94
|
}
|
|
37
95
|
|
|
38
|
-
|
|
96
|
+
// Internal type for a TCP socket with our added management properties.
|
|
97
|
+
// We use `any` for the raw socket since react-native-tcp-socket doesn't export a clean type.
|
|
98
|
+
interface ManagedSocket {
|
|
39
99
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
100
|
+
socket: any;
|
|
101
|
+
id: string;
|
|
102
|
+
isHandshakeComplete: boolean;
|
|
103
|
+
buffer: Buffer;
|
|
104
|
+
/** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
|
|
105
|
+
bufferLength: number;
|
|
106
|
+
lastPong: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
110
|
+
private server: ReturnType<typeof TcpSocket.createServer> | null = null;
|
|
111
|
+
private clients: Map<string, ManagedSocket> = new Map();
|
|
43
112
|
private port: number;
|
|
44
113
|
private debug: boolean;
|
|
114
|
+
private maxFrameSize: number;
|
|
115
|
+
private keepaliveInterval: number;
|
|
116
|
+
private keepaliveTimeout: number;
|
|
117
|
+
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
45
118
|
|
|
46
119
|
constructor(config: WebSocketConfig) {
|
|
47
120
|
super();
|
|
48
121
|
this.port = config.port;
|
|
49
122
|
this.debug = !!config.debug;
|
|
50
|
-
this.
|
|
123
|
+
this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
|
|
124
|
+
this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
|
|
125
|
+
this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
|
|
51
126
|
}
|
|
52
127
|
|
|
53
128
|
private log(...args: unknown[]) {
|
|
@@ -56,46 +131,49 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
56
131
|
}
|
|
57
132
|
}
|
|
58
133
|
|
|
59
|
-
private generateSocketId(): string {
|
|
60
|
-
// Generate a 21-character base36 ID for negligible collision probability
|
|
61
|
-
const a = Math.random().toString(36).substring(2, 15); // 13 chars
|
|
62
|
-
const b = Math.random().toString(36).substring(2, 10); // 8 chars
|
|
63
|
-
return a + b;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
134
|
public start() {
|
|
68
135
|
this.log(`[WebSocket] Starting server on port ${this.port}...`);
|
|
136
|
+
|
|
69
137
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
-
this.server = TcpSocket.createServer((
|
|
138
|
+
this.server = TcpSocket.createServer((rawSocket: any) => {
|
|
71
139
|
this.log(
|
|
72
|
-
`[WebSocket] New connection from ${
|
|
140
|
+
`[WebSocket] New connection from ${rawSocket.address?.()?.address}`,
|
|
73
141
|
);
|
|
74
|
-
let buffer: Buffer = Buffer.alloc(0);
|
|
75
142
|
|
|
76
|
-
|
|
143
|
+
const managed: ManagedSocket = {
|
|
144
|
+
socket: rawSocket,
|
|
145
|
+
id: "",
|
|
146
|
+
isHandshakeComplete: false,
|
|
147
|
+
buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
|
|
148
|
+
bufferLength: 0,
|
|
149
|
+
lastPong: Date.now(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
rawSocket.on("data", (data: Buffer | string) => {
|
|
77
153
|
this.log(
|
|
78
154
|
`[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
|
|
79
155
|
);
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
typeof data === "string" ? Buffer.from(data) : data,
|
|
84
|
-
]);
|
|
156
|
+
// Append new data using growing buffer strategy (avoids Buffer.concat per event)
|
|
157
|
+
const incoming = typeof data === "string" ? Buffer.from(data) : data;
|
|
158
|
+
appendToBuffer(managed, incoming);
|
|
85
159
|
|
|
86
160
|
// Handshake not yet performed?
|
|
87
|
-
if (!
|
|
88
|
-
const header = buffer.toString(
|
|
161
|
+
if (!managed.isHandshakeComplete) {
|
|
162
|
+
const header = managed.buffer.toString(
|
|
163
|
+
"utf8",
|
|
164
|
+
0,
|
|
165
|
+
managed.bufferLength,
|
|
166
|
+
);
|
|
89
167
|
const endOfHeader = header.indexOf("\r\n\r\n");
|
|
90
168
|
if (endOfHeader !== -1) {
|
|
91
|
-
this.handleHandshake(
|
|
92
|
-
//
|
|
169
|
+
this.handleHandshake(managed, header);
|
|
170
|
+
// Compact buffer past the handshake header
|
|
93
171
|
const headerByteLength = Buffer.byteLength(
|
|
94
172
|
header.substring(0, endOfHeader + 4),
|
|
95
173
|
"utf8",
|
|
96
174
|
);
|
|
97
|
-
|
|
98
|
-
|
|
175
|
+
compactBuffer(managed, headerByteLength);
|
|
176
|
+
managed.isHandshakeComplete = true;
|
|
99
177
|
// Fall through to process any remaining frames below
|
|
100
178
|
} else {
|
|
101
179
|
return;
|
|
@@ -103,81 +181,133 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
103
181
|
}
|
|
104
182
|
|
|
105
183
|
// Process all complete frames in the buffer
|
|
106
|
-
this.processFrames(
|
|
107
|
-
buffer = remaining;
|
|
108
|
-
});
|
|
184
|
+
this.processFrames(managed);
|
|
109
185
|
});
|
|
110
186
|
|
|
111
|
-
|
|
187
|
+
rawSocket.on("error", (error: Error) => {
|
|
112
188
|
this.emit("error", error);
|
|
113
189
|
});
|
|
114
190
|
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
this.clients.delete(
|
|
118
|
-
this.emit("disconnect",
|
|
191
|
+
rawSocket.on("close", () => {
|
|
192
|
+
if (managed.id) {
|
|
193
|
+
this.clients.delete(managed.id);
|
|
194
|
+
this.emit("disconnect", managed.id);
|
|
119
195
|
}
|
|
120
196
|
});
|
|
121
197
|
});
|
|
122
198
|
|
|
199
|
+
// Handle server-level errors (e.g., port already in use)
|
|
200
|
+
this.server.on("error", (error: Error) => {
|
|
201
|
+
this.log("[WebSocket] Server error:", error);
|
|
202
|
+
this.emit("error", error);
|
|
203
|
+
});
|
|
204
|
+
|
|
123
205
|
this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
|
|
124
206
|
this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
|
|
125
207
|
this.emit("listening", this.port);
|
|
126
208
|
});
|
|
209
|
+
|
|
210
|
+
// Start keepalive pings if enabled
|
|
211
|
+
if (this.keepaliveInterval > 0) {
|
|
212
|
+
this.startKeepalive();
|
|
213
|
+
}
|
|
127
214
|
}
|
|
128
215
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
216
|
+
private startKeepalive() {
|
|
217
|
+
this.keepaliveTimer = setInterval(() => {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
|
|
220
|
+
|
|
221
|
+
for (const [id, managed] of this.clients) {
|
|
222
|
+
// Check if previous keepalive timed out
|
|
223
|
+
if (
|
|
224
|
+
now - managed.lastPong >
|
|
225
|
+
this.keepaliveInterval + this.keepaliveTimeout
|
|
226
|
+
) {
|
|
227
|
+
this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
|
|
228
|
+
try {
|
|
229
|
+
managed.socket.destroy();
|
|
230
|
+
} catch {
|
|
231
|
+
// Already destroyed
|
|
232
|
+
}
|
|
233
|
+
this.clients.delete(id);
|
|
234
|
+
this.emit("disconnect", id);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
managed.socket.write(pingFrame);
|
|
240
|
+
} catch {
|
|
241
|
+
// Socket already closing
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}, this.keepaliveInterval);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private processFrames(managed: ManagedSocket) {
|
|
248
|
+
let offset = 0;
|
|
249
|
+
|
|
250
|
+
while (offset < managed.bufferLength) {
|
|
251
|
+
// Create a view of the unconsumed portion for decoding
|
|
252
|
+
const view = managed.buffer.subarray(offset, managed.bufferLength);
|
|
253
|
+
let frame: DecodedFrame | null;
|
|
254
|
+
try {
|
|
255
|
+
frame = this.decodeFrame(view);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// Frame too large or malformed -- disconnect the client
|
|
258
|
+
this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
|
|
259
|
+
try {
|
|
260
|
+
managed.socket.destroy();
|
|
261
|
+
} catch {
|
|
262
|
+
// Already destroyed
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
137
267
|
if (!frame) {
|
|
138
|
-
// Incomplete frame -- keep
|
|
268
|
+
// Incomplete frame -- keep remaining bytes, wait for more data
|
|
139
269
|
break;
|
|
140
270
|
}
|
|
141
271
|
|
|
142
|
-
// Advance
|
|
143
|
-
|
|
272
|
+
// Advance past the consumed frame
|
|
273
|
+
offset += frame.bytesConsumed;
|
|
144
274
|
|
|
145
275
|
// Handle frame by opcode
|
|
146
276
|
switch (frame.opcode) {
|
|
147
277
|
case OPCODE.TEXT: {
|
|
148
278
|
try {
|
|
149
279
|
const message = JSON.parse(frame.payload.toString("utf8"));
|
|
150
|
-
this.emit("message",
|
|
151
|
-
} catch
|
|
280
|
+
this.emit("message", managed.id, message);
|
|
281
|
+
} catch {
|
|
152
282
|
// Corrupt JSON in a complete frame -- discard this frame, continue processing
|
|
153
283
|
this.log(
|
|
154
|
-
`[WebSocket] Invalid JSON from ${
|
|
284
|
+
`[WebSocket] Invalid JSON from ${managed.id}, discarding frame`,
|
|
155
285
|
);
|
|
156
286
|
}
|
|
157
287
|
break;
|
|
158
288
|
}
|
|
159
289
|
|
|
160
290
|
case OPCODE.CLOSE: {
|
|
161
|
-
this.log(`[WebSocket] Close frame from ${
|
|
291
|
+
this.log(`[WebSocket] Close frame from ${managed.id}`);
|
|
162
292
|
// Send close frame back (RFC 6455 Section 5.5.1)
|
|
163
293
|
const closeFrame = Buffer.alloc(2);
|
|
164
294
|
closeFrame[0] = 0x88; // FIN + Close opcode
|
|
165
295
|
closeFrame[1] = 0x00; // No payload
|
|
166
296
|
try {
|
|
167
|
-
socket.write(closeFrame);
|
|
297
|
+
managed.socket.write(closeFrame);
|
|
168
298
|
} catch {
|
|
169
299
|
// Socket may already be closing
|
|
170
300
|
}
|
|
171
|
-
socket.destroy();
|
|
301
|
+
managed.socket.destroy();
|
|
172
302
|
break;
|
|
173
303
|
}
|
|
174
304
|
|
|
175
305
|
case OPCODE.PING: {
|
|
176
|
-
this.log(`[WebSocket] Ping from ${
|
|
306
|
+
this.log(`[WebSocket] Ping from ${managed.id}`);
|
|
177
307
|
// Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
|
|
178
308
|
const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
|
|
179
309
|
try {
|
|
180
|
-
socket.write(pongFrame);
|
|
310
|
+
managed.socket.write(pongFrame);
|
|
181
311
|
} catch {
|
|
182
312
|
// Socket may be closing
|
|
183
313
|
}
|
|
@@ -185,68 +315,133 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
185
315
|
}
|
|
186
316
|
|
|
187
317
|
case OPCODE.PONG: {
|
|
188
|
-
//
|
|
189
|
-
|
|
318
|
+
// Update last-seen pong time for keepalive tracking
|
|
319
|
+
managed.lastPong = Date.now();
|
|
320
|
+
this.log(`[WebSocket] Pong from ${managed.id}`);
|
|
190
321
|
break;
|
|
191
322
|
}
|
|
192
323
|
|
|
193
324
|
case OPCODE.BINARY: {
|
|
194
325
|
// Binary frames not supported -- log and discard
|
|
195
326
|
this.log(
|
|
196
|
-
`[WebSocket] Binary frame from ${
|
|
327
|
+
`[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
|
|
197
328
|
);
|
|
198
329
|
break;
|
|
199
330
|
}
|
|
200
331
|
|
|
201
332
|
default: {
|
|
202
333
|
this.log(
|
|
203
|
-
`[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${
|
|
334
|
+
`[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
|
|
204
335
|
);
|
|
205
336
|
break;
|
|
206
337
|
}
|
|
207
338
|
}
|
|
208
339
|
|
|
209
340
|
// If socket was destroyed (e.g., close frame), stop processing
|
|
210
|
-
if (socket.destroyed) break;
|
|
341
|
+
if (managed.socket.destroyed) break;
|
|
211
342
|
}
|
|
212
343
|
|
|
213
|
-
|
|
344
|
+
// Compact buffer: shift unconsumed bytes to the front
|
|
345
|
+
compactBuffer(managed, offset);
|
|
214
346
|
}
|
|
215
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Gracefully stop the server.
|
|
350
|
+
* Sends close frames to all clients before destroying connections.
|
|
351
|
+
*/
|
|
216
352
|
public stop() {
|
|
353
|
+
// Stop keepalive timer
|
|
354
|
+
if (this.keepaliveTimer) {
|
|
355
|
+
clearInterval(this.keepaliveTimer);
|
|
356
|
+
this.keepaliveTimer = null;
|
|
357
|
+
}
|
|
358
|
+
|
|
217
359
|
if (this.server) {
|
|
218
|
-
|
|
219
|
-
|
|
360
|
+
// Send close frames to all clients before destroying
|
|
361
|
+
const closeFrame = Buffer.alloc(2);
|
|
362
|
+
closeFrame[0] = 0x88; // FIN + Close opcode
|
|
363
|
+
closeFrame[1] = 0x00; // No payload
|
|
364
|
+
|
|
365
|
+
this.clients.forEach((managed) => {
|
|
366
|
+
try {
|
|
367
|
+
managed.socket.write(closeFrame);
|
|
368
|
+
} catch {
|
|
369
|
+
// Socket may already be closing
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
managed.socket.destroy();
|
|
373
|
+
} catch {
|
|
374
|
+
// Already destroyed
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
220
378
|
this.clients.clear();
|
|
379
|
+
this.server.close();
|
|
221
380
|
}
|
|
222
381
|
}
|
|
223
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Send data to a specific client by socket ID.
|
|
385
|
+
* Silently ignores unknown socket IDs and write errors.
|
|
386
|
+
*/
|
|
224
387
|
public send(socketId: string, data: unknown) {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
388
|
+
const managed = this.clients.get(socketId);
|
|
389
|
+
if (managed) {
|
|
390
|
+
try {
|
|
391
|
+
const frame = this.encodeFrame(JSON.stringify(data));
|
|
392
|
+
managed.socket.write(frame);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
|
|
395
|
+
this.emit(
|
|
396
|
+
"error",
|
|
397
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
229
400
|
}
|
|
230
401
|
}
|
|
231
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Broadcast data to all connected clients.
|
|
405
|
+
* Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
|
|
406
|
+
*/
|
|
232
407
|
public broadcast(data: unknown, excludeId?: string) {
|
|
233
408
|
const frame = this.encodeFrame(JSON.stringify(data));
|
|
234
|
-
this.clients.forEach((
|
|
409
|
+
this.clients.forEach((managed, id) => {
|
|
235
410
|
if (id !== excludeId) {
|
|
236
|
-
|
|
411
|
+
try {
|
|
412
|
+
managed.socket.write(frame);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
|
|
415
|
+
// Don't abort -- continue sending to remaining clients
|
|
416
|
+
}
|
|
237
417
|
}
|
|
238
418
|
});
|
|
239
419
|
}
|
|
240
420
|
|
|
421
|
+
/** Returns the number of currently connected clients. */
|
|
422
|
+
public get clientCount(): number {
|
|
423
|
+
return this.clients.size;
|
|
424
|
+
}
|
|
425
|
+
|
|
241
426
|
// --- Private Helpers ---
|
|
242
427
|
|
|
243
428
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
244
|
-
private handleHandshake(
|
|
429
|
+
private handleHandshake(managed: ManagedSocket, header: string) {
|
|
245
430
|
this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
|
|
246
431
|
const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
|
|
247
432
|
if (!keyMatch) {
|
|
248
433
|
console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
|
|
249
|
-
socket.destroy();
|
|
434
|
+
managed.socket.destroy();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
|
|
439
|
+
const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
|
|
440
|
+
if (versionMatch && versionMatch[1] !== "13") {
|
|
441
|
+
console.error(
|
|
442
|
+
`[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
|
|
443
|
+
);
|
|
444
|
+
managed.socket.destroy();
|
|
250
445
|
return;
|
|
251
446
|
}
|
|
252
447
|
|
|
@@ -269,15 +464,15 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
269
464
|
"[WebSocket] Sending Handshake Response:",
|
|
270
465
|
JSON.stringify(response),
|
|
271
466
|
);
|
|
272
|
-
socket.write(response);
|
|
467
|
+
managed.socket.write(response);
|
|
273
468
|
|
|
274
|
-
// Assign ID and store
|
|
275
|
-
|
|
276
|
-
this.clients.set(
|
|
277
|
-
this.emit("connection",
|
|
469
|
+
// Assign cryptographically random ID and store
|
|
470
|
+
managed.id = generateId();
|
|
471
|
+
this.clients.set(managed.id, managed);
|
|
472
|
+
this.emit("connection", managed.id);
|
|
278
473
|
} catch (error) {
|
|
279
474
|
console.error("[WebSocket] Handshake error:", error);
|
|
280
|
-
socket.destroy();
|
|
475
|
+
managed.socket.destroy();
|
|
281
476
|
}
|
|
282
477
|
}
|
|
283
478
|
|
|
@@ -310,13 +505,19 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
310
505
|
// Read 64-bit length. For safety, only use the lower 32 bits.
|
|
311
506
|
const highBits = buffer.readUInt32BE(2);
|
|
312
507
|
if (highBits > 0) {
|
|
313
|
-
|
|
314
|
-
throw new Error("Frame payload too large");
|
|
508
|
+
throw new Error("Frame payload too large (exceeds 4 GB)");
|
|
315
509
|
}
|
|
316
510
|
payloadLength = buffer.readUInt32BE(6);
|
|
317
511
|
headerLength = 10;
|
|
318
512
|
}
|
|
319
513
|
|
|
514
|
+
// Enforce max frame size to prevent memory exhaustion attacks
|
|
515
|
+
if (payloadLength > this.maxFrameSize) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
320
521
|
const maskLength = isMasked ? 4 : 0;
|
|
321
522
|
const totalFrameLength = headerLength + maskLength + payloadLength;
|
|
322
523
|
|
|
@@ -325,8 +526,8 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
325
526
|
|
|
326
527
|
let payload: Buffer;
|
|
327
528
|
if (isMasked) {
|
|
328
|
-
const mask = buffer.
|
|
329
|
-
const maskedPayload = buffer.
|
|
529
|
+
const mask = buffer.subarray(headerLength, headerLength + 4);
|
|
530
|
+
const maskedPayload = buffer.subarray(
|
|
330
531
|
headerLength + 4,
|
|
331
532
|
headerLength + 4 + payloadLength,
|
|
332
533
|
);
|
|
@@ -336,7 +537,7 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
336
537
|
}
|
|
337
538
|
} else {
|
|
338
539
|
payload = Buffer.from(
|
|
339
|
-
buffer.
|
|
540
|
+
buffer.subarray(headerLength, headerLength + payloadLength),
|
|
340
541
|
);
|
|
341
542
|
}
|
|
342
543
|
|
package/src/events.d.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "./event-emitter";
|
|
2
|
-
|
|
3
|
-
// Since the EventEmitter type definition might not perfectly match the Node.js one in RN environment,
|
|
4
|
-
// we'll declare the interface we expect.
|
|
5
|
-
export interface GameWebSocketServer extends EventEmitter {
|
|
6
|
-
on(event: "connection", listener: (socketId: string) => void): this;
|
|
7
|
-
on(
|
|
8
|
-
event: "message",
|
|
9
|
-
listener: (socketId: string, message: unknown) => void,
|
|
10
|
-
): this;
|
|
11
|
-
on(event: "disconnect", listener: (socketId: string) => void): this;
|
|
12
|
-
on(event: "listening", listener: (port: number) => void): this;
|
|
13
|
-
on(event: "error", listener: (error: Error) => void): this;
|
|
14
|
-
|
|
15
|
-
emit(event: "connection", socketId: string): boolean;
|
|
16
|
-
emit(event: "message", socketId: string, message: unknown): boolean;
|
|
17
|
-
emit(event: "disconnect", socketId: string): boolean;
|
|
18
|
-
emit(event: "listening", port: number): boolean;
|
|
19
|
-
emit(event: "error", error: Error): boolean;
|
|
20
|
-
}
|