@couch-kit/host 2.0.0 → 2.1.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/README.md CHANGED
@@ -216,12 +216,19 @@ Records game actions with timestamps for session replay and debugging. Useful fo
216
216
 
217
217
  ```tsx
218
218
  import { useActionRecorder, useGameHost } from "@couch-kit/host";
219
+ import type { GameAction } from "./shared/gameLogic";
219
220
 
220
221
  function GameScreen() {
221
- const { state } = useGameHost();
222
+ const { state, dispatch } = useGameHost();
222
223
  const { isRecording, recordedCount, startRecording, stopRecording, recordAction } =
223
224
  useActionRecorder();
224
225
 
226
+ // Recording is not automatic: call recordAction for each action you dispatch.
227
+ const dispatchAndRecord = (action: GameAction) => {
228
+ dispatch(action);
229
+ recordAction(action);
230
+ };
231
+
225
232
  const handleStartRecording = () => startRecording(state);
226
233
  const handleStop = () => {
227
234
  const recording = stopRecording();
@@ -233,6 +240,7 @@ function GameScreen() {
233
240
  <View>
234
241
  <Text>Actions recorded: {recordedCount}</Text>
235
242
  <Button title={isRecording ? "Stop" : "Record"} onPress={isRecording ? handleStop : handleStartRecording} />
243
+ <Button title="Reset" onPress={() => dispatchAndRecord({ type: "RESET" })} />
236
244
  </View>
237
245
  );
238
246
  }
@@ -244,7 +252,9 @@ Returns:
244
252
  - `recordedCount`: number of actions captured so far
245
253
  - `startRecording(currentState, metadata?)`: begin recording from the given state
246
254
  - `stopRecording()`: end recording and return the `ActionRecording` object (or `null`)
247
- - `recordAction(action, state)`: capture a single action + resulting state
255
+ - `recordAction(action)`: capture a single action with a timestamp. Nothing is recorded automatically — call it alongside `dispatch`. Resulting states are not stored; replay recomputes them from `initialState` and the reducer. No-op when no recording is active.
256
+ - `exportRecording()`: the current recording as a JSON string (or `null`)
257
+ - `discardRecording()`: drop the current recording and reset the count
248
258
 
249
259
  ### `getBestIpAddress()`
250
260
 
@@ -24,20 +24,51 @@ export interface ActionRecorderControls<S extends IGameState = IGameState, A ext
24
24
  isRecording: boolean;
25
25
  /** Number of actions recorded so far */
26
26
  recordedCount: number;
27
- /** Start recording actions. Captures current state as initial state. */
28
- startRecording: (currentState: S, metadata?: Record<string, unknown>) => void;
27
+ /**
28
+ * Start recording actions, capturing `currentState` as the initial state.
29
+ * With a {@link ActionRecorderOptions.source}, `currentState` may be omitted
30
+ * and the source's current state is used.
31
+ */
32
+ startRecording: (currentState?: S, metadata?: Record<string, unknown>) => void;
29
33
  /** Stop recording and return the recording */
30
34
  stopRecording: () => ActionRecording<S, A> | null;
31
- /** Record a single action (call this when dispatching) */
35
+ /**
36
+ * Record a single action. Only needed without a
37
+ * {@link ActionRecorderOptions.source}, which records every action itself.
38
+ */
32
39
  recordAction: (action: A) => void;
33
40
  /** Export the current recording as JSON string */
34
41
  exportRecording: () => string | null;
35
42
  /** Discard the current recording */
36
43
  discardRecording: () => void;
37
44
  }
45
+ /**
46
+ * Where a recorder reads actions from on its own. `useGameHost()` returns one.
47
+ */
48
+ export interface ActionRecorderSource<S extends IGameState = IGameState> {
49
+ getState: () => S;
50
+ subscribeActions: (listener: (action: IAction) => void) => () => void;
51
+ }
52
+ export interface ActionRecorderOptions<S extends IGameState = IGameState> {
53
+ /**
54
+ * Records every action the host reduces — including players' actions and
55
+ * join/leave lifecycle actions, which never pass through the host's own
56
+ * `dispatch`. Without it, only actions passed to `recordAction` are
57
+ * recorded, which on a host means only the host's own dispatches.
58
+ *
59
+ * Recordings made this way contain internal actions (`__PLAYER_JOINED__`
60
+ * and friends); `replayActions` handles them.
61
+ *
62
+ * @example
63
+ * const host = useGameHost<GameState, GameAction>();
64
+ * const recorder = useActionRecorder({ source: host });
65
+ * recorder.startRecording();
66
+ */
67
+ source?: ActionRecorderSource<S>;
68
+ }
38
69
  /**
39
70
  * Hook that enables recording of game actions for later replay.
40
71
  * Used on the host side to capture game sessions.
41
72
  */
42
- export declare function useActionRecorder<S extends IGameState = IGameState, A extends IAction = IAction>(): ActionRecorderControls<S, A>;
73
+ export declare function useActionRecorder<S extends IGameState = IGameState, A extends IAction = IAction>(options?: ActionRecorderOptions<S>): ActionRecorderControls<S, A>;
43
74
  //# sourceMappingURL=action-recorder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action-recorder.d.ts","sourceRoot":"","sources":["../src/action-recorder.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO;IACzD,MAAM,EAAE,CAAC,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe,CAC9B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB,CACrC,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,4CAA4C;IAC5C,WAAW,EAAE,OAAO,CAAC;IACrB,wCAAwC;IACxC,aAAa,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,cAAc,EAAE,CAAC,YAAY,EAAE,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAC9E,8CAA8C;IAC9C,aAAa,EAAE,MAAM,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IAClD,0DAA0D;IAC1D,YAAY,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAClC,kDAAkD;IAClD,eAAe,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACrC,oCAAoC;IACpC,gBAAgB,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO,KACxB,sBAAsB,CAAC,CAAC,EAAE,CAAC,CAAC,CA0DhC"}
1
+ {"version":3,"file":"action-recorder.d.ts","sourceRoot":"","sources":["../src/action-recorder.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO;IACzD,MAAM,EAAE,CAAC,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe,CAC9B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB,CACrC,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,4CAA4C;IAC5C,WAAW,EAAE,OAAO,CAAC;IACrB,wCAAwC;IACxC,aAAa,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,cAAc,EAAE,CACd,YAAY,CAAC,EAAE,CAAC,EAChB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC/B,IAAI,CAAC;IACV,8CAA8C;IAC9C,aAAa,EAAE,MAAM,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IAClD;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAClC,kDAAkD;IAClD,eAAe,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACrC,oCAAoC;IACpC,gBAAgB,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IACrE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAClB,gBAAgB,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;CACvE;AAED,MAAM,WAAW,qBAAqB,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IACtE;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,oBAAoB,CAAC,CAAC,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO,EAC3B,OAAO,GAAE,qBAAqB,CAAC,CAAC,CAAM,GAAG,sBAAsB,CAAC,CAAC,EAAE,CAAC,CAAC,CA8EtE"}
package/lib/provider.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { type IAction, type IGameState } from "@couch-kit/core";
3
- import { type GameHostRuntimeConfig } from "@couch-kit/runtime";
3
+ import { type GameHostRuntimeConfig, type RuntimeActionListener } from "@couch-kit/runtime";
4
4
  export interface GameHostConfig<S extends IGameState, A extends IAction> extends GameHostRuntimeConfig<S, A> {
5
5
  port?: number;
6
6
  wsPort?: number;
@@ -18,6 +18,14 @@ interface GameHostContextValue<S extends IGameState, A extends IAction> {
18
18
  dispatch: (action: A) => void;
19
19
  serverUrl: string | null;
20
20
  serverError: Error | null;
21
+ /** Reads canonical state now, without waiting for a re-render. */
22
+ getState: () => S;
23
+ /**
24
+ * Subscribes to every action the host reduces — its own dispatches, every
25
+ * player's actions, and player join/leave lifecycle actions. Pass the whole
26
+ * context to `useActionRecorder({ source })` to record complete sessions.
27
+ */
28
+ subscribeActions: (listener: RuntimeActionListener<S, A>) => () => void;
21
29
  }
22
30
  /**
23
31
  * React Native adapter for the transport-neutral authoritative game runtime.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oBAAoB,CAAC;AAI5B,MAAM,WAAW,cAAc,CAC7B,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,CACjB,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AAOD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBA4HA;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC3B,MAAM,oBAAoB,CAAC;AAI5B,MAAM,WAAW,cAAc,CAC7B,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,CACjB,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAClB;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,QAAQ,EAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,IAAI,CAAC;CACzE;AAOD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAmIA;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,WAAY,kBAAkB;;;;CAyEzD,CAAC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,WAAY,kBAAkB;;;;CA6FzD,CAAC"}
@@ -63,6 +63,11 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
63
63
  * Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
64
64
  */
65
65
  broadcast(data: unknown, excludeId?: string): void;
66
+ /**
67
+ * Send the same data to each of `socketIds`, serializing it once.
68
+ * Unknown IDs are skipped; a failed send doesn't skip the remaining clients.
69
+ */
70
+ multicast(socketIds: Iterable<string>, data: unknown): void;
66
71
  /** Returns the number of currently connected clients. */
67
72
  get clientCount(): number;
68
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,eAAe,CAAS;IAEhC,YAAY,MAAM,EAAE,eAAe,EAUlC;IAED,OAAO,CAAC,GAAG;IAME,KAAK,kBAqGjB;IAEY,IAAI,kBAkBhB;IAED;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAoB1C;IAED;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAkBjD;IAED,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,eAAe,CAAS;IAEhC,YAAY,MAAM,EAAE,eAAe,EASlC;IAED,OAAO,CAAC,GAAG;IAME,KAAK,kBAkGjB;IAEY,IAAI,kBAkBhB;IAED;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAoB1C;IAED;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAkBjD;IAED;;;OAGG;IACI,SAAS,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,OAAO,QAiB1D;IAED,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -57,8 +57,8 @@
57
57
  "prepublishOnly": "bun run build"
58
58
  },
59
59
  "dependencies": {
60
- "@couch-kit/core": "0.10.0",
61
- "@couch-kit/runtime": "0.3.0",
60
+ "@couch-kit/core": "0.10.1",
61
+ "@couch-kit/runtime": "0.5.0",
62
62
  "react-native-nitro-http-server": "^1.9.2"
63
63
  },
64
64
  "devDependencies": {
@@ -69,7 +69,7 @@
69
69
  "react": "19.3.0",
70
70
  "react-dom": "19.3.0",
71
71
  "react-native": "0.87.1",
72
- "react-native-nitro-modules": "0.35.10",
72
+ "react-native-nitro-modules": "0.37.1",
73
73
  "del-cli": "^7.0.0",
74
74
  "jest": "^30.0.0"
75
75
  },
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { IAction, IGameState } from "@couch-kit/core";
3
3
 
4
4
  /**
@@ -34,11 +34,21 @@ export interface ActionRecorderControls<
34
34
  isRecording: boolean;
35
35
  /** Number of actions recorded so far */
36
36
  recordedCount: number;
37
- /** Start recording actions. Captures current state as initial state. */
38
- startRecording: (currentState: S, metadata?: Record<string, unknown>) => void;
37
+ /**
38
+ * Start recording actions, capturing `currentState` as the initial state.
39
+ * With a {@link ActionRecorderOptions.source}, `currentState` may be omitted
40
+ * and the source's current state is used.
41
+ */
42
+ startRecording: (
43
+ currentState?: S,
44
+ metadata?: Record<string, unknown>,
45
+ ) => void;
39
46
  /** Stop recording and return the recording */
40
47
  stopRecording: () => ActionRecording<S, A> | null;
41
- /** Record a single action (call this when dispatching) */
48
+ /**
49
+ * Record a single action. Only needed without a
50
+ * {@link ActionRecorderOptions.source}, which records every action itself.
51
+ */
42
52
  recordAction: (action: A) => void;
43
53
  /** Export the current recording as JSON string */
44
54
  exportRecording: () => string | null;
@@ -46,6 +56,32 @@ export interface ActionRecorderControls<
46
56
  discardRecording: () => void;
47
57
  }
48
58
 
59
+ /**
60
+ * Where a recorder reads actions from on its own. `useGameHost()` returns one.
61
+ */
62
+ export interface ActionRecorderSource<S extends IGameState = IGameState> {
63
+ getState: () => S;
64
+ subscribeActions: (listener: (action: IAction) => void) => () => void;
65
+ }
66
+
67
+ export interface ActionRecorderOptions<S extends IGameState = IGameState> {
68
+ /**
69
+ * Records every action the host reduces — including players' actions and
70
+ * join/leave lifecycle actions, which never pass through the host's own
71
+ * `dispatch`. Without it, only actions passed to `recordAction` are
72
+ * recorded, which on a host means only the host's own dispatches.
73
+ *
74
+ * Recordings made this way contain internal actions (`__PLAYER_JOINED__`
75
+ * and friends); `replayActions` handles them.
76
+ *
77
+ * @example
78
+ * const host = useGameHost<GameState, GameAction>();
79
+ * const recorder = useActionRecorder({ source: host });
80
+ * recorder.startRecording();
81
+ */
82
+ source?: ActionRecorderSource<S>;
83
+ }
84
+
49
85
  /**
50
86
  * Hook that enables recording of game actions for later replay.
51
87
  * Used on the host side to capture game sessions.
@@ -53,15 +89,24 @@ export interface ActionRecorderControls<
53
89
  export function useActionRecorder<
54
90
  S extends IGameState = IGameState,
55
91
  A extends IAction = IAction,
56
- >(): ActionRecorderControls<S, A> {
92
+ >(options: ActionRecorderOptions<S> = {}): ActionRecorderControls<S, A> {
93
+ const { source } = options;
57
94
  const [isRecording, setIsRecording] = useState(false);
58
95
  const [recordedCount, setRecordedCount] = useState(0);
59
96
  const recordingRef = useRef<ActionRecording<S, A> | null>(null);
97
+ const sourceRef = useRef(source);
98
+ sourceRef.current = source;
60
99
 
61
100
  const startRecording = useCallback(
62
- (currentState: S, metadata?: Record<string, unknown>) => {
101
+ (currentState?: S, metadata?: Record<string, unknown>) => {
102
+ const initialState = currentState ?? sourceRef.current?.getState();
103
+ if (initialState === undefined) {
104
+ throw new Error(
105
+ "startRecording needs the current state when no source is configured",
106
+ );
107
+ }
63
108
  recordingRef.current = {
64
- initialState: currentState,
109
+ initialState,
65
110
  actions: [],
66
111
  startTimestamp: Date.now(),
67
112
  metadata,
@@ -82,7 +127,12 @@ export function useActionRecorder<
82
127
  }, []);
83
128
 
84
129
  const recordAction = useCallback((action: A) => {
85
- if (!recordingRef.current) return;
130
+ // A stopped recording keeps its actions but takes no new ones.
131
+ if (
132
+ !recordingRef.current ||
133
+ recordingRef.current.endTimestamp !== undefined
134
+ )
135
+ return;
86
136
 
87
137
  recordingRef.current.actions.push({
88
138
  action,
@@ -91,6 +141,12 @@ export function useActionRecorder<
91
141
  setRecordedCount((c) => c + 1);
92
142
  }, []);
93
143
 
144
+ const subscribeActions = source?.subscribeActions;
145
+ useEffect(() => {
146
+ if (!subscribeActions) return;
147
+ return subscribeActions((action) => recordAction(action as A));
148
+ }, [subscribeActions, recordAction]);
149
+
94
150
  const exportRecording = useCallback((): string | null => {
95
151
  if (!recordingRef.current) return null;
96
152
  return JSON.stringify(recordingRef.current, null, 2);
package/src/provider.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import {
17
17
  GameHostRuntime,
18
18
  type GameHostRuntimeConfig,
19
+ type RuntimeActionListener,
19
20
  } from "@couch-kit/runtime";
20
21
  import { useStaticServer } from "./server";
21
22
  import { GameWebSocketServer } from "./websocket";
@@ -41,6 +42,14 @@ interface GameHostContextValue<S extends IGameState, A extends IAction> {
41
42
  dispatch: (action: A) => void;
42
43
  serverUrl: string | null;
43
44
  serverError: Error | null;
45
+ /** Reads canonical state now, without waiting for a re-render. */
46
+ getState: () => S;
47
+ /**
48
+ * Subscribes to every action the host reduces — its own dispatches, every
49
+ * player's actions, and player join/leave lifecycle actions. Pass the whole
50
+ * context to `useActionRecorder({ source })` to record complete sessions.
51
+ */
52
+ subscribeActions: (listener: RuntimeActionListener<S, A>) => () => void;
44
53
  }
45
54
 
46
55
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -139,8 +148,8 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
139
148
  send: (connectionId, message) => {
140
149
  server.send(connectionId, message);
141
150
  },
142
- broadcast: (message) => {
143
- server.broadcast(message);
151
+ broadcast: (message, recipients) => {
152
+ server.multicast(recipients, message);
144
153
  },
145
154
  });
146
155
 
@@ -175,8 +184,15 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
175
184
  );
176
185
 
177
186
  const contextValue = useMemo(
178
- () => ({ state, dispatch, serverUrl, serverError }),
179
- [state, dispatch, serverUrl, serverError],
187
+ () => ({
188
+ state,
189
+ dispatch,
190
+ serverUrl,
191
+ serverError,
192
+ getState: runtime.getState,
193
+ subscribeActions: runtime.subscribeActions,
194
+ }),
195
+ [state, dispatch, serverUrl, serverError, runtime],
180
196
  );
181
197
 
182
198
  return (
package/src/server.ts CHANGED
@@ -29,13 +29,26 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
29
29
 
30
30
  useEffect(() => {
31
31
  let server: StaticServer | null = null;
32
+ // Set by cleanup. Starting is asynchronous, so an effect that was torn down
33
+ // mid-start (a config change, StrictMode's double mount, an unmount) must
34
+ // neither publish its result nor leave its server holding the port.
35
+ let cancelled = false;
32
36
  setLoading(true);
37
+ setError(null);
38
+
39
+ const stop = (target: StaticServer | null) => {
40
+ if (!target) return;
41
+ void Promise.resolve(target.stop()).catch(() => {
42
+ // Nothing useful to do if a server we no longer want fails to stop.
43
+ });
44
+ };
33
45
 
34
46
  const startServer = async () => {
35
47
  // In Dev Mode, we don't start the static server.
36
48
  // We just resolve the IP so the host knows where it is.
37
49
  if (config.devMode && config.devServerUrl) {
38
50
  const ip = await getBestIpAddress();
51
+ if (cancelled) return;
39
52
  if (ip) {
40
53
  // In dev mode, the URL is the laptop's dev server,
41
54
  // but we might need the TV's IP for the WebSocket connection later.
@@ -65,13 +78,21 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
65
78
  }
66
79
  const port = config.port || DEFAULT_HTTP_PORT;
67
80
 
68
- server = new StaticServer();
81
+ const started = new StaticServer();
82
+ server = started;
69
83
 
70
84
  // Use '0.0.0.0' to bind to all interfaces (local network)
71
- await server.start(port, path, "0.0.0.0");
85
+ await started.start(port, path, "0.0.0.0");
86
+ if (cancelled) {
87
+ // Cleanup ran while the server was still starting, when a stop may
88
+ // not have taken; stop it again now that it is actually up.
89
+ stop(started);
90
+ return;
91
+ }
72
92
 
73
93
  // We prefer the actual IP over "localhost" returned by some libs
74
94
  const ip = await getBestIpAddress();
95
+ if (cancelled) return;
75
96
  if (ip) {
76
97
  setUrl(`http://${ip}:${port}/index.html`);
