@saadnvd1/agent-os 0.3.775 → 0.3.777

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.
Files changed (39) hide show
  1. package/app/api/live-shares/[id]/route.ts +72 -0
  2. package/app/api/live-shares/route.test.ts +45 -0
  3. package/app/api/live-shares/route.ts +40 -0
  4. package/app/page.tsx +5 -0
  5. package/components/Chat/ChatPanel.tsx +6 -0
  6. package/components/Chat/EventRow.tsx +6 -0
  7. package/components/Live/GuestPromptCard.tsx +64 -0
  8. package/components/Live/InviteDialog.tsx +225 -0
  9. package/components/Live/LiveChip.tsx +109 -0
  10. package/components/Live/LivePanel.tsx +81 -0
  11. package/components/Live/LobbyCard.tsx +64 -0
  12. package/components/Pane/MobileTabBar.tsx +8 -0
  13. package/components/Pane/PaneMenu.tsx +10 -0
  14. package/components/SessionList/SessionRowMenu.tsx +6 -0
  15. package/components/Shares/SharedLinksPanel.tsx +51 -0
  16. package/data/live/index.ts +2 -0
  17. package/data/live/keys.ts +4 -0
  18. package/data/live/queries.ts +81 -0
  19. package/lib/chat/events.ts +4 -1
  20. package/lib/chat/registry.ts +10 -0
  21. package/lib/connect/control-api.ts +44 -0
  22. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  23. package/lib/live/clean.ts +54 -0
  24. package/lib/live/frames.ts +132 -0
  25. package/lib/live/inbox.ts +225 -0
  26. package/lib/live/publisher.ts +84 -0
  27. package/lib/live/ring.ts +61 -0
  28. package/lib/live/runtime.ts +51 -0
  29. package/lib/live/service.ts +318 -0
  30. package/lib/live/socket.ts +293 -0
  31. package/lib/live/store.ts +149 -0
  32. package/lib/live/types.ts +39 -0
  33. package/lib/share/connect.ts +24 -0
  34. package/lib/share/redact.ts +23 -1
  35. package/lib/share/secret-patterns.ts +77 -0
  36. package/lib/sidebar/row-menu.ts +2 -0
  37. package/package.json +1 -1
  38. package/server.ts +2 -0
  39. package/stores/liveUi.ts +17 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Live shares from the host's side: mint, run, decide on guests, end.
