carouselbot 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.mjs CHANGED
@@ -2,17 +2,22 @@
2
2
  import { createServer } from "node:http";
3
3
  import { randomBytes, randomUUID } from "node:crypto";
4
4
  import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
- import { basename, extname } from "node:path";
5
+ import { basename, delimiter, extname } from "node:path";
6
6
  import {
7
7
  ALLOWED_ORIGINS, AUDIT_LOG_PATH, BRIDGE_HOST, BRIDGE_PORT, BRIDGE_URL, DAEMON_LOCK_PATH,
8
- DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION, PROTOCOL_VERSION, STATE_DIRECTORY,
8
+ DAEMON_API_VERSION, DAEMON_INTERNAL_ACTIONS, DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION,
9
+ PROTOCOL_VERSION, STATE_DIRECTORY,
9
10
  } from "./config.mjs";
11
+ import { createLocalFontService } from "./local-fonts.mjs";
10
12
 
11
13
  const MAX_JSON_BYTES = 40 * 1024 * 1024;
12
14
  const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
13
15
  const EDITOR_TTL_MS = Number(process.env.CAROUSELBOT_EDITOR_TTL_MS || process.env.SLIDE_STUDIO_EDITOR_TTL_MS) || 60_000;
14
16
  const CLIENT_TTL_MS = 45_000;
15
17
  const MEDIA_TTL_MS = 5 * 60_000;
18
+ const FONT_MEDIA_TTL_MS = 5 * 60_000;
19
+ const MAX_FONT_MEDIA_ITEMS = 32;
20
+ const MAX_FONT_MEDIA_BYTES = 256 * 1024 * 1024;
16
21
  const COMMAND_TIMEOUT_MS = 90_000;
17
22
  const EDIT_SESSION_TTL_MS = Number(process.env.CAROUSELBOT_EDIT_SESSION_TTL_MS || process.env.SLIDE_STUDIO_EDIT_SESSION_TTL_MS) || 5 * 60_000;
18
23
  const MAX_AUDIT_EVENTS = 500;
@@ -24,8 +29,17 @@ const editors = new Map();
24
29
  const clients = new Map();
25
30
  const inflight = new Map();
26
31
  const media = new Map();
32
+ const fontMedia = new Map();
27
33
  const editSessions = new Map();
28
34
  const auditEvents = [];
35
+ const configuredFontDirectories = String(process.env.CAROUSELBOT_FONT_DIRS || process.env.SLIDE_STUDIO_FONT_DIRS || "")
36
+ .split(delimiter)
37
+ .map((value) => value.trim())
38
+ .filter(Boolean);
39
+ const localFonts = createLocalFontService({
40
+ cacheDirectory: STATE_DIRECTORY,
41
+ ...(configuredFontDirectories.length ? { directories: configuredFontDirectories } : {}),
42
+ });
29
43
  let focusedEditorId = null;
30
44
  let lockHandle = null;
31
45
  let idleSince = null;
@@ -65,6 +79,19 @@ function codedError(code, message, details = {}) {
65
79
  return error;
66
80
  }
67
81
 
