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.
- package/LICENSE +21 -0
- package/README.md +411 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +533 -0
- package/dist/cli.js.map +1 -0
- package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
- package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
- package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
- package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
- package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
- package/dist/frontend/assets/advancedFormat-bKUmsig-.js +1 -0
- package/dist/frontend/assets/ar-SA-G6X2FPQ2-C7K_UR3P.js +10 -0
- package/dist/frontend/assets/arc-BI_FJWE7.js +1 -0
- package/dist/frontend/assets/array-BKyUJesY.js +1 -0
- package/dist/frontend/assets/az-AZ-76LH7QW2-PkhuRuSr.js +1 -0
- package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
- package/dist/frontend/assets/bg-BG-XCXSNQG7-CgLRSYTl.js +5 -0
- package/dist/frontend/assets/blockDiagram-38ab4fdb-BFdBRYSn.js +118 -0
- package/dist/frontend/assets/blockDiagram-68f4deed-Ynbqr7bQ.js +118 -0
- package/dist/frontend/assets/bn-BD-2XOGV67Q-CEvkU0mz.js +5 -0
- package/dist/frontend/assets/c4Diagram-15b5d702-Dm7F1Pn_.js +10 -0
- package/dist/frontend/assets/c4Diagram-3d4e48cf-COkK-e67.js +10 -0
- package/dist/frontend/assets/ca-ES-6MX7JW3Y-DzP6q9bE.js +8 -0
- package/dist/frontend/assets/channel-BqCiG5n3.js +1 -0
- package/dist/frontend/assets/classDiagram-70f12bd4-CRzulwvH.js +2 -0
- package/dist/frontend/assets/classDiagram-d40c83e7-DNbA-n-a.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-d5a6b087-l-GglywH.js +2 -0
- package/dist/frontend/assets/classDiagram-v2-f2320105-B_TY1zoc.js +2 -0
- package/dist/frontend/assets/clone-B6Jqcrku.js +1 -0
- package/dist/frontend/assets/createText-2e5e7dd3-DB_k52Pn.js +5 -0
- package/dist/frontend/assets/createText-d213de94-D-G49giw.js +5 -0
- package/dist/frontend/assets/cs-CZ-2BRQDIVT-Bf4TOzpR.js +11 -0
- package/dist/frontend/assets/cytoscape-cose-bilkent-DQYypj0P.js +331 -0
- package/dist/frontend/assets/da-DK-5WZEPLOC-jO-5kyXb.js +5 -0
- package/dist/frontend/assets/de-DE-XR44H4JA-BkNXWOVh.js +8 -0
- package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
- package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
- package/dist/frontend/assets/edges-332bd1c7-CVvMtkTa.js +4 -0
- package/dist/frontend/assets/edges-e0da2a9e-DQBAvC8-.js +4 -0
- package/dist/frontend/assets/el-GR-BZB4AONW-Dv7Y9hMk.js +10 -0
- package/dist/frontend/assets/elk.bundled-YVS3PsMH.js +26 -0
- package/dist/frontend/assets/erDiagram-880f2ed8-BjBeR38f.js +51 -0
- package/dist/frontend/assets/erDiagram-9861fffd-B2Bi3R45.js +51 -0
- package/dist/frontend/assets/es-ES-U4NZUMDT-CWhs0SQY.js +9 -0
- package/dist/frontend/assets/eu-ES-A7QVB2H4-BfkFwc3e.js +11 -0
- package/dist/frontend/assets/fa-IR-HGAKTJCU-BgWzSj46.js +8 -0
- package/dist/frontend/assets/fi-FI-Z5N7JZ37-CUyvkum-.js +6 -0
- package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
- package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
- package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
- package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
- package/dist/frontend/assets/flowDb-7c981674-BSZyyVBf.js +10 -0
- package/dist/frontend/assets/flowDb-956e92f1-BC3-CLkF.js +10 -0
- package/dist/frontend/assets/flowDiagram-66a62f08-H57DAvmM.js +4 -0
- package/dist/frontend/assets/flowDiagram-cbd28bf7-Bw4Z2nBD.js +4 -0
- package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CBjbm_oB.js +1 -0
- package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CoQufPp2.js +1 -0
- package/dist/frontend/assets/flowchart-elk-definition-36e2d292-1kZNdf3X.js +114 -0
- package/dist/frontend/assets/flowchart-elk-definition-4a651766-DDhG2Esi.js +114 -0
- package/dist/frontend/assets/fr-FR-RHASNOE6-7xzoRwbd.js +9 -0
- package/dist/frontend/assets/ganttDiagram-04f9e578-CtUSsXCz.js +257 -0
- package/dist/frontend/assets/ganttDiagram-c361ad54-K42AklFY.js +257 -0
- package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-uW6XA6Y6.js +70 -0
- package/dist/frontend/assets/gitGraphDiagram-72cf32ee-CCNbhnBg.js +70 -0
- package/dist/frontend/assets/gl-ES-HMX3MZ6V-CXuDDwNP.js +10 -0
- package/dist/frontend/assets/graph-CYOmcSYN.js +1 -0
- package/dist/frontend/assets/graph-o42dOfI7.js +1 -0
- package/dist/frontend/assets/he-IL-6SHJWFNN-O9dzh9EM.js +10 -0
- package/dist/frontend/assets/hi-IN-IWLTKZ5I-DlrOXhWs.js +4 -0
- package/dist/frontend/assets/hu-HU-A5ZG7DT2-C3eNg-uI.js +7 -0
- package/dist/frontend/assets/id-ID-SAP4L64H-6iM4pI5c.js +10 -0
- package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
- package/dist/frontend/assets/index-3862675e-DrvLhypC.js +1 -0
- package/dist/frontend/assets/index-6079d271-BgVTRqg2.js +1 -0
- package/dist/frontend/assets/index-BRA5OktX.css +1 -0
- package/dist/frontend/assets/index-D-cr7jpJ.js +349 -0
- package/dist/frontend/assets/index-DGmpr33w.js +3 -0
- package/dist/frontend/assets/index-HkUXslJy.js +87 -0
- package/dist/frontend/assets/infoDiagram-4a4f5b27-ZxmAcaW1.js +7 -0
- package/dist/frontend/assets/infoDiagram-f8f76790-Bn-PhiFG.js +7 -0
- package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
- package/dist/frontend/assets/it-IT-JPQ66NNP-B26NQzGp.js +11 -0
- package/dist/frontend/assets/ja-JP-DBVTYXUO-CB0fvnYJ.js +8 -0
- package/dist/frontend/assets/journeyDiagram-29694f62-ggDKs06f.js +139 -0
- package/dist/frontend/assets/journeyDiagram-49397b02-pdDS-n-z.js +139 -0
- package/dist/frontend/assets/kaa-6HZHGXH3-YNdBdcwJ.js +1 -0
- package/dist/frontend/assets/kab-KAB-ZGHBKWFO-D86pwZO0.js +8 -0
- package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
- package/dist/frontend/assets/kk-KZ-P5N5QNE5-CpG5oheM.js +1 -0
- package/dist/frontend/assets/km-KH-HSX4SM5Z-DYOZ3Azw.js +11 -0
- package/dist/frontend/assets/ko-KR-MTYHY66A-BHorm6lC.js +9 -0
- package/dist/frontend/assets/ku-TR-6OUDTVRD-CM9RgEHv.js +9 -0
- package/dist/frontend/assets/layout-CCtkxrAf.js +1 -0
- package/dist/frontend/assets/layout-DW9Qf-At.js +1 -0
- package/dist/frontend/assets/line-DvZfgg9P.js +1 -0
- package/dist/frontend/assets/linear-FwFrGRfa.js +1 -0
- package/dist/frontend/assets/lt-LT-XHIRWOB4-DvriKXBj.js +3 -0
- package/dist/frontend/assets/lv-LV-5QDEKY6T-DzQpwk87.js +7 -0
- package/dist/frontend/assets/mindmap-definition-ac74a2e8-DsRo8f2w.js +95 -0
- package/dist/frontend/assets/mindmap-definition-fc14e90a-dQ9reluI.js +95 -0
- package/dist/frontend/assets/mr-IN-CRQNXWMA-C7RzxxP5.js +13 -0
- package/dist/frontend/assets/my-MM-5M5IBNSE-B-RRuy29.js +1 -0
- package/dist/frontend/assets/nb-NO-T6EIAALU-B47x16Ik.js +10 -0
- package/dist/frontend/assets/nl-NL-IS3SIHDZ-BxAKoQne.js +8 -0
- package/dist/frontend/assets/nn-NO-6E72VCQL-dfpbEDSZ.js +8 -0
- package/dist/frontend/assets/oc-FR-POXYY2M6-gv6geBjV.js +8 -0
- package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/frontend/assets/pa-IN-N4M65BXN-CY30-qH7.js +4 -0
- package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
- package/dist/frontend/assets/pica-DWYGHLQV.js +7 -0
- package/dist/frontend/assets/pie-CDiWh1rj.js +1 -0
- package/dist/frontend/assets/pieDiagram-421022e6-Bgy-duyf.js +35 -0
- package/dist/frontend/assets/pieDiagram-8a3498a8-DH7FsbPK.js +35 -0
- package/dist/frontend/assets/pl-PL-T2D74RX3-TrHugsIA.js +9 -0
- package/dist/frontend/assets/pt-BR-5N22H2LF-CJxdFp9c.js +9 -0
- package/dist/frontend/assets/pt-PT-UZXXM6DQ-CB-1q6YJ.js +9 -0
- package/dist/frontend/assets/quadrantDiagram-0957ecba-DcwYI1oi.js +7 -0
- package/dist/frontend/assets/quadrantDiagram-120e2f19-CvHU5a9v.js +7 -0
- package/dist/frontend/assets/requirementDiagram-23d650b8-tdagN8HP.js +52 -0
- package/dist/frontend/assets/requirementDiagram-deff3bca-BMGM-fSI.js +52 -0
- package/dist/frontend/assets/ro-RO-JPDTUUEW-DQq9DBOd.js +11 -0
- package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
- package/dist/frontend/assets/ru-RU-B4JR7IUQ-CWX32rfh.js +9 -0
- package/dist/frontend/assets/sankeyDiagram-04a897e0-3i6zWpY2.js +8 -0
- package/dist/frontend/assets/sankeyDiagram-23345273-ncj1Ol7S.js +8 -0
- package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
- package/dist/frontend/assets/selectAll-DJwesbwT.js +1 -0
- package/dist/frontend/assets/sequenceDiagram-17ac3bff-ChyKKRZA.js +122 -0
- package/dist/frontend/assets/sequenceDiagram-704730f1-D57_wP9k.js +122 -0
- package/dist/frontend/assets/si-LK-N5RQ5JYF-Lkc5ZxyG.js +1 -0
- package/dist/frontend/assets/sk-SK-C5VTKIMK-DyvYXjqZ.js +6 -0
- package/dist/frontend/assets/sl-SI-NN7IZMDC-D_MvP9Rh.js +6 -0
- package/dist/frontend/assets/stateDiagram-587899a1-OjFTnFLS.js +1 -0
- package/dist/frontend/assets/stateDiagram-9c5f0230-BzU36S5C.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-51a3dcff-CfX1qo7U.js +1 -0
- package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-BH7Ud4sU.js +1 -0
- package/dist/frontend/assets/styles-2ab5d517-CA8x9Nh5.js +116 -0
- package/dist/frontend/assets/styles-5f03d8d2-BnO2pLhV.js +160 -0
- package/dist/frontend/assets/styles-6aaf32cf-COnot1ea.js +207 -0
- package/dist/frontend/assets/styles-9a916d00-C3QkBgTF.js +160 -0
- package/dist/frontend/assets/styles-c10674c1-CPAG2_rr.js +116 -0
- package/dist/frontend/assets/styles-edf9a4b0-B8Q9fyu0.js +207 -0
- package/dist/frontend/assets/subset-shared.chunk.js +84 -0
- package/dist/frontend/assets/subset-worker.chunk.js +1 -0
- package/dist/frontend/assets/sv-SE-XGPEYMSR-t6voGQSI.js +10 -0
- package/dist/frontend/assets/svgDrawCommon-08f97a94-CdT21LWf.js +1 -0
- package/dist/frontend/assets/svgDrawCommon-3ba9043b-D1MU7jAE.js +1 -0
- package/dist/frontend/assets/ta-IN-2NMHFXQM-7VmfDDyV.js +9 -0
- package/dist/frontend/assets/th-TH-HPSO5L25-B5rLEY9P.js +2 -0
- package/dist/frontend/assets/timeline-definition-7e6b55e7-BIcL44O_.js +61 -0
- package/dist/frontend/assets/timeline-definition-85554ec2-TgrYYwpU.js +61 -0
- package/dist/frontend/assets/tr-TR-DEFEU3FU-0CgOHXTn.js +7 -0
- package/dist/frontend/assets/uk-UA-QMV73CPH-CzSFsIsx.js +6 -0
- package/dist/frontend/assets/union-CnQAJu0l.js +1 -0
- package/dist/frontend/assets/vi-VN-M7AON7JQ-Bhx3rYU9.js +5 -0
- package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
- package/dist/frontend/assets/xychartDiagram-b6496bcd-BMKDop6v.js +7 -0
- package/dist/frontend/assets/xychartDiagram-e933f94c-DAWithrN.js +7 -0
- package/dist/frontend/assets/zh-CN-LNUGB5OW-Br52_wH4.js +10 -0
- package/dist/frontend/assets/zh-HK-E62DVLB3-Cj6mnIQ9.js +1 -0
- package/dist/frontend/assets/zh-TW-RAJ6MFWO-yLEsVBbC.js +9 -0
- package/dist/frontend/assets/zipObject-BeXj5PVd.js +1 -0
- package/dist/frontend/index.html +339 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2305 -0
- package/dist/index.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +133 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1217 -0
- package/dist/server.js.map +1 -0
- package/dist/target.d.ts +41 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +244 -0
- package/dist/target.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +53 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/auth.d.ts +15 -0
- package/dist/utils/auth.d.ts.map +1 -0
- package/dist/utils/auth.js +68 -0
- package/dist/utils/auth.js.map +1 -0
- package/dist/utils/github.d.ts +26 -0
- package/dist/utils/github.d.ts.map +1 -0
- package/dist/utils/github.js +111 -0
- package/dist/utils/github.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +25 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/r2store.d.ts +19 -0
- package/dist/utils/r2store.d.ts.map +1 -0
- package/dist/utils/r2store.js +75 -0
- package/dist/utils/r2store.js.map +1 -0
- package/frontend/index.html +338 -0
- package/frontend/src/App.tsx +1091 -0
- package/frontend/src/main.tsx +15 -0
- package/frontend/src/utils/ghSync.ts +171 -0
- package/frontend/src/utils/mermaidConverter.ts +49 -0
- package/frontend/src/utils/webmcp.ts +285 -0
- package/package.json +101 -0
- package/scripts/check-local-bind.mjs +164 -0
- package/scripts/postinstall.mjs +23 -0
- package/scripts/publish-pages.sh +39 -0
- package/scripts/seed-r2.mjs +9 -0
- package/skills/excalidraw-skill/SKILL.md +303 -0
- package/skills/excalidraw-skill/evals/evals.json +23 -0
- package/skills/excalidraw-skill/references/cheatsheet.md +142 -0
- package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
- package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
- package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
- package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
- package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
- package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
- package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
- package/src/cli.ts +559 -0
- package/src/index.ts +2557 -0
- package/src/remote.ts +96 -0
- package/src/server.ts +1349 -0
- package/src/target.ts +208 -0
- package/src/types.ts +338 -0
- package/src/utils/auth.ts +61 -0
- package/src/utils/github.ts +93 -0
- package/src/utils/logger.ts +35 -0
- package/src/utils/r2store.ts +76 -0
- package/tsconfig.json +50 -0
- 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
|