@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,377 @@
1
+ import { verifyP2PAccessToken } from '../access-token';
2
+ import { isEnvelope, P2P_CLOSE_CODES } from '../protocol';
3
+ import { SignalingRelayCoordinator } from './coordinator';
4
+ import type { SignalEnvelope } from './protocol';
5
+
6
+ interface CloudflareWebSocket extends WebSocket {
7
+ accept(): void;
8
+ }
9
+
10
+ type ErrorSignalEnvelope = Extract<SignalEnvelope, { kind: 'error' }>;
11
+
12
+ interface DurableObjectStateLike {
13
+ storage?: {
14
+ get<T = unknown>(key: string): Promise<T | undefined>;
15
+ put(key: string, value: unknown): Promise<void>;
16
+ };
17
+ }
18
+
19
+ interface DurableRoomEnv {
20
+ P2P_COLYSEUS_RELAY_DISABLED?: string | undefined;
21
+ P2P_COLYSEUS_RELAY_DAILY_BUDGET_USD?: string | undefined;
22
+ P2P_COLYSEUS_RELAY_ESTIMATED_USD_PER_GIB?: string | undefined;
23
+ P2P_COLYSEUS_RELAY_MAX_GLOBAL_BYTES_PER_DAY?: string | undefined;
24
+ P2P_COLYSEUS_MAX_GLOBAL_REQUESTS_PER_DAY?: string | undefined;
25
+ P2P_COLYSEUS_MAX_GLOBAL_WEBSOCKET_MESSAGES_PER_DAY?: string | undefined;
26
+ P2P_COLYSEUS_MAX_ACTIVE_RELAY_SOCKETS?: string | undefined;
27
+ P2P_COLYSEUS_ACCESS_TOKEN_SECRET?: string | undefined;
28
+ }
29
+
30
+ export class P2PColyseusDurableRoom {
31
+ private readonly coordinator = new SignalingRelayCoordinator();
32
+ private readonly sockets = new Map<string, CloudflareWebSocket>();
33
+
34
+ constructor(
35
+ private readonly state?: DurableObjectStateLike,
36
+ private readonly env: DurableRoomEnv = {},
37
+ ) {}
38
+
39
+ async fetch(request: Request): Promise<Response> {
40
+ if (isRelayDisabled(this.env)) {
41
+ return json({ kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) }, 503);
42
+ }
43
+
44
+ const requestBudgetResult = await this.consumeDailyCounter(
45
+ 'relay-global-requests',
46
+ 1,
47
+ getMaxGlobalRequestsPerDay(this.env),
48
+ );
49
+ if (requestBudgetResult) return json(requestBudgetResult, 429);
50
+
51
+ if (request.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
52
+ return this.acceptWebSocket(request);
53
+ }
54
+
55
+ if (request.method === 'OPTIONS') {
56
+ return json(null);
57
+ }
58
+
59
+ if (request.method === 'GET') {
60
+ return json(await this.diagnostics());
61
+ }
62
+
63
+ if (request.method !== 'POST') {
64
+ return json({ kind: 'error', message: 'Use POST' }, 405);
65
+ }
66
+
67
+ let envelope: SignalEnvelope & { from?: string | undefined; ipHash?: string | undefined };
68
+ try {
69
+ envelope = (await request.json()) as SignalEnvelope & {
70
+ from?: string | undefined;
71
+ ipHash?: string | undefined;
72
+ };
73
+ } catch {
74
+ return json({ kind: 'error', message: String(P2P_CLOSE_CODES.badEnvelope) }, 400);
75
+ }
76
+ if (!isSignalEnvelope(envelope)) {
77
+ return json({ kind: 'error', message: String(P2P_CLOSE_CODES.badEnvelope) }, 400);
78
+ }
79
+ const ipHash = envelope.ipHash ?? request.headers.get('x-p2p-ip-hash') ?? undefined;
80
+ return json(await this.handle(ipHash ? { ...envelope, ipHash } : envelope));
81
+ }
82
+
83
+ async handle(
84
+ envelope: SignalEnvelope & { from?: string | undefined; ipHash?: string | undefined },
85
+ ): Promise<unknown> {
86
+ if (envelope.kind === 'host-register') {
87
+ const authError = await this.verifyAccess(
88
+ envelope.accessToken,
89
+ envelope.roomName,
90
+ envelope.from,
91
+ );
92
+ if (authError) return authError;
93
+ return this.coordinator.registerHost(
94
+ envelope.roomName,
95
+ envelope.from ?? 'host',
96
+ envelope.ipHash,
97
+ );
98
+ }
99
+
100
+ if (envelope.kind === 'join-request') {
101
+ const authError = await this.verifyAccess(
102
+ envelope.accessToken,
103
+ envelope.roomName,
104
+ envelope.from,
105
+ );
106
+ if (authError) return authError;
107
+ return this.coordinator.join(envelope.roomName, envelope.from ?? 'client');
108
+ }
109
+
110
+ if (
111
+ envelope.kind === 'rtc-offer' ||
112
+ envelope.kind === 'rtc-answer' ||
113
+ envelope.kind === 'rtc-ice' ||
114
+ envelope.kind === 'relay-open' ||
115
+ envelope.kind === 'relay-data'
116
+ ) {
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' };
122
+ }
123
+
124
+ if (envelope.kind === 'relay-drain') {
125
+ return { kind: 'relay-drained', messages: this.coordinator.drain(envelope.peerId) };
126
+ }
127
+
128
+ if (envelope.kind === 'heartbeat') {
129
+ return this.coordinator.heartbeat(
130
+ envelope.roomId,
131
+ envelope.from ?? this.coordinator.diagnostics().hostSignalId,
132
+ );
133
+ }
134
+
135
+ return { kind: 'error', message: `Unsupported signal: ${envelope.kind}` };
136
+ }
137
+
138
+ private acceptWebSocket(request: Request): Response {
139
+ if (this.sockets.size >= getMaxActiveRelaySockets(this.env)) {
140
+ return json({ kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) }, 429);
141
+ }
142
+ const url = new URL(request.url);
143
+ const peerId = url.searchParams.get('peerId');
144
+ const targetPeerId = url.searchParams.get('targetPeerId');
145
+ const roomId = url.searchParams.get('roomId');
146
+ if (!peerId || !targetPeerId || !roomId) {
147
+ return json({ kind: 'error', message: 'Missing peerId, targetPeerId, or roomId' }, 400);
148
+ }
149
+
150
+ const pair = newWebSocketPair();
151
+ const client = pair[0];
152
+ const server = pair[1];
153
+ server.accept();
154
+ this.coordinator.ensureRelayPeer(roomId, peerId, targetPeerId);
155
+ this.coordinator.forward({ kind: 'relay-open', roomId, target: targetPeerId, from: peerId });
156
+ this.sockets.set(peerId, server);
157
+ server.addEventListener('message', async (event) => {
158
+ 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',
172
+ 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);
182
+ return;
183
+ }
184
+ this.sockets.get(targetPeerId)?.send(JSON.stringify(envelope));
185
+ });
186
+ server.addEventListener('close', () => {
187
+ this.sockets.delete(peerId);
188
+ });
189
+
190
+ return new Response(null, { status: 101, webSocket: client } as ResponseInit);
191
+ }
192
+
193
+ private async consumeGlobalRelayBudget(bytes: number): Promise<ErrorSignalEnvelope | null> {
194
+ return this.consumeDailyCounter(
195
+ 'relay-global-bytes',
196
+ bytes,
197
+ getDailyGlobalRelayBytes(this.env),
198
+ );
199
+ }
200
+
201
+ private async consumeDailyCounter(
202
+ prefix: string,
203
+ amount: number,
204
+ max: number,
205
+ ): Promise<ErrorSignalEnvelope | null> {
206
+ if (!Number.isFinite(max) || max <= 0) {
207
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
208
+ }
209
+ const storage = this.state?.storage;
210
+ if (!storage) return null;
211
+
212
+ const key = `${prefix}:${dayKey(Date.now())}`;
213
+ try {
214
+ const current = (await storage.get<number>(key)) ?? 0;
215
+ if (current + amount > max) {
216
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
217
+ }
218
+ await storage.put(key, current + amount);
219
+ return null;
220
+ } catch {
221
+ return { kind: 'error', message: String(P2P_CLOSE_CODES.relayQuotaExceeded) };
222
+ }
223
+ }
224
+
225
+ private async diagnostics() {
226
+ const diagnostics = this.coordinator.diagnostics();
227
+ return {
228
+ ...diagnostics,
229
+ quota: {
230
+ ...diagnostics.quota,
231
+ maxGlobalRelayBytesPerDay: getDailyGlobalRelayBytes(this.env),
232
+ maxGlobalRequestsPerDay: getMaxGlobalRequestsPerDay(this.env),
233
+ maxGlobalWebSocketMessagesPerDay: getMaxGlobalWebSocketMessagesPerDay(this.env),
234
+ maxActiveRelaySockets: getMaxActiveRelaySockets(this.env),
235
+ },
236
+ dailyUsage: await this.dailyUsage(),
237
+ };
238
+ }
239
+
240
+ private async dailyUsage() {
241
+ const day = dayKey(Date.now());
242
+ const storage = this.state?.storage;
243
+ if (!storage) {
244
+ return {
245
+ day,
246
+ globalRequests: 0,
247
+ globalRelayBytes: 0,
248
+ globalWebSocketMessages: 0,
249
+ };
250
+ }
251
+ const [globalRequests, globalRelayBytes, globalWebSocketMessages] = await Promise.all([
252
+ storage.get<number>(`relay-global-requests:${day}`),
253
+ storage.get<number>(`relay-global-bytes:${day}`),
254
+ storage.get<number>(`relay-global-websocket-messages:${day}`),
255
+ ]);
256
+ return {
257
+ day,
258
+ globalRequests: globalRequests ?? 0,
259
+ globalRelayBytes: globalRelayBytes ?? 0,
260
+ globalWebSocketMessages: globalWebSocketMessages ?? 0,
261
+ };
262
+ }
263
+
264
+ private async verifyAccess(
265
+ token: string | undefined,
266
+ roomName: string,
267
+ peerId: string | undefined,
268
+ ): Promise<ErrorSignalEnvelope | null> {
269
+ 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) };
274
+ }
275
+ }
276
+
277
+ export function getDailyGlobalRelayBytes(env: DurableRoomEnv = {}): number {
278
+ const explicit = parsePositiveNumber(env.P2P_COLYSEUS_RELAY_MAX_GLOBAL_BYTES_PER_DAY);
279
+ if (explicit !== undefined) return Math.floor(explicit);
280
+
281
+ const budgetUsd = parsePositiveNumber(env.P2P_COLYSEUS_RELAY_DAILY_BUDGET_USD) ?? 100;
282
+ const estimatedUsdPerGib = parsePositiveNumber(env.P2P_COLYSEUS_RELAY_ESTIMATED_USD_PER_GIB) ?? 1;
283
+ return Math.floor((budgetUsd / estimatedUsdPerGib) * 1024 * 1024 * 1024);
284
+ }
285
+
286
+ export function getMaxGlobalRequestsPerDay(env: DurableRoomEnv = {}): number {
287
+ return Math.floor(parsePositiveNumber(env.P2P_COLYSEUS_MAX_GLOBAL_REQUESTS_PER_DAY) ?? 250_000);
288
+ }
289
+
290
+ export function getMaxGlobalWebSocketMessagesPerDay(env: DurableRoomEnv = {}): number {
291
+ return Math.floor(
292
+ parsePositiveNumber(env.P2P_COLYSEUS_MAX_GLOBAL_WEBSOCKET_MESSAGES_PER_DAY) ?? 2_000_000,
293
+ );
294
+ }
295
+
296
+ export function getMaxActiveRelaySockets(env: DurableRoomEnv = {}): number {
297
+ return Math.floor(parsePositiveNumber(env.P2P_COLYSEUS_MAX_ACTIVE_RELAY_SOCKETS) ?? 200);
298
+ }
299
+
300
+ function json(value: unknown, status = 200): Response {
301
+ return new Response(JSON.stringify(value), {
302
+ status,
303
+ headers: {
304
+ 'Content-Type': 'application/json',
305
+ 'Access-Control-Allow-Origin': '*',
306
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
307
+ 'Access-Control-Allow-Headers': 'Content-Type',
308
+ },
309
+ });
310
+ }
311
+
312
+ function isSignalEnvelope(value: unknown): value is SignalEnvelope {
313
+ if (!isRecord(value) || typeof value['kind'] !== 'string') return false;
314
+ switch (value['kind']) {
315
+ case 'host-register':
316
+ case 'join-request':
317
+ return typeof value['roomName'] === 'string' && value['roomName'].length > 0;
318
+ case 'rtc-offer':
319
+ case 'rtc-answer':
320
+ return (
321
+ typeof value['roomId'] === 'string' &&
322
+ typeof value['target'] === 'string' &&
323
+ isRecord(value['sdp'])
324
+ );
325
+ case 'rtc-ice':
326
+ return (
327
+ typeof value['roomId'] === 'string' &&
328
+ typeof value['target'] === 'string' &&
329
+ isRecord(value['candidate'])
330
+ );
331
+ case 'relay-open':
332
+ return typeof value['roomId'] === 'string' && typeof value['target'] === 'string';
333
+ case 'relay-data':
334
+ return (
335
+ typeof value['roomId'] === 'string' &&
336
+ typeof value['target'] === 'string' &&
337
+ isEnvelope(value['envelope'])
338
+ );
339
+ case 'relay-drain':
340
+ return typeof value['peerId'] === 'string';
341
+ case 'heartbeat':
342
+ return typeof value['roomId'] === 'string';
343
+ default:
344
+ return false;
345
+ }
346
+ }
347
+
348
+ function isRecord(value: unknown): value is Record<string, unknown> {
349
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
350
+ }
351
+
352
+ function newWebSocketPair(): [CloudflareWebSocket, CloudflareWebSocket] {
353
+ const Pair = (
354
+ globalThis as unknown as { WebSocketPair?: new () => Record<'0' | '1', CloudflareWebSocket> }
355
+ ).WebSocketPair;
356
+ if (!Pair) throw new Error('WebSocketPair is not available in this runtime');
357
+ const pair = new Pair();
358
+ return [pair[0], pair[1]];
359
+ }
360
+
361
+ function parsePositiveNumber(value: string | undefined): number | undefined {
362
+ if (value === undefined || value.trim() === '') return undefined;
363
+ const parsed = Number(value);
364
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
365
+ }
366
+
367
+ function isRelayDisabled(env: DurableRoomEnv): boolean {
368
+ return env.P2P_COLYSEUS_RELAY_DISABLED === 'true' || env.P2P_COLYSEUS_RELAY_DISABLED === '1';
369
+ }
370
+
371
+ function dayKey(time: number): string {
372
+ return new Date(time).toISOString().slice(0, 10);
373
+ }
374
+
375
+ function byteLength(value: unknown): number {
376
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
377
+ }
@@ -0,0 +1,22 @@
1
+ export const DEFAULT_RELAY_LIMITS = {
2
+ maxPlayers: 8,
3
+ maxConcurrentHostedRoomsPerIpHash: 3,
4
+ maxConcurrentRelayRoomsPerIpHash: 2,
5
+ maxGlobalRelayBytesPerDay: 100 * 1024 * 1024 * 1024,
6
+ maxGlobalRequestsPerDay: 250_000,
7
+ maxGlobalWebSocketMessagesPerDay: 2_000_000,
8
+ maxActiveRelaySockets: 200,
9
+ maxRelayBytesPerRoom: 100 * 1024 * 1024,
10
+ maxRelayBytesPerIpHashPerDay: 500 * 1024 * 1024,
11
+ maxRelayRoomDurationMs: 2 * 60 * 60 * 1000,
12
+ idleTimeoutMs: 120_000,
13
+ hostHeartbeatTimeoutMs: 15_000,
14
+ maxEnvelopeBytes: 16 * 1024,
15
+ maxMessagesPerPeerPerSecond: 30,
16
+ maxSignalingMessagesPerPeerPerMinute: 120,
17
+ maxIceCandidatesPerPeerPerJoin: 64,
18
+ } as const;
19
+
20
+ export type RelayLimits = {
21
+ readonly [K in keyof typeof DEFAULT_RELAY_LIMITS]: number;
22
+ };
@@ -0,0 +1,75 @@
1
+ import type { Envelope } from '../protocol';
2
+
3
+ export interface RoomMetadata {
4
+ maxClients?: number | undefined;
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ export type SignalEnvelope =
9
+ | {
10
+ readonly kind: 'host-register';
11
+ readonly roomName: string;
12
+ readonly metadata?: RoomMetadata | undefined;
13
+ readonly from?: string | undefined;
14
+ readonly ipHash?: string | undefined;
15
+ readonly accessToken?: string | undefined;
16
+ }
17
+ | { readonly kind: 'host-registered'; readonly roomId: string; readonly hostSignalId: string }
18
+ | {
19
+ readonly kind: 'join-request';
20
+ readonly roomName: string;
21
+ readonly roomId?: string | undefined;
22
+ readonly options?: unknown;
23
+ readonly from?: string | undefined;
24
+ readonly ipHash?: string | undefined;
25
+ readonly accessToken?: string | undefined;
26
+ }
27
+ | {
28
+ readonly kind: 'join-routed';
29
+ readonly roomId: string;
30
+ readonly hostSignalId: string;
31
+ readonly clientSignalId: string;
32
+ }
33
+ | {
34
+ readonly kind: 'rtc-offer';
35
+ readonly roomId: string;
36
+ readonly target: string;
37
+ readonly from?: string | undefined;
38
+ readonly ipHash?: string | undefined;
39
+ readonly sdp: RTCSessionDescriptionInit;
40
+ }
41
+ | {
42
+ readonly kind: 'rtc-answer';
43
+ readonly roomId: string;
44
+ readonly target: string;
45
+ readonly from?: string | undefined;
46
+ readonly ipHash?: string | undefined;
47
+ readonly sdp: RTCSessionDescriptionInit;
48
+ }
49
+ | {
50
+ readonly kind: 'rtc-ice';
51
+ readonly roomId: string;
52
+ readonly target: string;
53
+ readonly from?: string | undefined;
54
+ readonly ipHash?: string | undefined;
55
+ readonly candidate: RTCIceCandidateInit;
56
+ }
57
+ | {
58
+ readonly kind: 'relay-open';
59
+ readonly roomId: string;
60
+ readonly target: string;
61
+ readonly from?: string | undefined;
62
+ readonly ipHash?: string | undefined;
63
+ }
64
+ | {
65
+ readonly kind: 'relay-data';
66
+ readonly roomId: string;
67
+ readonly target: string;
68
+ readonly from?: string | undefined;
69
+ readonly ipHash?: string | undefined;
70
+ readonly envelope: Envelope;
71
+ }
72
+ | { readonly kind: 'relay-drain'; readonly peerId: string }
73
+ | { readonly kind: 'relay-drained'; readonly messages: readonly SignalEnvelope[] }
74
+ | { readonly kind: 'heartbeat'; readonly roomId: string; readonly from?: string | undefined }
75
+ | { readonly kind: 'error'; readonly message: string };
@@ -0,0 +1,110 @@
1
+ export { P2PColyseusDurableRoom } from './durable-room';
2
+
3
+ interface Env {
4
+ P2P_COLYSEUS_ROOM: {
5
+ idFromName(name: string): unknown;
6
+ get(id: unknown): { fetch(request: Request): Promise<Response> };
7
+ };
8
+ P2P_COLYSEUS_MAX_REQUEST_BODY_BYTES?: string | undefined;
9
+ P2P_COLYSEUS_ALLOWED_ORIGINS?: string | undefined;
10
+ }
11
+
12
+ export default {
13
+ async fetch(request: Request, env: Env): Promise<Response> {
14
+ let forwardedRequest = request;
15
+ if (request.method === 'OPTIONS') {
16
+ if (!isOriginAllowed(request, env)) {
17
+ return json({ kind: 'error', message: 'Origin not allowed' }, 403, request, env);
18
+ }
19
+ return new Response(null, { headers: corsHeaders(request, env) });
20
+ }
21
+ if (request.method !== 'GET' && request.method !== 'POST') {
22
+ return json({ kind: 'error', message: 'Use GET or POST' }, 405, request, env);
23
+ }
24
+ if (!isOriginAllowed(request, env)) {
25
+ return json({ kind: 'error', message: 'Origin not allowed' }, 403, request, env);
26
+ }
27
+ const upgrade = request.headers.get('Upgrade')?.toLowerCase();
28
+ if (upgrade === 'websocket') {
29
+ const url = new URL(request.url);
30
+ if (
31
+ !url.searchParams.get('peerId') ||
32
+ !url.searchParams.get('targetPeerId') ||
33
+ !url.searchParams.get('roomId')
34
+ ) {
35
+ return json(
36
+ { kind: 'error', message: 'Missing peerId, targetPeerId, or roomId' },
37
+ 400,
38
+ request,
39
+ env,
40
+ );
41
+ }
42
+ } else if (request.method === 'POST') {
43
+ const body = await request.text();
44
+ if (byteLength(body) > getMaxRequestBodyBytes(env)) {
45
+ return json({ kind: 'error', message: 'Request body too large' }, 413, request, env);
46
+ }
47
+ forwardedRequest = new Request(request.url, {
48
+ method: request.method,
49
+ headers: request.headers,
50
+ body,
51
+ });
52
+ }
53
+ const id = env.P2P_COLYSEUS_ROOM.idFromName('global');
54
+ const room = env.P2P_COLYSEUS_ROOM.get(id);
55
+ if (upgrade === 'websocket') {
56
+ return room.fetch(forwardedRequest);
57
+ }
58
+ const response = await room.fetch(forwardedRequest);
59
+ const headers = new Headers(response.headers);
60
+ for (const [key, value] of Object.entries(corsHeaders(request, env))) headers.set(key, value);
61
+ return new Response(response.body, {
62
+ status: response.status,
63
+ statusText: response.statusText,
64
+ headers,
65
+ });
66
+ },
67
+ };
68
+
69
+ function json(value: unknown, status = 200, request?: Request, env?: Env): Response {
70
+ return new Response(JSON.stringify(value), {
71
+ status,
72
+ headers: {
73
+ 'Content-Type': 'application/json',
74
+ ...corsHeaders(request, env),
75
+ },
76
+ });
77
+ }
78
+
79
+ function byteLength(value: string): number {
80
+ return new TextEncoder().encode(value).byteLength;
81
+ }
82
+
83
+ function corsHeaders(request?: Request, env?: Env): Record<string, string> {
84
+ const origin = request?.headers.get('Origin') ?? '*';
85
+ const allowedOrigin = env && isOriginAllowed(request, env) && origin !== '*' ? origin : '*';
86
+ return {
87
+ 'Access-Control-Allow-Origin': allowedOrigin,
88
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
89
+ 'Access-Control-Allow-Headers': 'Content-Type',
90
+ };
91
+ }
92
+
93
+ function isOriginAllowed(request: Request | undefined, env: Env): boolean {
94
+ const origin = request?.headers.get('Origin');
95
+ if (!origin) return true;
96
+ const configured = env.P2P_COLYSEUS_ALLOWED_ORIGINS;
97
+ if (!configured) return true;
98
+ const allowed = configured
99
+ .split(',')
100
+ .map((value) => value.trim())
101
+ .filter(Boolean);
102
+ return allowed.includes('*') || allowed.includes(origin);
103
+ }
104
+
105
+ function getMaxRequestBodyBytes(env: Env): number {
106
+ const value = env.P2P_COLYSEUS_MAX_REQUEST_BODY_BYTES;
107
+ if (!value) return 32 * 1024;
108
+ const parsed = Number(value);
109
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 32 * 1024;
110
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { StatePatch, StatePatchOperation } from './protocol';
2
+ import { encodeSnapshotState } from './schema';
3
+
4
+ export function encodeSnapshot(value: unknown): unknown {
5
+ return encodeSnapshotState(value);
6
+ }
7
+
8
+ export function createStatePatch(previous: unknown, next: unknown): StatePatch {
9
+ const operations: StatePatchOperation[] = [];
10
+ diffValue([], previous, next, operations);
11
+ return { type: 'patch', operations };
12
+ }
13
+
14
+ export function applyStatePatch(previous: unknown, patch: StatePatch): unknown {
15
+ const root = clone(previous);
16
+ for (const operation of patch.operations) {
17
+ if (operation.op === 'set') setPath(root, operation.path, operation.value);
18
+ else deletePath(root, operation.path);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ export function clone<T>(value: T): T {
24
+ if (value === undefined) return value;
25
+ return JSON.parse(JSON.stringify(value)) as T;
26
+ }
27
+
28
+ function diffValue(
29
+ path: string[],
30
+ previous: unknown,
31
+ next: unknown,
32
+ operations: StatePatchOperation[],
33
+ ): void {
34
+ if (isRecord(previous) && isRecord(next)) {
35
+ for (const key of Object.keys(previous)) {
36
+ if (!(key in next)) operations.push({ op: 'delete', path: [...path, key] });
37
+ }
38
+ for (const [key, nextValue] of Object.entries(next)) {
39
+ diffValue([...path, key], previous[key], nextValue, operations);
40
+ }
41
+ return;
42
+ }
43
+
44
+ if (JSON.stringify(previous) !== JSON.stringify(next)) {
45
+ operations.push({ op: 'set', path, value: clone(next) });
46
+ }
47
+ }
48
+
49
+ function setPath(root: unknown, path: readonly string[], value: unknown): void {
50
+ if (path.length === 0) return;
51
+ const parent = parentAt(root, path);
52
+ parent[path[path.length - 1]!] = clone(value);
53
+ }
54
+
55
+ function deletePath(root: unknown, path: readonly string[]): void {
56
+ if (path.length === 0) return;
57
+ const parent = parentAt(root, path);
58
+ delete parent[path[path.length - 1]!];
59
+ }
60
+
61
+ function parentAt(root: unknown, path: readonly string[]): Record<string, unknown> {
62
+ if (!isRecord(root)) throw new Error('Cannot patch non-object state root');
63
+ let parent = root;
64
+ for (const segment of path.slice(0, -1)) {
65
+ const child = parent[segment];
66
+ if (!isRecord(child)) {
67
+ parent[segment] = {};
68
+ parent = parent[segment] as Record<string, unknown>;
69
+ } else {
70
+ parent = child;
71
+ }
72
+ }
73
+ return parent;
74
+ }
75
+
76
+ function isRecord(value: unknown): value is Record<string, unknown> {
77
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
78
+ }