@vgai/p2p-colyseus 0.5.2 → 0.5.4
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/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@vgai/p2p-colyseus",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.4",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"description": "Colyseus-compatible transport for vgai — real Colyseus, loopback, WebRTC, or relay, chosen at runtime.",
|
|
8
8
|
"homepage": "https://github.com/volter-ai/vgai-engine#readme",
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts",
|
|
20
20
|
"./engine": "./src/engine.ts",
|
|
21
|
-
"./schema": "./src/schema.ts",
|
|
22
|
-
"./codec": "./src/codec.ts",
|
|
23
21
|
"./client": "./src/client.ts",
|
|
24
22
|
"./browser": "./src/browser.ts",
|
|
25
23
|
"./server": "./src/room.ts",
|
|
@@ -35,11 +33,11 @@
|
|
|
35
33
|
"wrangler:deploy": "wrangler deploy --config wrangler.toml"
|
|
36
34
|
},
|
|
37
35
|
"dependencies": {
|
|
36
|
+
"@colyseus/schema": "^4.0.14",
|
|
38
37
|
"ws": "^8.19.0"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@colyseus/sdk": "^0.17.34",
|
|
42
|
-
"@colyseus/schema": "^4.0.14",
|
|
43
41
|
"@colyseus/ws-transport": "^0.17.9",
|
|
44
42
|
"@types/node": "^25.3.0",
|
|
45
43
|
"@types/ws": "^8.5.13",
|
package/src/access-token.ts
CHANGED
|
@@ -1,7 +1,101 @@
|
|
|
1
1
|
export interface P2PAccessTokenClaims {
|
|
2
2
|
readonly roomName: string;
|
|
3
3
|
readonly peerId?: string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* The account this token was minted for. Absent on anonymous/self-hosted
|
|
6
|
+
* tokens (which must still verify — backward compat); present on managed-lane
|
|
7
|
+
* tokens so the relay can meter a free byte allotment per account.
|
|
8
|
+
*/
|
|
9
|
+
readonly userId?: string | undefined;
|
|
4
10
|
readonly expiresAt: number;
|
|
11
|
+
/**
|
|
12
|
+
* Token purpose. Absent/`'relay'` → a spendable relay access token the relay
|
|
13
|
+
* accepts. `'grant'` → a per-game mint GRANT (see {@link mintRelayGrant}):
|
|
14
|
+
* NOT spendable on the relay, only exchangeable at `/relay/session` for a
|
|
15
|
+
* fresh relay token. The relay read path ({@link readP2PAccessToken}) rejects
|
|
16
|
+
* `'grant'` outright, and a grant is signed with a DIFFERENT key besides, so
|
|
17
|
+
* a grant can never be replayed as a relay token.
|
|
18
|
+
*/
|
|
19
|
+
readonly kind?: 'relay' | 'grant' | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The grant claims baked into a deployed game's public manifest. A grant
|
|
24
|
+
* authorizes ANONYMOUS players of that one game to mint short-lived relay
|
|
25
|
+
* tokens for the deployer's account, scoped to the deployed room — the
|
|
26
|
+
* authorization that lets `/relay/session` (unauthenticated) safely issue a
|
|
27
|
+
* relay token bearing the deployer's `userId`. Abuse is bounded to the
|
|
28
|
+
* deployer's per-account/per-room relay byte budget, exactly as the old baked
|
|
29
|
+
* relay token was — but the grant is refreshable and, unlike a raw relay
|
|
30
|
+
* token, is not directly spendable on the relay.
|
|
31
|
+
*/
|
|
32
|
+
export interface RelayGrantClaims {
|
|
33
|
+
readonly kind: 'grant';
|
|
34
|
+
readonly userId: string;
|
|
35
|
+
readonly roomName: string;
|
|
36
|
+
readonly expiresAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Domain-separated grant-signing subkey derived from the relay access secret.
|
|
41
|
+
* A grant is signed with THIS key, never the raw secret, so the relay — which
|
|
42
|
+
* only ever holds the raw secret — cannot verify (and therefore cannot accept)
|
|
43
|
+
* a grant as a spendable token. This gives cryptographic key separation
|
|
44
|
+
* between "grant" and "relay token" with a SINGLE provisioned secret; the relay
|
|
45
|
+
* needs no new binding and never learns this subkey.
|
|
46
|
+
*/
|
|
47
|
+
export async function deriveRelayGrantKey(secret: string): Promise<string> {
|
|
48
|
+
return sign(secret, 'vgai-relay-grant-v1');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Mint a long-lived per-game relay-mint GRANT (see {@link RelayGrantClaims}).
|
|
53
|
+
* Signed with {@link deriveRelayGrantKey}, so it is inert against the relay.
|
|
54
|
+
* `vgai deploy` mints one of these (authenticated as the deployer) and bakes it
|
|
55
|
+
* into the served manifest; players exchange it at `/relay/session` for a fresh
|
|
56
|
+
* short-lived relay token at connect time — which is why a deployed game's
|
|
57
|
+
* multiplayer never expires on the token's clock, only on the grant's much
|
|
58
|
+
* longer one.
|
|
59
|
+
*/
|
|
60
|
+
export async function mintRelayGrant(
|
|
61
|
+
secret: string,
|
|
62
|
+
opts: { userId: string; roomName: string; ttlMs: number; nowMs: number },
|
|
63
|
+
): Promise<{ grant: string; expiresAt: number }> {
|
|
64
|
+
const grantKey = await deriveRelayGrantKey(secret);
|
|
65
|
+
const expiresAt = opts.nowMs + opts.ttlMs;
|
|
66
|
+
const grant = await createP2PAccessToken(grantKey, {
|
|
67
|
+
kind: 'grant',
|
|
68
|
+
userId: opts.userId,
|
|
69
|
+
roomName: opts.roomName,
|
|
70
|
+
expiresAt,
|
|
71
|
+
});
|
|
72
|
+
return { grant, expiresAt };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify a grant and return its `{ userId, roomName }` (or null when invalid,
|
|
77
|
+
* wrong-kind, or expired). The exchange path `/relay/session` calls this to
|
|
78
|
+
* decide whether to mint a relay token, and for which account/room.
|
|
79
|
+
*/
|
|
80
|
+
export async function readRelayGrant(
|
|
81
|
+
secret: string,
|
|
82
|
+
grant: string,
|
|
83
|
+
now = Date.now(),
|
|
84
|
+
): Promise<{ userId: string; roomName: string } | null> {
|
|
85
|
+
const grantKey = await deriveRelayGrantKey(secret);
|
|
86
|
+
const [payload, signature, extra] = grant.split('.');
|
|
87
|
+
if (!payload || !signature || extra !== undefined) return null;
|
|
88
|
+
if (!constantTimeEqual(signature, await sign(grantKey, payload))) return null;
|
|
89
|
+
let claims: RelayGrantClaims;
|
|
90
|
+
try {
|
|
91
|
+
claims = JSON.parse(decodeBase64Url(payload)) as RelayGrantClaims;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (claims.kind !== 'grant') return null;
|
|
96
|
+
if (typeof claims.userId !== 'string' || typeof claims.roomName !== 'string') return null;
|
|
97
|
+
if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null;
|
|
98
|
+
return { userId: claims.userId, roomName: claims.roomName };
|
|
5
99
|
}
|
|
6
100
|
|
|
7
101
|
export async function createP2PAccessToken(
|
|
@@ -13,27 +107,76 @@ export async function createP2PAccessToken(
|
|
|
13
107
|
return `${payload}.${signature}`;
|
|
14
108
|
}
|
|
15
109
|
|
|
16
|
-
|
|
110
|
+
/**
|
|
111
|
+
* NEW (2026-08): the shared relay-token mint. A thin wrapper over
|
|
112
|
+
* {@link createP2PAccessToken} that turns a wall-clock issuance moment plus a
|
|
113
|
+
* TTL into the `expiresAt` the token claims carry, so every managed home that
|
|
114
|
+
* issues relay access (the CF-native `@vgai/auth` Worker, and the legacy
|
|
115
|
+
* account-service route in `app.ts` until it is retired) computes expiry one
|
|
116
|
+
* way. Keeps the `createP2PAccessToken` primitive untouched — this only owns
|
|
117
|
+
* the `expiresAt = nowMs + ttlMs` convention and returns it alongside the token
|
|
118
|
+
* so the caller can echo it to the client.
|
|
119
|
+
*
|
|
120
|
+
* `expiresAt` is an epoch-MILLISECONDS instant, not seconds: the read path
|
|
121
|
+
* ({@link readP2PAccessToken}) compares it against `Date.now()` directly, and
|
|
122
|
+
* the account-service route it mirrors (`app.ts` `relayToken`) already mints
|
|
123
|
+
* `Date.now() + ttlMs`. A seconds value would be ~1000x smaller than `now` and
|
|
124
|
+
* so read as permanently expired.
|
|
125
|
+
*/
|
|
126
|
+
export async function mintRelayToken(
|
|
127
|
+
secret: string,
|
|
128
|
+
opts: { userId: string; roomName: string; ttlMs: number; nowMs: number },
|
|
129
|
+
): Promise<{ token: string; expiresAt: number }> {
|
|
130
|
+
const expiresAt = opts.nowMs + opts.ttlMs;
|
|
131
|
+
const token = await createP2PAccessToken(secret, {
|
|
132
|
+
roomName: opts.roomName,
|
|
133
|
+
userId: opts.userId,
|
|
134
|
+
expiresAt,
|
|
135
|
+
});
|
|
136
|
+
return { token, expiresAt };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify a token AND return its decoded claims (or null when invalid). This is
|
|
141
|
+
* the read path the relay uses to recover `userId` for per-account metering;
|
|
142
|
+
* {@link verifyP2PAccessToken} is the boolean shorthand over the same checks.
|
|
143
|
+
*/
|
|
144
|
+
export async function readP2PAccessToken(
|
|
17
145
|
secret: string,
|
|
18
146
|
token: string,
|
|
19
147
|
expected: { roomName: string; peerId?: string | undefined },
|
|
20
148
|
now = Date.now(),
|
|
21
|
-
): Promise<
|
|
149
|
+
): Promise<P2PAccessTokenClaims | null> {
|
|
22
150
|
const [payload, signature, extra] = token.split('.');
|
|
23
|
-
if (!payload || !signature || extra !== undefined) return
|
|
151
|
+
if (!payload || !signature || extra !== undefined) return null;
|
|
24
152
|
const expectedSignature = await sign(secret, payload);
|
|
25
|
-
if (!constantTimeEqual(signature, expectedSignature)) return
|
|
153
|
+
if (!constantTimeEqual(signature, expectedSignature)) return null;
|
|
26
154
|
|
|
27
155
|
let claims: P2PAccessTokenClaims;
|
|
28
156
|
try {
|
|
29
157
|
claims = JSON.parse(decodeBase64Url(payload)) as P2PAccessTokenClaims;
|
|
30
158
|
} catch {
|
|
31
|
-
return
|
|
159
|
+
return null;
|
|
32
160
|
}
|
|
33
161
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
162
|
+
// A GRANT is never spendable on the relay — it is only exchangeable at
|
|
163
|
+
// /relay/session. Reject it here too (belt-and-suspenders: a grant is also
|
|
164
|
+
// signed with a different key, so it would already fail the signature check
|
|
165
|
+
// above unless the grant key were misconfigured to equal the raw secret).
|
|
166
|
+
if (claims.kind === 'grant') return null;
|
|
167
|
+
if (claims.roomName !== expected.roomName) return null;
|
|
168
|
+
if (expected.peerId && claims.peerId && claims.peerId !== expected.peerId) return null;
|
|
169
|
+
if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null;
|
|
170
|
+
return claims;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function verifyP2PAccessToken(
|
|
174
|
+
secret: string,
|
|
175
|
+
token: string,
|
|
176
|
+
expected: { roomName: string; peerId?: string | undefined },
|
|
177
|
+
now = Date.now(),
|
|
178
|
+
): Promise<boolean> {
|
|
179
|
+
return (await readP2PAccessToken(secret, token, expected, now)) !== null;
|
|
37
180
|
}
|
|
38
181
|
|
|
39
182
|
async function sign(secret: string, payload: string): Promise<string> {
|
package/src/browser.ts
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
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';
|
|
1
47
|
export type { P2PAccessTokenClaims } from './access-token';
|
|
2
48
|
export { createP2PAccessToken, verifyP2PAccessToken } from './access-token';
|
|
3
49
|
export { Callbacks } from './callbacks';
|
|
@@ -19,7 +65,6 @@ export {
|
|
|
19
65
|
export { SignalingRelayCoordinator } from './cloudflare/coordinator';
|
|
20
66
|
export type { RelayLimits } from './cloudflare/limits';
|
|
21
67
|
export { DEFAULT_RELAY_LIMITS } from './cloudflare/limits';
|
|
22
|
-
export { applyStatePatch, clone, createStatePatch, encodeSnapshot } from './codec';
|
|
23
68
|
export type {
|
|
24
69
|
NetDiagnostics,
|
|
25
70
|
RelayProofDiagnostics,
|
|
@@ -146,57 +191,11 @@ export {
|
|
|
146
191
|
WebSocketClient,
|
|
147
192
|
WebSocketTransport,
|
|
148
193
|
} from './platform';
|
|
149
|
-
export type { Envelope
|
|
194
|
+
export type { Envelope } from './protocol';
|
|
150
195
|
export { isEnvelope, P2P_CLOSE_CODES } from './protocol';
|
|
151
196
|
export { CloudflareRelayPacketChannel } from './relay';
|
|
152
197
|
export type { CompatClient } from './room';
|
|
153
198
|
export { Room } from './room';
|
|
154
199
|
export type { RoomClass } from './runtime';
|
|
155
200
|
export { UniversalRoomRuntime } from './runtime';
|
|
156
|
-
export {
|
|
157
|
-
$changes,
|
|
158
|
-
$childType,
|
|
159
|
-
$decoder,
|
|
160
|
-
$deleteByIndex,
|
|
161
|
-
$encoder,
|
|
162
|
-
$filter,
|
|
163
|
-
$getByIndex,
|
|
164
|
-
$refId,
|
|
165
|
-
$track,
|
|
166
|
-
ArraySchema,
|
|
167
|
-
Callbacks as SchemaCallbacks,
|
|
168
|
-
ChangeTree,
|
|
169
|
-
CollectionSchema,
|
|
170
|
-
Decoder,
|
|
171
|
-
decode,
|
|
172
|
-
decodeKeyValueOperation,
|
|
173
|
-
decodeSchemaOperation,
|
|
174
|
-
defineCustomTypes,
|
|
175
|
-
defineTypes,
|
|
176
|
-
deprecated,
|
|
177
|
-
dumpChanges,
|
|
178
|
-
Encoder,
|
|
179
|
-
encode,
|
|
180
|
-
encodeArray,
|
|
181
|
-
encodeKeyValueOperation,
|
|
182
|
-
encodeSchemaOperation,
|
|
183
|
-
entity,
|
|
184
|
-
getDecoderStateCallbacks,
|
|
185
|
-
getRawChangesCallback,
|
|
186
|
-
MapSchema,
|
|
187
|
-
Metadata,
|
|
188
|
-
OPERATION,
|
|
189
|
-
Reflection,
|
|
190
|
-
ReflectionField,
|
|
191
|
-
ReflectionType,
|
|
192
|
-
registerType,
|
|
193
|
-
Schema,
|
|
194
|
-
SetSchema,
|
|
195
|
-
StateCallbackStrategy,
|
|
196
|
-
StateView,
|
|
197
|
-
schema,
|
|
198
|
-
TypeContext,
|
|
199
|
-
type,
|
|
200
|
-
view,
|
|
201
|
-
} from './schema';
|
|
202
201
|
export { createWebRTCDataChannelPacketChannel, WebRTCDataChannelPacketChannel } from './webrtc';
|
package/src/callbacks.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The client Callbacks API is the REAL `@colyseus/schema` one.
|
|
3
|
+
*
|
|
4
|
+
* `Callbacks.get(room)` returns the legacy string-keyed strategy
|
|
5
|
+
* (`onAdd('players', …)`, `onChange(item, …)`, `bindTo(…)`) and accepts a
|
|
6
|
+
* `{ serializer: { decoder } }` — exactly the shape `CompatRoom` exposes — so
|
|
7
|
+
* an authored game that calls `Callbacks.get(room)` behaves identically on the
|
|
8
|
+
* P2P path and against a real Colyseus server. The v4 `$` proxy is
|
|
9
|
+
* `getStateCallbacks(room)` (see `client.ts`).
|
|
10
|
+
*/
|
|
11
|
+
export { Callbacks } from '@colyseus/schema';
|
package/src/channels.ts
CHANGED
|
@@ -21,4 +21,15 @@ export interface PacketChannel {
|
|
|
21
21
|
* against a wire-incompatible peer instead of hanging forever.
|
|
22
22
|
*/
|
|
23
23
|
onProtocolError?(handler: (reason: string) => void): () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Optional: send this envelope over an UNRELIABLE (unordered, no-retransmit)
|
|
26
|
+
* sub-transport when the channel has one — the delivery `sendUnreliable`
|
|
27
|
+
* asks for. Only the WebRTC channel has a second `RTCDataChannel`
|
|
28
|
+
* (`{ordered: false, maxRetransmits: 0}`) to carry it; ordered transports
|
|
29
|
+
* (loopback, websocket, relay, real Colyseus) have no unreliable mode and
|
|
30
|
+
* omit this, so `CompatRoom.sendUnreliable` correctly degrades to the
|
|
31
|
+
* reliable `send` path on them. The envelope shape is identical to `send`'s
|
|
32
|
+
* — same JSON `Envelope` protocol, different channel.
|
|
33
|
+
*/
|
|
34
|
+
sendUnreliable?(envelope: Envelope): void;
|
|
24
35
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Decoder,
|
|
3
|
+
getDecoderStateCallbacks,
|
|
4
|
+
Schema,
|
|
5
|
+
type SchemaCallbackProxy,
|
|
6
|
+
} from '@colyseus/schema';
|
|
1
7
|
import type { PacketChannel } from './channels';
|
|
2
|
-
import { applyStatePatch } from './codec';
|
|
3
8
|
import type { P2PColyseusMode } from './engine';
|
|
4
9
|
import { createSignal } from './events';
|
|
5
10
|
import { createLoopbackPair } from './loopback';
|
|
6
11
|
import { type Envelope, P2P_CLOSE_CODES } from './protocol';
|
|
7
|
-
import {
|
|
12
|
+
import { getRegisteredRoom, registeredRoomNames, registerLoopbackRoom } from './room-registry';
|
|
8
13
|
import { type RoomClass, UniversalRoomRuntime } from './runtime';
|
|
9
14
|
import { createWebSocketPacketChannel } from './websocket';
|
|
15
|
+
import { base64ToBytes } from './wire-bytes';
|
|
16
|
+
|
|
17
|
+
export { registerLoopbackRoom } from './room-registry';
|
|
10
18
|
|
|
11
|
-
const loopbackRooms = new Map<string, RoomClass>();
|
|
12
19
|
let configuredP2P:
|
|
13
20
|
| {
|
|
14
21
|
mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
|
|
@@ -19,10 +26,6 @@ let configuredP2P:
|
|
|
19
26
|
const hostedP2PRoomIds = new Map<string, string>();
|
|
20
27
|
const localP2PClientCounts = new Map<string, number>();
|
|
21
28
|
|
|
22
|
-
export function registerLoopbackRoom(roomName: string, roomClass: RoomClass): void {
|
|
23
|
-
loopbackRooms.set(roomName, roomClass);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
29
|
export function configureP2PColyseusClient(options: {
|
|
27
30
|
mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
|
|
28
31
|
rooms?: Record<string, RoomClass> | undefined;
|
|
@@ -154,7 +157,7 @@ export class Client {
|
|
|
154
157
|
async getAvailableRooms(roomName?: string): Promise<AvailableRoom[]> {
|
|
155
158
|
const roomNames = configuredP2P
|
|
156
159
|
? Object.keys(configuredP2P.rooms ?? {})
|
|
157
|
-
:
|
|
160
|
+
: registeredRoomNames();
|
|
158
161
|
return roomNames
|
|
159
162
|
.filter((name) => roomName === undefined || name === roomName)
|
|
160
163
|
.map((name) => ({
|
|
@@ -215,7 +218,7 @@ export class Client {
|
|
|
215
218
|
if (!this.url.startsWith('loopback://')) {
|
|
216
219
|
throw new Error(`Unsupported @vgai/p2p-colyseus client URL: ${this.url}`);
|
|
217
220
|
}
|
|
218
|
-
const roomClass =
|
|
221
|
+
const roomClass = getRegisteredRoom(roomName);
|
|
219
222
|
if (!roomClass) throw new Error(`Loopback room not registered: ${roomName}`);
|
|
220
223
|
|
|
221
224
|
// joinOrCreate semantics: the creating client's options reach onCreate.
|
|
@@ -277,14 +280,65 @@ function publishP2PMode(mode: string): void {
|
|
|
277
280
|
|
|
278
281
|
export const ColyseusSDK = Client;
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
/**
|
|
284
|
+
* The v4 Callbacks proxy over the client's REAL `@colyseus/schema` decoder —
|
|
285
|
+
* identical to `@colyseus/sdk`'s `getStateCallbacks(room)`
|
|
286
|
+
* (`$(state).players.onAdd(...)`, `$(item).listen(...)`, `$(item).onChange`,
|
|
287
|
+
* `$(from).bindTo(...)`). The legacy string-keyed API is `Callbacks.get(room)`,
|
|
288
|
+
* which the real `@colyseus/schema` `Callbacks` provides directly off
|
|
289
|
+
* `room.serializer.decoder`.
|
|
290
|
+
*/
|
|
291
|
+
export function getStateCallbacks(room: CompatRoom): SchemaCallbackProxy<Schema> {
|
|
292
|
+
if (!room.decoder) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`getStateCallbacks: room "${room.name}" has no decoded @colyseus/schema state — ` +
|
|
295
|
+
`register the room class via configureP2PColyseusClient({ rooms }).`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return getDecoderStateCallbacks(room.decoder);
|
|
282
299
|
}
|
|
283
300
|
|
|
284
301
|
export function registerSerializer(): void {}
|
|
285
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Build the client-side decoder root from the LOCALLY-REGISTERED room class
|
|
305
|
+
* (shipped to the client via `configureP2PColyseusClient({ rooms })`). No
|
|
306
|
+
* Reflection handshake — a fresh instance of the room's `Schema` state class is
|
|
307
|
+
* all the real `Decoder` needs, and it is compatible with the host `Encoder`
|
|
308
|
+
* because both use the same class through the same `@colyseus/schema`.
|
|
309
|
+
*/
|
|
310
|
+
function buildDecoder(roomName: string): Decoder<Schema> | undefined {
|
|
311
|
+
const RoomClass = getRegisteredRoom(roomName);
|
|
312
|
+
if (!RoomClass) return undefined;
|
|
313
|
+
const probe = new RoomClass() as { state?: unknown; onCreate?: (options?: unknown) => void };
|
|
314
|
+
let state = probe.state;
|
|
315
|
+
if (!(state instanceof Schema)) {
|
|
316
|
+
// Rooms that assign state in onCreate rather than a field initializer.
|
|
317
|
+
try {
|
|
318
|
+
probe.onCreate?.();
|
|
319
|
+
} catch {
|
|
320
|
+
/* best-effort probe — the state class is all we need */
|
|
321
|
+
}
|
|
322
|
+
state = probe.state;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
(probe as { _disposeRuntime?: () => void })._disposeRuntime?.();
|
|
326
|
+
} catch {
|
|
327
|
+
/* the probe never attached a runtime; nothing to clean up */
|
|
328
|
+
}
|
|
329
|
+
if (!(state instanceof Schema)) return undefined;
|
|
330
|
+
return new Decoder(new (state.constructor as new () => Schema)());
|
|
331
|
+
}
|
|
332
|
+
|
|
286
333
|
export class CompatRoom {
|
|
287
|
-
|
|
334
|
+
/**
|
|
335
|
+
* The client's real `@colyseus/schema` decoder — undefined for stateless
|
|
336
|
+
* rooms. Rebuilt from a fresh state root on a FULL snapshot (resync) so
|
|
337
|
+
* host-side removals since the last sync cannot linger as ghost entries.
|
|
338
|
+
*/
|
|
339
|
+
decoder: Decoder<Schema> | undefined;
|
|
340
|
+
/** Exposed so the real `Callbacks.get(room)` / `getStateCallbacks(room)` resolve the decoder. */
|
|
341
|
+
readonly serializer: { decoder: Decoder<Schema> | undefined };
|
|
288
342
|
readonly onLeave = createSignal<(code?: number, reason?: string) => void>();
|
|
289
343
|
readonly onError = createSignal<(code: number, message?: string) => void>();
|
|
290
344
|
readonly onReconnect = createSignal<() => void>();
|
|
@@ -293,7 +347,7 @@ export class CompatRoom {
|
|
|
293
347
|
roomId = '';
|
|
294
348
|
sessionId = '';
|
|
295
349
|
reconnectionToken = '';
|
|
296
|
-
state: Record<string, unknown> =
|
|
350
|
+
state: Record<string, unknown> = {};
|
|
297
351
|
private readonly messages = new Map<string, Set<(payload: unknown) => void>>();
|
|
298
352
|
private readonly wildcardMessages = new Set<(type: string, payload: unknown) => void>();
|
|
299
353
|
private readonly pendingPings = new Map<number, (ms: number) => void>();
|
|
@@ -303,6 +357,9 @@ export class CompatRoom {
|
|
|
303
357
|
readonly name: string,
|
|
304
358
|
private readonly channel: PacketChannel,
|
|
305
359
|
) {
|
|
360
|
+
this.decoder = buildDecoder(name);
|
|
361
|
+
this.serializer = { decoder: this.decoder };
|
|
362
|
+
if (this.decoder) this.state = this.decoder.state as unknown as Record<string, unknown>;
|
|
306
363
|
this.channel.onEnvelope((envelope) => this.handleEnvelope(envelope));
|
|
307
364
|
this.channel.onClose((reason) => {
|
|
308
365
|
this.onDrop.emit(undefined, reason);
|
|
@@ -318,11 +375,11 @@ export class CompatRoom {
|
|
|
318
375
|
// package's compat wire protocol at all — most concretely, a compat
|
|
319
376
|
// client pointed at a real Colyseus server (raw `ws://`), which frames
|
|
320
377
|
// its own binary schema-diff protocol and never sends `join-ok`/
|
|
321
|
-
// `join-error`. Without this, that mismatch hangs `join()` forever
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
378
|
+
// `join-error`. Without this, that mismatch hangs `join()` forever. All
|
|
379
|
+
// three listeners are torn down the moment the join settles one way or
|
|
380
|
+
// another, so none of them can observe — or reject on — anything that
|
|
381
|
+
// happens in a normal session after a successful join, including its
|
|
382
|
+
// normal eventual close.
|
|
326
383
|
const unsubscribers: Array<() => void> = [];
|
|
327
384
|
const teardown = () => {
|
|
328
385
|
for (const off of unsubscribers) off();
|
|
@@ -336,8 +393,7 @@ export class CompatRoom {
|
|
|
336
393
|
this.sessionId = envelope.sessionId;
|
|
337
394
|
this.reconnectionToken = `${this.roomId}:${this.sessionId}`;
|
|
338
395
|
this.clock = envelope.clock;
|
|
339
|
-
this.
|
|
340
|
-
this.state = this.replication.state;
|
|
396
|
+
this.applyEncodedState(envelope.state);
|
|
341
397
|
resolve();
|
|
342
398
|
} else if (envelope.kind === 'join-error' && envelope.requestId === requestId) {
|
|
343
399
|
teardown();
|
|
@@ -369,6 +425,15 @@ export class CompatRoom {
|
|
|
369
425
|
}
|
|
370
426
|
|
|
371
427
|
sendUnreliable<T = unknown>(type: string, payload?: T): void {
|
|
428
|
+
// Route over the channel's unreliable (unordered, no-retransmit) transport
|
|
429
|
+
// when it has one — only the WebRTC channel does. On ordered transports
|
|
430
|
+
// (loopback, websocket, relay, real Colyseus) there is no unreliable
|
|
431
|
+
// channel, and degrading to the reliable `send` path is correct: the
|
|
432
|
+
// message still arrives, just ordered/reliable.
|
|
433
|
+
if (this.channel.sendUnreliable) {
|
|
434
|
+
this.channel.sendUnreliable({ kind: 'message', type, payload });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
372
437
|
this.send(type, payload);
|
|
373
438
|
}
|
|
374
439
|
|
|
@@ -450,11 +515,44 @@ export class CompatRoom {
|
|
|
450
515
|
}
|
|
451
516
|
}
|
|
452
517
|
|
|
518
|
+
/** Decode base64-carried `@colyseus/schema` bytes into the live decoder state. */
|
|
519
|
+
private applyEncodedState(base64: string): void {
|
|
520
|
+
if (!this.decoder) return;
|
|
521
|
+
this.decoder.decode(base64ToBytes(base64));
|
|
522
|
+
this.state = this.decoder.state as unknown as Record<string, unknown>;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Replace the decoder with a fresh root before applying a FULL snapshot. A
|
|
527
|
+
* full snapshot (`encodeAll`) emits only ADDs for current entries and never
|
|
528
|
+
* removals; decoding it onto the already-populated decoder would leave stale
|
|
529
|
+
* "ghost" entries for anything the host dropped since the last sync. A fresh
|
|
530
|
+
* root — built from the same locally-registered room class the join decoder
|
|
531
|
+
* used — reflects EXACTLY the snapshot.
|
|
532
|
+
*
|
|
533
|
+
* Consequence: this swaps the decoder the `$`/Callbacks proxy is bound to, so
|
|
534
|
+
* persistent `$(state)....onAdd/onChange` handlers registered before a resync
|
|
535
|
+
* stop firing afterward and would need re-registration. Accepted because a
|
|
536
|
+
* ghost entry is data corruption (correctness outranks callback persistence)
|
|
537
|
+
* and a full-snapshot resync is rare (state patches ride the ordered-reliable
|
|
538
|
+
* channel — gaps essentially only follow real loss or a setState-replace). If
|
|
539
|
+
* that ever bites a real game, the parity-correct fix is to reconcile the live
|
|
540
|
+
* decoder's collections in place instead of swapping the root.
|
|
541
|
+
*/
|
|
542
|
+
private rebuildDecoder(): void {
|
|
543
|
+
const fresh = buildDecoder(this.name);
|
|
544
|
+
if (!fresh) return;
|
|
545
|
+
this.decoder = fresh;
|
|
546
|
+
this.serializer.decoder = fresh;
|
|
547
|
+
this.state = fresh.state as unknown as Record<string, unknown>;
|
|
548
|
+
}
|
|
549
|
+
|
|
453
550
|
private handleStateSnapshot(envelope: Extract<Envelope, { kind: 'state-snapshot' }>): void {
|
|
454
551
|
if (envelope.clock <= this.clock) return;
|
|
455
552
|
this.clock = envelope.clock;
|
|
456
|
-
|
|
457
|
-
this.
|
|
553
|
+
// FULL snapshot only (resync / setState-replace) — clear ghosts, then decode.
|
|
554
|
+
this.rebuildDecoder();
|
|
555
|
+
this.applyEncodedState(envelope.state);
|
|
458
556
|
this.onStateChange.emit(this.state);
|
|
459
557
|
}
|
|
460
558
|
|
|
@@ -465,9 +563,7 @@ export class CompatRoom {
|
|
|
465
563
|
return;
|
|
466
564
|
}
|
|
467
565
|
this.clock = envelope.clock;
|
|
468
|
-
|
|
469
|
-
this.replication.applySnapshot(patched);
|
|
470
|
-
this.state = this.replication.state;
|
|
566
|
+
this.applyEncodedState(envelope.patch);
|
|
471
567
|
this.onStateChange.emit(this.state);
|
|
472
568
|
}
|
|
473
569
|
|