@luispm/zflow-graph 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/SECURITY.md +67 -0
- package/dist/adapters/yjs.esm.js +238 -0
- package/dist/adapters/yjs.esm.min.js +2 -0
- package/dist/adapters/yjs.umd.js +246 -0
- package/dist/webgl-renderer.esm.js +376 -0
- package/dist/webgl-renderer.esm.min.js +2 -0
- package/dist/webgl-renderer.umd.js +384 -0
- package/dist/zflow.esm.js +4365 -0
- package/dist/zflow.esm.js.map +1 -0
- package/dist/zflow.esm.min.js +2 -0
- package/dist/zflow.umd.js +4375 -0
- package/dist/zflow.umd.js.map +1 -0
- package/dist/zflow.umd.min.js +2 -0
- package/dist/zflow.wasm +0 -0
- package/docs/01-getting-started.md +210 -0
- package/docs/02-runtime.md +257 -0
- package/docs/03-kinds.md +282 -0
- package/docs/04-performance.md +218 -0
- package/docs/05-plugins.md +235 -0
- package/docs/06-multiplayer.md +180 -0
- package/docs/07-recipes.md +340 -0
- package/docs/08-api.md +436 -0
- package/docs/README.md +61 -0
- package/package.json +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis G.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# @luispm/zflow-graph
|
|
2
|
+
|
|
3
|
+
> WASM-powered node-edge graph editor + execution runtime. No framework. 100k nodes at 60 fps. Built-in multiplayer, WebGL, sub-flows.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@luispm/zflow-graph)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Most graph libraries make you choose:
|
|
9
|
+
|
|
10
|
+
- React Flow looks polished but can't pass ~5k nodes and depends on React
|
|
11
|
+
- ComfyUI runs flows but isn't a library
|
|
12
|
+
- Drawio is the size of a small OS
|
|
13
|
+
- tldraw is great at sketches but doesn't know what a port is
|
|
14
|
+
|
|
15
|
+
**@luispm/zflow-graph picks none of those tradeoffs.** It is a single self-contained ES module backed by a Zig→WASM core that ships in ~200 KB and runs in any browser tab, Electron, or Tauri-style desktop shell. It is an editor *and* a runtime.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @luispm/zflow-graph
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { ZFlow } from '@luispm/zflow-graph';
|
|
25
|
+
|
|
26
|
+
const flow = await ZFlow.create({
|
|
27
|
+
container: document.getElementById('app'),
|
|
28
|
+
wasmUrl: '/node_modules/@luispm/zflow-graph/dist/zflow.wasm',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Register a kind with an executable body.
|
|
32
|
+
flow.registerKind({
|
|
33
|
+
name: 'double',
|
|
34
|
+
nin: 1, nout: 1,
|
|
35
|
+
portIn: ['value'], portOut: ['value'],
|
|
36
|
+
execute: (ctx, ins) => ({ value: ins.value * 2 }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const a = flow.addNode({ kind: 'input', x: -200, y: 0, title: '21' });
|
|
40
|
+
const b = flow.addNode({ kind: 'double', x: 0, y: 0 });
|
|
41
|
+
const c = flow.addNode({ kind: 'output', x: 200, y: 0, title: 'Result' });
|
|
42
|
+
|
|
43
|
+
flow.addEdge({ from: a, to: b });
|
|
44
|
+
flow.addEdge({ from: b, to: c });
|
|
45
|
+
|
|
46
|
+
flow.setNodeInput(a, { value: 21 });
|
|
47
|
+
await flow.run(); // → c.value === 42
|
|
48
|
+
console.log(flow.getNodeValue(c));
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why use it
|
|
52
|
+
|
|
53
|
+
| Capability | @luispm/zflow-graph | React Flow | tldraw | Drawio |
|
|
54
|
+
| --------------------------------------- | :---------: | :--------: | :----: | :----: |
|
|
55
|
+
| WASM core (no framework) | ✅ | ❌ | ❌ | ❌ |
|
|
56
|
+
| 100k nodes @ 60 fps | ✅ | ❌ | ⚠️ | ❌ |
|
|
57
|
+
| Built-in graph execution runtime | ✅ | ❌ | ❌ | ❌ |
|
|
58
|
+
| Real CRDT multiplayer (Yjs adapter) | ✅ | ❌ (Pro) | ✅ | ❌ |
|
|
59
|
+
| WebGL renderer (instanced, opt-in) | ✅ | ❌ | ✅ | ❌ |
|
|
60
|
+
| Touch + pinch + pen | ✅ | ✅ | ✅ | ⚠️ |
|
|
61
|
+
| Sub-flows reusable as kinds | ✅ | ❌ (Pro) | ❌ | ❌ |
|
|
62
|
+
| Streaming async generator nodes | ✅ | ❌ | ❌ | ❌ |
|
|
63
|
+
| Inline expressions `{{node_X.value}}` | ✅ | ❌ | ❌ | ❌ |
|
|
64
|
+
| Schema type validation on edges | ✅ | ✅ | ❌ | ❌ |
|
|
65
|
+
| Plugin lifecycle hooks | ✅ | ⚠️ React | ❌ | ✅ |
|
|
66
|
+
| Mermaid + DOT import | ✅ | ❌ | ❌ | ✅ |
|
|
67
|
+
| Critical-path / SCC / cycles | ✅ | ❌ | ❌ | ❌ |
|
|
68
|
+
| Bundle gz | ~140 KB | 180 KB | 320 KB | 1.1 MB |
|
|
69
|
+
| Framework dep | None | React | React | none |
|
|
70
|
+
|
|
71
|
+
## Architecture in 30 seconds
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
┌─────────────────────────────────────────────────────────┐
|
|
75
|
+
│ Your app │
|
|
76
|
+
│ ├─ import { ZFlow } from '@luispm/zflow-graph' │
|
|
77
|
+
│ └─ flow.addNode(), flow.run(), flow.on(...) │
|
|
78
|
+
├─────────────────────────────────────────────────────────┤
|
|
79
|
+
│ zflow.js (~180 KB ES module, no deps) │
|
|
80
|
+
│ Canvas2D renderer ◄─ overlay text/UI ─┐ │
|
|
81
|
+
│ WebGL renderer (opt-in, instanced) ◄──┘ │
|
|
82
|
+
│ Runtime: topo, async, retry, memo, streaming, debug │
|
|
83
|
+
│ Plugin lifecycle · Yjs adapter · expression evaluator │
|
|
84
|
+
├─────────────────────────────────────────────────────────┤
|
|
85
|
+
│ zflow.wasm (~740 KB Zig WASM) │
|
|
86
|
+
│ SoA storage · spatial grid · snapshot undo │
|
|
87
|
+
│ Sugiyama + force layouts · SCC + critical-path │
|
|
88
|
+
│ Zero-copy Float32/Uint32 views into linear memory │
|
|
89
|
+
└─────────────────────────────────────────────────────────┘
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The JS holds typed-array **views** over WASM linear memory — there is no copy on read. The WASM never grows its memory after init, so views stay valid forever.
|
|
93
|
+
|
|
94
|
+
## Concepts
|
|
95
|
+
|
|
96
|
+
### Kinds
|
|
97
|
+
A kind is the type of a node: its color, shape, ports, and optionally its executable body.
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
flow.registerKind({
|
|
101
|
+
name: 'http-request',
|
|
102
|
+
color: '#5b8def', badge: 'H', w: 180, h: 70,
|
|
103
|
+
inputs: [{ name: 'url', type: 'string' }],
|
|
104
|
+
outputs: [{ name: 'body', type: 'string' }],
|
|
105
|
+
retry: { n: 3, delay: 500 },
|
|
106
|
+
execute: async (ctx, ins) => {
|
|
107
|
+
ctx.setProgress(0.3);
|
|
108
|
+
const res = await fetch(ins.url, { signal: ctx.signal });
|
|
109
|
+
return { body: await res.text() };
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### The runtime
|
|
115
|
+
Calling `flow.run()` walks the graph in topological order, calling each node's `execute(ctx, inputs)`. Outputs flow through edges. The runtime supports:
|
|
116
|
+
|
|
117
|
+
- **Async** — `execute` may return a `Promise`
|
|
118
|
+
- **Streaming** — `execute` may return an `AsyncGenerator` that `yield`s multiple values
|
|
119
|
+
- **Retry** — declarative `retry: { n, delay }`
|
|
120
|
+
- **Memoization** — `flow.setMemoization(true)` skips nodes whose inputs hash matches the previous run
|
|
121
|
+
- **Abort** — `flow.stop()` propagates an `AbortSignal` to every `ctx.signal`
|
|
122
|
+
- **Breakpoints** — `flow.setBreakpoint(id)` pauses before exec; `flow.stepOver()` to advance
|
|
123
|
+
|
|
124
|
+
### Multiplayer (Yjs)
|
|
125
|
+
Real-time co-editing via [Yjs](https://github.com/yjs/yjs). The adapter is opt-in — `yjs` is **not** a runtime dependency unless you import the adapter.
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
import { bindYjs } from '@luispm/zflow-graph/adapters/yjs';
|
|
129
|
+
import * as Y from 'yjs';
|
|
130
|
+
import { WebsocketProvider } from 'y-websocket';
|
|
131
|
+
|
|
132
|
+
const ydoc = new Y.Doc();
|
|
133
|
+
const provider = new WebsocketProvider('wss://demos.yjs.dev/ws', 'my-room', ydoc);
|
|
134
|
+
bindYjs(flow, ydoc, {
|
|
135
|
+
userName: 'Alice',
|
|
136
|
+
color: '#c062e8',
|
|
137
|
+
awareness: provider.awareness,
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Open two tabs of your app: nodes, edges, drags, and cursors sync at ~30 Hz.
|
|
142
|
+
|
|
143
|
+
### Performance: opt-in WebGL
|
|
144
|
+
For graphs beyond ~5k nodes, enable the WebGL renderer:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
await flow.enableWebGL(); // auto-enables past options.webglThreshold (default 2000)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The GL path uses `ANGLE_instanced_arrays` to paint every node body in a **single draw call**. Canvas2D continues to handle text, ports, badges, and UI on top. Pan/zoom is uniform-only — zero buffer uploads.
|
|
151
|
+
|
|
152
|
+
### Sub-flows as kinds
|
|
153
|
+
Wrap a group of nodes inside a frame, then turn that frame into a reusable kind:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
const frameId = flow.addFrame(0, 0, 400, 200, 'auth pipeline').id;
|
|
157
|
+
// ... add nodes inside the frame ...
|
|
158
|
+
const kindName = flow.registerSubflowFromFrame(frameId, { name: 'authPipe' });
|
|
159
|
+
|
|
160
|
+
// Now you can instantiate the whole sub-flow as a single node anywhere:
|
|
161
|
+
flow.addNode({ kind: 'authPipe', x: 600, y: 100 });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The library auto-detects inputs and outputs of the sub-flow based on which inner nodes lack inside-graph predecessors / successors.
|
|
165
|
+
|
|
166
|
+
## API at a glance
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
// Lifecycle
|
|
170
|
+
const flow = await ZFlow.create({ container, wasmUrl });
|
|
171
|
+
flow.dispose();
|
|
172
|
+
|
|
173
|
+
// Mutation
|
|
174
|
+
flow.addNode(spec) / addEdge(spec) / deleteSelection() / moveNode(id, x, y)
|
|
175
|
+
flow.addNodesBulk(specs) / addEdgesBulk(specs) // batch (50k nodes in ~50ms)
|
|
176
|
+
|
|
177
|
+
// Selection
|
|
178
|
+
flow.setSelected(id, on) / toggleSelected(id) / clearSelection() / selectAll()
|
|
179
|
+
flow.getSelection()
|
|
180
|
+
|
|
181
|
+
// Rich content
|
|
182
|
+
flow.setNodeTitle / Description / Color / Tags / Status / Progress
|
|
183
|
+
flow.setNodeImage / Checked / Tasks / Icon / Links
|
|
184
|
+
|
|
185
|
+
// Runtime
|
|
186
|
+
flow.registerKind({ name, execute, retry, inputs, outputs, ... })
|
|
187
|
+
flow.run({ from?, filter?, signal? })
|
|
188
|
+
flow.runFrom(nodeId) / runFrame(frameId)
|
|
189
|
+
flow.stop() / startLoop(ms) / stopLoop()
|
|
190
|
+
flow.setBreakpoint(id) / stepOver() / resume() / isPaused()
|
|
191
|
+
flow.setNodeInput(id, value) / getNodeValue(id)
|
|
192
|
+
flow.setNodeParams(id, params) // for built-in kinds: const, if
|
|
193
|
+
flow.evalExpression('{{node_3.value}} * 2')
|
|
194
|
+
|
|
195
|
+
// Algorithms
|
|
196
|
+
flow.shortestPath(from, to) / criticalPath() / findSCCs() / findCycles()
|
|
197
|
+
|
|
198
|
+
// Serialization
|
|
199
|
+
flow.toJSON() / loadJSON(data) / exportSVG() / exportPNG()
|
|
200
|
+
|
|
201
|
+
// Imports
|
|
202
|
+
flow.importMermaid(text) / importDot(text)
|
|
203
|
+
|
|
204
|
+
// Layout
|
|
205
|
+
flow.runAutoLayout() / runForceLayout() / fitView() / zoomTo() / panTo()
|
|
206
|
+
|
|
207
|
+
// Plugins
|
|
208
|
+
flow.use({ init, onNodeAdd, onBeforeExec, ... })
|
|
209
|
+
|
|
210
|
+
// Multiplayer (separate import)
|
|
211
|
+
import { bindYjs } from '@luispm/zflow-graph/adapters/yjs'
|
|
212
|
+
|
|
213
|
+
// Performance
|
|
214
|
+
await flow.enableWebGL() / disableWebGL()
|
|
215
|
+
flow.setMemoization(true)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Documentation
|
|
219
|
+
|
|
220
|
+
Full guides in [`docs/`](./docs):
|
|
221
|
+
1. [Getting Started](./docs/01-getting-started.md) — first 15 minutes
|
|
222
|
+
2. [The Runtime](./docs/02-runtime.md) — make your graph actually compute
|
|
223
|
+
3. [Designing Kinds](./docs/03-kinds.md) — schemas, ports, async, streaming
|
|
224
|
+
4. [Performance at Scale](./docs/04-performance.md) — WebGL, bulk, LOD, 100k nodes
|
|
225
|
+
5. [Plugin System](./docs/05-plugins.md) — lifecycle hooks
|
|
226
|
+
6. [Multiplayer (Yjs)](./docs/06-multiplayer.md) — real-time co-editing
|
|
227
|
+
7. [Recipes](./docs/07-recipes.md) — paste-and-run examples
|
|
228
|
+
8. [API Reference](./docs/08-api.md) — every method, every event
|
|
229
|
+
|
|
230
|
+
See the [examples folder](./examples) for working demos:
|
|
231
|
+
- `basic.html` — 3-node minimum
|
|
232
|
+
- `custom-kinds.html` — Plugin API
|
|
233
|
+
- `showcase.html` — full feature parade
|
|
234
|
+
- `runtime.html` — graph execution live
|
|
235
|
+
- `multiplayer.html` — Yjs CRDT in two tabs
|
|
236
|
+
- `powers.html` — schema validation + touch + WebGL
|
|
237
|
+
- `plugins-and-debug.html` — lifecycle hooks + breakpoints + sub-flows
|
|
238
|
+
- `stress.html` — 50k+ nodes WebGL benchmark
|
|
239
|
+
|
|
240
|
+
## Loading WASM
|
|
241
|
+
|
|
242
|
+
By default, `ZFlow.create({ wasmUrl })` fetches the WASM. For inline / offline scenarios, pre-load and pass bytes:
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
const wasmBytes = await fetch('./zflow.wasm').then(r => r.arrayBuffer());
|
|
246
|
+
const flow = await ZFlow.create({ container, wasmBytes });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If you bundle, copy `node_modules/@luispm/zflow-graph/dist/zflow.wasm` to your `public/` or static-asset directory and point `wasmUrl` at it.
|
|
250
|
+
|
|
251
|
+
## Limits
|
|
252
|
+
|
|
253
|
+
- Hard cap of 100,000 nodes / 200,000 edges per instance (compile-time in the WASM core)
|
|
254
|
+
- Spatial grid covers ±8192 world units; nodes outside this range are still selectable but not in `queryRect` results
|
|
255
|
+
- Snapshot-based undo keeps the last 8 states — large graphs make snapshots costly
|
|
256
|
+
- The WebGL renderer requires `ANGLE_instanced_arrays` (essentially every browser since 2014); falls back to per-node draws otherwise
|
|
257
|
+
- Yjs adapter sync rate is throttled to 30 Hz on position changes
|
|
258
|
+
|
|
259
|
+
## Building from source
|
|
260
|
+
|
|
261
|
+
You need Zig 0.16+ and Node 18+.
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
git clone https://github.com/luisg/@luispm/zflow-graph
|
|
265
|
+
cd @luispm/zflow-graph
|
|
266
|
+
npm install
|
|
267
|
+
zig build # produces dist/zflow.wasm
|
|
268
|
+
npm run build:js # produces dist/zflow.{esm,umd}{,.min}.js
|
|
269
|
+
npm test # 47 tests across 6 files
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Security
|
|
273
|
+
|
|
274
|
+
See [SECURITY.md](./SECURITY.md) for the honest threat model. TL;DR:
|
|
275
|
+
|
|
276
|
+
- All user-controlled strings in DOM overlays are HTML-escaped to prevent XSS.
|
|
277
|
+
- Zero runtime dependencies. Yjs is opt-in.
|
|
278
|
+
- `flow.use(plugin)` and `kind.execute` run with full page privileges — only install plugins you trust.
|
|
279
|
+
- `evalExpression()` uses `new Function` — do not pass expressions from untrusted users.
|
|
280
|
+
- Client-side JavaScript is **always readable**. There is no technical way to hide it. Use a license, or move sensitive logic to a server.
|
|
281
|
+
- Minified bundles ship **without sourcemaps** to avoid leaking the source to CDN deployments.
|
|
282
|
+
|
|
283
|
+
To report a vulnerability: `luis.padre21@gmail.com` with `[@luispm/zflow-graph security]` in subject.
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Security model
|
|
2
|
+
|
|
3
|
+
## What zflow-graph is
|
|
4
|
+
|
|
5
|
+
A **client-side** ES module + WASM that renders and executes node graphs. It runs entirely in the browser (or any embedded WebView). It has no server component, no network calls of its own, and no privileged APIs.
|
|
6
|
+
|
|
7
|
+
## What zflow-graph is **not**
|
|
8
|
+
|
|
9
|
+
- Not a sandbox for untrusted code. Functions passed to `kind.execute` run with **the same privileges as your page**.
|
|
10
|
+
- Not a secret store. Anything you put in `flow.toJSON()`, including `setNodeParams`, is visible in the browser.
|
|
11
|
+
- Not obfuscated. Minified JS is readable in 2026 by any LLM in seconds. We do not pretend otherwise.
|
|
12
|
+
|
|
13
|
+
## Threat model
|
|
14
|
+
|
|
15
|
+
| Threat | Mitigated by zflow? | Notes |
|
|
16
|
+
| ----------------------------------------------------- | :-----------------: | ----- |
|
|
17
|
+
| XSS via user-controlled node titles / descriptions | ✅ | All user strings rendered inside DOM overlays (preview popover, expression editor, context menu, autocomplete) are escaped with an internal `escapeHtml`. Canvas2D rendering is inherently text-safe (it's pixels). |
|
|
18
|
+
| Code IP theft (someone reads your bundle) | ❌ | **Impossible to fully prevent on the web.** Use license (MIT/GPL/proprietary) for legal recourse. Move sensitive logic to a server you control. |
|
|
19
|
+
| Supply-chain attack via `npm install zflow-graph` | ✅ | This package has **zero runtime dependencies**. Yjs is opt-in and only loaded if you import the adapter explicitly. Pin versions and audit with `npm audit`. |
|
|
20
|
+
| Untrusted plugin via `flow.use(plugin)` | ❌ | Plugins run with full access to the flow and the host page. **Only install plugins you trust.** Treat them like NPM packages. |
|
|
21
|
+
| Untrusted `kind.execute` body | ❌ | Same as above — anything in `execute` runs with page privileges. Don't load executors from user input. |
|
|
22
|
+
| Untrusted Mermaid / DOT / JSON imports | ✅ | The parsers are string-based — no `eval` or `new Function`. Worst case is malformed graph data. |
|
|
23
|
+
| Untrusted `evalExpression` input | ⚠️ | `evalExpression` uses `new Function` internally. **Do not pass expressions from untrusted users.** Use it for editor templates, not for user-submitted expressions. |
|
|
24
|
+
| CSP (Content Security Policy) compatibility | ⚠️ | `evalExpression` requires `unsafe-eval` because of `new Function`. If your CSP forbids it, disable expression evaluation or fork to use a safe expression parser. |
|
|
25
|
+
| WASM integrity | ✅ | The WASM is loaded by URL or pre-fetched bytes. If you want SRI-style integrity, hash the file at build time and verify before `WebAssembly.instantiate`. |
|
|
26
|
+
|
|
27
|
+
## Recommended host-side hardening
|
|
28
|
+
|
|
29
|
+
If you embed zflow-graph in an app, **the surrounding security work is your responsibility**:
|
|
30
|
+
|
|
31
|
+
```http
|
|
32
|
+
Content-Security-Policy:
|
|
33
|
+
default-src 'self';
|
|
34
|
+
script-src 'self' 'wasm-unsafe-eval'; # WASM needs wasm-unsafe-eval
|
|
35
|
+
worker-src 'self' blob:;
|
|
36
|
+
connect-src 'self' wss://your-yjs-relay.example;
|
|
37
|
+
img-src 'self' data: https:; # if you setNodeImage with external URLs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Notes:
|
|
41
|
+
- `wasm-unsafe-eval` is required to load `zflow.wasm`.
|
|
42
|
+
- `unsafe-eval` is required if you call `evalExpression()`. If you don't, leave it out.
|
|
43
|
+
- For Subresource Integrity of the WASM:
|
|
44
|
+
```js
|
|
45
|
+
const buf = await fetch('zflow.wasm').then(r => r.arrayBuffer());
|
|
46
|
+
const hash = await crypto.subtle.digest('SHA-256', buf);
|
|
47
|
+
if (toHex(hash) !== EXPECTED_HASH) throw new Error('WASM tampered');
|
|
48
|
+
const flow = await ZFlow.create({ container, wasmBytes: new Uint8Array(buf) });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Sourcemaps
|
|
52
|
+
|
|
53
|
+
- Sourcemaps are **only emitted for the non-minified bundles** (`*.esm.js`, `*.umd.js`).
|
|
54
|
+
- The minified bundles (`*.min.js`) ship **without sourcemaps** so accidentally deploying `dist/` to a CDN does not expose the full original source.
|
|
55
|
+
- To opt in to maps on minified builds for your own debugging, run `ZFLOW_MAPS=1 npm run build:js`.
|
|
56
|
+
|
|
57
|
+
## Reporting a vulnerability
|
|
58
|
+
|
|
59
|
+
Email **luis.padre21@gmail.com** with `[zflow-graph security]` in the subject. Please do not open public issues for security reports.
|
|
60
|
+
|
|
61
|
+
We aim to acknowledge within 72 hours. Critical issues get a patch within 7 days.
|
|
62
|
+
|
|
63
|
+
## What we will *not* do
|
|
64
|
+
|
|
65
|
+
- Add code obfuscation. It is performance overhead with no real security benefit. If you need IP protection, keep that code on a server.
|
|
66
|
+
- Add a "license check" that calls home. zflow-graph is MIT — once you have it, you have it.
|
|
67
|
+
- Promise that your secrets stay secret if you ship them in `flow.toJSON()`. They won't. Use a server.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
|
|
2
|
+
// zflow ↔ Yjs adapter — real multiplayer for the graph.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { ZFlow } from '../zflow.js';
|
|
6
|
+
// import { bindYjs } from '../adapters/yjs.js';
|
|
7
|
+
// import * as Y from 'yjs';
|
|
8
|
+
// import { WebsocketProvider } from 'y-websocket';
|
|
9
|
+
//
|
|
10
|
+
// const flow = await ZFlow.create({ container, wasmUrl });
|
|
11
|
+
// const ydoc = new Y.Doc();
|
|
12
|
+
// new WebsocketProvider('wss://demos.yjs.dev', 'my-room', ydoc);
|
|
13
|
+
// const binding = bindYjs(flow, ydoc, { userId: 'alice', userName: 'Alice', color: '#c062e8' });
|
|
14
|
+
//
|
|
15
|
+
// What it syncs:
|
|
16
|
+
// • Nodes (Y.Map keyed by stable client-side uuid → { id, kind, x, y, w, h, title, color, ... })
|
|
17
|
+
// • Edges (Y.Map keyed by uuid → { from, to, fp, tp, label })
|
|
18
|
+
// • Awareness (cursor position, selection, name, color)
|
|
19
|
+
//
|
|
20
|
+
// Conflict policy: last-write-wins per field via Y.Map. Position updates are
|
|
21
|
+
// throttled to ~30 Hz so dragging produces ~smooth remote motion without
|
|
22
|
+
// flooding the wire.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function bindYjs(flow, ydoc, opts = {}) {
|
|
26
|
+
const userId = opts.userId || 'user-' + Math.random().toString(36).slice(2, 8);
|
|
27
|
+
const userName = opts.userName || userId;
|
|
28
|
+
const userColor = opts.color || pickColor(userId);
|
|
29
|
+
|
|
30
|
+
const ynodes = ydoc.getMap('zflow.nodes');
|
|
31
|
+
const yedges = ydoc.getMap('zflow.edges');
|
|
32
|
+
const ymeta = ydoc.getMap('zflow.meta');
|
|
33
|
+
const aware = opts.awareness || null; // y-protocols/awareness.Awareness, if provided
|
|
34
|
+
|
|
35
|
+
// Bidirectional mapping between local numeric ids and stable Y uuids.
|
|
36
|
+
const localToUuid = new Map(); // nodeId -> uuid
|
|
37
|
+
const uuidToLocal = new Map(); // uuid -> nodeId
|
|
38
|
+
const edgeLocalToUuid = new Map();
|
|
39
|
+
const edgeUuidToLocal = new Map();
|
|
40
|
+
|
|
41
|
+
let applyingRemote = false; // re-entrancy guard
|
|
42
|
+
let pendingPosFlush = null; // throttle handle
|
|
43
|
+
|
|
44
|
+
// ── Local → Remote ─────────────────────────────────────────────────
|
|
45
|
+
// We intercept the high-level mutators by wrapping the WASM exports so
|
|
46
|
+
// every change locally also writes to Yjs.
|
|
47
|
+
const origAddNode = flow.addNode.bind(flow);
|
|
48
|
+
flow.addNode = (spec = {}) => {
|
|
49
|
+
const id = origAddNode(spec);
|
|
50
|
+
if (id < 0 || applyingRemote) return id;
|
|
51
|
+
const uuid = newUuid();
|
|
52
|
+
localToUuid.set(id, uuid);
|
|
53
|
+
uuidToLocal.set(uuid, id);
|
|
54
|
+
ynodes.set(uuid, captureNode(flow, id, spec));
|
|
55
|
+
return id;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const origAddEdge = flow.addEdge.bind(flow);
|
|
59
|
+
flow.addEdge = (spec = {}) => {
|
|
60
|
+
const id = origAddEdge(spec);
|
|
61
|
+
if (id < 0 || applyingRemote) return id;
|
|
62
|
+
const uuid = newUuid();
|
|
63
|
+
edgeLocalToUuid.set(id, uuid);
|
|
64
|
+
edgeUuidToLocal.set(uuid, id);
|
|
65
|
+
const fromU = localToUuid.get(typeof spec.from === 'number' ? spec.from : -1);
|
|
66
|
+
const toU = localToUuid.get(typeof spec.to === 'number' ? spec.to : -1);
|
|
67
|
+
yedges.set(uuid, { from: fromU, to: toU, fp: spec.fp ?? 0, tp: spec.tp ?? 0, label: spec.label || null });
|
|
68
|
+
return id;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Intercept deleteSelection so each removed local id pulls its uuid out of Y.
|
|
72
|
+
const origDelete = flow.deleteSelection.bind(flow);
|
|
73
|
+
flow.deleteSelection = () => {
|
|
74
|
+
if (applyingRemote) return origDelete();
|
|
75
|
+
const toRemove = [];
|
|
76
|
+
for (let i = 0; i < flow.w.nodeCount_(); i++) if (flow.V.selected[i]) toRemove.push(i);
|
|
77
|
+
origDelete();
|
|
78
|
+
// Local ids shift after delete; clear the affected uuid mappings by re-scan.
|
|
79
|
+
ydoc.transact(() => {
|
|
80
|
+
for (const localId of toRemove) {
|
|
81
|
+
const uuid = localToUuid.get(localId);
|
|
82
|
+
if (uuid) { ynodes.delete(uuid); localToUuid.delete(localId); uuidToLocal.delete(uuid); }
|
|
83
|
+
}
|
|
84
|
+
}, 'local-delete');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Throttle dragging updates.
|
|
88
|
+
flow.on('change', () => {
|
|
89
|
+
if (applyingRemote) return;
|
|
90
|
+
if (pendingPosFlush) return;
|
|
91
|
+
pendingPosFlush = setTimeout(() => {
|
|
92
|
+
pendingPosFlush = null;
|
|
93
|
+
ydoc.transact(() => {
|
|
94
|
+
for (const [localId, uuid] of localToUuid) {
|
|
95
|
+
if (localId >= flow.w.nodeCount_()) continue;
|
|
96
|
+
const cur = ynodes.get(uuid);
|
|
97
|
+
if (!cur) continue;
|
|
98
|
+
const next = captureNode(flow, localId);
|
|
99
|
+
if (cur.x !== next.x || cur.y !== next.y || cur.w !== next.w || cur.h !== next.h ||
|
|
100
|
+
cur.title !== next.title || cur.color !== next.color) {
|
|
101
|
+
ynodes.set(uuid, { ...cur, ...next });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, 'local-pos');
|
|
105
|
+
}, 33);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Remote → Local ─────────────────────────────────────────────────
|
|
109
|
+
ynodes.observe((event) => {
|
|
110
|
+
if (event.transaction.origin === 'local-pos') return;
|
|
111
|
+
applyingRemote = true;
|
|
112
|
+
try {
|
|
113
|
+
event.changes.keys.forEach((change, uuid) => {
|
|
114
|
+
if (change.action === 'add') { addRemoteNode(uuid, ynodes.get(uuid)); }
|
|
115
|
+
if (change.action === 'update') { updateRemoteNode(uuid, ynodes.get(uuid)); }
|
|
116
|
+
if (change.action === 'delete') {
|
|
117
|
+
const localId = uuidToLocal.get(uuid);
|
|
118
|
+
if (localId !== undefined) {
|
|
119
|
+
flow.w.setSelected(localId, 1);
|
|
120
|
+
const orig = flow.deleteSelection;
|
|
121
|
+
flow.deleteSelection = origDelete; // bypass intercept
|
|
122
|
+
try { flow.deleteSelection(); }
|
|
123
|
+
finally { flow.deleteSelection = orig; }
|
|
124
|
+
localToUuid.delete(localId);
|
|
125
|
+
uuidToLocal.delete(uuid);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
} finally { applyingRemote = false; }
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
yedges.observe((event) => {
|
|
133
|
+
applyingRemote = true;
|
|
134
|
+
try {
|
|
135
|
+
event.changes.keys.forEach((change, uuid) => {
|
|
136
|
+
if (change.action === 'add') {
|
|
137
|
+
const e = yedges.get(uuid);
|
|
138
|
+
const from = uuidToLocal.get(e.from), to = uuidToLocal.get(e.to);
|
|
139
|
+
if (from !== undefined && to !== undefined) {
|
|
140
|
+
const localId = origAddEdge({ from, to, fp: e.fp, tp: e.tp, label: e.label });
|
|
141
|
+
edgeLocalToUuid.set(localId, uuid);
|
|
142
|
+
edgeUuidToLocal.set(uuid, localId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (change.action === 'delete') {
|
|
146
|
+
// Local-side deletion isn't wired through a single API yet; leave as a TODO.
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} finally { applyingRemote = false; }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Awareness (cursors + selection) ────────────────────────────────
|
|
153
|
+
if (aware) {
|
|
154
|
+
aware.setLocalStateField('user', { name: userName, color: userColor });
|
|
155
|
+
flow.canvas.addEventListener('mousemove', (e) => {
|
|
156
|
+
const wp = flow._s2w(e.clientX, e.clientY);
|
|
157
|
+
aware.setLocalStateField('cursor', { x: wp.x, y: wp.y });
|
|
158
|
+
});
|
|
159
|
+
aware.on('change', () => {
|
|
160
|
+
const states = aware.getStates();
|
|
161
|
+
flow.clearRemoteCursors();
|
|
162
|
+
for (const [clientId, state] of states) {
|
|
163
|
+
if (clientId === aware.clientID) continue;
|
|
164
|
+
const u = state.user || {}, c = state.cursor;
|
|
165
|
+
if (c) flow.setRemoteCursor(String(clientId), c.x, c.y, u.name || String(clientId), u.color || '#5be0d0');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Initial backfill: pull whatever's already in the Y.Doc ─────────
|
|
171
|
+
applyingRemote = true;
|
|
172
|
+
try {
|
|
173
|
+
for (const [uuid, spec] of ynodes.entries()) addRemoteNode(uuid, spec);
|
|
174
|
+
for (const [uuid, spec] of yedges.entries()) {
|
|
175
|
+
const from = uuidToLocal.get(spec.from), to = uuidToLocal.get(spec.to);
|
|
176
|
+
if (from !== undefined && to !== undefined) {
|
|
177
|
+
const localId = origAddEdge({ from, to, fp: spec.fp, tp: spec.tp, label: spec.label });
|
|
178
|
+
edgeLocalToUuid.set(localId, uuid);
|
|
179
|
+
edgeUuidToLocal.set(uuid, localId);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} finally { applyingRemote = false; }
|
|
183
|
+
|
|
184
|
+
// ── Public adapter handle ──────────────────────────────────────────
|
|
185
|
+
return {
|
|
186
|
+
ynodes, yedges, ymeta, ydoc,
|
|
187
|
+
userId, userName, userColor,
|
|
188
|
+
destroy() {
|
|
189
|
+
flow.addNode = origAddNode;
|
|
190
|
+
flow.addEdge = origAddEdge;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
195
|
+
function addRemoteNode(uuid, spec) {
|
|
196
|
+
if (uuidToLocal.has(uuid)) return;
|
|
197
|
+
const localId = origAddNode({
|
|
198
|
+
kind: spec.kind, x: spec.x, y: spec.y,
|
|
199
|
+
w: spec.w, h: spec.h, title: spec.title, color: spec.color,
|
|
200
|
+
});
|
|
201
|
+
if (localId < 0) return;
|
|
202
|
+
localToUuid.set(localId, uuid);
|
|
203
|
+
uuidToLocal.set(uuid, localId);
|
|
204
|
+
}
|
|
205
|
+
function updateRemoteNode(uuid, spec) {
|
|
206
|
+
const localId = uuidToLocal.get(uuid);
|
|
207
|
+
if (localId === undefined) return;
|
|
208
|
+
if (spec.x !== undefined && spec.y !== undefined) {
|
|
209
|
+
flow.V.posX[localId] = spec.x; flow.V.posY[localId] = spec.y;
|
|
210
|
+
}
|
|
211
|
+
if (spec.w !== undefined) flow.V.sizeW[localId] = spec.w;
|
|
212
|
+
if (spec.h !== undefined) flow.V.sizeH[localId] = spec.h;
|
|
213
|
+
if (spec.title) flow.titles.set(localId, spec.title);
|
|
214
|
+
if (spec.color) flow.colors.set(localId, spec.color);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function captureNode(flow, id, spec = {}) {
|
|
219
|
+
const cat = flow.kinds[flow.V.kind[id]];
|
|
220
|
+
return {
|
|
221
|
+
kind: cat.name,
|
|
222
|
+
x: flow.V.posX[id], y: flow.V.posY[id],
|
|
223
|
+
w: flow.V.sizeW[id], h: flow.V.sizeH[id],
|
|
224
|
+
title: flow.titles.get(id) || spec.title || null,
|
|
225
|
+
color: flow.colors.get(id) || spec.color || null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function newUuid() {
|
|
229
|
+
return 'u_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
230
|
+
}
|
|
231
|
+
const PALETTE = ['#5b8def', '#c062e8', '#5bd17a', '#f0b93a', '#5be0d0', '#fb923c', '#e8462b'];
|
|
232
|
+
function pickColor(seed) {
|
|
233
|
+
let h = 0; for (const ch of seed) h = (h * 31 + ch.charCodeAt(0)) | 0;
|
|
234
|
+
return PALETTE[Math.abs(h) % PALETTE.length];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export { bindYjs };
|
|
238
|
+
//# sourceMappingURL=yjs.esm.js.map
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
|
|
2
|
+
function e(e,s,l={}){const r=l.userId||"user-"+Math.random().toString(36).slice(2,8),c=l.userName||r,i=l.color||function(e){let t=0;for(const o of e)t=31*t+o.charCodeAt(0)|0;return n[Math.abs(t)%n.length]}(r),d=s.getMap("zflow.nodes"),a=s.getMap("zflow.edges"),f=s.getMap("zflow.meta"),u=l.awareness||null,g=new Map,p=new Map,y=new Map,m=new Map;let b=!1,h=null;const w=e.addNode.bind(e);e.addNode=(n={})=>{const s=w(n);if(s<0||b)return s;const l=o();return g.set(s,l),p.set(l,s),d.set(l,t(e,s,n)),s};const v=e.addEdge.bind(e);e.addEdge=(e={})=>{const t=v(e);if(t<0||b)return t;const n=o();y.set(t,n),m.set(n,t);const s=g.get("number"==typeof e.from?e.from:-1),l=g.get("number"==typeof e.to?e.to:-1);return a.set(n,{from:s,to:l,fp:e.fp??0,tp:e.tp??0,label:e.label||null}),t};const S=e.deleteSelection.bind(e);e.deleteSelection=()=>{if(b)return S();const t=[];for(let o=0;o<e.w.nodeCount_();o++)e.V.selected[o]&&t.push(o);S(),s.transact(()=>{for(const e of t){const t=g.get(e);t&&(d.delete(t),g.delete(e),p.delete(t))}},"local-delete")},e.on("change",()=>{b||h||(h=setTimeout(()=>{h=null,s.transact(()=>{for(const[o,n]of g){if(o>=e.w.nodeCount_())continue;const s=d.get(n);if(!s)continue;const l=t(e,o);s.x===l.x&&s.y===l.y&&s.w===l.w&&s.h===l.h&&s.title===l.title&&s.color===l.color||d.set(n,{...s,...l})}},"local-pos")},33))}),d.observe(t=>{if("local-pos"!==t.transaction.origin){b=!0;try{t.changes.keys.forEach((t,o)=>{if("add"===t.action&&x(o,d.get(o)),"update"===t.action&&function(t,o){const n=p.get(t);if(void 0===n)return;void 0!==o.x&&void 0!==o.y&&(e.V.posX[n]=o.x,e.V.posY[n]=o.y);void 0!==o.w&&(e.V.sizeW[n]=o.w);void 0!==o.h&&(e.V.sizeH[n]=o.h);o.title&&e.titles.set(n,o.title);o.color&&e.colors.set(n,o.color)}(o,d.get(o)),"delete"===t.action){const t=p.get(o);if(void 0!==t){e.w.setSelected(t,1);const n=e.deleteSelection;e.deleteSelection=S;try{e.deleteSelection()}finally{e.deleteSelection=n}g.delete(t),p.delete(o)}}})}finally{b=!1}}}),a.observe(e=>{b=!0;try{e.changes.keys.forEach((e,t)=>{if("add"===e.action){const e=a.get(t),o=p.get(e.from),n=p.get(e.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:e.fp,tp:e.tp,label:e.label});y.set(s,t),m.set(t,s)}}e.action})}finally{b=!1}}),u&&(u.setLocalStateField("user",{name:c,color:i}),e.canvas.addEventListener("mousemove",t=>{const o=e._s2w(t.clientX,t.clientY);u.setLocalStateField("cursor",{x:o.x,y:o.y})}),u.on("change",()=>{const t=u.getStates();e.clearRemoteCursors();for(const[o,n]of t){if(o===u.clientID)continue;const t=n.user||{},s=n.cursor;s&&e.setRemoteCursor(String(o),s.x,s.y,t.name||String(o),t.color||"#5be0d0")}})),b=!0;try{for(const[e,t]of d.entries())x(e,t);for(const[e,t]of a.entries()){const o=p.get(t.from),n=p.get(t.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:t.fp,tp:t.tp,label:t.label});y.set(s,e),m.set(e,s)}}}finally{b=!1}return{ynodes:d,yedges:a,ymeta:f,ydoc:s,userId:r,userName:c,userColor:i,destroy(){e.addNode=w,e.addEdge=v}};function x(e,t){if(p.has(e))return;const o=w({kind:t.kind,x:t.x,y:t.y,w:t.w,h:t.h,title:t.title,color:t.color});o<0||(g.set(o,e),p.set(e,o))}}function t(e,t,o={}){return{kind:e.kinds[e.V.kind[t]].name,x:e.V.posX[t],y:e.V.posY[t],w:e.V.sizeW[t],h:e.V.sizeH[t],title:e.titles.get(t)||o.title||null,color:e.colors.get(t)||o.color||null}}function o(){return"u_"+Math.random().toString(36).slice(2,10)+Date.now().toString(36)}const n=["#5b8def","#c062e8","#5bd17a","#f0b93a","#5be0d0","#fb923c","#e8462b"];export{e as bindYjs};
|