agent-whiteboard 1.0.3 → 1.1.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.
Files changed (198) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +13 -2
  3. package/dist/client/assets/abnfDiagram-VCTEODGH-CcuBIZ5h.js +1 -0
  4. package/dist/client/assets/{arc-4691b008.js → arc-Aq92EeUK.js} +1 -1
  5. package/dist/client/assets/architectureDiagram-5GKGNRK7-CpMRoWIY.js +36 -0
  6. package/dist/client/assets/blockDiagram-I7D4REHJ-Dee0m4lz.js +129 -0
  7. package/dist/client/assets/{bootstrap.min-21652813.js → bootstrap.min-UtnB-TxT.js} +1 -2
  8. package/dist/client/assets/c4Diagram-7LVT6UL2-D_qs3YTM.js +38 -0
  9. package/dist/client/assets/channel-9qggtmY_.js +1 -0
  10. package/dist/client/assets/{chunk-QZHKN3VN-0f554985.js → chunk-2Q5K7J3B-CBwQ-uNF.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-23d48b24.js → chunk-5VM5RSS4-yGvVHapR.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-1104c326.js → chunk-F27PBJKO-BzFUf98n.js} +1 -1
  13. package/dist/client/assets/chunk-IMKFNOWR-D-Zj1GaL.js +231 -0
  14. package/dist/client/assets/{chunk-4BX2VUAB-3c93511d.js → chunk-JWPE2WC7-C8Aj4MVR.js} +1 -1
  15. package/dist/client/assets/chunk-POPQ4Y6H-DZP2a-yV.js +1 -0
  16. package/dist/client/assets/chunk-SVP7TREG-COihS1MQ.js +88 -0
  17. package/dist/client/assets/chunk-TICWLB2K-DNZTRqyJ.js +206 -0
  18. package/dist/client/assets/{chunk-55IACEB6-0243aefd.js → chunk-XXDRQBXY-BC_9L4PF.js} +1 -1
  19. package/dist/client/assets/classDiagram-ZZMXUADV-BUnGzX-0.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-VYDZK3BY-BUnGzX-0.js +1 -0
  21. package/dist/client/assets/cose-bilkent-JH36ORCC-DrJ8c1cV.js +1 -0
  22. package/dist/client/assets/cynefin-OW5HDTMX-CMVAR4fd.js +178 -0
  23. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CdAQ0Lst.js +62 -0
  24. package/dist/client/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  25. package/dist/client/assets/dagre-GXQ25YYZ-CybK0L_u.js +4 -0
  26. package/dist/client/assets/diagram-S7CK7UJ4-CHjTv5Sr.js +30 -0
  27. package/dist/client/assets/diagram-UQ7AKVKN-DfypPsGR.js +41 -0
  28. package/dist/client/assets/{diagram-KO2AKTUF-81f42eeb.js → diagram-VSXAHHWV-DHsmmCug.js} +3 -3
  29. package/dist/client/assets/diagram-VX7I27RA-CHZoTi9k.js +24 -0
  30. package/dist/client/assets/diagram-Z3DM3KII-Ceb2h13y.js +24 -0
  31. package/dist/client/assets/ebnfDiagram-PWID7BFC-BlZ247wB.js +1 -0
  32. package/dist/client/assets/embed-DcNeILcO.js +211 -0
  33. package/dist/client/assets/erDiagram-RLTQ6QDP-BQdw-U-S.js +99 -0
  34. package/dist/client/assets/flowDiagram-HODETNUW-DjUWHA94.js +168 -0
  35. package/dist/client/assets/ganttDiagram-EL5Y4UJY-CXu2s5vq.js +292 -0
  36. package/dist/client/assets/gitGraphDiagram-WWUBYQGX-CwlsB28k.js +106 -0
  37. package/dist/client/assets/index-CFelVSRu.js +19 -0
  38. package/dist/client/assets/index-GF8Uj64S.css +1 -0
  39. package/dist/client/assets/infoDiagram-27XIBGKW-C7nvTbsQ.js +2 -0
  40. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-50b43d03.js → ishikawaDiagram-5VMMS53U-DGFXG874.js} +2 -2
  41. package/dist/client/assets/{journeyDiagram-JHISSGLW-dce41bdf.js → journeyDiagram-3NMN7TZE-BDdRMcmq.js} +4 -4
  42. package/dist/client/assets/{kanban-definition-UN3LZRKU-92401a86.js → kanban-definition-UXKFOSKX-C3fnvfeO.js} +1 -1
  43. package/dist/client/assets/{katex-4c9ea27f.js → katex-BTI-AXqd.js} +3 -3
  44. package/dist/client/assets/{katex-0cba7652.js → katex-C5jXJg4s.js} +3 -3
  45. package/dist/client/assets/layout-BfegUiqt.js +1 -0
  46. package/dist/client/assets/linear-D1EL1Azg.js +1 -0
  47. package/dist/client/assets/mermaid.core-DioArgDk.js +310 -0
  48. package/dist/client/assets/{mindmap-definition-RKZ34NQL-66fe4544.js → mindmap-definition-YA3MSWOX-CFCbAUC0.js} +12 -12
  49. package/dist/client/assets/{ordinal-db88ae6f.js → ordinal-BeghXfj9.js} +1 -1
  50. package/dist/client/assets/pegDiagram-XKGWAZYB-B1ULJH6S.js +1 -0
  51. package/dist/client/assets/pieDiagram-E7YTZNPT-B9H3HosU.js +39 -0
  52. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-f64a7ed7.js → quadrantDiagram-AXDQQJYC-eWc3HHkM.js} +4 -4
  53. package/dist/client/assets/railroadDiagram-O6MQD6OU-DT7c-KwW.js +1 -0
  54. package/dist/client/assets/requirementDiagram-BXWQKSXE-Dk1yVWV5.js +84 -0
  55. package/dist/client/assets/sankeyDiagram-P5KCCOFB-B5a__hiX.js +40 -0
  56. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-CkCIByRS.js +162 -0
  57. package/dist/client/assets/sizeCapture-INFHLROL-CnZIW94r.js +1 -0
  58. package/dist/client/assets/stateDiagram-D77RDMKH-89Sfaoz9.js +1 -0
  59. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-CP--cdl-.js +1 -0
  60. package/dist/client/assets/step-BEQzeS8V.js +1 -0
  61. package/dist/client/assets/swimlanes-42K2YHIH-C7m88XIh.js +1 -0
  62. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-DmG2scPu.js +8 -0
  63. package/dist/client/assets/{time-043b3464.js → time-BaoZouO5.js} +1 -1
  64. package/dist/client/assets/{timeline-definition-PNZ67QCA-b0c81243.js → timeline-definition-24CTP7MA-BHKHgHq9.js} +10 -10
  65. package/dist/client/assets/vennDiagram-4TSXK5OY-BqKaRdWu.js +34 -0
  66. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-8d4d7626.js → wardleyDiagram-VM6X3IG4-XiDVRZV5.js} +3 -3
  67. package/dist/client/assets/xychartDiagram-S5SC5T6Z-CxP4dIVd.js +7 -0
  68. package/dist/client/index.html +2 -3
  69. package/dist/server/app.js +29 -397
  70. package/dist/server/channel.js +5 -3
  71. package/dist/server/export-html.js +26 -60
  72. package/dist/server/export-zip.js +83 -0
  73. package/dist/server/import-zip.js +196 -0
  74. package/dist/server/index.js +2 -1
  75. package/dist/server/mcp.js +9 -9
  76. package/dist/server/port.js +18 -0
  77. package/dist/server/render-core.js +5 -5
  78. package/dist/server/routes/export.js +95 -0
  79. package/dist/server/routes/import.js +24 -0
  80. package/dist/server/routes/render.js +184 -0
  81. package/dist/server/routes/slideshow.js +52 -0
  82. package/dist/server/routes/snapshots.js +119 -0
  83. package/dist/server/snapshot-reader.js +58 -20
  84. package/dist/server/snapshot-writer.js +18 -16
  85. package/dist/server/viewport-cache.js +97 -25
  86. package/package.json +13 -7
  87. package/dist/client/assets/architecture-7EHR7CIX-1221aa61.js +0 -1
  88. package/dist/client/assets/architectureDiagram-3BPJPVTR-1eef4d3c.js +0 -36
  89. package/dist/client/assets/blockDiagram-GPEHLZMM-313fb06c.js +0 -132
  90. package/dist/client/assets/c4Diagram-AAUBKEIU-92b023ad.js +0 -10
  91. package/dist/client/assets/channel-5ee4a8a7.js +0 -1
  92. package/dist/client/assets/chunk-2J33WTMH-fb021fdc.js +0 -1
  93. package/dist/client/assets/chunk-727SXJPM-8e0e0228.js +0 -206
  94. package/dist/client/assets/chunk-AQP2D5EJ-2ccc13c2.js +0 -231
  95. package/dist/client/assets/classDiagram-4FO5ZUOK-42ffecb6.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-42ffecb6.js +0 -1
  97. package/dist/client/assets/cose-bilkent-S5V4N54A-45ec2e52.js +0 -1
  98. package/dist/client/assets/cytoscape.esm-1491e259.js +0 -331
  99. package/dist/client/assets/dagre-BM42HDAG-ed748885.js +0 -4
  100. package/dist/client/assets/diagram-2AECGRRQ-ccc0ddec.js +0 -43
  101. package/dist/client/assets/diagram-5GNKFQAL-342efdea.js +0 -10
  102. package/dist/client/assets/diagram-LMA3HP47-39e66739.js +0 -24
  103. package/dist/client/assets/diagram-OG6HWLK6-b3e61903.js +0 -24
  104. package/dist/client/assets/embed-2b4adf34.js +0 -201
  105. package/dist/client/assets/erDiagram-TEJ5UH35-281f5cc7.js +0 -85
  106. package/dist/client/assets/eventmodeling-FCH6USID-6508120e.js +0 -1
  107. package/dist/client/assets/flowDiagram-I6XJVG4X-c3a4269e.js +0 -162
  108. package/dist/client/assets/ganttDiagram-6RSMTGT7-013b07ec.js +0 -292
  109. package/dist/client/assets/gitGraph-WXDBUCRP-02827359.js +0 -1
  110. package/dist/client/assets/gitGraphDiagram-PVQCEYII-de184acb.js +0 -106
  111. package/dist/client/assets/graph-f3dfaf53.js +0 -1
  112. package/dist/client/assets/index-d674a6a4.css +0 -1
  113. package/dist/client/assets/index-ffc5af4b.js +0 -5
  114. package/dist/client/assets/info-J43DQDTF-81e32294.js +0 -1
  115. package/dist/client/assets/infoDiagram-5YYISTIA-196101ee.js +0 -2
  116. package/dist/client/assets/layout-3a4eff80.js +0 -1
  117. package/dist/client/assets/linear-fed00341.js +0 -1
  118. package/dist/client/assets/mermaid-parser.core-52f9cd92.js +0 -173
  119. package/dist/client/assets/mermaid.core-7dfc221a.js +0 -306
  120. package/dist/client/assets/packet-YPE3B663-278a272a.js +0 -1
  121. package/dist/client/assets/pie-LRSECV5Y-2e55d9ae.js +0 -1
  122. package/dist/client/assets/pieDiagram-4H26LBE5-65b1bcbd.js +0 -30
  123. package/dist/client/assets/radar-GUYGQ44K-608e459f.js +0 -1
  124. package/dist/client/assets/requirementDiagram-4Y6WPE33-90ebf889.js +0 -84
  125. package/dist/client/assets/sankeyDiagram-5OEKKPKP-2d1a83b3.js +0 -40
  126. package/dist/client/assets/sequenceDiagram-3UESZ5HK-aece3633.js +0 -162
  127. package/dist/client/assets/stateDiagram-AJRCARHV-9bd5b5f4.js +0 -1
  128. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-7c16c251.js +0 -1
  129. package/dist/client/assets/step-171ad54a.js +0 -1
  130. package/dist/client/assets/treeView-BLDUP644-e54b9ddb.js +0 -1
  131. package/dist/client/assets/treemap-LRROVOQU-6a81775b.js +0 -1
  132. package/dist/client/assets/vennDiagram-CIIHVFJN-8eaf0464.js +0 -34
  133. package/dist/client/assets/wardley-L42UT6IY-7abd921f.js +0 -1
  134. package/dist/client/assets/xychartDiagram-2RQKCTM6-450f333c.js +0 -7
  135. /package/dist/client/assets/{KaTeX_AMS-Regular-0cdd387c.woff2 → KaTeX_AMS-Regular-BQhdFMY1.woff2} +0 -0
  136. /package/dist/client/assets/{KaTeX_AMS-Regular-30da91e8.woff → KaTeX_AMS-Regular-DMm9YOAa.woff} +0 -0
  137. /package/dist/client/assets/{KaTeX_AMS-Regular-68534840.ttf → KaTeX_AMS-Regular-DRggAlZN.ttf} +0 -0
  138. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-07d8e303.ttf → KaTeX_Caligraphic-Bold-ATXxdsX0.ttf} +0 -0
  139. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-1ae6bd74.woff → KaTeX_Caligraphic-Bold-BEiXGLvX.woff} +0 -0
  140. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-de7701e4.woff2 → KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2} +0 -0
  141. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-3398dd02.woff → KaTeX_Caligraphic-Regular-CTRA-rTL.woff} +0 -0
  142. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-5d53e70a.woff2 → KaTeX_Caligraphic-Regular-Di6jR-x-.woff2} +0 -0
  143. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-ed0b7437.ttf → KaTeX_Caligraphic-Regular-wX97UBjC.ttf} +0 -0
  144. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9163df9c.ttf → KaTeX_Fraktur-Bold-BdnERNNW.ttf} +0 -0
  145. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9be7ceb8.woff → KaTeX_Fraktur-Bold-BsDP51OF.woff} +0 -0
  146. /package/dist/client/assets/{KaTeX_Fraktur-Bold-74444efd.woff2 → KaTeX_Fraktur-Bold-CL6g_b3V.woff2} +0 -0
  147. /package/dist/client/assets/{KaTeX_Fraktur-Regular-1e6f9579.ttf → KaTeX_Fraktur-Regular-CB_wures.ttf} +0 -0
  148. /package/dist/client/assets/{KaTeX_Fraktur-Regular-51814d27.woff2 → KaTeX_Fraktur-Regular-CTYiF6lA.woff2} +0 -0
  149. /package/dist/client/assets/{KaTeX_Fraktur-Regular-5e28753b.woff → KaTeX_Fraktur-Regular-Dxdc4cR9.woff} +0 -0
  150. /package/dist/client/assets/{KaTeX_Main-Bold-0f60d1b8.woff2 → KaTeX_Main-Bold-Cx986IdX.woff2} +0 -0
  151. /package/dist/client/assets/{KaTeX_Main-Bold-c76c5d69.woff → KaTeX_Main-Bold-Jm3AIy58.woff} +0 -0
  152. /package/dist/client/assets/{KaTeX_Main-Bold-138ac28d.ttf → KaTeX_Main-Bold-waoOVXN0.ttf} +0 -0
  153. /package/dist/client/assets/{KaTeX_Main-BoldItalic-99cd42a3.woff2 → KaTeX_Main-BoldItalic-DxDJ3AOS.woff2} +0 -0
  154. /package/dist/client/assets/{KaTeX_Main-BoldItalic-70ee1f64.ttf → KaTeX_Main-BoldItalic-DzxPMmG6.ttf} +0 -0
  155. /package/dist/client/assets/{KaTeX_Main-BoldItalic-a6f7ec0d.woff → KaTeX_Main-BoldItalic-SpSLRI95.woff} +0 -0
  156. /package/dist/client/assets/{KaTeX_Main-Italic-0d85ae7c.ttf → KaTeX_Main-Italic-3WenGoN9.ttf} +0 -0
  157. /package/dist/client/assets/{KaTeX_Main-Italic-f1d6ef86.woff → KaTeX_Main-Italic-BMLOBm91.woff} +0 -0
  158. /package/dist/client/assets/{KaTeX_Main-Italic-97479ca6.woff2 → KaTeX_Main-Italic-NWA7e6Wa.woff2} +0 -0
  159. /package/dist/client/assets/{KaTeX_Main-Regular-c2342cd8.woff2 → KaTeX_Main-Regular-B22Nviop.woff2} +0 -0
  160. /package/dist/client/assets/{KaTeX_Main-Regular-c6368d87.woff → KaTeX_Main-Regular-Dr94JaBh.woff} +0 -0
  161. /package/dist/client/assets/{KaTeX_Main-Regular-d0332f52.ttf → KaTeX_Main-Regular-ypZvNtVU.ttf} +0 -0
  162. /package/dist/client/assets/{KaTeX_Math-BoldItalic-f9377ab0.ttf → KaTeX_Math-BoldItalic-B3XSjfu4.ttf} +0 -0
  163. /package/dist/client/assets/{KaTeX_Math-BoldItalic-dc47344d.woff2 → KaTeX_Math-BoldItalic-CZnvNsCZ.woff2} +0 -0
  164. /package/dist/client/assets/{KaTeX_Math-BoldItalic-850c0af5.woff → KaTeX_Math-BoldItalic-iY-2wyZ7.woff} +0 -0
  165. /package/dist/client/assets/{KaTeX_Math-Italic-8a8d2445.woff → KaTeX_Math-Italic-DA0__PXp.woff} +0 -0
  166. /package/dist/client/assets/{KaTeX_Math-Italic-08ce98e5.ttf → KaTeX_Math-Italic-flOr_0UB.ttf} +0 -0
  167. /package/dist/client/assets/{KaTeX_Math-Italic-7af58c5e.woff2 → KaTeX_Math-Italic-t53AETM-.woff2} +0 -0
  168. /package/dist/client/assets/{KaTeX_SansSerif-Bold-1ece03f7.ttf → KaTeX_SansSerif-Bold-CFMepnvq.ttf} +0 -0
  169. /package/dist/client/assets/{KaTeX_SansSerif-Bold-e99ae511.woff2 → KaTeX_SansSerif-Bold-D1sUS0GD.woff2} +0 -0
  170. /package/dist/client/assets/{KaTeX_SansSerif-Bold-ece03cfd.woff → KaTeX_SansSerif-Bold-DbIhKOiC.woff} +0 -0
  171. /package/dist/client/assets/{KaTeX_SansSerif-Italic-00b26ac8.woff2 → KaTeX_SansSerif-Italic-C3H0VqGB.woff2} +0 -0
  172. /package/dist/client/assets/{KaTeX_SansSerif-Italic-91ee6750.woff → KaTeX_SansSerif-Italic-DN2j7dab.woff} +0 -0
  173. /package/dist/client/assets/{KaTeX_SansSerif-Italic-3931dd81.ttf → KaTeX_SansSerif-Italic-YYjJ1zSn.ttf} +0 -0
  174. /package/dist/client/assets/{KaTeX_SansSerif-Regular-f36ea897.ttf → KaTeX_SansSerif-Regular-BNo7hRIc.ttf} +0 -0
  175. /package/dist/client/assets/{KaTeX_SansSerif-Regular-11e4dc8a.woff → KaTeX_SansSerif-Regular-CS6fqUqJ.woff} +0 -0
  176. /package/dist/client/assets/{KaTeX_SansSerif-Regular-68e8c73e.woff2 → KaTeX_SansSerif-Regular-DDBCnlJ7.woff2} +0 -0
  177. /package/dist/client/assets/{KaTeX_Script-Regular-1c67f068.ttf → KaTeX_Script-Regular-C5JkGWo-.ttf} +0 -0
  178. /package/dist/client/assets/{KaTeX_Script-Regular-036d4e95.woff2 → KaTeX_Script-Regular-D3wIWfF6.woff2} +0 -0
  179. /package/dist/client/assets/{KaTeX_Script-Regular-d96cdf2b.woff → KaTeX_Script-Regular-D5yQViql.woff} +0 -0
  180. /package/dist/client/assets/{KaTeX_Size1-Regular-c943cc98.woff → KaTeX_Size1-Regular-C195tn64.woff} +0 -0
  181. /package/dist/client/assets/{KaTeX_Size1-Regular-95b6d2f1.ttf → KaTeX_Size1-Regular-Dbsnue_I.ttf} +0 -0
  182. /package/dist/client/assets/{KaTeX_Size1-Regular-6b47c401.woff2 → KaTeX_Size1-Regular-mCD8mA8B.woff2} +0 -0
  183. /package/dist/client/assets/{KaTeX_Size2-Regular-a6b2099f.ttf → KaTeX_Size2-Regular-B7gKUWhC.ttf} +0 -0
  184. /package/dist/client/assets/{KaTeX_Size2-Regular-d04c5421.woff2 → KaTeX_Size2-Regular-Dy4dx90m.woff2} +0 -0
  185. /package/dist/client/assets/{KaTeX_Size2-Regular-2014c523.woff → KaTeX_Size2-Regular-oD1tc_U0.woff} +0 -0
  186. /package/dist/client/assets/{KaTeX_Size3-Regular-6ab6b62e.woff → KaTeX_Size3-Regular-CTq5MqoE.woff} +0 -0
  187. /package/dist/client/assets/{KaTeX_Size3-Regular-500e04d5.ttf → KaTeX_Size3-Regular-DgpXs0kz.ttf} +0 -0
  188. /package/dist/client/assets/{KaTeX_Size4-Regular-99f9c675.woff → KaTeX_Size4-Regular-BF-4gkZK.woff} +0 -0
  189. /package/dist/client/assets/{KaTeX_Size4-Regular-c647367d.ttf → KaTeX_Size4-Regular-DWFBv043.ttf} +0 -0
  190. /package/dist/client/assets/{KaTeX_Size4-Regular-a4af7d41.woff2 → KaTeX_Size4-Regular-Dl5lxZxV.woff2} +0 -0
  191. /package/dist/client/assets/{KaTeX_Typewriter-Regular-e14fed02.woff → KaTeX_Typewriter-Regular-C0xS9mPB.woff} +0 -0
  192. /package/dist/client/assets/{KaTeX_Typewriter-Regular-71d517d6.woff2 → KaTeX_Typewriter-Regular-CO6r4hn1.woff2} +0 -0
  193. /package/dist/client/assets/{KaTeX_Typewriter-Regular-f01f3e87.ttf → KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf} +0 -0
  194. /package/dist/client/assets/{colors-98f238a0.js → colors-Cc3OSVma.js} +0 -0
  195. /package/dist/client/assets/{defaultLocale-a8476f12.js → defaultLocale-CzDG__Ur.js} +0 -0
  196. /package/dist/client/assets/{index-a1656aec.js → index-Bi7T_FA4.js} +0 -0
  197. /package/dist/client/assets/{init-a5b10ee5.js → init-Dmth1JHB.js} +0 -0
  198. /package/dist/client/assets/{range-6bc2a9a0.js → range-OtVwhkKS.js} +0 -0
