sygnal 7.0.0 → 7.2.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/CHANGELOG.md +632 -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 +73 -45
  15. package/dist/astro/server.cjs.js.map +1 -1
  16. package/dist/astro/server.mjs +73 -45
  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 +224 -20
  27. package/dist/diagnostics.cjs.js.map +1 -1
  28. package/dist/diagnostics.esm.js +224 -20
  29. package/dist/diagnostics.esm.js.map +1 -1
  30. package/dist/guide/accessibility.md +1 -1
  31. package/dist/guide/ai-compose.md +150 -0
  32. package/dist/guide/behaviors.md +2 -1
  33. package/dist/guide/collaborative-state.md +265 -0
  34. package/dist/guide/element-commands.md +36 -2
  35. package/dist/guide/forms-reference.md +5 -5
  36. package/dist/guide/forms.md +3 -2
  37. package/dist/guide/http.md +28 -1
  38. package/dist/guide/islands.md +240 -0
  39. package/dist/guide/recipes/code-editor.md +1 -1
  40. package/dist/guide/renderers/agent-ui.md +138 -0
  41. package/dist/guide/renderers/custom-targets.md +126 -0
  42. package/dist/guide/renderers/overview.md +120 -0
  43. package/dist/guide/renderers/react-native.md +128 -0
  44. package/dist/guide/renderers/terminal.md +306 -0
  45. package/dist/guide/renderers/three.md +332 -0
  46. package/dist/guide/renderers/write-once.md +137 -0
  47. package/dist/guide/resources.md +2 -0
  48. package/dist/guide/sandboxed-components.md +223 -0
  49. package/dist/guide/timers.md +32 -5
  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 +2181 -0
  63. package/dist/host.cjs.js.map +1 -0
  64. package/dist/host.esm.js +2146 -0
  65. package/dist/host.esm.js.map +1 -0
  66. package/dist/index.cjs.js +3948 -586
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +584 -44
  69. package/dist/index.esm.js +3918 -586
  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 +37 -35
  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 +12 -4
  180. package/src/core/debug.ts +8 -3
  181. package/src/core/hooks.ts +16 -2
  182. package/src/core/hosts/collection.ts +1 -1
  183. package/src/core/hosts/switchable.ts +26 -8
  184. package/src/core/instance.ts +98 -45
  185. package/src/core/markers/portal.ts +4 -0
  186. package/src/core/registry.ts +4 -0
  187. package/src/core/runtime.ts +34 -25
  188. package/src/core/statics.ts +5 -5
  189. package/src/cycle/dom/EventDelegator.ts +37 -33
  190. package/src/cycle/dom/MainDOMSource.ts +16 -14
  191. package/src/cycle/dom/enrichEventStream.ts +13 -9
  192. package/src/cycle/dom/fromEvent.ts +6 -10
  193. package/src/cycle/dom/index.ts +11 -7
  194. package/src/cycle/dom/isolate.ts +13 -6
  195. package/src/cycle/dom/makeDOMDriver.ts +6 -2
  196. package/src/cycle/dom/utils.ts +9 -2
  197. package/src/cycle/run/adapt.ts +4 -12
  198. package/src/devtools.d.ts +9 -0
  199. package/src/devtools.ts +2 -1
  200. package/src/extra/ai/compose/catalog.ts +2 -1
  201. package/src/extra/ai/compose/decisions.ts +1 -2
  202. package/src/extra/ai/compose/generated.ts +234 -0
  203. package/src/extra/ai/compose/index.ts +6 -2
  204. package/src/extra/ai/compose/layout.ts +3 -1
  205. package/src/extra/ai/compose/llm.ts +7 -3
  206. package/src/extra/ai/compose/types.ts +2 -2
  207. package/src/extra/ai/transports/shared.ts +3 -1
  208. package/src/extra/api/contract.ts +485 -0
  209. package/src/extra/api/index.ts +26 -0
  210. package/src/extra/behaviors.ts +3 -3
  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/index.ts +1 -1
  220. package/src/extra/diagnostics/checks/next.ts +78 -4
  221. package/src/extra/diagnostics/checks/props.ts +6 -1
  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 +51 -1
  226. package/src/extra/elementCommands.ts +20 -4
  227. package/src/extra/fetchDriver.ts +4 -1
  228. package/src/extra/fetchEventSource.ts +6 -2
  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/replay/address.ts +49 -0
  238. package/src/extra/replay/entropy.ts +99 -0
  239. package/src/extra/replay/format.ts +263 -0
  240. package/src/extra/replay/index.ts +8 -0
  241. package/src/extra/replay/inert.ts +22 -0
  242. package/src/extra/replay/recorder.ts +247 -0
  243. package/src/extra/replay/replayer.ts +234 -0
  244. package/src/extra/replay/report.ts +126 -0
  245. package/src/extra/replay/schema.ts +52 -0
  246. package/src/extra/replay/version.ts +3 -0
  247. package/src/extra/router.ts +7 -3
  248. package/src/extra/selection.ts +2 -0
  249. package/src/extra/socketDriver.ts +51 -6
  250. package/src/extra/ssr.ts +128 -42
  251. package/src/extra/testing.ts +295 -39
  252. package/src/extra/timers.ts +96 -7
  253. package/src/extra/undo.ts +2 -0
  254. package/src/extra/virtual.ts +25 -2
  255. package/src/extra/widget.ts +106 -17
  256. package/src/host/actions.ts +383 -0
  257. package/src/host/delegation.ts +101 -0
  258. package/src/host/dom.ts +553 -0
  259. package/src/host/driver.ts +185 -0
  260. package/src/host/selector.ts +119 -0
  261. package/src/host/style.ts +51 -0
  262. package/src/host.d.ts +298 -0
  263. package/src/host.ts +46 -0
  264. package/src/index.d.ts +302 -38
  265. package/src/index.ts +15 -3
  266. package/src/pragma/blocks.ts +211 -0
  267. package/src/pragma/index.ts +44 -10
  268. package/src/primitives.d.ts +54 -0
  269. package/src/primitives.ts +136 -0
  270. package/src/react-native.d.ts +57 -0
  271. package/src/react-native.ts +285 -0
  272. package/src/react-peers.d.ts +21 -3
  273. package/src/replay.d.ts +185 -0
  274. package/src/replay.ts +6 -0
  275. package/src/sandbox/build.ts +119 -0
  276. package/src/sandbox/create.ts +187 -0
  277. package/src/sandbox/engine.ts +267 -0
  278. package/src/sandbox/generated.ts +195 -0
  279. package/src/sandbox/kernel.ts +180 -0
  280. package/src/sandbox/mirror.ts +269 -0
  281. package/src/sandbox/protocol.ts +128 -0
  282. package/src/sandbox/quickjs.ts +74 -0
  283. package/src/sandbox/ssr.ts +41 -0
  284. package/src/sandbox/widget.ts +60 -0
  285. package/src/sandbox/worker-entry.ts +59 -0
  286. package/src/sandbox/worker.ts +137 -0
  287. package/src/sandbox-build.d.ts +12 -0
  288. package/src/sandbox-build.ts +6 -0
  289. package/src/sandbox.d.ts +299 -0
  290. package/src/sandbox.ts +25 -0
  291. package/src/shared.ts +12 -0
  292. package/src/terminal/paint.ts +712 -0
  293. package/src/terminal.d.ts +167 -0
  294. package/src/terminal.ts +530 -0
  295. package/src/three/assets.ts +170 -0
  296. package/src/three/host.ts +36 -0
  297. package/src/three/scene.ts +217 -0
  298. package/src/three/target.ts +252 -0
  299. package/src/three.d.ts +221 -0
  300. package/src/three.ts +30 -0
  301. package/src/ui/accordion.ts +21 -7
  302. package/src/ui/dialog.ts +192 -90
  303. package/src/ui/disclosure.ts +2 -0
  304. package/src/ui/dom.ts +30 -0
  305. package/src/ui/layers.ts +132 -0
  306. package/src/ui/popover.ts +120 -21
  307. package/src/ui/shared.ts +98 -12
  308. package/src/ui/stack.ts +18 -0
  309. package/src/ui/tabs.ts +2 -0
  310. package/src/ui/toaster.ts +16 -15
  311. package/src/ui/tooltip.ts +24 -8
  312. package/src/ui/tooltips.ts +26 -14
  313. package/src/ui/zag/combobox.ts +180 -13
  314. package/src/ui/zag/menu.ts +97 -17
  315. package/src/ui/zag/select.ts +15 -4
  316. package/src/ui/zag/shared.ts +60 -8
  317. package/src/ui-combobox.d.ts +16 -1
  318. package/src/ui-menu.d.ts +22 -7
  319. package/src/ui-zag-types.d.ts +13 -5
  320. package/src/ui.d.ts +62 -7
  321. package/src/ui.ts +2 -2
  322. package/src/vite/api.ts +186 -0
  323. package/src/vite/compile.ts +280 -0
  324. package/src/vite/plugin.d.ts +34 -0
  325. package/src/vite/plugin.ts +78 -4
  326. package/src/vite/sandbox.ts +16 -0
  327. package/src/worker/page.ts +396 -0
  328. package/src/worker/protocol.ts +48 -0
  329. package/src/worker/target.ts +282 -0
  330. package/src/worker-page.d.ts +42 -0
  331. package/src/worker-page.ts +12 -0
  332. package/src/worker.d.ts +65 -0
  333. package/src/worker.ts +16 -0
  334. package/src/yjs/driver.ts +253 -0
  335. package/src/yjs/mapping.ts +389 -0
  336. package/src/yjs/shared.ts +138 -0
  337. package/src/yjs/testing.ts +91 -0
  338. package/src/yjs-testing.d.ts +34 -0
  339. package/src/yjs-testing.ts +7 -0
  340. package/src/yjs.d.ts +109 -0
  341. package/src/yjs.ts +18 -0
  342. package/src/zag.d.ts +2 -0
  343. package/src/zag.ts +8 -1
