@couch-kit/host 0.4.0 → 0.5.1
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 +13 -4
- package/src/event-emitter.ts +2 -2
- package/src/provider.tsx +34 -9
- package/src/websocket.ts +66 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
"main": "src/index.tsx",
|
|
23
23
|
"react-native": "src/index.tsx",
|
|
24
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,
|
|
25
33
|
"files": [
|
|
26
34
|
"src",
|
|
27
35
|
"lib",
|
|
@@ -43,12 +51,12 @@
|
|
|
43
51
|
"clean": "del-cli lib"
|
|
44
52
|
},
|
|
45
53
|
"dependencies": {
|
|
46
|
-
"@couch-kit/core": "0.3.
|
|
54
|
+
"@couch-kit/core": "0.3.1",
|
|
55
|
+
"buffer": "^6.0.3",
|
|
47
56
|
"js-sha1": "^0.7.0",
|
|
48
57
|
"react-native-fs": "^2.20.0",
|
|
49
58
|
"react-native-network-info": "^5.2.1",
|
|
50
59
|
"react-native-nitro-http-server": "^1.5.4",
|
|
51
|
-
"react-native-nitro-modules": "^0.33.2",
|
|
52
60
|
"react-native-tcp-socket": "^6.0.6"
|
|
53
61
|
},
|
|
54
62
|
"devDependencies": {
|
|
@@ -61,6 +69,7 @@
|
|
|
61
69
|
},
|
|
62
70
|
"peerDependencies": {
|
|
63
71
|
"react": "*",
|
|
64
|
-
"react-native": "*"
|
|
72
|
+
"react-native": "*",
|
|
73
|
+
"react-native-nitro-modules": ">=0.33.0"
|
|
65
74
|
}
|
|
66
75
|
}
|
package/src/event-emitter.ts
CHANGED
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
20
|
export class EventEmitter<
|
|
21
21
|
EventMap extends Record<string, any[]> = Record<string, any[]>,
|
|
22
22
|
> {
|
|
23
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
23
|
private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
|
|
24
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
25
25
|
|
|
26
26
|
on<K extends string & keyof EventMap>(
|
|
27
27
|
event: K,
|
package/src/provider.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import React, {
|
|
|
5
5
|
useMemo,
|
|
6
6
|
useReducer,
|
|
7
7
|
useRef,
|
|
8
|
+
useCallback,
|
|
8
9
|
} from "react";
|
|
9
10
|
import { GameWebSocketServer } from "./websocket";
|
|
10
11
|
import { useStaticServer } from "./server";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
InternalActionTypes,
|
|
14
15
|
DEFAULT_HTTP_PORT,
|
|
15
16
|
DEFAULT_WS_PORT_OFFSET,
|
|
17
|
+
createGameReducer,
|
|
16
18
|
type IGameState,
|
|
17
19
|
type IAction,
|
|
18
20
|
type InternalAction,
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
|
|
22
24
|
export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
23
25
|
initialState: S;
|
|
24
|
-
reducer: (state: S, action: A
|
|
26
|
+
reducer: (state: S, action: A) => S;
|
|
25
27
|
port?: number; // Static server port (default 8080)
|
|
26
28
|
wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
|
|
27
29
|
devMode?: boolean;
|
|
@@ -38,7 +40,7 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
38
40
|
|
|
39
41
|
interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
40
42
|
state: S;
|
|
41
|
-
dispatch: (action: A
|
|
43
|
+
dispatch: (action: A) => void;
|
|
42
44
|
serverUrl: string | null;
|
|
43
45
|
serverError: Error | null;
|
|
44
46
|
}
|
|
@@ -92,7 +94,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
92
94
|
children: React.ReactNode;
|
|
93
95
|
config: GameHostConfig<S, A>;
|
|
94
96
|
}) {
|
|
95
|
-
|
|
97
|
+
// Wrap the user's reducer with createGameReducer to handle internal actions
|
|
98
|
+
// (HYDRATE, PLAYER_JOINED, PLAYER_LEFT) automatically.
|
|
99
|
+
const [state, dispatch] = useReducer(
|
|
100
|
+
createGameReducer(config.reducer),
|
|
101
|
+
config.initialState,
|
|
102
|
+
);
|
|
96
103
|
|
|
97
104
|
// Keep a ref to state so we can access it inside callbacks/effects that don't depend on it
|
|
98
105
|
const stateRef = useRef(state);
|
|
@@ -262,22 +269,40 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
262
269
|
return () => {
|
|
263
270
|
server.stop();
|
|
264
271
|
};
|
|
265
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
266
272
|
}, []); // Run once on mount
|
|
267
273
|
|
|
268
|
-
// 3.
|
|
269
|
-
//
|
|
270
|
-
|
|
274
|
+
// 3. Throttled State Broadcasts (~30fps)
|
|
275
|
+
// Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
|
|
276
|
+
// reducing serialization overhead and network traffic for fast-updating games.
|
|
277
|
+
const broadcastPending = useRef(false);
|
|
278
|
+
const broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
279
|
+
|
|
280
|
+
const broadcastState = useCallback(() => {
|
|
271
281
|
if (wsServer.current) {
|
|
272
282
|
wsServer.current.broadcast({
|
|
273
283
|
type: MessageTypes.STATE_UPDATE,
|
|
274
284
|
payload: {
|
|
275
|
-
newState:
|
|
285
|
+
newState: stateRef.current,
|
|
276
286
|
timestamp: Date.now(),
|
|
277
287
|
},
|
|
278
288
|
});
|
|
279
289
|
}
|
|
280
|
-
|
|
290
|
+
broadcastPending.current = false;
|
|
291
|
+
}, []);
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!broadcastPending.current) {
|
|
295
|
+
broadcastPending.current = true;
|
|
296
|
+
broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return () => {
|
|
300
|
+
if (broadcastTimer.current) {
|
|
301
|
+
clearTimeout(broadcastTimer.current);
|
|
302
|
+
broadcastTimer.current = null;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}, [state, broadcastState]);
|
|
281
306
|
|
|
282
307
|
// Memoize context value to prevent unnecessary re-renders of consumers
|
|
283
308
|
// that only use stable references like dispatch
|
package/src/websocket.ts
CHANGED
|
@@ -50,6 +50,43 @@ const OPCODE = {
|
|
|
50
50
|
// Simple WebSocket Frame Parser/Builder
|
|
51
51
|
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
52
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
|
+
|
|
53
90
|
interface DecodedFrame {
|
|
54
91
|
opcode: number;
|
|
55
92
|
payload: Buffer;
|
|
@@ -64,6 +101,8 @@ interface ManagedSocket {
|
|
|
64
101
|
id: string;
|
|
65
102
|
isHandshakeComplete: boolean;
|
|
66
103
|
buffer: Buffer;
|
|
104
|
+
/** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
|
|
105
|
+
bufferLength: number;
|
|
67
106
|
lastPong: number;
|
|
68
107
|
}
|
|
69
108
|
|
|
@@ -105,7 +144,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
105
144
|
socket: rawSocket,
|
|
106
145
|
id: "",
|
|
107
146
|
isHandshakeComplete: false,
|
|
108
|
-
buffer: Buffer.alloc(
|
|
147
|
+
buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
|
|
148
|
+
bufferLength: 0,
|
|
109
149
|
lastPong: Date.now(),
|
|
110
150
|
};
|
|
111
151
|
|
|
@@ -113,24 +153,26 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
113
153
|
this.log(
|
|
114
154
|
`[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
|
|
115
155
|
);
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
typeof data === "string" ? Buffer.from(data) : data,
|
|
120
|
-
]);
|
|
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);
|
|
121
159
|
|
|
122
160
|
// Handshake not yet performed?
|
|
123
161
|
if (!managed.isHandshakeComplete) {
|
|
124
|
-
const header = managed.buffer.toString(
|
|
162
|
+
const header = managed.buffer.toString(
|
|
163
|
+
"utf8",
|
|
164
|
+
0,
|
|
165
|
+
managed.bufferLength,
|
|
166
|
+
);
|
|
125
167
|
const endOfHeader = header.indexOf("\r\n\r\n");
|
|
126
168
|
if (endOfHeader !== -1) {
|
|
127
169
|
this.handleHandshake(managed, header);
|
|
128
|
-
//
|
|
170
|
+
// Compact buffer past the handshake header
|
|
129
171
|
const headerByteLength = Buffer.byteLength(
|
|
130
172
|
header.substring(0, endOfHeader + 4),
|
|
131
173
|
"utf8",
|
|
132
174
|
);
|
|
133
|
-
managed
|
|
175
|
+
compactBuffer(managed, headerByteLength);
|
|
134
176
|
managed.isHandshakeComplete = true;
|
|
135
177
|
// Fall through to process any remaining frames below
|
|
136
178
|
} else {
|
|
@@ -139,9 +181,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
139
181
|
}
|
|
140
182
|
|
|
141
183
|
// Process all complete frames in the buffer
|
|
142
|
-
this.processFrames(managed
|
|
143
|
-
managed.buffer = remaining;
|
|
144
|
-
});
|
|
184
|
+
this.processFrames(managed);
|
|
145
185
|
});
|
|
146
186
|
|
|
147
187
|
rawSocket.on("error", (error: Error) => {
|
|
@@ -204,16 +244,15 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
204
244
|
}, this.keepaliveInterval);
|
|
205
245
|
}
|
|
206
246
|
|
|
207
|
-
private processFrames(
|
|
208
|
-
|
|
209
|
-
setBuffer: (b: Buffer) => void,
|
|
210
|
-
) {
|
|
211
|
-
let { buffer } = managed;
|
|
247
|
+
private processFrames(managed: ManagedSocket) {
|
|
248
|
+
let offset = 0;
|
|
212
249
|
|
|
213
|
-
while (
|
|
250
|
+
while (offset < managed.bufferLength) {
|
|
251
|
+
// Create a view of the unconsumed portion for decoding
|
|
252
|
+
const view = managed.buffer.subarray(offset, managed.bufferLength);
|
|
214
253
|
let frame: DecodedFrame | null;
|
|
215
254
|
try {
|
|
216
|
-
frame = this.decodeFrame(
|
|
255
|
+
frame = this.decodeFrame(view);
|
|
217
256
|
} catch (error) {
|
|
218
257
|
// Frame too large or malformed -- disconnect the client
|
|
219
258
|
this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
|
|
@@ -226,12 +265,12 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
226
265
|
}
|
|
227
266
|
|
|
228
267
|
if (!frame) {
|
|
229
|
-
// Incomplete frame -- keep
|
|
268
|
+
// Incomplete frame -- keep remaining bytes, wait for more data
|
|
230
269
|
break;
|
|
231
270
|
}
|
|
232
271
|
|
|
233
|
-
// Advance
|
|
234
|
-
|
|
272
|
+
// Advance past the consumed frame
|
|
273
|
+
offset += frame.bytesConsumed;
|
|
235
274
|
|
|
236
275
|
// Handle frame by opcode
|
|
237
276
|
switch (frame.opcode) {
|
|
@@ -302,7 +341,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
302
341
|
if (managed.socket.destroyed) break;
|
|
303
342
|
}
|
|
304
343
|
|
|
305
|
-
|
|
344
|
+
// Compact buffer: shift unconsumed bytes to the front
|
|
345
|
+
compactBuffer(managed, offset);
|
|
306
346
|
}
|
|
307
347
|
|
|
308
348
|
/**
|
|
@@ -486,8 +526,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
486
526
|
|
|
487
527
|
let payload: Buffer;
|
|
488
528
|
if (isMasked) {
|
|
489
|
-
const mask = buffer.
|
|
490
|
-
const maskedPayload = buffer.
|
|
529
|
+
const mask = buffer.subarray(headerLength, headerLength + 4);
|
|
530
|
+
const maskedPayload = buffer.subarray(
|
|
491
531
|
headerLength + 4,
|
|
492
532
|
headerLength + 4 + payloadLength,
|
|
493
533
|
);
|
|
@@ -497,7 +537,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
497
537
|
}
|
|
498
538
|
} else {
|
|
499
539
|
payload = Buffer.from(
|
|
500
|
-
buffer.
|
|
540
|
+
buffer.subarray(headerLength, headerLength + payloadLength),
|
|
501
541
|
);
|
|
502
542
|
}
|
|
503
543
|
|