@couch-kit/runtime 0.0.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.
@@ -0,0 +1,32 @@
1
+ /** Maximum actions per rate-limit window. */
2
+ export declare const RATE_LIMIT_MAX = 60;
3
+ /** Rate-limit window duration (ms). */
4
+ export declare const RATE_LIMIT_WINDOW = 1000;
5
+ export interface RateLimitInfo {
6
+ count: number;
7
+ windowStart: number;
8
+ }
9
+ export interface RateLimitResult extends RateLimitInfo {
10
+ allowed: boolean;
11
+ }
12
+ export interface ActionRateLimiterOptions {
13
+ maxActions?: number;
14
+ windowMs?: number;
15
+ now?: () => number;
16
+ }
17
+ /**
18
+ * Per-connection action limiter that preserves the host provider's original
19
+ * fixed-window algorithm.
20
+ */
21
+ export declare class ActionRateLimiter {
22
+ private readonly maxActions;
23
+ private readonly windowMs;
24
+ private readonly now;
25
+ private readonly limits;
26
+ constructor(options?: ActionRateLimiterOptions);
27
+ record(socketId: string): RateLimitResult;
28
+ reset(socketId: string): void;
29
+ clear(): void;
30
+ get(socketId: string): RateLimitInfo | undefined;
31
+ }
32
+ //# sourceMappingURL=rate-limiter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C,eAAO,MAAM,cAAc,KAAK,CAAC;AAEjC,uCAAuC;AACvC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,wBAAwB;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;gBAE/C,OAAO,GAAE,wBAA6B;IAMlD,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe;IAiBzC,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAI7B,KAAK,IAAI,IAAI;IAIb,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;CAGjD"}
@@ -0,0 +1,68 @@
1
+ import { type GameReducer, type HostMessage, type IAction, type IGameState } from "@couch-kit/core";
2
+ /** Minimal message-delivery surface required by the authoritative runtime. */
3
+ export interface GameRuntimeTransport {
4
+ send(connectionId: string, message: HostMessage): void;
5
+ broadcast(message: HostMessage): void;
6
+ }
7
+ /** Configuration shared by every authoritative Couch Kit host transport. */
8
+ export interface GameHostRuntimeConfig<S extends IGameState, A extends IAction> {
9
+ initialState: S;
10
+ reducer: GameReducer<S, A>;
11
+ debug?: boolean;
12
+ /** Timeout before a disconnected player is permanently removed. */
13
+ disconnectTimeout?: number;
14
+ /** Minimum interval between full-state broadcasts. */
15
+ stateThrottleMs?: number;
16
+ onPlayerJoined?: (playerId: string, name: string) => void;
17
+ onPlayerLeft?: (playerId: string) => void;
18
+ onError?: (error: Error) => void;
19
+ }
20
+ /**
21
+ * Owns the canonical game state and protocol lifecycle independently of React
22
+ * Native, WebSockets, or any future WebRTC transport.
23
+ */
24
+ export declare class GameHostRuntime<S extends IGameState, A extends IAction> {
25
+ private config;
26
+ private readonly reducer;
27
+ private state;
28
+ private transport;
29
+ private readonly listeners;
30
+ private readonly sessionManager;
31
+ private readonly rateLimiter;
32
+ private readonly broadcastScheduler;
33
+ private readonly assetsLoaded;
34
+ private readonly activeConnections;
35
+ private readonly joinedConnections;
36
+ private readonly pendingJoins;
37
+ private actionQueue;
38
+ private stateDirty;
39
+ private nextConnectionGeneration;
40
+ constructor(config: GameHostRuntimeConfig<S, A>, transport?: GameRuntimeTransport | null);
41
+ /** Returns the current canonical state snapshot. */
42
+ readonly getState: () => S;
43
+ /** Subscribes to canonical state changes. */
44
+ readonly subscribe: (listener: () => void) => (() => void);
45
+ /** Replaces the active message transport without resetting game state. */
46
+ setTransport(transport: GameRuntimeTransport | null): void;
47
+ /** Updates mutable callbacks and timing options while preserving state. */
48
+ updateConfig(config: GameHostRuntimeConfig<S, A>): void;
49
+ /** Dispatches a trusted action originating from the host display. */
50
+ readonly dispatch: (action: A) => void;
51
+ /** Records a newly opened transport connection for optional debug logging. */
52
+ handleConnection(connectionId: string): void;
53
+ /** Validates and processes one parsed client protocol message. */
54
+ handleMessage(connectionId: string, rawMessage: unknown): Promise<void>;
55
+ /** Processes a transport disconnect and starts session recovery cleanup. */
56
+ handleDisconnect(connectionId: string): void;
57
+ /** Surfaces transport failures through the configured host callback. */
58
+ handleError(error: Error): void;
59
+ /** Cancels runtime timers and releases transport-specific state. */
60
+ stop(): void;
61
+ private applyAction;
62
+ private readonly broadcastState;
63
+ private send;
64
+ private invokeLifecycleCallback;
65
+ private notifyError;
66
+ private log;
67
+ }
68
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import { type IGameState, type InternalAction } from "@couch-kit/core";
2
+ export interface SessionTimerScheduler<TTimer> {
3
+ setTimeout(callback: () => void, delayMs: number): TTimer;
4
+ clearTimeout(timer: TTimer): void;
5
+ }
6
+ export interface JoinSessionPayload {
7
+ name: string;
8
+ avatar?: string;
9
+ secret: string;
10
+ }
11
+ export interface HostSessionManagerOptions<TTimer> {
12
+ disconnectTimeout?: number;
13
+ getDisconnectTimeout?: () => number;
14
+ scheduler?: SessionTimerScheduler<TTimer>;
15
+ derivePlayerId?: (secret: string) => Promise<string>;
16
+ derivePlayerIdLegacy?: (secret: string) => string;
17
+ }
18
+ export type JoinSessionResult<S extends IGameState> = {
19
+ playerId: string;
20
+ socketId: string;
21
+ secret: string;
22
+ isReconnect: boolean;
23
+ action: InternalAction<S>;
24
+ };
25
+ export type DisconnectSessionResult<S extends IGameState> = {
26
+ kind: "unknown";
27
+ } | {
28
+ kind: "stale";
29
+ playerId: string;
30
+ secret: string;
31
+ } | {
32
+ kind: "left";
33
+ playerId: string;
34
+ secret: string;
35
+ action: InternalAction<S>;
36
+ };
37
+ type PlayersSource<S extends IGameState> = S["players"] | (() => S["players"]);
38
+ /**
39
+ * Tracks transport-neutral player sessions by secret and connection ID,
40
+ * including reconnects and delayed cleanup of disconnected players.
41
+ */
42
+ export declare class HostSessionManager<TTimer = ReturnType<typeof setTimeout>> {
43
+ private readonly sessions;
44
+ private readonly reverseMap;
45
+ private readonly cleanupTimers;
46
+ private readonly socketIdToPlayerId;
47
+ private readonly scheduler;
48
+ private readonly getDisconnectTimeout;
49
+ private readonly derivePlayerIdFn;
50
+ private readonly derivePlayerIdLegacyFn;
51
+ constructor(options?: HostSessionManagerOptions<TTimer>);
52
+ handleJoin<S extends IGameState>(socketId: string, payload: JoinSessionPayload, playersSource: PlayersSource<S>): Promise<JoinSessionResult<S>>;
53
+ handleDisconnect<S extends IGameState>(socketId: string): DisconnectSessionResult<S>;
54
+ scheduleRemoval(playerId: string, secret: string, onRemove: (playerId: string) => void): void;
55
+ cancelRemoval(playerId: string): void;
56
+ clearRemovalTimers(): void;
57
+ getPlayerIdForSocket(socketId: string): string | undefined;
58
+ abandonConnection(socketId: string): void;
59
+ getSocketIdForSecret(secret: string): string | undefined;
60
+ hasPendingRemoval(playerId: string): boolean;
61
+ }
62
+ export {};
63
+ //# sourceMappingURL=session-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@couch-kit/runtime",
3
+ "version": "0.0.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "provenance": true
7
+ },
8
+ "description": "Transport-neutral authoritative game runtime for Couch Kit hosts",
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=18.0.0"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/faluciano/react-native-couch-kit.git",
17
+ "directory": "packages/runtime"
18
+ },
19
+ "homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
20
+ "keywords": [
21
+ "couch-kit",
22
+ "party-game",
23
+ "game-runtime",
24
+ "websocket",
25
+ "webrtc"
26
+ ],
27
+ "main": "./dist/index.js",
28
+ "types": "./lib/index.d.ts",
29
+ "react-native": "./dist/index.js",
30
+ "source": "./src/index.ts",
31
+ "exports": {
32
+ ".": {
33
+ "react-native": {
34
+ "types": "./lib/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "types": "./lib/index.d.ts",
38
+ "import": "./dist/index.js",
39
+ "require": "./dist/index.cjs",
40
+ "default": "./dist/index.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "src",
45
+ "dist",
46
+ "lib",
47
+ "README.md",
48
+ "CHANGELOG.md"
49
+ ],
50
+ "sideEffects": false,
51
+ "scripts": {
52
+ "build": "bun build ./src/index.ts --outdir ./dist --target browser --external @couch-kit/core && bun build ./src/index.ts --outfile ./dist/index.cjs --target browser --format cjs --external @couch-kit/core && tsc -p tsconfig.build.json",
53
+ "prepublishOnly": "bun run build",
54
+ "test": "bun test",
55
+ "lint": "eslint src/",
56
+ "typecheck": "tsc --noEmit",
57
+ "clean": "rm -rf dist lib"
58
+ },
59
+ "dependencies": {
60
+ "@couch-kit/core": "0.9.2"
61
+ },
62
+ "devDependencies": {
63
+ "typescript": "^6.0.0"
64
+ }
65
+ }
@@ -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 by the runtime.
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
+ }
@@ -0,0 +1,83 @@
1
+ import { MessageTypes, type HostMessage } from "@couch-kit/core";
2
+
3
+ /** Default state broadcast throttle (~30fps). */
4
+ export const DEFAULT_STATE_THROTTLE_MS = 33;
5
+
6
+ export type StateUpdateMessage = Extract<
7
+ HostMessage,
8
+ { type: typeof MessageTypes.STATE_UPDATE }
9
+ >;
10
+
11
+ export interface TimerScheduler<TTimer> {
12
+ setTimeout(callback: () => void, delayMs: number): TTimer;
13
+ clearTimeout(timer: TTimer): void;
14
+ }
15
+
16
+ const defaultTimerScheduler: TimerScheduler<ReturnType<typeof setTimeout>> = {
17
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
18
+ clearTimeout: (timer) => clearTimeout(timer),
19
+ };
20
+
21
+ export interface BroadcastSchedulerOptions<TTimer> {
22
+ stateThrottleMs?: number;
23
+ scheduler?: TimerScheduler<TTimer>;
24
+ }
25
+
26
+ /**
27
+ * Debounced state-broadcast scheduler used by the authoritative runtime.
28
+ * Rapid state changes are coalesced into one broadcast after the latest change.
29
+ */
30
+ export class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
31
+ private stateThrottleMs: number;
32
+ private readonly scheduler: TimerScheduler<TTimer>;
33
+ private timer: TTimer | null = null;
34
+
35
+ constructor(options: BroadcastSchedulerOptions<TTimer> = {}) {
36
+ this.stateThrottleMs = options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
37
+ this.scheduler =
38
+ options.scheduler ??
39
+ (defaultTimerScheduler as unknown as TimerScheduler<TTimer>);
40
+ }
41
+
42
+ schedule(callback: () => void): void {
43
+ this.cancel();
44
+ this.timer = this.scheduler.setTimeout(() => {
45
+ this.timer = null;
46
+ callback();
47
+ }, this.stateThrottleMs);
48
+ }
49
+
50
+ cancel(): void {
51
+ if (this.timer) {
52
+ this.scheduler.clearTimeout(this.timer);
53
+ this.timer = null;
54
+ }
55
+ }
56
+
57
+ setStateThrottleMs(stateThrottleMs: number): void {
58
+ this.stateThrottleMs = stateThrottleMs;
59
+ }
60
+
61
+ hasPendingBroadcast(): boolean {
62
+ return this.timer !== null;
63
+ }
64
+ }
65
+
66
+ export function createStateUpdateMessage(
67
+ newState: unknown,
68
+ actions: readonly unknown[],
69
+ timestamp: number = Date.now(),
70
+ ): StateUpdateMessage {
71
+ return {
72
+ type: MessageTypes.STATE_UPDATE,
73
+ payload: {
74
+ newState,
75
+ timestamp,
76
+ ...(actions.length === 1
77
+ ? { action: actions[0] }
78
+ : actions.length > 1
79
+ ? { action: actions }
80
+ : {}),
81
+ },
82
+ };
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./action-authorization.js";
2
+ export * from "./broadcast-scheduler.js";
3
+ export * from "./message-validation.js";
4
+ export * from "./rate-limiter.js";
5
+ export * from "./runtime.js";
6
+ export * from "./session-manager.js";
@@ -0,0 +1,94 @@
1
+ import { MessageTypes, type ClientMessage } from "@couch-kit/core";
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 runtime 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
+
39
+ type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<
40
+ ClientMessage,
41
+ { type: TType }
42
+ >;
43
+
44
+ export type ValidatedClientMessage =
45
+ | {
46
+ type: typeof MessageTypes.JOIN;
47
+ payload: {
48
+ name: string;
49
+ secret?: unknown;
50
+ avatar?: unknown;
51
+ [key: string]: unknown;
52
+ };
53
+ }
54
+ | ClientMessageOf<"ACTION">
55
+ | ClientMessageOf<"PING">
56
+ | ClientMessageOf<"ASSETS_LOADED">;
57
+
58
+ /**
59
+ * Validates that an incoming message has the expected shape.
60
+ * Returns true if the message has a processable client-message shape.
61
+ */
62
+ export function isValidClientMessage(
63
+ msg: unknown,
64
+ ): msg is ValidatedClientMessage {
65
+ if (typeof msg !== "object" || msg === null) return false;
66
+ const m = msg as Record<string, unknown>;
67
+ if (typeof m.type !== "string") return false;
68
+
69
+ switch (m.type) {
70
+ case MessageTypes.JOIN:
71
+ return (
72
+ typeof m.payload === "object" &&
73
+ m.payload !== null &&
74
+ typeof (m.payload as Record<string, unknown>).name === "string"
75
+ );
76
+ case MessageTypes.ACTION:
77
+ return (
78
+ typeof m.payload === "object" &&
79
+ m.payload !== null &&
80
+ typeof (m.payload as Record<string, unknown>).type === "string"
81
+ );
82
+ case MessageTypes.PING:
83
+ return (
84
+ typeof m.payload === "object" &&
85
+ m.payload !== null &&
86
+ typeof (m.payload as Record<string, unknown>).id === "string" &&
87
+ typeof (m.payload as Record<string, unknown>).timestamp === "number"
88
+ );
89
+ case MessageTypes.ASSETS_LOADED:
90
+ return m.payload === true;
91
+ default:
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,66 @@
1
+ /** Maximum actions per rate-limit window. */
2
+ export const RATE_LIMIT_MAX = 60;
3
+
4
+ /** Rate-limit window duration (ms). */
5
+ export const RATE_LIMIT_WINDOW = 1000;
6
+
7
+ export interface RateLimitInfo {
8
+ count: number;
9
+ windowStart: number;
10
+ }
11
+
12
+ export interface RateLimitResult extends RateLimitInfo {
13
+ allowed: boolean;
14
+ }
15
+
16
+ export interface ActionRateLimiterOptions {
17
+ maxActions?: number;
18
+ windowMs?: number;
19
+ now?: () => number;
20
+ }
21
+
22
+ /**
23
+ * Per-connection action limiter that preserves the host provider's original
24
+ * fixed-window algorithm.
25
+ */
26
+ export class ActionRateLimiter {
27
+ private readonly maxActions: number;
28
+ private readonly windowMs: number;
29
+ private readonly now: () => number;
30
+ private readonly limits = new Map<string, RateLimitInfo>();
31
+
32
+ constructor(options: ActionRateLimiterOptions = {}) {
33
+ this.maxActions = options.maxActions ?? RATE_LIMIT_MAX;
34
+ this.windowMs = options.windowMs ?? RATE_LIMIT_WINDOW;
35
+ this.now = options.now ?? Date.now;
36
+ }
37
+
38
+ record(socketId: string): RateLimitResult {
39
+ const now = this.now();
40
+ let rateInfo = this.limits.get(socketId);
41
+
42
+ if (!rateInfo || now - rateInfo.windowStart > this.windowMs) {
43
+ rateInfo = { count: 0, windowStart: now };
44
+ this.limits.set(socketId, rateInfo);
45
+ }
46
+
47
+ rateInfo.count++;
48
+
49
+ return {
50
+ ...rateInfo,
51
+ allowed: rateInfo.count <= this.maxActions,
52
+ };
53
+ }
54
+
55
+ reset(socketId: string): void {
56
+ this.limits.delete(socketId);
57
+ }
58
+
59
+ clear(): void {
60
+ this.limits.clear();
61
+ }
62
+
63
+ get(socketId: string): RateLimitInfo | undefined {
64
+ return this.limits.get(socketId);
65
+ }
66
+ }