sygnal 7.1.0 → 7.2.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/dist/agent-ui.cjs.js +592 -0
  3. package/dist/agent-ui.cjs.js.map +1 -0
  4. package/dist/agent-ui.esm.js +582 -0
  5. package/dist/agent-ui.esm.js.map +1 -0
  6. package/dist/api.cjs.js +63 -0
  7. package/dist/api.cjs.js.map +1 -0
  8. package/dist/api.esm.js +2 -0
  9. package/dist/api.esm.js.map +1 -0
  10. package/dist/astro/index.cjs.js +968 -4
  11. package/dist/astro/index.cjs.js.map +1 -1
  12. package/dist/astro/index.mjs +968 -4
  13. package/dist/astro/index.mjs.map +1 -1
  14. package/dist/astro/server.cjs.js +95 -71
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +95 -71
  17. package/dist/astro/server.mjs.map +1 -1
  18. package/dist/canvas.cjs.js +2919 -0
  19. package/dist/canvas.cjs.js.map +1 -0
  20. package/dist/canvas.esm.js +2866 -0
  21. package/dist/canvas.esm.js.map +1 -0
  22. package/dist/devtools.cjs.js +38 -6
  23. package/dist/devtools.cjs.js.map +1 -1
  24. package/dist/devtools.esm.js +38 -6
  25. package/dist/devtools.esm.js.map +1 -1
  26. package/dist/diagnostics.cjs.js +164 -23
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +164 -23
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/ai-compose.md +150 -0
  31. package/dist/guide/behaviors.md +2 -1
  32. package/dist/guide/collaborative-state.md +265 -0
  33. package/dist/guide/element-commands.md +36 -2
  34. package/dist/guide/forms-reference.md +5 -5
  35. package/dist/guide/forms.md +3 -2
  36. package/dist/guide/http.md +138 -9
  37. package/dist/guide/islands.md +240 -0
  38. package/dist/guide/recipes/code-editor.md +1 -1
  39. package/dist/guide/renderers/agent-ui.md +138 -0
  40. package/dist/guide/renderers/custom-targets.md +126 -0
  41. package/dist/guide/renderers/overview.md +120 -0
  42. package/dist/guide/renderers/react-native.md +128 -0
  43. package/dist/guide/renderers/terminal.md +306 -0
  44. package/dist/guide/renderers/three.md +332 -0
  45. package/dist/guide/renderers/write-once.md +137 -0
  46. package/dist/guide/resources.md +114 -6
  47. package/dist/guide/sandboxed-components.md +223 -0
  48. package/dist/guide/ssr.md +5 -3
  49. package/dist/guide/timers.md +29 -2
  50. package/dist/guide/typed-apis.md +442 -0
  51. package/dist/guide/ui/accordion.md +11 -0
  52. package/dist/guide/ui/combobox.md +112 -8
  53. package/dist/guide/ui/dialog.md +96 -20
  54. package/dist/guide/ui/menu.md +57 -5
  55. package/dist/guide/ui/overview.md +8 -4
  56. package/dist/guide/ui/popover.md +69 -9
  57. package/dist/guide/ui/select.md +54 -4
  58. package/dist/guide/ui/tabs.md +1 -1
  59. package/dist/guide/ui/tooltip.md +7 -5
  60. package/dist/guide/virtual-collections.md +3 -0
  61. package/dist/guide/widgets.md +90 -1
  62. package/dist/host.cjs.js +2187 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2152 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +4430 -863
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +646 -43
  69. package/dist/index.esm.js +4402 -863
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/primitives.cjs.js +143 -0
  72. package/dist/primitives.cjs.js.map +1 -0
  73. package/dist/primitives.esm.js +133 -0
  74. package/dist/primitives.esm.js.map +1 -0
  75. package/dist/react-native.cjs.js +248 -0
  76. package/dist/react-native.cjs.js.map +1 -0
  77. package/dist/react-native.esm.js +244 -0
  78. package/dist/react-native.esm.js.map +1 -0
  79. package/dist/replay.cjs.js +39 -0
  80. package/dist/replay.cjs.js.map +1 -0
  81. package/dist/replay.esm.js +2 -0
  82. package/dist/replay.esm.js.map +1 -0
  83. package/dist/sandbox-build.cjs.js +140 -0
  84. package/dist/sandbox-build.cjs.js.map +1 -0
  85. package/dist/sandbox-build.esm.js +137 -0
  86. package/dist/sandbox-build.esm.js.map +1 -0
  87. package/dist/sandbox-kernel.esm.js +212 -0
  88. package/dist/sandbox-kernel.esm.js.map +1 -0
  89. package/dist/sandbox-worker.cjs.js +857 -0
  90. package/dist/sandbox-worker.cjs.js.map +1 -0
  91. package/dist/sandbox-worker.esm.js +854 -0
  92. package/dist/sandbox-worker.esm.js.map +1 -0
  93. package/dist/sandbox.cjs.js +1425 -0
  94. package/dist/sandbox.cjs.js.map +1 -0
  95. package/dist/sandbox.esm.js +1406 -0
  96. package/dist/sandbox.esm.js.map +1 -0
  97. package/dist/sygnal.min.js +1 -1
  98. package/dist/sygnal.min.js.map +1 -1
  99. package/dist/terminal.cjs.js +1314 -0
  100. package/dist/terminal.cjs.js.map +1 -0
  101. package/dist/terminal.esm.js +1304 -0
  102. package/dist/terminal.esm.js.map +1 -0
  103. package/dist/three.cjs.js +1111 -0
  104. package/dist/three.cjs.js.map +1 -0
  105. package/dist/three.esm.js +1079 -0
  106. package/dist/three.esm.js.map +1 -0
  107. package/dist/ui-combobox.cjs.js +256 -18
  108. package/dist/ui-combobox.cjs.js.map +1 -1
  109. package/dist/ui-combobox.esm.js +256 -18
  110. package/dist/ui-combobox.esm.js.map +1 -1
  111. package/dist/ui-menu.cjs.js +150 -16
  112. package/dist/ui-menu.cjs.js.map +1 -1
  113. package/dist/ui-menu.esm.js +151 -17
  114. package/dist/ui-menu.esm.js.map +1 -1
  115. package/dist/ui-select.cjs.js +95 -9
  116. package/dist/ui-select.cjs.js.map +1 -1
  117. package/dist/ui-select.esm.js +95 -9
  118. package/dist/ui-select.esm.js.map +1 -1
  119. package/dist/ui.cjs.js +679 -173
  120. package/dist/ui.cjs.js.map +1 -1
  121. package/dist/ui.esm.js +679 -175
  122. package/dist/ui.esm.js.map +1 -1
  123. package/dist/vike/config/package.json +1 -1
  124. package/dist/vite/plugin.cjs.js +968 -4
  125. package/dist/vite/plugin.cjs.js.map +1 -1
  126. package/dist/vite/plugin.mjs +968 -4
  127. package/dist/vite/plugin.mjs.map +1 -1
  128. package/dist/worker-page.cjs.js +669 -0
  129. package/dist/worker-page.cjs.js.map +1 -0
  130. package/dist/worker-page.esm.js +665 -0
  131. package/dist/worker-page.esm.js.map +1 -0
  132. package/dist/worker.cjs.js +377 -0
  133. package/dist/worker.cjs.js.map +1 -0
  134. package/dist/worker.esm.js +374 -0
  135. package/dist/worker.esm.js.map +1 -0
  136. package/dist/yjs-testing.cjs.js +98 -0
  137. package/dist/yjs-testing.cjs.js.map +1 -0
  138. package/dist/yjs-testing.esm.js +77 -0
  139. package/dist/yjs-testing.esm.js.map +1 -0
  140. package/dist/yjs.cjs.js +923 -0
  141. package/dist/yjs.cjs.js.map +1 -0
  142. package/dist/yjs.esm.js +900 -0
  143. package/dist/yjs.esm.js.map +1 -0
  144. package/dist/zag.cjs.js +11 -1
  145. package/dist/zag.cjs.js.map +1 -1
  146. package/dist/zag.esm.js +11 -1
  147. package/dist/zag.esm.js.map +1 -1
  148. package/llms/ai.md +65 -0
  149. package/llms/collaboration.md +5 -0
  150. package/llms/graphics.md +10 -0
  151. package/llms/renderers.md +7 -0
  152. package/llms/replay.md +5 -0
  153. package/llms/sandbox.md +5 -0
  154. package/llms/typed-apis.md +5 -0
  155. package/llms/ui.md +10 -0
  156. package/llms-full.txt +444 -0
  157. package/llms.txt +35 -33
  158. package/package.json +139 -3
  159. package/src/agent-ui/mcp.ts +152 -0
  160. package/src/agent-ui/snapshot.ts +212 -0
  161. package/src/agent-ui.d.ts +106 -0
  162. package/src/agent-ui.ts +249 -0
  163. package/src/ai.d.ts +95 -2
  164. package/src/api.d.ts +303 -0
  165. package/src/api.ts +9 -0
  166. package/src/canvas/Canvas.ts +585 -0
  167. package/src/canvas/backend.ts +627 -0
  168. package/src/canvas/geometry.ts +187 -0
  169. package/src/canvas/gesture.ts +23 -0
  170. package/src/canvas/image.ts +197 -0
  171. package/src/canvas/kit.ts +130 -0
  172. package/src/canvas/paint.ts +260 -0
  173. package/src/canvas/pointer.ts +25 -0
  174. package/src/canvas/snap.ts +68 -0
  175. package/src/canvas/svg.ts +99 -0
  176. package/src/canvas.d.ts +373 -0
  177. package/src/canvas.ts +46 -0
  178. package/src/core/actions.ts +14 -3
  179. package/src/core/cell.ts +13 -5
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +2 -2
  183. package/src/core/instance.ts +91 -41
  184. package/src/core/markers/clientonly.ts +3 -3
  185. package/src/core/markers/portal.ts +25 -11
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/cycle/dom/EventDelegator.ts +37 -33
  189. package/src/cycle/dom/MainDOMSource.ts +16 -14
  190. package/src/cycle/dom/enrichEventStream.ts +13 -9
  191. package/src/cycle/dom/fromEvent.ts +6 -10
  192. package/src/cycle/dom/index.ts +11 -7
  193. package/src/cycle/dom/isolate.ts +16 -7
  194. package/src/cycle/dom/makeDOMDriver.ts +10 -4
  195. package/src/cycle/dom/mockDOMSource.ts +3 -3
  196. package/src/cycle/dom/propsModule.ts +5 -1
  197. package/src/cycle/dom/utils.ts +9 -2
  198. package/src/cycle/run/adapt.ts +4 -12
  199. package/src/devtools.d.ts +9 -0
  200. package/src/devtools.ts +2 -1
  201. package/src/extra/ai/compose/catalog.ts +2 -1
  202. package/src/extra/ai/compose/decisions.ts +1 -2
  203. package/src/extra/ai/compose/generated.ts +234 -0
  204. package/src/extra/ai/compose/index.ts +6 -2
  205. package/src/extra/ai/compose/layout.ts +3 -1
  206. package/src/extra/ai/compose/llm.ts +7 -3
  207. package/src/extra/ai/compose/types.ts +2 -2
  208. package/src/extra/ai/transports/shared.ts +3 -44
  209. package/src/extra/api/contract.ts +485 -0
  210. package/src/extra/api/index.ts +26 -0
  211. package/src/extra/controlled.ts +44 -0
  212. package/src/extra/copyAsTest.ts +22 -0
  213. package/src/extra/devtools.ts +5 -3
  214. package/src/extra/devtoolsActions.ts +8 -0
  215. package/src/extra/devtoolsNext.ts +1 -1
  216. package/src/extra/diagnostics/checks/actionLog.ts +8 -3
  217. package/src/extra/diagnostics/checks/behaviors.ts +25 -0
  218. package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
  219. package/src/extra/diagnostics/checks/fetch.ts +1 -0
  220. package/src/extra/diagnostics/checks/index.ts +1 -1
  221. package/src/extra/diagnostics/checks/next.ts +35 -4
  222. package/src/extra/diagnostics/checks/replies.ts +11 -7
  223. package/src/extra/diagnostics/checks/timers.ts +8 -2
  224. package/src/extra/diagnostics/checks/virtual.ts +11 -0
  225. package/src/extra/diagnostics/codes.ts +41 -2
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +181 -40
  228. package/src/extra/fetchEventSource.ts +1 -1
  229. package/src/extra/field.ts +29 -0
  230. package/src/extra/form.ts +42 -16
  231. package/src/extra/frame.ts +54 -0
  232. package/src/extra/keymap.ts +41 -0
  233. package/src/extra/pager.ts +2 -0
  234. package/src/extra/persist.ts +2 -1
  235. package/src/extra/pointer.ts +262 -0
  236. package/src/extra/processForm.ts +34 -1
  237. package/src/extra/queryCache.ts +40 -10
  238. package/src/extra/replay/address.ts +49 -0
  239. package/src/extra/replay/entropy.ts +99 -0
  240. package/src/extra/replay/format.ts +263 -0
  241. package/src/extra/replay/index.ts +8 -0
  242. package/src/extra/replay/inert.ts +22 -0
  243. package/src/extra/replay/recorder.ts +247 -0
  244. package/src/extra/replay/replayer.ts +234 -0
  245. package/src/extra/replay/report.ts +126 -0
  246. package/src/extra/replay/schema.ts +52 -0
  247. package/src/extra/replay/version.ts +3 -0
  248. package/src/extra/router.ts +7 -3
  249. package/src/extra/selection.ts +2 -0
  250. package/src/extra/sse.ts +48 -0
  251. package/src/extra/ssr.ts +132 -76
  252. package/src/extra/testing.ts +360 -41
  253. package/src/extra/timers.ts +91 -5
  254. package/src/extra/undo.ts +2 -0
  255. package/src/extra/virtual.ts +28 -3
  256. package/src/extra/widget.ts +105 -21
  257. package/src/host/actions.ts +383 -0
  258. package/src/host/delegation.ts +101 -0
  259. package/src/host/dom.ts +553 -0
  260. package/src/host/driver.ts +185 -0
  261. package/src/host/selector.ts +119 -0
  262. package/src/host/style.ts +51 -0
  263. package/src/host.d.ts +298 -0
  264. package/src/host.ts +46 -0
  265. package/src/index.d.ts +363 -36
  266. package/src/index.ts +13 -3
  267. package/src/pragma/blocks.ts +211 -0
  268. package/src/pragma/index.ts +44 -10
  269. package/src/primitives.d.ts +54 -0
  270. package/src/primitives.ts +136 -0
  271. package/src/react-native.d.ts +57 -0
  272. package/src/react-native.ts +285 -0
  273. package/src/react-peers.d.ts +21 -3
  274. package/src/replay.d.ts +185 -0
  275. package/src/replay.ts +6 -0
  276. package/src/sandbox/build.ts +119 -0
  277. package/src/sandbox/create.ts +187 -0
  278. package/src/sandbox/engine.ts +267 -0
  279. package/src/sandbox/generated.ts +195 -0
  280. package/src/sandbox/kernel.ts +180 -0
  281. package/src/sandbox/mirror.ts +269 -0
  282. package/src/sandbox/protocol.ts +128 -0
  283. package/src/sandbox/quickjs.ts +74 -0
  284. package/src/sandbox/ssr.ts +41 -0
  285. package/src/sandbox/widget.ts +60 -0
  286. package/src/sandbox/worker-entry.ts +59 -0
  287. package/src/sandbox/worker.ts +137 -0
  288. package/src/sandbox-build.d.ts +12 -0
  289. package/src/sandbox-build.ts +6 -0
  290. package/src/sandbox.d.ts +299 -0
  291. package/src/sandbox.ts +25 -0
  292. package/src/shared.ts +12 -0
  293. package/src/terminal/paint.ts +712 -0
  294. package/src/terminal.d.ts +167 -0
  295. package/src/terminal.ts +530 -0
  296. package/src/three/assets.ts +170 -0
  297. package/src/three/host.ts +36 -0
  298. package/src/three/scene.ts +217 -0
  299. package/src/three/target.ts +252 -0
  300. package/src/three.d.ts +221 -0
  301. package/src/three.ts +30 -0
  302. package/src/ui/accordion.ts +21 -7
  303. package/src/ui/dialog.ts +192 -90
  304. package/src/ui/disclosure.ts +2 -0
  305. package/src/ui/dom.ts +30 -0
  306. package/src/ui/layers.ts +132 -0
  307. package/src/ui/popover.ts +120 -21
  308. package/src/ui/shared.ts +98 -12
  309. package/src/ui/stack.ts +18 -0
  310. package/src/ui/tabs.ts +2 -0
  311. package/src/ui/toaster.ts +16 -15
  312. package/src/ui/tooltip.ts +24 -8
  313. package/src/ui/tooltips.ts +26 -14
  314. package/src/ui/zag/combobox.ts +180 -13
  315. package/src/ui/zag/menu.ts +97 -17
  316. package/src/ui/zag/select.ts +15 -4
  317. package/src/ui/zag/shared.ts +60 -8
  318. package/src/ui-combobox.d.ts +16 -1
  319. package/src/ui-menu.d.ts +22 -7
  320. package/src/ui-zag-types.d.ts +13 -5
  321. package/src/ui.d.ts +62 -7
  322. package/src/ui.ts +2 -2
  323. package/src/vite/api.ts +186 -0
  324. package/src/vite/compile.ts +280 -0
  325. package/src/vite/plugin.d.ts +34 -0
  326. package/src/vite/plugin.ts +78 -4
  327. package/src/vite/sandbox.ts +16 -0
  328. package/src/worker/page.ts +396 -0
  329. package/src/worker/protocol.ts +48 -0
  330. package/src/worker/target.ts +282 -0
  331. package/src/worker-page.d.ts +42 -0
  332. package/src/worker-page.ts +12 -0
  333. package/src/worker.d.ts +65 -0
  334. package/src/worker.ts +16 -0
  335. package/src/yjs/driver.ts +253 -0
  336. package/src/yjs/mapping.ts +389 -0
  337. package/src/yjs/shared.ts +138 -0
  338. package/src/yjs/testing.ts +91 -0
  339. package/src/yjs-testing.d.ts +34 -0
  340. package/src/yjs-testing.ts +7 -0
  341. package/src/yjs.d.ts +109 -0
  342. package/src/yjs.ts +18 -0
  343. package/src/zag.d.ts +2 -0
  344. package/src/zag.ts +8 -1
