@field/cascade 0.2.22

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 (319) hide show
  1. package/AGENTS.md +168 -0
  2. package/ARCHITECTURE.md +137 -0
  3. package/DESIGN.md +161 -0
  4. package/LICENSE +21 -0
  5. package/README.md +229 -0
  6. package/dist/assets/CodeEditor-DR7u0q7p.css +1 -0
  7. package/dist/assets/CodeEditor-DoaNEUaM.js +641 -0
  8. package/dist/assets/CodePanel-B0_2XJ-t.js +2 -0
  9. package/dist/assets/CodePanel-IT1secyI.css +1 -0
  10. package/dist/assets/CustomNodeDialog-026lzecD.js +1 -0
  11. package/dist/assets/CustomNodeDialog-JGYnWY6m.css +1 -0
  12. package/dist/assets/ExportDialog-DOq0flMV.css +1 -0
  13. package/dist/assets/ExportDialog-DWN4nhXI.js +358 -0
  14. package/dist/assets/ProjectSettings-BzT164Yx.js +2 -0
  15. package/dist/assets/ProjectSettings-DdO9xoQz.css +1 -0
  16. package/dist/assets/SettingsDialog-CeK7ytpY.css +1 -0
  17. package/dist/assets/SettingsDialog-DmHb1V0r.js +1 -0
  18. package/dist/assets/VersionHistoryDialog-CWNmHQ-P.css +1 -0
  19. package/dist/assets/VersionHistoryDialog-hH6VsXLX.js +3 -0
  20. package/dist/assets/abap-08VXUWAP.js +1 -0
  21. package/dist/assets/apex-BWPQTe0t.js +1 -0
  22. package/dist/assets/azcli-Bc_sGQ0U.js +1 -0
  23. package/dist/assets/bat-i0X4ZdIN.js +1 -0
  24. package/dist/assets/bicep-B5-_aFwp.js +2 -0
  25. package/dist/assets/cameligo-DMUM7wLl.js +1 -0
  26. package/dist/assets/cascade.jpg +0 -0
  27. package/dist/assets/clojure-Cm7r79vr.js +1 -0
  28. package/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
  29. package/dist/assets/coffee-Ba7i2nA0.js +1 -0
  30. package/dist/assets/core-Bji4aZUU.js +965 -0
  31. package/dist/assets/cpp-C7h46wYY.js +1 -0
  32. package/dist/assets/csharp-BKxtCVv1.js +1 -0
  33. package/dist/assets/csp-bTuwJoIa.js +1 -0
  34. package/dist/assets/css-DIMkf-bt.js +3 -0
  35. package/dist/assets/css.worker-B3ciXF_0.js +93 -0
  36. package/dist/assets/cssMode-B6Z_Q1Kh.js +1 -0
  37. package/dist/assets/cypher-CVaqCwHa.js +1 -0
  38. package/dist/assets/dart-onAF5SnQ.js +1 -0
  39. package/dist/assets/dockerfile-DZFCIeNp.js +1 -0
  40. package/dist/assets/ecl-D05T4iGw.js +1 -0
  41. package/dist/assets/editor-1FQrmyyI.css +1 -0
  42. package/dist/assets/editor.api-jMoW_43X.js +847 -0
  43. package/dist/assets/editor.worker-q-txB4vs.js +30 -0
  44. package/dist/assets/elixir-6RTg0lbw.js +1 -0
  45. package/dist/assets/flow9-C5_-GSwl.js +1 -0
  46. package/dist/assets/freemarker2-DvvPV6m5.js +3 -0
  47. package/dist/assets/fsharp-C8Ef5oNN.js +1 -0
  48. package/dist/assets/geo-CXmCtzMM.js +2 -0
  49. package/dist/assets/go-C-y9NEjX.js +1 -0
  50. package/dist/assets/graphql-fmXr3nnJ.js +1 -0
  51. package/dist/assets/handlebars-B8rfuSIa.js +1 -0
  52. package/dist/assets/hcl-CpzslTdj.js +1 -0
  53. package/dist/assets/html-DyHm6JCp.js +1 -0
  54. package/dist/assets/html.worker-C93Ht9o9.js +506 -0
  55. package/dist/assets/htmlMode-E9XLWE5u.js +1 -0
  56. package/dist/assets/image-BUcQC7PG.js +2076 -0
  57. package/dist/assets/index-BXYXSdgh.js +224 -0
  58. package/dist/assets/index-CrNke7SW.css +1 -0
  59. package/dist/assets/index-client-DN_FvjN8.js +1 -0
  60. package/dist/assets/ini-sBoK_t0W.js +1 -0
  61. package/dist/assets/java-BEtHBSE6.js +1 -0
  62. package/dist/assets/javascript-C_bM5PCN.js +1 -0
  63. package/dist/assets/json.worker-B2V3pomh.js +62 -0
  64. package/dist/assets/jsonMode-BdyVS7G4.js +7 -0
  65. package/dist/assets/julia-Bri6UV-V.js +1 -0
  66. package/dist/assets/kotlin-BOotOW0E.js +1 -0
  67. package/dist/assets/legacy-DIu3XRwl.js +2 -0
  68. package/dist/assets/less-B9JPFI3C.js +2 -0
  69. package/dist/assets/lexon-CfSJPG6W.js +1 -0
  70. package/dist/assets/liquid-CDkn3spA.js +1 -0
  71. package/dist/assets/lspLanguageFeatures-AB5DMAsr.js +4 -0
  72. package/dist/assets/lua-CsQS60Ue.js +1 -0
  73. package/dist/assets/m3-D-oSqn_W.js +1 -0
  74. package/dist/assets/markdown-Cimd5fb3.js +1 -0
  75. package/dist/assets/mdx-BvH_NjDN.js +1 -0
  76. package/dist/assets/mips-CIPQ_RoX.js +1 -0
  77. package/dist/assets/msdax-DauUninz.js +1 -0
  78. package/dist/assets/mysql-SOo6toE5.js +1 -0
  79. package/dist/assets/nodeTypeUtils-CweA3NHd.js +1 -0
  80. package/dist/assets/objective-c-FvmIjYaQ.js +1 -0
  81. package/dist/assets/pascal-DrH0SRf2.js +1 -0
  82. package/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
  83. package/dist/assets/perl-oz_6vUea.js +1 -0
  84. package/dist/assets/pgsql-DTj74zXo.js +1 -0
  85. package/dist/assets/php-nr791fC2.js +1 -0
  86. package/dist/assets/pla-CopQ2nXW.js +1 -0
  87. package/dist/assets/postiats-43DmfD33.js +1 -0
  88. package/dist/assets/powerquery-D3hlyOfw.js +1 -0
  89. package/dist/assets/powershell-DmHpPYUd.js +1 -0
  90. package/dist/assets/projectSettingsApi-C1WvIwfP.js +1 -0
  91. package/dist/assets/protobuf-C531GsRP.js +2 -0
  92. package/dist/assets/pug-Z5eAx3Zn.js +1 -0
  93. package/dist/assets/python-B7uii4fi.js +1 -0
  94. package/dist/assets/qsharp-DkqhCAOL.js +1 -0
  95. package/dist/assets/quill-DjZ69wL_.js +121 -0
  96. package/dist/assets/r-BwWrilGY.js +1 -0
  97. package/dist/assets/razor-Z48fWdJc.js +1 -0
  98. package/dist/assets/redis-ClamHrr6.js +1 -0
  99. package/dist/assets/redshift-DT7zqm-g.js +1 -0
  100. package/dist/assets/restructuredtext-BYgofb2h.js +1 -0
  101. package/dist/assets/ruby-DezsRK8O.js +1 -0
  102. package/dist/assets/rust-DdL9SqIa.js +1 -0
  103. package/dist/assets/sb-CcwsVR0C.js +1 -0
  104. package/dist/assets/scala-DHpiXF5c.js +1 -0
  105. package/dist/assets/scheme-BeGwcela.js +1 -0
  106. package/dist/assets/scss-gp-XZpBa.js +3 -0
  107. package/dist/assets/shell-CC2rA5mh.js +1 -0
  108. package/dist/assets/solidity-BEEn4gHE.js +1 -0
  109. package/dist/assets/sophia-CRfGWb83.js +1 -0
  110. package/dist/assets/sparql-D_Lu-MrJ.js +1 -0
  111. package/dist/assets/sql-NEE52Syq.js +1 -0
  112. package/dist/assets/st-DbInun42.js +1 -0
  113. package/dist/assets/swift-Bxkupp3x.js +1 -0
  114. package/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
  115. package/dist/assets/tcl-DISqw1ZD.js +1 -0
  116. package/dist/assets/ts.worker-D7T1-Ig5.js +67738 -0
  117. package/dist/assets/tsMode-BDgacVG4.js +11 -0
  118. package/dist/assets/twig-De2hgUGE.js +1 -0
  119. package/dist/assets/typescript-Derb6Oqa.js +1 -0
  120. package/dist/assets/typespec-B8J7ngcE.js +1 -0
  121. package/dist/assets/vb-DV3o63ZY.js +1 -0
  122. package/dist/assets/wgsl-DpFanUEy.js +298 -0
  123. package/dist/assets/workers-BtoMX_pm.js +1 -0
  124. package/dist/assets/xml-B1CaFSrG.js +1 -0
  125. package/dist/assets/yaml-HmIeA0KH.js +1 -0
  126. package/dist/cli/index.js +11377 -0
  127. package/dist/cli/index.js.map +7 -0
  128. package/dist/cli/runtime/io.ts +445 -0
  129. package/dist/cli/runtime/net.ts +19 -0
  130. package/dist/cli/runtime/shell.ts +46 -0
  131. package/dist/cli/runtime/stage.ts +132 -0
  132. package/dist/contracts/capabilities.d.ts +109 -0
  133. package/dist/contracts/capabilities.d.ts.map +1 -0
  134. package/dist/contracts/capabilities.js +11 -0
  135. package/dist/contracts/capabilities.js.map +1 -0
  136. package/dist/contracts/color.d.ts +67 -0
  137. package/dist/contracts/color.d.ts.map +1 -0
  138. package/dist/contracts/color.js +83 -0
  139. package/dist/contracts/color.js.map +1 -0
  140. package/dist/contracts/definition.d.ts +118 -0
  141. package/dist/contracts/definition.d.ts.map +1 -0
  142. package/dist/contracts/definition.js +2 -0
  143. package/dist/contracts/definition.js.map +1 -0
  144. package/dist/contracts/document.d.ts +50 -0
  145. package/dist/contracts/document.d.ts.map +1 -0
  146. package/dist/contracts/document.js +2 -0
  147. package/dist/contracts/document.js.map +1 -0
  148. package/dist/contracts/geometry.d.ts +301 -0
  149. package/dist/contracts/geometry.d.ts.map +1 -0
  150. package/dist/contracts/geometry.js +604 -0
  151. package/dist/contracts/geometry.js.map +1 -0
  152. package/dist/contracts/index.d.ts +8 -0
  153. package/dist/contracts/index.d.ts.map +1 -0
  154. package/dist/contracts/index.js +8 -0
  155. package/dist/contracts/index.js.map +1 -0
  156. package/dist/contracts/schema.d.ts +6 -0
  157. package/dist/contracts/schema.d.ts.map +1 -0
  158. package/dist/contracts/schema.js +85 -0
  159. package/dist/contracts/schema.js.map +1 -0
  160. package/dist/contracts/validate.d.ts +13 -0
  161. package/dist/contracts/validate.d.ts.map +1 -0
  162. package/dist/contracts/validate.js +202 -0
  163. package/dist/contracts/validate.js.map +1 -0
  164. package/dist/contracts/values.d.ts +100 -0
  165. package/dist/contracts/values.d.ts.map +1 -0
  166. package/dist/contracts/values.js +59 -0
  167. package/dist/contracts/values.js.map +1 -0
  168. package/dist/graphs/default-orig.cascade +128 -0
  169. package/dist/graphs/default.cascade +21 -0
  170. package/dist/index.html +19 -0
  171. package/dist/runtime/browser.d.ts +3 -0
  172. package/dist/runtime/browser.d.ts.map +1 -0
  173. package/dist/runtime/browser.js +12 -0
  174. package/dist/runtime/browser.js.map +1 -0
  175. package/dist/runtime/builtins/core/index.d.ts +9 -0
  176. package/dist/runtime/builtins/core/index.d.ts.map +1 -0
  177. package/dist/runtime/builtins/core/index.js +21 -0
  178. package/dist/runtime/builtins/core/index.js.map +1 -0
  179. package/dist/runtime/builtins/core/random.d.ts +66 -0
  180. package/dist/runtime/builtins/core/random.d.ts.map +1 -0
  181. package/dist/runtime/builtins/core/random.js +46 -0
  182. package/dist/runtime/builtins/core/random.js.map +1 -0
  183. package/dist/runtime/builtins/core/remap.d.ts +105 -0
  184. package/dist/runtime/builtins/core/remap.d.ts.map +1 -0
  185. package/dist/runtime/builtins/core/remap.js +43 -0
  186. package/dist/runtime/builtins/core/remap.js.map +1 -0
  187. package/dist/runtime/builtins/core/routing.d.ts +117 -0
  188. package/dist/runtime/builtins/core/routing.d.ts.map +1 -0
  189. package/dist/runtime/builtins/core/routing.js +114 -0
  190. package/dist/runtime/builtins/core/routing.js.map +1 -0
  191. package/dist/runtime/builtins/core/structural.d.ts +86 -0
  192. package/dist/runtime/builtins/core/structural.d.ts.map +1 -0
  193. package/dist/runtime/builtins/core/structural.js +63 -0
  194. package/dist/runtime/builtins/core/structural.js.map +1 -0
  195. package/dist/runtime/builtins/geo/copyToPoints.d.ts +58 -0
  196. package/dist/runtime/builtins/geo/copyToPoints.d.ts.map +1 -0
  197. package/dist/runtime/builtins/geo/copyToPoints.js +35 -0
  198. package/dist/runtime/builtins/geo/copyToPoints.js.map +1 -0
  199. package/dist/runtime/builtins/geo/definitions.d.ts +526 -0
  200. package/dist/runtime/builtins/geo/definitions.d.ts.map +1 -0
  201. package/dist/runtime/builtins/geo/definitions.js +182 -0
  202. package/dist/runtime/builtins/geo/definitions.js.map +1 -0
  203. package/dist/runtime/builtins/geo/generators.d.ts +94 -0
  204. package/dist/runtime/builtins/geo/generators.d.ts.map +1 -0
  205. package/dist/runtime/builtins/geo/generators.js +41 -0
  206. package/dist/runtime/builtins/geo/generators.js.map +1 -0
  207. package/dist/runtime/builtins/geo/index.d.ts +23 -0
  208. package/dist/runtime/builtins/geo/index.d.ts.map +1 -0
  209. package/dist/runtime/builtins/geo/index.js +35 -0
  210. package/dist/runtime/builtins/geo/index.js.map +1 -0
  211. package/dist/runtime/builtins/geo/merge.d.ts +40 -0
  212. package/dist/runtime/builtins/geo/merge.d.ts.map +1 -0
  213. package/dist/runtime/builtins/geo/merge.js +24 -0
  214. package/dist/runtime/builtins/geo/merge.js.map +1 -0
  215. package/dist/runtime/builtins/geo/namespace.d.ts +15 -0
  216. package/dist/runtime/builtins/geo/namespace.d.ts.map +1 -0
  217. package/dist/runtime/builtins/geo/namespace.js +17 -0
  218. package/dist/runtime/builtins/geo/namespace.js.map +1 -0
  219. package/dist/runtime/builtins/geo/svgExport.d.ts +130 -0
  220. package/dist/runtime/builtins/geo/svgExport.d.ts.map +1 -0
  221. package/dist/runtime/builtins/geo/svgExport.js +50 -0
  222. package/dist/runtime/builtins/geo/svgExport.js.map +1 -0
  223. package/dist/runtime/builtins/geo/transform.d.ts +60 -0
  224. package/dist/runtime/builtins/geo/transform.d.ts.map +1 -0
  225. package/dist/runtime/builtins/geo/transform.js +32 -0
  226. package/dist/runtime/builtins/geo/transform.js.map +1 -0
  227. package/dist/runtime/builtins/index.d.ts +9 -0
  228. package/dist/runtime/builtins/index.d.ts.map +1 -0
  229. package/dist/runtime/builtins/index.js +16 -0
  230. package/dist/runtime/builtins/index.js.map +1 -0
  231. package/dist/runtime/definition/architecture.d.ts +8 -0
  232. package/dist/runtime/definition/architecture.d.ts.map +1 -0
  233. package/dist/runtime/definition/architecture.js +112 -0
  234. package/dist/runtime/definition/architecture.js.map +1 -0
  235. package/dist/runtime/definition/extract.d.ts +12 -0
  236. package/dist/runtime/definition/extract.d.ts.map +1 -0
  237. package/dist/runtime/definition/extract.js +151 -0
  238. package/dist/runtime/definition/extract.js.map +1 -0
  239. package/dist/runtime/error.d.ts +8 -0
  240. package/dist/runtime/error.d.ts.map +1 -0
  241. package/dist/runtime/error.js +11 -0
  242. package/dist/runtime/error.js.map +1 -0
  243. package/dist/runtime/geometry/attributes.d.ts +65 -0
  244. package/dist/runtime/geometry/attributes.d.ts.map +1 -0
  245. package/dist/runtime/geometry/attributes.js +201 -0
  246. package/dist/runtime/geometry/attributes.js.map +1 -0
  247. package/dist/runtime/geometry/builder.d.ts +57 -0
  248. package/dist/runtime/geometry/builder.d.ts.map +1 -0
  249. package/dist/runtime/geometry/builder.js +153 -0
  250. package/dist/runtime/geometry/builder.js.map +1 -0
  251. package/dist/runtime/geometry/copy.d.ts +36 -0
  252. package/dist/runtime/geometry/copy.d.ts.map +1 -0
  253. package/dist/runtime/geometry/copy.js +68 -0
  254. package/dist/runtime/geometry/copy.js.map +1 -0
  255. package/dist/runtime/geometry/generate.d.ts +43 -0
  256. package/dist/runtime/geometry/generate.d.ts.map +1 -0
  257. package/dist/runtime/geometry/generate.js +76 -0
  258. package/dist/runtime/geometry/generate.js.map +1 -0
  259. package/dist/runtime/geometry/groups.d.ts +18 -0
  260. package/dist/runtime/geometry/groups.d.ts.map +1 -0
  261. package/dist/runtime/geometry/groups.js +69 -0
  262. package/dist/runtime/geometry/groups.js.map +1 -0
  263. package/dist/runtime/geometry/index.d.ts +23 -0
  264. package/dist/runtime/geometry/index.d.ts.map +1 -0
  265. package/dist/runtime/geometry/index.js +23 -0
  266. package/dist/runtime/geometry/index.js.map +1 -0
  267. package/dist/runtime/geometry/matrix.d.ts +26 -0
  268. package/dist/runtime/geometry/matrix.d.ts.map +1 -0
  269. package/dist/runtime/geometry/matrix.js +27 -0
  270. package/dist/runtime/geometry/matrix.js.map +1 -0
  271. package/dist/runtime/geometry/merge.d.ts +33 -0
  272. package/dist/runtime/geometry/merge.d.ts.map +1 -0
  273. package/dist/runtime/geometry/merge.js +185 -0
  274. package/dist/runtime/geometry/merge.js.map +1 -0
  275. package/dist/runtime/geometry/primitives.d.ts +41 -0
  276. package/dist/runtime/geometry/primitives.d.ts.map +1 -0
  277. package/dist/runtime/geometry/primitives.js +131 -0
  278. package/dist/runtime/geometry/primitives.js.map +1 -0
  279. package/dist/runtime/geometry/promote.d.ts +44 -0
  280. package/dist/runtime/geometry/promote.d.ts.map +1 -0
  281. package/dist/runtime/geometry/promote.js +203 -0
  282. package/dist/runtime/geometry/promote.js.map +1 -0
  283. package/dist/runtime/geometry/resample.d.ts +29 -0
  284. package/dist/runtime/geometry/resample.d.ts.map +1 -0
  285. package/dist/runtime/geometry/resample.js +165 -0
  286. package/dist/runtime/geometry/resample.js.map +1 -0
  287. package/dist/runtime/geometry/svg.d.ts +91 -0
  288. package/dist/runtime/geometry/svg.d.ts.map +1 -0
  289. package/dist/runtime/geometry/svg.js +216 -0
  290. package/dist/runtime/geometry/svg.js.map +1 -0
  291. package/dist/runtime/geometry/transform.d.ts +18 -0
  292. package/dist/runtime/geometry/transform.d.ts.map +1 -0
  293. package/dist/runtime/geometry/transform.js +56 -0
  294. package/dist/runtime/geometry/transform.js.map +1 -0
  295. package/dist/runtime/index.d.ts +5 -0
  296. package/dist/runtime/index.d.ts.map +1 -0
  297. package/dist/runtime/index.js +4 -0
  298. package/dist/runtime/index.js.map +1 -0
  299. package/dist/runtime/node.d.ts +4 -0
  300. package/dist/runtime/node.d.ts.map +1 -0
  301. package/dist/runtime/node.js +12 -0
  302. package/dist/runtime/node.js.map +1 -0
  303. package/dist/runtime/runtime.d.ts +3 -0
  304. package/dist/runtime/runtime.d.ts.map +1 -0
  305. package/dist/runtime/runtime.js +1198 -0
  306. package/dist/runtime/runtime.js.map +1 -0
  307. package/dist/runtime/types.d.ts +241 -0
  308. package/dist/runtime/types.d.ts.map +1 -0
  309. package/dist/runtime/types.js +2 -0
  310. package/dist/runtime/types.js.map +1 -0
  311. package/dist/shell.d.ts +8 -0
  312. package/dist/shell.js +54 -0
  313. package/dist/shell.js.map +7 -0
  314. package/dist/stage.d.ts +7 -0
  315. package/dist/stage.js +84 -0
  316. package/dist/stage.js.map +7 -0
  317. package/dist/studio/panel.d.ts +48 -0
  318. package/doc/PROJECT_AUTHORING.md +360 -0
  319. package/package.json +126 -0
