excalidrop 0.1.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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +411 -0
  3. package/dist/cli.d.ts +24 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +533 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  8. package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  9. package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  10. package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  11. package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
  12. package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
  13. package/dist/frontend/assets/advancedFormat-bKUmsig-.js +1 -0
  14. package/dist/frontend/assets/ar-SA-G6X2FPQ2-C7K_UR3P.js +10 -0
  15. package/dist/frontend/assets/arc-BI_FJWE7.js +1 -0
  16. package/dist/frontend/assets/array-BKyUJesY.js +1 -0
  17. package/dist/frontend/assets/az-AZ-76LH7QW2-PkhuRuSr.js +1 -0
  18. package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
  19. package/dist/frontend/assets/bg-BG-XCXSNQG7-CgLRSYTl.js +5 -0
  20. package/dist/frontend/assets/blockDiagram-38ab4fdb-BFdBRYSn.js +118 -0
  21. package/dist/frontend/assets/blockDiagram-68f4deed-Ynbqr7bQ.js +118 -0
  22. package/dist/frontend/assets/bn-BD-2XOGV67Q-CEvkU0mz.js +5 -0
  23. package/dist/frontend/assets/c4Diagram-15b5d702-Dm7F1Pn_.js +10 -0
  24. package/dist/frontend/assets/c4Diagram-3d4e48cf-COkK-e67.js +10 -0
  25. package/dist/frontend/assets/ca-ES-6MX7JW3Y-DzP6q9bE.js +8 -0
  26. package/dist/frontend/assets/channel-BqCiG5n3.js +1 -0
  27. package/dist/frontend/assets/classDiagram-70f12bd4-CRzulwvH.js +2 -0
  28. package/dist/frontend/assets/classDiagram-d40c83e7-DNbA-n-a.js +2 -0
  29. package/dist/frontend/assets/classDiagram-v2-d5a6b087-l-GglywH.js +2 -0
  30. package/dist/frontend/assets/classDiagram-v2-f2320105-B_TY1zoc.js +2 -0
  31. package/dist/frontend/assets/clone-B6Jqcrku.js +1 -0
  32. package/dist/frontend/assets/createText-2e5e7dd3-DB_k52Pn.js +5 -0
  33. package/dist/frontend/assets/createText-d213de94-D-G49giw.js +5 -0
  34. package/dist/frontend/assets/cs-CZ-2BRQDIVT-Bf4TOzpR.js +11 -0
  35. package/dist/frontend/assets/cytoscape-cose-bilkent-DQYypj0P.js +331 -0
  36. package/dist/frontend/assets/da-DK-5WZEPLOC-jO-5kyXb.js +5 -0
  37. package/dist/frontend/assets/de-DE-XR44H4JA-BkNXWOVh.js +8 -0
  38. package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
  39. package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
  40. package/dist/frontend/assets/edges-332bd1c7-CVvMtkTa.js +4 -0
  41. package/dist/frontend/assets/edges-e0da2a9e-DQBAvC8-.js +4 -0
  42. package/dist/frontend/assets/el-GR-BZB4AONW-Dv7Y9hMk.js +10 -0
  43. package/dist/frontend/assets/elk.bundled-YVS3PsMH.js +26 -0
  44. package/dist/frontend/assets/erDiagram-880f2ed8-BjBeR38f.js +51 -0
  45. package/dist/frontend/assets/erDiagram-9861fffd-B2Bi3R45.js +51 -0
  46. package/dist/frontend/assets/es-ES-U4NZUMDT-CWhs0SQY.js +9 -0
  47. package/dist/frontend/assets/eu-ES-A7QVB2H4-BfkFwc3e.js +11 -0
  48. package/dist/frontend/assets/fa-IR-HGAKTJCU-BgWzSj46.js +8 -0
  49. package/dist/frontend/assets/fi-FI-Z5N7JZ37-CUyvkum-.js +6 -0
  50. package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
  51. package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
  52. package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
  53. package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
  54. package/dist/frontend/assets/flowDb-7c981674-BSZyyVBf.js +10 -0
  55. package/dist/frontend/assets/flowDb-956e92f1-BC3-CLkF.js +10 -0
  56. package/dist/frontend/assets/flowDiagram-66a62f08-H57DAvmM.js +4 -0
  57. package/dist/frontend/assets/flowDiagram-cbd28bf7-Bw4Z2nBD.js +4 -0
  58. package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CBjbm_oB.js +1 -0
  59. package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CoQufPp2.js +1 -0
  60. package/dist/frontend/assets/flowchart-elk-definition-36e2d292-1kZNdf3X.js +114 -0
  61. package/dist/frontend/assets/flowchart-elk-definition-4a651766-DDhG2Esi.js +114 -0
  62. package/dist/frontend/assets/fr-FR-RHASNOE6-7xzoRwbd.js +9 -0
  63. package/dist/frontend/assets/ganttDiagram-04f9e578-CtUSsXCz.js +257 -0
  64. package/dist/frontend/assets/ganttDiagram-c361ad54-K42AklFY.js +257 -0
  65. package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-uW6XA6Y6.js +70 -0
  66. package/dist/frontend/assets/gitGraphDiagram-72cf32ee-CCNbhnBg.js +70 -0
  67. package/dist/frontend/assets/gl-ES-HMX3MZ6V-CXuDDwNP.js +10 -0
  68. package/dist/frontend/assets/graph-CYOmcSYN.js +1 -0
  69. package/dist/frontend/assets/graph-o42dOfI7.js +1 -0
  70. package/dist/frontend/assets/he-IL-6SHJWFNN-O9dzh9EM.js +10 -0
  71. package/dist/frontend/assets/hi-IN-IWLTKZ5I-DlrOXhWs.js +4 -0
  72. package/dist/frontend/assets/hu-HU-A5ZG7DT2-C3eNg-uI.js +7 -0
  73. package/dist/frontend/assets/id-ID-SAP4L64H-6iM4pI5c.js +10 -0
  74. package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
  75. package/dist/frontend/assets/index-3862675e-DrvLhypC.js +1 -0
  76. package/dist/frontend/assets/index-6079d271-BgVTRqg2.js +1 -0
  77. package/dist/frontend/assets/index-BRA5OktX.css +1 -0
  78. package/dist/frontend/assets/index-D-cr7jpJ.js +349 -0
  79. package/dist/frontend/assets/index-DGmpr33w.js +3 -0
  80. package/dist/frontend/assets/index-HkUXslJy.js +87 -0
  81. package/dist/frontend/assets/infoDiagram-4a4f5b27-ZxmAcaW1.js +7 -0
  82. package/dist/frontend/assets/infoDiagram-f8f76790-Bn-PhiFG.js +7 -0
  83. package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
  84. package/dist/frontend/assets/it-IT-JPQ66NNP-B26NQzGp.js +11 -0
  85. package/dist/frontend/assets/ja-JP-DBVTYXUO-CB0fvnYJ.js +8 -0
  86. package/dist/frontend/assets/journeyDiagram-29694f62-ggDKs06f.js +139 -0
  87. package/dist/frontend/assets/journeyDiagram-49397b02-pdDS-n-z.js +139 -0
  88. package/dist/frontend/assets/kaa-6HZHGXH3-YNdBdcwJ.js +1 -0
  89. package/dist/frontend/assets/kab-KAB-ZGHBKWFO-D86pwZO0.js +8 -0
  90. package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
  91. package/dist/frontend/assets/kk-KZ-P5N5QNE5-CpG5oheM.js +1 -0
  92. package/dist/frontend/assets/km-KH-HSX4SM5Z-DYOZ3Azw.js +11 -0
  93. package/dist/frontend/assets/ko-KR-MTYHY66A-BHorm6lC.js +9 -0
  94. package/dist/frontend/assets/ku-TR-6OUDTVRD-CM9RgEHv.js +9 -0
  95. package/dist/frontend/assets/layout-CCtkxrAf.js +1 -0
  96. package/dist/frontend/assets/layout-DW9Qf-At.js +1 -0
  97. package/dist/frontend/assets/line-DvZfgg9P.js +1 -0
  98. package/dist/frontend/assets/linear-FwFrGRfa.js +1 -0
  99. package/dist/frontend/assets/lt-LT-XHIRWOB4-DvriKXBj.js +3 -0
  100. package/dist/frontend/assets/lv-LV-5QDEKY6T-DzQpwk87.js +7 -0
  101. package/dist/frontend/assets/mindmap-definition-ac74a2e8-DsRo8f2w.js +95 -0
  102. package/dist/frontend/assets/mindmap-definition-fc14e90a-dQ9reluI.js +95 -0
  103. package/dist/frontend/assets/mr-IN-CRQNXWMA-C7RzxxP5.js +13 -0
  104. package/dist/frontend/assets/my-MM-5M5IBNSE-B-RRuy29.js +1 -0
  105. package/dist/frontend/assets/nb-NO-T6EIAALU-B47x16Ik.js +10 -0
  106. package/dist/frontend/assets/nl-NL-IS3SIHDZ-BxAKoQne.js +8 -0
  107. package/dist/frontend/assets/nn-NO-6E72VCQL-dfpbEDSZ.js +8 -0
  108. package/dist/frontend/assets/oc-FR-POXYY2M6-gv6geBjV.js +8 -0
  109. package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/dist/frontend/assets/pa-IN-N4M65BXN-CY30-qH7.js +4 -0
  111. package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
  112. package/dist/frontend/assets/pica-DWYGHLQV.js +7 -0
  113. package/dist/frontend/assets/pie-CDiWh1rj.js +1 -0
  114. package/dist/frontend/assets/pieDiagram-421022e6-Bgy-duyf.js +35 -0
  115. package/dist/frontend/assets/pieDiagram-8a3498a8-DH7FsbPK.js +35 -0
  116. package/dist/frontend/assets/pl-PL-T2D74RX3-TrHugsIA.js +9 -0
  117. package/dist/frontend/assets/pt-BR-5N22H2LF-CJxdFp9c.js +9 -0
  118. package/dist/frontend/assets/pt-PT-UZXXM6DQ-CB-1q6YJ.js +9 -0
  119. package/dist/frontend/assets/quadrantDiagram-0957ecba-DcwYI1oi.js +7 -0
  120. package/dist/frontend/assets/quadrantDiagram-120e2f19-CvHU5a9v.js +7 -0
  121. package/dist/frontend/assets/requirementDiagram-23d650b8-tdagN8HP.js +52 -0
  122. package/dist/frontend/assets/requirementDiagram-deff3bca-BMGM-fSI.js +52 -0
  123. package/dist/frontend/assets/ro-RO-JPDTUUEW-DQq9DBOd.js +11 -0
  124. package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
  125. package/dist/frontend/assets/ru-RU-B4JR7IUQ-CWX32rfh.js +9 -0
  126. package/dist/frontend/assets/sankeyDiagram-04a897e0-3i6zWpY2.js +8 -0
  127. package/dist/frontend/assets/sankeyDiagram-23345273-ncj1Ol7S.js +8 -0
  128. package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
  129. package/dist/frontend/assets/selectAll-DJwesbwT.js +1 -0
  130. package/dist/frontend/assets/sequenceDiagram-17ac3bff-ChyKKRZA.js +122 -0
  131. package/dist/frontend/assets/sequenceDiagram-704730f1-D57_wP9k.js +122 -0
  132. package/dist/frontend/assets/si-LK-N5RQ5JYF-Lkc5ZxyG.js +1 -0
  133. package/dist/frontend/assets/sk-SK-C5VTKIMK-DyvYXjqZ.js +6 -0
  134. package/dist/frontend/assets/sl-SI-NN7IZMDC-D_MvP9Rh.js +6 -0
  135. package/dist/frontend/assets/stateDiagram-587899a1-OjFTnFLS.js +1 -0
  136. package/dist/frontend/assets/stateDiagram-9c5f0230-BzU36S5C.js +1 -0
  137. package/dist/frontend/assets/stateDiagram-v2-51a3dcff-CfX1qo7U.js +1 -0
  138. package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-BH7Ud4sU.js +1 -0
  139. package/dist/frontend/assets/styles-2ab5d517-CA8x9Nh5.js +116 -0
  140. package/dist/frontend/assets/styles-5f03d8d2-BnO2pLhV.js +160 -0
  141. package/dist/frontend/assets/styles-6aaf32cf-COnot1ea.js +207 -0
  142. package/dist/frontend/assets/styles-9a916d00-C3QkBgTF.js +160 -0
  143. package/dist/frontend/assets/styles-c10674c1-CPAG2_rr.js +116 -0
  144. package/dist/frontend/assets/styles-edf9a4b0-B8Q9fyu0.js +207 -0
  145. package/dist/frontend/assets/subset-shared.chunk.js +84 -0
  146. package/dist/frontend/assets/subset-worker.chunk.js +1 -0
  147. package/dist/frontend/assets/sv-SE-XGPEYMSR-t6voGQSI.js +10 -0
  148. package/dist/frontend/assets/svgDrawCommon-08f97a94-CdT21LWf.js +1 -0
  149. package/dist/frontend/assets/svgDrawCommon-3ba9043b-D1MU7jAE.js +1 -0
  150. package/dist/frontend/assets/ta-IN-2NMHFXQM-7VmfDDyV.js +9 -0
  151. package/dist/frontend/assets/th-TH-HPSO5L25-B5rLEY9P.js +2 -0
  152. package/dist/frontend/assets/timeline-definition-7e6b55e7-BIcL44O_.js +61 -0
  153. package/dist/frontend/assets/timeline-definition-85554ec2-TgrYYwpU.js +61 -0
  154. package/dist/frontend/assets/tr-TR-DEFEU3FU-0CgOHXTn.js +7 -0
  155. package/dist/frontend/assets/uk-UA-QMV73CPH-CzSFsIsx.js +6 -0
  156. package/dist/frontend/assets/union-CnQAJu0l.js +1 -0
  157. package/dist/frontend/assets/vi-VN-M7AON7JQ-Bhx3rYU9.js +5 -0
  158. package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
  159. package/dist/frontend/assets/xychartDiagram-b6496bcd-BMKDop6v.js +7 -0
  160. package/dist/frontend/assets/xychartDiagram-e933f94c-DAWithrN.js +7 -0
  161. package/dist/frontend/assets/zh-CN-LNUGB5OW-Br52_wH4.js +10 -0
  162. package/dist/frontend/assets/zh-HK-E62DVLB3-Cj6mnIQ9.js +1 -0
  163. package/dist/frontend/assets/zh-TW-RAJ6MFWO-yLEsVBbC.js +9 -0
  164. package/dist/frontend/assets/zipObject-BeXj5PVd.js +1 -0
  165. package/dist/frontend/index.html +339 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.d.ts.map +1 -0
  168. package/dist/index.js +2305 -0
  169. package/dist/index.js.map +1 -0
  170. package/dist/remote.d.ts +4 -0
  171. package/dist/remote.d.ts.map +1 -0
  172. package/dist/remote.js +133 -0
  173. package/dist/remote.js.map +1 -0
  174. package/dist/server.d.ts +4 -0
  175. package/dist/server.d.ts.map +1 -0
  176. package/dist/server.js +1217 -0
  177. package/dist/server.js.map +1 -0
  178. package/dist/target.d.ts +41 -0
  179. package/dist/target.d.ts.map +1 -0
  180. package/dist/target.js +244 -0
  181. package/dist/target.js.map +1 -0
  182. package/dist/types.d.ts +228 -0
  183. package/dist/types.d.ts.map +1 -0
  184. package/dist/types.js +53 -0
  185. package/dist/types.js.map +1 -0
  186. package/dist/utils/auth.d.ts +15 -0
  187. package/dist/utils/auth.d.ts.map +1 -0
  188. package/dist/utils/auth.js +68 -0
  189. package/dist/utils/auth.js.map +1 -0
  190. package/dist/utils/github.d.ts +26 -0
  191. package/dist/utils/github.d.ts.map +1 -0
  192. package/dist/utils/github.js +111 -0
  193. package/dist/utils/github.js.map +1 -0
  194. package/dist/utils/logger.d.ts +4 -0
  195. package/dist/utils/logger.d.ts.map +1 -0
  196. package/dist/utils/logger.js +25 -0
  197. package/dist/utils/logger.js.map +1 -0
  198. package/dist/utils/r2store.d.ts +19 -0
  199. package/dist/utils/r2store.d.ts.map +1 -0
  200. package/dist/utils/r2store.js +75 -0
  201. package/dist/utils/r2store.js.map +1 -0
  202. package/frontend/index.html +338 -0
  203. package/frontend/src/App.tsx +1091 -0
  204. package/frontend/src/main.tsx +15 -0
  205. package/frontend/src/utils/ghSync.ts +171 -0
  206. package/frontend/src/utils/mermaidConverter.ts +49 -0
  207. package/frontend/src/utils/webmcp.ts +285 -0
  208. package/package.json +101 -0
  209. package/scripts/check-local-bind.mjs +164 -0
  210. package/scripts/postinstall.mjs +23 -0
  211. package/scripts/publish-pages.sh +39 -0
  212. package/scripts/seed-r2.mjs +9 -0
  213. package/skills/excalidraw-skill/SKILL.md +303 -0
  214. package/skills/excalidraw-skill/evals/evals.json +23 -0
  215. package/skills/excalidraw-skill/references/cheatsheet.md +142 -0
  216. package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
  217. package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
  218. package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
  219. package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
  220. package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
  221. package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
  222. package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
  223. package/src/cli.ts +559 -0
  224. package/src/index.ts +2557 -0
  225. package/src/remote.ts +96 -0
  226. package/src/server.ts +1349 -0
  227. package/src/target.ts +208 -0
  228. package/src/types.ts +338 -0
  229. package/src/utils/auth.ts +61 -0
  230. package/src/utils/github.ts +93 -0
  231. package/src/utils/logger.ts +35 -0
  232. package/src/utils/r2store.ts +76 -0
  233. package/tsconfig.json +50 -0
  234. package/vite.config.js +37 -0
