@couch-kit/host 1.7.12 → 1.7.14
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 +1 -27
- package/lib/action-authorization.d.ts.map +1 -1
- package/lib/broadcast-scheduler.d.ts +1 -29
- package/lib/broadcast-scheduler.d.ts.map +1 -1
- package/lib/message-validation.d.ts +1 -19
- package/lib/message-validation.d.ts.map +1 -1
- package/lib/provider.d.ts +12 -36
- package/lib/provider.d.ts.map +1 -1
- package/lib/rate-limiter.d.ts +1 -31
- package/lib/rate-limiter.d.ts.map +1 -1
- package/lib/session-manager.d.ts +1 -61
- package/lib/session-manager.d.ts.map +1 -1
- package/lib/websocket.d.ts +8 -0
- package/lib/websocket.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/action-authorization.ts +5 -55
- package/src/broadcast-scheduler.ts +8 -84
- package/src/message-validation.ts +6 -58
- package/src/provider.tsx +100 -370
- package/src/rate-limiter.ts +8 -66
- package/src/session-manager.ts +8 -192
- package/src/websocket.ts +28 -4
package/src/session-manager.ts
CHANGED
|
@@ -1,192 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
type
|
|
8
|
-
} from "@couch-kit/
|
|
9
|
-
|
|
10
|
-
export interface SessionTimerScheduler<TTimer> {
|
|
11
|
-
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
12
|
-
clearTimeout(timer: TTimer): void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const defaultSessionTimerScheduler: SessionTimerScheduler<
|
|
16
|
-
ReturnType<typeof setTimeout>
|
|
17
|
-
> = {
|
|
18
|
-
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
19
|
-
clearTimeout: (timer) => clearTimeout(timer),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export interface JoinSessionPayload {
|
|
23
|
-
name: string;
|
|
24
|
-
avatar?: string;
|
|
25
|
-
secret: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface HostSessionManagerOptions<TTimer> {
|
|
29
|
-
disconnectTimeout?: number;
|
|
30
|
-
getDisconnectTimeout?: () => number;
|
|
31
|
-
scheduler?: SessionTimerScheduler<TTimer>;
|
|
32
|
-
derivePlayerId?: (secret: string) => Promise<string>;
|
|
33
|
-
derivePlayerIdLegacy?: (secret: string) => string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export type JoinSessionResult<S extends IGameState> = {
|
|
37
|
-
playerId: string;
|
|
38
|
-
socketId: string;
|
|
39
|
-
secret: string;
|
|
40
|
-
isReconnect: boolean;
|
|
41
|
-
action: InternalAction<S>;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export type DisconnectSessionResult<S extends IGameState> =
|
|
45
|
-
| { kind: "unknown" }
|
|
46
|
-
| { kind: "stale"; playerId: string; secret: string }
|
|
47
|
-
| {
|
|
48
|
-
kind: "left";
|
|
49
|
-
playerId: string;
|
|
50
|
-
secret: string;
|
|
51
|
-
action: InternalAction<S>;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
type PlayersSource<S extends IGameState> =
|
|
55
|
-
| S["players"]
|
|
56
|
-
| (() => S["players"]);
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Tracks host player sessions by secret and socket ID, including reconnects and
|
|
60
|
-
* delayed cleanup of disconnected players.
|
|
61
|
-
*/
|
|
62
|
-
export class HostSessionManager<
|
|
63
|
-
TTimer = ReturnType<typeof setTimeout>,
|
|
64
|
-
> {
|
|
65
|
-
private readonly sessions = new Map<string, string>();
|
|
66
|
-
private readonly reverseMap = new Map<string, string>();
|
|
67
|
-
private readonly cleanupTimers = new Map<string, TTimer>();
|
|
68
|
-
private readonly socketIdToPlayerId = new Map<string, string>();
|
|
69
|
-
private readonly scheduler: SessionTimerScheduler<TTimer>;
|
|
70
|
-
private readonly getDisconnectTimeout: () => number;
|
|
71
|
-
private readonly derivePlayerIdFn: (secret: string) => Promise<string>;
|
|
72
|
-
private readonly derivePlayerIdLegacyFn: (secret: string) => string;
|
|
73
|
-
|
|
74
|
-
constructor(options: HostSessionManagerOptions<TTimer> = {}) {
|
|
75
|
-
this.scheduler =
|
|
76
|
-
options.scheduler ??
|
|
77
|
-
(defaultSessionTimerScheduler as unknown as SessionTimerScheduler<TTimer>);
|
|
78
|
-
this.getDisconnectTimeout =
|
|
79
|
-
options.getDisconnectTimeout ??
|
|
80
|
-
(() => options.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT);
|
|
81
|
-
this.derivePlayerIdFn = options.derivePlayerId ?? derivePlayerId;
|
|
82
|
-
this.derivePlayerIdLegacyFn =
|
|
83
|
-
options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async handleJoin<S extends IGameState>(
|
|
87
|
-
socketId: string,
|
|
88
|
-
payload: JoinSessionPayload,
|
|
89
|
-
playersSource: PlayersSource<S>,
|
|
90
|
-
): Promise<JoinSessionResult<S>> {
|
|
91
|
-
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
92
|
-
const players =
|
|
93
|
-
typeof playersSource === "function" ? playersSource() : playersSource;
|
|
94
|
-
let playerId = hashedId;
|
|
95
|
-
|
|
96
|
-
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
97
|
-
if (!players[playerId] && players[legacyId]) {
|
|
98
|
-
playerId = legacyId;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
this.socketIdToPlayerId.set(socketId, playerId);
|
|
102
|
-
this.sessions.set(payload.secret, socketId);
|
|
103
|
-
this.reverseMap.set(socketId, payload.secret);
|
|
104
|
-
this.cancelRemoval(playerId);
|
|
105
|
-
|
|
106
|
-
const isReconnect = !!players[playerId];
|
|
107
|
-
const action = isReconnect
|
|
108
|
-
? ({
|
|
109
|
-
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
110
|
-
payload: { playerId },
|
|
111
|
-
} as InternalAction<S>)
|
|
112
|
-
: ({
|
|
113
|
-
type: InternalActionTypes.PLAYER_JOINED,
|
|
114
|
-
payload: { id: playerId, name: payload.name, avatar: payload.avatar },
|
|
115
|
-
} as InternalAction<S>);
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
playerId,
|
|
119
|
-
socketId,
|
|
120
|
-
secret: payload.secret,
|
|
121
|
-
isReconnect,
|
|
122
|
-
action,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
handleDisconnect<S extends IGameState>(
|
|
127
|
-
socketId: string,
|
|
128
|
-
): DisconnectSessionResult<S> {
|
|
129
|
-
const playerId = this.socketIdToPlayerId.get(socketId);
|
|
130
|
-
this.socketIdToPlayerId.delete(socketId);
|
|
131
|
-
|
|
132
|
-
const secret = this.reverseMap.get(socketId);
|
|
133
|
-
this.reverseMap.delete(socketId);
|
|
134
|
-
|
|
135
|
-
if (!playerId || !secret) return { kind: "unknown" };
|
|
136
|
-
|
|
137
|
-
if (this.sessions.get(secret) !== socketId) {
|
|
138
|
-
return { kind: "stale", playerId, secret };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
kind: "left",
|
|
143
|
-
playerId,
|
|
144
|
-
secret,
|
|
145
|
-
action: {
|
|
146
|
-
type: InternalActionTypes.PLAYER_LEFT,
|
|
147
|
-
payload: { playerId },
|
|
148
|
-
} as InternalAction<S>,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
scheduleRemoval(
|
|
153
|
-
playerId: string,
|
|
154
|
-
secret: string,
|
|
155
|
-
onRemove: (playerId: string) => void,
|
|
156
|
-
): void {
|
|
157
|
-
const timer = this.scheduler.setTimeout(() => {
|
|
158
|
-
this.cleanupTimers.delete(playerId);
|
|
159
|
-
this.sessions.delete(secret);
|
|
160
|
-
onRemove(playerId);
|
|
161
|
-
}, this.getDisconnectTimeout());
|
|
162
|
-
|
|
163
|
-
this.cleanupTimers.set(playerId, timer);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
cancelRemoval(playerId: string): void {
|
|
167
|
-
const existingTimer = this.cleanupTimers.get(playerId);
|
|
168
|
-
if (existingTimer) {
|
|
169
|
-
this.scheduler.clearTimeout(existingTimer);
|
|
170
|
-
this.cleanupTimers.delete(playerId);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
clearRemovalTimers(): void {
|
|
175
|
-
for (const timer of this.cleanupTimers.values()) {
|
|
176
|
-
this.scheduler.clearTimeout(timer);
|
|
177
|
-
}
|
|
178
|
-
this.cleanupTimers.clear();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
getPlayerIdForSocket(socketId: string): string | undefined {
|
|
182
|
-
return this.socketIdToPlayerId.get(socketId);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
getSocketIdForSecret(secret: string): string | undefined {
|
|
186
|
-
return this.sessions.get(secret);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
hasPendingRemoval(playerId: string): boolean {
|
|
190
|
-
return this.cleanupTimers.has(playerId);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
HostSessionManager,
|
|
3
|
+
type DisconnectSessionResult,
|
|
4
|
+
type HostSessionManagerOptions,
|
|
5
|
+
type JoinSessionPayload,
|
|
6
|
+
type JoinSessionResult,
|
|
7
|
+
type SessionTimerScheduler,
|
|
8
|
+
} from "@couch-kit/runtime";
|
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"
|