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.
- package/CHANGELOG.md +555 -0
- package/dist/agent-ui.cjs.js +592 -0
- package/dist/agent-ui.cjs.js.map +1 -0
- package/dist/agent-ui.esm.js +582 -0
- package/dist/agent-ui.esm.js.map +1 -0
- package/dist/api.cjs.js +63 -0
- package/dist/api.cjs.js.map +1 -0
- package/dist/api.esm.js +2 -0
- package/dist/api.esm.js.map +1 -0
- package/dist/astro/index.cjs.js +968 -4
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +968 -4
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +95 -71
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +95 -71
- package/dist/astro/server.mjs.map +1 -1
- package/dist/canvas.cjs.js +2919 -0
- package/dist/canvas.cjs.js.map +1 -0
- package/dist/canvas.esm.js +2866 -0
- package/dist/canvas.esm.js.map +1 -0
- package/dist/devtools.cjs.js +38 -6
- package/dist/devtools.cjs.js.map +1 -1
- package/dist/devtools.esm.js +38 -6
- package/dist/devtools.esm.js.map +1 -1
- package/dist/diagnostics.cjs.js +164 -23
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +164 -23
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/ai-compose.md +150 -0
- package/dist/guide/behaviors.md +2 -1
- package/dist/guide/collaborative-state.md +265 -0
- package/dist/guide/element-commands.md +36 -2
- package/dist/guide/forms-reference.md +5 -5
- package/dist/guide/forms.md +3 -2
- package/dist/guide/http.md +138 -9
- package/dist/guide/islands.md +240 -0
- package/dist/guide/recipes/code-editor.md +1 -1
- package/dist/guide/renderers/agent-ui.md +138 -0
- package/dist/guide/renderers/custom-targets.md +126 -0
- package/dist/guide/renderers/overview.md +120 -0
- package/dist/guide/renderers/react-native.md +128 -0
- package/dist/guide/renderers/terminal.md +306 -0
- package/dist/guide/renderers/three.md +332 -0
- package/dist/guide/renderers/write-once.md +137 -0
- package/dist/guide/resources.md +114 -6
- package/dist/guide/sandboxed-components.md +223 -0
- package/dist/guide/ssr.md +5 -3
- package/dist/guide/timers.md +29 -2
- package/dist/guide/typed-apis.md +442 -0
- package/dist/guide/ui/accordion.md +11 -0
- package/dist/guide/ui/combobox.md +112 -8
- package/dist/guide/ui/dialog.md +96 -20
- package/dist/guide/ui/menu.md +57 -5
- package/dist/guide/ui/overview.md +8 -4
- package/dist/guide/ui/popover.md +69 -9
- package/dist/guide/ui/select.md +54 -4
- package/dist/guide/ui/tabs.md +1 -1
- package/dist/guide/ui/tooltip.md +7 -5
- package/dist/guide/virtual-collections.md +3 -0
- package/dist/guide/widgets.md +90 -1
- package/dist/host.cjs.js +2187 -0
- package/dist/host.cjs.js.map +1 -0
- package/dist/host.esm.js +2152 -0
- package/dist/host.esm.js.map +1 -0
- package/dist/index.cjs.js +4430 -863
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +646 -43
- package/dist/index.esm.js +4402 -863
- package/dist/index.esm.js.map +1 -1
- package/dist/primitives.cjs.js +143 -0
- package/dist/primitives.cjs.js.map +1 -0
- package/dist/primitives.esm.js +133 -0
- package/dist/primitives.esm.js.map +1 -0
- package/dist/react-native.cjs.js +248 -0
- package/dist/react-native.cjs.js.map +1 -0
- package/dist/react-native.esm.js +244 -0
- package/dist/react-native.esm.js.map +1 -0
- package/dist/replay.cjs.js +39 -0
- package/dist/replay.cjs.js.map +1 -0
- package/dist/replay.esm.js +2 -0
- package/dist/replay.esm.js.map +1 -0
- package/dist/sandbox-build.cjs.js +140 -0
- package/dist/sandbox-build.cjs.js.map +1 -0
- package/dist/sandbox-build.esm.js +137 -0
- package/dist/sandbox-build.esm.js.map +1 -0
- package/dist/sandbox-kernel.esm.js +212 -0
- package/dist/sandbox-kernel.esm.js.map +1 -0
- package/dist/sandbox-worker.cjs.js +857 -0
- package/dist/sandbox-worker.cjs.js.map +1 -0
- package/dist/sandbox-worker.esm.js +854 -0
- package/dist/sandbox-worker.esm.js.map +1 -0
- package/dist/sandbox.cjs.js +1425 -0
- package/dist/sandbox.cjs.js.map +1 -0
- package/dist/sandbox.esm.js +1406 -0
- package/dist/sandbox.esm.js.map +1 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/terminal.cjs.js +1314 -0
- package/dist/terminal.cjs.js.map +1 -0
- package/dist/terminal.esm.js +1304 -0
- package/dist/terminal.esm.js.map +1 -0
- package/dist/three.cjs.js +1111 -0
- package/dist/three.cjs.js.map +1 -0
- package/dist/three.esm.js +1079 -0
- package/dist/three.esm.js.map +1 -0
- package/dist/ui-combobox.cjs.js +256 -18
- package/dist/ui-combobox.cjs.js.map +1 -1
- package/dist/ui-combobox.esm.js +256 -18
- package/dist/ui-combobox.esm.js.map +1 -1
- package/dist/ui-menu.cjs.js +150 -16
- package/dist/ui-menu.cjs.js.map +1 -1
- package/dist/ui-menu.esm.js +151 -17
- package/dist/ui-menu.esm.js.map +1 -1
- package/dist/ui-select.cjs.js +95 -9
- package/dist/ui-select.cjs.js.map +1 -1
- package/dist/ui-select.esm.js +95 -9
- package/dist/ui-select.esm.js.map +1 -1
- package/dist/ui.cjs.js +679 -173
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.esm.js +679 -175
- package/dist/ui.esm.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vite/plugin.cjs.js +968 -4
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +968 -4
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/worker-page.cjs.js +669 -0
- package/dist/worker-page.cjs.js.map +1 -0
- package/dist/worker-page.esm.js +665 -0
- package/dist/worker-page.esm.js.map +1 -0
- package/dist/worker.cjs.js +377 -0
- package/dist/worker.cjs.js.map +1 -0
- package/dist/worker.esm.js +374 -0
- package/dist/worker.esm.js.map +1 -0
- package/dist/yjs-testing.cjs.js +98 -0
- package/dist/yjs-testing.cjs.js.map +1 -0
- package/dist/yjs-testing.esm.js +77 -0
- package/dist/yjs-testing.esm.js.map +1 -0
- package/dist/yjs.cjs.js +923 -0
- package/dist/yjs.cjs.js.map +1 -0
- package/dist/yjs.esm.js +900 -0
- package/dist/yjs.esm.js.map +1 -0
- package/dist/zag.cjs.js +11 -1
- package/dist/zag.cjs.js.map +1 -1
- package/dist/zag.esm.js +11 -1
- package/dist/zag.esm.js.map +1 -1
- package/llms/ai.md +65 -0
- package/llms/collaboration.md +5 -0
- package/llms/graphics.md +10 -0
- package/llms/renderers.md +7 -0
- package/llms/replay.md +5 -0
- package/llms/sandbox.md +5 -0
- package/llms/typed-apis.md +5 -0
- package/llms/ui.md +10 -0
- package/llms-full.txt +444 -0
- package/llms.txt +35 -33
- package/package.json +139 -3
- package/src/agent-ui/mcp.ts +152 -0
- package/src/agent-ui/snapshot.ts +212 -0
- package/src/agent-ui.d.ts +106 -0
- package/src/agent-ui.ts +249 -0
- package/src/ai.d.ts +95 -2
- package/src/api.d.ts +303 -0
- package/src/api.ts +9 -0
- package/src/canvas/Canvas.ts +585 -0
- package/src/canvas/backend.ts +627 -0
- package/src/canvas/geometry.ts +187 -0
- package/src/canvas/gesture.ts +23 -0
- package/src/canvas/image.ts +197 -0
- package/src/canvas/kit.ts +130 -0
- package/src/canvas/paint.ts +260 -0
- package/src/canvas/pointer.ts +25 -0
- package/src/canvas/snap.ts +68 -0
- package/src/canvas/svg.ts +99 -0
- package/src/canvas.d.ts +373 -0
- package/src/canvas.ts +46 -0
- package/src/core/actions.ts +14 -3
- package/src/core/cell.ts +13 -5
- package/src/core/debug.ts +8 -3
- package/src/core/hooks.ts +16 -2
- package/src/core/hosts/collection.ts +2 -2
- package/src/core/instance.ts +91 -41
- package/src/core/markers/clientonly.ts +3 -3
- package/src/core/markers/portal.ts +25 -11
- package/src/core/registry.ts +4 -0
- package/src/core/runtime.ts +34 -25
- package/src/cycle/dom/EventDelegator.ts +37 -33
- package/src/cycle/dom/MainDOMSource.ts +16 -14
- package/src/cycle/dom/enrichEventStream.ts +13 -9
- package/src/cycle/dom/fromEvent.ts +6 -10
- package/src/cycle/dom/index.ts +11 -7
- package/src/cycle/dom/isolate.ts +16 -7
- package/src/cycle/dom/makeDOMDriver.ts +10 -4
- package/src/cycle/dom/mockDOMSource.ts +3 -3
- package/src/cycle/dom/propsModule.ts +5 -1
- package/src/cycle/dom/utils.ts +9 -2
- package/src/cycle/run/adapt.ts +4 -12
- package/src/devtools.d.ts +9 -0
- package/src/devtools.ts +2 -1
- package/src/extra/ai/compose/catalog.ts +2 -1
- package/src/extra/ai/compose/decisions.ts +1 -2
- package/src/extra/ai/compose/generated.ts +234 -0
- package/src/extra/ai/compose/index.ts +6 -2
- package/src/extra/ai/compose/layout.ts +3 -1
- package/src/extra/ai/compose/llm.ts +7 -3
- package/src/extra/ai/compose/types.ts +2 -2
- package/src/extra/ai/transports/shared.ts +3 -44
- package/src/extra/api/contract.ts +485 -0
- package/src/extra/api/index.ts +26 -0
- package/src/extra/controlled.ts +44 -0
- package/src/extra/copyAsTest.ts +22 -0
- package/src/extra/devtools.ts +5 -3
- package/src/extra/devtoolsActions.ts +8 -0
- package/src/extra/devtoolsNext.ts +1 -1
- package/src/extra/diagnostics/checks/actionLog.ts +8 -3
- package/src/extra/diagnostics/checks/behaviors.ts +25 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +17 -5
- package/src/extra/diagnostics/checks/fetch.ts +1 -0
- package/src/extra/diagnostics/checks/index.ts +1 -1
- package/src/extra/diagnostics/checks/next.ts +35 -4
- package/src/extra/diagnostics/checks/replies.ts +11 -7
- package/src/extra/diagnostics/checks/timers.ts +8 -2
- package/src/extra/diagnostics/checks/virtual.ts +11 -0
- package/src/extra/diagnostics/codes.ts +41 -2
- package/src/extra/elementCommands.ts +20 -4
- package/src/extra/fetchDriver.ts +181 -40
- package/src/extra/fetchEventSource.ts +1 -1
- package/src/extra/field.ts +29 -0
- package/src/extra/form.ts +42 -16
- package/src/extra/frame.ts +54 -0
- package/src/extra/keymap.ts +41 -0
- package/src/extra/pager.ts +2 -0
- package/src/extra/persist.ts +2 -1
- package/src/extra/pointer.ts +262 -0
- package/src/extra/processForm.ts +34 -1
- package/src/extra/queryCache.ts +40 -10
- package/src/extra/replay/address.ts +49 -0
- package/src/extra/replay/entropy.ts +99 -0
- package/src/extra/replay/format.ts +263 -0
- package/src/extra/replay/index.ts +8 -0
- package/src/extra/replay/inert.ts +22 -0
- package/src/extra/replay/recorder.ts +247 -0
- package/src/extra/replay/replayer.ts +234 -0
- package/src/extra/replay/report.ts +126 -0
- package/src/extra/replay/schema.ts +52 -0
- package/src/extra/replay/version.ts +3 -0
- package/src/extra/router.ts +7 -3
- package/src/extra/selection.ts +2 -0
- package/src/extra/sse.ts +48 -0
- package/src/extra/ssr.ts +132 -76
- package/src/extra/testing.ts +360 -41
- package/src/extra/timers.ts +91 -5
- package/src/extra/undo.ts +2 -0
- package/src/extra/virtual.ts +28 -3
- package/src/extra/widget.ts +105 -21
- package/src/host/actions.ts +383 -0
- package/src/host/delegation.ts +101 -0
- package/src/host/dom.ts +553 -0
- package/src/host/driver.ts +185 -0
- package/src/host/selector.ts +119 -0
- package/src/host/style.ts +51 -0
- package/src/host.d.ts +298 -0
- package/src/host.ts +46 -0
- package/src/index.d.ts +363 -36
- package/src/index.ts +13 -3
- package/src/pragma/blocks.ts +211 -0
- package/src/pragma/index.ts +44 -10
- package/src/primitives.d.ts +54 -0
- package/src/primitives.ts +136 -0
- package/src/react-native.d.ts +57 -0
- package/src/react-native.ts +285 -0
- package/src/react-peers.d.ts +21 -3
- package/src/replay.d.ts +185 -0
- package/src/replay.ts +6 -0
- package/src/sandbox/build.ts +119 -0
- package/src/sandbox/create.ts +187 -0
- package/src/sandbox/engine.ts +267 -0
- package/src/sandbox/generated.ts +195 -0
- package/src/sandbox/kernel.ts +180 -0
- package/src/sandbox/mirror.ts +269 -0
- package/src/sandbox/protocol.ts +128 -0
- package/src/sandbox/quickjs.ts +74 -0
- package/src/sandbox/ssr.ts +41 -0
- package/src/sandbox/widget.ts +60 -0
- package/src/sandbox/worker-entry.ts +59 -0
- package/src/sandbox/worker.ts +137 -0
- package/src/sandbox-build.d.ts +12 -0
- package/src/sandbox-build.ts +6 -0
- package/src/sandbox.d.ts +299 -0
- package/src/sandbox.ts +25 -0
- package/src/shared.ts +12 -0
- package/src/terminal/paint.ts +712 -0
- package/src/terminal.d.ts +167 -0
- package/src/terminal.ts +530 -0
- package/src/three/assets.ts +170 -0
- package/src/three/host.ts +36 -0
- package/src/three/scene.ts +217 -0
- package/src/three/target.ts +252 -0
- package/src/three.d.ts +221 -0
- package/src/three.ts +30 -0
- package/src/ui/accordion.ts +21 -7
- package/src/ui/dialog.ts +192 -90
- package/src/ui/disclosure.ts +2 -0
- package/src/ui/dom.ts +30 -0
- package/src/ui/layers.ts +132 -0
- package/src/ui/popover.ts +120 -21
- package/src/ui/shared.ts +98 -12
- package/src/ui/stack.ts +18 -0
- package/src/ui/tabs.ts +2 -0
- package/src/ui/toaster.ts +16 -15
- package/src/ui/tooltip.ts +24 -8
- package/src/ui/tooltips.ts +26 -14
- package/src/ui/zag/combobox.ts +180 -13
- package/src/ui/zag/menu.ts +97 -17
- package/src/ui/zag/select.ts +15 -4
- package/src/ui/zag/shared.ts +60 -8
- package/src/ui-combobox.d.ts +16 -1
- package/src/ui-menu.d.ts +22 -7
- package/src/ui-zag-types.d.ts +13 -5
- package/src/ui.d.ts +62 -7
- package/src/ui.ts +2 -2
- package/src/vite/api.ts +186 -0
- package/src/vite/compile.ts +280 -0
- package/src/vite/plugin.d.ts +34 -0
- package/src/vite/plugin.ts +78 -4
- package/src/vite/sandbox.ts +16 -0
- package/src/worker/page.ts +396 -0
- package/src/worker/protocol.ts +48 -0
- package/src/worker/target.ts +282 -0
- package/src/worker-page.d.ts +42 -0
- package/src/worker-page.ts +12 -0
- package/src/worker.d.ts +65 -0
- package/src/worker.ts +16 -0
- package/src/yjs/driver.ts +253 -0
- package/src/yjs/mapping.ts +389 -0
- package/src/yjs/shared.ts +138 -0
- package/src/yjs/testing.ts +91 -0
- package/src/yjs-testing.d.ts +34 -0
- package/src/yjs-testing.ts +7 -0
- package/src/yjs.d.ts +109 -0
- package/src/yjs.ts +18 -0
- package/src/zag.d.ts +2 -0
- 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) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''} 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
|
+
}
|