@irtio/client 0.5.2 → 0.7.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/{physics-H2VDQLAU.js → chunk-DABQDR3S.js} +417 -269
- package/dist/{chunk-7UTJ7RSF.js → chunk-XWVXZRBS.js} +73 -8
- package/dist/index.d.ts +1496 -179
- package/dist/index.js +1417 -47
- package/dist/physics-RT5T36P5.js +219 -0
- package/dist/physics2d-HOFMWPZV.js +255 -0
- package/package.json +15 -6
package/dist/index.js
CHANGED
|
@@ -2,12 +2,16 @@ import {
|
|
|
2
2
|
ClientStore,
|
|
3
3
|
E_CONNECT_FAILED,
|
|
4
4
|
MAX_PREDICTED_BODIES,
|
|
5
|
+
MAX_PROXY_BODIES,
|
|
5
6
|
PREDICTION_EPSILON,
|
|
6
7
|
RESIM_DEPTH,
|
|
7
8
|
SMOOTHING_HALF_LIFE_MS,
|
|
8
9
|
SMOOTHING_SNAP_UNITS,
|
|
9
10
|
emptyPredictionStats
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-XWVXZRBS.js";
|
|
12
|
+
|
|
13
|
+
// src/index.ts
|
|
14
|
+
import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
|
|
11
15
|
|
|
12
16
|
// src/endpoint.ts
|
|
13
17
|
var DEFAULT_REGION = "eu";
|
|
@@ -112,10 +116,408 @@ function linkForUrl(wsUrl, roomId) {
|
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
// src/identity.ts
|
|
120
|
+
var ACCOUNT_STORAGE_KEY = "irtio.account";
|
|
121
|
+
function identityStorageKey(project) {
|
|
122
|
+
return `irtio.identity.${project}`;
|
|
123
|
+
}
|
|
124
|
+
var DEFAULT_CONTROL_URL = "https://irt.io";
|
|
125
|
+
function browserStorage() {
|
|
126
|
+
try {
|
|
127
|
+
const ls = globalThis.localStorage;
|
|
128
|
+
if (!ls) return void 0;
|
|
129
|
+
ls.getItem("irtio.identity.probe");
|
|
130
|
+
return ls;
|
|
131
|
+
} catch {
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var IdentityError = class extends Error {
|
|
136
|
+
constructor(code, message, retryAfterMs) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.code = code;
|
|
139
|
+
this.retryAfterMs = retryAfterMs;
|
|
140
|
+
}
|
|
141
|
+
code;
|
|
142
|
+
name = "IdentityError";
|
|
143
|
+
/**
|
|
144
|
+
* How long the control plane asked us to wait, in milliseconds, when it said so. Only ever set
|
|
145
|
+
* on `E_IDENTITY_RATE_LIMITED`: a rate limit is a wait, not an outage, and a game that cannot
|
|
146
|
+
* tell the two apart drops to its offline path forever over a sixty-second backoff (bug #36).
|
|
147
|
+
*/
|
|
148
|
+
retryAfterMs;
|
|
149
|
+
};
|
|
150
|
+
var E_IDENTITY_RATE_LIMITED = "E_IDENTITY_RATE_LIMITED";
|
|
151
|
+
var MAX_IDENTITY_RETRY_WAIT_MS = 6e4;
|
|
152
|
+
function retryAfterMsFrom(res, body) {
|
|
153
|
+
const header = res.headers?.get?.("retry-after");
|
|
154
|
+
const fromHeader = header === null || header === void 0 ? Number.NaN : Number(header);
|
|
155
|
+
if (Number.isFinite(fromHeader) && fromHeader >= 0) return fromHeader * 1e3;
|
|
156
|
+
const fromBody = typeof body?.retryAfter === "number" ? body.retryAfter : Number.NaN;
|
|
157
|
+
if (Number.isFinite(fromBody) && fromBody >= 0) return fromBody * 1e3;
|
|
158
|
+
return 6e4;
|
|
159
|
+
}
|
|
160
|
+
var sleep = (ms) => new Promise((resolve) => {
|
|
161
|
+
setTimeout(resolve, ms);
|
|
162
|
+
});
|
|
163
|
+
var Identity = class {
|
|
164
|
+
project;
|
|
165
|
+
controlUrl;
|
|
166
|
+
fetchImpl;
|
|
167
|
+
storage;
|
|
168
|
+
token;
|
|
169
|
+
accountId;
|
|
170
|
+
cached;
|
|
171
|
+
inFlight;
|
|
172
|
+
constructor(options) {
|
|
173
|
+
this.project = options.project;
|
|
174
|
+
this.controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
175
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
176
|
+
this.storage = options.storage === null ? void 0 : options.storage ?? browserStorage() ?? void 0;
|
|
177
|
+
this.token = this.load();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Reads the stored credential, adopting a pre-D61 per-project one if that is all there is.
|
|
181
|
+
*
|
|
182
|
+
* Adoption is a rename and nothing more: migration 020 gave every pre-D61 identity an account
|
|
183
|
+
* whose id is the identity's own id, so the credential that was this player on this project is
|
|
184
|
+
* already this player's account credential everywhere. Moving it to the origin-wide key is what
|
|
185
|
+
* makes the same person on the same browser one player across the games on that origin.
|
|
186
|
+
*
|
|
187
|
+
* The old key is removed after a successful adoption. If the write fails (a storage that reads
|
|
188
|
+
* but will not write), the old value is used anyway and adoption is retried next time, which is
|
|
189
|
+
* the same degrade-quietly rule the rest of this module follows.
|
|
190
|
+
*/
|
|
191
|
+
load() {
|
|
192
|
+
try {
|
|
193
|
+
const current = this.storage?.getItem(ACCOUNT_STORAGE_KEY);
|
|
194
|
+
if (typeof current === "string" && current !== "") return current;
|
|
195
|
+
} catch {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
let legacy;
|
|
199
|
+
try {
|
|
200
|
+
legacy = this.storage?.getItem(identityStorageKey(this.project));
|
|
201
|
+
} catch {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
if (typeof legacy !== "string" || legacy === "") return void 0;
|
|
205
|
+
try {
|
|
206
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, legacy);
|
|
207
|
+
this.storage?.removeItem(identityStorageKey(this.project));
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
return legacy;
|
|
211
|
+
}
|
|
212
|
+
/** The stored identity token, or `undefined` before the first mint. Never sent to a room. */
|
|
213
|
+
get stored() {
|
|
214
|
+
return this.token;
|
|
215
|
+
}
|
|
216
|
+
/** The player id the room will see, once an assertion has been fetched. */
|
|
217
|
+
get playerId() {
|
|
218
|
+
return this.cached?.playerId;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Mints an identity if this browser has none, and returns the token.
|
|
222
|
+
*
|
|
223
|
+
* A mint refused with 429 is retried ONCE, after the window the control plane named, as long as
|
|
224
|
+
* that window is short enough to wait out (`MAX_IDENTITY_RETRY_WAIT_MS`). Everything else — and
|
|
225
|
+
* a second refusal — throws, and a rate limit throws `E_IDENTITY_RATE_LIMITED` with
|
|
226
|
+
* `retryAfterMs` rather than the unreachable-control-plane code, so a game can tell "wait" from
|
|
227
|
+
* "gone" (bug #36).
|
|
228
|
+
*/
|
|
229
|
+
async ensure() {
|
|
230
|
+
if (this.token !== void 0) return this.token;
|
|
231
|
+
try {
|
|
232
|
+
return await this.mint();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (!(err instanceof IdentityError) || err.code !== E_IDENTITY_RATE_LIMITED) throw err;
|
|
235
|
+
const wait = err.retryAfterMs ?? 6e4;
|
|
236
|
+
if (wait > MAX_IDENTITY_RETRY_WAIT_MS) throw err;
|
|
237
|
+
await sleep(wait);
|
|
238
|
+
return await this.mint();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async mint() {
|
|
242
|
+
const res = await this.post("/v1/identity", { project: this.project });
|
|
243
|
+
const body = await res.json();
|
|
244
|
+
if (typeof body.identity !== "string") {
|
|
245
|
+
throw new IdentityError("E_IDENTITY_MINT_FAILED", "the control plane returned no identity");
|
|
246
|
+
}
|
|
247
|
+
this.token = body.identity;
|
|
248
|
+
this.accountId = typeof body.account === "string" ? body.account : void 0;
|
|
249
|
+
try {
|
|
250
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
return body.identity;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* A currently-valid assertion for this project, exchanging one if the cached one is gone or
|
|
257
|
+
* within thirty seconds of expiry.
|
|
258
|
+
*
|
|
259
|
+
* The margin is what keeps a long reconnect from presenting a token that expires mid-handshake.
|
|
260
|
+
* Concurrent callers share one exchange, so a burst of reconnects is one HTTP request.
|
|
261
|
+
*/
|
|
262
|
+
async assertion(now = Date.now()) {
|
|
263
|
+
const cached = this.cached;
|
|
264
|
+
if (cached && cached.expiresAtMs - 3e4 > now) return cached.assertion;
|
|
265
|
+
if (this.inFlight) return this.inFlight;
|
|
266
|
+
this.inFlight = this.exchange().finally(() => {
|
|
267
|
+
this.inFlight = void 0;
|
|
268
|
+
});
|
|
269
|
+
return this.inFlight;
|
|
270
|
+
}
|
|
271
|
+
async exchange() {
|
|
272
|
+
const identity = await this.ensure();
|
|
273
|
+
let res;
|
|
274
|
+
try {
|
|
275
|
+
res = await this.post("/v1/identity/assertion", { identity, project: this.project });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof IdentityError && err.code === "E_IDENTITY_INVALID") {
|
|
278
|
+
this.forget();
|
|
279
|
+
const fresh = await this.ensure();
|
|
280
|
+
res = await this.post("/v1/identity/assertion", { identity: fresh, project: this.project });
|
|
281
|
+
} else {
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const body = await res.json();
|
|
286
|
+
if (typeof body.assertion !== "string" || typeof body.playerId !== "string") {
|
|
287
|
+
throw new IdentityError(
|
|
288
|
+
"E_IDENTITY_EXCHANGE_FAILED",
|
|
289
|
+
"the control plane returned no assertion"
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const ttl = typeof body.expiresInMs === "number" ? body.expiresInMs : 6e4;
|
|
293
|
+
this.cached = {
|
|
294
|
+
assertion: body.assertion,
|
|
295
|
+
playerId: body.playerId,
|
|
296
|
+
expiresAtMs: Date.now() + ttl
|
|
297
|
+
};
|
|
298
|
+
return body.assertion;
|
|
299
|
+
}
|
|
300
|
+
/** Drops the stored identity. The next `ensure()` mints a new player. */
|
|
301
|
+
forget() {
|
|
302
|
+
this.token = void 0;
|
|
303
|
+
this.accountId = void 0;
|
|
304
|
+
this.cached = void 0;
|
|
305
|
+
try {
|
|
306
|
+
this.storage?.removeItem(ACCOUNT_STORAGE_KEY);
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// -------------------------------------------------------------------------
|
|
311
|
+
// D61: linking a second device, and managing the ones already on the account
|
|
312
|
+
//
|
|
313
|
+
// Every call below presents the stored device credential in the `x-irt-identity` header, to the
|
|
314
|
+
// control plane, over HTTPS, and to nothing else. That is the same rule the module's header
|
|
315
|
+
// states about the credential generally, and these methods are the reason it needs restating:
|
|
316
|
+
// they are the first surface other than the exchange that the credential is sent to at all.
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
/**
|
|
319
|
+
* Asks the control plane for a link code to read out on another device.
|
|
320
|
+
*
|
|
321
|
+
* The code is short and typable because a person carries it between two screens. Show it, do
|
|
322
|
+
* not store it, and let it expire: a code left on a screen for the ten minutes it lives is the
|
|
323
|
+
* one thing about this that a player controls.
|
|
324
|
+
*/
|
|
325
|
+
async linkCode() {
|
|
326
|
+
const res = await this.accountFetch("POST", "/v1/account/link-code", {});
|
|
327
|
+
const body = await res.json();
|
|
328
|
+
if (typeof body.code !== "string") {
|
|
329
|
+
throw new IdentityError("E_LINK_CODE_FAILED", "the control plane returned no link code");
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
code: body.code,
|
|
333
|
+
expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 10 * 6e4
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Redeems a code read off another device, and **replaces** this browser's credential with a new
|
|
338
|
+
* one on that code's account.
|
|
339
|
+
*
|
|
340
|
+
* Two things happen in that order and both matter. The new credential is stored first, so a
|
|
341
|
+
* failure between the two leaves the player linked rather than credential-less. Then whatever
|
|
342
|
+
* this browser used to be is retired on the account it is leaving.
|
|
343
|
+
*
|
|
344
|
+
* **That retirement deletes the old account when this was its only device**, rather than
|
|
345
|
+
* revoking the credential and walking away. Revoking the last device leaves an account nothing
|
|
346
|
+
* can ever authenticate as, whose board rows and saved games are then unreachable by the player
|
|
347
|
+
* and undeletable by anyone. An account with other devices only loses this one.
|
|
348
|
+
*
|
|
349
|
+
* So this call can destroy the progress held on THIS browser. Warn the player first; the docs
|
|
350
|
+
* page has wording for it. The retirement is best-effort: if it fails the link still stands,
|
|
351
|
+
* because failing a link that already worked is the worse direction.
|
|
352
|
+
*/
|
|
353
|
+
async redeemLinkCode(code) {
|
|
354
|
+
const previous = this.token;
|
|
355
|
+
let previousDeviceId;
|
|
356
|
+
let previousWasOnlyDevice = false;
|
|
357
|
+
if (previous !== void 0) {
|
|
358
|
+
try {
|
|
359
|
+
const before = await this.devices();
|
|
360
|
+
previousDeviceId = before.self;
|
|
361
|
+
previousWasOnlyDevice = before.devices.length === 1;
|
|
362
|
+
} catch {
|
|
363
|
+
previousDeviceId = void 0;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const res = await this.post("/v1/account/link", { code }, `?project=${enc(this.project)}`);
|
|
367
|
+
const body = await res.json();
|
|
368
|
+
if (typeof body.identity !== "string" || typeof body.account !== "string") {
|
|
369
|
+
throw new IdentityError("E_LINK_FAILED", "the control plane returned no linked identity");
|
|
370
|
+
}
|
|
371
|
+
this.token = body.identity;
|
|
372
|
+
this.accountId = body.account;
|
|
373
|
+
this.cached = void 0;
|
|
374
|
+
try {
|
|
375
|
+
this.storage?.setItem(ACCOUNT_STORAGE_KEY, body.identity);
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
if (previous !== void 0 && previousDeviceId !== void 0) {
|
|
379
|
+
try {
|
|
380
|
+
const url = previousWasOnlyDevice ? `${this.controlUrl}/v1/account?project=${enc(this.project)}` : `${this.controlUrl}/v1/account/devices/${enc(previousDeviceId)}?project=${enc(this.project)}`;
|
|
381
|
+
await this.fetchImpl(url, {
|
|
382
|
+
method: "DELETE",
|
|
383
|
+
headers: { "x-irt-identity": previous }
|
|
384
|
+
});
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return { account: body.account };
|
|
389
|
+
}
|
|
390
|
+
/** The devices on this account: ids and timestamps, plus which one this browser is. */
|
|
391
|
+
async devices() {
|
|
392
|
+
const res = await this.accountFetch("GET", "/v1/account/devices");
|
|
393
|
+
const body = await res.json();
|
|
394
|
+
if (typeof body.account !== "string" || !Array.isArray(body.devices)) {
|
|
395
|
+
throw new IdentityError("E_ACCOUNT_READ_FAILED", "the control plane returned no device list");
|
|
396
|
+
}
|
|
397
|
+
this.accountId = body.account;
|
|
398
|
+
return {
|
|
399
|
+
account: body.account,
|
|
400
|
+
self: typeof body.self === "string" ? body.self : "",
|
|
401
|
+
devices: body.devices
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Revokes one device on this account. Revoking the device this browser IS leaves this instance
|
|
406
|
+
* holding a credential the control plane no longer knows, so it forgets it: the next `ensure()`
|
|
407
|
+
* mints a fresh player rather than looping on a refused exchange.
|
|
408
|
+
*/
|
|
409
|
+
async revokeDevice(deviceId) {
|
|
410
|
+
let self;
|
|
411
|
+
try {
|
|
412
|
+
self = (await this.devices()).self;
|
|
413
|
+
} catch {
|
|
414
|
+
self = void 0;
|
|
415
|
+
}
|
|
416
|
+
await this.accountFetch("DELETE", `/v1/account/devices/${enc(deviceId)}`);
|
|
417
|
+
if (self !== void 0 && self === deviceId) this.forget();
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Deletes this account and everything keyed on it, in every project it played, and forgets the
|
|
421
|
+
* credential. There is no undo and the control plane does not keep a copy.
|
|
422
|
+
*/
|
|
423
|
+
async deleteAccount() {
|
|
424
|
+
await this.accountFetch("DELETE", "/v1/account");
|
|
425
|
+
this.forget();
|
|
426
|
+
}
|
|
427
|
+
/** The account id, once anything has told us what it is. Opaque; never parse it. */
|
|
428
|
+
get account() {
|
|
429
|
+
return this.accountId;
|
|
430
|
+
}
|
|
431
|
+
/** One authenticated account-route call: credential in the header, project in the query. */
|
|
432
|
+
async accountFetch(method, path, body) {
|
|
433
|
+
const identity = await this.ensure();
|
|
434
|
+
const url = `${this.controlUrl}${path}?project=${enc(this.project)}`;
|
|
435
|
+
let res;
|
|
436
|
+
try {
|
|
437
|
+
res = await this.fetchImpl(url, {
|
|
438
|
+
method,
|
|
439
|
+
headers: {
|
|
440
|
+
"content-type": "application/json",
|
|
441
|
+
// The credential goes here and nowhere else. Not in the URL: a query string reaches
|
|
442
|
+
// access logs, `Referer` headers and browser history, and this one is long-lived.
|
|
443
|
+
"x-irt-identity": identity
|
|
444
|
+
},
|
|
445
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
446
|
+
});
|
|
447
|
+
} catch (err) {
|
|
448
|
+
throw new IdentityError(
|
|
449
|
+
"E_IDENTITY_UNREACHABLE",
|
|
450
|
+
`cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (res.ok) return res;
|
|
454
|
+
throw await errorFrom(res);
|
|
455
|
+
}
|
|
456
|
+
async post(path, body, query = "") {
|
|
457
|
+
let res;
|
|
458
|
+
try {
|
|
459
|
+
res = await this.fetchImpl(`${this.controlUrl}${path}${query}`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify(body)
|
|
463
|
+
});
|
|
464
|
+
} catch (err) {
|
|
465
|
+
throw new IdentityError(
|
|
466
|
+
"E_IDENTITY_UNREACHABLE",
|
|
467
|
+
`cannot reach the control plane at ${this.controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (res.ok) return res;
|
|
471
|
+
throw await errorFrom(res);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
function enc(value) {
|
|
475
|
+
return encodeURIComponent(value);
|
|
476
|
+
}
|
|
477
|
+
async function errorFrom(res) {
|
|
478
|
+
const text = await res.text().catch(() => "");
|
|
479
|
+
let code = `E_HTTP_${res.status}`;
|
|
480
|
+
let message = text || `request failed with status ${res.status}`;
|
|
481
|
+
let parsed;
|
|
482
|
+
try {
|
|
483
|
+
parsed = JSON.parse(text);
|
|
484
|
+
if (typeof parsed.code === "string") code = parsed.code;
|
|
485
|
+
if (typeof parsed.message === "string") message = parsed.message;
|
|
486
|
+
} catch {
|
|
487
|
+
}
|
|
488
|
+
if (res.status === 429) {
|
|
489
|
+
const retryAfterMs = retryAfterMsFrom(res, parsed);
|
|
490
|
+
return new IdentityError(
|
|
491
|
+
E_IDENTITY_RATE_LIMITED,
|
|
492
|
+
`${message} (rate limited; retry in ${Math.round(retryAfterMs / 1e3)}s)`,
|
|
493
|
+
retryAfterMs
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return new IdentityError(code, message);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/messages.ts
|
|
500
|
+
import { encodeFields } from "@irtio/schema";
|
|
501
|
+
function buildMessages(schema, session) {
|
|
502
|
+
const out = {};
|
|
503
|
+
for (const desc of schema?.messages ?? []) {
|
|
504
|
+
out[desc.name] = {
|
|
505
|
+
send(target, value) {
|
|
506
|
+
session.typedMessage(desc.index, target, encodeFields(desc.fields, value));
|
|
507
|
+
},
|
|
508
|
+
on(cb) {
|
|
509
|
+
return session.onTypedMessage(desc.index, cb);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
|
|
115
516
|
// src/session.ts
|
|
116
517
|
import {
|
|
117
518
|
FrameType,
|
|
118
519
|
PROTOCOL_VERSION,
|
|
520
|
+
ProfileLedger,
|
|
119
521
|
RELAY_HASH8,
|
|
120
522
|
decodeCall,
|
|
121
523
|
decodeErrorPayload,
|
|
@@ -134,21 +536,26 @@ import {
|
|
|
134
536
|
formatError,
|
|
135
537
|
readCorrectAppliedTick,
|
|
136
538
|
readCorrectClientTick,
|
|
539
|
+
readSchemaPayload,
|
|
137
540
|
relaySchema,
|
|
138
541
|
rpcTable,
|
|
139
542
|
withBuiltins
|
|
140
543
|
} from "@irtio/protocol";
|
|
544
|
+
import { EMPTY_PROFILE, diffProfiles, scaleProfile } from "@irtio/protocol";
|
|
141
545
|
import {
|
|
142
546
|
ByteReader,
|
|
143
547
|
decodeDelta,
|
|
144
548
|
decodeDeltaFrom,
|
|
145
549
|
decodeFields,
|
|
146
|
-
encodeFields
|
|
550
|
+
encodeFields as encodeFields2,
|
|
551
|
+
schemaFromCanonical
|
|
147
552
|
} from "@irtio/schema";
|
|
148
553
|
|
|
149
554
|
// src/render.ts
|
|
150
555
|
var NUMERIC = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32", "f32", "f64"]);
|
|
151
556
|
var INTEGER = /* @__PURE__ */ new Set(["u8", "u16", "u32", "i32"]);
|
|
557
|
+
var BASE_DRIFT_MS_PER_S = 2;
|
|
558
|
+
var SAME_T_EPSILON = 0.01;
|
|
152
559
|
function cloneRecord(value) {
|
|
153
560
|
const out = {};
|
|
154
561
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -176,29 +583,61 @@ function lerpRecord(desc, a, b, alpha) {
|
|
|
176
583
|
return out;
|
|
177
584
|
}
|
|
178
585
|
var RenderStore = class {
|
|
179
|
-
constructor(ext, store, meOf, now, delayMs) {
|
|
586
|
+
constructor(ext, store, meOf, now, delayMs, intervalMs) {
|
|
180
587
|
this.ext = ext;
|
|
181
588
|
this.store = store;
|
|
182
589
|
this.meOf = meOf;
|
|
183
590
|
this.now = now;
|
|
184
591
|
this.delayMs = delayMs;
|
|
592
|
+
this.intervalMs = intervalMs;
|
|
185
593
|
for (const c of ext.collections) this.descs.set(c.name, c);
|
|
186
|
-
this.view =
|
|
594
|
+
this.view = {};
|
|
595
|
+
this.buildViewInto(this.view);
|
|
187
596
|
}
|
|
188
597
|
ext;
|
|
189
598
|
store;
|
|
190
599
|
meOf;
|
|
191
600
|
now;
|
|
192
601
|
delayMs;
|
|
602
|
+
intervalMs;
|
|
193
603
|
/** collection → id → buffered keyframes. Only interpolating entity collections have entries. */
|
|
194
604
|
buffers = /* @__PURE__ */ new Map();
|
|
195
605
|
descs = /* @__PURE__ */ new Map();
|
|
606
|
+
/** Live collection facades by name, so a D50 swap can retarget rather than replace them. */
|
|
607
|
+
facades = /* @__PURE__ */ new Map();
|
|
196
608
|
/** The object handed out as `room.render`; identity survives a resync. */
|
|
197
609
|
view;
|
|
198
610
|
/** Render reads that ran past the newest delta and held it (buffer starvation, D20). */
|
|
199
611
|
starved = 0;
|
|
200
612
|
/** D22 part 2: set when the session predicts physics bodies; render reads consult it first. */
|
|
201
613
|
predictor;
|
|
614
|
+
/**
|
|
615
|
+
* The offset between the server's tick clock and this client's `now()`: a frame for tick `n`
|
|
616
|
+
* is stamped `base + n × intervalMs`. Undefined until the first delta of a connection.
|
|
617
|
+
*/
|
|
618
|
+
base;
|
|
619
|
+
/** `now()` at the last `base` move; bounds how far the upward drift correction may go. */
|
|
620
|
+
baseAt = 0;
|
|
621
|
+
/**
|
|
622
|
+
* D50: rebuild against `newExt`, keeping `view`'s identity and every collection facade behind
|
|
623
|
+
* it, exactly as `ClientStore.swapSchema` does.
|
|
624
|
+
*
|
|
625
|
+
* Every interpolation buffer is dropped. A keyframe is a plain record whose fields were laid
|
|
626
|
+
* out by the old descriptor, and `lerpRecord` walks `desc.fields` — interpolating an old
|
|
627
|
+
* keyframe against a new one under the new field list is precisely the silent misread this
|
|
628
|
+
* whole part exists to avoid. `seedSnapshot`, called moments later off the resync WELCOME,
|
|
629
|
+
* re-seeds one keyframe per entity, which is the same state the buffers would have started
|
|
630
|
+
* from on a reconnect.
|
|
631
|
+
*/
|
|
632
|
+
swapSchema(newExt) {
|
|
633
|
+
this.ext = newExt;
|
|
634
|
+
this.descs.clear();
|
|
635
|
+
for (const c of newExt.collections) this.descs.set(c.name, c);
|
|
636
|
+
this.buffers.clear();
|
|
637
|
+
this.starved = 0;
|
|
638
|
+
this.base = void 0;
|
|
639
|
+
this.buildViewInto(this.view);
|
|
640
|
+
}
|
|
202
641
|
// -- ingest ---------------------------------------------------------------
|
|
203
642
|
/**
|
|
204
643
|
* Seeds one keyframe per existing entity from a `WELCOME` snapshot, backdated by the delay so
|
|
@@ -207,6 +646,7 @@ var RenderStore = class {
|
|
|
207
646
|
*/
|
|
208
647
|
seedSnapshot() {
|
|
209
648
|
this.buffers.clear();
|
|
649
|
+
this.base = void 0;
|
|
210
650
|
const t = this.now() - this.delayMs();
|
|
211
651
|
for (const c of this.ext.collections) {
|
|
212
652
|
if (c.kind !== "entity" || !c.interpolate) continue;
|
|
@@ -219,9 +659,39 @@ var RenderStore = class {
|
|
|
219
659
|
}
|
|
220
660
|
}
|
|
221
661
|
}
|
|
222
|
-
/**
|
|
662
|
+
/**
|
|
663
|
+
* Maps `tick` onto the client clock, or returns `undefined` when the tick interval is unknown
|
|
664
|
+
* and the caller must fall back to arrival stamping.
|
|
665
|
+
*
|
|
666
|
+
* The offset is the minimum of `now() − tick × intervalMs` over the connection: the least
|
|
667
|
+
* delayed delta seen so far is the best evidence of where the server's tick clock sits, and
|
|
668
|
+
* every later delta is that plus its own queueing delay. So a better sample is taken
|
|
669
|
+
* immediately, a worse one only bleeds in at drift speed, and a discontinuity larger than the
|
|
670
|
+
* render window (hibernation wake, a backgrounded tab's clock jump, a tick stall) is a new
|
|
671
|
+
* clock rather than a late frame and snaps.
|
|
672
|
+
*/
|
|
673
|
+
stampFor(tick) {
|
|
674
|
+
const interval = this.intervalMs();
|
|
675
|
+
if (!(interval > 0)) return void 0;
|
|
676
|
+
const now = this.now();
|
|
677
|
+
const cand = now - tick * interval;
|
|
678
|
+
if (this.base === void 0 || cand < this.base || Math.abs(cand - this.base) > this.snapMs()) {
|
|
679
|
+
this.base = cand;
|
|
680
|
+
} else {
|
|
681
|
+
this.base = Math.min(cand, this.base + (now - this.baseAt) * (BASE_DRIFT_MS_PER_S / 1e3));
|
|
682
|
+
}
|
|
683
|
+
this.baseAt = now;
|
|
684
|
+
return Math.min(this.base + tick * interval, now);
|
|
685
|
+
}
|
|
686
|
+
snapMs() {
|
|
687
|
+
return Math.max(250, 4 * this.delayMs());
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Buffers every entity a `DELTA` touched, stamped on the server's tick clock so delivery
|
|
691
|
+
* burstiness cannot modulate the drawn velocity. Call after apply.
|
|
692
|
+
*/
|
|
223
693
|
recordDelta(delta) {
|
|
224
|
-
const t = this.now();
|
|
694
|
+
const t = this.stampFor(delta.tick) ?? this.now();
|
|
225
695
|
for (const dc of delta.collections) {
|
|
226
696
|
const desc = this.descs.get(dc.name);
|
|
227
697
|
if (!desc || desc.kind !== "entity" || !desc.interpolate) continue;
|
|
@@ -236,10 +706,26 @@ var RenderStore = class {
|
|
|
236
706
|
if (value === void 0) continue;
|
|
237
707
|
const buffer = this.bufferFor(dc.name, op.id);
|
|
238
708
|
buffer.removedAt = void 0;
|
|
239
|
-
|
|
709
|
+
this.pushFrame(buffer, t, cloneRecord(value));
|
|
240
710
|
}
|
|
241
711
|
}
|
|
242
712
|
}
|
|
713
|
+
/**
|
|
714
|
+
* Appends one keyframe, keeping `frames` strictly ascending in `t` — `prune` and the lerp both
|
|
715
|
+
* depend on it. Two constraints meet here: the hybrid spatial encode sends two DELTA frames for
|
|
716
|
+
* a single tick, whose tick-derived stamps are equal, so the second one replaces rather than
|
|
717
|
+
* appends; and an offset that just snapped backwards must not stamp behind what is buffered.
|
|
718
|
+
*/
|
|
719
|
+
pushFrame(buffer, t, value) {
|
|
720
|
+
const newest = buffer.frames[buffer.frames.length - 1];
|
|
721
|
+
if (newest && t <= newest.t) {
|
|
722
|
+
if (t >= newest.t - SAME_T_EPSILON)
|
|
723
|
+
buffer.frames[buffer.frames.length - 1] = { t: newest.t, value };
|
|
724
|
+
else buffer.frames.push({ t: newest.t + SAME_T_EPSILON, value });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
buffer.frames.push({ t, value });
|
|
728
|
+
}
|
|
243
729
|
bufferFor(collection, id) {
|
|
244
730
|
let byId = this.buffers.get(collection);
|
|
245
731
|
if (!byId) {
|
|
@@ -270,7 +756,7 @@ var RenderStore = class {
|
|
|
270
756
|
if (value !== void 0) return this.predictor.read(desc, id, { ...value });
|
|
271
757
|
}
|
|
272
758
|
}
|
|
273
|
-
if (!desc.serverOwned && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
759
|
+
if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
274
760
|
return this.store.instance(desc, id);
|
|
275
761
|
}
|
|
276
762
|
if (!desc.interpolate) return this.store.instance(desc, id);
|
|
@@ -292,14 +778,68 @@ var RenderStore = class {
|
|
|
292
778
|
if (renderT > a.t) this.starved++;
|
|
293
779
|
return cloneRecord(a.value);
|
|
294
780
|
}
|
|
295
|
-
const
|
|
781
|
+
const span = b.t - a.t;
|
|
782
|
+
const alpha = span > 0 ? (renderT - a.t) / span : 1;
|
|
296
783
|
return lerpRecord(desc, a.value, b.value, Math.min(1, Math.max(0, alpha)));
|
|
297
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* M6 lane D (D71): the body-channel values the renderer is drawing for one instance right now,
|
|
787
|
+
* written into `into`. `true` when there is a pose to draw at all.
|
|
788
|
+
*
|
|
789
|
+
* This is what a kinematic proxy is moved to before every local step, and it deliberately does
|
|
790
|
+
* **not** go through `get`. `get` asks the predictor first — which would recurse, since it calls
|
|
791
|
+
* `frame()` — and for an owned or predicted instance it answers out of the local world, which is
|
|
792
|
+
* the one answer a proxy must never be given (a proxy driven by the local world is a body driving
|
|
793
|
+
* itself). So this is the D20 buffer and nothing else: the authoritative pose interpolated at
|
|
794
|
+
* `interpDelayMs` behind arrival, held at the newest delta and never extrapolated past it.
|
|
795
|
+
*
|
|
796
|
+
* Allocation-free, and only the physics channels: it runs once per proxy per frame.
|
|
797
|
+
*
|
|
798
|
+
* Two things `get` does are left out on purpose. `starved` is not counted, because that number
|
|
799
|
+
* belongs to the render path and a physics read landing in it would double it. And a buffer whose
|
|
800
|
+
* entity has aged out is not `gc`'d here, because a read that drives physics must not decide when
|
|
801
|
+
* the render path's buffers die.
|
|
802
|
+
*/
|
|
803
|
+
drawn(desc, id, into) {
|
|
804
|
+
const physics = desc.physics;
|
|
805
|
+
if (!physics) return false;
|
|
806
|
+
const coll = this.store.plain[desc.name];
|
|
807
|
+
const fill = (value) => {
|
|
808
|
+
if (value === void 0) return false;
|
|
809
|
+
for (const [, field] of physics.channels) {
|
|
810
|
+
const v = value[field];
|
|
811
|
+
if (typeof v === "number") into[field] = v;
|
|
812
|
+
}
|
|
813
|
+
return true;
|
|
814
|
+
};
|
|
815
|
+
if (!desc.interpolate) return fill(coll.get(id));
|
|
816
|
+
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
817
|
+
if (!buffer || buffer.frames.length === 0) return fill(coll.get(id));
|
|
818
|
+
const renderT = this.renderTime();
|
|
819
|
+
if (buffer.removedAt !== void 0 && renderT >= buffer.removedAt) return false;
|
|
820
|
+
this.prune(buffer, renderT);
|
|
821
|
+
const frames = buffer.frames;
|
|
822
|
+
const a = frames[0];
|
|
823
|
+
if (renderT < a.t) return false;
|
|
824
|
+
const b = frames[1];
|
|
825
|
+
if (!b) return fill(a.value);
|
|
826
|
+
const span = b.t - a.t;
|
|
827
|
+
const alpha = span > 0 ? Math.min(1, Math.max(0, (renderT - a.t) / span)) : 1;
|
|
828
|
+
for (const [, field] of physics.channels) {
|
|
829
|
+
const av = a.value[field];
|
|
830
|
+
if (typeof av !== "number") continue;
|
|
831
|
+
const bv = b.value[field];
|
|
832
|
+
into[field] = typeof bv === "number" ? av + (bv - av) * alpha : av;
|
|
833
|
+
}
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
298
836
|
/** Is `collection[id]` visible at the render clock? */
|
|
299
837
|
has(desc, id) {
|
|
300
838
|
const coll = this.store.plain[desc.name];
|
|
301
839
|
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())
|
|
840
|
+
if (!desc.serverOwned && (!desc.physics || this.predictor) && coll.has(id) && coll.ownerOf(id) === this.meOf()) {
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
303
843
|
if (!desc.interpolate) return coll.has(id);
|
|
304
844
|
const buffer = this.buffers.get(desc.name)?.get(id);
|
|
305
845
|
if (!buffer || buffer.frames.length === 0) return coll.has(id);
|
|
@@ -345,20 +885,39 @@ var RenderStore = class {
|
|
|
345
885
|
if (byId && byId.size === 0) this.buffers.delete(collection);
|
|
346
886
|
}
|
|
347
887
|
// -- view -----------------------------------------------------------------
|
|
348
|
-
|
|
349
|
-
|
|
888
|
+
/** See `ClientStore.buildViewInto`: same contract, same reasons, one object reused forever. */
|
|
889
|
+
buildViewInto(view) {
|
|
890
|
+
const names = /* @__PURE__ */ new Set();
|
|
350
891
|
for (const c of this.ext.collections) {
|
|
892
|
+
names.add(c.name);
|
|
351
893
|
if (c.kind === "entity") {
|
|
352
|
-
const
|
|
353
|
-
|
|
894
|
+
const existing = this.facades.get(c.name);
|
|
895
|
+
if (existing) {
|
|
896
|
+
existing.retarget(c);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const impl = new RenderCollectionImpl(this, c);
|
|
900
|
+
this.facades.set(c.name, impl);
|
|
901
|
+
const facade = withRenderIndexSugar(impl);
|
|
902
|
+
Object.defineProperty(view, c.name, {
|
|
903
|
+
get: () => facade,
|
|
904
|
+
enumerable: true,
|
|
905
|
+
configurable: true
|
|
906
|
+
});
|
|
354
907
|
} else {
|
|
355
908
|
Object.defineProperty(view, c.name, {
|
|
356
909
|
get: () => this.store.view[c.name],
|
|
357
|
-
enumerable: true
|
|
910
|
+
enumerable: true,
|
|
911
|
+
configurable: true
|
|
358
912
|
});
|
|
359
913
|
}
|
|
360
914
|
}
|
|
361
|
-
|
|
915
|
+
for (const name of Object.keys(view)) {
|
|
916
|
+
if (!names.has(name)) {
|
|
917
|
+
delete view[name];
|
|
918
|
+
this.facades.delete(name);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
362
921
|
}
|
|
363
922
|
};
|
|
364
923
|
var RenderCollectionImpl = class {
|
|
@@ -368,6 +927,10 @@ var RenderCollectionImpl = class {
|
|
|
368
927
|
}
|
|
369
928
|
render;
|
|
370
929
|
desc;
|
|
930
|
+
/** D50: point this facade at the same collection in a rebuilt schema. */
|
|
931
|
+
retarget(desc) {
|
|
932
|
+
this.desc = desc;
|
|
933
|
+
}
|
|
371
934
|
get(id) {
|
|
372
935
|
return this.render.get(this.desc, id);
|
|
373
936
|
}
|
|
@@ -497,6 +1060,7 @@ var webSocketTransport = {
|
|
|
497
1060
|
};
|
|
498
1061
|
|
|
499
1062
|
// src/session.ts
|
|
1063
|
+
var PROFILE_WINDOW_MS = 1e3;
|
|
500
1064
|
var DEFAULT_WRITE_INTERVAL_MS = 50;
|
|
501
1065
|
var PING_INTERVAL_MS = 2e3;
|
|
502
1066
|
var RTT_ALPHA = 0.3;
|
|
@@ -513,9 +1077,11 @@ function nameOfCode(code) {
|
|
|
513
1077
|
return "E_INTERNAL";
|
|
514
1078
|
}
|
|
515
1079
|
}
|
|
1080
|
+
var INTERNAL_SESSION = /* @__PURE__ */ Symbol("irtio.session");
|
|
516
1081
|
var Session = class {
|
|
517
1082
|
constructor(options) {
|
|
518
1083
|
this.options = options;
|
|
1084
|
+
this.schema = options.schema;
|
|
519
1085
|
this.ext = options.schema ? withBuiltins(options.schema) : relaySchema;
|
|
520
1086
|
this.rpcSchema = options.schema ?? relaySchema;
|
|
521
1087
|
this.transport = options.transport ?? webSocketTransport;
|
|
@@ -524,52 +1090,95 @@ var Session = class {
|
|
|
524
1090
|
this.impls = new Map(Object.entries(options.rpc ?? {}));
|
|
525
1091
|
this.roomId = options.roomId;
|
|
526
1092
|
this.role = options.role ?? "";
|
|
1093
|
+
this.ledger = options.profile === true ? new ProfileLedger(this.ext, this.rpcSchema) : void 0;
|
|
527
1094
|
this.store = new ClientStore(this.ext, () => this.me);
|
|
528
1095
|
this.render = new RenderStore(
|
|
529
1096
|
this.ext,
|
|
530
1097
|
this.store,
|
|
531
1098
|
() => this.me,
|
|
532
1099
|
() => this.scheduler.now(),
|
|
533
|
-
() => this.interpDelayMs
|
|
1100
|
+
() => this.interpDelayMs,
|
|
1101
|
+
() => this.tickIntervalMs
|
|
534
1102
|
);
|
|
535
1103
|
const hasPhysics = this.ext.collections.some(
|
|
536
1104
|
(c) => c.physics !== void 0
|
|
537
1105
|
);
|
|
538
|
-
|
|
539
|
-
|
|
1106
|
+
const attach = (predictor) => {
|
|
1107
|
+
if (this.left) return;
|
|
1108
|
+
this.predictor = predictor;
|
|
1109
|
+
this.render.attachPredictor(predictor);
|
|
1110
|
+
predictor.setDrawnReader((desc, id, into) => this.render.drawn(desc, id, into));
|
|
1111
|
+
if (this.joined) {
|
|
1112
|
+
predictor.reset();
|
|
1113
|
+
void predictor.start();
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
const physics = options.physics;
|
|
1117
|
+
const physics2d = options.physics2d;
|
|
1118
|
+
if (physics && hasPhysics) {
|
|
540
1119
|
this.predictionRequested = true;
|
|
541
|
-
void import("./physics-
|
|
1120
|
+
void import("./physics-RT5T36P5.js").then(({ PhysicsPredictor }) => {
|
|
542
1121
|
if (this.left) return;
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1122
|
+
attach(
|
|
1123
|
+
new PhysicsPredictor(
|
|
1124
|
+
this.ext,
|
|
1125
|
+
this.store,
|
|
1126
|
+
physics,
|
|
1127
|
+
() => this.me,
|
|
1128
|
+
() => this.rtt,
|
|
1129
|
+
() => this.tickIntervalMs
|
|
1130
|
+
)
|
|
1131
|
+
);
|
|
1132
|
+
});
|
|
1133
|
+
} else if (physics2d && hasPhysics) {
|
|
1134
|
+
this.predictionRequested = true;
|
|
1135
|
+
void import("./physics2d-HOFMWPZV.js").then(({ Physics2dPredictor }) => {
|
|
1136
|
+
if (this.left) return;
|
|
1137
|
+
attach(
|
|
1138
|
+
new Physics2dPredictor(
|
|
1139
|
+
this.ext,
|
|
1140
|
+
this.store,
|
|
1141
|
+
physics2d,
|
|
1142
|
+
() => this.me,
|
|
1143
|
+
() => this.rtt,
|
|
1144
|
+
() => this.tickIntervalMs
|
|
1145
|
+
)
|
|
550
1146
|
);
|
|
551
|
-
this.predictor = predictor;
|
|
552
|
-
this.render.attachPredictor(predictor);
|
|
553
|
-
if (this.joined) {
|
|
554
|
-
predictor.reset();
|
|
555
|
-
void predictor.start();
|
|
556
|
-
}
|
|
557
1147
|
});
|
|
558
1148
|
}
|
|
559
1149
|
}
|
|
560
1150
|
options;
|
|
1151
|
+
/**
|
|
1152
|
+
* D50: not `readonly`. `swapSchema` replaces it when an additive deploy hands this session a
|
|
1153
|
+
* new descriptor mid-flight. Everything that decodes a frame reads it through `this`, so the
|
|
1154
|
+
* single assignment below is what actually moves the session onto the new schema.
|
|
1155
|
+
*/
|
|
561
1156
|
ext;
|
|
562
1157
|
store;
|
|
563
1158
|
render;
|
|
564
|
-
/** `true` when the join passed
|
|
1159
|
+
/** `true` when the join passed an engine option and the schema has body-backed collections. */
|
|
565
1160
|
predictionRequested = false;
|
|
566
1161
|
/**
|
|
567
|
-
* Present once
|
|
568
|
-
*
|
|
1162
|
+
* Present once the engine's adapter module has loaded — `./physics.js` for `physics`,
|
|
1163
|
+
* `./physics2d.js` for `physics2d`, each behind a dynamic import, so the predictor code and its
|
|
1164
|
+
* engine cost a non-physics game (or the other engine's game) zero bytes. Reads fall back to
|
|
1165
|
+
* interpolation until then.
|
|
569
1166
|
*/
|
|
570
1167
|
predictor;
|
|
571
|
-
/** The schema the `CALL`/`REPLY` rpc id space indexes into. */
|
|
1168
|
+
/** The schema the `CALL`/`REPLY` rpc id space indexes into. D50: swappable, see `swapSchema`. */
|
|
572
1169
|
rpcSchema;
|
|
1170
|
+
/**
|
|
1171
|
+
* The BUILDER schema this session is currently on: `options.schema` at construction, then
|
|
1172
|
+
* whatever a `SCHEMA` frame replaced it with. Read instead of `options.schema` everywhere, so a
|
|
1173
|
+
* swapped session announces its *current* hash when it reconnects rather than the one it was
|
|
1174
|
+
* born with — otherwise a resume after a swap would be refused as a mismatch.
|
|
1175
|
+
*
|
|
1176
|
+
* D70 made it internally readable rather than private: `buildMessages` reads the declared
|
|
1177
|
+
* shapes off it when the room object is built. Still not on the public `Room` type.
|
|
1178
|
+
*/
|
|
1179
|
+
schema;
|
|
1180
|
+
/** D50: how many times this session has swapped schema. Test seam and a diagnostic. */
|
|
1181
|
+
schemaSwaps = 0;
|
|
573
1182
|
transport;
|
|
574
1183
|
scheduler;
|
|
575
1184
|
writeIntervalMs;
|
|
@@ -587,7 +1196,10 @@ var Session = class {
|
|
|
587
1196
|
*/
|
|
588
1197
|
writeTick = 0;
|
|
589
1198
|
/** The room's tick interval from `WELCOME`, or 0 when unknown (relay / pre-week-8 server). */
|
|
1199
|
+
/** Milliseconds per tick, derived from the rate in `WELCOME`. 0 when the room has no tick. */
|
|
590
1200
|
tickIntervalMs = 0;
|
|
1201
|
+
/** The room's client cap from `WELCOME`, or 0 when unknown (relay / pre-this-field server). */
|
|
1202
|
+
maxClients = 0;
|
|
591
1203
|
rtt = 0;
|
|
592
1204
|
status = "connecting";
|
|
593
1205
|
socket;
|
|
@@ -612,6 +1224,34 @@ var Session = class {
|
|
|
612
1224
|
pending = /* @__PURE__ */ new Map();
|
|
613
1225
|
listeners = /* @__PURE__ */ new Map();
|
|
614
1226
|
messageListeners = /* @__PURE__ */ new Set();
|
|
1227
|
+
/**
|
|
1228
|
+
* D51: voice signaling listeners, kept in their own set rather than sharing `messageListeners`.
|
|
1229
|
+
*
|
|
1230
|
+
* The isolation clause has a client half as well as a server half. The supervisor guarantees that
|
|
1231
|
+
* a room handler never observes `{ kind: 'voice' }`; this set is what guarantees the same for a
|
|
1232
|
+
* *game*, which holds the other end of the same socket and whose `room.onMessage` would otherwise
|
|
1233
|
+
* receive every transport parameter and DTLS fingerprint in the call. Splitting the sets makes
|
|
1234
|
+
* the property structural — there is no `!== 'voice'` filter to forget somewhere in the fan-out,
|
|
1235
|
+
* and no ordering between two callbacks to get wrong.
|
|
1236
|
+
*
|
|
1237
|
+
* Nothing on the public `Room` type can reach this set. `joinVoice` gets at it through
|
|
1238
|
+
* `INTERNAL_SESSION`, the same route the D50 tests use for the session itself.
|
|
1239
|
+
*/
|
|
1240
|
+
voiceListeners = /* @__PURE__ */ new Set();
|
|
1241
|
+
/**
|
|
1242
|
+
* D70: typed-message listeners, by wire index, in their own map for the same structural reason
|
|
1243
|
+
* `voiceListeners` is its own set: a raw `onMessage` callback must never be handed a typed
|
|
1244
|
+
* frame and a typed callback must never be handed raw bytes. Splitting the registries makes
|
|
1245
|
+
* that a fact about the shape of the code rather than a filter somewhere in the fan-out.
|
|
1246
|
+
*/
|
|
1247
|
+
typedListeners = /* @__PURE__ */ new Map();
|
|
1248
|
+
/** D70: one warning per session for dropped typed messages — a flood must stay one line. */
|
|
1249
|
+
typedDropWarned = false;
|
|
1250
|
+
/**
|
|
1251
|
+
* D70: what `room.stats.messages` reads. `dropped` is the one worth watching: it counts typed
|
|
1252
|
+
* frames this client could not decode, which is a peer on a schema this one does not have.
|
|
1253
|
+
*/
|
|
1254
|
+
messageCounts = { sent: 0, received: 0, dropped: 0 };
|
|
615
1255
|
cancelFlush;
|
|
616
1256
|
cancelPing;
|
|
617
1257
|
cancelRetry;
|
|
@@ -680,6 +1320,19 @@ var Session = class {
|
|
|
680
1320
|
}
|
|
681
1321
|
async sendHello(socket) {
|
|
682
1322
|
let token;
|
|
1323
|
+
let assertion;
|
|
1324
|
+
const suppliedAssertion = this.options.assertion;
|
|
1325
|
+
if (suppliedAssertion !== void 0) {
|
|
1326
|
+
try {
|
|
1327
|
+
assertion = await suppliedAssertion();
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
this.transportFailed(
|
|
1330
|
+
err instanceof Error ? err : new Error(`identity provider failed: ${String(err)}`)
|
|
1331
|
+
);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
1335
|
+
}
|
|
683
1336
|
const supplied = this.options.token;
|
|
684
1337
|
if (supplied !== void 0) {
|
|
685
1338
|
try {
|
|
@@ -692,15 +1345,20 @@ var Session = class {
|
|
|
692
1345
|
}
|
|
693
1346
|
if (this.socket !== socket || !this.socketOpen || this.left) return;
|
|
694
1347
|
}
|
|
695
|
-
const hash8 = this.
|
|
1348
|
+
const hash8 = this.schema ? this.schema.hash8 : RELAY_HASH8;
|
|
696
1349
|
const hello = encodeHello({
|
|
697
1350
|
protocolVersion: PROTOCOL_VERSION,
|
|
698
|
-
credential: token !== void 0 ? { kind: "jwt", key: this.options.key, token } : { kind: "key", key: this.options.key },
|
|
1351
|
+
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
1352
|
roomId: this.roomId,
|
|
700
1353
|
schemaHash8: hash8,
|
|
701
1354
|
...this.resumeToken !== void 0 ? { resumeToken: this.resumeToken } : {},
|
|
702
1355
|
...this.options.role !== void 0 ? { role: this.options.role } : {},
|
|
703
|
-
...this.options.name !== void 0 ? { name: this.options.name } : {}
|
|
1356
|
+
...this.options.name !== void 0 ? { name: this.options.name } : {},
|
|
1357
|
+
// D50: this client can rebuild its codec from a SCHEMA frame. Announced unconditionally for
|
|
1358
|
+
// a schema'd session — there is nothing to opt out of, the server still decides, and a
|
|
1359
|
+
// server that predates the bit ignores it (it reads the mask it knows and stops).
|
|
1360
|
+
// A relay session has no schema to swap, so it does not ask.
|
|
1361
|
+
...this.schema !== void 0 ? { schemaSwap: true } : {}
|
|
704
1362
|
});
|
|
705
1363
|
this.send(FrameType.HELLO, hello);
|
|
706
1364
|
}
|
|
@@ -765,10 +1423,43 @@ var Session = class {
|
|
|
765
1423
|
// -------------------------------------------------------------------------
|
|
766
1424
|
// Frames
|
|
767
1425
|
// -------------------------------------------------------------------------
|
|
1426
|
+
/**
|
|
1427
|
+
* D65: the session's bandwidth ledger, present only when the join asked for one. A second
|
|
1428
|
+
* consumer of the same seam `onFrame` uses (the bots' observer is the first), so the two
|
|
1429
|
+
* compose rather than compete.
|
|
1430
|
+
*/
|
|
1431
|
+
ledger;
|
|
1432
|
+
/** D65: the rolling window `room.profile.perSecond()` reads, advanced on the session clock. */
|
|
1433
|
+
profileWindow;
|
|
1434
|
+
/**
|
|
1435
|
+
* D65: bytes per second over a rolling window of about a second, measured on the scheduler's
|
|
1436
|
+
* clock rather than `Date.now()` so a test with a fake clock gets a deterministic answer.
|
|
1437
|
+
*
|
|
1438
|
+
* The window advances only when somebody reads it: a reader at 1 Hz (the overlay) gets a
|
|
1439
|
+
* one-second window, and a reader that never calls costs nothing. A read sooner than a second
|
|
1440
|
+
* after the last one is extrapolated from the partial window rather than returning zeros.
|
|
1441
|
+
*/
|
|
1442
|
+
profilePerSecond() {
|
|
1443
|
+
const ledger = this.ledger;
|
|
1444
|
+
if (!ledger) return EMPTY_PROFILE;
|
|
1445
|
+
const now = this.scheduler.now();
|
|
1446
|
+
const snap = ledger.snapshot();
|
|
1447
|
+
const previous = this.profileWindow;
|
|
1448
|
+
if (!previous) {
|
|
1449
|
+
this.profileWindow = { at: now, snap };
|
|
1450
|
+
return EMPTY_PROFILE;
|
|
1451
|
+
}
|
|
1452
|
+
const elapsed = now - previous.at;
|
|
1453
|
+
if (elapsed <= 0) return EMPTY_PROFILE;
|
|
1454
|
+
const window = diffProfiles(previous.snap, snap);
|
|
1455
|
+
if (elapsed >= PROFILE_WINDOW_MS) this.profileWindow = { at: now, snap };
|
|
1456
|
+
return scaleProfile(window, elapsed / PROFILE_WINDOW_MS);
|
|
1457
|
+
}
|
|
768
1458
|
send(type, payload) {
|
|
769
1459
|
if (!this.socketOpen || !this.socket) return;
|
|
770
1460
|
const frame = encodeFrame(type, payload);
|
|
771
1461
|
this.options.onFrame?.("out", type, frame);
|
|
1462
|
+
this.ledger?.attribute("out", frame, { spatialChurn: true });
|
|
772
1463
|
this.socket.send(frame);
|
|
773
1464
|
}
|
|
774
1465
|
onFrame(bytes) {
|
|
@@ -783,6 +1474,7 @@ var Session = class {
|
|
|
783
1474
|
return;
|
|
784
1475
|
}
|
|
785
1476
|
this.options.onFrame?.("in", type, bytes);
|
|
1477
|
+
this.ledger?.attribute("in", bytes, { spatialChurn: true });
|
|
786
1478
|
try {
|
|
787
1479
|
switch (type) {
|
|
788
1480
|
case FrameType.WELCOME:
|
|
@@ -806,6 +1498,9 @@ var Session = class {
|
|
|
806
1498
|
case FrameType.REPLY:
|
|
807
1499
|
this.onReply(payload);
|
|
808
1500
|
return;
|
|
1501
|
+
case FrameType.SCHEMA:
|
|
1502
|
+
this.onSchema(payload);
|
|
1503
|
+
return;
|
|
809
1504
|
default:
|
|
810
1505
|
if (type === FrameType.MSG) this.onMsg(payload);
|
|
811
1506
|
return;
|
|
@@ -822,6 +1517,72 @@ var Session = class {
|
|
|
822
1517
|
fatal: false
|
|
823
1518
|
});
|
|
824
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* D50: a `SCHEMA` frame landed. The server sends one during an additive `migrate` deploy, after
|
|
1522
|
+
* the old worker has stopped sending and before the resync WELCOME, so this session can stay
|
|
1523
|
+
* open across a deploy instead of being closed with `E_SCHEMA_MISMATCH`.
|
|
1524
|
+
*
|
|
1525
|
+
* A decode failure here is reported as a non-fatal error and the swap is abandoned. That leaves
|
|
1526
|
+
* the session on the old schema with a resync WELCOME about to arrive under the new one, which
|
|
1527
|
+
* it will fail to decode — noisy, but noisy is the correct failure: the alternative is decoding
|
|
1528
|
+
* it anyway under the wrong descriptor, and a misdecode is silent.
|
|
1529
|
+
*/
|
|
1530
|
+
onSchema(payload) {
|
|
1531
|
+
const canonical = readSchemaPayload(payload);
|
|
1532
|
+
this.swapSchema(canonical);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* The one entry point for a mid-session schema change. Internal: not part of the public client
|
|
1536
|
+
* API this release, and not called from anywhere but `onSchema`.
|
|
1537
|
+
*
|
|
1538
|
+
* **What is rebuilt.** Every schema-derived handle in the client, top down:
|
|
1539
|
+
*
|
|
1540
|
+
* - `schema` / `ext` / `rpcSchema` here, which is what every `decodeDelta`, `encodeDelta`,
|
|
1541
|
+
* `decodeSnapshot` and rpc-id lookup reads through;
|
|
1542
|
+
* - `descsByName`, the correction path's collection index, invalidated so it re-derives;
|
|
1543
|
+
* - `ClientStore`: `ext`, its descriptor map, `plain`, `tracked`, and the per-collection
|
|
1544
|
+
* facades behind `room.state` — retargeted, not replaced;
|
|
1545
|
+
* - `RenderStore`: the same, plus every interpolation buffer;
|
|
1546
|
+
* - `PhysicsPredictor`, when one exists: its predicted-collection list and its live bodies.
|
|
1547
|
+
*
|
|
1548
|
+
* **What is dropped, and why** (part 4 plan §1.3). Everything below is state that was laid out
|
|
1549
|
+
* by the old descriptors and has no correct reading under the new ones. The resync WELCOME
|
|
1550
|
+
* arriving immediately after re-establishes all of it, which is what makes dropping cheap:
|
|
1551
|
+
*
|
|
1552
|
+
* - the authoritative state (`plain`) and its tracked proxy tree — re-seeded by `loadSnapshot`;
|
|
1553
|
+
* - flushed-but-unjudged writes, the evicted-tick watermark and the baseline intents — a
|
|
1554
|
+
* resync already discards these (see `ClientStore.loadSnapshot`), and their field names are
|
|
1555
|
+
* indexed against descriptors that no longer exist;
|
|
1556
|
+
* - every render interpolation keyframe — `lerpRecord` walks `desc.fields`, so mixing a
|
|
1557
|
+
* pre-swap keyframe with a post-swap one is a silent misread. `seedSnapshot` re-seeds from
|
|
1558
|
+
* the WELCOME;
|
|
1559
|
+
* - every predicted body and its pose ring — `PhysicsPredictor.reset()`'s existing job.
|
|
1560
|
+
*
|
|
1561
|
+
* **What is deliberately NOT dropped: in-flight calls** (§1.2, the default rule). A `PendingCall`
|
|
1562
|
+
* captured its `returns` descriptor at the moment it was issued, and the REPLY it is waiting for
|
|
1563
|
+
* was produced by a room that had those params in hand. So a call issued before the swap keeps
|
|
1564
|
+
* decoding its reply under the schema it was issued with, and resolves normally afterwards. This
|
|
1565
|
+
* costs nothing to implement — the descriptor is already captured per call rather than looked up
|
|
1566
|
+
* at reply time — and it is the honest semantics: the call did happen, under the old contract.
|
|
1567
|
+
*
|
|
1568
|
+
* The narrow case this leaves is a REPLY whose *shape* changed additively between the two
|
|
1569
|
+
* schemas. Decoding it under the old `returns` reads the fields the caller asked for and stops,
|
|
1570
|
+
* which is exactly what an appended field means. A breaking change to an RPC never reaches here:
|
|
1571
|
+
* it classifies breaking and the session is closed instead.
|
|
1572
|
+
*/
|
|
1573
|
+
swapSchema(canonical) {
|
|
1574
|
+
const next = schemaFromCanonical(canonical);
|
|
1575
|
+
if (this.schema && next.hash === this.schema.hash) return;
|
|
1576
|
+
this.schema = next;
|
|
1577
|
+
this.ext = withBuiltins(next);
|
|
1578
|
+
this.rpcSchema = next;
|
|
1579
|
+
this.descsByName = void 0;
|
|
1580
|
+
this.store.swapSchema(this.ext);
|
|
1581
|
+
this.render.swapSchema(this.ext);
|
|
1582
|
+
this.ledger?.swapSchema(this.ext, this.rpcSchema);
|
|
1583
|
+
this.predictor?.swapSchema(this.ext);
|
|
1584
|
+
this.schemaSwaps++;
|
|
1585
|
+
}
|
|
825
1586
|
onWelcome(payload) {
|
|
826
1587
|
const welcome = decodeWelcome(payload);
|
|
827
1588
|
const pending = this.store.capturePendingWrites();
|
|
@@ -829,11 +1590,13 @@ var Session = class {
|
|
|
829
1590
|
this.role = welcome.role;
|
|
830
1591
|
this.tick = welcome.tick;
|
|
831
1592
|
this.resumeToken = welcome.resumeToken;
|
|
832
|
-
this.tickIntervalMs = welcome.
|
|
1593
|
+
this.tickIntervalMs = welcome.tickRate > 0 ? 1e3 / welcome.tickRate : 0;
|
|
1594
|
+
this.maxClients = welcome.maxClients;
|
|
833
1595
|
if (welcome.roomId !== "") this.roomId = welcome.roomId;
|
|
834
1596
|
this.store.loadSnapshot(welcome.snapshot);
|
|
835
1597
|
this.store.applyPendingWrites(pending);
|
|
836
1598
|
this.render.seedSnapshot();
|
|
1599
|
+
this.emit("clients", this.clients);
|
|
837
1600
|
if (this.predictor) {
|
|
838
1601
|
this.predictor.reset();
|
|
839
1602
|
void this.predictor.start();
|
|
@@ -859,6 +1622,9 @@ var Session = class {
|
|
|
859
1622
|
this.predictor.noteAuthority(delta.tick);
|
|
860
1623
|
this.predictor.frame(this.scheduler.now());
|
|
861
1624
|
}
|
|
1625
|
+
if (delta.collections.some((dc) => dc.name === "clients")) {
|
|
1626
|
+
this.emit("clients", this.clients);
|
|
1627
|
+
}
|
|
862
1628
|
}
|
|
863
1629
|
onCorrect(payload) {
|
|
864
1630
|
const r = new ByteReader(payload);
|
|
@@ -897,6 +1663,9 @@ var Session = class {
|
|
|
897
1663
|
this.predictor.noteAuthority(delta.tick);
|
|
898
1664
|
this.predictor.frame(this.scheduler.now());
|
|
899
1665
|
}
|
|
1666
|
+
if (delta.collections.some((dc) => dc.name === "clients")) {
|
|
1667
|
+
this.emit("clients", this.clients);
|
|
1668
|
+
}
|
|
900
1669
|
}
|
|
901
1670
|
descsByName;
|
|
902
1671
|
descOfCollection(name) {
|
|
@@ -942,14 +1711,66 @@ var Session = class {
|
|
|
942
1711
|
const pong = decodePong(payload);
|
|
943
1712
|
const sample = Math.max(1, (this.scheduler.now() >>> 0) - pong.t >>> 0);
|
|
944
1713
|
this.rtt = this.rtt === 0 ? sample : Math.max(1, Math.round(this.rtt + (sample - this.rtt) * RTT_ALPHA));
|
|
1714
|
+
this.emit("rtt", this.rtt);
|
|
945
1715
|
if (pong.serverTick > this.tick) this.tick = pong.serverTick;
|
|
946
1716
|
}
|
|
947
1717
|
onMsg(payload) {
|
|
948
|
-
|
|
949
|
-
|
|
1718
|
+
let msg;
|
|
1719
|
+
try {
|
|
1720
|
+
msg = decodeMsg(payload);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
this.dropTypedMessage(err);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
950
1725
|
const bytes = msg.payload.slice();
|
|
1726
|
+
if (msg.target.kind === "voice") {
|
|
1727
|
+
for (const cb of [...this.voiceListeners]) cb(bytes);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
const from = msg.target.kind === "client" ? msg.target.clientId : "server";
|
|
1731
|
+
if (msg.typed) {
|
|
1732
|
+
this.deliverTyped(msg.typed.index, from, bytes);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
this.messageCounts.received++;
|
|
951
1736
|
for (const cb of [...this.messageListeners]) cb(from, bytes);
|
|
952
1737
|
}
|
|
1738
|
+
/**
|
|
1739
|
+
* D70: decode one typed payload against the current schema and hand it to that shape's
|
|
1740
|
+
* listeners.
|
|
1741
|
+
*
|
|
1742
|
+
* Everything a hostile peer can do here ends in the same place: a counter and a dropped frame.
|
|
1743
|
+
* An index past the schema's list, a truncated payload, an oversize `str`, an over-max `list`,
|
|
1744
|
+
* an unknown enum member, or a well-formed value of the wrong shape — each is caught, none
|
|
1745
|
+
* throws out of the frame loop, none closes the socket, and the next frame is delivered
|
|
1746
|
+
* normally. The `console.warn` fires once per session so a flood stays one line.
|
|
1747
|
+
*/
|
|
1748
|
+
deliverTyped(index, from, bytes) {
|
|
1749
|
+
const desc = this.schema?.messages?.[index];
|
|
1750
|
+
if (!desc) {
|
|
1751
|
+
this.dropTypedMessage(new Error(`no message with index ${index} in this schema`));
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
let value;
|
|
1755
|
+
try {
|
|
1756
|
+
value = decodeFields(desc.fields, bytes);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
this.dropTypedMessage(err);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
this.messageCounts.received++;
|
|
1762
|
+
const set = this.typedListeners.get(index);
|
|
1763
|
+
if (!set) return;
|
|
1764
|
+
for (const cb of [...set]) cb(from, value);
|
|
1765
|
+
}
|
|
1766
|
+
dropTypedMessage(err) {
|
|
1767
|
+
this.messageCounts.dropped++;
|
|
1768
|
+
if (this.typedDropWarned) return;
|
|
1769
|
+
this.typedDropWarned = true;
|
|
1770
|
+
console.warn(
|
|
1771
|
+
`irtio: dropped a typed message this client could not read (${err instanceof Error ? err.message : String(err)}). Read room.stats.messages.dropped for the count; further drops are silent.`
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
953
1774
|
// -------------------------------------------------------------------------
|
|
954
1775
|
// Timers
|
|
955
1776
|
// -------------------------------------------------------------------------
|
|
@@ -1020,7 +1841,7 @@ var Session = class {
|
|
|
1020
1841
|
let encoded;
|
|
1021
1842
|
try {
|
|
1022
1843
|
desc = this.descOf(name);
|
|
1023
|
-
encoded =
|
|
1844
|
+
encoded = encodeFields2(desc.params, params);
|
|
1024
1845
|
} catch (err) {
|
|
1025
1846
|
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
1026
1847
|
}
|
|
@@ -1041,7 +1862,10 @@ var Session = class {
|
|
|
1041
1862
|
reject,
|
|
1042
1863
|
cancelTimeout
|
|
1043
1864
|
});
|
|
1044
|
-
this.send(
|
|
1865
|
+
this.send(
|
|
1866
|
+
FrameType.CALL,
|
|
1867
|
+
encodeCall({ reqId, rpcId: desc.index, clientTick: this.tick, params: encoded })
|
|
1868
|
+
);
|
|
1045
1869
|
});
|
|
1046
1870
|
}
|
|
1047
1871
|
async requestOwnership(entity, id) {
|
|
@@ -1115,7 +1939,7 @@ var Session = class {
|
|
|
1115
1939
|
replyOk(desc, reqId, result) {
|
|
1116
1940
|
let bytes;
|
|
1117
1941
|
try {
|
|
1118
|
-
bytes = desc.returns ?
|
|
1942
|
+
bytes = desc.returns ? encodeFields2(desc.returns, result ?? {}) : new Uint8Array(0);
|
|
1119
1943
|
} catch (err) {
|
|
1120
1944
|
this.replyError(reqId, err instanceof Error ? err.message : String(err));
|
|
1121
1945
|
return;
|
|
@@ -1137,6 +1961,7 @@ var Session = class {
|
|
|
1137
1961
|
// -------------------------------------------------------------------------
|
|
1138
1962
|
message(target, bytes) {
|
|
1139
1963
|
const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
1964
|
+
this.messageCounts.sent++;
|
|
1140
1965
|
this.send(FrameType.MSG, encodeMsg({ target: wire, payload: bytes }));
|
|
1141
1966
|
}
|
|
1142
1967
|
onMessage(cb) {
|
|
@@ -1145,6 +1970,46 @@ var Session = class {
|
|
|
1145
1970
|
this.messageListeners.delete(cb);
|
|
1146
1971
|
};
|
|
1147
1972
|
}
|
|
1973
|
+
/**
|
|
1974
|
+
* D70: send one typed message. `payload` is already `encodeFields`'d by the caller
|
|
1975
|
+
* (`buildMessages`), which is where the shape and the throw on a bad value belong.
|
|
1976
|
+
*/
|
|
1977
|
+
typedMessage(index, target, payload) {
|
|
1978
|
+
const wire = target === "all" ? { kind: "all" } : typeof target === "string" ? { kind: "client", clientId: target } : { kind: "role", role: target.role };
|
|
1979
|
+
this.messageCounts.sent++;
|
|
1980
|
+
this.send(FrameType.MSG, encodeMsg({ target: wire, payload, typed: { index } }));
|
|
1981
|
+
}
|
|
1982
|
+
/** D70: subscribe to one message shape by wire index. Raw listeners never see these frames. */
|
|
1983
|
+
onTypedMessage(index, cb) {
|
|
1984
|
+
let set = this.typedListeners.get(index);
|
|
1985
|
+
if (!set) {
|
|
1986
|
+
set = /* @__PURE__ */ new Set();
|
|
1987
|
+
this.typedListeners.set(index, set);
|
|
1988
|
+
}
|
|
1989
|
+
set.add(cb);
|
|
1990
|
+
return () => {
|
|
1991
|
+
set?.delete(cb);
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* D51: write one voice signaling message. Internal — reached only through `INTERNAL_SESSION`,
|
|
1996
|
+
* so it is not on the `Room` type and a game cannot call it.
|
|
1997
|
+
*
|
|
1998
|
+
* This deliberately does NOT go through `message()`. `MessageTarget` has no voice member on
|
|
1999
|
+
* purpose (that is the third of the three isolation mechanisms), and widening it so that this
|
|
2000
|
+
* method could share the mapping would delete the mechanism to save four lines. Building the
|
|
2001
|
+
* `MsgTarget` here keeps `{ kind: 'voice' }` unrepresentable from anywhere a game can reach.
|
|
2002
|
+
*/
|
|
2003
|
+
sendVoice(bytes) {
|
|
2004
|
+
this.send(FrameType.MSG, encodeMsg({ target: { kind: "voice" }, payload: bytes }));
|
|
2005
|
+
}
|
|
2006
|
+
/** D51: subscribe to voice signaling replies. Internal, for the same reason as `sendVoice`. */
|
|
2007
|
+
onVoice(cb) {
|
|
2008
|
+
this.voiceListeners.add(cb);
|
|
2009
|
+
return () => {
|
|
2010
|
+
this.voiceListeners.delete(cb);
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
1148
2013
|
get clients() {
|
|
1149
2014
|
const coll = this.store.plain.clients;
|
|
1150
2015
|
if (!coll) return [];
|
|
@@ -1187,6 +2052,458 @@ function resolveKey(explicit, schema, url) {
|
|
|
1187
2052
|
);
|
|
1188
2053
|
}
|
|
1189
2054
|
|
|
2055
|
+
// src/match.ts
|
|
2056
|
+
var MatchError = class extends Error {
|
|
2057
|
+
constructor(code, message) {
|
|
2058
|
+
super(message);
|
|
2059
|
+
this.code = code;
|
|
2060
|
+
}
|
|
2061
|
+
code;
|
|
2062
|
+
name = "MatchError";
|
|
2063
|
+
};
|
|
2064
|
+
async function findMatch(project, options = {}) {
|
|
2065
|
+
const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
2066
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
2067
|
+
let res;
|
|
2068
|
+
try {
|
|
2069
|
+
res = await fetchImpl(`${controlUrl}/match`, {
|
|
2070
|
+
method: "POST",
|
|
2071
|
+
headers: { "content-type": "application/json" },
|
|
2072
|
+
body: JSON.stringify({
|
|
2073
|
+
project,
|
|
2074
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2075
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
2076
|
+
...options.identity !== void 0 ? { identity: options.identity } : {},
|
|
2077
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {}
|
|
2078
|
+
})
|
|
2079
|
+
});
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
throw new MatchError(
|
|
2082
|
+
"E_MATCH_UNREACHABLE",
|
|
2083
|
+
`cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
const text = await res.text().catch(() => "");
|
|
2087
|
+
let body = {};
|
|
2088
|
+
try {
|
|
2089
|
+
body = JSON.parse(text);
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
if (!res.ok) {
|
|
2093
|
+
throw new MatchError(
|
|
2094
|
+
typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
|
|
2095
|
+
typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
if (body.status === "timeout") {
|
|
2099
|
+
throw new MatchError(
|
|
2100
|
+
"E_NO_MATCH",
|
|
2101
|
+
`no match in the ${String(body.queue)} queue: ${String(body.waiting)} of ${String(
|
|
2102
|
+
body.size
|
|
2103
|
+
)} players were waiting. Try again, or bring a friend.`
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
if (body.status !== "matched" || typeof body.room !== "string") {
|
|
2107
|
+
throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
|
|
2108
|
+
}
|
|
2109
|
+
return {
|
|
2110
|
+
room: body.room,
|
|
2111
|
+
queue: typeof body.queue === "string" ? body.queue : "default",
|
|
2112
|
+
seat: typeof body.seat === "number" ? body.seat : 0,
|
|
2113
|
+
size: typeof body.size === "number" ? body.size : 0,
|
|
2114
|
+
backfill: body.backfill === true
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
async function createParty(project, options) {
|
|
2118
|
+
const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
2119
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
2120
|
+
let res;
|
|
2121
|
+
try {
|
|
2122
|
+
res = await fetchImpl(`${controlUrl}/match/party`, {
|
|
2123
|
+
method: "POST",
|
|
2124
|
+
headers: { "content-type": "application/json" },
|
|
2125
|
+
body: JSON.stringify({ project, size: options.size })
|
|
2126
|
+
});
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
throw new MatchError(
|
|
2129
|
+
"E_MATCH_UNREACHABLE",
|
|
2130
|
+
`cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
const text = await res.text().catch(() => "");
|
|
2134
|
+
let body = {};
|
|
2135
|
+
try {
|
|
2136
|
+
body = JSON.parse(text);
|
|
2137
|
+
} catch {
|
|
2138
|
+
}
|
|
2139
|
+
if (!res.ok) {
|
|
2140
|
+
throw new MatchError(
|
|
2141
|
+
typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
|
|
2142
|
+
typeof body.message === "string" ? body.message : `party failed with status ${res.status}`
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
if (typeof body.party !== "string") {
|
|
2146
|
+
throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
|
|
2147
|
+
}
|
|
2148
|
+
return {
|
|
2149
|
+
party: body.party,
|
|
2150
|
+
expiresInMs: typeof body.expiresInMs === "number" ? body.expiresInMs : 0
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
async function matchRoom(schema, options = {}) {
|
|
2154
|
+
const project = schema.project;
|
|
2155
|
+
const key = options.key ?? (typeof project === "string" ? project : void 0);
|
|
2156
|
+
if (key === void 0 || key === "") {
|
|
2157
|
+
throw new MatchError(
|
|
2158
|
+
"E_MATCH_NO_PROJECT",
|
|
2159
|
+
"matchRoom needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
const identity = options.identity === true ? new Identity({
|
|
2163
|
+
project: key,
|
|
2164
|
+
controlUrl: options.controlUrl,
|
|
2165
|
+
fetch: options.fetch
|
|
2166
|
+
}) : void 0;
|
|
2167
|
+
const ticket = await findMatch(key, {
|
|
2168
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2169
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
2170
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
2171
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
2172
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
2173
|
+
...identity !== void 0 ? { identity: await identity.ensure() } : {}
|
|
2174
|
+
});
|
|
2175
|
+
try {
|
|
2176
|
+
return await joinRoom(schema, { ...options, room: ticket.room });
|
|
2177
|
+
} catch (err) {
|
|
2178
|
+
if (!ticket.backfill || !isRoomFull(err)) throw err;
|
|
2179
|
+
const second = await findMatch(key, {
|
|
2180
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2181
|
+
...options.party !== void 0 ? { party: options.party } : {},
|
|
2182
|
+
...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
|
|
2183
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
2184
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
2185
|
+
...identity !== void 0 ? { identity: await identity.ensure() } : {}
|
|
2186
|
+
});
|
|
2187
|
+
return joinRoom(schema, { ...options, room: second.room });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
function isRoomFull(err) {
|
|
2191
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2192
|
+
return message.startsWith("E_ROOM_FULL");
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// src/voice.ts
|
|
2196
|
+
import {
|
|
2197
|
+
decodeVoiceMessage,
|
|
2198
|
+
encodeVoiceMessage
|
|
2199
|
+
} from "@irtio/protocol";
|
|
2200
|
+
var INTERNAL_VOICE_TRACKS = /* @__PURE__ */ Symbol.for("irtio.voice.tracks");
|
|
2201
|
+
function browserFactory() {
|
|
2202
|
+
return {
|
|
2203
|
+
createDevice: async () => {
|
|
2204
|
+
const mod = await import("mediasoup-client");
|
|
2205
|
+
return new mod.Device();
|
|
2206
|
+
},
|
|
2207
|
+
getMicTrack: async () => {
|
|
2208
|
+
const media = globalThis.navigator?.mediaDevices;
|
|
2209
|
+
if (!media?.getUserMedia) {
|
|
2210
|
+
throw new Error("irtio voice: getUserMedia is unavailable (needs a secure context)");
|
|
2211
|
+
}
|
|
2212
|
+
const stream = await media.getUserMedia({
|
|
2213
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
2214
|
+
video: false
|
|
2215
|
+
});
|
|
2216
|
+
const track = stream.getAudioTracks()[0];
|
|
2217
|
+
if (!track) throw new Error("irtio voice: no audio track from getUserMedia");
|
|
2218
|
+
return track;
|
|
2219
|
+
},
|
|
2220
|
+
attach: (peerId, track) => {
|
|
2221
|
+
const doc = globalThis.document;
|
|
2222
|
+
if (!doc) {
|
|
2223
|
+
return {
|
|
2224
|
+
detach: () => track.stop(),
|
|
2225
|
+
setVolume: () => void 0,
|
|
2226
|
+
setMuted: () => void 0
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
const el = doc.createElement("audio");
|
|
2230
|
+
el.autoplay = true;
|
|
2231
|
+
el.setAttribute("playsinline", "");
|
|
2232
|
+
el.dataset.irtioVoicePeer = peerId;
|
|
2233
|
+
el.style.display = "none";
|
|
2234
|
+
el.srcObject = new MediaStream([track]);
|
|
2235
|
+
doc.body.appendChild(el);
|
|
2236
|
+
return {
|
|
2237
|
+
detach: () => {
|
|
2238
|
+
el.srcObject = null;
|
|
2239
|
+
el.remove();
|
|
2240
|
+
track.stop();
|
|
2241
|
+
},
|
|
2242
|
+
setVolume: (volume) => {
|
|
2243
|
+
el.volume = volume;
|
|
2244
|
+
},
|
|
2245
|
+
setMuted: (muted) => {
|
|
2246
|
+
el.muted = muted;
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
function asPlayback(result) {
|
|
2253
|
+
return typeof result === "function" ? { detach: result, setVolume: () => void 0, setMuted: () => void 0 } : result;
|
|
2254
|
+
}
|
|
2255
|
+
async function joinVoice(room, options = {}) {
|
|
2256
|
+
const internals = {
|
|
2257
|
+
...browserFactory(),
|
|
2258
|
+
...options.__transportFactory
|
|
2259
|
+
};
|
|
2260
|
+
const session = room[INTERNAL_SESSION];
|
|
2261
|
+
if (!session) {
|
|
2262
|
+
throw new Error("irtio voice: joinVoice needs a Room from joinRoom()");
|
|
2263
|
+
}
|
|
2264
|
+
if (options.positional === true) {
|
|
2265
|
+
console.warn(
|
|
2266
|
+
"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."
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
2270
|
+
const emit = (event, value) => {
|
|
2271
|
+
for (const cb of [...listeners.get(event) ?? []]) cb(value);
|
|
2272
|
+
};
|
|
2273
|
+
let changeQueued = false;
|
|
2274
|
+
const changed = () => {
|
|
2275
|
+
if (changeQueued || closed) return;
|
|
2276
|
+
changeQueued = true;
|
|
2277
|
+
queueMicrotask(() => {
|
|
2278
|
+
changeQueued = false;
|
|
2279
|
+
if (!closed) emit("peers-changed", void 0);
|
|
2280
|
+
});
|
|
2281
|
+
};
|
|
2282
|
+
const waiters = [];
|
|
2283
|
+
const peers = /* @__PURE__ */ new Map();
|
|
2284
|
+
const playbacks = /* @__PURE__ */ new Map();
|
|
2285
|
+
const localPrefs = /* @__PURE__ */ new Map();
|
|
2286
|
+
const prefsFor = (peerId) => {
|
|
2287
|
+
let p = localPrefs.get(peerId);
|
|
2288
|
+
if (!p) {
|
|
2289
|
+
p = { mutedLocally: false, volume: 1 };
|
|
2290
|
+
localPrefs.set(peerId, p);
|
|
2291
|
+
}
|
|
2292
|
+
return p;
|
|
2293
|
+
};
|
|
2294
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
2295
|
+
let closed = false;
|
|
2296
|
+
let muted = false;
|
|
2297
|
+
let device;
|
|
2298
|
+
let sendTransport;
|
|
2299
|
+
let recvTransport;
|
|
2300
|
+
let micTrack;
|
|
2301
|
+
let producer;
|
|
2302
|
+
const signal = (m) => session.sendVoice(encodeVoiceMessage(m));
|
|
2303
|
+
function expect(match) {
|
|
2304
|
+
return new Promise((resolve, reject) => {
|
|
2305
|
+
waiters.push({ match, resolve, reject });
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
const unsubscribe = session.onVoice((bytes) => {
|
|
2309
|
+
const m = decodeVoiceMessage(bytes);
|
|
2310
|
+
if (!m) return;
|
|
2311
|
+
if (m.t === "error") {
|
|
2312
|
+
const err = new Error(`irtio voice: ${m.code} ${m.message}`);
|
|
2313
|
+
for (const w of waiters.splice(0)) w.reject(err);
|
|
2314
|
+
emit("error", { code: m.code, message: m.message });
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
for (let i = 0; i < waiters.length; i++) {
|
|
2318
|
+
const w = waiters[i];
|
|
2319
|
+
if (w.match(m)) {
|
|
2320
|
+
waiters.splice(i, 1);
|
|
2321
|
+
w.resolve(m);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
switch (m.t) {
|
|
2326
|
+
case "peer-joined": {
|
|
2327
|
+
const known = peers.has(m.peer.peerId);
|
|
2328
|
+
peers.set(m.peer.peerId, {
|
|
2329
|
+
muted: m.peer.muted,
|
|
2330
|
+
...m.peer.producerId !== void 0 ? { producerId: m.peer.producerId } : {}
|
|
2331
|
+
});
|
|
2332
|
+
if (!known) emit("peer-joined", m.peer.peerId);
|
|
2333
|
+
changed();
|
|
2334
|
+
if (m.peer.producerId !== void 0) void consumePeer(m.peer.producerId);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
case "peer-left": {
|
|
2338
|
+
peers.delete(m.peerId);
|
|
2339
|
+
playbacks.get(m.peerId)?.playback.detach();
|
|
2340
|
+
playbacks.delete(m.peerId);
|
|
2341
|
+
emit("peer-left", m.peerId);
|
|
2342
|
+
changed();
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
case "peer-muted": {
|
|
2346
|
+
const p = peers.get(m.peerId);
|
|
2347
|
+
if (p) p.muted = m.muted;
|
|
2348
|
+
emit("peer-muted", { peerId: m.peerId, muted: m.muted });
|
|
2349
|
+
changed();
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
default:
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
});
|
|
2356
|
+
async function consumePeer(producerId) {
|
|
2357
|
+
if (closed || consumed.has(producerId) || !recvTransport || !device) return;
|
|
2358
|
+
consumed.add(producerId);
|
|
2359
|
+
try {
|
|
2360
|
+
signal({ t: "consume", producerId, rtpCapabilities: device.rtpCapabilities });
|
|
2361
|
+
const reply = await expect(
|
|
2362
|
+
(m) => m.t === "consumed" && m.producerId === producerId
|
|
2363
|
+
);
|
|
2364
|
+
if (closed) return;
|
|
2365
|
+
const consumer = await recvTransport.consume({
|
|
2366
|
+
id: reply.consumerId,
|
|
2367
|
+
producerId: reply.producerId,
|
|
2368
|
+
kind: "audio",
|
|
2369
|
+
rtpParameters: reply.rtpParameters
|
|
2370
|
+
});
|
|
2371
|
+
const peerId = reply.peerId || producerId;
|
|
2372
|
+
playbacks.get(peerId)?.playback.detach();
|
|
2373
|
+
const playback = asPlayback(internals.attach(peerId, consumer.track));
|
|
2374
|
+
playbacks.set(peerId, { playback, track: consumer.track });
|
|
2375
|
+
const prefs = prefsFor(peerId);
|
|
2376
|
+
playback.setVolume(prefs.volume);
|
|
2377
|
+
playback.setMuted(prefs.mutedLocally);
|
|
2378
|
+
signal({ t: "resume", consumerId: reply.consumerId });
|
|
2379
|
+
changed();
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
consumed.delete(producerId);
|
|
2382
|
+
emit("error", {
|
|
2383
|
+
code: "E_VOICE_CONSUME",
|
|
2384
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
async function teardown() {
|
|
2389
|
+
if (closed) return;
|
|
2390
|
+
closed = true;
|
|
2391
|
+
for (const w of waiters.splice(0)) w.reject(new Error("irtio voice: left the call"));
|
|
2392
|
+
for (const p of playbacks.values()) p.playback.detach();
|
|
2393
|
+
playbacks.clear();
|
|
2394
|
+
peers.clear();
|
|
2395
|
+
producer?.close();
|
|
2396
|
+
micTrack?.stop();
|
|
2397
|
+
sendTransport?.close();
|
|
2398
|
+
recvTransport?.close();
|
|
2399
|
+
unsubscribe();
|
|
2400
|
+
}
|
|
2401
|
+
try {
|
|
2402
|
+
micTrack = await internals.getMicTrack();
|
|
2403
|
+
device = await internals.createDevice();
|
|
2404
|
+
signal({ t: "join", rtpCapabilities: {} });
|
|
2405
|
+
const joined = await expect(
|
|
2406
|
+
(m) => m.t === "joined"
|
|
2407
|
+
);
|
|
2408
|
+
await device.load({ routerRtpCapabilities: joined.routerRtpCapabilities });
|
|
2409
|
+
sendTransport = device.createSendTransport(joined.sendTransport);
|
|
2410
|
+
recvTransport = device.createRecvTransport(joined.recvTransport);
|
|
2411
|
+
for (const t of [sendTransport, recvTransport]) {
|
|
2412
|
+
const transport = t;
|
|
2413
|
+
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
|
|
2414
|
+
signal({ t: "connect", transportId: transport.id, dtlsParameters });
|
|
2415
|
+
expect((m) => m.t === "connected" && m.transportId === transport.id).then(
|
|
2416
|
+
() => callback(),
|
|
2417
|
+
(e) => errback(e)
|
|
2418
|
+
);
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
sendTransport.on("produce", ({ rtpParameters }, callback, errback) => {
|
|
2422
|
+
signal({
|
|
2423
|
+
t: "produce",
|
|
2424
|
+
transportId: sendTransport.id,
|
|
2425
|
+
kind: "audio",
|
|
2426
|
+
rtpParameters
|
|
2427
|
+
});
|
|
2428
|
+
expect((m) => m.t === "produced").then(
|
|
2429
|
+
(m) => callback({ id: m.producerId }),
|
|
2430
|
+
(e) => errback(e)
|
|
2431
|
+
);
|
|
2432
|
+
});
|
|
2433
|
+
producer = await sendTransport.produce({ track: micTrack });
|
|
2434
|
+
for (const peer of joined.peers) {
|
|
2435
|
+
peers.set(peer.peerId, {
|
|
2436
|
+
muted: peer.muted,
|
|
2437
|
+
...peer.producerId !== void 0 ? { producerId: peer.producerId } : {}
|
|
2438
|
+
});
|
|
2439
|
+
if (peer.producerId !== void 0) void consumePeer(peer.producerId);
|
|
2440
|
+
}
|
|
2441
|
+
changed();
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
await teardown();
|
|
2444
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
2445
|
+
}
|
|
2446
|
+
const handle = {
|
|
2447
|
+
mute(next) {
|
|
2448
|
+
if (closed || next === muted) return;
|
|
2449
|
+
muted = next;
|
|
2450
|
+
signal({ t: "mute", muted: next });
|
|
2451
|
+
changed();
|
|
2452
|
+
},
|
|
2453
|
+
setPeerVolume(peerId, volume) {
|
|
2454
|
+
if (closed) return;
|
|
2455
|
+
const v = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
|
|
2456
|
+
const prefs = prefsFor(peerId);
|
|
2457
|
+
if (prefs.volume === v) return;
|
|
2458
|
+
prefs.volume = v;
|
|
2459
|
+
playbacks.get(peerId)?.playback.setVolume(v);
|
|
2460
|
+
changed();
|
|
2461
|
+
},
|
|
2462
|
+
mutePeer(peerId, mutedLocally) {
|
|
2463
|
+
if (closed) return;
|
|
2464
|
+
const prefs = prefsFor(peerId);
|
|
2465
|
+
if (prefs.mutedLocally === mutedLocally) return;
|
|
2466
|
+
prefs.mutedLocally = mutedLocally;
|
|
2467
|
+
playbacks.get(peerId)?.playback.setMuted(mutedLocally);
|
|
2468
|
+
changed();
|
|
2469
|
+
},
|
|
2470
|
+
peerState(peerId) {
|
|
2471
|
+
const peer = peers.get(peerId);
|
|
2472
|
+
if (!peer) return void 0;
|
|
2473
|
+
const prefs = prefsFor(peerId);
|
|
2474
|
+
return { muted: peer.muted, mutedLocally: prefs.mutedLocally, volume: prefs.volume };
|
|
2475
|
+
},
|
|
2476
|
+
get muted() {
|
|
2477
|
+
return muted;
|
|
2478
|
+
},
|
|
2479
|
+
get peers() {
|
|
2480
|
+
return [...peers.keys()];
|
|
2481
|
+
},
|
|
2482
|
+
async leave() {
|
|
2483
|
+
if (closed) return;
|
|
2484
|
+
signal({ t: "leave" });
|
|
2485
|
+
await teardown();
|
|
2486
|
+
},
|
|
2487
|
+
on(event, cb) {
|
|
2488
|
+
let set = listeners.get(event);
|
|
2489
|
+
if (!set) {
|
|
2490
|
+
set = /* @__PURE__ */ new Set();
|
|
2491
|
+
listeners.set(event, set);
|
|
2492
|
+
}
|
|
2493
|
+
set.add(cb);
|
|
2494
|
+
return () => {
|
|
2495
|
+
set.delete(cb);
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
const tracks = {
|
|
2500
|
+
peerTrack: (peerId) => playbacks.get(peerId)?.track,
|
|
2501
|
+
micTrack: () => micTrack
|
|
2502
|
+
};
|
|
2503
|
+
Object.defineProperty(handle, INTERNAL_VOICE_TRACKS, { value: tracks, enumerable: false });
|
|
2504
|
+
return handle;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
1190
2507
|
// src/index.ts
|
|
1191
2508
|
function callProxy(session) {
|
|
1192
2509
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -1203,11 +2520,22 @@ function callProxy(session) {
|
|
|
1203
2520
|
});
|
|
1204
2521
|
}
|
|
1205
2522
|
async function joinRoom(schema, options = {}) {
|
|
2523
|
+
if (options.physics !== void 0 && options.physics2d !== void 0) {
|
|
2524
|
+
throw new Error(
|
|
2525
|
+
"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."
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
1206
2528
|
const url = resolveUrl(options.url, options.region);
|
|
1207
2529
|
const key = resolveKey(options.key, schema, url);
|
|
1208
2530
|
const explicitRoom = options.room !== void 0;
|
|
1209
2531
|
const fromLocation = !explicitRoom && currentLocation() !== void 0;
|
|
1210
2532
|
const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
|
|
2533
|
+
const identity = options.identity === true ? new Identity({ project: key, controlUrl: options.controlUrl }) : options.identity instanceof Identity ? options.identity : void 0;
|
|
2534
|
+
if (identity !== void 0 && options.token !== void 0) {
|
|
2535
|
+
throw new Error(
|
|
2536
|
+
"irtio: joinRoom was given both { token } and { identity }. A join asserts one identity \u2014 pass your own JWT, or the platform identity, not both."
|
|
2537
|
+
);
|
|
2538
|
+
}
|
|
1211
2539
|
const session = new Session({
|
|
1212
2540
|
schema,
|
|
1213
2541
|
url,
|
|
@@ -1217,13 +2545,16 @@ async function joinRoom(schema, options = {}) {
|
|
|
1217
2545
|
role: options.role,
|
|
1218
2546
|
name: options.name,
|
|
1219
2547
|
token: options.token,
|
|
2548
|
+
...identity !== void 0 ? { assertion: () => identity.assertion() } : {},
|
|
1220
2549
|
rpc: options.rpc,
|
|
1221
2550
|
writeIntervalMs: options.writeIntervalMs,
|
|
1222
2551
|
interpDelayMs: options.interpDelayMs,
|
|
1223
2552
|
physics: options.physics,
|
|
2553
|
+
physics2d: options.physics2d,
|
|
1224
2554
|
transport: options.transport,
|
|
1225
2555
|
scheduler: options.scheduler,
|
|
1226
2556
|
onFrame: options.onFrame,
|
|
2557
|
+
profile: options.profile,
|
|
1227
2558
|
onStatus: options.onStatus
|
|
1228
2559
|
});
|
|
1229
2560
|
await session.start();
|
|
@@ -1231,6 +2562,8 @@ async function joinRoom(schema, options = {}) {
|
|
|
1231
2562
|
}
|
|
1232
2563
|
function makeRoom(session) {
|
|
1233
2564
|
const call = callProxy(session);
|
|
2565
|
+
const messages = buildMessages(session.schema, session);
|
|
2566
|
+
const stats = { messages: session.messageCounts };
|
|
1234
2567
|
const room = {
|
|
1235
2568
|
get me() {
|
|
1236
2569
|
return session.me;
|
|
@@ -1244,6 +2577,9 @@ function makeRoom(session) {
|
|
|
1244
2577
|
get tick() {
|
|
1245
2578
|
return session.tick;
|
|
1246
2579
|
},
|
|
2580
|
+
get maxClients() {
|
|
2581
|
+
return session.maxClients;
|
|
2582
|
+
},
|
|
1247
2583
|
get status() {
|
|
1248
2584
|
return session.status;
|
|
1249
2585
|
},
|
|
@@ -1259,12 +2595,20 @@ function makeRoom(session) {
|
|
|
1259
2595
|
get clients() {
|
|
1260
2596
|
return session.clients;
|
|
1261
2597
|
},
|
|
2598
|
+
...session.ledger ? {
|
|
2599
|
+
profile: {
|
|
2600
|
+
total: () => session.ledger?.snapshot() ?? EMPTY_PROFILE2,
|
|
2601
|
+
perSecond: () => session.profilePerSecond()
|
|
2602
|
+
}
|
|
2603
|
+
} : {},
|
|
1262
2604
|
...session.predictionRequested ? {
|
|
1263
2605
|
prediction: {
|
|
1264
2606
|
get active() {
|
|
1265
2607
|
return session.predictor?.ready ?? false;
|
|
1266
2608
|
},
|
|
1267
2609
|
predicts: (collection, id) => session.predictor?.has(collection, id) ?? false,
|
|
2610
|
+
// ---- M6 lane D: proxies ----
|
|
2611
|
+
proxied: (collection, id) => session.predictor?.hasProxy(collection, id) ?? false,
|
|
1268
2612
|
get stats() {
|
|
1269
2613
|
return session.predictor?.stats ?? emptyPredictionStats();
|
|
1270
2614
|
}
|
|
@@ -1274,10 +2618,13 @@ function makeRoom(session) {
|
|
|
1274
2618
|
requestOwnership: (entity, id) => session.requestOwnership(entity, id),
|
|
1275
2619
|
message: (target, bytes) => session.message(target, bytes),
|
|
1276
2620
|
onMessage: (cb) => session.onMessage(cb),
|
|
2621
|
+
messages,
|
|
2622
|
+
stats,
|
|
1277
2623
|
on: (event, cb) => session.on(event, cb),
|
|
1278
2624
|
flush: () => session.flush(),
|
|
1279
2625
|
leave: () => session.leave()
|
|
1280
2626
|
};
|
|
2627
|
+
Object.defineProperty(room, INTERNAL_SESSION, { value: session, enumerable: false });
|
|
1281
2628
|
return room;
|
|
1282
2629
|
}
|
|
1283
2630
|
async function joinRelay(options = {}) {
|
|
@@ -1286,7 +2633,11 @@ async function joinRelay(options = {}) {
|
|
|
1286
2633
|
const fromLocation = !explicitRoom && currentLocation() !== void 0;
|
|
1287
2634
|
const roomId = explicitRoom ? roomIdFrom(options.room ?? "") : fromLocation ? roomIdFromLocation() : "";
|
|
1288
2635
|
const session = new Session({
|
|
1289
|
-
|
|
2636
|
+
// ---- M6 lane C: typed messages ----
|
|
2637
|
+
// D70: a relay join may now bring the project's deployed schema. This is a type change and a
|
|
2638
|
+
// hash change, not a transport change: the session already chose between the builder's schema
|
|
2639
|
+
// and `relaySchema`, and already put `schema.hash8` or `RELAY_HASH8` in the HELLO.
|
|
2640
|
+
schema: options.schema,
|
|
1290
2641
|
url,
|
|
1291
2642
|
key: options.key ?? resolveKey(void 0, void 0, url),
|
|
1292
2643
|
roomId,
|
|
@@ -1306,6 +2657,9 @@ async function joinRelay(options = {}) {
|
|
|
1306
2657
|
get id() {
|
|
1307
2658
|
return session.roomId;
|
|
1308
2659
|
},
|
|
2660
|
+
get maxClients() {
|
|
2661
|
+
return session.maxClients;
|
|
2662
|
+
},
|
|
1309
2663
|
get link() {
|
|
1310
2664
|
return session.link;
|
|
1311
2665
|
},
|
|
@@ -1320,18 +2674,29 @@ async function joinRelay(options = {}) {
|
|
|
1320
2674
|
},
|
|
1321
2675
|
message: (target, bytes) => session.message(target, bytes),
|
|
1322
2676
|
onMessage: (cb) => session.onMessage(cb),
|
|
2677
|
+
messages: buildMessages(session.schema, session),
|
|
2678
|
+
stats: { messages: session.messageCounts },
|
|
1323
2679
|
on: (event, cb) => session.on(event, cb),
|
|
1324
2680
|
leave: () => session.leave()
|
|
1325
2681
|
};
|
|
1326
2682
|
}
|
|
1327
2683
|
export {
|
|
2684
|
+
ACCOUNT_STORAGE_KEY,
|
|
1328
2685
|
CALL_TIMEOUT_MS,
|
|
1329
2686
|
ClientStore,
|
|
2687
|
+
DEFAULT_CONTROL_URL,
|
|
1330
2688
|
DEFAULT_REGION,
|
|
1331
2689
|
DEFAULT_WRITE_INTERVAL_MS,
|
|
1332
2690
|
DEV_PORT,
|
|
1333
2691
|
E_CONNECT_FAILED,
|
|
2692
|
+
E_IDENTITY_RATE_LIMITED,
|
|
2693
|
+
INTERNAL_VOICE_TRACKS,
|
|
2694
|
+
Identity,
|
|
2695
|
+
IdentityError,
|
|
2696
|
+
MAX_IDENTITY_RETRY_WAIT_MS,
|
|
1334
2697
|
MAX_PREDICTED_BODIES,
|
|
2698
|
+
MAX_PROXY_BODIES,
|
|
2699
|
+
MatchError,
|
|
1335
2700
|
PING_INTERVAL_MS,
|
|
1336
2701
|
PREDICTION_EPSILON,
|
|
1337
2702
|
REGION_RE,
|
|
@@ -1339,10 +2704,15 @@ export {
|
|
|
1339
2704
|
SMOOTHING_HALF_LIFE_MS,
|
|
1340
2705
|
SMOOTHING_SNAP_UNITS,
|
|
1341
2706
|
Session,
|
|
2707
|
+
createParty,
|
|
1342
2708
|
defaultScheduler,
|
|
2709
|
+
findMatch,
|
|
2710
|
+
identityStorageKey,
|
|
1343
2711
|
joinRelay,
|
|
1344
2712
|
joinRoom,
|
|
2713
|
+
joinVoice,
|
|
1345
2714
|
linkForUrl,
|
|
2715
|
+
matchRoom,
|
|
1346
2716
|
resolveUrl,
|
|
1347
2717
|
roomIdFrom,
|
|
1348
2718
|
webSocketTransport
|