77
98
  } else {
@@ -79,18 +100,17 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
79
100
  setUrl(`http://localhost:${port}/index.html`);
80
101
  }
81
102
  } catch (e) {
82
- setError(new Error(toErrorMessage(e)));
103
+ if (!cancelled) setError(new Error(toErrorMessage(e)));
83
104
  } finally {
84
- setLoading(false);
105
+ if (!cancelled) setLoading(false);
85
106
  }
86
107
  };
87
108
 
88
109
  startServer();
89
110
 
90
111
  return () => {
91
- if (server) {
92
- server.stop();
93
- }
112
+ cancelled = true;
113
+ stop(server);
94
114
  };
95
115
  }, [config.port, config.devMode, config.devServerUrl, config.staticDir]);
96
116
 
package/src/websocket.ts CHANGED
@@ -73,8 +73,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
73
73
  super();
74
74
  this.port = config.port;
75
75
  this.debug = !!config.debug;
76
- this.maxMessageBytes =
77
- config.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
76
+ this.maxMessageBytes = config.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
78
77
  // Only `port`, `debug`, and `maxMessageBytes` are honored. `maxFrameSize`,
79
78
  // `keepaliveInterval`, and `keepaliveTimeout` are deprecated no-ops: the
80
79
  // nitro-http WebSocket transport does not expose these knobs, so they are
