@bool01master/gemini-web-mcp 1.0.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.
@@ -0,0 +1,749 @@
1
+ import fs from "node:fs/promises";
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ const DEFAULT_HOST = process.env.GEMINI_BRIDGE_HOST || "127.0.0.1";
7
+ const DEFAULT_PORT = Number(process.env.GEMINI_BRIDGE_PORT || "8765");
8
+ const DEFAULT_OUTPUT_DIR = path.resolve(
9
+ process.env.GEMINI_WEB_OUTPUT_DIR || "artifacts",
10
+ );
11
+ const CLIENT_STALE_MS = 90_000;
12
+ const POLL_TIMEOUT_MS = 25_000;
13
+ const COMMAND_TIMEOUT_MS = 180_000;
14
+
15
+ /**
16
+ * Server-side safety-net: extract only the last model response from the raw
17
+ * text that the content script returns. The content script may return either
18
+ * a clean model response (if DOM selectors matched) or a noisy full-page diff.
19
+ * This function handles both cases.
20
+ */
21
+ function cleanResponseText(text) {
22
+ if (!text) return "";
23
+
24
+ // --- Try to extract the last model response via turn separators ---
25
+ // Gemini's conversation text contains "Gemini 说\n" / "Gemini said\n" and
26
+ // "你说\n" / "You said\n" markers between turns.
27
+ const modelMarkerRe = /\n(Gemini\s*(?:[说曰]|said))\s*\n/gi;
28
+ const positions = [];
29
+ let m;
30
+ while ((m = modelMarkerRe.exec(text)) !== null) {
31
+ positions.push(m.index + m[0].length);
32
+ }
33
+
34
+ if (positions.length > 0) {
35
+ let response = text.slice(positions[positions.length - 1]);
36
+
37
+ // Trim at next user turn
38
+ const userTurn = response.search(/\n(?:你[说曰]|You\s+said)\s*\n/i);
39
+ if (userTurn > 0) response = response.slice(0, userTurn);
40
+
41
+ // Trim footer: "工具\n" or "Gemini 是一款 AI ..."
42
+ const footer = response.search(
43
+ /\n(?:工具|Tools)\s*\n|Gemini\s*(?:是一款|is\s+a)\s*AI/i,
44
+ );
45
+ if (footer > 0) response = response.slice(0, footer);
46
+
47
+ // Trim trailing mode labels
48
+ response = response.replace(/\n(?:快速|思考)\s*$/i, "");
49
+
50
+ response = response.trim();
51
+ if (response.length > 3) return response;
52
+ }
53
+
54
+ // --- Fallback: just strip known noise ---
55
+ text = text.replace(/\n?Gemini\s*(?:是一款|is\s+a)\s*AI.*$/is, "");
56
+ text = text.replace(/\n(?:快速|思考|工具|Tools)\s*$/i, "");
57
+ text = text.replace(/^Gemini\s*[说曰]\s*/i, "");
58
+
59
+ return text.trim();
60
+ }
61
+
62
+ function ensureJsonHeaders(response, statusCode = 200) {
63
+ response.writeHead(statusCode, {
64
+ "Access-Control-Allow-Origin": "*",
65
+ "Access-Control-Allow-Headers": "Content-Type",
66
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
67
+ "Content-Type": "application/json; charset=utf-8",
68
+ "Cache-Control": "no-store",
69
+ });
70
+ }
71
+
72
+ async function readJsonBody(request) {
73
+ const chunks = [];
74
+
75
+ for await (const chunk of request) {
76
+ chunks.push(chunk);
77
+ }
78
+
79
+ const raw = Buffer.concat(chunks).toString("utf8");
80
+ return raw ? JSON.parse(raw) : {};
81
+ }
82
+
83
+ function now() {
84
+ return Date.now();
85
+ }
86
+
87
+ function slugify(text) {
88
+ return text
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, "-")
91
+ .replace(/^-+|-+$/g, "")
92
+ .slice(0, 48) || "prompt";
93
+ }
94
+
95
+ function makeTimestamp() {
96
+ const value = new Date();
97
+ const pad = (input) => String(input).padStart(2, "0");
98
+ return [
99
+ value.getFullYear(),
100
+ pad(value.getMonth() + 1),
101
+ pad(value.getDate()),
102
+ "-",
103
+ pad(value.getHours()),
104
+ pad(value.getMinutes()),
105
+ pad(value.getSeconds()),
106
+ ].join("");
107
+ }
108
+
109
+ function commandTimeoutForWait(waitTimeoutMs, explicitTimeoutMs) {
110
+ if (explicitTimeoutMs) {
111
+ return explicitTimeoutMs;
112
+ }
113
+
114
+ return Math.max(Number(waitTimeoutMs || 0) + 30_000, COMMAND_TIMEOUT_MS);
115
+ }
116
+
117
+ export class GeminiExtensionBridge {
118
+ constructor(options = {}) {
119
+ this.host = options.host || DEFAULT_HOST;
120
+ this.port = options.port || DEFAULT_PORT;
121
+ this.outputDir = path.resolve(options.outputDir || DEFAULT_OUTPUT_DIR);
122
+ this.server = null;
123
+ this.clients = new Map();
124
+ this.pendingCommands = new Map();
125
+ }
126
+
127
+ async start() {
128
+ if (this.server) {
129
+ return;
130
+ }
131
+
132
+ await fs.mkdir(this.outputDir, { recursive: true });
133
+
134
+ this.server = http.createServer(async (request, response) => {
135
+ try {
136
+ await this.handleRequest(request, response);
137
+ } catch (error) {
138
+ ensureJsonHeaders(response, 500);
139
+ response.end(
140
+ JSON.stringify({
141
+ ok: false,
142
+ error: error instanceof Error ? error.message : String(error),
143
+ }),
144
+ );
145
+ }
146
+ });
147
+
148
+ await new Promise((resolve, reject) => {
149
+ this.server.once("error", reject);
150
+ this.server.listen(this.port, this.host, () => {
151
+ this.server.off("error", reject);
152
+ resolve();
153
+ });
154
+ });
155
+ }
156
+
157
+ async close() {
158
+ for (const client of this.clients.values()) {
159
+ this.clearPendingPoll(client);
160
+ }
161
+
162
+ for (const pending of this.pendingCommands.values()) {
163
+ clearTimeout(pending.timer);
164
+ pending.reject(new Error("Bridge server closed."));
165
+ }
166
+ this.pendingCommands.clear();
167
+
168
+ if (!this.server) {
169
+ return;
170
+ }
171
+
172
+ await new Promise((resolve, reject) => {
173
+ this.server.close((error) => {
174
+ if (error) {
175
+ reject(error);
176
+ return;
177
+ }
178
+
179
+ resolve();
180
+ });
181
+ });
182
+
183
+ this.server = null;
184
+ }
185
+
186
+ async getStatus() {
187
+ this.pruneStaleClients();
188
+
189
+ return {
190
+ ok: true,
191
+ host: this.host,
192
+ port: this.port,
193
+ bridgeUrl: `http://${this.host}:${this.port}`,
194
+ clientCount: this.clients.size,
195
+ clients: Array.from(this.clients.values())
196
+ .map((client) => ({
197
+ clientId: client.clientId,
198
+ pageUrl: client.pageUrl,
199
+ title: client.title,
200
+ connectedAt: client.connectedAt,
201
+ lastSeenAt: client.lastSeenAt,
202
+ lastHeartbeat: client.lastHeartbeat,
203
+ capabilities: client.capabilities,
204
+ lastKnownState: client.lastKnownState,
205
+ }))
206
+ .sort((left, right) => right.lastSeenAt - left.lastSeenAt),
207
+ };
208
+ }
209
+
210
+ async runCommand(name, args = {}, options = {}) {
211
+ this.pruneStaleClients();
212
+
213
+ const client = this.selectClient(options.targetClientId);
214
+ if (!client) {
215
+ throw new Error(
216
+ [
217
+ "No Gemini extension client is connected.",
218
+ "Load the unpacked extension from `extension/` and keep a Gemini tab open.",
219
+ ].join(" "),
220
+ );
221
+ }
222
+
223
+ const requestId = randomUUID();
224
+ const timeoutMs = options.timeoutMs || COMMAND_TIMEOUT_MS;
225
+ const payload = {
226
+ requestId,
227
+ name,
228
+ args,
229
+ issuedAt: new Date().toISOString(),
230
+ };
231
+
232
+ const result = new Promise((resolve, reject) => {
233
+ const timer = setTimeout(() => {
234
+ this.pendingCommands.delete(requestId);
235
+ reject(
236
+ new Error(
237
+ `Timed out waiting for extension response to command ${name}.`,
238
+ ),
239
+ );
240
+ }, timeoutMs);
241
+
242
+ this.pendingCommands.set(requestId, {
243
+ requestId,
244
+ clientId: client.clientId,
245
+ resolve,
246
+ reject,
247
+ timer,
248
+ });
249
+ });
250
+
251
+ client.commandQueue.push(payload);
252
+ this.flushPendingPoll(client);
253
+
254
+ return result;
255
+ }
256
+
257
+ async finalizeRunResult(prompt, rawResult, options = {}) {
258
+ const outputDir = path.resolve(options.outputDir || this.outputDir);
259
+ const runDir = path.join(
260
+ outputDir,
261
+ `${makeTimestamp()}-${slugify(prompt)}`,
262
+ );
263
+ await fs.mkdir(runDir, { recursive: true });
264
+
265
+ const savedImages = [];
266
+ const images = Array.isArray(rawResult.images) ? rawResult.images : [];
267
+ const remoteImages = [];
268
+
269
+ for (const [index, image] of images.entries()) {
270
+ if (image?.url) {
271
+ remoteImages.push({
272
+ url: image.url,
273
+ width: image.width ?? null,
274
+ height: image.height ?? null,
275
+ alt: image.alt || "",
276
+ tagName: image.tagName || "",
277
+ error: image.error || null,
278
+ });
279
+ }
280
+
281
+ if (!image?.dataUrl || typeof image.dataUrl !== "string") {
282
+ continue;
283
+ }
284
+
285
+ const extension = this.extensionFromDataUrl(image.dataUrl);
286
+ const targetPath = path.join(runDir, `image-${index + 1}.${extension}`);
287
+ await fs.writeFile(targetPath, this.bufferFromDataUrl(image.dataUrl));
288
+ savedImages.push({
289
+ path: targetPath,
290
+ url: image.url || null,
291
+ mimeType: this.mimeTypeFromDataUrl(image.dataUrl),
292
+ width: image.width ?? null,
293
+ height: image.height ?? null,
294
+ alt: image.alt || "",
295
+ tagName: image.tagName || "",
296
+ });
297
+ }
298
+
299
+ const imageUrls = Array.from(
300
+ new Set(
301
+ [
302
+ ...savedImages.map((item) => item.url).filter(Boolean),
303
+ ...remoteImages.map((item) => item.url).filter(Boolean),
304
+ ],
305
+ ),
306
+ );
307
+
308
+ let downloadResult = null;
309
+ if (
310
+ imageUrls.length > 0 &&
311
+ options.downloadRemoteImages !== false
312
+ ) {
313
+ try {
314
+ downloadResult = await this.runCommand(
315
+ "download_image_urls",
316
+ { urls: imageUrls },
317
+ {
318
+ targetClientId: options.targetClientId,
319
+ timeoutMs: Math.max(60_000, imageUrls.length * 20_000),
320
+ },
321
+ );
322
+ } catch (error) {
323
+ downloadResult = {
324
+ downloads: imageUrls.map((url) => ({
325
+ url,
326
+ dataUrl: null,
327
+ error: error instanceof Error ? error.message : String(error),
328
+ })),
329
+ };
330
+ }
331
+ }
332
+
333
+ const downloadedImages = Array.isArray(downloadResult?.downloads)
334
+ ? downloadResult.downloads
335
+ : [];
336
+
337
+ for (const download of downloadedImages) {
338
+ if (!download?.url) {
339
+ continue;
340
+ }
341
+
342
+ const remoteImage = remoteImages.find((item) => item.url === download.url);
343
+ if (remoteImage) {
344
+ remoteImage.downloadError = download.error || null;
345
+ }
346
+
347
+ if (!download?.dataUrl || typeof download.dataUrl !== "string") {
348
+ continue;
349
+ }
350
+
351
+ const extension = this.extensionFromDataUrl(download.dataUrl);
352
+ const targetPath = path.join(
353
+ runDir,
354
+ `image-${savedImages.length + 1}.${extension}`,
355
+ );
356
+ await fs.writeFile(targetPath, this.bufferFromDataUrl(download.dataUrl));
357
+
358
+ const metadata = remoteImage || {};
359
+ savedImages.push({
360
+ path: targetPath,
361
+ url: download.url,
362
+ mimeType: this.mimeTypeFromDataUrl(download.dataUrl),
363
+ width: metadata.width ?? null,
364
+ height: metadata.height ?? null,
365
+ alt: metadata.alt || "",
366
+ tagName: metadata.tagName || "",
367
+ });
368
+
369
+ if (remoteImage) {
370
+ remoteImage.downloadPath = targetPath;
371
+ }
372
+ }
373
+
374
+ // For images that have URLs but failed to save locally, generate curl commands.
375
+ // Note: Gemini gg-dl URLs require authentication and curl may return 403.
376
+ const savedUrlSet = new Set(savedImages.map((item) => item.url).filter(Boolean));
377
+ const unsavedUrls = imageUrls.filter((url) => !savedUrlSet.has(url));
378
+ const curlCommands = unsavedUrls.map((url, i) => {
379
+ const ext = url.match(/\.(png|jpe?g|gif|webp|svg)/i)?.[1] || "png";
380
+ const outFile = path.join(runDir, `image-curl-${i + 1}.${ext}`);
381
+ return `curl -L -o '${outFile}' '${url}'`;
382
+ });
383
+
384
+ const result = {
385
+ prompt,
386
+ ready: Boolean(rawResult.ready),
387
+ url: rawResult.url || "",
388
+ mode: rawResult.mode || null,
389
+ text: cleanResponseText(rawResult.text || ""),
390
+ imageCountOnPage: Number(rawResult.imageCountOnPage || 0),
391
+ imagePaths: savedImages.map((item) => item.path),
392
+ curlCommands,
393
+ runDir,
394
+ };
395
+
396
+ // Full detail saved to disk for debugging; MCP result stays lean.
397
+ const detailedResult = {
398
+ ...result,
399
+ title: rawResult.title || "",
400
+ modeResult: rawResult.modeResult || null,
401
+ attachmentResult: rawResult.attachmentResult || null,
402
+ images: savedImages,
403
+ imageUrls,
404
+ remoteImages,
405
+ outputDir,
406
+ };
407
+
408
+ await fs.writeFile(
409
+ path.join(runDir, "result.json"),
410
+ JSON.stringify(detailedResult, null, 2),
411
+ "utf8",
412
+ );
413
+
414
+ return result;
415
+ }
416
+
417
+ async readLocalImages(imagePaths = []) {
418
+ const results = [];
419
+
420
+ for (const imagePath of imagePaths) {
421
+ const absolutePath = path.resolve(imagePath);
422
+ const buffer = await fs.readFile(absolutePath);
423
+ const mimeType = this.mimeTypeFromPath(absolutePath);
424
+
425
+ results.push({
426
+ path: absolutePath,
427
+ name: path.basename(absolutePath),
428
+ mimeType,
429
+ base64: buffer.toString("base64"),
430
+ });
431
+ }
432
+
433
+ return results;
434
+ }
435
+
436
+ async handleRequest(request, response) {
437
+ const url = new URL(request.url || "/", `http://${this.host}:${this.port}`);
438
+
439
+ if (request.method === "OPTIONS") {
440
+ ensureJsonHeaders(response, 204);
441
+ response.end();
442
+ return;
443
+ }
444
+
445
+ if (request.method === "GET" && url.pathname === "/health") {
446
+ ensureJsonHeaders(response, 200);
447
+ response.end(JSON.stringify(await this.getStatus()));
448
+ return;
449
+ }
450
+
451
+ if (request.method === "GET" && url.pathname === "/debug/page-status") {
452
+ const result = await this.runCommand("get_status", {}, { timeoutMs: 20_000 });
453
+ ensureJsonHeaders(response, 200);
454
+ response.end(JSON.stringify({ ok: true, result }));
455
+ return;
456
+ }
457
+
458
+ if (request.method === "POST" && url.pathname === "/debug/command") {
459
+ const payload = await readJsonBody(request);
460
+ const result = await this.runCommand(payload.name, payload.args || {}, {
461
+ timeoutMs: payload.timeoutMs,
462
+ targetClientId: payload.targetClientId,
463
+ });
464
+ ensureJsonHeaders(response, 200);
465
+ response.end(JSON.stringify({ ok: true, result }));
466
+ return;
467
+ }
468
+
469
+ if (request.method === "POST" && url.pathname === "/debug/run-prompt") {
470
+ const payload = await readJsonBody(request);
471
+ const preparedImages = await this.readLocalImages(payload.images || []);
472
+ const rawResult = await this.runCommand(
473
+ "run_prompt",
474
+ {
475
+ prompt: payload.prompt,
476
+ mode: payload.mode,
477
+ images: preparedImages,
478
+ newChat: payload.newChat,
479
+ waitTimeoutMs: payload.waitTimeoutMs,
480
+ maxImages: payload.maxImages,
481
+ },
482
+ {
483
+ timeoutMs: commandTimeoutForWait(
484
+ payload.waitTimeoutMs,
485
+ payload.timeoutMs,
486
+ ),
487
+ targetClientId: payload.targetClientId,
488
+ },
489
+ );
490
+ const result = await this.finalizeRunResult(payload.prompt, rawResult, {
491
+ outputDir: payload.outputDir,
492
+ targetClientId: payload.targetClientId,
493
+ });
494
+ ensureJsonHeaders(response, 200);
495
+ response.end(JSON.stringify({ ok: true, result }));
496
+ return;
497
+ }
498
+
499
+ if (request.method === "POST" && url.pathname === "/debug/capture-images") {
500
+ const payload = await readJsonBody(request);
501
+ const rawResult = await this.runCommand(
502
+ "capture_images",
503
+ {
504
+ maxImages: payload.maxImages,
505
+ },
506
+ {
507
+ timeoutMs: payload.timeoutMs || 60_000,
508
+ targetClientId: payload.targetClientId,
509
+ },
510
+ );
511
+ const result = await this.finalizeRunResult(
512
+ payload.label || "capture-images",
513
+ rawResult,
514
+ {
515
+ outputDir: payload.outputDir,
516
+ targetClientId: payload.targetClientId,
517
+ },
518
+ );
519
+ ensureJsonHeaders(response, 200);
520
+ response.end(JSON.stringify({ ok: true, result }));
521
+ return;
522
+ }
523
+
524
+ if (request.method === "POST" && url.pathname === "/bridge/register") {
525
+ const payload = await readJsonBody(request);
526
+ const client = this.upsertClient(payload);
527
+ ensureJsonHeaders(response, 200);
528
+ response.end(
529
+ JSON.stringify({
530
+ ok: true,
531
+ clientId: client.clientId,
532
+ pollTimeoutMs: POLL_TIMEOUT_MS,
533
+ }),
534
+ );
535
+ return;
536
+ }
537
+
538
+ if (request.method === "POST" && url.pathname === "/bridge/heartbeat") {
539
+ const payload = await readJsonBody(request);
540
+ const client = this.upsertClient(payload);
541
+ client.lastHeartbeat = new Date().toISOString();
542
+ client.lastKnownState = payload.state || client.lastKnownState || null;
543
+ ensureJsonHeaders(response, 200);
544
+ response.end(JSON.stringify({ ok: true }));
545
+ return;
546
+ }
547
+
548
+ if (request.method === "GET" && url.pathname === "/bridge/next") {
549
+ const clientId = url.searchParams.get("clientId");
550
+ if (!clientId) {
551
+ ensureJsonHeaders(response, 400);
552
+ response.end(JSON.stringify({ ok: false, error: "Missing clientId." }));
553
+ return;
554
+ }
555
+
556
+ const client = this.clients.get(clientId);
557
+ if (!client) {
558
+ ensureJsonHeaders(response, 404);
559
+ response.end(JSON.stringify({ ok: false, error: "Unknown clientId." }));
560
+ return;
561
+ }
562
+
563
+ client.lastSeenAt = now();
564
+
565
+ if (client.commandQueue.length > 0) {
566
+ ensureJsonHeaders(response, 200);
567
+ response.end(JSON.stringify({ ok: true, command: client.commandQueue.shift() }));
568
+ return;
569
+ }
570
+
571
+ this.clearPendingPoll(client);
572
+ client.pendingPoll = {
573
+ response,
574
+ timer: setTimeout(() => {
575
+ if (client.pendingPoll?.response === response) {
576
+ ensureJsonHeaders(response, 200);
577
+ response.end(JSON.stringify({ ok: true, command: null }));
578
+ client.pendingPoll = null;
579
+ }
580
+ }, POLL_TIMEOUT_MS),
581
+ };
582
+ return;
583
+ }
584
+
585
+ if (request.method === "POST" && url.pathname === "/bridge/result") {
586
+ const payload = await readJsonBody(request);
587
+ const client = this.clients.get(payload.clientId);
588
+ if (client) {
589
+ client.lastSeenAt = now();
590
+ client.lastKnownState = payload.result || client.lastKnownState || null;
591
+ }
592
+
593
+ const pending = this.pendingCommands.get(payload.requestId);
594
+ if (!pending) {
595
+ ensureJsonHeaders(response, 404);
596
+ response.end(
597
+ JSON.stringify({ ok: false, error: "Unknown requestId." }),
598
+ );
599
+ return;
600
+ }
601
+
602
+ clearTimeout(pending.timer);
603
+ this.pendingCommands.delete(payload.requestId);
604
+
605
+ ensureJsonHeaders(response, 200);
606
+ response.end(JSON.stringify({ ok: true }));
607
+
608
+ if (payload.ok === false) {
609
+ pending.reject(
610
+ new Error(payload.error || "Extension command failed without details."),
611
+ );
612
+ return;
613
+ }
614
+
615
+ pending.resolve(payload.result || {});
616
+ return;
617
+ }
618
+
619
+ ensureJsonHeaders(response, 404);
620
+ response.end(JSON.stringify({ ok: false, error: "Not found." }));
621
+ }
622
+
623
+ upsertClient(payload = {}) {
624
+ const clientId = payload.clientId || randomUUID();
625
+ const existing = this.clients.get(clientId);
626
+ const client =
627
+ existing ||
628
+ {
629
+ clientId,
630
+ connectedAt: new Date().toISOString(),
631
+ lastSeenAt: now(),
632
+ lastHeartbeat: null,
633
+ pageUrl: "",
634
+ title: "",
635
+ capabilities: {},
636
+ commandQueue: [],
637
+ pendingPoll: null,
638
+ lastKnownState: null,
639
+ };
640
+
641
+ client.pageUrl = payload.pageUrl || client.pageUrl;
642
+ client.title = payload.title || client.title;
643
+ client.capabilities = payload.capabilities || client.capabilities;
644
+ client.lastSeenAt = now();
645
+
646
+ this.clients.set(clientId, client);
647
+ return client;
648
+ }
649
+
650
+ selectClient(targetClientId) {
651
+ if (targetClientId) {
652
+ return this.clients.get(targetClientId) || null;
653
+ }
654
+
655
+ return Array.from(this.clients.values())
656
+ .sort((left, right) => {
657
+ const leftState = left.lastKnownState || {};
658
+ const rightState = right.lastKnownState || {};
659
+ const leftScore =
660
+ (leftState.visibilityState === "visible" ? 2 : 0) +
661
+ (leftState.hasFocus ? 1 : 0);
662
+ const rightScore =
663
+ (rightState.visibilityState === "visible" ? 2 : 0) +
664
+ (rightState.hasFocus ? 1 : 0);
665
+
666
+ if (rightScore !== leftScore) {
667
+ return rightScore - leftScore;
668
+ }
669
+
670
+ return right.lastSeenAt - left.lastSeenAt;
671
+ })
672
+ .find((client) => /gemini\.google\.com/i.test(client.pageUrl));
673
+ }
674
+
675
+ flushPendingPoll(client) {
676
+ if (!client.pendingPoll || client.commandQueue.length === 0) {
677
+ return;
678
+ }
679
+
680
+ const { response } = client.pendingPoll;
681
+ clearTimeout(client.pendingPoll.timer);
682
+ client.pendingPoll = null;
683
+ ensureJsonHeaders(response, 200);
684
+ response.end(JSON.stringify({ ok: true, command: client.commandQueue.shift() }));
685
+ }
686
+
687
+ clearPendingPoll(client) {
688
+ if (!client?.pendingPoll) {
689
+ return;
690
+ }
691
+
692
+ clearTimeout(client.pendingPoll.timer);
693
+ try {
694
+ ensureJsonHeaders(client.pendingPoll.response, 200);
695
+ client.pendingPoll.response.end(JSON.stringify({ ok: true, command: null }));
696
+ } catch {
697
+ // Ignore broken client connections during shutdown.
698
+ }
699
+ client.pendingPoll = null;
700
+ }
701
+
702
+ pruneStaleClients() {
703
+ for (const [clientId, client] of this.clients.entries()) {
704
+ if (now() - client.lastSeenAt <= CLIENT_STALE_MS) {
705
+ continue;
706
+ }
707
+
708
+ this.clearPendingPoll(client);
709
+ this.clients.delete(clientId);
710
+ }
711
+ }
712
+
713
+ bufferFromDataUrl(dataUrl) {
714
+ const [, base64] = dataUrl.split(",", 2);
715
+ return Buffer.from(base64, "base64");
716
+ }
717
+
718
+ mimeTypeFromDataUrl(dataUrl) {
719
+ const match = /^data:([^;]+);base64,/i.exec(dataUrl);
720
+ return match?.[1] || "application/octet-stream";
721
+ }
722
+
723
+ extensionFromDataUrl(dataUrl) {
724
+ const mimeType = this.mimeTypeFromDataUrl(dataUrl);
725
+ return {
726
+ "image/png": "png",
727
+ "image/jpeg": "jpg",
728
+ "image/webp": "webp",
729
+ "image/gif": "gif",
730
+ }[mimeType] || "bin";
731
+ }
732
+
733
+ mimeTypeFromPath(filePath) {
734
+ const extension = path.extname(filePath).toLowerCase();
735
+ return (
736
+ {
737
+ ".png": "image/png",
738
+ ".jpg": "image/jpeg",
739
+ ".jpeg": "image/jpeg",
740
+ ".webp": "image/webp",
741
+ ".gif": "image/gif",
742
+ ".bmp": "image/bmp",
743
+ ".heic": "image/heic",
744
+ ".heif": "image/heif",
745
+ ".svg": "image/svg+xml",
746
+ }[extension] || "application/octet-stream"
747
+ );
748
+ }
749
+ }