@couch-kit/core 0.5.1 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @couch-kit/core
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#19](https://github.com/faluciano/react-native-couch-kit/pull/19) [`978616b`](https://github.com/faluciano/react-native-couch-kit/commit/978616b767031e02e24fe48de8dbdf37d15be783) Thanks [@faluciano](https://github.com/faluciano)! - Add middleware system with `applyMiddleware`, `actionLogger`, and `actionValidator`. Middleware can observe and transform actions flowing through `createGameReducer`. Includes error boundaries for fault tolerance.
8
+
9
+ ## 0.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#16](https://github.com/faluciano/react-native-couch-kit/pull/16) [`86b5ff3`](https://github.com/faluciano/react-native-couch-kit/commit/86b5ff353ce9baac0c48e6a8315bbb57eb155daf) Thanks [@faluciano](https://github.com/faluciano)! - ### Phase 1: Quick Wins — Bug Fixes, Security, and Cleanup
14
+
15
+ **Bug fixes:**
16
+ - Fix `simulate` command: add missing `secret` to JOIN payload so bots are no longer rejected with `INVALID_SECRET`
17
+ - Send `RECONNECTED` message (instead of `WELCOME`) when a player reconnects, enabling clients to distinguish reconnections
18
+ - Wire `ASSETS_LOADED` end-to-end: `usePreload` now accepts an optional `sendMessage` callback to notify the host when assets finish loading; host tracks per-player asset status
19
+ - Include dispatched action(s) in `STATE_UPDATE` broadcasts via an action queue, so clients receive the `action` field
20
+
21
+ **Security:**
22
+ - Replace `derivePlayerId` with SHA-256 (Web Crypto API) — the old implementation exposed the first 16 hex chars of the player secret
23
+ - Add dual-derivation migration: host tries SHA-256 first, falls back to legacy ID for existing players
24
+ - Add per-socket rate limiting (60 actions/second) with `RATE_LIMITED` error response
25
+ - Cache `socketId → playerId` mapping to avoid async derivation in hot paths
26
+
27
+ **Cleanup:**
28
+ - Remove dead code: `buffer-utils.ts`, `declarations.d.ts`, and their test file (TCP WebSocket remnants)
29
+ - Deprecate unused constants: `MAX_FRAME_SIZE`, `KEEPALIVE_INTERVAL`, `KEEPALIVE_TIMEOUT` (will be removed in next major)
30
+ - Extract `DEFAULT_DISCONNECT_TIMEOUT` constant (replaces hardcoded 5-minute magic number)
31
+ - Add configurable `disconnectTimeout` to `GameHostConfig`
32
+
33
+ **DX improvements:**
34
+ - Auto-detect package manager in `bundle` command (checks lock files instead of hardcoding `bun`)
35
+ - Show build output (`stdio: "inherit"` instead of `"ignore"`)
36
+ - Log bot WELCOME/ERROR responses in `simulate` command
37
+
3
38
  ## 0.5.1
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -33,12 +33,12 @@ interface GameState extends IGameState {
33
33
  score: number;
34
34
  }
35
35
 
36
- type GameAction = { type: "SCORE" } | { type: "RESET" };
36
+ type GameAction = { type: "SCORE"; payload: number } | { type: "RESET" };
37
37
 
38
38
  const gameReducer = (state: GameState, action: GameAction): GameState => {
39
39
  switch (action.type) {
40
40
  case "SCORE":
41
- return { ...state, score: state.score + 1 };
41
+ return { ...state, score: state.score + action.payload };
42
42
  case "RESET":
43
43
  return { ...state, score: 0 };
44
44
  default:
@@ -47,6 +47,75 @@ const gameReducer = (state: GameState, action: GameAction): GameState => {
47
47
  };
48
48
  ```
49
49
 
50
+ ### Middleware
51
+
52
+ `createGameReducer` accepts an optional second argument with a `middleware` array. Middlewares follow the Redux pattern — each is a three-layer curried function that can observe, transform, or block actions before they reach the reducer.
53
+
54
+ ```typescript
55
+ import {
56
+ createGameReducer,
57
+ actionLogger,
58
+ actionValidator,
59
+ type Middleware,
60
+ } from "@couch-kit/core";
61
+
62
+ const reducer = createGameReducer(gameReducer, {
63
+ middleware: [
64
+ actionLogger(),
65
+ actionValidator({ SCORE: (action) => action.payload > 0 }),
66
+ ],
67
+ });
68
+ ```
69
+
70
+ Middlewares execute in array order: the first middleware sees the action first on the way in and last on the way out. Each middleware is wrapped in an error boundary — if one throws, it is skipped and the action continues to the next layer.
71
+
72
+ #### `actionLogger(options?)`
73
+
74
+ Logs every dispatched action with its previous state, action payload, and resulting state.
75
+
76
+ | Option | Type | Default | Description |
77
+ | ----------- | --------- | ------- | ----------------------------------------------- |
78
+ | `collapsed` | `boolean` | `true` | Use `console.groupCollapsed` instead of `group` |
79
+
80
+ ```typescript
81
+ actionLogger(); // collapsed groups (default)
82
+ actionLogger({ collapsed: false }); // expanded groups
83
+ ```
84
+
85
+ #### `actionValidator(schema)`
86
+
87
+ Validates actions against a schema before they reach the reducer. If a validator returns `false`, the action is dropped and a warning is logged.
88
+
89
+ ```typescript
90
+ import { actionValidator, type ActionSchema } from "@couch-kit/core";
91
+
92
+ const schema: ActionSchema<GameAction> = {
93
+ SCORE: (action) => typeof action.payload === "number" && action.payload > 0,
94
+ RESET: () => true,
95
+ };
96
+
97
+ const validator = actionValidator<GameState, GameAction>(schema);
98
+ ```
99
+
100
+ Actions without a matching validator in the schema pass through unchanged. Internal actions (types prefixed with `__`) are never validated.
101
+
102
+ #### Custom middleware
103
+
104
+ A middleware is a function with the signature:
105
+
106
+ ```typescript
107
+ const myMiddleware: Middleware<GameState, GameAction> =
108
+ (api) => (next) => (action) => {
109
+ // `api.getState()` returns the current state
110
+ // `next(action)` passes the action to the next layer
111
+ // Return the resulting state
112
+ const result = next(action);
113
+ return result;
114
+ };
115
+ ```
116
+
117
+ > **Note:** Internal actions (`__HYDRATE__`, `__PLAYER_JOINED__`, etc.) flow through middleware and are observable, but custom middleware should **not** block them. Doing so will break framework behaviour.
118
+
50
119
  ### Interfaces
51
120
 
52
121
  #### `IPlayer`
package/dist/index.js CHANGED
@@ -1,3 +1,77 @@
1
+ // src/middleware.ts
2
+ function applyMiddleware(...middlewares) {
3
+ return (reducer) => {
4
+ let currentState;
5
+ const api = {
6
+ getState: () => currentState
7
+ };
8
+ const baseDispatch = (action) => {
9
+ currentState = reducer(currentState, action);
10
+ return currentState;
11
+ };
12
+ const chain = middlewares.map((mw, index) => {
13
+ const layer = mw(api);
14
+ return (next) => {
15
+ return (action) => {
16
+ let nextCalled = false;
17
+ const safeNext = (a) => {
18
+ nextCalled = true;
19
+ return next(a);
20
+ };
21
+ const safeHandler = layer(safeNext);
22
+ try {
23
+ return safeHandler(action);
24
+ } catch (error) {
25
+ console.error(`[couch-kit] Middleware ${index} threw on action "${action.type}":`, error);
26
+ return nextCalled ? api.getState() : next(action);
27
+ }
28
+ };
29
+ };
30
+ });
31
+ const dispatch = chain.reduceRight((next, layer) => layer(next), baseDispatch);
32
+ return (state, action) => {
33
+ currentState = state;
34
+ return dispatch(action);
35
+ };
36
+ };
37
+ }
38
+ function actionLogger(options) {
39
+ const collapsed = options?.collapsed ?? true;
40
+ return (api) => (next) => (action) => {
41
+ const prevState = api.getState();
42
+ const group = collapsed ? console.groupCollapsed : console.group;
43
+ group(`[couch-kit] action ${action.type}`);
44
+ console.log("prev state", prevState);
45
+ console.log("action", action);
46
+ try {
47
+ const nextState = next(action);
48
+ console.log("next state", nextState);
49
+ return nextState;
50
+ } finally {
51
+ console.groupEnd();
52
+ }
53
+ };
54
+ }
55
+ var _internalTypes;
56
+ function getInternalTypes() {
57
+ if (!_internalTypes) {
58
+ _internalTypes = new Set(Object.values(InternalActionTypes));
59
+ }
60
+ return _internalTypes;
61
+ }
62
+ function actionValidator(schema) {
63
+ return (_api) => (next) => (action) => {
64
+ if (getInternalTypes().has(action.type))
65
+ return next(action);
66
+ const validate = schema[action.type];
67
+ if (validate && !validate(action)) {
68
+ console.warn(`[couch-kit] Action "${action.type}" failed validation, skipping`);
69
+ return _api.getState();
70
+ }
71
+ return next(action);
72
+ };
73
+ }
74
+
1
75
  // src/types.ts
2
76
  var InternalActionTypes = {
3
77
  HYDRATE: "__HYDRATE__",
@@ -6,8 +80,8 @@ var InternalActionTypes = {
6
80
  PLAYER_RECONNECTED: "__PLAYER_RECONNECTED__",
7
81
  PLAYER_REMOVED: "__PLAYER_REMOVED__"
8
82
  };
9
- function createGameReducer(reducer) {
10
- return (state, action) => {
83
+ function createGameReducer(reducer, options) {
84
+ const baseReducer = (state, action) => {
11
85
  switch (action.type) {
12
86
  case InternalActionTypes.HYDRATE:
13
87
  return action.payload;
@@ -67,6 +141,10 @@ function createGameReducer(reducer) {
67
141
  return reducer(state, action);
68
142
  }
69
143
  };
144
+ const middlewares = options?.middleware;
145
+ if (!middlewares || middlewares.length === 0)
146
+ return baseReducer;
147
+ return applyMiddleware(...middlewares)(baseReducer);
70
148
  }
71
149
  // src/protocol.ts
72
150
  var MessageTypes = {
@@ -89,6 +167,7 @@ var DEFAULT_BASE_DELAY = 1000;
89
167
  var DEFAULT_MAX_DELAY = 1e4;
90
168
  var DEFAULT_SYNC_INTERVAL = 5000;
91
169
  var MAX_PENDING_PINGS = 50;
170
+ var DEFAULT_DISCONNECT_TIMEOUT = 5 * 60 * 1000;
92
171
  var KEEPALIVE_INTERVAL = 30000;
93
172
  var KEEPALIVE_TIMEOUT = 1e4;
94
173
  function generateId() {
@@ -108,7 +187,12 @@ function isValidSecret(secret) {
108
187
  const hex = secret.replace(/-/g, "");
109
188
  return hex.length >= 32 && /^[0-9a-f]+$/i.test(hex);
110
189
  }
111
- function derivePlayerId(secret) {
190
+ async function derivePlayerId(secret) {
191
+ const data = new TextEncoder().encode(secret);
192
+ const hash = await crypto.subtle.digest("SHA-256", data);
193
+ return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
194
+ }
195
+ function derivePlayerIdLegacy(secret) {
112
196
  return secret.replace(/-/g, "").slice(0, 16);
113
197
  }
114
198
  function toErrorMessage(error) {
@@ -120,8 +204,12 @@ export {
120
204
  toErrorMessage,
121
205
  isValidSecret,
122
206
  generateId,
207
+ derivePlayerIdLegacy,
123
208
  derivePlayerId,
124
209
  createGameReducer,
210
+ applyMiddleware,
211
+ actionValidator,
212
+ actionLogger,
125
213
  MessageTypes,
126
214
  MAX_PENDING_PINGS,
127
215
  MAX_FRAME_SIZE,
@@ -133,5 +221,6 @@ export {
133
221
  DEFAULT_MAX_RETRIES,
134
222
  DEFAULT_MAX_DELAY,
135
223
  DEFAULT_HTTP_PORT,
224
+ DEFAULT_DISCONNECT_TIMEOUT,
136
225
  DEFAULT_BASE_DELAY
137
226
  };
@@ -3,7 +3,7 @@
3
3
  export declare const DEFAULT_HTTP_PORT = 8080;
4
4
  /** Default WebSocket port offset from the HTTP port (skips Metro on +1). */
5
5
  export declare const DEFAULT_WS_PORT_OFFSET = 2;
6
- /** Maximum allowed WebSocket frame payload size (1 MB). */
6
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
7
7
  export declare const MAX_FRAME_SIZE: number;
8
8
  /** Default maximum reconnection attempts for the client. */
9
9
  export declare const DEFAULT_MAX_RETRIES = 5;
@@ -15,9 +15,11 @@ export declare const DEFAULT_MAX_DELAY = 10000;
15
15
  export declare const DEFAULT_SYNC_INTERVAL = 5000;
16
16
  /** Maximum number of outstanding pings before cleanup. */
17
17
  export declare const MAX_PENDING_PINGS = 50;
18
- /** Server-side keepalive ping interval (ms). */
18
+ /** Default timeout (ms) before a disconnected player is removed from state. */
19
+ export declare const DEFAULT_DISCONNECT_TIMEOUT: number;
20
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
19
21
  export declare const KEEPALIVE_INTERVAL = 30000;
20
- /** Keepalive timeout -- disconnect if no pong received (ms). */
22
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
21
23
  export declare const KEEPALIVE_TIMEOUT = 10000;
22
24
  /**
23
25
  * Generate a cryptographically random ID string.
@@ -31,13 +33,17 @@ export declare function generateId(): string;
31
33
  */
32
34
  export declare function isValidSecret(secret: string): boolean;
33
35
  /**
34
- * Derives a stable, public player ID from a secret UUID.
36
+ * Derives a stable, public player ID from a secret UUID using SHA-256.
35
37
  *
36
- * Strips dashes and takes the first 16 hex characters. This is NOT a
37
- * cryptographic hash — it simply avoids exposing the raw secret in
38
- * state that gets broadcast to all clients.
38
+ * Takes the first 16 hex characters of the SHA-256 hash, producing
39
+ * a deterministic ID that cannot be reversed to recover the secret.
39
40
  */
40
- export declare function derivePlayerId(secret: string): string;
41
+ export declare function derivePlayerId(secret: string): Promise<string>;
42
+ /**
43
+ * @deprecated Legacy player ID derivation. Insecure — exposes first 16 hex chars of secret.
44
+ * Kept temporarily for dual-derivation migration in the host. Will be removed in next major.
45
+ */
46
+ export declare function derivePlayerIdLegacy(secret: string): string;
41
47
  /**
42
48
  * Safely extract an error message from an unknown caught value.
43
49
  * In JavaScript, anything can be thrown -- this normalizes it.
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,2DAA2D;AAC3D,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,4DAA4D;AAC5D,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,gDAAgD;AAChD,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAwBnC;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGrD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGrD"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,8FAA8F;AAC9F,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,4DAA4D;AAC5D,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,+EAA+E;AAC/E,eAAO,MAAM,0BAA0B,QAAgB,CAAC;AAExD,8FAA8F;AAC9F,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,8FAA8F;AAC9F,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAwBnC;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGrD;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOpE;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGrD"}
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types";
2
2
  export * from "./protocol";
3
3
  export * from "./constants";
4
+ export * from "./middleware";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
@@ -0,0 +1,67 @@
1
+ import type { IGameState, IAction, InternalAction, GameReducer } from "./types";
2
+ /** The API surface available to each middleware. */
3
+ export interface MiddlewareAPI<S extends IGameState> {
4
+ getState: () => S;
5
+ }
6
+ /** A dispatch function that accepts user actions or internal actions. */
7
+ export type MiddlewareDispatch<S extends IGameState, A extends IAction> = (action: A | InternalAction<S>) => S;
8
+ /**
9
+ * Redux-style middleware — a three-layer curried function:
10
+ *
11
+ * 1. Receives the middleware API (`getState`).
12
+ * 2. Receives `next` (the downstream dispatch).
13
+ * 3. Receives the `action` and returns the new state.
14
+ */
15
+ export type Middleware<S extends IGameState = IGameState, A extends IAction = IAction> = (api: MiddlewareAPI<S>) => (next: MiddlewareDispatch<S, A>) => (action: A | InternalAction<S>) => S;
16
+ /**
17
+ * Composes an array of middlewares into a higher-order function that wraps a
18
+ * game reducer.
19
+ *
20
+ * Middleware ordering follows the Redux convention: the **first** middleware in
21
+ * the array is the outermost layer — it sees the action first on the way in and
22
+ * last on the way out.
23
+ *
24
+ * Each middleware layer is wrapped in a try / catch. If a middleware throws, the
25
+ * error is logged and the action falls through to the next layer so a single
26
+ * broken middleware never tears down the entire dispatch chain. If the middleware
27
+ * already called `next` before throwing, the error boundary returns the current
28
+ * state instead of re-dispatching to prevent double-dispatch.
29
+ */
30
+ export declare function applyMiddleware<S extends IGameState, A extends IAction>(...middlewares: Middleware<S, A>[]): (reducer: GameReducer<S, A | InternalAction<S>>) => GameReducer<S, A | InternalAction<S>>;
31
+ /** Options for the built-in action logger middleware. */
32
+ export interface ActionLoggerOptions {
33
+ /** Use `console.groupCollapsed` instead of `console.group`. Defaults to `true`. */
34
+ collapsed?: boolean;
35
+ }
36
+ /**
37
+ * Middleware that logs every dispatched action together with the previous and
38
+ * next state.
39
+ *
40
+ * ```ts
41
+ * createGameReducer(reducer, { middleware: [actionLogger()] });
42
+ * ```
43
+ */
44
+ export declare function actionLogger<S extends IGameState, A extends IAction>(options?: ActionLoggerOptions): Middleware<S, A>;
45
+ /**
46
+ * A map from action type strings to validator functions. Each validator receives
47
+ * the fully-typed action and returns `true` if the action is valid.
48
+ */
49
+ export type ActionSchema<A extends IAction> = {
50
+ [K in A["type"]]?: (action: A & {
51
+ type: K;
52
+ }) => boolean;
53
+ };
54
+ /**
55
+ * Middleware that validates actions against a schema before they reach the
56
+ * reducer. Invalid actions are silently dropped (with a console warning).
57
+ *
58
+ * Internal actions are never validated — they always pass through.
59
+ *
60
+ * ```ts
61
+ * const validator = actionValidator<GameState, GameAction>({
62
+ * SCORE: (action) => action.payload > 0,
63
+ * });
64
+ * ```
65
+ */
66
+ export declare function actionValidator<S extends IGameState, A extends IAction>(schema: ActionSchema<A>): Middleware<S, A>;
67
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAOhF,oDAAoD;AACpD,MAAM,WAAW,aAAa,CAAC,CAAC,SAAS,UAAU;IACjD,QAAQ,EAAE,MAAM,CAAC,CAAC;CACnB;AAED,yEAAyE;AACzE,MAAM,MAAM,kBAAkB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,IAAI,CACxE,MAAM,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,KAC1B,CAAC,CAAC;AAEP;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,CACpB,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO,IACzB,CACF,GAAG,EAAE,aAAa,CAAC,CAAC,CAAC,KAClB,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AAM9E;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACrE,GAAG,WAAW,EAAE,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GACjC,CACD,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC,KAC3C,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC,CAwDzC;AAMD,yDAAyD;AACzD,MAAM,WAAW,mBAAmB;IAClC,mFAAmF;IACnF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAClE,OAAO,CAAC,EAAE,mBAAmB,GAC5B,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAmBlB;AAeD;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,OAAO,IAAI;KAC3C,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,GAAG;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,KAAK,OAAO;CACxD,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACrE,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,GACtB,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAgBlB"}
package/lib/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Middleware } from "./middleware";
1
2
  /**
2
3
  * Represents a player connected to the game session.
3
4
  *
@@ -84,6 +85,18 @@ export declare const InternalActionTypes: {
84
85
  * Used by both `GameHostProvider` and `useGameClient` to define game logic.
85
86
  */
86
87
  export type GameReducer<S extends IGameState, A extends IAction> = (state: S, action: A) => S;
88
+ /** Options for `createGameReducer`. */
89
+ export interface CreateGameReducerOptions<S extends IGameState, A extends IAction> {
90
+ /**
91
+ * Optional middleware stack. Middlewares are applied in order — the first
92
+ * middleware in the array is the outermost layer and sees every action first.
93
+ *
94
+ * Middleware can observe **and** transform both user actions and internal
95
+ * actions. Custom middleware should avoid blocking internal actions (types
96
+ * prefixed with `__`) to prevent breaking framework behaviour.
97
+ */
98
+ middleware?: Middleware<S, A>[];
99
+ }
87
100
  /**
88
101
  * Wraps a user-provided reducer with automatic handling of internal actions:
89
102
  *
@@ -95,6 +108,10 @@ export type GameReducer<S extends IGameState, A extends IAction> = (state: S, ac
95
108
  *
96
109
  * The wrapped reducer accepts both `A` (user actions) and `InternalAction<S>`.
97
110
  * User reducers only need to handle their own action types.
111
+ *
112
+ * When `options.middleware` is provided, the middleware stack wraps around the
113
+ * entire reducer (including internal-action handling). Middleware can observe
114
+ * all actions flowing through the reducer.
98
115
  */
99
- export declare function createGameReducer<S extends IGameState, A extends IAction>(reducer: GameReducer<S, A>): GameReducer<S, A | InternalAction<S>>;
116
+ export declare function createGameReducer<S extends IGameState, A extends IAction>(reducer: GameReducer<S, A>, options?: CreateGameReducerOptions<S, A>): GameReducer<S, A | InternalAction<S>>;
100
117
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU,IACxD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,GACnC;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxD,GACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAElE,+CAA+C;AAC/C,eAAO,MAAM,mBAAmB;;;;;;CAMtB,CAAC;AAEX;;;;;GAKG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,IAAI,CACjE,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,CAAC,KACN,CAAC,CAAC;AAEP;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACvE,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,GACzB,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC,CAuEvC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU,IACxD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,GACnC;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxD,GACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAElE,+CAA+C;AAC/C,eAAO,MAAM,mBAAmB;;;;;;CAMtB,CAAC;AAEX;;;;;GAKG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,IAAI,CACjE,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,CAAC,KACN,CAAC,CAAC;AAEP,uCAAuC;AACvC,MAAM,WAAW,wBAAwB,CACvC,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO;IAEjB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;CACjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACvE,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,EAC1B,OAAO,CAAC,EAAE,wBAAwB,CAAC,CAAC,EAAE,CAAC,CAAC,GACvC,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC,CAgFvC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/core",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
package/src/constants.ts CHANGED
@@ -6,7 +6,7 @@ export const DEFAULT_HTTP_PORT = 8080;
6
6
  /** Default WebSocket port offset from the HTTP port (skips Metro on +1). */
7
7
  export const DEFAULT_WS_PORT_OFFSET = 2;
8
8
 
9
- /** Maximum allowed WebSocket frame payload size (1 MB). */
9
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
10
10
  export const MAX_FRAME_SIZE = 1024 * 1024;
11
11
 
12
12
  /** Default maximum reconnection attempts for the client. */
@@ -24,10 +24,13 @@ export const DEFAULT_SYNC_INTERVAL = 5000;
24
24
  /** Maximum number of outstanding pings before cleanup. */
25
25
  export const MAX_PENDING_PINGS = 50;
26
26
 
27
- /** Server-side keepalive ping interval (ms). */
27
+ /** Default timeout (ms) before a disconnected player is removed from state. */
28
+ export const DEFAULT_DISCONNECT_TIMEOUT = 5 * 60 * 1000;
29
+
30
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
28
31
  export const KEEPALIVE_INTERVAL = 30000;
29
32
 
30
- /** Keepalive timeout -- disconnect if no pong received (ms). */
33
+ /** @deprecated Not used by Couch Kit internals. Will be removed in the next major version. */
31
34
  export const KEEPALIVE_TIMEOUT = 10000;
32
35
 
33
36
  /**
@@ -71,13 +74,25 @@ export function isValidSecret(secret: string): boolean {
71
74
  }
72
75
 
73
76
  /**
74
- * Derives a stable, public player ID from a secret UUID.
77
+ * Derives a stable, public player ID from a secret UUID using SHA-256.
75
78
  *
76
- * Strips dashes and takes the first 16 hex characters. This is NOT a
77
- * cryptographic hash — it simply avoids exposing the raw secret in
78
- * state that gets broadcast to all clients.
79
+ * Takes the first 16 hex characters of the SHA-256 hash, producing
80
+ * a deterministic ID that cannot be reversed to recover the secret.
81
+ */
82
+ export async function derivePlayerId(secret: string): Promise<string> {
83
+ const data = new TextEncoder().encode(secret);
84
+ const hash = await crypto.subtle.digest("SHA-256", data);
85
+ return Array.from(new Uint8Array(hash))
86
+ .map((b) => b.toString(16).padStart(2, "0"))
87
+ .join("")
88
+ .slice(0, 16);
89
+ }
90
+
91
+ /**
92
+ * @deprecated Legacy player ID derivation. Insecure — exposes first 16 hex chars of secret.
93
+ * Kept temporarily for dual-derivation migration in the host. Will be removed in next major.
79
94
  */
80
- export function derivePlayerId(secret: string): string {
95
+ export function derivePlayerIdLegacy(secret: string): string {
81
96
  return secret.replace(/-/g, "").slice(0, 16);
82
97
  }
83
98
 
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./types";
2
2
  export * from "./protocol";
3
3
  export * from "./constants";
4
+ export * from "./middleware";
@@ -0,0 +1,204 @@
1
+ import type { IGameState, IAction, InternalAction, GameReducer } from "./types";
2
+ import { InternalActionTypes } from "./types";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Middleware types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** The API surface available to each middleware. */
9
+ export interface MiddlewareAPI<S extends IGameState> {
10
+ getState: () => S;
11
+ }
12
+
13
+ /** A dispatch function that accepts user actions or internal actions. */
14
+ export type MiddlewareDispatch<S extends IGameState, A extends IAction> = (
15
+ action: A | InternalAction<S>,
16
+ ) => S;
17
+
18
+ /**
19
+ * Redux-style middleware — a three-layer curried function:
20
+ *
21
+ * 1. Receives the middleware API (`getState`).
22
+ * 2. Receives `next` (the downstream dispatch).
23
+ * 3. Receives the `action` and returns the new state.
24
+ */
25
+ export type Middleware<
26
+ S extends IGameState = IGameState,
27
+ A extends IAction = IAction,
28
+ > = (
29
+ api: MiddlewareAPI<S>,
30
+ ) => (next: MiddlewareDispatch<S, A>) => (action: A | InternalAction<S>) => S;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // applyMiddleware
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Composes an array of middlewares into a higher-order function that wraps a
38
+ * game reducer.
39
+ *
40
+ * Middleware ordering follows the Redux convention: the **first** middleware in
41
+ * the array is the outermost layer — it sees the action first on the way in and
42
+ * last on the way out.
43
+ *
44
+ * Each middleware layer is wrapped in a try / catch. If a middleware throws, the
45
+ * error is logged and the action falls through to the next layer so a single
46
+ * broken middleware never tears down the entire dispatch chain. If the middleware
47
+ * already called `next` before throwing, the error boundary returns the current
48
+ * state instead of re-dispatching to prevent double-dispatch.
49
+ */
50
+ export function applyMiddleware<S extends IGameState, A extends IAction>(
51
+ ...middlewares: Middleware<S, A>[]
52
+ ): (
53
+ reducer: GameReducer<S, A | InternalAction<S>>,
54
+ ) => GameReducer<S, A | InternalAction<S>> {
55
+ return (reducer: GameReducer<S, A | InternalAction<S>>) => {
56
+ // Mutable ref so `getState()` always returns the latest state.
57
+ let currentState: S;
58
+
59
+ const api: MiddlewareAPI<S> = {
60
+ getState: () => currentState,
61
+ };
62
+
63
+ // The innermost dispatch simply runs the actual reducer and updates the
64
+ // mutable ref.
65
+ const baseDispatch: MiddlewareDispatch<S, A> = (action) => {
66
+ currentState = reducer(currentState, action);
67
+ return currentState;
68
+ };
69
+
70
+ // Build the chain by initialising each middleware with the API, then
71
+ // composing right-to-left so the first middleware is outermost.
72
+ const chain = middlewares.map((mw, index) => {
73
+ const layer = mw(api);
74
+ // Return a wrapper that adds an error boundary around each layer.
75
+ // We call `layer(safeNext)` inside the action handler so we can track
76
+ // whether `next` was already called — preventing double-dispatch.
77
+ return (next: MiddlewareDispatch<S, A>): MiddlewareDispatch<S, A> => {
78
+ return (action: A | InternalAction<S>): S => {
79
+ let nextCalled = false;
80
+ const safeNext: MiddlewareDispatch<S, A> = (a) => {
81
+ nextCalled = true;
82
+ return next(a);
83
+ };
84
+ const safeHandler = layer(safeNext);
85
+ try {
86
+ return safeHandler(action);
87
+ } catch (error) {
88
+ console.error(
89
+ `[couch-kit] Middleware ${index} threw on action "${action.type}":`,
90
+ error,
91
+ );
92
+ return nextCalled ? api.getState() : next(action);
93
+ }
94
+ };
95
+ };
96
+ });
97
+
98
+ // Compose right-to-left: last middleware wraps baseDispatch first.
99
+ const dispatch = chain.reduceRight<MiddlewareDispatch<S, A>>(
100
+ (next, layer) => layer(next),
101
+ baseDispatch,
102
+ );
103
+
104
+ // Return a reducer with the same signature that threads through the chain.
105
+ return (state: S, action: A | InternalAction<S>): S => {
106
+ currentState = state;
107
+ return dispatch(action);
108
+ };
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // actionLogger
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** Options for the built-in action logger middleware. */
117
+ export interface ActionLoggerOptions {
118
+ /** Use `console.groupCollapsed` instead of `console.group`. Defaults to `true`. */
119
+ collapsed?: boolean;
120
+ }
121
+
122
+ /**
123
+ * Middleware that logs every dispatched action together with the previous and
124
+ * next state.
125
+ *
126
+ * ```ts
127
+ * createGameReducer(reducer, { middleware: [actionLogger()] });
128
+ * ```
129
+ */
130
+ export function actionLogger<S extends IGameState, A extends IAction>(
131
+ options?: ActionLoggerOptions,
132
+ ): Middleware<S, A> {
133
+ const collapsed = options?.collapsed ?? true;
134
+
135
+ return (api) => (next) => (action) => {
136
+ const prevState = api.getState();
137
+ const group = collapsed ? console.groupCollapsed : console.group;
138
+
139
+ group(`[couch-kit] action ${action.type}`);
140
+ console.log("prev state", prevState);
141
+ console.log("action", action);
142
+
143
+ try {
144
+ const nextState = next(action);
145
+ console.log("next state", nextState);
146
+ return nextState;
147
+ } finally {
148
+ console.groupEnd();
149
+ }
150
+ };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // actionValidator
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Lazily-initialised set of internal action type strings for fast lookup. */
158
+ let _internalTypes: Set<string> | undefined;
159
+ function getInternalTypes(): Set<string> {
160
+ if (!_internalTypes) {
161
+ _internalTypes = new Set<string>(Object.values(InternalActionTypes));
162
+ }
163
+ return _internalTypes;
164
+ }
165
+
166
+ /**
167
+ * A map from action type strings to validator functions. Each validator receives
168
+ * the fully-typed action and returns `true` if the action is valid.
169
+ */
170
+ export type ActionSchema<A extends IAction> = {
171
+ [K in A["type"]]?: (action: A & { type: K }) => boolean;
172
+ };
173
+
174
+ /**
175
+ * Middleware that validates actions against a schema before they reach the
176
+ * reducer. Invalid actions are silently dropped (with a console warning).
177
+ *
178
+ * Internal actions are never validated — they always pass through.
179
+ *
180
+ * ```ts
181
+ * const validator = actionValidator<GameState, GameAction>({
182
+ * SCORE: (action) => action.payload > 0,
183
+ * });
184
+ * ```
185
+ */
186
+ export function actionValidator<S extends IGameState, A extends IAction>(
187
+ schema: ActionSchema<A>,
188
+ ): Middleware<S, A> {
189
+ return (_api) => (next) => (action) => {
190
+ // Internal actions always pass through — never validate them.
191
+ if (getInternalTypes().has(action.type)) return next(action);
192
+
193
+ const validate = schema[action.type as A["type"]];
194
+
195
+ if (validate && !validate(action as A & { type: A["type"] })) {
196
+ console.warn(
197
+ `[couch-kit] Action "${action.type}" failed validation, skipping`,
198
+ );
199
+ return _api.getState();
200
+ }
201
+
202
+ return next(action);
203
+ };
204
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { applyMiddleware } from "./middleware";
2
+ import type { Middleware } from "./middleware";
3
+
1
4
  /**
2
5
  * Represents a player connected to the game session.
3
6
  *
@@ -76,6 +79,22 @@ export type GameReducer<S extends IGameState, A extends IAction> = (
76
79
  action: A,
77
80
  ) => S;
78
81
 
82
+ /** Options for `createGameReducer`. */
83
+ export interface CreateGameReducerOptions<
84
+ S extends IGameState,
85
+ A extends IAction,
86
+ > {
87
+ /**
88
+ * Optional middleware stack. Middlewares are applied in order — the first
89
+ * middleware in the array is the outermost layer and sees every action first.
90
+ *
91
+ * Middleware can observe **and** transform both user actions and internal
92
+ * actions. Custom middleware should avoid blocking internal actions (types
93
+ * prefixed with `__`) to prevent breaking framework behaviour.
94
+ */
95
+ middleware?: Middleware<S, A>[];
96
+ }
97
+
79
98
  /**
80
99
  * Wraps a user-provided reducer with automatic handling of internal actions:
81
100
  *
@@ -87,11 +106,19 @@ export type GameReducer<S extends IGameState, A extends IAction> = (
87
106
  *
88
107
  * The wrapped reducer accepts both `A` (user actions) and `InternalAction<S>`.
89
108
  * User reducers only need to handle their own action types.
109
+ *
110
+ * When `options.middleware` is provided, the middleware stack wraps around the
111
+ * entire reducer (including internal-action handling). Middleware can observe
112
+ * all actions flowing through the reducer.
90
113
  */
91
114
  export function createGameReducer<S extends IGameState, A extends IAction>(
92
115
  reducer: GameReducer<S, A>,
116
+ options?: CreateGameReducerOptions<S, A>,
93
117
  ): GameReducer<S, A | InternalAction<S>> {
94
- return (state: S, action: A | InternalAction<S>): S => {
118
+ const baseReducer: GameReducer<S, A | InternalAction<S>> = (
119
+ state: S,
120
+ action: A | InternalAction<S>,
121
+ ): S => {
95
122
  switch (action.type) {
96
123
  case InternalActionTypes.HYDRATE:
97
124
  return (action as InternalAction<S> & { type: "__HYDRATE__" }).payload;
@@ -161,4 +188,10 @@ export function createGameReducer<S extends IGameState, A extends IAction>(
161
188
  return reducer(state, action as A);
162
189
  }
163
190
  };
191
+
192
+ const middlewares = options?.middleware;
193
+
194
+ if (!middlewares || middlewares.length === 0) return baseReducer;
195
+
196
+ return applyMiddleware<S, A>(...middlewares)(baseReducer);
164
197
  }