@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.
- package/package.json +2 -4
- package/src/access-token.ts +151 -8
- package/src/browser.ts +47 -48
- package/src/callbacks.ts +11 -7
- package/src/channels.ts +11 -0
- package/src/client.ts +121 -25
- package/src/cloudflare/coordinator.ts +37 -17
- package/src/cloudflare/durable-room.ts +228 -44
- package/src/cloudflare/limits.ts +7 -0
- package/src/cloudflare/protocol.ts +6 -0
- package/src/engine.ts +58 -7
- package/src/index.ts +57 -50
- package/src/protocol.ts +24 -16
- package/src/room-registry.ts +22 -0
- package/src/room.ts +8 -5
- package/src/runtime.ts +167 -28
- package/src/webrtc.ts +48 -5
- package/src/websocket.ts +1 -1
- package/src/wire-bytes.ts +35 -0
- package/src/codec.ts +0 -78
- package/src/replication.ts +0 -164
- package/src/schema.ts +0 -287
|
@@ -18,51 +18,62 @@ interface RateWindow {
|
|
|
18
18
|
count: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
interface
|
|
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(
|
|
29
|
-
reserveRelayRoom(
|
|
30
|
-
consumeRelayBytes(
|
|
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,
|
|
45
|
+
private readonly records = new Map<string, PrincipalQuotaRecord>();
|
|
35
46
|
|
|
36
|
-
reserveHostedRoom(
|
|
37
|
-
const record = this.recordFor(
|
|
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(
|
|
43
|
-
const record = this.recordFor(
|
|
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
|
-
|
|
60
|
+
principal: string,
|
|
50
61
|
dayKey: string,
|
|
51
62
|
bytes: number,
|
|
52
63
|
maxBytesPerDay: number,
|
|
53
64
|
): boolean {
|
|
54
|
-
const record = this.recordFor(
|
|
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(
|
|
62
|
-
let record = this.records.get(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
principal &&
|
|
104
124
|
!this.quotaStore.reserveHostedRoom(
|
|
105
|
-
|
|
125
|
+
principal,
|
|
106
126
|
this.roomId,
|
|
107
127
|
this.limits.maxConcurrentHostedRoomsPerIpHash,
|
|
108
128
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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 (
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
401
|
+
ipHash: string | undefined,
|
|
402
|
+
): Promise<{ error?: ErrorSignalEnvelope; principal?: string | undefined }> {
|
|
269
403
|
const secret = this.env.P2P_COLYSEUS_ACCESS_TOKEN_SECRET;
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
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);
|
package/src/cloudflare/limits.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
441
|
+
if (!$) return () => undefined;
|
|
442
|
+
return $.onChange(item, handler);
|
|
400
443
|
},
|
|
401
444
|
onStateChange(handler) {
|
|
402
|
-
return
|
|
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);
|