codex@macbookpro
·
2026-04-01
firefox-bridge.ts
1import { randomUUID } from "node:crypto";
2
3import type {
4 BrowserBridgeActionDispatch,
5 BrowserBridgeActionResultSnapshot
6} from "./browser-types.js";
7import {
8 DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS,
9 DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS,
10 DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS,
11 DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS,
12 DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES,
13 DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS,
14 DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS,
15 resolveDeliveryActionResultTimeoutMs
16} from "./execution-timeouts.js";
17
18type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
19
20export type FirefoxBridgeResponseMode = "buffered" | "sse";
21export type FirefoxBridgeOutboundCommandType =
22 | "api_request"
23 | "browser.inject_message"
24 | "browser.proxy_delivery"
25 | "browser.send_message"
26 | "controller_reload"
27 | "open_tab"
28 | "plugin_status"
29 | "reload"
30 | "request_cancel"
31 | "request_credentials"
32 | "tab_focus"
33 | "tab_restore"
34 | "ws_reconnect";
35
36export type FirefoxBridgeErrorCode =
37 | "action_timeout"
38 | "client_disconnected"
39 | "client_not_found"
40 | "client_replaced"
41 | "duplicate_request_id"
42 | "no_active_client"
43 | "request_not_found"
44 | "request_timeout"
45 | "send_failed"
46 | "service_stopped";
47
48export interface FirefoxBridgeRegisteredClient {
49 clientId: string;
50 connectedAt: number;
51 connectionId: string;
52 lastMessageAt: number;
53 sendJson(payload: Record<string, unknown>): boolean;
54}
55
56export interface FirefoxBridgeCommandTarget {
57 clientId?: string | null;
58}
59
60export interface FirefoxBridgeDispatchReceipt {
61 clientId: string;
62 connectionId: string;
63 dispatchedAt: number;
64 type: FirefoxBridgeOutboundCommandType;
65}
66
67export interface FirefoxOpenTabCommandInput extends FirefoxBridgeCommandTarget {
68 platform?: string | null;
69}
70
71export interface FirefoxRequestCredentialsCommandInput extends FirefoxBridgeCommandTarget {
72 platform?: string | null;
73 reason?: string | null;
74}
75
76export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
77 platform?: string | null;
78 reason?: string | null;
79}
80
81export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTarget {
82 action: "controller_reload" | "plugin_status" | "tab_focus" | "tab_restore" | "ws_reconnect";
83 disconnectMs?: number | null;
84 platform?: string | null;
85 reason?: string | null;
86 repeatCount?: number | null;
87 repeatIntervalMs?: number | null;
88}
89
90export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
91 body?: unknown;
92 conversationId?: string | null;
93 headers?: Record<string, string> | null;
94 id?: string | null;
95 idleTimeoutMs?: number | null;
96 maxBufferedBytes?: number | null;
97 maxBufferedEvents?: number | null;
98 method?: string | null;
99 openTimeoutMs?: number | null;
100 path: string;
101 platform: string;
102 responseMode?: FirefoxBridgeResponseMode | null;
103 streamId?: string | null;
104 timeoutMs?: number | null;
105}
106
107export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
108 conversationId?: string | null;
109 messageText: string;
110 pageTitle?: string | null;
111 pageUrl?: string | null;
112 planId: string;
113 pollIntervalMs?: number | null;
114 platform: string;
115 retryAttempts?: number | null;
116 retryDelayMs?: number | null;
117 shellPage?: boolean | null;
118 tabId?: number | null;
119 timeoutMs?: number | null;
120}
121
122export interface FirefoxProxyDeliveryCommandInput extends FirefoxBridgeCommandTarget {
123 assistantMessageId: string;
124 conversationId?: string | null;
125 messageText: string;
126 organizationId?: string | null;
127 pageTitle?: string | null;
128 pageUrl?: string | null;
129 planId: string;
130 platform: string;
131 shellPage?: boolean | null;
132 tabId?: number | null;
133 timeoutMs?: number | null;
134}
135
136export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
137 conversationId?: string | null;
138 pageTitle?: string | null;
139 pageUrl?: string | null;
140 planId: string;
141 pollIntervalMs?: number | null;
142 platform: string;
143 retryAttempts?: number | null;
144 retryDelayMs?: number | null;
145 shellPage?: boolean | null;
146 tabId?: number | null;
147 timeoutMs?: number | null;
148}
149
150export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
151 platform?: string | null;
152 reason?: string | null;
153 requestId: string;
154 streamId?: string | null;
155}
156
157export interface FirefoxBridgeCancelReceipt extends FirefoxBridgeDispatchReceipt {
158 reason: string | null;
159 requestId: string;
160 streamId: string | null;
161}
162
163export interface FirefoxApiResponsePayload {
164 body: unknown;
165 error: string | null;
166 id: string;
167 ok: boolean;
168 status: number | null;
169}
170
171export interface FirefoxBridgeApiResponse extends FirefoxApiResponsePayload {
172 clientId: string;
173 connectionId: string;
174 respondedAt: number;
175}
176
177export interface FirefoxBridgeConnectionClosedEvent {
178 clientId: string | null;
179 code?: number | null;
180 connectionId: string;
181 reason?: string | null;
182}
183
184export interface FirefoxBridgeStreamPartialState {
185 buffered_bytes: number;
186 event_count: number;
187 last_seq: number;
188 opened: boolean;
189}
190
191export interface FirefoxBridgeStreamOpenEvent {
192 clientId: string;
193 connectionId: string;
194 meta: unknown;
195 openedAt: number;
196 requestId: string;
197 status: number | null;
198 streamId: string;
199 type: "stream_open";
200}
201
202export interface FirefoxBridgeStreamDataEvent {
203 clientId: string;
204 connectionId: string;
205 data: unknown;
206 event: string | null;
207 raw: string | null;
208 receivedAt: number;
209 requestId: string;
210 seq: number;
211 streamId: string;
212 type: "stream_event";
213}
214
215export interface FirefoxBridgeStreamEndEvent {
216 clientId: string;
217 connectionId: string;
218 endedAt: number;
219 partial: FirefoxBridgeStreamPartialState;
220 requestId: string;
221 status: number | null;
222 streamId: string;
223 type: "stream_end";
224}
225
226export interface FirefoxBridgeStreamErrorEvent {
227 clientId: string;
228 code: string;
229 connectionId: string;
230 erroredAt: number;
231 message: string;
232 partial: FirefoxBridgeStreamPartialState;
233 requestId: string;
234 status: number | null;
235 streamId: string;
236 type: "stream_error";
237}
238
239export type FirefoxBridgeStreamEvent =
240 | FirefoxBridgeStreamDataEvent
241 | FirefoxBridgeStreamEndEvent
242 | FirefoxBridgeStreamErrorEvent
243 | FirefoxBridgeStreamOpenEvent;
244
245export interface FirefoxBridgeApiStream extends AsyncIterable<FirefoxBridgeStreamEvent> {
246 readonly clientId: string;
247 readonly connectionId: string;
248 readonly requestId: string;
249 readonly streamId: string;
250 cancel(reason?: string | null): void;
251}
252
253export interface FirefoxStreamOpenPayload {
254 id: string;
255 meta?: unknown;
256 status: number | null;
257 streamId?: string | null;
258}
259
260export interface FirefoxStreamEventPayload {
261 data?: unknown;
262 event?: string | null;
263 id: string;
264 raw?: string | null;
265 seq: number;
266 streamId?: string | null;
267}
268
269export interface FirefoxStreamEndPayload {
270 id: string;
271 status: number | null;
272 streamId?: string | null;
273}
274
275export interface FirefoxStreamErrorPayload {
276 code?: string | null;
277 id: string;
278 message?: string | null;
279 status: number | null;
280 streamId?: string | null;
281}
282
283interface FirefoxPendingApiRequest {
284 clientId: string;
285 connectionId: string;
286 reject: (error: FirefoxBridgeError) => void;
287 requestId: string;
288 resolve: (response: FirefoxBridgeApiResponse) => void;
289 timer: TimeoutHandle;
290}
291
292interface FirefoxPendingActionRequest {
293 clientId: string;
294 connectionId: string;
295 reject: (error: FirefoxBridgeError) => void;
296 requestId: string;
297 resolve: (response: BrowserBridgeActionResultSnapshot) => void;
298 timer: TimeoutHandle;
299}
300
301interface FirefoxCommandBrokerOptions {
302 clearTimeoutImpl?: (handle: TimeoutHandle) => void;
303 now?: () => number;
304 resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
305 resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
306 setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
307}
308
309interface FirefoxStreamSessionOptions {
310 clearTimeoutImpl: (handle: TimeoutHandle) => void;
311 clientId: string;
312 connectionId: string;
313 idleTimeoutMs: number;
314 maxBufferedBytes: number;
315 maxBufferedEvents: number;
316 now: () => number;
317 onCancel: (reason?: string | null) => void;
318 onClose: (requestId: string) => void;
319 openTimeoutMs: number;
320 requestId: string;
321 setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
322 streamId: string;
323}
324
325interface FirefoxQueuedStreamEvent {
326 event: FirefoxBridgeStreamEvent;
327 size: number;
328}
329
330function normalizeOptionalString(value: unknown): string | null {
331 if (typeof value !== "string") {
332 return null;
333 }
334
335 const normalized = value.trim();
336 return normalized === "" ? null : normalized;
337}
338
339function normalizeHeaderRecord(
340 headers: Record<string, string> | null | undefined
341): Record<string, string> | undefined {
342 if (headers == null) {
343 return undefined;
344 }
345
346 const normalized: Record<string, string> = {};
347
348 for (const [name, value] of Object.entries(headers)) {
349 const normalizedName = normalizeOptionalString(name);
350
351 if (normalizedName == null || typeof value !== "string") {
352 continue;
353 }
354
355 normalized[normalizedName] = value;
356 }
357
358 return Object.keys(normalized).length > 0 ? normalized : undefined;
359}
360
361function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
362 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
363 return fallback;
364 }
365
366 return Math.round(value);
367}
368
369function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
370 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
371 return fallback;
372 }
373
374 return Math.max(1, Math.round(value));
375}
376
377function normalizeOptionalNonNegativeInteger(value: number | null | undefined): number | undefined {
378 if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
379 return undefined;
380 }
381
382 return Math.round(value);
383}
384
385function normalizeOptionalPositiveInteger(value: number | null | undefined): number | undefined {
386 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
387 return undefined;
388 }
389
390 return Math.max(1, Math.round(value));
391}
392
393function normalizeStatus(value: number | null | undefined): number | null {
394 if (typeof value !== "number" || !Number.isFinite(value)) {
395 return null;
396 }
397
398 return Math.round(value);
399}
400
401function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
402 const output: Record<string, unknown> = {};
403
404 for (const [key, value] of Object.entries(input)) {
405 if (value !== undefined) {
406 output[key] = value;
407 }
408 }
409
410 return output;
411}
412
413function estimateEventSize(event: FirefoxBridgeStreamEvent): number {
414 try {
415 return Buffer.from(JSON.stringify(event), "utf8").length;
416 } catch {
417 return 0;
418 }
419}
420
421function buildPartialState(
422 opened: boolean,
423 eventCount: number,
424 lastSeq: number,
425 bufferedBytes: number
426): FirefoxBridgeStreamPartialState {
427 return {
428 buffered_bytes: bufferedBytes,
429 event_count: eventCount,
430 last_seq: lastSeq,
431 opened
432 };
433}
434
435export class FirefoxBridgeError extends Error {
436 readonly clientId: string | null;
437 readonly code: FirefoxBridgeErrorCode;
438 readonly connectionId: string | null;
439 readonly requestId: string | null;
440 readonly timeoutMs: number | null;
441
442 constructor(
443 code: FirefoxBridgeErrorCode,
444 message: string,
445 options: {
446 clientId?: string | null;
447 connectionId?: string | null;
448 requestId?: string | null;
449 timeoutMs?: number | null;
450 } = {}
451 ) {
452 super(message);
453 this.name = "FirefoxBridgeError";
454 this.clientId = options.clientId ?? null;
455 this.code = code;
456 this.connectionId = options.connectionId ?? null;
457 this.requestId = options.requestId ?? null;
458 this.timeoutMs = normalizeOptionalPositiveInteger(options.timeoutMs ?? undefined) ?? null;
459 }
460}
461
462class FirefoxBridgeApiStreamSession implements FirefoxBridgeApiStream {
463 readonly clientId: string;
464 readonly connectionId: string;
465 readonly requestId: string;
466
467 private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
468 private readonly idleTimeoutMs: number;
469 private readonly maxBufferedBytes: number;
470 private readonly maxBufferedEvents: number;
471 private readonly now: () => number;
472 private readonly onCancel: (reason?: string | null) => void;
473 private readonly onClose: (requestId: string) => void;
474 private readonly openTimeoutMs: number;
475 private readonly queue: FirefoxQueuedStreamEvent[] = [];
476 private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
477 private readonly waiters: Array<(result: IteratorResult<FirefoxBridgeStreamEvent>) => void> = [];
478 private bufferedBytes = 0;
479 private cancelSent = false;
480 private closed = false;
481 private eventCount = 0;
482 private idleTimer: TimeoutHandle | null = null;
483 private lastSeq = 0;
484 private openTimer: TimeoutHandle | null = null;
485 private opened = false;
486 private streamIdValue: string;
487
488 constructor(options: FirefoxStreamSessionOptions) {
489 this.clearTimeoutImpl = options.clearTimeoutImpl;
490 this.clientId = options.clientId;
491 this.connectionId = options.connectionId;
492 this.idleTimeoutMs = options.idleTimeoutMs;
493 this.maxBufferedBytes = options.maxBufferedBytes;
494 this.maxBufferedEvents = options.maxBufferedEvents;
495 this.now = options.now;
496 this.onCancel = options.onCancel;
497 this.onClose = options.onClose;
498 this.openTimeoutMs = options.openTimeoutMs;
499 this.requestId = options.requestId;
500 this.setTimeoutImpl = options.setTimeoutImpl;
501 this.streamIdValue = options.streamId;
502 this.resetOpenTimer();
503 }
504
505 get streamId(): string {
506 return this.streamIdValue;
507 }
508
509 [Symbol.asyncIterator](): AsyncIterableIterator<FirefoxBridgeStreamEvent> {
510 return {
511 [Symbol.asyncIterator]: () => this[Symbol.asyncIterator](),
512 next: async () => await this.next()
513 };
514 }
515
516 cancel(reason?: string | null): void {
517 this.dispatchCancel(reason);
518 this.fail("request_cancelled", normalizeOptionalString(reason) ?? "Stream request was cancelled.");
519 }
520
521 next(): Promise<IteratorResult<FirefoxBridgeStreamEvent>> {
522 const queued = this.queue.shift();
523
524 if (queued != null) {
525 this.bufferedBytes = Math.max(0, this.bufferedBytes - queued.size);
526 return Promise.resolve({
527 done: false,
528 value: queued.event
529 });
530 }
531
532 if (this.closed) {
533 return Promise.resolve({
534 done: true,
535 value: undefined
536 });
537 }
538
539 return new Promise((resolve) => {
540 this.waiters.push(resolve);
541 });
542 }
543
544 markOpen(payload: FirefoxStreamOpenPayload): boolean {
545 if (this.closed) {
546 return false;
547 }
548
549 if (normalizeOptionalString(payload.streamId) != null) {
550 this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
551 }
552
553 this.opened = true;
554 this.resetIdleTimer();
555 return this.enqueue({
556 clientId: this.clientId,
557 connectionId: this.connectionId,
558 meta: payload.meta ?? null,
559 openedAt: this.now(),
560 requestId: this.requestId,
561 status: normalizeStatus(payload.status),
562 streamId: this.streamIdValue,
563 type: "stream_open"
564 });
565 }
566
567 markEvent(payload: FirefoxStreamEventPayload): boolean {
568 if (this.closed) {
569 return false;
570 }
571
572 if (!this.opened) {
573 this.markOpen({
574 id: this.requestId,
575 status: null,
576 streamId: payload.streamId ?? this.streamIdValue
577 });
578 }
579
580 if (normalizeOptionalString(payload.streamId) != null) {
581 this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
582 }
583
584 const seq = normalizePositiveInteger(payload.seq, this.lastSeq + 1);
585 this.eventCount += 1;
586 this.lastSeq = seq;
587 this.resetIdleTimer();
588 return this.enqueue({
589 clientId: this.clientId,
590 connectionId: this.connectionId,
591 data: payload.data ?? null,
592 event: normalizeOptionalString(payload.event),
593 raw: normalizeOptionalString(payload.raw),
594 receivedAt: this.now(),
595 requestId: this.requestId,
596 seq,
597 streamId: this.streamIdValue,
598 type: "stream_event"
599 });
600 }
601
602 markEnd(payload: FirefoxStreamEndPayload): boolean {
603 if (this.closed) {
604 return false;
605 }
606
607 return this.finishWithEvent({
608 clientId: this.clientId,
609 connectionId: this.connectionId,
610 endedAt: this.now(),
611 partial: buildPartialState(
612 this.opened,
613 this.eventCount,
614 this.lastSeq,
615 this.bufferedBytes
616 ),
617 requestId: this.requestId,
618 status: normalizeStatus(payload.status),
619 streamId:
620 normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
621 type: "stream_end"
622 });
623 }
624
625 markError(payload: FirefoxStreamErrorPayload): boolean {
626 if (this.closed) {
627 return false;
628 }
629
630 return this.finishWithEvent({
631 clientId: this.clientId,
632 code: normalizeOptionalString(payload.code) ?? "stream_error",
633 connectionId: this.connectionId,
634 erroredAt: this.now(),
635 message: normalizeOptionalString(payload.message) ?? "Browser stream failed.",
636 partial: buildPartialState(
637 this.opened,
638 this.eventCount,
639 this.lastSeq,
640 this.bufferedBytes
641 ),
642 requestId: this.requestId,
643 status: normalizeStatus(payload.status),
644 streamId:
645 normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
646 type: "stream_error"
647 });
648 }
649
650 fail(code: string, message: string, status: number | null = null): boolean {
651 return this.markError({
652 code,
653 id: this.requestId,
654 message,
655 status,
656 streamId: this.streamIdValue
657 });
658 }
659
660 private enqueue(event: FirefoxBridgeStreamEvent): boolean {
661 if (this.waiters.length > 0) {
662 const waiter = this.waiters.shift();
663
664 waiter?.({
665 done: false,
666 value: event
667 });
668 return true;
669 }
670
671 const size = estimateEventSize(event);
672
673 if (
674 event.type === "stream_event"
675 && (
676 this.queue.length + 1 > this.maxBufferedEvents
677 || this.bufferedBytes + size > this.maxBufferedBytes
678 )
679 ) {
680 this.dispatchCancel("stream_buffer_overflow");
681 return this.finishWithEvent({
682 clientId: this.clientId,
683 code: "stream_buffer_overflow",
684 connectionId: this.connectionId,
685 erroredAt: this.now(),
686 message: "Browser stream exceeded the conductor buffering limit.",
687 partial: buildPartialState(
688 this.opened,
689 this.eventCount,
690 this.lastSeq,
691 this.bufferedBytes
692 ),
693 requestId: this.requestId,
694 status: null,
695 streamId: this.streamIdValue,
696 type: "stream_error"
697 });
698 }
699
700 this.queue.push({
701 event,
702 size
703 });
704 this.bufferedBytes += size;
705 return true;
706 }
707
708 private finishWithEvent(event: FirefoxBridgeStreamEndEvent | FirefoxBridgeStreamErrorEvent): boolean {
709 if (this.closed) {
710 return false;
711 }
712
713 if (this.waiters.length > 0) {
714 const waiter = this.waiters.shift();
715 waiter?.({
716 done: false,
717 value: event
718 });
719 } else {
720 const size = estimateEventSize(event);
721 this.queue.push({
722 event,
723 size
724 });
725 this.bufferedBytes += size;
726 }
727
728 this.close();
729 return true;
730 }
731
732 private close(): void {
733 if (this.closed) {
734 return;
735 }
736
737 this.closed = true;
738 this.clearTimers();
739 this.onClose(this.requestId);
740
741 if (this.queue.length === 0) {
742 while (this.waiters.length > 0) {
743 const waiter = this.waiters.shift();
744 waiter?.({
745 done: true,
746 value: undefined
747 });
748 }
749 }
750 }
751
752 private dispatchCancel(reason?: string | null): void {
753 if (this.cancelSent) {
754 return;
755 }
756
757 this.cancelSent = true;
758 this.onCancel(reason);
759 }
760
761 private clearTimers(): void {
762 if (this.openTimer != null) {
763 this.clearTimeoutImpl(this.openTimer);
764 this.openTimer = null;
765 }
766
767 if (this.idleTimer != null) {
768 this.clearTimeoutImpl(this.idleTimer);
769 this.idleTimer = null;
770 }
771 }
772
773 private resetOpenTimer(): void {
774 if (this.openTimeoutMs <= 0 || this.closed) {
775 return;
776 }
777
778 if (this.openTimer != null) {
779 this.clearTimeoutImpl(this.openTimer);
780 }
781
782 this.openTimer = this.setTimeoutImpl(() => {
783 this.dispatchCancel("stream_open_timeout");
784 this.fail(
785 "stream_open_timeout",
786 `Browser stream "${this.requestId}" did not open within ${this.openTimeoutMs}ms.`
787 );
788 }, this.openTimeoutMs);
789 }
790
791 private resetIdleTimer(): void {
792 if (this.openTimer != null) {
793 this.clearTimeoutImpl(this.openTimer);
794 this.openTimer = null;
795 }
796
797 if (this.idleTimeoutMs <= 0 || this.closed) {
798 return;
799 }
800
801 if (this.idleTimer != null) {
802 this.clearTimeoutImpl(this.idleTimer);
803 }
804
805 this.idleTimer = this.setTimeoutImpl(() => {
806 this.dispatchCancel("stream_idle_timeout");
807 this.fail(
808 "stream_idle_timeout",
809 `Browser stream "${this.requestId}" was idle for more than ${this.idleTimeoutMs}ms.`
810 );
811 }, this.idleTimeoutMs);
812 }
813}
814
815export class FirefoxCommandBroker {
816 private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
817 private readonly now: () => number;
818 private readonly pendingActionRequests = new Map<string, FirefoxPendingActionRequest>();
819 private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
820 private readonly pendingStreamRequests = new Map<string, FirefoxBridgeApiStreamSession>();
821 private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
822 private readonly resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
823 private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
824
825 constructor(options: FirefoxCommandBrokerOptions) {
826 this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
827 this.now = options.now ?? (() => Date.now());
828 this.resolveActiveClient = options.resolveActiveClient;
829 this.resolveClientById = options.resolveClientById;
830 this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
831 }
832
833 dispatch(
834 type: Exclude<FirefoxBridgeOutboundCommandType, "api_request">,
835 payload: Record<string, unknown>,
836 target: FirefoxBridgeCommandTarget = {}
837 ): FirefoxBridgeDispatchReceipt {
838 const client = this.selectClient(target);
839 const dispatchedAt = this.now();
840 const envelope = compactRecord({
841 ...payload,
842 type
843 });
844
845 if (!client.sendJson(envelope)) {
846 throw new FirefoxBridgeError(
847 "send_failed",
848 `Failed to send ${type} to Firefox client "${client.clientId}".`,
849 {
850 clientId: client.clientId,
851 connectionId: client.connectionId
852 }
853 );
854 }
855
856 return {
857 clientId: client.clientId,
858 connectionId: client.connectionId,
859 dispatchedAt,
860 type
861 };
862 }
863
864 dispatchWithActionResult(
865 type: Exclude<FirefoxBridgeOutboundCommandType, "api_request" | "request_cancel">,
866 payload: Record<string, unknown>,
867 target: FirefoxBridgeCommandTarget = {},
868 timeoutMs: number = DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS
869 ): BrowserBridgeActionDispatch {
870 const client = this.selectClient(target);
871 const dispatchedAt = this.now();
872 const requestId = randomUUID();
873 const envelope = compactRecord({
874 ...payload,
875 requestId,
876 type
877 });
878
879 let resolveResult!: (response: BrowserBridgeActionResultSnapshot) => void;
880 let rejectResult!: (error: FirefoxBridgeError) => void;
881 const result = new Promise<BrowserBridgeActionResultSnapshot>((resolve, reject) => {
882 resolveResult = resolve;
883 rejectResult = reject;
884 });
885
886 // Low-level callers may only care about the dispatch side-effect.
887 void result.catch(() => {});
888
889 const timer = this.setTimeoutImpl(() => {
890 this.pendingActionRequests.delete(requestId);
891 rejectResult(
892 new FirefoxBridgeError(
893 "action_timeout",
894 `Firefox client "${client.clientId}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
895 {
896 clientId: client.clientId,
897 connectionId: client.connectionId,
898 requestId,
899 timeoutMs
900 }
901 )
902 );
903 }, normalizeTimeoutMs(timeoutMs, DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS));
904
905 this.pendingActionRequests.set(requestId, {
906 clientId: client.clientId,
907 connectionId: client.connectionId,
908 reject: rejectResult,
909 requestId,
910 resolve: resolveResult,
911 timer
912 });
913
914 if (!client.sendJson(envelope)) {
915 const pending = this.clearPendingActionRequest(requestId);
916 const error = new FirefoxBridgeError(
917 "send_failed",
918 `Failed to send ${type} to Firefox client "${client.clientId}".`,
919 {
920 clientId: client.clientId,
921 connectionId: client.connectionId,
922 requestId
923 }
924 );
925
926 pending?.reject(error);
927 throw error;
928 }
929
930 return {
931 clientId: client.clientId,
932 connectionId: client.connectionId,
933 dispatchedAt,
934 requestId,
935 result,
936 type
937 };
938 }
939
940 sendApiRequest(
941 payload: Record<string, unknown>,
942 options: FirefoxBridgeCommandTarget & {
943 requestId: string;
944 timeoutMs?: number | null;
945 }
946 ): Promise<FirefoxBridgeApiResponse> {
947 if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
948 throw new FirefoxBridgeError(
949 "duplicate_request_id",
950 `Firefox bridge request id "${options.requestId}" is already in flight.`,
951 {
952 requestId: options.requestId
953 }
954 );
955 }
956
957 const client = this.selectClient(options);
958 const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS);
959 const envelope = compactRecord({
960 ...payload,
961 id: options.requestId,
962 response_mode: "buffered",
963 type: "api_request"
964 });
965
966 return new Promise<FirefoxBridgeApiResponse>((resolve, reject) => {
967 const timer = this.setTimeoutImpl(() => {
968 this.pendingApiRequests.delete(options.requestId);
969 reject(
970 new FirefoxBridgeError(
971 "request_timeout",
972 `Firefox client "${client.clientId}" did not respond to api_request "${options.requestId}" within ${timeoutMs}ms.`,
973 {
974 clientId: client.clientId,
975 connectionId: client.connectionId,
976 requestId: options.requestId,
977 timeoutMs
978 }
979 )
980 );
981 }, timeoutMs);
982
983 this.pendingApiRequests.set(options.requestId, {
984 clientId: client.clientId,
985 connectionId: client.connectionId,
986 reject,
987 requestId: options.requestId,
988 resolve,
989 timer
990 });
991
992 if (!client.sendJson(envelope)) {
993 this.clearPendingRequest(options.requestId);
994 reject(
995 new FirefoxBridgeError(
996 "send_failed",
997 `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
998 {
999 clientId: client.clientId,
1000 connectionId: client.connectionId,
1001 requestId: options.requestId
1002 }
1003 )
1004 );
1005 }
1006 });
1007 }
1008
1009 openApiStream(
1010 payload: Record<string, unknown>,
1011 options: FirefoxBridgeCommandTarget & {
1012 idleTimeoutMs?: number | null;
1013 maxBufferedBytes?: number | null;
1014 maxBufferedEvents?: number | null;
1015 openTimeoutMs?: number | null;
1016 requestId: string;
1017 streamId: string;
1018 }
1019 ): FirefoxBridgeApiStream {
1020 if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
1021 throw new FirefoxBridgeError(
1022 "duplicate_request_id",
1023 `Firefox bridge request id "${options.requestId}" is already in flight.`,
1024 {
1025 requestId: options.requestId
1026 }
1027 );
1028 }
1029
1030 const client = this.selectClient(options);
1031 const streamSession = new FirefoxBridgeApiStreamSession({
1032 clearTimeoutImpl: this.clearTimeoutImpl,
1033 clientId: client.clientId,
1034 connectionId: client.connectionId,
1035 idleTimeoutMs: normalizeTimeoutMs(
1036 options.idleTimeoutMs,
1037 DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS
1038 ),
1039 maxBufferedBytes: normalizePositiveInteger(
1040 options.maxBufferedBytes,
1041 DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES
1042 ),
1043 maxBufferedEvents: normalizePositiveInteger(
1044 options.maxBufferedEvents,
1045 DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS
1046 ),
1047 now: this.now,
1048 onCancel: (reason) => {
1049 try {
1050 this.dispatch("request_cancel", {
1051 id: options.requestId,
1052 reason: normalizeOptionalString(reason) ?? undefined,
1053 stream_id: options.streamId
1054 }, {
1055 clientId: client.clientId
1056 });
1057 } catch {
1058 // Best-effort remote cancel.
1059 }
1060 },
1061 onClose: (requestId) => {
1062 this.pendingStreamRequests.delete(requestId);
1063 },
1064 openTimeoutMs: normalizeTimeoutMs(
1065 options.openTimeoutMs,
1066 DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS
1067 ),
1068 requestId: options.requestId,
1069 setTimeoutImpl: this.setTimeoutImpl,
1070 streamId: options.streamId
1071 });
1072 this.pendingStreamRequests.set(options.requestId, streamSession);
1073
1074 const envelope = compactRecord({
1075 ...payload,
1076 id: options.requestId,
1077 response_mode: "sse",
1078 stream_id: options.streamId,
1079 type: "api_request"
1080 });
1081
1082 if (!client.sendJson(envelope)) {
1083 this.pendingStreamRequests.delete(options.requestId);
1084 streamSession.fail(
1085 "send_failed",
1086 `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`
1087 );
1088 throw new FirefoxBridgeError(
1089 "send_failed",
1090 `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
1091 {
1092 clientId: client.clientId,
1093 connectionId: client.connectionId,
1094 requestId: options.requestId
1095 }
1096 );
1097 }
1098
1099 return streamSession;
1100 }
1101
1102 cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1103 const pendingApiRequest = this.pendingApiRequests.get(input.requestId) ?? null;
1104 const pendingStreamRequest = this.pendingStreamRequests.get(input.requestId) ?? null;
1105 const requestedClientId = normalizeOptionalString(input.clientId);
1106
1107 if (pendingApiRequest == null && pendingStreamRequest == null) {
1108 throw new FirefoxBridgeError(
1109 "request_not_found",
1110 `Firefox bridge request "${input.requestId}" is not in flight.`,
1111 {
1112 requestId: input.requestId
1113 }
1114 );
1115 }
1116
1117 const targetClientId =
1118 pendingApiRequest?.clientId
1119 ?? pendingStreamRequest?.clientId
1120 ?? null;
1121
1122 if (requestedClientId != null && targetClientId != null && requestedClientId !== targetClientId) {
1123 throw new FirefoxBridgeError(
1124 "client_not_found",
1125 `Firefox bridge request "${input.requestId}" is not running on client "${requestedClientId}".`,
1126 {
1127 clientId: requestedClientId,
1128 requestId: input.requestId
1129 }
1130 );
1131 }
1132
1133 const receipt = this.dispatch(
1134 "request_cancel",
1135 compactRecord({
1136 id: input.requestId,
1137 platform: normalizeOptionalString(input.platform) ?? undefined,
1138 reason: normalizeOptionalString(input.reason) ?? undefined,
1139 stream_id:
1140 normalizeOptionalString(input.streamId)
1141 ?? pendingStreamRequest?.streamId
1142 ?? undefined
1143 }),
1144 {
1145 clientId: targetClientId ?? requestedClientId
1146 }
1147 );
1148
1149 return {
1150 ...receipt,
1151 reason: normalizeOptionalString(input.reason),
1152 requestId: input.requestId,
1153 streamId:
1154 normalizeOptionalString(input.streamId)
1155 ?? pendingStreamRequest?.streamId
1156 ?? null
1157 };
1158 }
1159
1160 handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1161 const pending = this.pendingApiRequests.get(payload.id);
1162
1163 if (pending == null || pending.connectionId !== connectionId) {
1164 return false;
1165 }
1166
1167 this.clearPendingRequest(payload.id);
1168 pending.resolve({
1169 body: payload.body,
1170 clientId: pending.clientId,
1171 connectionId,
1172 error: payload.error,
1173 id: payload.id,
1174 ok: payload.ok,
1175 respondedAt: this.now(),
1176 status: payload.status
1177 });
1178 return true;
1179 }
1180
1181 handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
1182 const pending = this.pendingActionRequests.get(payload.request_id);
1183
1184 if (pending == null || pending.connectionId !== connectionId) {
1185 return false;
1186 }
1187
1188 this.clearPendingActionRequest(payload.request_id);
1189 pending.resolve(payload);
1190 return true;
1191 }
1192
1193 handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1194 const pending = this.pendingStreamRequests.get(payload.id);
1195
1196 if (pending == null || pending.connectionId !== connectionId) {
1197 return false;
1198 }
1199
1200 return pending.markOpen(payload);
1201 }
1202
1203 handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1204 const pending = this.pendingStreamRequests.get(payload.id);
1205
1206 if (pending == null || pending.connectionId !== connectionId) {
1207 return false;
1208 }
1209
1210 return pending.markEvent(payload);
1211 }
1212
1213 handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1214 const pending = this.pendingStreamRequests.get(payload.id);
1215
1216 if (pending == null || pending.connectionId !== connectionId) {
1217 return false;
1218 }
1219
1220 return pending.markEnd(payload);
1221 }
1222
1223 handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1224 const pending = this.pendingStreamRequests.get(payload.id);
1225
1226 if (pending == null || pending.connectionId !== connectionId) {
1227 return false;
1228 }
1229
1230 return pending.markError(payload);
1231 }
1232
1233 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1234 const actionRequestIds = [...this.pendingActionRequests.values()]
1235 .filter((entry) => entry.connectionId === event.connectionId)
1236 .map((entry) => entry.requestId);
1237 const requestIds = [...this.pendingApiRequests.values()]
1238 .filter((entry) => entry.connectionId === event.connectionId)
1239 .map((entry) => entry.requestId);
1240 const streamIds = [...this.pendingStreamRequests.values()]
1241 .filter((entry) => entry.connectionId === event.connectionId)
1242 .map((entry) => entry.requestId);
1243
1244 const errorCode: FirefoxBridgeErrorCode =
1245 event.code === 4001 ? "client_replaced" : "client_disconnected";
1246 const clientLabel = event.clientId ?? "unknown";
1247 const reasonSuffix =
1248 normalizeOptionalString(event.reason) == null ? "" : ` (${normalizeOptionalString(event.reason)})`;
1249
1250 for (const requestId of actionRequestIds) {
1251 const pending = this.clearPendingActionRequest(requestId);
1252
1253 if (pending == null) {
1254 continue;
1255 }
1256
1257 pending.reject(
1258 new FirefoxBridgeError(
1259 errorCode,
1260 errorCode === "client_replaced"
1261 ? `Firefox client "${clientLabel}" was replaced before action "${requestId}" completed${reasonSuffix}.`
1262 : `Firefox client "${clientLabel}" disconnected before action "${requestId}" completed${reasonSuffix}.`,
1263 {
1264 clientId: pending.clientId,
1265 connectionId: pending.connectionId,
1266 requestId
1267 }
1268 )
1269 );
1270 }
1271
1272 for (const requestId of requestIds) {
1273 const pending = this.clearPendingRequest(requestId);
1274
1275 if (pending == null) {
1276 continue;
1277 }
1278
1279 pending.reject(
1280 new FirefoxBridgeError(
1281 errorCode,
1282 errorCode === "client_replaced"
1283 ? `Firefox client "${clientLabel}" was replaced before api_request "${requestId}" completed${reasonSuffix}.`
1284 : `Firefox client "${clientLabel}" disconnected before api_request "${requestId}" completed${reasonSuffix}.`,
1285 {
1286 clientId: pending.clientId,
1287 connectionId: pending.connectionId,
1288 requestId
1289 }
1290 )
1291 );
1292 }
1293
1294 for (const requestId of streamIds) {
1295 const pending = this.pendingStreamRequests.get(requestId);
1296
1297 if (pending == null) {
1298 continue;
1299 }
1300
1301 pending.fail(
1302 errorCode,
1303 errorCode === "client_replaced"
1304 ? `Firefox client "${clientLabel}" was replaced before stream "${requestId}" completed${reasonSuffix}.`
1305 : `Firefox client "${clientLabel}" disconnected before stream "${requestId}" completed${reasonSuffix}.`
1306 );
1307 }
1308 }
1309
1310 stop(): void {
1311 const actionRequestIds = [...this.pendingActionRequests.keys()];
1312 const requestIds = [...this.pendingApiRequests.keys()];
1313 const streamIds = [...this.pendingStreamRequests.keys()];
1314
1315 for (const requestId of actionRequestIds) {
1316 const pending = this.clearPendingActionRequest(requestId);
1317
1318 if (pending == null) {
1319 continue;
1320 }
1321
1322 pending.reject(
1323 new FirefoxBridgeError(
1324 "service_stopped",
1325 `Firefox bridge stopped before action "${requestId}" completed.`,
1326 {
1327 clientId: pending.clientId,
1328 connectionId: pending.connectionId,
1329 requestId
1330 }
1331 )
1332 );
1333 }
1334
1335 for (const requestId of requestIds) {
1336 const pending = this.clearPendingRequest(requestId);
1337
1338 if (pending == null) {
1339 continue;
1340 }
1341
1342 pending.reject(
1343 new FirefoxBridgeError(
1344 "service_stopped",
1345 `Firefox bridge stopped before api_request "${requestId}" completed.`,
1346 {
1347 clientId: pending.clientId,
1348 connectionId: pending.connectionId,
1349 requestId
1350 }
1351 )
1352 );
1353 }
1354
1355 for (const requestId of streamIds) {
1356 const pending = this.pendingStreamRequests.get(requestId);
1357
1358 if (pending == null) {
1359 continue;
1360 }
1361
1362 pending.fail(
1363 "service_stopped",
1364 `Firefox bridge stopped before stream "${requestId}" completed.`
1365 );
1366 }
1367 }
1368
1369 private clearPendingRequest(requestId: string): FirefoxPendingApiRequest | null {
1370 const pending = this.pendingApiRequests.get(requestId);
1371
1372 if (pending == null) {
1373 return null;
1374 }
1375
1376 this.pendingApiRequests.delete(requestId);
1377 this.clearTimeoutImpl(pending.timer);
1378 return pending;
1379 }
1380
1381 private clearPendingActionRequest(requestId: string): FirefoxPendingActionRequest | null {
1382 const pending = this.pendingActionRequests.get(requestId);
1383
1384 if (pending == null) {
1385 return null;
1386 }
1387
1388 this.pendingActionRequests.delete(requestId);
1389 this.clearTimeoutImpl(pending.timer);
1390 return pending;
1391 }
1392
1393 private selectClient(target: FirefoxBridgeCommandTarget): FirefoxBridgeRegisteredClient {
1394 const normalizedClientId = normalizeOptionalString(target.clientId);
1395 const client =
1396 normalizedClientId == null
1397 ? this.resolveActiveClient()
1398 : this.resolveClientById(normalizedClientId);
1399
1400 if (client != null) {
1401 return client;
1402 }
1403
1404 if (normalizedClientId == null) {
1405 throw new FirefoxBridgeError(
1406 "no_active_client",
1407 "No active Firefox bridge client is connected."
1408 );
1409 }
1410
1411 throw new FirefoxBridgeError(
1412 "client_not_found",
1413 `Firefox bridge client "${normalizedClientId}" is not connected.`,
1414 {
1415 clientId: normalizedClientId
1416 }
1417 );
1418 }
1419}
1420
1421export class FirefoxBridgeService {
1422 constructor(private readonly broker: FirefoxCommandBroker) {}
1423
1424 dispatchPluginAction(
1425 input: FirefoxPluginActionCommandInput
1426 ): BrowserBridgeActionDispatch {
1427 return this.broker.dispatchWithActionResult(
1428 input.action,
1429 compactRecord({
1430 action: input.action,
1431 disconnect_ms: normalizeOptionalNonNegativeInteger(input.disconnectMs),
1432 platform: normalizeOptionalString(input.platform) ?? undefined,
1433 reason: normalizeOptionalString(input.reason) ?? undefined,
1434 repeat_count: normalizeOptionalPositiveInteger(input.repeatCount),
1435 repeat_interval_ms: normalizeOptionalNonNegativeInteger(input.repeatIntervalMs)
1436 }),
1437 input
1438 );
1439 }
1440
1441 openTab(input: FirefoxOpenTabCommandInput = {}): BrowserBridgeActionDispatch {
1442 const platform = normalizeOptionalString(input.platform);
1443
1444 return this.broker.dispatchWithActionResult(
1445 "open_tab",
1446 compactRecord({
1447 action: "tab_open",
1448 platform: platform ?? undefined
1449 }),
1450 input
1451 );
1452 }
1453
1454 requestCredentials(
1455 input: FirefoxRequestCredentialsCommandInput = {}
1456 ): BrowserBridgeActionDispatch {
1457 return this.broker.dispatchWithActionResult(
1458 "request_credentials",
1459 compactRecord({
1460 action: "request_credentials",
1461 platform: normalizeOptionalString(input.platform) ?? undefined,
1462 reason: normalizeOptionalString(input.reason) ?? undefined
1463 }),
1464 input
1465 );
1466 }
1467
1468 injectMessage(
1469 input: FirefoxInjectMessageCommandInput
1470 ): BrowserBridgeActionDispatch {
1471 return this.broker.dispatchWithActionResult(
1472 "browser.inject_message",
1473 compactRecord({
1474 action: "inject_message",
1475 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1476 message_text: input.messageText,
1477 page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1478 page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1479 poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
1480 plan_id: input.planId,
1481 platform: input.platform,
1482 retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
1483 retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
1484 shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1485 target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1486 timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1487 }),
1488 compactRecord({
1489 clientId: normalizeOptionalString(input.clientId) ?? undefined
1490 }),
1491 normalizeTimeoutMs(
1492 resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1493 DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1494 )
1495 );
1496 }
1497
1498 proxyDelivery(
1499 input: FirefoxProxyDeliveryCommandInput
1500 ): BrowserBridgeActionDispatch {
1501 return this.broker.dispatchWithActionResult(
1502 "browser.proxy_delivery",
1503 compactRecord({
1504 action: "proxy_delivery",
1505 assistant_message_id: normalizeOptionalString(input.assistantMessageId) ?? input.assistantMessageId,
1506 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1507 message_text: input.messageText,
1508 organization_id: normalizeOptionalString(input.organizationId) ?? undefined,
1509 page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1510 page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1511 plan_id: input.planId,
1512 platform: input.platform,
1513 shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1514 target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1515 timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1516 }),
1517 compactRecord({
1518 clientId: normalizeOptionalString(input.clientId) ?? undefined
1519 }),
1520 normalizeTimeoutMs(
1521 resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1522 DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1523 )
1524 );
1525 }
1526
1527 sendMessage(
1528 input: FirefoxSendMessageCommandInput
1529 ): BrowserBridgeActionDispatch {
1530 return this.broker.dispatchWithActionResult(
1531 "browser.send_message",
1532 compactRecord({
1533 action: "send_message",
1534 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1535 page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1536 page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1537 poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
1538 plan_id: input.planId,
1539 platform: input.platform,
1540 retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
1541 retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
1542 shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1543 target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1544 timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1545 }),
1546 compactRecord({
1547 clientId: normalizeOptionalString(input.clientId) ?? undefined
1548 }),
1549 normalizeTimeoutMs(
1550 resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1551 DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1552 )
1553 );
1554 }
1555
1556 reload(input: FirefoxReloadCommandInput = {}): BrowserBridgeActionDispatch {
1557 return this.broker.dispatchWithActionResult(
1558 "reload",
1559 compactRecord({
1560 action: normalizeOptionalString(input.platform) == null ? "controller_reload" : "tab_reload",
1561 platform: normalizeOptionalString(input.platform) ?? undefined,
1562 reason: normalizeOptionalString(input.reason) ?? undefined
1563 }),
1564 input
1565 );
1566 }
1567
1568 async apiRequest(input: FirefoxApiRequestCommandInput): Promise<FirefoxBridgeApiResponse> {
1569 const platform = normalizeOptionalString(input.platform);
1570 const path = normalizeOptionalString(input.path);
1571
1572 if (platform == null) {
1573 throw new Error("Firefox bridge api_request requires a non-empty platform.");
1574 }
1575
1576 if (path == null) {
1577 throw new Error("Firefox bridge api_request requires a non-empty path.");
1578 }
1579
1580 const requestId = normalizeOptionalString(input.id) ?? randomUUID();
1581
1582 return await this.broker.sendApiRequest(
1583 compactRecord({
1584 body: input.body ?? null,
1585 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1586 headers: normalizeHeaderRecord(input.headers),
1587 method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
1588 path,
1589 platform,
1590 response_mode: "buffered",
1591 timeout_ms: normalizeTimeoutMs(
1592 input.timeoutMs,
1593 DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
1594 )
1595 }),
1596 {
1597 clientId: input.clientId,
1598 requestId,
1599 timeoutMs: input.timeoutMs
1600 }
1601 );
1602 }
1603
1604 streamRequest(input: FirefoxApiRequestCommandInput): FirefoxBridgeApiStream {
1605 const platform = normalizeOptionalString(input.platform);
1606 const path = normalizeOptionalString(input.path);
1607
1608 if (platform == null) {
1609 throw new Error("Firefox bridge stream_request requires a non-empty platform.");
1610 }
1611
1612 if (path == null) {
1613 throw new Error("Firefox bridge stream_request requires a non-empty path.");
1614 }
1615
1616 const requestId = normalizeOptionalString(input.id) ?? randomUUID();
1617 const streamId = normalizeOptionalString(input.streamId) ?? requestId;
1618
1619 return this.broker.openApiStream(
1620 compactRecord({
1621 body: input.body ?? null,
1622 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1623 headers: normalizeHeaderRecord(input.headers),
1624 method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
1625 path,
1626 platform,
1627 response_mode: "sse",
1628 stream_id: streamId,
1629 timeout_ms: normalizeTimeoutMs(
1630 input.timeoutMs,
1631 DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
1632 )
1633 }),
1634 {
1635 clientId: input.clientId,
1636 idleTimeoutMs: input.idleTimeoutMs,
1637 maxBufferedBytes: input.maxBufferedBytes,
1638 maxBufferedEvents: input.maxBufferedEvents,
1639 openTimeoutMs: input.openTimeoutMs,
1640 requestId,
1641 streamId
1642 }
1643 );
1644 }
1645
1646 cancelApiRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1647 return this.cancelRequest(input);
1648 }
1649
1650 cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1651 const requestId = normalizeOptionalString(input.requestId);
1652
1653 if (requestId == null) {
1654 throw new Error("Firefox bridge cancel_request requires a non-empty requestId.");
1655 }
1656
1657 return this.broker.cancelRequest({
1658 ...input,
1659 requestId
1660 });
1661 }
1662
1663 handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1664 return this.broker.handleApiResponse(connectionId, {
1665 body: payload.body,
1666 error: payload.error,
1667 id: payload.id,
1668 ok: payload.ok,
1669 status: normalizeStatus(payload.status)
1670 });
1671 }
1672
1673 handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1674 return this.broker.handleStreamOpen(connectionId, {
1675 ...payload,
1676 status: normalizeStatus(payload.status)
1677 });
1678 }
1679
1680 handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1681 return this.broker.handleStreamEvent(connectionId, payload);
1682 }
1683
1684 handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1685 return this.broker.handleStreamEnd(connectionId, {
1686 ...payload,
1687 status: normalizeStatus(payload.status)
1688 });
1689 }
1690
1691 handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1692 return this.broker.handleStreamError(connectionId, {
1693 ...payload,
1694 status: normalizeStatus(payload.status)
1695 });
1696 }
1697
1698 handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
1699 return this.broker.handleActionResult(connectionId, payload);
1700 }
1701
1702 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1703 this.broker.handleConnectionClosed(event);
1704 }
1705
1706 stop(): void {
1707 this.broker.stop();
1708 }
1709}