package/src/server.ts ADDED
@@ -0,0 +1,1349 @@
1
+ import express, { Request, Response, NextFunction, Express } from 'express';
2
+ import cors from 'cors';
3
+ import { WebSocketServer } from 'ws';
4
+ import { createServer } from 'http';
5
+ import net from 'net';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import dotenv from 'dotenv';
9
+ import logger from './utils/logger.js';
10
+ import { getR2Config, r2Get, r2Put } from './utils/r2store.js';
11
+ import { authOptional, requireWriteAuth, requireReadAuth, signSession, allowedLogins } from './utils/auth.js';
12
+ import {
13
+ elements,
14
+ files,
15
+ snapshots,
16
+ generateId,
17
+ EXCALIDRAW_ELEMENT_TYPES,
18
+ ServerElement,
19
+ ExcalidrawElementType,
20
+ ExcalidrawFile,
21
+ WebSocketMessage,
22
+ ElementCreatedMessage,
23
+ ElementUpdatedMessage,
24
+ ElementDeletedMessage,
25
+ BatchCreatedMessage,
26
+ SyncStatusMessage,
27
+ InitialElementsMessage,
28
+ Snapshot,
29
+ normalizeFontFamily
30
+ } from './types.js';
31
+ import { z } from 'zod';
32
+ import WebSocket from 'ws';
33
+
34
+ // Load environment variables
35
+ dotenv.config();
36
+
37
+ const __filename = fileURLToPath(import.meta.url);
38
+ const __dirname = path.dirname(__filename);
39
+
40
+ const app: Express = express();
41
+ const server = createServer(app);
42
+ const wss = new WebSocketServer({ server });
43
+
44
+ // Middleware
45
+ app.use(cors());
46
+ app.use(express.json({ limit: '10mb' }));
47
+ app.use(authOptional);
48
+
49
+ // ─── R2 persistence (scene keyed by repo or SCENE_ID) ───
50
+ const SCENE_KEY = `${process.env.GITHUB_REPOSITORY?.replace('/', '__') || process.env.SCENE_ID || 'default'}.json`;
51
+ let r2Cfg = getR2Config();
52
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
53
+ async function loadFromR2(): Promise<void> {
54
+ r2Cfg = getR2Config();
55
+ if (!r2Cfg) return;
56
+ try {
57
+ const doc = await r2Get(r2Cfg, SCENE_KEY);
58
+ if (doc?.elements) { elements.clear(); for (const el of doc.elements) elements.set(el.id, el); if (doc.files) for (const f of doc.files) files.set(f.id, f); logger.info(`Loaded ${doc.elements.length} elements from R2`); }
59
+ } catch (e) { logger.warn('R2 load failed: ' + (e as Error).message); }
60
+ }
61
+ function schedulePersist(): void {
62
+ r2Cfg = getR2Config();
63
+ if (!r2Cfg) return;
64
+ if (persistTimer) clearTimeout(persistTimer);
65
+ persistTimer = setTimeout(async () => {
66
+ try { await r2Put(r2Cfg!, SCENE_KEY, { elements: Array.from(elements.values()), files: Array.from(files.values()), updatedAt: new Date().toISOString() }); logger.info('Persisted scene to R2'); }
67
+ catch (e) { logger.warn('R2 persist failed: ' + (e as Error).message); }
68
+ }, 800);
69
+ }
70
+ void loadFromR2();
71
+
72
+ // ─── GitHub OAuth (2FA via github.com login; code lives 10min, session 30d cookie) ───
73
+ app.get('/api/auth/login', (req, res) => {
74
+ const cid = process.env.GITHUB_CLIENT_ID;
75
+ if (!cid) return res.status(500).json({ success: false, error: 'GITHUB_CLIENT_ID not configured' });
76
+ const redirect = `${req.protocol}://${req.get('host')}/api/auth/callback`;
77
+ const url = `https://github.com/login/oauth/authorize?client_id=${cid}&redirect_uri=${encodeURIComponent(redirect)}&scope=read:user`;
78
+ res.redirect(url);
79
+ });
80
+ app.get('/api/auth/callback', async (req, res) => {
81
+ try {
82
+ const code = String(req.query.code || '');
83
+ const r = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, code }) });
84
+ const tok = await r.json() as any;
85
+ if (!tok.access_token) throw new Error(tok.error_description || 'OAuth exchange failed');
86
+ const me = await (await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${tok.access_token}`, 'User-Agent': 'excalidrop' } })).json() as any;
87
+ const allow = allowedLogins();
88
+ if (allow.length && !allow.includes(String(me.login).toLowerCase())) return res.status(403).send('User not allowed');
89
+ const token = signSession({ login: me.login, id: me.id, iat: Math.floor(Date.now() / 1000) });
90
+ res.setHeader('Set-Cookie', `excalidrop_token=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}`);
91
+ res.send(`<script>localStorage.setItem('excalidrop_token','${token}');location.href='/'</script>Logged in as ${me.login}. <a href="/">Open canvas</a>`);
92
+ } catch (e) { res.status(500).send('Login failed: ' + (e as Error).message); }
93
+ });
94
+ app.get('/api/auth/me', (req, res) => res.json({ user: (req as any).user || null, publicRead: String(process.env.PUBLIC_READ || 'false') }));
95
+ app.post('/api/auth/logout', (_req, res) => { res.setHeader('Set-Cookie', 'excalidrop_token=; Path=/; Max-Age=0'); res.json({ success: true }); });
96
+ // Writes always authed (when OAuth configured); reads public iff PUBLIC_READ=true.
97
+ const OPEN_WRITE_PATHS = new Set(['/api/export/image/result', '/api/viewport/result']);
98
+ app.use('/api/', (req, res, next) => {
99
+ if (req.path.startsWith('/auth')) return next();
100
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
101
+ if (OPEN_WRITE_PATHS.has(req.path)) return next();
102
+ return requireWriteAuth(req, res, next);
103
+ }
104
+ if (req.path.startsWith('/elements') || req.path.startsWith('/files') || req.path.startsWith('/snapshots')) return requireReadAuth(req, res, next);
105
+ next();
106
+ });
107
+ const persist = () => schedulePersist();
108
+
109
+ // Serve static files from the build directory
110
+ const staticDir = path.join(__dirname, '../dist');
111
+ app.use(express.static(staticDir));
112
+ // Also serve frontend assets
113
+ app.use(express.static(path.join(__dirname, '../dist/frontend')));
114
+ // Serve Excalidraw fonts so the font subsetting worker can fetch them for export
115
+ app.use('/assets/fonts', express.static(
116
+ path.join(__dirname, '../node_modules/@excalidraw/excalidraw/dist/prod/fonts')
117
+ ));
118
+
119
+ // WebSocket connections
120
+ const clients = new Set<WebSocket>();
121
+
122
+ // Broadcast to all connected clients
123
+ function broadcast(message: WebSocketMessage): void {
124
+ const data = JSON.stringify(message);
125
+ clients.forEach(client => {
126
+ try {
127
+ if (client.readyState === WebSocket.OPEN) {
128
+ client.send(data);
129
+ }
130
+ } catch (err) {
131
+ logger.warn('Failed to send to client, removing');
132
+ clients.delete(client);
133
+ }
134
+ });
135
+ }
136
+
137
+ function normalizeLineBreakMarkup(text: string): string {
138
+ return text
139
+ .replace(/<\s*b\s*r\s*\/?\s*>/gi, '\n')
140
+ .replace(/\n{3,}/g, '\n\n');
141
+ }
142
+
143
+ // WebSocket connection handling
144
+ wss.on('connection', (ws: WebSocket) => {
145
+ clients.add(ws);
146
+ logger.info('New WebSocket connection established');
147
+
148
+ // Send current elements to new client
149
+ const filesObj: Record<string, ExcalidrawFile> = {};
150
+ files.forEach((f, id) => { filesObj[id] = f; });
151
+ const initialMessage: InitialElementsMessage & { files?: Record<string, ExcalidrawFile> } = {
152
+ type: 'initial_elements',
153
+ elements: Array.from(elements.values()),
154
+ ...(files.size > 0 ? { files: filesObj } : {})
155
+ };
156
+ ws.send(JSON.stringify(initialMessage));
157
+
158
+ // Send sync status to new client
159
+ const syncMessage: SyncStatusMessage = {
160
+ type: 'sync_status',
161
+ elementCount: elements.size,
162
+ timestamp: new Date().toISOString()
163
+ };
164
+ ws.send(JSON.stringify(syncMessage));
165
+
166
+ ws.on('close', () => {
167
+ clients.delete(ws);
168
+ logger.info('WebSocket connection closed');
169
+ });
170
+
171
+ ws.on('error', (error) => {
172
+ logger.error('WebSocket error:', error);
173
+ clients.delete(ws);
174
+ });
175
+ });
176
+
177
+ // Schema validation
178
+ const CreateElementSchema = z.object({
179
+ id: z.string().optional(), // Allow passing ID for MCP sync
180
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
181
+ x: z.number(),
182
+ y: z.number(),
183
+ width: z.number().optional(),
184
+ height: z.number().optional(),
185
+ backgroundColor: z.string().optional(),
186
+ strokeColor: z.string().optional(),
187
+ strokeWidth: z.number().optional(),
188
+ strokeStyle: z.string().optional(),
189
+ roughness: z.number().optional(),
190
+ opacity: z.number().optional(),
191
+ text: z.string().optional(),
192
+ label: z.object({
193
+ text: z.string()
194
+ }).optional(),
195
+ fontSize: z.number().optional(),
196
+ fontFamily: z.union([z.string(), z.number()]).optional(),
197
+ groupIds: z.array(z.string()).optional(),
198
+ locked: z.boolean().optional(),
199
+ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
200
+ fillStyle: z.string().optional(),
201
+ // Arrow-specific properties
202
+ points: z.any().optional(),
203
+ start: z.object({ id: z.string() }).optional(),
204
+ end: z.object({ id: z.string() }).optional(),
205
+ startArrowhead: z.string().nullable().optional(),
206
+ endArrowhead: z.string().nullable().optional(),
207
+ elbowed: z.boolean().optional(),
208
+ // Arrow binding properties (preserved for Excalidraw frontend)
209
+ startBinding: z.object({
210
+ elementId: z.string(),
211
+ focus: z.number().optional(),
212
+ gap: z.number().optional(),
213
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
214
+ mode: z.string().optional(),
215
+ }).nullable().optional(),
216
+ endBinding: z.object({
217
+ elementId: z.string(),
218
+ focus: z.number().optional(),
219
+ gap: z.number().optional(),
220
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
221
+ mode: z.string().optional(),
222
+ }).nullable().optional(),
223
+ boundElements: z.array(z.object({
224
+ id: z.string(),
225
+ type: z.enum(['arrow', 'text']),
226
+ })).nullable().optional(),
227
+ // Image-specific properties
228
+ fileId: z.string().optional(),
229
+ status: z.string().optional(),
230
+ scale: z.tuple([z.number(), z.number()]).optional(),
231
+ });
232
+
233
+ const UpdateElementSchema = z.object({
234
+ id: z.string(),
235
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
236
+ x: z.number().optional(),
237
+ y: z.number().optional(),
238
+ width: z.number().optional(),
239
+ height: z.number().optional(),
240
+ backgroundColor: z.string().optional(),
241
+ strokeColor: z.string().optional(),
242
+ strokeWidth: z.number().optional(),
243
+ strokeStyle: z.string().optional(),
244
+ roughness: z.number().optional(),
245
+ opacity: z.number().optional(),
246
+ text: z.string().optional(),
247
+ originalText: z.string().optional(),
248
+ label: z.object({
249
+ text: z.string()
250
+ }).optional(),
251
+ fontSize: z.number().optional(),
252
+ fontFamily: z.union([z.string(), z.number()]).optional(),
253
+ groupIds: z.array(z.string()).optional(),
254
+ locked: z.boolean().optional(),
255
+ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
256
+ fillStyle: z.string().optional(),
257
+ points: z.array(z.union([
258
+ z.tuple([z.number(), z.number()]),
259
+ z.object({ x: z.number(), y: z.number() })
260
+ ])).optional(),
261
+ start: z.object({ id: z.string() }).optional(),
262
+ end: z.object({ id: z.string() }).optional(),
263
+ startArrowhead: z.string().nullable().optional(),
264
+ endArrowhead: z.string().nullable().optional(),
265
+ elbowed: z.boolean().optional(),
266
+ // Arrow binding properties (preserved for Excalidraw frontend)
267
+ startBinding: z.object({
268
+ elementId: z.string(),
269
+ focus: z.number().optional(),
270
+ gap: z.number().optional(),
271
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
272
+ mode: z.string().optional(),
273
+ }).nullable().optional(),
274
+ endBinding: z.object({
275
+ elementId: z.string(),
276
+ focus: z.number().optional(),
277
+ gap: z.number().optional(),
278
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
279
+ mode: z.string().optional(),
280
+ }).nullable().optional(),
281
+ boundElements: z.array(z.object({
282
+ id: z.string(),
283
+ type: z.enum(['arrow', 'text']),
284
+ })).nullable().optional(),
285
+ // Image-specific properties
286
+ fileId: z.string().optional(),
287
+ status: z.string().optional(),
288
+ scale: z.tuple([z.number(), z.number()]).optional(),
289
+ });
290
+
291
+ // API Routes
292
+
293
+ // Get all elements
294
+ app.get('/api/elements', (req: Request, res: Response) => {
295
+ try {
296
+ const elementsArray = Array.from(elements.values());
297
+ res.json({
298
+ success: true,
299
+ elements: elementsArray,
300
+ count: elementsArray.length
301
+ });
302
+ } catch (error) {
303
+ logger.error('Error fetching elements:', error);
304
+ res.status(500).json({
305
+ success: false,
306
+ error: (error as Error).message
307
+ });
308
+ }
309
+ });
310
+
311
+ // Create new element
312
+ app.post('/api/elements', (req: Request, res: Response) => {
313
+ try {
314
+ const params = CreateElementSchema.parse(req.body);
315
+ logger.info('Creating element via API', { type: params.type });
316
+
317
+ // Prioritize passed ID (for MCP sync), otherwise generate new ID
318
+ const id = params.id || generateId();
319
+ const element: ServerElement = {
320
+ id,
321
+ ...params,
322
+ fontFamily: normalizeFontFamily(params.fontFamily),
323
+ createdAt: new Date().toISOString(),
324
+ updatedAt: new Date().toISOString(),
325
+ version: 1
326
+ };
327
+
328
+ // Resolve arrow bindings against existing elements
329
+ if (element.type === 'arrow' || element.type === 'line') {
330
+ resolveArrowBindings([element]);
331
+ }
332
+
333
+ elements.set(id, element);
334
+
335
+ // Broadcast to all connected clients
336
+ const message: ElementCreatedMessage = {
337
+ type: 'element_created',
338
+ element: element
339
+ };
340
+ broadcast(message); persist();
341
+
342
+ res.json({
343
+ success: true,
344
+ element: element
345
+ });
346
+ } catch (error) {
347
+ logger.error('Error creating element:', error);
348
+ res.status(400).json({
349
+ success: false,
350
+ error: (error as Error).message
351
+ });
352
+ }
353
+ });
354
+
355
+ // Update element
356
+ app.put('/api/elements/:id', (req: Request, res: Response) => {
357
+ try {
358
+ const { id } = req.params;
359
+ const updates = UpdateElementSchema.parse({ id, ...req.body });
360
+
361
+ if (!id) {
362
+ return res.status(400).json({
363
+ success: false,
364
+ error: 'Element ID is required'
365
+ });
366
+ }
367
+
368
+ const existingElement = elements.get(id);
369
+ if (!existingElement) {
370
+ return res.status(404).json({
371
+ success: false,
372
+ error: `Element with ID ${id} not found`
373
+ });
374
+ }
375
+
376
+ const updatedElement: ServerElement = {
377
+ ...existingElement,
378
+ ...updates,
379
+ fontFamily: updates.fontFamily !== undefined ? normalizeFontFamily(updates.fontFamily) : existingElement.fontFamily,
380
+ updatedAt: new Date().toISOString(),
381
+ version: (existingElement.version || 0) + 1
382
+ };
383
+
384
+ // Keep Excalidraw text source in sync when clients update text via REST.
385
+ // If originalText lags behind text, rendered wrapping/position can drift.
386
+ const hasTextUpdate = Object.prototype.hasOwnProperty.call(req.body, 'text');
387
+ const hasOriginalTextUpdate = Object.prototype.hasOwnProperty.call(req.body, 'originalText');
388
+ if (updatedElement.type === EXCALIDRAW_ELEMENT_TYPES.TEXT && hasTextUpdate && !hasOriginalTextUpdate) {
389
+ const incomingText = updates.text ?? '';
390
+ const existingText = typeof existingElement.text === 'string' ? existingElement.text : '';
391
+ const existingOriginalText = typeof existingElement.originalText === 'string'
392
+ ? existingElement.originalText
393
+ : '';
394
+ const existingOriginalHasBr = /<\s*b\s*r\s*\/?\s*>/i.test(existingOriginalText);
395
+ const normalizedExistingText = normalizeLineBreakMarkup(existingText);
396
+ const normalizedExistingOriginalText = normalizeLineBreakMarkup(existingOriginalText);
397
+
398
+ // Handle common cleanup flow: caller normalizes the rendered text value.
399
+ // In this case, prefer normalized originalText so words aren't split by stale wraps.
400
+ if (existingOriginalHasBr && incomingText === normalizedExistingText && normalizedExistingOriginalText) {
401
+ updatedElement.text = normalizedExistingOriginalText;
402
+ updatedElement.originalText = normalizedExistingOriginalText;
403
+ } else {
404
+ updatedElement.originalText = incomingText;
405
+ }
406
+ }
407
+
408
+ elements.set(id, updatedElement);
409
+
410
+ // Broadcast to all connected clients
411
+ const message: ElementUpdatedMessage = {
412
+ type: 'element_updated',
413
+ element: updatedElement
414
+ };
415
+ broadcast(message); persist();
416
+
417
+ res.json({
418
+ success: true,
419
+ element: updatedElement
420
+ });
421
+ } catch (error) {
422
+ logger.error('Error updating element:', error);
423
+ res.status(400).json({
424
+ success: false,
425
+ error: (error as Error).message
426
+ });
427
+ }
428
+ });
429
+
430
+ // Clear all elements (must be before /:id route)
431
+ app.delete('/api/elements/clear', (req: Request, res: Response) => {
432
+ try {
433
+ const count = elements.size;
434
+ elements.clear();
435
+
436
+ broadcast({
437
+ type: 'canvas_cleared',
438
+ timestamp: new Date().toISOString()
439
+ });
440
+
441
+ logger.info(`Canvas cleared: ${count} elements removed`);
442
+
443
+ res.json({
444
+ success: true,
445
+ message: `Cleared ${count} elements`,
446
+ count
447
+ });
448
+ } catch (error) {
449
+ logger.error('Error clearing canvas:', error);
450
+ res.status(500).json({
451
+ success: false,
452
+ error: (error as Error).message
453
+ });
454
+ }
455
+ });
456
+
457
+ // Delete element
458
+ app.delete('/api/elements/:id', (req: Request, res: Response) => {
459
+ try {
460
+ const { id } = req.params;
461
+
462
+ if (!id) {
463
+ return res.status(400).json({
464
+ success: false,
465
+ error: 'Element ID is required'
466
+ });
467
+ }
468
+
469
+ if (!elements.has(id)) {
470
+ return res.status(404).json({
471
+ success: false,
472
+ error: `Element with ID ${id} not found`
473
+ });
474
+ }
475
+
476
+ elements.delete(id);
477
+
478
+ // Broadcast to all connected clients
479
+ const message: ElementDeletedMessage = {
480
+ type: 'element_deleted',
481
+ elementId: id!
482
+ };
483
+ broadcast(message); persist();
484
+
485
+ res.json({
486
+ success: true,
487
+ message: `Element ${id} deleted successfully`
488
+ });
489
+ } catch (error) {
490
+ logger.error('Error deleting element:', error);
491
+ res.status(500).json({
492
+ success: false,
493
+ error: (error as Error).message
494
+ });
495
+ }
496
+ });
497
+
498
+ // Query elements with filters
499
+ app.get('/api/elements/search', (req: Request, res: Response) => {
500
+ try {
501
+ const { type, x_min, x_max, y_min, y_max, ...filters } = req.query;
502
+ let results = Array.from(elements.values());
503
+
504
+ // Filter by type if specified
505
+ if (type && typeof type === 'string') {
506
+ results = results.filter(element => element.type === type);
507
+ }
508
+
509
+ // Filter by bounding box if specified
510
+ if (x_min !== undefined || x_max !== undefined || y_min !== undefined || y_max !== undefined) {
511
+ const xMin = x_min !== undefined ? Number(x_min) : -Infinity;
512
+ const xMax = x_max !== undefined ? Number(x_max) : Infinity;
513
+ const yMin = y_min !== undefined ? Number(y_min) : -Infinity;
514
+ const yMax = y_max !== undefined ? Number(y_max) : Infinity;
515
+
516
+ results = results.filter(el =>
517
+ el.x >= xMin &&
518
+ el.x <= xMax &&
519
+ el.y >= yMin &&
520
+ el.y <= yMax
521
+ );
522
+ }
523
+
524
+ // Apply additional exact-match filters
525
+ if (Object.keys(filters).length > 0) {
526
+ results = results.filter(element => {
527
+ return Object.entries(filters).every(([key, value]) => {
528
+ return (element as any)[key] === value;
529
+ });
530
+ });
531
+ }
532
+
533
+ res.json({
534
+ success: true,
535
+ elements: results,
536
+ count: results.length
537
+ });
538
+ } catch (error) {
539
+ logger.error('Error querying elements:', error);
540
+ res.status(500).json({
541
+ success: false,
542
+ error: (error as Error).message
543
+ });
544
+ }
545
+ });
546
+
547
+ // Get element by ID
548
+ app.get('/api/elements/:id', (req: Request, res: Response) => {
549
+ try {
550
+ const { id } = req.params;
551
+
552
+ if (!id) {
553
+ return res.status(400).json({
554
+ success: false,
555
+ error: 'Element ID is required'
556
+ });
557
+ }
558
+
559
+ const element = elements.get(id);
560
+
561
+ if (!element) {
562
+ return res.status(404).json({
563
+ success: false,
564
+ error: `Element with ID ${id} not found`
565
+ });
566
+ }
567
+
568
+ res.json({
569
+ success: true,
570
+ element: element
571
+ });
572
+ } catch (error) {
573
+ logger.error('Error fetching element:', error);
574
+ res.status(500).json({
575
+ success: false,
576
+ error: (error as Error).message
577
+ });
578
+ }
579
+ });
580
+
581
+ // Helper: compute edge point for an element given a direction toward a target
582
+ function computeEdgePoint(
583
+ el: ServerElement,
584
+ targetCenterX: number,
585
+ targetCenterY: number
586
+ ): { x: number; y: number } {
587
+ const cx = el.x + (el.width || 0) / 2;
588
+ const cy = el.y + (el.height || 0) / 2;
589
+ const dx = targetCenterX - cx;
590
+ const dy = targetCenterY - cy;
591
+
592
+ if (el.type === 'diamond') {
593
+ // Diamond edge: use diamond geometry (rotated square)
594
+ const hw = (el.width || 0) / 2;
595
+ const hh = (el.height || 0) / 2;
596
+ if (dx === 0 && dy === 0) return { x: cx, y: cy + hh };
597
+ const absDx = Math.abs(dx);
598
+ const absDy = Math.abs(dy);
599
+ // Scale factor to reach diamond edge
600
+ const scale = (absDx / hw + absDy / hh) > 0
601
+ ? 1 / (absDx / hw + absDy / hh)
602
+ : 1;
603
+ return { x: cx + dx * scale, y: cy + dy * scale };
604
+ }
605
+
606
+ if (el.type === 'ellipse') {
607
+ // Ellipse edge: parametric intersection
608
+ const a = (el.width || 0) / 2;
609
+ const b = (el.height || 0) / 2;
610
+ if (dx === 0 && dy === 0) return { x: cx, y: cy + b };
611
+ const angle = Math.atan2(dy, dx);
612
+ return { x: cx + a * Math.cos(angle), y: cy + b * Math.sin(angle) };
613
+ }
614
+
615
+ // Rectangle: find intersection with edges
616
+ const hw = (el.width || 0) / 2;
617
+ const hh = (el.height || 0) / 2;
618
+ if (dx === 0 && dy === 0) return { x: cx, y: cy + hh };
619
+ const angle = Math.atan2(dy, dx);
620
+ const tanA = Math.tan(angle);
621
+ // Check if ray intersects top/bottom edge or left/right edge
622
+ if (Math.abs(tanA * hw) <= hh) {
623
+ // Intersects left or right edge
624
+ const signX = dx >= 0 ? 1 : -1;
625
+ return { x: cx + signX * hw, y: cy + signX * hw * tanA };
626
+ } else {
627
+ // Intersects top or bottom edge
628
+ const signY = dy >= 0 ? 1 : -1;
629
+ return { x: cx + signY * hh / tanA, y: cy + signY * hh };
630
+ }
631
+ }
632
+
633
+ // Helper: resolve arrow bindings in a batch
634
+ function resolveArrowBindings(batchElements: ServerElement[]): void {
635
+ const elementMap = new Map<string, ServerElement>();
636
+ batchElements.forEach(el => elementMap.set(el.id, el));
637
+
638
+ // Also check existing elements for cross-batch references
639
+ elements.forEach((el, id) => {
640
+ if (!elementMap.has(id)) elementMap.set(id, el);
641
+ });
642
+
643
+ for (const el of batchElements) {
644
+ if (el.type !== 'arrow' && el.type !== 'line') continue;
645
+ const startRef = (el as any).start as { id: string } | undefined;
646
+ const endRef = (el as any).end as { id: string } | undefined;
647
+
648
+ if (!startRef && !endRef) continue;
649
+
650
+ const startEl = startRef ? elementMap.get(startRef.id) : undefined;
651
+ const endEl = endRef ? elementMap.get(endRef.id) : undefined;
652
+
653
+ // Calculate arrow path from edge to edge
654
+ const startCenter = startEl
655
+ ? { x: startEl.x + (startEl.width || 0) / 2, y: startEl.y + (startEl.height || 0) / 2 }
656
+ : { x: el.x, y: el.y };
657
+ const endCenter = endEl
658
+ ? { x: endEl.x + (endEl.width || 0) / 2, y: endEl.y + (endEl.height || 0) / 2 }
659
+ : { x: el.x + 100, y: el.y };
660
+
661
+ const GAP = 8;
662
+ const startPt = startEl
663
+ ? computeEdgePoint(startEl, endCenter.x, endCenter.y)
664
+ : startCenter;
665
+ const endPt = endEl
666
+ ? computeEdgePoint(endEl, startCenter.x, startCenter.y)
667
+ : endCenter;
668
+
669
+ // Apply gap: move start point slightly away from source, end point slightly away from target
670
+ const startDx = endPt.x - startPt.x;
671
+ const startDy = endPt.y - startPt.y;
672
+ const startDist = Math.sqrt(startDx * startDx + startDy * startDy) || 1;
673
+ const endDx = startPt.x - endPt.x;
674
+ const endDy = startPt.y - endPt.y;
675
+ const endDist = Math.sqrt(endDx * endDx + endDy * endDy) || 1;
676
+
677
+ const finalStart = {
678
+ x: startPt.x + (startDx / startDist) * GAP,
679
+ y: startPt.y + (startDy / startDist) * GAP
680
+ };
681
+ const finalEnd = {
682
+ x: endPt.x + (endDx / endDist) * GAP,
683
+ y: endPt.y + (endDy / endDist) * GAP
684
+ };
685
+
686
+ // Set arrow position and points
687
+ el.x = finalStart.x;
688
+ el.y = finalStart.y;
689
+ el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
690
+
691
+ // Do NOT delete `start` and `end` here.
692
+ // Excalidraw's frontend `convertToExcalidrawElements` method looks for these exact properties
693
+ // to calculate mathematically sound `startBinding`, `endBinding`, `focus`, `gap`, and `boundElements`.
694
+ }
695
+ }
696
+
697
+ // Batch create elements
698
+ app.post('/api/elements/batch', (req: Request, res: Response) => {
699
+ try {
700
+ const { elements: elementsToCreate } = req.body;
701
+
702
+ if (!Array.isArray(elementsToCreate)) {
703
+ return res.status(400).json({
704
+ success: false,
705
+ error: 'Expected an array of elements'
706
+ });
707
+ }
708
+
709
+ const createdElements: ServerElement[] = [];
710
+
711
+ elementsToCreate.forEach(elementData => {
712
+ const params = CreateElementSchema.parse(elementData);
713
+ // Prioritize passed ID (for MCP sync), otherwise generate new ID
714
+ const id = params.id || generateId();
715
+ const element: ServerElement = {
716
+ id,
717
+ ...params,
718
+ fontFamily: normalizeFontFamily(params.fontFamily),
719
+ createdAt: new Date().toISOString(),
720
+ updatedAt: new Date().toISOString(),
721
+ version: 1
722
+ };
723
+
724
+ createdElements.push(element);
725
+ });
726
+
727
+ // Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
728
+ resolveArrowBindings(createdElements);
729
+
730
+ // Store all elements after binding resolution
731
+ createdElements.forEach(el => elements.set(el.id, el));
732
+
733
+ // Broadcast to all connected clients
734
+ const message: BatchCreatedMessage = {
735
+ type: 'elements_batch_created',
736
+ elements: createdElements
737
+ };
738
+ broadcast(message); persist();
739
+
740
+ res.json({
741
+ success: true,
742
+ elements: createdElements,
743
+ count: createdElements.length
744
+ });
745
+ } catch (error) {
746
+ logger.error('Error batch creating elements:', error);
747
+ res.status(400).json({
748
+ success: false,
749
+ error: (error as Error).message
750
+ });
751
+ }
752
+ });
753
+
754
+ // Convert Mermaid diagram to Excalidraw elements
755
+ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
756
+ try {
757
+ const { mermaidDiagram, config } = req.body;
758
+
759
+ if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
760
+ return res.status(400).json({
761
+ success: false,
762
+ error: 'Mermaid diagram definition is required'
763
+ });
764
+ }
765
+
766
+ logger.info('Received Mermaid conversion request', {
767
+ diagramLength: mermaidDiagram.length,
768
+ hasConfig: !!config
769
+ });
770
+
771
+ // Broadcast to all WebSocket clients to process the Mermaid diagram
772
+ broadcast({
773
+ type: 'mermaid_convert',
774
+ mermaidDiagram,
775
+ config: config || {},
776
+ timestamp: new Date().toISOString()
777
+ });
778
+
779
+ // Return the diagram for frontend processing
780
+ res.json({
781
+ success: true,
782
+ mermaidDiagram,
783
+ config: config || {},
784
+ message: 'Mermaid diagram sent to frontend for conversion.'
785
+ });
786
+ } catch (error) {
787
+ logger.error('Error processing Mermaid diagram:', error);
788
+ res.status(400).json({
789
+ success: false,
790
+ error: (error as Error).message
791
+ });
792
+ }
793
+ });
794
+
795
+ // Sync elements from frontend (overwrite sync)
796
+ app.post('/api/elements/sync', (req: Request, res: Response) => {
797
+ try {
798
+ const { elements: frontendElements, timestamp } = req.body;
799
+
800
+ logger.info(`Sync request received: ${frontendElements.length} elements`, {
801
+ timestamp,
802
+ elementCount: frontendElements.length
803
+ });
804
+
805
+ // Validate input data
806
+ if (!Array.isArray(frontendElements)) {
807
+ return res.status(400).json({
808
+ success: false,
809
+ error: 'Expected elements to be an array'
810
+ });
811
+ }
812
+
813
+ // Record element count before sync
814
+ const beforeCount = elements.size;
815
+
816
+ // 1. Clear existing memory storage
817
+ elements.clear();
818
+ logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
819
+
820
+ // 2. Batch write new data
821
+ let successCount = 0;
822
+ const processedElements: ServerElement[] = [];
823
+
824
+ frontendElements.forEach((element: any, index: number) => {
825
+ try {
826
+ // Ensure element has ID, generate one if missing
827
+ const elementId = element.id || generateId();
828
+
829
+ // Add server metadata
830
+ const processedElement: ServerElement = {
831
+ ...element,
832
+ id: elementId,
833
+ syncedAt: new Date().toISOString(),
834
+ source: 'frontend_sync',
835
+ syncTimestamp: timestamp,
836
+ version: 1
837
+ };
838
+
839
+ // Store to memory
840
+ elements.set(elementId, processedElement);
841
+ processedElements.push(processedElement);
842
+ successCount++;
843
+
844
+ } catch (elementError) {
845
+ logger.warn(`Failed to process element ${index}:`, elementError);
846
+ }
847
+ });
848
+
849
+ logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
850
+
851
+ // 3. Broadcast sync event to all WebSocket clients
852
+ persist(); broadcast({
853
+ type: 'elements_synced',
854
+ count: successCount,
855
+ timestamp: new Date().toISOString(),
856
+ source: 'manual_sync'
857
+ });
858
+
859
+ // 4. Return sync results
860
+ res.json({
861
+ success: true,
862
+ message: `Successfully synced ${successCount} elements`,
863
+ count: successCount,
864
+ syncedAt: new Date().toISOString(),
865
+ beforeCount,
866
+ afterCount: elements.size
867
+ });
868
+
869
+ } catch (error) {
870
+ logger.error('Sync error:', error);
871
+ res.status(500).json({
872
+ success: false,
873
+ error: (error as Error).message,
874
+ details: 'Internal server error during sync operation'
875
+ });
876
+ }
877
+ });
878
+
879
+ // ─── Files API (for image elements) ───────────────────────────
880
+ // GET all files
881
+ app.get('/api/files', (_req: Request, res: Response) => {
882
+ const filesObj: Record<string, ExcalidrawFile> = {};
883
+ files.forEach((f, id) => { filesObj[id] = f; });
884
+ res.json({ files: filesObj });
885
+ });
886
+
887
+ // POST add/update files (batch)
888
+ app.post('/api/files', (req: Request, res: Response) => {
889
+ const body = req.body;
890
+ const fileList: ExcalidrawFile[] = Array.isArray(body) ? body : (body?.files || []);
891
+ for (const f of fileList) {
892
+ if (f.id && f.dataURL) {
893
+ files.set(f.id, { id: f.id, dataURL: f.dataURL, mimeType: f.mimeType || 'image/png', created: f.created || Date.now() });
894
+ }
895
+ }
896
+ // Broadcast files to connected clients
897
+ persist(); broadcast({ type: 'files_added', files: fileList });
898
+ res.json({ success: true, count: fileList.length });
899
+ });
900
+
901
+ // DELETE a file
902
+ app.delete('/api/files/:id', (req: Request, res: Response) => {
903
+ const id = req.params.id as string;
904
+ if (files.delete(id)) {
905
+ broadcast({ type: 'file_deleted', fileId: id });
906
+ res.json({ success: true });
907
+ } else {
908
+ res.status(404).json({ success: false, error: `File with ID ${id} not found` });
909
+ }
910
+ });
911
+
912
+ // Image export: request (MCP -> Express -> WebSocket -> Frontend)
913
+ interface PendingExport {
914
+ resolve: (data: { format: string; data: string }) => void;
915
+ reject: (error: Error) => void;
916
+ timeout: ReturnType<typeof setTimeout>;
917
+ collectionTimeout: ReturnType<typeof setTimeout> | null;
918
+ bestResult: { format: string; data: string } | null;
919
+ }
920
+ const pendingExports = new Map<string, PendingExport>();
921
+
922
+ app.post('/api/export/image', (req: Request, res: Response) => {
923
+ try {
924
+ const { format, background } = req.body;
925
+
926
+ if (!format || !['png', 'svg'].includes(format)) {
927
+ return res.status(400).json({
928
+ success: false,
929
+ error: 'format must be "png" or "svg"'
930
+ });
931
+ }
932
+
933
+ if (clients.size === 0) {
934
+ return res.status(503).json({
935
+ success: false,
936
+ error: 'No frontend client connected. Open the canvas in a browser first.'
937
+ });
938
+ }
939
+
940
+ const requestId = generateId();
941
+
942
+ const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => {
943
+ const timeout = setTimeout(() => {
944
+ const pending = pendingExports.get(requestId);
945
+ pendingExports.delete(requestId);
946
+ // If we collected any result during the window, use it
947
+ if (pending?.bestResult) {
948
+ resolve(pending.bestResult);
949
+ } else {
950
+ reject(new Error('Export timed out after 30 seconds'));
951
+ }
952
+ }, 30000);
953
+
954
+ pendingExports.set(requestId, { resolve, reject, timeout, collectionTimeout: null, bestResult: null });
955
+ });
956
+
957
+ // Re-broadcast current elements so all connected clients (including stale ones)
958
+ // sync to the canonical server state before exporting
959
+ const filesObj: Record<string, ExcalidrawFile> = {};
960
+ files.forEach((f, id) => { filesObj[id] = f; });
961
+ broadcast({
962
+ type: 'initial_elements',
963
+ elements: Array.from(elements.values()),
964
+ ...(files.size > 0 ? { files: filesObj } : {})
965
+ } as InitialElementsMessage & { files?: Record<string, ExcalidrawFile> });
966
+
967
+ // Give browsers time to process the reload before requesting export
968
+ setTimeout(() => {
969
+ broadcast({
970
+ type: 'export_image_request',
971
+ requestId,
972
+ format,
973
+ background: background ?? true
974
+ });
975
+ }, 800);
976
+
977
+ exportPromise
978
+ .then(result => {
979
+ res.json({
980
+ success: true,
981
+ format: result.format,
982
+ data: result.data
983
+ });
984
+ })
985
+ .catch(error => {
986
+ res.status(500).json({
987
+ success: false,
988
+ error: (error as Error).message
989
+ });
990
+ });
991
+ } catch (error) {
992
+ logger.error('Error initiating image export:', error);
993
+ res.status(500).json({
994
+ success: false,
995
+ error: (error as Error).message
996
+ });
997
+ }
998
+ });
999
+
1000
+ // Image export: result (Frontend -> Express -> MCP)
1001
+ app.post('/api/export/image/result', (req: Request, res: Response) => {
1002
+ try {
1003
+ const { requestId, format, data, error } = req.body;
1004
+
1005
+ if (!requestId) {
1006
+ return res.status(400).json({
1007
+ success: false,
1008
+ error: 'requestId is required'
1009
+ });
1010
+ }
1011
+
1012
+ const pending = pendingExports.get(requestId);
1013
+ if (!pending) {
1014
+ // Already resolved by another client, or expired — ignore silently
1015
+ return res.json({ success: true });
1016
+ }
1017
+
1018
+ if (error) {
1019
+ // Don't reject on error — another WebSocket client may still succeed.
1020
+ logger.warn(`Export error from one client (requestId=${requestId}): ${error}`);
1021
+ return res.json({ success: true });
1022
+ }
1023
+
1024
+ // Keep the largest response (most complete canvas state wins)
1025
+ if (!pending.bestResult || data.length > pending.bestResult.data.length) {
1026
+ pending.bestResult = { format, data };
1027
+ }
1028
+
1029
+ // Start a short collection window on the first response, then resolve with best
1030
+ if (!pending.collectionTimeout) {
1031
+ pending.collectionTimeout = setTimeout(() => {
1032
+ const p = pendingExports.get(requestId);
1033
+ if (p?.bestResult) {
1034
+ clearTimeout(p.timeout);
1035
+ pendingExports.delete(requestId);
1036
+ p.resolve(p.bestResult);
1037
+ }
1038
+ }, 3000);
1039
+ }
1040
+
1041
+ res.json({ success: true });
1042
+ } catch (error) {
1043
+ logger.error('Error processing export result:', error);
1044
+ res.status(500).json({
1045
+ success: false,
1046
+ error: (error as Error).message
1047
+ });
1048
+ }
1049
+ });
1050
+
1051
+ // Viewport control: request (MCP -> Express -> WebSocket -> Frontend)
1052
+ interface PendingViewport {
1053
+ resolve: (data: { success: boolean; message: string }) => void;
1054
+ reject: (error: Error) => void;
1055
+ timeout: ReturnType<typeof setTimeout>;
1056
+ }
1057
+ const pendingViewports = new Map<string, PendingViewport>();
1058
+
1059
+ app.post('/api/viewport', (req: Request, res: Response) => {
1060
+ try {
1061
+ const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
1062
+
1063
+ if (clients.size === 0) {
1064
+ return res.status(503).json({
1065
+ success: false,
1066
+ error: 'No frontend client connected. Open the canvas in a browser first.'
1067
+ });
1068
+ }
1069
+
1070
+ const requestId = generateId();
1071
+
1072
+ const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => {
1073
+ const timeout = setTimeout(() => {
1074
+ pendingViewports.delete(requestId);
1075
+ reject(new Error('Viewport request timed out after 10 seconds'));
1076
+ }, 10000);
1077
+
1078
+ pendingViewports.set(requestId, { resolve, reject, timeout });
1079
+ });
1080
+
1081
+ broadcast({
1082
+ type: 'set_viewport',
1083
+ requestId,
1084
+ scrollToContent,
1085
+ scrollToElementId,
1086
+ zoom,
1087
+ offsetX,
1088
+ offsetY
1089
+ });
1090
+
1091
+ viewportPromise
1092
+ .then(result => {
1093
+ res.json(result);
1094
+ })
1095
+ .catch(error => {
1096
+ res.status(500).json({
1097
+ success: false,
1098
+ error: (error as Error).message
1099
+ });
1100
+ });
1101
+ } catch (error) {
1102
+ logger.error('Error initiating viewport change:', error);
1103
+ res.status(500).json({
1104
+ success: false,
1105
+ error: (error as Error).message
1106
+ });
1107
+ }
1108
+ });
1109
+
1110
+ // Viewport control: result (Frontend -> Express -> MCP)
1111
+ app.post('/api/viewport/result', (req: Request, res: Response) => {
1112
+ try {
1113
+ const { requestId, success, message, error } = req.body;
1114
+
1115
+ if (!requestId) {
1116
+ return res.status(400).json({
1117
+ success: false,
1118
+ error: 'requestId is required'
1119
+ });
1120
+ }
1121
+
1122
+ const pending = pendingViewports.get(requestId);
1123
+ if (!pending) {
1124
+ return res.json({ success: true });
1125
+ }
1126
+
1127
+ if (error) {
1128
+ clearTimeout(pending.timeout);
1129
+ pendingViewports.delete(requestId);
1130
+ pending.resolve({ success: false, message: error });
1131
+ return res.json({ success: true });
1132
+ }
1133
+
1134
+ clearTimeout(pending.timeout);
1135
+ pendingViewports.delete(requestId);
1136
+ pending.resolve({ success: true, message: message || 'Viewport updated' });
1137
+
1138
+ res.json({ success: true });
1139
+ } catch (error) {
1140
+ logger.error('Error processing viewport result:', error);
1141
+ res.status(500).json({
1142
+ success: false,
1143
+ error: (error as Error).message
1144
+ });
1145
+ }
1146
+ });
1147
+
1148
+ // Snapshots: save
1149
+ app.post('/api/snapshots', (req: Request, res: Response) => {
1150
+ try {
1151
+ const { name } = req.body;
1152
+
1153
+ if (!name || typeof name !== 'string') {
1154
+ return res.status(400).json({
1155
+ success: false,
1156
+ error: 'Snapshot name is required'
1157
+ });
1158
+ }
1159
+
1160
+ const snapshot: Snapshot = {
1161
+ name,
1162
+ elements: Array.from(elements.values()),
1163
+ createdAt: new Date().toISOString()
1164
+ };
1165
+
1166
+ snapshots.set(name, snapshot);
1167
+ logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
1168
+
1169
+ res.json({
1170
+ success: true,
1171
+ name,
1172
+ elementCount: snapshot.elements.length,
1173
+ createdAt: snapshot.createdAt
1174
+ });
1175
+ } catch (error) {
1176
+ logger.error('Error saving snapshot:', error);
1177
+ res.status(500).json({
1178
+ success: false,
1179
+ error: (error as Error).message
1180
+ });
1181
+ }
1182
+ });
1183
+
1184
+ // Snapshots: list
1185
+ app.get('/api/snapshots', (req: Request, res: Response) => {
1186
+ try {
1187
+ const list = Array.from(snapshots.values()).map(s => ({
1188
+ name: s.name,
1189
+ elementCount: s.elements.length,
1190
+ createdAt: s.createdAt
1191
+ }));
1192
+
1193
+ res.json({
1194
+ success: true,
1195
+ snapshots: list,
1196
+ count: list.length
1197
+ });
1198
+ } catch (error) {
1199
+ logger.error('Error listing snapshots:', error);
1200
+ res.status(500).json({
1201
+ success: false,
1202
+ error: (error as Error).message
1203
+ });
1204
+ }
1205
+ });
1206
+
1207
+ // Snapshots: get by name
1208
+ app.get('/api/snapshots/:name', (req: Request, res: Response) => {
1209
+ try {
1210
+ const { name } = req.params;
1211
+ const snapshot = snapshots.get(name!);
1212
+
1213
+ if (!snapshot) {
1214
+ return res.status(404).json({
1215
+ success: false,
1216
+ error: `Snapshot "${name}" not found`
1217
+ });
1218
+ }
1219
+
1220
+ res.json({
1221
+ success: true,
1222
+ snapshot
1223
+ });
1224
+ } catch (error) {
1225
+ logger.error('Error fetching snapshot:', error);
1226
+ res.status(500).json({
1227
+ success: false,
1228
+ error: (error as Error).message
1229
+ });
1230
+ }
1231
+ });
1232
+
1233
+ // Serve the frontend
1234
+ app.get('/', (req: Request, res: Response) => {
1235
+ const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
1236
+ res.sendFile(htmlFile, (err) => {
1237
+ if (err) {
1238
+ logger.error('Error serving frontend:', err);
1239
+ res.status(404).send('Frontend not found. Please run "npm run build" first.');
1240
+ }
1241
+ });
1242
+ });
1243
+
1244
+ // Health check endpoint
1245
+ app.get('/health', (req: Request, res: Response) => {
1246
+ res.json({
1247
+ status: 'healthy',
1248
+ timestamp: new Date().toISOString(),
1249
+ elements_count: elements.size,
1250
+ websocket_clients: clients.size
1251
+ });
1252
+ });
1253
+
1254
+ // Sync status endpoint
1255
+ app.get('/api/sync/status', (req: Request, res: Response) => {
1256
+ res.json({
1257
+ success: true,
1258
+ elementCount: elements.size,
1259
+ timestamp: new Date().toISOString(),
1260
+ memoryUsage: {
1261
+ heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
1262
+ heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
1263
+ },
1264
+ websocketClients: clients.size
1265
+ });
1266
+ });
1267
+
1268
+ // Error handling middleware
1269
+ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
1270
+ logger.error('Unhandled error:', err);
1271
+ res.status(500).json({
1272
+ success: false,
1273
+ error: 'Internal server error'
1274
+ });
1275
+ });
1276
+
1277
+ // Start server
1278
+ const PORT = parseInt(process.env.PORT || '3001', 10);
1279
+ const HOST = process.env.HOST || '127.0.0.1';
1280
+ const LOOPBACK_GUARD_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']);
1281
+ const LOOPBACK_ADDRESSES = ['127.0.0.1', '::1'];
1282
+
1283
+ function formatHostForUrl(host: string): string {
1284
+ return host.includes(':') ? `[${host}]` : host;
1285
+ }
1286
+
1287
+ function canConnect(host: string, port: number): Promise<boolean> {
1288
+ return new Promise(resolve => {
1289
+ let settled = false;
1290
+ const socket = net.createConnection({ host, port });
1291
+
1292
+ const finish = (isOpen: boolean): void => {
1293
+ if (settled) return;
1294
+ settled = true;
1295
+ socket.destroy();
1296
+ resolve(isOpen);
1297
+ };
1298
+
1299
+ socket.setTimeout(250);
1300
+ socket.once('connect', () => finish(true));
1301
+ socket.once('timeout', () => finish(false));
1302
+ socket.once('error', () => finish(false));
1303
+ });
1304
+ }
1305
+
1306
+ async function findExistingLoopbackListener(port: number): Promise<string | null> {
1307
+ for (const host of LOOPBACK_ADDRESSES) {
1308
+ if (await canConnect(host, port)) {
1309
+ return host;
1310
+ }
1311
+ }
1312
+ return null;
1313
+ }
1314
+
1315
+ server.on('error', (error: NodeJS.ErrnoException) => {
1316
+ if (error.code === 'EADDRINUSE') {
1317
+ const address = (error as NodeJS.ErrnoException & { address?: string }).address || HOST;
1318
+ logger.error(`Canvas server port ${PORT} is already in use on ${formatHostForUrl(address)}.`);
1319
+ } else if (error.code === 'EACCES') {
1320
+ logger.error(`Canvas server cannot bind ${formatHostForUrl(HOST)}:${PORT}: permission denied.`);
1321
+ } else {
1322
+ logger.error('Failed to start canvas server:', error);
1323
+ }
1324
+ process.exit(1);
1325
+ });
1326
+
1327
+ async function startServer(): Promise<void> {
1328
+ if (LOOPBACK_GUARD_HOSTS.has(HOST)) {
1329
+ const existingHost = await findExistingLoopbackListener(PORT);
1330
+ if (existingHost) {
1331
+ logger.error(
1332
+ `Refusing to start canvas server on ${formatHostForUrl(HOST)}:${PORT}: ` +
1333
+ `${formatHostForUrl(existingHost)}:${PORT} is already listening. ` +
1334
+ 'This prevents duplicate IPv4/IPv6 canvas servers from splitting state.'
1335
+ );
1336
+ process.exit(1);
1337
+ }
1338
+ }
1339
+
1340
+ server.listen(PORT, HOST, () => {
1341
+ const hostForUrl = formatHostForUrl(HOST);
1342
+ logger.info(`POC server running on http://${hostForUrl}:${PORT}`);
1343
+ logger.info(`WebSocket server running on ws://${hostForUrl}:${PORT}`);
1344
+ });
1345
+ }
1346
+
1347
+ void startServer();
1348
+
1349
+ export default app;