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/dist/server.js ADDED
@@ -0,0 +1,1217 @@
1
+ import 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 { elements, files, snapshots, generateId, EXCALIDRAW_ELEMENT_TYPES, normalizeFontFamily } from './types.js';
13
+ import { z } from 'zod';
14
+ import WebSocket from 'ws';
15
+ // Load environment variables
16
+ dotenv.config();
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+ const app = express();
20
+ const server = createServer(app);
21
+ const wss = new WebSocketServer({ server });
22
+ // Middleware
23
+ app.use(cors());
24
+ app.use(express.json({ limit: '10mb' }));
25
+ app.use(authOptional);
26
+ // ─── R2 persistence (scene keyed by repo or SCENE_ID) ───
27
+ const SCENE_KEY = `${process.env.GITHUB_REPOSITORY?.replace('/', '__') || process.env.SCENE_ID || 'default'}.json`;
28
+ let r2Cfg = getR2Config();
29
+ let persistTimer = null;
30
+ async function loadFromR2() {
31
+ r2Cfg = getR2Config();
32
+ if (!r2Cfg)
33
+ return;
34
+ try {
35
+ const doc = await r2Get(r2Cfg, SCENE_KEY);
36
+ if (doc?.elements) {
37
+ elements.clear();
38
+ for (const el of doc.elements)
39
+ elements.set(el.id, el);
40
+ if (doc.files)
41
+ for (const f of doc.files)
42
+ files.set(f.id, f);
43
+ logger.info(`Loaded ${doc.elements.length} elements from R2`);
44
+ }
45
+ }
46
+ catch (e) {
47
+ logger.warn('R2 load failed: ' + e.message);
48
+ }
49
+ }
50
+ function schedulePersist() {
51
+ r2Cfg = getR2Config();
52
+ if (!r2Cfg)
53
+ return;
54
+ if (persistTimer)
55
+ clearTimeout(persistTimer);
56
+ persistTimer = setTimeout(async () => {
57
+ try {
58
+ await r2Put(r2Cfg, SCENE_KEY, { elements: Array.from(elements.values()), files: Array.from(files.values()), updatedAt: new Date().toISOString() });
59
+ logger.info('Persisted scene to R2');
60
+ }
61
+ catch (e) {
62
+ logger.warn('R2 persist failed: ' + e.message);
63
+ }
64
+ }, 800);
65
+ }
66
+ void loadFromR2();
67
+ // ─── GitHub OAuth (2FA via github.com login; code lives 10min, session 30d cookie) ───
68
+ app.get('/api/auth/login', (req, res) => {
69
+ const cid = process.env.GITHUB_CLIENT_ID;
70
+ if (!cid)
71
+ return res.status(500).json({ success: false, error: 'GITHUB_CLIENT_ID not configured' });
72
+ const redirect = `${req.protocol}://${req.get('host')}/api/auth/callback`;
73
+ const url = `https://github.com/login/oauth/authorize?client_id=${cid}&redirect_uri=${encodeURIComponent(redirect)}&scope=read:user`;
74
+ res.redirect(url);
75
+ });
76
+ app.get('/api/auth/callback', async (req, res) => {
77
+ try {
78
+ const code = String(req.query.code || '');
79
+ 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 }) });
80
+ const tok = await r.json();
81
+ if (!tok.access_token)
82
+ throw new Error(tok.error_description || 'OAuth exchange failed');
83
+ const me = await (await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${tok.access_token}`, 'User-Agent': 'excalidrop' } })).json();
84
+ const allow = allowedLogins();
85
+ if (allow.length && !allow.includes(String(me.login).toLowerCase()))
86
+ return res.status(403).send('User not allowed');
87
+ const token = signSession({ login: me.login, id: me.id, iat: Math.floor(Date.now() / 1000) });
88
+ res.setHeader('Set-Cookie', `excalidrop_token=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}`);
89
+ res.send(`<script>localStorage.setItem('excalidrop_token','${token}');location.href='/'</script>Logged in as ${me.login}. <a href="/">Open canvas</a>`);
90
+ }
91
+ catch (e) {
92
+ res.status(500).send('Login failed: ' + e.message);
93
+ }
94
+ });
95
+ app.get('/api/auth/me', (req, res) => res.json({ user: req.user || null, publicRead: String(process.env.PUBLIC_READ || 'false') }));
96
+ app.post('/api/auth/logout', (_req, res) => { res.setHeader('Set-Cookie', 'excalidrop_token=; Path=/; Max-Age=0'); res.json({ success: true }); });
97
+ // Writes always authed (when OAuth configured); reads public iff PUBLIC_READ=true.
98
+ const OPEN_WRITE_PATHS = new Set(['/api/export/image/result', '/api/viewport/result']);
99
+ app.use('/api/', (req, res, next) => {
100
+ if (req.path.startsWith('/auth'))
101
+ return next();
102
+ if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
103
+ if (OPEN_WRITE_PATHS.has(req.path))
104
+ return next();
105
+ return requireWriteAuth(req, res, next);
106
+ }
107
+ if (req.path.startsWith('/elements') || req.path.startsWith('/files') || req.path.startsWith('/snapshots'))
108
+ return requireReadAuth(req, res, next);
109
+ next();
110
+ });
111
+ const persist = () => schedulePersist();
112
+ // Serve static files from the build directory
113
+ const staticDir = path.join(__dirname, '../dist');
114
+ app.use(express.static(staticDir));
115
+ // Also serve frontend assets
116
+ app.use(express.static(path.join(__dirname, '../dist/frontend')));
117
+ // Serve Excalidraw fonts so the font subsetting worker can fetch them for export
118
+ app.use('/assets/fonts', express.static(path.join(__dirname, '../node_modules/@excalidraw/excalidraw/dist/prod/fonts')));
119
+ // WebSocket connections
120
+ const clients = new Set();
121
+ // Broadcast to all connected clients
122
+ function broadcast(message) {
123
+ const data = JSON.stringify(message);
124
+ clients.forEach(client => {
125
+ try {
126
+ if (client.readyState === WebSocket.OPEN) {
127
+ client.send(data);
128
+ }
129
+ }
130
+ catch (err) {
131
+ logger.warn('Failed to send to client, removing');
132
+ clients.delete(client);
133
+ }
134
+ });
135
+ }
136
+ function normalizeLineBreakMarkup(text) {
137
+ return text
138
+ .replace(/<\s*b\s*r\s*\/?\s*>/gi, '\n')
139
+ .replace(/\n{3,}/g, '\n\n');
140
+ }
141
+ // WebSocket connection handling
142
+ wss.on('connection', (ws) => {
143
+ clients.add(ws);
144
+ logger.info('New WebSocket connection established');
145
+ // Send current elements to new client
146
+ const filesObj = {};
147
+ files.forEach((f, id) => { filesObj[id] = f; });
148
+ const initialMessage = {
149
+ type: 'initial_elements',
150
+ elements: Array.from(elements.values()),
151
+ ...(files.size > 0 ? { files: filesObj } : {})
152
+ };
153
+ ws.send(JSON.stringify(initialMessage));
154
+ // Send sync status to new client
155
+ const syncMessage = {
156
+ type: 'sync_status',
157
+ elementCount: elements.size,
158
+ timestamp: new Date().toISOString()
159
+ };
160
+ ws.send(JSON.stringify(syncMessage));
161
+ ws.on('close', () => {
162
+ clients.delete(ws);
163
+ logger.info('WebSocket connection closed');
164
+ });
165
+ ws.on('error', (error) => {
166
+ logger.error('WebSocket error:', error);
167
+ clients.delete(ws);
168
+ });
169
+ });
170
+ // Schema validation
171
+ const CreateElementSchema = z.object({
172
+ id: z.string().optional(), // Allow passing ID for MCP sync
173
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
174
+ x: z.number(),
175
+ y: z.number(),
176
+ width: z.number().optional(),
177
+ height: z.number().optional(),
178
+ backgroundColor: z.string().optional(),
179
+ strokeColor: z.string().optional(),
180
+ strokeWidth: z.number().optional(),
181
+ strokeStyle: z.string().optional(),
182
+ roughness: z.number().optional(),
183
+ opacity: z.number().optional(),
184
+ text: z.string().optional(),
185
+ label: z.object({
186
+ text: z.string()
187
+ }).optional(),
188
+ fontSize: z.number().optional(),
189
+ fontFamily: z.union([z.string(), z.number()]).optional(),
190
+ groupIds: z.array(z.string()).optional(),
191
+ locked: z.boolean().optional(),
192
+ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
193
+ fillStyle: z.string().optional(),
194
+ // Arrow-specific properties
195
+ points: z.any().optional(),
196
+ start: z.object({ id: z.string() }).optional(),
197
+ end: z.object({ id: z.string() }).optional(),
198
+ startArrowhead: z.string().nullable().optional(),
199
+ endArrowhead: z.string().nullable().optional(),
200
+ elbowed: z.boolean().optional(),
201
+ // Arrow binding properties (preserved for Excalidraw frontend)
202
+ startBinding: z.object({
203
+ elementId: z.string(),
204
+ focus: z.number().optional(),
205
+ gap: z.number().optional(),
206
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
207
+ mode: z.string().optional(),
208
+ }).nullable().optional(),
209
+ endBinding: 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
+ boundElements: z.array(z.object({
217
+ id: z.string(),
218
+ type: z.enum(['arrow', 'text']),
219
+ })).nullable().optional(),
220
+ // Image-specific properties
221
+ fileId: z.string().optional(),
222
+ status: z.string().optional(),
223
+ scale: z.tuple([z.number(), z.number()]).optional(),
224
+ });
225
+ const UpdateElementSchema = z.object({
226
+ id: z.string(),
227
+ type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(),
228
+ x: z.number().optional(),
229
+ y: z.number().optional(),
230
+ width: z.number().optional(),
231
+ height: z.number().optional(),
232
+ backgroundColor: z.string().optional(),
233
+ strokeColor: z.string().optional(),
234
+ strokeWidth: z.number().optional(),
235
+ strokeStyle: z.string().optional(),
236
+ roughness: z.number().optional(),
237
+ opacity: z.number().optional(),
238
+ text: z.string().optional(),
239
+ originalText: z.string().optional(),
240
+ label: z.object({
241
+ text: z.string()
242
+ }).optional(),
243
+ fontSize: z.number().optional(),
244
+ fontFamily: z.union([z.string(), z.number()]).optional(),
245
+ groupIds: z.array(z.string()).optional(),
246
+ locked: z.boolean().optional(),
247
+ roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
248
+ fillStyle: z.string().optional(),
249
+ points: z.array(z.union([
250
+ z.tuple([z.number(), z.number()]),
251
+ z.object({ x: z.number(), y: z.number() })
252
+ ])).optional(),
253
+ start: z.object({ id: z.string() }).optional(),
254
+ end: z.object({ id: z.string() }).optional(),
255
+ startArrowhead: z.string().nullable().optional(),
256
+ endArrowhead: z.string().nullable().optional(),
257
+ elbowed: z.boolean().optional(),
258
+ // Arrow binding properties (preserved for Excalidraw frontend)
259
+ startBinding: z.object({
260
+ elementId: z.string(),
261
+ focus: z.number().optional(),
262
+ gap: z.number().optional(),
263
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
264
+ mode: z.string().optional(),
265
+ }).nullable().optional(),
266
+ endBinding: z.object({
267
+ elementId: z.string(),
268
+ focus: z.number().optional(),
269
+ gap: z.number().optional(),
270
+ fixedPoint: z.tuple([z.number(), z.number()]).nullable().optional(),
271
+ mode: z.string().optional(),
272
+ }).nullable().optional(),
273
+ boundElements: z.array(z.object({
274
+ id: z.string(),
275
+ type: z.enum(['arrow', 'text']),
276
+ })).nullable().optional(),
277
+ // Image-specific properties
278
+ fileId: z.string().optional(),
279
+ status: z.string().optional(),
280
+ scale: z.tuple([z.number(), z.number()]).optional(),
281
+ });
282
+ // API Routes
283
+ // Get all elements
284
+ app.get('/api/elements', (req, res) => {
285
+ try {
286
+ const elementsArray = Array.from(elements.values());
287
+ res.json({
288
+ success: true,
289
+ elements: elementsArray,
290
+ count: elementsArray.length
291
+ });
292
+ }
293
+ catch (error) {
294
+ logger.error('Error fetching elements:', error);
295
+ res.status(500).json({
296
+ success: false,
297
+ error: error.message
298
+ });
299
+ }
300
+ });
301
+ // Create new element
302
+ app.post('/api/elements', (req, res) => {
303
+ try {
304
+ const params = CreateElementSchema.parse(req.body);
305
+ logger.info('Creating element via API', { type: params.type });
306
+ // Prioritize passed ID (for MCP sync), otherwise generate new ID
307
+ const id = params.id || generateId();
308
+ const element = {
309
+ id,
310
+ ...params,
311
+ fontFamily: normalizeFontFamily(params.fontFamily),
312
+ createdAt: new Date().toISOString(),
313
+ updatedAt: new Date().toISOString(),
314
+ version: 1
315
+ };
316
+ // Resolve arrow bindings against existing elements
317
+ if (element.type === 'arrow' || element.type === 'line') {
318
+ resolveArrowBindings([element]);
319
+ }
320
+ elements.set(id, element);
321
+ // Broadcast to all connected clients
322
+ const message = {
323
+ type: 'element_created',
324
+ element: element
325
+ };
326
+ broadcast(message);
327
+ persist();
328
+ res.json({
329
+ success: true,
330
+ element: element
331
+ });
332
+ }
333
+ catch (error) {
334
+ logger.error('Error creating element:', error);
335
+ res.status(400).json({
336
+ success: false,
337
+ error: error.message
338
+ });
339
+ }
340
+ });
341
+ // Update element
342
+ app.put('/api/elements/:id', (req, res) => {
343
+ try {
344
+ const { id } = req.params;
345
+ const updates = UpdateElementSchema.parse({ id, ...req.body });
346
+ if (!id) {
347
+ return res.status(400).json({
348
+ success: false,
349
+ error: 'Element ID is required'
350
+ });
351
+ }
352
+ const existingElement = elements.get(id);
353
+ if (!existingElement) {
354
+ return res.status(404).json({
355
+ success: false,
356
+ error: `Element with ID ${id} not found`
357
+ });
358
+ }
359
+ const updatedElement = {
360
+ ...existingElement,
361
+ ...updates,
362
+ fontFamily: updates.fontFamily !== undefined ? normalizeFontFamily(updates.fontFamily) : existingElement.fontFamily,
363
+ updatedAt: new Date().toISOString(),
364
+ version: (existingElement.version || 0) + 1
365
+ };
366
+ // Keep Excalidraw text source in sync when clients update text via REST.
367
+ // If originalText lags behind text, rendered wrapping/position can drift.
368
+ const hasTextUpdate = Object.prototype.hasOwnProperty.call(req.body, 'text');
369
+ const hasOriginalTextUpdate = Object.prototype.hasOwnProperty.call(req.body, 'originalText');
370
+ if (updatedElement.type === EXCALIDRAW_ELEMENT_TYPES.TEXT && hasTextUpdate && !hasOriginalTextUpdate) {
371
+ const incomingText = updates.text ?? '';
372
+ const existingText = typeof existingElement.text === 'string' ? existingElement.text : '';
373
+ const existingOriginalText = typeof existingElement.originalText === 'string'
374
+ ? existingElement.originalText
375
+ : '';
376
+ const existingOriginalHasBr = /<\s*b\s*r\s*\/?\s*>/i.test(existingOriginalText);
377
+ const normalizedExistingText = normalizeLineBreakMarkup(existingText);
378
+ const normalizedExistingOriginalText = normalizeLineBreakMarkup(existingOriginalText);
379
+ // Handle common cleanup flow: caller normalizes the rendered text value.
380
+ // In this case, prefer normalized originalText so words aren't split by stale wraps.
381
+ if (existingOriginalHasBr && incomingText === normalizedExistingText && normalizedExistingOriginalText) {
382
+ updatedElement.text = normalizedExistingOriginalText;
383
+ updatedElement.originalText = normalizedExistingOriginalText;
384
+ }
385
+ else {
386
+ updatedElement.originalText = incomingText;
387
+ }
388
+ }
389
+ elements.set(id, updatedElement);
390
+ // Broadcast to all connected clients
391
+ const message = {
392
+ type: 'element_updated',
393
+ element: updatedElement
394
+ };
395
+ broadcast(message);
396
+ persist();
397
+ res.json({
398
+ success: true,
399
+ element: updatedElement
400
+ });
401
+ }
402
+ catch (error) {
403
+ logger.error('Error updating element:', error);
404
+ res.status(400).json({
405
+ success: false,
406
+ error: error.message
407
+ });
408
+ }
409
+ });
410
+ // Clear all elements (must be before /:id route)
411
+ app.delete('/api/elements/clear', (req, res) => {
412
+ try {
413
+ const count = elements.size;
414
+ elements.clear();
415
+ broadcast({
416
+ type: 'canvas_cleared',
417
+ timestamp: new Date().toISOString()
418
+ });
419
+ logger.info(`Canvas cleared: ${count} elements removed`);
420
+ res.json({
421
+ success: true,
422
+ message: `Cleared ${count} elements`,
423
+ count
424
+ });
425
+ }
426
+ catch (error) {
427
+ logger.error('Error clearing canvas:', error);
428
+ res.status(500).json({
429
+ success: false,
430
+ error: error.message
431
+ });
432
+ }
433
+ });
434
+ // Delete element
435
+ app.delete('/api/elements/:id', (req, res) => {
436
+ try {
437
+ const { id } = req.params;
438
+ if (!id) {
439
+ return res.status(400).json({
440
+ success: false,
441
+ error: 'Element ID is required'
442
+ });
443
+ }
444
+ if (!elements.has(id)) {
445
+ return res.status(404).json({
446
+ success: false,
447
+ error: `Element with ID ${id} not found`
448
+ });
449
+ }
450
+ elements.delete(id);
451
+ // Broadcast to all connected clients
452
+ const message = {
453
+ type: 'element_deleted',
454
+ elementId: id
455
+ };
456
+ broadcast(message);
457
+ persist();
458
+ res.json({
459
+ success: true,
460
+ message: `Element ${id} deleted successfully`
461
+ });
462
+ }
463
+ catch (error) {
464
+ logger.error('Error deleting element:', error);
465
+ res.status(500).json({
466
+ success: false,
467
+ error: error.message
468
+ });
469
+ }
470
+ });
471
+ // Query elements with filters
472
+ app.get('/api/elements/search', (req, res) => {
473
+ try {
474
+ const { type, x_min, x_max, y_min, y_max, ...filters } = req.query;
475
+ let results = Array.from(elements.values());
476
+ // Filter by type if specified
477
+ if (type && typeof type === 'string') {
478
+ results = results.filter(element => element.type === type);
479
+ }
480
+ // Filter by bounding box if specified
481
+ if (x_min !== undefined || x_max !== undefined || y_min !== undefined || y_max !== undefined) {
482
+ const xMin = x_min !== undefined ? Number(x_min) : -Infinity;
483
+ const xMax = x_max !== undefined ? Number(x_max) : Infinity;
484
+ const yMin = y_min !== undefined ? Number(y_min) : -Infinity;
485
+ const yMax = y_max !== undefined ? Number(y_max) : Infinity;
486
+ results = results.filter(el => el.x >= xMin &&
487
+ el.x <= xMax &&
488
+ el.y >= yMin &&
489
+ el.y <= yMax);
490
+ }
491
+ // Apply additional exact-match filters
492
+ if (Object.keys(filters).length > 0) {
493
+ results = results.filter(element => {
494
+ return Object.entries(filters).every(([key, value]) => {
495
+ return element[key] === value;
496
+ });
497
+ });
498
+ }
499
+ res.json({
500
+ success: true,
501
+ elements: results,
502
+ count: results.length
503
+ });
504
+ }
505
+ catch (error) {
506
+ logger.error('Error querying elements:', error);
507
+ res.status(500).json({
508
+ success: false,
509
+ error: error.message
510
+ });
511
+ }
512
+ });
513
+ // Get element by ID
514
+ app.get('/api/elements/:id', (req, res) => {
515
+ try {
516
+ const { id } = req.params;
517
+ if (!id) {
518
+ return res.status(400).json({
519
+ success: false,
520
+ error: 'Element ID is required'
521
+ });
522
+ }
523
+ const element = elements.get(id);
524
+ if (!element) {
525
+ return res.status(404).json({
526
+ success: false,
527
+ error: `Element with ID ${id} not found`
528
+ });
529
+ }
530
+ res.json({
531
+ success: true,
532
+ element: element
533
+ });
534
+ }
535
+ catch (error) {
536
+ logger.error('Error fetching element:', error);
537
+ res.status(500).json({
538
+ success: false,
539
+ error: error.message
540
+ });
541
+ }
542
+ });
543
+ // Helper: compute edge point for an element given a direction toward a target
544
+ function computeEdgePoint(el, targetCenterX, targetCenterY) {
545
+ const cx = el.x + (el.width || 0) / 2;
546
+ const cy = el.y + (el.height || 0) / 2;
547
+ const dx = targetCenterX - cx;
548
+ const dy = targetCenterY - cy;
549
+ if (el.type === 'diamond') {
550
+ // Diamond edge: use diamond geometry (rotated square)
551
+ const hw = (el.width || 0) / 2;
552
+ const hh = (el.height || 0) / 2;
553
+ if (dx === 0 && dy === 0)
554
+ return { x: cx, y: cy + hh };
555
+ const absDx = Math.abs(dx);
556
+ const absDy = Math.abs(dy);
557
+ // Scale factor to reach diamond edge
558
+ const scale = (absDx / hw + absDy / hh) > 0
559
+ ? 1 / (absDx / hw + absDy / hh)
560
+ : 1;
561
+ return { x: cx + dx * scale, y: cy + dy * scale };
562
+ }
563
+ if (el.type === 'ellipse') {
564
+ // Ellipse edge: parametric intersection
565
+ const a = (el.width || 0) / 2;
566
+ const b = (el.height || 0) / 2;
567
+ if (dx === 0 && dy === 0)
568
+ return { x: cx, y: cy + b };
569
+ const angle = Math.atan2(dy, dx);
570
+ return { x: cx + a * Math.cos(angle), y: cy + b * Math.sin(angle) };
571
+ }
572
+ // Rectangle: find intersection with edges
573
+ const hw = (el.width || 0) / 2;
574
+ const hh = (el.height || 0) / 2;
575
+ if (dx === 0 && dy === 0)
576
+ return { x: cx, y: cy + hh };
577
+ const angle = Math.atan2(dy, dx);
578
+ const tanA = Math.tan(angle);
579
+ // Check if ray intersects top/bottom edge or left/right edge
580
+ if (Math.abs(tanA * hw) <= hh) {
581
+ // Intersects left or right edge
582
+ const signX = dx >= 0 ? 1 : -1;
583
+ return { x: cx + signX * hw, y: cy + signX * hw * tanA };
584
+ }
585
+ else {
586
+ // Intersects top or bottom edge
587
+ const signY = dy >= 0 ? 1 : -1;
588
+ return { x: cx + signY * hh / tanA, y: cy + signY * hh };
589
+ }
590
+ }
591
+ // Helper: resolve arrow bindings in a batch
592
+ function resolveArrowBindings(batchElements) {
593
+ const elementMap = new Map();
594
+ batchElements.forEach(el => elementMap.set(el.id, el));
595
+ // Also check existing elements for cross-batch references
596
+ elements.forEach((el, id) => {
597
+ if (!elementMap.has(id))
598
+ elementMap.set(id, el);
599
+ });
600
+ for (const el of batchElements) {
601
+ if (el.type !== 'arrow' && el.type !== 'line')
602
+ continue;
603
+ const startRef = el.start;
604
+ const endRef = el.end;
605
+ if (!startRef && !endRef)
606
+ continue;
607
+ const startEl = startRef ? elementMap.get(startRef.id) : undefined;
608
+ const endEl = endRef ? elementMap.get(endRef.id) : undefined;
609
+ // Calculate arrow path from edge to edge
610
+ const startCenter = startEl
611
+ ? { x: startEl.x + (startEl.width || 0) / 2, y: startEl.y + (startEl.height || 0) / 2 }
612
+ : { x: el.x, y: el.y };
613
+ const endCenter = endEl
614
+ ? { x: endEl.x + (endEl.width || 0) / 2, y: endEl.y + (endEl.height || 0) / 2 }
615
+ : { x: el.x + 100, y: el.y };
616
+ const GAP = 8;
617
+ const startPt = startEl
618
+ ? computeEdgePoint(startEl, endCenter.x, endCenter.y)
619
+ : startCenter;
620
+ const endPt = endEl
621
+ ? computeEdgePoint(endEl, startCenter.x, startCenter.y)
622
+ : endCenter;
623
+ // Apply gap: move start point slightly away from source, end point slightly away from target
624
+ const startDx = endPt.x - startPt.x;
625
+ const startDy = endPt.y - startPt.y;
626
+ const startDist = Math.sqrt(startDx * startDx + startDy * startDy) || 1;
627
+ const endDx = startPt.x - endPt.x;
628
+ const endDy = startPt.y - endPt.y;
629
+ const endDist = Math.sqrt(endDx * endDx + endDy * endDy) || 1;
630
+ const finalStart = {
631
+ x: startPt.x + (startDx / startDist) * GAP,
632
+ y: startPt.y + (startDy / startDist) * GAP
633
+ };
634
+ const finalEnd = {
635
+ x: endPt.x + (endDx / endDist) * GAP,
636
+ y: endPt.y + (endDy / endDist) * GAP
637
+ };
638
+ // Set arrow position and points
639
+ el.x = finalStart.x;
640
+ el.y = finalStart.y;
641
+ el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
642
+ // Do NOT delete `start` and `end` here.
643
+ // Excalidraw's frontend `convertToExcalidrawElements` method looks for these exact properties
644
+ // to calculate mathematically sound `startBinding`, `endBinding`, `focus`, `gap`, and `boundElements`.
645
+ }
646
+ }
647
+ // Batch create elements
648
+ app.post('/api/elements/batch', (req, res) => {
649
+ try {
650
+ const { elements: elementsToCreate } = req.body;
651
+ if (!Array.isArray(elementsToCreate)) {
652
+ return res.status(400).json({
653
+ success: false,
654
+ error: 'Expected an array of elements'
655
+ });
656
+ }
657
+ const createdElements = [];
658
+ elementsToCreate.forEach(elementData => {
659
+ const params = CreateElementSchema.parse(elementData);
660
+ // Prioritize passed ID (for MCP sync), otherwise generate new ID
661
+ const id = params.id || generateId();
662
+ const element = {
663
+ id,
664
+ ...params,
665
+ fontFamily: normalizeFontFamily(params.fontFamily),
666
+ createdAt: new Date().toISOString(),
667
+ updatedAt: new Date().toISOString(),
668
+ version: 1
669
+ };
670
+ createdElements.push(element);
671
+ });
672
+ // Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
673
+ resolveArrowBindings(createdElements);
674
+ // Store all elements after binding resolution
675
+ createdElements.forEach(el => elements.set(el.id, el));
676
+ // Broadcast to all connected clients
677
+ const message = {
678
+ type: 'elements_batch_created',
679
+ elements: createdElements
680
+ };
681
+ broadcast(message);
682
+ persist();
683
+ res.json({
684
+ success: true,
685
+ elements: createdElements,
686
+ count: createdElements.length
687
+ });
688
+ }
689
+ catch (error) {
690
+ logger.error('Error batch creating elements:', error);
691
+ res.status(400).json({
692
+ success: false,
693
+ error: error.message
694
+ });
695
+ }
696
+ });
697
+ // Convert Mermaid diagram to Excalidraw elements
698
+ app.post('/api/elements/from-mermaid', (req, res) => {
699
+ try {
700
+ const { mermaidDiagram, config } = req.body;
701
+ if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
702
+ return res.status(400).json({
703
+ success: false,
704
+ error: 'Mermaid diagram definition is required'
705
+ });
706
+ }
707
+ logger.info('Received Mermaid conversion request', {
708
+ diagramLength: mermaidDiagram.length,
709
+ hasConfig: !!config
710
+ });
711
+ // Broadcast to all WebSocket clients to process the Mermaid diagram
712
+ broadcast({
713
+ type: 'mermaid_convert',
714
+ mermaidDiagram,
715
+ config: config || {},
716
+ timestamp: new Date().toISOString()
717
+ });
718
+ // Return the diagram for frontend processing
719
+ res.json({
720
+ success: true,
721
+ mermaidDiagram,
722
+ config: config || {},
723
+ message: 'Mermaid diagram sent to frontend for conversion.'
724
+ });
725
+ }
726
+ catch (error) {
727
+ logger.error('Error processing Mermaid diagram:', error);
728
+ res.status(400).json({
729
+ success: false,
730
+ error: error.message
731
+ });
732
+ }
733
+ });
734
+ // Sync elements from frontend (overwrite sync)
735
+ app.post('/api/elements/sync', (req, res) => {
736
+ try {
737
+ const { elements: frontendElements, timestamp } = req.body;
738
+ logger.info(`Sync request received: ${frontendElements.length} elements`, {
739
+ timestamp,
740
+ elementCount: frontendElements.length
741
+ });
742
+ // Validate input data
743
+ if (!Array.isArray(frontendElements)) {
744
+ return res.status(400).json({
745
+ success: false,
746
+ error: 'Expected elements to be an array'
747
+ });
748
+ }
749
+ // Record element count before sync
750
+ const beforeCount = elements.size;
751
+ // 1. Clear existing memory storage
752
+ elements.clear();
753
+ logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
754
+ // 2. Batch write new data
755
+ let successCount = 0;
756
+ const processedElements = [];
757
+ frontendElements.forEach((element, index) => {
758
+ try {
759
+ // Ensure element has ID, generate one if missing
760
+ const elementId = element.id || generateId();
761
+ // Add server metadata
762
+ const processedElement = {
763
+ ...element,
764
+ id: elementId,
765
+ syncedAt: new Date().toISOString(),
766
+ source: 'frontend_sync',
767
+ syncTimestamp: timestamp,
768
+ version: 1
769
+ };
770
+ // Store to memory
771
+ elements.set(elementId, processedElement);
772
+ processedElements.push(processedElement);
773
+ successCount++;
774
+ }
775
+ catch (elementError) {
776
+ logger.warn(`Failed to process element ${index}:`, elementError);
777
+ }
778
+ });
779
+ logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
780
+ // 3. Broadcast sync event to all WebSocket clients
781
+ persist();
782
+ broadcast({
783
+ type: 'elements_synced',
784
+ count: successCount,
785
+ timestamp: new Date().toISOString(),
786
+ source: 'manual_sync'
787
+ });
788
+ // 4. Return sync results
789
+ res.json({
790
+ success: true,
791
+ message: `Successfully synced ${successCount} elements`,
792
+ count: successCount,
793
+ syncedAt: new Date().toISOString(),
794
+ beforeCount,
795
+ afterCount: elements.size
796
+ });
797
+ }
798
+ catch (error) {
799
+ logger.error('Sync error:', error);
800
+ res.status(500).json({
801
+ success: false,
802
+ error: error.message,
803
+ details: 'Internal server error during sync operation'
804
+ });
805
+ }
806
+ });
807
+ // ─── Files API (for image elements) ───────────────────────────
808
+ // GET all files
809
+ app.get('/api/files', (_req, res) => {
810
+ const filesObj = {};
811
+ files.forEach((f, id) => { filesObj[id] = f; });
812
+ res.json({ files: filesObj });
813
+ });
814
+ // POST add/update files (batch)
815
+ app.post('/api/files', (req, res) => {
816
+ const body = req.body;
817
+ const fileList = Array.isArray(body) ? body : (body?.files || []);
818
+ for (const f of fileList) {
819
+ if (f.id && f.dataURL) {
820
+ files.set(f.id, { id: f.id, dataURL: f.dataURL, mimeType: f.mimeType || 'image/png', created: f.created || Date.now() });
821
+ }
822
+ }
823
+ // Broadcast files to connected clients
824
+ persist();
825
+ broadcast({ type: 'files_added', files: fileList });
826
+ res.json({ success: true, count: fileList.length });
827
+ });
828
+ // DELETE a file
829
+ app.delete('/api/files/:id', (req, res) => {
830
+ const id = req.params.id;
831
+ if (files.delete(id)) {
832
+ broadcast({ type: 'file_deleted', fileId: id });
833
+ res.json({ success: true });
834
+ }
835
+ else {
836
+ res.status(404).json({ success: false, error: `File with ID ${id} not found` });
837
+ }
838
+ });
839
+ const pendingExports = new Map();
840
+ app.post('/api/export/image', (req, res) => {
841
+ try {
842
+ const { format, background } = req.body;
843
+ if (!format || !['png', 'svg'].includes(format)) {
844
+ return res.status(400).json({
845
+ success: false,
846
+ error: 'format must be "png" or "svg"'
847
+ });
848
+ }
849
+ if (clients.size === 0) {
850
+ return res.status(503).json({
851
+ success: false,
852
+ error: 'No frontend client connected. Open the canvas in a browser first.'
853
+ });
854
+ }
855
+ const requestId = generateId();
856
+ const exportPromise = new Promise((resolve, reject) => {
857
+ const timeout = setTimeout(() => {
858
+ const pending = pendingExports.get(requestId);
859
+ pendingExports.delete(requestId);
860
+ // If we collected any result during the window, use it
861
+ if (pending?.bestResult) {
862
+ resolve(pending.bestResult);
863
+ }
864
+ else {
865
+ reject(new Error('Export timed out after 30 seconds'));
866
+ }
867
+ }, 30000);
868
+ pendingExports.set(requestId, { resolve, reject, timeout, collectionTimeout: null, bestResult: null });
869
+ });
870
+ // Re-broadcast current elements so all connected clients (including stale ones)
871
+ // sync to the canonical server state before exporting
872
+ const filesObj = {};
873
+ files.forEach((f, id) => { filesObj[id] = f; });
874
+ broadcast({
875
+ type: 'initial_elements',
876
+ elements: Array.from(elements.values()),
877
+ ...(files.size > 0 ? { files: filesObj } : {})
878
+ });
879
+ // Give browsers time to process the reload before requesting export
880
+ setTimeout(() => {
881
+ broadcast({
882
+ type: 'export_image_request',
883
+ requestId,
884
+ format,
885
+ background: background ?? true
886
+ });
887
+ }, 800);
888
+ exportPromise
889
+ .then(result => {
890
+ res.json({
891
+ success: true,
892
+ format: result.format,
893
+ data: result.data
894
+ });
895
+ })
896
+ .catch(error => {
897
+ res.status(500).json({
898
+ success: false,
899
+ error: error.message
900
+ });
901
+ });
902
+ }
903
+ catch (error) {
904
+ logger.error('Error initiating image export:', error);
905
+ res.status(500).json({
906
+ success: false,
907
+ error: error.message
908
+ });
909
+ }
910
+ });
911
+ // Image export: result (Frontend -> Express -> MCP)
912
+ app.post('/api/export/image/result', (req, res) => {
913
+ try {
914
+ const { requestId, format, data, error } = req.body;
915
+ if (!requestId) {
916
+ return res.status(400).json({
917
+ success: false,
918
+ error: 'requestId is required'
919
+ });
920
+ }
921
+ const pending = pendingExports.get(requestId);
922
+ if (!pending) {
923
+ // Already resolved by another client, or expired — ignore silently
924
+ return res.json({ success: true });
925
+ }
926
+ if (error) {
927
+ // Don't reject on error — another WebSocket client may still succeed.
928
+ logger.warn(`Export error from one client (requestId=${requestId}): ${error}`);
929
+ return res.json({ success: true });
930
+ }
931
+ // Keep the largest response (most complete canvas state wins)
932
+ if (!pending.bestResult || data.length > pending.bestResult.data.length) {
933
+ pending.bestResult = { format, data };
934
+ }
935
+ // Start a short collection window on the first response, then resolve with best
936
+ if (!pending.collectionTimeout) {
937
+ pending.collectionTimeout = setTimeout(() => {
938
+ const p = pendingExports.get(requestId);
939
+ if (p?.bestResult) {
940
+ clearTimeout(p.timeout);
941
+ pendingExports.delete(requestId);
942
+ p.resolve(p.bestResult);
943
+ }
944
+ }, 3000);
945
+ }
946
+ res.json({ success: true });
947
+ }
948
+ catch (error) {
949
+ logger.error('Error processing export result:', error);
950
+ res.status(500).json({
951
+ success: false,
952
+ error: error.message
953
+ });
954
+ }
955
+ });
956
+ const pendingViewports = new Map();
957
+ app.post('/api/viewport', (req, res) => {
958
+ try {
959
+ const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
960
+ if (clients.size === 0) {
961
+ return res.status(503).json({
962
+ success: false,
963
+ error: 'No frontend client connected. Open the canvas in a browser first.'
964
+ });
965
+ }
966
+ const requestId = generateId();
967
+ const viewportPromise = new Promise((resolve, reject) => {
968
+ const timeout = setTimeout(() => {
969
+ pendingViewports.delete(requestId);
970
+ reject(new Error('Viewport request timed out after 10 seconds'));
971
+ }, 10000);
972
+ pendingViewports.set(requestId, { resolve, reject, timeout });
973
+ });
974
+ broadcast({
975
+ type: 'set_viewport',
976
+ requestId,
977
+ scrollToContent,
978
+ scrollToElementId,
979
+ zoom,
980
+ offsetX,
981
+ offsetY
982
+ });
983
+ viewportPromise
984
+ .then(result => {
985
+ res.json(result);
986
+ })
987
+ .catch(error => {
988
+ res.status(500).json({
989
+ success: false,
990
+ error: error.message
991
+ });
992
+ });
993
+ }
994
+ catch (error) {
995
+ logger.error('Error initiating viewport change:', error);
996
+ res.status(500).json({
997
+ success: false,
998
+ error: error.message
999
+ });
1000
+ }
1001
+ });
1002
+ // Viewport control: result (Frontend -> Express -> MCP)
1003
+ app.post('/api/viewport/result', (req, res) => {
1004
+ try {
1005
+ const { requestId, success, message, error } = req.body;
1006
+ if (!requestId) {
1007
+ return res.status(400).json({
1008
+ success: false,
1009
+ error: 'requestId is required'
1010
+ });
1011
+ }
1012
+ const pending = pendingViewports.get(requestId);
1013
+ if (!pending) {
1014
+ return res.json({ success: true });
1015
+ }
1016
+ if (error) {
1017
+ clearTimeout(pending.timeout);
1018
+ pendingViewports.delete(requestId);
1019
+ pending.resolve({ success: false, message: error });
1020
+ return res.json({ success: true });
1021
+ }
1022
+ clearTimeout(pending.timeout);
1023
+ pendingViewports.delete(requestId);
1024
+ pending.resolve({ success: true, message: message || 'Viewport updated' });
1025
+ res.json({ success: true });
1026
+ }
1027
+ catch (error) {
1028
+ logger.error('Error processing viewport result:', error);
1029
+ res.status(500).json({
1030
+ success: false,
1031
+ error: error.message
1032
+ });
1033
+ }
1034
+ });
1035
+ // Snapshots: save
1036
+ app.post('/api/snapshots', (req, res) => {
1037
+ try {
1038
+ const { name } = req.body;
1039
+ if (!name || typeof name !== 'string') {
1040
+ return res.status(400).json({
1041
+ success: false,
1042
+ error: 'Snapshot name is required'
1043
+ });
1044
+ }
1045
+ const snapshot = {
1046
+ name,
1047
+ elements: Array.from(elements.values()),
1048
+ createdAt: new Date().toISOString()
1049
+ };
1050
+ snapshots.set(name, snapshot);
1051
+ logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
1052
+ res.json({
1053
+ success: true,
1054
+ name,
1055
+ elementCount: snapshot.elements.length,
1056
+ createdAt: snapshot.createdAt
1057
+ });
1058
+ }
1059
+ catch (error) {
1060
+ logger.error('Error saving snapshot:', error);
1061
+ res.status(500).json({
1062
+ success: false,
1063
+ error: error.message
1064
+ });
1065
+ }
1066
+ });
1067
+ // Snapshots: list
1068
+ app.get('/api/snapshots', (req, res) => {
1069
+ try {
1070
+ const list = Array.from(snapshots.values()).map(s => ({
1071
+ name: s.name,
1072
+ elementCount: s.elements.length,
1073
+ createdAt: s.createdAt
1074
+ }));
1075
+ res.json({
1076
+ success: true,
1077
+ snapshots: list,
1078
+ count: list.length
1079
+ });
1080
+ }
1081
+ catch (error) {
1082
+ logger.error('Error listing snapshots:', error);
1083
+ res.status(500).json({
1084
+ success: false,
1085
+ error: error.message
1086
+ });
1087
+ }
1088
+ });
1089
+ // Snapshots: get by name
1090
+ app.get('/api/snapshots/:name', (req, res) => {
1091
+ try {
1092
+ const { name } = req.params;
1093
+ const snapshot = snapshots.get(name);
1094
+ if (!snapshot) {
1095
+ return res.status(404).json({
1096
+ success: false,
1097
+ error: `Snapshot "${name}" not found`
1098
+ });
1099
+ }
1100
+ res.json({
1101
+ success: true,
1102
+ snapshot
1103
+ });
1104
+ }
1105
+ catch (error) {
1106
+ logger.error('Error fetching snapshot:', error);
1107
+ res.status(500).json({
1108
+ success: false,
1109
+ error: error.message
1110
+ });
1111
+ }
1112
+ });
1113
+ // Serve the frontend
1114
+ app.get('/', (req, res) => {
1115
+ const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
1116
+ res.sendFile(htmlFile, (err) => {
1117
+ if (err) {
1118
+ logger.error('Error serving frontend:', err);
1119
+ res.status(404).send('Frontend not found. Please run "npm run build" first.');
1120
+ }
1121
+ });
1122
+ });
1123
+ // Health check endpoint
1124
+ app.get('/health', (req, res) => {
1125
+ res.json({
1126
+ status: 'healthy',
1127
+ timestamp: new Date().toISOString(),
1128
+ elements_count: elements.size,
1129
+ websocket_clients: clients.size
1130
+ });
1131
+ });
1132
+ // Sync status endpoint
1133
+ app.get('/api/sync/status', (req, res) => {
1134
+ res.json({
1135
+ success: true,
1136
+ elementCount: elements.size,
1137
+ timestamp: new Date().toISOString(),
1138
+ memoryUsage: {
1139
+ heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
1140
+ heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
1141
+ },
1142
+ websocketClients: clients.size
1143
+ });
1144
+ });
1145
+ // Error handling middleware
1146
+ app.use((err, req, res, next) => {
1147
+ logger.error('Unhandled error:', err);
1148
+ res.status(500).json({
1149
+ success: false,
1150
+ error: 'Internal server error'
1151
+ });
1152
+ });
1153
+ // Start server
1154
+ const PORT = parseInt(process.env.PORT || '3001', 10);
1155
+ const HOST = process.env.HOST || '127.0.0.1';
1156
+ const LOOPBACK_GUARD_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '::']);
1157
+ const LOOPBACK_ADDRESSES = ['127.0.0.1', '::1'];
1158
+ function formatHostForUrl(host) {
1159
+ return host.includes(':') ? `[${host}]` : host;
1160
+ }
1161
+ function canConnect(host, port) {
1162
+ return new Promise(resolve => {
1163
+ let settled = false;
1164
+ const socket = net.createConnection({ host, port });
1165
+ const finish = (isOpen) => {
1166
+ if (settled)
1167
+ return;
1168
+ settled = true;
1169
+ socket.destroy();
1170
+ resolve(isOpen);
1171
+ };
1172
+ socket.setTimeout(250);
1173
+ socket.once('connect', () => finish(true));
1174
+ socket.once('timeout', () => finish(false));
1175
+ socket.once('error', () => finish(false));
1176
+ });
1177
+ }
1178
+ async function findExistingLoopbackListener(port) {
1179
+ for (const host of LOOPBACK_ADDRESSES) {
1180
+ if (await canConnect(host, port)) {
1181
+ return host;
1182
+ }
1183
+ }
1184
+ return null;
1185
+ }
1186
+ server.on('error', (error) => {
1187
+ if (error.code === 'EADDRINUSE') {
1188
+ const address = error.address || HOST;
1189
+ logger.error(`Canvas server port ${PORT} is already in use on ${formatHostForUrl(address)}.`);
1190
+ }
1191
+ else if (error.code === 'EACCES') {
1192
+ logger.error(`Canvas server cannot bind ${formatHostForUrl(HOST)}:${PORT}: permission denied.`);
1193
+ }
1194
+ else {
1195
+ logger.error('Failed to start canvas server:', error);
1196
+ }
1197
+ process.exit(1);
1198
+ });
1199
+ async function startServer() {
1200
+ if (LOOPBACK_GUARD_HOSTS.has(HOST)) {
1201
+ const existingHost = await findExistingLoopbackListener(PORT);
1202
+ if (existingHost) {
1203
+ logger.error(`Refusing to start canvas server on ${formatHostForUrl(HOST)}:${PORT}: ` +
1204
+ `${formatHostForUrl(existingHost)}:${PORT} is already listening. ` +
1205
+ 'This prevents duplicate IPv4/IPv6 canvas servers from splitting state.');
1206
+ process.exit(1);
1207
+ }
1208
+ }
1209
+ server.listen(PORT, HOST, () => {
1210
+ const hostForUrl = formatHostForUrl(HOST);
1211
+ logger.info(`POC server running on http://${hostForUrl}:${PORT}`);
1212
+ logger.info(`WebSocket server running on ws://${hostForUrl}:${PORT}`);
1213
+ });
1214
+ }
1215
+ void startServer();
1216
+ export default app;
1217
+ //# sourceMappingURL=server.js.map