@couch-kit/host 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -2
- package/src/event-emitter.ts +42 -15
- package/src/provider.tsx +151 -36
- package/src/server.ts +11 -5
- package/src/websocket.ts +230 -69
- package/src/events.d.ts +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,7 +43,7 @@
|
|
|
25
43
|
"clean": "del-cli lib"
|
|
26
44
|
},
|
|
27
45
|
"dependencies": {
|
|
28
|
-
"@couch-kit/core": "0.
|
|
46
|
+
"@couch-kit/core": "0.3.0",
|
|
29
47
|
"js-sha1": "^0.7.0",
|
|
30
48
|
"react-native-fs": "^2.20.0",
|
|
31
49
|
"react-native-network-info": "^5.2.1",
|
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
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
-
|
|
20
|
+
export class EventEmitter<
|
|
21
|
+
EventMap extends Record<string, any[]> = Record<string, any[]>,
|
|
22
|
+
> {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
|
|
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,6 +2,7 @@ import React, {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
4
|
useEffect,
|
|
5
|
+
useMemo,
|
|
5
6
|
useReducer,
|
|
6
7
|
useRef,
|
|
7
8
|
} from "react";
|
|
@@ -9,25 +10,35 @@ import { GameWebSocketServer } from "./websocket";
|
|
|
9
10
|
import { useStaticServer } from "./server";
|
|
10
11
|
import {
|
|
11
12
|
MessageTypes,
|
|
13
|
+
InternalActionTypes,
|
|
14
|
+
DEFAULT_HTTP_PORT,
|
|
15
|
+
DEFAULT_WS_PORT_OFFSET,
|
|
12
16
|
type IGameState,
|
|
13
17
|
type IAction,
|
|
18
|
+
type InternalAction,
|
|
14
19
|
type ClientMessage,
|
|
15
20
|
} from "@couch-kit/core";
|
|
16
21
|
|
|
17
|
-
interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
22
|
+
export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
18
23
|
initialState: S;
|
|
19
|
-
reducer: (state: S, action: A) => S;
|
|
24
|
+
reducer: (state: S, action: A | InternalAction<S>) => S;
|
|
20
25
|
port?: number; // Static server port (default 8080)
|
|
21
26
|
wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
|
|
22
27
|
devMode?: boolean;
|
|
23
28
|
devServerUrl?: string;
|
|
24
29
|
staticDir?: string; // Override the default www directory path (required on Android)
|
|
25
30
|
debug?: boolean;
|
|
31
|
+
/** Called when a player successfully joins. */
|
|
32
|
+
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
33
|
+
/** Called when a player disconnects. */
|
|
34
|
+
onPlayerLeft?: (playerId: string) => void;
|
|
35
|
+
/** Called when a server error occurs. */
|
|
36
|
+
onError?: (error: Error) => void;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
29
40
|
state: S;
|
|
30
|
-
dispatch: (action: A) => void;
|
|
41
|
+
dispatch: (action: A | InternalAction<S>) => void;
|
|
31
42
|
serverUrl: string | null;
|
|
32
43
|
serverError: Error | null;
|
|
33
44
|
}
|
|
@@ -38,6 +49,42 @@ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
|
|
|
38
49
|
null,
|
|
39
50
|
);
|
|
40
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Validates that an incoming message has the expected shape.
|
|
54
|
+
* Returns true if the message is a valid ClientMessage, false otherwise.
|
|
55
|
+
*/
|
|
56
|
+
function isValidClientMessage(msg: unknown): msg is ClientMessage {
|
|
57
|
+
if (typeof msg !== "object" || msg === null) return false;
|
|
58
|
+
const m = msg as Record<string, unknown>;
|
|
59
|
+
if (typeof m.type !== "string") return false;
|
|
60
|
+
|
|
61
|
+
switch (m.type) {
|
|
62
|
+
case MessageTypes.JOIN:
|
|
63
|
+
return (
|
|
64
|
+
typeof m.payload === "object" &&
|
|
65
|
+
m.payload !== null &&
|
|
66
|
+
typeof (m.payload as Record<string, unknown>).name === "string"
|
|
67
|
+
);
|
|
68
|
+
case MessageTypes.ACTION:
|
|
69
|
+
return (
|
|
70
|
+
typeof m.payload === "object" &&
|
|
71
|
+
m.payload !== null &&
|
|
72
|
+
typeof (m.payload as Record<string, unknown>).type === "string"
|
|
73
|
+
);
|
|
74
|
+
case MessageTypes.PING:
|
|
75
|
+
return (
|
|
76
|
+
typeof m.payload === "object" &&
|
|
77
|
+
m.payload !== null &&
|
|
78
|
+
typeof (m.payload as Record<string, unknown>).id === "string" &&
|
|
79
|
+
typeof (m.payload as Record<string, unknown>).timestamp === "number"
|
|
80
|
+
);
|
|
81
|
+
case MessageTypes.ASSETS_LOADED:
|
|
82
|
+
return m.payload === true;
|
|
83
|
+
default:
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
42
89
|
children,
|
|
43
90
|
config,
|
|
@@ -53,9 +100,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
53
100
|
stateRef.current = state;
|
|
54
101
|
}, [state]);
|
|
55
102
|
|
|
56
|
-
//
|
|
103
|
+
// Keep refs for callback props to avoid stale closures
|
|
104
|
+
const configRef = useRef(config);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
configRef.current = config;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 1. Start Static File Server
|
|
110
|
+
const httpPort = config.port || DEFAULT_HTTP_PORT;
|
|
57
111
|
const { url: serverUrl, error: serverError } = useStaticServer({
|
|
58
|
-
port:
|
|
112
|
+
port: httpPort,
|
|
59
113
|
devMode: config.devMode,
|
|
60
114
|
devServerUrl: config.devServerUrl,
|
|
61
115
|
staticDir: config.staticDir,
|
|
@@ -67,30 +121,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
67
121
|
// Track active sessions: secret -> playerId
|
|
68
122
|
const sessions = useRef<Map<string, string>>(new Map());
|
|
69
123
|
|
|
124
|
+
// Track socket IDs that have received their WELCOME message
|
|
125
|
+
const welcomedClients = useRef<Set<string>>(new Set());
|
|
126
|
+
|
|
70
127
|
useEffect(() => {
|
|
71
|
-
const
|
|
72
|
-
const port = config.wsPort || httpPort + 2;
|
|
128
|
+
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
73
129
|
const server = new GameWebSocketServer({ port, debug: config.debug });
|
|
74
130
|
|
|
75
131
|
server.start();
|
|
76
132
|
wsServer.current = server;
|
|
77
133
|
|
|
78
134
|
server.on("listening", (p) => {
|
|
79
|
-
if (
|
|
135
|
+
if (configRef.current.debug)
|
|
80
136
|
console.log(`[GameHost] WebSocket listening on port ${p}`);
|
|
81
137
|
});
|
|
82
138
|
|
|
83
139
|
server.on("connection", (socketId) => {
|
|
84
|
-
if (
|
|
140
|
+
if (configRef.current.debug)
|
|
141
|
+
console.log(`[GameHost] Client connected: ${socketId}`);
|
|
85
142
|
});
|
|
86
143
|
|
|
87
|
-
server.on("message", (socketId,
|
|
88
|
-
|
|
144
|
+
server.on("message", (socketId, rawMessage) => {
|
|
145
|
+
// Validate message structure before processing
|
|
146
|
+
if (!isValidClientMessage(rawMessage)) {
|
|
147
|
+
if (configRef.current.debug)
|
|
148
|
+
console.warn(
|
|
149
|
+
`[GameHost] Invalid message from ${socketId}:`,
|
|
150
|
+
rawMessage,
|
|
151
|
+
);
|
|
152
|
+
server.send(socketId, {
|
|
153
|
+
type: MessageTypes.ERROR,
|
|
154
|
+
payload: { code: "INVALID_MESSAGE", message: "Malformed message" },
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const message = rawMessage;
|
|
160
|
+
|
|
161
|
+
if (configRef.current.debug)
|
|
89
162
|
console.log(`[GameHost] Msg from ${socketId}:`, message);
|
|
90
163
|
|
|
91
164
|
switch (message.type) {
|
|
92
165
|
case MessageTypes.JOIN: {
|
|
93
|
-
// Check for existing session
|
|
94
166
|
const { secret, ...payload } = message.payload;
|
|
95
167
|
|
|
96
168
|
if (secret) {
|
|
@@ -98,25 +170,58 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
98
170
|
sessions.current.set(secret, socketId);
|
|
99
171
|
}
|
|
100
172
|
|
|
173
|
+
// Dispatch the internal PLAYER_JOINED action
|
|
101
174
|
dispatch({
|
|
102
|
-
type:
|
|
103
|
-
payload: { id: socketId,
|
|
104
|
-
} as
|
|
175
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
176
|
+
payload: { id: socketId, ...payload },
|
|
177
|
+
} as InternalAction<S>);
|
|
105
178
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
179
|
+
// Use queueMicrotask to send WELCOME after the reducer has processed
|
|
180
|
+
// the PLAYER_JOINED action, so the client receives state that includes
|
|
181
|
+
// themselves in the players list.
|
|
182
|
+
queueMicrotask(() => {
|
|
183
|
+
welcomedClients.current.add(socketId);
|
|
184
|
+
server.send(socketId, {
|
|
185
|
+
type: MessageTypes.WELCOME,
|
|
186
|
+
payload: {
|
|
187
|
+
playerId: socketId,
|
|
188
|
+
state: stateRef.current,
|
|
189
|
+
serverTime: Date.now(),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
113
192
|
});
|
|
193
|
+
|
|
194
|
+
configRef.current.onPlayerJoined?.(socketId, payload.name);
|
|
114
195
|
break;
|
|
115
196
|
}
|
|
116
197
|
|
|
117
|
-
case MessageTypes.ACTION:
|
|
118
|
-
|
|
198
|
+
case MessageTypes.ACTION: {
|
|
199
|
+
// Only accept actions with a user-defined type string,
|
|
200
|
+
// reject internal action types to prevent injection.
|
|
201
|
+
const actionPayload = message.payload as A;
|
|
202
|
+
if (
|
|
203
|
+
actionPayload.type === InternalActionTypes.HYDRATE ||
|
|
204
|
+
actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
|
|
205
|
+
actionPayload.type === InternalActionTypes.PLAYER_LEFT
|
|
206
|
+
) {
|
|
207
|
+
if (configRef.current.debug)
|
|
208
|
+
console.warn(
|
|
209
|
+
`[GameHost] Rejected internal action from ${socketId}:`,
|
|
210
|
+
actionPayload.type,
|
|
211
|
+
);
|
|
212
|
+
server.send(socketId, {
|
|
213
|
+
type: MessageTypes.ERROR,
|
|
214
|
+
payload: {
|
|
215
|
+
code: "FORBIDDEN_ACTION",
|
|
216
|
+
message:
|
|
217
|
+
"Internal action types cannot be dispatched by clients",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
dispatch(actionPayload);
|
|
119
223
|
break;
|
|
224
|
+
}
|
|
120
225
|
|
|
121
226
|
case MessageTypes.PING:
|
|
122
227
|
server.send(socketId, {
|
|
@@ -132,28 +237,38 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
132
237
|
});
|
|
133
238
|
|
|
134
239
|
server.on("disconnect", (socketId) => {
|
|
135
|
-
if (
|
|
240
|
+
if (configRef.current.debug)
|
|
136
241
|
console.log(`[GameHost] Client disconnected: ${socketId}`);
|
|
137
242
|
|
|
243
|
+
welcomedClients.current.delete(socketId);
|
|
244
|
+
|
|
138
245
|
// We do NOT remove the session from the map here,
|
|
139
246
|
// allowing them to reconnect later with the same secret.
|
|
140
247
|
|
|
141
248
|
dispatch({
|
|
142
|
-
type:
|
|
249
|
+
type: InternalActionTypes.PLAYER_LEFT,
|
|
143
250
|
payload: { playerId: socketId },
|
|
144
|
-
} as
|
|
251
|
+
} as InternalAction<S>);
|
|
252
|
+
|
|
253
|
+
configRef.current.onPlayerLeft?.(socketId);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
server.on("error", (error) => {
|
|
257
|
+
if (configRef.current.debug)
|
|
258
|
+
console.error(`[GameHost] Server error:`, error);
|
|
259
|
+
configRef.current.onError?.(error);
|
|
145
260
|
});
|
|
146
261
|
|
|
147
262
|
return () => {
|
|
148
263
|
server.stop();
|
|
149
264
|
};
|
|
265
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
266
|
}, []); // Run once on mount
|
|
151
267
|
|
|
152
268
|
// 3. Broadcast State Updates
|
|
153
|
-
// Whenever React state changes, send it to all clients
|
|
269
|
+
// Whenever React state changes, send it to all clients that have been welcomed
|
|
154
270
|
useEffect(() => {
|
|
155
271
|
if (wsServer.current) {
|
|
156
|
-
// Optimization: In the future, send deltas or only send if changed significantly
|
|
157
272
|
wsServer.current.broadcast({
|
|
158
273
|
type: MessageTypes.STATE_UPDATE,
|
|
159
274
|
payload: {
|
|
@@ -164,15 +279,15 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
164
279
|
}
|
|
165
280
|
}, [state]);
|
|
166
281
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
282
|
+
// Memoize context value to prevent unnecessary re-renders of consumers
|
|
283
|
+
// that only use stable references like dispatch
|
|
284
|
+
const contextValue = useMemo(
|
|
285
|
+
() => ({ state, dispatch, serverUrl, serverError }),
|
|
286
|
+
[state, serverUrl, serverError],
|
|
287
|
+
);
|
|
171
288
|
|
|
172
289
|
return (
|
|
173
|
-
<GameHostContext.Provider
|
|
174
|
-
value={{ state, dispatch, serverUrl, serverError }}
|
|
175
|
-
>
|
|
290
|
+
<GameHostContext.Provider value={contextValue}>
|
|
176
291
|
{children}
|
|
177
292
|
</GameHostContext.Provider>
|
|
178
293
|
);
|
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,
|
|
@@ -35,19 +56,34 @@ interface DecodedFrame {
|
|
|
35
56
|
bytesConsumed: number;
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
// Internal type for a TCP socket with our added management properties.
|
|
60
|
+
// We use `any` for the raw socket since react-native-tcp-socket doesn't export a clean type.
|
|
61
|
+
interface ManagedSocket {
|
|
39
62
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
socket: any;
|
|
64
|
+
id: string;
|
|
65
|
+
isHandshakeComplete: boolean;
|
|
66
|
+
buffer: Buffer;
|
|
67
|
+
lastPong: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
71
|
+
private server: ReturnType<typeof TcpSocket.createServer> | null = null;
|
|
72
|
+
private clients: Map<string, ManagedSocket> = new Map();
|
|
43
73
|
private port: number;
|
|
44
74
|
private debug: boolean;
|
|
75
|
+
private maxFrameSize: number;
|
|
76
|
+
private keepaliveInterval: number;
|
|
77
|
+
private keepaliveTimeout: number;
|
|
78
|
+
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
45
79
|
|
|
46
80
|
constructor(config: WebSocketConfig) {
|
|
47
81
|
super();
|
|
48
82
|
this.port = config.port;
|
|
49
83
|
this.debug = !!config.debug;
|
|
50
|
-
this.
|
|
84
|
+
this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
|
|
85
|
+
this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
|
|
86
|
+
this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
private log(...args: unknown[]) {
|
|
@@ -56,46 +92,46 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
56
92
|
}
|
|
57
93
|
}
|
|
58
94
|
|
|
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
95
|
public start() {
|
|
68
96
|
this.log(`[WebSocket] Starting server on port ${this.port}...`);
|
|
97
|
+
|
|
69
98
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
-
this.server = TcpSocket.createServer((
|
|
99
|
+
this.server = TcpSocket.createServer((rawSocket: any) => {
|
|
71
100
|
this.log(
|
|
72
|
-
`[WebSocket] New connection from ${
|
|
101
|
+
`[WebSocket] New connection from ${rawSocket.address?.()?.address}`,
|
|
73
102
|
);
|
|
74
|
-
let buffer: Buffer = Buffer.alloc(0);
|
|
75
103
|
|
|
76
|
-
|
|
104
|
+
const managed: ManagedSocket = {
|
|
105
|
+
socket: rawSocket,
|
|
106
|
+
id: "",
|
|
107
|
+
isHandshakeComplete: false,
|
|
108
|
+
buffer: Buffer.alloc(0),
|
|
109
|
+
lastPong: Date.now(),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
rawSocket.on("data", (data: Buffer | string) => {
|
|
77
113
|
this.log(
|
|
78
114
|
`[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
|
|
79
115
|
);
|
|
80
116
|
// Concatenate new data
|
|
81
|
-
buffer = Buffer.concat([
|
|
82
|
-
buffer,
|
|
117
|
+
managed.buffer = Buffer.concat([
|
|
118
|
+
managed.buffer,
|
|
83
119
|
typeof data === "string" ? Buffer.from(data) : data,
|
|
84
120
|
]);
|
|
85
121
|
|
|
86
122
|
// Handshake not yet performed?
|
|
87
|
-
if (!
|
|
88
|
-
const header = buffer.toString("utf8");
|
|
123
|
+
if (!managed.isHandshakeComplete) {
|
|
124
|
+
const header = managed.buffer.toString("utf8");
|
|
89
125
|
const endOfHeader = header.indexOf("\r\n\r\n");
|
|
90
126
|
if (endOfHeader !== -1) {
|
|
91
|
-
this.handleHandshake(
|
|
127
|
+
this.handleHandshake(managed, header);
|
|
92
128
|
// Retain any bytes after the handshake (could be the first WS frame)
|
|
93
129
|
const headerByteLength = Buffer.byteLength(
|
|
94
130
|
header.substring(0, endOfHeader + 4),
|
|
95
131
|
"utf8",
|
|
96
132
|
);
|
|
97
|
-
buffer = buffer.slice(headerByteLength);
|
|
98
|
-
|
|
133
|
+
managed.buffer = managed.buffer.slice(headerByteLength);
|
|
134
|
+
managed.isHandshakeComplete = true;
|
|
99
135
|
// Fall through to process any remaining frames below
|
|
100
136
|
} else {
|
|
101
137
|
return;
|
|
@@ -103,37 +139,92 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
103
139
|
}
|
|
104
140
|
|
|
105
141
|
// Process all complete frames in the buffer
|
|
106
|
-
this.processFrames(
|
|
107
|
-
buffer = remaining;
|
|
142
|
+
this.processFrames(managed, (remaining) => {
|
|
143
|
+
managed.buffer = remaining;
|
|
108
144
|
});
|
|
109
145
|
});
|
|
110
146
|
|
|
111
|
-
|
|
147
|
+
rawSocket.on("error", (error: Error) => {
|
|
112
148
|
this.emit("error", error);
|
|
113
149
|
});
|
|
114
150
|
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
this.clients.delete(
|
|
118
|
-
this.emit("disconnect",
|
|
151
|
+
rawSocket.on("close", () => {
|
|
152
|
+
if (managed.id) {
|
|
153
|
+
this.clients.delete(managed.id);
|
|
154
|
+
this.emit("disconnect", managed.id);
|
|
119
155
|
}
|
|
120
156
|
});
|
|
121
157
|
});
|
|
122
158
|
|
|
159
|
+
// Handle server-level errors (e.g., port already in use)
|
|
160
|
+
this.server.on("error", (error: Error) => {
|
|
161
|
+
this.log("[WebSocket] Server error:", error);
|
|
162
|
+
this.emit("error", error);
|
|
163
|
+
});
|
|
164
|
+
|
|
123
165
|
this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
|
|
124
166
|
this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
|
|
125
167
|
this.emit("listening", this.port);
|
|
126
168
|
});
|
|
169
|
+
|
|
170
|
+
// Start keepalive pings if enabled
|
|
171
|
+
if (this.keepaliveInterval > 0) {
|
|
172
|
+
this.startKeepalive();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private startKeepalive() {
|
|
177
|
+
this.keepaliveTimer = setInterval(() => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
|
|
180
|
+
|
|
181
|
+
for (const [id, managed] of this.clients) {
|
|
182
|
+
// Check if previous keepalive timed out
|
|
183
|
+
if (
|
|
184
|
+
now - managed.lastPong >
|
|
185
|
+
this.keepaliveInterval + this.keepaliveTimeout
|
|
186
|
+
) {
|
|
187
|
+
this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
|
|
188
|
+
try {
|
|
189
|
+
managed.socket.destroy();
|
|
190
|
+
} catch {
|
|
191
|
+
// Already destroyed
|
|
192
|
+
}
|
|
193
|
+
this.clients.delete(id);
|
|
194
|
+
this.emit("disconnect", id);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
managed.socket.write(pingFrame);
|
|
200
|
+
} catch {
|
|
201
|
+
// Socket already closing
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}, this.keepaliveInterval);
|
|
127
205
|
}
|
|
128
206
|
|
|
129
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
130
207
|
private processFrames(
|
|
131
|
-
|
|
132
|
-
buffer: Buffer,
|
|
208
|
+
managed: ManagedSocket,
|
|
133
209
|
setBuffer: (b: Buffer) => void,
|
|
134
210
|
) {
|
|
211
|
+
let { buffer } = managed;
|
|
212
|
+
|
|
135
213
|
while (buffer.length > 0) {
|
|
136
|
-
|
|
214
|
+
let frame: DecodedFrame | null;
|
|
215
|
+
try {
|
|
216
|
+
frame = this.decodeFrame(buffer);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// Frame too large or malformed -- disconnect the client
|
|
219
|
+
this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
|
|
220
|
+
try {
|
|
221
|
+
managed.socket.destroy();
|
|
222
|
+
} catch {
|
|
223
|
+
// Already destroyed
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
137
228
|
if (!frame) {
|
|
138
229
|
// Incomplete frame -- keep buffer, wait for more data
|
|
139
230
|
break;
|
|
@@ -147,37 +238,37 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
147
238
|
case OPCODE.TEXT: {
|
|
148
239
|
try {
|
|
149
240
|
const message = JSON.parse(frame.payload.toString("utf8"));
|
|
150
|
-
this.emit("message",
|
|
151
|
-
} catch
|
|
241
|
+
this.emit("message", managed.id, message);
|
|
242
|
+
} catch {
|
|
152
243
|
// Corrupt JSON in a complete frame -- discard this frame, continue processing
|
|
153
244
|
this.log(
|
|
154
|
-
`[WebSocket] Invalid JSON from ${
|
|
245
|
+
`[WebSocket] Invalid JSON from ${managed.id}, discarding frame`,
|
|
155
246
|
);
|
|
156
247
|
}
|
|
157
248
|
break;
|
|
158
249
|
}
|
|
159
250
|
|
|
160
251
|
case OPCODE.CLOSE: {
|
|
161
|
-
this.log(`[WebSocket] Close frame from ${
|
|
252
|
+
this.log(`[WebSocket] Close frame from ${managed.id}`);
|
|
162
253
|
// Send close frame back (RFC 6455 Section 5.5.1)
|
|
163
254
|
const closeFrame = Buffer.alloc(2);
|
|
164
255
|
closeFrame[0] = 0x88; // FIN + Close opcode
|
|
165
256
|
closeFrame[1] = 0x00; // No payload
|
|
166
257
|
try {
|
|
167
|
-
socket.write(closeFrame);
|
|
258
|
+
managed.socket.write(closeFrame);
|
|
168
259
|
} catch {
|
|
169
260
|
// Socket may already be closing
|
|
170
261
|
}
|
|
171
|
-
socket.destroy();
|
|
262
|
+
managed.socket.destroy();
|
|
172
263
|
break;
|
|
173
264
|
}
|
|
174
265
|
|
|
175
266
|
case OPCODE.PING: {
|
|
176
|
-
this.log(`[WebSocket] Ping from ${
|
|
267
|
+
this.log(`[WebSocket] Ping from ${managed.id}`);
|
|
177
268
|
// Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
|
|
178
269
|
const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
|
|
179
270
|
try {
|
|
180
|
-
socket.write(pongFrame);
|
|
271
|
+
managed.socket.write(pongFrame);
|
|
181
272
|
} catch {
|
|
182
273
|
// Socket may be closing
|
|
183
274
|
}
|
|
@@ -185,68 +276,132 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
185
276
|
}
|
|
186
277
|
|
|
187
278
|
case OPCODE.PONG: {
|
|
188
|
-
//
|
|
189
|
-
|
|
279
|
+
// Update last-seen pong time for keepalive tracking
|
|
280
|
+
managed.lastPong = Date.now();
|
|
281
|
+
this.log(`[WebSocket] Pong from ${managed.id}`);
|
|
190
282
|
break;
|
|
191
283
|
}
|
|
192
284
|
|
|
193
285
|
case OPCODE.BINARY: {
|
|
194
286
|
// Binary frames not supported -- log and discard
|
|
195
287
|
this.log(
|
|
196
|
-
`[WebSocket] Binary frame from ${
|
|
288
|
+
`[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
|
|
197
289
|
);
|
|
198
290
|
break;
|
|
199
291
|
}
|
|
200
292
|
|
|
201
293
|
default: {
|
|
202
294
|
this.log(
|
|
203
|
-
`[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${
|
|
295
|
+
`[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
|
|
204
296
|
);
|
|
205
297
|
break;
|
|
206
298
|
}
|
|
207
299
|
}
|
|
208
300
|
|
|
209
301
|
// If socket was destroyed (e.g., close frame), stop processing
|
|
210
|
-
if (socket.destroyed) break;
|
|
302
|
+
if (managed.socket.destroyed) break;
|
|
211
303
|
}
|
|
212
304
|
|
|
213
305
|
setBuffer(buffer);
|
|
214
306
|
}
|
|
215
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Gracefully stop the server.
|
|
310
|
+
* Sends close frames to all clients before destroying connections.
|
|
311
|
+
*/
|
|
216
312
|
public stop() {
|
|
313
|
+
// Stop keepalive timer
|
|
314
|
+
if (this.keepaliveTimer) {
|
|
315
|
+
clearInterval(this.keepaliveTimer);
|
|
316
|
+
this.keepaliveTimer = null;
|
|
317
|
+
}
|
|
318
|
+
|
|
217
319
|
if (this.server) {
|
|
218
|
-
|
|
219
|
-
|
|
320
|
+
// Send close frames to all clients before destroying
|
|
321
|
+
const closeFrame = Buffer.alloc(2);
|
|
322
|
+
closeFrame[0] = 0x88; // FIN + Close opcode
|
|
323
|
+
closeFrame[1] = 0x00; // No payload
|
|
324
|
+
|
|
325
|
+
this.clients.forEach((managed) => {
|
|
326
|
+
try {
|
|
327
|
+
managed.socket.write(closeFrame);
|
|
328
|
+
} catch {
|
|
329
|
+
// Socket may already be closing
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
managed.socket.destroy();
|
|
333
|
+
} catch {
|
|
334
|
+
// Already destroyed
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
220
338
|
this.clients.clear();
|
|
339
|
+
this.server.close();
|
|
221
340
|
}
|
|
222
341
|
}
|
|
223
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Send data to a specific client by socket ID.
|
|
345
|
+
* Silently ignores unknown socket IDs and write errors.
|
|
346
|
+
*/
|
|
224
347
|
public send(socketId: string, data: unknown) {
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
348
|
+
const managed = this.clients.get(socketId);
|
|
349
|
+
if (managed) {
|
|
350
|
+
try {
|
|
351
|
+
const frame = this.encodeFrame(JSON.stringify(data));
|
|
352
|
+
managed.socket.write(frame);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
|
|
355
|
+
this.emit(
|
|
356
|
+
"error",
|
|
357
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
229
360
|
}
|
|
230
361
|
}
|
|
231
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Broadcast data to all connected clients.
|
|
365
|
+
* Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
|
|
366
|
+
*/
|
|
232
367
|
public broadcast(data: unknown, excludeId?: string) {
|
|
233
368
|
const frame = this.encodeFrame(JSON.stringify(data));
|
|
234
|
-
this.clients.forEach((
|
|
369
|
+
this.clients.forEach((managed, id) => {
|
|
235
370
|
if (id !== excludeId) {
|
|
236
|
-
|
|
371
|
+
try {
|
|
372
|
+
managed.socket.write(frame);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
|
|
375
|
+
// Don't abort -- continue sending to remaining clients
|
|
376
|
+
}
|
|
237
377
|
}
|
|
238
378
|
});
|
|
239
379
|
}
|
|
240
380
|
|
|
381
|
+
/** Returns the number of currently connected clients. */
|
|
382
|
+
public get clientCount(): number {
|
|
383
|
+
return this.clients.size;
|
|
384
|
+
}
|
|
385
|
+
|
|
241
386
|
// --- Private Helpers ---
|
|
242
387
|
|
|
243
388
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
244
|
-
private handleHandshake(
|
|
389
|
+
private handleHandshake(managed: ManagedSocket, header: string) {
|
|
245
390
|
this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
|
|
246
391
|
const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
|
|
247
392
|
if (!keyMatch) {
|
|
248
393
|
console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
|
|
249
|
-
socket.destroy();
|
|
394
|
+
managed.socket.destroy();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
|
|
399
|
+
const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
|
|
400
|
+
if (versionMatch && versionMatch[1] !== "13") {
|
|
401
|
+
console.error(
|
|
402
|
+
`[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
|
|
403
|
+
);
|
|
404
|
+
managed.socket.destroy();
|
|
250
405
|
return;
|
|
251
406
|
}
|
|
252
407
|
|
|
@@ -269,15 +424,15 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
269
424
|
"[WebSocket] Sending Handshake Response:",
|
|
270
425
|
JSON.stringify(response),
|
|
271
426
|
);
|
|
272
|
-
socket.write(response);
|
|
427
|
+
managed.socket.write(response);
|
|
273
428
|
|
|
274
|
-
// Assign ID and store
|
|
275
|
-
|
|
276
|
-
this.clients.set(
|
|
277
|
-
this.emit("connection",
|
|
429
|
+
// Assign cryptographically random ID and store
|
|
430
|
+
managed.id = generateId();
|
|
431
|
+
this.clients.set(managed.id, managed);
|
|
432
|
+
this.emit("connection", managed.id);
|
|
278
433
|
} catch (error) {
|
|
279
434
|
console.error("[WebSocket] Handshake error:", error);
|
|
280
|
-
socket.destroy();
|
|
435
|
+
managed.socket.destroy();
|
|
281
436
|
}
|
|
282
437
|
}
|
|
283
438
|
|
|
@@ -310,13 +465,19 @@ export class GameWebSocketServer extends EventEmitter {
|
|
|
310
465
|
// Read 64-bit length. For safety, only use the lower 32 bits.
|
|
311
466
|
const highBits = buffer.readUInt32BE(2);
|
|
312
467
|
if (highBits > 0) {
|
|
313
|
-
|
|
314
|
-
throw new Error("Frame payload too large");
|
|
468
|
+
throw new Error("Frame payload too large (exceeds 4 GB)");
|
|
315
469
|
}
|
|
316
470
|
payloadLength = buffer.readUInt32BE(6);
|
|
317
471
|
headerLength = 10;
|
|
318
472
|
}
|
|
319
473
|
|
|
474
|
+
// Enforce max frame size to prevent memory exhaustion attacks
|
|
475
|
+
if (payloadLength > this.maxFrameSize) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
320
481
|
const maskLength = isMasked ? 4 : 0;
|
|
321
482
|
const totalFrameLength = headerLength + maskLength + payloadLength;
|
|
322
483
|
|
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
|
-
}
|