@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/src/index.ts CHANGED
@@ -1,5 +1,59 @@
1
- export type { P2PAccessTokenClaims } from './access-token';
2
- export { createP2PAccessToken, verifyP2PAccessToken } from './access-token';
1
+ export {
2
+ $changes,
3
+ $childType,
4
+ $decoder,
5
+ $deleteByIndex,
6
+ $encoder,
7
+ $filter,
8
+ $getByIndex,
9
+ $refId,
10
+ $track,
11
+ ArraySchema,
12
+ Callbacks as SchemaCallbacks,
13
+ ChangeTree,
14
+ CollectionSchema,
15
+ Decoder,
16
+ decode,
17
+ decodeKeyValueOperation,
18
+ decodeSchemaOperation,
19
+ defineCustomTypes,
20
+ defineTypes,
21
+ deprecated,
22
+ dumpChanges,
23
+ Encoder,
24
+ encode,
25
+ encodeArray,
26
+ encodeKeyValueOperation,
27
+ encodeSchemaOperation,
28
+ entity,
29
+ getDecoderStateCallbacks,
30
+ getRawChangesCallback,
31
+ MapSchema,
32
+ Metadata,
33
+ OPERATION,
34
+ Reflection,
35
+ ReflectionField,
36
+ ReflectionType,
37
+ registerType,
38
+ Schema,
39
+ SetSchema,
40
+ StateCallbackStrategy,
41
+ StateView,
42
+ schema,
43
+ TypeContext,
44
+ type,
45
+ view,
46
+ } from '@colyseus/schema';
47
+ export type { P2PAccessTokenClaims, RelayGrantClaims } from './access-token';
48
+ export {
49
+ createP2PAccessToken,
50
+ deriveRelayGrantKey,
51
+ mintRelayGrant,
52
+ mintRelayToken,
53
+ readP2PAccessToken,
54
+ readRelayGrant,
55
+ verifyP2PAccessToken,
56
+ } from './access-token';
3
57
  export { Callbacks } from './callbacks';
4
58
  export type { PacketChannel, PacketChannelMode } from './channels';