@@ -150,10 +149,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
150
149
  // Handle errors
151
150
  ws.onerror = (event: { message: string }) => {
152
151
  this.log(`[WebSocket] Error on ${socketId}:`, event.message);
153
- this.emit(
154
- "error",
155
- new Error(`WebSocket error: ${event.message}`),
156
- );
152
+ this.emit("error", new Error(`WebSocket error: ${event.message}`));
157
153
  };
158
154
  },
159
155
  );
@@ -260,6 +256,29 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
260
256
  }
261
257
  }
262
258
 
259
+ /**
260
+ * Send the same data to each of `socketIds`, serializing it once.
261
+ * Unknown IDs are skipped; a failed send doesn't skip the remaining clients.
262
+ */
263
+ public multicast(socketIds: Iterable<string>, data: unknown) {
264
+ try {
265
+ const message = JSON.stringify(data);
266
+ for (const id of socketIds) {
267
+ const client = this.clients.get(id);
268
+ if (!client) continue;
269
+ client.ws.send(message).catch((error: Error) => {
270
+ this.log(`[WebSocket] Failed to send to ${id}:`, error);
271
+ });
272
+ }
273
+ } catch (error) {
274
+ this.log(`[WebSocket] Failed to serialize multicast message:`, error);
275
+ this.emit(
276
+ "error",
277
+ error instanceof Error ? error : new Error(String(error)),
278
+ );
279
+ }
280
+ }
281
+
263
282
  /** Returns the number of currently connected clients. */
264
283
  public get clientCount(): number {
265
284
  return this.clients.size;