modd-network 1.0.2 → 1.0.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.
@@ -1,19 +1,14 @@
1
1
  "use strict";
2
- /**
3
- * MODD Network SDK
4
- * Pure network layer for real-time multiplayer applications
5
- * Works with any app type: games, chat, collaboration, etc.
6
- */
7
- var __importDefault = (this && this.__importDefault) || function (mod) {
8
- return (mod && mod.__esModule) ? mod : { "default": mod };
9
- };
10
2
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.modd = void 0;
3
+ exports.modd = exports.unregisterPlayerId = exports.registerPlayerId = exports.hashPlayerId = void 0;
4
+ exports.hashClientId = hashClientId;
5
+ exports.registerClientId = registerClientId;
6
+ exports.unregisterClientId = unregisterClientId;
7
+ exports.encodeSyncHash = encodeSyncHash;
8
+ exports.decodeBinaryMessage = decodeBinaryMessage;
12
9
  exports.connect = connect;
13
- const ws_1 = __importDefault(require("ws"));
14
- // ============================================
15
- // Binary Protocol
16
- // ============================================
10
+ const state_codec_1 = require("./state-codec");
11
+ // Binary Message Types (Must match node/src/binary-protocol.ts)
17
12
  const BinaryMessageType = {
18
13
  TICK: 0x01,
19
14
  INITIAL_STATE: 0x02,
@@ -22,128 +17,243 @@ const BinaryMessageType = {
22
17
  ERROR: 0x05,
23
18
  SNAPSHOT_UPDATE: 0x06,
24
19
  ROOM_LEFT: 0x07,
25
- SYNC_HASH: 0x08
20
+ SYNC_HASH: 0x08,
21
+ CLIENT_LIST_UPDATE: 0x09
26
22
  };
23
+ /**
24
+ * Hash a client ID to a 4-byte identifier (matches server-side hash)
25
+ * Uses FNV-1a hash for speed and consistency
26
+ */
27
+ function hashClientId(clientId) {
28
+ let hash = 2166136261; // FNV offset basis
29
+ for (let i = 0; i < clientId.length; i++) {
30
+ hash ^= clientId.charCodeAt(i);
31
+ hash = Math.imul(hash, 16777619) >>> 0; // FNV prime, keep as 32-bit unsigned
32
+ }
33
+ return hash;
34
+ }
35
+ // Backwards compat alias
36
+ exports.hashPlayerId = hashClientId;
37
+ // Map clientHash -> clientId for decoding TICK messages
38
+ const clientHashMap = new Map();
39
+ /**
40
+ * Register a client ID so we can decode their hash in TICK messages
41
+ * Call this when a client joins (from the join event's clientId)
42
+ */
43
+ function registerClientId(clientId) {
44
+ const hash = hashClientId(clientId);
45
+ clientHashMap.set(hash, clientId);
46
+ }
47
+ // Backwards compat alias
48
+ exports.registerPlayerId = registerClientId;
49
+ /**
50
+ * Unregister a client ID (call on leave)
51
+ */
52
+ function unregisterClientId(clientId) {
53
+ const hash = hashClientId(clientId);
54
+ clientHashMap.delete(hash);
55
+ }
56
+ // Backwards compat alias
57
+ exports.unregisterPlayerId = unregisterClientId;
58
+ /**
59
+ * Look up client ID from hash
60
+ */
61
+ function lookupClientHash(hash) {
62
+ return clientHashMap.get(hash);
63
+ }
64
+ // Encode sync hash
27
65
  function encodeSyncHash(roomId, hash, seq, frame) {
28
66
  const roomIdBytes = new TextEncoder().encode(roomId);
29
67
  const hashBytes = new TextEncoder().encode(hash);
30
- const totalLen = 1 + 2 + roomIdBytes.length + 2 + hashBytes.length + 4 + 4;
31
- const buffer = new ArrayBuffer(totalLen);
32
- const view = new DataView(buffer);
33
- const uint8 = new Uint8Array(buffer);
68
+ // 1 (type) + 2 (roomId len) + roomId + 2 (hash len) + hash + 4 (seq) + 4 (frame)
69
+ const buf = new Uint8Array(1 + 2 + roomIdBytes.length + 2 + hashBytes.length + 4 + 4);
70
+ const view = new DataView(buf.buffer);
34
71
  let offset = 0;
35
- view.setUint8(offset, BinaryMessageType.SYNC_HASH);
36
- offset += 1;
72
+ buf[offset++] = BinaryMessageType.SYNC_HASH;
37
73
  view.setUint16(offset, roomIdBytes.length, true);
38
74
  offset += 2;
39
- uint8.set(roomIdBytes, offset);
75
+ buf.set(roomIdBytes, offset);
40
76
  offset += roomIdBytes.length;
41
77
  view.setUint16(offset, hashBytes.length, true);
42
78
  offset += 2;
43
- uint8.set(hashBytes, offset);
79
+ buf.set(hashBytes, offset);
44
80
  offset += hashBytes.length;
45
81
  view.setUint32(offset, seq, true);
46
82
  offset += 4;
47
83
  view.setUint32(offset, frame, true);
48
- return buffer;
84
+ return buf;
49
85
  }
50
86
  function decodeBinaryMessage(buffer) {
51
- const view = new DataView(buffer);
52
87
  if (buffer.byteLength === 0)
53
88
  return null;
89
+ const view = new DataView(buffer);
54
90
  const type = view.getUint8(0);
55
- switch (type) {
56
- case BinaryMessageType.TICK: {
57
- const frame = view.getUint32(1, true);
58
- let inputs = [];
59
- if (buffer.byteLength > 5) {
60
- const inputCount = view.getUint16(5, true);
61
- if (inputCount > 0) {
62
- const inputsJson = new TextDecoder().decode(buffer.slice(7));
63
- inputs = JSON.parse(inputsJson);
91
+ try {
92
+ switch (type) {
93
+ case BinaryMessageType.TICK: {
94
+ // Binary format: [type:1][frame:4][count:1][inputs...]
95
+ // Each input: [clientHash:4][seq:4][dataLen:2][data:dataLen]
96
+ // Data can be JSON (join/leave) or binary (game inputs)
97
+ const frame = view.getUint32(1, true);
98
+ let events = [];
99
+ if (buffer.byteLength > 5) {
100
+ const inputCount = view.getUint8(5);
101
+ let offset = 6;
102
+ for (let i = 0; i < inputCount && offset < buffer.byteLength; i++) {
103
+ const clientHash = view.getUint32(offset, true);
104
+ offset += 4;
105
+ const seq = view.getUint32(offset, true);
106
+ offset += 4; // UInt32 to support >65535 events
107
+ const dataLen = view.getUint16(offset, true);
108
+ offset += 2;
109
+ if (offset + dataLen > buffer.byteLength)
110
+ break;
111
+ const rawBytes = new Uint8Array(buffer, offset, dataLen);
112
+ offset += dataLen;
113
+ // Detect format: JSON starts with '{' (0x7B) or '[' (0x5B)
114
+ let data;
115
+ const firstByte = rawBytes[0];
116
+ if (firstByte === 0x7B || firstByte === 0x5B) {
117
+ // JSON format - decode for join/leave events
118
+ try {
119
+ const jsonStr = new TextDecoder().decode(rawBytes);
120
+ data = JSON.parse(jsonStr);
121
+ }
122
+ catch {
123
+ data = rawBytes;
124
+ }
125
+ }
126
+ else {
127
+ // Binary format - pass raw bytes to game layer
128
+ data = rawBytes;
129
+ }
130
+ // Look up client ID from hash
131
+ let clientId;
132
+ if (typeof data === 'object' && !(data instanceof Uint8Array)) {
133
+ // Auto-register clientId if present (e.g., join events)
134
+ // This ensures hash lookups work for subsequent inputs
135
+ if (data.clientId && !lookupClientHash(clientHash)) {
136
+ registerClientId(data.clientId);
137
+ }
138
+ clientId = data.clientId || lookupClientHash(clientHash) || `hash_${clientHash.toString(16)}`;
139
+ }
140
+ else {
141
+ clientId = lookupClientHash(clientHash) || `hash_${clientHash.toString(16)}`;
142
+ }
143
+ events.push({ seq, data, clientId, clientHash });
144
+ }
64
145
  }
146
+ return { type: 'TICK', frame, events };
65
147
  }
66
- return { type: 'TICK', frame, inputs };
67
- }
68
- case BinaryMessageType.INITIAL_STATE: {
69
- let offset = 1;
70
- const frame = view.getUint32(offset, true);
71
- offset += 4;
72
- const roomIdLen = view.getUint16(offset, true);
73
- offset += 2;
74
- const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
75
- offset += roomIdLen;
76
- const snapshotLen = view.getUint32(offset, true);
77
- offset += 4;
78
- const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
79
- offset += snapshotLen;
80
- const inputsLen = view.getUint32(offset, true);
81
- offset += 4;
82
- const inputsJson = new TextDecoder().decode(buffer.slice(offset, offset + inputsLen));
83
- const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
84
- const inputs = JSON.parse(inputsJson);
85
- return { type: 'INITIAL_STATE', roomId, frame, snapshot, snapshotHash, inputs };
86
- }
87
- case BinaryMessageType.ROOM_JOINED: {
88
- const roomIdLen = view.getUint16(1, true);
89
- const roomId = new TextDecoder().decode(buffer.slice(3, 3 + roomIdLen));
90
- return { type: 'ROOM_JOINED', roomId };
91
- }
92
- case BinaryMessageType.ROOM_CREATED: {
93
- let offset = 1;
94
- const roomIdLen = view.getUint16(offset, true);
95
- offset += 2;
96
- const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
97
- offset += roomIdLen;
98
- const snapshotLen = view.getUint32(offset, true);
99
- offset += 4;
100
- const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
101
- const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
102
- return { type: 'ROOM_CREATED', roomId, snapshot, snapshotHash };
103
- }
104
- case BinaryMessageType.ERROR: {
105
- const msgLen = view.getUint16(1, true);
106
- const message = new TextDecoder().decode(buffer.slice(3, 3 + msgLen));
107
- return { type: 'ERROR', message };
108
- }
109
- case BinaryMessageType.SNAPSHOT_UPDATE: {
110
- let offset = 1;
111
- const roomIdLen = view.getUint16(offset, true);
112
- offset += 2;
113
- const roomId = new TextDecoder().decode(buffer.slice(offset, offset + roomIdLen));
114
- offset += roomIdLen;
115
- const snapshotLen = view.getUint32(offset, true);
116
- offset += 4;
117
- const snapshotJson = new TextDecoder().decode(buffer.slice(offset, offset + snapshotLen));
118
- const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
119
- return { type: 'SNAPSHOT_UPDATE', roomId, snapshot, snapshotHash };
120
- }
121
- case BinaryMessageType.ROOM_LEFT: {
122
- const roomIdLen = view.getUint16(1, true);
123
- const roomId = new TextDecoder().decode(buffer.slice(3, 3 + roomIdLen));
124
- return { type: 'ROOM_LEFT', roomId };
148
+ case BinaryMessageType.INITIAL_STATE: {
149
+ let offset = 1;
150
+ const frame = view.getUint32(offset, true);
151
+ offset += 4;
152
+ const roomIdLen = view.getUint16(offset, true);
153
+ offset += 2;
154
+ if (offset + roomIdLen > buffer.byteLength) {
155
+ console.error('[modd-network] Buffer overflow reading INITIAL_STATE roomId');
156
+ return null;
157
+ }
158
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
159
+ offset += roomIdLen;
160
+ const snapshotLen = view.getUint32(offset, true);
161
+ offset += 4;
162
+ if (offset + snapshotLen > buffer.byteLength) {
163
+ console.error('[modd-network] Buffer overflow reading INITIAL_STATE snapshot');
164
+ return null;
165
+ }
166
+ const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
167
+ offset += snapshotLen;
168
+ const inputsLen = view.getUint32(offset, true);
169
+ offset += 4;
170
+ if (offset + inputsLen > buffer.byteLength) {
171
+ console.error('[modd-network] Buffer overflow reading INITIAL_STATE inputs');
172
+ return null;
173
+ }
174
+ const inputsJson = new TextDecoder().decode(new Uint8Array(buffer, offset, inputsLen));
175
+ const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
176
+ const events = JSON.parse(inputsJson);
177
+ return { type: 'INITIAL_STATE', frame, snapshot, snapshotHash, events };
178
+ }
179
+ case BinaryMessageType.ROOM_CREATED: {
180
+ let offset = 1;
181
+ const roomIdLen = view.getUint16(offset, true);
182
+ offset += 2;
183
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
184
+ offset += roomIdLen;
185
+ const clientIdLen = view.getUint16(offset, true);
186
+ offset += 2;
187
+ const clientId = new TextDecoder().decode(new Uint8Array(buffer, offset, clientIdLen));
188
+ offset += clientIdLen;
189
+ const snapshotLen = view.getUint32(offset, true);
190
+ offset += 4;
191
+ const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
192
+ const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
193
+ return { type: 'ROOM_CREATED', roomId, clientId, snapshot, snapshotHash };
194
+ }
195
+ case BinaryMessageType.ROOM_JOINED: {
196
+ let offset = 1;
197
+ const roomIdLen = view.getUint16(offset, true);
198
+ offset += 2;
199
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
200
+ offset += roomIdLen;
201
+ const clientIdLen = view.getUint16(offset, true);
202
+ offset += 2;
203
+ const clientId = new TextDecoder().decode(new Uint8Array(buffer, offset, clientIdLen));
204
+ return { type: 'ROOM_JOINED', roomId, clientId };
205
+ }
206
+ case BinaryMessageType.ERROR: {
207
+ const msgLen = view.getUint16(1, true);
208
+ const message = new TextDecoder().decode(new Uint8Array(buffer, 3, msgLen));
209
+ return { type: 'ERROR', message };
210
+ }
211
+ case BinaryMessageType.SNAPSHOT_UPDATE: {
212
+ let offset = 1;
213
+ const roomIdLen = view.getUint16(offset, true);
214
+ offset += 2;
215
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
216
+ offset += roomIdLen;
217
+ const snapshotLen = view.getUint32(offset, true);
218
+ offset += 4;
219
+ const snapshotJson = new TextDecoder().decode(new Uint8Array(buffer, offset, snapshotLen));
220
+ const { snapshot, snapshotHash } = JSON.parse(snapshotJson);
221
+ return { type: 'SNAPSHOT_UPDATE', roomId, snapshot, snapshotHash };
222
+ }
223
+ case BinaryMessageType.ROOM_LEFT: {
224
+ const roomIdLen = view.getUint16(1, true);
225
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, 3, roomIdLen));
226
+ return { type: 'ROOM_LEFT', roomId };
227
+ }
228
+ case BinaryMessageType.CLIENT_LIST_UPDATE: {
229
+ let offset = 1;
230
+ const roomIdLen = view.getUint16(offset, true);
231
+ offset += 2;
232
+ const roomId = new TextDecoder().decode(new Uint8Array(buffer, offset, roomIdLen));
233
+ offset += roomIdLen;
234
+ const clientsLen = view.getUint32(offset, true);
235
+ offset += 4;
236
+ const clientsJson = new TextDecoder().decode(new Uint8Array(buffer, offset, clientsLen));
237
+ const clients = JSON.parse(clientsJson);
238
+ return { type: 'CLIENT_LIST_UPDATE', roomId, clients };
239
+ }
240
+ default:
241
+ return null;
125
242
  }
126
- default:
127
- return null;
243
+ }
244
+ catch (err) {
245
+ console.error('[modd-network] Decode error:', err);
246
+ return null;
128
247
  }
129
248
  }
130
- // Helper to normalize binary data to ArrayBuffer
131
249
  async function toArrayBuffer(data) {
132
- if (data instanceof ArrayBuffer) {
250
+ if (data instanceof ArrayBuffer)
133
251
  return data;
134
- }
135
- if (typeof Blob !== 'undefined' && data instanceof Blob) {
252
+ if (typeof Blob !== 'undefined' && data instanceof Blob)
136
253
  return await data.arrayBuffer();
137
- }
138
- // Fallback for Buffer, ArrayBufferView, or array-like
139
- // creating a new Uint8Array(data) copies the data to a new ArrayBuffer
140
254
  return new Uint8Array(data).buffer;
141
255
  }
142
- // ============================================
143
- // Main Connect Function
144
- // ============================================
145
- async function connect(roomId, options = {}) {
146
- const appId = options.appId || 'app';
256
+ async function connect(appId, roomId, options = {}) {
147
257
  const initialSnapshot = options.snapshot || {};
148
258
  const user = options.user || null;
149
259
  const onConnect = options.onConnect || (() => { });
@@ -152,17 +262,17 @@ async function connect(roomId, options = {}) {
152
262
  const onMessage = options.onMessage || (() => { });
153
263
  const onTick = options.onTick || null;
154
264
  const getStateHash = options.getStateHash || null;
155
- // Determine central service URL
156
265
  const centralServiceUrl = options.centralServiceUrl || (typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
157
266
  ? 'http://localhost:9001'
158
267
  : 'https://nodes.modd.io');
268
+ console.log('[modd-network] Central service URL:', centralServiceUrl);
159
269
  let connected = false;
160
- let initialStateReceived = null;
161
270
  let deliveredSeqs = new Set();
162
271
  let pendingTicks = [];
163
272
  let ws = null;
164
273
  let nodeUrl = null;
165
- // Bandwidth tracking
274
+ let nodeToken = null;
275
+ let connectionResolve = null;
166
276
  let bytesIn = 0;
167
277
  let bytesOut = 0;
168
278
  let lastBytesIn = 0;
@@ -170,280 +280,315 @@ async function connect(roomId, options = {}) {
170
280
  let bandwidthIn = 0;
171
281
  let bandwidthOut = 0;
172
282
  let bandwidthInterval = null;
173
- // Hash sync tracking
174
283
  let hashInterval = null;
175
284
  let lastSyncSeq = 0;
176
285
  let lastSyncFrame = 0;
177
286
  let currentFrame = 0;
287
+ let myClientId = null;
288
+ // Process events to auto-register clientIds from join/leave events
289
+ // This is CRITICAL for binary TICK decoding - the hash lookup needs clientIds registered
290
+ function processEventsForClientIds(events) {
291
+ for (const event of events) {
292
+ const data = event.data || {};
293
+ const eventType = data.type || event.type;
294
+ if (eventType === 'join') {
295
+ const clientId = data.clientId || event.clientId;
296
+ if (clientId) {
297
+ registerClientId(clientId);
298
+ }
299
+ }
300
+ else if (eventType === 'leave') {
301
+ const clientId = data.clientId || event.clientId;
302
+ if (clientId) {
303
+ unregisterClientId(clientId);
304
+ }
305
+ }
306
+ }
307
+ }
178
308
  try {
179
- const res = await fetch(`${centralServiceUrl}/api/apps/${appId}/rooms/${roomId}/connect`, {
180
- method: 'POST',
181
- headers: { 'Content-Type': 'application/json' },
182
- body: JSON.stringify({ creatorNodeId: options.creatorNodeId })
183
- });
184
- if (!res.ok) {
185
- throw new Error(`Central service error: ${res.status}`);
309
+ // Allow direct node URL for testing cross-node scenarios
310
+ if (options.nodeUrl) {
311
+ nodeUrl = options.nodeUrl;
312
+ console.log('[modd-network] Using direct Node URL:', nodeUrl);
313
+ }
314
+ else {
315
+ const requestBody = {};
316
+ if (options.joinToken) {
317
+ requestBody.joinToken = options.joinToken;
318
+ }
319
+ const res = await fetch(`${centralServiceUrl}/api/apps/${appId}/rooms/${roomId}/connect`, {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify(requestBody)
323
+ });
324
+ if (!res.ok) {
325
+ const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
326
+ throw new Error(errorData.error || `Central service error: ${res.status}`);
327
+ }
328
+ const responseData = await res.json();
329
+ nodeUrl = responseData.url;
330
+ nodeToken = responseData.token;
331
+ console.log('[modd-network] Received Node URL:', nodeUrl);
186
332
  }
187
- const { url } = await res.json();
188
- nodeUrl = url;
189
- // Use imported WebSocket (work in Node) or global WebSocket if present (Browser)
190
- // Note: importing 'ws' gives a constructor. In browser env, 'ws' module might be shimmed.
191
- // However, for best compat, let's use globalThis.WebSocket if available, else WS.
192
- const WS = (typeof globalThis !== 'undefined' && globalThis.WebSocket) ? globalThis.WebSocket : ws_1.default;
193
- // @ts-ignore - TS might complain about mixing types
194
- ws = new WS(nodeUrl);
195
- ws.binaryType = 'arraybuffer'; // Ensure we get ArrayBuffer if possible (in Browser)
196
- ws.onopen = () => {
197
- bandwidthInterval = setInterval(() => {
198
- bandwidthIn = bytesIn - lastBytesIn;
199
- bandwidthOut = bytesOut - lastBytesOut;
200
- lastBytesIn = bytesIn;
201
- lastBytesOut = bytesOut;
202
- }, 1000);
203
- if (getStateHash) {
204
- hashInterval = setInterval(() => {
333
+ const WS = (typeof globalThis !== 'undefined' && globalThis.WebSocket) ? globalThis.WebSocket : WebSocket;
334
+ return new Promise((resolve, reject) => {
335
+ connectionResolve = resolve;
336
+ // Append JWT token to WebSocket URL if provided by central service
337
+ const wsUrl = nodeToken ? `${nodeUrl}?token=${encodeURIComponent(nodeToken)}` : nodeUrl;
338
+ // @ts-ignore
339
+ ws = new WS(wsUrl);
340
+ ws.binaryType = 'arraybuffer';
341
+ const instance = {
342
+ send(data) {
205
343
  if (!connected || !ws || ws.readyState !== 1)
206
344
  return;
207
- try {
208
- const hash = getStateHash();
209
- if (hash) {
210
- const hashMsg = encodeSyncHash(roomId, hash, lastSyncSeq, lastSyncFrame);
211
- bytesOut += hashMsg.byteLength;
212
- ws.send(hashMsg);
213
- }
214
- }
215
- catch (err) {
216
- console.warn('[modd-network] Error getting state hash:', err);
345
+ if (data?.type === 'playerState' && data.id) {
346
+ const binary = (0, state_codec_1.encodePlayerState)(data);
347
+ const wrapper = new Uint8Array(1 + binary.length);
348
+ wrapper[0] = 0x20;
349
+ wrapper.set(binary, 1);
350
+ ws.send(wrapper);
351
+ return;
217
352
  }
218
- }, 1000);
219
- }
220
- const joinMsg = JSON.stringify({ type: 'JOIN_ROOM', payload: { roomId } });
221
- bytesOut += joinMsg.length;
222
- ws.send(joinMsg);
223
- };
224
- ws.onerror = (e) => onError(`Failed to connect to ${nodeUrl}: ${e.message || 'Unknown error'}`);
225
- ws.onclose = () => {
226
- connected = false;
227
- if (bandwidthInterval)
228
- clearInterval(bandwidthInterval);
229
- if (hashInterval)
230
- clearInterval(hashInterval);
231
- onDisconnect();
232
- };
233
- ws.onmessage = async (e) => {
234
- // Handle various binary formats
235
- let buffer;
236
- try {
237
- buffer = await toArrayBuffer(e.data);
238
- }
239
- catch (err) {
240
- console.warn('[modd-network] Failed to read message data:', err);
241
- return;
242
- }
243
- bytesIn += buffer.byteLength;
244
- const msg = decodeBinaryMessage(buffer);
245
- if (!msg) {
246
- console.warn('[modd-network] Failed to decode binary message');
247
- return;
248
- }
249
- switch (msg.type) {
250
- case 'TICK': {
251
- if (!connected) {
252
- pendingTicks.push(msg);
253
- break;
353
+ if (data?.type === 'entityState' && data.id) {
354
+ const binary = (0, state_codec_1.encodeEntityState)(data);
355
+ const wrapper = new Uint8Array(1 + binary.length);
356
+ wrapper[0] = 0x20;
357
+ wrapper.set(binary, 1);
358
+ ws.send(wrapper);
359
+ return;
254
360
  }
255
- currentFrame = msg.frame;
256
- lastSyncFrame = msg.frame;
257
- if (msg.inputs && msg.inputs.length > 0) {
258
- const maxSeq = Math.max(...msg.inputs.map(i => i.sequenceNumber || 0));
259
- if (maxSeq > lastSyncSeq)
260
- lastSyncSeq = maxSeq;
361
+ if (data?.type === 'inputState' && data.id) {
362
+ const binary = (0, state_codec_1.encodeInputState)(data);
363
+ const wrapper = new Uint8Array(1 + binary.length);
364
+ wrapper[0] = 0x20;
365
+ wrapper.set(binary, 1);
366
+ ws.send(wrapper);
367
+ return;
261
368
  }
262
- if (onTick) {
263
- const newInputs = msg.inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
264
- newInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
265
- onTick(msg.frame, newInputs);
369
+ const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data } });
370
+ bytesOut += msg.length;
371
+ ws.send(msg);
372
+ },
373
+ sendSnapshot(snapshot, hash) {
374
+ if (connected && ws) {
375
+ const msg = JSON.stringify({ type: 'SEND_SNAPSHOT', payload: { roomId, snapshot, hash } });
376
+ bytesOut += msg.length;
377
+ ws.send(msg);
266
378
  }
267
- break;
268
- }
269
- case 'ERROR': {
270
- if (msg.message === 'Room not found') {
271
- const createMsg = JSON.stringify({
272
- type: 'CREATE_ROOM',
273
- payload: { roomId, appId, snapshot: initialSnapshot }
274
- });
275
- bytesOut += createMsg.length;
276
- ws.send(createMsg);
379
+ },
380
+ leaveRoom() {
381
+ if (connected && user && ws) {
382
+ const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
383
+ bytesOut += msg.length;
384
+ ws.send(msg);
277
385
  }
278
- else {
279
- onError(msg.message);
386
+ if (ws)
387
+ ws.close();
388
+ },
389
+ getClients() {
390
+ if (connected && ws && ws.readyState === 1) {
391
+ const msg = JSON.stringify({ type: 'GET_CLIENTS', payload: { roomId } });
392
+ bytesOut += msg.length;
393
+ ws.send(msg);
280
394
  }
281
- break;
395
+ },
396
+ close() {
397
+ if (ws)
398
+ ws.close();
399
+ },
400
+ get connected() { return connected; },
401
+ get clientId() { return myClientId; },
402
+ get node() { return nodeUrl ? (nodeUrl.match(/:(\d+)/)?.[1] || nodeUrl) : null; },
403
+ get bandwidthIn() { return bandwidthIn; },
404
+ get bandwidthOut() { return bandwidthOut; },
405
+ get totalBytesIn() { return bytesIn; },
406
+ get totalBytesOut() { return bytesOut; },
407
+ get frame() { return currentFrame; },
408
+ getLatency() { return '0'; }
409
+ };
410
+ ws.onopen = () => {
411
+ bandwidthInterval = setInterval(() => {
412
+ bandwidthIn = bytesIn - lastBytesIn;
413
+ bandwidthOut = bytesOut - lastBytesOut;
414
+ lastBytesIn = bytesIn;
415
+ lastBytesOut = bytesOut;
416
+ }, 1000);
417
+ if (getStateHash) {
418
+ hashInterval = setInterval(() => {
419
+ if (!connected || !ws || ws.readyState !== 1)
420
+ return;
421
+ try {
422
+ const hash = getStateHash();
423
+ if (hash) {
424
+ const hashMsg = encodeSyncHash(roomId, hash, lastSyncSeq, lastSyncFrame);
425
+ bytesOut += hashMsg.byteLength;
426
+ ws.send(hashMsg);
427
+ }
428
+ }
429
+ catch (err) {
430
+ console.warn('[modd-network] Error getting state hash:', err);
431
+ }
432
+ }, 1000);
282
433
  }
283
- case 'ROOM_CREATED': {
284
- connected = true;
285
- if (user) {
286
- const joinInputMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'join', user } } });
287
- bytesOut += joinInputMsg.length;
288
- ws.send(joinInputMsg);
289
- }
290
- onConnect(initialSnapshot, []);
291
- break;
434
+ // Include user metadata - server will generate the join event
435
+ const joinMsg = JSON.stringify({ type: 'JOIN_ROOM', payload: { roomId, user } });
436
+ bytesOut += joinMsg.length;
437
+ ws.send(joinMsg);
438
+ };
439
+ ws.onerror = (e) => {
440
+ const errMsg = `Failed to connect to ${nodeUrl}: ${e.message || 'Unknown error'}`;
441
+ onError(errMsg);
442
+ if (!connected)
443
+ reject(new Error(errMsg));
444
+ };
445
+ ws.onclose = () => {
446
+ connected = false;
447
+ if (bandwidthInterval)
448
+ clearInterval(bandwidthInterval);
449
+ if (hashInterval)
450
+ clearInterval(hashInterval);
451
+ onDisconnect();
452
+ };
453
+ ws.onmessage = async (e) => {
454
+ let buffer;
455
+ try {
456
+ buffer = await toArrayBuffer(e.data);
292
457
  }
293
- case 'INITIAL_STATE': {
294
- initialStateReceived = {
295
- snapshot: msg.snapshot,
296
- inputs: msg.inputs || [],
297
- frame: msg.frame
298
- };
299
- lastSyncFrame = msg.frame;
300
- if (msg.inputs && msg.inputs.length > 0) {
301
- const maxSeq = Math.max(...msg.inputs.map(i => i.sequenceNumber || 0));
302
- if (maxSeq > lastSyncSeq)
303
- lastSyncSeq = maxSeq;
304
- }
305
- break;
458
+ catch (err) {
459
+ console.warn('[modd-network] Failed to read message data:', err);
460
+ return;
306
461
  }
307
- case 'ROOM_JOINED': {
308
- connected = true;
309
- if (user) {
310
- const joinInputMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'join', user } } });
311
- bytesOut += joinInputMsg.length;
312
- ws.send(joinInputMsg);
462
+ bytesIn += buffer.byteLength;
463
+ const msg = decodeBinaryMessage(buffer);
464
+ console.log("decoded msg: ", msg);
465
+ if (!msg)
466
+ return;
467
+ switch (msg.type) {
468
+ case 'TICK': {
469
+ if (!connected) {
470
+ pendingTicks.push(msg);
471
+ break;
472
+ }
473
+ currentFrame = msg.frame;
474
+ lastSyncFrame = msg.frame;
475
+ if (msg.events && msg.events.length > 0) {
476
+ const maxSeq = Math.max(...msg.events.map(e => e.seq || 0));
477
+ if (maxSeq > lastSyncSeq)
478
+ lastSyncSeq = maxSeq;
479
+ }
480
+ const newEvents = msg.events.filter(e => !deliveredSeqs.has(e.seq));
481
+ newEvents.forEach(e => deliveredSeqs.add(e.seq));
482
+ // Auto-register clientIds from join/leave events
483
+ if (newEvents.length > 0) {
484
+ processEventsForClientIds(newEvents);
485
+ }
486
+ if (onTick) {
487
+ onTick(msg.frame, newEvents);
488
+ }
489
+ break;
313
490
  }
314
- const processInitialState = () => {
315
- if (initialStateReceived) {
316
- const { snapshot, inputs, frame } = initialStateReceived;
317
- onConnect(snapshot, inputs);
318
- const newInputs = inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
319
- newInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
320
- if (onTick && newInputs.length > 0) {
321
- onTick(frame, newInputs, true);
322
- }
323
- else {
324
- newInputs.forEach(i => onMessage(i.data, i.sequenceNumber));
325
- }
326
- initialStateReceived = null;
327
- // Process buffered ticks
328
- if (pendingTicks.length > 0) {
329
- pendingTicks.sort((a, b) => a.frame - b.frame);
330
- for (const tickMsg of pendingTicks) {
331
- if (onTick) {
332
- const tickInputs = tickMsg.inputs.filter(i => !deliveredSeqs.has(i.sequenceNumber));
333
- tickInputs.forEach(i => deliveredSeqs.add(i.sequenceNumber));
334
- if (tickInputs.length > 0 || tickMsg.frame > frame) {
335
- onTick(tickMsg.frame, tickInputs);
336
- }
337
- }
491
+ case 'ERROR': {
492
+ if (msg.message === 'Room not found') {
493
+ // Include user metadata - server will generate the join event
494
+ const createMsg = JSON.stringify({
495
+ type: 'CREATE_ROOM',
496
+ payload: { roomId, snapshot: initialSnapshot, user }
497
+ });
498
+ bytesOut += createMsg.length;
499
+ ws.send(createMsg);
500
+ }
501
+ else {
502
+ onError(msg.message);
503
+ reject(new Error(msg.message));
504
+ }
505
+ break;
506
+ }
507
+ case 'ROOM_CREATED': {
508
+ connected = true;
509
+ currentFrame = 0;
510
+ // Server tells us our clientId - register it for hash lookup
511
+ if (msg.clientId) {
512
+ myClientId = msg.clientId;
513
+ registerClientId(msg.clientId);
514
+ console.log(`[modd-network] Assigned clientId: ${msg.clientId}`);
515
+ }
516
+ // Server generates join event - don't send one from client
517
+ onConnect(initialSnapshot, [], 0, nodeUrl);
518
+ if (connectionResolve)
519
+ connectionResolve(instance);
520
+ break;
521
+ }
522
+ case 'INITIAL_STATE': {
523
+ console.log('[modd-network] Received INITIAL_STATE, connecting...');
524
+ const { snapshot, events, frame } = msg;
525
+ currentFrame = frame;
526
+ lastSyncFrame = frame;
527
+ if (events && events.length > 0) {
528
+ const maxSeq = Math.max(...events.map(e => e.seq || 0));
529
+ if (maxSeq > lastSyncSeq)
530
+ lastSyncSeq = maxSeq;
531
+ }
532
+ // Auto-register clientIds from join events in history
533
+ if (events && events.length > 0) {
534
+ processEventsForClientIds(events);
535
+ }
536
+ // Treat INITIAL_STATE as proof of connection and readiness
537
+ connected = true;
538
+ onConnect(snapshot, events || [], frame, nodeUrl);
539
+ // Process pending ticks
540
+ if (pendingTicks.length > 0) {
541
+ pendingTicks.sort((a, b) => a.frame - b.frame);
542
+ for (const tickMsg of pendingTicks) {
543
+ const tickEvents = tickMsg.events.filter(e => !deliveredSeqs.has(e.seq));
544
+ tickEvents.forEach(e => deliveredSeqs.add(e.seq));
545
+ // Auto-register clientIds from pending ticks too
546
+ if (tickEvents.length > 0) {
547
+ processEventsForClientIds(tickEvents);
548
+ }
549
+ if (onTick && (tickEvents.length > 0 || tickMsg.frame > frame)) {
550
+ onTick(tickMsg.frame, tickEvents);
338
551
  }
339
- pendingTicks = [];
340
552
  }
553
+ pendingTicks = [];
341
554
  }
342
- else {
343
- onConnect(null, []);
555
+ if (connectionResolve)
556
+ connectionResolve(instance);
557
+ break;
558
+ }
559
+ case 'ROOM_JOINED': {
560
+ // Server tells us our clientId - register it for hash lookup
561
+ connected = true;
562
+ if (msg.clientId) {
563
+ myClientId = msg.clientId;
564
+ registerClientId(msg.clientId);
565
+ console.log(`[modd-network] Assigned clientId: ${msg.clientId}`);
344
566
  }
345
- };
346
- if (initialStateReceived) {
347
- processInitialState();
567
+ break;
348
568
  }
349
- else {
350
- setTimeout(processInitialState, 100);
569
+ case 'SNAPSHOT_UPDATE': {
570
+ if (options.onSnapshot)
571
+ options.onSnapshot(msg.snapshot, msg.snapshotHash);
572
+ break;
351
573
  }
352
- break;
353
- }
354
- case 'SNAPSHOT_UPDATE': {
355
- if (options.onSnapshot) {
356
- options.onSnapshot(msg.snapshot, msg.snapshotHash);
574
+ case 'ROOM_LEFT': {
575
+ console.log(`[modd-network] Left room ${msg.roomId}`);
576
+ break;
577
+ }
578
+ case 'CLIENT_LIST_UPDATE': {
579
+ if (options.onClientsUpdate)
580
+ options.onClientsUpdate(msg.clients);
581
+ break;
357
582
  }
358
- break;
359
- }
360
- case 'ROOM_LEFT': {
361
- console.log(`[modd-network] Left room ${msg.roomId}`);
362
- break;
363
- }
364
- }
365
- };
366
- // Auto leave on page unload
367
- if (user && typeof window !== 'undefined') {
368
- window.addEventListener('beforeunload', () => {
369
- if (connected && ws) {
370
- const leaveMsg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
371
- bytesOut += leaveMsg.length;
372
- ws.send(leaveMsg);
373
- }
374
- });
375
- }
376
- return {
377
- send(data) {
378
- if (connected && ws) {
379
- const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data } });
380
- bytesOut += msg.length;
381
- ws.send(msg);
382
- }
383
- },
384
- sendSnapshot(snapshot, hash) {
385
- if (connected && ws) {
386
- const msg = JSON.stringify({ type: 'SEND_SNAPSHOT', payload: { roomId, snapshot, hash } });
387
- bytesOut += msg.length;
388
- ws.send(msg);
389
- }
390
- },
391
- leaveRoom() {
392
- if (connected && user && ws) {
393
- const msg = JSON.stringify({ type: 'SEND_INPUT', payload: { roomId, data: { type: 'leave', user } } });
394
- bytesOut += msg.length;
395
- ws.send(msg);
396
583
  }
397
- if (ws)
398
- ws.close();
399
- },
400
- close() {
401
- if (ws)
402
- ws.close();
403
- },
404
- get connected() {
405
- return connected;
406
- },
407
- get node() {
408
- return nodeUrl ? (nodeUrl.match(/:(\d+)/)?.[1] || nodeUrl) : null;
409
- },
410
- get bandwidthIn() {
411
- return bandwidthIn;
412
- },
413
- get bandwidthOut() {
414
- return bandwidthOut;
415
- },
416
- get totalBytesIn() {
417
- return bytesIn;
418
- },
419
- get totalBytesOut() {
420
- return bytesOut;
421
- },
422
- get frame() {
423
- return currentFrame;
424
- }
425
- };
584
+ };
585
+ });
426
586
  }
427
587
  catch (err) {
428
- onError(`Failed to get node assignment: ${err.message}`);
429
- return {
430
- send() { },
431
- sendSnapshot() { },
432
- leaveRoom() { },
433
- close() { },
434
- get connected() { return false; },
435
- get node() { return null; },
436
- get bandwidthIn() { return 0; },
437
- get bandwidthOut() { return 0; },
438
- get totalBytesIn() { return 0; },
439
- get totalBytesOut() { return 0; },
440
- get frame() { return 0; }
441
- };
588
+ throw new Error(`Failed to get node assignment: ${err.message}`);
442
589
  }
443
590
  }
444
- // Legacy alias for backwards compatibility
445
591
  exports.modd = connect;
446
- // Browser global fallback
447
592
  if (typeof window !== 'undefined') {
448
593
  window.moddNetwork = { connect, modd: exports.modd };
449
594
  }