5
59
  export {
@@ -19,7 +73,6 @@ export {
19
73
  export { SignalingRelayCoordinator } from './cloudflare/coordinator';
20
74
  export type { RelayLimits } from './cloudflare/limits';
21
75
  export { DEFAULT_RELAY_LIMITS } from './cloudflare/limits';
22
- export { applyStatePatch, clone, createStatePatch, encodeSnapshot } from './codec';
23
76
  export type {
24
77
  NetDiagnostics,
25
78
  RelayProofDiagnostics,
@@ -139,58 +192,12 @@ export {
139
192
  WebSocketClient,
140
193
  WebSocketTransport,
141
194
  } from './platform';
142
- export type { Envelope, Snapshot, StatePatch, StatePatchOperation } from './protocol';
195
+ export type { Envelope } from './protocol';
143
196
  export { isEnvelope, P2P_CLOSE_CODES } from './protocol';
144
197
  export { CloudflareRelayPacketChannel } from './relay';
145
198
  export type { CompatClient } from './room';
146
199
  export { Room } from './room';
147
200
  export type { RoomClass } from './runtime';
148
201
  export { UniversalRoomRuntime } from './runtime';
149
- export {
150
- $changes,
151
- $childType,
152
- $decoder,
153
- $deleteByIndex,
154
- $encoder,
155
- $filter,
156
- $getByIndex,
157
- $refId,
158
- $track,
159
- ArraySchema,
160
- Callbacks as SchemaCallbacks,
161
- ChangeTree,
162
- CollectionSchema,
163
- Decoder,
164
- decode,
165
- decodeKeyValueOperation,
166
- decodeSchemaOperation,
167
- defineCustomTypes,
168
- defineTypes,
169
- deprecated,
170
- dumpChanges,
171
- Encoder,
172
- encode,
173
- encodeArray,
174
- encodeKeyValueOperation,
175
- encodeSchemaOperation,
176
- entity,
177
- getDecoderStateCallbacks,
178
- getRawChangesCallback,
179
- MapSchema,
180
- Metadata,
181
- OPERATION,
182
- Reflection,
183
- ReflectionField,
184
- ReflectionType,
185
- registerType,
186
- Schema,
187
- SetSchema,
188
- StateCallbackStrategy,
189
- StateView,
190
- schema,
191
- TypeContext,
192
- type,
193
- view,
194
- } from './schema';
195
202
  export { createWebRTCDataChannelPacketChannel, WebRTCDataChannelPacketChannel } from './webrtc';
196
203
  export { createWebSocketPacketChannel, WebSocketPacketChannel } from './websocket';
package/src/protocol.ts CHANGED
@@ -1,16 +1,13 @@
1
- export interface Snapshot {
2
- readonly type: 'snapshot';
3
- readonly state: unknown;
4
- }
5
-
6
- export interface StatePatch {
7
- readonly type: 'patch';
8
- readonly operations: readonly StatePatchOperation[];
9
- }
10
-
11
- export type StatePatchOperation =
12
- | { readonly op: 'set'; readonly path: readonly string[]; readonly value: unknown }
13
- | { readonly op: 'delete'; readonly path: readonly string[] };
1
+ /**
2
+ * The JSON `Envelope` wire protocol shared by every P2P transport.
3
+ *
4
+ * Authoritative state is encoded with the REAL `@colyseus/schema` `Encoder`
5
+ * (see `runtime.ts`) and decoded with the real `Decoder` (`client.ts`). The
6
+ * encoder's `Uint8Array` output rides as a base64 STRING on the envelope
7
+ * (`join-ok.state`, `state-snapshot.state`, `state-patch.patch`), so the whole
8
+ * envelope survives `JSON.stringify` on any transport unchanged. See
9
+ * `wire-bytes.ts`.
10
+ */
14
11
 
15
12
  export type Envelope =
16
13
  | {
@@ -23,13 +20,24 @@ export type Envelope =
23
20
  readonly kind: 'join-ok';
24
21
  readonly requestId: string;
25
22
  readonly sessionId: string;
26
- readonly snapshot: Snapshot;
23
+ /** base64-encoded full-state bytes from `Encoder.encodeAll`/`encodeAllView`. */
24
+ readonly state: string;
27
25
  readonly clock: number;
28
26
  }
29
27
  | { readonly kind: 'join-error'; readonly requestId: string; readonly message: string }
30
28
  | { readonly kind: 'message'; readonly type: string; readonly payload?: unknown }
31
- | { readonly kind: 'state-patch'; readonly patch: StatePatch; readonly clock: number }
32
- | { readonly kind: 'state-snapshot'; readonly snapshot: Snapshot; readonly clock: number }
29
+ | {
30
+ readonly kind: 'state-patch';
31
+ /** base64-encoded delta bytes from `Encoder.encode`/`encodeView`. */
32
+ readonly patch: string;
33
+ readonly clock: number;
34
+ }
35
+ | {
36
+ readonly kind: 'state-snapshot';
37
+ /** base64-encoded full-state bytes (resync). */
38
+ readonly state: string;
39
+ readonly clock: number;
40
+ }
33
41
  | { readonly kind: 'state-resync'; readonly clock: number }
34
42
  | { readonly kind: 'leave'; readonly code?: number; readonly reason?: string }
35
43
  | { readonly kind: 'ping'; readonly t: number }
@@ -0,0 +1,22 @@
1
+ import type { RoomClass } from './runtime';
2
+
3
+ /**
4
+ * The locally-registered room classes, keyed by room name. This is the source
5
+ * the CLIENT decoder is built from (brief decision: no Reflection handshake —
6
+ * the client derives its `@colyseus/schema` state class from the same room
7
+ * class the host holds). Both the loopback client path and the host runtime
8
+ * register here, so a co-located client always resolves the decoder root.
9
+ */
10
+ const rooms = new Map<string, RoomClass>();
11
+
12
+ export function registerLoopbackRoom(roomName: string, roomClass: RoomClass): void {
13
+ rooms.set(roomName, roomClass);
14
+ }
15
+
16
+ export function getRegisteredRoom(roomName: string): RoomClass | undefined {
17
+ return rooms.get(roomName);
18
+ }
19
+
20
+ export function registeredRoomNames(): string[] {
21
+ return [...rooms.keys()];
22
+ }
package/src/room.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { encodeSnapshotState } from './schema';
1
+ import type { StateView } from '@colyseus/schema';
2
2
 
3
3
  export interface CompatClient {
4
4
  readonly id: string;
@@ -7,6 +7,13 @@ export interface CompatClient {
7
7
  userData?: unknown;
8
8
  auth?: unknown;
9
9
  reconnectionToken: string;
10
+ /**
11
+ * Per-client visibility filter for `@view()`-gated state — identical to real
12
+ * Colyseus. A game sets `client.view = new StateView()` in `onJoin` and adds
13
+ * the refs this client may see; the host runtime encodes each viewed client
14
+ * separately so gated fields never reach a client whose view excludes them.
15
+ */
16
+ view?: StateView;
10
17
  ref: {
11
18
  on(event: string, handler: (...args: unknown[]) => void): void;
12
19
  off(event: string, handler: (...args: unknown[]) => void): void;
@@ -251,10 +258,6 @@ export abstract class Room<State = unknown> {
251
258
  this.runtime?.broadcastState();
252
259
  }
253
260
 
254
- _snapshot(): unknown {
255
- return encodeSnapshotState(this.state);
256
- }
257
-
258
261
  _disposeRuntime(): void {
259
262
  for (const timer of this.simulationTimers) clearInterval(timer);
260
263
  for (const timer of this.timeoutTimers) clearTimeout(timer);
package/src/runtime.ts CHANGED
@@ -1,14 +1,19 @@
1
+ import { Encoder, Schema, type StateView } from '@colyseus/schema';
1
2
  import type { PacketChannel } from './channels';
2
- import { clone, createStatePatch, encodeSnapshot } from './codec';
3
- import type { Envelope, Snapshot } from './protocol';
3
+ import type { Envelope } from './protocol';
4
4
  import { P2P_CLOSE_CODES } from './protocol';
5
5
  import type { BroadcastOptions, CompatClient, Room } from './room';
6
+ import { registerLoopbackRoom } from './room-registry';
7
+ import { bytesToBase64 } from './wire-bytes';
6
8
 
7
9
  export type RoomClass<T extends Room = Room> = new () => T;
8
10
 
11
+ const EMPTY_BYTES = new Uint8Array(0);
12
+
9
13
  interface RuntimeClient extends CompatClient {
10
14
  readonly channel: PacketChannel;
11
- lastState: unknown;
15
+ /** Per-client monotonic patch sequence — the client resyncs on a gap. */
16
+ clock: number;
12
17
  }
13
18
 
14
19
  interface PendingReconnection {
@@ -19,15 +24,29 @@ interface PendingReconnection {
19
24
 
20
25
  export class UniversalRoomRuntime<T extends Room = Room> {
21
26
  readonly room: T;
22
- private clock = 0;
23
27
  private readonly clients = new Map<string, RuntimeClient>();
24
28
  private readonly reconnections = new Map<string, PendingReconnection>();
29
+ /**
30
+ * The REAL `@colyseus/schema` encoder bound to `room.state`. Created lazily
31
+ * (after `onCreate`, so field-initialized or `setState`-assigned Schema is
32
+ * populated) and rebuilt if `room.state` is replaced. Rooms whose state is
33
+ * not a `Schema` (lifecycle-only fixtures) simply do not replicate — there
34
+ * is nothing the real encoder can carry.
35
+ */
36
+ private encoder?: Encoder<Schema>;
37
+ private hasFilters = false;
38
+ /** Set when `room.state` is REPLACED (setState) so all clients get a fresh full snapshot. */
39
+ private pendingResyncAll = false;
25
40
 
26
41
  constructor(
27
42
  roomClass: RoomClass<T>,
28
43
  private readonly roomName: string,
29
44
  options?: unknown,
30
45
  ) {
46
+ // The room class is also the client-side decoder root source; register it
47
+ // so a co-located client (loopback, browser host-local, universal Node)
48
+ // resolves the decoder from the same class the host encodes with.
49
+ registerLoopbackRoom(roomName, roomClass);
31
50
  this.room = new roomClass();
32
51
  this.room._attachRuntime({
33
52
  broadcastMessage: (type, payload, options) => this.broadcastMessage(type, payload, options),
@@ -115,7 +134,7 @@ export class UniversalRoomRuntime<T extends Room = Room> {
115
134
  reconnectionToken: pendingReconnection?.previousClient.reconnectionToken ?? sessionId,
116
135
  ref: events,
117
136
  channel,
118
- lastState: undefined,
137
+ clock: 0,
119
138
  raw: (data, _options, cb) => {
120
139
  channel.send({ kind: 'message', type: 'raw', payload: data });
121
140
  cb?.();
@@ -171,16 +190,19 @@ export class UniversalRoomRuntime<T extends Room = Room> {
171
190
  } else {
172
191
  this.room.onJoin?.(client, options);
173
192
  }
174
- const snapshot = this.createSnapshot();
175
- client.lastState = clone(snapshot.state);
193
+ // Full state for THIS client (respecting its `@view()`), then the delta
194
+ // that onJoin produced goes to the OTHER clients only — the joiner already
195
+ // has it in its full snapshot, so it must not also receive the patch.
196
+ const fullBytes = this.encodeFullState(client.view);
197
+ client.clock += 1;
176
198
  channel.send({
177
199
  kind: 'join-ok',
178
200
  requestId,
179
201
  sessionId,
180
- snapshot,
181
- clock: ++this.clock,
202
+ state: bytesToBase64(fullBytes),
203
+ clock: client.clock,
182
204
  });
183
- this.broadcastState();
205
+ this.flushPatches(sessionId);
184
206
  }
185
207
 
186
208
  private removeByChannel(channel: PacketChannel, consented: boolean): void {
@@ -238,18 +260,141 @@ export class UniversalRoomRuntime<T extends Room = Room> {
238
260
  }
239
261
 
240
262
  broadcastState(): boolean {
241
- const snapshot = this.createSnapshot();
242
- let clock: number | undefined;
243
- let sent = false;
244
- for (const client of this.clients.values()) {
245
- const patch = createStatePatch(client.lastState, snapshot.state);
246
- if (patch.operations.length === 0) continue;
247
- clock ??= ++this.clock;
248
- client.lastState = clone(snapshot.state);
249
- client.channel.send({ kind: 'state-patch', patch, clock });
250
- sent = true;
263
+ return this.flushPatches();
264
+ }
265
+
266
+ /**
267
+ * Bind (or rebind) the real encoder to `room.state`. Returns undefined when
268
+ * the room has no `Schema` state — such a room simply does not replicate.
269
+ */
270
+ private ensureEncoder(): Encoder<Schema> | undefined {
271
+ const state = this.room.state as unknown;
272
+ // Stateless / lifecycle-only rooms are legitimate — nothing to replicate,
273
+ // stay silent.
274
+ if (state === undefined || state === null) return undefined;
275
+ // A non-Schema state (e.g. `setState({ score: 0 })` with a plain object)
276
+ // CANNOT be replicated by the real encoder. Real Colyseus throws on this;
277
+ // surface it LOUDLY at the call rather than silently dropping replication —
278
+ // a silent divergence is exactly the defect this seam exists to kill.
279
+ if (!(state instanceof Schema)) {
280
+ const got =
281
+ typeof state === 'object'
282
+ ? ((state as { constructor?: { name?: string } }).constructor?.name ?? 'plain object')
283
+ : typeof state;
284
+ throw new Error(
285
+ `@vgai/p2p-colyseus: room "${this.roomName}" state must extend @colyseus/schema Schema; ` +
286
+ `got ${got}. setState(plainObject) does not replicate — declare a Schema state class ` +
287
+ `(e.g. \`class State extends Schema { @type('number') score = 0 }\`) and ` +
288
+ `setState(new State()).`,
289
+ );
290
+ }
291
+ if (this.encoder && this.encoder.state === state) return this.encoder;
292
+ if (this.encoder) this.pendingResyncAll = true; // state instance was replaced (setState)
293
+ this.encoder = new Encoder(state);
294
+ this.hasFilters = this.encoder.context.hasFilters;
295
+ return this.encoder;
296
+ }
297
+
298
+ /**
299
+ * Encode the FULL current state for a client — filtered to its `@view()`
300
+ * when the schema has view-gated fields and the client has a view. Mirrors
301
+ * `@colyseus/core` `SchemaSerializer.getFullState`.
302
+ */
303
+ private encodeFullState(view?: StateView): Uint8Array {
304
+ const encoder = this.ensureEncoder();
305
+ if (!encoder) return EMPTY_BYTES;
306
+ const it = { offset: 0 };
307
+ const full = encoder.encodeAll(it);
308
+ const sharedOffset = it.offset;
309
+ if (this.hasFilters && view) {
310
+ return Uint8Array.from(encoder.encodeAllView(view, sharedOffset, it));
251
311
  }
252
- return sent;
312
+ return Uint8Array.from(full);
313
+ }
314
+
315
+ /**
316
+ * Encode the pending delta once and fan it out per client — mirrors
317
+ * `@colyseus/core` `SchemaSerializer.applyPatches`: viewless clients share
318
+ * one encoded patch, while each `@view()` client gets `encodeView` bytes that
319
+ * NEVER contain another view's filtered fields (the confidentiality property).
320
+ * `exceptSessionId` skips a just-joined client that already holds the full
321
+ * state.
322
+ */
323
+ private flushPatches(exceptSessionId?: string): boolean {
324
+ const encoder = this.ensureEncoder();
325
+ const recipients = [...this.clients.values()].filter(
326
+ (client) => client.sessionId !== exceptSessionId,
327
+ );
328
+ if (!encoder || recipients.length === 0) {
329
+ encoder?.discardChanges();
330
+ return false;
331
+ }
332
+
333
+ if (this.pendingResyncAll) {
334
+ this.pendingResyncAll = false;
335
+ for (const client of recipients) this.sendSnapshotTo(client);
336
+ encoder.discardChanges();
337
+ return true;
338
+ }
339
+
340
+ if (!encoder.hasChanges) {
341
+ // No state mutation, but a client may have manual view add/remove ops.
342
+ if (this.hasFilters) {
343
+ const it = { offset: 0 };
344
+ const sharedOffset = it.offset;
345
+ for (const client of recipients) {
346
+ if (client.view && client.view.changes.size > 0) {
347
+ this.sendPatchTo(
348
+ client,
349
+ Uint8Array.from(encoder.encodeView(client.view, sharedOffset, it)),
350
+ );
351
+ }
352
+ }
353
+ }
354
+ return false;
355
+ }
356
+
357
+ const it = { offset: 0 };
358
+ const encodedChanges = encoder.encode(it);
359
+ const sharedOffset = it.offset;
360
+ // Copy the shared (non-filtered) changes before any encodeView reuses the buffer.
361
+ const sharedCopy = Uint8Array.from(encodedChanges);
362
+ if (!this.hasFilters) {
363
+ for (const client of recipients) this.sendPatchTo(client, sharedCopy);
364
+ } else {
365
+ const perView = new Map<StateView, Uint8Array>();
366
+ for (const client of recipients) {
367
+ if (!client.view) {
368
+ this.sendPatchTo(client, sharedCopy);
369
+ continue;
370
+ }
371
+ let bytes = perView.get(client.view);
372
+ if (!bytes) {
373
+ bytes = Uint8Array.from(encoder.encodeView(client.view, sharedOffset, it));
374
+ perView.set(client.view, bytes);
375
+ }
376
+ this.sendPatchTo(client, bytes);
377
+ }
378
+ }
379
+ encoder.discardChanges();
380
+ return true;
381
+ }
382
+
383
+ private sendPatchTo(client: RuntimeClient, bytes: Uint8Array): void {
384
+ client.channel.send({
385
+ kind: 'state-patch',
386
+ patch: bytesToBase64(bytes),
387
+ clock: ++client.clock,
388
+ });
389
+ }
390
+
391
+ private sendSnapshotTo(client: RuntimeClient): void {
392
+ const bytes = this.encodeFullState(client.view);
393
+ client.channel.send({
394
+ kind: 'state-snapshot',
395
+ state: bytesToBase64(bytes),
396
+ clock: ++client.clock,
397
+ });
253
398
  }
254
399
 
255
400
  private async disconnect(code = 4000): Promise<void> {
@@ -293,13 +438,7 @@ export class UniversalRoomRuntime<T extends Room = Room> {
293
438
  }
294
439
 
295
440
  private sendSnapshot(client: RuntimeClient): void {
296
- const snapshot = this.createSnapshot();
297
- client.lastState = clone(snapshot.state);
298
- client.channel.send({ kind: 'state-snapshot', snapshot, clock: ++this.clock });
299
- }
300
-
301
- private createSnapshot(): Snapshot {
302
- return { type: 'snapshot', state: encodeSnapshot(this.room._snapshot()) };
441
+ this.sendSnapshotTo(client);
303
442
  }
304
443
  }
305
444
 
package/src/webrtc.ts CHANGED
@@ -13,23 +13,53 @@ interface DataChannelLike {
13
13
  type EnvelopeHandler = (envelope: Envelope) => void;
14
14
  type CloseHandler = (reason?: string) => void;
15
15
 
16
+ /**
17
+ * The label of the SECOND data channel — unordered, `maxRetransmits: 0` — that
18
+ * carries `sendUnreliable` traffic. The offerer creates it alongside the
19
+ * reliable channel; the answerer receives it via `ondatachannel` and hands it
20
+ * to `attachUnreliableChannel`. Exported so the negotiation code (`engine.ts`)
21
+ * and this class agree on the one string.
22
+ */
23
+ export const WEBRTC_UNRELIABLE_CHANNEL_LABEL = 'p2p-colyseus-unreliable';
24
+
16
25
  export class WebRTCDataChannelPacketChannel implements PacketChannel {
17
26
  readonly mode = 'webrtc' as const;
18
27
  private readonly envelopeHandlers = new Set<EnvelopeHandler>();
19
28
  private readonly closeHandlers = new Set<CloseHandler>();
29
+ private unreliableChannel: DataChannelLike | null = null;
20
30
 
21
31
  constructor(
22
32
  readonly peerId: string,
23
33
  private readonly dataChannel: DataChannelLike,
34
+ unreliableChannel?: DataChannelLike,
24
35
  ) {
25
- dataChannel.addEventListener('message', (event) => {
36
+ this.bindReceive(dataChannel);
37
+ dataChannel.addEventListener('close', () => {
38
+ for (const handler of this.closeHandlers) handler('closed');
39
+ });
40
+ if (unreliableChannel) this.attachUnreliableChannel(unreliableChannel);
41
+ }
42
+
43
+ /**
44
+ * Attach the unreliable sub-channel after construction. The answerer learns
45
+ * of it through a SEPARATE `ondatachannel` event, which may arrive before or
46
+ * after the reliable channel that constructs this object; the offerer passes
47
+ * it in the constructor instead. Frames received on it dispatch through the
48
+ * same envelope handlers as the reliable channel. The unreliable channel does
49
+ * NOT govern this channel's lifecycle — the reliable channel's `close` does —
50
+ * so no close handler is bound to it.
51
+ */
52
+ attachUnreliableChannel(channel: DataChannelLike): void {
53
+ this.unreliableChannel = channel;
54
+ this.bindReceive(channel);
55
+ }
56
+
57
+ private bindReceive(channel: DataChannelLike): void {
58
+ channel.addEventListener('message', (event) => {
26
59
  if (typeof event.data !== 'string') return;
27
60
  const envelope = JSON.parse(event.data) as Envelope;
28
61
  for (const handler of this.envelopeHandlers) handler(envelope);
29
62
  });
30
- dataChannel.addEventListener('close', () => {
31
- for (const handler of this.closeHandlers) handler('closed');
32
- });
33
63
  }
34
64
 
35
65
  send(envelope: Envelope): void {
@@ -39,6 +69,17 @@ export class WebRTCDataChannelPacketChannel implements PacketChannel {
39
69
  this.dataChannel.send(JSON.stringify(envelope));
40
70
  }
41
71
 
72
+ sendUnreliable(envelope: Envelope): void {
73
+ const channel = this.unreliableChannel;
74
+ if (channel && channel.readyState === 'open') {
75
+ channel.send(JSON.stringify(envelope));
76
+ return;
77
+ }
78
+ // No unreliable channel yet (still negotiating) or it has gone away — never
79
+ // drop or throw: fall back to the reliable channel so the message arrives.
80
+ this.send(envelope);
81
+ }
82
+
42
83
  onEnvelope(handler: EnvelopeHandler): () => void {
43
84
  this.envelopeHandlers.add(handler);
44
85
  return () => this.envelopeHandlers.delete(handler);
@@ -51,6 +92,7 @@ export class WebRTCDataChannelPacketChannel implements PacketChannel {
51
92
 
52
93
  close(): void {
53
94
  this.dataChannel.close();
95
+ this.unreliableChannel?.close();
54
96
  }
55
97
 
56
98
  closeAfterFlush(delayMs = 100): void {
@@ -73,6 +115,7 @@ export class WebRTCDataChannelPacketChannel implements PacketChannel {
73
115
  export function createWebRTCDataChannelPacketChannel(
74
116
  peerId: string,
75
117
  dataChannel: RTCDataChannel,
118
+ unreliableChannel?: RTCDataChannel,
76
119
  ): PacketChannel {
77
- return new WebRTCDataChannelPacketChannel(peerId, dataChannel);
120
+ return new WebRTCDataChannelPacketChannel(peerId, dataChannel, unreliableChannel);
78
121
  }
package/src/websocket.ts CHANGED
@@ -30,7 +30,7 @@ export class WebSocketPacketChannel implements PacketChannel {
30
30
  // A real Colyseus server speaks a binary schema-diff protocol, not
31
31
  // this package's JSON Envelope format — a binary frame here is a
32
32
  // live signal the peer is wire-incompatible, not something to
33
- // silently discard (see docs/decisions/h4-endpoint-interop.md).
33
+ // silently discard.
34
34
  for (const handler of this.protocolErrorHandlers) {
35
35
  handler('received a non-text (binary) WebSocket frame');
36
36
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Base64 carriage for real `@colyseus/schema` binary state on the JSON
3
+ * `Envelope` wire.
4
+ *
5
+ * The replication seam encodes host state with the REAL `@colyseus/schema`
6
+ * `Encoder`, which emits a `Uint8Array`. Every P2P transport (browser-hosted
7
+ * WebRTC, universal WebSocket, forced/​fallback Cloudflare relay, in-process
8
+ * loopback) carries an `Envelope` that is `JSON.stringify`'d, so the binary
9
+ * patch/snapshot bytes ride as a base64 STRING field inside that envelope —
10
+ * universal, and unchanged by `JSON.stringify`. There is deliberately no
11
+ * native binary frame kind: a raw binary frame during join is the live signal
12
+ * `client.ts`'s H4 guard uses to detect a real Colyseus server, and a separate
13
+ * binary-frame optimization is out of scope here.
14
+ *
15
+ * `btoa`/`atob` exist in browsers and in Node ≥16 (`globalThis`), so these work
16
+ * in the deployed static bundle and in headless unit tests without a Buffer
17
+ * dependency.
18
+ */
19
+
20
+ const CHUNK = 0x8000;
21
+
22
+ export function bytesToBase64(bytes: Uint8Array): string {
23
+ let binary = '';
24
+ for (let i = 0; i < bytes.length; i += CHUNK) {
25
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
26
+ }
27
+ return btoa(binary);
28
+ }
29
+
30
+ export function base64ToBytes(base64: string): Uint8Array {
31
+ const binary = atob(base64);
32
+ const bytes = new Uint8Array(binary.length);
33
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
34
+ return bytes;
35
+ }