@neta-art/cohub 11.1.0 → 11.3.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/board/editor/context.d.ts +1 -0
- package/dist/board/editor/context.js +1 -0
- package/dist/board/editor/editor.d.ts +1 -0
- package/dist/board/editor/editor.js +7 -0
- package/dist/board/editor/interaction.d.ts +4 -0
- package/dist/board/editor/interaction.js +16 -7
- package/dist/board/editor/selection-transform.d.ts +1 -1
- package/dist/board/editor/selection-transform.js +13 -2
- package/dist/board/export/index.d.ts +2 -0
- package/dist/board/export/index.js +1 -0
- package/dist/board/export/scene.d.ts +2 -0
- package/dist/board/export/scene.js +4 -0
- package/dist/board/model/file-preview.d.ts +0 -1
- package/dist/board/model/file-preview.js +10 -1
- package/dist/board/model/index.d.ts +2 -1
- package/dist/board/model/index.js +2 -1
- package/dist/board/model/script-api.d.ts +69 -0
- package/dist/board/model/script-api.js +77 -0
- package/dist/board/render/audio-waveform.d.ts +1 -1
- package/dist/board/render/audio-waveform.js +8 -5
- package/dist/board/render/card-tag.d.ts +24 -0
- package/dist/board/render/card-tag.js +205 -0
- package/dist/board/render/index.d.ts +7 -4
- package/dist/board/render/index.js +7 -4
- package/dist/board/render/media-interaction.d.ts +2 -1
- package/dist/board/render/media-interaction.js +14 -6
- package/dist/board/render/palette.js +2 -2
- package/dist/board/render/renderers/arrow-card-renderer.js +20 -16
- package/dist/board/render/renderers/audio-card-renderer.d.ts +8 -1
- package/dist/board/render/renderers/audio-card-renderer.js +64 -60
- package/dist/board/render/renderers/base-card-renderer.d.ts +1 -26
- package/dist/board/render/renderers/base-card-renderer.js +6 -92
- package/dist/board/render/renderers/board-renderer-registry.d.ts +8 -0
- package/dist/board/render/renderers/board-renderer-registry.js +3 -3
- package/dist/board/render/renderers/custom-card-renderer.js +4 -7
- package/dist/board/render/renderers/effect-card-renderer.js +0 -5
- package/dist/board/render/renderers/file-card-renderer.d.ts +1 -11
- package/dist/board/render/renderers/file-card-renderer.js +43 -105
- package/dist/board/render/renderers/frame-card-renderer.js +14 -15
- package/dist/board/render/renderers/image-card-renderer.js +62 -67
- package/dist/board/render/renderers/media-key.d.ts +6 -0
- package/dist/board/render/renderers/media-key.js +39 -0
- package/dist/board/render/renderers/shape-card-renderer.js +6 -12
- package/dist/board/render/renderers/task-card-renderer.d.ts +1 -9
- package/dist/board/render/renderers/task-card-renderer.js +82 -56
- package/dist/board/render/renderers/unknown-card-renderer.js +17 -16
- package/dist/board/render/renderers/video-card-renderer.js +73 -80
- package/dist/board/render/stroke.d.ts +1 -1
- package/dist/board/render/stroke.js +5 -4
- package/dist/board/render/style.d.ts +29 -0
- package/dist/board/render/style.js +54 -0
- package/dist/board/render/surface.d.ts +34 -0
- package/dist/board/render/surface.js +132 -0
- package/dist/board/render/text-fit.d.ts +8 -0
- package/dist/board/render/text-fit.js +60 -0
- package/dist/board/render/text-resolution.d.ts +2 -1
- package/dist/board/render/text-resolution.js +4 -1
- package/dist/board/stage/index.d.ts +3 -2
- package/dist/board/stage/index.js +2 -1
- package/dist/board/stage/overlay.d.ts +41 -0
- package/dist/board/stage/overlay.js +182 -0
- package/dist/board/stage/scene.d.ts +2 -43
- package/dist/board/stage/scene.js +23 -131
- package/dist/board/stage/stage.d.ts +2 -0
- package/dist/board/stage/stage.js +99 -32
- package/dist/chunks/app-realtime.js +1697 -0
- package/dist/chunks/environment.d.ts +997 -3
- package/dist/chunks/http.d.ts +393 -3
- package/dist/chunks/http.js +40 -1
- package/dist/chunks/websocket.d.ts +4 -0
- package/dist/chunks/websocket.js +11 -2
- package/dist/http.d.ts +2 -2
- package/dist/index.d.ts +4 -323
- package/dist/index.js +58 -1660
- package/dist/protocol/dist/board-model.js +51 -51
- package/dist/protocol/dist/board-schema.d.ts +1 -2
- package/dist/protocol/dist/board-schema.js +7 -22
- package/package.json +2 -1
|
@@ -0,0 +1,1697 @@
|
|
|
1
|
+
import { O as readRtcTransportStats, k as waitForIceGathering } from "./http.js";
|
|
2
|
+
import { o as REALTIME_ROOM_EVENT_NAME_PATTERN, t as HttpError } from "./transport.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __exportAll = (all, no_symbols) => {
|
|
7
|
+
let target = {};
|
|
8
|
+
for (var name in all) __defProp(target, name, {
|
|
9
|
+
get: all[name],
|
|
10
|
+
enumerable: true
|
|
11
|
+
});
|
|
12
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
13
|
+
return target;
|
|
14
|
+
};
|
|
15
|
+
z.object({
|
|
16
|
+
urls: z.union([z.string().min(1).max(512), z.array(z.string().min(1).max(512)).min(1).max(16)]),
|
|
17
|
+
username: z.string().max(512).optional(),
|
|
18
|
+
credential: z.string().max(512).optional()
|
|
19
|
+
});
|
|
20
|
+
const sessionDescriptionSchema = z.object({
|
|
21
|
+
type: z.enum(["offer", "answer"]),
|
|
22
|
+
sdp: z.string().min(1).max(65536)
|
|
23
|
+
});
|
|
24
|
+
const APP_RTC_DELIVERIES = ["reliable", "latest"];
|
|
25
|
+
const APP_RTC_PUBLICATION_KINDS = [
|
|
26
|
+
"data",
|
|
27
|
+
"audio",
|
|
28
|
+
"video"
|
|
29
|
+
];
|
|
30
|
+
const APP_RTC_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
31
|
+
const isAppRtcName = (value) => typeof value === "string" && APP_RTC_NAME_PATTERN.test(value) && !value.toLowerCase().startsWith("cohub.");
|
|
32
|
+
const APP_RTC_LIMITS = {
|
|
33
|
+
channelsPerMember: 16,
|
|
34
|
+
tracksPerMember: 4,
|
|
35
|
+
dataSubscriptions: 512,
|
|
36
|
+
mediaSubscriptions: 16,
|
|
37
|
+
messageBytes: 65536,
|
|
38
|
+
operationsPerSecond: 10,
|
|
39
|
+
opensPerSecond: 2,
|
|
40
|
+
batch: 64
|
|
41
|
+
};
|
|
42
|
+
const APP_RTC_QUALITIES = [
|
|
43
|
+
"high",
|
|
44
|
+
"medium",
|
|
45
|
+
"low"
|
|
46
|
+
];
|
|
47
|
+
const APP_RTC_SIMULCAST_LAYERS = [
|
|
48
|
+
{
|
|
49
|
+
rid: "f",
|
|
50
|
+
quality: "high",
|
|
51
|
+
scaleResolutionDownBy: 1
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
rid: "h",
|
|
55
|
+
quality: "medium",
|
|
56
|
+
scaleResolutionDownBy: 2
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
rid: "q",
|
|
60
|
+
quality: "low",
|
|
61
|
+
scaleResolutionDownBy: 4
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
z.object({
|
|
65
|
+
id: z.string().min(1).max(32),
|
|
66
|
+
name: z.string().regex(APP_RTC_NAME_PATTERN),
|
|
67
|
+
kind: z.enum(APP_RTC_PUBLICATION_KINDS),
|
|
68
|
+
delivery: z.enum(APP_RTC_DELIVERIES).optional(),
|
|
69
|
+
simulcast: z.boolean().optional()
|
|
70
|
+
});
|
|
71
|
+
const nameSchema = z.string().refine(isAppRtcName, "invalid name");
|
|
72
|
+
const participantIdSchema = z.string().uuid();
|
|
73
|
+
z.object({
|
|
74
|
+
ticket: z.string().min(1).max(4096),
|
|
75
|
+
participantId: participantIdSchema
|
|
76
|
+
});
|
|
77
|
+
z.object({ answer: sessionDescriptionSchema.refine((value) => value.type === "answer", "expected an answer") });
|
|
78
|
+
z.union([z.object({ channels: z.array(z.object({
|
|
79
|
+
name: nameSchema,
|
|
80
|
+
delivery: z.enum(APP_RTC_DELIVERIES)
|
|
81
|
+
})).min(1).max(APP_RTC_LIMITS.channelsPerMember) }), z.object({
|
|
82
|
+
offer: sessionDescriptionSchema.refine((value) => value.type === "offer", "expected an offer"),
|
|
83
|
+
tracks: z.array(z.object({
|
|
84
|
+
name: nameSchema,
|
|
85
|
+
kind: z.enum(["audio", "video"]),
|
|
86
|
+
mid: z.string().min(1).max(32),
|
|
87
|
+
simulcast: z.boolean().optional()
|
|
88
|
+
})).min(1).max(APP_RTC_LIMITS.tracksPerMember)
|
|
89
|
+
})]);
|
|
90
|
+
z.object({
|
|
91
|
+
ids: z.array(z.string().min(1).max(32)).min(1).max(APP_RTC_LIMITS.channelsPerMember + APP_RTC_LIMITS.tracksPerMember),
|
|
92
|
+
offer: sessionDescriptionSchema.refine((value) => value.type === "offer", "expected an offer").optional()
|
|
93
|
+
});
|
|
94
|
+
const subscriptionRefSchema = z.object({
|
|
95
|
+
participantId: participantIdSchema,
|
|
96
|
+
publicationId: z.string().min(1).max(32)
|
|
97
|
+
});
|
|
98
|
+
const qualitySchema = z.enum(APP_RTC_QUALITIES);
|
|
99
|
+
z.object({
|
|
100
|
+
kind: z.enum(["data", "media"]),
|
|
101
|
+
items: z.array(subscriptionRefSchema.extend({ quality: qualitySchema.optional() })).min(1).max(APP_RTC_LIMITS.batch)
|
|
102
|
+
});
|
|
103
|
+
z.object({ items: z.array(subscriptionRefSchema.extend({
|
|
104
|
+
mid: z.string().min(1).max(32),
|
|
105
|
+
quality: qualitySchema.optional()
|
|
106
|
+
})).min(1).max(APP_RTC_LIMITS.mediaSubscriptions) });
|
|
107
|
+
z.object({
|
|
108
|
+
items: z.array(subscriptionRefSchema).min(1).max(APP_RTC_LIMITS.batch),
|
|
109
|
+
offer: sessionDescriptionSchema.refine((value) => value.type === "offer", "expected an offer").optional()
|
|
110
|
+
});
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/apis/app-room-rtc.ts
|
|
113
|
+
var AppRtcError = class extends Error {
|
|
114
|
+
code;
|
|
115
|
+
constructor(code, message) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.code = code;
|
|
118
|
+
this.name = "AppRtcError";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const SERVER_CODES = /* @__PURE__ */ new Set([
|
|
122
|
+
"RTC_UNAVAILABLE",
|
|
123
|
+
"RTC_FORBIDDEN",
|
|
124
|
+
"RTC_LIMIT_EXCEEDED",
|
|
125
|
+
"RTC_RATE_LIMITED",
|
|
126
|
+
"ROOM_NOT_FOUND",
|
|
127
|
+
"ROOM_EXPIRED"
|
|
128
|
+
]);
|
|
129
|
+
const toAppRtcError = (error) => {
|
|
130
|
+
if (error instanceof AppRtcError) return error;
|
|
131
|
+
if (error instanceof HttpError && SERVER_CODES.has(error.code)) return new AppRtcError(error.code, error.message);
|
|
132
|
+
return new AppRtcError("RTC_FAILED", error instanceof Error ? error.message : String(error));
|
|
133
|
+
};
|
|
134
|
+
const HEADER_BYTES = 5;
|
|
135
|
+
const FLAG_BINARY = 1;
|
|
136
|
+
const encoder = new TextEncoder();
|
|
137
|
+
const decoder = new TextDecoder();
|
|
138
|
+
function encodeAppRtcFrame(data, sequence) {
|
|
139
|
+
let payload;
|
|
140
|
+
let flags = 0;
|
|
141
|
+
if (data instanceof ArrayBuffer) {
|
|
142
|
+
payload = new Uint8Array(data);
|
|
143
|
+
flags = FLAG_BINARY;
|
|
144
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
145
|
+
payload = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
146
|
+
flags = FLAG_BINARY;
|
|
147
|
+
} else {
|
|
148
|
+
const json = JSON.stringify(data);
|
|
149
|
+
if (json === void 0) throw new TypeError("RTC message is not serializable");
|
|
150
|
+
payload = encoder.encode(json);
|
|
151
|
+
}
|
|
152
|
+
if (payload.byteLength > APP_RTC_LIMITS.messageBytes) throw new RangeError(`RTC message exceeds ${APP_RTC_LIMITS.messageBytes} bytes`);
|
|
153
|
+
const frame = new Uint8Array(HEADER_BYTES + payload.byteLength);
|
|
154
|
+
frame[0] = flags;
|
|
155
|
+
new DataView(frame.buffer).setUint32(1, sequence >>> 0);
|
|
156
|
+
frame.set(payload, HEADER_BYTES);
|
|
157
|
+
return frame;
|
|
158
|
+
}
|
|
159
|
+
function decodeAppRtcFrame(frame) {
|
|
160
|
+
if (frame.byteLength < HEADER_BYTES) return null;
|
|
161
|
+
const view = new DataView(frame);
|
|
162
|
+
const flags = view.getUint8(0);
|
|
163
|
+
const sequence = view.getUint32(1);
|
|
164
|
+
const payload = new Uint8Array(frame, HEADER_BYTES);
|
|
165
|
+
if (flags & FLAG_BINARY) return {
|
|
166
|
+
data: payload.slice().buffer,
|
|
167
|
+
sequence
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
return {
|
|
171
|
+
data: JSON.parse(decoder.decode(payload)),
|
|
172
|
+
sequence
|
|
173
|
+
};
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const isNewerSequence = (next, last) => last === null || next !== last && next - last >>> 0 < 2147483648;
|
|
179
|
+
const publicationsKey = (members) => members.map((member) => `${member.participantId}:${member.publications?.map((item) => item.id).join(",") ?? ""}`).join(";");
|
|
180
|
+
const GATHER_TIMEOUT_MS = 2e3;
|
|
181
|
+
const CONNECT_TIMEOUT_MS = 15e3;
|
|
182
|
+
const PENDING_RELIABLE_FRAMES = 256;
|
|
183
|
+
const LATEST_BACKLOG_BYTES = 65536;
|
|
184
|
+
const RECONNECT_DELAYS_MS = [
|
|
185
|
+
500,
|
|
186
|
+
1e3,
|
|
187
|
+
2e3,
|
|
188
|
+
5e3,
|
|
189
|
+
1e4,
|
|
190
|
+
3e4
|
|
191
|
+
];
|
|
192
|
+
const RATE_LIMIT_RETRY_MS = 1e3;
|
|
193
|
+
var ConnectionReplacedError = class extends Error {
|
|
194
|
+
constructor() {
|
|
195
|
+
super("RTC connection was replaced");
|
|
196
|
+
this.name = "ConnectionReplacedError";
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const policyOf = (delivery) => delivery === "latest" ? {
|
|
200
|
+
ordered: false,
|
|
201
|
+
maxRetransmits: 0
|
|
202
|
+
} : { ordered: true };
|
|
203
|
+
const isSessionLost = (error) => error instanceof HttpError && (error.code === "RTC_STALE" || error.code === "RTC_SESSION_NOT_FOUND");
|
|
204
|
+
const isRoomGone = (error) => error instanceof HttpError && (error.code === "ROOM_NOT_FOUND" || error.code === "ROOM_EXPIRED");
|
|
205
|
+
const chunk = (items, size) => {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size));
|
|
208
|
+
return chunks;
|
|
209
|
+
};
|
|
210
|
+
const mediaKey = (participantId, publicationId) => `${participantId}:${publicationId}`;
|
|
211
|
+
var AppRtcChannel = class {
|
|
212
|
+
rtc;
|
|
213
|
+
name;
|
|
214
|
+
delivery;
|
|
215
|
+
handlers = /* @__PURE__ */ new Set();
|
|
216
|
+
peerHandlers = /* @__PURE__ */ new Set();
|
|
217
|
+
openPeers = /* @__PURE__ */ new Set();
|
|
218
|
+
publication = null;
|
|
219
|
+
pending = [];
|
|
220
|
+
offFlush = null;
|
|
221
|
+
sequence = 0;
|
|
222
|
+
closed = false;
|
|
223
|
+
constructor(rtc, name, delivery) {
|
|
224
|
+
this.rtc = rtc;
|
|
225
|
+
this.name = name;
|
|
226
|
+
this.delivery = delivery;
|
|
227
|
+
}
|
|
228
|
+
get peers() {
|
|
229
|
+
return [...this.openPeers];
|
|
230
|
+
}
|
|
231
|
+
send(data) {
|
|
232
|
+
if (this.closed || this.rtc.state === "closed") return false;
|
|
233
|
+
this.sequence = this.sequence + 1 >>> 0;
|
|
234
|
+
const frame = encodeAppRtcFrame(data, this.sequence);
|
|
235
|
+
const channel = this.publication?.channel;
|
|
236
|
+
if (channel?.readyState === "open" && this.pending.length === 0) return this.write(channel, frame);
|
|
237
|
+
if (this.delivery === "latest") this.pending = [frame];
|
|
238
|
+
else if (this.pending.length >= PENDING_RELIABLE_FRAMES) return false;
|
|
239
|
+
else this.pending.push(frame);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
onMessage(handler) {
|
|
243
|
+
this.handlers.add(handler);
|
|
244
|
+
return () => this.handlers.delete(handler);
|
|
245
|
+
}
|
|
246
|
+
/** Reports when this client starts or stops receiving a peer's channel. */
|
|
247
|
+
onPeer(handler) {
|
|
248
|
+
this.peerHandlers.add(handler);
|
|
249
|
+
return () => this.peerHandlers.delete(handler);
|
|
250
|
+
}
|
|
251
|
+
close() {
|
|
252
|
+
if (this.closed) return;
|
|
253
|
+
this.closed = true;
|
|
254
|
+
this.handlers.clear();
|
|
255
|
+
this.peerHandlers.clear();
|
|
256
|
+
this.openPeers.clear();
|
|
257
|
+
this.pending = [];
|
|
258
|
+
this.offFlush?.();
|
|
259
|
+
this.offFlush = null;
|
|
260
|
+
this.rtc.closeChannel(this);
|
|
261
|
+
}
|
|
262
|
+
/** @internal */
|
|
263
|
+
get isOpen() {
|
|
264
|
+
return !this.closed;
|
|
265
|
+
}
|
|
266
|
+
/** @internal */
|
|
267
|
+
get publicationId() {
|
|
268
|
+
return this.publication?.id ?? null;
|
|
269
|
+
}
|
|
270
|
+
/** @internal */
|
|
271
|
+
attach(id, channel) {
|
|
272
|
+
this.offFlush?.();
|
|
273
|
+
this.publication = {
|
|
274
|
+
id,
|
|
275
|
+
channel
|
|
276
|
+
};
|
|
277
|
+
channel.binaryType = "arraybuffer";
|
|
278
|
+
channel.bufferedAmountLowThreshold = LATEST_BACKLOG_BYTES;
|
|
279
|
+
const flush = () => {
|
|
280
|
+
let sent = 0;
|
|
281
|
+
while (sent < this.pending.length) {
|
|
282
|
+
const frame = this.pending[sent];
|
|
283
|
+
if (!frame || !this.write(channel, frame)) break;
|
|
284
|
+
sent++;
|
|
285
|
+
}
|
|
286
|
+
this.pending = this.pending.slice(sent);
|
|
287
|
+
};
|
|
288
|
+
channel.addEventListener("open", flush);
|
|
289
|
+
channel.addEventListener("bufferedamountlow", flush);
|
|
290
|
+
this.offFlush = () => {
|
|
291
|
+
channel.removeEventListener("open", flush);
|
|
292
|
+
channel.removeEventListener("bufferedamountlow", flush);
|
|
293
|
+
};
|
|
294
|
+
if (channel.readyState === "open") flush();
|
|
295
|
+
}
|
|
296
|
+
/** @internal */
|
|
297
|
+
detach() {
|
|
298
|
+
this.offFlush?.();
|
|
299
|
+
this.offFlush = null;
|
|
300
|
+
this.publication = null;
|
|
301
|
+
}
|
|
302
|
+
/** @internal */
|
|
303
|
+
fail() {
|
|
304
|
+
this.close();
|
|
305
|
+
}
|
|
306
|
+
/** @internal */
|
|
307
|
+
deliver(from, data) {
|
|
308
|
+
for (const handler of this.handlers) handler({
|
|
309
|
+
from,
|
|
310
|
+
data
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** @internal */
|
|
314
|
+
setPeer(participantId, open) {
|
|
315
|
+
if (open ? this.openPeers.has(participantId) : !this.openPeers.delete(participantId)) return;
|
|
316
|
+
if (open) this.openPeers.add(participantId);
|
|
317
|
+
for (const handler of this.peerHandlers) handler({
|
|
318
|
+
participantId,
|
|
319
|
+
open
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
write(channel, frame) {
|
|
323
|
+
if (channel.bufferedAmount > (this.delivery === "latest" ? LATEST_BACKLOG_BYTES : 4194304)) return false;
|
|
324
|
+
try {
|
|
325
|
+
channel.send(frame);
|
|
326
|
+
return true;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
var AppRoomRtc = class {
|
|
333
|
+
host;
|
|
334
|
+
_state = "connecting";
|
|
335
|
+
pc = null;
|
|
336
|
+
sessionId = null;
|
|
337
|
+
queue = Promise.resolve();
|
|
338
|
+
releasePending = Promise.resolve();
|
|
339
|
+
reconcileQueued = false;
|
|
340
|
+
reconcileFailures = 0;
|
|
341
|
+
reconnectAttempt = 0;
|
|
342
|
+
reconnectTimer = null;
|
|
343
|
+
generation = 0;
|
|
344
|
+
filter = null;
|
|
345
|
+
channels = /* @__PURE__ */ new Map();
|
|
346
|
+
channelsToPublish = /* @__PURE__ */ new Set();
|
|
347
|
+
remoteData = /* @__PURE__ */ new Map();
|
|
348
|
+
localTracks = /* @__PURE__ */ new Map();
|
|
349
|
+
remoteMedia = /* @__PURE__ */ new Map();
|
|
350
|
+
mediaByMid = /* @__PURE__ */ new Map();
|
|
351
|
+
stateHandlers = /* @__PURE__ */ new Set();
|
|
352
|
+
trackHandlers = /* @__PURE__ */ new Set();
|
|
353
|
+
offMembers;
|
|
354
|
+
constructor(host) {
|
|
355
|
+
this.host = host;
|
|
356
|
+
let published = "";
|
|
357
|
+
this.offMembers = host.onMembersChanged((members) => {
|
|
358
|
+
const next = publicationsKey(members);
|
|
359
|
+
if (next === published) return;
|
|
360
|
+
published = next;
|
|
361
|
+
this.scheduleReconcile();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
get state() {
|
|
365
|
+
return this._state;
|
|
366
|
+
}
|
|
367
|
+
get tracks() {
|
|
368
|
+
const tracks = [];
|
|
369
|
+
for (const [key, item] of this.remoteMedia) {
|
|
370
|
+
if (!item.track) continue;
|
|
371
|
+
tracks.push({
|
|
372
|
+
participantId: item.participantId,
|
|
373
|
+
name: item.name,
|
|
374
|
+
kind: item.kind,
|
|
375
|
+
track: item.track,
|
|
376
|
+
quality: item.quality ?? null,
|
|
377
|
+
setQuality: (quality) => this.setQuality(key, quality)
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return tracks;
|
|
381
|
+
}
|
|
382
|
+
get candidates() {
|
|
383
|
+
return this.mediaCandidates().map(({ candidate }) => candidate);
|
|
384
|
+
}
|
|
385
|
+
onStateChange(handler) {
|
|
386
|
+
this.stateHandlers.add(handler);
|
|
387
|
+
return () => this.stateHandlers.delete(handler);
|
|
388
|
+
}
|
|
389
|
+
onTracksChanged(handler) {
|
|
390
|
+
this.trackHandlers.add(handler);
|
|
391
|
+
return () => this.trackHandlers.delete(handler);
|
|
392
|
+
}
|
|
393
|
+
select(filter) {
|
|
394
|
+
this.filter = filter;
|
|
395
|
+
this.scheduleReconcile();
|
|
396
|
+
}
|
|
397
|
+
channel(name, options = {}) {
|
|
398
|
+
if (!isAppRtcName(name)) throw new AppRtcError("RTC_FAILED", `invalid RTC channel name: ${name}`);
|
|
399
|
+
const delivery = options.delivery ?? "reliable";
|
|
400
|
+
const existing = this.channels.get(name);
|
|
401
|
+
if (existing?.isOpen) {
|
|
402
|
+
if (existing.delivery !== delivery) throw new AppRtcError("RTC_FAILED", `RTC channel ${name} is already open with ${existing.delivery} delivery`);
|
|
403
|
+
return existing;
|
|
404
|
+
}
|
|
405
|
+
if (this._state === "closed") throw new AppRtcError("RTC_CLOSED", "RTC connection is closed");
|
|
406
|
+
const channel = new AppRtcChannel(this, name, delivery);
|
|
407
|
+
this.channels.set(name, channel);
|
|
408
|
+
this.publishChannel(channel);
|
|
409
|
+
this.scheduleReconcile();
|
|
410
|
+
return channel;
|
|
411
|
+
}
|
|
412
|
+
async publish(tracks) {
|
|
413
|
+
if (this._state === "closed") throw new AppRtcError("RTC_CLOSED", "RTC connection is closed");
|
|
414
|
+
const inputs = Object.entries(tracks);
|
|
415
|
+
for (const [name] of inputs) {
|
|
416
|
+
if (!isAppRtcName(name)) throw new AppRtcError("RTC_FAILED", `invalid RTC track name: ${name}`);
|
|
417
|
+
if (this.localTracks.has(name)) throw new AppRtcError("RTC_FAILED", `RTC track ${name} is already published`);
|
|
418
|
+
}
|
|
419
|
+
if (this.localTracks.size + inputs.length > APP_RTC_LIMITS.tracksPerMember) throw new AppRtcError("RTC_LIMIT_EXCEEDED", `at most ${APP_RTC_LIMITS.tracksPerMember} tracks per member`);
|
|
420
|
+
const entries = inputs.map(([name, input]) => {
|
|
421
|
+
const { track, simulcast } = "track" in input ? input : {
|
|
422
|
+
track: input,
|
|
423
|
+
simulcast: false
|
|
424
|
+
};
|
|
425
|
+
const kind = track.kind === "audio" ? "audio" : "video";
|
|
426
|
+
return this.localEntry(name, kind, track, simulcast === true && kind === "video");
|
|
427
|
+
});
|
|
428
|
+
for (const entry of entries) this.localTracks.set(entry.name, entry);
|
|
429
|
+
try {
|
|
430
|
+
await this.enqueue(() => this.publishTracks(entries.map((entry) => entry.name)));
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (!(error instanceof ConnectionReplacedError)) {
|
|
433
|
+
for (const entry of entries) this.dropLocal(entry);
|
|
434
|
+
throw toAppRtcError(error);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return Object.fromEntries(entries.map((entry) => [entry.name, this.localHandle(entry)]));
|
|
438
|
+
}
|
|
439
|
+
async stats() {
|
|
440
|
+
return this.pc ? readRtcTransportStats(await this.pc.getStats()) : {
|
|
441
|
+
path: null,
|
|
442
|
+
relayProtocol: null,
|
|
443
|
+
rttMs: null
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
close() {
|
|
447
|
+
if (this._state === "closed") return;
|
|
448
|
+
this.setState("closed");
|
|
449
|
+
this.offMembers();
|
|
450
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
451
|
+
for (const channel of this.channels.values()) channel.close();
|
|
452
|
+
for (const entry of this.localTracks.values()) entry.track.removeEventListener("ended", entry.onEnded);
|
|
453
|
+
this.teardown(true);
|
|
454
|
+
}
|
|
455
|
+
async shutdown() {
|
|
456
|
+
this.close();
|
|
457
|
+
await this.releasePending;
|
|
458
|
+
}
|
|
459
|
+
/** @internal */
|
|
460
|
+
async start() {
|
|
461
|
+
try {
|
|
462
|
+
await this.connect();
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw toAppRtcError(error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** @internal */
|
|
468
|
+
async verify() {
|
|
469
|
+
if (this._state === "closed" || !this.sessionId) return;
|
|
470
|
+
try {
|
|
471
|
+
await this.request("GET", `/${this.sessionId}`);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (isSessionLost(error)) this.reconnect();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** @internal */
|
|
477
|
+
closeChannel(channel) {
|
|
478
|
+
if (this.channels.get(channel.name) === channel) this.channels.delete(channel.name);
|
|
479
|
+
const id = channel.publicationId;
|
|
480
|
+
channel.detach();
|
|
481
|
+
if (id) this.enqueue(() => this.unpublishData([id])).catch(() => void 0);
|
|
482
|
+
this.scheduleReconcile();
|
|
483
|
+
}
|
|
484
|
+
localEntry(name, kind, track, simulcast) {
|
|
485
|
+
const entry = {
|
|
486
|
+
name,
|
|
487
|
+
kind,
|
|
488
|
+
track,
|
|
489
|
+
simulcast,
|
|
490
|
+
transceiver: null,
|
|
491
|
+
id: null,
|
|
492
|
+
ended: /* @__PURE__ */ new Set(),
|
|
493
|
+
onEnded: () => void 0
|
|
494
|
+
};
|
|
495
|
+
entry.onEnded = () => {
|
|
496
|
+
if (this.localTracks.get(name) !== entry) return;
|
|
497
|
+
for (const handler of entry.ended) handler();
|
|
498
|
+
this.stopLocal(entry).catch(() => void 0);
|
|
499
|
+
};
|
|
500
|
+
track.addEventListener("ended", entry.onEnded);
|
|
501
|
+
return entry;
|
|
502
|
+
}
|
|
503
|
+
localHandle(entry) {
|
|
504
|
+
return {
|
|
505
|
+
name: entry.name,
|
|
506
|
+
kind: entry.kind,
|
|
507
|
+
simulcast: entry.simulcast,
|
|
508
|
+
get track() {
|
|
509
|
+
return entry.track;
|
|
510
|
+
},
|
|
511
|
+
replace: async (track) => {
|
|
512
|
+
if (track.kind !== entry.kind) throw new AppRtcError("RTC_FAILED", `replace ${entry.name} with another ${entry.kind} track`);
|
|
513
|
+
if (this.localTracks.get(entry.name) !== entry) throw new AppRtcError("RTC_CLOSED", `RTC track ${entry.name} has stopped`);
|
|
514
|
+
entry.track.removeEventListener("ended", entry.onEnded);
|
|
515
|
+
entry.track = track;
|
|
516
|
+
track.addEventListener("ended", entry.onEnded);
|
|
517
|
+
await entry.transceiver?.sender.replaceTrack(track);
|
|
518
|
+
},
|
|
519
|
+
onEnded: (handler) => {
|
|
520
|
+
entry.ended.add(handler);
|
|
521
|
+
return () => entry.ended.delete(handler);
|
|
522
|
+
},
|
|
523
|
+
stop: () => this.stopLocal(entry)
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
dropLocal(entry) {
|
|
527
|
+
entry.track.removeEventListener("ended", entry.onEnded);
|
|
528
|
+
if (this.localTracks.get(entry.name) === entry) this.localTracks.delete(entry.name);
|
|
529
|
+
}
|
|
530
|
+
stopLocal(entry) {
|
|
531
|
+
if (this.localTracks.get(entry.name) !== entry) return Promise.resolve();
|
|
532
|
+
this.dropLocal(entry);
|
|
533
|
+
return this.enqueue(() => this.unpublishTrack(entry)).catch((error) => {
|
|
534
|
+
if (error instanceof ConnectionReplacedError) return;
|
|
535
|
+
throw toAppRtcError(error);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
async publishTracks(names) {
|
|
539
|
+
const pc = this.requirePc();
|
|
540
|
+
const entries = names.flatMap((name) => {
|
|
541
|
+
const entry = this.localTracks.get(name);
|
|
542
|
+
return entry && !entry.id ? [entry] : [];
|
|
543
|
+
});
|
|
544
|
+
if (entries.length === 0) return;
|
|
545
|
+
for (const entry of entries) entry.transceiver = pc.addTransceiver(entry.track, {
|
|
546
|
+
direction: "sendonly",
|
|
547
|
+
...entry.simulcast ? { sendEncodings: APP_RTC_SIMULCAST_LAYERS.map(({ rid, scaleResolutionDownBy }) => ({
|
|
548
|
+
rid,
|
|
549
|
+
scaleResolutionDownBy
|
|
550
|
+
})) } : {}
|
|
551
|
+
});
|
|
552
|
+
const offer = await this.localOffer(pc);
|
|
553
|
+
let result;
|
|
554
|
+
try {
|
|
555
|
+
result = await this.request("POST", `/${this.sessionId}/publish`, {
|
|
556
|
+
offer,
|
|
557
|
+
tracks: entries.map((entry) => ({
|
|
558
|
+
name: entry.name,
|
|
559
|
+
kind: entry.kind,
|
|
560
|
+
mid: entry.transceiver?.mid ?? "",
|
|
561
|
+
...entry.simulcast ? { simulcast: true } : {}
|
|
562
|
+
}))
|
|
563
|
+
});
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof ConnectionReplacedError) throw error;
|
|
566
|
+
await this.settleOffer(pc, void 0).catch(() => void 0);
|
|
567
|
+
for (const entry of entries) {
|
|
568
|
+
entry.transceiver?.stop();
|
|
569
|
+
entry.transceiver = null;
|
|
570
|
+
}
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
await this.settleOffer(pc, result.answer);
|
|
574
|
+
for (const publication of result.publications) {
|
|
575
|
+
const entry = entries.find((candidate) => candidate.name === publication.name);
|
|
576
|
+
if (entry) entry.id = publication.id;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async unpublishTrack(entry) {
|
|
580
|
+
if (!entry.id || !entry.transceiver || !this.pc) return;
|
|
581
|
+
const id = entry.id;
|
|
582
|
+
entry.transceiver.stop();
|
|
583
|
+
await this.offerExchange(this.pc, (offer) => this.request("POST", `/${this.sessionId}/unpublish`, {
|
|
584
|
+
ids: [id],
|
|
585
|
+
offer
|
|
586
|
+
}));
|
|
587
|
+
entry.id = null;
|
|
588
|
+
}
|
|
589
|
+
publishChannel(channel) {
|
|
590
|
+
this.channelsToPublish.add(channel);
|
|
591
|
+
if (this.channelsToPublish.size > 1 || !this.pc) return;
|
|
592
|
+
this.enqueue(async () => {
|
|
593
|
+
const batch = [...this.channelsToPublish];
|
|
594
|
+
this.channelsToPublish.clear();
|
|
595
|
+
try {
|
|
596
|
+
await this.publishChannels(batch);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (isSessionLost(error) || error instanceof ConnectionReplacedError) throw error;
|
|
599
|
+
if (error instanceof HttpError && error.code === "RTC_RATE_LIMITED") {
|
|
600
|
+
setTimeout(() => {
|
|
601
|
+
for (const item of batch) if (item.isOpen) this.publishChannel(item);
|
|
602
|
+
}, RATE_LIMIT_RETRY_MS);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
for (const item of batch) item.fail();
|
|
606
|
+
console.warn("[Cohub AppRtc] failed to publish channels", error);
|
|
607
|
+
}
|
|
608
|
+
}).catch(() => void 0);
|
|
609
|
+
}
|
|
610
|
+
async connect() {
|
|
611
|
+
const generation = ++this.generation;
|
|
612
|
+
const opened = await this.send("POST", "", {
|
|
613
|
+
ticket: this.host.ticket,
|
|
614
|
+
participantId: this.host.participantId()
|
|
615
|
+
});
|
|
616
|
+
if (generation !== this.generation || this._state === "closed") {
|
|
617
|
+
this.send("DELETE", `/${opened.sessionId}`).catch(() => void 0);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
this.sessionId = opened.sessionId;
|
|
621
|
+
const configuration = {
|
|
622
|
+
iceServers: opened.iceServers,
|
|
623
|
+
bundlePolicy: "max-bundle"
|
|
624
|
+
};
|
|
625
|
+
let pc;
|
|
626
|
+
try {
|
|
627
|
+
pc = this.host.createPeerConnection?.(configuration) ?? new RTCPeerConnection(configuration);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
await this.shutdown();
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
this.pc = pc;
|
|
633
|
+
pc.addEventListener("connectionstatechange", () => {
|
|
634
|
+
if (this.pc !== pc) return;
|
|
635
|
+
if (pc.connectionState === "connected") {
|
|
636
|
+
this.reconnectAttempt = 0;
|
|
637
|
+
this.setState("connected");
|
|
638
|
+
} else if (pc.connectionState === "failed") this.reconnect();
|
|
639
|
+
});
|
|
640
|
+
pc.addEventListener("track", (event) => this.onTrack(event));
|
|
641
|
+
const ready = (async () => {
|
|
642
|
+
await this.answerOffer(opened.offer);
|
|
643
|
+
await this.connected(pc);
|
|
644
|
+
})();
|
|
645
|
+
this.queue = ready.catch(() => void 0);
|
|
646
|
+
try {
|
|
647
|
+
await ready;
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (generation === this.generation) this.reconnect();
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
this.channelsToPublish.clear();
|
|
653
|
+
const republish = [...this.channels.values()].filter((channel) => channel.isOpen);
|
|
654
|
+
const tracks = [...this.localTracks.keys()];
|
|
655
|
+
await Promise.all([republish.length ? this.enqueue(() => this.publishChannels(republish)).catch((error) => {
|
|
656
|
+
if (error instanceof ConnectionReplacedError || isSessionLost(error)) return;
|
|
657
|
+
for (const channel of republish) channel.fail();
|
|
658
|
+
console.warn("[Cohub AppRtc] failed to publish channels on the new connection", error);
|
|
659
|
+
}) : null, tracks.length ? this.enqueue(() => this.publishTracks(tracks)).catch((error) => {
|
|
660
|
+
if (error instanceof ConnectionReplacedError || isSessionLost(error)) return;
|
|
661
|
+
console.warn("[Cohub AppRtc] failed to publish tracks on the new connection", error);
|
|
662
|
+
}) : null]);
|
|
663
|
+
this.scheduleReconcile();
|
|
664
|
+
}
|
|
665
|
+
connected(pc) {
|
|
666
|
+
if (pc.connectionState === "connected") return Promise.resolve();
|
|
667
|
+
return new Promise((resolve, reject) => {
|
|
668
|
+
const timer = setTimeout(() => done(/* @__PURE__ */ new Error("RTC connection timed out")), CONNECT_TIMEOUT_MS);
|
|
669
|
+
const done = (error) => {
|
|
670
|
+
clearTimeout(timer);
|
|
671
|
+
pc.removeEventListener("connectionstatechange", check);
|
|
672
|
+
if (error) reject(error);
|
|
673
|
+
else resolve();
|
|
674
|
+
};
|
|
675
|
+
const check = () => {
|
|
676
|
+
if (pc.connectionState === "connected") done();
|
|
677
|
+
else if (pc.connectionState === "failed" || pc.connectionState === "closed") done(/* @__PURE__ */ new Error("RTC connection failed"));
|
|
678
|
+
};
|
|
679
|
+
pc.addEventListener("connectionstatechange", check);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
reconnect() {
|
|
683
|
+
if (this._state === "closed" || this.reconnectTimer) return;
|
|
684
|
+
this.setState(this._state === "connecting" ? "connecting" : "reconnecting");
|
|
685
|
+
this.teardown(true);
|
|
686
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? 3e4;
|
|
687
|
+
this.reconnectAttempt += 1;
|
|
688
|
+
this.reconnectTimer = setTimeout(() => {
|
|
689
|
+
this.reconnectTimer = null;
|
|
690
|
+
this.connect().catch((error) => {
|
|
691
|
+
if (isRoomGone(error) || error instanceof HttpError && (error.status === 403 || error.status === 503)) this.close();
|
|
692
|
+
else this.reconnect();
|
|
693
|
+
});
|
|
694
|
+
}, delay);
|
|
695
|
+
}
|
|
696
|
+
teardown(release) {
|
|
697
|
+
this.generation += 1;
|
|
698
|
+
const sessionId = this.sessionId;
|
|
699
|
+
this.sessionId = null;
|
|
700
|
+
this.queue = Promise.resolve();
|
|
701
|
+
for (const channel of this.channels.values()) channel.detach();
|
|
702
|
+
for (const item of this.remoteData.values()) this.dropRemoteData(item);
|
|
703
|
+
this.remoteData.clear();
|
|
704
|
+
for (const item of this.localTracks.values()) {
|
|
705
|
+
item.transceiver = null;
|
|
706
|
+
item.id = null;
|
|
707
|
+
}
|
|
708
|
+
const hadTracks = this.remoteMedia.size > 0;
|
|
709
|
+
this.remoteMedia.clear();
|
|
710
|
+
this.mediaByMid.clear();
|
|
711
|
+
this.pc?.close();
|
|
712
|
+
this.pc = null;
|
|
713
|
+
if (hadTracks) this.notifyTracks();
|
|
714
|
+
if (release && sessionId) {
|
|
715
|
+
this.releasePending = Promise.allSettled([this.releasePending, this.send("DELETE", `/${sessionId}`)]).then((results) => {
|
|
716
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
717
|
+
if (failed?.status === "rejected") throw failed.reason;
|
|
718
|
+
});
|
|
719
|
+
this.releasePending.catch(() => void 0);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
enqueue(operation) {
|
|
723
|
+
const generation = this.generation;
|
|
724
|
+
const run = async () => {
|
|
725
|
+
if (generation !== this.generation || !this.pc || !this.sessionId) throw new ConnectionReplacedError();
|
|
726
|
+
try {
|
|
727
|
+
return await operation();
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (isSessionLost(error)) this.reconnect();
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
const next = this.queue.then(run, run);
|
|
734
|
+
this.queue = next.catch(() => void 0);
|
|
735
|
+
return next;
|
|
736
|
+
}
|
|
737
|
+
async publishChannels(channels) {
|
|
738
|
+
const pc = this.requirePc();
|
|
739
|
+
const open = channels.filter((channel) => channel.isOpen && !channel.publicationId);
|
|
740
|
+
if (open.length === 0) return;
|
|
741
|
+
const result = await this.request("POST", `/${this.sessionId}/publish`, { channels: open.map((channel) => ({
|
|
742
|
+
name: channel.name,
|
|
743
|
+
delivery: channel.delivery
|
|
744
|
+
})) });
|
|
745
|
+
for (const publication of result.publications) {
|
|
746
|
+
const channel = open.find((item) => item.name === publication.name);
|
|
747
|
+
const id = result.channelIds?.[publication.id];
|
|
748
|
+
if (!channel || id === void 0) continue;
|
|
749
|
+
const dataChannel = pc.createDataChannel(publication.name, {
|
|
750
|
+
negotiated: true,
|
|
751
|
+
id,
|
|
752
|
+
...policyOf(channel.delivery)
|
|
753
|
+
});
|
|
754
|
+
channel.attach(publication.id, dataChannel);
|
|
755
|
+
if (!channel.isOpen) await this.unpublishData([publication.id]);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async unpublishData(ids) {
|
|
759
|
+
await this.request("POST", `/${this.sessionId}/unpublish`, { ids });
|
|
760
|
+
}
|
|
761
|
+
async settleOffer(pc, answer) {
|
|
762
|
+
if (answer) await pc.setRemoteDescription(answer);
|
|
763
|
+
else if (pc.signalingState === "have-local-offer") await pc.setLocalDescription({ type: "rollback" });
|
|
764
|
+
}
|
|
765
|
+
async offerExchange(pc, send) {
|
|
766
|
+
const offer = await this.localOffer(pc);
|
|
767
|
+
let answer;
|
|
768
|
+
try {
|
|
769
|
+
({answer} = await send(offer));
|
|
770
|
+
} catch (error) {
|
|
771
|
+
if (!(error instanceof ConnectionReplacedError)) await this.settleOffer(pc, void 0).catch(() => void 0);
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
await this.settleOffer(pc, answer);
|
|
775
|
+
}
|
|
776
|
+
scheduleReconcile() {
|
|
777
|
+
if (this.reconcileQueued || !this.pc || this._state === "closed") return;
|
|
778
|
+
this.reconcileQueued = true;
|
|
779
|
+
this.enqueue(async () => {
|
|
780
|
+
this.reconcileQueued = false;
|
|
781
|
+
await this.reconcile();
|
|
782
|
+
this.reconcileFailures = 0;
|
|
783
|
+
}).catch((error) => {
|
|
784
|
+
this.reconcileQueued = false;
|
|
785
|
+
if (isSessionLost(error) || error instanceof ConnectionReplacedError) return;
|
|
786
|
+
console.warn("[Cohub AppRtc] failed to update subscriptions", error);
|
|
787
|
+
if (error instanceof HttpError && error.code === "RTC_LIMIT_EXCEEDED") return;
|
|
788
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconcileFailures, RECONNECT_DELAYS_MS.length - 1)] ?? 3e4;
|
|
789
|
+
this.reconcileFailures += 1;
|
|
790
|
+
const generation = this.generation;
|
|
791
|
+
setTimeout(() => {
|
|
792
|
+
if (generation === this.generation) this.scheduleReconcile();
|
|
793
|
+
}, delay);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
mediaCandidates() {
|
|
797
|
+
const self = this.host.participantId();
|
|
798
|
+
const candidates = [];
|
|
799
|
+
for (const member of this.host.members()) {
|
|
800
|
+
if (member.participantId === self) continue;
|
|
801
|
+
for (const publication of member.publications ?? []) {
|
|
802
|
+
if (publication.kind === "data") continue;
|
|
803
|
+
candidates.push({
|
|
804
|
+
publication,
|
|
805
|
+
candidate: {
|
|
806
|
+
participantId: member.participantId,
|
|
807
|
+
name: publication.name,
|
|
808
|
+
kind: publication.kind,
|
|
809
|
+
simulcast: publication.simulcast === true,
|
|
810
|
+
subscribed: this.remoteMedia.has(mediaKey(member.participantId, publication.id))
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return candidates;
|
|
816
|
+
}
|
|
817
|
+
async reconcile() {
|
|
818
|
+
const self = this.host.participantId();
|
|
819
|
+
const wantedData = /* @__PURE__ */ new Map();
|
|
820
|
+
for (const member of this.host.members()) {
|
|
821
|
+
if (member.participantId === self) continue;
|
|
822
|
+
for (const publication of member.publications ?? []) {
|
|
823
|
+
if (publication.kind !== "data") continue;
|
|
824
|
+
const channel = this.channels.get(publication.name);
|
|
825
|
+
if (channel?.isOpen && channel.delivery === (publication.delivery ?? "reliable")) wantedData.set(mediaKey(member.participantId, publication.id), {
|
|
826
|
+
participantId: member.participantId,
|
|
827
|
+
publicationId: publication.id,
|
|
828
|
+
name: publication.name,
|
|
829
|
+
delivery: channel.delivery
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
const wantedMedia = /* @__PURE__ */ new Map();
|
|
834
|
+
for (const { candidate, publication } of this.mediaCandidates()) {
|
|
835
|
+
if (wantedMedia.size >= APP_RTC_LIMITS.mediaSubscriptions) break;
|
|
836
|
+
if (this.filter && !this.filter(candidate)) continue;
|
|
837
|
+
const key = mediaKey(candidate.participantId, publication.id);
|
|
838
|
+
const quality = this.remoteMedia.get(key)?.quality ?? (candidate.simulcast ? "high" : void 0);
|
|
839
|
+
wantedMedia.set(key, {
|
|
840
|
+
participantId: candidate.participantId,
|
|
841
|
+
publicationId: publication.id,
|
|
842
|
+
name: candidate.name,
|
|
843
|
+
kind: candidate.kind,
|
|
844
|
+
...quality ? { quality } : {}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
await this.reconcileData(wantedData);
|
|
848
|
+
await this.reconcileMedia(wantedMedia);
|
|
849
|
+
if ([...wantedData.keys()].some((key) => !this.remoteData.has(key)) || [...wantedMedia.keys()].some((key) => !this.remoteMedia.has(key))) throw new AppRtcError("RTC_FAILED", "Some RTC subscriptions could not be established");
|
|
850
|
+
}
|
|
851
|
+
dropRemoteData(item) {
|
|
852
|
+
item.channel.close();
|
|
853
|
+
this.channels.get(item.name)?.setPeer(item.participantId, false);
|
|
854
|
+
}
|
|
855
|
+
async reconcileData(wanted) {
|
|
856
|
+
const pc = this.requirePc();
|
|
857
|
+
const stale = [...this.remoteData].filter(([key]) => !wanted.has(key));
|
|
858
|
+
for (const batch of chunk(stale, APP_RTC_LIMITS.batch)) {
|
|
859
|
+
await this.request("POST", `/${this.sessionId}/unsubscribe`, { items: batch.map(([, item]) => ({
|
|
860
|
+
participantId: item.participantId,
|
|
861
|
+
publicationId: item.publicationId
|
|
862
|
+
})) });
|
|
863
|
+
for (const [key, item] of batch) {
|
|
864
|
+
this.dropRemoteData(item);
|
|
865
|
+
this.remoteData.delete(key);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const missing = [...wanted].filter(([key]) => !this.remoteData.has(key)).map(([, item]) => item);
|
|
869
|
+
for (const batch of chunk(missing, APP_RTC_LIMITS.batch)) {
|
|
870
|
+
const result = await this.request("POST", `/${this.sessionId}/subscribe`, {
|
|
871
|
+
kind: "data",
|
|
872
|
+
items: batch.map(({ participantId, publicationId }) => ({
|
|
873
|
+
participantId,
|
|
874
|
+
publicationId
|
|
875
|
+
}))
|
|
876
|
+
});
|
|
877
|
+
for (const subscription of result.subscriptions) {
|
|
878
|
+
const item = batch.find((entry) => entry.participantId === subscription.participantId && entry.publicationId === subscription.publicationId);
|
|
879
|
+
if (!item || subscription.channelId === void 0) continue;
|
|
880
|
+
const channel = pc.createDataChannel(item.name, {
|
|
881
|
+
negotiated: true,
|
|
882
|
+
id: subscription.channelId,
|
|
883
|
+
...policyOf(item.delivery)
|
|
884
|
+
});
|
|
885
|
+
channel.binaryType = "arraybuffer";
|
|
886
|
+
const remote = {
|
|
887
|
+
name: item.name,
|
|
888
|
+
participantId: item.participantId,
|
|
889
|
+
publicationId: item.publicationId,
|
|
890
|
+
channel,
|
|
891
|
+
last: null
|
|
892
|
+
};
|
|
893
|
+
channel.addEventListener("message", (event) => this.onData(remote, event.data));
|
|
894
|
+
channel.addEventListener("open", () => {
|
|
895
|
+
if (this.remoteData.get(mediaKey(remote.participantId, remote.publicationId)) === remote) this.channels.get(remote.name)?.setPeer(remote.participantId, true);
|
|
896
|
+
});
|
|
897
|
+
this.remoteData.set(mediaKey(item.participantId, item.publicationId), remote);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async reconcileMedia(wanted) {
|
|
902
|
+
const stale = [...this.remoteMedia].filter(([key]) => !wanted.has(key));
|
|
903
|
+
const missing = [...wanted].filter(([key]) => !this.remoteMedia.has(key)).map(([, item]) => item);
|
|
904
|
+
const moves = [];
|
|
905
|
+
for (const to of [...missing]) {
|
|
906
|
+
const index = stale.findIndex(([, receiver]) => receiver.kind === to.kind);
|
|
907
|
+
const found = index >= 0 ? stale.splice(index, 1)[0] : void 0;
|
|
908
|
+
if (!found) continue;
|
|
909
|
+
moves.push({
|
|
910
|
+
from: found[0],
|
|
911
|
+
receiver: found[1],
|
|
912
|
+
to
|
|
913
|
+
});
|
|
914
|
+
missing.splice(missing.indexOf(to), 1);
|
|
915
|
+
}
|
|
916
|
+
if (moves.length) {
|
|
917
|
+
const moved = await this.retarget(moves.map(({ receiver, to }) => ({
|
|
918
|
+
...to,
|
|
919
|
+
mid: receiver.mid
|
|
920
|
+
})));
|
|
921
|
+
for (const { from, receiver, to } of moves) if (moved.has(receiver.mid)) {
|
|
922
|
+
this.remoteMedia.delete(from);
|
|
923
|
+
this.remoteMedia.set(mediaKey(to.participantId, to.publicationId), {
|
|
924
|
+
...to,
|
|
925
|
+
mid: receiver.mid,
|
|
926
|
+
track: receiver.track
|
|
927
|
+
});
|
|
928
|
+
this.mediaByMid.set(receiver.mid, mediaKey(to.participantId, to.publicationId));
|
|
929
|
+
} else {
|
|
930
|
+
stale.push([from, receiver]);
|
|
931
|
+
missing.push(to);
|
|
932
|
+
}
|
|
933
|
+
if (moved.size) this.notifyTracks();
|
|
934
|
+
}
|
|
935
|
+
await this.unsubscribeMedia(stale);
|
|
936
|
+
await this.subscribeMedia(missing);
|
|
937
|
+
}
|
|
938
|
+
async retarget(items) {
|
|
939
|
+
try {
|
|
940
|
+
const result = await this.request("PATCH", `/${this.sessionId}/media`, { items: items.map(({ participantId, publicationId, mid, quality }) => ({
|
|
941
|
+
participantId,
|
|
942
|
+
publicationId,
|
|
943
|
+
mid,
|
|
944
|
+
...quality ? { quality } : {}
|
|
945
|
+
})) });
|
|
946
|
+
if (result.offer) await this.answerOffer(result.offer);
|
|
947
|
+
return new Set(result.updated.map((item) => item.mid));
|
|
948
|
+
} catch (error) {
|
|
949
|
+
if (error instanceof ConnectionReplacedError || isSessionLost(error)) throw error;
|
|
950
|
+
return /* @__PURE__ */ new Set();
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async setQuality(key, quality) {
|
|
954
|
+
await this.enqueue(async () => {
|
|
955
|
+
const item = this.remoteMedia.get(key);
|
|
956
|
+
if (!item || item.quality === void 0 || item.quality === quality) return;
|
|
957
|
+
if ((await this.retarget([{
|
|
958
|
+
...item,
|
|
959
|
+
quality
|
|
960
|
+
}])).has(item.mid)) {
|
|
961
|
+
item.quality = quality;
|
|
962
|
+
this.notifyTracks();
|
|
963
|
+
}
|
|
964
|
+
}).catch((error) => {
|
|
965
|
+
if (error instanceof ConnectionReplacedError) return;
|
|
966
|
+
throw toAppRtcError(error);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
async unsubscribeMedia(stale) {
|
|
970
|
+
if (stale.length === 0) return;
|
|
971
|
+
const pc = this.requirePc();
|
|
972
|
+
for (const [, item] of stale) {
|
|
973
|
+
pc.getTransceivers().find((transceiver) => transceiver.mid === item.mid)?.stop();
|
|
974
|
+
item.track = null;
|
|
975
|
+
}
|
|
976
|
+
this.notifyTracks();
|
|
977
|
+
await this.offerExchange(pc, (offer) => this.request("POST", `/${this.sessionId}/unsubscribe`, {
|
|
978
|
+
items: stale.map(([, item]) => ({
|
|
979
|
+
participantId: item.participantId,
|
|
980
|
+
publicationId: item.publicationId
|
|
981
|
+
})),
|
|
982
|
+
offer
|
|
983
|
+
}));
|
|
984
|
+
for (const [key, item] of stale) {
|
|
985
|
+
this.remoteMedia.delete(key);
|
|
986
|
+
this.mediaByMid.delete(item.mid);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
async subscribeMedia(missing) {
|
|
990
|
+
if (missing.length === 0) return;
|
|
991
|
+
const result = await this.request("POST", `/${this.sessionId}/subscribe`, {
|
|
992
|
+
kind: "media",
|
|
993
|
+
items: missing.map(({ participantId, publicationId, quality }) => ({
|
|
994
|
+
participantId,
|
|
995
|
+
publicationId,
|
|
996
|
+
...quality ? { quality } : {}
|
|
997
|
+
}))
|
|
998
|
+
});
|
|
999
|
+
for (const subscription of result.subscriptions) {
|
|
1000
|
+
const item = missing.find((entry) => entry.participantId === subscription.participantId && entry.publicationId === subscription.publicationId);
|
|
1001
|
+
if (!item || !subscription.mid) continue;
|
|
1002
|
+
const key = mediaKey(item.participantId, item.publicationId);
|
|
1003
|
+
this.remoteMedia.set(key, {
|
|
1004
|
+
...item,
|
|
1005
|
+
mid: subscription.mid,
|
|
1006
|
+
track: null
|
|
1007
|
+
});
|
|
1008
|
+
this.mediaByMid.set(subscription.mid, key);
|
|
1009
|
+
}
|
|
1010
|
+
if (!result.offer) return;
|
|
1011
|
+
try {
|
|
1012
|
+
await this.answerOffer(result.offer);
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
if (!(error instanceof ConnectionReplacedError)) this.reconnect();
|
|
1015
|
+
throw error;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
async answerOffer(offer) {
|
|
1019
|
+
const pc = this.requirePc();
|
|
1020
|
+
await pc.setRemoteDescription(offer);
|
|
1021
|
+
await pc.setLocalDescription(await pc.createAnswer());
|
|
1022
|
+
await waitForIceGathering(pc, GATHER_TIMEOUT_MS);
|
|
1023
|
+
const answer = pc.localDescription;
|
|
1024
|
+
if (!answer) throw new Error("the browser produced no answer");
|
|
1025
|
+
await this.request("PUT", `/${this.sessionId}`, { answer: {
|
|
1026
|
+
type: "answer",
|
|
1027
|
+
sdp: answer.sdp
|
|
1028
|
+
} });
|
|
1029
|
+
}
|
|
1030
|
+
async localOffer(pc) {
|
|
1031
|
+
await pc.setLocalDescription(await pc.createOffer());
|
|
1032
|
+
await waitForIceGathering(pc, GATHER_TIMEOUT_MS);
|
|
1033
|
+
const offer = pc.localDescription;
|
|
1034
|
+
if (!offer) throw new Error("the browser produced no offer");
|
|
1035
|
+
return {
|
|
1036
|
+
type: "offer",
|
|
1037
|
+
sdp: offer.sdp
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
async request(method, path, body) {
|
|
1041
|
+
const generation = this.generation;
|
|
1042
|
+
const settled = await this.send(method, path, body).then((value) => ({ value }), (error) => ({ error }));
|
|
1043
|
+
if (generation !== this.generation) throw new ConnectionReplacedError();
|
|
1044
|
+
if ("error" in settled) throw settled.error;
|
|
1045
|
+
return settled.value;
|
|
1046
|
+
}
|
|
1047
|
+
send(method, path, body) {
|
|
1048
|
+
const url = `${this.host.hosted ? `/api/rooms/${encodeURIComponent(this.host.roomId)}/rtc` : `/api/apps/${encodeURIComponent(this.host.appId)}/realtime/rooms/${encodeURIComponent(this.host.roomId)}/rtc`}${path}`;
|
|
1049
|
+
return this.host.transport.request(url, {
|
|
1050
|
+
method,
|
|
1051
|
+
...body === void 0 ? {} : {
|
|
1052
|
+
headers: { "Content-Type": "application/json" },
|
|
1053
|
+
body: JSON.stringify(body)
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
requirePc() {
|
|
1058
|
+
if (!this.pc || !this.sessionId) throw new ConnectionReplacedError();
|
|
1059
|
+
return this.pc;
|
|
1060
|
+
}
|
|
1061
|
+
onData(remote, raw) {
|
|
1062
|
+
if (!(raw instanceof ArrayBuffer)) return;
|
|
1063
|
+
const frame = decodeAppRtcFrame(raw);
|
|
1064
|
+
if (!frame) return;
|
|
1065
|
+
const channel = this.channels.get(remote.name);
|
|
1066
|
+
if (!channel?.isOpen) return;
|
|
1067
|
+
if (channel.delivery === "latest") {
|
|
1068
|
+
if (!isNewerSequence(frame.sequence, remote.last)) return;
|
|
1069
|
+
remote.last = frame.sequence;
|
|
1070
|
+
}
|
|
1071
|
+
channel.deliver(remote.participantId, frame.data);
|
|
1072
|
+
}
|
|
1073
|
+
onTrack(event) {
|
|
1074
|
+
const mid = event.transceiver.mid;
|
|
1075
|
+
const key = mid ? this.mediaByMid.get(mid) : void 0;
|
|
1076
|
+
const item = key ? this.remoteMedia.get(key) : void 0;
|
|
1077
|
+
if (!item || !mid) return;
|
|
1078
|
+
item.track = event.track;
|
|
1079
|
+
event.track.addEventListener("ended", () => {
|
|
1080
|
+
const current = this.mediaByMid.get(mid);
|
|
1081
|
+
const owner = current ? this.remoteMedia.get(current) : void 0;
|
|
1082
|
+
if (owner?.track === event.track) {
|
|
1083
|
+
owner.track = null;
|
|
1084
|
+
this.notifyTracks();
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
this.notifyTracks();
|
|
1088
|
+
}
|
|
1089
|
+
notifyTracks() {
|
|
1090
|
+
const tracks = this.tracks;
|
|
1091
|
+
for (const handler of this.trackHandlers) handler(tracks);
|
|
1092
|
+
}
|
|
1093
|
+
setState(state) {
|
|
1094
|
+
if (this._state === state) return;
|
|
1095
|
+
this._state = state;
|
|
1096
|
+
for (const handler of this.stateHandlers) handler(state);
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/apis/app-realtime.ts
|
|
1101
|
+
var app_realtime_exports = /* @__PURE__ */ __exportAll({
|
|
1102
|
+
AppRealtimeApi: () => AppRealtimeApi,
|
|
1103
|
+
AppRoom: () => AppRoom,
|
|
1104
|
+
WorkRealtimeApi: () => WorkRealtimeApi,
|
|
1105
|
+
WorkRoom: () => WorkRoom
|
|
1106
|
+
});
|
|
1107
|
+
const randomId = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1108
|
+
const textEncoder = new TextEncoder();
|
|
1109
|
+
const byteLength = (text) => textEncoder.encode(text).byteLength;
|
|
1110
|
+
/**
|
|
1111
|
+
* Ceiling on deltas held during a join, so a stalled handshake cannot buffer without
|
|
1112
|
+
* limit. Dropping the tail needs no extra signal: the gap detection below reports it.
|
|
1113
|
+
*/
|
|
1114
|
+
const APP_ROOM_MAX_JOIN_BUFFER = 512;
|
|
1115
|
+
/** Room events that mutate state, so they must not be applied before the join snapshot. */
|
|
1116
|
+
const APP_ROOM_DELTA_EVENTS = /* @__PURE__ */ new Set([
|
|
1117
|
+
"realtime.room.event",
|
|
1118
|
+
"realtime.room.member.joined",
|
|
1119
|
+
"realtime.room.member.left",
|
|
1120
|
+
"realtime.room.presence.updated",
|
|
1121
|
+
"realtime.room.publications.updated"
|
|
1122
|
+
]);
|
|
1123
|
+
const APP_ROOM_MEMBER_EVENTS = /* @__PURE__ */ new Set([
|
|
1124
|
+
"realtime.room.member.joined",
|
|
1125
|
+
"realtime.room.member.left",
|
|
1126
|
+
"realtime.room.presence.updated",
|
|
1127
|
+
"realtime.room.publications.updated"
|
|
1128
|
+
]);
|
|
1129
|
+
const requestError = (event) => {
|
|
1130
|
+
const payload = event.payload;
|
|
1131
|
+
return new Error(typeof payload.message === "string" ? payload.message : typeof payload.code === "string" ? payload.code : "room request failed");
|
|
1132
|
+
};
|
|
1133
|
+
const isRoomEvent = (event, roomId) => {
|
|
1134
|
+
if (event.domain !== "room") return false;
|
|
1135
|
+
return event.payload.roomId === roomId || event.rooms?.includes(`room:${roomId}`);
|
|
1136
|
+
};
|
|
1137
|
+
var AppRoom = class {
|
|
1138
|
+
websocket;
|
|
1139
|
+
admission;
|
|
1140
|
+
appContext;
|
|
1141
|
+
id;
|
|
1142
|
+
code;
|
|
1143
|
+
createdAt;
|
|
1144
|
+
expiresAt;
|
|
1145
|
+
maxParticipants;
|
|
1146
|
+
seatPerUser;
|
|
1147
|
+
/** Opaque, stable identity of this viewer inside the room. */
|
|
1148
|
+
userKey;
|
|
1149
|
+
_participantId;
|
|
1150
|
+
_state = "connecting";
|
|
1151
|
+
_members = [];
|
|
1152
|
+
joinBuffer = null;
|
|
1153
|
+
lastSequence = 0;
|
|
1154
|
+
hasJoined = false;
|
|
1155
|
+
explicitLeave = false;
|
|
1156
|
+
expiryTimer = null;
|
|
1157
|
+
pending = /* @__PURE__ */ new Map();
|
|
1158
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1159
|
+
allHandlers = /* @__PURE__ */ new Set();
|
|
1160
|
+
stateHandlers = /* @__PURE__ */ new Set();
|
|
1161
|
+
membersHandlers = /* @__PURE__ */ new Set();
|
|
1162
|
+
outOfSyncHandlers = /* @__PURE__ */ new Set();
|
|
1163
|
+
sendErrorHandlers = /* @__PURE__ */ new Set();
|
|
1164
|
+
offEvent;
|
|
1165
|
+
offOpen;
|
|
1166
|
+
offClose;
|
|
1167
|
+
offReconnecting;
|
|
1168
|
+
instanceId = randomId().replace(/[^A-Za-z0-9_-]/g, "");
|
|
1169
|
+
rtcConnection = null;
|
|
1170
|
+
constructor(websocket, admission, appContext) {
|
|
1171
|
+
this.websocket = websocket;
|
|
1172
|
+
this.admission = admission;
|
|
1173
|
+
this.appContext = appContext;
|
|
1174
|
+
this.id = admission.room.id;
|
|
1175
|
+
this.code = admission.room.code;
|
|
1176
|
+
this.createdAt = admission.room.createdAt;
|
|
1177
|
+
this.expiresAt = admission.room.expiresAt;
|
|
1178
|
+
this.maxParticipants = admission.room.maxParticipants;
|
|
1179
|
+
this.seatPerUser = admission.room.seatPerUser === true;
|
|
1180
|
+
this.userKey = admission.userKey;
|
|
1181
|
+
this._participantId = admission.participantId;
|
|
1182
|
+
this.offEvent = websocket.on("event", (event) => this.handleEvent(event));
|
|
1183
|
+
this.offOpen = websocket.on("open", () => {
|
|
1184
|
+
if (!this.hasJoined || this.explicitLeave || this._state === "expired" || this._state === "closed") return;
|
|
1185
|
+
this.rejoin();
|
|
1186
|
+
});
|
|
1187
|
+
this.offReconnecting = websocket.on("reconnecting", () => {
|
|
1188
|
+
if (this.hasJoined && !this.explicitLeave) this.setState("reconnecting");
|
|
1189
|
+
});
|
|
1190
|
+
this.offClose = websocket.on("close", ({ willReconnect }) => {
|
|
1191
|
+
this.rejectPending(/* @__PURE__ */ new Error("room connection closed"));
|
|
1192
|
+
if (this.explicitLeave || !willReconnect) {
|
|
1193
|
+
if (this._state !== "expired") this.setState("closed");
|
|
1194
|
+
this.hasJoined = false;
|
|
1195
|
+
this.dispose();
|
|
1196
|
+
} else if (this.hasJoined) this.setState("reconnecting");
|
|
1197
|
+
});
|
|
1198
|
+
const delay = Math.max(0, Date.parse(this.expiresAt) - Date.now());
|
|
1199
|
+
this.expiryTimer = setTimeout(() => this.expire(), Math.min(delay, 2147e6));
|
|
1200
|
+
}
|
|
1201
|
+
get state() {
|
|
1202
|
+
return this._state;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Identity of this connection inside the room. A `seatPerUser` join can take over a
|
|
1206
|
+
* seat the viewer already holds, so this may differ from the id issued at admission.
|
|
1207
|
+
*/
|
|
1208
|
+
get participantId() {
|
|
1209
|
+
return this._participantId;
|
|
1210
|
+
}
|
|
1211
|
+
get members() {
|
|
1212
|
+
return this._members.slice();
|
|
1213
|
+
}
|
|
1214
|
+
get sequence() {
|
|
1215
|
+
return this.lastSequence;
|
|
1216
|
+
}
|
|
1217
|
+
async connect(options = {}) {
|
|
1218
|
+
if (this.explicitLeave || this.state === "closed" || this.state === "expired") throw new Error("room is closed");
|
|
1219
|
+
if (this.hasJoined) return this;
|
|
1220
|
+
if (options.after !== void 0 && (!Number.isSafeInteger(options.after) || options.after < 0)) throw new Error("after must be a non-negative safe integer");
|
|
1221
|
+
this.lastSequence = options.after ?? 0;
|
|
1222
|
+
await this.joinOverWebsocket(false, options.after);
|
|
1223
|
+
return this;
|
|
1224
|
+
}
|
|
1225
|
+
subscribe(type, handler) {
|
|
1226
|
+
const handlers = this.handlers.get(type) ?? /* @__PURE__ */ new Set();
|
|
1227
|
+
handlers.add(handler);
|
|
1228
|
+
this.handlers.set(type, handlers);
|
|
1229
|
+
return () => handlers.delete(handler);
|
|
1230
|
+
}
|
|
1231
|
+
subscribeAll(handler) {
|
|
1232
|
+
this.allHandlers.add(handler);
|
|
1233
|
+
return () => this.allHandlers.delete(handler);
|
|
1234
|
+
}
|
|
1235
|
+
onStateChange(handler) {
|
|
1236
|
+
this.stateHandlers.add(handler);
|
|
1237
|
+
return () => this.stateHandlers.delete(handler);
|
|
1238
|
+
}
|
|
1239
|
+
onMembersChanged(handler) {
|
|
1240
|
+
this.membersHandlers.add(handler);
|
|
1241
|
+
return () => this.membersHandlers.delete(handler);
|
|
1242
|
+
}
|
|
1243
|
+
onOutOfSync(handler) {
|
|
1244
|
+
this.outOfSyncHandlers.add(handler);
|
|
1245
|
+
return () => this.outOfSyncHandlers.delete(handler);
|
|
1246
|
+
}
|
|
1247
|
+
/** Reports failures of {@link send}, which has no ack to reject. */
|
|
1248
|
+
onSendError(handler) {
|
|
1249
|
+
this.sendErrorHandlers.add(handler);
|
|
1250
|
+
return () => this.sendErrorHandlers.delete(handler);
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Publishes without waiting for the server ack, for high-frequency traffic such as
|
|
1254
|
+
* input frames. Awaiting {@link publish} instead caps a loop at `1000 / rtt` events
|
|
1255
|
+
* per second. Ordering still holds, but failures surface through
|
|
1256
|
+
* {@link onSendError} rather than a rejected promise, and calls are ignored while
|
|
1257
|
+
* the room is not joined; watch {@link onStateChange} to know when it resumes.
|
|
1258
|
+
*/
|
|
1259
|
+
send(type, data) {
|
|
1260
|
+
if (this._state !== "joined" || !this.hasJoined) return;
|
|
1261
|
+
const failure = this.validateSend(type, data);
|
|
1262
|
+
if (failure) {
|
|
1263
|
+
for (const handler of this.sendErrorHandlers) handler(failure);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
this.websocket.publishRealtimeRoom({
|
|
1267
|
+
roomId: this.id,
|
|
1268
|
+
event: type,
|
|
1269
|
+
data
|
|
1270
|
+
}).catch((error) => {
|
|
1271
|
+
const reason = error instanceof Error ? error : new Error(String(error));
|
|
1272
|
+
for (const handler of this.sendErrorHandlers) handler(reason);
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
validateSend(type, data) {
|
|
1276
|
+
if (!REALTIME_ROOM_EVENT_NAME_PATTERN.test(type)) return /* @__PURE__ */ new Error(`invalid room event name: ${type}`);
|
|
1277
|
+
let encoded;
|
|
1278
|
+
try {
|
|
1279
|
+
encoded = JSON.stringify(data);
|
|
1280
|
+
} catch {
|
|
1281
|
+
return /* @__PURE__ */ new Error("room event data is not serializable");
|
|
1282
|
+
}
|
|
1283
|
+
if (encoded === void 0) return /* @__PURE__ */ new Error("room event data is not serializable");
|
|
1284
|
+
if (byteLength(encoded) > 16384) return /* @__PURE__ */ new Error("room event payload is too large");
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
async publish(type, data, options) {
|
|
1288
|
+
this.assertJoined();
|
|
1289
|
+
const failure = this.validateSend(type, data);
|
|
1290
|
+
if (failure) throw failure;
|
|
1291
|
+
const requestId = randomId();
|
|
1292
|
+
const response = await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.request.ok"]), () => this.websocket.publishRealtimeRoom({
|
|
1293
|
+
roomId: this.id,
|
|
1294
|
+
event: type,
|
|
1295
|
+
data,
|
|
1296
|
+
clientEventId: options?.clientEventId,
|
|
1297
|
+
requestId
|
|
1298
|
+
}));
|
|
1299
|
+
const payload = response.payload;
|
|
1300
|
+
return {
|
|
1301
|
+
eventId: typeof payload.eventId === "string" ? payload.eventId : response.id,
|
|
1302
|
+
sequence: typeof payload.sequence === "number" ? payload.sequence : 0,
|
|
1303
|
+
clientEventId: typeof payload.clientEventId === "string" ? payload.clientEventId : null
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
/** Published events after `after`, oldest first; without it, the newest ones. */
|
|
1307
|
+
async history(input = {}) {
|
|
1308
|
+
this.assertJoined();
|
|
1309
|
+
if (!this.websocket.supportsCapability("realtime.room.history.v1")) throw new Error("Room history is not supported by the Gateway");
|
|
1310
|
+
const requestId = randomId();
|
|
1311
|
+
const response = await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.history"]), () => this.websocket.readRealtimeRoomHistory({
|
|
1312
|
+
roomId: this.id,
|
|
1313
|
+
...input,
|
|
1314
|
+
requestId
|
|
1315
|
+
}));
|
|
1316
|
+
return this.toHistory(response.payload);
|
|
1317
|
+
}
|
|
1318
|
+
/** An invite for someone outside the App, e.g. an Agent: `cohub rooms join <invite>`. */
|
|
1319
|
+
async invite(input = {}) {
|
|
1320
|
+
this.assertJoined();
|
|
1321
|
+
const context = this.appContext;
|
|
1322
|
+
if (!context) throw new Error("Invites are only available inside a published App or hosted Room");
|
|
1323
|
+
if (context.hosted) return context.transport.request(`/api/rooms/${encodeURIComponent(this.id)}/invites`, {
|
|
1324
|
+
method: "POST",
|
|
1325
|
+
headers: { "Content-Type": "application/json" },
|
|
1326
|
+
body: JSON.stringify(input)
|
|
1327
|
+
});
|
|
1328
|
+
return context.transport.request(`/api/apps/${encodeURIComponent(context.appId)}/realtime/rooms/${encodeURIComponent(this.id)}/invites`, {
|
|
1329
|
+
method: "POST",
|
|
1330
|
+
headers: { "Content-Type": "application/json" },
|
|
1331
|
+
body: JSON.stringify({
|
|
1332
|
+
ticket: this.admission.ticket,
|
|
1333
|
+
...input
|
|
1334
|
+
})
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
rtcFactory;
|
|
1338
|
+
rtc(options) {
|
|
1339
|
+
if (options?.createPeerConnection) this.rtcFactory = options.createPeerConnection;
|
|
1340
|
+
this.assertJoined();
|
|
1341
|
+
if (this.rtcConnection) {
|
|
1342
|
+
const current = this.rtcConnection;
|
|
1343
|
+
return current.then((rtc) => {
|
|
1344
|
+
if (rtc.state !== "closed") return rtc;
|
|
1345
|
+
return (this.rtcConnection === current ? this.openRtc() : this.rtcConnection) ?? this.openRtc();
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
return this.openRtc();
|
|
1349
|
+
}
|
|
1350
|
+
openRtc() {
|
|
1351
|
+
this.assertJoined();
|
|
1352
|
+
const context = this.appContext;
|
|
1353
|
+
if (!context) return Promise.reject(new AppRtcError("RTC_UNSUPPORTED", "RTC requires room context"));
|
|
1354
|
+
if (!this.rtcFactory && typeof RTCPeerConnection === "undefined") return Promise.reject(new AppRtcError("RTC_UNSUPPORTED", "RTC needs a WebRTC adapter"));
|
|
1355
|
+
if (!this.websocket.supportsCapability("realtime.room.rtc.v1")) return Promise.reject(new AppRtcError("RTC_UNSUPPORTED", "RTC is not supported by the Gateway"));
|
|
1356
|
+
const rtc = new AppRoomRtc({
|
|
1357
|
+
roomId: this.id,
|
|
1358
|
+
appId: context.appId,
|
|
1359
|
+
ticket: this.admission.ticket,
|
|
1360
|
+
transport: context.transport,
|
|
1361
|
+
hosted: context.hosted,
|
|
1362
|
+
createPeerConnection: this.rtcFactory,
|
|
1363
|
+
participantId: () => this._participantId,
|
|
1364
|
+
members: () => this._members,
|
|
1365
|
+
onMembersChanged: (handler) => this.onMembersChanged(handler)
|
|
1366
|
+
});
|
|
1367
|
+
const opening = rtc.start().then(() => rtc, (error) => {
|
|
1368
|
+
rtc.close();
|
|
1369
|
+
if (this.rtcConnection === opening) this.rtcConnection = null;
|
|
1370
|
+
throw error;
|
|
1371
|
+
});
|
|
1372
|
+
this.rtcConnection = opening;
|
|
1373
|
+
return opening;
|
|
1374
|
+
}
|
|
1375
|
+
async setPresence(presence) {
|
|
1376
|
+
this.assertJoined();
|
|
1377
|
+
const requestId = randomId();
|
|
1378
|
+
await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.request.ok"]), () => this.websocket.updateRealtimeRoomPresence({
|
|
1379
|
+
roomId: this.id,
|
|
1380
|
+
presence,
|
|
1381
|
+
requestId
|
|
1382
|
+
}));
|
|
1383
|
+
}
|
|
1384
|
+
async disconnect() {
|
|
1385
|
+
if (this.explicitLeave || this._state === "closed" || this._state === "expired") return;
|
|
1386
|
+
this.explicitLeave = true;
|
|
1387
|
+
try {
|
|
1388
|
+
if (this.hasJoined && this.websocket.state === "open") {
|
|
1389
|
+
const requestId = randomId();
|
|
1390
|
+
await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.request.ok"]), () => this.appContext?.hosted ? this.websocket.detachRealtimeRoom({
|
|
1391
|
+
roomId: this.id,
|
|
1392
|
+
requestId
|
|
1393
|
+
}) : this.websocket.leaveRealtimeRoom({
|
|
1394
|
+
roomId: this.id,
|
|
1395
|
+
requestId
|
|
1396
|
+
}));
|
|
1397
|
+
}
|
|
1398
|
+
} finally {
|
|
1399
|
+
this.hasJoined = false;
|
|
1400
|
+
this.setState("closed");
|
|
1401
|
+
this.dispose();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
async leave() {
|
|
1405
|
+
if (!this.appContext?.hosted) return this.disconnect();
|
|
1406
|
+
if (this.explicitLeave || this.state === "closed" || this.state === "expired") return;
|
|
1407
|
+
await this.appContext.transport.request(`/api/rooms/${encodeURIComponent(this.id)}/seat`, { method: "DELETE" });
|
|
1408
|
+
this.explicitLeave = true;
|
|
1409
|
+
this.hasJoined = false;
|
|
1410
|
+
this.rejectPending(/* @__PURE__ */ new Error("room left"));
|
|
1411
|
+
this.setState("closed");
|
|
1412
|
+
this.dispose();
|
|
1413
|
+
}
|
|
1414
|
+
async rejoin() {
|
|
1415
|
+
if (this.explicitLeave || this._state === "expired" || this._state === "closed") return;
|
|
1416
|
+
try {
|
|
1417
|
+
await this.joinOverWebsocket(true);
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
if (this.state === "expired") return;
|
|
1420
|
+
this.setState("closed");
|
|
1421
|
+
this.dispose();
|
|
1422
|
+
console.warn("[Cohub AppRoom] failed to rejoin room", error);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
async joinOverWebsocket(isReconnect, initialAfter) {
|
|
1426
|
+
if (!this.websocket.supportsCapability("realtime.room.v1") && this.websocket.state === "open") throw new Error("Realtime rooms are not supported by the Gateway");
|
|
1427
|
+
this.setState(isReconnect ? "reconnecting" : "connecting");
|
|
1428
|
+
const requestId = randomId();
|
|
1429
|
+
const after = isReconnect && this.websocket.supportsCapability("realtime.room.history.v1") ? this.lastSequence : initialAfter;
|
|
1430
|
+
let missed = null;
|
|
1431
|
+
this.joinBuffer = [];
|
|
1432
|
+
try {
|
|
1433
|
+
if (this.appContext?.hosted && isReconnect) {
|
|
1434
|
+
const admission = await this.appContext.transport.request(`/api/rooms/${encodeURIComponent(this.id)}/connect`, { method: "POST" });
|
|
1435
|
+
if (admission.participantId !== this.participantId) throw new Error("room seat changed during reconnect");
|
|
1436
|
+
this.admission.ticket = admission.ticket;
|
|
1437
|
+
}
|
|
1438
|
+
const payload = (await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.joined"]), () => this.websocket.joinRealtimeRoom({
|
|
1439
|
+
roomId: this.id,
|
|
1440
|
+
ticket: this.admission.ticket,
|
|
1441
|
+
instanceId: this.instanceId,
|
|
1442
|
+
after,
|
|
1443
|
+
requestId
|
|
1444
|
+
}))).payload;
|
|
1445
|
+
if (typeof payload.participantId !== "string" || !Array.isArray(payload.members)) throw new Error("invalid room join response");
|
|
1446
|
+
this._participantId = payload.participantId;
|
|
1447
|
+
this._members = payload.members;
|
|
1448
|
+
const sequence = typeof payload.sequence === "number" ? payload.sequence : 0;
|
|
1449
|
+
if (after !== void 0 && sequence > after) missed = await this.catchUp(payload.missed, after, sequence);
|
|
1450
|
+
this.lastSequence = sequence;
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
this.joinBuffer = null;
|
|
1453
|
+
throw error;
|
|
1454
|
+
}
|
|
1455
|
+
if (this.explicitLeave || this._state === "closed" || this._state === "expired") throw new Error("room is closed");
|
|
1456
|
+
this.hasJoined = true;
|
|
1457
|
+
this.setState("joined");
|
|
1458
|
+
this.notifyMembers();
|
|
1459
|
+
if (missed) {
|
|
1460
|
+
const { lost } = missed;
|
|
1461
|
+
if (lost) for (const handler of this.outOfSyncHandlers) handler(lost[0], lost[1]);
|
|
1462
|
+
for (const event of missed.events) this.emit(event);
|
|
1463
|
+
}
|
|
1464
|
+
const buffered = this.joinBuffer ?? [];
|
|
1465
|
+
this.joinBuffer = null;
|
|
1466
|
+
for (const item of buffered) {
|
|
1467
|
+
const sequence = item.payload.sequence;
|
|
1468
|
+
if (typeof sequence === "number" && sequence <= this.lastSequence) continue;
|
|
1469
|
+
this.handleEvent(item);
|
|
1470
|
+
}
|
|
1471
|
+
if (isReconnect) this.rtcConnection?.then((rtc) => rtc.verify()).catch(() => void 0);
|
|
1472
|
+
}
|
|
1473
|
+
request(requestId, expected, send) {
|
|
1474
|
+
return new Promise((resolve, reject) => {
|
|
1475
|
+
const timer = setTimeout(() => {
|
|
1476
|
+
this.pending.delete(requestId);
|
|
1477
|
+
reject(/* @__PURE__ */ new Error("room request timed out"));
|
|
1478
|
+
}, 2e4);
|
|
1479
|
+
this.pending.set(requestId, {
|
|
1480
|
+
expected,
|
|
1481
|
+
resolve,
|
|
1482
|
+
reject,
|
|
1483
|
+
timer
|
|
1484
|
+
});
|
|
1485
|
+
send().catch((error) => {
|
|
1486
|
+
this.pending.delete(requestId);
|
|
1487
|
+
clearTimeout(timer);
|
|
1488
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
/** Detaches the entry awaiting this requestId, so the caller can settle it. */
|
|
1493
|
+
takePending(requestId) {
|
|
1494
|
+
const pending = requestId ? this.pending.get(requestId) : void 0;
|
|
1495
|
+
if (!pending || !requestId) return null;
|
|
1496
|
+
this.pending.delete(requestId);
|
|
1497
|
+
clearTimeout(pending.timer);
|
|
1498
|
+
return pending;
|
|
1499
|
+
}
|
|
1500
|
+
handleEvent(event) {
|
|
1501
|
+
if (!isRoomEvent(event, this.id)) {
|
|
1502
|
+
if (event.type === "system.request.error") this.takePending(event.requestId)?.reject(requestError(event));
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (event.type === "realtime.room.request.error") {
|
|
1506
|
+
const failure = requestError(event);
|
|
1507
|
+
if (event.requestId) {
|
|
1508
|
+
this.takePending(event.requestId)?.reject(failure);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
for (const handler of this.sendErrorHandlers) handler(failure);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
const awaiting = event.requestId ? this.pending.get(event.requestId) : void 0;
|
|
1515
|
+
if (awaiting?.expected.has(event.type)) {
|
|
1516
|
+
this.takePending(event.requestId);
|
|
1517
|
+
awaiting.resolve(event);
|
|
1518
|
+
}
|
|
1519
|
+
if (this.joinBuffer && APP_ROOM_DELTA_EVENTS.has(event.type)) {
|
|
1520
|
+
if (this.joinBuffer.length < APP_ROOM_MAX_JOIN_BUFFER) this.joinBuffer.push(event);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const payload = event.payload;
|
|
1524
|
+
if (APP_ROOM_DELTA_EVENTS.has(event.type) && typeof payload.sequence === "number") this.observeSequence(payload.sequence);
|
|
1525
|
+
if (APP_ROOM_MEMBER_EVENTS.has(event.type)) {
|
|
1526
|
+
const member = payload.member;
|
|
1527
|
+
if (member?.participantId) {
|
|
1528
|
+
const members = new Map(this._members.map((item) => [item.participantId, item]));
|
|
1529
|
+
if (event.type === "realtime.room.member.left") members.delete(member.participantId);
|
|
1530
|
+
else members.set(member.participantId, member);
|
|
1531
|
+
this._members = [...members.values()];
|
|
1532
|
+
this.notifyMembers();
|
|
1533
|
+
}
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (event.type === "realtime.room.closed") {
|
|
1537
|
+
this.hasJoined = false;
|
|
1538
|
+
if (payload.reason === "expired") this.setState("expired");
|
|
1539
|
+
else this.setState("closed");
|
|
1540
|
+
this.rejectPending(/* @__PURE__ */ new Error(`room closed: ${typeof payload.reason === "string" ? payload.reason : "unknown"}`));
|
|
1541
|
+
this.dispose();
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (event.type !== "realtime.room.event") return;
|
|
1545
|
+
const { id, timestamp, payload: roomPayload } = event;
|
|
1546
|
+
this.emit({
|
|
1547
|
+
id,
|
|
1548
|
+
timestamp,
|
|
1549
|
+
roomId: roomPayload.roomId,
|
|
1550
|
+
sequence: roomPayload.sequence,
|
|
1551
|
+
type: roomPayload.event,
|
|
1552
|
+
data: roomPayload.data,
|
|
1553
|
+
clientEventId: roomPayload.clientEventId,
|
|
1554
|
+
sender: roomPayload.sender,
|
|
1555
|
+
self: roomPayload.sender.participantId === this.participantId
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
async catchUp(first, after, sequence) {
|
|
1559
|
+
if (!first) return {
|
|
1560
|
+
events: [],
|
|
1561
|
+
lost: [after + 1, sequence]
|
|
1562
|
+
};
|
|
1563
|
+
const events = this.toHistory(first).events;
|
|
1564
|
+
const { truncated } = first;
|
|
1565
|
+
let { cursor } = first;
|
|
1566
|
+
while (cursor < sequence) {
|
|
1567
|
+
const requestId = randomId();
|
|
1568
|
+
const page = (await this.request(requestId, /* @__PURE__ */ new Set(["realtime.room.history"]), () => this.websocket.readRealtimeRoomHistory({
|
|
1569
|
+
roomId: this.id,
|
|
1570
|
+
after: cursor,
|
|
1571
|
+
requestId
|
|
1572
|
+
})).catch(() => null))?.payload;
|
|
1573
|
+
if (!page || page.cursor <= cursor) break;
|
|
1574
|
+
events.push(...this.toHistory(page).events);
|
|
1575
|
+
cursor = page.cursor;
|
|
1576
|
+
}
|
|
1577
|
+
const kept = events.filter((event) => event.sequence <= sequence);
|
|
1578
|
+
const lost = truncated ? [after + 1, kept[0]?.sequence ?? sequence] : cursor < sequence ? [cursor + 1, sequence] : null;
|
|
1579
|
+
return {
|
|
1580
|
+
events: kept,
|
|
1581
|
+
lost: lost && lost[1] > lost[0] ? lost : null
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
emit(item) {
|
|
1585
|
+
this.handlers.get(item.type)?.forEach((handler) => {
|
|
1586
|
+
handler(item);
|
|
1587
|
+
});
|
|
1588
|
+
this.allHandlers.forEach((handler) => {
|
|
1589
|
+
handler(item);
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
toHistory(history) {
|
|
1593
|
+
return {
|
|
1594
|
+
events: history.events.map((event) => ({
|
|
1595
|
+
...event,
|
|
1596
|
+
data: event.data,
|
|
1597
|
+
roomId: this.id,
|
|
1598
|
+
self: event.sender.participantId === this.participantId
|
|
1599
|
+
})),
|
|
1600
|
+
cursor: history.cursor,
|
|
1601
|
+
truncated: history.truncated
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
observeSequence(sequence) {
|
|
1605
|
+
if (this.lastSequence > 0 && sequence > this.lastSequence + 1) for (const handler of this.outOfSyncHandlers) handler(this.lastSequence + 1, sequence);
|
|
1606
|
+
if (sequence > this.lastSequence) this.lastSequence = sequence;
|
|
1607
|
+
}
|
|
1608
|
+
assertJoined() {
|
|
1609
|
+
if (this._state !== "joined" || !this.hasJoined) throw new Error("room is not joined");
|
|
1610
|
+
}
|
|
1611
|
+
setState(state) {
|
|
1612
|
+
if (this._state === state) return;
|
|
1613
|
+
this._state = state;
|
|
1614
|
+
for (const handler of this.stateHandlers) handler(state);
|
|
1615
|
+
}
|
|
1616
|
+
notifyMembers() {
|
|
1617
|
+
const members = this.members;
|
|
1618
|
+
for (const handler of this.membersHandlers) handler(members);
|
|
1619
|
+
}
|
|
1620
|
+
rejectPending(error) {
|
|
1621
|
+
for (const [requestId, pending] of this.pending) {
|
|
1622
|
+
this.pending.delete(requestId);
|
|
1623
|
+
clearTimeout(pending.timer);
|
|
1624
|
+
pending.reject(error);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
expire() {
|
|
1628
|
+
if (this.explicitLeave || this._state === "closed" || this._state === "expired") return;
|
|
1629
|
+
this.hasJoined = false;
|
|
1630
|
+
this.setState("expired");
|
|
1631
|
+
this.rejectPending(/* @__PURE__ */ new Error("room expired"));
|
|
1632
|
+
this.dispose();
|
|
1633
|
+
}
|
|
1634
|
+
dispose() {
|
|
1635
|
+
const rtc = this.rtcConnection;
|
|
1636
|
+
this.rtcConnection = null;
|
|
1637
|
+
rtc?.then((connection) => connection.close()).catch(() => void 0);
|
|
1638
|
+
this.offEvent();
|
|
1639
|
+
this.offOpen();
|
|
1640
|
+
this.offClose();
|
|
1641
|
+
this.offReconnecting();
|
|
1642
|
+
if (this.expiryTimer) clearTimeout(this.expiryTimer);
|
|
1643
|
+
this.expiryTimer = null;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
var AppRealtimeApi = class {
|
|
1647
|
+
transport;
|
|
1648
|
+
websocket;
|
|
1649
|
+
getContext;
|
|
1650
|
+
constructor(transport, websocket, getContext) {
|
|
1651
|
+
this.transport = transport;
|
|
1652
|
+
this.websocket = websocket;
|
|
1653
|
+
this.getContext = getContext;
|
|
1654
|
+
}
|
|
1655
|
+
async createRoom(input = {}) {
|
|
1656
|
+
const appId = await this.requireAppId();
|
|
1657
|
+
const admission = await this.transport.request(`/api/apps/${encodeURIComponent(appId)}/realtime/rooms`, {
|
|
1658
|
+
method: "POST",
|
|
1659
|
+
headers: { "Content-Type": "application/json" },
|
|
1660
|
+
body: JSON.stringify(input)
|
|
1661
|
+
});
|
|
1662
|
+
return this.openRoom(appId, admission);
|
|
1663
|
+
}
|
|
1664
|
+
async joinRoom(input) {
|
|
1665
|
+
const appId = await this.requireAppId();
|
|
1666
|
+
const admission = await this.transport.request(`/api/apps/${encodeURIComponent(appId)}/realtime/rooms/join`, {
|
|
1667
|
+
method: "POST",
|
|
1668
|
+
headers: { "Content-Type": "application/json" },
|
|
1669
|
+
body: JSON.stringify(input)
|
|
1670
|
+
});
|
|
1671
|
+
return this.openRoom(appId, admission);
|
|
1672
|
+
}
|
|
1673
|
+
async requireAppId() {
|
|
1674
|
+
const context = await this.getContext();
|
|
1675
|
+
if (!context?.app?.id) throw new Error("App context is unavailable — not running inside a published app runtime.");
|
|
1676
|
+
return context.app.id;
|
|
1677
|
+
}
|
|
1678
|
+
async openRoom(appId, admission) {
|
|
1679
|
+
const room = new AppRoom(this.websocket, admission, {
|
|
1680
|
+
transport: this.transport,
|
|
1681
|
+
appId
|
|
1682
|
+
});
|
|
1683
|
+
try {
|
|
1684
|
+
await room.connect();
|
|
1685
|
+
return room;
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
await room.leave().catch(() => void 0);
|
|
1688
|
+
throw error;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
/** @deprecated Use `AppRealtimeApi`. */
|
|
1693
|
+
var WorkRealtimeApi = class extends AppRealtimeApi {};
|
|
1694
|
+
/** @deprecated Use `AppRoom`. */
|
|
1695
|
+
var WorkRoom = class extends AppRoom {};
|
|
1696
|
+
//#endregion
|
|
1697
|
+
export { app_realtime_exports as a, AppRtcError as c, WorkRoom as i, AppRoom as n, AppRoomRtc as o, WorkRealtimeApi as r, AppRtcChannel as s, AppRealtimeApi as t };
|