3
+ * Nothing a guest sends reaches an agent except through sendSuggestion,
4
+ * which the host taps.
5
+ */
6
+
7
+ import { loadConnect } from "../connect/config";
8
+ import { addTap, getSession, registry } from "../chat/registry";
9
+ import { sendChatConfirmed } from "../chat/runner";
10
+ import { shareContext } from "../share/context";
11
+ import { redactForShare } from "../share/redact";
12
+ import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
13
+ import type { LiveExpiry } from "../connect/control-api";
14
+ import type { LiveView } from "./types";
15
+ import { cleanBody } from "./clean";
16
+ import { itemMapper } from "./frames";
17
+ import { handleFrame } from "./inbox";
18
+ import { onMessage, seed, flush } from "./publisher";
19
+ import { Ring } from "./ring";
20
+ import { LOBBY_MS, live, type Guest, type Runtime } from "./runtime";
21
+ import { LiveSocket, type WsFactory } from "./socket";
22
+ import {
23
+ activeLiveShareFor,
24
+ activeLiveShares,
25
+ admittedGuestIds,
26
+ insertLiveShare,
27
+ logLive,
28
+ markLiveEnded,
29
+ openSuggestions,
30
+ type LiveShareRow,
31
+ } from "./store";
32
+
33
+ const SWEEP_MS = 60_000;
34
+
35
+ export function makeSocket(factory?: WsFactory): LiveSocket {
36
+ return new LiveSocket({
37
+ factory,
38
+ files: loadConnect,
39
+ onFrame: (shareId, frame) =>
40
+ handleFrame(shareId, frame, {
41
+ send: (id, action, data) => live.socket?.send(id, action, data),
42
+ ended: (id) => stop(id),
43
+ }),
44
+ onConfirmed: (shareId) => {
45
+ const rt = live.runtimes.get(shareId);
46
+ if (!rt) return;
47
+ live.socket?.send(shareId, "state", {
48
+ state: stateOfSession(rt.share.session_id),
49
+ });
50
+ },
51
+ onRejected: (shareId) => stop(shareId, true),
52
+ });
53
+ }
54
+
55
+ const socket = (): LiveSocket => (live.socket ??= makeSocket());
56
+
57
+ function stateOfSession(sessionId: string) {
58
+ const l = registry.live.get(sessionId);
59
+ const s = l?.state;
60
+ return s === "running"
61
+ ? "working"
62
+ : s === "waiting"
63
+ ? "waiting_on_host"
64
+ : "idle";
65
+ }
66
+
67
+ function attach(row: LiveShareRow): Runtime {
68
+ const ctx = shareContext();
69
+ const rt: Runtime = {
70
+ share: row,
71
+ guests: new Map(),
72
+ ring: new Ring(),
73
+ ctx,
74
+ map: itemMapper(ctx),
75
+ streaming: new Map(),
76
+ dirty: new Map(),
77
+ flushTimer: null,
78
+ slowUntil: 0,
79
+ notice: null,
80
+ drops: 0,
81
+ };
82
+ live.runtimes.set(row.id, rt);
83
+ try {
84
+ seed(rt, row.session_id);
85
+ } catch {}
86
+ socket().subscribe(row.id);
87
+ return rt;
88
+ }
89
+
90
+ /** Stops publishing a share and drops its connection; the row stays as history. */
91
+ function stop(shareId: string, markEnded = false): void {
92
+ const rt = live.runtimes.get(shareId);
93
+ if (markEnded) markLiveEnded(shareId);
94
+ if (rt?.flushTimer) clearTimeout(rt.flushTimer);
95
+ live.runtimes.delete(shareId);
96
+ live.socket?.unsubscribe(shareId);
97
+ }
98
+
99
+ /** Starts the tap and re-attaches the shares this machine still holds. */
100
+ export function startLive(): void {
101
+ if (live.started) return;
102
+ live.started = true;
103
+ addTap(onMessage);
104
+ for (const row of activeLiveShares()) attach(row);
105
+ setInterval(sweep, SWEEP_MS).unref();
106
+ }
107
+
108
+ function sweep(now = Date.now()): void {
109
+ const active = new Set(activeLiveShares(now).map((s) => s.id));
110
+ for (const id of [...live.runtimes.keys()]) {
111
+ if (!active.has(id)) stop(id, true);
112
+ else {
113
+ const rt = live.runtimes.get(id)!;
114
+ for (const [gid, g] of rt.guests)
115
+ if (g.status === "pending" && now - g.at > LOBBY_MS)
116
+ rt.guests.delete(gid);
117
+ }
118
+ }
119
+ }
120
+
121
+ function runtimeFor(id: string): Runtime {
122
+ const rt = live.runtimes.get(id);
123
+ if (!rt || rt.share.ended_at) throw new ShareError("Not found.", 404);
124
+ return rt;
125
+ }
126
+
127
+ export async function createLive(
128
+ sessionId: string,
129
+ expiresIn: LiveExpiry
130
+ ): Promise<LiveView> {
131
+ startLive();
132
+ const session = getSession(sessionId);
133
+ if (activeLiveShareFor(sessionId))
134
+ throw new ShareError("This session already has a live link.", 409);
135
+ const ctx = shareContext();
136
+ const title =
137
+ redactForShare(session.name, ctx)
138
+ .text.replace(/\s+/g, " ")
139
+ .trim()
140
+ .slice(0, 120) || "Chat";
141
+ const made = await createLiveRemote({ title, expiresIn });
142
+ const row: LiveShareRow = {
143
+ id: made.id,
144
+ session_id: sessionId,
145
+ title,
146
+ url: made.url,
147
+ ends_with_session: made.ends_with_session ? 1 : 0,
148
+ max_guests: made.max_guests,
149
+ created_at: new Date().toISOString(),
150
+ expires_at: made.expires_at,
151
+ ended_at: null,
152
+ };
153
+ insertLiveShare(row);
154
+ attach(row);
155
+ return viewOf(row.id)!;
156
+ }
157
+
158
+ /** Ends access: Connect disconnects every guest, then this machine lets go. */
159
+ export async function endLive(id: string): Promise<void> {
160
+ runtimeFor(id);
161
+ await revokeRemote(id);
162
+ markLiveEnded(id);
163
+ stop(id);
164
+ }
165
+
166
+ function guestOf(rt: Runtime, guestId: string): Guest {
167
+ const g = rt.guests.get(guestId);
168
+ if (!g) throw new ShareError("That guest isn't waiting any more.", 404);
169
+ return g;
170
+ }
171
+
172
+ function logGuest(rt: Runtime, kind: "admit" | "deny" | "kick", g: Guest) {
173
+ logLive({
174
+ share_id: rt.share.id,
175
+ kind,
176
+ guest_id: g.id,
177
+ guest_name: g.name,
178
+ ref: null,
179
+ text: null,
180
+ sent_text: null,
181
+ });
182
+ }
183
+
184
+ export function admit(id: string, guestId: string): void {
185
+ const rt = runtimeFor(id);
186
+ const g = guestOf(rt, guestId);
187
+ if (g.status !== "pending") throw new ShareError("Already let in.", 409);
188
+ logGuest(rt, "admit", g);
189
+ g.status = "admitted";
190
+ rt.notice = null;
191
+ live.socket?.send(id, "admit", { guest_id: g.id });
192
+ }
193
+
194
+ export function deny(id: string, guestId: string): void {
195
+ const rt = runtimeFor(id);
196
+ const g = guestOf(rt, guestId);
197
+ if (g.status !== "pending") throw new ShareError("Already let in.", 409);
198
+ logGuest(rt, "deny", g);
199
+ rt.guests.delete(g.id);
200
+ live.socket?.send(id, "deny", { guest_id: g.id });
201
+ }
202
+
203
+ export function kick(id: string, guestId: string): void {
204
+ const rt = runtimeFor(id);
205
+ const g = guestOf(rt, guestId);
206
+ logGuest(rt, "kick", g);
207
+ rt.guests.delete(g.id);
208
+ live.socket?.send(id, "kick", { guest_id: g.id });
209
+ }
210
+
211
+ const sending = new Set<string>();
212
+
213
+ /**
214
+ * The only path from a guest's text to an agent: the host reviewed the card
215
+ * (and may have edited it). The text is cleaned again first.
216
+ */
217
+ export async function sendSuggestion(
218
+ id: string,
219
+ suggestionId: string,
220
+ edited?: string
221
+ ): Promise<void> {
222
+ const rt = runtimeFor(id);
223
+ const card = openSuggestions(id).find((s) => s.id === suggestionId);
224
+ if (!card) throw new ShareError("That suggestion is gone.", 404);
225
+ const text = cleanBody(edited ?? card.text);
226
+ if (!text) throw new ShareError("The message is empty.", 400);
227
+ const key = `${id}:${suggestionId}`;
228
+ if (sending.has(key)) throw new ShareError("Already sending.", 409);
229
+ sending.add(key);
230
+ try {
231
+ await sendChatConfirmed(rt.share.session_id, {
232
+ text,
233
+ origin: { kind: "guest", label: card.guestName },
234
+ });
235
+ logLive({
236
+ share_id: id,
237
+ kind: "sent",
238
+ guest_id: card.guestId,
239
+ guest_name: card.guestName,
240
+ ref: card.id,
241
+ text: null,
242
+ sent_text: text,
243
+ });
244
+ live.socket?.send(id, "suggestion_status", {
245
+ guest_id: card.guestId,
246
+ id: card.id,
247
+ status: "sent",
248
+ });
249
+ } finally {
250
+ sending.delete(key);
251
+ }
252
+ }
253
+
254
+ export function dismissSuggestion(id: string, suggestionId: string): void {
255
+ runtimeFor(id);
256
+ const card = openSuggestions(id).find((s) => s.id === suggestionId);
257
+ if (!card) throw new ShareError("That suggestion is gone.", 404);
258
+ logLive({
259
+ share_id: id,
260
+ kind: "dismissed",
261
+ guest_id: card.guestId,
262
+ guest_name: card.guestName,
263
+ ref: card.id,
264
+ text: null,
265
+ sent_text: null,
266
+ });
267
+ live.socket?.send(id, "suggestion_status", {
268
+ guest_id: card.guestId,
269
+ id: card.id,
270
+ status: "dismissed",
271
+ });
272
+ }
273
+
274
+ export function viewOf(id: string, now = Date.now()): LiveView | null {
275
+ const rt = live.runtimes.get(id);
276
+ if (!rt || rt.share.ended_at) return null;
277
+ const names = new Map<string, number>();
278
+ const shown = [...rt.guests.values()].filter(
279
+ (g) => g.status === "admitted" || now - g.at <= LOBBY_MS
280
+ );
281
+ for (const g of shown) {
282
+ const k = g.name.normalize("NFKC").toLowerCase();
283
+ names.set(k, (names.get(k) ?? 0) + 1);
284
+ }
285
+ const admitted = admittedGuestIds(id);
286
+ return {
287
+ id,
288
+ sessionId: rt.share.session_id,
289
+ title: rt.share.title,
290
+ url: rt.share.url,
291
+ expiresAt: rt.share.expires_at,
292
+ endsWithSession: rt.share.ends_with_session === 1,
293
+ maxGuests: rt.share.max_guests,
294
+ connected: live.socket?.connected ?? false,
295
+ notice: rt.notice,
296
+ guests: shown
297
+ .filter((g) => g.status === "pending" || admitted.has(g.id))
298
+ .map((g) => ({
299
+ id: g.id,
300
+ name: g.name,
301
+ status: g.status,
302
+ online: g.online,
303
+ duplicateName:
304
+ (names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
305
+ })),
306
+ suggestions: openSuggestions(id),
307
+ };
308
+ }
309
+
310
+ export function listLive(sessionId?: string): LiveView[] {
311
+ return [...live.runtimes.keys()]
312
+ .map((id) => viewOf(id))
313
+ .filter(
314
+ (v): v is LiveView => !!v && (!sessionId || v.sessionId === sessionId)
315
+ );
316
+ }
317
+
318
+ export { flush };
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The machine's one outbound connection to Connect's ActionCable, signed with
3
+ * the machine key like every other request. Open only while a live share is
4
+ * subscribed; nothing here accepts a connection from anywhere.
5
+ */
6
+
7
+ import WebSocket from "ws";
8
+ import type { ConnectFiles } from "@/lib/connect/config";
9
+ import { signedHeaders } from "@/lib/connect/control-api";
10
+
11
+ export const CABLE_PATH = "/cable";
12
+ const PING_MS = 30_000;
13
+ const STALE_MS = 30_000;
14
+ const MAX_INCOMING = 256 * 1024;
15
+ export const MAX_FRAME = 60 * 1024;
16
+ const QUEUE_MAX = 500;
17
+ // Under Connect's 40/s, 300/min and 2 MB/min.
18
+ const PER_SECOND = 10;
19
+ const PER_MINUTE = 250;
20
+ const BYTES_PER_MINUTE = 1_500_000;
21
+
22
+ export interface WsLike {
23
+ readyState: number;
24
+ send(data: string): void;
25
+ close(): void;
26
+ terminate(): void;
27
+ on(event: "open", fn: () => void): void;
28
+ on(event: "message", fn: (data: Buffer | string) => void): void;
29
+ on(event: "close" | "error" | "unexpected-response", fn: () => void): void;
30
+ }
31
+
32
+ export type WsFactory = (
33
+ url: string,
34
+ headers: Record<string, string>
35
+ ) => WsLike;
36
+
37
+ const defaultFactory: WsFactory = (url, headers) =>
38
+ new WebSocket(url, ["actioncable-v1-json"], {
39
+ headers,
40
+ maxPayload: MAX_INCOMING,
41
+ followRedirects: false,
42
+ handshakeTimeout: 15_000,
43
+ }) as unknown as WsLike;
44
+
45
+ export const cableUrl = (apiUrl: string) =>
46
+ `${apiUrl.replace(/^http/, "ws")}${CABLE_PATH}`;
47
+
48
+ export interface SocketOptions {
49
+ files: () => ConnectFiles | null;
50
+ onFrame: (shareId: string, frame: Record<string, unknown>) => void;
51
+ /** A share's subscription was confirmed (again, after a reconnect). */
52
+ onConfirmed?: (shareId: string) => void;
53
+ /** Connect refused the subscription: the share is not ours or is gone. */
54
+ onRejected?: (shareId: string) => void;
55
+ factory?: WsFactory;
56
+ random?: () => number;
57
+ }
58
+
59
+ interface Queued {
60
+ shareId: string;
61
+ json: string;
62
+ }
63
+
64
+ const identifier = (shareId: string) =>
65
+ JSON.stringify({ channel: "LiveHostChannel", share_id: shareId });
66
+
67
+ export class LiveSocket {
68
+ private ws: WsLike | null = null;
69
+ private welcomed = false;
70
+ private shares = new Set<string>();
71
+ private confirmed = new Set<string>();
72
+ private queue: Queued[] = [];
73
+ private attempts = 0;
74
+ private timers: ReturnType<typeof setTimeout>[] = [];
75
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
76
+ private drainTimer: ReturnType<typeof setTimeout> | null = null;
77
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
78
+ private lastHeard = 0;
79
+ private sentAt: { at: number; bytes: number }[] = [];
80
+ dropped = 0;
81
+
82
+ constructor(private opts: SocketOptions) {}
83
+
84
+ get connected() {
85
+ return this.welcomed && this.ws?.readyState === WebSocket.OPEN;
86
+ }
87
+
88
+ has(shareId: string) {
89
+ return this.shares.has(shareId);
90
+ }
91
+
92
+ subscribe(shareId: string): void {
93
+ this.shares.add(shareId);
94
+ if (this.connected) this.rawSubscribe(shareId);
95
+ else this.connect();
96
+ }
97
+
98
+ unsubscribe(shareId: string): void {
99
+ this.shares.delete(shareId);
100
+ this.confirmed.delete(shareId);
101
+ this.queue = this.queue.filter((q) => q.shareId !== shareId);
102
+ if (this.connected)
103
+ this.rawSend({ command: "unsubscribe", identifier: identifier(shareId) });
104
+ if (!this.shares.size) this.close();
105
+ }
106
+
107
+ /** An action on a share's channel, paced to Connect's limits. */
108
+ send(shareId: string, action: string, data: Record<string, unknown> = {}) {
109
+ if (!this.shares.has(shareId)) return;
110
+ const json = JSON.stringify({
111
+ command: "message",
112
+ identifier: identifier(shareId),
113
+ data: JSON.stringify({ action, ...data }),
114
+ });
115
+ if (Buffer.byteLength(json) > MAX_FRAME) {
116
+ this.dropped++;
117
+ return;
118
+ }
119
+ if (this.queue.length >= QUEUE_MAX) {
120
+ this.queue.shift();
121
+ this.dropped++;
122
+ }
123
+ this.queue.push({ shareId, json });
124
+ this.drain();
125
+ }
126
+
127
+ close(): void {
128
+ this.shares.clear();
129
+ this.confirmed.clear();
130
+ this.queue = [];
131
+ this.teardown();
132
+ }
133
+
134
+ private teardown() {
135
+ this.timers.forEach(clearTimeout);
136
+ this.timers = [];
137
+ if (this.pingTimer) clearInterval(this.pingTimer);
138
+ if (this.drainTimer) clearTimeout(this.drainTimer);
139
+ if (this.retryTimer) clearTimeout(this.retryTimer);
140
+ this.pingTimer = this.drainTimer = this.retryTimer = null;
141
+ const ws = this.ws;
142
+ this.ws = null;
143
+ this.welcomed = false;
144
+ this.confirmed.clear();
145
+ try {
146
+ ws?.terminate();
147
+ } catch {}
148
+ }
149
+
150
+ private connect() {
151
+ if (this.ws || this.retryTimer || !this.shares.size) return;
152
+ const files = this.opts.files();
153
+ if (!files) return;
154
+ let ws: WsLike;
155
+ try {
156
+ ws = (this.opts.factory ?? defaultFactory)(
157
+ cableUrl(files.config.apiUrl),
158
+ signedHeaders({
159
+ machineId: files.config.machineId,
160
+ privateKey: files.machineKey,
161
+ method: "GET",
162
+ path: CABLE_PATH,
163
+ body: "",
164
+ })
165
+ );
166
+ } catch {
167
+ this.retry();
168
+ return;
169
+ }
170
+ this.ws = ws;
171
+ this.lastHeard = Date.now();
172
+ ws.on("message", (raw) => this.onMessage(ws, raw));
173
+ ws.on("close", () => this.lost(ws));
174
+ ws.on("error", () => this.lost(ws));
175
+ ws.on("unexpected-response", () => this.lost(ws));
176
+ }
177
+
178
+ private lost(ws: WsLike) {
179
+ if (this.ws !== ws) return;
180
+ this.teardown();
181
+ this.retry();
182
+ }
183
+
184
+ private retry() {
185
+ if (!this.shares.size || this.retryTimer) return;
186
+ const rand = this.opts.random ?? Math.random;
187
+ const base = Math.min(30_000, 1000 * 2 ** Math.min(this.attempts, 5));
188
+ this.attempts++;
189
+ this.retryTimer = setTimeout(
190
+ () => {
191
+ this.retryTimer = null;
192
+ this.connect();
193
+ },
194
+ base * (0.5 + rand() / 2)
195
+ );
196
+ }
197
+
198
+ private rawSend(msg: unknown) {
199
+ try {
200
+ this.ws?.send(JSON.stringify(msg));
201
+ } catch {}
202
+ }
203
+
204
+ private rawSubscribe(shareId: string) {
205
+ this.rawSend({ command: "subscribe", identifier: identifier(shareId) });
206
+ }
207
+
208
+ private onMessage(ws: WsLike, raw: Buffer | string) {
209
+ if (this.ws !== ws) return;
210
+ this.lastHeard = Date.now();
211
+ let m: Record<string, unknown>;
212
+ try {
213
+ m = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
214
+ } catch {
215
+ return;
216
+ }
217
+ if (!m || typeof m !== "object") return;
218
+ switch (m.type) {
219
+ case "welcome":
220
+ this.welcomed = true;
221
+ this.attempts = 0;
222
+ for (const id of this.shares) this.rawSubscribe(id);
223
+ this.pingTimer = setInterval(() => {
224
+ if (Date.now() - this.lastHeard > STALE_MS) return this.lost(ws);
225
+ for (const id of this.confirmed) this.send(id, "ping");
226
+ }, PING_MS);
227
+ return;
228
+ case "ping":
229
+ return;
230
+ case "disconnect":
231
+ return this.lost(ws);
232
+ case "confirm_subscription":
233
+ case "reject_subscription": {
234
+ const id = shareOf(m.identifier);
235
+ if (!id || !this.shares.has(id)) return;
236
+ if (m.type === "confirm_subscription") {
237
+ this.confirmed.add(id);
238
+ this.opts.onConfirmed?.(id);
239
+ this.drain();
240
+ } else {
241
+ this.unsubscribe(id);
242
+ this.opts.onRejected?.(id);
243
+ }
244
+ return;
245
+ }
246
+ }
247
+ const id = shareOf(m.identifier);
248
+ const frame = m.message;
249
+ if (id && this.shares.has(id) && frame && typeof frame === "object")
250
+ this.opts.onFrame(id, frame as Record<string, unknown>);
251
+ }
252
+
253
+ private drain() {
254
+ if (this.drainTimer || !this.connected) return;
255
+ const now = Date.now();
256
+ this.sentAt = this.sentAt.filter((s) => now - s.at < 60_000);
257
+ while (this.queue.length) {
258
+ const next = this.queue.find((q) => this.confirmed.has(q.shareId));
259
+ if (!next) return;
260
+ const bytes = Buffer.byteLength(next.json);
261
+ const lastSecond = this.sentAt.filter((s) => now - s.at < 1000).length;
262
+ const minuteBytes = this.sentAt.reduce((a, s) => a + s.bytes, 0);
263
+ if (
264
+ lastSecond >= PER_SECOND ||
265
+ this.sentAt.length >= PER_MINUTE ||
266
+ minuteBytes + bytes > BYTES_PER_MINUTE
267
+ ) {
268
+ this.drainTimer = setTimeout(() => {
269
+ this.drainTimer = null;
270
+ this.drain();
271
+ }, 250);
272
+ return;
273
+ }
274
+ this.queue.splice(this.queue.indexOf(next), 1);
275
+ this.sentAt.push({ at: now, bytes });
276
+ try {
277
+ this.ws?.send(next.json);
278
+ } catch {}
279
+ }
280
+ }
281
+ }
282
+
283
+ function shareOf(identifier: unknown): string | null {
284
+ if (typeof identifier !== "string") return null;
285
+ try {
286
+ const i = JSON.parse(identifier) as Record<string, unknown>;
287
+ return i.channel === "LiveHostChannel" && typeof i.share_id === "string"
288
+ ? i.share_id
289
+ : null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }