@irtio/client 0.5.1 → 0.6.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/dist/{chunk-7UTJ7RSF.js → chunk-6J6PKFUS.js} +69 -8
- package/dist/{physics-H2VDQLAU.js → chunk-TE66EDBB.js} +174 -239
- package/dist/index.d.ts +1170 -158
- package/dist/index.js +1240 -42
- package/dist/physics-S5LHL3HK.js +196 -0
- package/dist/physics2d-ZV2IRRRZ.js +206 -0
- package/package.json +15 -6
package/dist/index.js
CHANGED
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
SMOOTHING_HALF_LIFE_MS,
|
|
8
8
|
SMOOTHING_SNAP_UNITS,
|
|
9
9
|
emptyPredictionStats
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-6J6PKFUS.js";
|
|
11
|
+
|
|
12
|
+
// src/index.ts
|
|
13
|
+
import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
|
|
11
14
|
|
|
12
15
|
// src/endpoint.ts
|
|
13
16
|
var DEFAULT_REGION = "eu";
|
|
@@ -112,10 +115,391 @@ function linkForUrl(wsUrl, roomId) {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// src/identity.ts
|
|
119
|
+
var ACCOUNT_STORAGE_KEY = "irtio.account";
|
|
120
|
+
function identityStorageKey(project) {
|
|
121
|
+
return `irtio.identity.${project}`;
|
|
122
|
+
}
|
|
123
|
+
var DEFAULT_CONTROL_URL = "https://irt.io";
|
|
124
|
+
function browserStorage() {
|
|
125
|
+
try {
|
|
126
|
+
const ls = globalThis.localStorage;
|
|
127
|
+
if (!ls) return void 0;
|
|
128
|
+
ls.getItem("irtio.identity.probe");
|
|
129
|
+
return ls;
|
|
130
|
+
} catch {
|
|
131
|
+
return void 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
var IdentityError = class extends Error {
|
|
135
|
+
constructor(code, message, retryAfterMs) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.code = code;
|
|
138
|
+
this.retryAfterMs = retryAfterMs;
|
|
139
|
+
}
|
|
140
|
+
code;
|
|
141
|
+
name = "IdentityError";
|
|
142
|
+
/**
|
|
143
|
+
* How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
|
|
144
|
+
* on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
|
|
145
|
+
* tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
|
|
146
|
+
*/
|
|
147
|
+
retryAfterMs;
|
|
148
|
+
};
|
|
149
|
+
var E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
|
|
150
|
+
var MAX_IDENTITY_RETRY_WAIT_MS = 6e4;
|
|
151
|
+
function retryAfterMsFrom(res, body) {
|
|
152
|
+
const header = res.headers?.get?.("retry-after");
|
|
153
|
+
const fromHeader = header === null || header === void 0 ? Number.NaN : Number(header);
|
|
154
|
+
if (Number.isFinite(fromHeader) && fromHeader >= 0) return fromHeader * 1e3;
|
|
155
|
+
const fromBody = typeof body?.retryAfter === "number" ? body.retryAfter : Number.NaN;
|
|
156
|
+
if (Number.isFinite(fromBody) && fromBody >= 0) return fromBody * 1e3;
|
|
157
|
+
return 6e4;
|
|
158
|
+
}
|
|
159
|
+
var sleep = (ms) => new Promise((resolve) => {
|
|
160
|
+
setTimeout(resolve, ms);
|
|
161
|
+
});
|
|
162
|
+
var Identity = class {
|
|
163
|
+
project;
|
|
164
|
+
controlUrl;
|
|
165
|
+
fetchImpl;
|
|
166
|
+
storage;
|
|
167
|
+
token;
|
|
168
|
+
accountId;
|
|
169
|
+
cached;
|
|
170
|
+
inFlight;
|
|
171
|
+
constructor(options) {
|
|
172
|
+
this.project = options.project;
|
|
173
|
+
this.controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
174
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
175
|
+
this.storage = options.storage === null ? void 0 : options.storage ?? browserStorage() ?? void 0;
|
|
176
|
+
this.token = this.load();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
|
|
180
|
+
*
|
|
181
|
+
* Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
|
|
182
|
+
* whose id is the identity's own id, so the credential that was this player on this project is
|
|
183
|
+
* already this player's account credential everywhere. Moving it to the origin-wide key is what
|
|
184
|
+
* makes the same person on the same browser one player across the games on that origin.
|
|
185
|
+
*
|
|
186
|
+
* The old key is removed after a successful adoption. If the write fails (a storage that reads
|
|
187
|
+
* but will not write), the old value is used anyway and adoption is retried next time, which is
|
|
188
|
+
* the same degrade-quietly rule the rest of this module follows.
|
|
189
|
+
*/
|
|
190
|
+
load() {
|
|
191
|
+
try {
|
|
192
|
+
const current = this.storage?.getItem(ACCOUNT_STORAGE_KEY);
|
|
193
|
+
if (typeof current === "string" && current !== "") return current;
|
|
194
|
+
} catch {
|
|
195
|
+
return void 0;
|
|
196
|
+
}
|
|
197
|
+
let legacy;
|
|
198
|
+
try {
|
|
199
|
+
legacy = this.storage?.getItem(identityStorageKey(this.project));
|
|
200
|
+
} catch {
|
|
201
|
+
return void 0;
|
|
202
|
+
}
|
|
203
|
+
if (typeof legacy !== "string" || legacy === "") return void 0;
|
|
204
|
+
try {
|
|
205
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, legacy);
|
|
206
|
+
this.storage?.removeItem(identityStorageKey(this.project));
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
209
|
+
return legacy;
|
|
210
|
+
}
|
|
211
|
+
/** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
|
|
212
|
+
get stored() {
|
|
213
|
+
return this.token;
|
|
214
|
+
}
|
|
215
|
+
/** The player id the room will see, once an assertion has been fetched. */
|
|
216
|
+
get playerId() {
|
|
217
|
+
return this.cached?.playerId;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Mints an identity if this browser has none, and returns the token.
|
|
221
|
+
*
|
|
222
|
+
* A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
|
|
223
|
+
* that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
|
|
224
|
+
* a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
|
|
225
|
+
* `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
|
|
226
|
+
* "gone" (bug #36).
|
|
227
|
+
*/
|
|
228
|
+
async ensure() {
|
|
229
|
+
if (this.token !== void 0) return this.token;
|
|
230
|
+
try {
|
|
231
|
+
return await this.mint();
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (!(err instanceof IdentityError) || err.code !== E_IDENTITY_RATE_LIMITED) throw err;
|
|
234
|
+
const wait = err.retryAfterMs ?? 6e4;
|
|
235
|
+
if (wait > MAX_IDENTITY_RETRY_WAIT_MS) throw err;
|
|
236
|
+
await sleep(wait);
|
|
237
|
+
return await this.mint();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async mint() {
|
|
241
|
+
const res = await this.post("/v1/identity", { project: this.project });
|
|
242
|
+
const body = await res.json();
|
|
243
|
+
if (typeof body.identity !== "string") {
|
|
244
|
+
throw new IdentityError("E_IDENTITY_MINT_FAILED", "the control plane returned no identity");
|
|
245
|
+
}
|
|
246
|
+
this.token = body.identity;
|
|
247
|
+
this.accountId = typeof body.account === "string" ? body.account : void 0;
|
|
248
|
+
try {
|
|
249
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
return body.identity;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* A currently-valid assertion for this project, exchanging one if the cached one is gone or
|
|
256
|
+
* within thirty seconds of expiry.
|
|
257
|
+
*
|
|
258
|
+
* The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
|
|
259
|
+
* Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
|
|
260
|
+
*/
|
|
261
|
+
async assertion(now = Date.now()) {
|
|
262
|
+
const cached = this.cached;
|
|
263
|
+
if (cached && cached.expiresAtMs - 3e4 > now) return cached.assertion;
|
|
264
|
+
if (this.inFlight) return this.inFlight;
|
|
265
|
+
this.inFlight = this.exchange().finally(() => {
|
|
266
|
+
this.inFlight = void 0;
|
|
267
|
+
});
|
|
268
|
+
return this.inFlight;
|
|
269
|
+
}
|
|
270
|
+
async exchange() {
|
|
271
|
+
const identity = await this.ensure();
|
|
272
|
+
let res;
|
|
273
|
+
try {
|
|
274
|
+
res = await this.post("/v1/identity/assertion", { identity, project: this.project });
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if (err instanceof IdentityError && err.code === "E_IDENTITY_INVALID") {
|
|
277
|
+
this.forget();
|
|
278
|
+
const fresh = await this.ensure();
|
|
279
|
+
res = await this.post("/v1/identity/assertion", { identity: fresh, project: this.project });
|
|
280
|
+
} else {
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const body = await res.json();
|
|
285
|
+
if (typeof body.assertion !== "string" || typeof body.playerId !== "string") {
|
|
286
|
+
throw new IdentityError(
|
|
287
|
+
"E_IDENTITY_EXCHANGE_FAILED",
|
|
288
|
+
"the control plane returned no assertion"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const ttl = typeof body.expiresInMs === "number" ? body.expiresInMs : 6e4;
|
|
292
|
+
this.cached = {
|
|
293
|
+
assertion: body.assertion,
|
|
294
|
+
playerId: body.playerId,
|
|
295
|
+
expiresAtMs: Date.now() + ttl
|
|
296
|
+
};
|
|
297
|
+
return body.assertion;
|
|
298
|
+
}
|
|
299
|
+
/** Drops the stored identity. The next `ensure()` mints a new player. */
|
|
300
|
+
forget() {
|
|
301
|
+
this.token = void 0;
|
|
302
|
+
this.accountId = void 0;
|
|
303
|
+
this.cached = void 0;
|
|
304
|
+
try {
|
|
305
|
+
this.storage?.removeItem(ACCOUNT_STORAGE_KEY);
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
// D61: linking a second device, and managing the ones already on the account
|
|
311
|
+
//
|
|
312
|
+
// Every call below presents the stored device credential in the `x-irt-identity` header, to the
|
|
313
|
+
// control plane, over HTTPS, and to nothing else. That is the same rule the module's header
|
|
314
|
+
// states about the credential generally, and these methods are the reason it needs restating:
|
|
315
|
+
// they are the first surface other than the exchange that the credential is sent to at all.
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
/**
|
|
318
|
+
* Asks the control plane for a link code to read out on another device.
|
|
319
|
+
*
|
|
320
|
+
* The code is short and typable because a person carries it between two screens. Show it, do
|
|
321
|
+
* not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
|
|
322
|
+
* one thing about this that a player controls.
|
|
323
|
+
*/
|
|
324
|
+
async linkCode() {
|
|
325
|
+
const res = await this.accountFetch("POST", "/v1/account/link-code", {});
|
|
326
|
+
const body = await res.json();
|
|
327
|
+
if (typeof body.code !== "string") {
|
|
328
|
+
throw new IdentityError("E_LINK_CODE_FAILED", "the control plane returned no link code");
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
code: body.code,
|
|
332
|
+
expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 10 * 6e4
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Redeems a code read off another device, and **replaces** this browser's credential with a new
|
|
337
|
+
* one on that code's account.
|
|
338
|
+
*
|
|
339
|
+
* Two things happen in that order and both matter. The new credential is stored first, so a
|
|
340
|
+
* failure between the two leaves the player linked rather than credential-less. Then whatever
|
|
341
|
+
* this browser used to be is retired on the account it is leaving.
|
|
342
|
+
*
|
|
343
|
+
* **That retirement deletes the old account when this was its only device**, rather than
|
|
344
|
+
* revoking the credential and walking away. Revoking the last device leaves an account nothing
|
|
345
|
+
* can ever authenticate as, whose board rows and saved games are then unreachable by the player
|
|
346
|
+
* and undeletable by anyone. An account with other devices only loses this one.
|
|
347
|
+
*
|
|
348
|
+
* So this call can destroy the progress held on THIS browser. Warn the player first; the docs
|
|
349
|
+
* page has wording for it. The retirement is best-effort: if it fails the link still stands,
|
|
350
|
+
* because failing a link that already worked is the worse direction.
|
|
351
|
+
*/
|
|
352
|
+
async redeemLinkCode(code) {
|
|
353
|
+
const previous = this.token;
|
|
354
|
+
let previousDeviceId;
|
|
355
|
+
let previousWasOnlyDevice = false;
|
|
356
|
+
if (previous !== void 0) {
|
|
357
|
+
try {
|
|
358
|
+
const before = await this.devices();
|
|
359
|
+
previousDeviceId = before.self;
|
|
360
|
+
previousWasOnlyDevice = before.devices.length === 1;
|
|
361
|
+
} catch {
|
|
362
|
+
previousDeviceId = void 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const res = await this.post("/v1/account/link", { code }, `?project=${enc(this.project)}`);
|
|
366
|
+
const body = await res.json();
|
|
367
|
+
if (typeof body.identity !== "string" || typeof body.account !== "string") {
|
|
368
|
+
throw new IdentityError("E_LINK_FAILED", "the control plane returned no linked identity");
|
|
369
|
+
}
|
|
370
|
+
this.token = body.identity;
|
|
371
|
+
this.accountId = body.account;
|
|
372
|
+
this.cached = void 0;
|
|
373
|
+
try {
|
|
374
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
|
|
375
|
+
} catch {
|
|
376
|
+
}
|
|
377
|
+
if (previous !== void 0 && previousDeviceId !== void 0) {
|
|
378
|
+
try {
|
|
379
|
+
const url = previousWasOnlyDevice ? `${this.controlUrl}/v1/account?project=${enc(this.project)}` : `${this.controlUrl}/v1/account/devices/${enc(previousDeviceId)}?project=${enc(this.project)}`;
|
|
380
|
+
await this.fetchImpl(url, {
|
|
381
|
+
method: "DELETE",
|
|
382
|
+
headers: { "x-irt-identity": previous }
|
|
383
|
+
});
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return { account: body.account };
|
|
388
|
+
}
|
|
389
|
+
/** The devices on this account: ids and timestamps, plus which one this browser is. */
|
|
390
|
+
async devices() {
|
|
391
|
+
const res = await this.accountFetch("GET", "/v1/account/devices");
|
|
392
|
+
const body = await res.json();
|
|
393
|
+
if (typeof body.account !== "string" || !Array.isArray(body.devices)) {
|
|
394
|
+
throw new IdentityError("E_ACCOUNT_READ_FAILED", "the control plane returned no device list");
|
|
395
|
+
}
|
|
396
|
+
this.accountId = body.account;
|
|
397
|
+
return {
|
|
398
|
+
account: body.account,
|
|
399
|
+
self: typeof body.self === "string" ? body.self : "",
|
|
400
|
+
devices: body.devices
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Revokes one device on this account. Revoking the device this browser IS leaves this instance
|
|
405
|
+
* holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
|
|
406
|
+
* mints a fresh player rather than looping on a refused exchange.
|
|
407
|
+
*/
|
|
408
|
+
async revokeDevice(deviceId) {
|
|
409
|
+
let self;
|
|
410
|
+
try {
|
|
411
|
+
self = (await this.devices()).self;
|
|
412
|
+
} catch {
|
|
413
|
+
self = void 0;
|
|
414
|
+
}
|
|
415
|
+
await this.accountFetch("DELETE", `/v1/account/devices/${enc(deviceId)}`);
|
|
416
|
+
if (self !== void 0 && self === deviceId) this.forget();
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Deletes this account and everything keyed on it, in every project it played, and forgets the
|
|
420
|
+
* credential. There is no undo and the control plane does not keep a copy.
|
|
421
|
+
*/
|
|
422
|
+
async deleteAccount() {
|
|
423
|
+
await this.accountFetch("DELETE", "/v1/account");
|
|
424
|
+
this.forget();
|
|
425
|
+
}
|
|
426
|
+
/** The account id, once anything has told us what it is. Opaque; never parse it. */
|
|
427
|
+
get account() {
|
|
428
|
+
return this.accountId;
|
|
429
|
+
}
|
|
430
|
+
/** One authenticated account-route call: credential in the header, project in the query. */
|
|
431
|
+
async accountFetch(method, path, body) {
|
|
432
|
+
const identity = await this.ensure();
|
|
433
|
+
const url = `${this.controlUrl}${path}?project=${enc(this.project)}`;
|
|
434
|
+
let res;
|
|
435
|
+
try {
|
|
436
|
+
res = await this.fetchImpl(url, {
|
|
437
|
+
method,
|
|
438
|
+
headers: {
|
|
439
|
+
"content-type": "application/json",
|
|
440
|
+
// The credential goes here and nowhere else. Not in the URL: a query string reaches
|
|
441
|
+
// access logs, `Referer` headers and browser history, and this one is long-lived.
|
|
442
|
+
"x-irt-identity": identity
|
|
443
|
+
},
|
|
444
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
445
|
+
});
|
|
446
|
+
} catch (err) {
|
|
447
|
+
throw new IdentityError(
|
|
448
|
+
"E_IDENTITY_UNREACHABLE",
|
|
449
|
+
`cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (res.ok) return res;
|
|
453
|
+
throw await errorFrom(res);
|
|
454
|
+
}
|
|
455
|
+
async post(path, body, query = "") {
|
|
456
|
+
let res;
|
|
457
|
+
try {
|
|
458
|
+
res = await this.fetchImpl(`${this.controlUrl}${path}${query}`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: { "content-type": "application/json" },
|
|
461
|
+
body: JSON.stringify(body)
|
|
462
|
+
});
|
|
463
|
+
} catch (err) {
|
|
464
|
+
throw new IdentityError(
|
|
465
|
+
"E_IDENTITY_UNREACHABLE",
|
|
466
|
+
`cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (res.ok) return res;
|
|
470
|
+
throw await errorFrom(res);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
function enc(value) {
|
|
474
|
+
return encodeURIComponent(value);
|
|
475
|
+
}
|
|
476
|
+
async function errorFrom(res) {
|
|
477
|
+
const text = await res.text().catch(() => "");
|
|
478
|
+
let code = `E_HTTP_${res.status}`;
|
|
479
|
+
let message = text || `request failed with status ${res.status}`;
|
|
480
|
+
let parsed;
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(text);
|
|
483
|
+
if (typeof parsed.code === "string") code = parsed.code;
|
|
484
|
+
if (typeof parsed.message === "string") message = parsed.message;
|
|
485
|
+
} catch {
|
|
486
|
+
}
|
|
487
|
+
if (res.status === 429) {
|
|
488
|
+
const retryAfterMs = retryAfterMsFrom(res, parsed);
|
|
489
|
+
return new IdentityError(
|
|
490
|
+
E_IDENTITY_RATE_LIMITED,
|
|
491
|
+
`${message} (rate limited; retry in ${Math.round(retryAfterMs / 1e3)}s)`,
|
|
492
|
+
retryAfterMs
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return new IdentityError(code, message);
|
|
496
|
+
}
|
|
497
|
+
|
|
115
498
|
// src/session.ts
|
|
116
499
|
import {
|
|
117
500
|
FrameType,
|
|
118
501
|
PROTOCOL_VERSION,
|
|
502
|
+
ProfileLedger,
|
|
119
503
|
RELAY_HASH8,
|
|
120
504
|
decodeCall,
|
|
121
505
|
decodeErrorPayload,
|
|
@@ -134,21 +518,26 @@ import {
|
|
|
134
518
|
formatError,
|
|
135
519
|
readCorrectAppliedTick,
|
|
136
520
|
readCorrectClientTick,
|
|
521
|
+
readSchemaPayload,
|
|
137
522
|
relaySchema,
|
|
138
523
|
rpcTable,
|
|
139
524
|
withBuiltins
|
|
140
525
|
} from "@irtio/protocol";
|
|
526
|
+
import { EMPTY_PROFILE, diffProfiles, scaleProfile } from "@irtio/protocol";
|
|
141
527
|
import {
|
|
142
528
|
ByteReader,
|
|
143
529
|
decodeDelta,
|
|
144
530
|
decodeDeltaFrom,
|
|
145
531
|
decodeFields,
|
|
146
|
-
encodeFields
|
|
532
|
+
encodeFields,
|
|
533
|
+
schemaFromCanonical
|
|
147
534
|
} from "@irtio/schema";
|
|
148
535
|
|
|
149
536
|
// src/render.ts
|
|
150
537
|
var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
|
|
151
538
|
var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
|
|
539
|
+
var BASE_DRIFT_MS_PER_S = 2;
|
|
540
|
+
var SAME_T_EPSILON = 0.01;
|
|
152
541
|
function cloneRecord(value) {
|
|
153
542
|
const out = {};
|
|
154
543
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -176,29 +565,61 @@ function lerpRecord(desc, a, b, alpha) {
|
|
|
176
565
|
return out;
|
|
177
566
|
}
|
|
178
567
|
var RenderStore = class {
|
|
179
|
-
constructor(ext, store, meOf, now, delayMs) {
|
|
568
|
+
constructor(ext, store, meOf, now, delayMs, intervalMs) {
|
|
180
569
|
this.ext = ext;
|
|
181
570
|
this.store = store;
|
|
182
571
|
this.meOf = meOf;
|
|
183
572
|
this.now = now;
|
|
184
573
|
this.delayMs = delayMs;
|
|
574
|
+
this.intervalMs = intervalMs;
|
|
185
575
|
for (const c of ext.collections) this.descs.set(c.name, c);
|
|
186
|
-
this.view =
|
|
576
|
+
this.view = {};
|
|
577
|
+
this.buildViewInto(this.view);
|
|
187
578
|
}
|
|
188
579
|
ext;
|
|
189
580
|
store;
|
|
190
581
|
meOf;
|
|
191
582
|
now;
|
|
192
583
|
delayMs;
|
|
584
|
+
intervalMs;
|
|
193
585
|
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
194
586
|
buffers = /* @__PURE__ */ new Map();
|
|
195
587
|
descs = /* @__PURE__ */ new Map();
|
|
588
|
+
/** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
|
|
589
|
+
facades = /* @__PURE__ */ new Map();
|
|
196
590
|
/** The object handed out as `room.render`; identity survives a resync. */
|
|
197
591
|
view;
|
|
198
592
|
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
199
593
|
starved = 0;
|
|
200
594
|
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
201
595
|
predictor;
|
|
596
|
+
/**
|
|
597
|
+
* The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
|
|
598
|
+
* is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
|
|
599
|
+
*/
|
|
600
|
+
base;
|
|
601
|
+
/** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
|
|
602
|
+
baseAt = 0;
|
|
603
|
+
/**
|
|
604
|
+
* D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
|
|
605
|
+
* it, exactly as `ClientStore.swapSchema` does.
|
|
606
|
+
*
|
|
607
|
+
* Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
|
|
608
|
+
* out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
|
|
609
|
+
* keyframe against a new one under the new field list is precisely the silent misread this
|
|
610
|
+
* whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
|
|
611
|
+
* re-seeds one keyframe per entity, which is the same state the buffers would have started
|
|
612
|
+
* from on a reconnect.
|
|
613
|
+
*/
|
|
614
|
+
swapSchema(newExt) {
|
|
615
|
+
this.ext = newExt;
|
|
616
|
+
this.descs.clear();
|
|
617
|
+
for (const c of newExt.collections) this.descs.set(c.name, c);
|
|
618
|
+
this.buffers.clear();
|
|
619
|
+
this.starved = 0;
|
|
620
|
+
this.base = void 0;
|
|
621
|
+
this.buildViewInto(this.view);
|
|
622
|
+
}
|
|
202
623
|
// -- ingest ---------------------------------------------------------------
|
|
203
624
|
/**
|
|
204
625
|
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
@@ -207,6 +628,7 @@ var RenderStore = class {
|
|
|
207
628
|
*/
|
|
208
629
|
seedSnapshot() {
|
|
209
630
|
this.buffers.clear();
|
|
631
|
+
this.base = void 0;
|
|
210
632
|
const t = this.now() - this.delayMs();
|
|
211
633
|
for (const c of this.ext.collections) {
|
|
212
634
|
if (c.kind !== "entity" || !c.interpolate) continue;
|
|
@@ -219,9 +641,39 @@ var RenderStore = class {
|
|
|
219
641
|
}
|
|
220
642
|
}
|
|
221
643
|
}
|
|
222
|
-
/**
|
|
644
|
+
/**
|
|
645
|
+
* Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
|
|
646
|
+
* and the caller must fall back to arrival stamping.
|
|
647
|
+
*
|
|
648
|
+
* The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
|
|
649
|
+
* delayed delta seen so far is the best evidence of where the server's tick clock sits, and
|
|
650
|
+
* every later delta is that plus its own queueing delay. So a better sample is taken
|
|
651
|
+
* immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
|
|
652
|
+
* render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
|
|
653
|
+
* clock rather than a late frame and snaps.
|
|
654
|
+
*/
|
|
655
|
+
stampFor(tick) {
|
|
656
|
+
const interval = this.intervalMs();
|
|
657
|
+
if (!(interval > 0)) return void 0;
|
|
658
|
+
const now = this.now();
|
|
659
|
+
const cand = now - tick * interval;
|
|
660
|
+
if (this.base === void 0 || cand < this.base || Math.abs(cand - this.base) > this.snapMs()) {
|
|
661
|
+
this.base = cand;
|
|
662
|
+
} else {
|
|
663
|
+
this.base = Math.min(cand, this.base + (now - this.baseAt) * (BASE_DRIFT_MS_PER_S / 1e3));
|
|
664
|
+
}
|
|
665
|
+
this.baseAt = now;
|
|
666
|
+
return Math.min(this.base + tick * interval, now);
|
|
667
|
+
}
|
|
668
|
+
snapMs() {
|
|
669
|
+
return Math.max(250, 4 * this.delayMs());
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
|
|
673
|
+
* burstiness cannot modulate the drawn velocity. Call after apply.
|
|
674
|
+
*/
|
|
223
675
|
recordDelta(delta) {
|
|
224
|
-
const t = this.now();
|
|
676
|
+
const t = this.stampFor(delta.tick) ?? this.now();
|
|
225
677
|
for (const dc of delta.collections) {
|
|
226
678
|
const desc = this.descs.get(dc.name);
|
|
227
679
|
if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
|
|
@@ -236,10 +688,26 @@ var RenderStore = class {
|
|
|
236
688
|
if (value === void 0) continue;
|
|
237
689
|
const buffer = this.bufferFor(dc.name, op.id);
|
|
238
690
|
buffer.removedAt = void 0;
|
|
239
|
-
|
|
691
|
+
this.pushFrame(buffer, t, cloneRecord(value));
|
|
240
692
|
}
|
|
241
693
|
}
|
|
242
694
|
}
|
|
695
|
+
/**
|
|
696
|
+
* Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
|
|
697
|
+
* depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
|
|
698
|
+
* a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
|
|
699
|
+
* appends; and an offset that just snapped backwards must not stamp behind what is buffered.
|
|
700
|
+
*/
|
|
701
|
+
pushFrame(buffer, t, value) {
|
|
702
|
+
const newest = buffer.frames[buffer.frames.length - 1];
|
|
703
|
+
if (newest && t <= newest.t) {
|
|
704
|
+
if (t >= newest.t - SAME_T_EPSILON)
|
|
705
|
+
buffer.frames[buffer.frames.length - 1] = { t: newest.t, value };
|
|
706
|
+
else buffer.frames.push({ t: newest.t + SAME_T_EPSILON, value });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
buffer.frames.push({ t, value });
|
|
710
|
+
}
|
|
243
711
|
bufferFor(collection, id) {
|
|
244
712
|
let byId = this.buffers.get(collection);
|
|
245
713
|
if (!byId) {
|
|
@@ -270,7 +738,7 @@ var RenderStore = class {
|
|
|
270
738
|
if (value !== void 0) return this.predictor.read(desc, id, { ...value });
|
|
271
739
|
}
|
|
272
740
|
}
|
|
273
|
-
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
741
|
+
if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
274
742
|
return this.store.instance(desc, id);
|
|
275
743
|
}
|
|
276
744
|
if (!desc.interpolate) return this.store.instance(desc, id);
|
|
@@ -292,14 +760,17 @@ var RenderStore = class {
|
|
|
292
760
|
if (renderT > a.t) this.starved++;
|
|
293
761
|
return cloneRecord(a.value);
|
|
294
762
|
}
|
|
295
|
-
const
|
|
763
|
+
const span = b.t - a.t;
|
|
764
|
+
const alpha = span > 0 ? (renderT - a.t) / span : 1;
|
|
296
765
|
return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
|
|
297
766
|
}
|
|
298
767
|
/** Is `collection[id]` visible at the render clock? */
|
|
299
768
|
has(desc, id) {
|
|
300
769
|
const coll = this.store.plain[desc.name];
|
|
301
770
|
if (this.predictor && desc.physics && this.predictor.has(desc.name, id)) return coll.has(id);
|
|
302
|
-
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf())
|
|
771
|
+
if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
303
774
|
if (!desc.interpolate) return coll.has(id);
|
|
304
775
|
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
305
776
|
if (!buffer || buffer.frames.length === 0) return coll.has(id);
|
|
@@ -345,20 +816,39 @@ var RenderStore = class {
|
|
|
345
816
|
if (byId && byId.size === 0) this.buffers.delete(collection);
|
|
346
817
|
}
|
|
347
818
|
// -- view -----------------------------------------------------------------
|
|
348
|
-
|
|
349
|
-
|
|
819
|
+
/** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
|
|
820
|
+
buildViewInto(view) {
|
|
821
|
+
const names = /* @__PURE__ */ new Set();
|
|
350
822
|
for (const c of this.ext.collections) {
|
|
823
|
+
names.add(c.name);
|
|
351
824
|
if (c.kind === "entity") {
|
|
352
|
-
const
|
|
353
|
-
|
|
825
|
+
const existing = this.facades.get(c.name);
|
|
826
|
+
if (existing) {
|
|
827
|
+
existing.retarget(c);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const impl = new RenderCollectionImpl(this, c);
|
|
831
|
+
this.facades.set(c.name, impl);
|
|
832
|
+
const facade = withRenderIndexSugar(impl);
|
|
833
|
+
Object.defineProperty(view, c.name, {
|
|
834
|
+
get: () => facade,
|
|
835
|
+
enumerable: true,
|
|
836
|
+
configurable: true
|
|
837
|
+
});
|
|
354
838
|
} else {
|
|
355
839
|
Object.defineProperty(view, c.name, {
|
|
356
840
|
get: () => this.store.view[c.name],
|
|
357
|
-
enumerable: true
|
|
841
|
+
enumerable: true,
|
|
842
|
+
configurable: true
|
|
358
843
|
});
|
|
359
844
|
}
|
|
360
845
|
}
|
|
361
|
-
|
|
846
|
+
for (const name of Object.keys(view)) {
|
|
847
|
+
if (!names.has(name)) {
|
|
848
|
+
delete view[name];
|
|
849
|
+
this.facades.delete(name);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
362
852
|
}
|
|
363
853
|
};
|
|
364
854
|
var RenderCollectionImpl = class {
|
|
@@ -368,6 +858,10 @@ var RenderCollectionImpl = class {
|
|
|
368
858
|
}
|
|
369
859
|
render;
|
|
370
860
|
desc;
|
|
861
|
+
/** D50: point this facade at the same collection in a rebuilt schema. */
|
|
862
|
+
retarget(desc) {
|
|
863
|
+
this.desc = desc;
|
|
864
|
+
}
|
|
371
865
|
get(id) {
|
|
372
866
|
return this.render.get(this.desc, id);
|
|
373
867
|
}
|
|
@@ -497,6 +991,7 @@ var webSocketTransport = {
|
|
|
497
991
|
};
|
|
498
992
|
|
|
499
993
|
// src/session.ts
|
|
994
|
+
var PROFILE_WINDOW_MS = 1e3;
|
|
500
995
|
var DEFAULT_WRITE_INTERVAL_MS = 50;
|
|
501
996
|
var PING_INTERVAL_MS = 2e3;
|
|
502
997
|
var RTT_ALPHA = 0.3;
|
|
@@ -513,9 +1008,11 @@ function nameOfCode(code) {
|
|
|
513
1008
|
return "E_INTERNAL";
|
|
514
1009
|
}
|
|
515
1010
|
}
|
|
1011
|
+
var INTERNAL_SESSION = /* @__PURE__ */ Symbol("irtio.session");
|
|
516
1012
|
var Session = class {
|
|
517
1013
|
constructor(options) {
|
|
518
1014
|
this.options = options;
|
|
1015
|
+
this.schema = options.schema;
|
|
519
1016
|
this.ext = options.schema ? withBuiltins(options.schema) : relaySchema;
|
|
520
1017
|
this.rpcSchema = options.schema ?? relaySchema;
|
|
521
1018
|
this.transport = options.transport ?? webSocketTransport;
|
|
@@ -524,52 +1021,91 @@ var Session = class {
|
|
|
524
1021
|
this.impls = new Map(Object.entries(options.rpc ?? {}));
|
|
525
1022
|
this.roomId = options.roomId;
|
|
526
1023
|
this.role = options.role ?? "";
|
|
1024
|
+
this.ledger = options.profile === true ? new ProfileLedger(this.ext, this.rpcSchema) : void 0;
|
|
527
1025
|
this.store = new ClientStore(this.ext, () => this.me);
|
|
528
1026
|
this.render = new RenderStore(
|
|
529
1027
|
this.ext,
|
|
530
1028
|
this.store,
|
|
531
1029
|
() => this.me,
|
|
532
1030
|
() => this.scheduler.now(),
|
|
533
|
-
() => this.interpDelayMs
|
|
1031
|
+
() => this.interpDelayMs,
|
|
1032
|
+
() => this.tickIntervalMs
|
|
534
1033
|
);
|
|
535
1034
|
const hasPhysics = this.ext.collections.some(
|
|
536
1035
|
(c) => c.physics !== void 0
|
|
537
1036
|
);
|
|
538
|
-
|
|
539
|
-
|
|
1037
|
+
const attach = (predictor) => {
|
|
1038
|
+
if (this.left) return;
|
|
1039
|
+
this.predictor = predictor;
|
|
1040
|
+
this.render.attachPredictor(predictor);
|
|
1041
|
+
if (this.joined) {
|
|
1042
|
+
predictor.reset();
|
|
1043
|
+
void predictor.start();
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
const physics = options.physics;
|
|
1047
|
+
const physics2d = options.physics2d;
|
|
1048
|
+
if (physics && hasPhysics) {
|
|
540
1049
|
this.predictionRequested = true;
|
|
541
|
-
void import("./physics-
|
|
1050
|
+
void import("./physics-S5LHL3HK.js").then(({ PhysicsPredictor }) => {
|
|
542
1051
|
if (this.left) return;
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1052
|
+
attach(
|
|
1053
|
+
new PhysicsPredictor(
|
|
1054
|
+
this.ext,
|
|
1055
|
+
this.store,
|
|
1056
|
+
physics,
|
|
1057
|
+
() => this.me,
|
|
1058
|
+
() => this.rtt,
|
|
1059
|
+
() => this.tickIntervalMs
|
|
1060
|
+
)
|
|
1061
|
+
);
|
|
1062
|
+
});
|
|
1063
|
+
} else if (physics2d && hasPhysics) {
|
|
1064
|
+
this.predictionRequested = true;
|
|
1065
|
+
void import("./physics2d-ZV2IRRRZ.js").then(({ Physics2dPredictor }) => {
|
|
1066
|
+
if (this.left) return;
|
|
1067
|
+
attach(
|
|
1068
|
+
new Physics2dPredictor(
|
|
1069
|
+
this.ext,
|
|
1070
|
+
this.store,
|
|
1071
|
+
physics2d,
|
|
1072
|
+
() => this.me,
|
|
1073
|
+
() => this.rtt,
|
|
1074
|
+
() => this.tickIntervalMs
|
|
1075
|
+
)
|
|
550
1076
|
);
|
|
551
|
-
this.predictor = predictor;
|
|
552
|
-
this.render.attachPredictor(predictor);
|
|
553
|
-
if (this.joined) {
|
|
554
|
-
predictor.reset();
|
|
555
|
-
void predictor.start();
|
|
556
|
-
}
|
|
557
1077
|
});
|
|
558
1078
|
}
|
|
559
1079
|
}
|
|
560
1080
|
options;
|
|
1081
|
+
/**
|
|
1082
|
+
* D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
|
|
1083
|
+
* new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
|
|
1084
|
+
* single assignment below is what actually moves the session onto the new schema.
|
|
1085
|
+
*/
|
|
561
1086
|
ext;
|
|
562
1087
|
store;
|
|
563
1088
|
render;
|
|
564
|
-
/** `true` when the join passed
|
|
1089
|
+
/** `true` when the join passed an engine option and the schema has body-backed collections. */
|
|
565
1090
|
predictionRequested = false;
|
|
566
1091
|
/**
|
|
567
|
-
* Present once
|
|
568
|
-
*
|
|
1092
|
+
* Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
|
|
1093
|
+
* `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
|
|
1094
|
+
* engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
|
|
1095
|
+
* interpolation until then.
|
|
569
1096
|
*/
|
|
570
1097
|
predictor;
|
|
571
|
-
/** The schema the `CALL`/`REPLY` rpc id space indexes into. */
|
|
1098
|
+
/** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
|
|
572
1099
|
rpcSchema;
|
|
1100
|
+
/**
|
|
1101
|
+
* The BUILDER schema this session is currently on: `options.schema` at construction, then
|
|
1102
|
+
* whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
|
|
1103
|
+
* swapped session announces its *current* hash when it reconnects rather than the one it was
|
|
1104
|
+
* born with — otherwise a resume after a swap would be refused as a mismatch.
|
|
1105
|
+
*/
|
|
1106
|
+
schema;
|
|
1107
|
+
/** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
|
|
1108
|
+
schemaSwaps = 0;
|
|
573
1109
|
transport;
|
|
574
1110
|
scheduler;
|
|
575
1111
|
writeIntervalMs;
|
|
@@ -587,7 +1123,10 @@ var Session = class {
|
|
|
587
1123
|
*/
|
|
588
1124
|
writeTick = 0;
|
|
589
1125
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
1126
|
+
/** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
|
|
590
1127
|
tickIntervalMs = 0;
|
|
1128
|
+
/** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
|
|
1129
|
+
maxClients = 0;
|
|
591
1130
|
rtt = 0;
|
|
592
1131
|
status = "connecting";
|
|
593
1132
|
socket;
|
|
@@ -612,6 +1151,20 @@ var Session = class {
|
|
|
612
1151
|
pending = /* @__PURE__ */ new Map();
|
|
613
1152
|
listeners = /* @__PURE__ */ new Map();
|
|
614
1153
|
messageListeners = /* @__PURE__ */ new Set();
|
|
1154
|
+
/**
|
|
1155
|
+
* D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
|
|
1156
|
+
*
|
|
1157
|
+
* The isolation clause has a client half as well as a server half. The supervisor guarantees that
|
|
1158
|
+
* a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
|
|
1159
|
+
* *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
|
|
1160
|
+
* receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
|
|
1161
|
+
* the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
|
|
1162
|
+
* and no ordering between two callbacks to get wrong.
|
|
1163
|
+
*
|
|
1164
|
+
* Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
|
|
1165
|
+
* `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
|
|
1166
|
+
*/
|
|
1167
|
+
voiceListeners = /* @__PURE__ */ new Set();
|
|
615
1168
|
cancelFlush;
|
|
616
1169
|
cancelPing;
|
|
617
1170
|
cancelRetry;
|
|
@@ -680,6 +1233,19 @@ var Session = class {
|
|
|
680
1233
|
}
|
|
681
1234
|
async sendHello(socket) {
|
|
682
1235
|
let token;
|
|
1236
|
+
let assertion;
|
|
1237
|
+
const suppliedAssertion = this.options.assertion;
|
|
1238
|
+
if (suppliedAssertion !== void 0) {
|
|
1239
|
+
try {
|
|
1240
|
+
assertion = await suppliedAssertion();
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
this.transportFailed(
|
|
1243
|
+
err instanceof Error ? err : new Error(`identity provider failed: ${String(err)}`)
|
|
1244
|
+
);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
1248
|
+
}
|
|
683
1249
|
const supplied = this.options.token;
|
|
684
1250
|
if (supplied !== void 0) {
|
|
685
1251
|
try {
|
|
@@ -692,15 +1258,20 @@ var Session = class {
|
|
|
692
1258
|
}
|
|
693
1259
|
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
694
1260
|
}
|
|
695
|
-
const hash8 = this.
|
|
1261
|
+
const hash8 = this.schema ? this.schema.hash8 : RELAY_HASH8;
|
|
696
1262
|
const hello = encodeHello({
|
|
697
1263
|
protocolVersion: PROTOCOL_VERSION,
|
|
698
|
-
credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
|
|
1264
|
+
credential: assertion !== void 0 ? { kind: "assertion", key: this.options.key, token: assertion } : token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
|
|
699
1265
|
roomId: this.roomId,
|
|
700
1266
|
schemaHash8: hash8,
|
|
701
1267
|
...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
|
|
702
1268
|
...this.options.role !== void 0 ? { role: this.options.role } : {},
|
|
703
|
-
...this.options.name !== void 0 ? { name: this.options.name } : {}
|
|
1269
|
+
...this.options.name !== void 0 ? { name: this.options.name } : {},
|
|
1270
|
+
// D50: this client can rebuild its codec from a SCHEMA frame. Announced unconditionally for
|
|
1271
|
+
// a schema'd session — there is nothing to opt out of, the server still decides, and a
|
|
1272
|
+
// server that predates the bit ignores it (it reads the mask it knows and stops).
|
|
1273
|
+
// A relay session has no schema to swap, so it does not ask.
|
|
1274
|
+
...this.schema !== void 0 ? { schemaSwap: true } : {}
|
|
704
1275
|
});
|
|
705
1276
|
this.send(FrameType.HELLO, hello);
|
|
706
1277
|
}
|
|
@@ -765,10 +1336,43 @@ var Session = class {
|
|
|
765
1336
|
// -------------------------------------------------------------------------
|
|
766
1337
|
// Frames
|
|
767
1338
|
// -------------------------------------------------------------------------
|
|
1339
|
+
/**
|
|
1340
|
+
* D65: the session's bandwidth ledger, present only when the join asked for one. A second
|
|
1341
|
+
* consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
|
|
1342
|
+
* compose rather than compete.
|
|
1343
|
+
*/
|
|
1344
|
+
ledger;
|
|
1345
|
+
/** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
|
|
1346
|
+
profileWindow;
|
|
1347
|
+
/**
|
|
1348
|
+
* D65: bytes per second over a rolling window of about a second, measured on the scheduler's
|
|
1349
|
+
* clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
|
|
1350
|
+
*
|
|
1351
|
+
* The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
|
|
1352
|
+
* one-second window, and a reader that never calls costs nothing. A read sooner than a second
|
|
1353
|
+
* after the last one is extrapolated from the partial window rather than returning zeros.
|
|
1354
|
+
*/
|
|
1355
|
+
profilePerSecond() {
|
|
1356
|
+
const ledger = this.ledger;
|
|
1357
|
+
if (!ledger) return EMPTY_PROFILE;
|
|
1358
|
+
const now = this.scheduler.now();
|
|
1359
|
+
const snap = ledger.snapshot();
|
|
1360
|
+
const previous = this.profileWindow;
|
|
1361
|
+
if (!previous) {
|
|
1362
|
+
this.profileWindow = { at: now, snap };
|
|
1363
|
+
return EMPTY_PROFILE;
|
|
1364
|
+
}
|
|
1365
|
+
const elapsed = now - previous.at;
|
|
1366
|
+
if (elapsed <= 0) return EMPTY_PROFILE;
|
|
1367
|
+
const window = diffProfiles(previous.snap, snap);
|
|
1368
|
+
if (elapsed >= PROFILE_WINDOW_MS) this.profileWindow = { at: now, snap };
|
|
1369
|
+
return scaleProfile(window, elapsed / PROFILE_WINDOW_MS);
|
|
1370
|
+
}
|
|
768
1371
|
send(type, payload) {
|
|
769
1372
|
if (!this.socketOpen || !this.socket) return;
|
|
770
1373
|
const frame = encodeFrame(type, payload);
|
|
771
1374
|
this.options.onFrame?.("out", type, frame);
|
|
1375
|
+
this.ledger?.attribute("out", frame, { spatialChurn: true });
|
|
772
1376
|
this.socket.send(frame);
|
|
773
1377
|
}
|
|
774
1378
|
onFrame(bytes) {
|
|
@@ -783,6 +1387,7 @@ var Session = class {
|
|
|
783
1387
|
return;
|
|
784
1388
|
}
|
|
785
1389
|
this.options.onFrame?.("in", type, bytes);
|
|
1390
|
+
this.ledger?.attribute("in", bytes, { spatialChurn: true });
|
|
786
1391
|
try {
|
|
787
1392
|
switch (type) {
|
|
788
1393
|
case FrameType.WELCOME:
|
|
@@ -806,6 +1411,9 @@ var Session = class {
|
|
|
806
1411
|
case FrameType.REPLY:
|
|
807
1412
|
this.onReply(payload);
|
|
808
1413
|
return;
|
|
1414
|
+
case FrameType.SCHEMA:
|
|
1415
|
+
this.onSchema(payload);
|
|
1416
|
+
return;
|
|
809
1417
|
default:
|
|
810
1418
|
if (type === FrameType.MSG) this.onMsg(payload);
|
|
811
1419
|
return;
|
|
@@ -822,6 +1430,72 @@ var Session = class {
|
|
|
822
1430
|
fatal: false
|
|
823
1431
|
});
|
|
824
1432
|
}
|
|
1433
|
+
/**
|
|
1434
|
+
* D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
|
|
1435
|
+
* the old worker has stopped sending and before the resync WELCOME, so this session can stay
|
|
1436
|
+
* open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
|
|
1437
|
+
*
|
|
1438
|
+
* A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
|
|
1439
|
+
* the session on the old schema with a resync WELCOME about to arrive under the new one, which
|
|
1440
|
+
* it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
|
|
1441
|
+
* it anyway under the wrong descriptor, and a misdecode is silent.
|
|
1442
|
+
*/
|
|
1443
|
+
onSchema(payload) {
|
|
1444
|
+
const canonical = readSchemaPayload(payload);
|
|
1445
|
+
this.swapSchema(canonical);
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* The one entry point for a mid-session schema change. Internal: not part of the public client
|
|
1449
|
+
* API this release, and not called from anywhere but `onSchema`.
|
|
1450
|
+
*
|
|
1451
|
+
* **What is rebuilt.** Every schema-derived handle in the client, top down:
|
|
1452
|
+
*
|
|
1453
|
+
* - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
|
|
1454
|
+
* `decodeSnapshot` and rpc-id lookup reads through;
|
|
1455
|
+
* - `descsByName`, the correction path's collection index, invalidated so it re-derives;
|
|
1456
|
+
* - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
|
|
1457
|
+
* facades behind `room.state` — retargeted, not replaced;
|
|
1458
|
+
* - `RenderStore`: the same, plus every interpolation buffer;
|
|
1459
|
+
* - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
|
|
1460
|
+
*
|
|
1461
|
+
* **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
|
|
1462
|
+
* by the old descriptors and has no correct reading under the new ones. The resync WELCOME
|
|
1463
|
+
* arriving immediately after re-establishes all of it, which is what makes dropping cheap:
|
|
1464
|
+
*
|
|
1465
|
+
* - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
|
|
1466
|
+
* - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
|
|
1467
|
+
* resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
|
|
1468
|
+
* indexed against descriptors that no longer exist;
|
|
1469
|
+
* - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
|
|
1470
|
+
* pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
|
|
1471
|
+
* the WELCOME;
|
|
1472
|
+
* - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
|
|
1473
|
+
*
|
|
1474
|
+
* **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
|
|
1475
|
+
* captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
|
|
1476
|
+
* was produced by a room that had those params in hand. So a call issued before the swap keeps
|
|
1477
|
+
* decoding its reply under the schema it was issued with, and resolves normally afterwards. This
|
|
1478
|
+
* costs nothing to implement — the descriptor is already captured per call rather than looked up
|
|
1479
|
+
* at reply time — and it is the honest semantics: the call did happen, under the old contract.
|
|
1480
|
+
*
|
|
1481
|
+
* The narrow case this leaves is a REPLY whose *shape* changed additively between the two
|
|
1482
|
+
* schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
|
|
1483
|
+
* which is exactly what an appended field means. A breaking change to an RPC never reaches here:
|
|
1484
|
+
* it classifies breaking and the session is closed instead.
|
|
1485
|
+
*/
|
|
1486
|
+
swapSchema(canonical) {
|
|
1487
|
+
const next = schemaFromCanonical(canonical);
|
|
1488
|
+
if (this.schema && next.hash === this.schema.hash) return;
|
|
1489
|
+
this.schema = next;
|
|
1490
|
+
this.ext = withBuiltins(next);
|
|
1491
|
+
this.rpcSchema = next;
|
|
1492
|
+
this.descsByName = void 0;
|
|
1493
|
+
this.store.swapSchema(this.ext);
|
|
1494
|
+
this.render.swapSchema(this.ext);
|
|
1495
|
+
this.ledger?.swapSchema(this.ext, this.rpcSchema);
|
|
1496
|
+
this.predictor?.swapSchema(this.ext);
|
|
1497
|
+
this.schemaSwaps++;
|
|
1498
|
+
}
|
|
825
1499
|
onWelcome(payload) {
|
|
826
1500
|
const welcome = decodeWelcome(payload);
|
|
827
1501
|
const pending = this.store.capturePendingWrites();
|
|
@@ -829,11 +1503,13 @@ var Session = class {
|
|
|
829
1503
|
this.role = welcome.role;
|
|
830
1504
|
this.tick = welcome.tick;
|
|
831
1505
|
this.resumeToken = welcome.resumeToken;
|
|
832
|
-
this.tickIntervalMs = welcome.
|
|
1506
|
+
this.tickIntervalMs = welcome.tickRate > 0 ? 1e3 / welcome.tickRate : 0;
|
|
1507
|
+
this.maxClients = welcome.maxClients;
|
|
833
1508
|
if (welcome.roomId !== "") this.roomId = welcome.roomId;
|
|
834
1509
|
this.store.loadSnapshot(welcome.snapshot);
|
|
835
1510
|
this.store.applyPendingWrites(pending);
|
|
836
1511
|
this.render.seedSnapshot();
|
|
1512
|
+
this.emit("clients", this.clients);
|
|
837
1513
|
if (this.predictor) {
|
|
838
1514
|
this.predictor.reset();
|
|
839
1515
|
void this.predictor.start();
|
|
@@ -859,6 +1535,9 @@ var Session = class {
|
|
|
859
1535
|
this.predictor.noteAuthority(delta.tick);
|
|
860
1536
|
this.predictor.frame(this.scheduler.now());
|
|
861
1537
|
}
|
|
1538
|
+
if (delta.collections.some((dc) => dc.name === "clients")) {
|
|
1539
|
+
this.emit("clients", this.clients);
|
|
1540
|
+
}
|
|
862
1541
|
}
|
|
863
1542
|
onCorrect(payload) {
|
|
864
1543
|
const r = new ByteReader(payload);
|
|
@@ -897,6 +1576,9 @@ var Session = class {
|
|
|
897
1576
|
this.predictor.noteAuthority(delta.tick);
|
|
898
1577
|
this.predictor.frame(this.scheduler.now());
|
|
899
1578
|
}
|
|
1579
|
+
if (delta.collections.some((dc) => dc.name === "clients")) {
|
|
1580
|
+
this.emit("clients", this.clients);
|
|
1581
|
+
}
|
|
900
1582
|
}
|
|
901
1583
|
descsByName;
|
|
902
1584
|
descOfCollection(name) {
|
|
@@ -942,12 +1624,17 @@ var Session = class {
|
|
|
942
1624
|
const pong = decodePong(payload);
|
|
943
1625
|
const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
|
|
944
1626
|
this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
|
|
1627
|
+
this.emit("rtt", this.rtt);
|
|
945
1628
|
if (pong.serverTick > this.tick) this.tick = pong.serverTick;
|
|
946
1629
|
}
|
|
947
1630
|
onMsg(payload) {
|
|
948
1631
|
const msg = decodeMsg(payload);
|
|
949
|
-
const from = msg.target.kind === "client" ? msg.target.clientId : "server";
|
|
950
1632
|
const bytes = msg.payload.slice();
|
|
1633
|
+
if (msg.target.kind === "voice") {
|
|
1634
|
+
for (const cb of [...this.voiceListeners]) cb(bytes);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const from = msg.target.kind === "client" ? msg.target.clientId : "server";
|
|
951
1638
|
for (const cb of [...this.messageListeners]) cb(from, bytes);
|
|
952
1639
|
}
|
|
953
1640
|
// -------------------------------------------------------------------------
|
|
@@ -1145,6 +1832,25 @@ var Session = class {
|
|
|
1145
1832
|
this.messageListeners.delete(cb);
|
|
1146
1833
|
};
|
|
1147
1834
|
}
|
|
1835
|
+
/**
|
|
1836
|
+
* D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
|
|
1837
|
+
* so it is not on the `Room` type and a game cannot call it.
|
|
1838
|
+
*
|
|
1839
|
+
* This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
|
|
1840
|
+
* purpose (that is the third of the three isolation mechanisms), and widening it so that this
|
|
1841
|
+
* method could share the mapping would delete the mechanism to save four lines. Building the
|
|
1842
|
+
* `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
|
|
1843
|
+
*/
|
|
1844
|
+
sendVoice(bytes) {
|
|
1845
|
+
this.send(FrameType.MSG, encodeMsg({ target: { kind: "voice" }, payload: bytes }));
|
|
1846
|
+
}
|
|
1847
|
+
/** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
|
|
1848
|
+
onVoice(cb) {
|
|
1849
|
+
this.voiceListeners.add(cb);
|
|
1850
|
+
return () => {
|
|
1851
|
+
this.voiceListeners.delete(cb);
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1148
1854
|
get clients() {
|
|
1149
1855
|
const coll = this.store.plain.clients;
|
|
1150
1856
|
if (!coll) return [];
|
|
@@ -1187,6 +1893,458 @@ function resolveKey(explicit, schema, url) {
|
|
|
1187
1893
|
);
|
|
1188
1894
|
}
|
|
1189
1895
|
|
|
1896
|
+
// src/match.ts
|
|
1897
|
+
var MatchError = class extends Error {
|
|
1898
|
+
constructor(code, message) {
|
|
1899
|
+
super(message);
|
|
1900
|
+
this.code = code;
|
|
1901
|
+
}
|
|
1902
|
+
code;
|
|
1903
|
+
name = "MatchError";
|
|
1904
|
+
};
|
|
1905
|
+
async function findMatch(project, options = {}) {
|
|
1906
|
+
const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
1907
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
1908
|
+
let res;
|
|
1909
|
+
try {
|
|
1910
|
+
res = await fetchImpl(`${controlUrl}/match`, {
|
|
1911
|
+
method: "POST",
|
|
1912
|
+
headers: { "content-type": "application/json" },
|
|
1913
|
+
body: JSON.stringify({
|
|
1914
|
+
project,
|
|
1915
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
1916
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
1917
|
+
...options.identity !== void 0 ? { identity: options.identity } : {},
|
|
1918
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
|
|
1919
|
+
})
|
|
1920
|
+
});
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
throw new MatchError(
|
|
1923
|
+
"E_MATCH_UNREACHABLE",
|
|
1924
|
+
`cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
const text = await res.text().catch(() => "");
|
|
1928
|
+
let body = {};
|
|
1929
|
+
try {
|
|
1930
|
+
body = JSON.parse(text);
|
|
1931
|
+
} catch {
|
|
1932
|
+
}
|
|
1933
|
+
if (!res.ok) {
|
|
1934
|
+
throw new MatchError(
|
|
1935
|
+
typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
|
|
1936
|
+
typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
if (body.status === "timeout") {
|
|
1940
|
+
throw new MatchError(
|
|
1941
|
+
"E_NO_MATCH",
|
|
1942
|
+
`no match in the ${String(body.queue)} queue: ${String(body.waiting)} of ${String(
|
|
1943
|
+
body.size
|
|
1944
|
+
)} players were waiting. Try again, or bring a friend.`
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
if (body.status !== "matched" || typeof body.room !== "string") {
|
|
1948
|
+
throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
|
|
1949
|
+
}
|
|
1950
|
+
return {
|
|
1951
|
+
room: body.room,
|
|
1952
|
+
queue: typeof body.queue === "string" ? body.queue : "default",
|
|
1953
|
+
seat: typeof body.seat === "number" ? body.seat : 0,
|
|
1954
|
+
size: typeof body.size === "number" ? body.size : 0,
|
|
1955
|
+
backfill: body.backfill === true
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
async function createParty(project, options) {
|
|
1959
|
+
const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
1960
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
1961
|
+
let res;
|
|
1962
|
+
try {
|
|
1963
|
+
res = await fetchImpl(`${controlUrl}/match/party`, {
|
|
1964
|
+
method: "POST",
|
|
1965
|
+
headers: { "content-type": "application/json" },
|
|
1966
|
+
body: JSON.stringify({ project, size: options.size })
|
|
1967
|
+
});
|
|
1968
|
+
} catch (err) {
|
|
1969
|
+
throw new MatchError(
|
|
1970
|
+
"E_MATCH_UNREACHABLE",
|
|
1971
|
+
`cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
const text = await res.text().catch(() => "");
|
|
1975
|
+
let body = {};
|
|
1976
|
+
try {
|
|
1977
|
+
body = JSON.parse(text);
|
|
1978
|
+
} catch {
|
|
1979
|
+
}
|
|
1980
|
+
if (!res.ok) {
|
|
1981
|
+
throw new MatchError(
|
|
1982
|
+
typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
|
|
1983
|
+
typeof body.message === "string" ? body.message : `party failed with status ${res.status}`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
if (typeof body.party !== "string") {
|
|
1987
|
+
throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
|
|
1988
|
+
}
|
|
1989
|
+
return {
|
|
1990
|
+
party: body.party,
|
|
1991
|
+
expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 0
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
async function matchRoom(schema, options = {}) {
|
|
1995
|
+
const project = schema.project;
|
|
1996
|
+
const key = options.key ?? (typeof project === "string" ? project : void 0);
|
|
1997
|
+
if (key === void 0 || key === "") {
|
|
1998
|
+
throw new MatchError(
|
|
1999
|
+
"E_MATCH_NO_PROJECT",
|
|
2000
|
+
"matchRoom needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
const identity = options.identity === true ? new Identity({
|
|
2004
|
+
project: key,
|
|
2005
|
+
controlUrl: options.controlUrl,
|
|
2006
|
+
fetch: options.fetch
|
|
2007
|
+
}) : void 0;
|
|
2008
|
+
const ticket = await findMatch(key, {
|
|
2009
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2010
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
2011
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
2012
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
2013
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
2014
|
+
...identity !== void 0 ? { identity: await identity.ensure() } : {}
|
|
2015
|
+
});
|
|
2016
|
+
try {
|
|
2017
|
+
return await joinRoom(schema, { ...options, room: ticket.room });
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
if (!ticket.backfill || !isRoomFull(err)) throw err;
|
|
2020
|
+
const second = await findMatch(key, {
|
|
2021
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2022
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
2023
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
2024
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
2025
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
2026
|
+
...identity !== void 0 ? { identity: await identity.ensure() } : {}
|
|
2027
|
+
});
|
|
2028
|
+
return joinRoom(schema, { ...options, room: second.room });
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
function isRoomFull(err) {
|
|
2032
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2033
|
+
return message.startsWith("E_ROOM_FULL");
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// src/voice.ts
|
|
2037
|
+
import {
|
|
2038
|
+
decodeVoiceMessage,
|
|
2039
|
+
encodeVoiceMessage
|
|
2040
|
+
} from "@irtio/protocol";
|
|
2041
|
+
var INTERNAL_VOICE_TRACKS = /* @__PURE__ */ Symbol.for("irtio.voice.tracks");
|
|
2042
|
+
function browserFactory() {
|
|
2043
|
+
return {
|
|
2044
|
+
createDevice: async () => {
|
|
2045
|
+
const mod = await import("mediasoup-client");
|
|
2046
|
+
return new mod.Device();
|
|
2047
|
+
},
|
|
2048
|
+
getMicTrack: async () => {
|
|
2049
|
+
const media = globalThis.navigator?.mediaDevices;
|
|
2050
|
+
if (!media?.getUserMedia) {
|
|
2051
|
+
throw new Error("irtio voice: getUserMedia is unavailable (needs a secure context)");
|
|
2052
|
+
}
|
|
2053
|
+
const stream = await media.getUserMedia({
|
|
2054
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
2055
|
+
video: false
|
|
2056
|
+
});
|
|
2057
|
+
const track = stream.getAudioTracks()[0];
|
|
2058
|
+
if (!track) throw new Error("irtio voice: no audio track from getUserMedia");
|
|
2059
|
+
return track;
|
|
2060
|
+
},
|
|
2061
|
+
attach: (peerId, track) => {
|
|
2062
|
+
const doc = globalThis.document;
|
|
2063
|
+
if (!doc) {
|
|
2064
|
+
return {
|
|
2065
|
+
detach: () => track.stop(),
|
|
2066
|
+
setVolume: () => void 0,
|
|
2067
|
+
setMuted: () => void 0
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
const el = doc.createElement("audio");
|
|
2071
|
+
el.autoplay = true;
|
|
2072
|
+
el.setAttribute("playsinline", "");
|
|
2073
|
+
el.dataset.irtioVoicePeer = peerId;
|
|
2074
|
+
el.style.display = "none";
|
|
2075
|
+
el.srcObject = new MediaStream([track]);
|
|
2076
|
+
doc.body.appendChild(el);
|
|
2077
|
+
return {
|
|
2078
|
+
detach: () => {
|
|
2079
|
+
el.srcObject = null;
|
|
2080
|
+
el.remove();
|
|
2081
|
+
track.stop();
|
|
2082
|
+
},
|
|
2083
|
+
setVolume: (volume) => {
|
|
2084
|
+
el.volume = volume;
|
|
2085
|
+
},
|
|
2086
|
+
setMuted: (muted) => {
|
|
2087
|
+
el.muted = muted;
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
function asPlayback(result) {
|
|
2094
|
+
return typeof result === "function" ? { detach: result, setVolume: () => void 0, setMuted: () => void 0 } : result;
|
|
2095
|
+
}
|
|
2096
|
+
async function joinVoice(room, options = {}) {
|
|
2097
|
+
const internals = {
|
|
2098
|
+
...browserFactory(),
|
|
2099
|
+
...options.__transportFactory
|
|
2100
|
+
};
|
|
2101
|
+
const session = room[INTERNAL_SESSION];
|
|
2102
|
+
if (!session) {
|
|
2103
|
+
throw new Error("irtio voice: joinVoice needs a Room from joinRoom()");
|
|
2104
|
+
}
|
|
2105
|
+
if (options.positional === true) {
|
|
2106
|
+
console.warn(
|
|
2107
|
+
"irtio voice: positional audio is not in this release; joinVoice({ positional: true }) produces ordinary flat audio. The option is accepted so calling code keeps working."
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
2111
|
+
const emit = (event, value) => {
|
|
2112
|
+
for (const cb of [...listeners.get(event) ?? []]) cb(value);
|
|
2113
|
+
};
|
|
2114
|
+
let changeQueued = false;
|
|
2115
|
+
const changed = () => {
|
|
2116
|
+
if (changeQueued || closed) return;
|
|
2117
|
+
changeQueued = true;
|
|
2118
|
+
queueMicrotask(() => {
|
|
2119
|
+
changeQueued = false;
|
|
2120
|
+
if (!closed) emit("peers-changed", void 0);
|
|
2121
|
+
});
|
|
2122
|
+
};
|
|
2123
|
+
const waiters = [];
|
|
2124
|
+
const peers = /* @__PURE__ */ new Map();
|
|
2125
|
+
const playbacks = /* @__PURE__ */ new Map();
|
|
2126
|
+
const localPrefs = /* @__PURE__ */ new Map();
|
|
2127
|
+
const prefsFor = (peerId) => {
|
|
2128
|
+
let p = localPrefs.get(peerId);
|
|
2129
|
+
if (!p) {
|
|
2130
|
+
p = { mutedLocally: false, volume: 1 };
|
|
2131
|
+
localPrefs.set(peerId, p);
|
|
2132
|
+
}
|
|
2133
|
+
return p;
|
|
2134
|
+
};
|
|
2135
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
2136
|
+
let closed = false;
|
|
2137
|
+
let muted = false;
|
|
2138
|
+
let device;
|
|
2139
|
+
let sendTransport;
|
|
2140
|
+
let recvTransport;
|
|
2141
|
+
let micTrack;
|
|
2142
|
+
let producer;
|
|
2143
|
+
const signal = (m) => session.sendVoice(encodeVoiceMessage(m));
|
|
2144
|
+
function expect(match) {
|
|
2145
|
+
return new Promise((resolve, reject) => {
|
|
2146
|
+
waiters.push({ match, resolve, reject });
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
const unsubscribe = session.onVoice((bytes) => {
|
|
2150
|
+
const m = decodeVoiceMessage(bytes);
|
|
2151
|
+
if (!m) return;
|
|
2152
|
+
if (m.t === "error") {
|
|
2153
|
+
const err = new Error(`irtio voice: ${m.code} ${m.message}`);
|
|
2154
|
+
for (const w of waiters.splice(0)) w.reject(err);
|
|
2155
|
+
emit("error", { code: m.code, message: m.message });
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
for (let i = 0; i < waiters.length; i++) {
|
|
2159
|
+
const w = waiters[i];
|
|
2160
|
+
if (w.match(m)) {
|
|
2161
|
+
waiters.splice(i, 1);
|
|
2162
|
+
w.resolve(m);
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
switch (m.t) {
|
|
2167
|
+
case "peer-joined": {
|
|
2168
|
+
const known = peers.has(m.peer.peerId);
|
|
2169
|
+
peers.set(m.peer.peerId, {
|
|
2170
|
+
muted: m.peer.muted,
|
|
2171
|
+
...m.peer.producerId !== void 0 ? { producerId: m.peer.producerId } : {}
|
|
2172
|
+
});
|
|
2173
|
+
if (!known) emit("peer-joined", m.peer.peerId);
|
|
2174
|
+
changed();
|
|
2175
|
+
if (m.peer.producerId !== void 0) void consumePeer(m.peer.producerId);
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
case "peer-left": {
|
|
2179
|
+
peers.delete(m.peerId);
|
|
2180
|
+
playbacks.get(m.peerId)?.playback.detach();
|
|
2181
|
+
playbacks.delete(m.peerId);
|
|
2182
|
+
emit("peer-left", m.peerId);
|
|
2183
|
+
changed();
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
case "peer-muted": {
|
|
2187
|
+
const p = peers.get(m.peerId);
|
|
2188
|
+
if (p) p.muted = m.muted;
|
|
2189
|
+
emit("peer-muted", { peerId: m.peerId, muted: m.muted });
|
|
2190
|
+
changed();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
default:
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
async function consumePeer(producerId) {
|
|
2198
|
+
if (closed || consumed.has(producerId) || !recvTransport || !device) return;
|
|
2199
|
+
consumed.add(producerId);
|
|
2200
|
+
try {
|
|
2201
|
+
signal({ t: "consume", producerId, rtpCapabilities: device.rtpCapabilities });
|
|
2202
|
+
const reply = await expect(
|
|
2203
|
+
(m) => m.t === "consumed" && m.producerId === producerId
|
|
2204
|
+
);
|
|
2205
|
+
if (closed) return;
|
|
2206
|
+
const consumer = await recvTransport.consume({
|
|
2207
|
+
id: reply.consumerId,
|
|
2208
|
+
producerId: reply.producerId,
|
|
2209
|
+
kind: "audio",
|
|
2210
|
+
rtpParameters: reply.rtpParameters
|
|
2211
|
+
});
|
|
2212
|
+
const peerId = reply.peerId || producerId;
|
|
2213
|
+
playbacks.get(peerId)?.playback.detach();
|
|
2214
|
+
const playback = asPlayback(internals.attach(peerId, consumer.track));
|
|
2215
|
+
playbacks.set(peerId, { playback, track: consumer.track });
|
|
2216
|
+
const prefs = prefsFor(peerId);
|
|
2217
|
+
playback.setVolume(prefs.volume);
|
|
2218
|
+
playback.setMuted(prefs.mutedLocally);
|
|
2219
|
+
signal({ t: "resume", consumerId: reply.consumerId });
|
|
2220
|
+
changed();
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
consumed.delete(producerId);
|
|
2223
|
+
emit("error", {
|
|
2224
|
+
code: "E_VOICE_CONSUME",
|
|
2225
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async function teardown() {
|
|
2230
|
+
if (closed) return;
|
|
2231
|
+
closed = true;
|
|
2232
|
+
for (const w of waiters.splice(0)) w.reject(new Error("irtio voice: left the call"));
|
|
2233
|
+
for (const p of playbacks.values()) p.playback.detach();
|
|
2234
|
+
playbacks.clear();
|
|
2235
|
+
peers.clear();
|
|
2236
|
+
producer?.close();
|
|
2237
|
+
micTrack?.stop();
|
|
2238
|
+
sendTransport?.close();
|
|
2239
|
+
recvTransport?.close();
|
|
2240
|
+
unsubscribe();
|
|
2241
|
+
}
|
|
2242
|
+
try {
|
|
2243
|
+
micTrack = await internals.getMicTrack();
|
|
2244
|
+
device = await internals.createDevice();
|
|
2245
|
+
signal({ t: "join", rtpCapabilities: {} });
|
|
2246
|
+
const joined = await expect(
|
|
2247
|
+
(m) => m.t === "joined"
|
|
2248
|
+
);
|
|
2249
|
+
await device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });
|
|
2250
|
+
sendTransport = device.createSendTransport(joined.sendTransport);
|
|
2251
|
+
recvTransport = device.createRecvTransport(joined.recvTransport);
|
|
2252
|
+
for (const t of [sendTransport, recvTransport]) {
|
|
2253
|
+
const transport = t;
|
|
2254
|
+
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
|
|
2255
|
+
signal({ t: "connect", transportId: transport.id, dtlsParameters });
|
|
2256
|
+
expect((m) => m.t === "connected" && m.transportId === transport.id).then(
|
|
2257
|
+
() => callback(),
|
|
2258
|
+
(e) => errback(e)
|
|
2259
|
+
);
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
sendTransport.on("produce", ({ rtpParameters }, callback, errback) => {
|
|
2263
|
+
signal({
|
|
2264
|
+
t: "produce",
|
|
2265
|
+
transportId: sendTransport.id,
|
|
2266
|
+
kind: "audio",
|
|
2267
|
+
rtpParameters
|
|
2268
|
+
});
|
|
2269
|
+
expect((m) => m.t === "produced").then(
|
|
2270
|
+
(m) => callback({ id: m.producerId }),
|
|
2271
|
+
(e) => errback(e)
|
|
2272
|
+
);
|
|
2273
|
+
});
|
|
2274
|
+
producer = await sendTransport.produce({ track: micTrack });
|
|
2275
|
+
for (const peer of joined.peers) {
|
|
2276
|
+
peers.set(peer.peerId, {
|
|
2277
|
+
muted: peer.muted,
|
|
2278
|
+
...peer.producerId !== void 0 ? { producerId: peer.producerId } : {}
|
|
2279
|
+
});
|
|
2280
|
+
if (peer.producerId !== void 0) void consumePeer(peer.producerId);
|
|
2281
|
+
}
|
|
2282
|
+
changed();
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
await teardown();
|
|
2285
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
2286
|
+
}
|
|
2287
|
+
const handle = {
|
|
2288
|
+
mute(next) {
|
|
2289
|
+
if (closed || next === muted) return;
|
|
2290
|
+
muted = next;
|
|
2291
|
+
signal({ t: "mute", muted: next });
|
|
2292
|
+
changed();
|
|
2293
|
+
},
|
|
2294
|
+
setPeerVolume(peerId, volume) {
|
|
2295
|
+
if (closed) return;
|
|
2296
|
+
const v = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
|
|
2297
|
+
const prefs = prefsFor(peerId);
|
|
2298
|
+
if (prefs.volume === v) return;
|
|
2299
|
+
prefs.volume = v;
|
|
2300
|
+
playbacks.get(peerId)?.playback.setVolume(v);
|
|
2301
|
+
changed();
|
|
2302
|
+
},
|
|
2303
|
+
mutePeer(peerId, mutedLocally) {
|
|
2304
|
+
if (closed) return;
|
|
2305
|
+
const prefs = prefsFor(peerId);
|
|
2306
|
+
if (prefs.mutedLocally === mutedLocally) return;
|
|
2307
|
+
prefs.mutedLocally = mutedLocally;
|
|
2308
|
+
playbacks.get(peerId)?.playback.setMuted(mutedLocally);
|
|
2309
|
+
changed();
|
|
2310
|
+
},
|
|
2311
|
+
peerState(peerId) {
|
|
2312
|
+
const peer = peers.get(peerId);
|
|
2313
|
+
if (!peer) return void 0;
|
|
2314
|
+
const prefs = prefsFor(peerId);
|
|
2315
|
+
return { muted: peer.muted, mutedLocally: prefs.mutedLocally, volume: prefs.volume };
|
|
2316
|
+
},
|
|
2317
|
+
get muted() {
|
|
2318
|
+
return muted;
|
|
2319
|
+
},
|
|
2320
|
+
get peers() {
|
|
2321
|
+
return [...peers.keys()];
|
|
2322
|
+
},
|
|
2323
|
+
async leave() {
|
|
2324
|
+
if (closed) return;
|
|
2325
|
+
signal({ t: "leave" });
|
|
2326
|
+
await teardown();
|
|
2327
|
+
},
|
|
2328
|
+
on(event, cb) {
|
|
2329
|
+
let set = listeners.get(event);
|
|
2330
|
+
if (!set) {
|
|
2331
|
+
set = /* @__PURE__ */ new Set();
|
|
2332
|
+
listeners.set(event, set);
|
|
2333
|
+
}
|
|
2334
|
+
set.add(cb);
|
|
2335
|
+
return () => {
|
|
2336
|
+
set.delete(cb);
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
const tracks = {
|
|
2341
|
+
peerTrack: (peerId) => playbacks.get(peerId)?.track,
|
|
2342
|
+
micTrack: () => micTrack
|
|
2343
|
+
};
|
|
2344
|
+
Object.defineProperty(handle, INTERNAL_VOICE_TRACKS, { value: tracks, enumerable: false });
|
|
2345
|
+
return handle;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
1190
2348
|
// src/index.ts
|
|
1191
2349
|
function callProxy(session) {
|
|
1192
2350
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -1203,11 +2361,22 @@ function callProxy(session) {
|
|
|
1203
2361
|
});
|
|
1204
2362
|
}
|
|
1205
2363
|
async function joinRoom(schema, options = {}) {
|
|
2364
|
+
if (options.physics !== void 0 && options.physics2d !== void 0) {
|
|
2365
|
+
throw new Error(
|
|
2366
|
+
"irtio: joinRoom was given both { physics } and { physics2d }. A room runs one engine and the client predicts with that one, so pass the option matching the engine your room config declares."
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
1206
2369
|
const url = resolveUrl(options.url, options.region);
|
|
1207
2370
|
const key = resolveKey(options.key, schema, url);
|
|
1208
2371
|
const explicitRoom = options.room !== void 0;
|
|
1209
2372
|
const fromLocation = !explicitRoom && currentLocation() !== void 0;
|
|
1210
2373
|
const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
|
|
2374
|
+
const identity = options.identity === true ? new Identity({ project: key, controlUrl: options.controlUrl }) : options.identity instanceof Identity ? options.identity : void 0;
|
|
2375
|
+
if (identity !== void 0 && options.token !== void 0) {
|
|
2376
|
+
throw new Error(
|
|
2377
|
+
"irtio: joinRoom was given both { token } and { identity }. A join asserts one identity \u2014 pass your own JWT, or the platform identity, not both."
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
1211
2380
|
const session = new Session({
|
|
1212
2381
|
schema,
|
|
1213
2382
|
url,
|
|
@@ -1217,13 +2386,16 @@ async function joinRoom(schema, options = {}) {
|
|
|
1217
2386
|
role: options.role,
|
|
1218
2387
|
name: options.name,
|
|
1219
2388
|
token: options.token,
|
|
2389
|
+
...identity !== void 0 ? { assertion: () => identity.assertion() } : {},
|
|
1220
2390
|
rpc: options.rpc,
|
|
1221
2391
|
writeIntervalMs: options.writeIntervalMs,
|
|
1222
2392
|
interpDelayMs: options.interpDelayMs,
|
|
1223
2393
|
physics: options.physics,
|
|
2394
|
+
physics2d: options.physics2d,
|
|
1224
2395
|
transport: options.transport,
|
|
1225
2396
|
scheduler: options.scheduler,
|
|
1226
2397
|
onFrame: options.onFrame,
|
|
2398
|
+
profile: options.profile,
|
|
1227
2399
|
onStatus: options.onStatus
|
|
1228
2400
|
});
|
|
1229
2401
|
await session.start();
|
|
@@ -1244,6 +2416,9 @@ function makeRoom(session) {
|
|
|
1244
2416
|
get tick() {
|
|
1245
2417
|
return session.tick;
|
|
1246
2418
|
},
|
|
2419
|
+
get maxClients() {
|
|
2420
|
+
return session.maxClients;
|
|
2421
|
+
},
|
|
1247
2422
|
get status() {
|
|
1248
2423
|
return session.status;
|
|
1249
2424
|
},
|
|
@@ -1259,6 +2434,12 @@ function makeRoom(session) {
|
|
|
1259
2434
|
get clients() {
|
|
1260
2435
|
return session.clients;
|
|
1261
2436
|
},
|
|
2437
|
+
...session.ledger ? {
|
|
2438
|
+
profile: {
|
|
2439
|
+
total: () => session.ledger?.snapshot() ?? EMPTY_PROFILE2,
|
|
2440
|
+
perSecond: () => session.profilePerSecond()
|
|
2441
|
+
}
|
|
2442
|
+
} : {},
|
|
1262
2443
|
...session.predictionRequested ? {
|
|
1263
2444
|
prediction: {
|
|
1264
2445
|
get active() {
|
|
@@ -1278,6 +2459,7 @@ function makeRoom(session) {
|
|
|
1278
2459
|
flush: () => session.flush(),
|
|
1279
2460
|
leave: () => session.leave()
|
|
1280
2461
|
};
|
|
2462
|
+
Object.defineProperty(room, INTERNAL_SESSION, { value: session, enumerable: false });
|
|
1281
2463
|
return room;
|
|
1282
2464
|
}
|
|
1283
2465
|
async function joinRelay(options = {}) {
|
|
@@ -1306,6 +2488,9 @@ async function joinRelay(options = {}) {
|
|
|
1306
2488
|
get id() {
|
|
1307
2489
|
return session.roomId;
|
|
1308
2490
|
},
|
|
2491
|
+
get maxClients() {
|
|
2492
|
+
return session.maxClients;
|
|
2493
|
+
},
|
|
1309
2494
|
get link() {
|
|
1310
2495
|
return session.link;
|
|
1311
2496
|
},
|
|
@@ -1325,13 +2510,21 @@ async function joinRelay(options = {}) {
|
|
|
1325
2510
|
};
|
|
1326
2511
|
}
|
|
1327
2512
|
export {
|
|
2513
|
+
ACCOUNT_STORAGE_KEY,
|
|
1328
2514
|
CALL_TIMEOUT_MS,
|
|
1329
2515
|
ClientStore,
|
|
2516
|
+
DEFAULT_CONTROL_URL,
|
|
1330
2517
|
DEFAULT_REGION,
|
|
1331
2518
|
DEFAULT_WRITE_INTERVAL_MS,
|
|
1332
2519
|
DEV_PORT,
|
|
1333
2520
|
E_CONNECT_FAILED,
|
|
2521
|
+
E_IDENTITY_RATE_LIMITED,
|
|
2522
|
+
INTERNAL_VOICE_TRACKS,
|
|
2523
|
+
Identity,
|
|
2524
|
+
IdentityError,
|
|
2525
|
+
MAX_IDENTITY_RETRY_WAIT_MS,
|
|
1334
2526
|
MAX_PREDICTED_BODIES,
|
|
2527
|
+
MatchError,
|
|
1335
2528
|
PING_INTERVAL_MS,
|
|
1336
2529
|
PREDICTION_EPSILON,
|
|
1337
2530
|
REGION_RE,
|
|
@@ -1339,10 +2532,15 @@ export {
|
|
|
1339
2532
|
SMOOTHING_HALF_LIFE_MS,
|
|
1340
2533
|
SMOOTHING_SNAP_UNITS,
|
|
1341
2534
|
Session,
|
|
2535
|
+
createParty,
|
|
1342
2536
|
defaultScheduler,
|
|
2537
|
+
findMatch,
|
|
2538
|
+
identityStorageKey,
|
|
1343
2539
|
joinRelay,
|
|
1344
2540
|
joinRoom,
|
|
2541
|
+
joinVoice,
|
|
1345
2542
|
linkForUrl,
|
|
2543
|
+
matchRoom,
|
|
1346
2544
|
resolveUrl,
|
|
1347
2545
|
roomIdFrom,
|
|
1348
2546
|
webSocketTransport
|