@@ -1,47 +1,12 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { createRequire } from "module";
3
3
  import { createHash } from "crypto";
4
- import { Window } from "happy-dom";
4
+ import { JSDOM } from "jsdom";
5
5
  import katex from "katex";
6
6
  import * as vl from "vega-lite";
7
7
  import * as vega from "vega";
8
8
  import DOMPurify from "dompurify";
9
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
10
  // ── Renderers ──────────────────────────────────────────────────────────────
46
11
  function renderMermaidContainer(payload) {
47
12
  return `<pre class="mermaid">${escapeHtml(payload)}</pre>`;
@@ -59,7 +24,13 @@ function renderSvgPayload(payload, purify) {
59
24
  return purify.sanitize(payload, { USE_PROFILES: { svg: true } });
60
25
  }
61
26
  function renderHtmlPayload(payload, purify) {
62
- return purify.sanitize(payload, { USE_PROFILES: { html: true } });
27
+ // ADD_TAGS: the "html" profile's default allowlist excludes <style> (unlike
28
+ // the "svg" profile below, which already allows it) — explicit here since
29
+ // scopeEmbeddedStyles() (B20) depends on <style> surviving sanitization.
30
+ // FORCE_BODY: without it, a leading <style> is parsed as implicit <head>
31
+ // content and silently dropped from DOMPurify's body-only output — ADD_TAGS
32
+ // alone isn't enough.
33
+ return purify.sanitize(payload, { USE_PROFILES: { html: true }, ADD_TAGS: ["style"], FORCE_BODY: true });
63
34
  }
64
35
  /**
65
36
  * Pure string-transform primitive: wraps a CSS string in `@scope (<selector-list>)
@@ -412,10 +383,24 @@ function itemsIncludeType(items, type) {
412
383
  return items.some(({ record }) => record.frames.some((f) => f.type === type));
413
384
  }
414
385
  // ── Public entrypoint ──────────────────────────────────────────────────────
415
- async function generateExportHtmlInner(items, mode) {
416
- const win = new Window();
417
- const savedGlobals = saveGlobals();
418
- setGlobals(win);
386
+ /**
387
+ * Each call builds its own jsdom `Window` and passes it explicitly to
388
+ * `DOMPurify(win)` — sanitization runs entirely against that instance, never
389
+ * against Node's global object. KaTeX's `renderToString()` and Vega's
390
+ * `View.toSVG()` (via `renderer: "none"`) need no DOM at all. Because
391
+ * nothing here reads or writes `global.document`/`window`, concurrent calls
392
+ * (reachable from both `POST /export-html` and the `export_html` MCP tool,
393
+ * see bug B14 in `01`) can never stomp on each other's state — no
394
+ * serialization queue is needed.
395
+ *
396
+ * jsdom, not happy-dom: DOMPurify 3.4.8+ silently fails to sanitize
397
+ * descendant nodes against a happy-dom `Window` (upstream incompatibility,
398
+ * capricorn86/happy-dom#1810) — <script> tags and on* attributes pass
399
+ * through unstripped. DOMPurify's own maintainers recommend jsdom for
400
+ * server-side use; confirmed this sanitizes correctly against jsdom.
401
+ */
402
+ export async function generateExportHtml(items, mode) {
403
+ const win = new JSDOM("").window;
419
404
  const purify = DOMPurify(win);
420
405
  const rendered = [];
421
406
  try {
@@ -431,7 +416,6 @@ async function generateExportHtmlInner(items, mode) {
431
416
  }
432
417
  }
433
418
  finally {
434
- restoreGlobals(savedGlobals);
435
419
  win.close();
436
420
  }
437
421
  const hasKatex = itemsIncludeType(items, "katex");
@@ -441,21 +425,3 @@ async function generateExportHtmlInner(items, mode) {
441
425
  const downloadFilename = buildDownloadFilename(uniqueWorkspaces);
442
426
  return { html, downloadFilename };
443
427
  }
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,83 @@
1
+ import { createRequire } from "module";
2
+ import archiver from "archiver";
3
+ import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
4
+ /**
5
+ * Reads own package.json version via createRequire (works whether this
6
+ * module runs as server/export-zip.ts under tsx, one level below repo root,
7
+ * or as the compiled dist/server/export-zip.js, two levels below).
8
+ */
9
+ function getAppVersion() {
10
+ const req = createRequire(import.meta.url);
11
+ for (const rel of ["../package.json", "../../package.json"]) {
12
+ try {
13
+ const pkg = req(rel);
14
+ if (typeof pkg.version === "string")
15
+ return pkg.version;
16
+ }
17
+ catch {
18
+ // try next candidate
19
+ }
20
+ }
21
+ return "unknown";
22
+ }
23
+ function buildZipDownloadFilename(workspace) {
24
+ const now = new Date();
25
+ const pad = (n) => String(n).padStart(2, "0");
26
+ const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
27
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
28
+ const sanitized = workspace.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 24);
29
+ return `${sanitized}-${ts}.zip`;
30
+ }
31
+ /**
32
+ * Builds a downloadable zip (F36) of the given snapshots' raw JSON files
33
+ * plus a generated manifest.json — no rendering/sanitization pass, unlike
34
+ * generateExportHtml(). Every item must share the same workspace (the
35
+ * manifest format is single-workspace); a mixed-workspace list is rejected
36
+ * rather than silently exporting a subset. Ids that don't resolve to a file
37
+ * on disk are silently skipped (matching /export-html's per-item leniency);
38
+ * an all-miss request is rejected. Viewport-cache data is never included —
39
+ * this only ever touches the snapshot JSON files themselves.
40
+ */
41
+ export async function generateExportZip(items, root) {
42
+ if (items.length === 0) {
43
+ return { ok: false, error: "items must be a non-empty array" };
44
+ }
45
+ const workspace = items[0].workspace;
46
+ if (items.some((item) => item.workspace !== workspace)) {
47
+ return { ok: false, error: "all items must belong to the same workspace" };
48
+ }
49
+ const files = [];
50
+ for (const item of items) {
51
+ const found = await findSnapshotFileByIdInWorkspace(workspace, item.id, root);
52
+ if (found !== null)
53
+ files.push(found);
54
+ }
55
+ if (files.length === 0) {
56
+ return { ok: false, error: "no valid items to export" };
57
+ }
58
+ const manifest = {
59
+ formatVersion: 1,
60
+ workspace,
61
+ exportedAt: new Date().toISOString(),
62
+ appVersion: getAppVersion(),
63
+ snapshots: files.map((f) => f.filename),
64
+ };
65
+ const archive = archiver("zip", { zlib: { level: 9 } });
66
+ const chunks = [];
67
+ archive.on("data", (chunk) => chunks.push(chunk));
68
+ const done = new Promise((resolve, reject) => {
69
+ archive.on("end", resolve);
70
+ archive.on("error", reject);
71
+ });
72
+ for (const f of files) {
73
+ archive.append(f.raw, { name: f.filename });
74
+ }
75
+ archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
76
+ await archive.finalize();
77
+ await done;
78
+ return {
79
+ ok: true,
80
+ zip: Buffer.concat(chunks),
81
+ downloadFilename: buildZipDownloadFilename(workspace),
82
+ };
83
+ }
@@ -0,0 +1,196 @@
1
+ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import extract from "extract-zip";
5
+ import { z } from "zod";
6
+ import { isValidSnapshotFilename, validateWorkspaceInput } from "./validate.js";
7
+ import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
8
+ const DEFAULT_LIMITS = {
9
+ maxEntryUncompressedBytes: 20 * 1024 * 1024, // 20MB — generous for a snapshot JSON file
10
+ maxTotalUncompressedBytes: 200 * 1024 * 1024, // 200MB total across the whole archive
11
+ maxEntries: 2000,
12
+ };
13
+ const manifestSchema = z.object({
14
+ formatVersion: z.number(),
15
+ workspace: z.string().min(1),
16
+ exportedAt: z.string(),
17
+ appVersion: z.string(),
18
+ snapshots: z.array(z.string()),
19
+ });
20
+ function parseIdTimestamp(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ return {
24
+ id: typeof parsed.id === "string" ? parsed.id : undefined,
25
+ timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : undefined,
26
+ };
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /**
33
+ * F40 — picks the filename of the newest (by ISO-8601 timestamp string
34
+ * comparison, same convention as snapshot-reader.ts's own sort) entry among
35
+ * everything actually added/updated by this import, so the caller can load
36
+ * it and switch the client's active workspace as a side effect.
37
+ */
38
+ function pickNewestFilename(entries) {
39
+ const withTimestamp = entries.filter((e) => e.timestamp !== undefined);
40
+ if (withTimestamp.length === 0)
41
+ return undefined;
42
+ return withTimestamp.reduce((newest, e) => (e.timestamp > newest.timestamp ? e : newest)).filename;
43
+ }
44
+ /**
45
+ * F39/NF55 — imports an uploaded workspace-export zip (F36's format).
46
+ * Extracts to a fresh temp directory first (never directly into the live
47
+ * snapshots tree) via `extract-zip`, which rejects any entry whose resolved
48
+ * path would escape that directory (zip-slip) as part of its own
49
+ * extraction — confirmed empirically, not just by reading its docs. Only
50
+ * `manifest.json` and the files it explicitly lists are ever trusted; a
51
+ * listed filename is re-validated with `isValidSnapshotFilename()` before
52
+ * being joined into any path, since the manifest itself is just JSON text
53
+ * an attacker could otherwise use to point outside the temp/destination
54
+ * directory even though the zip *entries* themselves are already safe.
55
+ */
56
+ export async function importZip(zipBuffer, targetWorkspaceInput, mode, root, limits = {}) {
57
+ const { maxEntryUncompressedBytes, maxTotalUncompressedBytes, maxEntries } = { ...DEFAULT_LIMITS, ...limits };
58
+ const validated = validateWorkspaceInput(targetWorkspaceInput);
59
+ if (!validated.ok) {
60
+ return { ok: false, error: validated.error };
61
+ }
62
+ const targetWorkspace = validated.workspace;
63
+ const tempRoot = await mkdtemp(join(tmpdir(), "agent-whiteboard-import-"));
64
+ const zipPath = join(tempRoot, "upload.zip");
65
+ const extractDir = join(tempRoot, "extracted");
66
+ try {
67
+ await writeFile(zipPath, zipBuffer);
68
+ await mkdir(extractDir, { recursive: true });
69
+ let totalUncompressed = 0;
70
+ let entryCount = 0;
71
+ try {
72
+ await extract(zipPath, {
73
+ dir: extractDir,
74
+ onEntry: (entry) => {
75
+ entryCount++;
76
+ if (entryCount > maxEntries) {
77
+ throw new Error("zip contains too many entries");
78
+ }
79
+ if (entry.uncompressedSize > maxEntryUncompressedBytes) {
80
+ throw new Error("zip contains an entry that is too large");
81
+ }
82
+ totalUncompressed += entry.uncompressedSize;
83
+ if (totalUncompressed > maxTotalUncompressedBytes) {
84
+ throw new Error("zip's total uncompressed size is too large");
85
+ }
86
+ },
87
+ });
88
+ }
89
+ catch (err) {
90
+ return { ok: false, error: err instanceof Error ? err.message : "failed to extract zip" };
91
+ }
92
+ let manifestRaw;
93
+ try {
94
+ manifestRaw = await readFile(join(extractDir, "manifest.json"), "utf-8");
95
+ }
96
+ catch {
97
+ return { ok: false, error: "manifest.json is missing from the zip" };
98
+ }
99
+ let manifestJson;
100
+ try {
101
+ manifestJson = JSON.parse(manifestRaw);
102
+ }
103
+ catch {
104
+ return { ok: false, error: "manifest.json is not valid JSON" };
105
+ }
106
+ const manifestResult = manifestSchema.safeParse(manifestJson);
107
+ if (!manifestResult.success) {
108
+ return { ok: false, error: "manifest.json is malformed" };
109
+ }
110
+ const manifest = manifestResult.data;
111
+ const destDir = join(root, targetWorkspace);
112
+ const destExists = await stat(destDir).then(() => true, () => false);
113
+ // F40 — every added/updated file's {filename, timestamp}, so the newest
114
+ // one (by timestamp) can be reported back for the client's post-import
115
+ // POST /snapshots/load. Skipped files are deliberately excluded — nothing
116
+ // about them changed.
117
+ const changed = [];
118
+ if (mode === "create") {
119
+ if (destExists) {
120
+ return { ok: false, error: `workspace "${targetWorkspace}" already exists` };
121
+ }
122
+ await mkdir(destDir, { recursive: true });
123
+ let added = 0;
124
+ for (const filename of manifest.snapshots) {
125
+ if (!isValidSnapshotFilename(filename))
126
+ continue;
127
+ const srcPath = join(extractDir, filename);
128
+ let raw;
129
+ try {
130
+ raw = await readFile(srcPath, "utf-8");
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ await writeFile(join(destDir, filename), raw, "utf-8");
136
+ added++;
137
+ changed.push({ filename, timestamp: parseIdTimestamp(raw).timestamp });
138
+ }
139
+ return { ok: true, workspace: targetWorkspace, added, updated: 0, skipped: 0, newestFilename: pickNewestFilename(changed) };
140
+ }
141
+ // mode === "merge"
142
+ if (!destExists) {
143
+ return { ok: false, error: `workspace "${targetWorkspace}" does not exist` };
144
+ }
145
+ let added = 0;
146
+ let updated = 0;
147
+ let skipped = 0;
148
+ for (const filename of manifest.snapshots) {
149
+ if (!isValidSnapshotFilename(filename))
150
+ continue;
151
+ let raw;
152
+ try {
153
+ raw = await readFile(join(extractDir, filename), "utf-8");
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ const incoming = parseIdTimestamp(raw);
159
+ if (incoming.id === undefined) {
160
+ // Legacy/id-less (pre-v0.11) — always imported as new, never matched.
161
+ await writeFile(join(destDir, filename), raw, "utf-8");
162
+ added++;
163
+ changed.push({ filename, timestamp: incoming.timestamp });
164
+ continue;
165
+ }
166
+ const existing = await findSnapshotFileByIdInWorkspace(targetWorkspace, incoming.id, root);
167
+ if (existing === null) {
168
+ await writeFile(join(destDir, filename), raw, "utf-8");
169
+ added++;
170
+ changed.push({ filename, timestamp: incoming.timestamp });
171
+ continue;
172
+ }
173
+ const existingTimestamp = parseIdTimestamp(existing.raw).timestamp;
174
+ if (existingTimestamp === incoming.timestamp) {
175
+ skipped++;
176
+ continue;
177
+ }
178
+ if (existingTimestamp !== undefined && incoming.timestamp !== undefined && existingTimestamp > incoming.timestamp) {
179
+ // Destination already has the newer version — nothing to do.
180
+ skipped++;
181
+ continue;
182
+ }
183
+ // Incoming is newer — overwrite (at most one file per id afterward).
184
+ if (existing.filename !== filename) {
185
+ await unlink(join(destDir, existing.filename)).catch(() => { });
186
+ }
187
+ await writeFile(join(destDir, filename), raw, "utf-8");
188
+ updated++;
189
+ changed.push({ filename, timestamp: incoming.timestamp });
190
+ }
191
+ return { ok: true, workspace: targetWorkspace, added, updated, skipped, newestFilename: pickNewestFilename(changed) };
192
+ }
193
+ finally {
194
+ await rm(tempRoot, { recursive: true, force: true });
195
+ }
196
+ }
@@ -11,6 +11,7 @@ import { pathToFileURL } from "node:url";
11
11
  import { WebSocketServer } from "ws";
12
12
  import { createApp } from "./app.js";
13
13
  import { createMcpServer } from "./mcp.js";
14
+ import { parsePort } from "./port.js";
14
15
  import { addClient } from "./ws.js";
15
16
  // F27 (v1.0) — the server has no authentication on any endpoint (accepted
16
17
  // trade-off, see docs/02_assumptions-and-risks.md), so single-user/local-only
@@ -26,7 +27,7 @@ function assertLoopbackHost(host) {
26
27
  "Set ALLOW_NON_LOOPBACK=1 if you understand the risk and want to override this.");
27
28
  }
28
29
  export function startServer(options = {}) {
29
- const PORT = parseInt(process.env.PORT ?? "3000", 10);
30
+ const PORT = parsePort(process.env.PORT, 3000, "PORT");
30
31
  const HOST = process.env.HOST ?? "localhost";
31
32
  assertLoopbackHost(HOST);
32
33
  const app = createApp({ staticRoot: options.staticRoot });
@@ -76,9 +76,9 @@ export function createMcpServer() {
76
76
  .enum(["next", "prev"])
77
77
  .describe('"next" to advance, "prev" to rewind.'),
78
78
  }),
79
- }, ({ direction }) => {
79
+ }, async ({ direction }) => {
80
80
  return {
81
- content: [{ type: "text", text: JSON.stringify(stepAndBroadcast(direction)) }],
81
+ content: [{ type: "text", text: JSON.stringify(await stepAndBroadcast(direction)) }],
82
82
  };
83
83
  });
84
84
  // seek(frame) — jump the step cursor to an arbitrary frame index.
@@ -90,9 +90,9 @@ export function createMcpServer() {
90
90
  inputSchema: z.object({
91
91
  frame: z.number().int().nonnegative().describe("Zero-based frame index to jump to."),
92
92
  }),
93
- }, ({ frame }) => {
93
+ }, async ({ frame }) => {
94
94
  return {
95
- content: [{ type: "text", text: JSON.stringify(seekAndBroadcast(frame)) }],
95
+ content: [{ type: "text", text: JSON.stringify(await seekAndBroadcast(frame)) }],
96
96
  };
97
97
  });
