@vgai/p2p-colyseus 0.5.2 → 0.5.3

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.
@@ -18,51 +18,62 @@ interface RateWindow {
18
18
  count: number;
19
19
  }
20
20
 
21
- interface IpHashQuotaRecord {
21
+ interface PrincipalQuotaRecord {
22
22
  hostedRoomIds: Set<string>;
23
23
  relayRoomIds: Set<string>;
24
24
  relayBytesByDay: Map<string, number>;
25
25
  }
26
26
 
27
+ /**
28
+ * Quota store keyed by a neutral `principal` (an account id, or — as dormant
29
+ * scaffolding — an ip hash). The relay's durable per-account byte allotment
30
+ * lives in Durable Object storage (see `durable-room.ts`); this in-memory store
31
+ * backs the coordinator's per-principal concurrent-room and per-day byte caps.
32
+ */
27
33
  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;
34
+ reserveHostedRoom(principal: string, roomId: string, maxRooms: number): boolean;
35
+ reserveRelayRoom(principal: string, roomId: string, maxRooms: number): boolean;
36
+ consumeRelayBytes(
37
+ principal: string,
38
+ dayKey: string,
39
+ bytes: number,
40
+ maxBytesPerDay: number,
41
+ ): boolean;
31
42
  }
32
43
 
