@dcl/sdk 7.20.2-22104870534.commit-0df3cc0 → 7.20.2-22169778016.commit-030cbfe
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/atom.d.ts +19 -0
- package/atom.js +83 -0
- package/future.d.ts +8 -0
- package/future.js +26 -0
- package/network/binary-message-bus.d.ts +6 -3
- package/network/binary-message-bus.js +9 -5
- package/network/chunking.d.ts +5 -0
- package/network/chunking.js +38 -0
- package/network/events/implementation.d.ts +93 -0
- package/network/events/implementation.js +230 -0
- package/network/events/index.d.ts +42 -0
- package/network/events/index.js +43 -0
- package/network/events/protocol.d.ts +27 -0
- package/network/events/protocol.js +66 -0
- package/network/events/registry.d.ts +8 -0
- package/network/events/registry.js +3 -0
- package/network/index.d.ts +8 -2
- package/network/index.js +16 -3
- package/network/message-bus-sync.d.ts +14 -1
- package/network/message-bus-sync.js +166 -103
- package/network/server/index.d.ts +14 -0
- package/network/server/index.js +219 -0
- package/network/server/utils.d.ts +18 -0
- package/network/server/utils.js +135 -0
- package/network/state.js +3 -5
- package/package.json +6 -6
- package/server/env-var.d.ts +15 -0
- package/server/env-var.js +31 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +3 -0
- package/server/storage/constants.d.ts +23 -0
- package/server/storage/constants.js +2 -0
- package/server/storage/index.d.ts +22 -0
- package/server/storage/index.js +29 -0
- package/server/storage/player.d.ts +43 -0
- package/server/storage/player.js +92 -0
- package/server/storage/scene.d.ts +38 -0
- package/server/storage/scene.js +90 -0
- package/server/storage-url.d.ts +10 -0
- package/server/storage-url.js +29 -0
- package/server/utils.d.ts +35 -0
- package/server/utils.js +56 -0
- package/src/atom.ts +98 -0
- package/src/future.ts +38 -0
- package/src/network/binary-message-bus.ts +9 -4
- package/src/network/chunking.ts +45 -0
- package/src/network/events/implementation.ts +286 -0
- package/src/network/events/index.ts +48 -0
- package/src/network/events/protocol.ts +94 -0
- package/src/network/events/registry.ts +18 -0
- package/src/network/index.ts +40 -3
- package/src/network/message-bus-sync.ts +180 -110
- package/src/network/server/index.ts +301 -0
- package/src/network/server/utils.ts +189 -0
- package/src/network/state.ts +3 -4
- package/src/server/env-var.ts +36 -0
- package/src/server/index.ts +2 -0
- package/src/server/storage/constants.ts +22 -0
- package/src/server/storage/index.ts +44 -0
- package/src/server/storage/player.ts +156 -0
- package/src/server/storage/scene.ts +149 -0
- package/src/server/storage-url.ts +34 -0
- package/src/server/utils.ts +73 -0
|
@@ -1,27 +1,53 @@
|
|
|
1
|
-
import { IEngine, Transport, RealmInfo
|
|
1
|
+
import { IEngine, Transport, RealmInfo } from '@dcl/ecs'
|
|
2
2
|
import { type SendBinaryRequest, type SendBinaryResponse } from '~system/CommunicationsController'
|
|
3
3
|
|
|
4
4
|
import { syncFilter } from './filter'
|
|
5
5
|
import { engineToCrdt } from './state'
|
|
6
|
-
import { BinaryMessageBus, CommsMessage
|
|
6
|
+
import { BinaryMessageBus, CommsMessage } from './binary-message-bus'
|
|
7
7
|
import { fetchProfile } from './utils'
|
|
8
8
|
import { entityUtils } from './entities'
|
|
9
|
+
import { createServerValidator } from './server'
|
|
9
10
|
import { GetUserDataRequest, GetUserDataResponse } from '~system/UserIdentity'
|
|
10
11
|
import { definePlayerHelper } from '../players'
|
|
11
12
|
import { serializeCrdtMessages } from '../internal/transports/logger'
|
|
13
|
+
import { IsServerRequest, IsServerResponse } from '~system/EngineApi'
|
|
14
|
+
import { Atom } from '../atom'
|
|
15
|
+
import { setGlobalRoom, Room } from './events/implementation'
|
|
12
16
|
|
|
13
17
|
export type IProfile = { networkId: number; userId: string }
|
|
14
18
|
// user that we asked for the inital crdt state
|
|
19
|
+
export const AUTH_SERVER_PEER_ID = 'authoritative-server'
|
|
20
|
+
export const DEBUG_NETWORK_MESSAGES = () => (globalThis as any).DEBUG_NETWORK_MESSAGES ?? false
|
|
21
|
+
|
|
22
|
+
// Test environment detection without 'as any'
|
|
23
|
+
const isTestEnvironment = (): boolean => {
|
|
24
|
+
try {
|
|
25
|
+
if (typeof globalThis === 'undefined') return false
|
|
26
|
+
const globalWithProcess = globalThis as unknown as { process?: { env?: { NODE_ENV?: string } } }
|
|
27
|
+
return globalWithProcess.process?.env?.NODE_ENV === 'test'
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
export function addSyncTransport(
|
|
16
34
|
engine: IEngine,
|
|
17
35
|
sendBinary: (msg: SendBinaryRequest) => Promise<SendBinaryResponse>,
|
|
18
|
-
getUserData: (value: GetUserDataRequest) => Promise<GetUserDataResponse
|
|
36
|
+
getUserData: (value: GetUserDataRequest) => Promise<GetUserDataResponse>,
|
|
37
|
+
isServerFn: (request: IsServerRequest) => Promise<IsServerResponse>,
|
|
38
|
+
name: string
|
|
19
39
|
) {
|
|
20
|
-
const DEBUG_NETWORK_MESSAGES = () => (globalThis as any).DEBUG_NETWORK_MESSAGES ?? false
|
|
21
40
|
// Profile Info
|
|
22
41
|
const myProfile: IProfile = {} as IProfile
|
|
23
42
|
fetchProfile(myProfile!, getUserData)
|
|
24
43
|
|
|
44
|
+
const isServerAtom = Atom<boolean>()
|
|
45
|
+
const isRoomReadyAtom = Atom<boolean>(false)
|
|
46
|
+
|
|
47
|
+
void isServerFn({}).then(($: IsServerResponse) => {
|
|
48
|
+
return isServerAtom.swap(!!$.isServer)
|
|
49
|
+
})
|
|
50
|
+
|
|
25
51
|
// Entity utils
|
|
26
52
|
const entityDefinitions = entityUtils(engine, myProfile)
|
|
27
53
|
|
|
@@ -40,108 +66,196 @@ export function addSyncTransport(
|
|
|
40
66
|
const players = definePlayerHelper(engine)
|
|
41
67
|
|
|
42
68
|
let stateIsSyncronized = false
|
|
43
|
-
let transportInitialzed = false
|
|
44
69
|
|
|
70
|
+
/**
|
|
71
|
+
* We need to wait till 2 ticks that is when the engine is ready to send new messages.
|
|
72
|
+
* The first tick is for the client engine processing the CRDT messages,
|
|
73
|
+
* and the second one are the messages created by the main() function.
|
|
74
|
+
* So to avoid sending those messages, that all the clients have, through the network we put this validation here.
|
|
75
|
+
*/
|
|
76
|
+
let tick = 0
|
|
77
|
+
const TRANSPORT_INITIALIZED_NUMBER = isTestEnvironment() ? 0 : 2
|
|
45
78
|
// Add Sync Transport
|
|
46
79
|
const transport: Transport = {
|
|
47
80
|
filter: syncFilter(engine),
|
|
48
81
|
send: async (messages) => {
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
if (tick <= TRANSPORT_INITIALIZED_NUMBER) tick++
|
|
83
|
+
for (const message of tick > TRANSPORT_INITIALIZED_NUMBER ? [messages].flat() : []) {
|
|
84
|
+
if (message.byteLength) {
|
|
51
85
|
DEBUG_NETWORK_MESSAGES() &&
|
|
52
86
|
console.log(...Array.from(serializeCrdtMessages('[NetworkMessage sent]:', message, engine)))
|
|
53
|
-
|
|
87
|
+
|
|
88
|
+
// Convert regular messages to network messages for broadcasting with chunking
|
|
89
|
+
for (const chunk of serverValidator.convertRegularToNetworkMessage(message)) {
|
|
90
|
+
binaryMessageBus.emit(CommsMessage.CRDT, chunk)
|
|
91
|
+
}
|
|
54
92
|
}
|
|
55
93
|
}
|
|
56
94
|
const peerMessages = getMessagesToSend()
|
|
57
|
-
let totalSize = 0
|
|
58
|
-
for (const message of peerMessages) {
|
|
59
|
-
for (const data of message.data) {
|
|
60
|
-
totalSize += data.byteLength
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (totalSize) {
|
|
64
|
-
DEBUG_NETWORK_MESSAGES() && console.log('Sending network messages: ', totalSize / 1024, 'KB')
|
|
65
|
-
}
|
|
66
95
|
const response = await sendBinary({ data: [], peerData: peerMessages })
|
|
67
96
|
binaryMessageBus.__processMessages(response.data)
|
|
68
|
-
transportInitialzed = true
|
|
69
97
|
},
|
|
70
|
-
type:
|
|
98
|
+
type: name
|
|
71
99
|
}
|
|
100
|
+
|
|
101
|
+
// Server validation setup
|
|
102
|
+
const serverValidator = createServerValidator({
|
|
103
|
+
engine,
|
|
104
|
+
binaryMessageBus
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Initialize Event Bus with registered schemas
|
|
108
|
+
const eventBus = new Room(engine, binaryMessageBus, isServerAtom, isRoomReadyAtom)
|
|
109
|
+
|
|
110
|
+
// Set global eventBus instance
|
|
111
|
+
setGlobalRoom(eventBus)
|
|
112
|
+
|
|
72
113
|
engine.addTransport(transport)
|
|
73
114
|
// End add sync transport
|
|
74
115
|
|
|
75
116
|
// Receive & Process CRDT_STATE
|
|
76
|
-
binaryMessageBus.on(CommsMessage.
|
|
77
|
-
|
|
78
|
-
|
|
117
|
+
binaryMessageBus.on(CommsMessage.REQ_CRDT_STATE, async (data, sender) => {
|
|
118
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[REQ_CRDT_STATE]', sender, Date.now())
|
|
119
|
+
for (const chunk of engineToCrdt(engine)) {
|
|
120
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[Emiting:]', sender, Date.now())
|
|
121
|
+
binaryMessageBus.emit(CommsMessage.RES_CRDT_STATE, chunk, [sender])
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
binaryMessageBus.on(CommsMessage.RES_CRDT_STATE, async (data, sender) => {
|
|
125
|
+
requestingState = false
|
|
126
|
+
elapsedTimeSinceRequest = 0
|
|
127
|
+
if (isServerAtom.getOrNull() || sender !== AUTH_SERVER_PEER_ID) return
|
|
79
128
|
DEBUG_NETWORK_MESSAGES() && console.log('[Processing CRDT State]', data.byteLength / 1024, 'KB')
|
|
80
|
-
transport.onmessage!(data)
|
|
129
|
+
transport.onmessage!(serverValidator.processClientMessages(data, sender))
|
|
81
130
|
stateIsSyncronized = true
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// Answer to REQ_CRDT_STATE
|
|
85
|
-
binaryMessageBus.on(CommsMessage.REQ_CRDT_STATE, async (_, userId) => {
|
|
86
|
-
DEBUG_NETWORK_MESSAGES() && console.log(`Sending CRDT State to: ${userId}`)
|
|
87
131
|
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
// IMPORTANT: Only mark room as ready AFTER state is synchronized
|
|
133
|
+
// This ensures comms is truly connected and working
|
|
134
|
+
const realmInfo = RealmInfo.getOrNull(engine.RootEntity)
|
|
135
|
+
if (realmInfo && checkRoomReady(realmInfo)) {
|
|
136
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[isRoomReady] Marking room as ready after state sync')
|
|
137
|
+
isRoomReadyAtom.swap(true)
|
|
90
138
|
}
|
|
91
139
|
})
|
|
92
140
|
|
|
93
|
-
//
|
|
94
|
-
binaryMessageBus.on(CommsMessage.CRDT, (value) => {
|
|
141
|
+
// received message from the network
|
|
142
|
+
binaryMessageBus.on(CommsMessage.CRDT, (value, sender) => {
|
|
143
|
+
const isServer = isServerAtom.getOrNull()
|
|
95
144
|
DEBUG_NETWORK_MESSAGES() &&
|
|
96
|
-
console.log(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
145
|
+
console.log(
|
|
146
|
+
transport.type,
|
|
147
|
+
...Array.from(serializeCrdtMessages('[NetworkMessage received]:', value, engine)),
|
|
148
|
+
isServer
|
|
149
|
+
)
|
|
150
|
+
if (isServer) {
|
|
151
|
+
transport.onmessage!(serverValidator.processServerMessages(value, sender))
|
|
152
|
+
} else if (sender === AUTH_SERVER_PEER_ID) {
|
|
153
|
+
// Process network messages from server and convert to regular messages
|
|
154
|
+
transport.onmessage!(serverValidator.processClientMessages(value, sender))
|
|
107
155
|
}
|
|
156
|
+
})
|
|
108
157
|
|
|
109
|
-
|
|
158
|
+
// received authoritative message from server - force apply to fix invalid local state
|
|
159
|
+
binaryMessageBus.on(CommsMessage.CRDT_AUTHORITATIVE, (value, sender) => {
|
|
160
|
+
// Only accept authoritative messages from authoritative server
|
|
161
|
+
if (sender !== AUTH_SERVER_PEER_ID) return
|
|
110
162
|
|
|
111
|
-
//
|
|
112
|
-
|
|
163
|
+
// DEBUG_NETWORK_MESSAGES() &&
|
|
164
|
+
console.log('[AUTHORITATIVE] Received authoritative message from server:', value.byteLength, 'bytes')
|
|
113
165
|
|
|
114
|
-
|
|
166
|
+
// Process authoritative messages by forcing them through normal CRDT processing
|
|
167
|
+
// but with a timestamp that's guaranteed to be accepted
|
|
168
|
+
const authoritativeBuffer = serverValidator.processClientMessages(value, sender, true)
|
|
169
|
+
if (authoritativeBuffer.byteLength > 0) {
|
|
170
|
+
// Apply authoritative message through normal transport, but the server's messages
|
|
171
|
+
// should be processed as authoritative with special timestamp handling
|
|
172
|
+
transport.onmessage!(authoritativeBuffer)
|
|
115
173
|
|
|
116
|
-
|
|
117
|
-
if (players.length > 1 && retryCount <= 2) {
|
|
118
|
-
DEBUG_NETWORK_MESSAGES() &&
|
|
119
|
-
console.log(`Requesting state again ${retryCount} (no response). Players connected: ${players.length - 1}`)
|
|
120
|
-
void requestState(retryCount + 1)
|
|
121
|
-
} else {
|
|
122
|
-
DEBUG_NETWORK_MESSAGES() && console.log('No active players. State syncronized')
|
|
123
|
-
stateIsSyncronized = true
|
|
124
|
-
}
|
|
174
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[AUTHORITATIVE] Applied server authoritative message to local state')
|
|
125
175
|
}
|
|
126
|
-
}
|
|
176
|
+
})
|
|
127
177
|
|
|
128
178
|
players.onEnterScene((player) => {
|
|
129
179
|
DEBUG_NETWORK_MESSAGES() && console.log('[onEnterScene]', player.userId)
|
|
180
|
+
if (!isServerAtom.getOrNull() && myProfile.userId === player.userId) {
|
|
181
|
+
requestState()
|
|
182
|
+
}
|
|
130
183
|
})
|
|
131
184
|
|
|
185
|
+
// Helper to check room ready conditions
|
|
186
|
+
function checkRoomReady(realmInfo: ReturnType<typeof RealmInfo.getOrNull>): boolean {
|
|
187
|
+
if (!realmInfo) return false
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Check if room instance exists
|
|
191
|
+
if (!eventBus) return false
|
|
192
|
+
|
|
193
|
+
return !!(realmInfo.commsAdapter && realmInfo.isConnectedSceneRoom && realmInfo.room)
|
|
194
|
+
} catch {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
132
199
|
// Asks for the REQ_CRDT_STATE when its connected to comms
|
|
133
200
|
RealmInfo.onChange(engine.RootEntity, (value) => {
|
|
201
|
+
const isServer = isServerAtom.getOrNull()
|
|
202
|
+
|
|
134
203
|
if (!value?.isConnectedSceneRoom) {
|
|
135
|
-
|
|
136
|
-
|
|
204
|
+
// Only react when actually transitioning from ready to not ready
|
|
205
|
+
if (isRoomReadyAtom.getOrNull() === true) {
|
|
206
|
+
DEBUG_NETWORK_MESSAGES() && console.log('Disconnected from comms')
|
|
207
|
+
isRoomReadyAtom.swap(false)
|
|
208
|
+
if (!isServer) {
|
|
209
|
+
stateIsSyncronized = false
|
|
210
|
+
}
|
|
211
|
+
}
|
|
137
212
|
}
|
|
138
213
|
|
|
139
214
|
if (value?.isConnectedSceneRoom) {
|
|
140
|
-
|
|
215
|
+
requestState()
|
|
216
|
+
|
|
217
|
+
// For servers, mark as ready immediately when connected
|
|
218
|
+
// (servers don't need to sync state from anyone)
|
|
219
|
+
if (isServer && checkRoomReady(value) && isRoomReadyAtom.getOrNull() === false) {
|
|
220
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[isRoomReady] Server marking room as ready')
|
|
221
|
+
isRoomReadyAtom.swap(true)
|
|
222
|
+
}
|
|
223
|
+
// For clients, room will be marked ready after receiving CRDT state (above)
|
|
141
224
|
}
|
|
225
|
+
})
|
|
142
226
|
|
|
143
|
-
|
|
144
|
-
|
|
227
|
+
let requestingState = false
|
|
228
|
+
let elapsedTimeSinceRequest = 0
|
|
229
|
+
const STATE_REQUEST_RETRY_INTERVAL = 2.0 // seconds
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Why we have to request the state if we have a server that can send us the state when we joined?
|
|
233
|
+
* The thing is that when the server detects a new JOIN_PARTICIPANT on livekit room, it sends automatically the state to that peer.
|
|
234
|
+
* But in unity, it takes more time, so that message is not being delivered to the client.
|
|
235
|
+
* So instead, when we are finally connected to the room, we request the state, and then the server answers with the state :)
|
|
236
|
+
*
|
|
237
|
+
* If no response is received within 2 seconds, the request is automatically retried.
|
|
238
|
+
*/
|
|
239
|
+
function requestState() {
|
|
240
|
+
if (isServerAtom.getOrNull()) return
|
|
241
|
+
if (RealmInfo.getOrNull(engine.RootEntity)?.isConnectedSceneRoom && !requestingState) {
|
|
242
|
+
requestingState = true
|
|
243
|
+
elapsedTimeSinceRequest = 0
|
|
244
|
+
DEBUG_NETWORK_MESSAGES() && console.log('Requesting state...')
|
|
245
|
+
binaryMessageBus.emit(CommsMessage.REQ_CRDT_STATE, new Uint8Array())
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// System to retry state request if no response is received within the retry interval
|
|
250
|
+
engine.addSystem((dt: number) => {
|
|
251
|
+
if (requestingState && !stateIsSyncronized) {
|
|
252
|
+
elapsedTimeSinceRequest += dt
|
|
253
|
+
if (elapsedTimeSinceRequest >= STATE_REQUEST_RETRY_INTERVAL) {
|
|
254
|
+
DEBUG_NETWORK_MESSAGES() && console.log('State request timed out, retrying...')
|
|
255
|
+
elapsedTimeSinceRequest = 0
|
|
256
|
+
requestingState = false
|
|
257
|
+
requestState()
|
|
258
|
+
}
|
|
145
259
|
}
|
|
146
260
|
})
|
|
147
261
|
|
|
@@ -153,56 +267,12 @@ export function addSyncTransport(
|
|
|
153
267
|
return stateIsSyncronized
|
|
154
268
|
}
|
|
155
269
|
|
|
156
|
-
function sleep(ms: number) {
|
|
157
|
-
return new Promise<void>((resolve) => {
|
|
158
|
-
let timer = 0
|
|
159
|
-
function sleepSystem(dt: number) {
|
|
160
|
-
timer += dt
|
|
161
|
-
if (timer * 1000 >= ms) {
|
|
162
|
-
engine.removeSystem(sleepSystem)
|
|
163
|
-
resolve()
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
engine.addSystem(sleepSystem)
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
|
|
170
270
|
return {
|
|
171
271
|
...entityDefinitions,
|
|
172
272
|
myProfile,
|
|
173
|
-
isStateSyncronized
|
|
273
|
+
isStateSyncronized,
|
|
274
|
+
binaryMessageBus,
|
|
275
|
+
eventBus,
|
|
276
|
+
isRoomReadyAtom
|
|
174
277
|
}
|
|
175
278
|
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Messages Protocol Encoding
|
|
179
|
-
*
|
|
180
|
-
* CRDT: Plain Uint8Array
|
|
181
|
-
*
|
|
182
|
-
* CRDT_STATE_RES { sender: string, data: Uint8Array}
|
|
183
|
-
*/
|
|
184
|
-
function decodeCRDTState(data: Uint8Array) {
|
|
185
|
-
let offset = 0
|
|
186
|
-
const r = new Uint8Array(data)
|
|
187
|
-
const view = new DataView(r.buffer)
|
|
188
|
-
const senderLength = view.getUint8(offset)
|
|
189
|
-
offset += 1
|
|
190
|
-
const sender = decodeString(data.subarray(1, senderLength + 1))
|
|
191
|
-
offset += senderLength
|
|
192
|
-
const state = r.subarray(offset)
|
|
193
|
-
|
|
194
|
-
return { sender, data: state }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function encodeCRDTState(address: string, data: Uint8Array) {
|
|
198
|
-
// address to uint8array
|
|
199
|
-
const addressBuffer = encodeString(address)
|
|
200
|
-
const addressOffset = 1
|
|
201
|
-
const messageLength = addressOffset + addressBuffer.byteLength + data.byteLength
|
|
202
|
-
|
|
203
|
-
const serializedMessage = new Uint8Array(messageLength)
|
|
204
|
-
serializedMessage.set(new Uint8Array([addressBuffer.byteLength]), 0)
|
|
205
|
-
serializedMessage.set(addressBuffer, 1)
|
|
206
|
-
serializedMessage.set(data, addressBuffer.byteLength + 1)
|
|
207
|
-
return serializedMessage
|
|
208
|
-
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IEngine,
|
|
3
|
+
Entity,
|
|
4
|
+
CrdtMessageType,
|
|
5
|
+
CrdtMessageBody,
|
|
6
|
+
ProcessMessageResultType,
|
|
7
|
+
ComponentType,
|
|
8
|
+
PutNetworkComponentOperation
|
|
9
|
+
} from '@dcl/ecs'
|
|
10
|
+
import * as components from '@dcl/ecs/dist/components'
|
|
11
|
+
import { ReadWriteByteBuffer } from '@dcl/ecs/dist/serialization/ByteBuffer'
|
|
12
|
+
import { CommsMessage } from '../binary-message-bus'
|
|
13
|
+
import { chunkCrdtMessages } from '../chunking'
|
|
14
|
+
import * as utils from './utils'
|
|
15
|
+
import { AUTH_SERVER_PEER_ID, DEBUG_NETWORK_MESSAGES } from '../message-bus-sync'
|
|
16
|
+
import { type BinaryMessageBus } from '../binary-message-bus'
|
|
17
|
+
import {
|
|
18
|
+
LastWriteWinElementSetComponentDefinition,
|
|
19
|
+
GrowOnlyValueSetComponentDefinition,
|
|
20
|
+
ComponentDefinition,
|
|
21
|
+
InternalBaseComponent
|
|
22
|
+
} from '@dcl/ecs/dist/engine/component'
|
|
23
|
+
|
|
24
|
+
export const LIVEKIT_MAX_SIZE = 12
|
|
25
|
+
|
|
26
|
+
export interface ServerValidationConfig {
|
|
27
|
+
engine: IEngine
|
|
28
|
+
binaryMessageBus: ReturnType<typeof BinaryMessageBus>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createServerValidator(config: ServerValidationConfig) {
|
|
32
|
+
const { engine, binaryMessageBus } = config
|
|
33
|
+
|
|
34
|
+
// Initialize components for network operations and transform fixing
|
|
35
|
+
const NetworkEntity = components.NetworkEntity(engine)
|
|
36
|
+
const CreatedBy = components.CreatedBy(engine)
|
|
37
|
+
const NetworkParent = components.NetworkParent(engine)
|
|
38
|
+
|
|
39
|
+
// Type guard to check if component supports corrections (both LWW and GrowOnlySet)
|
|
40
|
+
function supportsCorrections<T>(
|
|
41
|
+
component: ComponentDefinition<T>
|
|
42
|
+
): component is LastWriteWinElementSetComponentDefinition<T> | GrowOnlyValueSetComponentDefinition<T> {
|
|
43
|
+
return (
|
|
44
|
+
(component.componentType === ComponentType.LastWriteWinElementSet ||
|
|
45
|
+
component.componentType === ComponentType.GrowOnlyValueSet) &&
|
|
46
|
+
'getCrdtState' in component
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findExistingNetworkEntity(message: utils.NetworkMessage): Entity | null {
|
|
51
|
+
// Look for existing network entity mapping (don't create new ones)
|
|
52
|
+
for (const [entityId, networkData] of engine.getEntitiesWith(NetworkEntity)) {
|
|
53
|
+
if (networkData.networkId === message.networkId && networkData.entityId === message.entityId) {
|
|
54
|
+
return entityId
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Return null if not found
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findOrCreateNetworkEntity(message: utils.NetworkMessage, sender: string, isServer: boolean): Entity {
|
|
62
|
+
// Look for existing network entity mapping first
|
|
63
|
+
const existingEntity = findExistingNetworkEntity(message)
|
|
64
|
+
|
|
65
|
+
if (existingEntity) {
|
|
66
|
+
return existingEntity
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Create new entity and network mapping
|
|
70
|
+
const newEntityId = engine.addEntity()
|
|
71
|
+
NetworkEntity.createOrReplace(newEntityId, {
|
|
72
|
+
networkId: message.networkId,
|
|
73
|
+
entityId: message.entityId
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (isServer) {
|
|
77
|
+
CreatedBy.createOrReplace(newEntityId, { address: sender })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
DEBUG_NETWORK_MESSAGES() &&
|
|
81
|
+
console.log(`[DEBUG] Created new entity ${newEntityId} for network ${message.networkId}:${message.entityId}`)
|
|
82
|
+
return newEntityId
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function convertNetworkToRegularMessage(
|
|
86
|
+
networkMessage: utils.NetworkMessage,
|
|
87
|
+
localEntityId: Entity,
|
|
88
|
+
forceCorrections = false
|
|
89
|
+
): (CrdtMessageBody & { messageBuffer: Uint8Array }) | null {
|
|
90
|
+
const buffer = new ReadWriteByteBuffer()
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Use the well-tested networkMessageToLocal utility with transform fixing for Unity
|
|
94
|
+
const message = utils.networkMessageToLocal(
|
|
95
|
+
networkMessage,
|
|
96
|
+
localEntityId,
|
|
97
|
+
buffer,
|
|
98
|
+
NetworkParent,
|
|
99
|
+
forceCorrections
|
|
100
|
+
)
|
|
101
|
+
return { ...message, messageBuffer: buffer.toBinary() }
|
|
102
|
+
} catch (error) {
|
|
103
|
+
DEBUG_NETWORK_MESSAGES() && console.error('Error converting network message:', error)
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateMessagePermissions(message: utils.RegularMessage, sender: string, _localEntityId: Entity): boolean {
|
|
109
|
+
// Basic checks
|
|
110
|
+
if (!sender || sender === AUTH_SERVER_PEER_ID) {
|
|
111
|
+
return false // Server shouldn't send messages to itself
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (message.type === CrdtMessageType.DELETE_ENTITY) {
|
|
115
|
+
// TODO: how to handle this case ?
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (message.type === CrdtMessageType.PUT_COMPONENT || message.type === CrdtMessageType.DELETE_COMPONENT) {
|
|
119
|
+
const component = engine.getComponent(message.componentId) as InternalBaseComponent<unknown>
|
|
120
|
+
const buf = 'data' in message ? new ReadWriteByteBuffer(message.data) : null
|
|
121
|
+
const value = buf ? component.schema.deserialize(buf) : null
|
|
122
|
+
const dryRunCRDT = component.__dry_run_updateFromCrdt(message)
|
|
123
|
+
const validCRDT = [
|
|
124
|
+
ProcessMessageResultType.StateUpdatedData,
|
|
125
|
+
ProcessMessageResultType.StateUpdatedTimestamp,
|
|
126
|
+
ProcessMessageResultType.EntityDeleted
|
|
127
|
+
].includes(dryRunCRDT)
|
|
128
|
+
const createdBy = CreatedBy.getOrNull(message.entityId)
|
|
129
|
+
const validMessage =
|
|
130
|
+
validCRDT &&
|
|
131
|
+
component.__run_validateBeforeChange(message.entityId, value, sender, createdBy?.address ?? AUTH_SERVER_PEER_ID)
|
|
132
|
+
|
|
133
|
+
return !!validMessage
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For now, basic validation - in the future this will check component sync permissions
|
|
137
|
+
// TODO: Check if sender owns the entity
|
|
138
|
+
// TODO: Check component sync mode ('all' | 'owner' | 'server')
|
|
139
|
+
// TODO: Run component custom validation
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function broadcastBatchedMessages(messages: utils.NetworkMessage[], excludeSender: string) {
|
|
144
|
+
if (messages.length === 0) return
|
|
145
|
+
|
|
146
|
+
// Build the complete buffer with all messages
|
|
147
|
+
const networkBuffer = new ReadWriteByteBuffer()
|
|
148
|
+
for (const message of messages) {
|
|
149
|
+
// Skip oversized messages upfront
|
|
150
|
+
if (message.messageBuffer.byteLength / 1024 > LIVEKIT_MAX_SIZE) {
|
|
151
|
+
console.error(
|
|
152
|
+
`Message too large (${message.messageBuffer.byteLength} bytes), skipping message from ${excludeSender}`
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
networkBuffer.writeBuffer(message.messageBuffer, false)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Use the chunking function to split into proper chunks
|
|
160
|
+
const chunks = chunkCrdtMessages(networkBuffer.toBinary(), LIVEKIT_MAX_SIZE)
|
|
161
|
+
|
|
162
|
+
for (const chunk of chunks) {
|
|
163
|
+
binaryMessageBus.emit(CommsMessage.CRDT, chunk)
|
|
164
|
+
}
|
|
165
|
+
DEBUG_NETWORK_MESSAGES() &&
|
|
166
|
+
console.log(`Total: ${messages.length} messages in ${chunks.length} chunks from ${excludeSender}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sendCorrectionToSender(networkMessage: utils.NetworkMessage, sender: string, localEntityId: Entity) {
|
|
170
|
+
try {
|
|
171
|
+
// Only handle component messages (PUT/DELETE), not entity deletion
|
|
172
|
+
if (networkMessage.type === CrdtMessageType.DELETE_ENTITY_NETWORK) {
|
|
173
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[AUTHORITATIVE] Cannot send authoritative message for entity deletion')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Safe to access componentId and timestamp now
|
|
178
|
+
const component = engine.getComponent(networkMessage.componentId)
|
|
179
|
+
|
|
180
|
+
// Only proceed if component supports authoritative messages (LWW or GrowOnlySet)
|
|
181
|
+
if (!supportsCorrections(component)) {
|
|
182
|
+
DEBUG_NETWORK_MESSAGES() && console.log('[AUTHORITATIVE] Component does not support authoritative messages')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const serverCRDTState = component.getCrdtState(localEntityId)
|
|
187
|
+
|
|
188
|
+
if (serverCRDTState) {
|
|
189
|
+
// Create authoritative message using PUT_COMPONENT_NETWORK
|
|
190
|
+
// Each client will convert this to AUTHORITATIVE_PUT_COMPONENT with proper entity mapping
|
|
191
|
+
const correctionBuffer = new ReadWriteByteBuffer()
|
|
192
|
+
PutNetworkComponentOperation.write(
|
|
193
|
+
networkMessage.entityId, // Use original network entity ID
|
|
194
|
+
serverCRDTState.timestamp,
|
|
195
|
+
networkMessage.componentId,
|
|
196
|
+
networkMessage.networkId,
|
|
197
|
+
serverCRDTState.data,
|
|
198
|
+
correctionBuffer
|
|
199
|
+
)
|
|
200
|
+
// Send authoritative message directly to the sender
|
|
201
|
+
binaryMessageBus.emit(CommsMessage.CRDT_AUTHORITATIVE, correctionBuffer.toBinary(), [sender])
|
|
202
|
+
|
|
203
|
+
DEBUG_NETWORK_MESSAGES() &&
|
|
204
|
+
console.log(
|
|
205
|
+
`[AUTHORITATIVE] Sent authoritative message to ${sender} for entity ${localEntityId} component ${networkMessage.componentId} with timestamp ${networkMessage.timestamp}`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
DEBUG_NETWORK_MESSAGES() && console.error('Error sending correction:', error)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
findExistingNetworkEntity,
|
|
215
|
+
// transform Network messages to CRDT Common Messages.
|
|
216
|
+
processClientMessages: function processClientMessages(value: Uint8Array, sender: string, forceCorrections = false) {
|
|
217
|
+
// console.log(`[CLIENT] Processing message from ${sender}, ${value.length} bytes`)
|
|
218
|
+
|
|
219
|
+
// Collect all regular messages in a single buffer for batched application
|
|
220
|
+
const combinedBuffer = new ReadWriteByteBuffer()
|
|
221
|
+
|
|
222
|
+
// Clients process network messages from server and convert them to regular messages
|
|
223
|
+
for (const message of utils.readMessages(value)) {
|
|
224
|
+
// Only process network messages in client message handler
|
|
225
|
+
if (utils.isNetworkMessage(message)) {
|
|
226
|
+
const networkMessage = message as utils.NetworkMessage
|
|
227
|
+
|
|
228
|
+
// Find or create network entity mapping
|
|
229
|
+
const localEntityId = findOrCreateNetworkEntity(networkMessage, sender, false)
|
|
230
|
+
|
|
231
|
+
// Convert network message to regular message or correction message
|
|
232
|
+
const regularMessage = convertNetworkToRegularMessage(networkMessage, localEntityId, forceCorrections)
|
|
233
|
+
|
|
234
|
+
if (regularMessage?.messageBuffer.byteLength) {
|
|
235
|
+
combinedBuffer.writeBuffer(regularMessage.messageBuffer, false)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return combinedBuffer.toBinary()
|
|
240
|
+
},
|
|
241
|
+
// Sever Code: process message, handle permissions, and broadcast if needed.
|
|
242
|
+
processServerMessages: function processServerMessages(value: Uint8Array, sender: string) {
|
|
243
|
+
// console.log(`[SERVER] Processing message from ${sender}, ${value.length} bytes`)
|
|
244
|
+
|
|
245
|
+
// Collect all valid messages for batched broadcasting
|
|
246
|
+
const messagesToBroadcast: utils.NetworkMessage[] = []
|
|
247
|
+
const regularMessagesBuffer = new ReadWriteByteBuffer()
|
|
248
|
+
|
|
249
|
+
for (const message of utils.readMessages(value)) {
|
|
250
|
+
try {
|
|
251
|
+
// Only process network messages in server message handler
|
|
252
|
+
if (utils.isNetworkMessage(message)) {
|
|
253
|
+
const networkMessage = message as utils.NetworkMessage
|
|
254
|
+
// 1. Find or create network entity mapping
|
|
255
|
+
const localEntityId = findOrCreateNetworkEntity(networkMessage, sender, true)
|
|
256
|
+
|
|
257
|
+
// 2. Convert network message to regular message and collect for local application
|
|
258
|
+
const regularMessage = convertNetworkToRegularMessage(networkMessage, localEntityId)
|
|
259
|
+
|
|
260
|
+
// 3. Basic permission validation
|
|
261
|
+
if (!validateMessagePermissions(regularMessage as any, sender, localEntityId)) {
|
|
262
|
+
// Send correction back to sender with server's authoritative state
|
|
263
|
+
sendCorrectionToSender(networkMessage, sender, localEntityId)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 4. Collect valid message for batched broadcasting
|
|
268
|
+
messagesToBroadcast.push(networkMessage)
|
|
269
|
+
|
|
270
|
+
if (regularMessage?.messageBuffer.byteLength) {
|
|
271
|
+
regularMessagesBuffer.writeBuffer(regularMessage.messageBuffer, false)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
DEBUG_NETWORK_MESSAGES() && console.error('Error processing server message:', error)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Batch broadcast all valid messages together
|
|
279
|
+
broadcastBatchedMessages(messagesToBroadcast, sender)
|
|
280
|
+
return regularMessagesBuffer.toBinary()
|
|
281
|
+
},
|
|
282
|
+
// engine changes that needs to be broadcasted.
|
|
283
|
+
convertRegularToNetworkMessage: function convertRegularToNetworkMessage(regularMessage: Uint8Array): Uint8Array[] {
|
|
284
|
+
const groupedBuffer = new ReadWriteByteBuffer()
|
|
285
|
+
|
|
286
|
+
// First pass: Convert all regular messages to network format and group them into one big buffer
|
|
287
|
+
for (const message of utils.readMessages(regularMessage)) {
|
|
288
|
+
// Only convert regular messages that have network data
|
|
289
|
+
const networkData = NetworkEntity.getOrNull(message.entityId)
|
|
290
|
+
|
|
291
|
+
if (networkData && !utils.isNetworkMessage(message)) {
|
|
292
|
+
utils.localMessageToNetwork(message, networkData, groupedBuffer)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Second pass: Use the new chunking function that respects message boundaries
|
|
297
|
+
const totalData = groupedBuffer.toBinary()
|
|
298
|
+
return chunkCrdtMessages(totalData, LIVEKIT_MAX_SIZE)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|