ghost-bridge 0.8.0 → 0.9.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/README.md +15 -1
- package/dist/server.js +158 -38
- package/extension/background.js +529 -216
- package/extension/manifest.json +3 -2
- package/extension/offscreen.js +149 -10
- package/package.json +4 -4
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Ghost Bridge",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Zero-restart Chrome debugger bridge for Claude MCP, optimized for no-sourcemap production debugging.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"scripting",
|
|
10
10
|
"storage",
|
|
11
11
|
"tabs",
|
|
12
|
-
"offscreen"
|
|
12
|
+
"offscreen",
|
|
13
|
+
"idle"
|
|
13
14
|
],
|
|
14
15
|
"host_permissions": [
|
|
15
16
|
"ws://localhost/*",
|
package/extension/offscreen.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
let ws = null
|
|
5
5
|
let reconnectTimer = null
|
|
6
|
+
let heartbeatTimer = null
|
|
6
7
|
let manualDisconnect = false // 用户主动断开标志,防止 onclose 触发重连
|
|
7
8
|
let connectionGeneration = 0
|
|
8
9
|
let config = {
|
|
@@ -10,14 +11,16 @@ let config = {
|
|
|
10
11
|
token: '',
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 15000
|
|
15
|
+
const HEARTBEAT_TIMEOUT_MS = 45000
|
|
16
|
+
const DEFAULT_TOKEN = 'ghost-bridge-local'
|
|
17
|
+
|
|
13
18
|
function log(msg) {
|
|
14
19
|
console.log(`[ghost-bridge offscreen] ${msg}`)
|
|
15
20
|
// 转发日志到 service worker
|
|
16
21
|
chrome.runtime.sendMessage({ type: 'log', msg }).catch(() => {})
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
const DEFAULT_TOKEN = 'ghost-bridge-local'
|
|
20
|
-
|
|
21
24
|
function clearReconnectTimer() {
|
|
22
25
|
if (reconnectTimer) {
|
|
23
26
|
clearTimeout(reconnectTimer)
|
|
@@ -25,6 +28,13 @@ function clearReconnectTimer() {
|
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function clearHeartbeatTimer() {
|
|
32
|
+
if (heartbeatTimer) {
|
|
33
|
+
clearInterval(heartbeatTimer)
|
|
34
|
+
heartbeatTimer = null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
function isCurrentConnection(generation, socket) {
|
|
29
39
|
return generation === connectionGeneration && socket === ws
|
|
30
40
|
}
|
|
@@ -33,11 +43,13 @@ function scheduleReconnect(generation, delay) {
|
|
|
33
43
|
if (generation !== connectionGeneration) return
|
|
34
44
|
clearReconnectTimer()
|
|
35
45
|
reconnectTimer = setTimeout(() => {
|
|
36
|
-
|
|
46
|
+
reconnectTimer = null
|
|
47
|
+
if (!manualDisconnect && generation === connectionGeneration) connect()
|
|
37
48
|
}, delay)
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
function closeCurrentSocket() {
|
|
52
|
+
clearHeartbeatTimer()
|
|
41
53
|
if (!ws) return
|
|
42
54
|
try {
|
|
43
55
|
ws.close()
|
|
@@ -45,6 +57,50 @@ function closeCurrentSocket() {
|
|
|
45
57
|
ws = null
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
function describeCloseEvent(event) {
|
|
61
|
+
const reason = event.reason ? ` reason="${event.reason}"` : ''
|
|
62
|
+
return `code=${event.code}${reason} clean=${event.wasClean}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function describeReadyState(socket) {
|
|
66
|
+
const states = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']
|
|
67
|
+
return states[socket.readyState] || String(socket.readyState)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startHeartbeat(generation, socket, port, requireAck) {
|
|
71
|
+
clearHeartbeatTimer()
|
|
72
|
+
let lastAckAt = Date.now()
|
|
73
|
+
|
|
74
|
+
heartbeatTimer = setInterval(() => {
|
|
75
|
+
if (!isCurrentConnection(generation, socket) || socket.readyState !== WebSocket.OPEN) {
|
|
76
|
+
clearHeartbeatTimer()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const now = Date.now()
|
|
81
|
+
if (requireAck && now - lastAckAt > HEARTBEAT_TIMEOUT_MS) {
|
|
82
|
+
log(`端口 ${port} 心跳超时,关闭连接后重连...`)
|
|
83
|
+
try {
|
|
84
|
+
socket.close(4000, 'Heartbeat timeout')
|
|
85
|
+
} catch {}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
socket.send(JSON.stringify({ type: 'heartbeat', token: config.token, ts: now }))
|
|
91
|
+
} catch (e) {
|
|
92
|
+
log(`端口 ${port} 心跳发送失败:${e.message}`)
|
|
93
|
+
try {
|
|
94
|
+
socket.close(4001, 'Heartbeat send failed')
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
lastAckAt = Date.now()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
48
104
|
// 连接到服务器
|
|
49
105
|
function connect() {
|
|
50
106
|
// 如果已手动断开,不再尝试连接
|
|
@@ -76,6 +132,7 @@ function connect() {
|
|
|
76
132
|
let identityVerified = false
|
|
77
133
|
let socketOpened = false
|
|
78
134
|
let terminalErrorMessage = ''
|
|
135
|
+
let markHeartbeatAck = null
|
|
79
136
|
|
|
80
137
|
socket.onopen = () => {
|
|
81
138
|
if (!isCurrentConnection(generation, socket)) return
|
|
@@ -98,6 +155,12 @@ function connect() {
|
|
|
98
155
|
if (msg.type === 'identity') {
|
|
99
156
|
if (msg.service === 'ghost-bridge' && msg.token === config.token) {
|
|
100
157
|
identityVerified = true
|
|
158
|
+
markHeartbeatAck = startHeartbeat(
|
|
159
|
+
generation,
|
|
160
|
+
socket,
|
|
161
|
+
port,
|
|
162
|
+
Array.isArray(msg.capabilities) && msg.capabilities.includes('heartbeat')
|
|
163
|
+
)
|
|
101
164
|
log(`✅ 已连接到 ghost-bridge 服务 (端口 ${port})`)
|
|
102
165
|
chrome.runtime.sendMessage({
|
|
103
166
|
type: 'status',
|
|
@@ -122,11 +185,17 @@ function connect() {
|
|
|
122
185
|
currentPort: port,
|
|
123
186
|
errorMessage: terminalErrorMessage,
|
|
124
187
|
}).catch(() => {})
|
|
125
|
-
|
|
188
|
+
socket.close()
|
|
126
189
|
}
|
|
127
190
|
return
|
|
128
191
|
}
|
|
129
192
|
|
|
193
|
+
if (msg.type === 'heartbeat_ack') {
|
|
194
|
+
if (markHeartbeatAck) markHeartbeatAck()
|
|
195
|
+
if (pendingHealthCheck) resolveHealthCheck(true)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
130
199
|
// 转发命令到 service worker
|
|
131
200
|
if (identityVerified && msg.id) {
|
|
132
201
|
chrome.runtime.sendMessage({ type: 'command', data: msg }).catch(() => {})
|
|
@@ -139,14 +208,20 @@ function connect() {
|
|
|
139
208
|
socket.onclose = (event) => {
|
|
140
209
|
if (!isCurrentConnection(generation, socket)) return
|
|
141
210
|
clearTimeout(connectionTimeout)
|
|
211
|
+
clearHeartbeatTimer()
|
|
212
|
+
ws = null
|
|
213
|
+
const closeInfo = describeCloseEvent(event)
|
|
142
214
|
|
|
143
215
|
// 用户主动断开,不重连
|
|
144
|
-
if (manualDisconnect)
|
|
216
|
+
if (manualDisconnect) {
|
|
217
|
+
log(`连接已按用户请求关闭 (${closeInfo})`)
|
|
218
|
+
return
|
|
219
|
+
}
|
|
145
220
|
|
|
146
221
|
if (!identityVerified) {
|
|
147
222
|
if (terminalErrorMessage || socketOpened) {
|
|
148
223
|
const errorMessage = terminalErrorMessage || `Port ${port} is occupied or responding with a non-ghost-bridge protocol.`
|
|
149
|
-
log(`${errorMessage} 2秒后重试...`)
|
|
224
|
+
log(`${errorMessage} (${closeInfo}) 2秒后重试...`)
|
|
150
225
|
chrome.runtime.sendMessage({
|
|
151
226
|
type: 'status',
|
|
152
227
|
status: 'error',
|
|
@@ -168,26 +243,79 @@ function connect() {
|
|
|
168
243
|
}
|
|
169
244
|
|
|
170
245
|
// 连接断开,重试
|
|
171
|
-
log(`端口 ${port}
|
|
172
|
-
chrome.runtime.sendMessage({
|
|
246
|
+
log(`端口 ${port} 连接断开 (${closeInfo}),尝试重连...`)
|
|
247
|
+
chrome.runtime.sendMessage({
|
|
248
|
+
type: 'status',
|
|
249
|
+
status: 'disconnected',
|
|
250
|
+
currentPort: port,
|
|
251
|
+
errorMessage: `Connection closed: ${closeInfo}`,
|
|
252
|
+
}).catch(() => {})
|
|
173
253
|
scheduleReconnect(generation, 1000)
|
|
174
254
|
}
|
|
175
255
|
|
|
176
256
|
socket.onerror = () => {
|
|
177
257
|
if (!isCurrentConnection(generation, socket)) return
|
|
178
258
|
clearTimeout(connectionTimeout)
|
|
259
|
+
log(`端口 ${port} WebSocket 错误,readyState=${describeReadyState(socket)}`)
|
|
179
260
|
}
|
|
180
261
|
}
|
|
181
262
|
|
|
182
263
|
// 发送消息到服务器
|
|
183
264
|
function sendToServer(data) {
|
|
184
265
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
185
|
-
|
|
186
|
-
|
|
266
|
+
try {
|
|
267
|
+
ws.send(JSON.stringify(data))
|
|
268
|
+
return true
|
|
269
|
+
} catch (e) {
|
|
270
|
+
log(`发送消息到服务器失败:${e.message}`)
|
|
271
|
+
}
|
|
187
272
|
}
|
|
188
273
|
return false
|
|
189
274
|
}
|
|
190
275
|
|
|
276
|
+
// ========== 唤醒探活 ==========
|
|
277
|
+
// 系统睡眠/锁屏唤醒后连接可能处于半开状态且 onclose 不会触发,
|
|
278
|
+
// 由 background 的 idle 钩子通知这里立即发一次心跳,短超时内无响应就主动重连
|
|
279
|
+
let pendingHealthCheck = null
|
|
280
|
+
|
|
281
|
+
function resolveHealthCheck(alive) {
|
|
282
|
+
if (!pendingHealthCheck) return
|
|
283
|
+
clearTimeout(pendingHealthCheck.timer)
|
|
284
|
+
const resolve = pendingHealthCheck.resolve
|
|
285
|
+
pendingHealthCheck = null
|
|
286
|
+
resolve(alive)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function requestHealthCheck() {
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
if (manualDisconnect) return resolve(false)
|
|
292
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
293
|
+
log('唤醒探活:连接未打开,立即重连')
|
|
294
|
+
connect()
|
|
295
|
+
return resolve(false)
|
|
296
|
+
}
|
|
297
|
+
// 收敛上一次未完成的探活
|
|
298
|
+
if (pendingHealthCheck) resolveHealthCheck(false)
|
|
299
|
+
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
log('唤醒探活:心跳无响应,判定为死连接,关闭后重连')
|
|
302
|
+
resolveHealthCheck(false)
|
|
303
|
+
try {
|
|
304
|
+
ws.close(4002, 'Health check timeout')
|
|
305
|
+
} catch {}
|
|
306
|
+
}, 5000)
|
|
307
|
+
pendingHealthCheck = { timer, resolve }
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
ws.send(JSON.stringify({ type: 'heartbeat', token: config.token, ts: Date.now() }))
|
|
311
|
+
} catch (e) {
|
|
312
|
+
log(`唤醒探活:心跳发送失败 (${e.message}),立即重连`)
|
|
313
|
+
resolveHealthCheck(false)
|
|
314
|
+
connect()
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
191
319
|
// 断开连接
|
|
192
320
|
function disconnect() {
|
|
193
321
|
manualDisconnect = true // 标记为手动断开,阻止 onclose 重连
|
|
@@ -223,6 +351,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
223
351
|
if (message.type === 'getOffscreenStatus') {
|
|
224
352
|
sendResponse({
|
|
225
353
|
connected: ws && ws.readyState === WebSocket.OPEN,
|
|
354
|
+
readyState: ws ? describeReadyState(ws) : 'CLOSED',
|
|
355
|
+
port: config.basePort,
|
|
356
|
+
})
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 唤醒钩子触发的立即探活(background 的 chrome.idle 监听转发)
|
|
361
|
+
if (message.type === 'healthCheck') {
|
|
362
|
+
requestHealthCheck().then((alive) => {
|
|
363
|
+
if (!alive) log('唤醒探活完成:连接已重建/重连中')
|
|
364
|
+
sendResponse({ alive })
|
|
226
365
|
})
|
|
227
366
|
return true
|
|
228
367
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghost-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
"author": "<Author>",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "https://github.com
|
|
20
|
+
"url": "https://github.com/learnStop163/ghost-bridge.git"
|
|
21
21
|
},
|
|
22
22
|
"bugs": {
|
|
23
|
-
"url": "https://github.com
|
|
23
|
+
"url": "https://github.com/learnStop163/ghost-bridge/issues"
|
|
24
24
|
},
|
|
25
|
-
"homepage": "https://github.com
|
|
25
|
+
"homepage": "https://github.com/learnStop163/ghost-bridge#readme",
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=18.0.0"
|