82
+ function daemonHealth(details = {}) {
83
+ return {
84
+ ok: true,
85
+ service: PACKAGE_NAME,
86
+ pid: process.pid,
87
+ version: PACKAGE_VERSION,
88
+ protocolVersion: PROTOCOL_VERSION,
89
+ daemonApiVersion: DAEMON_API_VERSION,
90
+ capabilities: { internalActions: [...DAEMON_INTERNAL_ACTIONS] },
91
+ ...details,
92
+ };
93
+ }
94
+
68
95
  function publicSession(session) {
69
96
  return {
70
97
  id: session.id,
@@ -189,7 +216,7 @@ function browserCors(origin) {
189
216
  "Access-Control-Allow-Origin": origin,
190
217
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
191
218
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
192
- "Access-Control-Expose-Headers": "X-CarouselBot-Filename, X-Slide-Studio-Filename",
219
+ "Access-Control-Expose-Headers": "X-CarouselBot-Filename, X-Slide-Studio-Filename, X-CarouselBot-Local-Font-Id",
193
220
  "Access-Control-Allow-Private-Network": "true",
194
221
  "Access-Control-Max-Age": "600",
195
222
  "Cache-Control": "no-store",
@@ -203,6 +230,18 @@ function sendJson(response, statusCode, value, headers = {}) {
203
230
  response.end(body);
204
231
  }
205
232
 
233
+ function sendLocalFont(response, item, headers = {}) {
234
+ response.writeHead(200, {
235
+ ...headers,
236
+ "Content-Type": item.mimeType,
237
+ "Content-Length": item.buffer.length,
238
+ "X-CarouselBot-Filename": encodeURIComponent(item.filename),
239
+ "X-Slide-Studio-Filename": encodeURIComponent(item.filename),
240
+ "X-CarouselBot-Local-Font-Id": encodeURIComponent(item.font.localFontId),
241
+ });
242
+ response.end(item.buffer);
243
+ }
244
+
206
245
  function readJson(request) {
207
246
  return new Promise((resolve, reject) => {
208
247
  const chunks = [];
@@ -269,6 +308,7 @@ function disconnectEditor(editorId, message = "Browser editor disconnected.") {
269
308
  endEditorPoll(editor);
270
309
  for (const session of editSessions.values()) if (session.editorId === editorId) releaseEditSession(session.id, "editor disconnected");
271
310
  editors.delete(editorId);
311
+ for (const [id, item] of fontMedia) if (item.editorId === editorId) fontMedia.delete(id);
272
312
  if (focusedEditorId === editorId) focusedEditorId = null;
273
313
  for (const [requestId, pending] of inflight) {
274
314
  if (pending.editorId !== editorId) continue;
@@ -305,6 +345,22 @@ function selectEditor(clientId) {
305
345
  throw new Error("Multiple editors are connected and none is selected. Call list_editors, then select_editor.");
306
346
  }
307
347
 
348
+ function requireLocalFontPermission(editor) {
349
+ if (editor?.localFontsEnabled) return editor;
350
+ throw codedError("FONT_PERMISSION_REQUIRED", "Open CarouselBot and enable local fonts.");
351
+ }
352
+
353
+ function selectLocalFontEditor(clientId) {
354
+ return requireLocalFontPermission(selectEditor(clientId));
355
+ }
356
+
357
+ function localFontEditorForCall(clientId, editSessionId) {
358
+ if (!editSessionId) return selectLocalFontEditor(clientId);
359
+ const session = requireEditSession(editSessionId);
360
+ session.lastClientId = clientId;
361
+ return requireLocalFontPermission(editors.get(session.editorId));
362
+ }
363
+
308
364
  function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId }) {
309
365
  const client = clients.get(clientId) || { id: clientId, name: "MCP agent" };
310
366
  if (editSessionId) {
@@ -348,10 +404,21 @@ function callBrowser(clientId, toolName, operation, label, { editSessionId = nul
348
404
  return new Promise((resolve, reject) => {
349
405
  const timer = setTimeout(() => {
350
406
  inflight.delete(requestId);
407
+ if (operation?.fontMediaId) fontMedia.delete(operation.fontMediaId);
351
408
  recordAudit({ action: "tool.result", client, session, editorId: editor.id, projectId, toolName, status: "error", message: "Browser timeout" });
352
409
  reject(codedError("BROWSER_TIMEOUT", "The browser did not answer within 90 seconds."));
353
410
  }, COMMAND_TIMEOUT_MS);
354
- inflight.set(requestId, { resolve, reject, timer, editorId: editor.id, client, session, projectId, toolName });
411
+ inflight.set(requestId, {
412
+ resolve,
413
+ reject,
414
+ timer,
415
+ editorId: editor.id,
416
+ client,
417
+ session,
418
+ projectId,
419
+ toolName,
420
+ fontMediaId: operation?.fontMediaId || null,
421
+ });
355
422
  queueEditorEvent(editor, { kind: "command", requestId, toolName, operation, label, editSessionId: session?.id || null, agent: publicClient(client) });
356
423
  });
357
424
  }
@@ -382,6 +449,51 @@ async function prepareMedia(filePath) {
382
449
  return { mediaId: id, filename: basename(filePath), mimeType, size: buffer.length };
383
450
  }
384
451
 
452
+ function localFontFilename(font, mimeType) {
453
+ const extension = ({
454
+ "font/ttf": "ttf",
455
+ "font/otf": "otf",
456
+ "font/woff": "woff",
457
+ "font/woff2": "woff2",
458
+ })[mimeType] || "font";
459
+ const stem = String(font?.postscriptName || font?.localFontId || "local-font")
460
+ .replace(/[^a-z0-9._-]+/gi, "-")
461
+ .replace(/^-+|-+$/g, "") || "local-font";
462
+ return `${stem}.${extension}`;
463
+ }
464
+
465
+ async function readLocalFontFace(localFontId) {
466
+ if (!localFontId || typeof localFontId !== "string") throw codedError("FONT_NOT_FOUND", "A valid localFontId is required.");
467
+ const prepared = await localFonts.readFace(localFontId);
468
+ if (!prepared?.font || !prepared?.buffer) throw codedError("FONT_NOT_FOUND", `Local font is unavailable: ${localFontId}`);
469
+ const buffer = Buffer.isBuffer(prepared.buffer) ? prepared.buffer : Buffer.from(prepared.buffer);
470
+ const mimeType = prepared.mimeType || "application/octet-stream";
471
+ return { font: prepared.font, buffer, mimeType, filename: localFontFilename(prepared.font, mimeType) };
472
+ }
473
+
474
+ async function prepareFont(clientId, localFontId, editSessionId) {
475
+ const editor = localFontEditorForCall(clientId, editSessionId);
476
+ const prepared = await readLocalFontFace(localFontId);
477
+ const now = Date.now();
478
+ let retainedBytes = 0;
479
+ for (const [id, item] of fontMedia) {
480
+ if (item.expiresAt < now) fontMedia.delete(id);
481
+ else retainedBytes += item.buffer.length;
482
+ }
483
+ if (fontMedia.size >= MAX_FONT_MEDIA_ITEMS || retainedBytes + prepared.buffer.length > MAX_FONT_MEDIA_BYTES) {
484
+ throw codedError("FONT_TRANSFER_LIMIT", "Too many local fonts are waiting to be transferred. Finish the pending imports and retry.");
485
+ }
486
+ const fontMediaId = randomUUID();
487
+ fontMedia.set(fontMediaId, {
488
+ ...prepared,
489
+ id: fontMediaId,
490
+ editorId: editor.id,
491
+ localFontId: prepared.font.localFontId,
492
+ expiresAt: now + FONT_MEDIA_TTL_MS,
493
+ });
494
+ return { font: prepared.font, fontMediaId };
495
+ }
496
+
385
497
  async function writeExport(filePath, data, overwrite) {
386
498
  const buffer = Buffer.from(data, "base64");
387
499
  const handle = await open(filePath, overwrite ? "w" : "wx", 0o600).catch((error) => {
@@ -430,6 +542,11 @@ async function handleInternalCall(body) {
430
542
  const events = auditEvents.filter((event) => (!body.projectId || event.projectId === body.projectId) && (!body.status || event.status === body.status));
431
543
  return { events: events.slice(-limit).reverse(), localLogPath: AUDIT_LOG_PATH };
432
544
  }
545
+ if (body.action === "list_local_fonts") {
546
+ localFontEditorForCall(body.clientId, body.editSessionId);
547
+ return localFonts.list({ query: body.query, limit: body.limit, cursor: body.cursor, sort: body.sort });
548
+ }
549
+ if (body.action === "prepare_font") return prepareFont(body.clientId, body.localFontId, body.editSessionId);
433
550
  if (body.action === "prepare_media") return prepareMedia(body.path);
434
551
  if (body.action === "write_export") return writeExport(body.path, body.data, Boolean(body.overwrite));
435
552
  if (body.action === "notify") {
@@ -443,7 +560,10 @@ async function handleInternalCall(body) {
443
560
  for (const item of body.items) results.push(await callBrowser(body.clientId, item.toolName || "apply_operations", item.operation, item.label, { editSessionId: body.editSessionId, mutating: true }));
444
561
  return { applied: results.length, results };
445
562
  }
446
- throw new Error(`Unknown internal action: ${body.action}`);
563
+ throw codedError("UNSUPPORTED_INTERNAL_ACTION", `Unknown internal action: ${body.action}`, {
564
+ action: body.action,
565
+ supportedActions: [...DAEMON_INTERNAL_ACTIONS],
566
+ });
447
567
  }
448
568
 
449
569
  const server = createServer(async (request, response) => {
@@ -461,13 +581,13 @@ const server = createServer(async (request, response) => {
461
581
  }
462
582
  if (url.pathname === "/health" && request.method === "GET") {
463
583
  if (origin && !cors) return sendJson(response, 403, { error: "Origin not allowed." });
464
- return sendJson(response, 200, { ok: true, service: PACKAGE_NAME, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION, editors: activeEditors().length, agents: activeClients().length }, cors || {});
584
+ return sendJson(response, 200, daemonHealth({ editors: activeEditors().length, agents: activeClients().length }), cors || {});
465
585
  }
466
586
 
467
587
  try {
468
588
  if (url.pathname.startsWith("/internal/")) {
469
589
  if (!requireInternal(request, response)) return;
470
- if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200, { ok: true, pid: process.pid, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION });
590
+ if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200, daemonHealth());
471
591
  if (url.pathname === "/internal/shutdown" && request.method === "POST") {
472
592
  sendJson(response, 202, { ok: true, pid: process.pid });
473
593
  setImmediate(() => void shutdown());
@@ -518,12 +638,14 @@ const server = createServer(async (request, response) => {
518
638
  }
519
639
  const editor = {
520
640
  id: body.editorId, queue: [], poll: null, pageUrl: body.pageUrl,
521
- pollTimer: null, state: body.state, lastSeen: Date.now(), cors, sessionToken: randomBytes(32).toString("base64url"),
641
+ pollTimer: null, state: body.state, lastSeen: Date.now(), cors,
642
+ localFontsEnabled: body.localFontsEnabled === true,
643
+ sessionToken: randomBytes(32).toString("base64url"),
522
644
  };
523
645
  editors.set(editor.id, editor);
524
646
  if (body.hasFocus && body.visibilityState === "visible") focusedEditorId = editor.id;
525
647
  log(`Editor connected (${editor.id.slice(0, 8)})`);
526
- return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, version: PACKAGE_VERSION, agents: activeClients().map(publicClient), editSessions: activeEditSessions().map(publicSession) }, cors);
648
+ return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, version: PACKAGE_VERSION, localFontsEnabled: editor.localFontsEnabled, agents: activeClients().map(publicClient), editSessions: activeEditSessions().map(publicSession) }, cors);
527
649
  }
528
650
  if (url.pathname === "/activate" && request.method === "POST") {
529
651
  const body = await readJson(request);
@@ -545,6 +667,55 @@ const server = createServer(async (request, response) => {
545
667
  disconnectEditor(editor.id);
546
668
  return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
547
669
  }
670
+ if (url.pathname === "/fonts/enable" && request.method === "POST") {
671
+ const body = await readJson(request);
672
+ const editor = requireEditor(request, response, body.editorId, cors);
673
+ if (!editor) return;
674
+ editor.localFontsEnabled = true;
675
+ return sendJson(response, 200, { enabled: true }, cors);
676
+ }
677
+ if (url.pathname === "/fonts" && request.method === "GET") {
678
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
679
+ if (!editor) return;
680
+ requireLocalFontPermission(editor);
681
+ const result = await localFonts.list({
682
+ query: url.searchParams.get("query") || "",
683
+ limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
684
+ cursor: url.searchParams.get("cursor") || null,
685
+ sort: url.searchParams.get("sort") || undefined,
686
+ });
687
+ return sendJson(response, 200, result, cors);
688
+ }
689
+ if (url.pathname === "/fonts/use" && request.method === "POST") {
690
+ const body = await readJson(request);
691
+ const editor = requireEditor(request, response, body.editorId, cors);
692
+ if (!editor) return;
693
+ requireLocalFontPermission(editor);
694
+ const font = await localFonts.markUsed(body.localFontId);
695
+ if (!font) throw codedError("FONT_NOT_FOUND", `Local font is unavailable: ${body.localFontId}`);
696
+ return sendJson(response, 200, { font }, cors);
697
+ }
698
+ if (url.pathname.startsWith("/fonts/") && request.method === "GET") {
699
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
700
+ if (!editor) return;
701
+ requireLocalFontPermission(editor);
702
+ const localFontId = decodeURIComponent(url.pathname.slice("/fonts/".length));
703
+ return sendLocalFont(response, await readLocalFontFace(localFontId), cors);
704
+ }
705
+ if (url.pathname.startsWith("/font-media/") && request.method === "GET") {
706
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
707
+ if (!editor) return;
708
+ requireLocalFontPermission(editor);
709
+ const id = decodeURIComponent(url.pathname.slice("/font-media/".length));
710
+ const item = fontMedia.get(id);
711
+ if (!item || item.expiresAt < Date.now()) {
712
+ fontMedia.delete(id);
713
+ throw codedError("FONT_MEDIA_UNAVAILABLE", "Local font transfer is missing or expired.");
714
+ }
715
+ if (item.editorId !== editor.id) throw codedError("FONT_MEDIA_UNAVAILABLE", "Local font transfer is missing or expired.");
716
+ fontMedia.delete(id);
717
+ return sendLocalFont(response, item, cors);
718
+ }
548
719
  if (url.pathname === "/events" && request.method === "GET") {
549
720
  const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
550
721
  if (!editor) return;
@@ -580,6 +751,7 @@ const server = createServer(async (request, response) => {
580
751
  if (!pending || pending.editorId !== editor.id) return sendJson(response, 404, { error: "Unknown request." }, cors);
581
752
  inflight.delete(body.requestId);
582
753
  clearTimeout(pending.timer);
754
+ if (pending.fontMediaId) fontMedia.delete(pending.fontMediaId);
583
755
  if (body.state) editor.state = body.state;
584
756
  if (body.ok) {
585
757
  try {
@@ -612,8 +784,16 @@ const server = createServer(async (request, response) => {
612
784
  return sendJson(response, 404, { error: "Not found." }, cors);
613
785
  } catch (error) {
614
786
  const headers = cors || {};
615
- const statusCode = error.code === "ENOENT" ? 404 : error.code === "EACCES" ? 403 : 400;
616
- return sendJson(response, statusCode, { error: error.message }, headers);
787
+ const statusCode = ["ENOENT", "FONT_NOT_FOUND", "FONT_MEDIA_UNAVAILABLE"].includes(error.code)
788
+ ? 404
789
+ : ["EACCES", "FONT_PERMISSION_REQUIRED"].includes(error.code)
790
+ ? 403
791
+ : error.code === "FONT_TRANSFER_LIMIT" ? 429 : 400;
792
+ return sendJson(response, statusCode, {
793
+ error: error.message,
794
+ ...(error.code ? { code: error.code } : {}),
795
+ ...(error.details && Object.keys(error.details).length ? { details: error.details } : {}),
796
+ }, headers);
617
797
  }
618
798
  });
619
799
 
@@ -643,7 +823,14 @@ async function acquireDaemonLock() {
643
823
 
644
824
  async function writeDaemonState() {
645
825
  const temporary = `${DAEMON_STATE_PATH}.${process.pid}.tmp`;
646
- await writeFile(temporary, JSON.stringify({ pid: process.pid, port: BRIDGE_PORT, secret: daemonSecret, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION }), { mode: 0o600 });
826
+ await writeFile(temporary, JSON.stringify({
827
+ pid: process.pid,
828
+ port: BRIDGE_PORT,
829
+ secret: daemonSecret,
830
+ version: PACKAGE_VERSION,
831
+ protocolVersion: PROTOCOL_VERSION,
832
+ daemonApiVersion: DAEMON_API_VERSION,
833
+ }), { mode: 0o600 });
647
834
  await rename(temporary, DAEMON_STATE_PATH);
648
835
  }
649
836
 
@@ -661,6 +848,7 @@ async function cleanup() {
661
848
  setInterval(() => {
662
849
  const now = Date.now();
663
850
  for (const [id, item] of media) if (item.expiresAt < now) media.delete(id);
851
+ for (const [id, item] of fontMedia) if (item.expiresAt < now) fontMedia.delete(id);
664
852
  for (const session of editSessions.values()) if (session.lastSeen < now - EDIT_SESSION_TTL_MS) releaseEditSession(session.id, "lease expired");
665
853
  let clientsChanged = false;
666
854
  for (const [id, client] of clients) if (client.lastSeen < now - CLIENT_TTL_MS) {