sliccy 0.1.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 (63) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +512 -0
  3. package/dist/cli/chrome-launch.d.ts +47 -0
  4. package/dist/cli/chrome-launch.js +324 -0
  5. package/dist/cli/cli-log-dedup.d.ts +19 -0
  6. package/dist/cli/cli-log-dedup.js +62 -0
  7. package/dist/cli/electron-controller.d.ts +38 -0
  8. package/dist/cli/electron-controller.js +287 -0
  9. package/dist/cli/electron-main.d.ts +1 -0
  10. package/dist/cli/electron-main.js +183 -0
  11. package/dist/cli/electron-runtime.d.ts +58 -0
  12. package/dist/cli/electron-runtime.js +133 -0
  13. package/dist/cli/file-logger.d.ts +38 -0
  14. package/dist/cli/file-logger.js +207 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +1023 -0
  17. package/dist/cli/launch-url.d.ts +9 -0
  18. package/dist/cli/launch-url.js +34 -0
  19. package/dist/cli/qa-setup.d.ts +1 -0
  20. package/dist/cli/qa-setup.js +36 -0
  21. package/dist/cli/release-package.d.ts +17 -0
  22. package/dist/cli/release-package.js +232 -0
  23. package/dist/cli/runtime-flags.d.ts +21 -0
  24. package/dist/cli/runtime-flags.js +154 -0
  25. package/dist/cli/sync-release-version.d.ts +2 -0
  26. package/dist/cli/sync-release-version.js +34 -0
  27. package/dist/tray-url-shared.d.ts +11 -0
  28. package/dist/tray-url-shared.js +56 -0
  29. package/dist/ui/assets/___vite-browser-external_commonjs-proxy-7ULRRj69.js +1 -0
  30. package/dist/ui/assets/__vite-browser-external-D7Ct-6yo.js +1 -0
  31. package/dist/ui/assets/addon-fit-DOCEibfw.js +12 -0
  32. package/dist/ui/assets/bsh-watchdog-D19WB0U1.js +2 -0
  33. package/dist/ui/assets/index-BVQAdk-Y.js +8 -0
  34. package/dist/ui/assets/index-BjJrFm2K.js +43 -0
  35. package/dist/ui/assets/index-C1dglHrI.js +3 -0
  36. package/dist/ui/assets/index-D3Enm5ux.js +13091 -0
  37. package/dist/ui/assets/index-D7hjyFh1.js +2 -0
  38. package/dist/ui/assets/index-D8uSC2sl.js +45 -0
  39. package/dist/ui/assets/index-DEglHp2j.js +1 -0
  40. package/dist/ui/assets/index-DvjzakYY.js +14406 -0
  41. package/dist/ui/assets/index-deZeJCgO.js +1 -0
  42. package/dist/ui/assets/index-mz3VYh0I.js +131 -0
  43. package/dist/ui/assets/index-r2m8Dpaz.js +54 -0
  44. package/dist/ui/assets/index-ygVJ3eFG.js +11 -0
  45. package/dist/ui/assets/lick-manager-proxy-G3WuipZ-.js +1 -0
  46. package/dist/ui/assets/magic-string.es-BPLJknd7.js +10 -0
  47. package/dist/ui/assets/oauth-service-DIahkF-o.js +1 -0
  48. package/dist/ui/assets/offscreen-client-ByVIJGHW.js +1 -0
  49. package/dist/ui/assets/pdfjs-uyZuKmOq.js +59 -0
  50. package/dist/ui/assets/pyodide-D73G_Ygx.mjs +4 -0
  51. package/dist/ui/assets/sql-wasm-BggYNCID.js +2 -0
  52. package/dist/ui/assets/stream-lEC9OYG2.js +1 -0
  53. package/dist/ui/assets/xterm-Bb8UKAlD.js +27 -0
  54. package/dist/ui/assets/xterm-DOrYoP_4.css +32 -0
  55. package/dist/ui/electron-overlay-entry.js +360 -0
  56. package/dist/ui/favicon.png +0 -0
  57. package/dist/ui/fonts/AdobeClean-Bold.otf +0 -0
  58. package/dist/ui/fonts/AdobeClean-ExtraBold.otf +0 -0
  59. package/dist/ui/fonts/AdobeClean-Medium.otf +0 -0
  60. package/dist/ui/fonts/AdobeClean-Regular.otf +0 -0
  61. package/dist/ui/index.html +1981 -0
  62. package/dist/ui/preview-sw.js +4 -0
  63. package/package.json +81 -0
