@noya-app/noya-multiplayer-react 0.1.88 → 0.2.0
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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +53 -0
- package/dist/index.bundle.js +34 -30
- package/dist/index.d.mts +187 -18
- package/dist/index.d.ts +187 -18
- package/dist/index.js +496 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +499 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/WebSocketConnection.ts +556 -20
- package/src/__tests__/WebSocketConnection.test.ts +1180 -0
- package/src/__tests__/noyaApp.test.ts +28 -0
- package/src/noyaApp.ts +15 -0
- package/src/useResourceUrl.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noya-app/noya-multiplayer-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"module": "./dist/index.mjs",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dev": "npm run build -- --watch"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@noya-app/state-manager": "0.
|
|
14
|
+
"@noya-app/state-manager": "0.3.0",
|
|
15
15
|
"@noya-app/emitter": "0.1.0",
|
|
16
16
|
"@noya-app/observable": "0.1.12",
|
|
17
17
|
"@noya-app/task-runner": "0.1.7",
|
|
@@ -1,38 +1,122 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ClientMessageBatch,
|
|
3
|
+
ClientTransportMessage,
|
|
2
4
|
ClientToServerMessage,
|
|
3
|
-
|
|
5
|
+
ConnectionEvent as StateManagerConnectionEvent,
|
|
6
|
+
EnqueueInputMessage,
|
|
7
|
+
MAX_CLIENT_MESSAGE_BATCH_SIZE,
|
|
8
|
+
MAX_REALTIME_INPUT_BATCHES_IN_FLIGHT,
|
|
4
9
|
ReconnectingWebSocket,
|
|
5
10
|
ReconnectingWebSocketState,
|
|
6
11
|
ServerToClientMessage,
|
|
12
|
+
SimulationFrameReceiptMessage,
|
|
7
13
|
UserActivityDetector,
|
|
8
14
|
} from "@noya-app/state-manager";
|
|
9
15
|
|
|
10
|
-
export type ConnectionEvent<State> =
|
|
11
|
-
| {
|
|
12
|
-
type: "stateChange";
|
|
13
|
-
state: ReconnectingWebSocketState;
|
|
14
|
-
}
|
|
15
|
-
| {
|
|
16
|
-
type: "send";
|
|
17
|
-
message: ClientToServerMessage<State>;
|
|
18
|
-
}
|
|
19
|
-
| {
|
|
20
|
-
type: "receive";
|
|
21
|
-
message: ServerToClientMessage<State>;
|
|
22
|
-
}
|
|
23
|
-
| {
|
|
24
|
-
type: "error";
|
|
25
|
-
error: MultiplayerStateManagerError;
|
|
26
|
-
};
|
|
16
|
+
export type ConnectionEvent<State> = StateManagerConnectionEvent<State>;
|
|
27
17
|
|
|
28
18
|
export type ConnectionOptions<State> = {
|
|
29
19
|
debug?: boolean;
|
|
20
|
+
heartbeatIntervalMs?: number;
|
|
21
|
+
silentTimeoutMs?: number;
|
|
22
|
+
realtimeInputWatchdogMs?: number;
|
|
30
23
|
onConnectionEvent?: (event: ConnectionEvent<State>) => void;
|
|
31
24
|
};
|
|
32
25
|
|
|
26
|
+
type SimulationResumeCursor = {
|
|
27
|
+
sequence: number;
|
|
28
|
+
sessionIdentity?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function simulationSessionIdentity(state: unknown) {
|
|
32
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return;
|
|
33
|
+
const game = Reflect.get(state, "$game");
|
|
34
|
+
if (!game || typeof game !== "object" || Array.isArray(game)) return;
|
|
35
|
+
const startedAt = Reflect.get(game, "startedAt");
|
|
36
|
+
if (
|
|
37
|
+
startedAt === null ||
|
|
38
|
+
typeof startedAt === "string" ||
|
|
39
|
+
(typeof startedAt === "number" && Number.isFinite(startedAt))
|
|
40
|
+
) {
|
|
41
|
+
return `startedAt:${typeof startedAt}:${String(startedAt)}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function simulationStateSequence(state: unknown) {
|
|
46
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return;
|
|
47
|
+
const simulation = Reflect.get(state, "simulation");
|
|
48
|
+
const simulationTick =
|
|
49
|
+
simulation && typeof simulation === "object"
|
|
50
|
+
? Reflect.get(simulation, "tick")
|
|
51
|
+
: undefined;
|
|
52
|
+
if (Number.isSafeInteger(simulationTick) && (simulationTick as number) >= 0) {
|
|
53
|
+
return simulationTick as number;
|
|
54
|
+
}
|
|
55
|
+
const game = Reflect.get(state, "$game");
|
|
56
|
+
const gameTick =
|
|
57
|
+
game && typeof game === "object"
|
|
58
|
+
? Reflect.get(game, "simulationTick")
|
|
59
|
+
: undefined;
|
|
60
|
+
return Number.isSafeInteger(gameTick) && (gameTick as number) >= 0
|
|
61
|
+
? (gameTick as number)
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sameSimulationSession(
|
|
66
|
+
left: SimulationResumeCursor,
|
|
67
|
+
right: SimulationResumeCursor
|
|
68
|
+
) {
|
|
69
|
+
return (
|
|
70
|
+
left.sessionIdentity === undefined ||
|
|
71
|
+
right.sessionIdentity === undefined ||
|
|
72
|
+
left.sessionIdentity === right.sessionIdentity
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createRealtimeInputSessionId() {
|
|
77
|
+
return typeof crypto !== "undefined" &&
|
|
78
|
+
typeof crypto.randomUUID === "function"
|
|
79
|
+
? crypto.randomUUID()
|
|
80
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
33
83
|
export class WebSocketConnection<State> {
|
|
34
84
|
private ws: ReconnectingWebSocket;
|
|
35
85
|
private closedForever = false;
|
|
86
|
+
private heartbeatIntervalHandle?: ReturnType<typeof setInterval>;
|
|
87
|
+
private silentTimeoutHandle?: ReturnType<typeof setTimeout>;
|
|
88
|
+
private simulationClockPulseIntervalHandle?: ReturnType<typeof setInterval>;
|
|
89
|
+
private simulationClockPulseSocket?: ReconnectingWebSocket;
|
|
90
|
+
private simulationClockDirectFailed = false;
|
|
91
|
+
private heartbeatSequence = 0;
|
|
92
|
+
private pendingSimulationFrames = new Map<
|
|
93
|
+
string,
|
|
94
|
+
Extract<ServerToClientMessage<State>, { type: "simulationFrame" }>
|
|
95
|
+
>();
|
|
96
|
+
private frameFlushHandle?: number;
|
|
97
|
+
private frameFlushScheduler?: "animationFrame" | "timeout";
|
|
98
|
+
private droppedSimulationFrames = 0;
|
|
99
|
+
private pendingFrameReceipts = new Map<
|
|
100
|
+
string,
|
|
101
|
+
SimulationFrameReceiptMessage
|
|
102
|
+
>();
|
|
103
|
+
private deliveredSimulationFrameCursors = new Map<
|
|
104
|
+
string,
|
|
105
|
+
SimulationResumeCursor
|
|
106
|
+
>();
|
|
107
|
+
private queuedRealtimeInputs: EnqueueInputMessage[] = [];
|
|
108
|
+
/**
|
|
109
|
+
* A batch stays here until the server explicitly confirms ingestion.
|
|
110
|
+
* Reconnects resend these exact batches, so a frame that was already in
|
|
111
|
+
* flight can never create a hole in an input stream.
|
|
112
|
+
*/
|
|
113
|
+
private unconfirmedRealtimeBatches: ClientMessageBatch[] = [];
|
|
114
|
+
private lastSentRealtimeSequences = new Map<string, number>();
|
|
115
|
+
private readonly realtimeInputSessionId = createRealtimeInputSessionId();
|
|
116
|
+
private nextRealtimeInputBatchSequence = 0;
|
|
117
|
+
private realtimeInputWatchdogHandle?: ReturnType<typeof setTimeout>;
|
|
118
|
+
private realtimeBatchesSent = 0;
|
|
119
|
+
private realtimeInputsSent = 0;
|
|
36
120
|
|
|
37
121
|
constructor(
|
|
38
122
|
private url: URL,
|
|
@@ -56,6 +140,10 @@ export class WebSocketConnection<State> {
|
|
|
56
140
|
private handleOpen = () => {
|
|
57
141
|
if (this.closedForever) return;
|
|
58
142
|
|
|
143
|
+
this.startHeartbeat();
|
|
144
|
+
this.replayUnconfirmedRealtimeBatches();
|
|
145
|
+
this.armRealtimeInputWatchdog();
|
|
146
|
+
|
|
59
147
|
if (this.options.debug) {
|
|
60
148
|
console.info("ws connected");
|
|
61
149
|
}
|
|
@@ -69,19 +157,151 @@ export class WebSocketConnection<State> {
|
|
|
69
157
|
private handleMessage = (event: MessageEvent) => {
|
|
70
158
|
if (this.closedForever) return;
|
|
71
159
|
|
|
160
|
+
this.armSilentTimeout();
|
|
161
|
+
|
|
72
162
|
if (this.options.debug) {
|
|
73
163
|
console.info("ws receiving message ", event.data);
|
|
74
164
|
}
|
|
75
165
|
|
|
76
166
|
const parsed = JSON.parse(event.data) as ServerToClientMessage<State>;
|
|
167
|
+
if (parsed.type === "simulationClockConfig") {
|
|
168
|
+
this.applySimulationClockConfig(parsed);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (parsed.type === "simulationFrame") {
|
|
172
|
+
const cursor = {
|
|
173
|
+
sequence: parsed.frame.sequence,
|
|
174
|
+
sessionIdentity: simulationSessionIdentity(parsed.frame.state),
|
|
175
|
+
};
|
|
176
|
+
const receipt: SimulationFrameReceiptMessage = {
|
|
177
|
+
type: "simulationFrameReceipt",
|
|
178
|
+
scriptId: parsed.frame.scriptId,
|
|
179
|
+
sequence: parsed.frame.sequence,
|
|
180
|
+
};
|
|
181
|
+
const pendingReceipt = this.pendingFrameReceipts.get(
|
|
182
|
+
parsed.frame.scriptId
|
|
183
|
+
);
|
|
184
|
+
if (!pendingReceipt || receipt.sequence > pendingReceipt.sequence) {
|
|
185
|
+
this.pendingFrameReceipts.set(parsed.frame.scriptId, receipt);
|
|
186
|
+
}
|
|
187
|
+
const delivered = this.deliveredSimulationFrameCursors.get(
|
|
188
|
+
parsed.frame.scriptId
|
|
189
|
+
);
|
|
190
|
+
const pending = this.pendingSimulationFrames.get(parsed.frame.scriptId);
|
|
191
|
+
const pendingCursor = pending
|
|
192
|
+
? {
|
|
193
|
+
sequence: pending.frame.sequence,
|
|
194
|
+
sessionIdentity: simulationSessionIdentity(pending.frame.state),
|
|
195
|
+
}
|
|
196
|
+
: undefined;
|
|
197
|
+
if (
|
|
198
|
+
(delivered &&
|
|
199
|
+
sameSimulationSession(delivered, cursor) &&
|
|
200
|
+
cursor.sequence <= delivered.sequence) ||
|
|
201
|
+
(pendingCursor &&
|
|
202
|
+
sameSimulationSession(pendingCursor, cursor) &&
|
|
203
|
+
cursor.sequence <= pendingCursor.sequence)
|
|
204
|
+
) {
|
|
205
|
+
this.droppedSimulationFrames += 1;
|
|
206
|
+
this.scheduleSimulationFrameFlush();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (pending) this.droppedSimulationFrames += 1;
|
|
210
|
+
this.pendingSimulationFrames.set(parsed.frame.scriptId, parsed);
|
|
211
|
+
this.scheduleSimulationFrameFlush();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
parsed.type === "object" &&
|
|
216
|
+
this.shouldSuppressNonMonotonicObject(parsed.object)
|
|
217
|
+
) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (parsed.type === "realtimeInputReceipt") {
|
|
222
|
+
this.confirmRealtimeInputBatches(parsed.sessionId, parsed.sequence);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.flushSimulationFrames();
|
|
226
|
+
this.emitReceivedMessage(parsed);
|
|
227
|
+
};
|
|
77
228
|
|
|
229
|
+
private emitReceivedMessage(message: ServerToClientMessage<State>) {
|
|
78
230
|
this.options.onConnectionEvent?.({
|
|
79
231
|
type: "receive",
|
|
80
|
-
message
|
|
232
|
+
message,
|
|
81
233
|
});
|
|
82
|
-
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private scheduleSimulationFrameFlush() {
|
|
237
|
+
if (this.frameFlushHandle !== undefined) return;
|
|
238
|
+
const flush = () => {
|
|
239
|
+
this.frameFlushHandle = undefined;
|
|
240
|
+
this.frameFlushScheduler = undefined;
|
|
241
|
+
this.flushSimulationFrames();
|
|
242
|
+
};
|
|
243
|
+
if (typeof requestAnimationFrame === "function") {
|
|
244
|
+
this.frameFlushScheduler = "animationFrame";
|
|
245
|
+
this.frameFlushHandle = requestAnimationFrame(flush);
|
|
246
|
+
} else {
|
|
247
|
+
this.frameFlushScheduler = "timeout";
|
|
248
|
+
this.frameFlushHandle = setTimeout(flush, 0) as unknown as number;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private flushSimulationFrames() {
|
|
253
|
+
this.cancelSimulationFrameFlush();
|
|
254
|
+
this.flushPendingFrameReceipts();
|
|
255
|
+
const frames = Array.from(this.pendingSimulationFrames.values());
|
|
256
|
+
this.pendingSimulationFrames.clear();
|
|
257
|
+
for (const frame of frames) {
|
|
258
|
+
this.deliveredSimulationFrameCursors.set(frame.frame.scriptId, {
|
|
259
|
+
sequence: frame.frame.sequence,
|
|
260
|
+
sessionIdentity: simulationSessionIdentity(frame.frame.state),
|
|
261
|
+
});
|
|
262
|
+
this.emitReceivedMessage(frame);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private shouldSuppressNonMonotonicObject(state: State) {
|
|
267
|
+
const sequence = simulationStateSequence(state);
|
|
268
|
+
if (sequence === undefined) return false;
|
|
269
|
+
const cursor = {
|
|
270
|
+
sequence,
|
|
271
|
+
sessionIdentity: simulationSessionIdentity(state),
|
|
272
|
+
};
|
|
273
|
+
const previous = Array.from(this.deliveredSimulationFrameCursors.values());
|
|
274
|
+
const comparable = previous.filter((entry) =>
|
|
275
|
+
sameSimulationSession(entry, cursor)
|
|
276
|
+
);
|
|
277
|
+
if (
|
|
278
|
+
cursor.sessionIdentity !== undefined &&
|
|
279
|
+
previous.some((entry) => entry.sessionIdentity !== undefined) &&
|
|
280
|
+
comparable.length === 0
|
|
281
|
+
) {
|
|
282
|
+
this.deliveredSimulationFrameCursors.clear();
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
return comparable.some((entry) => entry.sequence > cursor.sequence);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private cancelSimulationFrameFlush() {
|
|
289
|
+
if (this.frameFlushHandle === undefined) return;
|
|
290
|
+
|
|
291
|
+
if (this.frameFlushScheduler === "animationFrame") {
|
|
292
|
+
cancelAnimationFrame(this.frameFlushHandle);
|
|
293
|
+
} else {
|
|
294
|
+
clearTimeout(this.frameFlushHandle);
|
|
295
|
+
}
|
|
296
|
+
this.frameFlushHandle = undefined;
|
|
297
|
+
this.frameFlushScheduler = undefined;
|
|
298
|
+
}
|
|
83
299
|
|
|
84
300
|
private handleClose = () => {
|
|
301
|
+
this.stopHeartbeat();
|
|
302
|
+
this.stopSimulationClockPulses();
|
|
303
|
+
this.stopRealtimeInputWatchdog();
|
|
304
|
+
|
|
85
305
|
if (this.closedForever) return;
|
|
86
306
|
|
|
87
307
|
if (this.options.debug) {
|
|
@@ -94,9 +314,180 @@ export class WebSocketConnection<State> {
|
|
|
94
314
|
});
|
|
95
315
|
};
|
|
96
316
|
|
|
317
|
+
private startHeartbeat() {
|
|
318
|
+
this.stopHeartbeat();
|
|
319
|
+
this.armSilentTimeout();
|
|
320
|
+
|
|
321
|
+
const intervalMs = this.options.heartbeatIntervalMs ?? 15_000;
|
|
322
|
+
if (intervalMs <= 0) return;
|
|
323
|
+
|
|
324
|
+
this.heartbeatIntervalHandle = setInterval(() => {
|
|
325
|
+
if (this.closedForever || this.ws.state !== "OPEN") return;
|
|
326
|
+
|
|
327
|
+
this.send({
|
|
328
|
+
type: "ping",
|
|
329
|
+
id: `heartbeat-${Date.now()}-${++this.heartbeatSequence}`,
|
|
330
|
+
clientSentAt: Date.now(),
|
|
331
|
+
});
|
|
332
|
+
}, intervalMs);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private armSilentTimeout() {
|
|
336
|
+
if (this.silentTimeoutHandle !== undefined) {
|
|
337
|
+
clearTimeout(this.silentTimeoutHandle);
|
|
338
|
+
this.silentTimeoutHandle = undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const timeoutMs = this.options.silentTimeoutMs ?? 45_000;
|
|
342
|
+
if (timeoutMs <= 0 || this.closedForever || this.ws.state !== "OPEN")
|
|
343
|
+
return;
|
|
344
|
+
|
|
345
|
+
this.silentTimeoutHandle = setTimeout(() => {
|
|
346
|
+
this.silentTimeoutHandle = undefined;
|
|
347
|
+
if (this.closedForever || this.ws.state !== "OPEN") return;
|
|
348
|
+
|
|
349
|
+
this.stopSimulationClockPulses();
|
|
350
|
+
this.stopHeartbeat();
|
|
351
|
+
this.ws.reconnect();
|
|
352
|
+
}, timeoutMs);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private stopHeartbeat() {
|
|
356
|
+
if (this.heartbeatIntervalHandle !== undefined) {
|
|
357
|
+
clearInterval(this.heartbeatIntervalHandle);
|
|
358
|
+
this.heartbeatIntervalHandle = undefined;
|
|
359
|
+
}
|
|
360
|
+
if (this.silentTimeoutHandle !== undefined) {
|
|
361
|
+
clearTimeout(this.silentTimeoutHandle);
|
|
362
|
+
this.silentTimeoutHandle = undefined;
|
|
363
|
+
}
|
|
364
|
+
this.cancelSimulationFrameFlush();
|
|
365
|
+
this.pendingSimulationFrames.clear();
|
|
366
|
+
this.pendingFrameReceipts.clear();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private applySimulationClockConfig(
|
|
370
|
+
config: Extract<
|
|
371
|
+
ServerToClientMessage<State>,
|
|
372
|
+
{ type: "simulationClockConfig" }
|
|
373
|
+
>
|
|
374
|
+
) {
|
|
375
|
+
this.stopSimulationClockPulses();
|
|
376
|
+
if (
|
|
377
|
+
config.enabled !== true ||
|
|
378
|
+
!Number.isFinite(config.intervalMs) ||
|
|
379
|
+
config.intervalMs <= 0 ||
|
|
380
|
+
this.closedForever ||
|
|
381
|
+
this.ws.state !== "OPEN"
|
|
382
|
+
) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const intervalMs = Math.max(1, Math.floor(config.intervalMs));
|
|
387
|
+
if (config.pulsePath !== undefined) {
|
|
388
|
+
this.openSimulationClockPulseSocket(config.pulsePath);
|
|
389
|
+
}
|
|
390
|
+
this.simulationClockPulseIntervalHandle = setInterval(() => {
|
|
391
|
+
if (this.closedForever || this.ws.state !== "OPEN") return;
|
|
392
|
+
const pulse = JSON.stringify({ type: "simulationClockPulse" });
|
|
393
|
+
if (this.simulationClockPulseSocket?.state === "OPEN") {
|
|
394
|
+
this.simulationClockPulseSocket.send(pulse);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (
|
|
398
|
+
this.simulationClockPulseSocket &&
|
|
399
|
+
!this.simulationClockDirectFailed
|
|
400
|
+
) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.sendTransportMessage({ type: "simulationClockPulse" });
|
|
404
|
+
}, intervalMs);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private openSimulationClockPulseSocket(pulsePath: string) {
|
|
408
|
+
if (
|
|
409
|
+
!pulsePath.startsWith("/") ||
|
|
410
|
+
pulsePath.includes("?") ||
|
|
411
|
+
pulsePath.includes("#")
|
|
412
|
+
) {
|
|
413
|
+
this.simulationClockDirectFailed = true;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const pulseUrl = new URL(this.url);
|
|
417
|
+
pulseUrl.pathname = pulsePath;
|
|
418
|
+
pulseUrl.hash = "";
|
|
419
|
+
const socket = new ReconnectingWebSocket({
|
|
420
|
+
onopen: () => {
|
|
421
|
+
if (this.simulationClockPulseSocket !== socket) return;
|
|
422
|
+
this.simulationClockDirectFailed = false;
|
|
423
|
+
},
|
|
424
|
+
onclose: () => {
|
|
425
|
+
if (this.simulationClockPulseSocket !== socket) return;
|
|
426
|
+
this.simulationClockDirectFailed = true;
|
|
427
|
+
},
|
|
428
|
+
onerror: () => {
|
|
429
|
+
if (this.simulationClockPulseSocket !== socket) return;
|
|
430
|
+
this.simulationClockDirectFailed = true;
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
this.simulationClockPulseSocket = socket;
|
|
434
|
+
this.simulationClockDirectFailed = false;
|
|
435
|
+
try {
|
|
436
|
+
socket.connect(pulseUrl.toString());
|
|
437
|
+
} catch {
|
|
438
|
+
this.simulationClockPulseSocket = undefined;
|
|
439
|
+
this.simulationClockDirectFailed = true;
|
|
440
|
+
socket.shutdown();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private stopSimulationClockPulses() {
|
|
445
|
+
if (this.simulationClockPulseIntervalHandle !== undefined) {
|
|
446
|
+
clearInterval(this.simulationClockPulseIntervalHandle);
|
|
447
|
+
this.simulationClockPulseIntervalHandle = undefined;
|
|
448
|
+
}
|
|
449
|
+
const socket = this.simulationClockPulseSocket;
|
|
450
|
+
this.simulationClockPulseSocket = undefined;
|
|
451
|
+
this.simulationClockDirectFailed = false;
|
|
452
|
+
socket?.shutdown();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
getSimulationFrameDiagnostics() {
|
|
456
|
+
return {
|
|
457
|
+
pending: this.pendingSimulationFrames.size,
|
|
458
|
+
dropped: this.droppedSimulationFrames,
|
|
459
|
+
pendingReceipts: this.pendingFrameReceipts.size,
|
|
460
|
+
queuedRealtimeInputs: this.queuedRealtimeInputs.length,
|
|
461
|
+
replayRealtimeInputs: this.unconfirmedRealtimeBatches.reduce(
|
|
462
|
+
(count, batch) => count + batch.messages.length,
|
|
463
|
+
0
|
|
464
|
+
),
|
|
465
|
+
unconfirmedRealtimeBatches: this.unconfirmedRealtimeBatches.length,
|
|
466
|
+
realtimeBatchesSent: this.realtimeBatchesSent,
|
|
467
|
+
realtimeInputsSent: this.realtimeInputsSent,
|
|
468
|
+
realtimeWatchdogArmed: this.realtimeInputWatchdogHandle !== undefined,
|
|
469
|
+
simulationClockPulsesActive:
|
|
470
|
+
this.simulationClockPulseIntervalHandle !== undefined,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
97
474
|
send(message: ClientToServerMessage<State>) {
|
|
98
475
|
if (this.closedForever) return;
|
|
99
476
|
|
|
477
|
+
if (
|
|
478
|
+
message.type === "enqueueInput" &&
|
|
479
|
+
message.transport === "realtime" &&
|
|
480
|
+
this.getRealtimeInputSequence(message)
|
|
481
|
+
) {
|
|
482
|
+
this.queuedRealtimeInputs.push(message);
|
|
483
|
+
this.armRealtimeInputWatchdog();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.sendTransportMessage(message);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private sendTransportMessage(message: ClientTransportMessage<State>) {
|
|
100
491
|
if (this.options.debug) {
|
|
101
492
|
console.info("ws sending message", message);
|
|
102
493
|
}
|
|
@@ -109,10 +500,155 @@ export class WebSocketConnection<State> {
|
|
|
109
500
|
this.ws.send(JSON.stringify(message));
|
|
110
501
|
}
|
|
111
502
|
|
|
503
|
+
private getRealtimeInputSequence(message: EnqueueInputMessage) {
|
|
504
|
+
const payload = message.payload;
|
|
505
|
+
if (!payload || typeof payload !== "object") return;
|
|
506
|
+
const streamId = Reflect.get(payload, "streamId");
|
|
507
|
+
const sequence = Reflect.get(payload, "sequence");
|
|
508
|
+
if (
|
|
509
|
+
typeof streamId !== "string" ||
|
|
510
|
+
streamId.length === 0 ||
|
|
511
|
+
!Number.isInteger(sequence) ||
|
|
512
|
+
(sequence as number) < 0
|
|
513
|
+
) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
key: `${message.queue}\u0000${streamId}`,
|
|
518
|
+
sequence: sequence as number,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private getContiguousRealtimeInputCount() {
|
|
523
|
+
const sequences = new Map(this.lastSentRealtimeSequences);
|
|
524
|
+
let count = 0;
|
|
525
|
+
for (const message of this.queuedRealtimeInputs) {
|
|
526
|
+
if (count >= MAX_CLIENT_MESSAGE_BATCH_SIZE) break;
|
|
527
|
+
const input = this.getRealtimeInputSequence(message);
|
|
528
|
+
if (!input) break;
|
|
529
|
+
const previous = sequences.get(input.key);
|
|
530
|
+
if (input.sequence !== (previous === undefined ? 0 : previous + 1)) {
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
sequences.set(input.key, input.sequence);
|
|
534
|
+
count += 1;
|
|
535
|
+
}
|
|
536
|
+
return { count, sequences };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private flushRealtimeBatch(receipt?: SimulationFrameReceiptMessage) {
|
|
540
|
+
if (this.closedForever || this.ws.state !== "OPEN") return false;
|
|
541
|
+
const canSendInputBatch =
|
|
542
|
+
this.unconfirmedRealtimeBatches.length <
|
|
543
|
+
MAX_REALTIME_INPUT_BATCHES_IN_FLIGHT;
|
|
544
|
+
const contiguous = this.getContiguousRealtimeInputCount();
|
|
545
|
+
const messages = canSendInputBatch
|
|
546
|
+
? this.queuedRealtimeInputs.slice(0, contiguous.count)
|
|
547
|
+
: [];
|
|
548
|
+
if (!receipt && messages.length === 0) return false;
|
|
549
|
+
|
|
550
|
+
const batch: ClientMessageBatch = {
|
|
551
|
+
type: "clientMessageBatch",
|
|
552
|
+
...(receipt && { receipt }),
|
|
553
|
+
...(messages.length > 0 && {
|
|
554
|
+
inputBatch: {
|
|
555
|
+
sessionId: this.realtimeInputSessionId,
|
|
556
|
+
sequence: this.nextRealtimeInputBatchSequence,
|
|
557
|
+
},
|
|
558
|
+
}),
|
|
559
|
+
messages,
|
|
560
|
+
};
|
|
561
|
+
this.sendTransportMessage(batch);
|
|
562
|
+
if (messages.length > 0) {
|
|
563
|
+
this.queuedRealtimeInputs.splice(0, messages.length);
|
|
564
|
+
this.lastSentRealtimeSequences = contiguous.sequences;
|
|
565
|
+
this.unconfirmedRealtimeBatches.push(batch);
|
|
566
|
+
this.nextRealtimeInputBatchSequence += 1;
|
|
567
|
+
this.realtimeInputsSent += messages.length;
|
|
568
|
+
}
|
|
569
|
+
this.realtimeBatchesSent += 1;
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private flushPendingFrameReceipts() {
|
|
574
|
+
for (const [scriptId, receipt] of Array.from(
|
|
575
|
+
this.pendingFrameReceipts.entries()
|
|
576
|
+
)) {
|
|
577
|
+
if (!this.flushRealtimeBatch(receipt)) continue;
|
|
578
|
+
if (this.pendingFrameReceipts.get(scriptId) === receipt) {
|
|
579
|
+
this.pendingFrameReceipts.delete(scriptId);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (this.queuedRealtimeInputs.length > 0) {
|
|
583
|
+
this.armRealtimeInputWatchdog();
|
|
584
|
+
} else {
|
|
585
|
+
this.stopRealtimeInputWatchdog();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private armRealtimeInputWatchdog() {
|
|
590
|
+
if (
|
|
591
|
+
this.realtimeInputWatchdogHandle !== undefined ||
|
|
592
|
+
this.queuedRealtimeInputs.length === 0 ||
|
|
593
|
+
this.closedForever ||
|
|
594
|
+
this.ws.state !== "OPEN"
|
|
595
|
+
) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const configuredDelay = this.options.realtimeInputWatchdogMs;
|
|
599
|
+
const delay =
|
|
600
|
+
configuredDelay !== undefined && Number.isFinite(configuredDelay)
|
|
601
|
+
? Math.max(1, Math.floor(configuredDelay))
|
|
602
|
+
: 200;
|
|
603
|
+
this.realtimeInputWatchdogHandle = setTimeout(() => {
|
|
604
|
+
this.realtimeInputWatchdogHandle = undefined;
|
|
605
|
+
this.flushRealtimeBatch();
|
|
606
|
+
if (this.queuedRealtimeInputs.length > 0) {
|
|
607
|
+
this.armRealtimeInputWatchdog();
|
|
608
|
+
}
|
|
609
|
+
}, delay);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private stopRealtimeInputWatchdog() {
|
|
613
|
+
if (this.realtimeInputWatchdogHandle === undefined) return;
|
|
614
|
+
clearTimeout(this.realtimeInputWatchdogHandle);
|
|
615
|
+
this.realtimeInputWatchdogHandle = undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private replayUnconfirmedRealtimeBatches() {
|
|
619
|
+
if (this.closedForever || this.ws.state !== "OPEN") return;
|
|
620
|
+
for (const batch of this.unconfirmedRealtimeBatches) {
|
|
621
|
+
this.sendTransportMessage(batch);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private confirmRealtimeInputBatches(sessionId: string, sequence: number) {
|
|
626
|
+
if (
|
|
627
|
+
sessionId !== this.realtimeInputSessionId ||
|
|
628
|
+
!Number.isInteger(sequence) ||
|
|
629
|
+
sequence < 0
|
|
630
|
+
) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.unconfirmedRealtimeBatches = this.unconfirmedRealtimeBatches.filter(
|
|
634
|
+
(batch) => !batch.inputBatch || batch.inputBatch.sequence > sequence
|
|
635
|
+
);
|
|
636
|
+
if (this.queuedRealtimeInputs.length > 0) {
|
|
637
|
+
this.armRealtimeInputWatchdog();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
112
641
|
close() {
|
|
113
642
|
if (this.closedForever) return;
|
|
114
643
|
|
|
115
644
|
this.closedForever = true;
|
|
645
|
+
this.stopHeartbeat();
|
|
646
|
+
this.stopSimulationClockPulses();
|
|
647
|
+
this.stopRealtimeInputWatchdog();
|
|
648
|
+
this.queuedRealtimeInputs = [];
|
|
649
|
+
this.unconfirmedRealtimeBatches = [];
|
|
650
|
+
this.lastSentRealtimeSequences.clear();
|
|
651
|
+
this.deliveredSimulationFrameCursors.clear();
|
|
116
652
|
this.ws.shutdown();
|
|
117
653
|
|
|
118
654
|
// remove all listeners
|