@@ -0,0 +1,445 @@
1
+ /**
2
+ * `cascade/io` — the runtime a BROWSER-side node imports.
3
+ *
4
+ * The interop rests on one rule: an image port carries a project-relative PATH,
5
+ * never pixels. A Python node writes a file and emits its path; a WebGL node
6
+ * reads that path as a texture, renders, writes its result back, and emits a
7
+ * path of the same kind. Neither knows or cares which side produced its input,
8
+ * which is what makes the two interchangeable — and it lets the heavy stages
9
+ * stay in Python while the interactive ones run in the page.
10
+ *
11
+ * The counterpart on the server side is the project's own exec bridge
12
+ * (`runStage`), which is per-project because the stages are.
13
+ *
14
+ * This file is compiled into any node module that imports 'cascade/io'; it is
15
+ * not published to npm and has no dependencies.
16
+ */
17
+
18
+ export interface MediaOptions {
19
+ /** Ask for a resized copy. Never upscales past the file's own resolution. */
20
+ width?: number;
21
+ /** 'webp' | 'png' | 'jpeg' | 'avif'. Defaults to WebP, which is what you want
22
+ * for anything being looked at rather than processed. */
23
+ format?: string;
24
+ /** The original bytes, unresized and unconverted. Use this when the pixels
25
+ * matter — reading a render back for further processing, not previewing it. */
26
+ raw?: boolean;
27
+ quality?: number;
28
+ }
29
+
30
+ /** Build the URL that serves a project file to the page. */
31
+ export function mediaUrl(path: string, options: MediaOptions = {}): string {
32
+ if (/^(https?:|data:|blob:)/.test(path)) return path;
33
+ const clean = path.replace(/^\.?\//, '');
34
+ const encoded = clean.split('/').map(encodeURIComponent).join('/');
35
+ const params = new URLSearchParams();
36
+ if (options.raw) params.set('raw', '1');
37
+ else {
38
+ if (options.width) params.set('w', String(options.width));
39
+ if (options.format) params.set('fmt', options.format);
40
+ if (options.quality) params.set('q', String(options.quality));
41
+ }
42
+ const query = params.toString();
43
+ return `/api/media/${encoded}${query ? `?${query}` : ''}`;
44
+ }
45
+
46
+ /**
47
+ * Load a project image as an <img>. Defaults to `raw` — a node is processing
48
+ * these pixels, not showing them, so a re-encoded copy would be the wrong thing
49
+ * to hand it. Pass a width explicitly to work at a smaller size on purpose.
50
+ */
51
+ export async function loadImage(path: string, options: MediaOptions = { raw: true }): Promise<HTMLImageElement> {
52
+ const image = new Image();
53
+ image.crossOrigin = 'anonymous';
54
+ image.src = mediaUrl(path, options);
55
+ await image.decode();
56
+ return image;
57
+ }
58
+
59
+ /** Same, as an ImageBitmap — the right input for `texImage2D`. */
60
+ export async function loadBitmap(path: string, options: MediaOptions = { raw: true }): Promise<ImageBitmap> {
61
+ const response = await fetch(mediaUrl(path, options));
62
+ if (!response.ok) throw new Error(`cascade/io: cannot load ${path} (${response.status})`);
63
+ return createImageBitmap(await response.blob());
64
+ }
65
+
66
+ /**
67
+ * A .npy field (a mask, a signal, a height or density field) rendered to
68
+ * greyscale, ready to upload as a texture. The numeric array itself stays on
69
+ * the server side for now; this is the visual form of it.
70
+ */
71
+ export async function loadFieldBitmap(path: string, options: MediaOptions = {}): Promise<ImageBitmap> {
72
+ return loadBitmap(path, { ...options, raw: false });
73
+ }
74
+
75
+ export interface SaveOptions {
76
+ /** 'png' (default), 'webp' or 'jpeg'. PNG is the default because the usual
77
+ * consumer of a node's output is another node, and a lossy intermediate
78
+ * compounds down a pipeline. Choose webp when the output is a preview. */
79
+ format?: 'png' | 'webp' | 'jpeg';
80
+ quality?: number;
81
+ }
82
+
83
+ /**
84
+ * Write pixels into the project and get back the path to hand downstream.
85
+ * Confined to the cache directory, like every other node output.
86
+ *
87
+ * const out = await saveImage(canvas, cachePath(node.id, '.png'));
88
+ * node.out('path').setValue(out);
89
+ */
90
+ export async function saveImage(
91
+ source: HTMLCanvasElement | OffscreenCanvas | Blob,
92
+ path: string,
93
+ options: SaveOptions = {}
94
+ ): Promise<string> {
95
+ const format = options.format ?? 'png';
96
+ const type = `image/${format}`;
97
+ let blob: Blob;
98
+
99
+ if (source instanceof Blob) {
100
+ blob = source;
101
+ } else if ('convertToBlob' in source) {
102
+ blob = await (source as OffscreenCanvas).convertToBlob({ type, quality: options.quality });
103
+ } else {
104
+ blob = await new Promise<Blob>((resolve, reject) => {
105
+ (source as HTMLCanvasElement).toBlob(
106
+ (result) => (result ? resolve(result) : reject(new Error('cascade/io: canvas produced no data'))),
107
+ type,
108
+ options.quality
109
+ );
110
+ });
111
+ }
112
+
113
+ return saveBytes(blob, path);
114
+ }
115
+
116
+ /** Write any bytes into the project cache and return the path. */
117
+ export async function saveBytes(data: Blob | ArrayBuffer | Uint8Array, path: string): Promise<string> {
118
+ const clean = path.replace(/^\.?\//, '');
119
+ const response = await fetch(`/api/media/${clean.split('/').map(encodeURIComponent).join('/')}`, {
120
+ method: 'PUT',
121
+ headers: { 'Content-Type': 'application/octet-stream' },
122
+ body: data as BodyInit,
123
+ });
124
+ if (!response.ok) {
125
+ throw new Error(`cascade/io: cannot write ${path} — ${await response.text()}`);
126
+ }
127
+ const result = await response.json();
128
+ return result.path as string;
129
+ }
130
+
131
+ /**
132
+ * The output path convention, shared with the Python side: scoped by node id so
133
+ * one node's scratch file never lands on a sibling's. The server puts the cache
134
+ * key into the filename when it writes, so different inputs keep separate files.
135
+ */
136
+ export function cachePath(nodeId: string, suffix: string): string {
137
+ const safe = nodeId.replace(/[^a-zA-Z0-9_-]/g, '_');
138
+ return `.cascade-cache/${safe}${suffix}`;
139
+ }
140
+
141
+ // ---------------------------------------------------------------- float data
142
+
143
+ /**
144
+ * Float pixel data, shared between Python and JavaScript with no conversion.
145
+ *
146
+ * This works because a `.npy` file holding little-endian float32 in C order IS
147
+ * a Float32Array with a header in front of it. numpy pads that header so the
148
+ * data begins on a 64-byte boundary — measured on this project's own files, it
149
+ * starts at byte 128 every time — which means a typed array can be constructed
150
+ * as a VIEW onto the fetched buffer rather than a copy of it. A 10 MB density
151
+ * field costs one fetch and no marshalling.
152
+ *
153
+ * From there it goes straight where you need it: into `texImage2D` as an R32F
154
+ * or RGBA32F texture, into a compute pass, or through `toImageData` for
155
+ * Canvas2D. And `saveFloats` writes the same layout back, so `np.load` on the
156
+ * Python side reads it as an ordinary array.
157
+ */
158
+ export interface FloatImage {
159
+ data: Float32Array;
160
+ /** [width, height] */
161
+ size: [number, number];
162
+ /** 1 for a mask or signal, 3 for RGB, 4 for RGBA. */
163
+ channels: number;
164
+ }
165
+
166
+ interface NpyHeader {
167
+ dtype: string;
168
+ shape: number[];
169
+ dataOffset: number;
170
+ fortran: boolean;
171
+ }
172
+
173
+ function parseNpyHeader(buffer: ArrayBuffer): NpyHeader {
174
+ const bytes = new Uint8Array(buffer);
175
+ const magic = String.fromCharCode(...bytes.subarray(0, 6));
176
+ if (magic !== '\x93NUMPY') throw new Error('cascade/io: not a .npy file');
177
+
178
+ const view = new DataView(buffer);
179
+ const major = bytes[6];
180
+ const headerLength = major >= 2 ? view.getUint32(8, true) : view.getUint16(8, true);
181
+ const headerStart = major >= 2 ? 12 : 10;
182
+ const header = String.fromCharCode(...bytes.subarray(headerStart, headerStart + headerLength));
183
+
184
+ const dtype = header.match(/'descr':\s*'([^']+)'/)?.[1];
185
+ if (!dtype) throw new Error('cascade/io: .npy header has no dtype');
186
+
187
+ const shape = (header.match(/'shape':\s*\(([^)]*)\)/)?.[1] ?? '')
188
+ .split(',')
189
+ .map(part => part.trim())
190
+ .filter(Boolean)
191
+ .map(Number);
192
+
193
+ return {
194
+ dtype,
195
+ shape,
196
+ dataOffset: headerStart + headerLength,
197
+ fortran: /'fortran_order':\s*True/.test(header),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Read a float image as a zero-copy Float32Array view. Non-float and
203
+ * non-native-endian arrays are converted rather than refused, so a uint8 mask
204
+ * still loads — it just costs a pass.
205
+ */
206
+ export async function loadFloats(path: string): Promise<FloatImage> {
207
+ const response = await fetch(mediaUrl(path, { raw: true }));
208
+ if (!response.ok) throw new Error(`cascade/io: cannot load ${path} (${response.status})`);
209
+ const buffer = await response.arrayBuffer();
210
+ const header = parseNpyHeader(buffer);
211
+
212
+ if (header.fortran) throw new Error('cascade/io: Fortran-ordered .npy is not supported');
213
+ if (header.shape.length < 2) throw new Error(`cascade/io: expected an image-shaped array, got ${header.shape.length}D`);
214
+
215
+ const [height, width] = header.shape;
216
+ const channels = header.shape.length > 2 ? header.shape.slice(2).reduce((a, b) => a * b, 1) : 1;
217
+ const count = width * height * channels;
218
+
219
+ let data: Float32Array;
220
+ if (header.dtype === '<f4' || header.dtype === '=f4') {
221
+ // The whole point: a view, not a copy.
222
+ data = new Float32Array(buffer, header.dataOffset, count);
223
+ } else {
224
+ const source = readAs(buffer, header.dataOffset, header.dtype, count);
225
+ data = source instanceof Float32Array ? source : Float32Array.from(source);
226
+ }
227
+
228
+ return { data, size: [width, height], channels };
229
+ }
230
+
231
+ function readAs(buffer: ArrayBuffer, offset: number, dtype: string, count: number): ArrayLike<number> {
232
+ switch (dtype) {
233
+ case '<f8': case '=f8': return new Float64Array(buffer, offset, count);
234
+ case '|u1': case '<u1': case '=u1': case '|b1': return new Uint8Array(buffer, offset, count);
235
+ case '|i1': return new Int8Array(buffer, offset, count);
236
+ case '<i2': return new Int16Array(buffer, offset, count);
237
+ case '<u2': return new Uint16Array(buffer, offset, count);
238
+ case '<i4': return new Int32Array(buffer, offset, count);
239
+ case '<u4': return new Uint32Array(buffer, offset, count);
240
+ default: throw new Error(`cascade/io: .npy dtype ${dtype} is not supported`);
241
+ }
242
+ }
243
+
244
+ /** Build the .npy container around raw float32 bytes, laid out exactly as
245
+ * numpy expects so `np.load` reads it with no special handling. */
246
+ function encodeNpy(data: Float32Array, shape: number[]): Uint8Array {
247
+ const dict = `{'descr': '<f4', 'fortran_order': False, 'shape': (${shape.join(', ')}${shape.length === 1 ? ',' : ''}), }`;
248
+ // numpy pads so magic + version + length + header is a multiple of 64, which
249
+ // is what keeps the data 4-byte aligned for a zero-copy read on the way back.
250
+ const unpadded = 10 + dict.length + 1;
251
+ const padding = (64 - (unpadded % 64)) % 64;
252
+ const header = dict + ' '.repeat(padding) + '\n';
253
+
254
+ const out = new Uint8Array(10 + header.length + data.byteLength);
255
+ const view = new DataView(out.buffer);
256
+ out.set([0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59, 1, 0], 0); // \x93NUMPY v1.0
257
+ view.setUint16(8, header.length, true);
258
+ for (let i = 0; i < header.length; i++) out[10 + i] = header.charCodeAt(i);
259
+ out.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), 10 + header.length);
260
+ return out;
261
+ }
262
+
263
+ /** Write float pixels back into the project. Python reads the result with a
264
+ * plain `np.load`. Returns the stored path. */
265
+ export async function saveFloats(image: FloatImage, path: string): Promise<string> {
266
+ const [width, height] = image.size;
267
+ const shape = image.channels > 1 ? [height, width, image.channels] : [height, width];
268
+ return saveBytes(encodeNpy(image.data, shape), path);
269
+ }
270
+
271
+ /**
272
+ * Float pixels -> ImageData for Canvas2D. Values are assumed to be 0..1 unless
273
+ * a range is given; pass the field's actual range to see it the way the viewer
274
+ * does rather than clipped.
275
+ */
276
+ export function toImageData(image: FloatImage, range: [number, number] = [0, 1]): ImageData {
277
+ const [width, height] = image.size;
278
+ const [low, high] = range;
279
+ const span = high - low || 1;
280
+ const out = new Uint8ClampedArray(width * height * 4);
281
+
282
+ for (let i = 0; i < width * height; i++) {
283
+ const base = i * image.channels;
284
+ const r = ((image.data[base] - low) / span) * 255;
285
+ const g = image.channels > 1 ? ((image.data[base + 1] - low) / span) * 255 : r;
286
+ const b = image.channels > 2 ? ((image.data[base + 2] - low) / span) * 255 : r;
287
+ const a = image.channels > 3 ? image.data[base + 3] * 255 : 255;
288
+ out[i * 4] = r;
289
+ out[i * 4 + 1] = g;
290
+ out[i * 4 + 2] = b;
291
+ out[i * 4 + 3] = a;
292
+ }
293
+ return new ImageData(out, width, height);
294
+ }
295
+
296
+ /**
297
+ * Upload float pixels as a GPU texture, no conversion on the way. Needs the
298
+ * EXT_color_buffer_float extension to render INTO one; sampling works without.
299
+ */
300
+ export function uploadFloatTexture(gl: WebGL2RenderingContext, image: FloatImage): WebGLTexture {
301
+ const [width, height] = image.size;
302
+ const formats: Record<number, [number, number]> = {
303
+ 1: [gl.R32F, gl.RED],
304
+ 2: [gl.RG32F, gl.RG],
305
+ 3: [gl.RGB32F, gl.RGB],
306
+ 4: [gl.RGBA32F, gl.RGBA],
307
+ };
308
+ const pair = formats[image.channels];
309
+ if (!pair) throw new Error(`cascade/io: ${image.channels} channels has no texture format`);
310
+ const [internalFormat, format] = pair;
311
+
312
+ const texture = gl.createTexture()!;
313
+ gl.bindTexture(gl.TEXTURE_2D, texture);
314
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
315
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
316
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
317
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
318
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
319
+ gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, width, height, 0, format, gl.FLOAT, image.data);
320
+ return texture;
321
+ }
322
+
323
+ // ------------------------------------------------------------------ textures
324
+
325
+ /**
326
+ * A live GPU texture, passed between browser nodes.
327
+ *
328
+ * Separate from `image` on purpose, and the distinction is about cost rather
329
+ * than taste. An `image` is file-backed: durable, portable, readable by Python,
330
+ * and it costs a write and a read every time it is handed on. A `texture` never
331
+ * leaves the page — a chain of WebGL nodes passes one along with no round trip
332
+ * at all, which is what makes an interactive shader chain feel immediate.
333
+ *
334
+ * Because it is session-bound it cannot be cached, cannot be inspected after a
335
+ * reload, and cannot be read by a Python stage. So the conversions are explicit:
336
+ * `toImage` is a GPU readback and a file write, and it should be visible in the
337
+ * graph rather than happening silently on a connection.
338
+ */
339
+ export interface TextureRef {
340
+ gl: WebGL2RenderingContext;
341
+ texture: WebGLTexture;
342
+ /** [width, height] */
343
+ size: [number, number];
344
+ channels: number;
345
+ /** True when the texture holds float data rather than 8-bit. */
346
+ float: boolean;
347
+ }
348
+
349
+ /** Upload an image file to the GPU. The integer path for `uploadFloatTexture`. */
350
+ export async function textureFromImage(
351
+ gl: WebGL2RenderingContext,
352
+ image: string | { path: string },
353
+ options: MediaOptions = { raw: true }
354
+ ): Promise<TextureRef> {
355
+ const path = typeof image === 'string' ? image : image.path;
356
+
357
+ if (path.endsWith('.npy')) {
358
+ const floats = await loadFloats(path);
359
+ return {
360
+ gl,
361
+ texture: uploadFloatTexture(gl, floats),
362
+ size: floats.size,
363
+ channels: floats.channels,
364
+ float: true,
365
+ };
366
+ }
367
+
368
+ const bitmap = await loadBitmap(path, options);
369
+ const texture = gl.createTexture()!;
370
+ gl.bindTexture(gl.TEXTURE_2D, texture);
371
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
372
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
373
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
374
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
375
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
376
+ const size: [number, number] = [bitmap.width, bitmap.height];
377
+ bitmap.close();
378
+ return { gl, texture, size, channels: 4, float: false };
379
+ }
380
+
381
+ /** Allocate an empty texture to render into. */
382
+ export function createTexture(
383
+ gl: WebGL2RenderingContext,
384
+ size: [number, number],
385
+ options: { float?: boolean; channels?: number } = {}
386
+ ): TextureRef {
387
+ const [width, height] = size;
388
+ const float = options.float ?? false;
389
+ const channels = options.channels ?? 4;
390
+ const texture = gl.createTexture()!;
391
+ gl.bindTexture(gl.TEXTURE_2D, texture);
392
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
393
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
394
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
395
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
396
+ gl.texStorage2D(gl.TEXTURE_2D, 1, float ? gl.RGBA32F : gl.RGBA8, width, height);
397
+ return { gl, texture, size, channels, float };
398
+ }
399
+
400
+ /**
401
+ * Read a texture back off the GPU and write it into the project as an image.
402
+ * The one place a texture becomes durable — deliberately a named step, because
403
+ * a readback stalls the pipeline and a write costs a file.
404
+ */
405
+ export async function textureToImage(
406
+ ref: TextureRef,
407
+ path: string,
408
+ options: { format?: 'png' | 'webp'; space?: 'srgb' | 'linear' } = {}
409
+ ): Promise<ImageRef> {
410
+ const { gl, size } = ref;
411
+ const [width, height] = size;
412
+
413
+ const framebuffer = gl.createFramebuffer();
414
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
415
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, ref.texture, 0);
416
+
417
+ try {
418
+ if (ref.float) {
419
+ const pixels = new Float32Array(width * height * 4);
420
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.FLOAT, pixels);
421
+ const stored = await saveFloats({ data: pixels, size, channels: 4 }, path.replace(/\.[^.]+$/, '') + '.npy');
422
+ return { path: stored, size, channels: 'rgba', depth: 'f32', space: options.space ?? 'linear' };
423
+ }
424
+
425
+ const pixels = new Uint8ClampedArray(width * height * 4);
426
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
427
+ const canvas = new OffscreenCanvas(width, height);
428
+ canvas.getContext('2d')!.putImageData(new ImageData(pixels, width, height), 0, 0);
429
+ const format = options.format ?? 'png';
430
+ const stored = await saveImage(canvas, path, { format });
431
+ return { path: stored, size, channels: 'rgba', depth: 'u8', space: options.space ?? 'srgb' };
432
+ } finally {
433
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
434
+ gl.deleteFramebuffer(framebuffer);
435
+ }
436
+ }
437
+
438
+ /** The value on an `image` port. Mirrors Cascade's core ImageRef. */
439
+ export interface ImageRef {
440
+ path: string;
441
+ size: [number, number];
442
+ channels: 'r' | 'a' | 'rg' | 'rgb' | 'rgba';
443
+ depth: 'u8' | 'u16' | 'f16' | 'f32';
444
+ space: 'srgb' | 'linear';
445
+ }
@@ -0,0 +1,19 @@
1
+ let capability: Promise<string> | null = null;
2
+
3
+ export async function authorizedFetch(credential: string, url: string, init: RequestInit = {}): Promise<Response> {
4
+ capability ??= fetch('/api/net/capability').then(async (response) => {
5
+ if (!response.ok) throw new Error(`Authorized fetch capability failed: HTTP ${response.status}`);
6
+ return (await response.json()).capability as string;
7
+ });
8
+ const headers = Object.fromEntries(new Headers(init.headers).entries());
9
+ const response = await fetch('/api/net', {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json', 'X-Cascade-Net-Capability': await capability },
12
+ body: JSON.stringify({ credential, url, method: init.method ?? 'GET', headers,
13
+ ...(typeof init.body === 'string' ? { body: init.body } : {}) }),
14
+ signal: init.signal,
15
+ });
16
+ const result = await response.json();
17
+ if (!response.ok || !result.ok) throw new Error(result.error ?? `Authorized fetch failed: HTTP ${response.status}`);
18
+ return new Response(result.body, { status: result.status, statusText: result.statusText, headers: result.headers });
19
+ }
@@ -0,0 +1,46 @@
1
+ import {
2
+ ShellProcessError,
3
+ type ShellProcessErrorKind,
4
+ type ShellRunOptions,
5
+ type ShellRunResult,
6
+ } from '@cascade/contracts';
7
+
8
+ export { ShellProcessError } from '@cascade/contracts';
9
+ export type { ShellProcessErrorKind, ShellRunOptions, ShellRunResult } from '@cascade/contracts';
10
+
11
+ let capabilityPromise: Promise<string> | undefined;
12
+
13
+ export async function run(command: string, args: readonly string[] = [], options: ShellRunOptions = {}): Promise<ShellRunResult> {
14
+ capabilityPromise ??= fetch('/api/shell/capability', { cache: 'no-store' })
15
+ .then(async (response) => {
16
+ if (!response.ok) throw new Error('Cascade shell capability is unavailable');
17
+ return (await response.json() as { capability: string }).capability;
18
+ })
19
+ .catch((error) => {
20
+ capabilityPromise = undefined;
21
+ throw error;
22
+ });
23
+ const capability = await capabilityPromise;
24
+ const response = await fetch('/api/shell', {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json', 'X-Cascade-Shell-Capability': capability },
27
+ body: JSON.stringify({ command, args, cwd: options.cwd, timeout: options.timeout, stdin: options.stdin, env: options.env }),
28
+ signal: options.signal as AbortSignal | undefined,
29
+ });
30
+ const payload = await response.json() as ({ ok: true } & ShellRunResult) | ({ ok: false; error: string; kind?: ShellProcessErrorKind } & Partial<ShellRunResult>);
31
+ if (payload.ok) return payload;
32
+ if (payload.kind === 'nonzero') return toResult(payload);
33
+ if (payload.kind) throw new ShellProcessError(payload.kind, toResult(payload), payload.error);
34
+ throw new Error(payload.error);
35
+ }
36
+
37
+ export async function runJson<T = unknown>(command: string, args: readonly string[] = [], options: ShellRunOptions = {}): Promise<T> {
38
+ const result = await run(command, args, options);
39
+ if (result.code !== 0) throw new ShellProcessError('nonzero', result, result.stderr || `Command exited with code ${result.code}`);
40
+ const line = result.stdout.split(/\r?\n/).filter((value) => value.trim()).at(-1) ?? '';
41
+ try { return JSON.parse(line) as T; } catch { throw new ShellProcessError('invalid-json', result, 'Command output was not valid JSON'); }
42
+ }
43
+
44
+ function toResult(value: Partial<ShellRunResult>): ShellRunResult {
45
+ return { stdout: value.stdout ?? '', stderr: value.stderr ?? '', code: value.code ?? null, timedOut: value.timedOut ?? false, cancelled: value.cancelled ?? false, outputLimited: value.outputLimited ?? false };
46
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `cascade/stage` — call a project's Python stage from either host.
3
+ *
4
+ * A project's heavy work lives in Python stages, and until now every project
5
+ * hand-rolled its own bridge to them by posting to `/api/exec`. That works in
6
+ * Studio and fails everywhere else: `cascade run` has no server to post to and
7
+ * no origin to resolve a relative URL against, so any graph with a Python node
8
+ * could not be rendered offline. Each project reimplementing the same fetch
9
+ * also meant each project reimplementing the same response unwrapping, and
10
+ * getting it subtly wrong.
11
+ *
12
+ * So the bridge belongs to Cascade, with one transport per host: the page posts
13
+ * to the server, and a headless host installs `installStageBridge` before it
14
+ * executes the graph. A node imports `runStage` and never learns which it got.
15
+ *
16
+ * The counterpart for files is `cascade/io`, which keeps the same rule: a port
17
+ * carries a project-relative path, never pixels.
18
+ */
19
+
20
+ export interface StageBridge {
21
+ (stage: string, args: Record<string, unknown>): Promise<unknown>;
22
+ }
23
+
24
+ const BRIDGE_KEY = '__cascadeStageBridge';
25
+
26
+ /**
27
+ * Install the local transport. Called by a host that can reach the filesystem —
28
+ * the CLI does this before running a graph. Returns a disposer so a host can
29
+ * take it back down, which matters for tests more than for a one-shot run.
30
+ */
31
+ export function installStageBridge(bridge: StageBridge | null): () => void {
32
+ const globals = globalThis as Record<string, unknown>;
33
+ const previous = globals[BRIDGE_KEY];
34
+ globals[BRIDGE_KEY] = bridge ?? undefined;
35
+ return () => {
36
+ globals[BRIDGE_KEY] = previous;
37
+ };
38
+ }
39
+
40
+ function localBridge(): StageBridge | null {
41
+ const bridge = (globalThis as Record<string, unknown>)[BRIDGE_KEY];
42
+ return typeof bridge === 'function' ? (bridge as StageBridge) : null;
43
+ }
44
+
45
+ let capabilityPromise: Promise<string | null> | undefined;
46
+
47
+ async function discoverCapability(): Promise<string | null> {
48
+ capabilityPromise ??= fetch('/api/exec/capability', { cache: 'no-store' })
49
+ .then(async (response) => {
50
+ if (response.status === 404) {
51
+ capabilityPromise = undefined;
52
+ return null;
53
+ }
54
+ if (!response.ok) throw new Error(`Cascade exec capability is unavailable (${response.status})`);
55
+ const token = (await response.json() as { capability?: unknown }).capability;
56
+ if (typeof token !== 'string' || !token) {
57
+ throw new Error('Cascade exec capability response did not contain a capability');
58
+ }
59
+ return token;
60
+ })
61
+ .catch((error) => {
62
+ capabilityPromise = undefined;
63
+ throw error;
64
+ });
65
+ return capabilityPromise;
66
+ }
67
+
68
+ async function capability(): Promise<string> {
69
+ const token = await discoverCapability();
70
+ if (token === null) throw new Error('Cascade exec capability is unavailable (404)');
71
+ return token;
72
+ }
73
+
74
+ async function overHttp<T>(stage: string, args: Record<string, unknown>): Promise<T> {
75
+ const postStage = async () => fetch('/api/exec', {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json', 'X-Cascade-Exec-Capability': await capability() },
78
+ body: JSON.stringify({ stage, args }),
79
+ });
80
+
81
+ let response = await postStage();
82
+ if (response.status === 403) {
83
+ capabilityPromise = undefined;
84
+ response = await postStage();
85
+ }
86
+
87
+ const body = await response.json() as { ok?: boolean; stdout?: string; stderr?: string; error?: string };
88
+ if (!response.ok || !body.ok) {
89
+ throw new Error(`stage "${stage}" failed: ${body.stderr || body.error || 'unknown error'}`);
90
+ }
91
+ if (typeof body.stdout !== 'string') {
92
+ throw new Error(`stage "${stage}" returned an invalid response`);
93
+ }
94
+ // The dispatcher's result is the last line it printed, so a stage that logs
95
+ // on its way through does not corrupt its own return value.
96
+ const lastLine = body.stdout.trim().split('\n').pop() || '{}';
97
+ return JSON.parse(lastLine) as T;
98
+ }
99
+
100
+ /** Whether this host has a local stage bridge or a browser-accessible stage
101
+ * endpoint. A missing browser route is a supported "not available" result;
102
+ * authorization and transport failures remain errors. */
103
+ export async function stageAvailable(): Promise<boolean> {
104
+ if (localBridge()) return true;
105
+ if (typeof document === 'undefined') return false;
106
+ return (await discoverCapability()) !== null;
107
+ }
108
+
109
+ /**
110
+ * Run a stage and return its parsed result.
111
+ *
112
+ * A headless host without a bridge installed gets a message that says so,
113
+ * rather than a URL parse error from a fetch that was never going to work —
114
+ * which is what this whole file exists to stop happening.
115
+ */
116
+ export async function runStage<T = unknown>(stage: string, args: Record<string, unknown> = {}): Promise<T> {
117
+ const bridge = localBridge();
118
+ if (bridge) return await bridge(stage, args) as T;
119
+ if (typeof document === 'undefined') {
120
+ throw new Error(
121
+ `Cannot run stage "${stage}": no host bridge is installed and there is no page to post from. ` +
122
+ 'A headless host must call installStageBridge() from cascade/stage before executing the graph.',
123
+ );
124
+ }
125
+ return overHttp<T>(stage, args);
126
+ }
127
+
128
+ /** A scratch path for a node's output, scoped by node id so re-running a graph
129
+ * does not clobber a sibling's file. */
130
+ export function cachePath(nodeId: string, suffix: string): string {
131
+ return `.cascade-cache/${nodeId.replace(/[^a-zA-Z0-9_-]/g, '_')}${suffix}`;
132
+ }