@@ -0,0 +1,180 @@
1
+ /*
2
+ * The guest kernel: runs INSIDE the QuickJS context, next to the untrusted component.
3
+ *
4
+ * Built as dist/sandbox-kernel.esm.js with 'sygnal' and 'sygnal/host' external; sygnal/vite
5
+ * (`sygnal/sandbox/kernel?raw`) or buildSandboxKernel() bundles it with the app's installed
6
+ * sygnal, xstream and snabbdom into one script (D209: never shipped prebuilt), after the
7
+ * platform prelude (build.ts PLATFORM: timers, console, microtasks; the core's modules read
8
+ * setTimeout when they load). Nothing here is a security boundary: the component runs in the
9
+ * same context and can tamper with all of it. The boundary is the bridge (`__host`), which the
10
+ * page checks (engine.ts, mirror.ts). The kernel only makes the guest side convenient:
11
+ *
12
+ * - the component renders through the real core into a sygnal/host tree (HostDocument);
13
+ * - after each entry the tree goes out as JSON (ids from nodeId; a subtree whose revision didn't
14
+ * move goes out as its bare id), with the event types the app listens for;
15
+ * - events come in as {id, type, init}: the kernel finds the host element, syncs value/checked
16
+ * as a browser would have, dispatches a HostEvent and returns whether it was prevented;
17
+ * - drivers: DOM (host tree), STATE (the core's own), EVENTS (a bus; each event also goes to the
18
+ * page), PROPS (from the page), HTTP (makeFetchDriver over the page's checked fetch).
19
+ */
20
+ import {run, xs, createElement, makeFetchDriver, ABORT, classes, set, toggle, event, debounce, throttle, delay, dropRepeats, sampleCombine, Collection, Switchable} from '../index'
21
+ import {HostDocument, HostEvent, makeHostDriver, nodeId} from '../host'
22
+
23
+ const PROTOCOL = 1
24
+ const G: any = globalThis
25
+ const H: any = G.__host
26
+ const timers: Map<number, () => void> = G.__sandboxTimers
27
+
28
+ // --- the tree going out --------------------------------------------------------------------------
29
+ const doc: any = new HostDocument()
30
+ const byId = new Map<number, any>() // id → host node (events look elements up)
31
+ const sent = new Map<number, number>() // id → revision last sent
32
+ // {i, t, a, s, v, k, c} for an element, {i, x} for a text, a bare id for an unchanged subtree
33
+ function ser(n: any, out: Map<number, any>): any {
34
+ const i = nodeId(n)
35
+ out.set(i, n)
36
+ if (sent.get(i) === n.revision) { mark(n, out); return i }
37
+ sent.set(i, n.revision)
38
+ if (n.nodeType === 3) return {i, x: String(n.textContent)}
39
+ const a: any = {}
40
+ for (const [k, v] of n.attributes) a[k] = v
41
+ const s: any = {}
42
+ for (const k of Object.keys(n.style)) s[k] = String(n.style[k])
43
+ const o: any = {i, t: n.localName, a, c: n.childNodes.map((c: any) => ser(c, out))}
44
+ if (Object.keys(s).length) o.s = s
45
+ if (/^(input|textarea|select)$/.test(n.localName)) { o.v = n.value; o.k = n.checked }
46
+ if (n.localName === 'option' && n.selected) o.k = true
47
+ return o
48
+ }
49
+ // an unchanged subtree's nodes stay addressable
50
+ function mark(n: any, out: Map<number, any>) { if (n.childNodes) for (const c of n.childNodes) { out.set(nodeId(c), c); mark(c, out) } }
51
+
52
+ let dirty = false, lastMsg = '', focused: any = null
53
+ function flushTree() {
54
+ dirty = false
55
+ const out = new Map<number, any>()
56
+ const tree = doc.body.childNodes.map((c: any) => ser(c, out))
57
+ byId.clear()
58
+ for (const [k, v] of out) byId.set(k, v)
59
+ for (const k of [...sent.keys()]) if (!out.has(k)) sent.delete(k)
60
+ // the event types to listen for at the sandbox root (delegated, as the DOM driver does)
61
+ const types = [...new Set([...doc.delegates].map((d: any) => d.type))]
62
+ const msg: any = {v: PROTOCOL, tree, types}
63
+ // focus the guest moved (autoFocus, an ELEMENT focus command); the page applies it only with
64
+ // the focus capability and while focus is inside the box
65
+ const active = doc.activeElement
66
+ if (active && active !== focused && byId.has(nodeId(active))) msg.f = nodeId(active)
67
+ focused = active
68
+ const json = JSON.stringify(msg)
69
+ // nothing moved (a change that patched back to what was sent): no message
70
+ if (json !== lastMsg || tree.some((n: any) => typeof n != 'number')) H.render(lastMsg = json)
71
+ }
72
+ // one render per guest entry: the page calls __sandbox.flush() once the entry's promise jobs ran
73
+ // (a checkbox click changes the tree twice, its checked state, then the patch: one message)
74
+ doc.onChange = () => { dirty = true }
75
+
76
+ // --- events coming in ----------------------------------------------------------------------------
77
+ const EVENT_FIELDS = /^(key|code|shiftKey|ctrlKey|altKey|metaKey|button|clientX|clientY|deltaX|deltaY|detail)$/
78
+ const props$: any = xs.createWithMemory()
79
+ const fetches = new Map<number, {ok: (v: any) => void; fail: (e: any) => void}>()
80
+ let app: any
81
+
82
+ G.__sandbox = {
83
+ v: PROTOCOL,
84
+ // returns 1 when the guest prevented the default action
85
+ dispatch(id: number, type: string, initJson: string) {
86
+ const el = byId.get(id)
87
+ if (!el || el.nodeType !== 1) return 0
88
+ const init = JSON.parse(initJson || '{}')
89
+ if ('value' in init) el.value = init.value
90
+ if ('checked' in init) el.checked = !!init.checked
91
+ if (type === 'focus' || type === 'focusin') doc.activeElement = focused = el
92
+ const e: any = new HostEvent(type, {bubbles: !/^(focus|blur|mouseenter|mouseleave)$/.test(type)})
93
+ for (const k in init) if (EVENT_FIELDS.test(k)) e[k] = init[k]
94
+ return el.dispatchEvent(e) ? 0 : 1
95
+ },
96
+ flush() { if (dirty || doc.activeElement !== focused) flushTree(); return 0 },
97
+ fire(id: number) { const f = timers.get(id); timers.delete(id); if (f) f() },
98
+ resolve(id: number, ok: number, payload: string) {
99
+ const p = fetches.get(id)
100
+ fetches.delete(id)
101
+ if (p) ok ? p.ok(JSON.parse(payload)) : p.fail(new Error(payload))
102
+ },
103
+ props(json: string) { props$.shamefullySendNext(JSON.parse(json)) },
104
+ start(exportName: string) {
105
+ const mod = G.module.exports
106
+ const App = exportName ? mod[exportName] : (mod.default || Object.values(mod).find((v) => typeof v == 'function'))
107
+ if (typeof App != 'function') throw new Error('sandbox: the source exports no component')
108
+ // a root without state never renders (SYG204); generated code often has none
109
+ if (App.initialState === undefined) App.initialState = {}
110
+ wiringCheck(App)
111
+ app = run(App, {
112
+ DOM: makeHostDriver(doc, {onError: (e: any) => console.error('patch', String(e?.message || e))}),
113
+ EVENTS: eventsDriver,
114
+ PROPS: () => ({stream: props$, get: (k?: string) => props$.map((p: any) => (k ? p?.[k] : p))}),
115
+ HTTP: makeFetchDriver({fetch: pageFetch as any}),
116
+ } as any, {useDefaultDrivers: false} as any)
117
+ return 1
118
+ },
119
+ dispose() { app && app.dispose() },
120
+ }
121
+
122
+ // sygnal-check's SYG101 at boot, on the actions the intent really returns: an action with no model
123
+ // entry does nothing and throws nothing (the core's own check is in the dev build only). Model-written
124
+ // code gets this wrong silently, so the kernel says so; a page may treat the line as a failure
125
+ // (compose.generated does). Behavior actions ('page.COMPOSE') belong to `uses`: not checked.
126
+ function wiringCheck(App: any) {
127
+ const intent = App.intent, model = App.model
128
+ if (typeof intent != 'function' || (model != null && typeof model != 'object')) return
129
+ App.intent = function (this: any, ...args: any[]) {
130
+ const out = intent.apply(this, args)
131
+ if (out && typeof out == 'object') {
132
+ for (const k of Object.keys(out)) {
133
+ if (k.includes('.') || (model && Object.prototype.hasOwnProperty.call(model, k))) continue
134
+ console.warn(`[Sygnal SYG101] intent action '${k}' has no model entry, so it never does anything; add '${k}' to model or remove it from intent`)
135
+ }
136
+ }
137
+ return out
138
+ }
139
+ }
140
+
141
+ // --- drivers -------------------------------------------------------------------------------------
142
+ // HTTP: the core's own makeFetchDriver over a Response-shaped capability; the page checks the URL
143
+ let fetchSeq = 0
144
+ const pageFetch = (url: string, init: any = {}) => new Promise((ok, fail) => {
145
+ const id = ++fetchSeq
146
+ fetches.set(id, {ok, fail})
147
+ H.fetch(id, String(url), JSON.stringify({method: init.method || 'GET', headers: init.headers || {}, body: typeof init.body == 'string' ? init.body : undefined}))
148
+ }).then((r: any) => ({
149
+ ok: r.status >= 200 && r.status < 300, status: r.status, statusText: '',
150
+ headers: {get: (k: string) => r.headers[String(k).toLowerCase()] ?? null},
151
+ json: async () => JSON.parse(r.body), text: async () => r.body,
152
+ }))
153
+
154
+ // EVENTS: an in-guest bus (eventBusDriver needs EventTarget); each event also goes to the page,
155
+ // which only delivers the types declared in `emits`, validated by their schemas
156
+ function eventsDriver(out$: any) {
157
+ const bus: any = xs.create()
158
+ out$.addListener({
159
+ next: (ev: any) => {
160
+ bus.shamefullySendNext(ev)
161
+ try { H.emit(JSON.stringify({type: String(ev?.type), data: ev?.data})) } catch (e) { console.error('EVENTS: data must be JSON', String(e)) }
162
+ },
163
+ error: () => {}, complete: () => {},
164
+ })
165
+ return {select: (type?: string | string[]) => bus.filter((ev: any) => !type || ev.type === type || (Array.isArray(type) && type.includes(ev.type))).map((ev: any) => ev.data ?? null)}
166
+ }
167
+
168
+ // what a component may import: `require('sygnal')` (a compiled `import {ABORT} from 'sygnal'`)
169
+ function Fragment() {}
170
+ ;(Fragment as any).__sygnalFragment = true
171
+ const SYGNAL = Object.freeze({xs, createElement, Fragment, ABORT, classes, set, toggle, event, debounce, throttle, delay, dropRepeats, sampleCombine, Collection, Switchable})
172
+ G.require = (name: string) => {
173
+ if (name === 'sygnal' || name === 'sygnal/jsx') return SYGNAL
174
+ throw new Error(`sandbox: '${name}' can't be imported here`)
175
+ }
176
+ // the JSX a host compiles guest code to: __h(tag, props, ...children), __frag
177
+ G.__h = createElement
178
+ G.__frag = Fragment
179
+ G.module = {exports: {}}
180
+ G.exports = G.module.exports
@@ -0,0 +1,269 @@
1
+ /*
2
+ * The page's side of the tree: mirrors the guest's serialized host tree into the real DOM through
3
+ * a sanitizer, and forwards DOM events back.
4
+ *
5
+ * Everything that arrives is untrusted (the guest can send any JSON). The mirror:
6
+ * - creates only allowlisted tags and attributes (no on*, srcdoc, formaction, <script>,
7
+ * <iframe>, <style>, <svg>, …); unknown tags are dropped, never rendered;
8
+ * - checks URLs: <a href> only http(s)/mailto (clicks never navigate by themselves: see
9
+ * bindEvents), <img src> only data:image/* or an allowlisted origin (an image is a request);
10
+ * - filters inline style (no url(), image-set(), expression(), escapes; plain names only);
11
+ * - prefixes ids (and for / aria-* references to them) so the guest can't clobber the page's
12
+ * ids or `window.<id>`; `name` only on form fields; classes are kept (the page's design system
13
+ * styles the guest) unless `classPrefix` is set;
14
+ * - limits nodes, depth and text so a huge tree can't stall the page;
15
+ * - confines layout: the root has `contain: content` (a containing block for position: fixed,
16
+ * paint clipped), `isolation: isolate` and `overflow: hidden` (no overlaying the page).
17
+ * A violation is recorded (violations[]) and the offending part dropped; a malformed tree (a
18
+ * cycle, a node inside itself) throws, and the sandbox is killed.
19
+ */
20
+
21
+ const TAGS = new Set(('div span p a button input textarea select option optgroup label ul ol li h1 h2 h3 h4 h5 h6 strong em b i u s small ' +
22
+ 'code pre kbd br hr img table caption thead tbody tfoot tr td th colgroup col form fieldset legend section article header ' +
23
+ 'footer nav main aside figure figcaption details summary progress meter time abbr blockquote q cite dl dt dd mark sub sup output').split(' '))
24
+ const ATTRS = /^(class|title|role|tabindex|lang|dir|hidden|alt|width|height|colspan|rowspan|scope|open|value|placeholder|checked|selected|disabled|readonly|required|multiple|min|max|step|minlength|maxlength|pattern|size|rows|cols|wrap|autocomplete|spellcheck|start|reversed|datetime|high|low|optimum|label|draggable)$|^aria-[a-z-]+$|^data-[a-z0-9-]+$/
25
+ // no password or file inputs (phishing, file exfiltration)
26
+ const INPUT_TYPES = /^(text|search|email|url|tel|number|range|date|time|datetime-local|month|week|color|checkbox|radio|button|submit|reset|hidden)$/
27
+ const ID_REFS = /^(for|aria-labelledby|aria-describedby|aria-controls|aria-owns|aria-activedescendant|list)$/
28
+ const SAFE_HREF = /^(https?:|mailto:)/i
29
+ const BAD_STYLE = /url\s*\(|image-set|expression|javascript:|\\|@|<|behavior|binding/i
30
+ const FIELD = /^(input|textarea|select|button|output|fieldset)$/
31
+ const CLASS_TOKEN = /^[A-Za-z0-9_-]+$/
32
+
33
+ export interface MirrorPolicy {
34
+ maxNodes: number
35
+ maxDepth: number
36
+ maxText: number
37
+ /** origins `<img src>` may load from (an image is a request: an exfiltration channel) */
38
+ imageOrigins: string[]
39
+ /** prefix every guest class ('sbx-' → `class="sbx-card"`): the page's CSS no longer applies */
40
+ classPrefix: string
41
+ /** the prefix of guest ids (default: a per-sandbox 'sbxN-') */
42
+ idPrefix?: string
43
+ }
44
+ export const DEFAULT_POLICY: MirrorPolicy = {maxNodes: 5000, maxDepth: 128, maxText: 1 << 20, imageOrigins: [], classPrefix: ''}
45
+
46
+ type Violate = (what: string) => void
47
+
48
+ /** one element's attributes and inline style through the allowlist (the mirror and SSR share it) */
49
+ export function sanitize(tag: string, n: any, prefix: string, policy: MirrorPolicy, violate: Violate): {attrs: Map<string, string>; css: string} {
50
+ const attrs = new Map<string, string>()
51
+ const a = n.a && typeof n.a == 'object' ? n.a : {}
52
+ for (const k0 of Object.keys(a)) {
53
+ const k = String(k0).toLowerCase(), v = String(a[k0])
54
+ if (k === 'id') attrs.set('id', prefix + v)
55
+ else if (ID_REFS.test(k)) attrs.set(k, v.split(/\s+/).filter(Boolean).map((x) => prefix + x).join(' '))
56
+ else if (k === 'name') { if (FIELD.test(tag)) attrs.set(k, v); else violate(`name on <${tag}>`) }
57
+ else if (k === 'type') { if (tag !== 'input' || INPUT_TYPES.test(v)) attrs.set(k, v); else violate(`input type ${v}`) }
58
+ else if (k === 'href') { if (tag === 'a' && SAFE_HREF.test(v.trim())) attrs.set(k, v); else violate(`href ${v.slice(0, 40)}`) }
59
+ else if (k === 'src') { if (tag === 'img' && imageOk(v, policy)) attrs.set(k, v); else violate(`src ${v.slice(0, 40)}`) }
60
+ else if (k === 'style') violate('style attribute') // inline style arrives as n.s, filtered below
61
+ else if (k === 'class' && policy.classPrefix) attrs.set(k, v.split(/\s+/).filter((c) => CLASS_TOKEN.test(c)).map((c) => policy.classPrefix + c).join(' '))
62
+ else if (ATTRS.test(k)) attrs.set(k, v)
63
+ else violate(`attribute ${k}`)
64
+ }
65
+ if (tag === 'a' && attrs.has('href')) { attrs.set('rel', 'noopener noreferrer'); attrs.set('target', '_blank') }
66
+ const css: string[] = []
67
+ const s = n.s && typeof n.s == 'object' ? n.s : {}
68
+ for (const k of Object.keys(s)) {
69
+ const name = String(k).replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), v = String(s[k])
70
+ if (!/^-?[a-z][a-z-]*$/.test(name) || BAD_STYLE.test(v)) { violate(`style ${name}`); continue }
71
+ css.push(`${name}: ${v}`)
72
+ }
73
+ return {attrs, css: css.join('; ')}
74
+ }
75
+ function imageOk(v: string, policy: MirrorPolicy) {
76
+ if (/^data:image\/(png|gif|jpeg|webp);base64,/i.test(v)) return true
77
+ try { return policy.imageOrigins.includes(new URL(v).origin) } catch { return false }
78
+ }
79
+
80
+ const VOID = new Set(['br', 'hr', 'img', 'input', 'col'])
81
+ const esc = (s: unknown) => String(s).replace(/[&<>"']/g, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'} as any)[c])
82
+
83
+ /**
84
+ * A render message's tree as HTML (SSR, edge, multi-tenant): the same allowlist and limits as the
85
+ * mirror. A full tree only (no bare ids).
86
+ */
87
+ export function treeToHTML(tree: unknown, opts: {policy?: Partial<MirrorPolicy>; prefix?: string} = {}): {html: string; violations: string[]} {
88
+ const policy = {...DEFAULT_POLICY, ...opts.policy}, prefix = opts.prefix ?? policy.idPrefix ?? 'sbx-'
89
+ const violations: string[] = [], violate = (w: string) => { if (violations.length < 100) violations.push(w) }
90
+ let count = 0, text = 0
91
+ const out = (n: any, depth: number): string => {
92
+ if (depth > policy.maxDepth || ++count > policy.maxNodes) throw new Error('sandbox tree: over the limits')
93
+ if (!n || typeof n != 'object') throw new Error('sandbox tree: malformed node (SSR needs a full tree)')
94
+ if (typeof n.x == 'string') {
95
+ if ((text += n.x.length) > policy.maxText) throw new Error('sandbox tree: too much text')
96
+ return esc(n.x)
97
+ }
98
+ const tag = String(n.t)
99
+ if (!TAGS.has(tag)) { violate(`tag <${tag}>`); return '' }
100
+ const {attrs, css} = sanitize(tag, n, prefix, policy, violate)
101
+ if ('v' in n && tag === 'input') attrs.set('value', String(n.v))
102
+ if (n.k && (tag === 'input' || tag === 'option')) attrs.set(tag === 'option' ? 'selected' : 'checked', '')
103
+ if (css) attrs.set('style', css)
104
+ const open = `<${tag}${[...attrs].map(([k, v]) => ` ${k}="${esc(v)}"`).join('')}>`
105
+ if (VOID.has(tag)) return open
106
+ const inner = tag === 'textarea' ? esc(n.v ?? '') : (Array.isArray(n.c) ? n.c : []).map((c: any) => out(c, depth + 1)).join('')
107
+ return `${open}${inner}</${tag}>`
108
+ }
109
+ return {html: (Array.isArray(tree) ? tree : []).map((n) => out(n, 0)).join(''), violations}
110
+ }
111
+
112
+ let roots = 0
113
+
114
+ export function createMirror(container: Element, opts: {policy?: Partial<MirrorPolicy>; focus?: boolean} = {}) {
115
+ const policy = {...DEFAULT_POLICY, ...opts.policy}
116
+ const doc = container.ownerDocument
117
+ const prefix = policy.idPrefix ?? `sbx${++roots}-`
118
+ const root = doc.createElement('div')
119
+ root.setAttribute('data-sandbox-root', '')
120
+ root.style.cssText = 'contain: content; isolation: isolate; position: relative; overflow: hidden'
121
+ container.appendChild(root)
122
+
123
+ const nodes = new Map<number, Node>() // guest id → real node
124
+ const idOf = new WeakMap<Node, number>() // real node → guest id
125
+ const violations: string[] = []
126
+ const violate = (what: string) => { if (violations.length < 100) violations.push(what) }
127
+ let count = 0, text = 0, alive = new Set<number>()
128
+
129
+ const keepSubtree = (n: Node) => {
130
+ const i = idOf.get(n)
131
+ if (i !== undefined) alive.add(i)
132
+ n.childNodes.forEach(keepSubtree)
133
+ }
134
+
135
+ function build(n: any, depth: number): Node | null {
136
+ if (depth > policy.maxDepth) throw new Error('sandbox tree: too deep')
137
+ if (++count > policy.maxNodes) throw new Error('sandbox tree: too many nodes')
138
+ if (typeof n == 'number') {
139
+ const real = nodes.get(n)
140
+ if (!real) return null // dropped by the sanitizer when it was sent
141
+ keepSubtree(real)
142
+ return real
143
+ }
144
+ if (!n || typeof n != 'object' || typeof n.i != 'number') throw new Error('sandbox tree: malformed node')
145
+ alive.add(n.i)
146
+ if (typeof n.x == 'string') {
147
+ if ((text += n.x.length) > policy.maxText) throw new Error('sandbox tree: too much text')
148
+ let t = nodes.get(n.i) as Text | undefined
149
+ if (!t || t.nodeType !== 3) { t = doc.createTextNode(n.x); nodes.set(n.i, t); idOf.set(t, n.i) }
150
+ else if (t.data !== n.x) t.data = n.x
151
+ return t
152
+ }
153
+ const tag = String(n.t)
154
+ if (!TAGS.has(tag)) { violate(`tag <${tag}>`); return null }
155
+ let el = nodes.get(n.i) as HTMLElement | undefined
156
+ if (!el || el.localName !== tag) { el = doc.createElement(tag); nodes.set(n.i, el); idOf.set(el, n.i) }
157
+ const {attrs: want, css} = sanitize(tag, n, prefix, policy, violate)
158
+ for (const a of [...el.attributes]) if (a.name !== 'style' && !want.has(a.name)) el.removeAttribute(a.name)
159
+ for (const [k, v] of want) if (el.getAttribute(k) !== v) el.setAttribute(k, v)
160
+ if ((el.getAttribute('style') || '') !== css) css ? el.setAttribute('style', css) : el.removeAttribute('style')
161
+ // field state the guest controls
162
+ const f = el as any
163
+ if ('v' in n && f.value !== String(n.v)) f.value = String(n.v)
164
+ if ('k' in n && (tag === 'input' || tag === 'option')) tag === 'option' ? (f.selected = !!n.k) : (f.checked = !!n.k)
165
+ const kids: Node[] = []
166
+ for (const c of Array.isArray(n.c) ? n.c : []) { const r = build(c, depth + 1); if (r) kids.push(r) }
167
+ for (let i = 0; i < kids.length; i++) {
168
+ if (kids[i].contains(el)) throw new Error('sandbox tree: a node inside itself')
169
+ if (el.childNodes[i] !== kids[i]) el.insertBefore(kids[i], el.childNodes[i] || null)
170
+ }
171
+ while (el.childNodes.length > kids.length) el.removeChild(el.lastChild!)
172
+ return el
173
+ }
174
+
175
+ return {
176
+ root, violations, prefix,
177
+ /** apply one render's tree; throws on a malformed one */
178
+ apply(tree: unknown) {
179
+ count = 0; text = 0; alive = new Set()
180
+ const top: Node[] = []
181
+ for (const n of Array.isArray(tree) ? tree : []) { const r = build(n, 0); if (r) top.push(r) }
182
+ for (let i = 0; i < top.length; i++) {
183
+ if (root.childNodes[i] !== top[i]) root.insertBefore(top[i], root.childNodes[i] || null)
184
+ }
185
+ while (root.childNodes.length > top.length) root.removeChild(root.lastChild!)
186
+ for (const k of [...nodes.keys()]) if (!alive.has(k)) nodes.delete(k)
187
+ },
188
+ /**
189
+ * The guest moved focus (README §9 Q5): only with the focus capability, and only while the
190
+ * page's focus is already inside the box. A guest can manage focus in its own dialog, never
191
+ * take it from the page.
192
+ */
193
+ focus(id: number) {
194
+ const el = nodes.get(id) as HTMLElement | undefined
195
+ if (!opts.focus) return void violate('focus (no focus capability)')
196
+ if (!el || el.nodeType !== 1 || !root.contains(doc.activeElement) || doc.activeElement === el) return
197
+ el.focus?.()
198
+ },
199
+ /** the guest id of a real node, or of its nearest mirrored ancestor inside the root */
200
+ idFor(target: any): number | undefined {
201
+ for (let n = target; n && n !== root; n = n.parentNode) {
202
+ const i = idOf.get(n)
203
+ if (i !== undefined) return i
204
+ }
205
+ return undefined
206
+ },
207
+ clear() { root.remove(); nodes.clear() },
208
+ }
209
+ }
210
+ export type Mirror = ReturnType<typeof createMirror>
211
+
212
+ const FORWARD = /^(click|dblclick|input|change|submit|keydown|keyup|focus|blur|focusin|focusout|pointerdown|pointerup|mouseenter|mouseleave)$/
213
+ const NO_BUBBLE = /^(focus|blur|mouseenter|mouseleave)$/
214
+ const STOP = ('click dblclick auxclick contextmenu mousedown mouseup mouseover mouseout pointerdown pointerup pointerover pointerout ' +
215
+ 'input change submit reset keydown keyup keypress beforeinput focusin focusout touchstart touchend wheel select').split(' ')
216
+ const KEYS = ['key', 'code', 'shiftKey', 'ctrlKey', 'altKey', 'metaKey', 'button', 'clientX', 'clientY']
217
+
218
+ /**
219
+ * DOM events → the guest: one listener at the mirror's root per type the guest listens for
220
+ * (delegation, as the DOM driver does), with the event's plain fields and the target's value /
221
+ * checked. `dispatch` returns whether the guest prevented the default (in-thread: synchronous;
222
+ * a worker answers too late, so only declared keys are prevented). A form submit and a link
223
+ * click never act on the page by themselves: an unprevented link click goes to `navigate(href)`.
224
+ */
225
+ export function bindEvents(mirror: Mirror, dispatch: (id: number, type: string, init: any) => boolean, navigate?: (href: string) => void) {
226
+ const root = mirror.root, on = new Map<string, [EventListener, boolean]>()
227
+ const add = (key: string, l: EventListener, capture = false) => {
228
+ on.set(key, [l, capture])
229
+ root.addEventListener(key.replace(/^\$/, ''), l, capture)
230
+ }
231
+ function onEvent(e: any) {
232
+ const id = mirror.idFor(e.target)
233
+ if (id === undefined) return
234
+ const t = e.target, init: any = {}
235
+ for (const k of KEYS) if (e[k] !== undefined) init[k] = e[k]
236
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) {
237
+ init.value = t.value
238
+ if (t.type === 'checkbox' || t.type === 'radio') init.checked = t.checked
239
+ }
240
+ if (dispatch(id, e.type, init)) e.preventDefault()
241
+ }
242
+ add('$submit', (e) => e.preventDefault())
243
+ // The guest's elements are not the host app's: its delegated intents (DOM.select('.danger'),
244
+ // bubbling at the app root) must never see a sandboxed element's events, or a guest could render
245
+ // a class the host listens to and have the user trigger a host action. The page's own capture
246
+ // listeners still see them.
247
+ for (const t of STOP) add('$' + t, (e) => e.stopPropagation())
248
+ const link = (e: any) => {
249
+ const a = e.target?.closest?.('a[href]')
250
+ if (!a || !root.contains(a)) return
251
+ const was = e.defaultPrevented
252
+ e.preventDefault()
253
+ if (!was) navigate?.(a.getAttribute('href'))
254
+ }
255
+ add('$click', link)
256
+ return {
257
+ listen(types: string[]) {
258
+ for (const t of types) if (!on.has(t) && FORWARD.test(t)) add(t, onEvent, NO_BUBBLE.test(t))
259
+ // the link check runs after the guest's own click (whose preventDefault it honours): last
260
+ if (types.includes('click')) { root.removeEventListener('click', link); root.addEventListener('click', link) }
261
+ },
262
+ detach() {
263
+ for (const [t, [l, c]] of on) root.removeEventListener(t.replace(/^\$/, ''), l, c)
264
+ root.removeEventListener('click', link)
265
+ on.clear()
266
+ },
267
+ }
268
+ }
269
+ export type EventBinding = ReturnType<typeof bindEvents>
@@ -0,0 +1,128 @@
1
+ /*
2
+ * sygnal/sandbox: what the page and the guest kernel agree on (research: dev-plans/research/sandbox).
3
+ *
4
+ * The protocol is internal and versioned: a kernel built from another Sygnal version is refused
5
+ * at boot ('protocol'), never half understood. It is the part a native or edge host would
6
+ * reimplement:
7
+ *
8
+ * page → guest (QuickJS entries on the kernel's `__sandbox`):
9
+ * dispatch(id, type, initJson) → 1 when prevented fire(timerId) resolve(fetchId, ok, json)
10
+ * props(json) start(exportName) flush() v (the protocol number)
11
+ * guest → page (host functions on `__host`, each checked):
12
+ * render(json) {v, tree, types, f?}: tree = [node], node = {i, t, a, s?, v?, k?, c} | {i, x} | id
13
+ * (an id alone: an unchanged subtree), types = DOM event types to forward,
14
+ * f = the id the guest moved focus to
15
+ * emit(json) {type, data} log(level, text)
16
+ * timer(id, ms) (ms < 0: setImmediate) clearTimer(id) fetch(id, url, initJson)
17
+ * page ↔ worker (postMessage; `isolation: 'worker'`):
18
+ * → init {v, kernel, source, props, limits, fetch} event {id, type, init} props {p} ping fetched {id, ok, res|error}
19
+ * ← ready {v, wasmMs, bootMs} render {json} emit {e} log {l, x} fetch {id, url, init} pong error {reason, message}
20
+ */
21
+ export const PROTOCOL = 1
22
+
23
+ /** Why a sandbox was killed (`SandboxError.reason`) */
24
+ export type SandboxReason = 'timeout' | 'memory' | 'cpu' | 'protocol' | 'error' | 'crash'
25
+
26
+ export class SandboxError extends Error {
27
+ reason: SandboxReason
28
+ constructor(reason: SandboxReason, message: string) {
29
+ super(message)
30
+ this.name = 'SandboxError'
31
+ this.reason = reason
32
+ }
33
+ }
34
+
35
+ export interface SandboxLimits {
36
+ memoryBytes: number
37
+ stackBytes: number
38
+ maxWasmBytes: number
39
+ bootMs: number
40
+ callMs: number
41
+ cpuShare: number
42
+ hardMs: number
43
+ maxTimers: number
44
+ minTimerMs: number
45
+ maxFetches: number
46
+ maxMessageBytes: number
47
+ maxLogs: number
48
+ }
49
+
50
+ // README §8: an instance per sandbox capped at 64 MB, heap 32 MB, stack 256 KB, boot 1 s, 100 ms per entry, CPU 50 %
51
+ export const DEFAULT_LIMITS: SandboxLimits = {
52
+ memoryBytes: 32 << 20, // QuickJS's heap (the kernel alone takes ~4 MB)
53
+ // QuickJS's own stack check must fire before the host engine's: at 512 KB a guest recursion
54
+ // overflows V8's native stack first, a host RangeError unwinds the WASM mid-call, and freeing
55
+ // that runtime aborts the whole module (README §5, finding 3). 384 KB and below are caught.
56
+ stackBytes: 256 << 10,
57
+ // the instance's whole linear memory (heap, ArrayBuffers, C stack): setMemoryLimit() doesn't
58
+ // count ArrayBuffer storage (README finding 1), a capped WebAssembly.Memory does
59
+ maxWasmBytes: 64 << 20,
60
+ bootMs: 1000, // kernel + component + first render
61
+ callMs: 100, // one event, timer or fetch reply, with the flush it causes
62
+ cpuShare: 0.5, // guest time per rolling second
63
+ hardMs: 1000, // worker: the watchdog terminates a worker silent this long
64
+ maxTimers: 64,
65
+ minTimerMs: 4,
66
+ maxFetches: 4, // in flight
67
+ maxMessageBytes: 1 << 20,
68
+ maxLogs: 100,
69
+ }
70
+ /** above this, a guest recursion can overflow the host's stack before QuickJS's check (a crash) */
71
+ export const MAX_STACK_BYTES = 384 << 10
72
+
73
+ export function limitsOf(l?: Partial<SandboxLimits>): SandboxLimits {
74
+ const out = {...DEFAULT_LIMITS}
75
+ for (const k of Object.keys(l || {}) as (keyof SandboxLimits)[]) {
76
+ if (!(k in DEFAULT_LIMITS)) continue
77
+ const v = Number((l as any)[k])
78
+ if (!(v > 0)) throw new TypeError(`sygnal/sandbox: limits.${k} must be a positive number`)
79
+ out[k] = v
80
+ }
81
+ if (out.stackBytes > MAX_STACK_BYTES) throw new RangeError(`sygnal/sandbox: limits.stackBytes over ${MAX_STACK_BYTES} can crash the WASM module instead of throwing in the guest`)
82
+ return out
83
+ }
84
+
85
+ export interface FetchCapability {
86
+ /** URL prefixes ('https://api.example.com/v1/': same origin, a path under it) or RegExps on the href */
87
+ allow: (string | RegExp)[]
88
+ /** default ['GET'] */
89
+ methods?: string[]
90
+ /** resolves relative URLs */
91
+ base?: string
92
+ /** default: globalThis.fetch */
93
+ impl?: (url: string, init: RequestInit) => Promise<Response>
94
+ /** default: limits.maxMessageBytes */
95
+ maxBodyBytes?: number
96
+ }
97
+
98
+ /** an allowlist entry is a URL prefix: same origin, path under its path */
99
+ export function allowed(u: URL, allow: (string | RegExp)[]): boolean {
100
+ if (u.username || u.password) return false
101
+ return allow.some((a) => {
102
+ if (a instanceof RegExp) return a.test(u.href)
103
+ const p = new URL(a)
104
+ return u.origin === p.origin && u.pathname.startsWith(p.pathname)
105
+ })
106
+ }
107
+
108
+ /**
109
+ * The request the page makes for a guest (both tiers): the allowlist, the methods, accept /
110
+ * content-type headers only, `credentials: 'omit'` (never the user's cookies), `redirect: 'error'`
111
+ * (a redirect could leave the allowlist), the response size capped. Resolves with a plain
112
+ * {status, headers, body} or rejects with the reason.
113
+ */
114
+ export async function capFetch(f: FetchCapability | undefined, url: string, init: any, maxBytes: number, signal?: AbortSignal) {
115
+ if (!f) throw new Error('no fetch capability')
116
+ let u: URL
117
+ try { u = f.base ? new URL(url, f.base) : new URL(url) } catch { throw new Error(`bad URL ${String(url).slice(0, 80)}`) }
118
+ if (!allowed(u, f.allow || [])) throw new Error(`${u.origin}${u.pathname} is not allowed`)
119
+ const method = String(init?.method || 'GET').toUpperCase()
120
+ if (!(f.methods || ['GET']).map((m) => m.toUpperCase()).includes(method)) throw new Error(`method ${method} is not allowed`)
121
+ const headers: Record<string, string> = {}
122
+ for (const [k, v] of Object.entries(init?.headers || {})) if (/^(accept|content-type)$/i.test(k)) headers[k] = String(v)
123
+ const body = typeof init?.body == 'string' && method !== 'GET' && method !== 'HEAD' ? init.body : undefined
124
+ const r = await (f.impl || globalThis.fetch)(u.href, {method, headers, body, credentials: 'omit', redirect: 'error', signal})
125
+ const text = await r.text()
126
+ if (text.length > (f.maxBodyBytes || maxBytes)) throw new Error('response too large')
127
+ return {status: r.status, headers: {'content-type': r.headers.get('content-type') || ''}, body: text}
128
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * The QuickJS WASM module: `quickjs-emscripten-core` + `@jitl/quickjs-wasmfile-release-sync`,
3
+ * optional peer dependencies (D209), loaded on first use. Stock packages only (README §9 Q6):
4
+ * a quickjs-ng variant can be passed as `variant`.
5
+ */
6
+ import {SandboxError} from './protocol'
7
+
8
+ let compiled: Promise<WebAssembly.Module> | null = null
9
+ let shared: Promise<any> | null = null
10
+
11
+ export interface LoadQuickJSOptions {
12
+ /** the whole linear memory of a fresh instance capped at this (heap, ArrayBuffers, C stack) */
13
+ maxWasmBytes?: number
14
+ /** a shared module (one per page) instead of a fresh instance: no ArrayBuffer cap, and one crash poisons it for everyone */
15
+ shared?: boolean
16
+ /** another variant (default '@jitl/quickjs-wasmfile-release-sync') */
17
+ variant?: any
18
+ /** the .wasm bytes or a compiled WebAssembly.Module (default: the variant's own file) */
19
+ wasm?: ArrayBuffer | Uint8Array | WebAssembly.Module
20
+ }
21
+
22
+ const isNode = () => typeof (globalThis as any).process == 'object' && !!(globalThis as any).process.versions?.node && typeof (globalThis as any).window == 'undefined'
23
+
24
+ /**
25
+ * A QuickJS module. By default a fresh instance whose own `WebAssembly.Memory` is capped at
26
+ * `maxWasmBytes` (64 MB): QuickJS's `setMemoryLimit()` doesn't count ArrayBuffer storage, so
27
+ * without the cap a guest can grow the instance to 2 GB (README finding 1). Instances share
28
+ * one compiled `WebAssembly.Module` (compiled once per page).
29
+ */
30
+ export async function loadQuickJS(opts: LoadQuickJSOptions = {}): Promise<any> {
31
+ if (opts.shared && !opts.wasm && !opts.variant && shared) return shared
32
+ const p = load(opts)
33
+ if (opts.shared && !opts.wasm && !opts.variant) shared = p
34
+ return p
35
+ }
36
+
37
+ async function load({maxWasmBytes = 64 << 20, shared: sh, variant, wasm}: LoadQuickJSOptions): Promise<any> {
38
+ let core: any, base: any
39
+ try {
40
+ core = await import('quickjs-emscripten-core')
41
+ base = variant || (await import('@jitl/quickjs-wasmfile-release-sync')).default
42
+ } catch (e: any) {
43
+ throw new Error("sygnal/sandbox needs quickjs-emscripten-core and @jitl/quickjs-wasmfile-release-sync (optional peer dependencies): npm install quickjs-emscripten-core @jitl/quickjs-wasmfile-release-sync (" + (e?.message || e) + ')')
44
+ }
45
+ const custom: any = {}
46
+ if (wasm) custom.wasmModule = wasm instanceof WebAssembly.Module ? wasm : await WebAssembly.compile(wasm as any)
47
+ else if (!variant && isNode()) custom.wasmModule = await (compiled ??= nodeWasm())
48
+ // a browser without `wasm` lets the variant fetch its .wasm itself (the bundler emits the file)
49
+ if (!sh) custom.wasmMemory = new WebAssembly.Memory({initial: 256, maximum: Math.max(256, Math.ceil(maxWasmBytes / 65536))})
50
+ return core.newQuickJSWASMModuleFromVariant(Object.keys(custom).length ? core.newVariant(base, custom) : base)
51
+ }
52
+
53
+ async function nodeWasm(): Promise<WebAssembly.Module> {
54
+ const [{readFile}, {createRequire}] = await Promise.all([nodeImport('node:fs/promises'), nodeImport('node:module')])
55
+ // next to sygnal (a peer), else from the app's root
56
+ let file: string
57
+ try { file = createRequire(import.meta.url).resolve('@jitl/quickjs-wasmfile-release-sync/wasm') } catch {
58
+ file = createRequire((globalThis as any).process.cwd() + '/').resolve('@jitl/quickjs-wasmfile-release-sync/wasm')
59
+ }
60
+ return WebAssembly.compile(await readFile(file))
61
+ }
62
+
63
+ // Node built-ins by a name bundlers don't follow (a browser bundle never loads them)
64
+ export const nodeImport = (name: string): Promise<any> => import(/* @vite-ignore */ /* webpackIgnore: true */ name)
65
+
66
+ /**
67
+ * Modules a guest crashed: the host's stack overflowed or the WASM trapped in the middle of a
68
+ * QuickJS call, so its C state was unwound without cleanup. createSandbox refuses them.
69
+ */
70
+ export const poisoned = new WeakSet<object>()
71
+ export const checkModule = (QJS: any) => {
72
+ if (!QJS || typeof QJS.newRuntime != 'function') throw new TypeError('sygnal/sandbox: quickjs must be a module from loadQuickJS()')
73
+ if (poisoned.has(QJS)) throw new SandboxError('crash', 'this QuickJS module crashed earlier; load a fresh one')
74
+ }