Download src/meeting-bot/node-host.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 24.9 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/meeting-bot/node-host.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/meeting-bot/node-host.ts
-
curl -L -o node-host.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/meeting-bot/node-host.ts
24.9 kB
| import { spawn, spawnSync, type ChildProcess } from "node:child_process"; | |
| import { randomUUID } from "node:crypto"; | |
| import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; | |
| import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; | |
| import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; | |
| import { formatErrorMessage } from "../infra/errors.js"; | |
| import type { MeetingAudioBackendSelection, MeetingAudioRuntime } from "./audio-backend.js"; | |
| import { decodeMeetingAudioBase64 } from "./audio-base64.js"; | |
| import { terminateMeetingBridgeProcess } from "./bridge-process.js"; | |
| import { splitCommandArgv } from "./command-argv.js"; | |
| import { | |
| prepareMeetingNodeAudio, | |
| readMeetingNodeCommand, | |
| type MeetingNodeAudioConfig, | |
| } from "./node-audio-config.js"; | |
| import { MeetingNodeAudioPullWaiters } from "./node-audio-pull-waiters.js"; | |
| import type { MeetingRealtimeAudioFormat } from "./realtime-audio-format.js"; | |
| export type { MeetingNodeAudioConfig } from "./node-audio-config.js"; | |
| const NODE_BRIDGE_TERMINATION_GRACE_MS = 2_000; | |
| const NODE_BRIDGE_INPUT_DRAIN_MS = NODE_BRIDGE_TERMINATION_GRACE_MS + 1_000; | |
| const NODE_BRIDGE_TERMINAL_RETENTION_MS = 5_000; | |
| const NODE_BRIDGE_MAX_QUEUED_INPUT_CHUNKS = 200; | |
| const NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES = 1024 * 1024; | |
| type NodeOutputWriteWaiter = { | |
| output: ChildProcess; | |
| release: () => void; | |
| }; | |
| type NodeBridgeSession = { | |
| id: string; | |
| url?: string; | |
| mode?: string; | |
| outputCommand: { command: string; args: string[] }; | |
| input?: ChildProcess; | |
| output?: ChildProcess; | |
| chunks: Buffer[]; | |
| queuedInputBytes: number; | |
| waiters: MeetingNodeAudioPullWaiters; | |
| closed: boolean; | |
| createdAt: string; | |
| lastInputAt?: string; | |
| lastOutputAt?: string; | |
| lastClearAt?: string; | |
| lastInputBytes: number; | |
| lastOutputBytes: number; | |
| clearCount: number; | |
| outputGeneration: number; | |
| outputWriteWaiters: Set<NodeOutputWriteWaiter>; | |
| stopPromise?: Promise<void>; | |
| retiredOutputStops: Set<Promise<void>>; | |
| stopping: boolean; | |
| discardQueuedAudioOnStop: boolean; | |
| terminalEvictionTimer?: ReturnType<typeof setTimeout>; | |
| }; | |
| export type MeetingNodeHostOptions = { | |
| commandName: string; | |
| displayName: string; | |
| browserLabel: string; | |
| bridgeIdPrefix: string; | |
| defaultAudioInputCommand: readonly string[]; | |
| defaultAudioOutputCommand: readonly string[]; | |
| defaultAudio?: { | |
| backend?: MeetingAudioBackendSelection; | |
| bufferBytes: number; | |
| format: MeetingRealtimeAudioFormat; | |
| }; | |
| talkBackModes: ReadonlySet<string>; | |
| agentMode: string; | |
| normalizeUrl(input: unknown): string; | |
| normalizeMeetingKey(url?: string): string | undefined; | |
| assertAudioAvailable(timeoutMs: number): void | Promise<void>; | |
| prepareAudio?(config: MeetingNodeAudioConfig, timeoutMs: number): Promise<MeetingAudioRuntime>; | |
| browser: { | |
| application: string; | |
| buildProfileArgs(profile: string): string[]; | |
| openedStatus: string; | |
| openedNotes: string[]; | |
| }; | |
| }; | |
| function readPositiveNumberOr(value: unknown, fallback: number): number { | |
| return asPositiveFiniteNumber(value) ?? fallback; | |
| } | |
| function readOutputGeneration(value: unknown): number | undefined { | |
| if (value === undefined) { | |
| return undefined; | |
| } | |
| if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { | |
| return value; | |
| } | |
| throw new Error("outputGeneration must be a non-negative integer"); | |
| } | |
| function runCommandWithTimeout(argv: string[], timeoutMs: number) { | |
| const { command, args } = splitCommandArgv(argv, "command"); | |
| const result = spawnSync(command, args, { encoding: "utf8", timeout: timeoutMs }); | |
| const errorMessage = result.error ? formatErrorMessage(result.error) : ""; | |
| const stderr = | |
| errorMessage && result.stderr | |
| ? `${errorMessage}: ${result.stderr}` | |
| : errorMessage || result.stderr || (result.signal ? `terminated by ${result.signal}` : ""); | |
| return { | |
| code: typeof result.status === "number" ? result.status : 1, | |
| stdout: result.stdout ?? "", | |
| stderr, | |
| }; | |
| } | |
| function waitForInputDrain( | |
| stream: ChildProcess["stdout"] | undefined, | |
| timeoutMs: number, | |
| ): Promise<void> { | |
| if (!stream || stream.readableEnded || stream.destroyed) { | |
| return Promise.resolve(); | |
| } | |
| return new Promise<void>((resolve) => { | |
| let settled = false; | |
| const finish = () => { | |
| if (settled) { | |
| return; | |
| } | |
| settled = true; | |
| clearTimeout(timeout); | |
| stream.off("end", finish); | |
| stream.off("close", finish); | |
| resolve(); | |
| }; | |
| const timeout = setTimeout(finish, timeoutMs); | |
| timeout.unref?.(); | |
| stream.once("end", finish); | |
| stream.once("close", finish); | |
| if (stream.readableEnded || stream.destroyed) { | |
| finish(); | |
| } | |
| }); | |
| } | |
| export function createMeetingNodeHost(options: MeetingNodeHostOptions): { | |
| handleCommand(paramsJSON?: string | null): Promise<string>; | |
| } { | |
| const sessions = new Map<string, NodeBridgeSession>(); | |
| const wake = (session: NodeBridgeSession) => { | |
| session.waiters.wake(); | |
| }; | |
| const releaseOutputWriteWaiters = (session: NodeBridgeSession, output?: ChildProcess): void => { | |
| for (const waiter of session.outputWriteWaiters) { | |
| if (!output || waiter.output === output) { | |
| waiter.release(); | |
| } | |
| } | |
| }; | |
| const retireOutputProcess = (session: NodeBridgeSession, outputProcess?: ChildProcess) => { | |
| const stopPromise = terminateMeetingBridgeProcess(outputProcess, { | |
| graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS, | |
| }); | |
| session.retiredOutputStops.add(stopPromise); | |
| void stopPromise.finally(() => { | |
| session.retiredOutputStops.delete(stopPromise); | |
| }); | |
| }; | |
| const deleteSession = (session: NodeBridgeSession): void => { | |
| if (session.terminalEvictionTimer) { | |
| clearTimeout(session.terminalEvictionTimer); | |
| session.terminalEvictionTimer = undefined; | |
| } | |
| session.chunks.length = 0; | |
| session.queuedInputBytes = 0; | |
| if (sessions.get(session.id) === session) { | |
| sessions.delete(session.id); | |
| } | |
| }; | |
| const armTerminalEviction = (session: NodeBridgeSession): void => { | |
| if (session.terminalEvictionTimer) { | |
| clearTimeout(session.terminalEvictionTimer); | |
| } | |
| session.terminalEvictionTimer = setTimeout(() => { | |
| deleteSession(session); | |
| }, NODE_BRIDGE_TERMINAL_RETENTION_MS); | |
| session.terminalEvictionTimer.unref?.(); | |
| }; | |
| const stopSession = ( | |
| session: NodeBridgeSession, | |
| stopOptions: { discardQueuedAudio?: boolean } = {}, | |
| ): Promise<void> => { | |
| session.stopping = true; | |
| if (stopOptions.discardQueuedAudio) { | |
| session.discardQueuedAudioOnStop = true; | |
| if (session.terminalEvictionTimer) { | |
| clearTimeout(session.terminalEvictionTimer); | |
| session.terminalEvictionTimer = undefined; | |
| } | |
| session.chunks.length = 0; | |
| session.queuedInputBytes = 0; | |
| if (!session.closed) { | |
| session.closed = true; | |
| } | |
| wake(session); | |
| } | |
| releaseOutputWriteWaiters(session); | |
| // Process and stream errors can arrive together during teardown. Close once | |
| // so every caller shares one bounded process-termination promise. | |
| if (session.stopPromise) { | |
| return session.stopPromise; | |
| } | |
| const terminalReady = session.discardQueuedAudioOnStop | |
| ? Promise.resolve() | |
| : waitForInputDrain(session.input?.stdout, NODE_BRIDGE_INPUT_DRAIN_MS).then(() => { | |
| if (session.discardQueuedAudioOnStop || session.closed) { | |
| return; | |
| } | |
| session.closed = true; | |
| wake(session); | |
| }); | |
| session.stopPromise = Promise.all([ | |
| terminateMeetingBridgeProcess(session.input, { | |
| graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS, | |
| }), | |
| terminateMeetingBridgeProcess(session.output, { | |
| graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS, | |
| }), | |
| ...session.retiredOutputStops, | |
| terminalReady, | |
| ]).then(() => { | |
| if (session.discardQueuedAudioOnStop) { | |
| deleteSession(session); | |
| return; | |
| } | |
| // A terminal pull normally deletes the session first. This deadline | |
| // releases bounded retention when no consumer returns to drain it. | |
| armTerminalEviction(session); | |
| }); | |
| return session.stopPromise; | |
| }; | |
| const attachOutputProcessHandlers = (session: NodeBridgeSession, outputProcess: ChildProcess) => { | |
| const stopIfCurrent = () => { | |
| if (session.output === outputProcess) { | |
| void stopSession(session); | |
| } | |
| }; | |
| outputProcess.on("exit", stopIfCurrent); | |
| outputProcess.on("error", stopIfCurrent); | |
| outputProcess.stdin?.on("error", stopIfCurrent); | |
| outputProcess.stderr?.on("error", stopIfCurrent); | |
| }; | |
| const startOutputProcess = (command: { command: string; args: string[] }) => | |
| spawn(command.command, command.args, { stdio: ["pipe", "ignore", "pipe"] }); | |
| const startCommandPair = (params: { | |
| inputCommand: string[]; | |
| outputCommand: string[]; | |
| url?: string; | |
| mode?: string; | |
| }): NodeBridgeSession => { | |
| const input = splitCommandArgv(params.inputCommand, "audio command"); | |
| const output = splitCommandArgv(params.outputCommand, "audio command"); | |
| const session: NodeBridgeSession = { | |
| id: `${options.bridgeIdPrefix}${randomUUID()}`, | |
| url: params.url, | |
| mode: params.mode, | |
| outputCommand: output, | |
| chunks: [], | |
| queuedInputBytes: 0, | |
| waiters: new MeetingNodeAudioPullWaiters(), | |
| closed: false, | |
| createdAt: new Date().toISOString(), | |
| lastInputBytes: 0, | |
| lastOutputBytes: 0, | |
| clearCount: 0, | |
| outputGeneration: 0, | |
| outputWriteWaiters: new Set(), | |
| retiredOutputStops: new Set(), | |
| stopping: false, | |
| discardQueuedAudioOnStop: false, | |
| }; | |
| const outputProcess = startOutputProcess(output); | |
| let inputProcess: ChildProcess; | |
| try { | |
| inputProcess = spawn(input.command, input.args, { | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| } catch (error) { | |
| void terminateMeetingBridgeProcess(outputProcess, { | |
| graceMs: NODE_BRIDGE_TERMINATION_GRACE_MS, | |
| }); | |
| throw error; | |
| } | |
| session.input = inputProcess; | |
| session.output = outputProcess; | |
| inputProcess.stdout?.on("data", (chunk) => { | |
| if (session.discardQueuedAudioOnStop || session.closed) { | |
| return; | |
| } | |
| const audio = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); | |
| session.lastInputAt = new Date().toISOString(); | |
| session.lastInputBytes += audio.byteLength; | |
| // Keep the existing normal 200-chunk window while bounding custom capture | |
| // commands whose stdout chunks are much larger than the default 4 KiB. | |
| const retainedAudio = Buffer.from( | |
| audio.subarray(Math.max(0, audio.byteLength - NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES)), | |
| ); | |
| session.chunks.push(retainedAudio); | |
| session.queuedInputBytes += retainedAudio.byteLength; | |
| while ( | |
| session.chunks.length > NODE_BRIDGE_MAX_QUEUED_INPUT_CHUNKS || | |
| session.queuedInputBytes > NODE_BRIDGE_MAX_QUEUED_INPUT_BYTES | |
| ) { | |
| const evicted = session.chunks.shift(); | |
| if (evicted) { | |
| session.queuedInputBytes -= evicted.byteLength; | |
| } | |
| } | |
| if (!session.stopPromise) { | |
| wake(session); | |
| } | |
| }); | |
| const stop = () => { | |
| void stopSession(session); | |
| }; | |
| inputProcess.on("exit", stop); | |
| inputProcess.on("error", stop); | |
| inputProcess.stdout?.on("error", stop); | |
| inputProcess.stderr?.on("error", stop); | |
| attachOutputProcessHandlers(session, outputProcess); | |
| sessions.set(session.id, session); | |
| return session; | |
| }; | |
| const pullAudio = async (params: Record<string, unknown>) => { | |
| const bridgeId = readNonEmptyString(params.bridgeId); | |
| if (!bridgeId) { | |
| throw new Error("bridgeId required"); | |
| } | |
| const session = sessions.get(bridgeId); | |
| if (!session) { | |
| throw new Error(`unknown bridgeId: ${bridgeId}`); | |
| } | |
| const timeoutMs = Math.min(readPositiveNumberOr(params.timeoutMs, 250), 2_000); | |
| if (session.chunks.length === 0 && !session.closed) { | |
| await session.waiters.wait(timeoutMs); | |
| } | |
| const chunk = session.chunks.shift(); | |
| if (chunk) { | |
| session.queuedInputBytes -= chunk.byteLength; | |
| } | |
| const closed = session.closed && session.chunks.length === 0; | |
| if (closed) { | |
| deleteSession(session); | |
| } else if (chunk && session.closed && session.terminalEvictionTimer) { | |
| // Terminal audio can span many node RPCs. Retain only while the consumer | |
| // keeps making progress, then evict after one full idle window. | |
| armTerminalEviction(session); | |
| } | |
| return { | |
| bridgeId, | |
| closed, | |
| base64: chunk ? chunk.toString("base64") : undefined, | |
| }; | |
| }; | |
| const staleOutputResult = (session: NodeBridgeSession) => ({ | |
| bridgeId: session.id, | |
| ok: true, | |
| stale: true, | |
| }); | |
| const writeOutputChunk = ( | |
| session: NodeBridgeSession, | |
| output: ChildProcess, | |
| audio: Buffer, | |
| ): Promise<void> => | |
| new Promise<void>((resolve, reject) => { | |
| const stdin = output.stdin; | |
| if (!stdin) { | |
| reject(new Error("audio output stream is closed")); | |
| return; | |
| } | |
| let settled = false; | |
| const finish = (error?: Error) => { | |
| if (settled) { | |
| return; | |
| } | |
| settled = true; | |
| session.outputWriteWaiters.delete(waiter); | |
| if (error) { | |
| reject(error); | |
| } else { | |
| resolve(); | |
| } | |
| }; | |
| const waiter: NodeOutputWriteWaiter = { output, release: () => finish() }; | |
| session.outputWriteWaiters.add(waiter); | |
| try { | |
| stdin.write(audio, (error) => finish(error ?? undefined)); | |
| } catch (error) { | |
| finish(error instanceof Error ? error : new Error(formatErrorMessage(error))); | |
| return; | |
| } | |
| if (stdin.destroyed || stdin.writableEnded) { | |
| finish(new Error("audio output stream is closed")); | |
| } | |
| }); | |
| const pushAudio = async (params: Record<string, unknown>) => { | |
| const bridgeId = readNonEmptyString(params.bridgeId); | |
| const base64 = readNonEmptyString(params.base64); | |
| if (!bridgeId || !base64) { | |
| throw new Error("bridgeId and base64 required"); | |
| } | |
| const session = sessions.get(bridgeId); | |
| if (!session || session.stopping || session.closed) { | |
| throw new Error(`bridge is not open: ${bridgeId}`); | |
| } | |
| const requestedGeneration = readOutputGeneration(params.outputGeneration); | |
| if (requestedGeneration !== undefined && requestedGeneration !== session.outputGeneration) { | |
| return staleOutputResult(session); | |
| } | |
| const output = session.output; | |
| if (!output?.stdin) { | |
| throw new Error(`bridge is not open: ${bridgeId}`); | |
| } | |
| const audio = decodeMeetingAudioBase64(base64, "pushAudio"); | |
| try { | |
| await writeOutputChunk(session, output, audio); | |
| } catch { | |
| if ( | |
| session.output !== output || | |
| (requestedGeneration !== undefined && requestedGeneration !== session.outputGeneration) | |
| ) { | |
| return staleOutputResult(session); | |
| } | |
| void stopSession(session); | |
| throw new Error(`bridge is not open: ${bridgeId}`); | |
| } | |
| if ( | |
| session.stopping || | |
| session.closed || | |
| session.output !== output || | |
| (requestedGeneration !== undefined && requestedGeneration !== session.outputGeneration) | |
| ) { | |
| return staleOutputResult(session); | |
| } | |
| session.lastOutputAt = new Date().toISOString(); | |
| session.lastOutputBytes += audio.byteLength; | |
| return { bridgeId, ok: true }; | |
| }; | |
| const clearAudio = (params: Record<string, unknown>) => { | |
| const bridgeId = readNonEmptyString(params.bridgeId); | |
| if (!bridgeId) { | |
| throw new Error("bridgeId required"); | |
| } | |
| const session = sessions.get(bridgeId); | |
| if (!session || session.stopping || session.closed) { | |
| throw new Error(`bridge is not open: ${bridgeId}`); | |
| } | |
| const requestedGeneration = readOutputGeneration(params.outputGeneration); | |
| if (requestedGeneration !== undefined && requestedGeneration <= session.outputGeneration) { | |
| return staleOutputResult(session); | |
| } | |
| if (requestedGeneration === undefined && session.outputGeneration >= Number.MAX_SAFE_INTEGER) { | |
| throw new Error("outputGeneration exhausted"); | |
| } | |
| const nextGeneration = requestedGeneration ?? session.outputGeneration + 1; | |
| const previousOutput = session.output; | |
| const outputProcess = startOutputProcess(session.outputCommand); | |
| session.output = outputProcess; | |
| session.outputGeneration = nextGeneration; | |
| attachOutputProcessHandlers(session, outputProcess); | |
| releaseOutputWriteWaiters(session, previousOutput); | |
| session.clearCount += 1; | |
| session.lastClearAt = new Date().toISOString(); | |
| retireOutputProcess(session, previousOutput); | |
| return { bridgeId, ok: true, clearCount: session.clearCount }; | |
| }; | |
| const startBrowser = async (params: Record<string, unknown>) => { | |
| const url = options.normalizeUrl(params.url); | |
| const timeoutMs = readPositiveNumberOr(params.joinTimeoutMs, 30_000); | |
| const mode = readNonEmptyString(params.mode); | |
| let audioRuntime: MeetingAudioRuntime | undefined; | |
| let bridgeId: string | undefined; | |
| let audioBridge: | |
| | { type: "external-command" } | |
| | { type: "node-command-pair"; outputGeneration: true } | |
| | undefined; | |
| if (mode && options.talkBackModes.has(mode)) { | |
| audioRuntime = await prepareMeetingNodeAudio(params, Math.min(timeoutMs, 10_000), options); | |
| const healthCommand = readMeetingNodeCommand(params.audioBridgeHealthCommand); | |
| if (healthCommand) { | |
| const health = runCommandWithTimeout(healthCommand, timeoutMs); | |
| if (health.code !== 0) { | |
| throw new Error( | |
| `Chrome audio bridge health check failed: ${health.stderr || health.stdout || health.code}`, | |
| ); | |
| } | |
| } | |
| const bridgeCommand = readMeetingNodeCommand(params.audioBridgeCommand); | |
| if (bridgeCommand) { | |
| if (mode === options.agentMode) { | |
| throw new Error( | |
| "Chrome agent mode requires audioInputCommand and audioOutputCommand so OpenClaw can run STT and regular TTS directly.", | |
| ); | |
| } | |
| const bridge = runCommandWithTimeout(bridgeCommand, timeoutMs); | |
| if (bridge.code !== 0) { | |
| throw new Error( | |
| `failed to start Chrome audio bridge: ${bridge.stderr || bridge.stdout || bridge.code}`, | |
| ); | |
| } | |
| audioBridge = { type: "external-command" }; | |
| } else { | |
| const session = startCommandPair({ | |
| inputCommand: audioRuntime.inputCommand, | |
| outputCommand: audioRuntime.outputCommand, | |
| url, | |
| mode, | |
| }); | |
| bridgeId = session.id; | |
| audioBridge = { type: "node-command-pair", outputGeneration: true }; | |
| } | |
| } | |
| if (params.launch !== false) { | |
| const argv = ["open", "-a", options.browser.application, url]; | |
| const browserProfile = readNonEmptyString(params.browserProfile); | |
| if (browserProfile) { | |
| argv.push(...options.browser.buildProfileArgs(browserProfile)); | |
| } | |
| try { | |
| const result = runCommandWithTimeout(argv, timeoutMs); | |
| if (result.code !== 0) { | |
| throw new Error( | |
| `failed to launch Chrome for ${options.browserLabel}: ${result.stderr || result.stdout || result.code}`, | |
| ); | |
| } | |
| } catch (error) { | |
| if (bridgeId) { | |
| const session = sessions.get(bridgeId); | |
| if (session) { | |
| void stopSession(session, { discardQueuedAudio: true }); | |
| } | |
| } | |
| throw error; | |
| } | |
| } | |
| return { | |
| launched: params.launch !== false, | |
| audioBackend: audioRuntime?.backend, | |
| audioDeviceLabel: audioRuntime?.deviceLabel, | |
| bridgeId, | |
| audioBridge, | |
| browser: | |
| params.launch !== false | |
| ? { | |
| status: options.browser.openedStatus, | |
| browserUrl: url, | |
| notes: options.browser.openedNotes, | |
| } | |
| : undefined, | |
| }; | |
| }; | |
| const bridgeStatus = (params: Record<string, unknown>) => { | |
| const bridgeId = readNonEmptyString(params.bridgeId); | |
| const session = bridgeId ? sessions.get(bridgeId) : undefined; | |
| return { | |
| bridge: session | |
| ? { | |
| bridgeId, | |
| closed: session.stopping || session.closed, | |
| createdAt: session.createdAt, | |
| lastInputAt: session.lastInputAt, | |
| lastOutputAt: session.lastOutputAt, | |
| lastClearAt: session.lastClearAt, | |
| lastInputBytes: session.lastInputBytes, | |
| lastOutputBytes: session.lastOutputBytes, | |
| clearCount: session.clearCount, | |
| queuedInputChunks: session.chunks.length, | |
| } | |
| : bridgeId | |
| ? { bridgeId, closed: true } | |
| : undefined, | |
| }; | |
| }; | |
| const summarizeSession = (session: NodeBridgeSession) => ({ | |
| bridgeId: session.id, | |
| url: session.url, | |
| mode: session.mode, | |
| closed: session.closed, | |
| createdAt: session.createdAt, | |
| lastInputAt: session.lastInputAt, | |
| lastOutputAt: session.lastOutputAt, | |
| lastInputBytes: session.lastInputBytes, | |
| lastOutputBytes: session.lastOutputBytes, | |
| }); | |
| const listSessions = (params: Record<string, unknown>) => { | |
| const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); | |
| const mode = readNonEmptyString(params.mode); | |
| const bridges = [...sessions.values()] | |
| .filter((session) => !session.stopping && !session.closed) | |
| .filter((session) => !urlKey || options.normalizeMeetingKey(session.url) === urlKey) | |
| .filter((session) => !mode || session.mode === mode) | |
| .map(summarizeSession); | |
| return { bridges }; | |
| }; | |
| const stopSessionsByUrl = async (params: Record<string, unknown>) => { | |
| const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); | |
| if (!urlKey) { | |
| throw new Error("url required"); | |
| } | |
| const mode = readNonEmptyString(params.mode); | |
| const exceptBridgeId = readNonEmptyString(params.exceptBridgeId); | |
| let stopped = 0; | |
| const stopping: Promise<void>[] = []; | |
| for (const [bridgeId, session] of sessions) { | |
| if (exceptBridgeId && bridgeId === exceptBridgeId) { | |
| continue; | |
| } | |
| if (options.normalizeMeetingKey(session.url) !== urlKey) { | |
| continue; | |
| } | |
| if (mode && session.mode !== mode) { | |
| continue; | |
| } | |
| const wasClosed = session.stopping || session.closed; | |
| stopping.push(stopSession(session, { discardQueuedAudio: true })); | |
| if (!wasClosed) { | |
| stopped += 1; | |
| } | |
| } | |
| await Promise.all(stopping); | |
| return { ok: true, stopped }; | |
| }; | |
| const stopBrowser = async (params: Record<string, unknown>) => { | |
| const bridgeId = readNonEmptyString(params.bridgeId); | |
| if (!bridgeId) { | |
| return { ok: true, stopped: false }; | |
| } | |
| const session = sessions.get(bridgeId); | |
| if (!session) { | |
| return { ok: true, stopped: false }; | |
| } | |
| const wasStopped = session.stopping || session.closed; | |
| await stopSession(session, { discardQueuedAudio: true }); | |
| return { ok: true, stopped: !wasStopped }; | |
| }; | |
| return { | |
| async handleCommand(paramsJSON?: string | null): Promise<string> { | |
| let raw: unknown = {}; | |
| if (paramsJSON) { | |
| try { | |
| raw = JSON.parse(paramsJSON) as unknown; | |
| } catch { | |
| throw new Error(`${options.displayName} node host received malformed params JSON.`); | |
| } | |
| } | |
| const params = asOptionalRecord(raw) ?? {}; | |
| const action = readNonEmptyString(params.action); | |
| let result: unknown; | |
| switch (action) { | |
| case "setup": | |
| { | |
| const audioRuntime = await prepareMeetingNodeAudio(params, 10_000, options); | |
| result = { | |
| ok: true, | |
| audioBackend: audioRuntime.backend, | |
| audioDeviceLabel: audioRuntime.deviceLabel, | |
| }; | |
| } | |
| break; | |
| case "start": | |
| result = await startBrowser(params); | |
| break; | |
| case "status": | |
| result = bridgeStatus(params); | |
| break; | |
| case "list": | |
| result = listSessions(params); | |
| break; | |
| case "stopByUrl": | |
| result = await stopSessionsByUrl(params); | |
| break; | |
| case "pullAudio": | |
| result = await pullAudio(params); | |
| break; | |
| case "pushAudio": | |
| result = await pushAudio(params); | |
| break; | |
| case "clearAudio": | |
| result = clearAudio(params); | |
| break; | |
| case "stop": | |
| result = await stopBrowser(params); | |
| break; | |
| default: | |
| throw new Error(`unsupported ${options.commandName} action`); | |
| } | |
| return JSON.stringify(result); | |
| }, | |
| }; | |
| } | |