@couch-kit/core 0.6.0 → 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,11 @@
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
+
3
9
  ## 0.6.0
4
10
 
5
11
  ### Minor 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 = {
@@ -129,6 +207,9 @@ export {
129
207
  derivePlayerIdLegacy,
130
208
  derivePlayerId,
131
209
  createGameReducer,
210
+ applyMiddleware,
211
+ actionValidator,
212
+ actionLogger,
132
213
  MessageTypes,
133
214
  MAX_PENDING_PINGS,
134
215
  MAX_FRAME_SIZE,
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.6.0",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
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
  }