chrome-ai-bridge 2.3.8 → 2.3.10
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/build/extension/README.md +181 -0
- package/build/extension/background.mjs +1263 -0
- package/build/extension/debug-logger.mjs +148 -0
- package/build/extension/icons/icon-128.png +0 -0
- package/build/extension/icons/icon-16.png +0 -0
- package/build/extension/icons/icon-32.png +0 -0
- package/build/extension/icons/icon-48.png +0 -0
- package/build/extension/icons/icon.svg +19 -0
- package/build/extension/manifest.json +28 -0
- package/build/extension/relay-server.ts +505 -0
- package/build/extension/ui/connect.html +429 -0
- package/build/extension/ui/connect.js +491 -0
- package/build/src/extension/relay-server.js +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DebugLogger - Chrome拡張機能デバッグ用ログ管理クラス
|
|
3
|
+
*
|
|
4
|
+
* カテゴリ:
|
|
5
|
+
* - ws: WebSocket接続関連
|
|
6
|
+
* - cdp: Chrome DevTools Protocol関連
|
|
7
|
+
* - tab: タブ操作関連
|
|
8
|
+
* - relay: リレーサーバー関連
|
|
9
|
+
* - error: エラー
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
class DebugLogger {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.logs = [];
|
|
15
|
+
this.maxLogs = 500;
|
|
16
|
+
this.enabled = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ログエントリを追加
|
|
21
|
+
* @param {string} category - ログカテゴリ ('ws', 'cdp', 'tab', 'relay', 'error')
|
|
22
|
+
* @param {string} message - ログメッセージ
|
|
23
|
+
* @param {any} data - 追加データ(オプション)
|
|
24
|
+
*/
|
|
25
|
+
log(category, message, data = null) {
|
|
26
|
+
if (!this.enabled) return;
|
|
27
|
+
|
|
28
|
+
const entry = {
|
|
29
|
+
ts: new Date().toISOString(),
|
|
30
|
+
category,
|
|
31
|
+
message,
|
|
32
|
+
data: data !== null ? this._safeStringify(data) : null
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
this.logs.push(entry);
|
|
36
|
+
|
|
37
|
+
// 最大件数を超えたら古いログを削除
|
|
38
|
+
if (this.logs.length > this.maxLogs) {
|
|
39
|
+
this.logs.shift();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// コンソールにも出力
|
|
43
|
+
const prefix = `[${category.toUpperCase()}]`;
|
|
44
|
+
if (data !== null) {
|
|
45
|
+
console.log(prefix, message, data);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(prefix, message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* エラーログを追加(ショートカット)
|
|
53
|
+
* @param {string} message - エラーメッセージ
|
|
54
|
+
* @param {any} error - エラーオブジェクト
|
|
55
|
+
*/
|
|
56
|
+
error(message, error = null) {
|
|
57
|
+
const errorData = error instanceof Error
|
|
58
|
+
? { name: error.name, message: error.message, stack: error.stack }
|
|
59
|
+
: error;
|
|
60
|
+
this.log('error', message, errorData);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ログを取得
|
|
65
|
+
* @param {string|null} filter - カテゴリでフィルタ(nullで全件)
|
|
66
|
+
* @param {number} limit - 取得件数(デフォルト: 100)
|
|
67
|
+
* @returns {Array} ログエントリの配列
|
|
68
|
+
*/
|
|
69
|
+
getLogs(filter = null, limit = 100) {
|
|
70
|
+
let result = filter
|
|
71
|
+
? this.logs.filter(l => l.category === filter)
|
|
72
|
+
: this.logs;
|
|
73
|
+
|
|
74
|
+
// 最新のログから返す
|
|
75
|
+
return result.slice(-limit);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ログをクリア
|
|
80
|
+
*/
|
|
81
|
+
clear() {
|
|
82
|
+
this.logs = [];
|
|
83
|
+
console.log('[DEBUG] Logs cleared');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ログを有効/無効にする
|
|
88
|
+
* @param {boolean} enabled
|
|
89
|
+
*/
|
|
90
|
+
setEnabled(enabled) {
|
|
91
|
+
this.enabled = enabled;
|
|
92
|
+
console.log('[DEBUG] Logger', enabled ? 'enabled' : 'disabled');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 安全なJSON変換(循環参照対策)
|
|
97
|
+
* @param {any} obj
|
|
98
|
+
* @returns {any}
|
|
99
|
+
*/
|
|
100
|
+
_safeStringify(obj) {
|
|
101
|
+
if (obj === null || obj === undefined) return obj;
|
|
102
|
+
if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
|
103
|
+
return obj;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const seen = new WeakSet();
|
|
108
|
+
return JSON.parse(JSON.stringify(obj, (key, value) => {
|
|
109
|
+
if (typeof value === 'object' && value !== null) {
|
|
110
|
+
if (seen.has(value)) {
|
|
111
|
+
return '[Circular]';
|
|
112
|
+
}
|
|
113
|
+
seen.add(value);
|
|
114
|
+
}
|
|
115
|
+
// WebSocketなど大きなオブジェクトは省略
|
|
116
|
+
if (value instanceof WebSocket) {
|
|
117
|
+
return `[WebSocket: ${value.readyState}]`;
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return String(obj);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 統計情報を取得
|
|
128
|
+
* @returns {Object}
|
|
129
|
+
*/
|
|
130
|
+
getStats() {
|
|
131
|
+
const stats = {
|
|
132
|
+
total: this.logs.length,
|
|
133
|
+
byCategory: {}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
for (const log of this.logs) {
|
|
137
|
+
stats.byCategory[log.category] = (stats.byCategory[log.category] || 0) + 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return stats;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// シングルトンインスタンスをエクスポート
|
|
145
|
+
export const debugLogger = new DebugLogger();
|
|
146
|
+
|
|
147
|
+
// デフォルトエクスポート
|
|
148
|
+
export default debugLogger;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" style="stop-color:#4285F4"/>
|
|
5
|
+
<stop offset="100%" style="stop-color:#7C3AED"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<!-- Background circle -->
|
|
9
|
+
<circle cx="64" cy="64" r="60" fill="url(#grad)"/>
|
|
10
|
+
<!-- Bridge shape - two nodes connected -->
|
|
11
|
+
<circle cx="36" cy="64" r="16" fill="white" opacity="0.95"/>
|
|
12
|
+
<circle cx="92" cy="64" r="16" fill="white" opacity="0.95"/>
|
|
13
|
+
<!-- Connection line -->
|
|
14
|
+
<rect x="36" y="58" width="56" height="12" fill="white" opacity="0.95" rx="6"/>
|
|
15
|
+
<!-- AI sparkle on left -->
|
|
16
|
+
<path d="M36 52 L38 56 L42 56 L39 59 L40 64 L36 61 L32 64 L33 59 L30 56 L34 56 Z" fill="#4285F4"/>
|
|
17
|
+
<!-- Chrome dot on right -->
|
|
18
|
+
<circle cx="92" cy="64" r="6" fill="#7C3AED"/>
|
|
19
|
+
</svg>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "chrome-ai-bridge Extension",
|
|
4
|
+
"version": "2.0.23",
|
|
5
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqDLiSB+b/gbnQ4zWRP65jnd27KmzpjJyR1JAQIjCD/dNORzTgk6+G0TjYEDZLIDceKHzGJudqnwq4q9g3T1eJ0SECZNXnaoE00WkgXfAUSQn6cmmXR3aQGFky/zbCmxnkRa0vYupxszlhw0yrlSZrIJd/weWF75Byh0zJfZ84kqDDhaj7TlB5laHICnoSLmPTif4mQcUW9oOKmAJPriPw4CWATKZsrQ4X46djxefSmfbqYfb9rttAqJVst40gO0Gsl6GOGxMHMds5Cl9GELc0dI3Gpobw07hQldZb8TeyilI/SnOaeS3HPtrp+KyEgRu8SgRdlrvuq6DeEZsP+kK7wIDAQAB",
|
|
6
|
+
"description": "Bridge between Chrome tabs and chrome-ai-bridge MCP server",
|
|
7
|
+
"permissions": ["debugger", "activeTab", "tabs", "storage", "alarms"],
|
|
8
|
+
"host_permissions": ["<all_urls>"],
|
|
9
|
+
"background": {
|
|
10
|
+
"service_worker": "background.mjs",
|
|
11
|
+
"type": "module"
|
|
12
|
+
},
|
|
13
|
+
"icons": {
|
|
14
|
+
"16": "icons/icon-16.png",
|
|
15
|
+
"32": "icons/icon-32.png",
|
|
16
|
+
"48": "icons/icon-48.png",
|
|
17
|
+
"128": "icons/icon-128.png"
|
|
18
|
+
},
|
|
19
|
+
"action": {
|
|
20
|
+
"default_title": "chrome-ai-bridge",
|
|
21
|
+
"default_icon": {
|
|
22
|
+
"16": "icons/icon-16.png",
|
|
23
|
+
"32": "icons/icon-32.png",
|
|
24
|
+
"48": "icons/icon-48.png"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"options_page": "ui/connect.html"
|
|
28
|
+
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelayServer - WebSocket server for Extension communication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
|
|
11
|
+
// デバッグログをファイルに出力
|
|
12
|
+
const DEBUG_LOG_PATH = '/tmp/relay-server-debug.log';
|
|
13
|
+
function debugLog(...args: any[]) {
|
|
14
|
+
const timestamp = new Date().toISOString();
|
|
15
|
+
const message = `[${timestamp}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}\n`;
|
|
16
|
+
fs.appendFileSync(DEBUG_LOG_PATH, message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RelayServerOptions {
|
|
20
|
+
port?: number; // 0 for auto-assign
|
|
21
|
+
host?: string;
|
|
22
|
+
token?: string; // Authentication token
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CDPCommand {
|
|
27
|
+
id: number;
|
|
28
|
+
method: string;
|
|
29
|
+
params?: any;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CDPEvent {
|
|
33
|
+
method: string;
|
|
34
|
+
params?: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class RelayServer extends EventEmitter {
|
|
38
|
+
private wss: WebSocketServer | null = null;
|
|
39
|
+
private ws: WebSocket | null = null; // Single connection (1 tab per server)
|
|
40
|
+
private port: number = 0;
|
|
41
|
+
private host: string;
|
|
42
|
+
private token: string;
|
|
43
|
+
private sessionId: string;
|
|
44
|
+
private instanceId: string;
|
|
45
|
+
private startedAt: number;
|
|
46
|
+
private tabId: number | null = null;
|
|
47
|
+
private ready: boolean = false;
|
|
48
|
+
private nextId = 1;
|
|
49
|
+
private pending = new Map<number, {
|
|
50
|
+
resolve: (value: any) => void;
|
|
51
|
+
reject: (err: Error) => void;
|
|
52
|
+
method: string;
|
|
53
|
+
startedAt: number;
|
|
54
|
+
}>();
|
|
55
|
+
private discoveryServer: http.Server | null = null;
|
|
56
|
+
private discoveryPort: number | null = null;
|
|
57
|
+
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(options: RelayServerOptions = {}) {
|
|
60
|
+
super();
|
|
61
|
+
this.host = options.host || '127.0.0.1';
|
|
62
|
+
this.token = options.token || this.generateToken();
|
|
63
|
+
this.sessionId = options.sessionId || this.generateSessionId();
|
|
64
|
+
this.instanceId = crypto.randomUUID();
|
|
65
|
+
this.startedAt = Date.now();
|
|
66
|
+
this.port = options.port || 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start WebSocket server
|
|
71
|
+
*/
|
|
72
|
+
async start(): Promise<number> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
this.wss = new WebSocketServer({
|
|
75
|
+
host: this.host,
|
|
76
|
+
port: this.port
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.wss.on('listening', () => {
|
|
80
|
+
const address = this.wss!.address() as WebSocket.AddressInfo;
|
|
81
|
+
this.port = address.port;
|
|
82
|
+
debugLog(`[RelayServer] Listening on ws://${this.host}:${this.port}`);
|
|
83
|
+
resolve(this.port);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.wss.on('error', (error) => {
|
|
87
|
+
debugLog('[RelayServer] Server error:', error);
|
|
88
|
+
reject(error);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.wss.on('connection', (ws, req) => {
|
|
92
|
+
this.handleConnection(ws, req);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Handle WebSocket connection from Extension
|
|
99
|
+
*/
|
|
100
|
+
private handleConnection(ws: WebSocket, req: any) {
|
|
101
|
+
debugLog('[RelayServer] New connection from Extension');
|
|
102
|
+
|
|
103
|
+
// Validate token
|
|
104
|
+
const url = new URL(req.url || '', `ws://${this.host}`);
|
|
105
|
+
const clientToken = url.searchParams.get('token');
|
|
106
|
+
const clientSessionId = url.searchParams.get('sid');
|
|
107
|
+
|
|
108
|
+
if (clientToken !== this.token) {
|
|
109
|
+
debugLog('[RelayServer] Invalid token');
|
|
110
|
+
ws.close(1008, 'Invalid token');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (clientSessionId && clientSessionId !== this.sessionId) {
|
|
114
|
+
debugLog('[RelayServer] Invalid session id', {expected: this.sessionId, received: clientSessionId});
|
|
115
|
+
ws.close(1008, 'Invalid session id');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only allow one connection
|
|
120
|
+
if (this.ws) {
|
|
121
|
+
debugLog('[RelayServer] Connection already exists');
|
|
122
|
+
ws.close(1008, 'Connection already exists');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.ws = ws;
|
|
127
|
+
this.startKeepAlive();
|
|
128
|
+
|
|
129
|
+
ws.on('message', (data) => {
|
|
130
|
+
this.handleMessage(data.toString());
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Guard: only update state if this socket is still the current one.
|
|
134
|
+
// Prevents a stale socket's close event from corrupting a newer connection.
|
|
135
|
+
ws.on('close', () => {
|
|
136
|
+
if (this.ws !== ws) {
|
|
137
|
+
debugLog('[RelayServer] Stale socket closed (ignored — already replaced)');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
debugLog('[RelayServer] Extension disconnected');
|
|
141
|
+
this.stopKeepAlive();
|
|
142
|
+
this.rejectPendingRequests(
|
|
143
|
+
new Error('RELAY_DISCONNECTED: Extension socket closed before request completion'),
|
|
144
|
+
);
|
|
145
|
+
this.ws = null;
|
|
146
|
+
this.ready = false;
|
|
147
|
+
this.emit('disconnected');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ws.on('error', (error) => {
|
|
151
|
+
debugLog('[RelayServer] WebSocket error:', error);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
debugLog('[RelayServer] Extension connected');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private rejectPendingRequests(error: Error): void {
|
|
158
|
+
if (this.pending.size === 0) return;
|
|
159
|
+
const pendingEntries = Array.from(this.pending.entries());
|
|
160
|
+
this.pending.clear();
|
|
161
|
+
for (const [id, pending] of pendingEntries) {
|
|
162
|
+
debugLog('[RelayServer] Rejecting pending request', {
|
|
163
|
+
id,
|
|
164
|
+
method: pending.method,
|
|
165
|
+
startedAt: pending.startedAt,
|
|
166
|
+
reason: error.message,
|
|
167
|
+
});
|
|
168
|
+
pending.reject(error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Handle message from Extension
|
|
174
|
+
*/
|
|
175
|
+
private handleMessage(data: string) {
|
|
176
|
+
try {
|
|
177
|
+
const message = JSON.parse(data);
|
|
178
|
+
|
|
179
|
+
if (typeof message.id === 'number' && (message.result !== undefined || message.error !== undefined)) {
|
|
180
|
+
const pending = this.pending.get(message.id);
|
|
181
|
+
if (pending) {
|
|
182
|
+
this.pending.delete(message.id);
|
|
183
|
+
if (message.error) {
|
|
184
|
+
const error =
|
|
185
|
+
typeof message.error === 'string'
|
|
186
|
+
? new Error(message.error)
|
|
187
|
+
: new Error(message.error.message || 'Unknown error');
|
|
188
|
+
pending.reject(error);
|
|
189
|
+
} else {
|
|
190
|
+
pending.resolve(message.result);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (message.error) {
|
|
196
|
+
const error =
|
|
197
|
+
typeof message.error === 'string'
|
|
198
|
+
? message.error
|
|
199
|
+
: message.error.message || 'Unknown error';
|
|
200
|
+
this.emit('cdp-error', { id: message.id, error });
|
|
201
|
+
} else {
|
|
202
|
+
this.emit('cdp-result', { id: message.id, result: message.result });
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (message?.method === 'forwardCDPEvent' && message.params) {
|
|
208
|
+
this.emit('cdp-event', {
|
|
209
|
+
method: message.params.method,
|
|
210
|
+
params: message.params.params,
|
|
211
|
+
sessionId: message.params.sessionId,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
switch (message.type) {
|
|
217
|
+
case 'ready':
|
|
218
|
+
this.tabId = message.tabId;
|
|
219
|
+
this.ready = true;
|
|
220
|
+
debugLog(`[RelayServer] Connection ready for tab ${this.tabId}`);
|
|
221
|
+
this.emit('ready', this.tabId);
|
|
222
|
+
break;
|
|
223
|
+
case 'pong':
|
|
224
|
+
debugLog('[RelayServer] Received keep-alive pong');
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case 'forwardCDPResult':
|
|
228
|
+
this.emit('cdp-result', { id: message.id, result: message.result });
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case 'forwardCDPError':
|
|
232
|
+
this.emit('cdp-error', { id: message.id, error: message.error });
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'forwardCDPEvent':
|
|
236
|
+
this.emit('cdp-event', {
|
|
237
|
+
method: message.method,
|
|
238
|
+
params: message.params
|
|
239
|
+
});
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case 'detached':
|
|
243
|
+
debugLog(`[RelayServer] Tab ${message.tabId} detached: ${message.reason}`);
|
|
244
|
+
this.emit('detached', message.reason);
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
default:
|
|
248
|
+
debugLog('[RelayServer] Unknown message type:', message.type);
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
debugLog('[RelayServer] Failed to parse message:', error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Send CDP command to Extension
|
|
257
|
+
*/
|
|
258
|
+
sendCDPCommand(id: number, method: string, params?: any): void {
|
|
259
|
+
if (!this.ws || !this.ready) {
|
|
260
|
+
throw new Error('Extension not connected or not ready');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.ws.send(JSON.stringify({
|
|
264
|
+
type: 'forwardCDPCommand',
|
|
265
|
+
id,
|
|
266
|
+
method,
|
|
267
|
+
params
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
sendMessage(message: any): void {
|
|
272
|
+
if (!this.ws || !this.ready) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Extension not connected or not ready (connected=${Boolean(this.ws)}, ready=${this.ready})`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
278
|
+
throw new Error('WebSocket not open');
|
|
279
|
+
}
|
|
280
|
+
this.ws.send(JSON.stringify(message));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async sendRequest(method: string, params?: any, timeoutMs = 30000): Promise<any> {
|
|
284
|
+
if (!this.ws || !this.ready) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Extension not connected or not ready (method=${method}, connected=${Boolean(this.ws)}, ready=${this.ready})`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
290
|
+
throw new Error('WebSocket not open');
|
|
291
|
+
}
|
|
292
|
+
const id = this.nextId++;
|
|
293
|
+
const payload = {id, method, params};
|
|
294
|
+
const startedAt = Date.now();
|
|
295
|
+
const response = new Promise<any>((resolve, reject) => {
|
|
296
|
+
const timeoutId = setTimeout(() => {
|
|
297
|
+
this.pending.delete(id);
|
|
298
|
+
reject(new Error(`RELAY_REQUEST_TIMEOUT: method=${method} timeoutMs=${timeoutMs}`));
|
|
299
|
+
}, timeoutMs);
|
|
300
|
+
timeoutId.unref();
|
|
301
|
+
this.pending.set(id, {
|
|
302
|
+
resolve: (value: any) => {
|
|
303
|
+
clearTimeout(timeoutId);
|
|
304
|
+
debugLog(`[RelayServer] Request success: ${method}`, {id, elapsedMs: Date.now() - startedAt});
|
|
305
|
+
resolve(value);
|
|
306
|
+
},
|
|
307
|
+
reject: (err: Error) => {
|
|
308
|
+
clearTimeout(timeoutId);
|
|
309
|
+
debugLog(`[RelayServer] Request failed: ${method}`, {id, elapsedMs: Date.now() - startedAt, error: err.message});
|
|
310
|
+
reject(err);
|
|
311
|
+
},
|
|
312
|
+
method,
|
|
313
|
+
startedAt,
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
try {
|
|
317
|
+
this.ws.send(JSON.stringify(payload));
|
|
318
|
+
debugLog(`[RelayServer] Request sent: ${method}`, {id});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.pending.delete(id);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
return response;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Start simple discovery HTTP server for extension to find relay URL.
|
|
328
|
+
* Extension polls this endpoint when user clicks the extension icon.
|
|
329
|
+
*/
|
|
330
|
+
async startDiscoveryServer(options: {
|
|
331
|
+
tabUrl?: string;
|
|
332
|
+
tabId?: number;
|
|
333
|
+
newTab?: boolean;
|
|
334
|
+
allowTabTakeover?: boolean;
|
|
335
|
+
} = {}): Promise<number | null> {
|
|
336
|
+
const ports = [38765, 38766, 38767, 38768, 38769, 38770, 38771, 38772, 38773, 38774, 38775];
|
|
337
|
+
const wsUrl = this.getConnectionURL();
|
|
338
|
+
|
|
339
|
+
for (const port of ports) {
|
|
340
|
+
const started = await new Promise<boolean>((resolve) => {
|
|
341
|
+
const server = http.createServer(async (req, res) => {
|
|
342
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
343
|
+
|
|
344
|
+
if (req.method === 'GET' && req.url === '/relay-info') {
|
|
345
|
+
res.setHeader('Content-Type', 'application/json');
|
|
346
|
+
res.end(JSON.stringify({
|
|
347
|
+
wsUrl,
|
|
348
|
+
tabUrl: options.tabUrl || null,
|
|
349
|
+
tabId: options.tabId ?? null,
|
|
350
|
+
newTab: Boolean(options.newTab),
|
|
351
|
+
allowTabTakeover: Boolean(options.allowTabTakeover),
|
|
352
|
+
sessionId: this.sessionId,
|
|
353
|
+
startedAt: this.startedAt,
|
|
354
|
+
instanceId: this.instanceId,
|
|
355
|
+
expiresAt: Date.now() + 60000,
|
|
356
|
+
}));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (req.method === 'POST' && req.url === '/reload-extension') {
|
|
361
|
+
res.setHeader('Content-Type', 'application/json');
|
|
362
|
+
if (!this.ws || !this.ready) {
|
|
363
|
+
res.statusCode = 503;
|
|
364
|
+
res.end(JSON.stringify({ error: 'Extension not connected' }));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
await this.sendRequest('reloadExtension');
|
|
369
|
+
res.end(JSON.stringify({ success: true }));
|
|
370
|
+
} catch (err: any) {
|
|
371
|
+
// Extension reloads and drops connection - this is expected
|
|
372
|
+
res.end(JSON.stringify({ success: true, note: 'Extension reloading' }));
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
res.statusCode = 404;
|
|
378
|
+
res.end('Not Found');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
server.on('error', (error: any) => {
|
|
382
|
+
if (error?.code === 'EADDRINUSE') {
|
|
383
|
+
resolve(false);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
debugLog('[RelayServer] Discovery server error:', error);
|
|
387
|
+
resolve(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
server.listen(port, this.host, () => {
|
|
391
|
+
this.discoveryServer = server;
|
|
392
|
+
this.discoveryPort = port;
|
|
393
|
+
debugLog(`[RelayServer] Discovery available on http://${this.host}:${port}/relay-info`);
|
|
394
|
+
resolve(true);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (started) {
|
|
399
|
+
return port;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
debugLog('[RelayServer] Could not start discovery server on any port');
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Stop server
|
|
409
|
+
*/
|
|
410
|
+
async stop(): Promise<void> {
|
|
411
|
+
this.stopKeepAlive();
|
|
412
|
+
|
|
413
|
+
if (this.ws) {
|
|
414
|
+
try {
|
|
415
|
+
this.ws.close();
|
|
416
|
+
} catch {
|
|
417
|
+
// ignore close errors
|
|
418
|
+
}
|
|
419
|
+
this.ws = null;
|
|
420
|
+
}
|
|
421
|
+
this.ready = false;
|
|
422
|
+
this.tabId = null;
|
|
423
|
+
|
|
424
|
+
this.rejectPendingRequests(
|
|
425
|
+
new Error('RELAY_STOPPED: Relay stopped before request completion'),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (this.discoveryServer) {
|
|
429
|
+
this.discoveryServer.close();
|
|
430
|
+
this.discoveryServer = null;
|
|
431
|
+
this.discoveryPort = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (this.wss) {
|
|
435
|
+
return new Promise((resolve) => {
|
|
436
|
+
this.wss!.close(() => {
|
|
437
|
+
this.wss = null;
|
|
438
|
+
debugLog('[RelayServer] Server stopped');
|
|
439
|
+
resolve();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Start keep-alive ping to prevent Service Worker from sleeping
|
|
447
|
+
*/
|
|
448
|
+
private startKeepAlive(): void {
|
|
449
|
+
this.stopKeepAlive();
|
|
450
|
+
this.keepAliveTimer = setInterval(() => {
|
|
451
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
452
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
453
|
+
debugLog('[RelayServer] Sent keep-alive ping');
|
|
454
|
+
}
|
|
455
|
+
}, 30000); // 30 seconds
|
|
456
|
+
debugLog('[RelayServer] Keep-alive started');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Stop keep-alive ping
|
|
461
|
+
*/
|
|
462
|
+
private stopKeepAlive(): void {
|
|
463
|
+
if (this.keepAliveTimer) {
|
|
464
|
+
clearInterval(this.keepAliveTimer);
|
|
465
|
+
this.keepAliveTimer = null;
|
|
466
|
+
debugLog('[RelayServer] Keep-alive stopped');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Generate random token
|
|
472
|
+
*/
|
|
473
|
+
private generateToken(): string {
|
|
474
|
+
return crypto.randomBytes(32).toString('hex');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
getPort(): number {
|
|
478
|
+
return this.port;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
getToken(): string {
|
|
482
|
+
return this.token;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
getTabId(): number | null {
|
|
486
|
+
return this.tabId;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
isReady(): boolean {
|
|
490
|
+
return this.ready;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
getConnectionURL(): string {
|
|
495
|
+
return `ws://${this.host}:${this.port}?token=${this.token}&sid=${encodeURIComponent(this.sessionId)}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
getSessionId(): string {
|
|
499
|
+
return this.sessionId;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private generateSessionId(): string {
|
|
503
|
+
return crypto.randomBytes(16).toString('hex');
|
|
504
|
+
}
|
|
505
|
+
}
|