@agentick/client-multiplexer 0.0.1

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.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Shared Transport
3
+ *
4
+ * A ClientTransport implementation that multiplexes connections across browser tabs.
5
+ * Only the leader tab maintains a real SSE connection; followers communicate via BroadcastChannel.
6
+ */
7
+ import { createSSETransport, createWSTransport, } from "@agentick/client";
8
+ import { createLeaderElector } from "./leader-elector.js";
9
+ import { createBroadcastBridge, generateRequestId, } from "./broadcast-bridge.js";
10
+ // ============================================================================
11
+ // Shared Transport Implementation
12
+ // ============================================================================
13
+ export class SharedTransport {
14
+ config;
15
+ elector;
16
+ bridge;
17
+ // Real transport (only used by leader)
18
+ realTransport = null;
19
+ // This tab's subscriptions
20
+ mySessionSubscriptions = new Set();
21
+ myChannelSubscriptions = new Set(); // "sessionId:channelName"
22
+ // State
23
+ _state = "disconnected";
24
+ _connectionId;
25
+ // Handlers
26
+ eventHandlers = new Set();
27
+ stateHandlers = new Set();
28
+ // Pending requests (for followers awaiting responses)
29
+ pendingRequests = new Map();
30
+ // Pending send streams (for followers)
31
+ pendingStreams = new Map();
32
+ // Ready promise - resolves when transport is truly operational
33
+ readyResolve;
34
+ readyPromise;
35
+ constructor(config) {
36
+ this.config = config;
37
+ const channelKey = config.baseUrl.replace(/[^a-zA-Z0-9]/g, "_");
38
+ this.elector = createLeaderElector(channelKey);
39
+ this.bridge = createBroadcastBridge(channelKey, this.elector.tabId);
40
+ this.setupBridgeHandlers();
41
+ this.setupLeadershipHandlers();
42
+ }
43
+ // ══════════════════════════════════════════════════════════════════════════
44
+ // ClientTransport Interface
45
+ // ══════════════════════════════════════════════════════════════════════════
46
+ get state() {
47
+ return this._state;
48
+ }
49
+ get connectionId() {
50
+ return this._connectionId;
51
+ }
52
+ async connect() {
53
+ if (this._state === "connected" || this._state === "connecting") {
54
+ // If already connecting, wait for ready
55
+ if (this.readyPromise) {
56
+ await this.readyPromise;
57
+ }
58
+ return;
59
+ }
60
+ this.setState("connecting");
61
+ // Create ready promise
62
+ this.readyPromise = new Promise((resolve) => {
63
+ this.readyResolve = resolve;
64
+ });
65
+ // Simple approach: wait for leadership election to complete
66
+ // Web Locks will resolve quickly - either we get the lock (leader) or someone else has it (follower)
67
+ await this.elector.awaitLeadership();
68
+ // Now we know definitively if we're leader or not
69
+ if (this.elector.isLeader) {
70
+ // We're the leader - connect to the real server
71
+ await this.connectAsLeader();
72
+ this.readyResolve?.();
73
+ }
74
+ else {
75
+ // We're a follower - the leader exists (they have the lock)
76
+ this._connectionId = `follower-${this.elector.tabId}`;
77
+ this.setState("connected");
78
+ // Wait for the leader to be ready (they might still be connecting to server)
79
+ await this.waitForLeader();
80
+ this.readyResolve?.();
81
+ }
82
+ }
83
+ /**
84
+ * Wait until a leader is available AND ready to handle requests (for followers).
85
+ * The key distinction is waiting for "leader:transport_ready" not just "leader:ready".
86
+ */
87
+ waitForLeader() {
88
+ return new Promise((resolve) => {
89
+ // Ask if there's a leader ready to handle requests
90
+ this.bridge.broadcast({ type: "ping:leader", tabId: this.elector.tabId });
91
+ const timeout = setTimeout(() => {
92
+ cleanup();
93
+ // No leader responded - we might need to become leader
94
+ // Trigger re-election
95
+ this.elector.awaitLeadership().catch(() => { });
96
+ resolve();
97
+ }, 2000); // Increased timeout to allow leader more time to connect
98
+ const cleanup = this.bridge.onMessage((msg) => {
99
+ // Only resolve when leader's TRANSPORT is ready, not just when leader exists
100
+ // "pong:leader" is sent by leader with ready transport
101
+ // "leader:transport_ready" is broadcast when leader finishes connecting
102
+ // "event" means leader is actively sending events (definitely ready)
103
+ if (msg.type === "pong:leader" ||
104
+ msg.type === "leader:transport_ready" ||
105
+ msg.type === "event") {
106
+ clearTimeout(timeout);
107
+ cleanup();
108
+ resolve();
109
+ }
110
+ // Note: "leader:ready" is just the start of leader setup, not safe to use yet
111
+ });
112
+ });
113
+ }
114
+ disconnect() {
115
+ if (this.elector.isLeader && this.realTransport) {
116
+ this.realTransport.disconnect();
117
+ this.realTransport = null;
118
+ }
119
+ this.elector.resign();
120
+ this.bridge.close();
121
+ this._connectionId = undefined;
122
+ this.setState("disconnected");
123
+ }
124
+ send(input, sessionId) {
125
+ const self = this;
126
+ const effectiveSessionId = sessionId ?? "default";
127
+ // Check if we can use real transport directly (leader with ready transport)
128
+ if (this.elector.isLeader && this.realTransport) {
129
+ return this.realTransport.send(input, sessionId);
130
+ }
131
+ // Either we're a follower OR we're a leader whose transport isn't ready yet.
132
+ // In both cases, we need to wait and then decide how to proceed.
133
+ const requestId = generateRequestId(this.elector.tabId);
134
+ let aborted = false;
135
+ const streamState = {
136
+ events: [],
137
+ resolve: () => { },
138
+ reject: (_error) => { },
139
+ aborted: false,
140
+ };
141
+ this.pendingStreams.set(requestId, streamState);
142
+ const iterable = {
143
+ async *[Symbol.asyncIterator]() {
144
+ // Wait for transport to be ready (handles leadership transitions)
145
+ if (self.readyPromise) {
146
+ await self.readyPromise;
147
+ }
148
+ // After waiting, check again if we should use real transport
149
+ if (self.elector.isLeader && self.realTransport) {
150
+ // We're leader with ready transport - use it directly
151
+ self.pendingStreams.delete(requestId);
152
+ const stream = self.realTransport.send(input, sessionId);
153
+ for await (const event of stream) {
154
+ yield event;
155
+ }
156
+ return;
157
+ }
158
+ // We're a follower - forward to leader via bridge
159
+ self.bridge.broadcast({
160
+ type: "request:send",
161
+ requestId,
162
+ tabId: self.elector.tabId,
163
+ sessionId: effectiveSessionId,
164
+ input,
165
+ });
166
+ // Wait for stream to complete or error
167
+ await new Promise((resolve, reject) => {
168
+ streamState.resolve = resolve;
169
+ streamState.reject = reject;
170
+ });
171
+ // Yield all collected events
172
+ for (const event of streamState.events) {
173
+ yield event;
174
+ }
175
+ self.pendingStreams.delete(requestId);
176
+ },
177
+ abort(reason) {
178
+ if (aborted)
179
+ return;
180
+ aborted = true;
181
+ streamState.aborted = true;
182
+ // Tell leader to abort
183
+ self.bridge.broadcast({
184
+ type: "request:abort",
185
+ requestId: generateRequestId(self.elector.tabId),
186
+ tabId: self.elector.tabId,
187
+ sessionId: effectiveSessionId,
188
+ reason,
189
+ });
190
+ streamState.reject(new Error(reason ?? "Aborted"));
191
+ },
192
+ };
193
+ return iterable;
194
+ }
195
+ async subscribeToSession(sessionId) {
196
+ this.mySessionSubscriptions.add(sessionId);
197
+ // Wait for transport to be ready
198
+ if (this.readyPromise) {
199
+ await this.readyPromise;
200
+ }
201
+ if (this.elector.isLeader && this.realTransport) {
202
+ await this.realTransport.subscribeToSession(sessionId);
203
+ }
204
+ else {
205
+ await this.forwardRequest("request:subscribe", { sessionId });
206
+ }
207
+ }
208
+ async unsubscribeFromSession(sessionId) {
209
+ this.mySessionSubscriptions.delete(sessionId);
210
+ // Wait for transport to be ready
211
+ if (this.readyPromise) {
212
+ await this.readyPromise;
213
+ }
214
+ if (this.elector.isLeader && this.realTransport) {
215
+ // Only unsubscribe if no other tabs need this session
216
+ // For simplicity, leader always stays subscribed (cleanup on session close)
217
+ await this.realTransport.unsubscribeFromSession(sessionId);
218
+ }
219
+ else {
220
+ await this.forwardRequest("request:unsubscribe", { sessionId });
221
+ }
222
+ }
223
+ async abortSession(sessionId, reason) {
224
+ // Wait for transport to be ready
225
+ if (this.readyPromise) {
226
+ await this.readyPromise;
227
+ }
228
+ if (this.elector.isLeader && this.realTransport) {
229
+ await this.realTransport.abortSession(sessionId, reason);
230
+ }
231
+ else {
232
+ await this.forwardRequest("request:abort", { sessionId, reason });
233
+ }
234
+ }
235
+ async closeSession(sessionId) {
236
+ this.mySessionSubscriptions.delete(sessionId);
237
+ // Remove channel subscriptions for this session
238
+ for (const key of this.myChannelSubscriptions) {
239
+ if (key.startsWith(`${sessionId}:`)) {
240
+ this.myChannelSubscriptions.delete(key);
241
+ }
242
+ }
243
+ // Wait for transport to be ready
244
+ if (this.readyPromise) {
245
+ await this.readyPromise;
246
+ }
247
+ if (this.elector.isLeader && this.realTransport) {
248
+ await this.realTransport.closeSession(sessionId);
249
+ }
250
+ else {
251
+ await this.forwardRequest("request:close", { sessionId });
252
+ }
253
+ }
254
+ async submitToolResult(sessionId, toolUseId, result) {
255
+ // Wait for transport to be ready
256
+ if (this.readyPromise) {
257
+ await this.readyPromise;
258
+ }
259
+ if (this.elector.isLeader && this.realTransport) {
260
+ await this.realTransport.submitToolResult(sessionId, toolUseId, result);
261
+ }
262
+ else {
263
+ await this.forwardRequest("request:toolResult", { sessionId, toolUseId, result });
264
+ }
265
+ }
266
+ async subscribeToChannel(sessionId, channel) {
267
+ const key = `${sessionId}:${channel}`;
268
+ this.myChannelSubscriptions.add(key);
269
+ // Wait for transport to be ready
270
+ if (this.readyPromise) {
271
+ await this.readyPromise;
272
+ }
273
+ if (this.elector.isLeader && this.realTransport) {
274
+ await this.realTransport.subscribeToChannel(sessionId, channel);
275
+ }
276
+ else {
277
+ await this.forwardRequest("request:channelSubscribe", { sessionId, channel });
278
+ }
279
+ }
280
+ async publishToChannel(sessionId, channel, event) {
281
+ // Wait for transport to be ready
282
+ if (this.readyPromise) {
283
+ await this.readyPromise;
284
+ }
285
+ if (this.elector.isLeader && this.realTransport) {
286
+ await this.realTransport.publishToChannel(sessionId, channel, event);
287
+ }
288
+ else {
289
+ await this.forwardRequest("request:channelPublish", { sessionId, channel, event });
290
+ }
291
+ }
292
+ onEvent(handler) {
293
+ this.eventHandlers.add(handler);
294
+ return () => this.eventHandlers.delete(handler);
295
+ }
296
+ onStateChange(handler) {
297
+ this.stateHandlers.add(handler);
298
+ return () => this.stateHandlers.delete(handler);
299
+ }
300
+ // ══════════════════════════════════════════════════════════════════════════
301
+ // Leadership Info (optional, for debugging/UI)
302
+ // ══════════════════════════════════════════════════════════════════════════
303
+ get isLeader() {
304
+ return this.elector.isLeader;
305
+ }
306
+ get tabId() {
307
+ return this.elector.tabId;
308
+ }
309
+ onLeadershipChange(callback) {
310
+ return this.elector.onLeadershipChange(callback);
311
+ }
312
+ // ══════════════════════════════════════════════════════════════════════════
313
+ // Internal: Leadership Handling
314
+ // ══════════════════════════════════════════════════════════════════════════
315
+ setupLeadershipHandlers() {
316
+ this.elector.onLeadershipChange(async (isLeader) => {
317
+ if (isLeader) {
318
+ await this.onBecomeLeader();
319
+ }
320
+ else {
321
+ this.onLoseLeadership();
322
+ }
323
+ });
324
+ }
325
+ async onBecomeLeader() {
326
+ // Create a new ready promise for this transition
327
+ // This ensures any pending requests wait until we're fully ready
328
+ this.readyPromise = new Promise((resolve) => {
329
+ this.readyResolve = resolve;
330
+ });
331
+ // IMPORTANT: The order here is critical to avoid race conditions:
332
+ // 1. First, collect subscriptions from other tabs (they need to know we're becoming leader)
333
+ // 2. Then, connect our transport
334
+ // 3. Then, re-subscribe on the new transport
335
+ // 4. ONLY THEN announce we're ready to handle requests
336
+ // Step 1: Request subscription info from other tabs (but DON'T signal readiness yet)
337
+ this.bridge.broadcast({ type: "leader:collecting_subscriptions", tabId: this.elector.tabId });
338
+ // Collect subscription announcements from other tabs
339
+ const announcements = await this.bridge.collectResponses("subscriptions:announce", 300);
340
+ // Aggregate all subscriptions (ours + others)
341
+ const allSessions = new Set(this.mySessionSubscriptions);
342
+ const allChannels = new Set(this.myChannelSubscriptions);
343
+ for (const ann of announcements) {
344
+ for (const s of ann.sessions)
345
+ allSessions.add(s);
346
+ for (const c of ann.channels)
347
+ allChannels.add(c);
348
+ }
349
+ // Step 2: Connect real transport
350
+ try {
351
+ await this.connectAsLeader();
352
+ }
353
+ catch (error) {
354
+ console.error("Leader failed to connect transport:", error);
355
+ // Resign leadership so another tab can try
356
+ this.elector.resign();
357
+ return;
358
+ }
359
+ // Step 3: Subscribe to aggregated sessions/channels
360
+ if (this.realTransport) {
361
+ for (const sessionId of allSessions) {
362
+ await this.realTransport.subscribeToSession(sessionId).catch(() => { });
363
+ }
364
+ for (const channelKey of allChannels) {
365
+ const [sessionId, channel] = channelKey.split(":");
366
+ if (sessionId && channel) {
367
+ await this.realTransport.subscribeToChannel(sessionId, channel).catch(() => { });
368
+ }
369
+ }
370
+ }
371
+ // Step 4: NOW we're fully ready - announce it so followers can proceed
372
+ this.bridge.broadcast({ type: "leader:transport_ready", tabId: this.elector.tabId });
373
+ // Mark ourselves as ready
374
+ this.readyResolve?.();
375
+ }
376
+ onLoseLeadership() {
377
+ // Create a new ready promise for the transition to follower
378
+ this.readyPromise = new Promise((resolve) => {
379
+ this.readyResolve = resolve;
380
+ });
381
+ // Clean up real transport
382
+ if (this.realTransport) {
383
+ this.realTransport.disconnect();
384
+ this.realTransport = null;
385
+ }
386
+ // We're now a follower, stay "connected" via bridge
387
+ this._connectionId = `follower-${this.elector.tabId}`;
388
+ // Wait for new leader, then mark ready
389
+ this.waitForLeader().then(() => {
390
+ this.readyResolve?.();
391
+ });
392
+ }
393
+ async connectAsLeader() {
394
+ // Determine transport type
395
+ const transportType = this.detectTransportType();
396
+ if (transportType === "websocket") {
397
+ this.realTransport = createWSTransport({
398
+ baseUrl: this.config.baseUrl,
399
+ token: this.config.token,
400
+ headers: this.config.headers,
401
+ timeout: this.config.timeout,
402
+ withCredentials: this.config.withCredentials,
403
+ clientId: this.config.clientId,
404
+ WebSocket: this.config.WebSocket,
405
+ reconnect: this.config.reconnect,
406
+ });
407
+ }
408
+ else {
409
+ this.realTransport = createSSETransport({
410
+ ...this.config,
411
+ paths: this.config.paths,
412
+ });
413
+ }
414
+ // Forward events from real transport to all tabs via bridge
415
+ this.realTransport.onEvent((event) => {
416
+ // Broadcast to all tabs
417
+ this.bridge.broadcast({ type: "event", event });
418
+ // Also handle locally if we care about this session
419
+ this.handleIncomingEvent(event);
420
+ });
421
+ this.realTransport.onStateChange((state) => {
422
+ this.setState(state);
423
+ // If leader's connection fails, resign so another tab can take over
424
+ if (state === "error" && this.elector.isLeader) {
425
+ console.warn("Leader transport error - resigning leadership");
426
+ this.elector.resign();
427
+ }
428
+ });
429
+ await this.realTransport.connect();
430
+ this._connectionId = this.realTransport.connectionId;
431
+ this.setState("connected");
432
+ }
433
+ detectTransportType() {
434
+ if (this.config.transport && this.config.transport !== "auto") {
435
+ return this.config.transport;
436
+ }
437
+ // Auto-detect based on URL scheme
438
+ const url = this.config.baseUrl.toLowerCase();
439
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
440
+ return "websocket";
441
+ }
442
+ return "sse";
443
+ }
444
+ // ══════════════════════════════════════════════════════════════════════════
445
+ // Internal: Bridge Message Handling
446
+ // ══════════════════════════════════════════════════════════════════════════
447
+ setupBridgeHandlers() {
448
+ this.bridge.onMessage((msg) => this.handleBridgeMessage(msg));
449
+ }
450
+ handleBridgeMessage(msg) {
451
+ switch (msg.type) {
452
+ // Leadership coordination
453
+ case "leader:collecting_subscriptions":
454
+ // New leader is asking for subscriptions (but not ready yet)
455
+ if (msg.tabId !== this.elector.tabId) {
456
+ this.bridge.broadcast({
457
+ type: "subscriptions:announce",
458
+ tabId: this.elector.tabId,
459
+ sessions: [...this.mySessionSubscriptions],
460
+ channels: [...this.myChannelSubscriptions],
461
+ });
462
+ }
463
+ break;
464
+ case "leader:transport_ready":
465
+ // Leader is now ready to handle requests - handled by waitForLeader promise
466
+ break;
467
+ case "ping:leader":
468
+ // Follower asking if there's a leader ready to handle requests
469
+ // IMPORTANT: Only respond if we have a ready transport, not just if we're the leader
470
+ if (this.elector.isLeader && this.realTransport && msg.tabId !== this.elector.tabId) {
471
+ this.bridge.broadcast({ type: "pong:leader", tabId: this.elector.tabId });
472
+ }
473
+ break;
474
+ case "pong:leader":
475
+ // Leader responded - handled by waitForLeader promise
476
+ break;
477
+ // Event from leader
478
+ case "event":
479
+ this.handleIncomingEvent(msg.event);
480
+ break;
481
+ // Stream events for pending sends
482
+ case "stream:event":
483
+ this.handleStreamEvent(msg.requestId, msg.event);
484
+ break;
485
+ case "stream:end":
486
+ this.handleStreamEnd(msg.requestId);
487
+ break;
488
+ case "stream:error":
489
+ this.handleStreamError(msg.requestId, msg.error);
490
+ break;
491
+ // Response to a request
492
+ case "response":
493
+ this.handleResponse(msg);
494
+ break;
495
+ // Requests from followers (only leader handles these)
496
+ case "request:send":
497
+ case "request:subscribe":
498
+ case "request:unsubscribe":
499
+ case "request:abort":
500
+ case "request:close":
501
+ case "request:toolResult":
502
+ case "request:channelSubscribe":
503
+ case "request:channelPublish":
504
+ if (this.elector.isLeader) {
505
+ this.handleFollowerRequest(msg);
506
+ }
507
+ break;
508
+ }
509
+ }
510
+ handleIncomingEvent(event) {
511
+ // Only dispatch if this tab cares about this session
512
+ const sessionId = event.sessionId;
513
+ if (sessionId && !this.mySessionSubscriptions.has(sessionId)) {
514
+ // Check if it's a channel event for a channel we're subscribed to
515
+ if (event.type === "channel" && event.channel) {
516
+ const channelKey = `${sessionId}:${event.channel}`;
517
+ if (!this.myChannelSubscriptions.has(channelKey)) {
518
+ return; // We don't care about this
519
+ }
520
+ }
521
+ else {
522
+ return; // We don't care about this session
523
+ }
524
+ }
525
+ // Dispatch to handlers
526
+ for (const handler of this.eventHandlers) {
527
+ try {
528
+ handler(event);
529
+ }
530
+ catch (e) {
531
+ console.error("Error in event handler:", e);
532
+ }
533
+ }
534
+ }
535
+ handleStreamEvent(requestId, event) {
536
+ const stream = this.pendingStreams.get(requestId);
537
+ if (stream && !stream.aborted) {
538
+ stream.events.push(event);
539
+ }
540
+ }
541
+ handleStreamEnd(requestId) {
542
+ const stream = this.pendingStreams.get(requestId);
543
+ if (stream) {
544
+ stream.resolve();
545
+ }
546
+ }
547
+ handleStreamError(requestId, error) {
548
+ const stream = this.pendingStreams.get(requestId);
549
+ if (stream) {
550
+ stream.reject(new Error(error));
551
+ }
552
+ }
553
+ handleResponse(msg) {
554
+ const pending = this.pendingRequests.get(msg.requestId);
555
+ if (pending) {
556
+ this.pendingRequests.delete(msg.requestId);
557
+ if (msg.ok) {
558
+ pending.resolve(msg.result);
559
+ }
560
+ else {
561
+ pending.reject(new Error(msg.error ?? "Request failed"));
562
+ }
563
+ }
564
+ }
565
+ async handleFollowerRequest(msg) {
566
+ // Extract requestId for error handling (all request types have it)
567
+ const requestId = msg.requestId;
568
+ if (!requestId)
569
+ return;
570
+ // If transport not ready, send error back to follower
571
+ if (!this.realTransport) {
572
+ if (msg.type === "request:send") {
573
+ this.bridge.broadcast({
574
+ type: "stream:error",
575
+ requestId,
576
+ error: "Leader transport not ready",
577
+ });
578
+ }
579
+ else {
580
+ this.sendResponse(requestId, false, "Leader transport not ready");
581
+ }
582
+ return;
583
+ }
584
+ try {
585
+ switch (msg.type) {
586
+ case "request:send": {
587
+ // Stream send - forward events back to the specific tab
588
+ const stream = this.realTransport.send(msg.input, msg.sessionId);
589
+ try {
590
+ for await (const event of stream) {
591
+ this.bridge.broadcast({
592
+ type: "stream:event",
593
+ requestId,
594
+ event,
595
+ });
596
+ }
597
+ this.bridge.broadcast({ type: "stream:end", requestId });
598
+ }
599
+ catch (error) {
600
+ this.bridge.broadcast({
601
+ type: "stream:error",
602
+ requestId,
603
+ error: error instanceof Error ? error.message : String(error),
604
+ });
605
+ }
606
+ break;
607
+ }
608
+ case "request:subscribe":
609
+ await this.realTransport.subscribeToSession(msg.sessionId);
610
+ this.sendResponse(requestId, true);
611
+ break;
612
+ case "request:unsubscribe":
613
+ await this.realTransport.unsubscribeFromSession(msg.sessionId);
614
+ this.sendResponse(requestId, true);
615
+ break;
616
+ case "request:abort":
617
+ await this.realTransport.abortSession(msg.sessionId, msg.reason);
618
+ this.sendResponse(requestId, true);
619
+ break;
620
+ case "request:close":
621
+ await this.realTransport.closeSession(msg.sessionId);
622
+ this.sendResponse(requestId, true);
623
+ break;
624
+ case "request:toolResult":
625
+ await this.realTransport.submitToolResult(msg.sessionId, msg.toolUseId, msg.result);
626
+ this.sendResponse(requestId, true);
627
+ break;
628
+ case "request:channelSubscribe":
629
+ await this.realTransport.subscribeToChannel(msg.sessionId, msg.channel);
630
+ this.sendResponse(requestId, true);
631
+ break;
632
+ case "request:channelPublish":
633
+ await this.realTransport.publishToChannel(msg.sessionId, msg.channel, msg.event);
634
+ this.sendResponse(requestId, true);
635
+ break;
636
+ }
637
+ }
638
+ catch (error) {
639
+ this.sendResponse(requestId, false, error instanceof Error ? error.message : String(error));
640
+ }
641
+ }
642
+ // ══════════════════════════════════════════════════════════════════════════
643
+ // Internal: Helpers
644
+ // ══════════════════════════════════════════════════════════════════════════
645
+ setState(state) {
646
+ if (this._state !== state) {
647
+ this._state = state;
648
+ for (const handler of this.stateHandlers) {
649
+ try {
650
+ handler(state);
651
+ }
652
+ catch (e) {
653
+ console.error("Error in state handler:", e);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ async forwardRequest(type, params) {
659
+ const requestId = generateRequestId(this.elector.tabId);
660
+ return new Promise((resolve, reject) => {
661
+ this.pendingRequests.set(requestId, { resolve, reject });
662
+ this.bridge.broadcast({
663
+ type,
664
+ requestId,
665
+ tabId: this.elector.tabId,
666
+ ...params,
667
+ });
668
+ // Timeout after 30 seconds
669
+ setTimeout(() => {
670
+ if (this.pendingRequests.has(requestId)) {
671
+ this.pendingRequests.delete(requestId);
672
+ reject(new Error("Request timed out"));
673
+ }
674
+ }, 30000);
675
+ });
676
+ }
677
+ sendResponse(requestId, ok, resultOrError) {
678
+ if (ok) {
679
+ this.bridge.broadcast({ type: "response", requestId, ok: true, result: resultOrError });
680
+ }
681
+ else {
682
+ this.bridge.broadcast({
683
+ type: "response",
684
+ requestId,
685
+ ok: false,
686
+ error: resultOrError,
687
+ });
688
+ }
689
+ }
690
+ }
691
+ // ============================================================================
692
+ // Factory Function
693
+ // ============================================================================
694
+ export function createSharedTransport(config) {
695
+ return new SharedTransport(config);
696
+ }
697
+ //# sourceMappingURL=shared-transport.js.map