@couch-kit/host 1.7.12 → 1.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/action-authorization.d.ts +1 -27
- package/lib/action-authorization.d.ts.map +1 -1
- package/lib/broadcast-scheduler.d.ts +1 -29
- package/lib/broadcast-scheduler.d.ts.map +1 -1
- package/lib/message-validation.d.ts +1 -19
- package/lib/message-validation.d.ts.map +1 -1
- package/lib/provider.d.ts +12 -36
- package/lib/provider.d.ts.map +1 -1
- package/lib/rate-limiter.d.ts +1 -31
- package/lib/rate-limiter.d.ts.map +1 -1
- package/lib/session-manager.d.ts +1 -61
- package/lib/session-manager.d.ts.map +1 -1
- package/lib/websocket.d.ts +8 -0
- package/lib/websocket.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/action-authorization.ts +5 -55
- package/src/broadcast-scheduler.ts +8 -84
- package/src/message-validation.ts +6 -58
- package/src/provider.tsx +100 -370
- package/src/rate-limiter.ts +8 -66
- package/src/session-manager.ts +8 -192
- package/src/websocket.ts +28 -4
package/src/provider.tsx
CHANGED
|
@@ -1,58 +1,39 @@
|
|
|
1
1
|
import React, {
|
|
2
2
|
createContext,
|
|
3
|
+
useCallback,
|
|
3
4
|
useContext,
|
|
4
5
|
useEffect,
|
|
5
6
|
useMemo,
|
|
6
|
-
useReducer,
|
|
7
7
|
useRef,
|
|
8
|
-
|
|
8
|
+
useSyncExternalStore,
|
|
9
9
|
} from "react";
|
|
10
|
-
import { GameWebSocketServer } from "./websocket";
|
|
11
|
-
import { useStaticServer } from "./server";
|
|
12
10
|
import {
|
|
13
|
-
MessageTypes,
|
|
14
|
-
InternalActionTypes,
|
|
15
11
|
DEFAULT_HTTP_PORT,
|
|
16
12
|
DEFAULT_WS_PORT_OFFSET,
|
|
17
|
-
DEFAULT_DISCONNECT_TIMEOUT,
|
|
18
|
-
createGameReducer,
|
|
19
|
-
isValidSecret,
|
|
20
|
-
type IGameState,
|
|
21
13
|
type IAction,
|
|
22
|
-
type
|
|
14
|
+
type IGameState,
|
|
23
15
|
} from "@couch-kit/core";
|
|
24
|
-
import { isValidClientMessage } from "./message-validation";
|
|
25
|
-
import { ActionRateLimiter } from "./rate-limiter";
|
|
26
16
|
import {
|
|
27
|
-
|
|
28
|
-
type
|
|
29
|
-
} from "
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
BroadcastScheduler,
|
|
33
|
-
DEFAULT_STATE_THROTTLE_MS,
|
|
34
|
-
createStateUpdateMessage,
|
|
35
|
-
} from "./broadcast-scheduler";
|
|
17
|
+
GameHostRuntime,
|
|
18
|
+
type GameHostRuntimeConfig,
|
|
19
|
+
} from "@couch-kit/runtime";
|
|
20
|
+
import { useStaticServer } from "./server";
|
|
21
|
+
import { GameWebSocketServer } from "./websocket";
|
|
36
22
|
|
|
37
|
-
export interface GameHostConfig<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
23
|
+
export interface GameHostConfig<
|
|
24
|
+
S extends IGameState,
|
|
25
|
+
A extends IAction,
|
|
26
|
+
> extends GameHostRuntimeConfig<S, A> {
|
|
27
|
+
port?: number;
|
|
28
|
+
wsPort?: number;
|
|
42
29
|
devMode?: boolean;
|
|
43
30
|
devServerUrl?: string;
|
|
44
|
-
staticDir?: string;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/** Called when a player successfully joins. */
|
|
51
|
-
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
52
|
-
/** Called when a player disconnects. */
|
|
53
|
-
onPlayerLeft?: (playerId: string) => void;
|
|
54
|
-
/** Called when a server error occurs. */
|
|
55
|
-
onError?: (error: Error) => void;
|
|
31
|
+
staticDir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Maximum size of an inbound client message before parsing.
|
|
34
|
+
* Defaults to 256 KiB.
|
|
35
|
+
*/
|
|
36
|
+
maxMessageBytes?: number;
|
|
56
37
|
}
|
|
57
38
|
|
|
58
39
|
interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
@@ -62,27 +43,16 @@ interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
|
62
43
|
serverError: Error | null;
|
|
63
44
|
}
|
|
64
45
|
|
|
65
|
-
// Create Context with 'any' fallback because Context generics are tricky in React
|
|
66
46
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
47
|
const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
|
|
68
48
|
null,
|
|
69
49
|
);
|
|
70
50
|
|
|
71
51
|
/**
|
|
72
|
-
* React
|
|
73
|
-
*
|
|
74
|
-
* Starts a static file server (for the web controller) and a WebSocket game server
|
|
75
|
-
* (for real-time state sync). Manages the canonical game state using the provided
|
|
76
|
-
* reducer and broadcasts state updates to all connected clients.
|
|
77
|
-
*
|
|
78
|
-
* @param config - Host configuration including reducer, initial state, ports, and callbacks.
|
|
52
|
+
* React Native adapter for the transport-neutral authoritative game runtime.
|
|
79
53
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* <GameHostProvider config={{ reducer: gameReducer, initialState }}>
|
|
83
|
-
* <GameScreen />
|
|
84
|
-
* </GameHostProvider>
|
|
85
|
-
* ```
|
|
54
|
+
* The runtime owns canonical state and protocol behavior. This provider starts
|
|
55
|
+
* the native static/WebSocket servers and exposes runtime state through React.
|
|
86
56
|
*/
|
|
87
57
|
export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
88
58
|
children,
|
|
@@ -91,66 +61,24 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
91
61
|
children: React.ReactNode;
|
|
92
62
|
config: GameHostConfig<S, A>;
|
|
93
63
|
}) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
// Keep a ref to state so we can access it inside callbacks/effects that don't depend on it
|
|
102
|
-
const stateRef = useRef(state);
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
stateRef.current = state;
|
|
105
|
-
}, [state]);
|
|
106
|
-
|
|
107
|
-
// Send WELCOME/RECONNECTED messages after state has settled (post-render).
|
|
108
|
-
// This guarantees the joining player is included in the state snapshot.
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (pendingWelcome.current.size === 0) return;
|
|
111
|
-
if (!wsServer.current) return;
|
|
112
|
-
|
|
113
|
-
if (configRef.current.debug) {
|
|
114
|
-
console.log(
|
|
115
|
-
`[GameHost] Sending WELCOME/RECONNECTED to ${pendingWelcome.current.size} client(s)`,
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const server = wsServer.current;
|
|
120
|
-
for (const [
|
|
121
|
-
socketId,
|
|
122
|
-
{ playerId, isReconnect },
|
|
123
|
-
] of pendingWelcome.current) {
|
|
124
|
-
welcomedClients.current.add(socketId);
|
|
125
|
-
if (isReconnect) {
|
|
126
|
-
server.send(socketId, {
|
|
127
|
-
type: MessageTypes.RECONNECTED,
|
|
128
|
-
payload: {
|
|
129
|
-
playerId,
|
|
130
|
-
state,
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
} else {
|
|
134
|
-
server.send(socketId, {
|
|
135
|
-
type: MessageTypes.WELCOME,
|
|
136
|
-
payload: {
|
|
137
|
-
playerId,
|
|
138
|
-
state,
|
|
139
|
-
serverTime: Date.now(),
|
|
140
|
-
},
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
pendingWelcome.current.clear();
|
|
145
|
-
}, [state]);
|
|
64
|
+
const runtimeRef = useRef<GameHostRuntime<S, A> | null>(null);
|
|
65
|
+
if (!runtimeRef.current) {
|
|
66
|
+
runtimeRef.current = new GameHostRuntime(config);
|
|
67
|
+
}
|
|
68
|
+
const runtime = runtimeRef.current;
|
|
146
69
|
|
|
147
|
-
// Keep refs for callback props to avoid stale closures
|
|
148
70
|
const configRef = useRef(config);
|
|
149
71
|
useEffect(() => {
|
|
150
72
|
configRef.current = config;
|
|
151
|
-
|
|
73
|
+
runtime.updateConfig(config);
|
|
74
|
+
}, [config, runtime]);
|
|
75
|
+
|
|
76
|
+
const state = useSyncExternalStore(
|
|
77
|
+
runtime.subscribe,
|
|
78
|
+
runtime.getState,
|
|
79
|
+
runtime.getState,
|
|
80
|
+
);
|
|
152
81
|
|
|
153
|
-
// 1. Start Static File Server
|
|
154
82
|
const httpPort = config.port || DEFAULT_HTTP_PORT;
|
|
155
83
|
const { url: serverUrl, error: serverError } = useStaticServer({
|
|
156
84
|
port: httpPort,
|
|
@@ -159,287 +87,96 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
159
87
|
staticDir: config.staticDir,
|
|
160
88
|
});
|
|
161
89
|
|
|
162
|
-
|
|
163
|
-
const wsServer = useRef<GameWebSocketServer | null>(null);
|
|
164
|
-
|
|
165
|
-
const sessionManager = useRef(
|
|
166
|
-
new HostSessionManager({
|
|
167
|
-
getDisconnectTimeout: () =>
|
|
168
|
-
configRef.current.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT,
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
// Track socket IDs that have received their WELCOME message
|
|
173
|
-
const welcomedClients = useRef<Set<string>>(new Set());
|
|
174
|
-
|
|
175
|
-
// Track socket IDs that need a WELCOME/RECONNECTED message after state settles
|
|
176
|
-
const pendingWelcome = useRef<
|
|
177
|
-
Map<string, { playerId: string; isReconnect: boolean }>
|
|
178
|
-
>(new Map());
|
|
179
|
-
|
|
180
|
-
// Track which players have finished loading assets
|
|
181
|
-
const assetsLoaded = useRef<Map<string, boolean>>(new Map());
|
|
182
|
-
|
|
183
|
-
// Queue of actions dispatched since last broadcast (for STATE_UPDATE.action)
|
|
184
|
-
const actionQueue = useRef<unknown[]>([]);
|
|
185
|
-
|
|
186
|
-
const rateLimiter = useRef(new ActionRateLimiter());
|
|
90
|
+
const shutdownRef = useRef<Promise<void>>(Promise.resolve());
|
|
187
91
|
|
|
188
92
|
useEffect(() => {
|
|
93
|
+
let active = true;
|
|
189
94
|
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
190
|
-
const server = new GameWebSocketServer({
|
|
95
|
+
const server = new GameWebSocketServer({
|
|
96
|
+
port,
|
|
97
|
+
debug: config.debug,
|
|
98
|
+
maxMessageBytes: config.maxMessageBytes,
|
|
99
|
+
});
|
|
191
100
|
|
|
192
|
-
|
|
193
|
-
|
|
101
|
+
server.on("listening", (listeningPort) => {
|
|
102
|
+
if (!active) return;
|
|
194
103
|
if (configRef.current.debug) {
|
|
195
|
-
console.
|
|
104
|
+
console.log(`[GameHost] WebSocket listening on port ${listeningPort}`);
|
|
196
105
|
}
|
|
197
|
-
configRef.current.onError?.(error);
|
|
198
106
|
});
|
|
199
|
-
wsServer.current = server;
|
|
200
107
|
|
|
201
|
-
server.on("
|
|
202
|
-
if (
|
|
203
|
-
|
|
108
|
+
server.on("connection", (connectionId) => {
|
|
109
|
+
if (!active) return;
|
|
110
|
+
runtime.handleConnection(connectionId);
|
|
204
111
|
});
|
|
205
112
|
|
|
206
|
-
server.on("
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (!isValidClientMessage(rawMessage)) {
|
|
214
|
-
if (configRef.current.debug)
|
|
215
|
-
console.warn(
|
|
216
|
-
`[GameHost] Invalid message from ${socketId}:`,
|
|
217
|
-
rawMessage,
|
|
113
|
+
server.on("message", (connectionId, rawMessage) => {
|
|
114
|
+
if (!active) return;
|
|
115
|
+
void runtime
|
|
116
|
+
.handleMessage(connectionId, rawMessage)
|
|
117
|
+
.catch((error: unknown) => {
|
|
118
|
+
runtime.handleError(
|
|
119
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
218
120
|
);
|
|
219
|
-
server.send(socketId, {
|
|
220
|
-
type: MessageTypes.ERROR,
|
|
221
|
-
payload: { code: "INVALID_MESSAGE", message: "Malformed message" },
|
|
222
121
|
});
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const message = rawMessage;
|
|
227
|
-
|
|
228
|
-
if (configRef.current.debug)
|
|
229
|
-
console.log(`[GameHost] Msg from ${socketId}:`, message);
|
|
230
|
-
|
|
231
|
-
switch (message.type) {
|
|
232
|
-
case MessageTypes.JOIN: {
|
|
233
|
-
const { secret, ...payload } = message.payload;
|
|
234
|
-
|
|
235
|
-
// Validate secret format
|
|
236
|
-
if (
|
|
237
|
-
!secret ||
|
|
238
|
-
typeof secret !== "string" ||
|
|
239
|
-
!isValidSecret(secret)
|
|
240
|
-
) {
|
|
241
|
-
server.send(socketId, {
|
|
242
|
-
type: MessageTypes.ERROR,
|
|
243
|
-
payload: {
|
|
244
|
-
code: "INVALID_SECRET",
|
|
245
|
-
message: "Invalid or missing session secret",
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
sessionManager.current
|
|
252
|
-
.handleJoin<S>(
|
|
253
|
-
socketId,
|
|
254
|
-
message.payload as JoinSessionPayload,
|
|
255
|
-
() => stateRef.current.players,
|
|
256
|
-
)
|
|
257
|
-
.then(({ playerId, isReconnect, action }) => {
|
|
258
|
-
dispatch(action);
|
|
259
|
-
|
|
260
|
-
// Initialize/reset assets loaded status
|
|
261
|
-
assetsLoaded.current.set(playerId, false);
|
|
262
|
-
|
|
263
|
-
// Queue WELCOME/RECONNECTED message
|
|
264
|
-
pendingWelcome.current.set(socketId, {
|
|
265
|
-
playerId,
|
|
266
|
-
isReconnect,
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
configRef.current.onPlayerJoined?.(playerId, payload.name);
|
|
270
|
-
})
|
|
271
|
-
.catch((err) => {
|
|
272
|
-
if (configRef.current.debug) {
|
|
273
|
-
console.error("[GameHost] Failed to derive player ID:", err);
|
|
274
|
-
}
|
|
275
|
-
server.send(socketId, {
|
|
276
|
-
type: MessageTypes.ERROR,
|
|
277
|
-
payload: {
|
|
278
|
-
code: "JOIN_FAILED",
|
|
279
|
-
message: "Failed to process join request",
|
|
280
|
-
},
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
break;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
case MessageTypes.ACTION: {
|
|
287
|
-
const actionPayload = message.payload as A;
|
|
288
|
-
|
|
289
|
-
// Authorize before doing any work: reject client-injected internal
|
|
290
|
-
// action types and actions from sockets that never completed a JOIN.
|
|
291
|
-
const resolvedPlayerId =
|
|
292
|
-
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
293
|
-
const auth = authorizeClientAction(
|
|
294
|
-
actionPayload.type,
|
|
295
|
-
resolvedPlayerId,
|
|
296
|
-
);
|
|
297
|
-
if (auth.kind === "reject") {
|
|
298
|
-
if (configRef.current.debug)
|
|
299
|
-
console.warn(
|
|
300
|
-
`[GameHost] Rejected action from ${socketId} (${auth.code}):`,
|
|
301
|
-
actionPayload.type,
|
|
302
|
-
);
|
|
303
|
-
server.send(socketId, {
|
|
304
|
-
type: MessageTypes.ERROR,
|
|
305
|
-
payload: { code: auth.code, message: auth.message },
|
|
306
|
-
});
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Rate limiting
|
|
311
|
-
if (!rateLimiter.current.record(socketId).allowed) {
|
|
312
|
-
if (configRef.current.debug)
|
|
313
|
-
console.warn(`[GameHost] Rate limited ${socketId}`);
|
|
314
|
-
server.send(socketId, {
|
|
315
|
-
type: MessageTypes.ERROR,
|
|
316
|
-
payload: {
|
|
317
|
-
code: "RATE_LIMITED",
|
|
318
|
-
message: "Too many actions, slow down",
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
dispatch({ ...actionPayload, playerId: auth.playerId });
|
|
325
|
-
actionQueue.current.push(actionPayload);
|
|
326
|
-
break;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
case MessageTypes.PING:
|
|
330
|
-
server.send(socketId, {
|
|
331
|
-
type: MessageTypes.PONG,
|
|
332
|
-
payload: {
|
|
333
|
-
id: message.payload.id,
|
|
334
|
-
origTimestamp: message.payload.timestamp,
|
|
335
|
-
serverTime: Date.now(),
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
break;
|
|
339
|
-
|
|
340
|
-
case MessageTypes.ASSETS_LOADED: {
|
|
341
|
-
const loadedPlayerId =
|
|
342
|
-
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
343
|
-
if (loadedPlayerId) {
|
|
344
|
-
assetsLoaded.current.set(loadedPlayerId, true);
|
|
345
|
-
if (configRef.current.debug)
|
|
346
|
-
console.log(`[GameHost] Assets loaded for ${loadedPlayerId}`);
|
|
347
|
-
}
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
122
|
});
|
|
352
123
|
|
|
353
|
-
server.on("disconnect", (
|
|
354
|
-
if (
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
welcomedClients.current.delete(socketId);
|
|
358
|
-
|
|
359
|
-
// Clean up rate limits
|
|
360
|
-
rateLimiter.current.reset(socketId);
|
|
361
|
-
|
|
362
|
-
const result = sessionManager.current.handleDisconnect<S>(socketId);
|
|
363
|
-
if (result.kind === "unknown") return; // Unknown socket, nothing to do
|
|
364
|
-
|
|
365
|
-
// Clean up assets loaded tracking
|
|
366
|
-
assetsLoaded.current.delete(result.playerId);
|
|
367
|
-
|
|
368
|
-
if (result.kind === "stale") {
|
|
369
|
-
// Player already reconnected on a newer socket — skip
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Mark disconnected (don't remove from sessions — allow reconnect)
|
|
374
|
-
dispatch(result.action);
|
|
375
|
-
|
|
376
|
-
configRef.current.onPlayerLeft?.(result.playerId);
|
|
377
|
-
|
|
378
|
-
// Start stale player cleanup timer
|
|
379
|
-
sessionManager.current.scheduleRemoval(
|
|
380
|
-
result.playerId,
|
|
381
|
-
result.secret,
|
|
382
|
-
(playerId) => {
|
|
383
|
-
dispatch({
|
|
384
|
-
type: InternalActionTypes.PLAYER_REMOVED,
|
|
385
|
-
payload: { playerId },
|
|
386
|
-
} as InternalAction<S>);
|
|
387
|
-
},
|
|
388
|
-
);
|
|
124
|
+
server.on("disconnect", (connectionId) => {
|
|
125
|
+
if (!active) return;
|
|
126
|
+
runtime.handleDisconnect(connectionId);
|
|
389
127
|
});
|
|
390
128
|
|
|
391
129
|
server.on("error", (error) => {
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
configRef.current.onError?.(error);
|
|
130
|
+
if (!active) return;
|
|
131
|
+
runtime.handleError(error);
|
|
395
132
|
});
|
|
396
133
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
};
|
|
401
|
-
}, []); // Run once on mount
|
|
134
|
+
const startPromise = shutdownRef.current
|
|
135
|
+
.then(async () => {
|
|
136
|
+
if (!active) return;
|
|
402
137
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
useEffect(() => {
|
|
413
|
-
broadcastScheduler.current.setStateThrottleMs(
|
|
414
|
-
config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS,
|
|
415
|
-
);
|
|
416
|
-
}, [config.stateThrottleMs]);
|
|
417
|
-
|
|
418
|
-
const broadcastState = useCallback(() => {
|
|
419
|
-
if (wsServer.current) {
|
|
420
|
-
const actions = actionQueue.current;
|
|
421
|
-
actionQueue.current = [];
|
|
422
|
-
wsServer.current.broadcast(
|
|
423
|
-
createStateUpdateMessage(stateRef.current, actions),
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
}, []);
|
|
138
|
+
runtime.setTransport({
|
|
139
|
+
send: (connectionId, message) => {
|
|
140
|
+
server.send(connectionId, message);
|
|
141
|
+
},
|
|
142
|
+
broadcast: (message) => {
|
|
143
|
+
server.broadcast(message);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
427
146
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
147
|
+
await server.start();
|
|
148
|
+
})
|
|
149
|
+
.catch((error: unknown) => {
|
|
150
|
+
if (!active) return;
|
|
151
|
+
runtime.handleError(
|
|
152
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
432
155
|
|
|
433
156
|
return () => {
|
|
434
|
-
|
|
157
|
+
active = false;
|
|
158
|
+
runtime.setTransport(null);
|
|
159
|
+
runtime.stop();
|
|
160
|
+
shutdownRef.current = startPromise
|
|
161
|
+
.then(() => server.stop())
|
|
162
|
+
.catch((error: unknown) => {
|
|
163
|
+
if (configRef.current.debug) {
|
|
164
|
+
console.error("[GameHost] Failed to stop WebSocket server:", error);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
435
167
|
};
|
|
436
|
-
}, [
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const dispatch = useCallback(
|
|
171
|
+
(action: A) => {
|
|
172
|
+
runtime.dispatch(action);
|
|
173
|
+
},
|
|
174
|
+
[runtime],
|
|
175
|
+
);
|
|
437
176
|
|
|
438
|
-
// Memoize context value to prevent unnecessary re-renders of consumers
|
|
439
|
-
// that only use stable references like dispatch
|
|
440
177
|
const contextValue = useMemo(
|
|
441
178
|
() => ({ state, dispatch, serverUrl, serverError }),
|
|
442
|
-
[state, serverUrl, serverError],
|
|
179
|
+
[state, dispatch, serverUrl, serverError],
|
|
443
180
|
);
|
|
444
181
|
|
|
445
182
|
return (
|
|
@@ -450,14 +187,7 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
450
187
|
}
|
|
451
188
|
|
|
452
189
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
* Must be used within a `<GameHostProvider>`. Returns the canonical game state,
|
|
456
|
-
* a dispatch function for actions, the server URL (for QR codes), and any
|
|
457
|
-
* server startup errors.
|
|
458
|
-
*
|
|
459
|
-
* @returns An object with `state`, `dispatch`, `serverUrl`, and `serverError`.
|
|
460
|
-
* @throws If used outside of a `<GameHostProvider>`.
|
|
190
|
+
* Accesses canonical host state, trusted dispatch, and controller server URL.
|
|
461
191
|
*/
|
|
462
192
|
export function useGameHost<S extends IGameState, A extends IAction>() {
|
|
463
193
|
const context = useContext(GameHostContext);
|
package/src/rate-limiter.ts
CHANGED
|
@@ -1,66 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
windowStart: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface RateLimitResult extends RateLimitInfo {
|
|
13
|
-
allowed: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ActionRateLimiterOptions {
|
|
17
|
-
maxActions?: number;
|
|
18
|
-
windowMs?: number;
|
|
19
|
-
now?: () => number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Per-socket action limiter that preserves the host provider's original
|
|
24
|
-
* fixed-window algorithm.
|
|
25
|
-
*/
|
|
26
|
-
export class ActionRateLimiter {
|
|
27
|
-
private readonly maxActions: number;
|
|
28
|
-
private readonly windowMs: number;
|
|
29
|
-
private readonly now: () => number;
|
|
30
|
-
private readonly limits = new Map<string, RateLimitInfo>();
|
|
31
|
-
|
|
32
|
-
constructor(options: ActionRateLimiterOptions = {}) {
|
|
33
|
-
this.maxActions = options.maxActions ?? RATE_LIMIT_MAX;
|
|
34
|
-
this.windowMs = options.windowMs ?? RATE_LIMIT_WINDOW;
|
|
35
|
-
this.now = options.now ?? Date.now;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
record(socketId: string): RateLimitResult {
|
|
39
|
-
const now = this.now();
|
|
40
|
-
let rateInfo = this.limits.get(socketId);
|
|
41
|
-
|
|
42
|
-
if (!rateInfo || now - rateInfo.windowStart > this.windowMs) {
|
|
43
|
-
rateInfo = { count: 0, windowStart: now };
|
|
44
|
-
this.limits.set(socketId, rateInfo);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
rateInfo.count++;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
...rateInfo,
|
|
51
|
-
allowed: rateInfo.count <= this.maxActions,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
reset(socketId: string): void {
|
|
56
|
-
this.limits.delete(socketId);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
clear(): void {
|
|
60
|
-
this.limits.clear();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
get(socketId: string): RateLimitInfo | undefined {
|
|
64
|
-
return this.limits.get(socketId);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
ActionRateLimiter,
|
|
3
|
+
RATE_LIMIT_MAX,
|
|
4
|
+
RATE_LIMIT_WINDOW,
|
|
5
|
+
type ActionRateLimiterOptions,
|
|
6
|
+
type RateLimitInfo,
|
|
7
|
+
type RateLimitResult,
|
|
8
|
+
} from "@couch-kit/runtime";
|