@irtio/protocol 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/index.d.ts +1095 -9
- package/dist/index.js +889 -39
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/frame.ts
|
|
2
|
-
var PROTOCOL_VERSION =
|
|
2
|
+
var PROTOCOL_VERSION = 2;
|
|
3
3
|
var FrameType = {
|
|
4
4
|
HELLO: 1,
|
|
5
5
|
WELCOME: 2,
|
|
@@ -18,7 +18,19 @@ var FrameType = {
|
|
|
18
18
|
* reconnect grace window and the room's `onLeave` sees `reason: 'left'` instead of waiting for
|
|
19
19
|
* the close to time out into `'timeout'`. Additive — protocol version stays 1.
|
|
20
20
|
*/
|
|
21
|
-
LEAVE: 12
|
|
21
|
+
LEAVE: 12,
|
|
22
|
+
/**
|
|
23
|
+
* Server → client, payload the new schema's canonical JSON as UTF-8 (D50, gap-and-resync).
|
|
24
|
+
* Sent during a `migrate`-strategy deploy whose schema change is purely additive, after the old
|
|
25
|
+
* worker stops sending and before the resync WELCOME, to a session that asked for it. The
|
|
26
|
+
* client rebuilds its codec from the descriptor and keeps its socket; before this, any schema
|
|
27
|
+
* change closed it with `E_SCHEMA_MISMATCH`.
|
|
28
|
+
*
|
|
29
|
+
* Gated, not versioned. `decodeFrame` throws on an unknown type, so a client that predates this
|
|
30
|
+
* frame must never receive one: the server sends it only to a session whose HELLO set
|
|
31
|
+
* `HELLO_SCHEMA_SWAP_BIT`. Additive — protocol version stays 1, the same way LEAVE=12 did.
|
|
32
|
+
*/
|
|
33
|
+
SCHEMA: 13
|
|
22
34
|
};
|
|
23
35
|
var FRAME_TYPE_VALUES = new Set(Object.values(FrameType));
|
|
24
36
|
function isFrameType(v) {
|
|
@@ -45,7 +57,7 @@ var ErrorCode = {
|
|
|
45
57
|
E_SCHEMA_MISMATCH: { code: 4, message: "schema hash mismatch" },
|
|
46
58
|
E_ROOM_NOT_FOUND: {
|
|
47
59
|
code: 5,
|
|
48
|
-
message: "room {roomId} not found
|
|
60
|
+
message: "room {roomId} not found. Room ids are 1-32 of A-Z a-z 0-9 - _, optionally behind a lowercase <type>: prefix"
|
|
49
61
|
},
|
|
50
62
|
E_ROOM_FULL: { code: 6, message: "room {roomId} is full" },
|
|
51
63
|
E_ROOM_CLOSED: { code: 7, message: "room {roomId} is closed" },
|
|
@@ -74,8 +86,72 @@ var ErrorCode = {
|
|
|
74
86
|
E_TOKEN_WRONG_ROOM: { code: 24, message: "auth token is for another room" },
|
|
75
87
|
E_TOKEN_MALFORMED: { code: 25, message: "auth token is malformed: {reason}" },
|
|
76
88
|
E_TOKEN_BAD_ISSUER: { code: 26, message: "auth token issuer {issuer} is not accepted" },
|
|
77
|
-
E_TOKEN_BAD_ALG: { code: 27, message: "auth token algorithm {alg} is not allowed" }
|
|
89
|
+
E_TOKEN_BAD_ALG: { code: 27, message: "auth token algorithm {alg} is not allowed" },
|
|
90
|
+
/**
|
|
91
|
+
* M4 (D48): the project is at a usage cap and this join is new work.
|
|
92
|
+
*
|
|
93
|
+
* The template carries `{detail}` rather than a fixed sentence because the actionable half
|
|
94
|
+
* differs by tier and by meter, and a refusal that does not say what to do next is the thing
|
|
95
|
+
* `errors.md`'s fix-line convention exists to prevent. Control composes the detail; the box
|
|
96
|
+
* relays it. Non-protocol surfaces (the store gateway's write refusal, control's HTTP refusals
|
|
97
|
+
* for deploys and uploads) reuse the same `E_USAGE_CAP` string code in their own shapes, so one
|
|
98
|
+
* grep finds every place a cap can be met.
|
|
99
|
+
*/
|
|
100
|
+
E_USAGE_CAP: { code: 28, message: "{detail}" },
|
|
101
|
+
/**
|
|
102
|
+
* M4 part 5 (D53): the tenant cannot verify platform identity assertions because it holds no
|
|
103
|
+
* platform public key yet.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately not `E_AUTH` and not `E_TOKEN_INVALID`. Those say "your credential is bad", and
|
|
106
|
+
* this is the opposite — the credential may be perfect and the box is not yet in a position to
|
|
107
|
+
* say so. Keys are delivered to a tenant the way cap state is (a push from control right after
|
|
108
|
+
* the VM comes up), so the honest reading of this code is "retry in a moment", which is what
|
|
109
|
+
* the fix line in the errors reference says.
|
|
110
|
+
*/
|
|
111
|
+
E_ASSERTION_UNVERIFIABLE: {
|
|
112
|
+
code: 29,
|
|
113
|
+
message: "this server cannot verify identity assertions yet"
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* M5 part 3.5: a room type is already running as many rooms as it declared it would.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately its own code rather than `E_ROOM_FULL`, which is about clients in a room, and
|
|
119
|
+
* emphatically not `E_INTERNAL`. This refusal is what makes declared sizing honest: the tenant's
|
|
120
|
+
* VM memory was computed as the declared per-room heap times this number, so room N+1 is a room
|
|
121
|
+
* the machine was never built to hold. The message names the type, the limit and the field to
|
|
122
|
+
* change, because all three are things the developer controls.
|
|
123
|
+
*/
|
|
124
|
+
E_TYPE_AT_CAPACITY: { code: 30, message: "{detail}" },
|
|
125
|
+
/**
|
|
126
|
+
* M5 part 3.5 (Part C): the project has used its egress allowance and forwarding has stopped.
|
|
127
|
+
*
|
|
128
|
+
* Its own code, and its own WebSocket close code ({@link CLOSE_EGRESS_WALL}), for one reason: a
|
|
129
|
+
* developer watching sockets close has to be able to tell a wall from a bug. `E_USAGE_CAP` is
|
|
130
|
+
* the polite refusal of *new* work at a cap control evaluated up to a minute ago; this is the
|
|
131
|
+
* box stopping traffic on its own, locally, the moment its leased budget ran out.
|
|
132
|
+
*/
|
|
133
|
+
E_EGRESS_WALL: { code: 31, message: "{detail}" },
|
|
134
|
+
/**
|
|
135
|
+
* M5 part 3.5 (Part C): a free-tier shape limit — the relay client cap, or the concurrent
|
|
136
|
+
* awake-rooms cap. The message names the limit and the tier, because the fix is a card.
|
|
137
|
+
*/
|
|
138
|
+
E_TIER_LIMIT: { code: 32, message: "{detail}" },
|
|
139
|
+
/**
|
|
140
|
+
* M5 part 7 (D67-i): the project's region has no server with room for another tenant VM.
|
|
141
|
+
*
|
|
142
|
+
* Sent by the router, fatal, with close code 1013 (RFC 6455 "try again later"). Before this
|
|
143
|
+
* code a full region looked like a slow start: the control plane re-ran placement on every
|
|
144
|
+
* lookup poll for thirty seconds and the client then saw `E_STARTING` followed by `E_INTERNAL`.
|
|
145
|
+
* The message names the region and the two fixes because both are an operator's, never the
|
|
146
|
+
* player's: the client does not retry this join on its own.
|
|
147
|
+
*/
|
|
148
|
+
E_PLACEMENT: {
|
|
149
|
+
code: 33,
|
|
150
|
+
message: "no capacity in region {region}; add a server or raise maxVms"
|
|
151
|
+
}
|
|
78
152
|
};
|
|
153
|
+
var CLOSE_TRY_AGAIN_LATER = 1013;
|
|
154
|
+
var CLOSE_EGRESS_WALL = 4290;
|
|
79
155
|
var ERROR_CATALOGUE = Object.keys(ErrorCode).map((name) => ({ name, code: ErrorCode[name].code, message: ErrorCode[name].message }));
|
|
80
156
|
var BY_CODE = new Map(
|
|
81
157
|
ERROR_CATALOGUE.map((e) => [e.code, e])
|
|
@@ -101,6 +177,7 @@ function assertEof(r, what) {
|
|
|
101
177
|
var HELLO_RESUME_BIT = 1 << 0;
|
|
102
178
|
var HELLO_ROLE_BIT = 1 << 1;
|
|
103
179
|
var HELLO_NAME_BIT = 1 << 2;
|
|
180
|
+
var HELLO_SCHEMA_SWAP_BIT = 1 << 3;
|
|
104
181
|
function encodeHello(h) {
|
|
105
182
|
if (h.schemaHash8.length !== 8) {
|
|
106
183
|
throw new Error(`encodeHello: schemaHash8 must be 8 bytes, got ${h.schemaHash8.length}`);
|
|
@@ -114,7 +191,7 @@ function encodeHello(h) {
|
|
|
114
191
|
w.u8(1);
|
|
115
192
|
w.str(h.credential.token);
|
|
116
193
|
} else {
|
|
117
|
-
w.u8(2);
|
|
194
|
+
w.u8(h.credential.kind === "assertion" ? 3 : 2);
|
|
118
195
|
w.str(h.credential.key);
|
|
119
196
|
w.str(h.credential.token);
|
|
120
197
|
}
|
|
@@ -124,6 +201,7 @@ function encodeHello(h) {
|
|
|
124
201
|
if (h.resumeToken !== void 0) mask |= HELLO_RESUME_BIT;
|
|
125
202
|
if (h.role !== void 0) mask |= HELLO_ROLE_BIT;
|
|
126
203
|
if (h.name !== void 0) mask |= HELLO_NAME_BIT;
|
|
204
|
+
if (h.schemaSwap === true) mask |= HELLO_SCHEMA_SWAP_BIT;
|
|
127
205
|
w.u8(mask);
|
|
128
206
|
if (h.resumeToken !== void 0) w.str(h.resumeToken);
|
|
129
207
|
if (h.role !== void 0) w.str(h.role);
|
|
@@ -138,6 +216,7 @@ function decodeHello(bytes) {
|
|
|
138
216
|
if (credKind === 0) credential = { kind: "key", key: r.str() };
|
|
139
217
|
else if (credKind === 1) credential = { kind: "token", token: r.str() };
|
|
140
218
|
else if (credKind === 2) credential = { kind: "jwt", key: r.str(), token: r.str() };
|
|
219
|
+
else if (credKind === 3) credential = { kind: "assertion", key: r.str(), token: r.str() };
|
|
141
220
|
else throw new Error(`decodeHello: unknown credential kind ${credKind}`);
|
|
142
221
|
const roomId = r.str();
|
|
143
222
|
const schemaHash8 = r.bytes(8);
|
|
@@ -145,13 +224,15 @@ function decodeHello(bytes) {
|
|
|
145
224
|
const resumeToken = (mask & HELLO_RESUME_BIT) !== 0 ? r.str() : void 0;
|
|
146
225
|
const role = (mask & HELLO_ROLE_BIT) !== 0 ? r.str() : void 0;
|
|
147
226
|
const name = (mask & HELLO_NAME_BIT) !== 0 ? r.str() : void 0;
|
|
227
|
+
const schemaSwap = (mask & HELLO_SCHEMA_SWAP_BIT) !== 0;
|
|
148
228
|
assertEof(r, "decodeHello");
|
|
149
229
|
const hello = { protocolVersion, credential, roomId, schemaHash8 };
|
|
150
230
|
return {
|
|
151
231
|
...hello,
|
|
152
232
|
...resumeToken !== void 0 ? { resumeToken } : {},
|
|
153
233
|
...role !== void 0 ? { role } : {},
|
|
154
|
-
...name !== void 0 ? { name } : {}
|
|
234
|
+
...name !== void 0 ? { name } : {},
|
|
235
|
+
...schemaSwap ? { schemaSwap: true } : {}
|
|
155
236
|
};
|
|
156
237
|
}
|
|
157
238
|
function encodeWelcome(w0) {
|
|
@@ -162,7 +243,8 @@ function encodeWelcome(w0) {
|
|
|
162
243
|
w.blob(w0.snapshot);
|
|
163
244
|
w.str(w0.resumeToken);
|
|
164
245
|
w.str(w0.roomId);
|
|
165
|
-
w.u16(w0.
|
|
246
|
+
w.u16(w0.tickRate);
|
|
247
|
+
w.u16(w0.maxClients);
|
|
166
248
|
return w.finish();
|
|
167
249
|
}
|
|
168
250
|
function decodeWelcome(bytes) {
|
|
@@ -173,9 +255,10 @@ function decodeWelcome(bytes) {
|
|
|
173
255
|
const snapshot = r.blob();
|
|
174
256
|
const resumeToken = r.str();
|
|
175
257
|
const roomId = r.str();
|
|
176
|
-
const
|
|
258
|
+
const tickRate = r.eof ? 0 : r.u16();
|
|
259
|
+
const maxClients = r.eof ? 0 : r.u16();
|
|
177
260
|
assertEof(r, "decodeWelcome");
|
|
178
|
-
return { clientId, role, tick, snapshot, resumeToken, roomId,
|
|
261
|
+
return { clientId, role, tick, snapshot, resumeToken, roomId, tickRate, maxClients };
|
|
179
262
|
}
|
|
180
263
|
function encodeErrorPayload(e) {
|
|
181
264
|
const w = new ByteWriter();
|
|
@@ -245,6 +328,15 @@ function encodeWriteFrame(codecBytes) {
|
|
|
245
328
|
function encodeCorrectFrame(codecBytes, clientTick, appliedTick) {
|
|
246
329
|
return encodeFrame(FrameType.CORRECT, correctPayload(codecBytes, clientTick, appliedTick));
|
|
247
330
|
}
|
|
331
|
+
function schemaPayload(canonical) {
|
|
332
|
+
return new TextEncoder().encode(canonical);
|
|
333
|
+
}
|
|
334
|
+
function readSchemaPayload(payload) {
|
|
335
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
|
336
|
+
}
|
|
337
|
+
function encodeSchemaFrame(canonical) {
|
|
338
|
+
return encodeFrame(FrameType.SCHEMA, schemaPayload(canonical));
|
|
339
|
+
}
|
|
248
340
|
|
|
249
341
|
// src/rpc.ts
|
|
250
342
|
import { ByteReader as ByteReader2, ByteWriter as ByteWriter2, bool, server, str } from "@irtio/schema";
|
|
@@ -323,12 +415,422 @@ function rpcByIdOf(schema, id) {
|
|
|
323
415
|
return found;
|
|
324
416
|
}
|
|
325
417
|
|
|
418
|
+
// src/profile.ts
|
|
419
|
+
import { ByteReader as ByteReader3, walkDelta, walkSnapshot } from "@irtio/schema";
|
|
420
|
+
|
|
421
|
+
// src/presence.ts
|
|
422
|
+
import { bool as bool2, defineSchema, entity, str as str2 } from "@irtio/schema";
|
|
423
|
+
var presenceEntity = entity(
|
|
424
|
+
{ clientId: str2(32), role: str2(32), name: str2(32), connected: bool2 },
|
|
425
|
+
{ serverOwned: true }
|
|
426
|
+
);
|
|
427
|
+
var PRESENCE_COLLECTION = "clients";
|
|
428
|
+
var extended = /* @__PURE__ */ new WeakMap();
|
|
429
|
+
function withBuiltins(schema) {
|
|
430
|
+
let ext = extended.get(schema);
|
|
431
|
+
if (!ext) {
|
|
432
|
+
ext = defineSchema(
|
|
433
|
+
{ ...schema.defs, [PRESENCE_COLLECTION]: presenceEntity },
|
|
434
|
+
{
|
|
435
|
+
rpc: schema.rpc,
|
|
436
|
+
roles: schema.roles,
|
|
437
|
+
...schema.project !== void 0 ? { project: schema.project } : {},
|
|
438
|
+
allowReservedNames: true
|
|
439
|
+
}
|
|
440
|
+
);
|
|
441
|
+
extended.set(schema, ext);
|
|
442
|
+
}
|
|
443
|
+
return ext;
|
|
444
|
+
}
|
|
445
|
+
var relaySchema = withBuiltins(defineSchema({}));
|
|
446
|
+
var RELAY_HASH8 = new Uint8Array(8);
|
|
447
|
+
function isRelayHash8(hash8) {
|
|
448
|
+
if (hash8.length !== 8) return false;
|
|
449
|
+
for (const b of hash8) if (b !== 0) return false;
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/profile.ts
|
|
454
|
+
var PROFILE_KINDS = [
|
|
455
|
+
"field",
|
|
456
|
+
"presence",
|
|
457
|
+
"correction",
|
|
458
|
+
"write",
|
|
459
|
+
"churn",
|
|
460
|
+
"rpc",
|
|
461
|
+
"message",
|
|
462
|
+
"voice",
|
|
463
|
+
"join",
|
|
464
|
+
"overhead",
|
|
465
|
+
"control"
|
|
466
|
+
];
|
|
467
|
+
var EMPTY_PROFILE = {
|
|
468
|
+
rows: [],
|
|
469
|
+
bytesIn: 0,
|
|
470
|
+
bytesOut: 0,
|
|
471
|
+
framesIn: 0,
|
|
472
|
+
framesOut: 0,
|
|
473
|
+
walks: 0
|
|
474
|
+
};
|
|
475
|
+
var CONTROL_KEYS = {
|
|
476
|
+
[FrameType.HELLO]: "hello",
|
|
477
|
+
[FrameType.WELCOME]: "welcome",
|
|
478
|
+
[FrameType.ERROR]: "error",
|
|
479
|
+
[FrameType.PING]: "ping",
|
|
480
|
+
[FrameType.PONG]: "pong",
|
|
481
|
+
[FrameType.LEAVE]: "leave",
|
|
482
|
+
[FrameType.SCHEMA]: "schema"
|
|
483
|
+
};
|
|
484
|
+
var MSG_KEYS = ["all", "client", "role", "server"];
|
|
485
|
+
var PENDING_CALLS_MAX = 512;
|
|
486
|
+
var ProfileLedger = class {
|
|
487
|
+
rows = /* @__PURE__ */ new Map();
|
|
488
|
+
memo = /* @__PURE__ */ new Map();
|
|
489
|
+
/** Peer and reqId to the rpc name its CALL carried, so the matching REPLY can be named too. */
|
|
490
|
+
pending = /* @__PURE__ */ new Map();
|
|
491
|
+
scratch = [];
|
|
492
|
+
bytesIn = 0;
|
|
493
|
+
bytesOut = 0;
|
|
494
|
+
framesIn = 0;
|
|
495
|
+
framesOut = 0;
|
|
496
|
+
walkCount = 0;
|
|
497
|
+
rpcSchema;
|
|
498
|
+
/**
|
|
499
|
+
* `schema` is the runtime-extended schema (`withBuiltins`), which is what delta and snapshot
|
|
500
|
+
* bodies are encoded against. `rpcSchema` is the one whose `rpcTable` the `rpcId` on the wire
|
|
501
|
+
* indexes; the two tables agree today (extending adds a collection, not an rpc), but the
|
|
502
|
+
* senders name them separately and so does this.
|
|
503
|
+
*/
|
|
504
|
+
schema;
|
|
505
|
+
constructor(schema, rpcSchema) {
|
|
506
|
+
this.schema = schema;
|
|
507
|
+
this.rpcSchema = rpcSchema ?? schema;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* D50: a session whose schema was swapped mid-flight keeps its ledger and its accumulated rows.
|
|
511
|
+
* Rows named after a field the new schema dropped simply stop growing, which is the honest
|
|
512
|
+
* reading — those bytes really were spent.
|
|
513
|
+
*/
|
|
514
|
+
swapSchema(schema, rpcSchema) {
|
|
515
|
+
this.schema = schema;
|
|
516
|
+
this.rpcSchema = rpcSchema ?? schema;
|
|
517
|
+
this.memo.clear();
|
|
518
|
+
}
|
|
519
|
+
get walks() {
|
|
520
|
+
return this.walkCount;
|
|
521
|
+
}
|
|
522
|
+
/** Drops the shared-payload memo. Call at the top of each flush. */
|
|
523
|
+
newFlush() {
|
|
524
|
+
if (this.memo.size > 0) this.memo.clear();
|
|
525
|
+
}
|
|
526
|
+
reset() {
|
|
527
|
+
this.rows.clear();
|
|
528
|
+
this.memo.clear();
|
|
529
|
+
this.pending.clear();
|
|
530
|
+
this.bytesIn = 0;
|
|
531
|
+
this.bytesOut = 0;
|
|
532
|
+
this.framesIn = 0;
|
|
533
|
+
this.framesOut = 0;
|
|
534
|
+
this.walkCount = 0;
|
|
535
|
+
}
|
|
536
|
+
snapshot() {
|
|
537
|
+
const rows = [];
|
|
538
|
+
for (const kind of PROFILE_KINDS) {
|
|
539
|
+
const byKey = this.rows.get(kind);
|
|
540
|
+
if (!byKey) continue;
|
|
541
|
+
for (const [key, v] of byKey) rows.push({ kind, key, out: v.out, in: v.in });
|
|
542
|
+
}
|
|
543
|
+
rows.sort((a, b) => b.out + b.in - (a.out + a.in) || (a.key < b.key ? -1 : 1));
|
|
544
|
+
return {
|
|
545
|
+
rows,
|
|
546
|
+
bytesIn: this.bytesIn,
|
|
547
|
+
bytesOut: this.bytesOut,
|
|
548
|
+
framesIn: this.framesIn,
|
|
549
|
+
framesOut: this.framesOut,
|
|
550
|
+
walks: this.walkCount
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Attributes one whole frame, envelope byte included. Never throws: a frame this ledger cannot
|
|
555
|
+
* read still contributes its full length, to `overhead/unattributed`.
|
|
556
|
+
*/
|
|
557
|
+
attribute(dir, frame, options) {
|
|
558
|
+
if (dir === "in") {
|
|
559
|
+
this.framesIn++;
|
|
560
|
+
this.bytesIn += frame.length;
|
|
561
|
+
} else {
|
|
562
|
+
this.framesOut++;
|
|
563
|
+
this.bytesOut += frame.length;
|
|
564
|
+
}
|
|
565
|
+
const shared = options?.shared;
|
|
566
|
+
if (shared !== void 0) {
|
|
567
|
+
const cached = this.memo.get(shared);
|
|
568
|
+
if (cached) {
|
|
569
|
+
this.commit(dir, cached);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const entries = shared !== void 0 ? [] : this.scratch;
|
|
574
|
+
entries.length = 0;
|
|
575
|
+
this.walkCount++;
|
|
576
|
+
this.describe(frame, entries, options);
|
|
577
|
+
const total = sum(entries);
|
|
578
|
+
if (total !== frame.length) {
|
|
579
|
+
entries.push({
|
|
580
|
+
kind: "overhead",
|
|
581
|
+
key: "unattributed",
|
|
582
|
+
bytes: frame.length - total
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (shared !== void 0) this.memo.set(shared, entries);
|
|
586
|
+
this.commit(dir, entries);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Attributes bare snapshot bytes under `join`. The room worker needs this: it hands the host
|
|
590
|
+
* snapshot bytes and the host wraps them in a `WELCOME`, so the room never sees that frame,
|
|
591
|
+
* but the bytes are still the room's egress and the join rows are the reason anyone profiles a
|
|
592
|
+
* join at all. A client, which does see the whole frame, uses `attribute` instead.
|
|
593
|
+
*/
|
|
594
|
+
attributeSnapshot(dir, bytes) {
|
|
595
|
+
if (dir === "in") {
|
|
596
|
+
this.bytesIn += bytes.length;
|
|
597
|
+
} else {
|
|
598
|
+
this.bytesOut += bytes.length;
|
|
599
|
+
}
|
|
600
|
+
const entries = [];
|
|
601
|
+
this.walkCount++;
|
|
602
|
+
try {
|
|
603
|
+
this.snapshotBody(bytes, entries);
|
|
604
|
+
} catch {
|
|
605
|
+
entries.length = 0;
|
|
606
|
+
}
|
|
607
|
+
const total = sum(entries);
|
|
608
|
+
if (total !== bytes.length) {
|
|
609
|
+
entries.push({ kind: "overhead", key: "unattributed", bytes: bytes.length - total });
|
|
610
|
+
}
|
|
611
|
+
this.commit(dir, entries);
|
|
612
|
+
}
|
|
613
|
+
// -------------------------------------------------------------------------
|
|
614
|
+
commit(dir, entries) {
|
|
615
|
+
for (const e of entries) {
|
|
616
|
+
if (e.bytes === 0) continue;
|
|
617
|
+
let byKey = this.rows.get(e.kind);
|
|
618
|
+
if (!byKey) {
|
|
619
|
+
byKey = /* @__PURE__ */ new Map();
|
|
620
|
+
this.rows.set(e.kind, byKey);
|
|
621
|
+
}
|
|
622
|
+
let row = byKey.get(e.key);
|
|
623
|
+
if (!row) {
|
|
624
|
+
row = { out: 0, in: 0 };
|
|
625
|
+
byKey.set(e.key, row);
|
|
626
|
+
}
|
|
627
|
+
if (dir === "in") row.in += e.bytes;
|
|
628
|
+
else row.out += e.bytes;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
describe(frame, out, options) {
|
|
632
|
+
if (frame.length === 0) return;
|
|
633
|
+
out.push({ kind: "overhead", key: "envelope", bytes: 1 });
|
|
634
|
+
const type = frame[0];
|
|
635
|
+
const payload = frame.subarray(1);
|
|
636
|
+
try {
|
|
637
|
+
switch (type) {
|
|
638
|
+
case FrameType.DELTA:
|
|
639
|
+
this.body(payload, out, "field", options?.churn, options?.spatialChurn === true);
|
|
640
|
+
return;
|
|
641
|
+
case FrameType.WRITE:
|
|
642
|
+
this.body(payload, out, "write", void 0);
|
|
643
|
+
return;
|
|
644
|
+
case FrameType.CORRECT: {
|
|
645
|
+
const r = new ByteReader3(payload);
|
|
646
|
+
this.body(r, out, "correction", void 0);
|
|
647
|
+
const tail = payload.length - r.pos;
|
|
648
|
+
if (tail > 0) out.push({ kind: "correction", key: "tail", bytes: tail });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
case FrameType.CALL: {
|
|
652
|
+
const r = new ByteReader3(payload);
|
|
653
|
+
const reqId = r.u32();
|
|
654
|
+
const rpcId = r.u16();
|
|
655
|
+
const name = this.rpcName(rpcId);
|
|
656
|
+
this.remember(options?.peer, reqId, name);
|
|
657
|
+
out.push({ kind: "rpc", key: name, bytes: payload.length });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
case FrameType.REPLY: {
|
|
661
|
+
const r = new ByteReader3(payload);
|
|
662
|
+
const reqId = r.u32();
|
|
663
|
+
out.push({ kind: "rpc", key: this.recall(options?.peer, reqId), bytes: payload.length });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
case FrameType.MSG: {
|
|
667
|
+
const kind = payload[0];
|
|
668
|
+
if (kind === 4) out.push({ kind: "voice", key: "signal", bytes: payload.length });
|
|
669
|
+
else if (kind !== void 0 && kind < MSG_KEYS.length) {
|
|
670
|
+
out.push({ kind: "message", key: MSG_KEYS[kind], bytes: payload.length });
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
case FrameType.WELCOME:
|
|
675
|
+
this.welcome(payload, out);
|
|
676
|
+
return;
|
|
677
|
+
default: {
|
|
678
|
+
const key = CONTROL_KEYS[type];
|
|
679
|
+
if (key !== void 0) out.push({ kind: "control", key, bytes: payload.length });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** WELCOME: the snapshot inside it is `join`; everything around it is `control/welcome`. */
|
|
687
|
+
welcome(payload, out) {
|
|
688
|
+
const r = new ByteReader3(payload);
|
|
689
|
+
skipStr(r);
|
|
690
|
+
skipStr(r);
|
|
691
|
+
r.u32();
|
|
692
|
+
const len = r.varint();
|
|
693
|
+
const before = r.pos;
|
|
694
|
+
if (r.remaining < len) throw new Error("welcome: short snapshot");
|
|
695
|
+
const consumed = this.snapshotBody(payload.subarray(before, before + len), out);
|
|
696
|
+
out.push({ kind: "control", key: "welcome", bytes: payload.length - consumed });
|
|
697
|
+
}
|
|
698
|
+
snapshotBody(bytes, out) {
|
|
699
|
+
const collect = this.collector(out, "join", "join", "header", void 0);
|
|
700
|
+
return walkSnapshot(this.schema, bytes, collect);
|
|
701
|
+
}
|
|
702
|
+
body(bytes, out, fieldKind, churn, spatialChurn = false) {
|
|
703
|
+
const collect = this.collector(out, fieldKind, "overhead", "delta-header", churn, spatialChurn);
|
|
704
|
+
walkDelta(this.schema, bytes, collect);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The walker's consumer. `op` latches: a churn op routes its own framing *and* every field
|
|
708
|
+
* that follows it into `churn`, because a synthetic add carries a whole record whose bytes are
|
|
709
|
+
* the cost of the entity crossing the boundary, not the cost of the fields changing.
|
|
710
|
+
*/
|
|
711
|
+
collector(out, fieldKind, overheadKind, headerKey, churn, spatialChurn = false) {
|
|
712
|
+
let churnCollection;
|
|
713
|
+
return {
|
|
714
|
+
header: (bytes) => {
|
|
715
|
+
out.push({ kind: overheadKind, key: headerKey, bytes });
|
|
716
|
+
},
|
|
717
|
+
op: (c, kind, id, bytes) => {
|
|
718
|
+
const isChurn = kind !== "update" && (spatialChurn ? c.visibility === "spatial-grid" : churn !== void 0 && churn.get(c.name)?.has(id) === true);
|
|
719
|
+
churnCollection = isChurn ? c.name : void 0;
|
|
720
|
+
out.push(
|
|
721
|
+
churnCollection !== void 0 ? { kind: "churn", key: c.name, bytes } : { kind: overheadKind, key: overheadKind === "join" ? headerKey : "op", bytes }
|
|
722
|
+
);
|
|
723
|
+
},
|
|
724
|
+
field: (c, f, bytes) => {
|
|
725
|
+
if (churnCollection !== void 0) {
|
|
726
|
+
out.push({ kind: "churn", key: churnCollection, bytes });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const key = `${c.name}.${f.name}`;
|
|
730
|
+
out.push({
|
|
731
|
+
kind: fieldKind === "field" && c.name === PRESENCE_COLLECTION ? "presence" : fieldKind,
|
|
732
|
+
key,
|
|
733
|
+
bytes
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
rpcName(rpcId) {
|
|
739
|
+
try {
|
|
740
|
+
return rpcByIdOf(this.rpcSchema, rpcId).name;
|
|
741
|
+
} catch {
|
|
742
|
+
return `rpc#${rpcId}`;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
remember(peer, reqId, name) {
|
|
746
|
+
if (this.pending.size >= PENDING_CALLS_MAX) this.pending.clear();
|
|
747
|
+
this.pending.set(`${peer ?? ""}\0${reqId}`, name);
|
|
748
|
+
}
|
|
749
|
+
recall(peer, reqId) {
|
|
750
|
+
const k = `${peer ?? ""}\0${reqId}`;
|
|
751
|
+
const name = this.pending.get(k);
|
|
752
|
+
if (name === void 0) return "(unmatched reply)";
|
|
753
|
+
this.pending.delete(k);
|
|
754
|
+
return name;
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
function sum(entries) {
|
|
758
|
+
let n = 0;
|
|
759
|
+
for (const e of entries) n += e.bytes;
|
|
760
|
+
return n;
|
|
761
|
+
}
|
|
762
|
+
function skipStr(r) {
|
|
763
|
+
const n = r.varint();
|
|
764
|
+
if (r.remaining < n) throw new Error("unexpected end of buffer");
|
|
765
|
+
r.pos += n;
|
|
766
|
+
}
|
|
767
|
+
function diffProfiles(prev, next) {
|
|
768
|
+
const before = /* @__PURE__ */ new Map();
|
|
769
|
+
for (const r of prev.rows) before.set(`${r.kind} ${r.key}`, r);
|
|
770
|
+
const rows = [];
|
|
771
|
+
for (const r of next.rows) {
|
|
772
|
+
const b = before.get(`${r.kind} ${r.key}`);
|
|
773
|
+
const out = r.out - (b?.out ?? 0);
|
|
774
|
+
const inb = r.in - (b?.in ?? 0);
|
|
775
|
+
if (out !== 0 || inb !== 0) rows.push({ kind: r.kind, key: r.key, out, in: inb });
|
|
776
|
+
}
|
|
777
|
+
rows.sort((a, b) => b.out + b.in - (a.out + a.in) || (a.key < b.key ? -1 : 1));
|
|
778
|
+
return {
|
|
779
|
+
rows,
|
|
780
|
+
bytesIn: next.bytesIn - prev.bytesIn,
|
|
781
|
+
bytesOut: next.bytesOut - prev.bytesOut,
|
|
782
|
+
framesIn: next.framesIn - prev.framesIn,
|
|
783
|
+
framesOut: next.framesOut - prev.framesOut,
|
|
784
|
+
walks: next.walks - prev.walks
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
function topProfileRows(snap, n) {
|
|
788
|
+
return [...snap.rows].sort((a, b) => b.out + b.in - (a.out + a.in)).slice(0, n);
|
|
789
|
+
}
|
|
790
|
+
function scaleProfile(snap, n) {
|
|
791
|
+
if (n <= 1) return snap;
|
|
792
|
+
return {
|
|
793
|
+
rows: snap.rows.map((r) => ({ kind: r.kind, key: r.key, out: r.out / n, in: r.in / n })),
|
|
794
|
+
bytesIn: snap.bytesIn / n,
|
|
795
|
+
bytesOut: snap.bytesOut / n,
|
|
796
|
+
framesIn: snap.framesIn / n,
|
|
797
|
+
framesOut: snap.framesOut / n,
|
|
798
|
+
walks: snap.walks
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function mergeProfiles(a, b) {
|
|
802
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
803
|
+
for (const r of [...a.rows, ...b.rows]) {
|
|
804
|
+
const k = `${r.kind} ${r.key}`;
|
|
805
|
+
const row = byKey.get(k);
|
|
806
|
+
if (row) {
|
|
807
|
+
row.out += r.out;
|
|
808
|
+
row.in += r.in;
|
|
809
|
+
} else byKey.set(k, { kind: r.kind, key: r.key, out: r.out, in: r.in });
|
|
810
|
+
}
|
|
811
|
+
const rows = [...byKey.values()].sort(
|
|
812
|
+
(x, y) => y.out + y.in - (x.out + x.in) || (x.key < y.key ? -1 : 1)
|
|
813
|
+
);
|
|
814
|
+
return {
|
|
815
|
+
rows,
|
|
816
|
+
bytesIn: a.bytesIn + b.bytesIn,
|
|
817
|
+
bytesOut: a.bytesOut + b.bytesOut,
|
|
818
|
+
framesIn: a.framesIn + b.framesIn,
|
|
819
|
+
framesOut: a.framesOut + b.framesOut,
|
|
820
|
+
walks: a.walks + b.walks
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
326
824
|
// src/msg.ts
|
|
327
|
-
import { ByteReader as
|
|
825
|
+
import { ByteReader as ByteReader4, ByteWriter as ByteWriter3 } from "@irtio/schema";
|
|
328
826
|
var MSG_KIND_ALL = 0;
|
|
329
827
|
var MSG_KIND_CLIENT = 1;
|
|
330
828
|
var MSG_KIND_ROLE = 2;
|
|
331
829
|
var MSG_KIND_SERVER = 3;
|
|
830
|
+
var MSG_KIND_VOICE = 4;
|
|
831
|
+
function isVoiceMsg(payload) {
|
|
832
|
+
return payload.length > 0 && payload[0] === MSG_KIND_VOICE;
|
|
833
|
+
}
|
|
332
834
|
function encodeMsg(m) {
|
|
333
835
|
const w = new ByteWriter3();
|
|
334
836
|
switch (m.target.kind) {
|
|
@@ -346,12 +848,15 @@ function encodeMsg(m) {
|
|
|
346
848
|
case "server":
|
|
347
849
|
w.u8(MSG_KIND_SERVER);
|
|
348
850
|
break;
|
|
851
|
+
case "voice":
|
|
852
|
+
w.u8(MSG_KIND_VOICE);
|
|
853
|
+
break;
|
|
349
854
|
}
|
|
350
855
|
w.bytes(m.payload);
|
|
351
856
|
return w.finish();
|
|
352
857
|
}
|
|
353
858
|
function decodeMsg(bytes) {
|
|
354
|
-
const r = new
|
|
859
|
+
const r = new ByteReader4(bytes);
|
|
355
860
|
const kind = r.u8();
|
|
356
861
|
let target;
|
|
357
862
|
switch (kind) {
|
|
@@ -367,6 +872,9 @@ function decodeMsg(bytes) {
|
|
|
367
872
|
case MSG_KIND_SERVER:
|
|
368
873
|
target = { kind: "server" };
|
|
369
874
|
break;
|
|
875
|
+
case MSG_KIND_VOICE:
|
|
876
|
+
target = { kind: "voice" };
|
|
877
|
+
break;
|
|
370
878
|
default:
|
|
371
879
|
throw new Error(`decodeMsg: unknown target discriminator ${kind}`);
|
|
372
880
|
}
|
|
@@ -374,36 +882,316 @@ function decodeMsg(bytes) {
|
|
|
374
882
|
return { target, payload };
|
|
375
883
|
}
|
|
376
884
|
|
|
377
|
-
// src/
|
|
378
|
-
|
|
379
|
-
var
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
rpc: schema.rpc,
|
|
392
|
-
roles: schema.roles,
|
|
393
|
-
...schema.project !== void 0 ? { project: schema.project } : {},
|
|
394
|
-
allowReservedNames: true
|
|
395
|
-
}
|
|
396
|
-
);
|
|
397
|
-
extended.set(schema, ext);
|
|
885
|
+
// src/voice.ts
|
|
886
|
+
var ENC = new TextEncoder();
|
|
887
|
+
var DEC = new TextDecoder();
|
|
888
|
+
function encodeVoiceMessage(m) {
|
|
889
|
+
return ENC.encode(JSON.stringify(m));
|
|
890
|
+
}
|
|
891
|
+
function decodeVoiceMessage(bytes) {
|
|
892
|
+
try {
|
|
893
|
+
const parsed = JSON.parse(DEC.decode(bytes));
|
|
894
|
+
if (parsed === null || typeof parsed !== "object") return void 0;
|
|
895
|
+
if (typeof parsed.t !== "string") return void 0;
|
|
896
|
+
return parsed;
|
|
897
|
+
} catch {
|
|
898
|
+
return void 0;
|
|
398
899
|
}
|
|
399
|
-
return ext;
|
|
400
900
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
901
|
+
|
|
902
|
+
// src/codes.ts
|
|
903
|
+
var CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
904
|
+
var ROOM_TYPE_DELIMITER = ":";
|
|
905
|
+
var ROOM_TYPE_RE = /^[a-z0-9][a-z0-9_-]{0,23}$/;
|
|
906
|
+
var ROOM_ID_RE = /^(?:[a-z0-9][a-z0-9_-]{0,23}:)?[A-Za-z0-9_-]{1,32}$/;
|
|
907
|
+
var DEFAULT_ROOM_TYPE = "default";
|
|
908
|
+
function parseRoomId(raw) {
|
|
909
|
+
if (!ROOM_ID_RE.test(raw)) return void 0;
|
|
910
|
+
const at = raw.indexOf(ROOM_TYPE_DELIMITER);
|
|
911
|
+
if (at < 0) return { type: void 0, id: raw };
|
|
912
|
+
return { type: raw.slice(0, at), id: raw.slice(at + 1) };
|
|
913
|
+
}
|
|
914
|
+
function formatRoomId(type, id) {
|
|
915
|
+
return type === void 0 ? id : `${type}${ROOM_TYPE_DELIMITER}${id}`;
|
|
916
|
+
}
|
|
917
|
+
var MATCH_CODE_LENGTH = 10;
|
|
918
|
+
|
|
919
|
+
// src/bus.ts
|
|
920
|
+
var BUS_LIMITS = {
|
|
921
|
+
/**
|
|
922
|
+
* Max UTF-8 bytes in one payload, for both verbs.
|
|
923
|
+
*
|
|
924
|
+
* 4 KiB is a quarter of the KV value limit, and it is sized for what the feature is for: a
|
|
925
|
+
* match-ready notice, an invite, a bracket announcement — an id, a couple of names, a reason.
|
|
926
|
+
* A payload is also a *store* cost for `send`, because a mailbox entry persists in the alarm
|
|
927
|
+
* sidecar, so the ceiling on one room's mailbox is this times `mailboxDepth`, and both numbers
|
|
928
|
+
* were picked together.
|
|
929
|
+
*/
|
|
930
|
+
payloadBytes: 4 * 1024,
|
|
931
|
+
/** Max UTF-8 bytes in a channel name. Names are vocabulary, not data. */
|
|
932
|
+
channelBytes: 128,
|
|
933
|
+
/** Max channels one room may hold a subscription to at once. */
|
|
934
|
+
subscriptionsPerRoom: 32,
|
|
935
|
+
/**
|
|
936
|
+
* Max undelivered mailbox entries for one target room.
|
|
937
|
+
*
|
|
938
|
+
* This is a store bound as much as a memory one: entries live in the alarm sidecar, so at the
|
|
939
|
+
* payload cap above, a full mailbox is 256 KiB of persisted object. Past this, a `send` is
|
|
940
|
+
* refused at the sender with a named error rather than silently dropped at the target, because
|
|
941
|
+
* a sender that is outrunning a receiver is a fact the sender can act on.
|
|
942
|
+
*/
|
|
943
|
+
mailboxDepth: 64,
|
|
944
|
+
/**
|
|
945
|
+
* How many times one mailbox entry may be delivered before it is dead-lettered.
|
|
946
|
+
*
|
|
947
|
+
* The bound exists because at-least-once and the room crash threshold compose badly on purpose.
|
|
948
|
+
* A handler that always throws surfaces as a crash, and the supervisor's window is 3 restarts in
|
|
949
|
+
* 60 seconds; an entry that re-armed forever would close the room in four deliveries, on every
|
|
950
|
+
* wake, for as long as the entry existed. Five attempts is enough to ride out a transient (a
|
|
951
|
+
* deploy mid-flight, a wake that raced a hibernate) and few enough that a genuinely poisonous
|
|
952
|
+
* message costs a room one bad minute rather than its life. What happens then is a log line and
|
|
953
|
+
* a drop: honest for v1, and the docs say so rather than implying a dead-letter queue exists.
|
|
954
|
+
*/
|
|
955
|
+
maxDeliveryAttempts: 5,
|
|
956
|
+
/**
|
|
957
|
+
* Bus operations one room may issue per second, averaged, and the burst it may take at once.
|
|
958
|
+
*
|
|
959
|
+
* This is the "one hot room cannot melt the supervisor" guardrail D59 asks for. It counts both
|
|
960
|
+
* verbs together, because both cost the supervisor a fan-out or a store write, and a limiter
|
|
961
|
+
* that only counted the cheap one would be a limiter a room could route around.
|
|
962
|
+
*/
|
|
963
|
+
opsPerSecond: 50,
|
|
964
|
+
opsBurst: 100,
|
|
965
|
+
/**
|
|
966
|
+
* How long a delivered-but-unacknowledged mailbox entry stays armed before it is delivered
|
|
967
|
+
* again. A visibility timeout, and it is what makes at-least-once actually true rather than
|
|
968
|
+
* nearly true.
|
|
969
|
+
*
|
|
970
|
+
* Posting a message to a worker is not the same as the worker running it: a room that dies
|
|
971
|
+
* between the post and the handler (a crash, a restart, a hibernate that raced the delivery)
|
|
972
|
+
* would otherwise lose the message with no trace, which is at-most-once wearing at-least-once's
|
|
973
|
+
* name. So an entry is re-armed for this long at the moment it is posted, and cleared only when
|
|
974
|
+
* the worker says the handler ran. 30 seconds is far longer than a handler takes and short
|
|
975
|
+
* enough that a genuinely lost delivery is retried while anyone still cares.
|
|
976
|
+
*/
|
|
977
|
+
redeliveryDelayMs: 3e4
|
|
978
|
+
};
|
|
979
|
+
var BUS_CHANNEL_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
980
|
+
var BUS_ERRORS = {
|
|
981
|
+
badChannel: "E_BUS_BAD_CHANNEL",
|
|
982
|
+
payloadTooLarge: "E_BUS_PAYLOAD_TOO_LARGE",
|
|
983
|
+
tooManySubscriptions: "E_BUS_TOO_MANY_SUBSCRIPTIONS",
|
|
984
|
+
/** The named room does not exist in this tenant, or is a relay room and runs no handlers. */
|
|
985
|
+
noSuchRoom: "E_BUS_NO_SUCH_ROOM",
|
|
986
|
+
mailboxFull: "E_BUS_MAILBOX_FULL",
|
|
987
|
+
rateLimited: "E_BUS_RATE_LIMITED"
|
|
988
|
+
};
|
|
989
|
+
function utf8Bytes(s) {
|
|
990
|
+
return new TextEncoder().encode(s).length;
|
|
991
|
+
}
|
|
992
|
+
function busChannelProblem(channel) {
|
|
993
|
+
if (typeof channel !== "string" || channel === "") {
|
|
994
|
+
return { code: BUS_ERRORS.badChannel, message: "a channel name is required" };
|
|
995
|
+
}
|
|
996
|
+
if (utf8Bytes(channel) > BUS_LIMITS.channelBytes) {
|
|
997
|
+
return {
|
|
998
|
+
code: BUS_ERRORS.badChannel,
|
|
999
|
+
message: `channel name is longer than ${BUS_LIMITS.channelBytes} bytes`
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
if (!BUS_CHANNEL_RE.test(channel)) {
|
|
1003
|
+
return {
|
|
1004
|
+
code: BUS_ERRORS.badChannel,
|
|
1005
|
+
message: `illegal channel name ${JSON.stringify(channel)}: lowercase, starts alphanumeric, and made of letters, digits, dots, underscores and dashes`
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return void 0;
|
|
1009
|
+
}
|
|
1010
|
+
function busPayloadProblem(payload) {
|
|
1011
|
+
if (typeof payload !== "string") {
|
|
1012
|
+
return {
|
|
1013
|
+
code: BUS_ERRORS.payloadTooLarge,
|
|
1014
|
+
message: "a bus payload must be a string (serialise your own object)"
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const bytes = utf8Bytes(payload);
|
|
1018
|
+
if (bytes > BUS_LIMITS.payloadBytes) {
|
|
1019
|
+
return {
|
|
1020
|
+
code: BUS_ERRORS.payloadTooLarge,
|
|
1021
|
+
message: `payload is ${bytes} bytes; the limit is ${BUS_LIMITS.payloadBytes}`
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
return void 0;
|
|
1025
|
+
}
|
|
1026
|
+
var BUS_MAILBOX_PREFIX = "__bus.";
|
|
1027
|
+
function isMailboxAlarm(name) {
|
|
1028
|
+
return name.startsWith(BUS_MAILBOX_PREFIX);
|
|
1029
|
+
}
|
|
1030
|
+
var BUS_OUTBOX_PREFIX = "__busout.";
|
|
1031
|
+
function isOutboxAlarm(name) {
|
|
1032
|
+
return name.startsWith(BUS_OUTBOX_PREFIX);
|
|
1033
|
+
}
|
|
1034
|
+
var BUS_OUTBOX = {
|
|
1035
|
+
firstRetryMs: 1e3,
|
|
1036
|
+
maxRetryMs: 6e4,
|
|
1037
|
+
ttlMs: 60 * 60 * 1e3
|
|
1038
|
+
};
|
|
1039
|
+
var BUS_SHARD_STARTING = "E_SHARD_STARTING";
|
|
1040
|
+
|
|
1041
|
+
// src/classes.ts
|
|
1042
|
+
var ROOM_MEMORY_MB_MIN = 32;
|
|
1043
|
+
var ROOM_MEMORY_MB_MAX = 1024;
|
|
1044
|
+
var ROOM_CLASS_MAX_MB = {
|
|
1045
|
+
small: 64,
|
|
1046
|
+
medium: 256,
|
|
1047
|
+
large: ROOM_MEMORY_MB_MAX
|
|
1048
|
+
};
|
|
1049
|
+
function roomClassFor(memoryMb) {
|
|
1050
|
+
if (memoryMb === void 0 || !Number.isFinite(memoryMb)) return "small";
|
|
1051
|
+
if (memoryMb <= ROOM_CLASS_MAX_MB.small) return "small";
|
|
1052
|
+
if (memoryMb <= ROOM_CLASS_MAX_MB.medium) return "medium";
|
|
1053
|
+
return "large";
|
|
1054
|
+
}
|
|
1055
|
+
function roomHoursSourceFor(cls) {
|
|
1056
|
+
return cls === "small" ? "room" : cls;
|
|
1057
|
+
}
|
|
1058
|
+
function roomClassOfSource(source) {
|
|
1059
|
+
if (source === "room") return "small";
|
|
1060
|
+
if (source === "medium" || source === "large") return source;
|
|
1061
|
+
return void 0;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/sizing.ts
|
|
1065
|
+
var VM_MEM_MIB_MIN = 128;
|
|
1066
|
+
var VM_MEM_MIB_MAX = 4096;
|
|
1067
|
+
var SUPERVISOR_BASELINE_MIB = 54;
|
|
1068
|
+
var NATIVE_RESERVE_FRACTION = 8;
|
|
1069
|
+
var PHYSICS_HEADROOM_MIB = 112;
|
|
1070
|
+
var YOUNG_GEN_MB = 32;
|
|
1071
|
+
var DEFAULT_MEMORY_MB = 32;
|
|
1072
|
+
var DEFAULT_MAX_AWAKE = 1;
|
|
1073
|
+
var MAX_AWAKE_MAX = 256;
|
|
1074
|
+
function nativeReserveOf(vmMib) {
|
|
1075
|
+
return Math.floor(vmMib / NATIVE_RESERVE_FRACTION);
|
|
1076
|
+
}
|
|
1077
|
+
function typeFootprintMib(type) {
|
|
1078
|
+
const memoryMb = normalizeMemoryMb(type.memoryMb);
|
|
1079
|
+
const maxAwake = normalizeMaxAwake(type.maxAwake);
|
|
1080
|
+
return (memoryMb + YOUNG_GEN_MB) * maxAwake;
|
|
1081
|
+
}
|
|
1082
|
+
function normalizeMemoryMb(value) {
|
|
1083
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return DEFAULT_MEMORY_MB;
|
|
1084
|
+
return Math.floor(value);
|
|
1085
|
+
}
|
|
1086
|
+
function normalizeMaxAwake(value) {
|
|
1087
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_AWAKE;
|
|
1088
|
+
return Math.min(MAX_AWAKE_MAX, Math.floor(value));
|
|
1089
|
+
}
|
|
1090
|
+
function vmSizeFor(types, options = {}) {
|
|
1091
|
+
const baselineMib = options.baselineMib ?? SUPERVISOR_BASELINE_MIB;
|
|
1092
|
+
let workersMib = 0;
|
|
1093
|
+
let anyPhysics = false;
|
|
1094
|
+
for (const type of types) {
|
|
1095
|
+
workersMib += typeFootprintMib(type);
|
|
1096
|
+
if (type.physics === true) anyPhysics = true;
|
|
1097
|
+
}
|
|
1098
|
+
const physicsHeadroomMib = anyPhysics ? PHYSICS_HEADROOM_MIB : 0;
|
|
1099
|
+
const preReserveMib = workersMib + baselineMib + physicsHeadroomMib;
|
|
1100
|
+
const unclamped = Math.ceil(
|
|
1101
|
+
preReserveMib * NATIVE_RESERVE_FRACTION / (NATIVE_RESERVE_FRACTION - 1)
|
|
1102
|
+
);
|
|
1103
|
+
const vmMib = Math.min(VM_MEM_MIB_MAX, Math.max(VM_MEM_MIB_MIN, unclamped));
|
|
1104
|
+
const clamped = unclamped < VM_MEM_MIB_MIN ? "floor" : unclamped > VM_MEM_MIB_MAX ? "ceiling" : void 0;
|
|
1105
|
+
const parts = types.map(
|
|
1106
|
+
(t) => `${t.type} ${normalizeMemoryMb(t.memoryMb)}+${YOUNG_GEN_MB} MB x ${normalizeMaxAwake(t.maxAwake)}`
|
|
1107
|
+
).join(", ");
|
|
1108
|
+
const detail = `${vmMib} MiB from ${parts || "no room types"}, supervisor baseline ${baselineMib} MiB` + (physicsHeadroomMib > 0 ? `, physics headroom ${physicsHeadroomMib} MiB` : "") + `, native reserve ${vmMib - preReserveMib} MiB` + (clamped === "floor" ? ` (raised to the ${VM_MEM_MIB_MIN} MiB floor)` : clamped === "ceiling" ? ` (lowered to the ${VM_MEM_MIB_MAX} MiB ceiling)` : "");
|
|
1109
|
+
return {
|
|
1110
|
+
vmMib,
|
|
1111
|
+
workersMib,
|
|
1112
|
+
baselineMib,
|
|
1113
|
+
physicsHeadroomMib,
|
|
1114
|
+
preReserveMib,
|
|
1115
|
+
nativeReserveMib: vmMib - preReserveMib,
|
|
1116
|
+
...clamped !== void 0 ? { clamped } : {},
|
|
1117
|
+
detail
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
var ROOMS_PER_VCPU = {
|
|
1121
|
+
small: 20,
|
|
1122
|
+
medium: 8,
|
|
1123
|
+
large: 5
|
|
1124
|
+
};
|
|
1125
|
+
var CPU_QUOTA_PCT_MIN = 10;
|
|
1126
|
+
var CPU_QUOTA_PCT_MAX = 100;
|
|
1127
|
+
function vmCpuFor(types) {
|
|
1128
|
+
if (types.length === 0) {
|
|
1129
|
+
return {
|
|
1130
|
+
quotaPct: CPU_QUOTA_PCT_MAX,
|
|
1131
|
+
rawPct: CPU_QUOTA_PCT_MAX,
|
|
1132
|
+
undeclared: true,
|
|
1133
|
+
detail: "no room types declared, so no CPU throttle"
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
const declared = types.some(
|
|
1137
|
+
(t) => t.memoryMb !== void 0 || t.maxAwake !== void 0 && t.maxAwake > 0
|
|
1138
|
+
);
|
|
1139
|
+
if (!declared) {
|
|
1140
|
+
return {
|
|
1141
|
+
quotaPct: CPU_QUOTA_PCT_MAX,
|
|
1142
|
+
rawPct: CPU_QUOTA_PCT_MAX,
|
|
1143
|
+
undeclared: true,
|
|
1144
|
+
detail: `${types.length} room type(s), none declaring memoryMb or maxAwake, so no CPU throttle`
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
let rawPct = 0;
|
|
1148
|
+
const parts = [];
|
|
1149
|
+
for (const type of types) {
|
|
1150
|
+
const cls = roomClassFor(normalizeMemoryMbForClass(type.memoryMb));
|
|
1151
|
+
const maxAwake = normalizeMaxAwake(type.maxAwake);
|
|
1152
|
+
const density = ROOMS_PER_VCPU[cls];
|
|
1153
|
+
const share = maxAwake * CPU_QUOTA_PCT_MAX / density;
|
|
1154
|
+
rawPct += share;
|
|
1155
|
+
parts.push(`${type.type} ${cls} x ${maxAwake} / ${density} per vCPU`);
|
|
1156
|
+
}
|
|
1157
|
+
const rounded = Math.ceil(rawPct);
|
|
1158
|
+
const quotaPct = Math.min(CPU_QUOTA_PCT_MAX, Math.max(CPU_QUOTA_PCT_MIN, rounded));
|
|
1159
|
+
const clamped = rounded < CPU_QUOTA_PCT_MIN ? "floor" : rounded > CPU_QUOTA_PCT_MAX ? "ceiling" : void 0;
|
|
1160
|
+
return {
|
|
1161
|
+
quotaPct,
|
|
1162
|
+
rawPct,
|
|
1163
|
+
undeclared: false,
|
|
1164
|
+
...clamped !== void 0 ? { clamped } : {},
|
|
1165
|
+
detail: `CPUQuota ${quotaPct}% from ${parts.join(", ")}` + (clamped === "floor" ? ` (raised to the ${CPU_QUOTA_PCT_MIN}% floor)` : clamped === "ceiling" ? ` (lowered to the ${CPU_QUOTA_PCT_MAX}% ceiling from ${rounded}%)` : "")
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function normalizeMemoryMbForClass(value) {
|
|
1169
|
+
if (value === void 0 || !Number.isFinite(value) || value <= 0) return void 0;
|
|
1170
|
+
return Math.floor(value);
|
|
1171
|
+
}
|
|
1172
|
+
function declarationsFit(vmMemMib, types, options = {}) {
|
|
1173
|
+
const required = vmSizeFor(types, options);
|
|
1174
|
+
return { fits: required.vmMib <= vmMemMib, required };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/retention.ts
|
|
1178
|
+
var RETENTION_RE = /^[1-9][0-9]{0,4}(m|h|d)$/;
|
|
1179
|
+
var RETENTION_MIN_MS = 6e4;
|
|
1180
|
+
var RETENTION_MAX_MS = 3650 * 24 * 60 * 60 * 1e3;
|
|
1181
|
+
var UNIT_MS = {
|
|
1182
|
+
m: 6e4,
|
|
1183
|
+
h: 60 * 60 * 1e3,
|
|
1184
|
+
d: 24 * 60 * 60 * 1e3
|
|
1185
|
+
};
|
|
1186
|
+
function parseRetention(raw) {
|
|
1187
|
+
if (typeof raw !== "string") return void 0;
|
|
1188
|
+
if (!RETENTION_RE.test(raw)) return void 0;
|
|
1189
|
+
const unit = raw.slice(-1);
|
|
1190
|
+
const scale = UNIT_MS[unit];
|
|
1191
|
+
if (scale === void 0) return void 0;
|
|
1192
|
+
const ms = Number(raw.slice(0, -1)) * scale;
|
|
1193
|
+
if (ms < RETENTION_MIN_MS || ms > RETENTION_MAX_MS) return void 0;
|
|
1194
|
+
return ms;
|
|
407
1195
|
}
|
|
408
1196
|
|
|
409
1197
|
// src/origin.ts
|
|
@@ -429,14 +1217,54 @@ function parseOriginList(raw) {
|
|
|
429
1217
|
return raw.split(",").map((o) => o.trim()).filter((o) => o.length > 0);
|
|
430
1218
|
}
|
|
431
1219
|
export {
|
|
1220
|
+
BUS_CHANNEL_RE,
|
|
1221
|
+
BUS_ERRORS,
|
|
1222
|
+
BUS_LIMITS,
|
|
1223
|
+
BUS_MAILBOX_PREFIX,
|
|
1224
|
+
BUS_OUTBOX,
|
|
1225
|
+
BUS_OUTBOX_PREFIX,
|
|
1226
|
+
BUS_SHARD_STARTING,
|
|
1227
|
+
CLOSE_EGRESS_WALL,
|
|
1228
|
+
CLOSE_TRY_AGAIN_LATER,
|
|
1229
|
+
CODE_ALPHABET,
|
|
1230
|
+
CPU_QUOTA_PCT_MAX,
|
|
1231
|
+
CPU_QUOTA_PCT_MIN,
|
|
1232
|
+
DEFAULT_MAX_AWAKE,
|
|
1233
|
+
DEFAULT_MEMORY_MB,
|
|
1234
|
+
DEFAULT_ROOM_TYPE,
|
|
1235
|
+
EMPTY_PROFILE,
|
|
432
1236
|
ERROR_CATALOGUE,
|
|
433
1237
|
ErrorCode,
|
|
434
1238
|
FrameType,
|
|
1239
|
+
HELLO_SCHEMA_SWAP_BIT,
|
|
1240
|
+
MATCH_CODE_LENGTH,
|
|
1241
|
+
MAX_AWAKE_MAX,
|
|
1242
|
+
NATIVE_RESERVE_FRACTION,
|
|
1243
|
+
PHYSICS_HEADROOM_MIB,
|
|
435
1244
|
PRESENCE_COLLECTION,
|
|
1245
|
+
PROFILE_KINDS,
|
|
436
1246
|
PROTOCOL_VERSION,
|
|
1247
|
+
ProfileLedger,
|
|
437
1248
|
RELAY_HASH8,
|
|
1249
|
+
RETENTION_MAX_MS,
|
|
1250
|
+
RETENTION_MIN_MS,
|
|
1251
|
+
RETENTION_RE,
|
|
1252
|
+
ROOMS_PER_VCPU,
|
|
1253
|
+
ROOM_CLASS_MAX_MB,
|
|
1254
|
+
ROOM_ID_RE,
|
|
1255
|
+
ROOM_MEMORY_MB_MAX,
|
|
1256
|
+
ROOM_MEMORY_MB_MIN,
|
|
1257
|
+
ROOM_TYPE_DELIMITER,
|
|
1258
|
+
ROOM_TYPE_RE,
|
|
1259
|
+
SUPERVISOR_BASELINE_MIB,
|
|
1260
|
+
VM_MEM_MIB_MAX,
|
|
1261
|
+
VM_MEM_MIB_MIN,
|
|
1262
|
+
YOUNG_GEN_MB,
|
|
438
1263
|
builtinRpcs,
|
|
1264
|
+
busChannelProblem,
|
|
1265
|
+
busPayloadProblem,
|
|
439
1266
|
correctPayload,
|
|
1267
|
+
declarationsFit,
|
|
440
1268
|
decodeCall,
|
|
441
1269
|
decodeErrorPayload,
|
|
442
1270
|
decodeFrame,
|
|
@@ -445,8 +1273,10 @@ export {
|
|
|
445
1273
|
decodePing,
|
|
446
1274
|
decodePong,
|
|
447
1275
|
decodeReply,
|
|
1276
|
+
decodeVoiceMessage,
|
|
448
1277
|
decodeWelcome,
|
|
449
1278
|
deltaPayload,
|
|
1279
|
+
diffProfiles,
|
|
450
1280
|
encodeCall,
|
|
451
1281
|
encodeCorrectFrame,
|
|
452
1282
|
encodeDeltaFrame,
|
|
@@ -457,23 +1287,43 @@ export {
|
|
|
457
1287
|
encodePing,
|
|
458
1288
|
encodePong,
|
|
459
1289
|
encodeReply,
|
|
1290
|
+
encodeSchemaFrame,
|
|
1291
|
+
encodeVoiceMessage,
|
|
460
1292
|
encodeWelcome,
|
|
461
1293
|
encodeWriteFrame,
|
|
462
1294
|
errorByCode,
|
|
463
1295
|
formatError,
|
|
1296
|
+
formatRoomId,
|
|
464
1297
|
isFrameType,
|
|
465
1298
|
isLocalhostOrigin,
|
|
1299
|
+
isMailboxAlarm,
|
|
1300
|
+
isOutboxAlarm,
|
|
466
1301
|
isRelayHash8,
|
|
1302
|
+
isVoiceMsg,
|
|
1303
|
+
mergeProfiles,
|
|
1304
|
+
nativeReserveOf,
|
|
467
1305
|
originAllowed,
|
|
468
1306
|
parseOriginList,
|
|
1307
|
+
parseRetention,
|
|
1308
|
+
parseRoomId,
|
|
469
1309
|
presenceEntity,
|
|
470
1310
|
readCorrectAppliedTick,
|
|
471
1311
|
readCorrectClientTick,
|
|
1312
|
+
readSchemaPayload,
|
|
472
1313
|
relaySchema,
|
|
473
1314
|
requestOwnership,
|
|
1315
|
+
roomClassFor,
|
|
1316
|
+
roomClassOfSource,
|
|
1317
|
+
roomHoursSourceFor,
|
|
474
1318
|
rpcByIdOf,
|
|
475
1319
|
rpcIdOf,
|
|
476
1320
|
rpcTable,
|
|
1321
|
+
scaleProfile,
|
|
1322
|
+
schemaPayload,
|
|
1323
|
+
topProfileRows,
|
|
1324
|
+
typeFootprintMib,
|
|
1325
|
+
vmCpuFor,
|
|
1326
|
+
vmSizeFor,
|
|
477
1327
|
withBuiltins,
|
|
478
1328
|
writePayload
|
|
479
1329
|
};
|