@couch-kit/host 1.7.11 → 1.7.13
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/lib/action-authorization.d.ts +28 -0
- package/lib/action-authorization.d.ts.map +1 -0
- package/lib/message-validation.d.ts +15 -0
- package/lib/message-validation.d.ts.map +1 -1
- package/lib/provider.d.ts +5 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/websocket.d.ts +8 -0
- package/lib/websocket.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/action-authorization.ts +55 -0
- package/src/message-validation.ts +36 -0
- package/src/provider.tsx +24 -20
- package/src/websocket.ts +28 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Error codes surfaced when a client ACTION is rejected before dispatch. */
|
|
2
|
+
export type ActionRejectionCode = "FORBIDDEN_ACTION" | "NOT_JOINED";
|
|
3
|
+
/** Outcome of authorizing an inbound client ACTION message. */
|
|
4
|
+
export type ActionAuthorization = {
|
|
5
|
+
kind: "allow";
|
|
6
|
+
playerId: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "reject";
|
|
9
|
+
code: ActionRejectionCode;
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Decide whether an inbound client ACTION may be dispatched.
|
|
14
|
+
*
|
|
15
|
+
* Rejects, in order:
|
|
16
|
+
* - **Internal action types** — clients must not be able to inject framework
|
|
17
|
+
* actions (`__HYDRATE__`, `__PLAYER_JOINED__`, etc.) to forge state.
|
|
18
|
+
* - **Un-joined sockets** — a socket with no resolved player ID never completed
|
|
19
|
+
* a JOIN, so its actions have no owner and must not mutate state.
|
|
20
|
+
*
|
|
21
|
+
* The decision is pure so it can be unit-tested without a WebSocket or React.
|
|
22
|
+
*
|
|
23
|
+
* @param actionType - The `type` field of the client's action payload.
|
|
24
|
+
* @param resolvedPlayerId - The player ID cached for the socket at JOIN time, or
|
|
25
|
+
* `undefined` if the socket has not joined.
|
|
26
|
+
*/
|
|
27
|
+
export declare function authorizeClientAction(actionType: string, resolvedPlayerId: string | undefined): ActionAuthorization;
|
|
28
|
+
//# sourceMappingURL=action-authorization.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"action-authorization.d.ts","sourceRoot":"","sources":["../src/action-authorization.ts"],"names":[],"mappings":"AAEA,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,kBAAkB,GAAG,YAAY,CAAC;AAEpE,+DAA+D;AAC/D,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,mBAAmB,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,EAClB,gBAAgB,EAAE,MAAM,GAAG,SAAS,GACnC,mBAAmB,CAkBrB"}
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { MessageTypes, type ClientMessage } from "@couch-kit/core";
|
|
2
|
+
/**
|
|
3
|
+
* Default maximum size (in bytes) of an inbound client message. Frames larger
|
|
4
|
+
* than this are discarded before `JSON.parse`, bounding the memory a single
|
|
5
|
+
* malicious LAN client can force the host to allocate. Generous enough for
|
|
6
|
+
* data-URI avatars in JOIN payloads while blocking multi-megabyte abuse.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_MAX_MESSAGE_BYTES: number;
|
|
9
|
+
/**
|
|
10
|
+
* Compute the UTF-8 byte length of a raw WebSocket frame payload.
|
|
11
|
+
*
|
|
12
|
+
* For binary frames the `ArrayBuffer.byteLength` is exact. For text frames the
|
|
13
|
+
* UTF-8 size is counted directly from the JS string without allocating an
|
|
14
|
+
* encoder buffer, so an oversized frame can be rejected cheaply.
|
|
15
|
+
*/
|
|
16
|
+
export declare function frameByteLength(data: string | ArrayBuffer): number;
|
|
2
17
|
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<ClientMessage, {
|
|
3
18
|
type: TType;
|
|
4
19
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,CA8B/B"}
|
|
1
|
+
{"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,QAAa,CAAC;AAEpD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,MAAM,CAmBlE;AAED,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,CA8B/B"}
|
package/lib/provider.d.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
13
13
|
disconnectTimeout?: number;
|
|
14
14
|
/** State broadcast throttle interval in milliseconds (default: 33ms, ~30fps). */
|
|
15
15
|
stateThrottleMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Maximum size (in bytes) of an inbound client message. Frames larger than
|
|
18
|
+
* this are discarded before parsing (default: 256 KiB).
|
|
19
|
+
*/
|
|
20
|
+
maxMessageBytes?: number;
|
|
16
21
|
/** Called when a player successfully joins. */
|
|
17
22
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
18
23
|
/** Called when a player disconnects. */
|
package/lib/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AAczB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AAQD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAyWA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
|
package/lib/websocket.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ import { EventEmitter } from "./event-emitter";
|
|
|
8
8
|
export interface WebSocketConfig {
|
|
9
9
|
port: number;
|
|
10
10
|
debug?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum size (in bytes) of an inbound client message. Frames larger than
|
|
13
|
+
* this are discarded before parsing, bounding the memory a single client can
|
|
14
|
+
* force the host to allocate. Defaults to {@link DEFAULT_MAX_MESSAGE_BYTES}
|
|
15
|
+
* (256 KiB).
|
|
16
|
+
*/
|
|
17
|
+
maxMessageBytes?: number;
|
|
11
18
|
/**
|
|
12
19
|
* @deprecated No effect. The underlying nitro-http WebSocket transport
|
|
13
20
|
* manages frame sizing internally and this value is never read. This field
|
|
@@ -41,6 +48,7 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
|
|
|
41
48
|
private clients;
|
|
42
49
|
private port;
|
|
43
50
|
private debug;
|
|
51
|
+
private maxMessageBytes;
|
|
44
52
|
constructor(config: WebSocketConfig);
|
|
45
53
|
private log;
|
|
46
54
|
start(): Promise<void>;
|
package/lib/websocket.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,eAAe,CAAS;gBAEpB,MAAM,EAAE,eAAe;IAYnC,OAAO,CAAC,GAAG;IAME,KAAK;IAuGL,IAAI;IAoBjB;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAsB3C;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM;IAoBlD,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { InternalActionTypes } from "@couch-kit/core";
|
|
2
|
+
|
|
3
|
+
/** Error codes surfaced when a client ACTION is rejected before dispatch. */
|
|
4
|
+
export type ActionRejectionCode = "FORBIDDEN_ACTION" | "NOT_JOINED";
|
|
5
|
+
|
|
6
|
+
/** Outcome of authorizing an inbound client ACTION message. */
|
|
7
|
+
export type ActionAuthorization =
|
|
8
|
+
| { kind: "allow"; playerId: string }
|
|
9
|
+
| { kind: "reject"; code: ActionRejectionCode; message: string };
|
|
10
|
+
|
|
11
|
+
const INTERNAL_ACTION_TYPES = new Set<string>([
|
|
12
|
+
InternalActionTypes.HYDRATE,
|
|
13
|
+
InternalActionTypes.PLAYER_JOINED,
|
|
14
|
+
InternalActionTypes.PLAYER_LEFT,
|
|
15
|
+
InternalActionTypes.PLAYER_RECONNECTED,
|
|
16
|
+
InternalActionTypes.PLAYER_REMOVED,
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether an inbound client ACTION may be dispatched.
|
|
21
|
+
*
|
|
22
|
+
* Rejects, in order:
|
|
23
|
+
* - **Internal action types** — clients must not be able to inject framework
|
|
24
|
+
* actions (`__HYDRATE__`, `__PLAYER_JOINED__`, etc.) to forge state.
|
|
25
|
+
* - **Un-joined sockets** — a socket with no resolved player ID never completed
|
|
26
|
+
* a JOIN, so its actions have no owner and must not mutate state.
|
|
27
|
+
*
|
|
28
|
+
* The decision is pure so it can be unit-tested without a WebSocket or React.
|
|
29
|
+
*
|
|
30
|
+
* @param actionType - The `type` field of the client's action payload.
|
|
31
|
+
* @param resolvedPlayerId - The player ID cached for the socket at JOIN time, or
|
|
32
|
+
* `undefined` if the socket has not joined.
|
|
33
|
+
*/
|
|
34
|
+
export function authorizeClientAction(
|
|
35
|
+
actionType: string,
|
|
36
|
+
resolvedPlayerId: string | undefined,
|
|
37
|
+
): ActionAuthorization {
|
|
38
|
+
if (INTERNAL_ACTION_TYPES.has(actionType)) {
|
|
39
|
+
return {
|
|
40
|
+
kind: "reject",
|
|
41
|
+
code: "FORBIDDEN_ACTION",
|
|
42
|
+
message: "Internal action types cannot be dispatched by clients",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!resolvedPlayerId) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "reject",
|
|
49
|
+
code: "NOT_JOINED",
|
|
50
|
+
message: "You must JOIN before dispatching actions",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { kind: "allow", playerId: resolvedPlayerId };
|
|
55
|
+
}
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import { MessageTypes, type ClientMessage } from "@couch-kit/core";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Default maximum size (in bytes) of an inbound client message. Frames larger
|
|
5
|
+
* than this are discarded before `JSON.parse`, bounding the memory a single
|
|
6
|
+
* malicious LAN client can force the host to allocate. Generous enough for
|
|
7
|
+
* data-URI avatars in JOIN payloads while blocking multi-megabyte abuse.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_MAX_MESSAGE_BYTES = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute the UTF-8 byte length of a raw WebSocket frame payload.
|
|
13
|
+
*
|
|
14
|
+
* For binary frames the `ArrayBuffer.byteLength` is exact. For text frames the
|
|
15
|
+
* UTF-8 size is counted directly from the JS string without allocating an
|
|
16
|
+
* encoder buffer, so an oversized frame can be rejected cheaply.
|
|
17
|
+
*/
|
|
18
|
+
export function frameByteLength(data: string | ArrayBuffer): number {
|
|
19
|
+
if (typeof data !== "string") return data.byteLength;
|
|
20
|
+
|
|
21
|
+
let bytes = 0;
|
|
22
|
+
for (let i = 0; i < data.length; i++) {
|
|
23
|
+
const code = data.charCodeAt(i);
|
|
24
|
+
if (code < 0x80) {
|
|
25
|
+
bytes += 1;
|
|
26
|
+
} else if (code < 0x800) {
|
|
27
|
+
bytes += 2;
|
|
28
|
+
} else if (code >= 0xd800 && code <= 0xdbff) {
|
|
29
|
+
// High surrogate: a full pair encodes to 4 UTF-8 bytes.
|
|
30
|
+
bytes += 4;
|
|
31
|
+
i++; // Skip the paired low surrogate.
|
|
32
|
+
} else {
|
|
33
|
+
bytes += 3;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
3
39
|
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<
|
|
4
40
|
ClientMessage,
|
|
5
41
|
{ type: TType }
|
package/src/provider.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
HostSessionManager,
|
|
28
28
|
type JoinSessionPayload,
|
|
29
29
|
} from "./session-manager";
|
|
30
|
+
import { authorizeClientAction } from "./action-authorization";
|
|
30
31
|
import {
|
|
31
32
|
BroadcastScheduler,
|
|
32
33
|
DEFAULT_STATE_THROTTLE_MS,
|
|
@@ -46,6 +47,11 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
46
47
|
disconnectTimeout?: number;
|
|
47
48
|
/** State broadcast throttle interval in milliseconds (default: 33ms, ~30fps). */
|
|
48
49
|
stateThrottleMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Maximum size (in bytes) of an inbound client message. Frames larger than
|
|
52
|
+
* this are discarded before parsing (default: 256 KiB).
|
|
53
|
+
*/
|
|
54
|
+
maxMessageBytes?: number;
|
|
49
55
|
/** Called when a player successfully joins. */
|
|
50
56
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
51
57
|
/** Called when a player disconnects. */
|
|
@@ -186,7 +192,11 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
186
192
|
|
|
187
193
|
useEffect(() => {
|
|
188
194
|
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
189
|
-
const server = new GameWebSocketServer({
|
|
195
|
+
const server = new GameWebSocketServer({
|
|
196
|
+
port,
|
|
197
|
+
debug: config.debug,
|
|
198
|
+
maxMessageBytes: config.maxMessageBytes,
|
|
199
|
+
});
|
|
190
200
|
|
|
191
201
|
// Start the WebSocket server asynchronously
|
|
192
202
|
server.start().catch((error) => {
|
|
@@ -283,28 +293,25 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
283
293
|
}
|
|
284
294
|
|
|
285
295
|
case MessageTypes.ACTION: {
|
|
286
|
-
// Only accept actions with a user-defined type string,
|
|
287
|
-
// reject internal action types to prevent injection.
|
|
288
296
|
const actionPayload = message.payload as A;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
297
|
+
|
|
298
|
+
// Authorize before doing any work: reject client-injected internal
|
|
299
|
+
// action types and actions from sockets that never completed a JOIN.
|
|
300
|
+
const resolvedPlayerId =
|
|
301
|
+
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
302
|
+
const auth = authorizeClientAction(
|
|
303
|
+
actionPayload.type,
|
|
304
|
+
resolvedPlayerId,
|
|
305
|
+
);
|
|
306
|
+
if (auth.kind === "reject") {
|
|
296
307
|
if (configRef.current.debug)
|
|
297
308
|
console.warn(
|
|
298
|
-
`[GameHost] Rejected
|
|
309
|
+
`[GameHost] Rejected action from ${socketId} (${auth.code}):`,
|
|
299
310
|
actionPayload.type,
|
|
300
311
|
);
|
|
301
312
|
server.send(socketId, {
|
|
302
313
|
type: MessageTypes.ERROR,
|
|
303
|
-
payload: {
|
|
304
|
-
code: "FORBIDDEN_ACTION",
|
|
305
|
-
message:
|
|
306
|
-
"Internal action types cannot be dispatched by clients",
|
|
307
|
-
},
|
|
314
|
+
payload: { code: auth.code, message: auth.message },
|
|
308
315
|
});
|
|
309
316
|
return;
|
|
310
317
|
}
|
|
@@ -323,10 +330,7 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
323
330
|
return;
|
|
324
331
|
}
|
|
325
332
|
|
|
326
|
-
|
|
327
|
-
const resolvedPlayerId =
|
|
328
|
-
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
329
|
-
dispatch({ ...actionPayload, playerId: resolvedPlayerId });
|
|
333
|
+
dispatch({ ...actionPayload, playerId: auth.playerId });
|
|
330
334
|
actionQueue.current.push(actionPayload);
|
|
331
335
|
break;
|
|
332
336
|
}
|
package/src/websocket.ts
CHANGED
|
@@ -12,10 +12,21 @@ import type {
|
|
|
12
12
|
} from "react-native-nitro-http-server";
|
|
13
13
|
import { EventEmitter } from "./event-emitter";
|
|
14
14
|
import { generateId, DEFAULT_WS_PATH } from "@couch-kit/core";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
17
|
+
frameByteLength,
|
|
18
|
+
} from "./message-validation";
|
|
15
19
|
|
|
16
20
|
export interface WebSocketConfig {
|
|
17
21
|
port: number;
|
|
18
22
|
debug?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Maximum size (in bytes) of an inbound client message. Frames larger than
|
|
25
|
+
* this are discarded before parsing, bounding the memory a single client can
|
|
26
|
+
* force the host to allocate. Defaults to {@link DEFAULT_MAX_MESSAGE_BYTES}
|
|
27
|
+
* (256 KiB).
|
|
28
|
+
*/
|
|
29
|
+
maxMessageBytes?: number;
|
|
19
30
|
/**
|
|
20
31
|
* @deprecated No effect. The underlying nitro-http WebSocket transport
|
|
21
32
|
* manages frame sizing internally and this value is never read. This field
|
|
@@ -56,15 +67,18 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
56
67
|
private clients: Map<string, WebSocketClient> = new Map();
|
|
57
68
|
private port: number;
|
|
58
69
|
private debug: boolean;
|
|
70
|
+
private maxMessageBytes: number;
|
|
59
71
|
|
|
60
72
|
constructor(config: WebSocketConfig) {
|
|
61
73
|
super();
|
|
62
74
|
this.port = config.port;
|
|
63
75
|
this.debug = !!config.debug;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
76
|
+
this.maxMessageBytes =
|
|
77
|
+
config.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
|
|
78
|
+
// Only `port`, `debug`, and `maxMessageBytes` are honored. `maxFrameSize`,
|
|
79
|
+
// `keepaliveInterval`, and `keepaliveTimeout` are deprecated no-ops: the
|
|
80
|
+
// nitro-http WebSocket transport does not expose these knobs, so they are
|
|
81
|
+
// intentionally ignored (see the @deprecated tags on WebSocketConfig).
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
private log(...args: unknown[]) {
|
|
@@ -93,6 +107,16 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
93
107
|
|
|
94
108
|
// Handle incoming messages
|
|
95
109
|
ws.onmessage = (event: { data: string | ArrayBuffer }) => {
|
|
110
|
+
// Reject oversized frames before parsing to bound memory usage.
|
|
111
|
+
const size = frameByteLength(event.data);
|
|
112
|
+
if (size > this.maxMessageBytes) {
|
|
113
|
+
this.log(
|
|
114
|
+
`[WebSocket] Message from ${socketId} exceeds limit ` +
|
|
115
|
+
`(${size} > ${this.maxMessageBytes} bytes), discarding`,
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
try {
|
|
97
121
|
const data =
|
|
98
122
|
typeof event.data === "string"
|