@couch-kit/host 1.7.13 → 1.7.15

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