98
98
  // clear() — reset the canvas.
@@ -317,10 +317,10 @@ export function createMcpServer() {
317
317
  id: z.string().optional().describe("Optional UUID returned by a previous render() or commit_step_frames() call. " +
318
318
  "When provided, retrieves that specific snapshot's payload instead of the current canvas state."),
319
319
  }),
320
- }, ({ id }) => {
320
+ }, async ({ id }) => {
321
321
  if (id !== undefined && id !== "") {
322
322
  const root = getSnapshotsRoot();
323
- const payload = findSnapshotById(id, root);
323
+ const payload = await findSnapshotById(id, root);
324
324
  if (payload === null) {
325
325
  return {
326
326
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: "graph not found" }) }],
@@ -349,7 +349,7 @@ export function createMcpServer() {
349
349
  .describe("REQUIRED. Workspace name to list snapshots for. " +
350
350
  "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
351
351
  }),
352
- }, ({ workspace }) => {
352
+ }, async ({ workspace }) => {
353
353
  const workspaceResult = validateWorkspaceInput(workspace);
354
354
  if (!workspaceResult.ok) {
355
355
  return {
@@ -357,7 +357,7 @@ export function createMcpServer() {
357
357
  };
358
358
  }
359
359
  const root = getSnapshotsRoot();
360
- const snapshots = listSnapshots(workspaceResult.workspace, root);
360
+ const snapshots = await listSnapshots(workspaceResult.workspace, root);
361
361
  return {
362
362
  content: [{ type: "text", text: JSON.stringify({ ok: true, snapshots }) }],
363
363
  };
@@ -402,7 +402,7 @@ export function createMcpServer() {
402
402
  const root = getSnapshotsRoot();
403
403
  const validItems = [];
404
404
  for (const id of ids) {
405
- const record = findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
405
+ const record = await findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
406
406
  if (record !== null) {
407
407
  validItems.push({ workspace: validatedWorkspace, id, record });
408
408
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared port-env-var validation (NF52) — used by index.ts's PORT and
3
+ * channel.ts/app.ts's CHANNEL_PORT, so an invalid value fails fast with a
4
+ * clear error instead of a low-level bind/listen/fetch failure.
5
+ *
6
+ * 0 is accepted (not just 1–65535): Node's listen()/serve() treat it as "let
7
+ * the OS assign a free port", which tests/unit/server/index.test.ts already
8
+ * relies on for ephemeral test binding.
9
+ */
10
+ export function parsePort(value, fallback, envVarName) {
11
+ if (value === undefined)
12
+ return fallback;
13
+ const port = Number(value);
14
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
15
+ throw new Error(`Invalid ${envVarName} "${value}": must be an integer between 0 and 65535.`);
16
+ }
17
+ return port;
18
+ }
@@ -125,7 +125,7 @@ export async function applyLoadedSnapshotResult(frames, workspace, title, nodeTo
125
125
  // B19/FR21 — composite id:frameIndex key). Looked up under the snapshot's
126
126
  // own id, not a synthesized one — a synthesized id can never have a cache
127
127
  // entry anyway.
128
- const viewport = snapshotId !== undefined ? getViewport(snapshotId, 0) : undefined;
128
+ const viewport = snapshotId !== undefined ? await getViewport(snapshotId, 0) : undefined;
129
129
  // A concrete id is required on every broadcast (v0.26 Sprint 42) — pre-v0.11
130
130
  // snapshots may lack one (J1, `02`), so synthesize a fresh one here.
131
131
  const resolvedId = snapshotId ?? generateSnapshotId();
@@ -161,7 +161,7 @@ export async function applyLoadedSnapshotResult(frames, workspace, title, nodeTo
161
161
  * verbatim between app.ts and mcp.ts, the one hot-path pair render-core.ts's
162
162
  * own NF12 header comment didn't yet cover).
163
163
  */
164
- export function stepAndBroadcast(direction) {
164
+ export async function stepAndBroadcast(direction) {
165
165
  const result = stepCursor(direction);
166
166
  if (!result) {
167
167
  return { ok: false, error: "no step-frames sequence is loaded" };
@@ -174,7 +174,7 @@ export function stepAndBroadcast(direction) {
174
174
  // restores its own saved viewport independently (v0.26.1, bug B19/FR21) —
175
175
  // no longer "must not re-fit" for the whole sequence.
176
176
  const resolvedId = id ?? generateSnapshotId();
177
- const viewport = getViewport(resolvedId, result.currentFrame);
177
+ const viewport = await getViewport(resolvedId, result.currentFrame);
178
178
  broadcastStepFrames(frames, state.frameType, result.currentFrame, resolvedId, title, state.nodeToFrame, viewport);
179
179
  }
180
180
  return { ok: true, current_frame: result.currentFrame, total_frames: result.totalFrames };
@@ -183,7 +183,7 @@ export function stepAndBroadcast(direction) {
183
183
  * Jumps the step cursor to an arbitrary frame index and broadcasts it.
184
184
  * Shared by POST /seek and the MCP `seek` tool (NF19, see `stepAndBroadcast`).
185
185
  */
186
- export function seekAndBroadcast(frame) {
186
+ export async function seekAndBroadcast(frame) {
187
187
  const state = getCanvas();
188
188
  if (!isStepSequence(state)) {
189
189
  return { ok: false, error: "no step-frames sequence is loaded" };
@@ -207,7 +207,7 @@ export function seekAndBroadcast(frame) {
207
207
  id: resolvedId,
208
208
  // Per-frame restore (v0.26.1, bug B19/FR21) — each frame of a sequence
209
209
  // re-fits or restores independently instead of sharing one viewport.
210
- viewport: getViewport(resolvedId, frame),
210
+ viewport: await getViewport(resolvedId, frame),
211
211
  });
212
212
  return { ok: true, current_frame: frame, total_frames: total };
213
213
  }
@@ -0,0 +1,95 @@
1
+ import { exportCanvas } from "../session.js";
2
+ import { findSnapshotById, findSnapshotByIdInWorkspace } from "../snapshot-reader.js";
3
+ import { validateWorkspaceInput } from "../render-core.js";
4
+ import { generateExportHtml } from "../export-html.js";
5
+ import { generateExportZip } from "../export-zip.js";
6
+ import { getSnapshotsRoot } from "../paths.js";
7
+ export function registerExportRoutes(app) {
8
+ app.get("/export", async (c) => {
9
+ const id = c.req.query("id");
10
+ if (id !== undefined && id !== "") {
11
+ const root = getSnapshotsRoot();
12
+ const payload = await findSnapshotById(id, root);
13
+ if (payload === null) {
14
+ return c.json({ ok: false, error: "graph not found" }, 404);
15
+ }
16
+ return c.json({ ok: true, data: payload });
17
+ }
18
+ return c.json({ ok: true, data: exportCanvas() });
19
+ });
20
+ // ── HTML Export (v0.13) ────────────────────────────────────────────────────
21
+ app.post("/export-html", async (c) => {
22
+ const body = await c.req.json();
23
+ if (!Array.isArray(body.items) || body.items.length === 0) {
24
+ return c.json({ ok: false, error: "items must be a non-empty array" }, 400);
25
+ }
26
+ // Default cdn (v0.32, F23): offline (fully embedded, today's pre-v0.32
27
+ // behavior) is opt-in via mode: "offline"; any other value falls back to cdn.
28
+ const mode = body.mode === "offline" ? "offline" : "cdn";
29
+ const root = getSnapshotsRoot();
30
+ const validItems = [];
31
+ for (const item of body.items) {
32
+ if (typeof item !== "object" || item === null)
33
+ continue;
34
+ const { workspace, id } = item;
35
+ if (typeof workspace !== "string")
36
+ continue;
37
+ const validated = validateWorkspaceInput(workspace);
38
+ if (!validated.ok)
39
+ continue;
40
+ if (typeof id !== "string")
41
+ continue;
42
+ const record = await findSnapshotByIdInWorkspace(validated.workspace, id, root);
43
+ if (record === null)
44
+ continue;
45
+ validItems.push({ workspace: validated.workspace, id, record });
46
+ }
47
+ if (validItems.length === 0) {
48
+ return c.json({ ok: false, error: "no valid items to export" }, 400);
49
+ }
50
+ const { html, downloadFilename } = await generateExportHtml(validItems, mode);
51
+ return new Response(html, {
52
+ status: 200,
53
+ headers: {
54
+ "Content-Type": "text/html; charset=utf-8",
55
+ "Content-Disposition": `attachment; filename="${downloadFilename}"`,
56
+ },
57
+ });
58
+ });
59
+ // ── Zip Export (v1.6, F36) ───────────────────────────────────────────────
60
+ app.post("/export-zip", async (c) => {
61
+ const body = await c.req.json();
62
+ if (!Array.isArray(body.items) || body.items.length === 0) {
63
+ return c.json({ ok: false, error: "items must be a non-empty array" }, 400);
64
+ }
65
+ const zipItems = [];
66
+ for (const item of body.items) {
67
+ if (typeof item !== "object" || item === null)
68
+ continue;
69
+ const { workspace, id } = item;
70
+ if (typeof workspace !== "string")
71
+ continue;
72
+ const validated = validateWorkspaceInput(workspace);
73
+ if (!validated.ok)
74
+ continue;
75
+ if (typeof id !== "string")
76
+ continue;
77
+ zipItems.push({ workspace: validated.workspace, id });
78
+ }
79
+ if (zipItems.length === 0) {
80
+ return c.json({ ok: false, error: "no valid items to export" }, 400);
81
+ }
82
+ const root = getSnapshotsRoot();
83
+ const result = await generateExportZip(zipItems, root);
84
+ if (!result.ok) {
85
+ return c.json({ ok: false, error: result.error }, 400);
86
+ }
87
+ return new Response(new Uint8Array(result.zip), {
88
+ status: 200,
89
+ headers: {
90
+ "Content-Type": "application/zip",
91
+ "Content-Disposition": `attachment; filename="${result.downloadFilename}"`,
92
+ },
93
+ });
94
+ });
95
+ }
@@ -0,0 +1,24 @@
1
+ import { bodyLimit } from "hono/body-limit";
2
+ import { importZip } from "../import-zip.js";
3
+ import { getSnapshotsRoot } from "../paths.js";
4
+ // NF55 — a per-route override, larger than app.ts's global 10MB JSON cap
5
+ // (app.ts excludes this path from that global middleware so the two caps
6
+ // don't stack — the stricter one would otherwise always win).
7
+ export const IMPORT_MAX_BODY_SIZE_BYTES = 50 * 1024 * 1024;
8
+ export function registerImportRoutes(app) {
9
+ app.post("/import", bodyLimit({ maxSize: IMPORT_MAX_BODY_SIZE_BYTES }), async (c) => {
10
+ const body = await c.req.parseBody();
11
+ const file = body["file"];
12
+ if (!(file instanceof File)) {
13
+ return c.json({ ok: false, error: "file is required" }, 400);
14
+ }
15
+ const mode = body["mode"] === "merge" ? "merge" : "create";
16
+ const buffer = Buffer.from(await file.arrayBuffer());
17
+ const root = getSnapshotsRoot();
18
+ const result = await importZip(buffer, body["targetWorkspace"], mode, root);
19
+ if (!result.ok) {
20
+ return c.json({ ok: false, error: result.error }, 400);
21
+ }
22
+ return c.json(result);
23
+ });
24
+ }