@@ -0,0 +1,1406 @@
1
+ import { defineWidget } from 'sygnal';
2
+
3
+ /*
4
+ * sygnal/sandbox: what the page and the guest kernel agree on (research: dev-plans/research/sandbox).
5
+ *
6
+ * The protocol is internal and versioned: a kernel built from another Sygnal version is refused
7
+ * at boot ('protocol'), never half understood. It is the part a native or edge host would
8
+ * reimplement:
9
+ *
10
+ * page → guest (QuickJS entries on the kernel's `__sandbox`):
11
+ * dispatch(id, type, initJson) → 1 when prevented fire(timerId) resolve(fetchId, ok, json)
12
+ * props(json) start(exportName) flush() v (the protocol number)
13
+ * guest → page (host functions on `__host`, each checked):
14
+ * render(json) {v, tree, types, f?}: tree = [node], node = {i, t, a, s?, v?, k?, c} | {i, x} | id
15
+ * (an id alone: an unchanged subtree), types = DOM event types to forward,
16
+ * f = the id the guest moved focus to
17
+ * emit(json) {type, data} log(level, text)
18
+ * timer(id, ms) (ms < 0: setImmediate) clearTimer(id) fetch(id, url, initJson)
19
+ * page ↔ worker (postMessage; `isolation: 'worker'`):
20
+ * → init {v, kernel, source, props, limits, fetch} event {id, type, init} props {p} ping fetched {id, ok, res|error}
21
+ * ← ready {v, wasmMs, bootMs} render {json} emit {e} log {l, x} fetch {id, url, init} pong error {reason, message}
22
+ */
23
+ const PROTOCOL = 1;
24
+ class SandboxError extends Error {
25
+ constructor(reason, message) {
26
+ super(message);
27
+ this.name = 'SandboxError';
28
+ this.reason = reason;
29
+ }
30
+ }
31
+ // 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 %
32
+ const DEFAULT_LIMITS = {
33
+ memoryBytes: 32 << 20, // QuickJS's heap (the kernel alone takes ~4 MB)
34
+ // QuickJS's own stack check must fire before the host engine's: at 512 KB a guest recursion
35
+ // overflows V8's native stack first, a host RangeError unwinds the WASM mid-call, and freeing
36
+ // that runtime aborts the whole module (README §5, finding 3). 384 KB and below are caught.
37
+ stackBytes: 256 << 10,
38
+ // the instance's whole linear memory (heap, ArrayBuffers, C stack): setMemoryLimit() doesn't
39
+ // count ArrayBuffer storage (README finding 1), a capped WebAssembly.Memory does
40
+ maxWasmBytes: 64 << 20,
41
+ bootMs: 1000, // kernel + component + first render
42
+ callMs: 100, // one event, timer or fetch reply, with the flush it causes
43
+ cpuShare: 0.5, // guest time per rolling second
44
+ hardMs: 1000, // worker: the watchdog terminates a worker silent this long
45
+ maxTimers: 64,
46
+ minTimerMs: 4,
47
+ maxFetches: 4, // in flight
48
+ maxMessageBytes: 1 << 20,
49
+ maxLogs: 100,
50
+ };
51
+ /** above this, a guest recursion can overflow the host's stack before QuickJS's check (a crash) */
52
+ const MAX_STACK_BYTES = 384 << 10;
53
+ function limitsOf(l) {
54
+ const out = { ...DEFAULT_LIMITS };
55
+ for (const k of Object.keys(l || {})) {
56
+ if (!(k in DEFAULT_LIMITS))
57
+ continue;
58
+ const v = Number(l[k]);
59
+ if (!(v > 0))
60
+ throw new TypeError(`sygnal/sandbox: limits.${k} must be a positive number`);
61
+ out[k] = v;
62
+ }
63
+ if (out.stackBytes > MAX_STACK_BYTES)
64
+ throw new RangeError(`sygnal/sandbox: limits.stackBytes over ${MAX_STACK_BYTES} can crash the WASM module instead of throwing in the guest`);
65
+ return out;
66
+ }
67
+ /** an allowlist entry is a URL prefix: same origin, path under its path */
68
+ function allowed(u, allow) {
69
+ if (u.username || u.password)
70
+ return false;
71
+ return allow.some((a) => {
72
+ if (a instanceof RegExp)
73
+ return a.test(u.href);
74
+ const p = new URL(a);
75
+ return u.origin === p.origin && u.pathname.startsWith(p.pathname);
76
+ });
77
+ }
78
+ /**
79
+ * The request the page makes for a guest (both tiers): the allowlist, the methods, accept /
80
+ * content-type headers only, `credentials: 'omit'` (never the user's cookies), `redirect: 'error'`
81
+ * (a redirect could leave the allowlist), the response size capped. Resolves with a plain
82
+ * {status, headers, body} or rejects with the reason.
83
+ */
84
+ async function capFetch(f, url, init, maxBytes, signal) {
85
+ if (!f)
86
+ throw new Error('no fetch capability');
87
+ let u;
88
+ try {
89
+ u = f.base ? new URL(url, f.base) : new URL(url);
90
+ }
91
+ catch {
92
+ throw new Error(`bad URL ${String(url).slice(0, 80)}`);
93
+ }
94
+ if (!allowed(u, f.allow || []))
95
+ throw new Error(`${u.origin}${u.pathname} is not allowed`);
96
+ const method = String(init?.method || 'GET').toUpperCase();
97
+ if (!(f.methods || ['GET']).map((m) => m.toUpperCase()).includes(method))
98
+ throw new Error(`method ${method} is not allowed`);
99
+ const headers = {};
100
+ for (const [k, v] of Object.entries(init?.headers || {}))
101
+ if (/^(accept|content-type)$/i.test(k))
102
+ headers[k] = String(v);
103
+ const body = typeof init?.body == 'string' && method !== 'GET' && method !== 'HEAD' ? init.body : undefined;
104
+ const r = await (f.impl || globalThis.fetch)(u.href, { method, headers, body, credentials: 'omit', redirect: 'error', signal });
105
+ const text = await r.text();
106
+ if (text.length > (f.maxBodyBytes || maxBytes))
107
+ throw new Error('response too large');
108
+ return { status: r.status, headers: { 'content-type': r.headers.get('content-type') || '' }, body: text };
109
+ }
110
+
111
+ /*
112
+ * The QuickJS WASM module: `quickjs-emscripten-core` + `@jitl/quickjs-wasmfile-release-sync`,
113
+ * optional peer dependencies (D209), loaded on first use. Stock packages only (README §9 Q6):
114
+ * a quickjs-ng variant can be passed as `variant`.
115
+ */
116
+ let compiled = null;
117
+ let shared = null;
118
+ const isNode$1 = () => typeof globalThis.process == 'object' && !!globalThis.process.versions?.node && typeof globalThis.window == 'undefined';
119
+ /**
120
+ * A QuickJS module. By default a fresh instance whose own `WebAssembly.Memory` is capped at
121
+ * `maxWasmBytes` (64 MB): QuickJS's `setMemoryLimit()` doesn't count ArrayBuffer storage, so
122
+ * without the cap a guest can grow the instance to 2 GB (README finding 1). Instances share
123
+ * one compiled `WebAssembly.Module` (compiled once per page).
124
+ */
125
+ async function loadQuickJS(opts = {}) {
126
+ if (opts.shared && !opts.wasm && !opts.variant && shared)
127
+ return shared;
128
+ const p = load(opts);
129
+ if (opts.shared && !opts.wasm && !opts.variant)
130
+ shared = p;
131
+ return p;
132
+ }
133
+ async function load({ maxWasmBytes = 64 << 20, shared: sh, variant, wasm }) {
134
+ let core, base;
135
+ try {
136
+ core = await import('quickjs-emscripten-core');
137
+ base = variant || (await import('@jitl/quickjs-wasmfile-release-sync')).default;
138
+ }
139
+ catch (e) {
140
+ 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) + ')');
141
+ }
142
+ const custom = {};
143
+ if (wasm)
144
+ custom.wasmModule = wasm instanceof WebAssembly.Module ? wasm : await WebAssembly.compile(wasm);
145
+ else if (!variant && isNode$1())
146
+ custom.wasmModule = await (compiled ?? (compiled = nodeWasm()));
147
+ // a browser without `wasm` lets the variant fetch its .wasm itself (the bundler emits the file)
148
+ if (!sh)
149
+ custom.wasmMemory = new WebAssembly.Memory({ initial: 256, maximum: Math.max(256, Math.ceil(maxWasmBytes / 65536)) });
150
+ return core.newQuickJSWASMModuleFromVariant(Object.keys(custom).length ? core.newVariant(base, custom) : base);
151
+ }
152
+ async function nodeWasm() {
153
+ const [{ readFile }, { createRequire }] = await Promise.all([nodeImport('node:fs/promises'), nodeImport('node:module')]);
154
+ // next to sygnal (a peer), else from the app's root
155
+ let file;
156
+ try {
157
+ file = createRequire(import.meta.url).resolve('@jitl/quickjs-wasmfile-release-sync/wasm');
158
+ }
159
+ catch {
160
+ file = createRequire(globalThis.process.cwd() + '/').resolve('@jitl/quickjs-wasmfile-release-sync/wasm');
161
+ }
162
+ return WebAssembly.compile(await readFile(file));
163
+ }
164
+ // Node built-ins by a name bundlers don't follow (a browser bundle never loads them)
165
+ const nodeImport = (name) => import(/* @vite-ignore */ /* webpackIgnore: true */ name);
166
+ /**
167
+ * Modules a guest crashed: the host's stack overflowed or the WASM trapped in the middle of a
168
+ * QuickJS call, so its C state was unwound without cleanup. createSandbox refuses them.
169
+ */
170
+ const poisoned = new WeakSet();
171
+ const checkModule = (QJS) => {
172
+ if (!QJS || typeof QJS.newRuntime != 'function')
173
+ throw new TypeError('sygnal/sandbox: quickjs must be a module from loadQuickJS()');
174
+ if (poisoned.has(QJS))
175
+ throw new SandboxError('crash', 'this QuickJS module crashed earlier; load a fresh one');
176
+ };
177
+
178
+ /*
179
+ * The page's side of the tree: mirrors the guest's serialized host tree into the real DOM through
180
+ * a sanitizer, and forwards DOM events back.
181
+ *
182
+ * Everything that arrives is untrusted (the guest can send any JSON). The mirror:
183
+ * - creates only allowlisted tags and attributes (no on*, srcdoc, formaction, <script>,
184
+ * <iframe>, <style>, <svg>, …); unknown tags are dropped, never rendered;
185
+ * - checks URLs: <a href> only http(s)/mailto (clicks never navigate by themselves: see
186
+ * bindEvents), <img src> only data:image/* or an allowlisted origin (an image is a request);
187
+ * - filters inline style (no url(), image-set(), expression(), escapes; plain names only);
188
+ * - prefixes ids (and for / aria-* references to them) so the guest can't clobber the page's
189
+ * ids or `window.<id>`; `name` only on form fields; classes are kept (the page's design system
190
+ * styles the guest) unless `classPrefix` is set;
191
+ * - limits nodes, depth and text so a huge tree can't stall the page;
192
+ * - confines layout: the root has `contain: content` (a containing block for position: fixed,
193
+ * paint clipped), `isolation: isolate` and `overflow: hidden` (no overlaying the page).
194
+ * A violation is recorded (violations[]) and the offending part dropped; a malformed tree (a
195
+ * cycle, a node inside itself) throws, and the sandbox is killed.
196
+ */
197
+ 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 ' +
198
+ 'code pre kbd br hr img table caption thead tbody tfoot tr td th colgroup col form fieldset legend section article header ' +
199
+ 'footer nav main aside figure figcaption details summary progress meter time abbr blockquote q cite dl dt dd mark sub sup output').split(' '));
200
+ 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-]+$/;
201
+ // no password or file inputs (phishing, file exfiltration)
202
+ const INPUT_TYPES = /^(text|search|email|url|tel|number|range|date|time|datetime-local|month|week|color|checkbox|radio|button|submit|reset|hidden)$/;
203
+ const ID_REFS = /^(for|aria-labelledby|aria-describedby|aria-controls|aria-owns|aria-activedescendant|list)$/;
204
+ const SAFE_HREF = /^(https?:|mailto:)/i;
205
+ const BAD_STYLE = /url\s*\(|image-set|expression|javascript:|\\|@|<|behavior|binding/i;
206
+ const FIELD = /^(input|textarea|select|button|output|fieldset)$/;
207
+ const CLASS_TOKEN = /^[A-Za-z0-9_-]+$/;
208
+ const DEFAULT_POLICY = { maxNodes: 5000, maxDepth: 128, maxText: 1 << 20, imageOrigins: [], classPrefix: '' };
209
+ /** one element's attributes and inline style through the allowlist (the mirror and SSR share it) */
210
+ function sanitize(tag, n, prefix, policy, violate) {
211
+ const attrs = new Map();
212
+ const a = n.a && typeof n.a == 'object' ? n.a : {};
213
+ for (const k0 of Object.keys(a)) {
214
+ const k = String(k0).toLowerCase(), v = String(a[k0]);
215
+ if (k === 'id')
216
+ attrs.set('id', prefix + v);
217
+ else if (ID_REFS.test(k))
218
+ attrs.set(k, v.split(/\s+/).filter(Boolean).map((x) => prefix + x).join(' '));
219
+ else if (k === 'name') {
220
+ if (FIELD.test(tag))
221
+ attrs.set(k, v);
222
+ else
223
+ violate(`name on <${tag}>`);
224
+ }
225
+ else if (k === 'type') {
226
+ if (tag !== 'input' || INPUT_TYPES.test(v))
227
+ attrs.set(k, v);
228
+ else
229
+ violate(`input type ${v}`);
230
+ }
231
+ else if (k === 'href') {
232
+ if (tag === 'a' && SAFE_HREF.test(v.trim()))
233
+ attrs.set(k, v);
234
+ else
235
+ violate(`href ${v.slice(0, 40)}`);
236
+ }
237
+ else if (k === 'src') {
238
+ if (tag === 'img' && imageOk(v, policy))
239
+ attrs.set(k, v);
240
+ else
241
+ violate(`src ${v.slice(0, 40)}`);
242
+ }
243
+ else if (k === 'style')
244
+ violate('style attribute'); // inline style arrives as n.s, filtered below
245
+ else if (k === 'class' && policy.classPrefix)
246
+ attrs.set(k, v.split(/\s+/).filter((c) => CLASS_TOKEN.test(c)).map((c) => policy.classPrefix + c).join(' '));
247
+ else if (ATTRS.test(k))
248
+ attrs.set(k, v);
249
+ else
250
+ violate(`attribute ${k}`);
251
+ }
252
+ if (tag === 'a' && attrs.has('href')) {
253
+ attrs.set('rel', 'noopener noreferrer');
254
+ attrs.set('target', '_blank');
255
+ }
256
+ const css = [];
257
+ const s = n.s && typeof n.s == 'object' ? n.s : {};
258
+ for (const k of Object.keys(s)) {
259
+ const name = String(k).replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()), v = String(s[k]);
260
+ if (!/^-?[a-z][a-z-]*$/.test(name) || BAD_STYLE.test(v)) {
261
+ violate(`style ${name}`);
262
+ continue;
263
+ }
264
+ css.push(`${name}: ${v}`);
265
+ }
266
+ return { attrs, css: css.join('; ') };
267
+ }
268
+ function imageOk(v, policy) {
269
+ if (/^data:image\/(png|gif|jpeg|webp);base64,/i.test(v))
270
+ return true;
271
+ try {
272
+ return policy.imageOrigins.includes(new URL(v).origin);
273
+ }
274
+ catch {
275
+ return false;
276
+ }
277
+ }
278
+ const VOID = new Set(['br', 'hr', 'img', 'input', 'col']);
279
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
280
+ /**
281
+ * A render message's tree as HTML (SSR, edge, multi-tenant): the same allowlist and limits as the
282
+ * mirror. A full tree only (no bare ids).
283
+ */
284
+ function treeToHTML(tree, opts = {}) {
285
+ const policy = { ...DEFAULT_POLICY, ...opts.policy }, prefix = opts.prefix ?? policy.idPrefix ?? 'sbx-';
286
+ const violations = [], violate = (w) => { if (violations.length < 100)
287
+ violations.push(w); };
288
+ let count = 0, text = 0;
289
+ const out = (n, depth) => {
290
+ if (depth > policy.maxDepth || ++count > policy.maxNodes)
291
+ throw new Error('sandbox tree: over the limits');
292
+ if (!n || typeof n != 'object')
293
+ throw new Error('sandbox tree: malformed node (SSR needs a full tree)');
294
+ if (typeof n.x == 'string') {
295
+ if ((text += n.x.length) > policy.maxText)
296
+ throw new Error('sandbox tree: too much text');
297
+ return esc(n.x);
298
+ }
299
+ const tag = String(n.t);
300
+ if (!TAGS.has(tag)) {
301
+ violate(`tag <${tag}>`);
302
+ return '';
303
+ }
304
+ const { attrs, css } = sanitize(tag, n, prefix, policy, violate);
305
+ if ('v' in n && tag === 'input')
306
+ attrs.set('value', String(n.v));
307
+ if (n.k && (tag === 'input' || tag === 'option'))
308
+ attrs.set(tag === 'option' ? 'selected' : 'checked', '');
309
+ if (css)
310
+ attrs.set('style', css);
311
+ const open = `<${tag}${[...attrs].map(([k, v]) => ` ${k}="${esc(v)}"`).join('')}>`;
312
+ if (VOID.has(tag))
313
+ return open;
314
+ const inner = tag === 'textarea' ? esc(n.v ?? '') : (Array.isArray(n.c) ? n.c : []).map((c) => out(c, depth + 1)).join('');
315
+ return `${open}${inner}</${tag}>`;
316
+ };
317
+ return { html: (Array.isArray(tree) ? tree : []).map((n) => out(n, 0)).join(''), violations };
318
+ }
319
+ let roots = 0;
320
+ function createMirror(container, opts = {}) {
321
+ const policy = { ...DEFAULT_POLICY, ...opts.policy };
322
+ const doc = container.ownerDocument;
323
+ const prefix = policy.idPrefix ?? `sbx${++roots}-`;
324
+ const root = doc.createElement('div');
325
+ root.setAttribute('data-sandbox-root', '');
326
+ root.style.cssText = 'contain: content; isolation: isolate; position: relative; overflow: hidden';
327
+ container.appendChild(root);
328
+ const nodes = new Map(); // guest id → real node
329
+ const idOf = new WeakMap(); // real node → guest id
330
+ const violations = [];
331
+ const violate = (what) => { if (violations.length < 100)
332
+ violations.push(what); };
333
+ let count = 0, text = 0, alive = new Set();
334
+ const keepSubtree = (n) => {
335
+ const i = idOf.get(n);
336
+ if (i !== undefined)
337
+ alive.add(i);
338
+ n.childNodes.forEach(keepSubtree);
339
+ };
340
+ function build(n, depth) {
341
+ if (depth > policy.maxDepth)
342
+ throw new Error('sandbox tree: too deep');
343
+ if (++count > policy.maxNodes)
344
+ throw new Error('sandbox tree: too many nodes');
345
+ if (typeof n == 'number') {
346
+ const real = nodes.get(n);
347
+ if (!real)
348
+ return null; // dropped by the sanitizer when it was sent
349
+ keepSubtree(real);
350
+ return real;
351
+ }
352
+ if (!n || typeof n != 'object' || typeof n.i != 'number')
353
+ throw new Error('sandbox tree: malformed node');
354
+ alive.add(n.i);
355
+ if (typeof n.x == 'string') {
356
+ if ((text += n.x.length) > policy.maxText)
357
+ throw new Error('sandbox tree: too much text');
358
+ let t = nodes.get(n.i);
359
+ if (!t || t.nodeType !== 3) {
360
+ t = doc.createTextNode(n.x);
361
+ nodes.set(n.i, t);
362
+ idOf.set(t, n.i);
363
+ }
364
+ else if (t.data !== n.x)
365
+ t.data = n.x;
366
+ return t;
367
+ }
368
+ const tag = String(n.t);
369
+ if (!TAGS.has(tag)) {
370
+ violate(`tag <${tag}>`);
371
+ return null;
372
+ }
373
+ let el = nodes.get(n.i);
374
+ if (!el || el.localName !== tag) {
375
+ el = doc.createElement(tag);
376
+ nodes.set(n.i, el);
377
+ idOf.set(el, n.i);
378
+ }
379
+ const { attrs: want, css } = sanitize(tag, n, prefix, policy, violate);
380
+ for (const a of [...el.attributes])
381
+ if (a.name !== 'style' && !want.has(a.name))
382
+ el.removeAttribute(a.name);
383
+ for (const [k, v] of want)
384
+ if (el.getAttribute(k) !== v)
385
+ el.setAttribute(k, v);
386
+ if ((el.getAttribute('style') || '') !== css)
387
+ css ? el.setAttribute('style', css) : el.removeAttribute('style');
388
+ // field state the guest controls
389
+ const f = el;
390
+ if ('v' in n && f.value !== String(n.v))
391
+ f.value = String(n.v);
392
+ if ('k' in n && (tag === 'input' || tag === 'option'))
393
+ tag === 'option' ? (f.selected = !!n.k) : (f.checked = !!n.k);
394
+ const kids = [];
395
+ for (const c of Array.isArray(n.c) ? n.c : []) {
396
+ const r = build(c, depth + 1);
397
+ if (r)
398
+ kids.push(r);
399
+ }
400
+ for (let i = 0; i < kids.length; i++) {
401
+ if (kids[i].contains(el))
402
+ throw new Error('sandbox tree: a node inside itself');
403
+ if (el.childNodes[i] !== kids[i])
404
+ el.insertBefore(kids[i], el.childNodes[i] || null);
405
+ }
406
+ while (el.childNodes.length > kids.length)
407
+ el.removeChild(el.lastChild);
408
+ return el;
409
+ }
410
+ return {
411
+ root, violations, prefix,
412
+ /** apply one render's tree; throws on a malformed one */
413
+ apply(tree) {
414
+ count = 0;
415
+ text = 0;
416
+ alive = new Set();
417
+ const top = [];
418
+ for (const n of Array.isArray(tree) ? tree : []) {
419
+ const r = build(n, 0);
420
+ if (r)
421
+ top.push(r);
422
+ }
423
+ for (let i = 0; i < top.length; i++) {
424
+ if (root.childNodes[i] !== top[i])
425
+ root.insertBefore(top[i], root.childNodes[i] || null);
426
+ }
427
+ while (root.childNodes.length > top.length)
428
+ root.removeChild(root.lastChild);
429
+ for (const k of [...nodes.keys()])
430
+ if (!alive.has(k))
431
+ nodes.delete(k);
432
+ },
433
+ /**
434
+ * The guest moved focus (README §9 Q5): only with the focus capability, and only while the
435
+ * page's focus is already inside the box. A guest can manage focus in its own dialog, never
436
+ * take it from the page.
437
+ */
438
+ focus(id) {
439
+ const el = nodes.get(id);
440
+ if (!opts.focus)
441
+ return void violate('focus (no focus capability)');
442
+ if (!el || el.nodeType !== 1 || !root.contains(doc.activeElement) || doc.activeElement === el)
443
+ return;
444
+ el.focus?.();
445
+ },
446
+ /** the guest id of a real node, or of its nearest mirrored ancestor inside the root */
447
+ idFor(target) {
448
+ for (let n = target; n && n !== root; n = n.parentNode) {
449
+ const i = idOf.get(n);
450
+ if (i !== undefined)
451
+ return i;
452
+ }
453
+ return undefined;
454
+ },
455
+ clear() { root.remove(); nodes.clear(); },
456
+ };
457
+ }
458
+ const FORWARD = /^(click|dblclick|input|change|submit|keydown|keyup|focus|blur|focusin|focusout|pointerdown|pointerup|mouseenter|mouseleave)$/;
459
+ const NO_BUBBLE = /^(focus|blur|mouseenter|mouseleave)$/;
460
+ const STOP = ('click dblclick auxclick contextmenu mousedown mouseup mouseover mouseout pointerdown pointerup pointerover pointerout ' +
461
+ 'input change submit reset keydown keyup keypress beforeinput focusin focusout touchstart touchend wheel select').split(' ');
462
+ const KEYS = ['key', 'code', 'shiftKey', 'ctrlKey', 'altKey', 'metaKey', 'button', 'clientX', 'clientY'];
463
+ /**
464
+ * DOM events → the guest: one listener at the mirror's root per type the guest listens for
465
+ * (delegation, as the DOM driver does), with the event's plain fields and the target's value /
466
+ * checked. `dispatch` returns whether the guest prevented the default (in-thread: synchronous;
467
+ * a worker answers too late, so only declared keys are prevented). A form submit and a link
468
+ * click never act on the page by themselves: an unprevented link click goes to `navigate(href)`.
469
+ */
470
+ function bindEvents(mirror, dispatch, navigate) {
471
+ const root = mirror.root, on = new Map();
472
+ const add = (key, l, capture = false) => {
473
+ on.set(key, [l, capture]);
474
+ root.addEventListener(key.replace(/^\$/, ''), l, capture);
475
+ };
476
+ function onEvent(e) {
477
+ const id = mirror.idFor(e.target);
478
+ if (id === undefined)
479
+ return;
480
+ const t = e.target, init = {};
481
+ for (const k of KEYS)
482
+ if (e[k] !== undefined)
483
+ init[k] = e[k];
484
+ if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) {
485
+ init.value = t.value;
486
+ if (t.type === 'checkbox' || t.type === 'radio')
487
+ init.checked = t.checked;
488
+ }
489
+ if (dispatch(id, e.type, init))
490
+ e.preventDefault();
491
+ }
492
+ add('$submit', (e) => e.preventDefault());
493
+ // The guest's elements are not the host app's: its delegated intents (DOM.select('.danger'),
494
+ // bubbling at the app root) must never see a sandboxed element's events, or a guest could render
495
+ // a class the host listens to and have the user trigger a host action. The page's own capture
496
+ // listeners still see them.
497
+ for (const t of STOP)
498
+ add('$' + t, (e) => e.stopPropagation());
499
+ const link = (e) => {
500
+ const a = e.target?.closest?.('a[href]');
501
+ if (!a || !root.contains(a))
502
+ return;
503
+ const was = e.defaultPrevented;
504
+ e.preventDefault();
505
+ if (!was)
506
+ navigate?.(a.getAttribute('href'));
507
+ };
508
+ add('$click', link);
509
+ return {
510
+ listen(types) {
511
+ for (const t of types)
512
+ if (!on.has(t) && FORWARD.test(t))
513
+ add(t, onEvent, NO_BUBBLE.test(t));
514
+ // the link check runs after the guest's own click (whose preventDefault it honours): last
515
+ if (types.includes('click')) {
516
+ root.removeEventListener('click', link);
517
+ root.addEventListener('click', link);
518
+ }
519
+ },
520
+ detach() {
521
+ for (const [t, [l, c]] of on)
522
+ root.removeEventListener(t.replace(/^\$/, ''), l, c);
523
+ root.removeEventListener('click', link);
524
+ on.clear();
525
+ },
526
+ };
527
+ }
528
+
529
+ /*
530
+ * The in-thread engine: one guest in its own QuickJS runtime and context. Used directly for
531
+ * `isolation: 'thread'`, and inside the Worker for `isolation: 'worker'` (no container there:
532
+ * renders go out as JSON).
533
+ *
534
+ * Isolation comes from QuickJS: the guest's globals are QuickJS's own (no window, document,
535
+ * fetch, storage; the Function constructor reaches only the guest global). Its only way out is
536
+ * the `__host` functions below, each of which checks its arguments. Every entry (boot, an event,
537
+ * a timer, a fetch reply) runs under a deadline (the interrupt handler) with the runtime's
538
+ * memory and stack limits, and a CPU share over a rolling second. A guest that trips a limit is
539
+ * killed: there is no safe way to resume a context interrupted half way through a flush.
540
+ *
541
+ * The deadline stops accidents, not hostile code: QuickJS polls the interrupt handler once per
542
+ * 10,000 ticks and never inside a builtin (README finding 2). The Worker's watchdog is the hard stop.
543
+ */
544
+ const ENTRIES = ['dispatch', 'fire', 'resolve', 'props', 'start', 'flush'];
545
+ function createEngine(QJS, opts) {
546
+ checkModule(QJS);
547
+ const limits = opts.limits;
548
+ const now = () => performance.now();
549
+ const stats = { bootMs: 0, calls: 0, guestMs: 0, renders: 0, lastCallMs: 0 };
550
+ let deadline = Infinity;
551
+ const rt = QJS.newRuntime();
552
+ rt.setMemoryLimit(limits.memoryBytes);
553
+ rt.setMaxStackSize(limits.stackBytes);
554
+ rt.setInterruptHandler(() => now() > deadline);
555
+ const ctx = rt.newContext();
556
+ let dead = false, crashed = false, logs = 0, immediates = 0;
557
+ // renders are deltas against the previous one (unchanged subtrees are bare ids): all are applied, in order
558
+ const renders = [], emits = [];
559
+ const timers = new Map(), fetches = new Map();
560
+ const mirror = opts.container ? createMirror(opts.container, { policy: opts.policy, focus: !!opts.focus }) : null;
561
+ // a DOM event goes into the guest synchronously, so its preventDefault() reaches the page's event
562
+ const events = mirror && bindEvents(mirror, (id, type, init) => !!enter('dispatch', [id, type, JSON.stringify(init)]), (href) => opts.navigate?.(href));
563
+ // --- the bridge: the only functions the guest can reach --------------------------------------
564
+ const str = (h, max = limits.maxMessageBytes) => {
565
+ if (ctx.typeof(h) !== 'string')
566
+ throw new SandboxError('protocol', 'expected a string');
567
+ const s = ctx.getString(h);
568
+ if (s.length > max)
569
+ throw new SandboxError('protocol', `message over ${max} bytes`);
570
+ return s;
571
+ };
572
+ const num = (h) => {
573
+ const n = ctx.typeof(h) === 'number' ? ctx.getNumber(h) : NaN;
574
+ if (!Number.isFinite(n))
575
+ throw new SandboxError('protocol', 'expected a number');
576
+ return n;
577
+ };
578
+ const host = ctx.newObject();
579
+ const fn = (name, f) => {
580
+ const h = ctx.newFunction(name, (...a) => {
581
+ try {
582
+ return f(...a);
583
+ }
584
+ catch (e) {
585
+ // a protocol violation kills; anything else is thrown into the guest
586
+ if (e instanceof SandboxError)
587
+ queueMicrotask(() => kill(e.reason, e.message));
588
+ return { error: ctx.newError(String(e?.message || e)) };
589
+ }
590
+ });
591
+ ctx.setProp(host, name, h);
592
+ h.dispose();
593
+ };
594
+ fn('render', (h) => {
595
+ if (renders.length >= 4)
596
+ throw new SandboxError('protocol', 'too many renders in one call');
597
+ renders.push(str(h));
598
+ stats.renders++;
599
+ });
600
+ fn('emit', (h) => {
601
+ // delivered when the guest call returns, after its render (never into a running guest call)
602
+ if (emits.length >= 64)
603
+ throw new SandboxError('protocol', 'too many EVENTS in one call');
604
+ const ev = JSON.parse(str(h, 64 << 10));
605
+ if (!ev || typeof ev != 'object' || typeof ev.type != 'string')
606
+ throw new SandboxError('protocol', 'an event needs a string type');
607
+ emits.push(ev);
608
+ });
609
+ fn('log', (lv, h) => {
610
+ if (++logs > limits.maxLogs)
611
+ return;
612
+ const level = str(lv, 16), text = str(h, 4096);
613
+ if (opts.log)
614
+ opts.log(level, text);
615
+ else
616
+ console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log']('[sandbox]', text);
617
+ });
618
+ fn('timer', (idH, msH) => {
619
+ const id = num(idH), raw = num(msH);
620
+ // < 0: setImmediate (the core's macrotask hop), counted apart from the component's timers
621
+ if (raw < 0) {
622
+ if (immediates >= 16)
623
+ throw new Error('sandbox: too many setImmediate');
624
+ immediates++;
625
+ timers.set(id, setTimeout(() => { immediates--; timers.delete(id); enter('fire', [id]); }, 0));
626
+ return;
627
+ }
628
+ const ms = Math.max(limits.minTimerMs, Math.min(raw, 2 ** 31 - 1));
629
+ if (timers.size - immediates >= limits.maxTimers)
630
+ throw new Error(`sandbox: more than ${limits.maxTimers} timers`);
631
+ timers.set(id, setTimeout(() => { timers.delete(id); enter('fire', [id]); }, ms));
632
+ });
633
+ fn('clearTimer', (idH) => {
634
+ const id = num(idH);
635
+ clearTimeout(timers.get(id));
636
+ timers.delete(id);
637
+ });
638
+ fn('fetch', (idH, urlH, initH) => {
639
+ const id = num(idH), url = str(urlH, 4096), init = JSON.parse(str(initH, 256 << 10));
640
+ const done = (ok, payload) => { fetches.delete(id); if (!dead)
641
+ enter('resolve', [id, ok ? 1 : 0, payload]); };
642
+ if (fetches.size >= limits.maxFetches)
643
+ return void queueMicrotask(() => done(false, 'sandbox fetch denied: too many requests in flight'));
644
+ const ac = new AbortController();
645
+ fetches.set(id, ac);
646
+ capFetch(opts.fetch, url, init, limits.maxMessageBytes, ac.signal)
647
+ .then((res) => done(true, JSON.stringify(res)), (e) => done(false, `sandbox fetch denied: ${String(e?.message || e)}`));
648
+ });
649
+ ctx.setProp(ctx.global, '__host', host);
650
+ host.dispose();
651
+ // --- entering the guest -----------------------------------------------------------------------
652
+ let windowStart = now(), windowMs = 0;
653
+ const entries = {};
654
+ function enter(name, args, budget = limits.callMs) {
655
+ if (dead)
656
+ return;
657
+ const t0 = now();
658
+ deadline = t0 + budget;
659
+ let out;
660
+ try {
661
+ const hs = args.map((a) => (typeof a == 'number' ? ctx.newNumber(a) : ctx.newString(String(a))));
662
+ const r = ctx.callFunction(entries[name], ctx.undefined, ...hs);
663
+ hs.forEach((h) => h.dispose());
664
+ out = settle(r);
665
+ // the promise jobs (the core's flush is a microtask) run under the same deadline
666
+ settle(rt.executePendingJobs(10000));
667
+ if (rt.hasPendingJob())
668
+ throw new SandboxError('timeout', 'endless promise jobs');
669
+ // then the tree, once
670
+ settle(ctx.callFunction(entries.flush, ctx.undefined));
671
+ }
672
+ catch (e) {
673
+ deadline = Infinity;
674
+ kill(e.reason || classify(e), e.message || String(e));
675
+ return;
676
+ }
677
+ deadline = Infinity;
678
+ const ms = now() - t0;
679
+ stats.calls++;
680
+ stats.guestMs += ms;
681
+ stats.lastCallMs = ms;
682
+ if (t0 - windowStart > 1000) {
683
+ windowStart = t0;
684
+ windowMs = 0;
685
+ }
686
+ if ((windowMs += ms) > limits.cpuShare * 1000) {
687
+ kill('cpu', `over ${limits.cpuShare * 100}% CPU`);
688
+ return;
689
+ }
690
+ deliver();
691
+ return out;
692
+ }
693
+ function settle(r) {
694
+ if (r.error) {
695
+ const e = ctx.dump(r.error);
696
+ r.error.dispose();
697
+ const err = new Error(typeof e == 'object' && e ? `${e.name}: ${e.message}` : String(e));
698
+ err.guest = true;
699
+ throw err;
700
+ }
701
+ const v = ctx.typeof(r.value) === 'number' ? ctx.getNumber(r.value) : undefined;
702
+ r.value.dispose?.();
703
+ return v;
704
+ }
705
+ function classify(e) {
706
+ // not the guest's own exception: the host's stack or the WASM gave out mid-call (finding 3)
707
+ if (!e.guest) {
708
+ poisoned.add(QJS);
709
+ crashed = true;
710
+ return 'crash';
711
+ }
712
+ if (/out of memory|stack overflow/i.test(e.message))
713
+ return 'memory';
714
+ if (/interrupted/i.test(e.message)) {
715
+ // near the memory limit every allocation collects the whole heap: that thrash ends as a timeout
716
+ try {
717
+ const h = rt.computeMemoryUsage(), m = ctx.dump(h);
718
+ h.dispose();
719
+ if (m.memory_used_size > 0.9 * limits.memoryBytes)
720
+ return 'memory';
721
+ }
722
+ catch { }
723
+ return 'timeout';
724
+ }
725
+ return 'error';
726
+ }
727
+ function deliver() {
728
+ while (renders.length && !dead) {
729
+ const json = renders.shift();
730
+ if (!mirror) {
731
+ opts.onRender?.(json);
732
+ continue;
733
+ }
734
+ try {
735
+ applyRender(mirror, events, json);
736
+ }
737
+ catch (e) {
738
+ kill('protocol', e.message);
739
+ }
740
+ }
741
+ while (emits.length && !dead)
742
+ opts.onEmit?.(emits.shift());
743
+ }
744
+ // --- lifecycle --------------------------------------------------------------------------------
745
+ function kill(reason, message) {
746
+ if (dead)
747
+ return;
748
+ dead = true;
749
+ cleanup();
750
+ opts.onError?.(new SandboxError(reason, message));
751
+ }
752
+ function cleanup() {
753
+ for (const t of timers.values())
754
+ clearTimeout(t);
755
+ for (const ac of fetches.values())
756
+ ac.abort();
757
+ timers.clear();
758
+ fetches.clear();
759
+ if (mirror) {
760
+ events.detach();
761
+ mirror.clear();
762
+ }
763
+ // a crashed runtime is leaked: freeing it asserts (an abort that would end the whole module)
764
+ if (crashed)
765
+ return;
766
+ for (const k in entries)
767
+ entries[k]?.alive && entries[k].dispose();
768
+ try {
769
+ ctx.dispose();
770
+ rt.dispose();
771
+ }
772
+ catch {
773
+ poisoned.add(QJS);
774
+ }
775
+ }
776
+ const t0 = now();
777
+ deadline = t0 + limits.bootMs;
778
+ try {
779
+ settle(ctx.evalCode(opts.kernel, 'sygnal-sandbox-kernel.js'));
780
+ const sbx = ctx.getProp(ctx.global, '__sandbox');
781
+ const v = ctx.typeof(sbx) === 'object' ? ctx.getProp(sbx, 'v') : null;
782
+ const version = v && ctx.typeof(v) === 'number' ? ctx.getNumber(v) : 0;
783
+ v?.dispose();
784
+ if (version !== PROTOCOL) {
785
+ sbx.dispose();
786
+ throw new SandboxError('protocol', version
787
+ ? `the kernel speaks protocol ${version}, this sygnal/sandbox ${PROTOCOL}: rebuild it from the installed sygnal`
788
+ : 'not a sandbox kernel: import it from sygnal/sandbox/kernel?raw through sygnal/vite, or build it with buildSandboxKernel()');
789
+ }
790
+ // the kernel's own functions, taken before the component runs (it could replace __sandbox)
791
+ for (const k of ENTRIES)
792
+ entries[k] = ctx.getProp(sbx, k);
793
+ sbx.dispose();
794
+ settle(ctx.evalCode(opts.source, 'component.js'));
795
+ deadline = Infinity;
796
+ if (opts.props !== undefined)
797
+ enter('props', [JSON.stringify(opts.props)], limits.bootMs);
798
+ enter('start', [opts.exportName || ''], Math.max(1, limits.bootMs - (now() - t0)));
799
+ }
800
+ catch (e) {
801
+ deadline = Infinity;
802
+ kill(e.reason || classify(e), e.message);
803
+ }
804
+ stats.bootMs = now() - t0;
805
+ return {
806
+ stats,
807
+ get dead() { return dead; },
808
+ get violations() { return mirror ? mirror.violations : []; },
809
+ root: mirror ? mirror.root : null,
810
+ setProps(p) { enter('props', [JSON.stringify(p ?? null)]); },
811
+ /** an event as if it came from the element with that guest id (tests, benchmarks, the worker) */
812
+ dispatch(id, type, init = {}) { return enter('dispatch', [id, type, JSON.stringify(init)]); },
813
+ memory() {
814
+ if (dead)
815
+ return null;
816
+ const h = rt.computeMemoryUsage(), m = ctx.dump(h);
817
+ h.dispose();
818
+ return m;
819
+ },
820
+ dispose() { if (!dead) {
821
+ dead = true;
822
+ cleanup();
823
+ } },
824
+ };
825
+ }
826
+ /** one render message onto a mirror (both tiers); throws on a malformed one */
827
+ function applyRender(mirror, events, json) {
828
+ const msg = JSON.parse(json);
829
+ if (!msg || typeof msg != 'object' || msg.v !== PROTOCOL)
830
+ throw new Error('sandbox render: wrong protocol');
831
+ mirror.apply(msg.tree);
832
+ events.listen(Array.isArray(msg.types) ? msg.types.map(String) : []);
833
+ if (typeof msg.f == 'number')
834
+ mirror.focus(msg.f);
835
+ }
836
+
837
+ /*
838
+ * `isolation: 'worker'` (the default, README §9 Q7): QuickJS in a Worker (a browser Worker or
839
+ * Node's worker_threads), for code that may be hostile. The page keeps the DOM mirror, the fetch
840
+ * policy and the emits check; the worker runs the engine without a container (worker-entry.ts).
841
+ * Against the in-thread tier:
842
+ * + a hard stop: a watchdog pings the worker and terminate()s it when it doesn't answer in
843
+ * `limits.hardMs` (an in-thread deadline can't stop a loop over slow builtins, finding 2);
844
+ * + a guest never stalls the page's main thread;
845
+ * - events are asynchronous: the guest's preventDefault() can't reach the DOM event (submit and
846
+ * link clicks are always prevented; keys the guest handles are declared up front: `preventKeys`);
847
+ * - one more hop per event, and a worker per sandbox.
848
+ */
849
+ const isNode = () => typeof globalThis.process == 'object' && !!globalThis.process.versions?.node && typeof globalThis.Worker == 'undefined';
850
+ /** the worker script that ships with sygnal/sandbox: worker_threads in Node, a module Worker in a browser */
851
+ async function defaultWorker() {
852
+ if (isNode()) {
853
+ const [{ Worker }, { fileURLToPath }] = await Promise.all([nodeImport('node:worker_threads'), nodeImport('node:url')]);
854
+ // the CommonJS build, next to this file (a string the bundlers don't take for an asset)
855
+ const file = fileURLToPath(import.meta.url.replace(/[^/]*$/, 'sandbox-worker.cjs.js'));
856
+ const w = new Worker(file);
857
+ w.unref();
858
+ return w;
859
+ }
860
+ // the pattern Vite and webpack bundle as a worker (with its imports: quickjs and the variant's .wasm)
861
+ return new Worker(new URL('./sandbox-worker.esm.js', import.meta.url), { type: 'module' });
862
+ }
863
+ function createWorkerEngine(opts) {
864
+ const limits = opts.limits;
865
+ const mirror = createMirror(opts.container, { policy: opts.policy, focus: !!opts.focus });
866
+ const preventKeys = new Set(opts.preventKeys || []);
867
+ const stats = { readyMs: 0, wasmMs: 0, bootMs: 0, renders: 0 };
868
+ const queue = [];
869
+ let w = null, dead = false;
870
+ let lastPong = performance.now() + limits.bootMs + 2000;
871
+ const t0 = performance.now();
872
+ const post = (m) => { if (dead)
873
+ return; w ? w.postMessage(m) : queue.push(m); };
874
+ const events = bindEvents(mirror, (id, type, init) => {
875
+ post({ t: 'event', id, type, init });
876
+ return /^key/.test(type) && preventKeys.has(init.key);
877
+ }, (href) => opts.navigate?.(href));
878
+ let resolveReady, rejectReady;
879
+ const ready = new Promise((ok, fail) => { resolveReady = ok; rejectReady = fail; });
880
+ ready.catch(() => { });
881
+ const watchdog = setInterval(() => {
882
+ if (performance.now() - lastPong > limits.hardMs)
883
+ return kill('timeout', `the worker didn't answer in ${limits.hardMs} ms; terminated`);
884
+ if (w)
885
+ w.postMessage({ t: 'ping' });
886
+ }, Math.max(25, limits.hardMs / 4));
887
+ watchdog.unref?.();
888
+ async function onMessage(m) {
889
+ if (dead || !m || typeof m != 'object')
890
+ return;
891
+ if (m.t === 'pong')
892
+ lastPong = performance.now();
893
+ else if (m.t === 'render') {
894
+ lastPong = performance.now();
895
+ try {
896
+ applyRender(mirror, events, String(m.json));
897
+ stats.renders++;
898
+ }
899
+ catch (e) {
900
+ kill('protocol', e.message);
901
+ }
902
+ }
903
+ else if (m.t === 'emit')
904
+ opts.onEmit?.(m.e);
905
+ else if (m.t === 'log')
906
+ opts.log ? opts.log(String(m.l), String(m.x).slice(0, 4096)) : console.log('[sandbox]', String(m.x).slice(0, 4096));
907
+ else if (m.t === 'ready') {
908
+ if (m.v !== PROTOCOL)
909
+ return kill('protocol', `the worker speaks protocol ${m.v}, the page ${PROTOCOL}`);
910
+ lastPong = performance.now();
911
+ stats.readyMs = performance.now() - t0;
912
+ stats.wasmMs = +m.wasmMs || 0;
913
+ stats.bootMs = +m.bootMs || 0;
914
+ resolveReady();
915
+ }
916
+ else if (m.t === 'error')
917
+ kill(m.reason, String(m.message));
918
+ else if (m.t === 'fetch') {
919
+ // the page checks again: the worker's own check ran in a thread the guest shares
920
+ let res;
921
+ try {
922
+ res = { ok: true, res: await capFetch(opts.fetch, String(m.url), m.init || {}, limits.maxMessageBytes) };
923
+ }
924
+ catch (e) {
925
+ res = { ok: false, error: String(e?.message || e) };
926
+ }
927
+ post({ t: 'fetched', id: m.id, ...res });
928
+ }
929
+ }
930
+ Promise.resolve()
931
+ .then(() => (opts.worker || defaultWorker)())
932
+ .then((worker) => {
933
+ if (dead)
934
+ return worker.terminate();
935
+ w = worker;
936
+ if (w.on)
937
+ w.on('message', onMessage);
938
+ else
939
+ w.addEventListener('message', (e) => onMessage(e.data));
940
+ const f = opts.fetch;
941
+ w.postMessage({
942
+ t: 'init', v: PROTOCOL, kernel: opts.kernel, source: opts.source, exportName: opts.exportName, props: opts.props, limits, wasm: opts.wasm,
943
+ fetch: f && { allow: f.allow, methods: f.methods, base: f.base, maxBodyBytes: f.maxBodyBytes },
944
+ });
945
+ for (const m of queue.splice(0))
946
+ w.postMessage(m);
947
+ })
948
+ .catch((e) => kill('error', `the worker didn't start: ${e?.message || e}`));
949
+ function kill(reason, message) {
950
+ if (dead)
951
+ return;
952
+ cleanup();
953
+ const err = new SandboxError(reason, message);
954
+ rejectReady(err);
955
+ opts.onError?.(err);
956
+ }
957
+ function cleanup() {
958
+ dead = true;
959
+ clearInterval(watchdog);
960
+ w?.terminate();
961
+ events.detach();
962
+ mirror.clear();
963
+ }
964
+ return {
965
+ stats, ready, root: mirror.root,
966
+ get dead() { return dead; },
967
+ get violations() { return mirror.violations; },
968
+ setProps(p) { post({ t: 'props', p: p ?? null }); },
969
+ dispatch(id, type, init = {}) { post({ t: 'event', id, type, init }); return undefined; },
970
+ memory() { return null; },
971
+ dispose() { if (!dead) {
972
+ cleanup();
973
+ rejectReady(new SandboxError('error', 'disposed'));
974
+ } },
975
+ };
976
+ }
977
+
978
+ /*
979
+ * createSandbox(): one untrusted Sygnal component, rendered into the page's real DOM, behind one
980
+ * API for both tiers (README §8, §9 Q7):
981
+ *
982
+ * isolation: 'worker' (default) QuickJS in a Worker with a watchdog: for code that may be
983
+ * hostile (plugins, a marketplace, other tenants)
984
+ * isolation: 'thread' QuickJS on the page's thread with a capped instance: for
985
+ * model-written code (accidents, prompt injection), not hostile
986
+ * code: synchronous events (preventDefault works), but a loop over
987
+ * slow builtins can overrun the deadline by seconds
988
+ *
989
+ * The guest's EVENTS reach `onEmit` only when their type is declared in `emits` and the data
990
+ * passes that type's Standard Schema (README §9 Q3); anything else is dropped (SYG692 in dev).
991
+ */
992
+ const diag = (code, message, fix, data) => {
993
+ try {
994
+ globalThis.__SYGNAL_DIAGNOSTICS__?.report?.(code, { message, fix, severity: 'warn', data });
995
+ }
996
+ catch (e) {
997
+ setTimeout(() => { throw e; });
998
+ }
999
+ };
1000
+ /** the page's gate for the guest's EVENTS: declared types only, data through the type's schema, in order */
1001
+ function emitGate(emits, deliver, reject) {
1002
+ let chain = null, warnedNone = false;
1003
+ const declared = emits ? Object.keys(emits) : [];
1004
+ const drop = (type, reason, why, issues) => {
1005
+ diag('SYG692', `sygnal/sandbox: dropped the guest's '${type}' event: ${why}`, "Declare each event type the page accepts in emits, with a Standard Schema for its data: emits={{ ANSWER: schema }}", issues);
1006
+ reject?.({ type, reason, declared, issues: Array.isArray(issues)
1007
+ ? issues.map((i) => ({ path: (i?.path || []).map((p) => (p && typeof p == 'object' ? p.key : p)), message: String(i?.message ?? i) }))
1008
+ : [] });
1009
+ };
1010
+ const check = (ev) => {
1011
+ if (!emits) {
1012
+ if (!warnedNone) {
1013
+ warnedNone = true;
1014
+ drop(ev.type, 'none', 'no emits declared');
1015
+ }
1016
+ return null;
1017
+ }
1018
+ const schema = Object.prototype.hasOwnProperty.call(emits, ev.type) ? emits[ev.type] : undefined;
1019
+ if (!schema) {
1020
+ drop(ev.type, 'undeclared', 'not declared in emits');
1021
+ return null;
1022
+ }
1023
+ const done = (r) => {
1024
+ if (r && r.issues) {
1025
+ drop(ev.type, 'invalid', 'invalid data: ' + r.issues.map((i) => i.message).join('; '), r.issues);
1026
+ return null;
1027
+ }
1028
+ return { type: ev.type, data: r ? r.value : undefined };
1029
+ };
1030
+ try {
1031
+ const r = schema['~standard'].validate(ev.data);
1032
+ return r && typeof r.then == 'function' ? r.then(done, (e) => { drop(ev.type, 'threw', 'its schema threw: ' + (e?.message || e)); return null; }) : done(r);
1033
+ }
1034
+ catch (e) {
1035
+ drop(ev.type, 'threw', 'its schema threw: ' + (e?.message || e));
1036
+ return null;
1037
+ }
1038
+ };
1039
+ return (ev) => {
1040
+ if (!ev || typeof ev.type != 'string')
1041
+ return;
1042
+ const r = check(ev);
1043
+ if (!chain && !(r && typeof r.then == 'function')) {
1044
+ if (r)
1045
+ deliver(r);
1046
+ return;
1047
+ }
1048
+ // an async schema: keep the guest's order
1049
+ const prev = chain || Promise.resolve();
1050
+ const next = prev.then(() => r).then((x) => { if (x)
1051
+ deliver(x); });
1052
+ chain = next;
1053
+ next.then(() => { if (chain === next)
1054
+ chain = null; });
1055
+ };
1056
+ }
1057
+ function createSandbox(opts) {
1058
+ if (!opts || typeof opts.kernel != 'string' || !opts.kernel)
1059
+ throw new TypeError("sygnal/sandbox: `kernel` is required (import kernel from 'sygnal/sandbox/kernel?raw' with sygnal/vite, or buildSandboxKernel())");
1060
+ if (typeof opts.source != 'string')
1061
+ throw new TypeError('sygnal/sandbox: `source` must be the component compiled to a CommonJS string');
1062
+ const isolation = opts.isolation ?? 'worker';
1063
+ if (isolation !== 'worker' && isolation !== 'thread')
1064
+ throw new TypeError(`sygnal/sandbox: isolation must be 'worker' or 'thread', got ${JSON.stringify(isolation)}`);
1065
+ const limits = limitsOf(opts.limits);
1066
+ if (!opts.container || typeof opts.container.appendChild != 'function')
1067
+ throw new TypeError('sygnal/sandbox: `container` must be an element (renderToHTML renders without one)');
1068
+ const caps = opts.capabilities || {};
1069
+ const gate = emitGate(opts.emits, (ev) => opts.onEmit?.(ev), opts.onReject && ((r) => opts.onReject(r)));
1070
+ let dead = false, failure = null;
1071
+ const onError = (e) => {
1072
+ if (dead)
1073
+ return;
1074
+ dead = true;
1075
+ failure = e;
1076
+ opts.onError?.(e);
1077
+ };
1078
+ const common = {
1079
+ kernel: opts.kernel, source: opts.source, exportName: opts.exportName, props: opts.props, limits,
1080
+ container: opts.container, policy: opts.policy, fetch: caps.fetch, focus: !!caps.focus, navigate: caps.navigate, log: caps.log,
1081
+ onEmit: gate, onError,
1082
+ };
1083
+ if (isolation === 'worker') {
1084
+ const w = createWorkerEngine({ ...common, worker: opts.worker, preventKeys: opts.preventKeys, wasm: opts.wasm });
1085
+ return w;
1086
+ }
1087
+ // thread: an engine now (a module given) or once a fresh capped instance is loaded
1088
+ let e = null, props = opts.props, disposed = false;
1089
+ const boot = (QJS) => {
1090
+ if (disposed)
1091
+ return;
1092
+ e = createEngine(QJS, { ...common, props });
1093
+ if (failure)
1094
+ throw failure;
1095
+ };
1096
+ let ready;
1097
+ try {
1098
+ ready = opts.quickjs ? (boot(opts.quickjs), Promise.resolve()) : loadQuickJS({ maxWasmBytes: limits.maxWasmBytes }).then(boot);
1099
+ }
1100
+ catch (err) {
1101
+ ready = Promise.reject(err);
1102
+ }
1103
+ ready = ready.catch((err) => {
1104
+ const se = err instanceof SandboxError ? err : new SandboxError('error', String(err?.message || err));
1105
+ onError(se);
1106
+ throw failure || se;
1107
+ });
1108
+ ready.catch(() => { });
1109
+ return {
1110
+ ready,
1111
+ get dead() { return dead || disposed || !!e?.dead; },
1112
+ get violations() { return e ? e.violations : []; },
1113
+ get stats() { return e ? e.stats : {}; },
1114
+ setProps(p) { if (e)
1115
+ e.setProps(p);
1116
+ else
1117
+ props = p; },
1118
+ dispose() { disposed = true; e?.dispose(); },
1119
+ memory() { return e ? e.memory() : null; },
1120
+ dispatch(id, type, init) { return e ? e.dispatch(id, type, init) : undefined; },
1121
+ };
1122
+ }
1123
+
1124
+ /*
1125
+ * <Sandboxed>: a sandboxed component as a JSX tag of the host app, on defineWidget (PLAN-5 W-1).
1126
+ * The host's intent reads the guest's accepted EVENTS as `emit` DOM events on the tag:
1127
+ *
1128
+ * import kernel from 'sygnal/sandbox/kernel?raw'
1129
+ * // view: <Sandboxed className="card" kernel={kernel} source={compiled} emits={{ ANSWER: AnswerSchema }}
1130
+ * // input={{ bill: state.bill }} capabilities={{ fetch: { allow: ['https://api.example.com/v1/'] } }} />
1131
+ * // intent: ANSWER: DOM.select('.card').events('emit').detail().filter(e => e.type === 'ANSWER').map(e => e.data),
1132
+ * // FAILED: DOM.select('.card').events('sandboxerror').detail()
1133
+ *
1134
+ * `input` goes to the guest's PROPS (JSON); a new `source` replaces the sandbox. `sandboxed(defaults)`
1135
+ * makes a tag with props preset (`kernel`, `worker`, `limits`, …).
1136
+ */
1137
+ function sandboxed(defaults = {}) {
1138
+ // a prop left undefined keeps its default
1139
+ const merged = (p) => {
1140
+ const o = { ...defaults };
1141
+ for (const k of Object.keys(p))
1142
+ if (p[k] !== undefined)
1143
+ o[k] = p[k];
1144
+ o.capabilities = { ...defaults.capabilities, ...p.capabilities };
1145
+ return o;
1146
+ };
1147
+ const start = (el, props, dispatch) => createSandbox({
1148
+ ...merged(props),
1149
+ container: el,
1150
+ onEmit: (ev) => dispatch('emit', ev),
1151
+ onError: (e) => dispatch('sandboxerror', { reason: e.reason, message: e.message }),
1152
+ props: props.input ?? null,
1153
+ });
1154
+ return defineWidget({
1155
+ tag: 'div',
1156
+ events: ['emit', 'sandboxerror'],
1157
+ mount(el, props, dispatch) {
1158
+ return { sb: start(el, props, dispatch), source: props.source, kernel: merged(props).kernel, input: props.input, dispatch };
1159
+ },
1160
+ update(w, props, el) {
1161
+ if (props.source !== w.source || merged(props).kernel !== w.kernel) {
1162
+ w.sb.dispose();
1163
+ w.sb = start(el, props, w.dispatch);
1164
+ w.source = props.source;
1165
+ w.kernel = merged(props).kernel;
1166
+ w.input = props.input;
1167
+ }
1168
+ else if (props.input !== w.input) {
1169
+ w.input = props.input;
1170
+ w.sb.setProps(props.input ?? null);
1171
+ }
1172
+ },
1173
+ unmount(w) { w.sb.dispose(); },
1174
+ });
1175
+ }
1176
+ // pure: an app that never renders the tag drops it (and defineWidget's registration with it)
1177
+ const Sandboxed = /*#__PURE__*/ sandboxed();
1178
+
1179
+ /*
1180
+ * SSR of an untrusted component (edge, multi-tenant): a fresh runtime per call, its first render
1181
+ * printed through the mirror's allowlist (treeToHTML). No DOM anywhere. By default each call gets
1182
+ * its own capped instance too, so tenants share nothing; pass `quickjs` to reuse one module (a
1183
+ * crash then poisons it for every tenant on it: check `poisoned`). This runs on the caller's
1184
+ * thread with the in-thread deadline: for code that may be hostile, call it in a Worker or a
1185
+ * process you can kill.
1186
+ */
1187
+ async function renderToHTML(opts) {
1188
+ const limits = limitsOf(opts.limits);
1189
+ const QJS = opts.quickjs || await loadQuickJS({ maxWasmBytes: limits.maxWasmBytes });
1190
+ const t0 = performance.now();
1191
+ let json = null, error = null;
1192
+ const sb = createEngine(QJS, {
1193
+ kernel: opts.kernel, source: opts.source, exportName: opts.exportName, props: opts.props, limits,
1194
+ log: opts.log || (() => { }),
1195
+ onRender: (j) => { json ?? (json = j); },
1196
+ onError: (e) => { error = e; },
1197
+ });
1198
+ sb.dispose();
1199
+ if (error)
1200
+ throw error;
1201
+ if (!json)
1202
+ throw new Error('sygnal/sandbox: the component rendered nothing');
1203
+ return { ...treeToHTML(JSON.parse(json).tree, { policy: opts.policy }), ms: performance.now() - t0 };
1204
+ }
1205
+
1206
+ /*
1207
+ * generatedRuntime(): the runtime of model-generated parts (experimental, outside semver; research:
1208
+ * dev-plans/research/compose-sandbox). sygnal/ai's `compose.generated(runtime, { data, emits })`
1209
+ * builds the catalog part, the prompt rules and the repair loop on it; this side owns everything
1210
+ * that touches the code: compile, the check before mount, the box, its failures.
1211
+ *
1212
+ * import kernel from 'sygnal/sandbox/kernel?raw'
1213
+ * const runtime = generatedRuntime({ kernel, compile: sucraseCompiler(() => import('sucrase')) })
1214
+ *
1215
+ * `runtime.Box` is a widget tag: <Box code emits input isolation maxCode limits policy capabilities checks />.
1216
+ * - `code` is the model's source (an ES module with JSX). It is compiled on the page (`compile`:
1217
+ * trusted code reading untrusted text) and checked before it mounts: over `maxCode`, a syntax
1218
+ * error, a JSX event handler (`onClick={…}`: dropped in a Sygnal view, silent) and the app's own
1219
+ * `check` (sygnal-check, where it can run) fail the part with reason 'compile' and no box boots.
1220
+ * The kernel checks the wiring at boot (an intent action with no model entry, SYG101: silent
1221
+ * otherwise): reason 'check'. A new `code` replaces the box in the same element.
1222
+ * - `emits` is required (`{ TYPE: StandardSchema }`): the guest's events go through createSandbox's
1223
+ * gate; an accepted one is an `emit` event `{ type, data }` (the schema's output), a dropped one a
1224
+ * `sandboxreject` event `{ type, reason, issues, declared }`.
1225
+ * - Failures are `sandboxerror` events `{ reason, message }`: compile, check, runtime (the guest's
1226
+ * console.error: the core's SYG406 for a view that throws, SYG216 for a reducer), empty (nothing
1227
+ * drawn 250 ms after ready), and the sandbox's own (timeout, memory, cpu, protocol, error, crash).
1228
+ * Ready is `sandboxready` `{ tier, readyMs, bootMs }`.
1229
+ * - Defaults: `isolation: 'worker'`, no capabilities, GENERATED_LIMITS (smaller than a plugin's:
1230
+ * a part is a card, not an app).
1231
+ */
1232
+ /** a generated part's limits: 16 MB heap in a 48 MB instance, boot 500 ms, 50 ms per event */
1233
+ const GENERATED_LIMITS = Object.freeze({
1234
+ memoryBytes: 16 << 20, maxWasmBytes: 48 << 20, stackBytes: 256 << 10, bootMs: 500, callMs: 50, hardMs: 1000,
1235
+ });
1236
+ /** how long a booted box may take to draw before it counts as empty (the core's first render can trail ready) */
1237
+ const EMPTY_GRACE_MS = 250;
1238
+ const HANDLER = /<[A-Za-z][\w.]*\b[^<>]*?\s(on[A-Z][A-Za-z]*)\s*=\s*\{/;
1239
+ const isSchema = (s) => !!s && typeof s == 'object' && typeof s['~standard']?.validate == 'function';
1240
+ const msg = (e) => String(e?.message ?? e);
1241
+ /** the page's check before mount, with no parser: JSX event handlers, which a Sygnal view drops silently */
1242
+ function lintGenerated(code) {
1243
+ const m = HANDLER.exec(code);
1244
+ if (!m)
1245
+ return [];
1246
+ const line = code.slice(0, m.index).split('\n').length;
1247
+ return [`line ${line}: JSX event handlers (${m[1]}) don't run in a Sygnal view; read the event in intent: DOM.select('.cls').events('${m[1].slice(2).toLowerCase()}')`];
1248
+ }
1249
+ /**
1250
+ * sucraseCompiler(load): `compile` with sucrase (the app's own dependency: sygnal never imports it).
1251
+ * `load` is the module or `() => import('sucrase')`, so the compiler loads with the first part.
1252
+ */
1253
+ function sucraseCompiler(load) {
1254
+ let mod = null;
1255
+ return async (code) => {
1256
+ const s = await (mod ?? (mod = Promise.resolve(typeof load == 'function' ? load() : load)));
1257
+ const transform = s.transform || s.default?.transform;
1258
+ return transform(code, { transforms: ['jsx', 'imports'], jsxPragma: '__h', jsxFragmentPragma: '__frag', production: true }).code;
1259
+ };
1260
+ }
1261
+ function generatedRuntime(o) {
1262
+ if (!o || typeof o.kernel != 'string' || !o.kernel)
1263
+ throw new TypeError("sygnal/sandbox generatedRuntime: `kernel` is required (import kernel from 'sygnal/sandbox/kernel?raw')");
1264
+ if (typeof o.compile != 'function')
1265
+ throw new TypeError("sygnal/sandbox generatedRuntime: `compile` is required, e.g. sucraseCompiler(load), where load is the app's own dynamic import of sucrase");
1266
+ const limits = limitsOf({ ...GENERATED_LIMITS, ...o.limits });
1267
+ const stats = { mounted: 0, live: 0, compiles: 0, compileMs: 0 };
1268
+ /** compile and check: `{ ok, source }`, or `{ ok: false, error }` with what a repair turn needs */
1269
+ async function compile(code, checks = true) {
1270
+ const t0 = performance.now();
1271
+ stats.compiles++;
1272
+ try {
1273
+ const source = await o.compile(code);
1274
+ if (checks) {
1275
+ const issues = [...lintGenerated(code), ...((o.check && (await o.check(code))) || [])];
1276
+ if (issues.length)
1277
+ return { ok: false, error: issues.join('\n') };
1278
+ }
1279
+ return { ok: true, source };
1280
+ }
1281
+ catch (e) {
1282
+ // a compiler's first lines name line:column
1283
+ return { ok: false, error: msg(e).split('\n').slice(0, 4).join('\n') };
1284
+ }
1285
+ finally {
1286
+ stats.compileMs += performance.now() - t0;
1287
+ }
1288
+ }
1289
+ function start(el, props, dispatch, w) {
1290
+ const gen = w.gen = (w.gen || 0) + 1;
1291
+ w.code = props.code;
1292
+ w.failed = false;
1293
+ w.sb = null;
1294
+ const live = () => w.gen === gen && !w.gone;
1295
+ const fail = (reason, message, stop = false) => {
1296
+ if (w.failed || !live())
1297
+ return;
1298
+ w.failed = true;
1299
+ if (stop)
1300
+ w.sb?.dispose();
1301
+ dispatch('sandboxerror', { reason, message });
1302
+ };
1303
+ const code = String(props.code ?? '');
1304
+ // over the limit is a failure the repair loop hears about, never silently cut code
1305
+ if (props.maxCode && code.length > props.maxCode)
1306
+ return fail('compile', `the code is over the limit of ${props.maxCode} characters; write a shorter part`);
1307
+ api.compile(code, props.checks !== false).then((c) => {
1308
+ if (!live())
1309
+ return;
1310
+ if (!c.ok)
1311
+ return fail('compile', c.error);
1312
+ const lim = props.limits ? limitsOf({ ...limits, ...props.limits }) : limits;
1313
+ const t0 = performance.now();
1314
+ stats.mounted++;
1315
+ stats.live++;
1316
+ let counted = true;
1317
+ const done = () => { if (counted) {
1318
+ counted = false;
1319
+ stats.live--;
1320
+ } };
1321
+ let sb;
1322
+ try {
1323
+ sb = w.sb = createSandbox({
1324
+ kernel: o.kernel, source: c.source, container: el, isolation: props.isolation ?? 'worker', emits: props.emits, props: props.input ?? null,
1325
+ limits: lim, policy: props.policy, quickjs: o.quickjs, worker: o.worker, wasm: o.wasm,
1326
+ capabilities: {
1327
+ ...props.capabilities,
1328
+ log: (level, text) => {
1329
+ if (live()) {
1330
+ if (level == 'error')
1331
+ fail('runtime', text);
1332
+ else if (level == 'warn' && /^\[Sygnal SYG101\]/.test(text))
1333
+ fail('check', text.replace(/^\[Sygnal (SYG\d+)\]\s*/, '$1: '), true);
1334
+ }
1335
+ o.log?.(level, text);
1336
+ },
1337
+ },
1338
+ onEmit: (ev) => { if (live())
1339
+ dispatch('emit', ev); },
1340
+ onReject: (r) => { if (live())
1341
+ dispatch('sandboxreject', r); },
1342
+ onError: (e) => { done(); fail(e.reason, e.message); },
1343
+ });
1344
+ }
1345
+ catch (e) {
1346
+ done();
1347
+ return fail('error', msg(e));
1348
+ }
1349
+ const dispose = sb.dispose.bind(sb);
1350
+ sb.dispose = () => { done(); dispose(); };
1351
+ sb.ready.then(() => {
1352
+ if (w.failed || !live())
1353
+ return;
1354
+ const s = { tier: props.isolation ?? 'worker', readyMs: performance.now() - t0, bootMs: sb.stats.bootMs || 0 };
1355
+ // drawn: text or a control below the root (a view that returns null leaves an empty root)
1356
+ const drawn = () => { const r = el.querySelector('[data-sandbox-root]'); return !!r && ((r.textContent || '').trim() !== '' || !!r.querySelector('input, select, textarea, img, progress, meter, svg')); };
1357
+ // a view stopped by the boot deadline: the core's error boundary catches QuickJS's
1358
+ // 'interrupted' and the deadline stops its log too, so the box lives on, empty
1359
+ if (s.bootMs >= lim.bootMs * 0.9 && !drawn())
1360
+ return fail('timeout', `the component ran into the ${lim.bootMs} ms boot deadline (an endless loop?)`, true);
1361
+ if (drawn())
1362
+ return dispatch('sandboxready', s);
1363
+ // the first render can trail ready (the core's macrotask hop is a host timer): look again
1364
+ setTimeout(() => {
1365
+ if (w.failed || !live())
1366
+ return;
1367
+ drawn() ? dispatch('sandboxready', s) : fail('empty', 'the component rendered nothing', true);
1368
+ }, EMPTY_GRACE_MS);
1369
+ }, () => { });
1370
+ });
1371
+ }
1372
+ const Box = defineWidget({
1373
+ tag: 'div',
1374
+ events: ['emit', 'sandboxready', 'sandboxerror', 'sandboxreject'],
1375
+ ownProps: ['code', 'maxCode', 'emits', 'input', 'isolation', 'limits', 'policy', 'capabilities', 'checks'],
1376
+ mount(el, props, dispatch) {
1377
+ if (!props.emits || typeof props.emits != 'object' || !Object.values(props.emits).every(isSchema)) {
1378
+ throw new TypeError('sygnal/sandbox generated Box: `emits` is required: { TYPE: a Standard Schema } for every event the code may send');
1379
+ }
1380
+ const w = { el, dispatch, inputJson: JSON.stringify(props.input ?? null) };
1381
+ start(el, props, dispatch, w);
1382
+ return w;
1383
+ },
1384
+ update(w, props) {
1385
+ if (props.code !== w.code) {
1386
+ // a repaired or edited part: a new box in the same element
1387
+ w.sb?.dispose();
1388
+ w.inputJson = JSON.stringify(props.input ?? null);
1389
+ return start(w.el, props, w.dispatch, w);
1390
+ }
1391
+ // a host render passes a new input object each time: only a changed value crosses the bridge
1392
+ const json = JSON.stringify(props.input ?? null);
1393
+ if (json !== w.inputJson) {
1394
+ w.inputJson = json;
1395
+ w.sb?.setProps(props.input ?? null);
1396
+ }
1397
+ },
1398
+ unmount(w) { w.gone = true; w.sb?.dispose(); },
1399
+ });
1400
+ // the Box compiles through api.compile, so a wrapper (a cache, a test's spy) sees every mount
1401
+ const api = { Box, compile, limits, stats, kernel: o.kernel };
1402
+ return api;
1403
+ }
1404
+
1405
+ export { DEFAULT_LIMITS, DEFAULT_POLICY, GENERATED_LIMITS, PROTOCOL, SandboxError, Sandboxed, createSandbox, emitGate, generatedRuntime, lintGenerated, loadQuickJS, poisoned, renderToHTML, sandboxed, sanitize, sucraseCompiler, treeToHTML };
1406
+ //# sourceMappingURL=sandbox.esm.js.map