poke-browser 0.2.8

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.
Binary file
@@ -0,0 +1,48 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "poke-browser",
4
+ "version": "0.2.8",
5
+ "description": "Browser automation bridge for MCP agents via WebSocket.",
6
+ "permissions": [
7
+ "tabs",
8
+ "activeTab",
9
+ "scripting",
10
+ "debugger",
11
+ "storage",
12
+ "cookies",
13
+ "alarms",
14
+ "offscreen"
15
+ ],
16
+ "host_permissions": [
17
+ "<all_urls>"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js"
21
+ },
22
+ "action": {
23
+ "default_title": "poke-browser",
24
+ "default_popup": "popup.html",
25
+ "default_icon": {
26
+ "16": "icon.png",
27
+ "48": "icon.png",
28
+ "128": "icon.png"
29
+ }
30
+ },
31
+ "content_scripts": [
32
+ {
33
+ "matches": [
34
+ "<all_urls>"
35
+ ],
36
+ "js": [
37
+ "content.js"
38
+ ],
39
+ "run_at": "document_idle",
40
+ "all_frames": false
41
+ }
42
+ ],
43
+ "icons": {
44
+ "16": "icon.png",
45
+ "48": "icon.png",
46
+ "128": "icon.png"
47
+ }
48
+ }
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>poke-browser MCP bridge</title>
6
+ </head>
7
+ <body>
8
+ <script src="offscreen.js"></script>
9
+ </body>
10
+ </html>
@@ -0,0 +1,246 @@
1
+ /**
2
+ * MV3 offscreen document: holds the MCP WebSocket so the connection survives
3
+ * service worker suspension (service workers cannot keep long-lived sockets).
4
+ *
5
+ * MCP outbound frames use `chrome.runtime.connect` + `port.postMessage` (handleFromBg listens for
6
+ * `ws_send`). There is no `chrome.runtime.onMessage` path for those — the long-lived Port is the
7
+ * supported bridge when the service worker may suspend.
8
+ */
9
+
10
+ /** Offscreen documents cannot use chrome.storage; port / ws URL come from the document URL (set by background). */
11
+ const params = new URLSearchParams(location.search);
12
+ let mcpPort = Number.parseInt(params.get("port") ?? "9009", 10);
13
+ if (!Number.isFinite(mcpPort) || mcpPort <= 0 || mcpPort > 65535) {
14
+ mcpPort = 9009;
15
+ }
16
+
17
+ /** When set, used as the full WebSocket URL; otherwise `ws://127.0.0.1:${mcpPort}` is used. */
18
+ let mcpWsUrl = /** @type {string | null} */ (null);
19
+ const wsFromQuery = params.get("wsUrl");
20
+ if (wsFromQuery && wsFromQuery.trim()) {
21
+ mcpWsUrl = wsFromQuery.trim();
22
+ }
23
+
24
+ const WS_INITIAL_RETRY_MS = 1000;
25
+ const WS_MAX_RETRY_MS = 30000;
26
+ const WS_MAX_RETRIES = 20;
27
+
28
+ /** @type {WebSocket | null} */
29
+ let socket = null;
30
+ /** When true, the next socket `close` does not schedule reconnect (internal reconnect path). */
31
+ let suppressReconnectOnce = false;
32
+ /** @type {ReturnType<typeof setTimeout> | null} */
33
+ let reconnectTimer = null;
34
+ let wsRetryDelayMs = WS_INITIAL_RETRY_MS;
35
+ let wsReconnectCycles = 0;
36
+
37
+ /** @type {chrome.runtime.Port | null} */
38
+ let bgPort = null;
39
+
40
+ function postToBg(msg) {
41
+ try {
42
+ if (!chrome.runtime?.id) return;
43
+ bgPort?.postMessage(msg);
44
+ } catch {
45
+ /* extension context invalidated */
46
+ }
47
+ }
48
+
49
+ function notifyStatus(status) {
50
+ postToBg({ type: "ws_status", status });
51
+ }
52
+
53
+ function notifyLog(direction, summary) {
54
+ postToBg({ type: "ws_log", direction, summary });
55
+ }
56
+
57
+ function clearReconnectTimer() {
58
+ if (reconnectTimer) {
59
+ clearTimeout(reconnectTimer);
60
+ reconnectTimer = null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {number} [closeCode]
66
+ */
67
+ function scheduleReconnectAfterClose(closeCode = 0) {
68
+ clearReconnectTimer();
69
+ if (wsReconnectCycles >= WS_MAX_RETRIES) {
70
+ console.error("[poke-browser offscreen] max WebSocket reconnect attempts reached");
71
+ notifyLog("out", `WebSocket: gave up after ${WS_MAX_RETRIES} failed reconnects`);
72
+ postToBg({ type: "ws_disconnected", code: 0 });
73
+ return;
74
+ }
75
+ let delay = wsRetryDelayMs;
76
+ if (closeCode === 4000) {
77
+ delay = Math.max(delay, 5000);
78
+ }
79
+ wsRetryDelayMs = Math.min(wsRetryDelayMs * 2, WS_MAX_RETRY_MS);
80
+ wsReconnectCycles += 1;
81
+ console.log("[poke-browser offscreen] Reconnect in", delay, "ms (cycle", wsReconnectCycles, "/", WS_MAX_RETRIES, ")");
82
+ notifyLog("out", `WebSocket: reconnect in ${delay}ms (${wsReconnectCycles}/${WS_MAX_RETRIES})`);
83
+ reconnectTimer = setTimeout(() => {
84
+ reconnectTimer = null;
85
+ connectMcpSocket();
86
+ }, delay);
87
+ }
88
+
89
+ function resetWebSocketBackoff() {
90
+ clearReconnectTimer();
91
+ wsRetryDelayMs = WS_INITIAL_RETRY_MS;
92
+ wsReconnectCycles = 0;
93
+ }
94
+
95
+ function connectMcpSocket() {
96
+ if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
97
+ console.log("[poke-browser offscreen] connect skipped (socket already open/connecting)");
98
+ return;
99
+ }
100
+
101
+ notifyStatus("connecting");
102
+ const url = mcpWsUrl && mcpWsUrl.length > 0 ? mcpWsUrl : `ws://127.0.0.1:${mcpPort}`;
103
+ console.log("[poke-browser offscreen] Connecting to", url);
104
+ try {
105
+ socket = new WebSocket(url);
106
+ } catch (e) {
107
+ postToBg({ type: "ws_disconnected", code: 0 });
108
+ notifyLog("out", `WebSocket construct failed: ${String(e)}`);
109
+ scheduleReconnectAfterClose();
110
+ return;
111
+ }
112
+
113
+ socket.addEventListener("open", () => {
114
+ resetWebSocketBackoff();
115
+ postToBg({ type: "ws_connected" });
116
+ notifyLog("out", `Connected to MCP WebSocket ${url}`);
117
+ postToBg({ type: "request_hello_credentials" });
118
+ });
119
+
120
+ socket.addEventListener("message", (event) => {
121
+ const raw = String(event.data);
122
+ console.log("[poke-browser offscreen] From MCP (first 200 chars):", raw.slice(0, 200));
123
+ postToBg({ type: "ws_message", data: raw });
124
+ });
125
+
126
+ socket.addEventListener("close", (event) => {
127
+ postToBg({ type: "ws_disconnected", code: event.code });
128
+ console.log(
129
+ "[poke-browser offscreen] WebSocket CLOSED, code:",
130
+ event.code,
131
+ "reason:",
132
+ event.reason,
133
+ "wasClean:",
134
+ event.wasClean,
135
+ );
136
+ notifyLog("out", "WebSocket closed");
137
+ socket = null;
138
+ if (suppressReconnectOnce) {
139
+ suppressReconnectOnce = false;
140
+ return;
141
+ }
142
+ if (event.code === 1000 || event.code === 1001) {
143
+ console.log("[poke-browser offscreen] Clean close, not reconnecting");
144
+ return;
145
+ }
146
+ scheduleReconnectAfterClose(event.code);
147
+ });
148
+
149
+ socket.addEventListener("error", (event) => {
150
+ console.error("[poke-browser offscreen] WebSocket ERROR:", event);
151
+ notifyLog("out", "WebSocket error (see close for reconnect)");
152
+ });
153
+ }
154
+
155
+ /**
156
+ * @param {{ type?: string, payload?: unknown }} msg
157
+ */
158
+ function sendHelloFromCredentials(msg) {
159
+ const token = typeof msg.token === "string" ? msg.token : "";
160
+ const version = typeof msg.version === "string" ? msg.version : "0";
161
+ const hello =
162
+ token.length > 0
163
+ ? {
164
+ type: "hello",
165
+ token,
166
+ client: "poke-browser-extension",
167
+ version,
168
+ }
169
+ : {
170
+ type: "hello",
171
+ client: "poke-browser-extension",
172
+ version,
173
+ };
174
+ try {
175
+ if (socket?.readyState === WebSocket.OPEN) {
176
+ socket.send(JSON.stringify(hello));
177
+ }
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ }
182
+
183
+ function handleFromBg(msg) {
184
+ if (msg.type === "hello_credentials") {
185
+ sendHelloFromCredentials(msg);
186
+ return;
187
+ }
188
+ if (msg.type === "ws_send" && msg.payload !== undefined) {
189
+ try {
190
+ if (socket?.readyState === WebSocket.OPEN) {
191
+ const line = typeof msg.payload === "string" ? msg.payload : JSON.stringify(msg.payload);
192
+ socket.send(line);
193
+ }
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ return;
198
+ }
199
+ if (msg.type === "reconnect") {
200
+ if (typeof msg.wsUrl === "string" && msg.wsUrl.trim()) {
201
+ mcpWsUrl = msg.wsUrl.trim();
202
+ } else if (typeof msg.port === "number" && Number.isFinite(msg.port) && msg.port > 0 && msg.port < 65536) {
203
+ mcpPort = Math.trunc(msg.port);
204
+ mcpWsUrl = null;
205
+ }
206
+ clearReconnectTimer();
207
+ resetWebSocketBackoff();
208
+ if (socket) {
209
+ suppressReconnectOnce = true;
210
+ try {
211
+ socket.close();
212
+ } catch {
213
+ /* ignore */
214
+ }
215
+ socket = null;
216
+ suppressReconnectOnce = false;
217
+ }
218
+ connectMcpSocket();
219
+ return;
220
+ }
221
+ if (msg.type === "sw_wake") {
222
+ if (socket?.readyState === WebSocket.OPEN) {
223
+ try {
224
+ socket.send(JSON.stringify({ type: "ping" }));
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ } else {
229
+ connectMcpSocket();
230
+ }
231
+ }
232
+ }
233
+
234
+ function attachBridgePort() {
235
+ if (bgPort) return;
236
+ if (!chrome.runtime?.id) return;
237
+ bgPort = chrome.runtime.connect({ name: "POKE_WS_BRIDGE" });
238
+ bgPort.onMessage.addListener(handleFromBg);
239
+ bgPort.onDisconnect.addListener(() => {
240
+ bgPort = null;
241
+ setTimeout(attachBridgePort, 500);
242
+ });
243
+ }
244
+
245
+ attachBridgePort();
246
+ connectMcpSocket();