@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.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/broadcast-bridge.d.ts +115 -0
- package/dist/broadcast-bridge.d.ts.map +1 -0
- package/dist/broadcast-bridge.js +59 -0
- package/dist/broadcast-bridge.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/leader-elector.d.ts +19 -0
- package/dist/leader-elector.d.ts.map +1 -0
- package/dist/leader-elector.js +193 -0
- package/dist/leader-elector.js.map +1 -0
- package/dist/shared-transport.d.ts +84 -0
- package/dist/shared-transport.d.ts.map +1 -0
- package/dist/shared-transport.js +697 -0
- package/dist/shared-transport.js.map +1 -0
- package/package.json +32 -0
|
@@ -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
|