@@ -0,0 +1,1023 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from 'http';
3
+ import { createServer as createNetServer } from 'net';
4
+ import { spawn } from 'child_process';
5
+ import { existsSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import express from 'express';
9
+ import { WebSocketServer, WebSocket } from 'ws';
10
+ import { ElectronAppAlreadyRunningError, ElectronOverlayInjector, launchElectronApp, } from './electron-controller.js';
11
+ import { buildChromeLaunchArgs, ensureQaProfileScaffold, findChromeExecutable, resolveChromeLaunchProfile, waitForCdpPortFromStderr, } from './chrome-launch.js';
12
+ import { resolveCliBrowserLaunchUrl } from './launch-url.js';
13
+ import { parseCliRuntimeFlags } from './runtime-flags.js';
14
+ import { FileLogger } from './file-logger.js';
15
+ import { CliLogDedup } from './cli-log-dedup.js';
16
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
17
+ const PROJECT_ROOT = resolve(__dirname, '..', '..');
18
+ const RUNTIME_FLAGS = parseCliRuntimeFlags(process.argv.slice(2));
19
+ const DEV_MODE = RUNTIME_FLAGS.dev;
20
+ const SERVE_ONLY = RUNTIME_FLAGS.serveOnly;
21
+ const ELECTRON_MODE = RUNTIME_FLAGS.electron;
22
+ const ELECTRON_APP = RUNTIME_FLAGS.electronApp;
23
+ const KILL_EXISTING_ELECTRON_APP = RUNTIME_FLAGS.kill;
24
+ // ---------------------------------------------------------------------------
25
+ // File logger — persistent log file in ~/.slicc/logs/
26
+ // ---------------------------------------------------------------------------
27
+ const fileLogger = new FileLogger({
28
+ logDir: RUNTIME_FLAGS.logDir ?? undefined,
29
+ logLevel: RUNTIME_FLAGS.logLevel,
30
+ devMode: DEV_MODE,
31
+ });
32
+ if (fileLogger.logFile) {
33
+ console.log(`Log file: ${fileLogger.logFile}`);
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Request logging middleware
37
+ // ---------------------------------------------------------------------------
38
+ function requestLogger(req, res, next) {
39
+ const start = Date.now();
40
+ const { method, url } = req;
41
+ res.on('finish', () => {
42
+ const duration = Date.now() - start;
43
+ const status = res.statusCode;
44
+ const tag = status >= 400 ? '\x1b[31m' : status >= 300 ? '\x1b[33m' : '\x1b[32m';
45
+ const reset = '\x1b[0m';
46
+ console.log(`${tag}${status}${reset} ${method} ${url} ${duration}ms`);
47
+ });
48
+ next();
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // CDP helper — wait for the DevTools WebSocket endpoint to become available
52
+ // ---------------------------------------------------------------------------
53
+ async function waitForCDP(port, retries = 30, delayMs = 500) {
54
+ for (let i = 0; i < retries; i++) {
55
+ try {
56
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
57
+ const json = (await res.json());
58
+ return json.webSocketDebuggerUrl;
59
+ }
60
+ catch {
61
+ await new Promise((r) => setTimeout(r, delayMs));
62
+ }
63
+ }
64
+ throw new Error(`CDP did not become available on port ${port}`);
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // ANSI color helpers
68
+ // ---------------------------------------------------------------------------
69
+ const ANSI_RED = '\x1b[31m';
70
+ const ANSI_YELLOW = '\x1b[33m';
71
+ const ANSI_CYAN = '\x1b[36m';
72
+ const ANSI_RESET = '\x1b[0m';
73
+ function pipeChildOutput(child, label) {
74
+ child.stdout?.on('data', (data) => {
75
+ process.stdout.write(`[${label}:out] ${data}`);
76
+ });
77
+ child.stderr?.on('data', (data) => {
78
+ process.stderr.write(`[${label}:err] ${data}`);
79
+ });
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // Port selection — tries the preferred port, falls back to OS-assigned
83
+ // ---------------------------------------------------------------------------
84
+ function tryListenOnPort(port) {
85
+ return new Promise((resolve, reject) => {
86
+ const server = createNetServer();
87
+ server.on('error', reject);
88
+ // Bind on 127.0.0.1 specifically — Chrome's --remote-debugging-port binds
89
+ // on 127.0.0.1, so checking on 0.0.0.0/:: would miss the conflict.
90
+ server.listen(port, '127.0.0.1', () => {
91
+ const addr = server.address();
92
+ const assignedPort = addr && typeof addr === 'object' ? addr.port : port;
93
+ server.close(() => resolve(assignedPort));
94
+ });
95
+ });
96
+ }
97
+ async function findAvailablePort(preferred) {
98
+ try {
99
+ return await tryListenOnPort(preferred);
100
+ }
101
+ catch (err) {
102
+ if (err.code === 'EADDRINUSE') {
103
+ return tryListenOnPort(0);
104
+ }
105
+ throw err;
106
+ }
107
+ }
108
+ function formatPreviewProperties(properties) {
109
+ return properties.map((p) => {
110
+ let val;
111
+ if (p.type === 'object')
112
+ val = p.subtype === 'array' ? '[...]' : '{...}';
113
+ else if (p.type === 'string')
114
+ val = `"${p.value}"`;
115
+ else
116
+ val = p.value;
117
+ return `${p.name}: ${val}`;
118
+ }).join(', ');
119
+ }
120
+ function formatRemoteObject(obj) {
121
+ if (obj.type === 'undefined')
122
+ return 'undefined';
123
+ if (obj.type === 'object' && obj.subtype === 'null')
124
+ return 'null';
125
+ // Format objects/arrays using preview properties when available
126
+ if (obj.type === 'object' && obj.preview?.properties && obj.preview.properties.length > 0) {
127
+ const inner = formatPreviewProperties(obj.preview.properties);
128
+ const suffix = obj.preview.overflow ? ', ...' : '';
129
+ if (obj.subtype === 'array')
130
+ return `[${inner}${suffix}]`;
131
+ return `{ ${inner}${suffix} }`;
132
+ }
133
+ if (obj.preview?.description)
134
+ return obj.preview.description;
135
+ if (obj.description !== undefined)
136
+ return obj.description;
137
+ if (obj.value !== undefined)
138
+ return String(obj.value);
139
+ return `[${obj.type}]`;
140
+ }
141
+ function colorForType(type) {
142
+ switch (type) {
143
+ case 'error': return ANSI_RED;
144
+ case 'warning': return ANSI_YELLOW;
145
+ default: return ANSI_CYAN;
146
+ }
147
+ }
148
+ async function findPageTarget(cdpPort, pageUrl) {
149
+ try {
150
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json`);
151
+ const targets = (await res.json());
152
+ const match = targets.find((t) => t.type === 'page' && t.url.includes(`localhost:${pageUrl}`) && t.webSocketDebuggerUrl);
153
+ return match ? { webSocketDebuggerUrl: match.webSocketDebuggerUrl } : null;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ async function attachConsoleForwarder(cdpPort, pageUrl) {
160
+ const pageDedup = new CliLogDedup('[page]');
161
+ const connect = async () => {
162
+ // Poll for the page target
163
+ let target = null;
164
+ for (let i = 0; i < 20; i++) {
165
+ target = await findPageTarget(cdpPort, pageUrl);
166
+ if (target)
167
+ break;
168
+ await new Promise((r) => setTimeout(r, 500));
169
+ }
170
+ if (!target) {
171
+ console.log('[page] Could not find page target — console forwarding disabled');
172
+ return;
173
+ }
174
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
175
+ ws.on('open', () => {
176
+ ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
177
+ });
178
+ ws.on('message', (raw) => {
179
+ try {
180
+ const msg = JSON.parse(String(raw));
181
+ if (msg.method === 'Runtime.consoleAPICalled') {
182
+ const params = msg.params;
183
+ const type = params.type;
184
+ const color = colorForType(type);
185
+ const argsStr = params.args.map(formatRemoteObject).join(' ');
186
+ const line = `[page:${type}] ${argsStr}`;
187
+ if (pageDedup.shouldLog(line)) {
188
+ console.log(`${color}[page:${type}]${ANSI_RESET} ${argsStr}`);
189
+ }
190
+ }
191
+ if (msg.method === 'Runtime.exceptionThrown') {
192
+ const params = msg.params;
193
+ const details = params.exceptionDetails;
194
+ const desc = details.exception?.description ?? details.text;
195
+ console.log(`${ANSI_RED}[page:exception]${ANSI_RESET} ${desc}`);
196
+ if (details.stackTrace) {
197
+ for (const frame of details.stackTrace.callFrames) {
198
+ const fn = frame.functionName || '<anonymous>';
199
+ console.log(`${ANSI_RED} at ${fn} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})${ANSI_RESET}`);
200
+ }
201
+ }
202
+ }
203
+ }
204
+ catch {
205
+ // Ignore malformed messages
206
+ }
207
+ });
208
+ ws.on('close', () => {
209
+ // Reconnect after a short delay (page may have reloaded)
210
+ setTimeout(() => { connect(); }, 1000);
211
+ });
212
+ ws.on('error', () => {
213
+ // Error will trigger close, which handles reconnection
214
+ });
215
+ };
216
+ await connect();
217
+ }
218
+ // ---------------------------------------------------------------------------
219
+ // Main
220
+ // ---------------------------------------------------------------------------
221
+ const PREFERRED_SERVE_PORT = parseInt(process.env['PORT'] ?? '5710', 10);
222
+ const PREFERRED_CDP_PORT = RUNTIME_FLAGS.cdpPort;
223
+ const PREFERRED_HMR_PORT = 24679;
224
+ async function main() {
225
+ // Resolve available ports before anything else — serve port must be known
226
+ // before Chrome launches (the launch URL contains it).
227
+ const SERVE_PORT = await findAvailablePort(PREFERRED_SERVE_PORT);
228
+ // For Chrome CDP, we pass port 0 to let Chrome pick any available port,
229
+ // then parse the actual port from its stderr. This avoids race conditions
230
+ // where Node's port probe succeeds but Chrome still can't bind the port.
231
+ // Electron mode keeps the preferred port (external CDP, not launched by us).
232
+ const REQUESTED_CDP_PORT = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
233
+ let CDP_PORT = ELECTRON_MODE ? PREFERRED_CDP_PORT : 0;
234
+ const HMR_PORT = DEV_MODE
235
+ ? await findAvailablePort(PREFERRED_HMR_PORT)
236
+ : PREFERRED_HMR_PORT;
237
+ const SERVE_ORIGIN = `http://localhost:${SERVE_PORT}`;
238
+ if (SERVE_PORT !== PREFERRED_SERVE_PORT) {
239
+ console.log(`Port ${PREFERRED_SERVE_PORT} in use, serving on port ${SERVE_PORT}`);
240
+ }
241
+ if (DEV_MODE && HMR_PORT !== PREFERRED_HMR_PORT) {
242
+ console.log(`HMR port ${PREFERRED_HMR_PORT} in use, using port ${HMR_PORT}`);
243
+ }
244
+ if (DEV_MODE) {
245
+ console.log('Starting in dev mode (Vite HMR enabled)');
246
+ }
247
+ if (SERVE_ONLY) {
248
+ console.log(`Starting in serve-only mode (reusing external CDP on port ${CDP_PORT})`);
249
+ }
250
+ if (ELECTRON_MODE) {
251
+ console.log('Starting in Electron mode');
252
+ }
253
+ let launchedBrowserProcess = null;
254
+ let launchedBrowserLabel = 'Browser';
255
+ let overlayInjector = null;
256
+ let shuttingDown = false;
257
+ // Tray join URL discovered from an existing leader on the preferred port.
258
+ // Populated in Electron mode when auto-discovering the leader's tray.
259
+ let discoveredTrayJoinUrl = RUNTIME_FLAGS.joinUrl ?? null;
260
+ // 1. Launch Chrome unless an external CDP provider is already running.
261
+ if (ELECTRON_MODE && !SERVE_ONLY) {
262
+ if (!ELECTRON_APP) {
263
+ console.error('Electron mode requires an app path. Pass --electron <path> or --electron-app=<path>.');
264
+ process.exit(1);
265
+ }
266
+ try {
267
+ const { child, displayName } = await launchElectronApp({
268
+ appPath: ELECTRON_APP,
269
+ cdpPort: CDP_PORT,
270
+ kill: KILL_EXISTING_ELECTRON_APP,
271
+ });
272
+ launchedBrowserProcess = child;
273
+ launchedBrowserLabel = displayName;
274
+ pipeChildOutput(child, 'electron-app');
275
+ child.on('exit', (code) => {
276
+ if (shuttingDown)
277
+ return;
278
+ console.log(`${displayName} exited with code ${code}`);
279
+ process.exit(0);
280
+ });
281
+ console.log(`Waiting for ${displayName} CDP on port ${CDP_PORT}...`);
282
+ await waitForCDP(CDP_PORT, 40, 500);
283
+ console.log(`Connected to ${displayName} on CDP port ${CDP_PORT}`);
284
+ // Auto-discover leader's tray join URL when another instance runs on the preferred port.
285
+ // The leader may still be creating its tray session, so retry a few times.
286
+ if (!discoveredTrayJoinUrl && SERVE_PORT !== PREFERRED_SERVE_PORT) {
287
+ const leaderOrigin = `http://localhost:${PREFERRED_SERVE_PORT}`;
288
+ for (let attempt = 0; attempt < 5 && !discoveredTrayJoinUrl; attempt++) {
289
+ try {
290
+ const resp = await fetch(`${leaderOrigin}/api/tray-status`, { signal: AbortSignal.timeout(3000) });
291
+ if (resp.ok) {
292
+ const status = await resp.json();
293
+ if (status.joinUrl) {
294
+ discoveredTrayJoinUrl = status.joinUrl;
295
+ console.log(`Discovered leader tray join URL: ${status.joinUrl}`);
296
+ }
297
+ else if (status.state === 'connecting') {
298
+ // Leader is still setting up — wait and retry
299
+ await new Promise(r => setTimeout(r, 2000));
300
+ }
301
+ else {
302
+ console.log(`Leader on port ${PREFERRED_SERVE_PORT} has no active tray (state: ${status.state ?? 'unknown'})`);
303
+ break;
304
+ }
305
+ }
306
+ else {
307
+ break;
308
+ }
309
+ }
310
+ catch {
311
+ // Leader not reachable or no tray status endpoint — continue without tray
312
+ break;
313
+ }
314
+ }
315
+ }
316
+ }
317
+ catch (error) {
318
+ if (error instanceof ElectronAppAlreadyRunningError) {
319
+ console.error(error.message);
320
+ process.exit(1);
321
+ }
322
+ throw error;
323
+ }
324
+ }
325
+ else if (!SERVE_ONLY) {
326
+ let browserLaunchUrl = resolveCliBrowserLaunchUrl({
327
+ serveOrigin: SERVE_ORIGIN,
328
+ lead: RUNTIME_FLAGS.lead,
329
+ leadWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl,
330
+ envWorkerBaseUrl: process.env['WORKER_BASE_URL'] ?? null,
331
+ join: RUNTIME_FLAGS.join,
332
+ joinUrl: RUNTIME_FLAGS.joinUrl,
333
+ });
334
+ // Append optional prompt parameter
335
+ if (RUNTIME_FLAGS.prompt) {
336
+ const sep = browserLaunchUrl.includes('?') ? '&' : '?';
337
+ browserLaunchUrl += `${sep}prompt=${encodeURIComponent(RUNTIME_FLAGS.prompt)}`;
338
+ }
339
+ if (RUNTIME_FLAGS.join) {
340
+ console.log(`Join launch URL: ${browserLaunchUrl}`);
341
+ }
342
+ else if (RUNTIME_FLAGS.lead) {
343
+ console.log(`Lead launch URL: ${browserLaunchUrl}`);
344
+ }
345
+ const chromeProfile = (() => {
346
+ try {
347
+ return resolveChromeLaunchProfile({
348
+ projectRoot: PROJECT_ROOT,
349
+ tmpDir: process.env['TMPDIR'] ?? '/tmp',
350
+ profile: RUNTIME_FLAGS.profile,
351
+ });
352
+ }
353
+ catch (error) {
354
+ console.error(error instanceof Error ? error.message : String(error));
355
+ process.exit(1);
356
+ }
357
+ throw new Error('unreachable');
358
+ })();
359
+ const chromePath = findChromeExecutable({
360
+ executablePreference: !DEV_MODE && !chromeProfile.id ? 'installed' : 'chrome-for-testing',
361
+ });
362
+ if (!chromePath) {
363
+ console.error('Could not find Chrome/Chromium. Please install Chrome or set CHROME_PATH.');
364
+ process.exit(1);
365
+ }
366
+ console.log(`Found Chrome: ${chromePath}`);
367
+ if (chromeProfile.id) {
368
+ await ensureQaProfileScaffold(PROJECT_ROOT);
369
+ }
370
+ if (chromeProfile.extensionPath && !existsSync(chromeProfile.extensionPath)) {
371
+ console.error(`Extension profile requires ${chromeProfile.extensionPath}. Run \`npm run qa:setup\` or \`npm run build:extension\` first.`);
372
+ process.exit(1);
373
+ }
374
+ if (chromeProfile.id) {
375
+ console.log(`Using QA Chrome profile: ${chromeProfile.id}`);
376
+ console.log(`Profile directory: ${chromeProfile.userDataDir}`);
377
+ if (chromeProfile.extensionPath) {
378
+ console.log(`Auto-loading unpacked extension from ${chromeProfile.extensionPath}`);
379
+ }
380
+ }
381
+ const chromeArgs = buildChromeLaunchArgs({
382
+ cdpPort: REQUESTED_CDP_PORT,
383
+ launchUrl: browserLaunchUrl,
384
+ profile: chromeProfile,
385
+ });
386
+ launchedBrowserProcess = spawn(chromePath, chromeArgs, {
387
+ stdio: ['ignore', 'pipe', 'pipe'],
388
+ detached: false,
389
+ });
390
+ launchedBrowserLabel = chromeProfile.displayName;
391
+ // Parse the actual CDP port from Chrome's stderr before piping output.
392
+ // Chrome prints "DevTools listening on ws://HOST:PORT/..." to stderr.
393
+ const actualCdpPort = await waitForCdpPortFromStderr(launchedBrowserProcess);
394
+ CDP_PORT = actualCdpPort;
395
+ console.log(`Chrome CDP listening on port ${CDP_PORT}`);
396
+ pipeChildOutput(launchedBrowserProcess, 'chrome');
397
+ launchedBrowserProcess.on('exit', (code) => {
398
+ if (shuttingDown)
399
+ return;
400
+ console.log(`Chrome exited with code ${code}`);
401
+ process.exit(0);
402
+ });
403
+ }
404
+ // 3. Set up express app with request logging
405
+ const app = express();
406
+ app.use(requestLogger);
407
+ // ---------------------------------------------------------------------------
408
+ // Lick system — WebSocket bridge for webhooks/crontasks (all logic in browser)
409
+ // ---------------------------------------------------------------------------
410
+ // WebSocket for bidirectional communication with browser
411
+ const lickWss = new WebSocketServer({ noServer: true });
412
+ const lickClients = new Set();
413
+ const pendingRequests = new Map();
414
+ let requestIdCounter = 0;
415
+ lickWss.on('connection', (ws) => {
416
+ lickClients.add(ws);
417
+ console.log('[licks] Browser client connected');
418
+ ws.on('message', (data) => {
419
+ try {
420
+ const msg = JSON.parse(data.toString());
421
+ // Handle responses to pending requests
422
+ if (msg.type === 'response' && msg.requestId) {
423
+ const pending = pendingRequests.get(msg.requestId);
424
+ if (pending) {
425
+ pendingRequests.delete(msg.requestId);
426
+ if (msg.error) {
427
+ pending.reject(new Error(msg.error));
428
+ }
429
+ else {
430
+ pending.resolve(msg.data);
431
+ }
432
+ }
433
+ }
434
+ }
435
+ catch {
436
+ // Ignore invalid messages
437
+ }
438
+ });
439
+ ws.on('close', () => {
440
+ lickClients.delete(ws);
441
+ console.log('[licks] Browser client disconnected');
442
+ });
443
+ });
444
+ /** Send a request to the browser and wait for response */
445
+ function sendLickRequest(type, data, timeout = 5000) {
446
+ return new Promise((resolve, reject) => {
447
+ const requestId = `req_${++requestIdCounter}`;
448
+ const msg = JSON.stringify({ type, requestId, ...data });
449
+ // Find a connected client
450
+ const client = Array.from(lickClients).find(c => c.readyState === WebSocket.OPEN);
451
+ if (!client) {
452
+ reject(new Error('No browser connected'));
453
+ return;
454
+ }
455
+ // Set up timeout
456
+ const timer = setTimeout(() => {
457
+ pendingRequests.delete(requestId);
458
+ reject(new Error('Request timeout'));
459
+ }, timeout);
460
+ pendingRequests.set(requestId, {
461
+ resolve: (data) => {
462
+ clearTimeout(timer);
463
+ resolve(data);
464
+ },
465
+ reject: (err) => {
466
+ clearTimeout(timer);
467
+ reject(err);
468
+ },
469
+ });
470
+ client.send(msg);
471
+ });
472
+ }
473
+ /** Broadcast an event to all connected browsers (no response expected) */
474
+ function broadcastLickEvent(event) {
475
+ const msg = JSON.stringify(event);
476
+ for (const client of lickClients) {
477
+ if (client.readyState === WebSocket.OPEN) {
478
+ client.send(msg);
479
+ }
480
+ }
481
+ }
482
+ // ---------------------------------------------------------------------------
483
+ // OAuth callback — generic redirect target for OAuth providers (implicit + PKCE)
484
+ // ---------------------------------------------------------------------------
485
+ // Pending OAuth result for server-side relay (Electron overlay can't use window.opener)
486
+ let pendingOAuthResult = null;
487
+ app.get('/auth/callback', (_req, res) => {
488
+ // The callback page tries window.opener.postMessage first (works in CLI popup mode).
489
+ // If window.opener is null (Electron overlay — opens system browser), it falls back
490
+ // to POSTing the result to /api/oauth-result for the UI to poll.
491
+ res.send(`<!DOCTYPE html><html><body><script>
492
+ var q = new URLSearchParams(location.search);
493
+ var h = new URLSearchParams(location.hash.replace(/^#/, ''));
494
+ var payload = {
495
+ type: 'oauth-callback',
496
+ redirectUrl: location.href,
497
+ code: q.get('code'),
498
+ state: q.get('state') || h.get('state'),
499
+ error: q.get('error') || h.get('error'),
500
+ access_token: h.get('access_token'),
501
+ expires_in: h.get('expires_in'),
502
+ token_type: h.get('token_type')
503
+ };
504
+ if (window.opener) {
505
+ window.opener.postMessage(payload, '*');
506
+ } else {
507
+ fetch('/api/oauth-result', {
508
+ method: 'POST',
509
+ headers: { 'Content-Type': 'application/json' },
510
+ body: JSON.stringify(payload)
511
+ }).catch(function(err) { console.error('[oauth-callback] Failed to relay result to server:', err); });
512
+ }
513
+ window.close();
514
+ </script><p>Completing login... you can close this window.</p></body></html>`);
515
+ });
516
+ app.post('/api/oauth-result', express.json(), (req, res) => {
517
+ const body = req.body;
518
+ const redirectUrl = typeof body.redirectUrl === 'string' ? body.redirectUrl : '';
519
+ if (!redirectUrl) {
520
+ console.warn('[oauth-result] Received callback with empty redirectUrl');
521
+ }
522
+ pendingOAuthResult = {
523
+ redirectUrl,
524
+ error: typeof body.error === 'string' ? body.error : undefined,
525
+ };
526
+ res.json({ ok: true });
527
+ });
528
+ app.get('/api/oauth-result', (_req, res) => {
529
+ if (pendingOAuthResult) {
530
+ const result = pendingOAuthResult;
531
+ pendingOAuthResult = null;
532
+ res.json(result);
533
+ }
534
+ else {
535
+ res.status(204).end();
536
+ }
537
+ });
538
+ app.use(express.json({ limit: '50mb' }));
539
+ app.get('/api/runtime-config', (_req, res) => {
540
+ res.json({
541
+ trayWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl ?? process.env['WORKER_BASE_URL'] ?? null,
542
+ trayJoinUrl: discoveredTrayJoinUrl ?? null,
543
+ });
544
+ });
545
+ // Tray status API — forwards to browser to get leader tray join info
546
+ app.get('/api/tray-status', async (_req, res) => {
547
+ try {
548
+ const data = await sendLickRequest('tray_status', {});
549
+ res.json(data);
550
+ }
551
+ catch (err) {
552
+ res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
553
+ }
554
+ });
555
+ // Webhook management API — forwards to browser
556
+ app.get('/api/webhooks', async (_req, res) => {
557
+ try {
558
+ const data = await sendLickRequest('list_webhooks', {});
559
+ res.json(data);
560
+ }
561
+ catch (err) {
562
+ res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
563
+ }
564
+ });
565
+ app.post('/api/webhooks', async (req, res) => {
566
+ try {
567
+ const data = await sendLickRequest('create_webhook', req.body);
568
+ res.json(data);
569
+ }
570
+ catch (err) {
571
+ const msg = err instanceof Error ? err.message : String(err);
572
+ res.status(msg.includes('Invalid') ? 400 : 503).json({ error: msg });
573
+ }
574
+ });
575
+ app.delete('/api/webhooks/:id', async (req, res) => {
576
+ try {
577
+ const data = await sendLickRequest('delete_webhook', { id: req.params.id });
578
+ if (data.error) {
579
+ res.status(404).json({ error: data.error });
580
+ }
581
+ else {
582
+ res.json(data);
583
+ }
584
+ }
585
+ catch (err) {
586
+ res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
587
+ }
588
+ });
589
+ // Webhook receiver — handle CORS preflight
590
+ app.options('/webhooks/:id', (_req, res) => {
591
+ res.set({
592
+ 'Access-Control-Allow-Origin': '*',
593
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
594
+ 'Access-Control-Allow-Headers': 'Content-Type',
595
+ });
596
+ res.sendStatus(204);
597
+ });
598
+ // Webhook receiver — forwards POST to browser for processing
599
+ app.post('/webhooks/:id', async (req, res) => {
600
+ res.set({ 'Access-Control-Allow-Origin': '*' });
601
+ const { id } = req.params;
602
+ // Collect body
603
+ let body = req.body;
604
+ if (!body || Object.keys(body).length === 0) {
605
+ const chunks = [];
606
+ for await (const chunk of req) {
607
+ chunks.push(Buffer.from(chunk));
608
+ }
609
+ const raw = Buffer.concat(chunks).toString('utf-8');
610
+ try {
611
+ body = JSON.parse(raw);
612
+ }
613
+ catch {
614
+ body = { raw };
615
+ }
616
+ }
617
+ // Forward to browser for processing
618
+ broadcastLickEvent({
619
+ type: 'webhook_event',
620
+ webhookId: id,
621
+ timestamp: new Date().toISOString(),
622
+ headers: req.headers,
623
+ body,
624
+ });
625
+ res.json({ ok: true, received: true });
626
+ });
627
+ // Cron task management API — forwards to browser
628
+ app.get('/api/crontasks', async (_req, res) => {
629
+ try {
630
+ const data = await sendLickRequest('list_crontasks', {});
631
+ res.json(data);
632
+ }
633
+ catch (err) {
634
+ res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
635
+ }
636
+ });
637
+ app.post('/api/crontasks', async (req, res) => {
638
+ try {
639
+ const data = await sendLickRequest('create_crontask', req.body);
640
+ res.json(data);
641
+ }
642
+ catch (err) {
643
+ const msg = err instanceof Error ? err.message : String(err);
644
+ res.status(msg.includes('Invalid') || msg.includes('required') ? 400 : 503).json({ error: msg });
645
+ }
646
+ });
647
+ app.delete('/api/crontasks/:id', async (req, res) => {
648
+ try {
649
+ const data = await sendLickRequest('delete_crontask', { id: req.params.id });
650
+ if (data.error) {
651
+ res.status(404).json({ error: data.error });
652
+ }
653
+ else {
654
+ res.json(data);
655
+ }
656
+ }
657
+ catch (err) {
658
+ res.status(503).json({ error: err instanceof Error ? err.message : 'Browser not connected' });
659
+ }
660
+ });
661
+ // Fetch proxy — forwards cross-origin requests from the browser to bypass CORS.
662
+ // Used by just-bash's curl which calls the browser's fetch() API.
663
+ // Note: express.json() may have already parsed the body, so we check req.body first.
664
+ app.all('/api/fetch-proxy', async (req, res) => {
665
+ // Get the body - either from express.json() parsed body or collect raw chunks
666
+ let rawBody;
667
+ if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
668
+ // Body was already parsed by express.json() - re-serialize it
669
+ rawBody = Buffer.from(JSON.stringify(req.body), 'utf-8');
670
+ }
671
+ else {
672
+ // Collect raw body manually (for non-JSON content types)
673
+ const chunks = [];
674
+ for await (const chunk of req) {
675
+ chunks.push(Buffer.from(chunk));
676
+ }
677
+ rawBody = Buffer.concat(chunks);
678
+ }
679
+ const targetUrl = req.headers['x-target-url'];
680
+ if (!targetUrl) {
681
+ res.status(400).json({ error: 'Missing X-Target-URL header' });
682
+ return;
683
+ }
684
+ try {
685
+ const fetchInit = {
686
+ method: req.method,
687
+ redirect: 'follow', // Follow redirects for git protocol compatibility
688
+ };
689
+ // Forward relevant headers (excluding hop-by-hop and proxy headers)
690
+ const skipHeaders = new Set(['host', 'connection', 'x-target-url', 'content-length', 'transfer-encoding']);
691
+ const headers = {};
692
+ for (const [key, value] of Object.entries(req.headers)) {
693
+ if (!skipHeaders.has(key) && typeof value === 'string') {
694
+ headers[key] = value;
695
+ }
696
+ }
697
+ // Always request uncompressed responses from upstream — the proxy doesn't
698
+ // decompress, and the browser→proxy link is localhost (no benefit to compression).
699
+ // Without this, Cloudflare may Brotli-compress the response, the proxy strips
700
+ // Content-Encoding (line below), and the browser receives compressed garbage.
701
+ headers['accept-encoding'] = 'identity';
702
+ if (Object.keys(headers).length > 0)
703
+ fetchInit.headers = headers;
704
+ if (rawBody.length > 0 && !['GET', 'HEAD'].includes(req.method)) {
705
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
706
+ fetchInit.body = rawBody;
707
+ }
708
+ const upstream = await fetch(targetUrl, fetchInit);
709
+ // Forward status, prevent browser caching of proxy responses
710
+ res.status(upstream.status);
711
+ res.setHeader('Cache-Control', 'no-store, no-cache');
712
+ // Forward response headers (strip www-authenticate to prevent
713
+ // the browser from showing a native Basic Auth dialog — isomorphic-git
714
+ // handles 401s through its own onAuth callback)
715
+ upstream.headers.forEach((v, k) => {
716
+ const lower = k.toLowerCase();
717
+ if (lower !== 'transfer-encoding' && lower !== 'content-encoding' && lower !== 'www-authenticate') {
718
+ res.setHeader(k, v);
719
+ }
720
+ });
721
+ // Send body as raw binary - explicitly set content-length and use end()
722
+ // instead of send() to avoid any Express middleware transformations
723
+ const body = await upstream.arrayBuffer();
724
+ const buffer = Buffer.from(body);
725
+ res.setHeader('Content-Length', buffer.length);
726
+ res.end(buffer);
727
+ }
728
+ catch (err) {
729
+ const message = err instanceof Error ? err.message : String(err);
730
+ res.status(502).json({ error: `Proxy fetch failed: ${message}` });
731
+ }
732
+ });
733
+ // Create the HTTP server BEFORE Vite so we can register our upgrade handler first
734
+ const server = createServer(app);
735
+ if (DEV_MODE) {
736
+ // Dev mode: use Vite's dev server as middleware for HMR
737
+ const { createServer: createViteServer } = await import('vite');
738
+ const vite = await createViteServer({
739
+ server: {
740
+ middlewareMode: true,
741
+ hmr: {
742
+ port: HMR_PORT, // Use a separate port for HMR WebSocket to avoid conflicting with /cdp
743
+ },
744
+ },
745
+ root: process.cwd(),
746
+ });
747
+ app.use(vite.middlewares);
748
+ console.log(`Vite dev server middleware attached (HMR active on port ${HMR_PORT})`);
749
+ }
750
+ else {
751
+ // Production mode: serve built static files
752
+ const uiDir = resolve(__dirname, '..', 'ui');
753
+ app.use(express.static(uiDir));
754
+ // SPA fallback — serve index.html for all non-file routes
755
+ app.get('*', (_req, res) => {
756
+ res.sendFile(join(uiDir, 'index.html'));
757
+ });
758
+ }
759
+ // 4. CDP WebSocket proxy at /cdp
760
+ // Use noServer mode so Vite's dev middleware doesn't intercept the upgrade.
761
+ const wss = new WebSocketServer({ noServer: true });
762
+ server.on('upgrade', (request, socket, head) => {
763
+ const { pathname } = new URL(request.url, `http://${request.headers.host}`);
764
+ if (pathname === '/cdp') {
765
+ wss.handleUpgrade(request, socket, head, (ws) => {
766
+ wss.emit('connection', ws, request);
767
+ });
768
+ }
769
+ else if (pathname === '/licks-ws') {
770
+ lickWss.handleUpgrade(request, socket, head, (ws) => {
771
+ lickWss.emit('connection', ws, request);
772
+ });
773
+ }
774
+ // For other paths, do nothing — let Vite handle HMR upgrades
775
+ });
776
+ // ---------------------------------------------------------------------------
777
+ // Shared CDP proxy state — Chrome's browser-level debugger URL only accepts
778
+ // ONE concurrent WebSocket connection. We keep a single chromeWs and swap
779
+ // out the active client when a new one connects.
780
+ // ---------------------------------------------------------------------------
781
+ let cdpUrl = null;
782
+ let chromeWs = null;
783
+ let activeClientWs = null;
784
+ let messageBuffer = null;
785
+ const cdpDedup = new CliLogDedup();
786
+ // Ensure everything is cleaned up when CLI exits
787
+ const gracefulShutdown = async () => {
788
+ if (shuttingDown)
789
+ return;
790
+ shuttingDown = true;
791
+ console.log('\nShutting down...');
792
+ fileLogger.close();
793
+ overlayInjector?.stop();
794
+ overlayInjector = null;
795
+ // Close the shared Chrome WebSocket and all client connections
796
+ if (chromeWs) {
797
+ try {
798
+ chromeWs.close();
799
+ }
800
+ catch { /* ignore */ }
801
+ chromeWs = null;
802
+ }
803
+ if (activeClientWs) {
804
+ try {
805
+ activeClientWs.close();
806
+ }
807
+ catch { /* ignore */ }
808
+ activeClientWs = null;
809
+ }
810
+ for (const client of wss.clients) {
811
+ client.close();
812
+ }
813
+ wss.close();
814
+ // Stop accepting new HTTP connections
815
+ server.close();
816
+ if (launchedBrowserProcess) {
817
+ let browserExited = false;
818
+ launchedBrowserProcess.on('exit', () => { browserExited = true; });
819
+ try {
820
+ const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`);
821
+ const json = (await res.json());
822
+ const browserWs = new WebSocket(json.webSocketDebuggerUrl);
823
+ await new Promise((resolve, reject) => {
824
+ browserWs.on('open', () => {
825
+ browserWs.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
826
+ resolve();
827
+ });
828
+ browserWs.on('error', reject);
829
+ });
830
+ }
831
+ catch {
832
+ // CDP not available — the launched browser may still be starting up; fall through to kill.
833
+ }
834
+ const deadline = Date.now() + 3000;
835
+ while (!browserExited && Date.now() < deadline) {
836
+ await new Promise((r) => setTimeout(r, 100));
837
+ }
838
+ if (!browserExited) {
839
+ try {
840
+ launchedBrowserProcess.kill('SIGKILL');
841
+ }
842
+ catch { /* ignore */ }
843
+ }
844
+ console.log(`${launchedBrowserLabel} closed`);
845
+ }
846
+ process.exit(0);
847
+ };
848
+ process.on('SIGINT', () => { gracefulShutdown(); });
849
+ process.on('SIGTERM', () => { gracefulShutdown(); });
850
+ process.on('exit', () => {
851
+ // Synchronous last-resort cleanup — kill the launched browser if it is still running.
852
+ if (!shuttingDown && launchedBrowserProcess) {
853
+ try {
854
+ launchedBrowserProcess.kill();
855
+ }
856
+ catch { /* ignore */ }
857
+ }
858
+ });
859
+ function ensureChromeConnection(url) {
860
+ return new Promise((resolve, reject) => {
861
+ if (chromeWs && chromeWs.readyState === WebSocket.OPEN) {
862
+ // Already connected — flush any buffered messages and go direct
863
+ if (messageBuffer) {
864
+ for (const msg of messageBuffer) {
865
+ chromeWs.send(String(msg));
866
+ }
867
+ messageBuffer = null;
868
+ }
869
+ resolve();
870
+ return;
871
+ }
872
+ // Clean up old connection
873
+ if (chromeWs) {
874
+ try {
875
+ chromeWs.close();
876
+ }
877
+ catch { /* ignore */ }
878
+ }
879
+ messageBuffer = [];
880
+ chromeWs = new WebSocket(url);
881
+ chromeWs.on('open', () => {
882
+ console.log('[cdp-proxy] chromeWs open');
883
+ // Flush buffered messages
884
+ if (messageBuffer) {
885
+ for (const msg of messageBuffer) {
886
+ chromeWs.send(String(msg));
887
+ }
888
+ messageBuffer = null;
889
+ }
890
+ resolve();
891
+ });
892
+ chromeWs.on('message', (data) => {
893
+ const preview = String(data).slice(0, 200);
894
+ const msg = `[cdp-proxy] Chrome→Client: ${preview}`;
895
+ if (cdpDedup.shouldLog(msg))
896
+ console.debug(msg);
897
+ if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
898
+ activeClientWs.send(String(data));
899
+ }
900
+ });
901
+ chromeWs.on('close', (code, reason) => {
902
+ console.log(`[cdp-proxy] Chrome WS closed. code=${code}, reason=${String(reason)}`);
903
+ chromeWs = null;
904
+ });
905
+ chromeWs.on('error', (err) => {
906
+ console.log(`[cdp-proxy] Chrome WS error: ${err}`);
907
+ chromeWs = null;
908
+ reject(err);
909
+ });
910
+ });
911
+ }
912
+ wss.on('connection', async (clientWs) => {
913
+ try {
914
+ // Close previous client connection — only one client active at a time
915
+ if (activeClientWs && activeClientWs.readyState === WebSocket.OPEN) {
916
+ console.log('[cdp-proxy] Closing previous client connection');
917
+ activeClientWs.close();
918
+ }
919
+ activeClientWs = clientWs;
920
+ console.log('[cdp-proxy] New client connected');
921
+ // Initialize buffer BEFORE any await so messages arriving during
922
+ // waitForCDP or ensureChromeConnection are captured, not dropped.
923
+ if (messageBuffer === null) {
924
+ messageBuffer = [];
925
+ }
926
+ // Register ALL handlers BEFORE any async work so no messages are lost
927
+ clientWs.on('message', (data) => {
928
+ const preview = String(data).slice(0, 200);
929
+ if (chromeWs && chromeWs.readyState === WebSocket.OPEN && messageBuffer === null) {
930
+ const msg = `[cdp-proxy] Client→Chrome: ${preview}`;
931
+ if (cdpDedup.shouldLog(msg))
932
+ console.debug(msg);
933
+ chromeWs.send(String(data));
934
+ }
935
+ else if (messageBuffer !== null) {
936
+ messageBuffer.push(data);
937
+ const msg = `[cdp-proxy] Client→Chrome (buffered): ${preview}`;
938
+ if (cdpDedup.shouldLog(msg))
939
+ console.debug(msg);
940
+ }
941
+ else {
942
+ // Chrome not connected and no buffer — this shouldn't happen but log it
943
+ console.log(`[cdp-proxy] Client→Chrome (DROPPED — no connection): ${preview}`);
944
+ }
945
+ });
946
+ clientWs.on('close', () => {
947
+ console.log('[cdp-proxy] Client disconnected');
948
+ if (activeClientWs === clientWs) {
949
+ activeClientWs = null;
950
+ }
951
+ // Don't close chromeWs — keep it alive for the next client
952
+ });
953
+ clientWs.on('error', (err) => {
954
+ console.log(`[cdp-proxy] Client WS error: ${err}`);
955
+ if (activeClientWs === clientWs) {
956
+ activeClientWs = null;
957
+ }
958
+ });
959
+ // NOW do async work — messages arriving during these awaits are buffered
960
+ if (!cdpUrl) {
961
+ cdpUrl = await waitForCDP(CDP_PORT);
962
+ console.log(`[cdp-proxy] CDP available at: ${cdpUrl}`);
963
+ }
964
+ await ensureChromeConnection(cdpUrl);
965
+ }
966
+ catch (err) {
967
+ console.error('[cdp-proxy] Connection error:', err);
968
+ clientWs.close();
969
+ }
970
+ });
971
+ server.listen(SERVE_PORT, '127.0.0.1', () => {
972
+ console.log(`Serving UI at ${SERVE_ORIGIN}`);
973
+ console.log(`CDP proxy at ws://localhost:${SERVE_PORT}/cdp`);
974
+ fileLogger.log('info', 'CLI server started', { port: SERVE_PORT, cdpPort: CDP_PORT, devMode: DEV_MODE, electronMode: ELECTRON_MODE });
975
+ // Pre-connect to Chrome's CDP so the proxy is warm when the first client connects.
976
+ // Without this, the first browser automation command has to wait for CDP discovery + WS handshake.
977
+ (async () => {
978
+ try {
979
+ cdpUrl = await waitForCDP(CDP_PORT);
980
+ console.log(`[cdp-proxy] Pre-connected: CDP available at ${cdpUrl}`);
981
+ await ensureChromeConnection(cdpUrl);
982
+ console.log('[cdp-proxy] Chrome WebSocket ready (pre-warmed)');
983
+ }
984
+ catch (err) {
985
+ console.log('[cdp-proxy] Pre-connect failed (will retry on first client):', err);
986
+ }
987
+ })();
988
+ if (ELECTRON_MODE) {
989
+ void (async () => {
990
+ try {
991
+ overlayInjector = await ElectronOverlayInjector.create({
992
+ cdpPort: CDP_PORT,
993
+ servePort: SERVE_PORT,
994
+ dev: DEV_MODE,
995
+ projectRoot: PROJECT_ROOT,
996
+ });
997
+ await overlayInjector.start();
998
+ console.log('[electron-float] Overlay injector is watching Electron page targets');
999
+ }
1000
+ catch (error) {
1001
+ const message = error instanceof Error ? error.message : String(error);
1002
+ console.error('[electron-float] Failed to start overlay injector:', message);
1003
+ }
1004
+ })();
1005
+ }
1006
+ if (!ELECTRON_MODE) {
1007
+ setTimeout(() => {
1008
+ attachConsoleForwarder(CDP_PORT, String(SERVE_PORT)).catch((err) => {
1009
+ console.error('[page] Console forwarder error:', err);
1010
+ });
1011
+ }, 2500);
1012
+ }
1013
+ });
1014
+ }
1015
+ main().catch((err) => {
1016
+ console.error('Fatal error:', err);
1017
+ const errorData = err instanceof Error
1018
+ ? { name: err.name, message: err.message, stack: err.stack }
1019
+ : { value: String(err) };
1020
+ fileLogger.log('error', 'Fatal error', errorData);
1021
+ fileLogger.close();
1022
+ process.exit(1);
1023
+ });