@mebius-io/web 0.1.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.cjs ADDED
@@ -0,0 +1,723 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ Mebius: () => Mebius,
34
+ MebiusBroadcaster: () => MebiusBroadcaster,
35
+ MebiusClient: () => MebiusClient,
36
+ MebiusError: () => MebiusError,
37
+ MebiusPlayer: () => MebiusPlayer,
38
+ mebiusError: () => mebiusError
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/errors.ts
43
+ var MebiusError = class _MebiusError extends Error {
44
+ constructor(code, message, cause) {
45
+ super(message);
46
+ this.name = "MebiusError";
47
+ this.code = code;
48
+ this.cause = cause;
49
+ Object.setPrototypeOf(this, _MebiusError.prototype);
50
+ }
51
+ };
52
+ var DEFAULT_MESSAGES = {
53
+ TOKEN_EXPIRED: "Your Mebius token has expired. Mint a fresh token and reconnect.",
54
+ PERMISSION_DENIED: "Camera/microphone permission was denied by the user or browser.",
55
+ CONNECTION_FAILED: "Could not establish a connection to the Mebius gateway.",
56
+ NOT_CONNECTED: "Not connected to Mebius. Call connect() before using this client.",
57
+ STREAM_NOT_FOUND: "The requested stream could not be found on the Mebius gateway.",
58
+ UNKNOWN: "An unexpected Mebius error occurred."
59
+ };
60
+ function mebiusError(code, message, cause) {
61
+ return new MebiusError(code, message ?? DEFAULT_MESSAGES[code], cause);
62
+ }
63
+
64
+ // src/events.ts
65
+ var TypedEmitter = class {
66
+ constructor() {
67
+ this.listeners = /* @__PURE__ */ new Map();
68
+ }
69
+ /** Subscribe to an event. Returns an unsubscribe function. */
70
+ on(event, cb) {
71
+ let set = this.listeners.get(event);
72
+ if (!set) {
73
+ set = /* @__PURE__ */ new Set();
74
+ this.listeners.set(event, set);
75
+ }
76
+ set.add(cb);
77
+ return () => this.off(event, cb);
78
+ }
79
+ /** Unsubscribe a previously-registered listener. */
80
+ off(event, cb) {
81
+ this.listeners.get(event)?.delete(cb);
82
+ }
83
+ /** Emit an event to all listeners. Internal use. */
84
+ emit(event, payload) {
85
+ const set = this.listeners.get(event);
86
+ if (!set) return;
87
+ for (const cb of [...set]) cb(payload);
88
+ }
89
+ /** Remove every listener. Internal use during teardown. */
90
+ removeAllListeners() {
91
+ this.listeners.clear();
92
+ }
93
+ };
94
+
95
+ // src/internal/view-target.ts
96
+ function resolveVideoElement(target) {
97
+ if (typeof target !== "string") {
98
+ if (target instanceof HTMLVideoElement) return target;
99
+ throw mebiusError("UNKNOWN", "View target must be a <video> element or a CSS selector.");
100
+ }
101
+ const el = document.querySelector(target);
102
+ if (!el) {
103
+ throw mebiusError("UNKNOWN", `No element matches the selector "${target}".`);
104
+ }
105
+ if (!(el instanceof HTMLVideoElement)) {
106
+ throw mebiusError("UNKNOWN", `Selector "${target}" did not resolve to a <video> element.`);
107
+ }
108
+ return el;
109
+ }
110
+
111
+ // src/internal/webrtc-util.ts
112
+ function waitForIceGathering(pc, timeoutMs = 2e3) {
113
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
114
+ return new Promise((resolve) => {
115
+ const done = () => {
116
+ pc.removeEventListener("icegatheringstatechange", check);
117
+ clearTimeout(timer);
118
+ resolve();
119
+ };
120
+ const check = () => {
121
+ if (pc.iceGatheringState === "complete") done();
122
+ };
123
+ const timer = setTimeout(done, timeoutMs);
124
+ pc.addEventListener("icegatheringstatechange", check);
125
+ });
126
+ }
127
+ var DEFAULT_RTC_CONFIG = {
128
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
129
+ };
130
+
131
+ // src/internal/publish-transport.ts
132
+ var WhipPublishTransport = class {
133
+ constructor(signaling) {
134
+ this.signaling = signaling;
135
+ this.pc = null;
136
+ this.resourceUrl = null;
137
+ }
138
+ async start(streamId, stream) {
139
+ const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
140
+ this.pc = pc;
141
+ for (const track of stream.getTracks()) {
142
+ pc.addTrack(track, stream);
143
+ }
144
+ const offer = await pc.createOffer();
145
+ await pc.setLocalDescription(offer);
146
+ await waitForIceGathering(pc);
147
+ const localSdp = pc.localDescription?.sdp;
148
+ if (!localSdp) throw mebiusError("CONNECTION_FAILED", "Failed to create a local session.");
149
+ const { answerSdp, resourceUrl } = await this.signaling.exchangeSdp(
150
+ "whip",
151
+ streamId,
152
+ localSdp
153
+ );
154
+ this.resourceUrl = resourceUrl;
155
+ await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
156
+ }
157
+ async replaceVideoTrack(track) {
158
+ const sender = this.pc?.getSenders().find((s) => s.track?.kind === "video");
159
+ if (sender) await sender.replaceTrack(track);
160
+ }
161
+ async stop() {
162
+ await this.signaling.deleteResource(this.resourceUrl);
163
+ this.resourceUrl = null;
164
+ this.pc?.getSenders().forEach((s) => s.track?.stop());
165
+ this.pc?.close();
166
+ this.pc = null;
167
+ }
168
+ async getStats() {
169
+ if (!this.pc) return null;
170
+ const report = await this.pc.getStats();
171
+ let bitrateKbps = 0;
172
+ let framesPerSecond = 0;
173
+ let rttMs;
174
+ report.forEach((stat) => {
175
+ if (stat.type === "outbound-rtp" && !stat.isRemote) {
176
+ if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
177
+ }
178
+ if (stat.type === "candidate-pair" && stat.state === "succeeded") {
179
+ if (typeof stat.availableOutgoingBitrate === "number") {
180
+ bitrateKbps = Math.round(stat.availableOutgoingBitrate / 1e3);
181
+ }
182
+ if (typeof stat.currentRoundTripTime === "number") {
183
+ rttMs = Math.round(stat.currentRoundTripTime * 1e3);
184
+ }
185
+ }
186
+ });
187
+ return { bitrateKbps, framesPerSecond, rttMs };
188
+ }
189
+ };
190
+
191
+ // src/internal/ll-view-transport.ts
192
+ var WhepViewTransport = class {
193
+ constructor(signaling) {
194
+ this.signaling = signaling;
195
+ this.pc = null;
196
+ this.resourceUrl = null;
197
+ this.endedCb = null;
198
+ this.bufferingCb = null;
199
+ }
200
+ onEnded(cb) {
201
+ this.endedCb = cb;
202
+ }
203
+ onBuffering(cb) {
204
+ this.bufferingCb = cb;
205
+ }
206
+ async start(streamId, video) {
207
+ const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
208
+ this.pc = pc;
209
+ const remote = new MediaStream();
210
+ pc.addTransceiver("video", { direction: "recvonly" });
211
+ pc.addTransceiver("audio", { direction: "recvonly" });
212
+ pc.ontrack = (ev) => {
213
+ remote.addTrack(ev.track);
214
+ video.srcObject = remote;
215
+ void video.play().catch(() => {
216
+ });
217
+ };
218
+ pc.onconnectionstatechange = () => {
219
+ if (pc.connectionState === "disconnected" || pc.connectionState === "failed") {
220
+ this.bufferingCb?.();
221
+ }
222
+ if (pc.connectionState === "closed") this.endedCb?.();
223
+ };
224
+ const offer = await pc.createOffer();
225
+ await pc.setLocalDescription(offer);
226
+ await waitForIceGathering(pc);
227
+ const localSdp = pc.localDescription?.sdp;
228
+ if (!localSdp) throw mebiusError("CONNECTION_FAILED", "Failed to create a local session.");
229
+ const { answerSdp, resourceUrl } = await this.signaling.exchangeSdp(
230
+ "whep",
231
+ streamId,
232
+ localSdp
233
+ );
234
+ this.resourceUrl = resourceUrl;
235
+ await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
236
+ }
237
+ async stop() {
238
+ await this.signaling.deleteResource(this.resourceUrl);
239
+ this.resourceUrl = null;
240
+ this.pc?.close();
241
+ this.pc = null;
242
+ }
243
+ async getStats() {
244
+ if (!this.pc) return null;
245
+ const report = await this.pc.getStats();
246
+ let bitrateKbps = 0;
247
+ let framesPerSecond = 0;
248
+ let latencyMs;
249
+ report.forEach((stat) => {
250
+ if (stat.type === "inbound-rtp") {
251
+ if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
252
+ if (typeof stat.jitter === "number") latencyMs = Math.round(stat.jitter * 1e3);
253
+ }
254
+ if (stat.type === "candidate-pair" && stat.state === "succeeded") {
255
+ if (typeof stat.availableIncomingBitrate === "number") {
256
+ bitrateKbps = Math.round(stat.availableIncomingBitrate / 1e3);
257
+ }
258
+ }
259
+ });
260
+ return { bitrateKbps, framesPerSecond, latencyMs };
261
+ }
262
+ };
263
+
264
+ // src/internal/balanced-view-transport.ts
265
+ var FlvViewTransport = class {
266
+ constructor(signaling) {
267
+ this.signaling = signaling;
268
+ this.player = null;
269
+ this.video = null;
270
+ this.endedCb = null;
271
+ this.bufferingCb = null;
272
+ }
273
+ onEnded(cb) {
274
+ this.endedCb = cb;
275
+ }
276
+ onBuffering(cb) {
277
+ this.bufferingCb = cb;
278
+ }
279
+ async start(streamId, video) {
280
+ this.video = video;
281
+ const url = this.signaling.balancedStreamUrl(streamId);
282
+ video.addEventListener("ended", () => this.endedCb?.());
283
+ video.addEventListener("waiting", () => this.bufferingCb?.());
284
+ let mod;
285
+ try {
286
+ const spec = "flv.js";
287
+ mod = await import(
288
+ /* @vite-ignore */
289
+ spec
290
+ );
291
+ } catch (cause) {
292
+ throw mebiusError("CONNECTION_FAILED", "Balanced playback support failed to load.", cause);
293
+ }
294
+ const flvjs = mod.default;
295
+ if (!flvjs.isSupported()) {
296
+ throw mebiusError("CONNECTION_FAILED", "Balanced playback is not supported in this browser.");
297
+ }
298
+ const player = flvjs.createPlayer({ type: "flv", url, isLive: true });
299
+ this.player = player;
300
+ player.on(flvjs.Events.ERROR ?? "error", () => this.bufferingCb?.());
301
+ player.attachMediaElement(video);
302
+ player.load();
303
+ await Promise.resolve(player.play()).catch(() => void 0);
304
+ }
305
+ async stop() {
306
+ if (this.player) {
307
+ this.player.unload();
308
+ this.player.detachMediaElement();
309
+ this.player.destroy();
310
+ this.player = null;
311
+ }
312
+ if (this.video) {
313
+ this.video.removeAttribute("src");
314
+ this.video.load();
315
+ }
316
+ this.video = null;
317
+ }
318
+ async getStats() {
319
+ if (!this.video) return null;
320
+ return {
321
+ bitrateKbps: 0,
322
+ framesPerSecond: 0,
323
+ latencyMs: void 0
324
+ };
325
+ }
326
+ };
327
+
328
+ // src/internal/scale-view-transport.ts
329
+ var HlsViewTransport = class {
330
+ constructor(signaling) {
331
+ this.signaling = signaling;
332
+ this.hls = null;
333
+ this.video = null;
334
+ this.endedCb = null;
335
+ this.bufferingCb = null;
336
+ }
337
+ onEnded(cb) {
338
+ this.endedCb = cb;
339
+ }
340
+ onBuffering(cb) {
341
+ this.bufferingCb = cb;
342
+ }
343
+ async start(streamId, video) {
344
+ this.video = video;
345
+ const url = this.signaling.scalePlaylistUrl(streamId);
346
+ video.addEventListener("ended", () => this.endedCb?.());
347
+ video.addEventListener("waiting", () => this.bufferingCb?.());
348
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
349
+ video.src = url;
350
+ await video.play().catch(() => void 0);
351
+ return;
352
+ }
353
+ let mod;
354
+ try {
355
+ mod = await import("hls.js");
356
+ } catch (cause) {
357
+ throw mebiusError("CONNECTION_FAILED", "Scale playback support failed to load.", cause);
358
+ }
359
+ const Hls = mod.default;
360
+ if (!Hls.isSupported()) {
361
+ throw mebiusError("CONNECTION_FAILED", "Scale playback is not supported in this browser.");
362
+ }
363
+ const hls = new Hls({ lowLatencyMode: true });
364
+ this.hls = hls;
365
+ hls.on(Hls.Events.ERROR, (_evt, data) => {
366
+ if (data.fatal) this.bufferingCb?.();
367
+ });
368
+ hls.loadSource(url);
369
+ hls.attachMedia(video);
370
+ await video.play().catch(() => void 0);
371
+ }
372
+ async stop() {
373
+ this.hls?.destroy();
374
+ this.hls = null;
375
+ if (this.video) {
376
+ this.video.removeAttribute("src");
377
+ this.video.load();
378
+ }
379
+ this.video = null;
380
+ }
381
+ async getStats() {
382
+ if (!this.video) return null;
383
+ const level = this.hls?.levels?.[this.hls.currentLevel];
384
+ return {
385
+ bitrateKbps: level ? Math.round(level.bitrate / 1e3) : 0,
386
+ framesPerSecond: 0
387
+ };
388
+ }
389
+ };
390
+
391
+ // src/internal/transport.ts
392
+ function createPublishTransport(signaling) {
393
+ return new WhipPublishTransport(signaling);
394
+ }
395
+ function createViewTransport(mode, signaling) {
396
+ switch (mode) {
397
+ case "low-latency":
398
+ return new WhepViewTransport(signaling);
399
+ case "balanced":
400
+ return new FlvViewTransport(signaling);
401
+ case "scale":
402
+ return new HlsViewTransport(signaling);
403
+ }
404
+ }
405
+
406
+ // src/broadcaster.ts
407
+ var STATS_INTERVAL_MS = 2e3;
408
+ var MebiusBroadcaster = class extends TypedEmitter {
409
+ /** @internal */
410
+ constructor(signaling, options) {
411
+ super();
412
+ this.options = options;
413
+ this.stream = null;
414
+ this.facingMode = "user";
415
+ this.statsTimer = null;
416
+ this.started = false;
417
+ this.transport = createPublishTransport(signaling);
418
+ }
419
+ /** Begin broadcasting under the given stream id. */
420
+ async start(streamId) {
421
+ if (this.started) return;
422
+ this.stream = await this.capture();
423
+ await this.transport.start(streamId, this.stream);
424
+ this.started = true;
425
+ this.startStats();
426
+ this.emit("started", { streamId });
427
+ }
428
+ /** Stop broadcasting and release the camera/microphone. */
429
+ async stop() {
430
+ this.stopStats();
431
+ await this.transport.stop();
432
+ this.stream?.getTracks().forEach((t) => t.stop());
433
+ this.stream = null;
434
+ this.started = false;
435
+ this.emit("stopped", void 0);
436
+ }
437
+ /** Flip between front and back camera (where available). */
438
+ async switchCamera() {
439
+ if (!this.stream) return;
440
+ this.facingMode = this.facingMode === "user" ? "environment" : "user";
441
+ const next = await navigator.mediaDevices.getUserMedia({
442
+ video: { facingMode: this.facingMode },
443
+ audio: false
444
+ });
445
+ const newTrack = next.getVideoTracks()[0] ?? null;
446
+ const oldTrack = this.stream.getVideoTracks()[0];
447
+ if (oldTrack) {
448
+ this.stream.removeTrack(oldTrack);
449
+ oldTrack.stop();
450
+ }
451
+ if (newTrack) this.stream.addTrack(newTrack);
452
+ await this.transport.replaceVideoTrack(newTrack);
453
+ }
454
+ /** Mute or unmute the outgoing microphone. */
455
+ setMicEnabled(enabled) {
456
+ this.stream?.getAudioTracks().forEach((t) => t.enabled = enabled);
457
+ }
458
+ /** Enable or disable the outgoing camera. */
459
+ setCameraEnabled(enabled) {
460
+ this.stream?.getVideoTracks().forEach((t) => t.enabled = enabled);
461
+ }
462
+ /**
463
+ * Web convenience: render the local camera preview into a `<video>` element.
464
+ * This is the web analog of the mobile preview view; it does not affect what
465
+ * is broadcast.
466
+ */
467
+ attachPreview(target) {
468
+ if (!this.stream) return;
469
+ const video = resolveVideoElement(target);
470
+ video.srcObject = this.stream;
471
+ video.muted = true;
472
+ void video.play().catch(() => void 0);
473
+ }
474
+ async capture() {
475
+ const video = normalize(this.options.video, true);
476
+ const audio = normalize(this.options.audio, true);
477
+ try {
478
+ return await navigator.mediaDevices.getUserMedia({ video, audio });
479
+ } catch (cause) {
480
+ throw mebiusError("PERMISSION_DENIED", void 0, cause);
481
+ }
482
+ }
483
+ startStats() {
484
+ this.statsTimer = setInterval(async () => {
485
+ const stats = await this.transport.getStats();
486
+ if (stats) this.emit("stats", stats);
487
+ }, STATS_INTERVAL_MS);
488
+ }
489
+ stopStats() {
490
+ if (this.statsTimer) clearInterval(this.statsTimer);
491
+ this.statsTimer = null;
492
+ }
493
+ };
494
+ function normalize(c, fallback) {
495
+ if (c === void 0) return fallback;
496
+ return c;
497
+ }
498
+
499
+ // src/player.ts
500
+ var STATS_INTERVAL_MS2 = 2e3;
501
+ var MebiusPlayer = class extends TypedEmitter {
502
+ /** @internal */
503
+ constructor(signaling, options) {
504
+ super();
505
+ this.video = null;
506
+ this.statsTimer = null;
507
+ this.playing = false;
508
+ this.transport = createViewTransport(options.mode, signaling);
509
+ this.transport.onEnded(() => {
510
+ this.playing = false;
511
+ this.stopStats();
512
+ this.emit("ended", void 0);
513
+ });
514
+ this.transport.onBuffering(() => this.emit("buffering", void 0));
515
+ }
516
+ /** Start playing `streamId` into the given video element or selector. */
517
+ async play(streamId, viewTarget) {
518
+ if (this.playing) return;
519
+ this.video = resolveVideoElement(viewTarget);
520
+ await this.transport.start(streamId, this.video);
521
+ this.playing = true;
522
+ this.startStats();
523
+ this.emit("playing", { streamId });
524
+ }
525
+ /** Stop playback and detach from the video element. */
526
+ async stop() {
527
+ this.stopStats();
528
+ await this.transport.stop();
529
+ this.video = null;
530
+ this.playing = false;
531
+ }
532
+ /** Set output volume in the range 0..1. */
533
+ setVolume(volume) {
534
+ const v = Math.min(1, Math.max(0, volume));
535
+ if (this.video) this.video.volume = v;
536
+ }
537
+ startStats() {
538
+ this.statsTimer = setInterval(async () => {
539
+ const stats = await this.transport.getStats();
540
+ if (stats) this.emit("stats", stats);
541
+ }, STATS_INTERVAL_MS2);
542
+ }
543
+ stopStats() {
544
+ if (this.statsTimer) clearInterval(this.statsTimer);
545
+ this.statsTimer = null;
546
+ }
547
+ };
548
+
549
+ // src/internal/signaling.ts
550
+ var SignalingClient = class {
551
+ constructor(gateway, token) {
552
+ this.gateway = gateway;
553
+ this.token = token;
554
+ }
555
+ base() {
556
+ return this.gateway.replace(/\/+$/, "");
557
+ }
558
+ headers(contentType) {
559
+ const h = { Authorization: `Bearer ${this.token}` };
560
+ if (contentType) h["Content-Type"] = contentType;
561
+ return h;
562
+ }
563
+ // Build the playlist URL used by scale-mode playback (HLS path, hidden).
564
+ /** Playlist URL for scale-mode playback. */
565
+ scalePlaylistUrl(streamId) {
566
+ return `${this.base()}/hls/${encodeURIComponent(streamId)}/index.m3u8`;
567
+ }
568
+ // Build the HTTP-FLV pull URL used by balanced-mode playback. Served by the
569
+ // CDN (CDN_PULL_FORMAT `.flv`) or an SRS/nginx-rtmp edge in front of the
570
+ // engine — MediaMTX itself does not vend HTTP-FLV. Hidden from the public API.
571
+ /** Pull URL for balanced-mode playback. */
572
+ balancedStreamUrl(streamId) {
573
+ return `${this.base()}/flv/${encodeURIComponent(streamId)}.flv`;
574
+ }
575
+ // Performs the SDP offer/answer exchange for a publish (WHIP) or low-latency
576
+ // view (WHEP) session. Protocol detail kept in line comments so it never
577
+ // leaks into the bundled public .d.ts.
578
+ /**
579
+ * Run the session offer/answer exchange. Throws a {@link MebiusError} with a
580
+ * Mebius-flavored code on failure — never the raw protocol name.
581
+ */
582
+ async exchangeSdp(kind, streamId, offerSdp) {
583
+ const url = `${this.base()}/${kind}/${encodeURIComponent(streamId)}`;
584
+ let res;
585
+ try {
586
+ res = await fetch(url, {
587
+ method: "POST",
588
+ headers: this.headers("application/sdp"),
589
+ body: offerSdp
590
+ });
591
+ } catch (cause) {
592
+ throw mebiusError("CONNECTION_FAILED", void 0, cause);
593
+ }
594
+ if (res.status === 401 || res.status === 403) {
595
+ throw mebiusError("TOKEN_EXPIRED");
596
+ }
597
+ if (res.status === 404) {
598
+ throw mebiusError("STREAM_NOT_FOUND");
599
+ }
600
+ if (!res.ok) {
601
+ throw mebiusError("CONNECTION_FAILED", `Mebius gateway returned ${res.status}.`);
602
+ }
603
+ const answerSdp = await res.text();
604
+ const location = res.headers.get("Location");
605
+ const resourceUrl = location ? new URL(location, url).toString() : null;
606
+ return { answerSdp, resourceUrl };
607
+ }
608
+ /** Tear down a previously-created session resource. Best-effort. */
609
+ async deleteResource(resourceUrl) {
610
+ if (!resourceUrl) return;
611
+ try {
612
+ await fetch(resourceUrl, { method: "DELETE", headers: this.headers() });
613
+ } catch {
614
+ }
615
+ }
616
+ };
617
+
618
+ // src/internal/token.ts
619
+ function base64UrlDecode(input) {
620
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
621
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - padded.length % 4);
622
+ const b64 = padded + pad;
623
+ if (typeof atob === "function") return atob(b64);
624
+ return Buffer.from(b64, "base64").toString("binary");
625
+ }
626
+ function readToken(token) {
627
+ const parts = token.split(".");
628
+ if (parts.length < 2) return { expiresAtMs: null };
629
+ try {
630
+ const payload = JSON.parse(base64UrlDecode(parts[1] ?? ""));
631
+ return { expiresAtMs: typeof payload.exp === "number" ? payload.exp * 1e3 : null };
632
+ } catch {
633
+ return { expiresAtMs: null };
634
+ }
635
+ }
636
+
637
+ // src/client.ts
638
+ var MebiusClient = class extends TypedEmitter {
639
+ /** @internal */
640
+ constructor(config2, token) {
641
+ super();
642
+ this.token = token;
643
+ this.expiryTimer = null;
644
+ this.connected = false;
645
+ this.signaling = new SignalingClient(config2.gateway, token);
646
+ }
647
+ /** @internal Called by {@link Mebius.connect}. */
648
+ open() {
649
+ const { expiresAtMs } = readToken(this.token);
650
+ const now = Date.now();
651
+ if (expiresAtMs !== null && expiresAtMs <= now) {
652
+ queueMicrotask(() => this.emit("error", mebiusError("TOKEN_EXPIRED")));
653
+ return;
654
+ }
655
+ this.connected = true;
656
+ if (expiresAtMs !== null) {
657
+ this.expiryTimer = setTimeout(
658
+ () => this.emit("error", mebiusError("TOKEN_EXPIRED")),
659
+ Math.max(0, expiresAtMs - now)
660
+ );
661
+ }
662
+ queueMicrotask(() => this.emit("connected", void 0));
663
+ }
664
+ /** Create a broadcaster bound to this connection. */
665
+ createBroadcaster(options = {}) {
666
+ this.assertConnected();
667
+ return new MebiusBroadcaster(this.signaling, options);
668
+ }
669
+ /** Create a player bound to this connection. */
670
+ createPlayer(options) {
671
+ this.assertConnected();
672
+ return new MebiusPlayer(this.signaling, options);
673
+ }
674
+ /** Close the connection and release resources. */
675
+ disconnect(reason) {
676
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
677
+ this.expiryTimer = null;
678
+ this.connected = false;
679
+ this.emit("disconnected", { reason });
680
+ this.removeAllListeners();
681
+ }
682
+ assertConnected() {
683
+ if (!this.connected) throw mebiusError("NOT_CONNECTED");
684
+ }
685
+ };
686
+
687
+ // src/mebius.ts
688
+ var config = null;
689
+ var Mebius = {
690
+ /** Configure the SDK once, before connecting. */
691
+ init(options) {
692
+ if (!options.appId) throw mebiusError("UNKNOWN", "Mebius.init requires an appId.");
693
+ if (!options.gateway) throw mebiusError("UNKNOWN", "Mebius.init requires a gateway URL.");
694
+ config = { ...options };
695
+ },
696
+ /**
697
+ * Connect using a short-lived token minted by your backend. Returns a
698
+ * {@link MebiusClient}. Listen for `"connected"` / `"error"` on it.
699
+ */
700
+ connect(options) {
701
+ if (!config) {
702
+ throw mebiusError("UNKNOWN", "Call Mebius.init() before Mebius.connect().");
703
+ }
704
+ if (!options.token) throw mebiusError("UNKNOWN", "Mebius.connect requires a token.");
705
+ const client = new MebiusClient(config, options.token);
706
+ client.open();
707
+ return client;
708
+ },
709
+ /** @internal Reset configuration (used in tests). */
710
+ _reset() {
711
+ config = null;
712
+ }
713
+ };
714
+ // Annotate the CommonJS export names for ESM import in node:
715
+ 0 && (module.exports = {
716
+ Mebius,
717
+ MebiusBroadcaster,
718
+ MebiusClient,
719
+ MebiusError,
720
+ MebiusPlayer,
721
+ mebiusError
722
+ });
723
+ //# sourceMappingURL=index.cjs.map