@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/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
- 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;
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 context provider that turns a React Native TV app into a local game server.
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
- * @example
81
- * ```tsx
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
- // Wrap the user's reducer with createGameReducer to handle internal actions
95
- // (HYDRATE, PLAYER_JOINED, PLAYER_LEFT) automatically.
96
- const [state, dispatch] = useReducer(
97
- createGameReducer(config.reducer),
98
- config.initialState,
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
- // 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
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({ port, debug: config.debug });
95
+ const server = new GameWebSocketServer({
96
+ port,
97
+ debug: config.debug,
98
+ maxMessageBytes: config.maxMessageBytes,
99
+ });
191
100
 
192
- // Start the WebSocket server asynchronously
193
- server.start().catch((error) => {
101
+ server.on("listening", (listeningPort) => {
102
+ if (!active) return;
194
103
  if (configRef.current.debug) {
195
- console.error("[GameHost] Failed to start WebSocket server:", error);
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("listening", (p) => {
202
- if (configRef.current.debug)
203
- console.log(`[GameHost] WebSocket listening on port ${p}`);
108
+ server.on("connection", (connectionId) => {
109
+ if (!active) return;
110
+ runtime.handleConnection(connectionId);
204
111
  });
205
112
 
206
- server.on("connection", (socketId) => {
207
- if (configRef.current.debug)
208
- console.log(`[GameHost] Client connected: ${socketId}`);
209
- });
210
-
211
- server.on("message", (socketId, rawMessage) => {
212
- // Validate message structure before processing
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", (socketId) => {
354
- if (configRef.current.debug)
355
- console.log(`[GameHost] Client disconnected: ${socketId}`);
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 (configRef.current.debug)
393
- console.error(`[GameHost] Server error:`, error);
394
- configRef.current.onError?.(error);
130
+ if (!active) return;
131
+ runtime.handleError(error);
395
132
  });
396
133
 
397
- return () => {
398
- server.stop();
399
- sessionManager.current.clearRemovalTimers();
400
- };
401
- }, []); // Run once on mount
134
+ const startPromise = shutdownRef.current
135
+ .then(async () => {
136
+ if (!active) return;
402
137
 
403
- // 3. Throttled State Broadcasts (~30fps)
404
- // Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
405
- // reducing serialization overhead and network traffic for fast-updating games.
406
- const broadcastScheduler = useRef(
407
- new BroadcastScheduler({
408
- stateThrottleMs: config.stateThrottleMs,
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
- useEffect(() => {
429
- // Cancel any pending broadcast and schedule a fresh one.
430
- // This ensures the broadcast always uses the latest stateRef.
431
- 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
+ });
432
155
 
433
156
  return () => {
434
- 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
+ });
435
167
  };
436
- }, [state, broadcastState]);
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
- * React hook to access the game host context.
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);
@@ -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";