@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
package/src/index.ts
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
| {
|
|
32
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
181
|
-
clock:
|
|
202
|
+
state: bytesToBase64(fullBytes),
|
|
203
|
+
clock: client.clock,
|
|
182
204
|
});
|
|
183
|
-
this.
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|