@lokiplay/sdk 0.1.2 → 0.2.1
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/README.md +36 -4
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +270 -60
- package/dist/index.js.map +1 -1
- package/dist/synchronized-room.d.ts +103 -0
- package/dist/synchronized-room.d.ts.map +1 -0
- package/dist/synchronized-room.js +1029 -0
- package/dist/synchronized-room.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
import { actionIdentityKey, canonicalJson, PROTOCOL_VERSION, } from "@lokiplay/protocol";
|
|
2
|
+
export const SYNCHRONIZED_ROOM_MAX_MESSAGE_BYTES = 16_384;
|
|
3
|
+
export const SYNCHRONIZED_ROOM_MAX_PENDING = 32;
|
|
4
|
+
export const SYNCHRONIZED_ROOM_MAX_RECENT_ACTIONS = 64;
|
|
5
|
+
export const SYNCHRONIZED_ROOM_ACTION_TTL_MS = 600_000;
|
|
6
|
+
export const SYNCHRONIZED_ROOM_MAX_REDUCER_MS = 50;
|
|
7
|
+
export const SYNCHRONIZED_ROOM_COMMIT_TIMEOUT_MS = 10_000;
|
|
8
|
+
export class SynchronizedRoomError extends Error {
|
|
9
|
+
outcome;
|
|
10
|
+
constructor(outcome, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.outcome = outcome;
|
|
13
|
+
this.name = "SynchronizedRoomError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const ACTION_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
|
17
|
+
const cloneJson = (value) => {
|
|
18
|
+
if (typeof structuredClone === "function")
|
|
19
|
+
return structuredClone(value);
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
};
|
|
22
|
+
const createActionId = () => {
|
|
23
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
24
|
+
return crypto.randomUUID();
|
|
25
|
+
}
|
|
26
|
+
return `act_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
27
|
+
};
|
|
28
|
+
const asMembers = (list, hostId) => list.map((member) => ({
|
|
29
|
+
playerId: member.playerId,
|
|
30
|
+
sessionId: member.sessionId,
|
|
31
|
+
joinedAt: member.joinedAt,
|
|
32
|
+
team: member.team,
|
|
33
|
+
host: member.playerId === hostId || member.host,
|
|
34
|
+
}));
|
|
35
|
+
const mergeMembers = (current, message, hostId) => {
|
|
36
|
+
const byId = new Map(current.map((member) => [member.playerId, member]));
|
|
37
|
+
for (const leave of message.leaves)
|
|
38
|
+
byId.delete(leave.playerId);
|
|
39
|
+
for (const join of message.joins) {
|
|
40
|
+
byId.set(join.playerId, {
|
|
41
|
+
playerId: join.playerId,
|
|
42
|
+
sessionId: join.sessionId,
|
|
43
|
+
joinedAt: join.joinedAt,
|
|
44
|
+
team: join.team,
|
|
45
|
+
host: join.playerId === hostId || join.host,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (message.members.length) {
|
|
49
|
+
return asMembers(message.members, hostId);
|
|
50
|
+
}
|
|
51
|
+
return [...byId.values()].map((member) => ({
|
|
52
|
+
...member,
|
|
53
|
+
host: member.playerId === hostId,
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
56
|
+
const outcomeFromError = (code, message, actionOutcome) => {
|
|
57
|
+
if (actionOutcome)
|
|
58
|
+
return actionOutcome;
|
|
59
|
+
if (code === "STALE_VERSION")
|
|
60
|
+
return "state_conflict";
|
|
61
|
+
if (code === "HOST_REQUIRED")
|
|
62
|
+
return "authority_changed";
|
|
63
|
+
if (code === "RATE_LIMITED")
|
|
64
|
+
return "rate_limited";
|
|
65
|
+
if (code === "ROOM_NOT_FOUND")
|
|
66
|
+
return "room_closed";
|
|
67
|
+
if (message.includes("duplicate action"))
|
|
68
|
+
return "duplicate";
|
|
69
|
+
if (code === "INVALID_MESSAGE")
|
|
70
|
+
return "invalid";
|
|
71
|
+
return "rejected";
|
|
72
|
+
};
|
|
73
|
+
const assertJsonCompatible = (value) => {
|
|
74
|
+
canonicalJson(value);
|
|
75
|
+
};
|
|
76
|
+
const isEmptyObject = (value) => Boolean(value &&
|
|
77
|
+
typeof value === "object" &&
|
|
78
|
+
!Array.isArray(value) &&
|
|
79
|
+
Object.keys(value).length === 0);
|
|
80
|
+
const isUncommittedEmptyState = (message) => (message.type === "snapshot" || message.type === "state") &&
|
|
81
|
+
(message.stateVersion === undefined || message.stateVersion === 0) &&
|
|
82
|
+
isEmptyObject(message.state);
|
|
83
|
+
const protocolRejectionMessage = (message) => {
|
|
84
|
+
const text = message.trim() || "action rejected";
|
|
85
|
+
return text.length > 200 ? text.slice(0, 200) : text;
|
|
86
|
+
};
|
|
87
|
+
const serializedBytes = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
88
|
+
const monotonicNow = () => typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
89
|
+
export class SynchronizedRoom {
|
|
90
|
+
#host;
|
|
91
|
+
#options;
|
|
92
|
+
#listeners = new Set();
|
|
93
|
+
#committed = new Set();
|
|
94
|
+
#pending = new Map();
|
|
95
|
+
#inflight = new Map();
|
|
96
|
+
#recent = new Map();
|
|
97
|
+
#queue = [];
|
|
98
|
+
#waiters = new Map();
|
|
99
|
+
#commitTimers = new Map();
|
|
100
|
+
#prepared = new Map();
|
|
101
|
+
#unsubscribe;
|
|
102
|
+
#unsubscribeConnection;
|
|
103
|
+
#playerId = "";
|
|
104
|
+
#roomId = "";
|
|
105
|
+
#inviteCode = "";
|
|
106
|
+
#hostId = "";
|
|
107
|
+
#members = [];
|
|
108
|
+
#state;
|
|
109
|
+
#stateVersion = 0;
|
|
110
|
+
#connection = "idle";
|
|
111
|
+
#lastError;
|
|
112
|
+
#epoch = 0;
|
|
113
|
+
#generation = 0;
|
|
114
|
+
#processing = false;
|
|
115
|
+
#resyncing = false;
|
|
116
|
+
#explicitReconnect = false;
|
|
117
|
+
#previousState;
|
|
118
|
+
constructor(host, options) {
|
|
119
|
+
this.#host = host;
|
|
120
|
+
this.#options = options;
|
|
121
|
+
this.#state = this.#parseState(options.initialState);
|
|
122
|
+
assertJsonCompatible(this.#state);
|
|
123
|
+
this.#previousState = this.#state;
|
|
124
|
+
}
|
|
125
|
+
get isHost() {
|
|
126
|
+
return Boolean(this.#playerId) && this.#playerId === this.#hostId;
|
|
127
|
+
}
|
|
128
|
+
get members() {
|
|
129
|
+
return this.#members.map((member) => ({ ...member }));
|
|
130
|
+
}
|
|
131
|
+
getSnapshot() {
|
|
132
|
+
return {
|
|
133
|
+
roomId: this.#roomId,
|
|
134
|
+
inviteCode: this.#inviteCode,
|
|
135
|
+
playerId: this.#playerId,
|
|
136
|
+
hostId: this.#hostId,
|
|
137
|
+
members: this.members,
|
|
138
|
+
state: cloneJson(this.#state),
|
|
139
|
+
stateVersion: this.#stateVersion,
|
|
140
|
+
connection: this.#connection,
|
|
141
|
+
lastError: this.#lastError,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
subscribe(listener) {
|
|
145
|
+
this.#listeners.add(listener);
|
|
146
|
+
return () => this.#listeners.delete(listener);
|
|
147
|
+
}
|
|
148
|
+
onCommitted(listener) {
|
|
149
|
+
this.#committed.add(listener);
|
|
150
|
+
return () => this.#committed.delete(listener);
|
|
151
|
+
}
|
|
152
|
+
async create() {
|
|
153
|
+
return this.#enter(() => this.#host.createRoom(), { bootstrap: true });
|
|
154
|
+
}
|
|
155
|
+
async join(input) {
|
|
156
|
+
return this.#enter(() => this.#host.joinRoom(input), { bootstrap: false });
|
|
157
|
+
}
|
|
158
|
+
async dispatch(action) {
|
|
159
|
+
if (this.#connection !== "connected" &&
|
|
160
|
+
this.#connection !== "resynchronizing") {
|
|
161
|
+
throw new SynchronizedRoomError("rejected", "room is not connected");
|
|
162
|
+
}
|
|
163
|
+
if (this.#pending.size >= SYNCHRONIZED_ROOM_MAX_PENDING) {
|
|
164
|
+
throw new SynchronizedRoomError("rejected", "pending queue is full");
|
|
165
|
+
}
|
|
166
|
+
const parsed = this.#parseAction(action);
|
|
167
|
+
assertJsonCompatible(parsed);
|
|
168
|
+
const actionId = createActionId();
|
|
169
|
+
if (serializedBytes({ type: "action", actionId, payload: parsed }) >
|
|
170
|
+
SYNCHRONIZED_ROOM_MAX_MESSAGE_BYTES) {
|
|
171
|
+
throw new SynchronizedRoomError("invalid", "action exceeds maximum size");
|
|
172
|
+
}
|
|
173
|
+
const senderId = this.#requirePlayerId();
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
this.#pending.set(actionId, {
|
|
176
|
+
actionId,
|
|
177
|
+
action: parsed,
|
|
178
|
+
senderId,
|
|
179
|
+
epoch: this.#epoch,
|
|
180
|
+
resolve: resolve,
|
|
181
|
+
reject,
|
|
182
|
+
});
|
|
183
|
+
this.#armCommitTimeout(actionId);
|
|
184
|
+
void this.#submit(actionId, parsed, senderId).catch((error) => {
|
|
185
|
+
this.#failPending(actionId, error instanceof SynchronizedRoomError
|
|
186
|
+
? error
|
|
187
|
+
: new SynchronizedRoomError("indeterminate", String(error)));
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async leave() {
|
|
192
|
+
this.#generation += 1;
|
|
193
|
+
this.#setConnection("leaving");
|
|
194
|
+
this.#epoch += 1;
|
|
195
|
+
this.#rejectAll("room_closed", "room left");
|
|
196
|
+
this.#queue.length = 0;
|
|
197
|
+
this.#inflight.clear();
|
|
198
|
+
this.#prepared.clear();
|
|
199
|
+
this.#clearAllCommitTimeouts();
|
|
200
|
+
this.#releaseWaiters();
|
|
201
|
+
const roomId = this.#roomId;
|
|
202
|
+
try {
|
|
203
|
+
await this.#host.leaveRoom(roomId || undefined);
|
|
204
|
+
this.#clearIdentity();
|
|
205
|
+
this.#unbind();
|
|
206
|
+
this.#setConnection("closed");
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
this.#setConnection("leave_failed");
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async close() {
|
|
214
|
+
this.#generation += 1;
|
|
215
|
+
this.#epoch += 1;
|
|
216
|
+
this.#rejectAll("room_closed", "room closed");
|
|
217
|
+
this.#queue.length = 0;
|
|
218
|
+
this.#inflight.clear();
|
|
219
|
+
this.#prepared.clear();
|
|
220
|
+
this.#clearAllCommitTimeouts();
|
|
221
|
+
this.#releaseWaiters();
|
|
222
|
+
const roomId = this.#roomId;
|
|
223
|
+
this.#clearIdentity();
|
|
224
|
+
this.#unbind();
|
|
225
|
+
this.#setConnection("closed");
|
|
226
|
+
try {
|
|
227
|
+
await this.#host.leaveRoom(roomId || undefined);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// close() may abandon an unresolved leave.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async reconnect() {
|
|
234
|
+
if (this.#isTerminal()) {
|
|
235
|
+
throw new SynchronizedRoomError("rejected", "cannot reconnect from a terminal state");
|
|
236
|
+
}
|
|
237
|
+
this.#explicitReconnect = true;
|
|
238
|
+
this.#bind();
|
|
239
|
+
this.#setConnection("reconnecting");
|
|
240
|
+
this.#epoch += 1;
|
|
241
|
+
this.#resyncing = true;
|
|
242
|
+
this.#queue.length = 0;
|
|
243
|
+
this.#releaseWaiters();
|
|
244
|
+
this.#refreshPendingTimers();
|
|
245
|
+
try {
|
|
246
|
+
await this.#host.reconnect();
|
|
247
|
+
if (this.#resyncing &&
|
|
248
|
+
(this.#connection === "reconnecting" ||
|
|
249
|
+
this.#connection === "resynchronizing")) {
|
|
250
|
+
this.#setConnection("resynchronizing");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.#failRoom("indeterminate", error instanceof Error ? error.message : "reconnect failed");
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
this.#explicitReconnect = false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
#requirePlayerId() {
|
|
262
|
+
const playerId = this.#playerId || this.#host.playerId();
|
|
263
|
+
if (!playerId)
|
|
264
|
+
throw new SynchronizedRoomError("rejected", "authenticate before dispatching");
|
|
265
|
+
this.#playerId = playerId;
|
|
266
|
+
return playerId;
|
|
267
|
+
}
|
|
268
|
+
async #enter(join, options) {
|
|
269
|
+
if (this.#connection === "leave_failed") {
|
|
270
|
+
throw new SynchronizedRoomError("rejected", "resolve the failed leave before joining");
|
|
271
|
+
}
|
|
272
|
+
const generation = ++this.#generation;
|
|
273
|
+
this.#epoch += 1;
|
|
274
|
+
this.#queue.length = 0;
|
|
275
|
+
this.#inflight.clear();
|
|
276
|
+
this.#prepared.clear();
|
|
277
|
+
this.#recent.clear();
|
|
278
|
+
this.#clearAllCommitTimeouts();
|
|
279
|
+
if (this.#pending.size)
|
|
280
|
+
this.#rejectAll("rejected", "entering a new room");
|
|
281
|
+
this.#releaseWaiters();
|
|
282
|
+
this.#resyncing = false;
|
|
283
|
+
this.#setConnection("joining");
|
|
284
|
+
let joinedRoomId = "";
|
|
285
|
+
try {
|
|
286
|
+
this.#bind();
|
|
287
|
+
this.#playerId = this.#requirePlayerId();
|
|
288
|
+
const joined = await join();
|
|
289
|
+
joinedRoomId = joined.roomId;
|
|
290
|
+
if (generation !== this.#generation) {
|
|
291
|
+
await this.#host.leaveRoom(joined.roomId).catch(() => undefined);
|
|
292
|
+
throw new SynchronizedRoomError("rejected", "join superseded");
|
|
293
|
+
}
|
|
294
|
+
this.#requireCapabilities(joined.snapshot);
|
|
295
|
+
this.#roomId = joined.roomId;
|
|
296
|
+
this.#inviteCode = joined.inviteCode;
|
|
297
|
+
this.#applyAuthoritative(joined.snapshot, true, {
|
|
298
|
+
preserveLocalState: options.bootstrap,
|
|
299
|
+
generation,
|
|
300
|
+
});
|
|
301
|
+
this.#throwIfFailed("invalid join snapshot");
|
|
302
|
+
if (generation !== this.#generation) {
|
|
303
|
+
await this.#host.leaveRoom(joined.roomId).catch(() => undefined);
|
|
304
|
+
throw new SynchronizedRoomError("rejected", "join superseded");
|
|
305
|
+
}
|
|
306
|
+
if (options.bootstrap && this.isHost) {
|
|
307
|
+
await this.#bootstrapInitialState();
|
|
308
|
+
}
|
|
309
|
+
if (generation !== this.#generation) {
|
|
310
|
+
await this.#host.leaveRoom(joined.roomId).catch(() => undefined);
|
|
311
|
+
throw new SynchronizedRoomError("rejected", "join superseded");
|
|
312
|
+
}
|
|
313
|
+
this.#throwIfFailed("failed to enter room");
|
|
314
|
+
this.#setConnection("connected");
|
|
315
|
+
return this.getSnapshot();
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
this.#queue.length = 0;
|
|
319
|
+
this.#inflight.clear();
|
|
320
|
+
this.#prepared.clear();
|
|
321
|
+
this.#clearAllCommitTimeouts();
|
|
322
|
+
this.#rejectAll(error instanceof SynchronizedRoomError ? error.outcome : "rejected", error instanceof Error ? error.message : "failed to enter room");
|
|
323
|
+
this.#releaseWaiters();
|
|
324
|
+
this.#unbind();
|
|
325
|
+
if (joinedRoomId) {
|
|
326
|
+
await this.#host.leaveRoom(joinedRoomId).catch(() => undefined);
|
|
327
|
+
}
|
|
328
|
+
if (generation === this.#generation) {
|
|
329
|
+
this.#clearIdentity();
|
|
330
|
+
this.#setConnection("failed");
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async #bootstrapInitialState() {
|
|
336
|
+
const initial = this.#parseState(this.#options.initialState);
|
|
337
|
+
assertJsonCompatible(initial);
|
|
338
|
+
if (serializedBytes({
|
|
339
|
+
type: "host_state",
|
|
340
|
+
expectedStateVersion: 0,
|
|
341
|
+
state: initial,
|
|
342
|
+
}) > SYNCHRONIZED_ROOM_MAX_MESSAGE_BYTES) {
|
|
343
|
+
throw new SynchronizedRoomError("invalid", "state exceeds maximum size");
|
|
344
|
+
}
|
|
345
|
+
if (this.#stateVersion !== 0)
|
|
346
|
+
return;
|
|
347
|
+
this.#state = initial;
|
|
348
|
+
const actionId = createActionId();
|
|
349
|
+
const confirmation = this.#awaitCommit(actionId);
|
|
350
|
+
await this.#host.sendHostState(0, initial, {
|
|
351
|
+
actionId,
|
|
352
|
+
expectedStateVersion: 0,
|
|
353
|
+
senderId: this.#playerId,
|
|
354
|
+
});
|
|
355
|
+
const result = await confirmation;
|
|
356
|
+
if (result !== "committed") {
|
|
357
|
+
throw new SynchronizedRoomError("indeterminate", result === "timeout"
|
|
358
|
+
? "authoritative confirmation timed out"
|
|
359
|
+
: "initial state was not confirmed");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
#bind() {
|
|
363
|
+
if (this.#unsubscribe)
|
|
364
|
+
return;
|
|
365
|
+
this.#unsubscribe = this.#host.onMessage((message) => this.#onMessage(message));
|
|
366
|
+
this.#unsubscribeConnection = this.#host.onConnection?.((event) => {
|
|
367
|
+
if (this.#isInactive())
|
|
368
|
+
return;
|
|
369
|
+
if (event === "reconnect_failed") {
|
|
370
|
+
this.#failRoom("indeterminate", "reconnect failed");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (event === "disconnected") {
|
|
374
|
+
this.#epoch += 1;
|
|
375
|
+
this.#resyncing = true;
|
|
376
|
+
this.#queue.length = 0;
|
|
377
|
+
this.#releaseWaiters();
|
|
378
|
+
this.#refreshPendingTimers();
|
|
379
|
+
this.#setConnection("reconnecting");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (this.#connection === "reconnecting" || this.#connection === "resynchronizing") {
|
|
383
|
+
this.#setConnection("resynchronizing");
|
|
384
|
+
if (this.#explicitReconnect)
|
|
385
|
+
return;
|
|
386
|
+
void this.#host.requestSnapshot().catch((error) => {
|
|
387
|
+
this.#failRoom("indeterminate", error instanceof Error ? error.message : "snapshot request failed");
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
#unbind() {
|
|
393
|
+
this.#unsubscribe?.();
|
|
394
|
+
this.#unsubscribe = undefined;
|
|
395
|
+
this.#unsubscribeConnection?.();
|
|
396
|
+
this.#unsubscribeConnection = undefined;
|
|
397
|
+
}
|
|
398
|
+
async #submit(actionId, action, senderId) {
|
|
399
|
+
if (this.isHost) {
|
|
400
|
+
this.#enqueue({ actionId, action, senderId, epoch: this.#epoch });
|
|
401
|
+
this.#scheduleDrain();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await this.#host.sendAction(action, { actionId });
|
|
405
|
+
}
|
|
406
|
+
#scheduleDrain() {
|
|
407
|
+
void this.#drain().catch(() => undefined);
|
|
408
|
+
}
|
|
409
|
+
async #drain() {
|
|
410
|
+
if (this.#processing || this.#resyncing)
|
|
411
|
+
return;
|
|
412
|
+
this.#processing = true;
|
|
413
|
+
try {
|
|
414
|
+
while (this.#queue.length && this.isHost && this.#connection === "connected") {
|
|
415
|
+
const item = this.#queue.shift();
|
|
416
|
+
if (!item)
|
|
417
|
+
break;
|
|
418
|
+
if (item.epoch !== this.#epoch)
|
|
419
|
+
continue;
|
|
420
|
+
const identity = this.#identity(item.senderId, item.actionId);
|
|
421
|
+
if (this.#seen(item.senderId, item.actionId)) {
|
|
422
|
+
this.#inflight.delete(identity);
|
|
423
|
+
this.#succeed(item.actionId, item.senderId);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const prepared = this.#prepared.get(identity);
|
|
427
|
+
let next;
|
|
428
|
+
if (prepared) {
|
|
429
|
+
next = prepared.next;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
const previous = this.#state;
|
|
433
|
+
try {
|
|
434
|
+
const startedAt = monotonicNow();
|
|
435
|
+
const reduced = this.#options.reduce(cloneJson(previous), cloneJson(item.action), {
|
|
436
|
+
actionId: item.actionId,
|
|
437
|
+
senderId: item.senderId,
|
|
438
|
+
hostId: this.#hostId,
|
|
439
|
+
members: this.members,
|
|
440
|
+
});
|
|
441
|
+
if (reduced &&
|
|
442
|
+
typeof reduced === "object" &&
|
|
443
|
+
"then" in reduced) {
|
|
444
|
+
throw new Error("reducer must be synchronous");
|
|
445
|
+
}
|
|
446
|
+
if (monotonicNow() - startedAt > SYNCHRONIZED_ROOM_MAX_REDUCER_MS) {
|
|
447
|
+
throw new Error("reducer exceeded execution budget");
|
|
448
|
+
}
|
|
449
|
+
next = this.#parseState(reduced);
|
|
450
|
+
assertJsonCompatible(next);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
await this.#rejectQueued(item, "rejected", error instanceof Error ? error.message : "reducer rejected the action");
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
this.#prepared.set(identity, {
|
|
457
|
+
identity,
|
|
458
|
+
item,
|
|
459
|
+
next,
|
|
460
|
+
expectedVersion: this.#stateVersion,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const envelopeBytes = serializedBytes({
|
|
464
|
+
type: "host_state",
|
|
465
|
+
expectedStateVersion: this.#stateVersion,
|
|
466
|
+
actionId: item.actionId,
|
|
467
|
+
senderId: item.senderId,
|
|
468
|
+
state: next,
|
|
469
|
+
});
|
|
470
|
+
if (envelopeBytes > SYNCHRONIZED_ROOM_MAX_MESSAGE_BYTES) {
|
|
471
|
+
this.#prepared.delete(identity);
|
|
472
|
+
await this.#rejectQueued(item, "invalid", "state exceeds maximum size");
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const confirmation = this.#awaitCommit(item.actionId);
|
|
477
|
+
await this.#host.sendHostState(this.#stateVersion, next, {
|
|
478
|
+
actionId: item.actionId,
|
|
479
|
+
expectedStateVersion: this.#stateVersion,
|
|
480
|
+
senderId: item.senderId,
|
|
481
|
+
});
|
|
482
|
+
if (this.#resyncing || this.#connection !== "connected") {
|
|
483
|
+
this.#releaseWaiters(item.actionId);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const result = await confirmation;
|
|
487
|
+
if (result === "timeout") {
|
|
488
|
+
this.#failPending(item.actionId, new SynchronizedRoomError("indeterminate", "authoritative confirmation timed out"));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (result === "aborted" ||
|
|
492
|
+
this.#resyncing ||
|
|
493
|
+
this.#connection !== "connected") {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (this.#resyncing || this.#connection !== "connected")
|
|
499
|
+
continue;
|
|
500
|
+
this.#failPending(item.actionId, error instanceof SynchronizedRoomError
|
|
501
|
+
? error
|
|
502
|
+
: new SynchronizedRoomError("indeterminate", error instanceof Error ? error.message : "state commit failed"));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
this.#processing = false;
|
|
508
|
+
if (this.#queue.length &&
|
|
509
|
+
!this.#resyncing &&
|
|
510
|
+
this.isHost &&
|
|
511
|
+
this.#connection === "connected") {
|
|
512
|
+
this.#scheduleDrain();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
#onMessage(message) {
|
|
517
|
+
if (this.#isInactive() && message.type !== "room_closed")
|
|
518
|
+
return;
|
|
519
|
+
if (this.#roomId && message.roomId !== this.#roomId)
|
|
520
|
+
return;
|
|
521
|
+
if (message.type === "snapshot" || message.type === "state") {
|
|
522
|
+
this.#applyAuthoritative(message, message.type === "snapshot");
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (message.type === "action") {
|
|
526
|
+
if (!this.isHost || !message.actionId)
|
|
527
|
+
return;
|
|
528
|
+
const pending = this.#pending.get(message.actionId);
|
|
529
|
+
const action = pending?.action ?? this.#tryParseAction(message.payload);
|
|
530
|
+
if (action === undefined) {
|
|
531
|
+
this.#rejectRemote(message.actionId, "invalid", "invalid action", message.senderId);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.#enqueue({
|
|
535
|
+
actionId: message.actionId,
|
|
536
|
+
action,
|
|
537
|
+
senderId: message.senderId,
|
|
538
|
+
epoch: this.#epoch,
|
|
539
|
+
});
|
|
540
|
+
this.#scheduleDrain();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (message.type === "presence") {
|
|
544
|
+
this.#hostId = message.members.find((member) => member.host)?.playerId || this.#hostId;
|
|
545
|
+
this.#members = mergeMembers(this.#members, message, this.#hostId);
|
|
546
|
+
this.#emit();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (message.type === "host_changed") {
|
|
550
|
+
this.#pauseForResync();
|
|
551
|
+
this.#hostId = message.hostId;
|
|
552
|
+
this.#members = this.#members.map((member) => ({
|
|
553
|
+
...member,
|
|
554
|
+
host: member.playerId === message.hostId,
|
|
555
|
+
}));
|
|
556
|
+
this.#releaseWaiters();
|
|
557
|
+
void this.#recover("authority_changed", "authority changed");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (message.type === "room_closed") {
|
|
561
|
+
this.#generation += 1;
|
|
562
|
+
this.#rejectAll("room_closed", "room closed");
|
|
563
|
+
this.#inflight.clear();
|
|
564
|
+
this.#prepared.clear();
|
|
565
|
+
this.#queue.length = 0;
|
|
566
|
+
this.#clearAllCommitTimeouts();
|
|
567
|
+
this.#releaseWaiters();
|
|
568
|
+
this.#clearIdentity();
|
|
569
|
+
this.#unbind();
|
|
570
|
+
this.#setConnection("closed");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (message.type === "error") {
|
|
574
|
+
this.#onError(message);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
#onError(message) {
|
|
578
|
+
const outcome = outcomeFromError(message.code, message.message, message.actionOutcome);
|
|
579
|
+
const error = new SynchronizedRoomError(outcome, message.message);
|
|
580
|
+
this.#lastError = error;
|
|
581
|
+
if (outcome === "state_conflict" || outcome === "authority_changed") {
|
|
582
|
+
this.#pauseForResync();
|
|
583
|
+
void this.#recover(outcome, message.message);
|
|
584
|
+
this.#emit();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (outcome === "duplicate") {
|
|
588
|
+
if (message.actionId && this.#pending.has(message.actionId)) {
|
|
589
|
+
const senderId = message.senderId || this.#pending.get(message.actionId)?.senderId;
|
|
590
|
+
if (senderId && this.#seen(senderId, message.actionId)) {
|
|
591
|
+
this.#succeed(message.actionId, senderId);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
this.#armCommitTimeout(message.actionId);
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.#emit();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (message.actionId && this.#pending.has(message.actionId)) {
|
|
602
|
+
this.#failPending(message.actionId, error);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (message.actionId)
|
|
606
|
+
this.#releaseWaiters(message.actionId);
|
|
607
|
+
this.#emit();
|
|
608
|
+
}
|
|
609
|
+
#pauseForResync() {
|
|
610
|
+
this.#epoch += 1;
|
|
611
|
+
this.#queue.length = 0;
|
|
612
|
+
this.#resyncing = true;
|
|
613
|
+
this.#releaseWaiters();
|
|
614
|
+
this.#refreshPendingTimers();
|
|
615
|
+
this.#setConnection("resynchronizing");
|
|
616
|
+
}
|
|
617
|
+
async #recover(outcome, message) {
|
|
618
|
+
if (this.#isInactive())
|
|
619
|
+
return;
|
|
620
|
+
this.#refreshPendingTimers();
|
|
621
|
+
try {
|
|
622
|
+
await this.#host.requestSnapshot();
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
this.#failRoom(outcome, message);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
#failRoom(outcome, message) {
|
|
629
|
+
this.#resyncing = false;
|
|
630
|
+
this.#queue.length = 0;
|
|
631
|
+
this.#inflight.clear();
|
|
632
|
+
this.#prepared.clear();
|
|
633
|
+
this.#clearAllCommitTimeouts();
|
|
634
|
+
this.#rejectAll(outcome, message);
|
|
635
|
+
this.#releaseWaiters();
|
|
636
|
+
this.#unbind();
|
|
637
|
+
this.#setConnection("failed");
|
|
638
|
+
}
|
|
639
|
+
#applyAuthoritative(message, replace, options = {}) {
|
|
640
|
+
if (message.type !== "snapshot" && message.type !== "state")
|
|
641
|
+
return;
|
|
642
|
+
if (this.#isInactive())
|
|
643
|
+
return;
|
|
644
|
+
if (options.generation !== undefined &&
|
|
645
|
+
options.generation !== this.#generation) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (replace) {
|
|
649
|
+
try {
|
|
650
|
+
this.#requireCapabilities(message);
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
this.#failRoom("invalid", error instanceof Error ? error.message : "incompatible runtime");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (message.hostId)
|
|
658
|
+
this.#hostId = message.hostId;
|
|
659
|
+
if (message.type === "snapshot" && message.members?.length) {
|
|
660
|
+
this.#members = asMembers(message.members, this.#hostId);
|
|
661
|
+
}
|
|
662
|
+
const incomingVersion = message.stateVersion;
|
|
663
|
+
const stale = incomingVersion !== undefined && incomingVersion < this.#stateVersion;
|
|
664
|
+
const skipEmptyBootstrap = options.preserveLocalState && isUncommittedEmptyState(message);
|
|
665
|
+
if (!stale && incomingVersion !== undefined) {
|
|
666
|
+
this.#stateVersion = incomingVersion;
|
|
667
|
+
}
|
|
668
|
+
if (message.state !== undefined && !skipEmptyBootstrap && !stale) {
|
|
669
|
+
try {
|
|
670
|
+
this.#previousState = this.#state;
|
|
671
|
+
this.#state = this.#parseState(message.state);
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
this.#failRoom("invalid", error instanceof Error ? error.message : "invalid authoritative state");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const senderId = "senderId" in message ? message.senderId : undefined;
|
|
679
|
+
if (message.actionId) {
|
|
680
|
+
this.#remember(senderId || this.#playerId, message.actionId);
|
|
681
|
+
}
|
|
682
|
+
const identity = message.actionId
|
|
683
|
+
? this.#identity(senderId || this.#playerId, message.actionId)
|
|
684
|
+
: "";
|
|
685
|
+
const committedItem = identity ? this.#inflight.get(identity) : undefined;
|
|
686
|
+
if (identity) {
|
|
687
|
+
this.#inflight.delete(identity);
|
|
688
|
+
this.#prepared.delete(identity);
|
|
689
|
+
}
|
|
690
|
+
const pendingMatches = message.actionId &&
|
|
691
|
+
this.#pending.has(message.actionId) &&
|
|
692
|
+
(!senderId || senderId === this.#pending.get(message.actionId)?.senderId);
|
|
693
|
+
if (pendingMatches && !stale) {
|
|
694
|
+
this.#succeed(message.actionId, senderId);
|
|
695
|
+
}
|
|
696
|
+
else if (message.actionId) {
|
|
697
|
+
this.#notifyWaiters(message.actionId);
|
|
698
|
+
}
|
|
699
|
+
if (committedItem && !stale) {
|
|
700
|
+
this.#notifyCommitted(committedItem);
|
|
701
|
+
}
|
|
702
|
+
if (this.#resyncing && !stale && (replace || incomingVersion !== undefined)) {
|
|
703
|
+
this.#resyncing = false;
|
|
704
|
+
this.#setConnection("connected");
|
|
705
|
+
this.#requeuePending();
|
|
706
|
+
}
|
|
707
|
+
else if (this.#connection === "joining") {
|
|
708
|
+
// Join completion sets connected after bootstrap or the join snapshot.
|
|
709
|
+
}
|
|
710
|
+
else if (!this.#resyncing &&
|
|
711
|
+
this.#connection !== "closed" &&
|
|
712
|
+
this.#connection !== "failed" &&
|
|
713
|
+
this.#connection !== "leaving" &&
|
|
714
|
+
this.#connection !== "leave_failed" &&
|
|
715
|
+
this.#connection !== "reconnecting") {
|
|
716
|
+
this.#setConnection("connected");
|
|
717
|
+
}
|
|
718
|
+
this.#emit();
|
|
719
|
+
this.#scheduleDrain();
|
|
720
|
+
}
|
|
721
|
+
#requeuePending() {
|
|
722
|
+
this.#queue.length = 0;
|
|
723
|
+
if (this.isHost) {
|
|
724
|
+
for (const item of this.#inflight.values()) {
|
|
725
|
+
this.#enqueue({ ...item, epoch: this.#epoch });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
this.#inflight.clear();
|
|
730
|
+
}
|
|
731
|
+
for (const pending of this.#pending.values()) {
|
|
732
|
+
if (this.#seen(pending.senderId, pending.actionId))
|
|
733
|
+
continue;
|
|
734
|
+
pending.epoch = this.#epoch;
|
|
735
|
+
if (this.isHost) {
|
|
736
|
+
this.#enqueue({
|
|
737
|
+
actionId: pending.actionId,
|
|
738
|
+
action: pending.action,
|
|
739
|
+
senderId: pending.senderId,
|
|
740
|
+
epoch: this.#epoch,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
void this.#host.sendAction(pending.action, { actionId: pending.actionId }).catch((error) => {
|
|
745
|
+
this.#failPending(pending.actionId, new SynchronizedRoomError("indeterminate", error instanceof Error ? error.message : "resend failed"));
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
this.#scheduleDrain();
|
|
750
|
+
}
|
|
751
|
+
#succeed(actionId, senderId) {
|
|
752
|
+
const pending = this.#pending.get(actionId);
|
|
753
|
+
const identity = this.#identity(senderId || pending?.senderId || this.#playerId, actionId);
|
|
754
|
+
this.#inflight.delete(identity);
|
|
755
|
+
this.#prepared.delete(identity);
|
|
756
|
+
this.#clearCommitTimeout(actionId);
|
|
757
|
+
this.#notifyWaiters(actionId);
|
|
758
|
+
if (!pending)
|
|
759
|
+
return;
|
|
760
|
+
this.#pending.delete(actionId);
|
|
761
|
+
this.#remember(pending.senderId, actionId);
|
|
762
|
+
pending.resolve(this.getSnapshot());
|
|
763
|
+
}
|
|
764
|
+
#failPending(actionId, error) {
|
|
765
|
+
const pending = this.#pending.get(actionId);
|
|
766
|
+
const identity = this.#identity(pending?.senderId || this.#playerId, actionId);
|
|
767
|
+
this.#inflight.delete(identity);
|
|
768
|
+
this.#prepared.delete(identity);
|
|
769
|
+
this.#clearCommitTimeout(actionId);
|
|
770
|
+
this.#releaseWaiters(actionId);
|
|
771
|
+
if (!pending)
|
|
772
|
+
return;
|
|
773
|
+
this.#pending.delete(actionId);
|
|
774
|
+
this.#lastError = error;
|
|
775
|
+
pending.reject(error);
|
|
776
|
+
this.#emit();
|
|
777
|
+
}
|
|
778
|
+
#rejectAll(outcome, message) {
|
|
779
|
+
const error = new SynchronizedRoomError(outcome, message);
|
|
780
|
+
this.#lastError = error;
|
|
781
|
+
for (const actionId of [...this.#pending.keys()])
|
|
782
|
+
this.#failPending(actionId, error);
|
|
783
|
+
}
|
|
784
|
+
#awaitCommit(actionId) {
|
|
785
|
+
if (this.#resyncing || this.#isCommitClosed())
|
|
786
|
+
return Promise.resolve("aborted");
|
|
787
|
+
return new Promise((resolve) => {
|
|
788
|
+
if (this.#resyncing || this.#isCommitClosed()) {
|
|
789
|
+
resolve("aborted");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const timer = setTimeout(() => {
|
|
793
|
+
const waiters = this.#waiters.get(actionId);
|
|
794
|
+
if (!waiters)
|
|
795
|
+
return;
|
|
796
|
+
this.#waiters.delete(actionId);
|
|
797
|
+
for (const waiter of waiters)
|
|
798
|
+
waiter.resolve("timeout");
|
|
799
|
+
}, SYNCHRONIZED_ROOM_COMMIT_TIMEOUT_MS);
|
|
800
|
+
timer.unref?.();
|
|
801
|
+
const waiters = this.#waiters.get(actionId) ?? [];
|
|
802
|
+
waiters.push({
|
|
803
|
+
resolve: (result) => {
|
|
804
|
+
clearTimeout(timer);
|
|
805
|
+
resolve(result);
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
this.#waiters.set(actionId, waiters);
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
#notifyWaiters(actionId) {
|
|
812
|
+
const waiters = this.#waiters.get(actionId);
|
|
813
|
+
if (!waiters)
|
|
814
|
+
return;
|
|
815
|
+
this.#waiters.delete(actionId);
|
|
816
|
+
for (const waiter of waiters) {
|
|
817
|
+
try {
|
|
818
|
+
waiter.resolve("committed");
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
// Waiter callbacks must not block commit completion.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
#releaseWaiters(actionId) {
|
|
826
|
+
const ids = actionId ? [actionId] : [...this.#waiters.keys()];
|
|
827
|
+
for (const id of ids) {
|
|
828
|
+
const waiters = this.#waiters.get(id);
|
|
829
|
+
if (!waiters)
|
|
830
|
+
continue;
|
|
831
|
+
this.#waiters.delete(id);
|
|
832
|
+
for (const waiter of waiters) {
|
|
833
|
+
try {
|
|
834
|
+
waiter.resolve("aborted");
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// Waiter callbacks must not block recovery.
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
#armCommitTimeout(actionId) {
|
|
843
|
+
this.#clearCommitTimeout(actionId);
|
|
844
|
+
const timer = setTimeout(() => {
|
|
845
|
+
this.#commitTimers.delete(actionId);
|
|
846
|
+
this.#failPending(actionId, new SynchronizedRoomError("indeterminate", "authoritative confirmation timed out"));
|
|
847
|
+
}, SYNCHRONIZED_ROOM_COMMIT_TIMEOUT_MS);
|
|
848
|
+
timer.unref?.();
|
|
849
|
+
this.#commitTimers.set(actionId, timer);
|
|
850
|
+
}
|
|
851
|
+
#clearCommitTimeout(actionId) {
|
|
852
|
+
const timer = this.#commitTimers.get(actionId);
|
|
853
|
+
if (timer === undefined)
|
|
854
|
+
return;
|
|
855
|
+
clearTimeout(timer);
|
|
856
|
+
this.#commitTimers.delete(actionId);
|
|
857
|
+
}
|
|
858
|
+
#clearAllCommitTimeouts() {
|
|
859
|
+
for (const timer of this.#commitTimers.values())
|
|
860
|
+
clearTimeout(timer);
|
|
861
|
+
this.#commitTimers.clear();
|
|
862
|
+
}
|
|
863
|
+
#notifyCommitted(item) {
|
|
864
|
+
const snapshot = this.getSnapshot();
|
|
865
|
+
for (const listener of this.#committed) {
|
|
866
|
+
try {
|
|
867
|
+
listener({
|
|
868
|
+
actionId: item.actionId,
|
|
869
|
+
action: item.action,
|
|
870
|
+
senderId: item.senderId,
|
|
871
|
+
previousState: this.#previousState,
|
|
872
|
+
state: snapshot.state,
|
|
873
|
+
stateVersion: snapshot.stateVersion,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// Listener exceptions must not hang dispatch() or drain.
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
#enqueue(item) {
|
|
882
|
+
const identity = this.#identity(item.senderId, item.actionId);
|
|
883
|
+
if (!this.#inflight.has(identity) &&
|
|
884
|
+
this.#inflight.size >= SYNCHRONIZED_ROOM_MAX_RECENT_ACTIONS) {
|
|
885
|
+
if (item.senderId === this.#playerId) {
|
|
886
|
+
this.#failPending(item.actionId, new SynchronizedRoomError("rate_limited", "host action queue is full"));
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
this.#rejectRemote(item.actionId, "rejected", "host action queue is full", item.senderId);
|
|
890
|
+
}
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
this.#inflight.set(identity, item);
|
|
894
|
+
if (!this.#queue.some((queued) => this.#identity(queued.senderId, queued.actionId) === identity)) {
|
|
895
|
+
this.#queue.push(item);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async #rejectQueued(item, outcome, message) {
|
|
899
|
+
this.#inflight.delete(this.#identity(item.senderId, item.actionId));
|
|
900
|
+
this.#prepared.delete(this.#identity(item.senderId, item.actionId));
|
|
901
|
+
if (item.senderId === this.#playerId) {
|
|
902
|
+
this.#failPending(item.actionId, new SynchronizedRoomError(outcome, message));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
await this.#host.sendActionRejection(item.actionId, outcome, protocolRejectionMessage(message), { senderId: item.senderId });
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
// Keep drain running; the sender resolves via rejection or timeout.
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
#rejectRemote(actionId, outcome, message, senderId) {
|
|
913
|
+
void this.#host
|
|
914
|
+
.sendActionRejection(actionId, outcome, protocolRejectionMessage(message), {
|
|
915
|
+
senderId,
|
|
916
|
+
})
|
|
917
|
+
.catch(() => undefined);
|
|
918
|
+
}
|
|
919
|
+
#identity(senderId, actionId) {
|
|
920
|
+
return actionIdentityKey(senderId, actionId);
|
|
921
|
+
}
|
|
922
|
+
#seen(senderId, actionId) {
|
|
923
|
+
const key = this.#identity(senderId, actionId);
|
|
924
|
+
const seenAt = this.#recent.get(key);
|
|
925
|
+
if (seenAt === undefined)
|
|
926
|
+
return false;
|
|
927
|
+
if (Date.now() - seenAt > SYNCHRONIZED_ROOM_ACTION_TTL_MS) {
|
|
928
|
+
this.#recent.delete(key);
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
#remember(senderId, actionId) {
|
|
934
|
+
if (!ACTION_ID.test(actionId) || !senderId)
|
|
935
|
+
return;
|
|
936
|
+
const key = this.#identity(senderId, actionId);
|
|
937
|
+
this.#recent.set(key, Date.now());
|
|
938
|
+
for (const [id, seenAt] of this.#recent) {
|
|
939
|
+
if (Date.now() - seenAt > SYNCHRONIZED_ROOM_ACTION_TTL_MS)
|
|
940
|
+
this.#recent.delete(id);
|
|
941
|
+
}
|
|
942
|
+
if (this.#recent.size <= SYNCHRONIZED_ROOM_MAX_RECENT_ACTIONS)
|
|
943
|
+
return;
|
|
944
|
+
const oldest = this.#recent.keys().next().value;
|
|
945
|
+
if (oldest)
|
|
946
|
+
this.#recent.delete(oldest);
|
|
947
|
+
}
|
|
948
|
+
#parseState(value) {
|
|
949
|
+
return this.#options.stateSchema
|
|
950
|
+
? this.#options.stateSchema.parse(value)
|
|
951
|
+
: value;
|
|
952
|
+
}
|
|
953
|
+
#parseAction(value) {
|
|
954
|
+
return this.#options.actionSchema
|
|
955
|
+
? this.#options.actionSchema.parse(value)
|
|
956
|
+
: value;
|
|
957
|
+
}
|
|
958
|
+
#tryParseAction(value) {
|
|
959
|
+
try {
|
|
960
|
+
return this.#parseAction(value);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
#clearIdentity() {
|
|
967
|
+
this.#roomId = "";
|
|
968
|
+
this.#inviteCode = "";
|
|
969
|
+
this.#hostId = "";
|
|
970
|
+
this.#members = [];
|
|
971
|
+
}
|
|
972
|
+
#isInactive() {
|
|
973
|
+
return (this.#connection === "leaving" ||
|
|
974
|
+
this.#connection === "leave_failed" ||
|
|
975
|
+
this.#connection === "closed" ||
|
|
976
|
+
this.#connection === "failed");
|
|
977
|
+
}
|
|
978
|
+
#isTerminal() {
|
|
979
|
+
return (this.#connection === "closed" ||
|
|
980
|
+
this.#connection === "failed" ||
|
|
981
|
+
this.#connection === "leave_failed" ||
|
|
982
|
+
this.#connection === "idle");
|
|
983
|
+
}
|
|
984
|
+
#requireCapabilities(message) {
|
|
985
|
+
if (message.type !== "snapshot")
|
|
986
|
+
return;
|
|
987
|
+
const capabilities = message.capabilities;
|
|
988
|
+
if (!capabilities || capabilities.synchronized_rooms !== true) {
|
|
989
|
+
throw new SynchronizedRoomError("invalid", "runtime does not advertise synchronized_rooms");
|
|
990
|
+
}
|
|
991
|
+
if (capabilities.minimumProtocolVersion !== undefined &&
|
|
992
|
+
capabilities.minimumProtocolVersion > PROTOCOL_VERSION) {
|
|
993
|
+
throw new SynchronizedRoomError("invalid", "runtime requires a newer protocol version");
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
#refreshPendingTimers() {
|
|
997
|
+
for (const actionId of this.#pending.keys())
|
|
998
|
+
this.#armCommitTimeout(actionId);
|
|
999
|
+
}
|
|
1000
|
+
#isCommitClosed() {
|
|
1001
|
+
return (this.#connection === "closed" ||
|
|
1002
|
+
this.#connection === "failed" ||
|
|
1003
|
+
this.#connection === "leaving" ||
|
|
1004
|
+
this.#connection === "leave_failed" ||
|
|
1005
|
+
this.#connection === "reconnecting");
|
|
1006
|
+
}
|
|
1007
|
+
#throwIfFailed(message) {
|
|
1008
|
+
if (this.#connection !== "failed")
|
|
1009
|
+
return;
|
|
1010
|
+
throw (this.#lastError ??
|
|
1011
|
+
new SynchronizedRoomError("invalid", message));
|
|
1012
|
+
}
|
|
1013
|
+
#setConnection(connection) {
|
|
1014
|
+
this.#connection = connection;
|
|
1015
|
+
this.#emit();
|
|
1016
|
+
}
|
|
1017
|
+
#emit() {
|
|
1018
|
+
const snapshot = this.getSnapshot();
|
|
1019
|
+
for (const listener of this.#listeners) {
|
|
1020
|
+
try {
|
|
1021
|
+
listener(snapshot);
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
// Snapshot listeners must not break connection or commit handling.
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
//# sourceMappingURL=synchronized-room.js.map
|