@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 +35 -0
- package/README.md +71 -2
- package/dist/index.js +92 -3
- package/lib/constants.d.ts +14 -8
- package/lib/constants.d.ts.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/middleware.d.ts +67 -0
- package/lib/middleware.d.ts.map +1 -0
- package/lib/types.d.ts +18 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +23 -8
- package/src/index.ts +1 -0
- package/src/middleware.ts +204 -0
- package/src/types.ts +34 -1
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 +
|
|
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
|
-
|
|
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
|
};
|
package/lib/constants.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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.
|
package/lib/constants.d.ts.map
CHANGED
|
@@ -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,
|
|
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
package/lib/index.d.ts.map
CHANGED
|
@@ -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
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"
|
|
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
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
}
|