mouaif 0.3.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,731 @@
1
+ 'use strict';
2
+
3
+ // Inspector — server-side bridge to Chrome DevTools Protocol (CDP).
4
+ //
5
+ // Implements docs/decisions.md section 6: the mobile UI consumes CDP
6
+ // events but the server is a thin relay. We do NOT embed the Chrome
7
+ // panel in an iframe. The mobile UI is built from scratch on top of
8
+ // the CDP wire format, which is plain JSON over WebSocket.
9
+ //
10
+ // Architecture:
11
+ //
12
+ // Browser tab --ws--> /api/inspector/proxy?url=... --ws--> Chrome
13
+ //
14
+ // The proxy is a per-connection WebSocket relay. The browser speaks
15
+ // CDP directly (it knows the protocol); the server only forwards
16
+ // frames. Each browser WS connection is paired with one upstream
17
+ // Chrome WS; closing either side closes the other.
18
+ //
19
+ // This is the simplest correct implementation. It keeps the server
20
+ // free of CDP client state (no `chrome-remote-interface` style
21
+ // session bookkeeping), which is what we want for a transparent
22
+ // debug pipe.
23
+ //
24
+ // Public surface:
25
+ //
26
+ // const inspector = require('./inspector.js');
27
+ //
28
+ // inspector.fetchInfo(debuggerHost) -> { webSocketDebuggerUrl, ... }
29
+ // inspector.fetchTargets(debuggerHost) -> [ { id, type, url, title, ... }, ... ]
30
+ // inspector.handleProxy(req, socket, head, { debuggerHost })
31
+ // -- wired into http.Server's 'upgrade' event in src/index.js
32
+ //
33
+ // inspector.ERROR_CODES -- typed errors mapped to HTTP status
34
+ //
35
+ // Configuration:
36
+ //
37
+ // The "debugger host" (i.e. the Chrome instance to talk to) is
38
+ // resolved per request from a `host` query string on the proxy URL,
39
+ // defaulting to MOUAIF_CHROME_URL or 'http://127.0.0.1:9222'.
40
+ // The user can paste any reachable Chrome /chrome endpoint from the
41
+ // Inspector tab; the value is remembered in the app settings store.
42
+ //
43
+ // The default port 9222 is what `chrome --remote-debugging-port=9222`
44
+ // opens by default. On Android / iOS Chrome, the same flag works;
45
+ // the user is responsible for port-forwarding (adb reverse) on
46
+ // physical devices.
47
+
48
+ const http = require('http');
49
+ const https = require('https');
50
+ const { URL } = require('url');
51
+ const { WebSocketServer, WebSocket } = require('ws');
52
+ const { qs } = require('./util.js');
53
+ const settings = require('./settings.js');
54
+
55
+ const DEFAULT_CHROME_PORT = 9222;
56
+ const DEFAULT_CHROME_HOST = '127.0.0.1';
57
+ const APP_KEY_DEBUGGER_HOST = 'inspectorDebuggerUrl';
58
+
59
+ // Default placeholder when nothing is configured. Surfaced to the UI as
60
+ // "no debugger host configured yet" rather than silently trying localhost.
61
+ function defaultDebuggerUrl() {
62
+ return process.env.MOUAIF_CHROME_URL || ('http://' + DEFAULT_CHROME_HOST + ':' + DEFAULT_CHROME_PORT);
63
+ }
64
+
65
+ function getDebuggerUrl() {
66
+ const app = settings.getApp();
67
+ const raw = app && typeof app[APP_KEY_DEBUGGER_HOST] === 'string' ? app[APP_KEY_DEBUGGER_HOST].trim() : '';
68
+ return raw || defaultDebuggerUrl();
69
+ }
70
+
71
+ function setDebuggerUrl(url) {
72
+ // Stored as-is. Empty string clears the override.
73
+ settings.setApp({ [APP_KEY_DEBUGGER_HOST]: typeof url === 'string' ? url : '' });
74
+ }
75
+
76
+ // ---- Chrome /json/version + /json/list ---------------------------------
77
+ // Chrome exposes target metadata over plain HTTP. We use http / https
78
+ // modules — fetch is fine too but we keep dependencies low and we want
79
+ // to surface non-2xx as typed errors.
80
+
81
+ function httpGetJson(targetUrl, timeoutMs) {
82
+ return new Promise((resolve, reject) => {
83
+ let parsed;
84
+ try { parsed = new URL(targetUrl); }
85
+ catch (e) { const err = new Error('Invalid URL: ' + targetUrl); err.code = 'EBADURL'; reject(err); return; }
86
+ const lib = parsed.protocol === 'https:' ? https : http;
87
+ const req = lib.get(parsed, (res) => {
88
+ const chunks = [];
89
+ res.on('data', (c) => chunks.push(c));
90
+ res.on('end', () => {
91
+ const text = Buffer.concat(chunks).toString('utf-8');
92
+ if (res.statusCode < 200 || res.statusCode >= 300) {
93
+ const err = new Error('Chrome returned HTTP ' + res.statusCode + ' for ' + parsed.pathname);
94
+ err.code = 'EUPSTREAM';
95
+ err.status = res.statusCode;
96
+ err.body = text.slice(0, 2000);
97
+ reject(err);
98
+ return;
99
+ }
100
+ let json;
101
+ try { json = JSON.parse(text); }
102
+ catch (e) {
103
+ const err = new Error('Chrome returned non-JSON for ' + parsed.pathname + ': ' + e.message);
104
+ err.code = 'EPARSE';
105
+ reject(err);
106
+ return;
107
+ }
108
+ resolve(json);
109
+ });
110
+ });
111
+ req.on('error', (e) => {
112
+ const err = new Error('Could not reach Chrome at ' + targetUrl + ': ' + e.message);
113
+ err.code = 'ECHROME_UNREACHABLE';
114
+ err.cause = e;
115
+ reject(err);
116
+ });
117
+ if (timeoutMs) {
118
+ req.setTimeout(timeoutMs, () => {
119
+ req.destroy(new Error('timeout after ' + timeoutMs + 'ms'));
120
+ });
121
+ }
122
+ });
123
+ }
124
+
125
+ // /json/version returns { webSocketDebuggerUrl, ... }
126
+ async function fetchInspectorInfo(debuggerUrl) {
127
+ const base = stripTrailingSlash(debuggerUrl || getDebuggerUrl());
128
+ return httpGetJson(base + '/json/version', 5000);
129
+ }
130
+
131
+ // /json/list returns the array of discoverable targets.
132
+ async function fetchInspectorTargets(debuggerUrl) {
133
+ const base = stripTrailingSlash(debuggerUrl || getDebuggerUrl());
134
+ const list = await httpGetJson(base + '/json/list', 5000);
135
+ if (!Array.isArray(list)) {
136
+ const err = new Error('Expected array from /json/list, got ' + typeof list);
137
+ err.code = 'EPARSE';
138
+ throw err;
139
+ }
140
+ return list;
141
+ }
142
+
143
+ // openInspectorTarget — opens `pageUrl` in a fresh tab of the debug
144
+ // Chrome and returns its target record ({ id, url, webSocketDebuggerUrl,
145
+ // ... }). Used by the "inspect this URL" flow and by the inspector
146
+ // header's "open in a new tab" action.
147
+ //
148
+ // Modern Chrome (137+) removed the /json/new HTTP endpoint (it 404s),
149
+ // so the primary path is the CDP `Target.createTarget` command sent over
150
+ // the browser-level WebSocket from /json/version. Older Chrome still
151
+ // accepts PUT /json/new?<url>, kept as a fallback. GET on /json/new was
152
+ // dropped for CSRF reasons, so only PUT is ever attempted.
153
+ async function openInspectorTarget(debuggerUrl, pageUrl) {
154
+ const base = stripTrailingSlash(debuggerUrl || getDebuggerUrl());
155
+ // The CDP browser WS id is read fresh from /json/version each call,
156
+ // but Chrome may have restarted between that fetch and the moment we
157
+ // open the WS — the old browser-level id then returns 404 "not found"
158
+ // from Chrome itself. Retry once with a freshly re-fetched id before
159
+ // giving up; this covers the real-world "Chrome restarted" race.
160
+ let cdpErr = null;
161
+ for (let attempt = 0; attempt < 2; attempt++) {
162
+ try {
163
+ return await openTargetViaCdp(base, pageUrl);
164
+ } catch (e) {
165
+ cdpErr = e;
166
+ // The fallback path is handled below; retryable errors get one
167
+ // more CDP attempt, everything else surfaces immediately.
168
+ if (!e || !e.__cdpRetryable) break;
169
+ await new Promise((r) => setTimeout(r, 300));
170
+ }
171
+ }
172
+ // Fall back to the classic HTTP endpoint for older Chrome builds —
173
+ // but only when the CDP path itself isn't the thing that's broken
174
+ // (e.g. Chrome reachable but /json/version lacks a browser WS, or a
175
+ // real network failure). Masking a genuine CDP error with a /json/new
176
+ // 404 would make the modern-path failure harder to diagnose.
177
+ if (cdpErr && cdpErr.__cdpFallback) {
178
+ try {
179
+ const target = await httpRequestJson(base + '/json/new?' + encodeURIComponent(pageUrl), { method: 'PUT' }, 5000);
180
+ if (!target || typeof target !== 'object' || !target.id) {
181
+ const err = new Error('Unexpected /json/new response: ' + typeof target);
182
+ err.code = 'EPARSE';
183
+ throw err;
184
+ }
185
+ return target;
186
+ } catch (e) {
187
+ // Modern Chrome 137+ removed /json/new — it 404s with "not found".
188
+ // If the fallback also fails, surface a clear, actionable error
189
+ // instead of the raw "not found" from Chrome.
190
+ if (e && e.status === 404) {
191
+ const err = new Error('This Chrome build does not expose the /json/new endpoint. Open the page manually in the debug Chrome, or use an existing tab from the target list.');
192
+ err.code = 'EUPSTREAM';
193
+ throw err;
194
+ }
195
+ throw e;
196
+ }
197
+ }
198
+ throw cdpErr;
199
+ }
200
+
201
+ // openTargetViaCdp — sends Target.createTarget over the browser-level
202
+ // CDP WebSocket. Requires /json/version to expose webSocketDebuggerUrl;
203
+ // returns the fresh target record (Chrome /json/new would return the
204
+ // same shape). Rejects with a typed error on any failure.
205
+ async function openTargetViaCdp(base, pageUrl) {
206
+ const info = await httpGetJson(base + '/json/version', 5000);
207
+ const wsUrl = info && info.webSocketDebuggerUrl;
208
+ if (!wsUrl || !String(wsUrl).trim()) {
209
+ // Old Chrome may expose /json/version without a browser-level WS (or
210
+ // a remote-debugging mode where only per-page WS is offered). That's
211
+ // exactly when the PUT /json/new fallback is the right call.
212
+ const err = new Error('Chrome /json/version has no webSocketDebuggerUrl; cannot open a tab via CDP');
213
+ err.code = 'EUPSTREAM';
214
+ err.__cdpFallback = true;
215
+ throw err;
216
+ }
217
+ const { WebSocket } = require('ws');
218
+ return new Promise((resolve, reject) => {
219
+ let ws;
220
+ try { ws = new WebSocket(wsUrl, { perMessageDeflate: false }); }
221
+ catch (e) {
222
+ const err = new Error('Could not open browser WebSocket at ' + wsUrl + ': ' + e.message);
223
+ err.code = 'ECHROME_UNREACHABLE';
224
+ reject(err); return;
225
+ }
226
+ const timer = setTimeout(() => {
227
+ try { ws.terminate(); } catch { /* ignore */ }
228
+ const err = new Error('Timeout waiting for browser WebSocket at ' + wsUrl);
229
+ err.code = 'ECHROME_UNREACHABLE';
230
+ reject(err);
231
+ }, 5000);
232
+ ws.on('open', () => {
233
+ const id = 1;
234
+ ws.send(JSON.stringify({
235
+ id,
236
+ method: 'Target.createTarget',
237
+ params: { url: pageUrl, newWindow: false }
238
+ }));
239
+ });
240
+ ws.on('message', (data) => {
241
+ let msg;
242
+ try { msg = JSON.parse(data.toString()); }
243
+ catch { return; }
244
+ if (msg.id !== 1) return;
245
+ clearTimeout(timer);
246
+ try { ws.close(); } catch { /* ignore */ }
247
+ if (msg.error) {
248
+ const err = new Error('Target.createTarget failed: ' + (msg.error.message || 'unknown CDP error'));
249
+ err.code = 'EUPSTREAM';
250
+ // A stale browser WS id (Chrome restarted between /json/version
251
+ // and the WS open) fails with an error like "not found" — retry
252
+ // once with a fresh id from /json/version.
253
+ err.__cdpRetryable = true;
254
+ reject(err);
255
+ return;
256
+ }
257
+ const targetId = msg.result && msg.result.targetId;
258
+ if (!targetId) {
259
+ const err = new Error('Target.createTarget returned no targetId');
260
+ err.code = 'EPARSE';
261
+ reject(err);
262
+ return;
263
+ }
264
+ // Return the same shape /json/new would have returned, so callers
265
+ // (REST handler, UI) treat both paths identically.
266
+ resolve({
267
+ id: targetId,
268
+ type: 'page',
269
+ url: pageUrl,
270
+ title: '',
271
+ webSocketDebuggerUrl: base + '/devtools/page/' + encodeURIComponent(targetId)
272
+ });
273
+ });
274
+ ws.on('error', (e) => {
275
+ clearTimeout(timer);
276
+ const err = new Error('Browser WebSocket error: ' + (e && e.message || e));
277
+ err.code = 'ECHROME_UNREACHABLE';
278
+ // 404 from Chrome for an unknown /devtools/browser/<id> also means
279
+ // the id went stale — retry with a fresh one.
280
+ err.__cdpRetryable = true;
281
+ reject(err);
282
+ });
283
+ ws.on('close', () => {
284
+ clearTimeout(timer);
285
+ });
286
+ });
287
+ }
288
+
289
+ // sendBrowserCommand — sends a single CDP method over the browser-level
290
+ // WebSocket (from /json/version) and resolves with the result. Used for
291
+ // browser-domain commands such as Target.closeTarget. Mirrors
292
+ // openTargetViaCdp's retry behavior for a stale browser WS id (Chrome
293
+ // restarted) — retries once with a freshly re-fetched id.
294
+ async function sendBrowserCommand(debuggerUrl, method, params) {
295
+ const base = stripTrailingSlash(debuggerUrl || getDebuggerUrl());
296
+ let lastErr = null;
297
+ for (let attempt = 0; attempt < 2; attempt++) {
298
+ try {
299
+ return await sendBrowserCommandOnce(base, method, params);
300
+ } catch (e) {
301
+ lastErr = e;
302
+ if (!e || !e.__cdpRetryable) break;
303
+ await new Promise((r) => setTimeout(r, 300));
304
+ }
305
+ }
306
+ throw lastErr;
307
+ }
308
+
309
+ async function sendBrowserCommandOnce(base, method, params) {
310
+ const info = await httpGetJson(base + '/json/version', 5000);
311
+ const wsUrl = info && info.webSocketDebuggerUrl;
312
+ if (!wsUrl || !String(wsUrl).trim()) {
313
+ const err = new Error('Chrome /json/version has no webSocketDebuggerUrl; cannot send browser command');
314
+ err.code = 'EUPSTREAM';
315
+ throw err;
316
+ }
317
+ const { WebSocket } = require('ws');
318
+ return new Promise((resolve, reject) => {
319
+ let ws;
320
+ try { ws = new WebSocket(wsUrl, { perMessageDeflate: false }); }
321
+ catch (e) {
322
+ const err = new Error('Could not open browser WebSocket at ' + wsUrl + ': ' + e.message);
323
+ err.code = 'ECHROME_UNREACHABLE';
324
+ reject(err); return;
325
+ }
326
+ const timer = setTimeout(() => {
327
+ try { ws.terminate(); } catch { /* ignore */ }
328
+ const err = new Error('Timeout waiting for browser WebSocket at ' + wsUrl);
329
+ err.code = 'ECHROME_UNREACHABLE';
330
+ reject(err);
331
+ }, 5000);
332
+ ws.on('open', () => {
333
+ ws.send(JSON.stringify({ id: 1, method, params: params || {} }));
334
+ });
335
+ ws.on('message', (data) => {
336
+ let msg;
337
+ try { msg = JSON.parse(data.toString()); }
338
+ catch { return; }
339
+ if (msg.id !== 1) return;
340
+ clearTimeout(timer);
341
+ try { ws.close(); } catch { /* ignore */ }
342
+ if (msg.error) {
343
+ const err = new Error(method + ' failed: ' + (msg.error.message || 'unknown CDP error'));
344
+ err.code = 'EUPSTREAM';
345
+ err.__cdpRetryable = true;
346
+ reject(err);
347
+ return;
348
+ }
349
+ resolve(msg.result || {});
350
+ });
351
+ ws.on('error', (e) => {
352
+ clearTimeout(timer);
353
+ const err = new Error('Browser WebSocket error: ' + (e && e.message || e));
354
+ err.code = 'ECHROME_UNREACHABLE';
355
+ err.__cdpRetryable = true;
356
+ reject(err);
357
+ });
358
+ ws.on('close', () => { clearTimeout(timer); });
359
+ });
360
+ }
361
+
362
+ // sendTargetCommand — sends a single CDP method over a *target*-level
363
+ // WebSocket (resolved from the targetId via /json/list). Used for
364
+ // page-domain commands such as Page.reload and Page.navigate. Rejects
365
+ // with a typed error when the target is gone or unreachable.
366
+ async function sendTargetCommand(debuggerUrl, targetId, method, params) {
367
+ const base = stripTrailingSlash(debuggerUrl || getDebuggerUrl());
368
+ const list = await httpGetJson(base + '/json/list', 5000);
369
+ const t = list.find((x) => x && x.id === targetId);
370
+ if (!t || !t.webSocketDebuggerUrl) {
371
+ const err = new Error('target not found: ' + targetId);
372
+ err.code = 'ETARGET_NOT_FOUND';
373
+ throw err;
374
+ }
375
+ const { WebSocket } = require('ws');
376
+ return new Promise((resolve, reject) => {
377
+ let ws;
378
+ try { ws = new WebSocket(t.webSocketDebuggerUrl, { perMessageDeflate: false }); }
379
+ catch (e) {
380
+ const err = new Error('Could not open target WebSocket: ' + e.message);
381
+ err.code = 'ECHROME_UNREACHABLE';
382
+ reject(err); return;
383
+ }
384
+ const timer = setTimeout(() => {
385
+ try { ws.terminate(); } catch { /* ignore */ }
386
+ const err = new Error('Timeout waiting for target WebSocket at ' + t.webSocketDebuggerUrl);
387
+ err.code = 'ECHROME_UNREACHABLE';
388
+ reject(err);
389
+ }, 5000);
390
+ ws.on('open', () => {
391
+ ws.send(JSON.stringify({ id: 1, method, params: params || {} }));
392
+ });
393
+ ws.on('message', (data) => {
394
+ let msg;
395
+ try { msg = JSON.parse(data.toString()); }
396
+ catch { return; }
397
+ if (msg.id !== 1) return;
398
+ clearTimeout(timer);
399
+ try { ws.close(); } catch { /* ignore */ }
400
+ if (msg.error) {
401
+ const err = new Error(method + ' failed: ' + (msg.error.message || 'unknown CDP error'));
402
+ err.code = 'EUPSTREAM';
403
+ reject(err);
404
+ return;
405
+ }
406
+ resolve(msg.result || {});
407
+ });
408
+ ws.on('error', (e) => {
409
+ clearTimeout(timer);
410
+ const err = new Error('Target WebSocket error: ' + (e && e.message || e));
411
+ err.code = 'ECHROME_UNREACHABLE';
412
+ reject(err);
413
+ });
414
+ ws.on('close', () => { clearTimeout(timer); });
415
+ });
416
+ }
417
+
418
+ // closeInspectorTarget — closes a tab (Target.closeTarget, browser-level).
419
+ async function closeInspectorTarget(debuggerUrl, targetId) {
420
+ await sendBrowserCommand(debuggerUrl, 'Target.closeTarget', { targetId });
421
+ return { ok: true };
422
+ }
423
+
424
+ // reloadInspectorTarget — reloads a tab (Page.reload, target-level).
425
+ async function reloadInspectorTarget(debuggerUrl, targetId) {
426
+ await sendTargetCommand(debuggerUrl, targetId, 'Page.reload', { ignoreCache: false });
427
+ return { ok: true };
428
+ }
429
+
430
+ // navigateInspectorTarget — navigates a tab to a new URL (Page.navigate,
431
+ // target-level). Returns the navigation result (frameId, loaderId).
432
+ async function navigateInspectorTarget(debuggerUrl, targetId, url) {
433
+ return sendTargetCommand(debuggerUrl, targetId, 'Page.navigate', { url });
434
+ }
435
+ // historyInspectorTarget — the attached tab's session history
436
+ // (Page.getNavigationHistory, target-level), shaped for the Inspector's nav
437
+ // row. `canGoBack` / `canGoForward` are what let the two history arrows be
438
+ // *disabled* instead of reporting "no page to go back to" only after the
439
+ // tap. `entries` carries only what a history list needs to draw itself
440
+ // (id, url, title) so a long-lived tab cannot ship a huge payload.
441
+ async function historyInspectorTarget(debuggerUrl, targetId) {
442
+ const history = await sendTargetCommand(debuggerUrl, targetId, 'Page.getNavigationHistory', {});
443
+ const list = Array.isArray(history && history.entries) ? history.entries : [];
444
+ const index = history && typeof history.currentIndex === 'number' ? history.currentIndex : -1;
445
+ return {
446
+ index,
447
+ canGoBack: index > 0 && !!list[index - 1],
448
+ canGoForward: index >= 0 && !!list[index + 1],
449
+ // `entryId` is Chrome's own field name (CDP `Page.navigateToHistoryEntry`
450
+ // takes `{ entryId }`). Only entries carrying a usable id are published:
451
+ // a missing or non-numeric id would otherwise reach the step below as an
452
+ // argument Chrome rejects, turning "go back" into a hard failure instead
453
+ // of the no-op it should be.
454
+ entries: list
455
+ .filter((e) => e && Number.isFinite(e.id))
456
+ .map((e) => ({ entryId: e.id, url: e.url || '', title: e.title || '' }))
457
+ };
458
+ }
459
+
460
+ // stepHistory — move the history cursor `delta` entries (negative = back,
461
+ // positive = forward) and report whether it actually moved. One helper for
462
+ // both directions so forward cannot drift from back. A step with nowhere to
463
+ // go is a friendly no-op, not an error: the caller turns `moved: false` into
464
+ // its own wording and no request is sent to Chrome.
465
+ async function stepHistory(debuggerUrl, targetId, delta) {
466
+ const history = await historyInspectorTarget(debuggerUrl, targetId);
467
+ const wanted = history.index >= 0 ? history.entries[history.index + delta] : null;
468
+ if (!wanted) return { ok: true, moved: false };
469
+ await sendTargetCommand(debuggerUrl, targetId, 'Page.navigateToHistoryEntry', { entryId: wanted.entryId });
470
+ return { ok: true, moved: true };
471
+ }
472
+
473
+ // goBackInspectorTarget — navigates a tab one entry back in its history.
474
+ // Returns { ok: true, wentBack: <bool> } so the UI can tell "went back"
475
+ // from "nothing to go back to" without treating the latter as an error.
476
+ async function goBackInspectorTarget(debuggerUrl, targetId) {
477
+ const r = await stepHistory(debuggerUrl, targetId, -1);
478
+ return { ok: true, wentBack: r.moved };
479
+ }
480
+
481
+ // goForwardInspectorTarget — the same step in the other direction, for a tab
482
+ // the user has already navigated back from. Returns { ok, wentForward }.
483
+ async function goForwardInspectorTarget(debuggerUrl, targetId) {
484
+ const r = await stepHistory(debuggerUrl, targetId, 1);
485
+ return { ok: true, wentForward: r.moved };
486
+ }
487
+ // httpRequestJson — httpGetJson generalized to any method (Chrome's
488
+ // /json/new requires PUT). Same typed-error behavior as httpGetJson.
489
+ function httpRequestJson(targetUrl, opts, timeoutMs) {
490
+ return new Promise((resolve, reject) => {
491
+ let parsed;
492
+ try { parsed = new URL(targetUrl); }
493
+ catch (e) { const err = new Error('Invalid URL: ' + targetUrl); err.code = 'EBADURL'; reject(err); return; }
494
+ const lib = parsed.protocol === 'https:' ? https : http;
495
+ const req = lib.request(parsed, { method: (opts && opts.method) || 'GET' }, (res) => {
496
+ const chunks = [];
497
+ res.on('data', (c) => chunks.push(c));
498
+ res.on('end', () => {
499
+ const text = Buffer.concat(chunks).toString('utf-8');
500
+ if (res.statusCode < 200 || res.statusCode >= 300) {
501
+ const err = new Error('Chrome returned HTTP ' + res.statusCode + ' for ' + parsed.pathname);
502
+ err.code = 'EUPSTREAM';
503
+ err.status = res.statusCode;
504
+ err.body = text.slice(0, 2000);
505
+ reject(err);
506
+ return;
507
+ }
508
+ let json;
509
+ try { json = JSON.parse(text); }
510
+ catch (e) {
511
+ const err = new Error('Chrome returned non-JSON for ' + parsed.pathname + ': ' + e.message);
512
+ err.code = 'EPARSE';
513
+ reject(err);
514
+ return;
515
+ }
516
+ resolve(json);
517
+ });
518
+ });
519
+ req.on('error', (e) => {
520
+ const err = new Error('Could not reach Chrome at ' + targetUrl + ': ' + e.message);
521
+ err.code = 'ECHROME_UNREACHABLE';
522
+ err.cause = e;
523
+ reject(err);
524
+ });
525
+ if (timeoutMs) {
526
+ req.setTimeout(timeoutMs, () => {
527
+ req.destroy(new Error('timeout after ' + timeoutMs + 'ms'));
528
+ });
529
+ }
530
+ req.end();
531
+ });
532
+ }
533
+
534
+ function stripTrailingSlash(s) { return String(s || '').replace(/\/+$/, ''); }
535
+
536
+ // ---- Proxy: WebSocket <-> WebSocket ------------------------------------
537
+ //
538
+ // The browser opens a WS to the mouaif server. We accept the upgrade,
539
+ // immediately open a WS to the target Chrome /devtools/page/<id>, and
540
+ // pipe messages both ways. The browser then speaks CDP directly.
541
+ //
542
+ // We support two proxy URL shapes:
543
+ //
544
+ // ws://host/api/inspector/proxy?ws=ws%3A%2F%2F127.0.0.1%3A9222%2Fdevtools%2Fpage%2FAB12CD
545
+ // -- direct passthrough; the browser already knows the target.
546
+ //
547
+ // ws://host/api/inspector/proxy?host=http%3A%2F%2F127.0.0.1%3A9222&targetId=AB12CD
548
+ // -- we look up the target's webSocketDebuggerUrl server-side and
549
+ // connect on the browser's behalf. Used by the "tap a target"
550
+ // UI flow so the mobile app never has to talk to /json/list
551
+ // over a second WebSocket.
552
+ //
553
+ // Both shapes are equivalent on the wire; the only difference is which
554
+ // side does the /json/list call.
555
+
556
+ async function handleProxy(req, socket, head, opts) {
557
+ const q = (req.url && req.url.indexOf('?') >= 0)
558
+ ? Object.fromEntries(new URL(req.url, 'http://placeholder').searchParams)
559
+ : {};
560
+
561
+ // Prefer the explicit ws URL if provided. Fall back to host+targetId.
562
+ let upstreamWsUrl = typeof q.ws === 'string' ? q.ws : '';
563
+ if (!upstreamWsUrl) {
564
+ const host = typeof q.host === 'string' && q.host ? q.host : (opts && opts.debuggerUrl) || getDebuggerUrl();
565
+ const targetId = qs(q, 'targetId');
566
+ if (!targetId) {
567
+ writeProxyError(socket, 400, 'EBADINPUT', 'either ?ws=<wsUrl> or ?targetId=<id> is required');
568
+ return;
569
+ }
570
+ try {
571
+ const list = await fetchInspectorTargets(host);
572
+ const t = list.find((x) => x && x.id === targetId);
573
+ if (!t || !t.webSocketDebuggerUrl) {
574
+ writeProxyError(socket, 404, 'ETARGET_NOT_FOUND', 'target not found: ' + targetId);
575
+ return;
576
+ }
577
+ upstreamWsUrl = t.webSocketDebuggerUrl;
578
+ } catch (e) {
579
+ const status = e.code === 'ECHROME_UNREACHABLE' ? 502 : (e.status || 500);
580
+ writeProxyError(socket, status, e.code || 'EUPSTREAM', e.message);
581
+ return;
582
+ }
583
+ }
584
+
585
+ let upstreamWs;
586
+ try {
587
+ upstreamWs = new WebSocket(upstreamWsUrl, { perMessageDeflate: false });
588
+ } catch (e) {
589
+ writeProxyError(socket, 502, 'EWS_OPEN_FAILED', e && e.message || 'WebSocket open failed');
590
+ return;
591
+ }
592
+
593
+ let browserWs;
594
+ try {
595
+ // The noServer WebSocketServer lets us complete the upgrade on the
596
+ // browser side ourselves. `opts.wss.handleUpgrade` performs the
597
+ // handshake and hands us a ready WebSocket in the callback.
598
+ if (!opts || !opts.wss) {
599
+ writeProxyError(socket, 500, 'EUPGRADE_NO_WSS', 'inspector: no WebSocketServer on the upgrade handler');
600
+ try { upstreamWs.terminate(); } catch { /* ignore */ }
601
+ return;
602
+ }
603
+ opts.wss.handleUpgrade(req, socket, head, (ws) => {
604
+ // Now that the browser side is upgraded, finish the upstream
605
+ // handshake and start piping.
606
+ wirePair(ws, upstreamWs);
607
+ });
608
+ } catch (e) {
609
+ try { upstreamWs.terminate(); } catch { /* ignore */ }
610
+ writeProxyError(socket, 500, 'EUPGRADE_FAILED', e && e.message || 'upgrade failed');
611
+ return;
612
+ }
613
+ }
614
+
615
+ function writeProxyError(socket, status, code, message) {
616
+ // The HTTP upgrade response is just headers; we can't send a JSON
617
+ // body over a failed upgrade. Send a 4xx-style status line and a
618
+ // tiny text body so the browser's WS open handler can show a useful
619
+ // error in its onerror.
620
+ let payload;
621
+ try {
622
+ payload = JSON.stringify({ code, error: message });
623
+ } catch { payload = '{"code":"' + code + '","error":"' + String(message).replace(/"/g, '\\"') + '"}'; }
624
+ const reason = code + ': ' + message;
625
+ try {
626
+ socket.write(
627
+ 'HTTP/1.1 ' + status + ' ' + (http.STATUS_CODES[status] || 'Error') + '\r\n' +
628
+ 'Content-Type: application/json\r\n' +
629
+ 'Connection: close\r\n' +
630
+ 'Content-Length: ' + Buffer.byteLength(payload) + '\r\n\r\n' +
631
+ payload
632
+ );
633
+ socket.end();
634
+ } catch {
635
+ try { socket.destroy(); } catch { /* ignore */ }
636
+ }
637
+ }
638
+
639
+ function wirePair(browserWs, upstreamWs) {
640
+ // Open the upstream socket if it isn't already.
641
+ let opened = upstreamWs.readyState === WebSocket.OPEN;
642
+ const pendingFromBrowser = [];
643
+
644
+ function flushPendingToUpstream() {
645
+ while (pendingFromBrowser.length && upstreamWs.readyState === WebSocket.OPEN) {
646
+ const data = pendingFromBrowser.shift();
647
+ try { upstreamWs.send(data); }
648
+ catch { /* socket closed */ }
649
+ }
650
+ }
651
+
652
+ if (!opened) {
653
+ upstreamWs.on('open', () => {
654
+ opened = true;
655
+ flushPendingToUpstream();
656
+ });
657
+ }
658
+
659
+ upstreamWs.on('message', (data, isBinary) => {
660
+ if (browserWs.readyState !== WebSocket.OPEN) return;
661
+ // `data` from ws@8 is a Buffer; send accepts Buffer / string / ArrayBuffer.
662
+ try { browserWs.send(data, { binary: isBinary }); }
663
+ catch { /* socket closed */ }
664
+ });
665
+
666
+ browserWs.on('message', (data, isBinary) => {
667
+ if (upstreamWs.readyState === WebSocket.OPEN) {
668
+ try { upstreamWs.send(data, { binary: isBinary }); }
669
+ catch { /* socket closed */ }
670
+ } else {
671
+ // Buffer frames until the upstream opens. This is uncommon — the
672
+ // browser typically waits for the proxy handshake before sending
673
+ // any commands — but it keeps the protocol honest.
674
+ if (isBinary) {
675
+ // We don't expect CDP clients to send binary; ignore safely.
676
+ return;
677
+ }
678
+ pendingFromBrowser.push(typeof data === 'string' ? data : data.toString('utf-8'));
679
+ }
680
+ });
681
+
682
+ function closeBoth(reason) {
683
+ try { browserWs.close(1000, reason || 'upstream closed'); } catch { /* ignore */ }
684
+ try { upstreamWs.close(1000, reason || 'browser closed'); } catch { /* ignore */ }
685
+ }
686
+ upstreamWs.on('close', (code, reason) => {
687
+ closeBoth(reason && reason.toString ? reason.toString() : 'upstream closed');
688
+ });
689
+ upstreamWs.on('error', () => {
690
+ closeBoth('upstream error');
691
+ });
692
+ browserWs.on('close', () => {
693
+ try { upstreamWs.close(1000, 'browser closed'); } catch { /* ignore */ }
694
+ });
695
+ browserWs.on('error', () => {
696
+ try { upstreamWs.close(1000, 'browser error'); } catch { /* ignore */ }
697
+ });
698
+ }
699
+
700
+ // Create a no-op WebSocketServer instance so the http server can
701
+ // delegate upgrades to it. We could use `new WebSocketServer({ noServer: true })`
702
+ // directly. We export the constructor through a lazy init so callers
703
+ // don't pay for it if they don't need it.
704
+ function makeNoServerWss() {
705
+ return new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: 16 * 1024 * 1024 });
706
+ }
707
+
708
+ module.exports = {
709
+ // config
710
+ APP_KEY_DEBUGGER_HOST,
711
+ defaultDebuggerUrl,
712
+ getDebuggerUrl,
713
+ setDebuggerUrl,
714
+ // fetchers (used by REST routes and tests)
715
+ fetchInspectorInfo,
716
+ fetchInspectorTargets,
717
+ openInspectorTarget,
718
+ closeInspectorTarget,
719
+ sendTargetCommand,
720
+ reloadInspectorTarget,
721
+ navigateInspectorTarget,
722
+ historyInspectorTarget,
723
+ goBackInspectorTarget,
724
+ goForwardInspectorTarget,
725
+ // WS proxy
726
+ handleProxy,
727
+ makeNoServerWss,
728
+ // constants
729
+ DEFAULT_CHROME_HOST,
730
+ DEFAULT_CHROME_PORT
731
+ };