33
44
  export class InMemoryRelayQuotaStore implements RelayQuotaStore {
34
- private readonly records = new Map<string, IpHashQuotaRecord>();
45
+ private readonly records = new Map<string, PrincipalQuotaRecord>();
35
46
 
36
- reserveHostedRoom(ipHash: string, roomId: string, maxRooms: number): boolean {
37
- const record = this.recordFor(ipHash);
47
+ reserveHostedRoom(principal: string, roomId: string, maxRooms: number): boolean {
48
+ const record = this.recordFor(principal);
38
49
  record.hostedRoomIds.add(roomId);
39
50
  return record.hostedRoomIds.size <= maxRooms;
40
51
  }
41
52
 
42
- reserveRelayRoom(ipHash: string, roomId: string, maxRooms: number): boolean {
43
- const record = this.recordFor(ipHash);
53
+ reserveRelayRoom(principal: string, roomId: string, maxRooms: number): boolean {
54
+ const record = this.recordFor(principal);
44
55
  record.relayRoomIds.add(roomId);
45
56
  return record.relayRoomIds.size <= maxRooms;
46
57
  }
47
58
 
48
59
  consumeRelayBytes(
49
- ipHash: string,
60
+ principal: string,
50
61
  dayKey: string,
51
62
  bytes: number,
52
63
  maxBytesPerDay: number,
53
64
  ): boolean {
54
- const record = this.recordFor(ipHash);
65
+ const record = this.recordFor(principal);
55
66
  const next = (record.relayBytesByDay.get(dayKey) ?? 0) + bytes;
56
67
  if (next > maxBytesPerDay) return false;
57
68
  record.relayBytesByDay.set(dayKey, next);
58
69
  return true;
59
70
  }
60
71
 
61
- private recordFor(ipHash: string): IpHashQuotaRecord {
62
- let record = this.records.get(ipHash);
72
+ private recordFor(principal: string): PrincipalQuotaRecord {
73
+ let record = this.records.get(principal);
63
74
  if (!record) {
64
75
  record = { hostedRoomIds: new Set(), relayRoomIds: new Set(), relayBytesByDay: new Map() };
65
- this.records.set(ipHash, record);
76
+ this.records.set(principal, record);
66
77
  }
67
78
  return record;
68
79
  }
@@ -94,15 +105,24 @@ export class SignalingRelayCoordinator {
94
105
  private readonly logger?: RelayLogger | undefined,
95
106
  ) {}
96
107
 
97
- registerHost(roomName: string, hostSignalId = 'host', ipHash?: string): SignalEnvelope {
108
+ /**
109
+ * The room name bound at host-register (empty until a host registers). The
110
+ * POST relay-data biller verifies each managed access token against it, so a
111
+ * token minted for a different room can never meter bytes here.
112
+ */
113
+ get boundRoomName(): string {
114
+ return this.roomName;
115
+ }
116
+
117
+ registerHost(roomName: string, hostSignalId = 'host', principal?: string): SignalEnvelope {
98
118
  const rateLimit = this.consumeSignal(hostSignalId);
99
119
  if (rateLimit) return rateLimit;
100
120
  const now = this.now();
101
121
  this.roomId = crypto.randomUUID?.() ?? `room-${Date.now()}`;
102
122
  if (
103
- ipHash &&
123
+ principal &&
104
124
  !this.quotaStore.reserveHostedRoom(
105
- ipHash,
125
+ principal,
106
126
  this.roomId,
107
127
  this.limits.maxConcurrentHostedRoomsPerIpHash,
108
128
  )
@@ -1,6 +1,7 @@
1
- import { verifyP2PAccessToken } from '../access-token';
1
+ import { readP2PAccessToken } from '../access-token';
2
2
  import { isEnvelope, P2P_CLOSE_CODES } from '../protocol';
3
3
  import { SignalingRelayCoordinator } from './coordinator';
4
+ import { DEFAULT_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY } from './limits';
4
5
  import type { SignalEnvelope } from './protocol';
5
6
 
6
7
  interface CloudflareWebSocket extends WebSocket {
@@ -25,11 +26,25 @@ interface DurableRoomEnv {
25
26
  P2P_COLYSEUS_MAX_GLOBAL_WEBSOCKET_MESSAGES_PER_DAY?: string | undefined;
26
27
  P2P_COLYSEUS_MAX_ACTIVE_RELAY_SOCKETS?: string | undefined;
27
28
  P2P_COLYSEUS_ACCESS_TOKEN_SECRET?: string | undefined;
29
+ P2P_COLYSEUS_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY?: string | undefined;
30
+ P2P_COLYSEUS_REQUIRE_ACCOUNT?: string | undefined;
28
31
  }
29
32
 
30
33
  export class P2PColyseusDurableRoom {
31
34
  private readonly coordinator = new SignalingRelayCoordinator();
32
35
  private readonly sockets = new Map<string, CloudflareWebSocket>();
36
+ /**
37
+ * peerId → metering principal, learned when a peer authenticates at
38
+ * host-register / join. The WebSocket relay-data path binds this principal
39
+ * ONCE at accept time — and under enforcement `acceptWebSocket` REFUSES a
40
+ * peerId absent from this map, so the bind is always a verified userId, never
41
+ * an ip-hash fallback. The POST relay-data path uses it only when
42
+ * account enforcement is OFF (self-host / BYOK); under enforcement that path
43
+ * ignores this map entirely and meters against the `userId` carried on the
44
+ * frame's own verified `accessToken` (see `meterPostRelayData`), so a
45
+ * spoofable per-message `from` is never authoritative for billing.
46
+ */
47
+ private readonly peerPrincipals = new Map<string, string>();
33
48
 
34
49
  constructor(
35
50
  private readonly state?: DurableObjectStateLike,
@@ -84,27 +99,29 @@ export class P2PColyseusDurableRoom {
84
99
  envelope: SignalEnvelope & { from?: string | undefined; ipHash?: string | undefined },
85
100
  ): Promise<unknown> {
86
101
  if (envelope.kind === 'host-register') {
87
- const authError = await this.verifyAccess(
102
+ const auth = await this.authorize(
88
103
  envelope.accessToken,
89
104
  envelope.roomName,
90
105
  envelope.from,
91
- );
92
- if (authError) return authError;
93
- return this.coordinator.registerHost(
94
- envelope.roomName,
95
- envelope.from ?? 'host',
96
106
  envelope.ipHash,
97
107
  );
108
+ if (auth.error) return auth.error;
109
+ const hostId = envelope.from ?? 'host';
110
+ this.rememberPrincipal(hostId, auth.principal);
111
+ return this.coordinator.registerHost(envelope.roomName, hostId, envelope.ipHash);
98
112
  }
99
113
 
100
114
  if (envelope.kind === 'join-request') {
101
- const authError = await this.verifyAccess(
115
+ const auth = await this.authorize(
102
116
  envelope.accessToken,
103
117
  envelope.roomName,
104
118
  envelope.from,
119
+ envelope.ipHash,
105
120
  );
106
- if (authError) return authError;
107
- return this.coordinator.join(envelope.roomName, envelope.from ?? 'client');
121
+ if (auth.error) return auth.error;
122
+ const clientId = envelope.from ?? 'client';
123
+ this.rememberPrincipal(clientId, auth.principal);
124
+ return this.coordinator.join(envelope.roomName, clientId);
108
125
  }
109
126
 
110
127
  if (
@@ -114,11 +131,7 @@ export class P2PColyseusDurableRoom {
114
131
  envelope.kind === 'relay-open' ||
115
132
  envelope.kind === 'relay-data'
116
133
  ) {
117
- if (envelope.kind === 'relay-data') {
118
- const budgetResult = await this.consumeGlobalRelayBudget(byteLength(envelope));
119
- if (budgetResult) return budgetResult;
120
- }
121
- return this.coordinator.forward(envelope) ?? { kind: 'ok' };
134
+ return this.forwardSignal(envelope);
122
135
  }
123
136
 
124
137
  if (envelope.kind === 'relay-drain') {
@@ -147,6 +160,23 @@ export class P2PColyseusDurableRoom {
147
160
  return json({ kind: 'error', message: 'Missing peerId, targetPeerId, or roomId' }, 400);
148
161
  }
149
162
 
163
+ // Under account enforcement the relay-data WebSocket is reachable ONLY by a peer that
164
+ // already authenticated at host-register/join (both token-gated by authorize()). That
165
+ // handshake is what binds peerId → verified userId in peerPrincipals. An unauthenticated
166
+ // peerId has no userId to meter against, so principalForPeer would fall back to ip-hash —
167
+ // the exact tokenless bypass the POST relay-data path forbids (meterPostRelayData →
168
+ // authenticatedRelayPrincipal). Refuse the upgrade so both relay-data transports gate
169
+ // identically. Self-host (REQUIRE_ACCOUNT off) is unchanged: no principal is required.
170
+ const remembered = this.peerPrincipals.get(peerId);
171
+ if (getRequireAccount(this.env) && remembered === undefined) {
172
+ return json({ kind: 'error', message: String(P2P_CLOSE_CODES.unauthorized) }, 401);
173
+ }
174
+
175
+ const principal = this.principalForPeer(
176
+ peerId,
177
+ request.headers.get('x-p2p-ip-hash') ?? undefined,
178
+ );
179
+
150
180
  const pair = newWebSocketPair();
151
181
  const client = pair[0];
152
182
  const server = pair[1];
@@ -156,40 +186,133 @@ export class P2PColyseusDurableRoom {
156
186
  this.sockets.set(peerId, server);
157
187
  server.addEventListener('message', async (event) => {
158
188
  if (typeof event.data !== 'string') return;
159
- const messageBudgetResult = await this.consumeDailyCounter(
160
- 'relay-global-websocket-messages',
161
- 1,
162
- getMaxGlobalWebSocketMessagesPerDay(this.env),
163
- );
164
- if (messageBudgetResult) {
165
- server.send(JSON.stringify(messageBudgetResult));
166
- server.close(Number(messageBudgetResult.message), messageBudgetResult.message);
167
- return;
168
- }
169
- const envelope = JSON.parse(event.data) as unknown;
170
- const signalEnvelope: SignalEnvelope & { target: string; from: string } = {
171
- kind: 'relay-data',
189
+ const outcome = await this.meterRelaySocketMessage(
190
+ principal,
172
191
  roomId,
173
- target: targetPeerId,
174
- from: peerId,
175
- envelope: envelope as never,
176
- };
177
- const budgetResult = await this.consumeGlobalRelayBudget(byteLength(signalEnvelope));
178
- const result = budgetResult ?? this.coordinator.forward(signalEnvelope);
179
- if (result?.kind === 'error') {
180
- server.send(JSON.stringify(result));
181
- server.close(Number(result.message), result.message);
192
+ peerId,
193
+ targetPeerId,
194
+ event.data,
195
+ );
196
+ if (outcome.kind === 'error') {
197
+ server.send(JSON.stringify(outcome.error));
198
+ server.close(Number(outcome.error.message), outcome.error.message);
182
199
  return;
183
200
  }
184
- this.sockets.get(targetPeerId)?.send(JSON.stringify(envelope));
201
+ this.sockets.get(targetPeerId)?.send(JSON.stringify(outcome.payload));
185
202
  });
186
203
  server.addEventListener('close', () => {
187
204
  this.sockets.delete(peerId);
205
+ this.peerPrincipals.delete(peerId);
188
206
  });
189
207
 
190
208
  return new Response(null, { status: 101, webSocket: client } as ResponseInit);
191
209
  }
192
210
 
211
+ /**
212
+ * Meter a single WebSocket relay-data frame: global WS-message count, global
213
+ * relay bytes, then the SAME per-account daily byte counter the POST relay
214
+ * path uses. This is the exact body the accepted socket's `message` listener
215
+ * runs; it is a named method so the WebSocket byte path is unit-testable
216
+ * without constructing a status-101 `Response` (which the test runtime
217
+ * rejects).
218
+ */
219
+ private async meterRelaySocketMessage(
220
+ principal: string | undefined,
221
+ roomId: string,
222
+ peerId: string,
223
+ targetPeerId: string,
224
+ rawData: string,
225
+ ): Promise<
226
+ { kind: 'error'; error: ErrorSignalEnvelope } | { kind: 'forward'; payload: unknown }
227
+ > {
228
+ const messageBudget = await this.consumeDailyCounter(
229
+ 'relay-global-websocket-messages',
230
+ 1,
231
+ getMaxGlobalWebSocketMessagesPerDay(this.env),
232
+ );
233
+ if (messageBudget) return { kind: 'error', error: messageBudget };
234
+ const inner = JSON.parse(rawData) as unknown;
235
+ const signalEnvelope: SignalEnvelope & { target: string; from: string } = {
236
+ kind: 'relay-data',
237
+ roomId,
238
+ target: targetPeerId,
239
+ from: peerId,
240
+ envelope: inner as never,
241
+ };
242
+ const bytes = byteLength(signalEnvelope);
243
+ const globalBudget = await this.consumeGlobalRelayBudget(bytes);
244
+ if (globalBudget) return { kind: 'error', error: globalBudget };
245
+ const accountBudget = await this.consumeAccountRelayBytes(principal, bytes);
246
+ if (accountBudget) return { kind: 'error', error: accountBudget };
247
+ const forwardResult = this.coordinator.forward(signalEnvelope);
248
+ if (forwardResult?.kind === 'error') return { kind: 'error', error: forwardResult };
249
+ return { kind: 'forward', payload: inner };
250
+ }
251
+
252
+ private async forwardSignal(
253
+ envelope: SignalEnvelope & { from?: string | undefined; ipHash?: string | undefined },
254
+ ): Promise<unknown> {
255
+ if (envelope.kind === 'relay-data') {
256
+ const budgetResult = await this.meterPostRelayData(envelope);
257
+ if (budgetResult) return budgetResult;
258
+ }
259
+ return this.coordinator.forward(envelope) ?? { kind: 'ok' };
260
+ }
261
+
262
+ /**
263
+ * Meter a POST relay-data frame: global relay bytes, then the per-account
264
+ * daily allotment (the same counter the WebSocket path decrements).
265
+ *
266
+ * Under `P2P_COLYSEUS_REQUIRE_ACCOUNT`, the metering principal is the
267
+ * `userId` recovered from the frame's own verified `accessToken` — NEVER the
268
+ * client-supplied `envelope.from`, which is spoofable. Because a peer cannot
269
+ * mint a token bearing another account's `userId`, this makes it IMPOSSIBLE
270
+ * for peer A's traffic to drain peer B's allotment: `from` is never
271
+ * authoritative for billing, so spoofing it changes nothing. A missing or
272
+ * invalid token, or one carrying no `userId`, is refused (`unauthorized`).
273
+ *
274
+ * With enforcement OFF (self-host / BYOK) today's allow-when-unset metering
275
+ * is preserved byte-for-byte: no token is required, and the principal falls
276
+ * back to the join-time remembered principal (or ip hash) via
277
+ * {@link principalForPeer}.
278
+ */
279
+ private async meterPostRelayData(
280
+ envelope: Extract<SignalEnvelope, { kind: 'relay-data' }> & { ipHash?: string | undefined },
281
+ ): Promise<ErrorSignalEnvelope | null> {
282
+ let principal: string | undefined;
283
+ if (getRequireAccount(this.env)) {
284
+ const auth = await this.authenticatedRelayPrincipal(envelope.accessToken);
285
+ if (auth.error) return auth.error;
286
+ principal = auth.principal;
287
+ } else {
288
+ principal = this.principalForPeer(envelope.from, envelope.ipHash);
289
+ }
290
+ const bytes = byteLength(envelope);
291
+ const globalBudget = await this.consumeGlobalRelayBudget(bytes);
292
+ if (globalBudget) return globalBudget;
293
+ return this.consumeAccountRelayBytes(principal, bytes);
294
+ }
295
+
296
+ /**
297
+ * Resolve the billing principal for a POST relay-data frame under account
298
+ * enforcement from the frame's OWN token. The token is verified against this
299
+ * room's bound name and must carry a `userId`; the principal is that `userId`
300
+ * and nothing else. `envelope.from` is deliberately not consulted — the token
301
+ * is the only trustworthy sender identity, and binding to it (rather than to
302
+ * a per-request `from`) is what makes cross-account draining impossible.
303
+ */
304
+ private async authenticatedRelayPrincipal(
305
+ token: string | undefined,
306
+ ): Promise<{ error?: ErrorSignalEnvelope; principal?: string }> {
307
+ const secret = this.env.P2P_COLYSEUS_ACCESS_TOKEN_SECRET;
308
+ if (!secret || !token) return { error: unauthorized() };
309
+ const claims = await readP2PAccessToken(secret, token, {
310
+ roomName: this.coordinator.boundRoomName,
311
+ });
312
+ if (!claims?.userId) return { error: unauthorized() };
313
+ return { principal: claims.userId };
314
+ }
315
+
193
316
  private async consumeGlobalRelayBudget(bytes: number): Promise<ErrorSignalEnvelope | null> {
194
317
  return this.consumeDailyCounter(
195
318
  'relay-global-bytes',
@@ -261,19 +384,80 @@ export class P2PColyseusDurableRoom {
261
384
  };
262
385
  }
263
386
 
264
- private async verifyAccess(
387
+ /**
388
+ * Verify a peer's access token when one is PRESENT and resolve its metering
389
+ * principal. This mirrors {@link meterPostRelayData}'s soft-mode model so that
390
+ * merely SETTING `P2P_COLYSEUS_ACCESS_TOKEN_SECRET` is non-breaking: a missing
391
+ * token is ALLOWED while `P2P_COLYSEUS_REQUIRE_ACCOUNT` is off, even with the
392
+ * secret set (the principal falls back to the ip hash — self-host / BYOK). A
393
+ * token that IS supplied is still verified against the secret and rejected if
394
+ * invalid. Only flipping `REQUIRE_ACCOUNT` on makes a token-carried `userId`
395
+ * mandatory — so setting the secret alone changes nothing until that flip.
396
+ */
397
+ private async authorize(
265
398
  token: string | undefined,
266
399
  roomName: string,
267
400
  peerId: string | undefined,
268
- ): Promise<ErrorSignalEnvelope | null> {
401
+ ipHash: string | undefined,
402
+ ): Promise<{ error?: ErrorSignalEnvelope; principal?: string | undefined }> {
269
403
  const secret = this.env.P2P_COLYSEUS_ACCESS_TOKEN_SECRET;
270
- if (!secret) return null;
271
- if (!token) return { kind: 'error', message: String(P2P_CLOSE_CODES.unauthorized) };
272
- const verified = await verifyP2PAccessToken(secret, token, { roomName, peerId });
273
- return verified ? null : { kind: 'error', message: String(P2P_CLOSE_CODES.unauthorized) };
404
+ let userId: string | undefined;
405
+ if (secret && token) {
406
+ const claims = await readP2PAccessToken(secret, token, { roomName, peerId });
407
+ if (!claims) return { error: unauthorized() };
408
+ userId = claims.userId;
409
+ }
410
+ if (getRequireAccount(this.env) && !userId) return { error: unauthorized() };
411
+ return { principal: userId ?? ipHash };
412
+ }
413
+
414
+ private rememberPrincipal(peerId: string, principal: string | undefined): void {
415
+ if (principal !== undefined) this.peerPrincipals.set(peerId, principal);
416
+ }
417
+
418
+ private principalForPeer(
419
+ peerId: string | undefined,
420
+ ipHash: string | undefined,
421
+ ): string | undefined {
422
+ const remembered = peerId ? this.peerPrincipals.get(peerId) : undefined;
423
+ return remembered ?? ipHash;
424
+ }
425
+
426
+ private async consumeAccountRelayBytes(
427
+ principal: string | undefined,
428
+ bytes: number,
429
+ ): Promise<ErrorSignalEnvelope | null> {
430
+ if (!principal) return null;
431
+ return this.consumeDailyCounter(
432
+ `relay-account-bytes:${principal}`,
433
+ bytes,
434
+ getFreeRelayBytesPerAccountPerDay(this.env),
435
+ );
274
436
  }
275
437
  }
276
438
 
439
+ function unauthorized(): ErrorSignalEnvelope {
440
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.unauthorized) };
441
+ }
442
+
443
+ export function getFreeRelayBytesPerAccountPerDay(env: DurableRoomEnv = {}): number {
444
+ return Math.floor(
445
+ parsePositiveNumber(env.P2P_COLYSEUS_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY) ??
446
+ DEFAULT_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY,
447
+ );
448
+ }
449
+
450
+ // Accept every plausible truthy spelling (case-insensitive), not just '1'/'true'. This is a
451
+ // ONE-WAY security flip: if an operator sets it to a value the reader silently ignored (e.g.
452
+ // `on`, `yes`), enforcement would stay OFF while they believed the room was closed — the worst
453
+ // possible failure for an access gate. Erring toward "enabled" for any clear affirmative
454
+ // removes that footgun. Only an explicit falsy/absent value leaves enforcement off.
455
+ const REQUIRE_ACCOUNT_TRUTHY = new Set(['1', 'true', 'on', 'yes', 'enabled']);
456
+ export function getRequireAccount(env: DurableRoomEnv = {}): boolean {
457
+ const value = env.P2P_COLYSEUS_REQUIRE_ACCOUNT;
458
+ return typeof value === 'string' && REQUIRE_ACCOUNT_TRUTHY.has(value.trim().toLowerCase());
459
+ }
460
+
277
461
  export function getDailyGlobalRelayBytes(env: DurableRoomEnv = {}): number {
278
462
  const explicit = parsePositiveNumber(env.P2P_COLYSEUS_RELAY_MAX_GLOBAL_BYTES_PER_DAY);
279
463
  if (explicit !== undefined) return Math.floor(explicit);
@@ -20,3 +20,10 @@ export const DEFAULT_RELAY_LIMITS = {
20
20
  export type RelayLimits = {
21
21
  readonly [K in keyof typeof DEFAULT_RELAY_LIMITS]: number;
22
22
  };
23
+
24
+ /**
25
+ * Free relay bytes an account may spend per day on the managed lane. Same
26
+ * 500 MiB shape as `maxRelayBytesPerIpHashPerDay`; overridable at deploy time
27
+ * via `P2P_COLYSEUS_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY`.
28
+ */
29
+ export const DEFAULT_FREE_RELAY_BYTES_PER_ACCOUNT_PER_DAY = 500 * 1024 * 1024;
@@ -67,6 +67,12 @@ export type SignalEnvelope =
67
67
  readonly target: string;
68
68
  readonly from?: string | undefined;
69
69
  readonly ipHash?: string | undefined;
70
+ // The session's managed access token, attached by the forced-relay client
71
+ // transports (`HttpRelayPacketChannel`/`HostRelayPacketChannel`). It is the
72
+ // ONLY trustworthy sender identity a POST relay-data frame carries, so
73
+ // under account enforcement the relay meters bytes against its verified
74
+ // `userId` — never the spoofable `from`. Absent for self-host / BYOK.
75
+ readonly accessToken?: string | undefined;
70
76
  readonly envelope: Envelope;
71
77
  }
72
78
  | { readonly kind: 'relay-drain'; readonly peerId: string }
package/src/engine.ts CHANGED
@@ -4,7 +4,7 @@ import type { SignalEnvelope } from './cloudflare/protocol';
4
4
  import { createLoopbackPair } from './loopback';
5
5
  import type { Envelope } from './protocol';
6
6
  import { type RoomClass, UniversalRoomRuntime } from './runtime';
7
- import { WebRTCDataChannelPacketChannel } from './webrtc';
7
+ import { WEBRTC_UNRELIABLE_CHANNEL_LABEL, WebRTCDataChannelPacketChannel } from './webrtc';
8
8
 
9
9
  const HOST_LOSS_CLOSE_DELAY_MS = 1_000;
10
10
 
@@ -134,6 +134,7 @@ export async function connectP2PHostRoom(
134
134
  iceServers: mode.iceServers,
135
135
  forceRelay: mode.forceRelay === true,
136
136
  heartbeatIntervalMs: mode.heartbeatIntervalMs,
137
+ accessToken: mode.accessToken,
137
138
  });
138
139
  const originalLeave = localRoom.leave.bind(localRoom);
139
140
  localRoom.leave = async (consented = true) => {
@@ -166,6 +167,15 @@ export async function connectP2PJoinRoom(
166
167
 
167
168
  const peer = new RTCPeerConnection({ iceServers: mode.iceServers });
168
169
  const dataChannel = peer.createDataChannel('p2p-colyseus');
170
+ // Second channel for `sendUnreliable`: unordered, no retransmits. Created
171
+ // alongside the reliable one so both are negotiated in the single
172
+ // offer/answer round-trip; we only WAIT on the reliable channel to open
173
+ // (below), and `WebRTCDataChannelPacketChannel` falls back to it if this one
174
+ // is not open yet.
175
+ const unreliableChannel = peer.createDataChannel(WEBRTC_UNRELIABLE_CHANNEL_LABEL, {
176
+ ordered: false,
177
+ maxRetransmits: 0,
178
+ });
169
179
  const pendingCandidates: RTCIceCandidateInit[] = [];
170
180
  peer.onicecandidate = (event) => {
171
181
  if (!event.candidate) return;
@@ -212,7 +222,7 @@ export async function connectP2PJoinRoom(
212
222
  }
213
223
 
214
224
  const room = await connectRoomOverChannel(
215
- new WebRTCDataChannelPacketChannel(joined.hostSignalId, dataChannel),
225
+ new WebRTCDataChannelPacketChannel(joined.hostSignalId, dataChannel, unreliableChannel),
216
226
  opts.room,
217
227
  opts.joinOptions,
218
228
  );
@@ -234,6 +244,7 @@ async function connectP2PJoinRelayRoom(
234
244
  peerId: clientSignalId,
235
245
  signalPeerId: clientSignalId,
236
246
  targetPeerId: joined.hostSignalId,
247
+ accessToken: mode.accessToken,
237
248
  }),
238
249
  opts.room,
239
250
  opts.joinOptions,
@@ -251,6 +262,7 @@ function startHostSignalLoop(options: {
251
262
  iceServers: RTCIceServer[];
252
263
  forceRelay: boolean;
253
264
  heartbeatIntervalMs?: number | undefined;
265
+ accessToken?: string | undefined;
254
266
  }): () => void {
255
267
  let stopped = false;
256
268
  const heartbeatInterval = setInterval(() => {
@@ -263,6 +275,12 @@ function startHostSignalLoop(options: {
263
275
  }, options.heartbeatIntervalMs ?? 5_000);
264
276
  const peers = new Map<string, RTCPeerConnection>();
265
277
  const pending = new Map<string, RTCIceCandidateInit[]>();
278
+ // Per-peer reliable WebRTC channel, plus any unreliable sub-channel whose
279
+ // `ondatachannel` arrived before its reliable sibling. The two channels a
280
+ // client offers surface as TWO `ondatachannel` events; we fold them into one
281
+ // `WebRTCDataChannelPacketChannel`, tolerant of either arrival order.
282
+ const webrtcChannels = new Map<string, WebRTCDataChannelPacketChannel>();
283
+ const pendingUnreliable = new Map<string, RTCDataChannel>();
266
284
  const attachedChannels = new Set<{
267
285
  send(envelope: Envelope): void;
268
286
  close(reason?: string): void;
@@ -277,6 +295,7 @@ function startHostSignalLoop(options: {
277
295
  peerId: target,
278
296
  signalPeerId: options.hostSignalId,
279
297
  targetPeerId: target,
298
+ accessToken: options.accessToken,
280
299
  });
281
300
  relayChannels.set(target, channel);
282
301
  attachedChannels.add(channel);
@@ -303,7 +322,19 @@ function startHostSignalLoop(options: {
303
322
  const peer = new RTCPeerConnection({ iceServers: options.iceServers });
304
323
  peers.set(target, peer);
305
324
  peer.ondatachannel = (event) => {
325
+ if (event.channel.label === WEBRTC_UNRELIABLE_CHANNEL_LABEL) {
326
+ const existing = webrtcChannels.get(target);
327
+ if (existing) existing.attachUnreliableChannel(event.channel);
328
+ else pendingUnreliable.set(target, event.channel);
329
+ return;
330
+ }
306
331
  const channel = new WebRTCDataChannelPacketChannel(target, event.channel);
332
+ const unreliable = pendingUnreliable.get(target);
333
+ if (unreliable) {
334
+ channel.attachUnreliableChannel(unreliable);
335
+ pendingUnreliable.delete(target);
336
+ }
337
+ webrtcChannels.set(target, channel);
307
338
  attachedChannels.add(channel);
308
339
  options.runtime.attach(channel);
309
340
  };
@@ -382,7 +413,12 @@ function closePeerAfterHostLoss(peer: RTCPeerConnection): void {
382
413
  }
383
414
 
384
415
  function adaptCompatRoom(room: CompatRoom, roomId?: string): EngineNetTransport {
385
- const sourceReplication = Callbacks.get(room);
416
+ // The engine transport is string-keyed and STABLE; bridge the real
417
+ // `@colyseus/schema` string-keyed callbacks strategy (over the client's real
418
+ // decoder) to it. `onStateChange` is not a callbacks-strategy method — it is
419
+ // the room's own signal, emitted after each decode.
420
+ // biome-ignore lint/suspicious/noExplicitAny: bridging the generic Callbacks strategy to the string-keyed engine interface.
421
+ const $ = room.decoder ? (Callbacks.get(room.decoder) as any) : undefined;
386
422
  const transport: EngineNetTransport = {
387
423
  get sessionId() {
388
424
  return room.sessionId;
@@ -390,16 +426,23 @@ function adaptCompatRoom(room: CompatRoom, roomId?: string): EngineNetTransport
390
426
  roomId,
391
427
  replication: {
392
428
  onAdd(collection, handler) {
393
- return sourceReplication.onAdd(collection, (item, key) => handler(item as never, key));
429
+ if (!$) return () => undefined;
430
+ return $.onAdd(collection, (item: unknown, key: unknown) =>
431
+ handler(item as never, String(key)),
432
+ );
394
433
  },
395
434
  onRemove(collection, handler) {
396
- return sourceReplication.onRemove(collection, (item, key) => handler(item as never, key));
435
+ if (!$) return () => undefined;
436
+ return $.onRemove(collection, (item: unknown, key: unknown) =>
437
+ handler(item as never, String(key)),
438
+ );
397
439
  },
398
440
  onChange(item, handler) {
399
- return sourceReplication.onChange(item, handler);
441
+ if (!$) return () => undefined;
442
+ return $.onChange(item, handler);
400
443
  },
401
444
  onStateChange(handler) {
402
- return sourceReplication.onStateChange((state) => handler(state as never));
445
+ return room.onStateChange.add((state) => handler(state as never));
403
446
  },
404
447
  },
405
448
  send(type, payload) {
@@ -433,6 +476,7 @@ class HttpRelayPacketChannel {
433
476
  peerId: string;
434
477
  signalPeerId: string;
435
478
  targetPeerId: string;
479
+ accessToken?: string | undefined;
436
480
  },
437
481
  ) {
438
482
  void this.poll();
@@ -467,6 +511,9 @@ class HttpRelayPacketChannel {
467
511
  roomId: this.options.roomId,
468
512
  target: this.options.targetPeerId,
469
513
  from: this.options.signalPeerId,
514
+ // Attach the managed token so the relay can meter these bytes against the
515
+ // authenticated account under enforcement; omitted for self-host / BYOK.
516
+ ...(this.options.accessToken ? { accessToken: this.options.accessToken } : {}),
470
517
  envelope,
471
518
  });
472
519
  if (result.kind === 'error') throw new Error(result.message);
@@ -516,6 +563,7 @@ class HostRelayPacketChannel {
516
563
  peerId: string;
517
564
  signalPeerId: string;
518
565
  targetPeerId: string;
566
+ accessToken?: string | undefined;
519
567
  },
520
568
  ) {}
521
569
 
@@ -553,6 +601,9 @@ class HostRelayPacketChannel {
553
601
  roomId: this.options.roomId,
554
602
  target: this.options.targetPeerId,
555
603
  from: this.options.signalPeerId,
604
+ // Attach the managed token so the relay can meter these bytes against the
605
+ // authenticated account under enforcement; omitted for self-host / BYOK.
606
+ ...(this.options.accessToken ? { accessToken: this.options.accessToken } : {}),
556
607
  envelope,
557
608
  });
558
609
  if (result.kind === 'error') throw new Error(result.message);