agent-whiteboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +470 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/cli.js +30 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-0cdd387c.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-30da91e8.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-68534840.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-07d8e303.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-1ae6bd74.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-de7701e4.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-3398dd02.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-5d53e70a.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-ed0b7437.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-74444efd.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-9163df9c.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-9be7ceb8.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-1e6f9579.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-51814d27.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-5e28753b.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-0f60d1b8.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-138ac28d.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-c76c5d69.woff +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-70ee1f64.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-99cd42a3.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-a6f7ec0d.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-0d85ae7c.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-97479ca6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-f1d6ef86.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-c2342cd8.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-c6368d87.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-d0332f52.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-850c0af5.woff +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-dc47344d.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-f9377ab0.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-08ce98e5.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-7af58c5e.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-8a8d2445.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-1ece03f7.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-e99ae511.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-ece03cfd.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-00b26ac8.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-3931dd81.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-91ee6750.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-11e4dc8a.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-68e8c73e.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-f36ea897.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-036d4e95.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-1c67f068.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-d96cdf2b.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-6b47c401.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-95b6d2f1.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-c943cc98.woff +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-2014c523.woff +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-a6b2099f.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-d04c5421.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-500e04d5.ttf +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-6ab6b62e.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-99f9c675.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-a4af7d41.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-c647367d.ttf +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-71d517d6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-e14fed02.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-f01f3e87.ttf +0 -0
- package/dist/client/assets/arc-4691b008.js +1 -0
- package/dist/client/assets/architecture-7EHR7CIX-e08620e8.js +1 -0
- package/dist/client/assets/architectureDiagram-3BPJPVTR-9e2f2dbd.js +36 -0
- package/dist/client/assets/blockDiagram-GPEHLZMM-d9dd3f7a.js +132 -0
- package/dist/client/assets/bootstrap.min-21652813.js +6 -0
- package/dist/client/assets/c4Diagram-AAUBKEIU-4ed97d79.js +10 -0
- package/dist/client/assets/channel-9d07a35d.js +1 -0
- package/dist/client/assets/chunk-2J33WTMH-8049adc8.js +1 -0
- package/dist/client/assets/chunk-4BX2VUAB-fd7396be.js +1 -0
- package/dist/client/assets/chunk-55IACEB6-ae1ac93c.js +1 -0
- package/dist/client/assets/chunk-727SXJPM-7e3658d9.js +206 -0
- package/dist/client/assets/chunk-AQP2D5EJ-114f64b0.js +231 -0
- package/dist/client/assets/chunk-FMBD7UC4-f2647c5e.js +15 -0
- package/dist/client/assets/chunk-ND2GUHAM-0a2fcd8c.js +1 -0
- package/dist/client/assets/chunk-QZHKN3VN-d00b3f33.js +1 -0
- package/dist/client/assets/classDiagram-4FO5ZUOK-ebb5cc4a.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ebb5cc4a.js +1 -0
- package/dist/client/assets/colors-98f238a0.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-1f808fd7.js +1 -0
- package/dist/client/assets/cytoscape.esm-1491e259.js +331 -0
- package/dist/client/assets/dagre-BM42HDAG-e8ec1c9d.js +4 -0
- package/dist/client/assets/defaultLocale-a8476f12.js +1 -0
- package/dist/client/assets/diagram-2AECGRRQ-83b385bf.js +43 -0
- package/dist/client/assets/diagram-5GNKFQAL-d9406c6a.js +10 -0
- package/dist/client/assets/diagram-KO2AKTUF-3db5b95a.js +3 -0
- package/dist/client/assets/diagram-LMA3HP47-fbd90397.js +24 -0
- package/dist/client/assets/diagram-OG6HWLK6-3c697534.js +24 -0
- package/dist/client/assets/embed-2b4adf34.js +201 -0
- package/dist/client/assets/erDiagram-TEJ5UH35-7f41448f.js +85 -0
- package/dist/client/assets/eventmodeling-FCH6USID-e6d9019e.js +1 -0
- package/dist/client/assets/flowDiagram-I6XJVG4X-38f05cc7.js +162 -0
- package/dist/client/assets/ganttDiagram-6RSMTGT7-39287f5a.js +292 -0
- package/dist/client/assets/gitGraph-WXDBUCRP-037e225a.js +1 -0
- package/dist/client/assets/gitGraphDiagram-PVQCEYII-3c2e3ef7.js +106 -0
- package/dist/client/assets/graph-f3dfaf53.js +1 -0
- package/dist/client/assets/index-4abf3da0.js +5 -0
- package/dist/client/assets/index-a1656aec.js +1 -0
- package/dist/client/assets/index-d674a6a4.css +1 -0
- package/dist/client/assets/info-J43DQDTF-51cd3390.js +1 -0
- package/dist/client/assets/infoDiagram-5YYISTIA-ef2826b7.js +2 -0
- package/dist/client/assets/init-a5b10ee5.js +1 -0
- package/dist/client/assets/ishikawaDiagram-YF4QCWOH-d0b8a2e0.js +70 -0
- package/dist/client/assets/journeyDiagram-JHISSGLW-586ffcf8.js +139 -0
- package/dist/client/assets/kanban-definition-UN3LZRKU-9e58e14f.js +89 -0
- package/dist/client/assets/katex-0cba7652.js +257 -0
- package/dist/client/assets/katex-4c9ea27f.js +257 -0
- package/dist/client/assets/layout-3a4eff80.js +1 -0
- package/dist/client/assets/linear-fed00341.js +1 -0
- package/dist/client/assets/mermaid-parser.core-7ee09942.js +173 -0
- package/dist/client/assets/mermaid.core-9fc8f6d2.js +306 -0
- package/dist/client/assets/mindmap-definition-RKZ34NQL-a40dd09f.js +96 -0
- package/dist/client/assets/ordinal-db88ae6f.js +1 -0
- package/dist/client/assets/packet-YPE3B663-f55b568a.js +1 -0
- package/dist/client/assets/pie-LRSECV5Y-88c1a5e6.js +1 -0
- package/dist/client/assets/pieDiagram-4H26LBE5-51aae5ea.js +30 -0
- package/dist/client/assets/quadrantDiagram-W4KKPZXB-bc4686f9.js +7 -0
- package/dist/client/assets/radar-GUYGQ44K-6ede145d.js +1 -0
- package/dist/client/assets/range-6bc2a9a0.js +1 -0
- package/dist/client/assets/requirementDiagram-4Y6WPE33-8466f5a1.js +84 -0
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-fd54c1f6.js +40 -0
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-baa63c66.js +162 -0
- package/dist/client/assets/stateDiagram-AJRCARHV-bb4f0d0b.js +1 -0
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-9479cd12.js +1 -0
- package/dist/client/assets/step-171ad54a.js +1 -0
- package/dist/client/assets/time-043b3464.js +1 -0
- package/dist/client/assets/timeline-definition-PNZ67QCA-f64b58bd.js +120 -0
- package/dist/client/assets/treeView-BLDUP644-73ba1a83.js +1 -0
- package/dist/client/assets/treemap-LRROVOQU-98e47821.js +1 -0
- package/dist/client/assets/vennDiagram-CIIHVFJN-a410b1f7.js +34 -0
- package/dist/client/assets/wardley-L42UT6IY-a27badb9.js +1 -0
- package/dist/client/assets/wardleyDiagram-YWT4CUSO-527ffe52.js +78 -0
- package/dist/client/assets/xychartDiagram-2RQKCTM6-42b5914b.js +7 -0
- package/dist/client/favicon-16x16.png +0 -0
- package/dist/client/favicon-32x32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/index.html +18 -0
- package/dist/server/app.js +429 -0
- package/dist/server/channel.js +42 -0
- package/dist/server/events.js +90 -0
- package/dist/server/export-html.js +461 -0
- package/dist/server/index.js +79 -0
- package/dist/server/interaction.js +135 -0
- package/dist/server/mcp.js +421 -0
- package/dist/server/paths.js +11 -0
- package/dist/server/persist.js +56 -0
- package/dist/server/presentation.js +5 -0
- package/dist/server/render-core.js +213 -0
- package/dist/server/session.js +103 -0
- package/dist/server/slideshow.js +83 -0
- package/dist/server/snapshot-reader.js +190 -0
- package/dist/server/snapshot-writer.js +117 -0
- package/dist/server/snapshot.js +47 -0
- package/dist/server/step-frames-builder.js +70 -0
- package/dist/server/validate.js +140 -0
- package/dist/server/viewport-cache.js +69 -0
- package/dist/server/ws.js +71 -0
- package/package.json +92 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// In-process event bus for user interaction signals.
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
const bus = new EventEmitter();
|
|
4
|
+
const WAIT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
5
|
+
// ── Done signal ───────────────────────────────────────────────────────────────
|
|
6
|
+
let doneArmed = false;
|
|
7
|
+
export function getDoneArmed() {
|
|
8
|
+
return doneArmed;
|
|
9
|
+
}
|
|
10
|
+
// Lazily imported to avoid circular dependency (ws.ts imports events.ts indirectly).
|
|
11
|
+
let _broadcastFn = null;
|
|
12
|
+
export function setBroadcastFn(fn) {
|
|
13
|
+
_broadcastFn = fn;
|
|
14
|
+
}
|
|
15
|
+
function broadcastDoneArmed(armed) {
|
|
16
|
+
_broadcastFn?.({ action: 'set_done_armed', armed });
|
|
17
|
+
}
|
|
18
|
+
/** Signal that the user clicked Done — wakes all pending waitForDone() calls. */
|
|
19
|
+
export function signalDone() {
|
|
20
|
+
bus.emit('done');
|
|
21
|
+
}
|
|
22
|
+
/** Resolve when the user clicks Done (or after the timeout). */
|
|
23
|
+
export function waitForDone() {
|
|
24
|
+
doneArmed = true;
|
|
25
|
+
broadcastDoneArmed(true);
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const onDone = () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
doneArmed = false;
|
|
30
|
+
broadcastDoneArmed(false);
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
bus.off('done', onDone);
|
|
35
|
+
doneArmed = false;
|
|
36
|
+
broadcastDoneArmed(false);
|
|
37
|
+
resolve();
|
|
38
|
+
}, WAIT_TIMEOUT_MS);
|
|
39
|
+
bus.once('done', onDone);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// At most one pending waitForClick() at a time.
|
|
43
|
+
let clickResolve = null;
|
|
44
|
+
let clickTimer = null;
|
|
45
|
+
/** Signal that the user clicked a node/edge — resolves the pending waitForClick(). No-op if none pending. */
|
|
46
|
+
export function signalClick(event) {
|
|
47
|
+
if (!clickResolve)
|
|
48
|
+
return;
|
|
49
|
+
const resolve = clickResolve;
|
|
50
|
+
clickResolve = null;
|
|
51
|
+
if (clickTimer) {
|
|
52
|
+
clearTimeout(clickTimer);
|
|
53
|
+
clickTimer = null;
|
|
54
|
+
}
|
|
55
|
+
resolve(event);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve when the user clicks a node/edge (or after the timeout).
|
|
59
|
+
* At most one call may be pending — a second call cancels and replaces the first.
|
|
60
|
+
*/
|
|
61
|
+
export function waitForClick() {
|
|
62
|
+
// Cancel any existing listener.
|
|
63
|
+
if (clickResolve) {
|
|
64
|
+
clickResolve({ type: 'timeout', id: '', label: '', action: null });
|
|
65
|
+
clickResolve = null;
|
|
66
|
+
}
|
|
67
|
+
if (clickTimer) {
|
|
68
|
+
clearTimeout(clickTimer);
|
|
69
|
+
clickTimer = null;
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
clickResolve = resolve;
|
|
73
|
+
clickTimer = setTimeout(() => {
|
|
74
|
+
clickResolve = null;
|
|
75
|
+
clickTimer = null;
|
|
76
|
+
resolve({ type: 'timeout', id: '', label: '', action: null });
|
|
77
|
+
}, WAIT_TIMEOUT_MS);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Reset click state — for use in tests only. */
|
|
81
|
+
export function resetClick() {
|
|
82
|
+
if (clickResolve) {
|
|
83
|
+
clickResolve({ type: 'timeout', id: '', label: '', action: null });
|
|
84
|
+
clickResolve = null;
|
|
85
|
+
}
|
|
86
|
+
if (clickTimer) {
|
|
87
|
+
clearTimeout(clickTimer);
|
|
88
|
+
clickTimer = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { Window } from "happy-dom";
|
|
5
|
+
import katex from "katex";
|
|
6
|
+
import * as vl from "vega-lite";
|
|
7
|
+
import * as vega from "vega";
|
|
8
|
+
import DOMPurify from "dompurify";
|
|
9
|
+
import { badgeType } from "./snapshot-reader.js";
|
|
10
|
+
function saveGlobals() {
|
|
11
|
+
const keys = [
|
|
12
|
+
"document", "window", "CSSStyleSheet", "SVGElement", "HTMLElement",
|
|
13
|
+
"Element", "DOMParser", "MutationObserver",
|
|
14
|
+
"requestAnimationFrame", "cancelAnimationFrame",
|
|
15
|
+
];
|
|
16
|
+
const saved = new Map();
|
|
17
|
+
for (const k of keys)
|
|
18
|
+
saved.set(k, global[k]);
|
|
19
|
+
return saved;
|
|
20
|
+
}
|
|
21
|
+
function setGlobals(win) {
|
|
22
|
+
const g = global;
|
|
23
|
+
const w = win;
|
|
24
|
+
g["document"] = win.document;
|
|
25
|
+
g["window"] = win;
|
|
26
|
+
g["CSSStyleSheet"] = w["CSSStyleSheet"];
|
|
27
|
+
g["SVGElement"] = w["SVGElement"];
|
|
28
|
+
g["HTMLElement"] = w["HTMLElement"];
|
|
29
|
+
g["Element"] = w["Element"];
|
|
30
|
+
g["DOMParser"] = w["DOMParser"];
|
|
31
|
+
g["MutationObserver"] = w["MutationObserver"];
|
|
32
|
+
g["requestAnimationFrame"] = (fn) => setTimeout(() => fn(Date.now()), 16);
|
|
33
|
+
g["cancelAnimationFrame"] = clearTimeout;
|
|
34
|
+
}
|
|
35
|
+
function restoreGlobals(saved) {
|
|
36
|
+
for (const [k, v] of saved) {
|
|
37
|
+
if (v === undefined) {
|
|
38
|
+
delete global[k];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
global[k] = v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ── Renderers ──────────────────────────────────────────────────────────────
|
|
46
|
+
function renderMermaidContainer(payload) {
|
|
47
|
+
return `<pre class="mermaid">${escapeHtml(payload)}</pre>`;
|
|
48
|
+
}
|
|
49
|
+
function renderKatex(payload) {
|
|
50
|
+
return katex.renderToString(payload, { displayMode: true, throwOnError: false });
|
|
51
|
+
}
|
|
52
|
+
async function renderVegaLite(payload) {
|
|
53
|
+
const spec = JSON.parse(payload);
|
|
54
|
+
const vegaSpec = vl.compile(spec).spec;
|
|
55
|
+
const view = new vega.View(vega.parse(vegaSpec), { renderer: "none" });
|
|
56
|
+
return view.toSVG();
|
|
57
|
+
}
|
|
58
|
+
function renderSvgPayload(payload, purify) {
|
|
59
|
+
return purify.sanitize(payload, { USE_PROFILES: { svg: true } });
|
|
60
|
+
}
|
|
61
|
+
function renderHtmlPayload(payload, purify) {
|
|
62
|
+
return purify.sanitize(payload, { USE_PROFILES: { html: true } });
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pure string-transform primitive: wraps a CSS string in `@scope (<selector-list>)
|
|
66
|
+
* { ... }`, limiting selector matching to the given anchor ids' subtrees. Has no
|
|
67
|
+
* knowledge of *why* it's being called — used both to contain a payload's own
|
|
68
|
+
* embedded <style> tag (scopeEmbeddedStyles(), single anchor) and to scope the
|
|
69
|
+
* shared Bootstrap stylesheet across every html-type item/frame anchor in one
|
|
70
|
+
* export (v0.31 Sprint 70, multi-anchor).
|
|
71
|
+
*/
|
|
72
|
+
export function scopeCss(css, anchorIds) {
|
|
73
|
+
const selectorList = anchorIds.map((id) => `#${id}`).join(", ");
|
|
74
|
+
return `@scope (${selectorList}) {\n${css}\n}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Contains any <style> tag embedded in rendered "html"/"svg" content to the
|
|
78
|
+
* element it's meant to style, instead of leaking document-wide. A <style>
|
|
79
|
+
* element is normally document-scoped regardless of DOM nesting — a payload
|
|
80
|
+
* shipping its own theme stylesheet (e.g. a markdown->HTML converter's
|
|
81
|
+
* readable-width CSS, `body { max-width: 900px }`) would otherwise silently
|
|
82
|
+
* override the whole export's layout. `@scope` limits selector matching to
|
|
83
|
+
* `anchorId`'s subtree: a `body {}` rule then matches nothing (no `<body>`
|
|
84
|
+
* descendant exists there), while `table {}`/`code {}`/etc. still apply
|
|
85
|
+
* correctly to the payload's own content. Real root cause of bug B20 ("main
|
|
86
|
+
* too narrow") — see `01`/`03`/`04`. An earlier fix (`FORBID_TAGS: ["style"]`)
|
|
87
|
+
* closed the leak but also discarded the payload's own legitimate table/code
|
|
88
|
+
* formatting living in the same <style> block; scoping keeps both.
|
|
89
|
+
*/
|
|
90
|
+
function scopeEmbeddedStyles(html, anchorId) {
|
|
91
|
+
return html.replace(/<style>([\s\S]*?)<\/style>/gi, (_, css) => `<style>${scopeCss(css, [anchorId])}</style>`);
|
|
92
|
+
}
|
|
93
|
+
async function renderByType(type, payload, purify) {
|
|
94
|
+
switch (type) {
|
|
95
|
+
case "mermaid":
|
|
96
|
+
return renderMermaidContainer(payload);
|
|
97
|
+
case "katex":
|
|
98
|
+
return renderKatex(payload);
|
|
99
|
+
case "vega-lite":
|
|
100
|
+
return renderVegaLite(payload);
|
|
101
|
+
case "svg":
|
|
102
|
+
return renderSvgPayload(payload, purify);
|
|
103
|
+
case "html":
|
|
104
|
+
return renderHtmlPayload(payload, purify);
|
|
105
|
+
default:
|
|
106
|
+
throw new Error(`unsupported render type: ${type}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Renders a snapshot's already-resolved `frames[]` (v0.26 Sprint 43 — no more
|
|
111
|
+
* `JSON.parse(payload)` of a step-frames envelope; a sequence is just an
|
|
112
|
+
* array with more than one element). A single frame renders as one content
|
|
113
|
+
* block; multiple frames render as a step-frames section, each frame using
|
|
114
|
+
* its own already-resolved `type` (no `frame_type` fallback needed).
|
|
115
|
+
*/
|
|
116
|
+
async function renderFrames(frames, purify) {
|
|
117
|
+
if (frames.length > 1) {
|
|
118
|
+
const rendered = [];
|
|
119
|
+
for (const frame of frames) {
|
|
120
|
+
try {
|
|
121
|
+
const html = await renderByType(frame.type, frame.payload, purify);
|
|
122
|
+
rendered.push({ label: frame.label, html, type: frame.type });
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
126
|
+
rendered.push({ label: frame.label, html: `<p class="export-error">${escapeHtml(msg)}</p>`, type: frame.type });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { kind: "stepFrames", frames: rendered };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const html = await renderByType(frames[0].type, frames[0].payload, purify);
|
|
133
|
+
return { kind: "html", html, type: frames[0].type };
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
137
|
+
return { kind: "error", message: msg };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── HTML assembly ──────────────────────────────────────────────────────────
|
|
141
|
+
function escapeHtml(s) {
|
|
142
|
+
return s
|
|
143
|
+
.replace(/&/g, "&")
|
|
144
|
+
.replace(/</g, "<")
|
|
145
|
+
.replace(/>/g, ">")
|
|
146
|
+
.replace(/"/g, """);
|
|
147
|
+
}
|
|
148
|
+
function formatTimestampHuman(iso) {
|
|
149
|
+
try {
|
|
150
|
+
return new Date(iso).toLocaleString("en-US", {
|
|
151
|
+
year: "numeric",
|
|
152
|
+
month: "short",
|
|
153
|
+
day: "numeric",
|
|
154
|
+
hour: "2-digit",
|
|
155
|
+
minute: "2-digit",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return iso;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
let cachedKatexCss;
|
|
163
|
+
function getKatexCss() {
|
|
164
|
+
if (cachedKatexCss !== undefined)
|
|
165
|
+
return cachedKatexCss;
|
|
166
|
+
try {
|
|
167
|
+
const req = createRequire(import.meta.url);
|
|
168
|
+
const cssPath = req.resolve("katex/dist/katex.min.css");
|
|
169
|
+
cachedKatexCss = readFileSync(cssPath, "utf-8");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
cachedKatexCss = "";
|
|
173
|
+
}
|
|
174
|
+
return cachedKatexCss;
|
|
175
|
+
}
|
|
176
|
+
let cachedBootstrapCss;
|
|
177
|
+
/**
|
|
178
|
+
* Bootstrap's color system (alert/badge/table variants, etc.) is defined
|
|
179
|
+
* almost entirely via CSS custom properties set on `:root`. `@scope` only
|
|
180
|
+
* matches elements within the scope root's own subtree — `:root` (the
|
|
181
|
+
* `<html>` element) is an ancestor of the scope root, never a descendant,
|
|
182
|
+
* so a bare `:root` rule inside `@scope (#anchor) { ... }` never matches
|
|
183
|
+
* and its custom properties are silently never set. `:scope`, inside an
|
|
184
|
+
* `@scope` block, refers to the scope root element itself — properties set
|
|
185
|
+
* there still inherit normally to every descendant. Without this rewrite,
|
|
186
|
+
* colors/backgrounds silently fall back to initial values while
|
|
187
|
+
* non-variable properties (padding, etc.) keep working, producing
|
|
188
|
+
* unstyled-looking Bootstrap components with no error. Found via manual
|
|
189
|
+
* browser verification, v0.31 Sprint 71/70.
|
|
190
|
+
*/
|
|
191
|
+
function getBootstrapCss() {
|
|
192
|
+
if (cachedBootstrapCss !== undefined)
|
|
193
|
+
return cachedBootstrapCss;
|
|
194
|
+
try {
|
|
195
|
+
const req = createRequire(import.meta.url);
|
|
196
|
+
const cssPath = req.resolve("bootstrap/dist/css/bootstrap.min.css");
|
|
197
|
+
const raw = readFileSync(cssPath, "utf-8");
|
|
198
|
+
cachedBootstrapCss = raw.replace(/:root/g, ":scope");
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
cachedBootstrapCss = "";
|
|
202
|
+
}
|
|
203
|
+
return cachedBootstrapCss;
|
|
204
|
+
}
|
|
205
|
+
let cachedMermaidBundle;
|
|
206
|
+
function getMermaidBundle() {
|
|
207
|
+
if (cachedMermaidBundle !== undefined)
|
|
208
|
+
return cachedMermaidBundle;
|
|
209
|
+
const req = createRequire(import.meta.url);
|
|
210
|
+
const bundlePath = req.resolve("mermaid/dist/mermaid.min.js");
|
|
211
|
+
const source = readFileSync(bundlePath, "utf-8");
|
|
212
|
+
// Guard against a literal "</script" sequence inside the bundle prematurely
|
|
213
|
+
// closing the inline <script> tag when parsed as HTML.
|
|
214
|
+
cachedMermaidBundle = source.replace(/<\/script/gi, "<\\/script");
|
|
215
|
+
return cachedMermaidBundle;
|
|
216
|
+
}
|
|
217
|
+
const cachedCdnAssets = new Map();
|
|
218
|
+
/**
|
|
219
|
+
* Builds a pinned-version, SRI-hashed jsdelivr reference for an npm package's
|
|
220
|
+
* dist file. The hash is computed from the *locally installed* copy (same
|
|
221
|
+
* `package.json` version jsdelivr's npm mirror serves) rather than hardcoded,
|
|
222
|
+
* so it never drifts out of sync when mermaid/bootstrap/katex are
|
|
223
|
+
* version-bumped — no manual regeneration step required.
|
|
224
|
+
*/
|
|
225
|
+
function getCdnAsset(pkgName, distRelPath) {
|
|
226
|
+
const cacheKey = `${pkgName}/${distRelPath}`;
|
|
227
|
+
const cached = cachedCdnAssets.get(cacheKey);
|
|
228
|
+
if (cached)
|
|
229
|
+
return cached;
|
|
230
|
+
const req = createRequire(import.meta.url);
|
|
231
|
+
const filePath = req.resolve(`${pkgName}/${distRelPath}`);
|
|
232
|
+
const buffer = readFileSync(filePath);
|
|
233
|
+
const version = req(`${pkgName}/package.json`).version;
|
|
234
|
+
const integrity = `sha384-${createHash("sha384").update(buffer).digest("base64")}`;
|
|
235
|
+
const asset = {
|
|
236
|
+
url: `https://cdn.jsdelivr.net/npm/${pkgName}@${version}/${distRelPath}`,
|
|
237
|
+
integrity,
|
|
238
|
+
};
|
|
239
|
+
cachedCdnAssets.set(cacheKey, asset);
|
|
240
|
+
return asset;
|
|
241
|
+
}
|
|
242
|
+
function getMermaidCdnAsset() {
|
|
243
|
+
return getCdnAsset("mermaid", "dist/mermaid.min.js");
|
|
244
|
+
}
|
|
245
|
+
function getBootstrapCdnAsset() {
|
|
246
|
+
return getCdnAsset("bootstrap", "dist/css/bootstrap.min.css");
|
|
247
|
+
}
|
|
248
|
+
function getKatexCdnAsset() {
|
|
249
|
+
return getCdnAsset("katex", "dist/katex.min.css");
|
|
250
|
+
}
|
|
251
|
+
function cdnScriptTag(asset) {
|
|
252
|
+
return `<script src="${asset.url}" integrity="${asset.integrity}" crossorigin="anonymous"></script>`;
|
|
253
|
+
}
|
|
254
|
+
function cdnStylesheetTag(asset) {
|
|
255
|
+
return `<link rel="stylesheet" href="${asset.url}" integrity="${asset.integrity}" crossorigin="anonymous">`;
|
|
256
|
+
}
|
|
257
|
+
const LAYOUT_CSS = `
|
|
258
|
+
* { box-sizing: border-box; }
|
|
259
|
+
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; display: flex; background: #f9f9f9; }
|
|
260
|
+
nav { width: 240px; min-width: 200px; padding: 24px 16px; background: #fff; border-right: 1px solid #e0e0e0; position: sticky; top: 0; height: 100vh; overflow-y: auto; flex-shrink: 0; }
|
|
261
|
+
nav h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 12px; }
|
|
262
|
+
nav ul { list-style: none; margin: 0; padding: 0; }
|
|
263
|
+
nav li { margin: 2px 0; }
|
|
264
|
+
nav a { font-size: 13px; color: #2980b9; text-decoration: none; display: block; padding: 2px 0 2px 8px; }
|
|
265
|
+
nav a:hover { text-decoration: underline; }
|
|
266
|
+
nav .toc-ws { font-weight: 600; color: #333; font-size: 12px; margin-top: 10px; padding-left: 0 !important; }
|
|
267
|
+
nav .toc-ws > a { font-weight: 600; color: #333; padding-left: 0; }
|
|
268
|
+
nav .toc-frames { padding-left: 12px; }
|
|
269
|
+
nav .toc-frames a { font-size: 12px; color: #5b8ab8; }
|
|
270
|
+
main { flex: 1; padding: 32px 40px; min-width: 0; }
|
|
271
|
+
.workspace-section { margin-bottom: 48px; }
|
|
272
|
+
.workspace-heading { font-size: 20px; font-weight: 700; color: #222; margin: 0 0 20px; padding-bottom: 8px; border-bottom: 2px solid #e0e0e0; }
|
|
273
|
+
.item-section { margin-bottom: 28px; padding: 20px; background: #fff; border: 1px solid #e8e8e8; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); overflow-x: auto; }
|
|
274
|
+
.item-heading { font-size: 15px; font-weight: 600; color: #333; margin: 0 0 4px; }
|
|
275
|
+
.item-meta { font-size: 12px; color: #888; margin: 0 0 16px; display: flex; align-items: center; gap: 8px; }
|
|
276
|
+
.type-badge { background: #e8f4fd; color: #2980b9; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
277
|
+
.frame-section { margin-top: 16px; padding: 12px 16px; border: 1px solid #f0f0f0; border-radius: 4px; background: #fafafa; overflow-x: auto; }
|
|
278
|
+
.frame-heading { font-size: 12px; color: #777; margin: 0 0 10px; font-style: italic; }
|
|
279
|
+
.export-error { color: #c0392b; background: #fdf0f0; padding: 10px 14px; border-radius: 4px; border-left: 3px solid #e74c3c; font-size: 13px; margin: 0; }
|
|
280
|
+
svg { max-width: 100%; height: auto; }
|
|
281
|
+
table { max-width: 100%; border-collapse: collapse; }
|
|
282
|
+
pre, code { max-width: 100%; }
|
|
283
|
+
`.trim();
|
|
284
|
+
function assembleHtml(items, hasKatex, hasMermaid, mode) {
|
|
285
|
+
// Group by workspace, order workspaces by earliest item timestamp.
|
|
286
|
+
const wsMap = new Map();
|
|
287
|
+
for (const item of items) {
|
|
288
|
+
if (!wsMap.has(item.workspace))
|
|
289
|
+
wsMap.set(item.workspace, []);
|
|
290
|
+
wsMap.get(item.workspace).push(item);
|
|
291
|
+
}
|
|
292
|
+
for (const list of wsMap.values()) {
|
|
293
|
+
list.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
294
|
+
}
|
|
295
|
+
const workspaces = [...wsMap.entries()].sort(([, al], [, bl]) => al[0].timestamp.localeCompare(bl[0].timestamp));
|
|
296
|
+
let counter = 0;
|
|
297
|
+
const nextId = () => `item-${++counter}`;
|
|
298
|
+
let tocHtml = "";
|
|
299
|
+
let mainHtml = "";
|
|
300
|
+
const htmlAnchorIds = [];
|
|
301
|
+
for (const [ws, wsList] of workspaces) {
|
|
302
|
+
const wsAnchor = `ws-${ws.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
|
303
|
+
tocHtml += `<li class="toc-ws"><a href="#${wsAnchor}">${escapeHtml(ws)}</a><ul>`;
|
|
304
|
+
mainHtml += `<section class="workspace-section" id="${wsAnchor}">`;
|
|
305
|
+
mainHtml += `<h2 class="workspace-heading">${escapeHtml(ws)}</h2>`;
|
|
306
|
+
for (const item of wsList) {
|
|
307
|
+
const itemId = nextId();
|
|
308
|
+
const label = item.title ?? formatTimestampHuman(item.timestamp);
|
|
309
|
+
const { content } = item;
|
|
310
|
+
if (content.kind === "stepFrames") {
|
|
311
|
+
// Parent link points at frame 0's anchor — same target as that
|
|
312
|
+
// frame's own submenu entry below (B22 in `01`).
|
|
313
|
+
tocHtml += `<li><a href="#${itemId}-frame-0">${escapeHtml(label)}</a><ul class="toc-frames">`;
|
|
314
|
+
for (let i = 0; i < content.frames.length; i++) {
|
|
315
|
+
const frameLabel = content.frames[i].label ?? `Frame ${i + 1}`;
|
|
316
|
+
tocHtml += `<li><a href="#${itemId}-frame-${i}">${escapeHtml(frameLabel)}</a></li>`;
|
|
317
|
+
}
|
|
318
|
+
tocHtml += `</ul></li>`;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
tocHtml += `<li><a href="#${itemId}">${escapeHtml(label)}</a></li>`;
|
|
322
|
+
}
|
|
323
|
+
mainHtml += `<section class="item-section" id="${itemId}">`;
|
|
324
|
+
mainHtml += `<h3 class="item-heading">${escapeHtml(item.title ?? "—")}</h3>`;
|
|
325
|
+
mainHtml += `<p class="item-meta"><span class="type-badge">${escapeHtml(item.type)}</span><span>${escapeHtml(formatTimestampHuman(item.timestamp))}</span></p>`;
|
|
326
|
+
if (content.kind === "error") {
|
|
327
|
+
mainHtml += `<p class="export-error">${escapeHtml(content.message)}</p>`;
|
|
328
|
+
}
|
|
329
|
+
else if (content.kind === "stepFrames") {
|
|
330
|
+
for (let i = 0; i < content.frames.length; i++) {
|
|
331
|
+
const frame = content.frames[i];
|
|
332
|
+
const frameId = `${itemId}-frame-${i}`;
|
|
333
|
+
if (frame.type === "html")
|
|
334
|
+
htmlAnchorIds.push(frameId);
|
|
335
|
+
mainHtml += `<section class="frame-section" id="${frameId}">`;
|
|
336
|
+
mainHtml += `<h4 class="frame-heading">${escapeHtml(frame.label ?? `Frame ${i + 1}`)}</h4>`;
|
|
337
|
+
mainHtml += scopeEmbeddedStyles(frame.html, frameId);
|
|
338
|
+
mainHtml += `</section>`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
if (content.type === "html")
|
|
343
|
+
htmlAnchorIds.push(itemId);
|
|
344
|
+
mainHtml += scopeEmbeddedStyles(content.html, itemId);
|
|
345
|
+
}
|
|
346
|
+
mainHtml += `</section>`;
|
|
347
|
+
}
|
|
348
|
+
tocHtml += `</ul></li>`;
|
|
349
|
+
mainHtml += `</section>`;
|
|
350
|
+
}
|
|
351
|
+
const hasBootstrap = htmlAnchorIds.length > 0;
|
|
352
|
+
const katexBlock = hasKatex
|
|
353
|
+
? mode === "cdn"
|
|
354
|
+
? `${cdnStylesheetTag(getKatexCdnAsset())}\n`
|
|
355
|
+
: `<style>${getKatexCss()}</style>\n`
|
|
356
|
+
: "";
|
|
357
|
+
// Bootstrap's bare-element scoping (@scope, bug B20) only works when the
|
|
358
|
+
// CSS is inlined as a <style> block — a CDN <link> can't be @scope-wrapped.
|
|
359
|
+
// cdn mode links Bootstrap unscoped anyway (accepted risk, see O4 in `02`);
|
|
360
|
+
// offline mode keeps the inlined+scoped behavior unchanged.
|
|
361
|
+
const bootstrapBlock = hasBootstrap
|
|
362
|
+
? mode === "cdn"
|
|
363
|
+
? `${cdnStylesheetTag(getBootstrapCdnAsset())}\n`
|
|
364
|
+
: `<style>${scopeCss(getBootstrapCss(), htmlAnchorIds)}</style>\n`
|
|
365
|
+
: "";
|
|
366
|
+
const mermaidInitScript = `<script>
|
|
367
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
368
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
|
|
369
|
+
mermaid.run({ querySelector: ".mermaid" });
|
|
370
|
+
});
|
|
371
|
+
</script>
|
|
372
|
+
`;
|
|
373
|
+
const mermaidBlock = hasMermaid
|
|
374
|
+
? mode === "cdn"
|
|
375
|
+
? `${cdnScriptTag(getMermaidCdnAsset())}\n${mermaidInitScript}`
|
|
376
|
+
: `<script>${getMermaidBundle()}</script>\n${mermaidInitScript}`
|
|
377
|
+
: "";
|
|
378
|
+
const csp = mode === "cdn"
|
|
379
|
+
? "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'"
|
|
380
|
+
: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'none'";
|
|
381
|
+
return `<!DOCTYPE html>
|
|
382
|
+
<html lang="en">
|
|
383
|
+
<head>
|
|
384
|
+
<meta charset="UTF-8">
|
|
385
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
386
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
|
387
|
+
<title>Whiteboard Export</title>
|
|
388
|
+
<style>${LAYOUT_CSS}</style>
|
|
389
|
+
${katexBlock}${bootstrapBlock}</head>
|
|
390
|
+
<body>
|
|
391
|
+
<nav>
|
|
392
|
+
<h2>Contents</h2>
|
|
393
|
+
<ul>${tocHtml}</ul>
|
|
394
|
+
</nav>
|
|
395
|
+
<main>${mainHtml}</main>
|
|
396
|
+
${mermaidBlock}</body>
|
|
397
|
+
</html>`;
|
|
398
|
+
}
|
|
399
|
+
// ── Download filename ──────────────────────────────────────────────────────
|
|
400
|
+
function buildDownloadFilename(workspaces) {
|
|
401
|
+
const now = new Date();
|
|
402
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
403
|
+
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
404
|
+
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
405
|
+
if (workspaces.length === 1) {
|
|
406
|
+
const sanitized = workspaces[0].replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 24);
|
|
407
|
+
return `${sanitized}-${ts}.html`;
|
|
408
|
+
}
|
|
409
|
+
return `export-${ts}.html`;
|
|
410
|
+
}
|
|
411
|
+
function itemsIncludeType(items, type) {
|
|
412
|
+
return items.some(({ record }) => record.frames.some((f) => f.type === type));
|
|
413
|
+
}
|
|
414
|
+
// ── Public entrypoint ──────────────────────────────────────────────────────
|
|
415
|
+
async function generateExportHtmlInner(items, mode) {
|
|
416
|
+
const win = new Window();
|
|
417
|
+
const savedGlobals = saveGlobals();
|
|
418
|
+
setGlobals(win);
|
|
419
|
+
const purify = DOMPurify(win);
|
|
420
|
+
const rendered = [];
|
|
421
|
+
try {
|
|
422
|
+
for (const { workspace, record } of items) {
|
|
423
|
+
const content = await renderFrames(record.frames, purify);
|
|
424
|
+
rendered.push({
|
|
425
|
+
workspace,
|
|
426
|
+
type: badgeType(record.frames),
|
|
427
|
+
timestamp: record.timestamp,
|
|
428
|
+
title: record.title,
|
|
429
|
+
content,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
restoreGlobals(savedGlobals);
|
|
435
|
+
win.close();
|
|
436
|
+
}
|
|
437
|
+
const hasKatex = itemsIncludeType(items, "katex");
|
|
438
|
+
const hasMermaid = itemsIncludeType(items, "mermaid");
|
|
439
|
+
const html = assembleHtml(rendered, hasKatex, hasMermaid, mode);
|
|
440
|
+
const uniqueWorkspaces = [...new Set(items.map((i) => i.workspace))];
|
|
441
|
+
const downloadFilename = buildDownloadFilename(uniqueWorkspaces);
|
|
442
|
+
return { html, downloadFilename };
|
|
443
|
+
}
|
|
444
|
+
// generateExportHtmlInner() patches global DOM state (document, window, ...)
|
|
445
|
+
// for the duration of a call so happy-dom-backed rendering works, then
|
|
446
|
+
// restores it in a `finally`. That save/set/restore isn't reentrant: two
|
|
447
|
+
// overlapping calls (POST /export-html and the export_html MCP tool can run
|
|
448
|
+
// concurrently) each save/restore against whatever the *other* call happened
|
|
449
|
+
// to have in place at that moment, which can leave global DOM state pointing
|
|
450
|
+
// at an already-closed Window once both settle (B14). A simple queue forces
|
|
451
|
+
// calls to run one at a time, in the order they were made, so only one
|
|
452
|
+
// call's globals are ever active at once.
|
|
453
|
+
let exportQueue = Promise.resolve();
|
|
454
|
+
export function generateExportHtml(items, mode) {
|
|
455
|
+
const result = exportQueue.then(() => generateExportHtmlInner(items, mode));
|
|
456
|
+
// Chain the queue off a version that never rejects, so one failed export
|
|
457
|
+
// doesn't permanently wedge every export queued after it; the caller of
|
|
458
|
+
// generateExportHtml() still sees the original rejection via `result`.
|
|
459
|
+
exportQueue = result.then(() => undefined, () => undefined);
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Entry point — starts HTTP + WebSocket + MCP servers.
|
|
2
|
+
//
|
|
3
|
+
// Exports startServer() so it can be called as a library (e.g. from
|
|
4
|
+
// bin/cli.js, the npx production entrypoint) without the auto-start-on-import
|
|
5
|
+
// side effect below firing twice. Auto-start only happens when this file is
|
|
6
|
+
// itself the process's main module (`tsx watch server/index.ts`, or a direct
|
|
7
|
+
// `node dist/server/index.js` invocation) — see the isMainModule() check.
|
|
8
|
+
import { serve } from "@hono/node-server";
|
|
9
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { WebSocketServer } from "ws";
|
|
12
|
+
import { createApp } from "./app.js";
|
|
13
|
+
import { createMcpServer } from "./mcp.js";
|
|
14
|
+
import { addClient } from "./ws.js";
|
|
15
|
+
// F27 (v1.0) — the server has no authentication on any endpoint (accepted
|
|
16
|
+
// trade-off, see docs/02_assumptions-and-risks.md), so single-user/local-only
|
|
17
|
+
// is enforced here rather than left as a documentation-only claim.
|
|
18
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
19
|
+
function assertLoopbackHost(host) {
|
|
20
|
+
if (LOOPBACK_HOSTS.has(host))
|
|
21
|
+
return;
|
|
22
|
+
if (process.env.ALLOW_NON_LOOPBACK === "1")
|
|
23
|
+
return;
|
|
24
|
+
throw new Error(`Refusing to bind to non-loopback HOST "${host}": this server has no authentication on any ` +
|
|
25
|
+
"endpoint and is designed for single-user, local-only use (see docs/02_assumptions-and-risks.md). " +
|
|
26
|
+
"Set ALLOW_NON_LOOPBACK=1 if you understand the risk and want to override this.");
|
|
27
|
+
}
|
|
28
|
+
export function startServer(options = {}) {
|
|
29
|
+
const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
|
30
|
+
const HOST = process.env.HOST ?? "localhost";
|
|
31
|
+
assertLoopbackHost(HOST);
|
|
32
|
+
const app = createApp({ staticRoot: options.staticRoot });
|
|
33
|
+
// ── MCP (SSE) ──────────────────────────────────────────────────────────────
|
|
34
|
+
const mcpTransports = new Map();
|
|
35
|
+
app.get("/mcp", async (c) => {
|
|
36
|
+
const { incoming: req, outgoing: res } = c.env;
|
|
37
|
+
const transport = new SSEServerTransport("/mcp/message", res);
|
|
38
|
+
const sessionId = transport.sessionId;
|
|
39
|
+
mcpTransports.set(sessionId, transport);
|
|
40
|
+
transport.onclose = () => mcpTransports.delete(sessionId);
|
|
41
|
+
// Each connection needs its own McpServer instance — the SDK only allows
|
|
42
|
+
// one transport per instance and throws if connect() is called again.
|
|
43
|
+
const mcpServer = createMcpServer();
|
|
44
|
+
await mcpServer.connect(transport);
|
|
45
|
+
return new Promise(() => {
|
|
46
|
+
req.on("close", () => mcpTransports.delete(sessionId));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
app.post("/mcp/message", async (c) => {
|
|
50
|
+
const sessionId = c.req.query("sessionId");
|
|
51
|
+
if (!sessionId)
|
|
52
|
+
return c.json({ error: "missing sessionId" }, 400);
|
|
53
|
+
const transport = mcpTransports.get(sessionId);
|
|
54
|
+
if (!transport)
|
|
55
|
+
return c.json({ error: "session not found" }, 404);
|
|
56
|
+
const { incoming: req, outgoing: res } = c.env;
|
|
57
|
+
await transport.handlePostMessage(req, res);
|
|
58
|
+
return new Promise(() => undefined);
|
|
59
|
+
});
|
|
60
|
+
// ── Start ──────────────────────────────────────────────────────────────────
|
|
61
|
+
const server = serve({ fetch: app.fetch, hostname: HOST, port: PORT }, () => {
|
|
62
|
+
console.log(`agent-whiteboard running at http://${HOST}:${PORT}`);
|
|
63
|
+
console.log(`MCP endpoint: http://${HOST}:${PORT}/mcp`);
|
|
64
|
+
console.log(`WebSocket: ws://${HOST}:${PORT}/stream`);
|
|
65
|
+
options.onReady?.();
|
|
66
|
+
});
|
|
67
|
+
const wss = new WebSocketServer({ server: server, path: "/stream" });
|
|
68
|
+
wss.on("connection", (ws) => {
|
|
69
|
+
addClient(ws);
|
|
70
|
+
});
|
|
71
|
+
return server;
|
|
72
|
+
}
|
|
73
|
+
function isMainModule() {
|
|
74
|
+
const entry = process.argv[1];
|
|
75
|
+
return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
|
|
76
|
+
}
|
|
77
|
+
if (isMainModule()) {
|
|
78
|
+
startServer();
|
|
79
|
+
}
|