chrome-bridge-mcp 1.8.0
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 +185 -0
- package/extension/console-capture.js +46 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +44 -0
- package/extension/page-instrumentation.js +115 -0
- package/extension/popup.css +128 -0
- package/extension/popup.html +64 -0
- package/extension/popup.js +142 -0
- package/extension/service-worker.js +3711 -0
- package/extension/stack-detect.js +54 -0
- package/extension/telemetry.js +29 -0
- package/install.sh +70 -0
- package/package.json +57 -0
- package/server/assertions.js +81 -0
- package/server/cli.js +380 -0
- package/server/formatters.js +35 -0
- package/server/har.js +22 -0
- package/server/index.js +107 -0
- package/server/launcher.js +97 -0
- package/server/link-checker.js +57 -0
- package/server/protocol.js +135 -0
- package/server/security-headers.js +68 -0
- package/server/stub-server.js +81 -0
- package/server/tools.js +1728 -0
- package/server/ws-manager.js +655 -0
- package/tools/measure-schema.mjs +108 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gestisce la connessione con l'estensione Chrome via WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* Supporta due modalità:
|
|
5
|
+
* - PRIMARY: avvia un WebSocket server sulla porta configurata.
|
|
6
|
+
* Accetta la connessione dell'estensione Chrome e, opzionalmente,
|
|
7
|
+
* connessioni relay da altre istanze MCP.
|
|
8
|
+
* - RELAY: se la porta è già occupata (altra istanza primary attiva),
|
|
9
|
+
* si connette come client al server esistente e inoltra i comandi.
|
|
10
|
+
*
|
|
11
|
+
* Dall'esterno (tools.js) l'interfaccia è identica in entrambe le modalità:
|
|
12
|
+
* - isConnected() → boolean
|
|
13
|
+
* - sendCommand(type, params) → Promise<data>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
17
|
+
import { DEFAULT_PORT, PING_INTERVAL_MS, IDENT_TIMEOUT_MS, PENDING_RELAY_TTL_MS, getTimeout, createCommand, MessageType, VERSION } from './protocol.js';
|
|
18
|
+
|
|
19
|
+
export class WSManager {
|
|
20
|
+
constructor(port = DEFAULT_PORT, opts = {}) {
|
|
21
|
+
this.port = port;
|
|
22
|
+
// Default loopback: il bind su 0.0.0.0 esponeva il bridge a tutta la rete
|
|
23
|
+
// con l'unico gate di un header Origin falsificabile e token null di
|
|
24
|
+
// default — chiunque poteva prendere lo slot estensione e ottenere
|
|
25
|
+
// execute_js nella sessione autenticata del browser. Su Crostini il
|
|
26
|
+
// port-forward richiede 0.0.0.0: opt-in esplicito, non default.
|
|
27
|
+
this.host = opts.host ?? process.env.CHROME_BRIDGE_HOST ?? '127.0.0.1';
|
|
28
|
+
this.identTimeout = opts.identTimeout ?? IDENT_TIMEOUT_MS;
|
|
29
|
+
this.token = opts.token ?? process.env.CHROME_BRIDGE_TOKEN ?? null;
|
|
30
|
+
this.pingIntervalMs = opts.pingInterval ?? PING_INTERVAL_MS;
|
|
31
|
+
this.pongGraceMs = opts.pongGrace ?? 10000;
|
|
32
|
+
this.lastPong = 0;
|
|
33
|
+
this.stopped = false;
|
|
34
|
+
this.mode = null; // 'primary' | 'relay'
|
|
35
|
+
this.relayExtConnected = undefined; // relay mode: stato estensione riportato dal primary
|
|
36
|
+
this.relayExtVersion = null;
|
|
37
|
+
this._extVersion = null; // versione estensione da ext_init (primary)
|
|
38
|
+
|
|
39
|
+
// --- primary mode ---
|
|
40
|
+
this.wss = null;
|
|
41
|
+
this.client = null; // Connessione Chrome extension
|
|
42
|
+
this.relayClients = new Set();
|
|
43
|
+
this.pendingRelay = new Map(); // command id → { ws: relay WebSocket, ts: timestamp }
|
|
44
|
+
this.pingTimer = null;
|
|
45
|
+
|
|
46
|
+
// --- relay mode ---
|
|
47
|
+
this.relaySocket = null;
|
|
48
|
+
|
|
49
|
+
// --- shared ---
|
|
50
|
+
this.pending = new Map(); // id → { resolve, reject, timer }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Public API ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Avvia il manager: tenta primary, fallback relay.
|
|
57
|
+
*/
|
|
58
|
+
async start() {
|
|
59
|
+
try {
|
|
60
|
+
await this._startPrimary();
|
|
61
|
+
this.mode = 'primary';
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err.code === 'EADDRINUSE') {
|
|
64
|
+
console.error(`[chrome-bridge] Port ${this.port} in use — connecting as relay`);
|
|
65
|
+
await this._startRelay();
|
|
66
|
+
this.mode = 'relay';
|
|
67
|
+
} else {
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verifica se è possibile inviare comandi.
|
|
75
|
+
*/
|
|
76
|
+
isConnected() {
|
|
77
|
+
if (this.mode === 'relay') {
|
|
78
|
+
// Il socket relay aperto non implica un'estensione collegata: get_status
|
|
79
|
+
// rispondeva connected:true senza estensione da nessuna parte.
|
|
80
|
+
return this.relaySocket !== null
|
|
81
|
+
&& this.relaySocket.readyState === WebSocket.OPEN
|
|
82
|
+
&& this.relayExtConnected !== false;
|
|
83
|
+
}
|
|
84
|
+
return this.client !== null && this.client.readyState === WebSocket.OPEN;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Versione dell'estensione collegata (da ext_init), o null. */
|
|
88
|
+
get extVersion() {
|
|
89
|
+
return this.mode === 'relay' ? (this.relayExtVersion ?? null) : (this._extVersion ?? null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Invia un comando all'estensione Chrome e attende la risposta.
|
|
94
|
+
* Funziona identicamente in primary e relay mode.
|
|
95
|
+
*/
|
|
96
|
+
sendCommand(type, params = {}) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
if (!this.isConnected()) {
|
|
99
|
+
// Un errore vago qui costa un turno intero al modello: dice cosa
|
|
100
|
+
// osservare e qual è la prossima azione.
|
|
101
|
+
reject(new Error(
|
|
102
|
+
`Chrome extension not connected (server ${this.mode} on ${this.host}:${this.port}`
|
|
103
|
+
+ `${this.mode === 'relay' ? ', reached through another chrome-bridge instance' : ''})`
|
|
104
|
+
+ ' — open Chrome, check the chrome-bridge extension is enabled and its port matches',
|
|
105
|
+
));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const command = createCommand(type, params);
|
|
110
|
+
// Il timeout di trasporto non può essere più basso di quello chiesto dal
|
|
111
|
+
// chiamante: `wait_for --timeout 90000` moriva a 60 s con un messaggio
|
|
112
|
+
// che il modello leggeva come "l'elemento non è comparso".
|
|
113
|
+
const asked = Number(params?.timeout);
|
|
114
|
+
const timeout = Number.isFinite(asked) && asked > 0
|
|
115
|
+
? Math.max(getTimeout(type), asked + 5000)
|
|
116
|
+
: getTimeout(type);
|
|
117
|
+
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
this.pending.delete(command.id);
|
|
120
|
+
reject(new Error(
|
|
121
|
+
`Command ${type} timed out after ${timeout}ms`
|
|
122
|
+
+ (params?.tab_id != null ? ` (tab ${params.tab_id})` : '')
|
|
123
|
+
+ ' — the tab may be busy, crashed or showing a modal dialog: try tab_action reload, handle_dialogs, or raise the timeout',
|
|
124
|
+
));
|
|
125
|
+
}, timeout);
|
|
126
|
+
|
|
127
|
+
this.pending.set(command.id, { resolve, reject, timer });
|
|
128
|
+
|
|
129
|
+
const socket = this.mode === 'relay' ? this.relaySocket : this.client;
|
|
130
|
+
socket.send(JSON.stringify(command));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Chiude tutto.
|
|
136
|
+
*/
|
|
137
|
+
async stop() {
|
|
138
|
+
this.stopped = true;
|
|
139
|
+
|
|
140
|
+
if (this.pingTimer) {
|
|
141
|
+
clearInterval(this.pingTimer);
|
|
142
|
+
this.pingTimer = null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this._rejectAllPending('Server shutting down');
|
|
146
|
+
|
|
147
|
+
if (this.mode === 'relay') {
|
|
148
|
+
if (this.relaySocket) {
|
|
149
|
+
this.relaySocket.close(1000, 'MCP shutting down');
|
|
150
|
+
this.relaySocket = null;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// primary mode — terminate forzato per evitare hang sull'handshake.
|
|
156
|
+
// Si itera wss.clients, non i due Set tracciati: un peer che completava
|
|
157
|
+
// l'handshake DURANTE lo shutdown restava OPEN, wss.close() non richiamava
|
|
158
|
+
// mai la callback e il processo MCP non usciva più (SIGKILL necessario).
|
|
159
|
+
this.relayClients.clear();
|
|
160
|
+
this.pendingRelay.clear();
|
|
161
|
+
this.client = null;
|
|
162
|
+
|
|
163
|
+
if (this.wss) {
|
|
164
|
+
for (const ws of this.wss.clients) {
|
|
165
|
+
try { ws.terminate(); } catch {}
|
|
166
|
+
}
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
// Rete di sicurezza: close() può non richiamare se un socket resta
|
|
169
|
+
// appeso. Lo shutdown non deve dipendere dalla buona volontà di ws.
|
|
170
|
+
const safety = setTimeout(resolve, 500);
|
|
171
|
+
this.wss.close(() => { clearTimeout(safety); resolve(); });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Primary mode ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
_startPrimary() {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
this.wss = new WebSocketServer({
|
|
181
|
+
host: this.host,
|
|
182
|
+
port: this.port,
|
|
183
|
+
maxPayload: 32 * 1024 * 1024,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.wss.on('listening', () => {
|
|
187
|
+
// Porta 0 = effimera: leggi quella reale assegnata dal kernel
|
|
188
|
+
if (!this.port) this.port = this.wss.address().port;
|
|
189
|
+
console.error(`[chrome-bridge] WebSocket server listening on ${this.host}:${this.port}`);
|
|
190
|
+
this._startPing();
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
this.wss.on('error', (err) => {
|
|
195
|
+
console.error(`[chrome-bridge] WebSocket server error:`, err.message);
|
|
196
|
+
reject(err);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.wss.on('connection', (ws, req) => {
|
|
200
|
+
this._handleNewConnection(ws, req);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ogni connessione DEVE identificarsi col primo messaggio:
|
|
207
|
+
* - { type: 'ext_init', token? } → estensione Chrome (Origin chrome-extension:// obbligatorio)
|
|
208
|
+
* - { type: 'relay_init' } → relay client (solo loopback)
|
|
209
|
+
* Connessioni mute o non valide vengono terminate.
|
|
210
|
+
*/
|
|
211
|
+
_handleNewConnection(ws, req) {
|
|
212
|
+
// Durante lo shutdown non si accettano nuovi peer: era la via per cui un
|
|
213
|
+
// handshake in corso rimetteva un socket OPEN dopo il terminate.
|
|
214
|
+
if (this.stopped) { try { ws.terminate(); } catch {} return; }
|
|
215
|
+
const origin = req.headers.origin || '';
|
|
216
|
+
const remote = req.socket.remoteAddress || '';
|
|
217
|
+
const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
218
|
+
const idTimer = setTimeout(() => {
|
|
219
|
+
console.error(`[chrome-bridge] Unidentified connection from ${remote} — terminating`);
|
|
220
|
+
ws.terminate();
|
|
221
|
+
}, this.identTimeout);
|
|
222
|
+
|
|
223
|
+
const onFirstMessage = (raw) => {
|
|
224
|
+
let msg;
|
|
225
|
+
try {
|
|
226
|
+
msg = JSON.parse(raw.toString());
|
|
227
|
+
} catch {
|
|
228
|
+
console.error('[chrome-bridge] Invalid JSON during handshake — terminating');
|
|
229
|
+
clearTimeout(idTimer);
|
|
230
|
+
ws.terminate();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
clearTimeout(idTimer);
|
|
235
|
+
ws.removeListener('message', onFirstMessage);
|
|
236
|
+
|
|
237
|
+
if (msg.type === MessageType.RELAY_INIT) {
|
|
238
|
+
if (!isLoopback) {
|
|
239
|
+
console.error(`[chrome-bridge] relay_init from non-loopback ${remote} — rejected`);
|
|
240
|
+
ws.terminate();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Il token proteggeva solo ext_init: un relay locale non autenticato
|
|
244
|
+
// aggirava interamente il segreto condiviso.
|
|
245
|
+
if (this.token && msg.token !== this.token) {
|
|
246
|
+
console.error('[chrome-bridge] relay_init with invalid token — rejected');
|
|
247
|
+
ws.terminate();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this._setupRelayClient(ws);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (msg.type === MessageType.EXT_INIT) {
|
|
255
|
+
// L'estensione pubblicata si collega solo a ws://localhost: una
|
|
256
|
+
// ext_init non-loopback è per definizione un client artigianale.
|
|
257
|
+
if (!isLoopback && this.host === '127.0.0.1') {
|
|
258
|
+
console.error(`[chrome-bridge] ext_init from non-loopback ${remote} — rejected`);
|
|
259
|
+
ws.terminate();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!origin.startsWith('chrome-extension://')) {
|
|
263
|
+
console.error(`[chrome-bridge] ext_init with origin "${origin}" — rejected`);
|
|
264
|
+
ws.terminate();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (this.token && msg.token !== this.token) {
|
|
268
|
+
console.error('[chrome-bridge] ext_init with invalid token — rejected');
|
|
269
|
+
ws.terminate();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this._setupChromeClient(ws, msg.version ?? null);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.error(`[chrome-bridge] Unexpected first message type "${msg.type}" — rejected`);
|
|
277
|
+
ws.terminate();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
ws.on('message', onFirstMessage);
|
|
281
|
+
ws.on('close', () => clearTimeout(idTimer));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_setupChromeClient(ws, extVersion = null) {
|
|
285
|
+
this._extVersion = extVersion;
|
|
286
|
+
// Lo skew server/estensione era invisibile: con la latenza di review del
|
|
287
|
+
// Chrome Web Store è la norma, non l'eccezione.
|
|
288
|
+
if (extVersion && extVersion !== VERSION) {
|
|
289
|
+
console.error(`[chrome-bridge] Version skew: server ${VERSION}, extension ${extVersion}`);
|
|
290
|
+
}
|
|
291
|
+
console.error(`[chrome-bridge] Chrome extension connected${extVersion ? ` (v${extVersion})` : ''}`);
|
|
292
|
+
try { ws.send(JSON.stringify({ type: 'ext_init_ok', version: VERSION })); } catch {}
|
|
293
|
+
|
|
294
|
+
if (this.client) {
|
|
295
|
+
console.error('[chrome-bridge] Replacing existing Chrome connection');
|
|
296
|
+
this._rejectAllPending('Replaced by new extension connection');
|
|
297
|
+
// Notifica anche i relay in attesa: le risposte ai loro comandi non arriveranno mai
|
|
298
|
+
for (const [id, entry] of this.pendingRelay) {
|
|
299
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
300
|
+
entry.ws.send(JSON.stringify({
|
|
301
|
+
id,
|
|
302
|
+
type: MessageType.ERROR,
|
|
303
|
+
error: 'Chrome extension reconnected',
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
this.pendingRelay.clear();
|
|
308
|
+
this.client.close(1000, 'Replaced by new connection');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.client = ws;
|
|
312
|
+
this.lastPong = Date.now();
|
|
313
|
+
this._broadcastExtState();
|
|
314
|
+
|
|
315
|
+
ws.on('message', (raw) => {
|
|
316
|
+
let msg;
|
|
317
|
+
try {
|
|
318
|
+
msg = JSON.parse(raw.toString());
|
|
319
|
+
} catch {
|
|
320
|
+
console.error('[chrome-bridge] Invalid JSON received');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this._handleChromeMessage(msg);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
ws.on('close', () => {
|
|
327
|
+
console.error('[chrome-bridge] Chrome extension disconnected');
|
|
328
|
+
if (this.client === ws) {
|
|
329
|
+
this.client = null;
|
|
330
|
+
this._extVersion = null;
|
|
331
|
+
this._broadcastExtState();
|
|
332
|
+
// Rigetta pending locali
|
|
333
|
+
this._rejectAllPending('Extension disconnected');
|
|
334
|
+
// Notifica relay clients
|
|
335
|
+
for (const [id, entry] of this.pendingRelay) {
|
|
336
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
337
|
+
entry.ws.send(JSON.stringify({
|
|
338
|
+
id,
|
|
339
|
+
type: MessageType.ERROR,
|
|
340
|
+
error: 'Chrome extension disconnected',
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.pendingRelay.clear();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
ws.on('error', (err) => {
|
|
349
|
+
console.error('[chrome-bridge] Chrome client error:', err.message);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_setupRelayClient(ws) {
|
|
354
|
+
console.error('[chrome-bridge] Relay client connected');
|
|
355
|
+
this.relayClients.add(ws);
|
|
356
|
+
// Ack: senza questo, un relay puntato su un WS server ESTRANEO che occupa
|
|
357
|
+
// la porta dichiarava mode=relay e isConnected()=true, e il primo comando
|
|
358
|
+
// moriva 30 s dopo con un messaggio che non nominava la causa.
|
|
359
|
+
this._sendRelayHello(ws);
|
|
360
|
+
|
|
361
|
+
ws.on('message', (raw) => {
|
|
362
|
+
let msg;
|
|
363
|
+
try {
|
|
364
|
+
msg = JSON.parse(raw.toString());
|
|
365
|
+
} catch {
|
|
366
|
+
console.error('[chrome-bridge] Invalid JSON from relay client');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Il relay client invia comandi da inoltrare a Chrome
|
|
371
|
+
if (!this.client || this.client.readyState !== WebSocket.OPEN) {
|
|
372
|
+
ws.send(JSON.stringify({
|
|
373
|
+
id: msg.id,
|
|
374
|
+
type: MessageType.ERROR,
|
|
375
|
+
error: 'Chrome extension not connected',
|
|
376
|
+
}));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Traccia quale relay ha inviato questo comando
|
|
381
|
+
this.pendingRelay.set(msg.id, { ws, ts: Date.now() });
|
|
382
|
+
this.client.send(JSON.stringify(msg));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
ws.on('close', () => {
|
|
386
|
+
console.error('[chrome-bridge] Relay client disconnected');
|
|
387
|
+
this.relayClients.delete(ws);
|
|
388
|
+
// Pulisci pending relay per questo client
|
|
389
|
+
for (const [id, entry] of this.pendingRelay) {
|
|
390
|
+
if (entry.ws === ws) {
|
|
391
|
+
this.pendingRelay.delete(id);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
ws.on('error', (err) => {
|
|
397
|
+
console.error('[chrome-bridge] Relay client error:', err.message);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Gestisce un messaggio dall'estensione Chrome.
|
|
403
|
+
* Smista le risposte: ai pending locali o ai relay client.
|
|
404
|
+
*/
|
|
405
|
+
_handleChromeMessage(msg) {
|
|
406
|
+
if (msg.type === MessageType.PONG) {
|
|
407
|
+
this.lastPong = Date.now();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Risposta per un relay client?
|
|
412
|
+
const entry = this.pendingRelay.get(msg.id);
|
|
413
|
+
if (entry) {
|
|
414
|
+
this.pendingRelay.delete(msg.id);
|
|
415
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
416
|
+
entry.ws.send(JSON.stringify(msg));
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Risposta per un comando locale
|
|
422
|
+
const pending = this.pending.get(msg.id);
|
|
423
|
+
if (!pending) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.pending.delete(msg.id);
|
|
428
|
+
clearTimeout(pending.timer);
|
|
429
|
+
|
|
430
|
+
if (msg.type === MessageType.ERROR) {
|
|
431
|
+
pending.reject(new Error(msg.error || 'Unknown error from extension'));
|
|
432
|
+
} else {
|
|
433
|
+
pending.resolve(msg.data);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Relay mode ────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/** Ack di identificazione verso un relay client + stato corrente dell'estensione. */
|
|
440
|
+
_sendRelayHello(ws) {
|
|
441
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
442
|
+
try {
|
|
443
|
+
ws.send(JSON.stringify({
|
|
444
|
+
type: 'relay_init_ok',
|
|
445
|
+
version: VERSION,
|
|
446
|
+
ext_connected: this.client !== null && this.client.readyState === WebSocket.OPEN,
|
|
447
|
+
ext_version: this._extVersion ?? null,
|
|
448
|
+
}));
|
|
449
|
+
} catch {}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Notifica a tutti i relay che lo stato dell'estensione è cambiato. */
|
|
453
|
+
_broadcastExtState() {
|
|
454
|
+
for (const ws of this.relayClients) this._sendRelayHello(ws);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_startRelay() {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
this.relaySocket = new WebSocket(`ws://127.0.0.1:${this.port}`);
|
|
460
|
+
// Attende l'ack: una porta occupata da un processo che non è
|
|
461
|
+
// chrome-bridge deve fallire subito e con un messaggio azionabile,
|
|
462
|
+
// non costare un timeout di 30 s al primo comando.
|
|
463
|
+
//
|
|
464
|
+
// Il silenzio però è ambiguo: un primary chrome-bridge PRECEDENTE a questa
|
|
465
|
+
// versione non conosce relay_init_ok, e durante un aggiornamento è il caso
|
|
466
|
+
// normale (una sessione vecchia è ancora attiva). Prima di fallire si
|
|
467
|
+
// sonda il peer con un comando reale: un chrome-bridge risponde sempre —
|
|
468
|
+
// col risultato o con un errore "extension not connected" — mentre un
|
|
469
|
+
// server estraneo resta muto.
|
|
470
|
+
let settled = false;
|
|
471
|
+
const finish = (err) => {
|
|
472
|
+
if (settled) return;
|
|
473
|
+
settled = true;
|
|
474
|
+
clearTimeout(ackTimer);
|
|
475
|
+
clearTimeout(this._probeTimer);
|
|
476
|
+
this._relayAck = null;
|
|
477
|
+
this._probeId = null;
|
|
478
|
+
if (!err) return resolve();
|
|
479
|
+
const sock = this.relaySocket;
|
|
480
|
+
this.relaySocket = null;
|
|
481
|
+
try { sock?.terminate(); } catch {}
|
|
482
|
+
reject(err);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const probe = () => {
|
|
486
|
+
const cmd = createCommand(MessageType.GET_TABS);
|
|
487
|
+
this._probeId = cmd.id;
|
|
488
|
+
try {
|
|
489
|
+
this.relaySocket.send(JSON.stringify(cmd));
|
|
490
|
+
} catch {
|
|
491
|
+
finish(new Error(`Port ${this.port} is held by a process that is not chrome-bridge — set CHROME_BRIDGE_PORT to a free port or stop that process`));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this._probeTimer = setTimeout(() => {
|
|
495
|
+
finish(new Error(`Port ${this.port} is held by a process that is not chrome-bridge (no relay_init_ok and no answer to a probe command) — set CHROME_BRIDGE_PORT to a free port or stop that process`));
|
|
496
|
+
}, 1500);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const ackTimer = setTimeout(probe, 2000);
|
|
500
|
+
this._relayAck = () => finish(null);
|
|
501
|
+
this._probeAnswered = () => {
|
|
502
|
+
console.error('[chrome-bridge] Relay peer answered a probe but not relay_init_ok: older chrome-bridge, proceeding');
|
|
503
|
+
finish(null);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
this.relaySocket.on('open', () => {
|
|
507
|
+
// Identifica questa connessione come relay
|
|
508
|
+
const init = { type: MessageType.RELAY_INIT };
|
|
509
|
+
if (this.token) init.token = this.token;
|
|
510
|
+
this.relaySocket.send(JSON.stringify(init));
|
|
511
|
+
console.error(`[chrome-bridge] Connected as relay to existing server on port ${this.port}`);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
this.relaySocket.on('message', (raw) => {
|
|
515
|
+
let msg;
|
|
516
|
+
try {
|
|
517
|
+
msg = JSON.parse(raw.toString());
|
|
518
|
+
} catch {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (msg.type === 'relay_init_ok') {
|
|
523
|
+
this.primaryVersion = msg.version ?? null;
|
|
524
|
+
this.relayExtConnected = msg.ext_connected === true;
|
|
525
|
+
this.relayExtVersion = msg.ext_version ?? null;
|
|
526
|
+
if (this._relayAck) this._relayAck();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Risposta alla sonda: il peer parla il protocollo, è un chrome-bridge
|
|
531
|
+
// più vecchio. relayExtConnected resta undefined (sconosciuto), che
|
|
532
|
+
// isConnected() tratta come "non bloccare".
|
|
533
|
+
if (this._probeId && msg.id === this._probeId) {
|
|
534
|
+
if (this._probeAnswered) this._probeAnswered();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const pending = this.pending.get(msg.id);
|
|
539
|
+
if (!pending) return;
|
|
540
|
+
|
|
541
|
+
this.pending.delete(msg.id);
|
|
542
|
+
clearTimeout(pending.timer);
|
|
543
|
+
|
|
544
|
+
if (msg.type === MessageType.ERROR) {
|
|
545
|
+
pending.reject(new Error(msg.error || 'Unknown error'));
|
|
546
|
+
} else {
|
|
547
|
+
pending.resolve(msg.data);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
this.relaySocket.on('close', () => {
|
|
552
|
+
console.error('[chrome-bridge] Relay connection closed');
|
|
553
|
+
this.relaySocket = null;
|
|
554
|
+
this._rejectAllPending('Relay connection closed');
|
|
555
|
+
this._promoteToPrimary();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
this.relaySocket.on('error', (err) => {
|
|
559
|
+
console.error('[chrome-bridge] Relay connection error:', err.message);
|
|
560
|
+
reject(err);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Promozione: quando il primary muore, il relay tenta di diventare primary.
|
|
567
|
+
* Attende un breve intervallo per dare tempo al vecchio WSS di chiudersi,
|
|
568
|
+
* poi ritenta con backoff se la porta non è ancora libera.
|
|
569
|
+
*/
|
|
570
|
+
async _promoteToPrimary() {
|
|
571
|
+
if (this.stopped) return;
|
|
572
|
+
|
|
573
|
+
const MAX_ATTEMPTS = 5;
|
|
574
|
+
const BASE_DELAY_MS = 500;
|
|
575
|
+
|
|
576
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
577
|
+
const delay = BASE_DELAY_MS * attempt;
|
|
578
|
+
await new Promise(r => setTimeout(r, delay));
|
|
579
|
+
if (this.stopped) return;
|
|
580
|
+
|
|
581
|
+
console.error(`[chrome-bridge] Promotion attempt ${attempt}/${MAX_ATTEMPTS}...`);
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
await this._startPrimary();
|
|
585
|
+
// stop() chiamato mentre la promozione era in volo: smonta ciò che è appena stato creato
|
|
586
|
+
if (this.stopped) {
|
|
587
|
+
if (this.pingTimer) {
|
|
588
|
+
clearInterval(this.pingTimer);
|
|
589
|
+
this.pingTimer = null;
|
|
590
|
+
}
|
|
591
|
+
if (this.wss) {
|
|
592
|
+
this.wss.close();
|
|
593
|
+
this.wss = null;
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
this.mode = 'primary';
|
|
598
|
+
console.error('[chrome-bridge] Promoted to primary successfully');
|
|
599
|
+
return;
|
|
600
|
+
} catch (err) {
|
|
601
|
+
if (err.code === 'EADDRINUSE') {
|
|
602
|
+
// Porta occupata: un altro relay ha vinto, o il vecchio server non ha ancora chiuso
|
|
603
|
+
console.error(`[chrome-bridge] Port still in use (attempt ${attempt})`);
|
|
604
|
+
// All'ultimo tentativo, prova a riconnettersi come relay
|
|
605
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
606
|
+
console.error('[chrome-bridge] Reconnecting as relay to new primary');
|
|
607
|
+
try {
|
|
608
|
+
await this._startRelay();
|
|
609
|
+
this.mode = 'relay';
|
|
610
|
+
return;
|
|
611
|
+
} catch (relayErr) {
|
|
612
|
+
console.error('[chrome-bridge] Failed to reconnect as relay:', relayErr.message);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
console.error('[chrome-bridge] Promotion failed:', err.message);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ─── Shared helpers ────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
_startPing() {
|
|
626
|
+
this.pingTimer = setInterval(() => {
|
|
627
|
+
if (this.client && this.client.readyState === WebSocket.OPEN) {
|
|
628
|
+
// Half-open detection: nessun pong da troppo tempo → terminate
|
|
629
|
+
// (lastPong è sempre inizializzato in _setupChromeClient)
|
|
630
|
+
if (Date.now() - this.lastPong > this.pingIntervalMs * 2 + this.pongGraceMs) {
|
|
631
|
+
console.error('[chrome-bridge] Extension unresponsive (no pong) — terminating connection');
|
|
632
|
+
this.client.terminate();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
this.client.send(JSON.stringify({
|
|
636
|
+
type: MessageType.PING,
|
|
637
|
+
timestamp: Date.now(),
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
// Sweep pendingRelay scaduti
|
|
641
|
+
const cutoff = Date.now() - PENDING_RELAY_TTL_MS;
|
|
642
|
+
for (const [id, entry] of this.pendingRelay) {
|
|
643
|
+
if (entry.ts < cutoff) this.pendingRelay.delete(id);
|
|
644
|
+
}
|
|
645
|
+
}, this.pingIntervalMs);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
_rejectAllPending(reason) {
|
|
649
|
+
for (const [id, entry] of this.pending) {
|
|
650
|
+
clearTimeout(entry.timer);
|
|
651
|
+
entry.reject(new Error(reason));
|
|
652
|
+
}
|
|
653
|
+
this.pending.clear();
|
|
654
|
+
}
|
|
655
|
+
}
|