@vgai/p2p-colyseus 0.1.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,383 @@
1
+ import type { RelayProofDiagnostics } from '../diagnostics';
2
+ import { P2P_CLOSE_CODES } from '../protocol';
3
+ import { DEFAULT_RELAY_LIMITS, type RelayLimits } from './limits';
4
+ import type { SignalEnvelope } from './protocol';
5
+
6
+ export interface RelayLogger {
7
+ info(event: string, fields?: Record<string, unknown>): void;
8
+ }
9
+
10
+ interface SignalPeer {
11
+ id: string;
12
+ sent: SignalEnvelope[];
13
+ handlers: Set<(envelope: SignalEnvelope) => void>;
14
+ }
15
+
16
+ interface RateWindow {
17
+ startedAt: number;
18
+ count: number;
19
+ }
20
+
21
+ interface IpHashQuotaRecord {
22
+ hostedRoomIds: Set<string>;
23
+ relayRoomIds: Set<string>;
24
+ relayBytesByDay: Map<string, number>;
25
+ }
26
+
27
+ export interface RelayQuotaStore {
28
+ reserveHostedRoom(ipHash: string, roomId: string, maxRooms: number): boolean;
29
+ reserveRelayRoom(ipHash: string, roomId: string, maxRooms: number): boolean;
30
+ consumeRelayBytes(ipHash: string, dayKey: string, bytes: number, maxBytesPerDay: number): boolean;
31
+ }
32
+
33
+ export class InMemoryRelayQuotaStore implements RelayQuotaStore {
34
+ private readonly records = new Map<string, IpHashQuotaRecord>();
35
+
36
+ reserveHostedRoom(ipHash: string, roomId: string, maxRooms: number): boolean {
37
+ const record = this.recordFor(ipHash);
38
+ record.hostedRoomIds.add(roomId);
39
+ return record.hostedRoomIds.size <= maxRooms;
40
+ }
41
+
42
+ reserveRelayRoom(ipHash: string, roomId: string, maxRooms: number): boolean {
43
+ const record = this.recordFor(ipHash);
44
+ record.relayRoomIds.add(roomId);
45
+ return record.relayRoomIds.size <= maxRooms;
46
+ }
47
+
48
+ consumeRelayBytes(
49
+ ipHash: string,
50
+ dayKey: string,
51
+ bytes: number,
52
+ maxBytesPerDay: number,
53
+ ): boolean {
54
+ const record = this.recordFor(ipHash);
55
+ const next = (record.relayBytesByDay.get(dayKey) ?? 0) + bytes;
56
+ if (next > maxBytesPerDay) return false;
57
+ record.relayBytesByDay.set(dayKey, next);
58
+ return true;
59
+ }
60
+
61
+ private recordFor(ipHash: string): IpHashQuotaRecord {
62
+ let record = this.records.get(ipHash);
63
+ if (!record) {
64
+ record = { hostedRoomIds: new Set(), relayRoomIds: new Set(), relayBytesByDay: new Map() };
65
+ this.records.set(ipHash, record);
66
+ }
67
+ return record;
68
+ }
69
+ }
70
+
71
+ export class SignalingRelayCoordinator {
72
+ private roomId = '';
73
+ private roomName = '';
74
+ private hostSignalId = '';
75
+ private createdAt = 0;
76
+ private lastActivityAt = 0;
77
+ private lastHostHeartbeatAt = 0;
78
+ private readonly peers = new Map<string, SignalPeer>();
79
+ private readonly signalRate = new Map<string, RateWindow>();
80
+ private readonly relayRate = new Map<string, RateWindow>();
81
+ private readonly iceCandidates = new Map<string, number>();
82
+ private readonly relayAllowed = new Set<string>();
83
+ private offerCount = 0;
84
+ private answerCount = 0;
85
+ private iceCandidateCount = 0;
86
+ private relayOpenCount = 0;
87
+ private relayBytesIn = 0;
88
+ private relayBytesOut = 0;
89
+
90
+ constructor(
91
+ private readonly limits: RelayLimits = DEFAULT_RELAY_LIMITS,
92
+ private readonly now: () => number = () => Date.now(),
93
+ private readonly quotaStore: RelayQuotaStore = new InMemoryRelayQuotaStore(),
94
+ private readonly logger?: RelayLogger | undefined,
95
+ ) {}
96
+
97
+ registerHost(roomName: string, hostSignalId = 'host', ipHash?: string): SignalEnvelope {
98
+ const rateLimit = this.consumeSignal(hostSignalId);
99
+ if (rateLimit) return rateLimit;
100
+ const now = this.now();
101
+ this.roomId = crypto.randomUUID?.() ?? `room-${Date.now()}`;
102
+ if (
103
+ ipHash &&
104
+ !this.quotaStore.reserveHostedRoom(
105
+ ipHash,
106
+ this.roomId,
107
+ this.limits.maxConcurrentHostedRoomsPerIpHash,
108
+ )
109
+ ) {
110
+ this.roomId = '';
111
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
112
+ }
113
+ this.roomName = roomName;
114
+ this.hostSignalId = hostSignalId;
115
+ this.createdAt = now;
116
+ this.lastActivityAt = now;
117
+ this.lastHostHeartbeatAt = now;
118
+ this.peers.clear();
119
+ this.signalRate.clear();
120
+ this.relayRate.clear();
121
+ this.iceCandidates.clear();
122
+ this.relayAllowed.clear();
123
+ this.offerCount = 0;
124
+ this.answerCount = 0;
125
+ this.iceCandidateCount = 0;
126
+ this.relayOpenCount = 0;
127
+ this.relayBytesIn = 0;
128
+ this.relayBytesOut = 0;
129
+ this.peers.set(hostSignalId, { id: hostSignalId, sent: [], handlers: new Set() });
130
+ this.log('relay.host_registered', { roomId: this.roomId, roomName, hostSignalId });
131
+ return { kind: 'host-registered', roomId: this.roomId, hostSignalId };
132
+ }
133
+
134
+ ensureRelayPeer(roomId: string, peerId: string, targetPeerId: string): void {
135
+ const now = this.now();
136
+ if (!this.roomId) {
137
+ this.roomId = roomId;
138
+ this.roomName = 'websocket-relay';
139
+ this.hostSignalId = peerId;
140
+ this.createdAt = now;
141
+ this.lastActivityAt = now;
142
+ this.lastHostHeartbeatAt = now;
143
+ }
144
+ if (this.roomId !== roomId) return;
145
+ this.peers.set(peerId, this.peers.get(peerId) ?? { id: peerId, sent: [], handlers: new Set() });
146
+ this.peers.set(
147
+ targetPeerId,
148
+ this.peers.get(targetPeerId) ?? { id: targetPeerId, sent: [], handlers: new Set() },
149
+ );
150
+ }
151
+
152
+ join(roomName: string, clientSignalId: string): SignalEnvelope {
153
+ const rateLimit = this.consumeSignal(clientSignalId);
154
+ if (rateLimit) return rateLimit;
155
+ const unavailable = this.unavailableRoomError();
156
+ if (unavailable) return unavailable;
157
+ if (!this.roomId || roomName !== this.roomName) {
158
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.roomNotFound) };
159
+ }
160
+ if (this.peers.size >= this.limits.maxPlayers) {
161
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.roomFull) };
162
+ }
163
+ this.lastActivityAt = this.now();
164
+ this.peers.set(clientSignalId, { id: clientSignalId, sent: [], handlers: new Set() });
165
+ this.log('relay.join_routed', { roomId: this.roomId, roomName, clientSignalId });
166
+ return {
167
+ kind: 'join-routed',
168
+ roomId: this.roomId,
169
+ hostSignalId: this.hostSignalId,
170
+ clientSignalId,
171
+ };
172
+ }
173
+
174
+ forward(envelope: SignalEnvelope & { target?: string }): SignalEnvelope | null {
175
+ const sourcePeerId = sourceOf(envelope);
176
+ if (envelope.kind !== 'relay-data') {
177
+ const signalRateLimit = this.consumeSignal(sourcePeerId);
178
+ if (signalRateLimit) return signalRateLimit;
179
+ }
180
+ const unavailable = this.unavailableRoomError();
181
+ if (unavailable) return unavailable;
182
+ if (envelope.kind === 'rtc-offer') this.offerCount++;
183
+ if (envelope.kind === 'rtc-answer') this.answerCount++;
184
+ if (envelope.kind === 'rtc-ice') {
185
+ const count = (this.iceCandidates.get(sourcePeerId) ?? 0) + 1;
186
+ this.iceCandidates.set(sourcePeerId, count);
187
+ if (count > this.limits.maxIceCandidatesPerPeerPerJoin) {
188
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.rateLimited) };
189
+ }
190
+ this.iceCandidateCount++;
191
+ }
192
+ if (envelope.kind === 'relay-open') {
193
+ if (
194
+ envelope.ipHash &&
195
+ !this.quotaStore.reserveRelayRoom(
196
+ envelope.ipHash,
197
+ envelope.roomId,
198
+ this.limits.maxConcurrentRelayRoomsPerIpHash,
199
+ )
200
+ ) {
201
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
202
+ }
203
+ this.relayAllowed.add(sourcePeerId);
204
+ this.relayOpenCount++;
205
+ this.log('relay.opened', {
206
+ roomId: envelope.roomId,
207
+ from: sourcePeerId,
208
+ target: envelope.target,
209
+ });
210
+ }
211
+ if (envelope.kind === 'relay-data') {
212
+ if (!this.relayAllowed.has(sourcePeerId)) {
213
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayNotAllowed) };
214
+ }
215
+ const relayRateLimit = this.consumeRelay(sourcePeerId);
216
+ if (relayRateLimit) return relayRateLimit;
217
+ const bytes = byteLength(envelope);
218
+ if (bytes > this.limits.maxEnvelopeBytes) {
219
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.envelopeTooLarge) };
220
+ }
221
+ if (this.relayBytesIn + bytes > this.limits.maxRelayBytesPerRoom) {
222
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
223
+ }
224
+ if (
225
+ envelope.ipHash &&
226
+ !this.quotaStore.consumeRelayBytes(
227
+ envelope.ipHash,
228
+ dayKey(this.now()),
229
+ bytes,
230
+ this.limits.maxRelayBytesPerIpHashPerDay,
231
+ )
232
+ ) {
233
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
234
+ }
235
+ this.relayBytesIn += bytes;
236
+ this.relayBytesOut += bytes;
237
+ this.log('relay.data', {
238
+ roomId: envelope.roomId,
239
+ from: sourcePeerId,
240
+ target: envelope.target,
241
+ bytes,
242
+ });
243
+ }
244
+
245
+ const target = 'target' in envelope ? envelope.target : undefined;
246
+ if (!target) return null;
247
+ const peer = this.peers.get(target);
248
+ this.lastActivityAt = this.now();
249
+ peer?.sent.push(envelope);
250
+ for (const handler of peer?.handlers ?? []) handler(envelope);
251
+ return null;
252
+ }
253
+
254
+ onEnvelope(peerId: string, handler: (envelope: SignalEnvelope) => void): () => void {
255
+ const peer = this.peers.get(peerId);
256
+ if (!peer) throw new Error(`Unknown signaling peer: ${peerId}`);
257
+ peer.handlers.add(handler);
258
+ return () => peer.handlers.delete(handler);
259
+ }
260
+
261
+ drain(peerId: string): SignalEnvelope[] {
262
+ const peer = this.peers.get(peerId);
263
+ if (!peer) return [];
264
+ this.lastActivityAt = this.now();
265
+ const sent = peer.sent.slice();
266
+ peer.sent.length = 0;
267
+ return sent;
268
+ }
269
+
270
+ heartbeat(roomId: string, peerId: string): SignalEnvelope {
271
+ const rateLimit = this.consumeSignal(peerId);
272
+ if (rateLimit) return rateLimit;
273
+ if (roomId !== this.roomId)
274
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.roomNotFound) };
275
+ if (peerId === this.hostSignalId) this.lastHostHeartbeatAt = this.now();
276
+ this.lastActivityAt = this.now();
277
+ this.log('relay.heartbeat', { roomId, peerId });
278
+ return { kind: 'heartbeat', roomId };
279
+ }
280
+
281
+ diagnostics(): RelayProofDiagnostics {
282
+ const maybeBudgetLimits = this.limits as RelayLimits & {
283
+ maxGlobalRequestsPerDay?: number;
284
+ maxGlobalWebSocketMessagesPerDay?: number;
285
+ maxActiveRelaySockets?: number;
286
+ };
287
+ return {
288
+ roomId: this.roomId,
289
+ hostSignalId: this.hostSignalId,
290
+ clientSignalIds: [...this.peers.keys()].filter((id) => id !== this.hostSignalId),
291
+ offerCount: this.offerCount,
292
+ answerCount: this.answerCount,
293
+ iceCandidateCount: this.iceCandidateCount,
294
+ relayOpenCount: this.relayOpenCount,
295
+ relayBytesIn: this.relayBytesIn,
296
+ relayBytesOut: this.relayBytesOut,
297
+ authoritativeStateOwned: false,
298
+ quota: {
299
+ maxPlayers: this.limits.maxPlayers,
300
+ maxEnvelopeBytes: this.limits.maxEnvelopeBytes,
301
+ maxMessagesPerSecond: this.limits.maxMessagesPerPeerPerSecond,
302
+ maxGlobalRelayBytesPerDay: this.limits.maxGlobalRelayBytesPerDay,
303
+ maxGlobalRequestsPerDay: maybeBudgetLimits.maxGlobalRequestsPerDay ?? 250_000,
304
+ maxGlobalWebSocketMessagesPerDay:
305
+ maybeBudgetLimits.maxGlobalWebSocketMessagesPerDay ?? 2_000_000,
306
+ maxActiveRelaySockets: maybeBudgetLimits.maxActiveRelaySockets ?? 200,
307
+ maxRelayBytesPerRoom: this.limits.maxRelayBytesPerRoom,
308
+ },
309
+ };
310
+ }
311
+
312
+ private consumeSignal(peerId: string): SignalEnvelope | null {
313
+ return consumeRate(
314
+ this.signalRate,
315
+ peerId,
316
+ this.now(),
317
+ 60_000,
318
+ this.limits.maxSignalingMessagesPerPeerPerMinute,
319
+ );
320
+ }
321
+
322
+ private consumeRelay(peerId: string): SignalEnvelope | null {
323
+ return consumeRate(
324
+ this.relayRate,
325
+ peerId,
326
+ this.now(),
327
+ 1_000,
328
+ this.limits.maxMessagesPerPeerPerSecond,
329
+ );
330
+ }
331
+
332
+ private unavailableRoomError(): SignalEnvelope | null {
333
+ if (!this.roomId) return null;
334
+ const now = this.now();
335
+ if (now - this.createdAt > this.limits.maxRelayRoomDurationMs) {
336
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
337
+ }
338
+ if (now - this.lastActivityAt > this.limits.idleTimeoutMs) {
339
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.roomNotFound) };
340
+ }
341
+ if (now - this.lastHostHeartbeatAt > this.limits.hostHeartbeatTimeoutMs) {
342
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.hostMissing) };
343
+ }
344
+ return null;
345
+ }
346
+
347
+ private log(event: string, fields: Record<string, unknown>): void {
348
+ this.logger?.info(event, fields);
349
+ }
350
+ }
351
+
352
+ function byteLength(value: unknown): number {
353
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
354
+ }
355
+
356
+ function consumeRate(
357
+ windows: Map<string, RateWindow>,
358
+ peerId: string,
359
+ now: number,
360
+ windowMs: number,
361
+ maxCount: number,
362
+ ): SignalEnvelope | null {
363
+ const current = windows.get(peerId);
364
+ if (!current || now - current.startedAt >= windowMs) {
365
+ windows.set(peerId, { startedAt: now, count: 1 });
366
+ return null;
367
+ }
368
+ current.count++;
369
+ if (current.count > maxCount) {
370
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.rateLimited) };
371
+ }
372
+ return null;
373
+ }
374
+
375
+ function dayKey(ms: number): string {
376
+ return new Date(ms).toISOString().slice(0, 10);
377
+ }
378
+
379
+ function sourceOf(envelope: SignalEnvelope & { target?: string }): string {
380
+ if ('from' in envelope && typeof envelope.from === 'string') return envelope.from;
381
+ if ('target' in envelope && typeof envelope.target === 'string') return `peer:${envelope.target}`;
382
+ return 'anonymous';
383
+ }