@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# 4 · Performance at Scale
|
|
2
|
+
|
|
3
|
+
zflow-graph is engineered to keep 60 fps at 100k nodes. This isn't an accident — it's the result of specific architectural choices you should understand if you want to push it.
|
|
4
|
+
|
|
5
|
+
## What runs where
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
JS heap WASM linear memory
|
|
9
|
+
───────────── ──────────────────
|
|
10
|
+
canvas2d ctx pos_x[] ← Float32 view
|
|
11
|
+
WebGL context (opt) pos_y[] ← Float32 view
|
|
12
|
+
event listeners size_w[] ← Float32 view
|
|
13
|
+
plugin instances size_h[] ← Float32 view
|
|
14
|
+
metric history selected[] ← Uint8 view
|
|
15
|
+
notes, frames (small) edge_from[] ← Uint32 view
|
|
16
|
+
edge_to[] ← Uint32 view
|
|
17
|
+
spatial grid ← Zig-only
|
|
18
|
+
undo stack ← Zig-only
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Reading `flow.V.posX[i]` is a **direct memory access** into WASM. There is no copy.
|
|
22
|
+
|
|
23
|
+
Mutating these arrays from JS works too — `flow.V.posX[5] = 100` moves node 5. But invalidate caches with `flow._gl?.markNodeDirty(5)` if WebGL is on.
|
|
24
|
+
|
|
25
|
+
## The three speeds
|
|
26
|
+
|
|
27
|
+
### Speed 1: small graphs (< 1k nodes)
|
|
28
|
+
Canvas2D handles everything. Pan, zoom, drag, rich text rendering — all at 60 fps. Don't even think about WebGL.
|
|
29
|
+
|
|
30
|
+
### Speed 2: medium graphs (1k – 5k nodes)
|
|
31
|
+
Canvas2D still works but starts to wobble with shadows and gradients. Two options:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
flow.options.edgeFlowSpeed = 0; // disable particle animation
|
|
35
|
+
flow.options.hoverPreview = false; // disable popover
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or just turn on WebGL:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
await flow.enableWebGL();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Speed 3: large graphs (> 5k nodes)
|
|
45
|
+
WebGL is mandatory. Canvas2D for the body + edges is too slow. With WebGL:
|
|
46
|
+
|
|
47
|
+
- All node bodies → **one** instanced draw call
|
|
48
|
+
- All edges → **one** line draw call
|
|
49
|
+
- Text/badges/ports → still Canvas2D, but **LOD-gated** (skipped below zoom 0.4)
|
|
50
|
+
- Camera moves → uniform-only, **zero buffer uploads**
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const flow = await ZFlow.create({
|
|
54
|
+
container,
|
|
55
|
+
wasmUrl: '/zflow.wasm',
|
|
56
|
+
webglThreshold: 2000, // auto-enable past this many nodes
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Bulk operations
|
|
61
|
+
|
|
62
|
+
Adding nodes one by one is fine for ~100. Above that, bulk:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
// SLOW — emits change + runs hooks + dirties everything 50,000 times
|
|
66
|
+
for (let i = 0; i < 50000; i++) flow.addNode({ kind: 'process', x: i * 10, y: 0 });
|
|
67
|
+
|
|
68
|
+
// FAST — same result in ~50ms
|
|
69
|
+
flow.addNodesBulk(
|
|
70
|
+
Array.from({ length: 50000 }, (_, i) => ({ kind: 'process', x: i * 10, y: 0 }))
|
|
71
|
+
);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Same for edges:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
flow.addEdgesBulk(specs);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both return arrays of created ids in input order. They suspend event emission during the loop and emit a single `change` at the end.
|
|
81
|
+
|
|
82
|
+
## LOD (Level of Detail)
|
|
83
|
+
|
|
84
|
+
The library auto-degrades detail when zoom is low:
|
|
85
|
+
|
|
86
|
+
| Zoom | What renders |
|
|
87
|
+
| ------------ | --------------------------------------------- |
|
|
88
|
+
| `> 0.4` | Everything: text, ports, badges, shadows, descriptions, sparklines |
|
|
89
|
+
| `0.35 – 0.4` | Skip text. Ports + selection visible. |
|
|
90
|
+
| `0.25 – 0.35`| Skip ports too. Bodies + edges only. |
|
|
91
|
+
| `< 0.25` | Skip canvas overlay entirely. WebGL bodies only. |
|
|
92
|
+
|
|
93
|
+
With > 5,000 nodes, the threshold is bumped to `0.55` automatically — the rationale is that 5k nodes at zoom 0.4 are unreadable anyway.
|
|
94
|
+
|
|
95
|
+
You can override:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
// Currently the thresholds are internal constants. To override, fork the lib
|
|
99
|
+
// or skip your own detail-drawing in a plugin's beforeRender hook.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Viewport culling
|
|
103
|
+
|
|
104
|
+
Two layers:
|
|
105
|
+
|
|
106
|
+
1. **Spatial grid in Zig** — `queryRect(minX, minY, maxX, maxY)` returns node ids whose AABB intersects the rect. O(1) average, O(k) where k = visible nodes. The library uses this for any graph > 300 nodes.
|
|
107
|
+
|
|
108
|
+
2. **JS frustum check** — for every returned id, verify it's actually inside the viewport. Cheap second pass.
|
|
109
|
+
|
|
110
|
+
This means the cost of rendering doesn't scale with total nodes — it scales with **visible** nodes. Zoomed out so 5 nodes are visible? You pay for 5 draws (plus the GL buffer which is constant per-frame).
|
|
111
|
+
|
|
112
|
+
## Memory profile
|
|
113
|
+
|
|
114
|
+
At 100k nodes, the WASM uses ~40 MB of linear memory:
|
|
115
|
+
|
|
116
|
+
- Node SoA: ~5 MB (8 typed arrays × 100k × varying widths)
|
|
117
|
+
- Edge SoA: ~3 MB (200k edges)
|
|
118
|
+
- Spatial grid: ~1 MB
|
|
119
|
+
- Snapshot stack (8 deep): ~33 MB
|
|
120
|
+
|
|
121
|
+
JS-side maps (titles, descriptions, colors) only allocate for nodes that actually have those fields. With `null` rich content, JS heap is < 5 MB even at 100k nodes.
|
|
122
|
+
|
|
123
|
+
If you need more than 100k nodes per graph, you have two options:
|
|
124
|
+
- Edit `NODE_CAP` in `src/core.zig` and rebuild WASM
|
|
125
|
+
- Use multiple flow instances and link them via a parent UI
|
|
126
|
+
|
|
127
|
+
## Drag performance
|
|
128
|
+
|
|
129
|
+
When you drag N selected nodes, the runtime needs to mark all edges touching them as dirty (so the GL bezier buffer regenerates). The naive implementation is `O(N × edges)` — for 25 nodes × 200k edges = 5M ops per frame, unusable.
|
|
130
|
+
|
|
131
|
+
zflow caches an adjacency map per-node:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
flow._ensureAdj(); // O(edges) once
|
|
135
|
+
flow._nodeAdj[id] // → [edgeIdx, edgeIdx, ...] for that node
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Drag becomes `O(N × avgDegree)` which is microseconds. The cache invalidates on add/delete edge.
|
|
139
|
+
|
|
140
|
+
## Memoization
|
|
141
|
+
|
|
142
|
+
For grids with many sources that don't change often:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
flow.setMemoization(true);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Each node's input hash (FNV-1a) is recorded after exec. On the next run, if the hash matches, the node is skipped and `node:cached` fires. Fast (~80µs per node for 1k-entry inputs).
|
|
149
|
+
|
|
150
|
+
Caveats:
|
|
151
|
+
- Skipped nodes don't update sparkline / status
|
|
152
|
+
- If `execute` has side effects (DB writes, network), you don't want this on
|
|
153
|
+
- Memoization invalidates per-node, not graph-wide — a downstream node may still re-run if its inputs hash changed
|
|
154
|
+
|
|
155
|
+
## Streaming + downstream propagation
|
|
156
|
+
|
|
157
|
+
Each `yield` from an async generator propagates through downstream nodes **synchronously** before the next yield. This means 10 yields × 4 downstream nodes = 40 executions, all in sequence.
|
|
158
|
+
|
|
159
|
+
If you need parallel pipelines, use separate streams:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
const a = flow.addNode({ kind: 'stream' });
|
|
163
|
+
const b = flow.addNode({ kind: 'stream' });
|
|
164
|
+
// They run independently — flow.run() will start both in parallel because
|
|
165
|
+
// async generators are awaited per node, but the topo walker processes them
|
|
166
|
+
// in order. For true parallelism, use Promise.all in your executor.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Web Workers (manual)
|
|
170
|
+
|
|
171
|
+
The library runs entirely on the main thread. For CPU-heavy executors:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
flow.registerKind({
|
|
175
|
+
name: 'heavy-compute',
|
|
176
|
+
execute: async (ctx, ins) => {
|
|
177
|
+
const worker = new Worker('./heavy-worker.js', { type: 'module' });
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
worker.onmessage = (e) => { worker.terminate(); resolve(e.data); };
|
|
180
|
+
worker.onerror = reject;
|
|
181
|
+
ctx.signal.addEventListener('abort', () => worker.terminate());
|
|
182
|
+
worker.postMessage(ins);
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
(Future versions may move auto-layout and force-layout to workers automatically.)
|
|
189
|
+
|
|
190
|
+
## Profiling
|
|
191
|
+
|
|
192
|
+
Easy fps monitor:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
let frames = 0, t0 = performance.now();
|
|
196
|
+
const orig = flow._loop.bind(flow);
|
|
197
|
+
flow._loop = () => { frames++; orig(); };
|
|
198
|
+
setInterval(() => {
|
|
199
|
+
console.log('fps:', frames * 1000 / (performance.now() - t0));
|
|
200
|
+
frames = 0; t0 = performance.now();
|
|
201
|
+
}, 500);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Or use the Chrome DevTools Performance tab. Look for:
|
|
205
|
+
- `_render` taking > 16ms per frame → CPU bound, try WebGL
|
|
206
|
+
- `_drawNode` calls > 1000/frame → enable viewport culling (already on > 300 nodes)
|
|
207
|
+
- Long GC pauses → check if you're allocating in a render hook
|
|
208
|
+
|
|
209
|
+
## What we don't optimize (yet)
|
|
210
|
+
|
|
211
|
+
- **Layout for huge graphs** — Sugiyama and force layout both run on main thread. Above 5k nodes they freeze the UI. Run them in a worker for now.
|
|
212
|
+
- **Streaming with very high frequency** — yielding > 200x/sec saturates the event loop with bubble animations. Throttle inside your generator.
|
|
213
|
+
- **HTML overlay nodes** — each one is a real DOM element. Don't create 1000 of them. Use canvas-rendered kinds at scale, HTML only for special-case interactive nodes.
|
|
214
|
+
- **Search at very large graphs** — `flow.search(query)` is O(n). For > 10k nodes, build your own index.
|
|
215
|
+
|
|
216
|
+
## Next
|
|
217
|
+
|
|
218
|
+
→ [Plugins](./05-plugins.md) — extend behavior without forking
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# 5 · Plugin System
|
|
2
|
+
|
|
3
|
+
Plugins are how you extend zflow without forking. A plugin is a plain object with optional lifecycle hooks.
|
|
4
|
+
|
|
5
|
+
## Anatomy
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
const myPlugin = {
|
|
9
|
+
name: 'autosave', // for logging / display
|
|
10
|
+
|
|
11
|
+
init: (flow) => { /* called once on use() */ },
|
|
12
|
+
dispose: (flow) => { /* called when removed */ },
|
|
13
|
+
|
|
14
|
+
// Render hooks
|
|
15
|
+
beforeRender: (flow, ctx) => {},
|
|
16
|
+
afterRender: (flow, ctx) => {},
|
|
17
|
+
|
|
18
|
+
// Mutation hooks
|
|
19
|
+
onNodeAdd: (flow, nodeId, spec) => {},
|
|
20
|
+
onNodeDelete: (flow, nodeId) => {},
|
|
21
|
+
onEdgeAdd: (flow, edgeIdx, spec) => {},
|
|
22
|
+
|
|
23
|
+
// Runtime hooks
|
|
24
|
+
onBeforeExec: (flow, nodeId, inputs) => {}, // return false → skip exec
|
|
25
|
+
onAfterExec: (flow, nodeId, output) => {},
|
|
26
|
+
|
|
27
|
+
// Other
|
|
28
|
+
onConnect: (flow, fromN, fp, toN, tp) => {}, // return false → reject
|
|
29
|
+
onChange: (flow) => {},
|
|
30
|
+
onSelectionChange: (flow, ids) => {},
|
|
31
|
+
|
|
32
|
+
// Bulk additions
|
|
33
|
+
kinds: [{ name: 'foo', execute: ... }],
|
|
34
|
+
commands: [{ label: 'Do thing', run: () => {} }],
|
|
35
|
+
extendAPI: (flow) => { flow.myMethod = () => 42; },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const dispose = flow.use(myPlugin);
|
|
39
|
+
// ... later ...
|
|
40
|
+
dispose(); // remove the plugin
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
All fields are optional. Plugins with no hooks are valid (e.g., for bundling kinds).
|
|
44
|
+
|
|
45
|
+
## Example: autosave to localStorage
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
flow.use({
|
|
49
|
+
name: 'autosave',
|
|
50
|
+
init: (f) => {
|
|
51
|
+
const saved = localStorage.getItem('graph');
|
|
52
|
+
if (saved) f.loadJSON(JSON.parse(saved));
|
|
53
|
+
},
|
|
54
|
+
onChange: (f) => {
|
|
55
|
+
localStorage.setItem('graph', JSON.stringify(f.toJSON()));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Example: fps overlay
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
flow.use({
|
|
64
|
+
name: 'fps',
|
|
65
|
+
_frames: 0, _t0: 0, _fps: 0,
|
|
66
|
+
init() { this._t0 = performance.now(); },
|
|
67
|
+
afterRender: function (f, ctx) {
|
|
68
|
+
this._frames++;
|
|
69
|
+
const now = performance.now();
|
|
70
|
+
if (now - this._t0 > 500) {
|
|
71
|
+
this._fps = Math.round(this._frames * 1000 / (now - this._t0));
|
|
72
|
+
this._frames = 0; this._t0 = now;
|
|
73
|
+
}
|
|
74
|
+
ctx.save();
|
|
75
|
+
ctx.font = '600 11px ui-monospace, Consolas, monospace';
|
|
76
|
+
ctx.fillStyle = this._fps >= 55 ? '#5bd17a' : this._fps >= 30 ? '#f0b93a' : '#e8462b';
|
|
77
|
+
ctx.fillText(`${this._fps} fps`, f.canvas.width - 80, 22);
|
|
78
|
+
ctx.restore();
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Note: hooks receive `flow` and `ctx` as the **first arguments**. If you use a method on the plugin object (with `function () {}`), `this` is the plugin itself. With arrow functions, `this` is the surrounding scope. Choose accordingly.
|
|
84
|
+
|
|
85
|
+
## Example: confirmation before destructive ops
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
flow.use({
|
|
89
|
+
name: 'confirm-delete',
|
|
90
|
+
onNodeDelete: (f, nodeId) => {
|
|
91
|
+
const title = f.titles.get(nodeId);
|
|
92
|
+
if (title === 'production-db') {
|
|
93
|
+
const ok = confirm(`Really delete '${title}'?`);
|
|
94
|
+
if (!ok) return false; // (currently not enforced — see note below)
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> **Note:** `onNodeDelete` doesn't yet support cancellation via `return false`. The hook fires after the deletion. Wrap `flow.deleteSelection()` in your own UI code if you need to block.
|
|
101
|
+
|
|
102
|
+
## Example: registering many kinds at once
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
flow.use({
|
|
106
|
+
name: 'database-kinds',
|
|
107
|
+
kinds: [
|
|
108
|
+
{ name: 'db-query', execute: async (ctx, ins) => {/*...*/} },
|
|
109
|
+
{ name: 'db-insert', execute: async (ctx, ins) => {/*...*/} },
|
|
110
|
+
{ name: 'db-update', execute: async (ctx, ins) => {/*...*/} },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The `kinds:` array is shorthand for calling `flow.registerKind()` for each entry during `init`.
|
|
116
|
+
|
|
117
|
+
## Example: extending the API
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
flow.use({
|
|
121
|
+
name: 'graph-stats',
|
|
122
|
+
extendAPI: (f) => {
|
|
123
|
+
f.stats = () => ({
|
|
124
|
+
nodes: f.nodeCount(),
|
|
125
|
+
edges: f.edgeCount(),
|
|
126
|
+
avgDegree: f.edgeCount() * 2 / Math.max(1, f.nodeCount()),
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log(flow.stats());
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Consumers now use `flow.stats()` as if it were built-in.
|
|
135
|
+
|
|
136
|
+
## Example: command palette entries
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
flow.use({
|
|
140
|
+
name: 'export-pdf',
|
|
141
|
+
commands: [
|
|
142
|
+
{
|
|
143
|
+
label: 'Export to PDF',
|
|
144
|
+
hotkey: 'Ctrl+Shift+P',
|
|
145
|
+
run: () => myPDFExport(flow.toJSON()),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
label: 'Sync to backend',
|
|
149
|
+
run: async () => {
|
|
150
|
+
await fetch('/api/save', { method: 'POST', body: JSON.stringify(flow.toJSON()) });
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
These appear in `Ctrl+K`. The `hotkey` field is display-only — wire actual hotkeys yourself with `keydown` listeners if you want them globally bound.
|
|
158
|
+
|
|
159
|
+
## Example: rejecting connections
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
flow.use({
|
|
163
|
+
name: 'no-cross-region',
|
|
164
|
+
onConnect: (f, fromN, fp, toN, tp) => {
|
|
165
|
+
const fromRegion = f.tags.get(fromN)?.find(t => t.startsWith('region:'));
|
|
166
|
+
const toRegion = f.tags.get(toN)?.find(t => t.startsWith('region:'));
|
|
167
|
+
if (fromRegion && toRegion && fromRegion !== toRegion) {
|
|
168
|
+
console.warn('Cross-region connections forbidden');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Returning `false` causes the connection to be rejected. The user sees the "type mismatch" toast (we should add a way to customize the message — TODO).
|
|
176
|
+
|
|
177
|
+
## Example: instrument executor calls (poor-man's APM)
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
flow.use({
|
|
181
|
+
name: 'apm',
|
|
182
|
+
_starts: new Map(),
|
|
183
|
+
onBeforeExec(f, id) { this._starts.set(id, performance.now()); },
|
|
184
|
+
onAfterExec(f, id) {
|
|
185
|
+
const dur = performance.now() - this._starts.get(id);
|
|
186
|
+
if (dur > 100) console.warn(`slow node ${id}: ${dur.toFixed(1)}ms`);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Example: a complete logging plugin
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
function loggingPlugin(opts = {}) {
|
|
195
|
+
const prefix = opts.prefix || '[flow]';
|
|
196
|
+
return {
|
|
197
|
+
name: 'logger',
|
|
198
|
+
onNodeAdd: (f, id, spec) => console.log(prefix, 'add node', id, spec.kind),
|
|
199
|
+
onEdgeAdd: (f, id, spec) => console.log(prefix, 'add edge', spec.from, '→', spec.to),
|
|
200
|
+
onBeforeExec: (f, id) => console.log(prefix, '▸', id),
|
|
201
|
+
onAfterExec: (f, id, out) => console.log(prefix, '✓', id, out),
|
|
202
|
+
onNodeDelete: (f, id) => console.log(prefix, '✗', id),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
flow.use(loggingPlugin({ prefix: '[myapp]' }));
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
You can also pass a function — it's called with `flow` and should return a plugin object:
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
flow.use((flow) => ({
|
|
213
|
+
name: 'auto-fit',
|
|
214
|
+
onChange: () => flow.fitView(),
|
|
215
|
+
}));
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## What plugins **cannot** do
|
|
219
|
+
|
|
220
|
+
- They cannot prevent rendering (only modify it via `afterRender`)
|
|
221
|
+
- They cannot replace the WASM core
|
|
222
|
+
- They cannot intercept events between the WASM and JS layers — the hooks only fire at the JS-side public API boundary
|
|
223
|
+
- They cannot stop another plugin from running (no ordering control yet)
|
|
224
|
+
|
|
225
|
+
If you need any of these, you've reached the limits of plugins — fork the source.
|
|
226
|
+
|
|
227
|
+
## Security note
|
|
228
|
+
|
|
229
|
+
A plugin runs with **full access** to your page. It can read DOM, send fetches, modify global state. Treat plugins like npm packages: only install what you trust.
|
|
230
|
+
|
|
231
|
+
If you build a plugin marketplace where users install third-party plugins, you'd need to sandbox each plugin in a Worker or iframe — out of scope for the core lib.
|
|
232
|
+
|
|
233
|
+
## Next
|
|
234
|
+
|
|
235
|
+
→ [Multiplayer](./06-multiplayer.md) — real-time co-editing with Yjs
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# 6 · Multiplayer with Yjs
|
|
2
|
+
|
|
3
|
+
@luispm/zflow-graph ships an opt-in [Yjs](https://github.com/yjs/yjs) adapter for real-time collaborative editing. Two browser tabs (or two users on different machines) editing the same graph see each other's changes within ~30ms.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
- Nodes live in a `Y.Map` keyed by stable UUIDs
|
|
8
|
+
- Edges live in another `Y.Map`
|
|
9
|
+
- Position updates are throttled to 30 Hz
|
|
10
|
+
- Awareness (cursors, selection) uses Yjs' built-in awareness protocol
|
|
11
|
+
- Conflicts resolve via Yjs' CRDT semantics — last write wins per field, no merge UI needed
|
|
12
|
+
|
|
13
|
+
The adapter is **opt-in**. If you don't import it, Yjs isn't loaded and `flow.toJSON()` works as a standalone snapshot.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
You'll need `yjs` and a provider. For the public demo server:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install yjs y-websocket
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import * as Y from 'yjs';
|
|
25
|
+
import { WebsocketProvider } from 'y-websocket';
|
|
26
|
+
import { ZFlow } from '@luispm/zflow-graph';
|
|
27
|
+
import { bindYjs } from '@luispm/zflow-graph/adapters/yjs';
|
|
28
|
+
|
|
29
|
+
const flow = await ZFlow.create({ container, wasmUrl: '/zflow.wasm' });
|
|
30
|
+
|
|
31
|
+
const ydoc = new Y.Doc();
|
|
32
|
+
const provider = new WebsocketProvider('wss://demos.yjs.dev/ws', 'my-room', ydoc);
|
|
33
|
+
|
|
34
|
+
bindYjs(flow, ydoc, {
|
|
35
|
+
userName: 'Alice',
|
|
36
|
+
color: '#c062e8',
|
|
37
|
+
awareness: provider.awareness,
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Now open the same URL in another tab. Add a node in tab A — it appears in tab B.
|
|
42
|
+
|
|
43
|
+
## What syncs
|
|
44
|
+
|
|
45
|
+
| Operation | Synced? |
|
|
46
|
+
| -------------------------- | :-----: |
|
|
47
|
+
| `flow.addNode(...)` | ✅ |
|
|
48
|
+
| `flow.addEdge(...)` | ✅ |
|
|
49
|
+
| `flow.moveNode(id, x, y)` | ✅ (throttled to 30 Hz) |
|
|
50
|
+
| Drag a node | ✅ |
|
|
51
|
+
| `flow.setNodeTitle(...)` | ✅ |
|
|
52
|
+
| `flow.setNodeColor(...)` | ✅ |
|
|
53
|
+
| `flow.deleteSelection()` | ✅ |
|
|
54
|
+
| Resize a node | ✅ |
|
|
55
|
+
| Cursor movement | ✅ via awareness |
|
|
56
|
+
| Selection | ⚠️ adapter has the hook but doesn't broadcast yet |
|
|
57
|
+
| Frames / notes | ❌ not wired through yet |
|
|
58
|
+
| Runtime state (`flow._values`) | ❌ runtime is local-only by design |
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const binding = bindYjs(flow, ydoc, {
|
|
64
|
+
// Required for awareness (cursors)
|
|
65
|
+
awareness: provider.awareness,
|
|
66
|
+
|
|
67
|
+
// Your identity
|
|
68
|
+
userId: 'alice@example.com', // unique, stable
|
|
69
|
+
userName: 'Alice', // displayed in remote cursors
|
|
70
|
+
color: '#c062e8', // displayed in remote cursors
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Returned handle:
|
|
74
|
+
binding.ynodes // Y.Map of nodes
|
|
75
|
+
binding.yedges // Y.Map of edges
|
|
76
|
+
binding.ymeta // Y.Map for arbitrary app metadata
|
|
77
|
+
binding.destroy() // unhooks, restores original flow methods
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Providers
|
|
81
|
+
|
|
82
|
+
Yjs is provider-agnostic. Common choices:
|
|
83
|
+
|
|
84
|
+
- **y-websocket** — your own relay server (the simplest)
|
|
85
|
+
- **y-webrtc** — peer-to-peer, no server, works through a signaling channel
|
|
86
|
+
- **y-indexeddb** — local persistence (combine with one of the above for offline-first)
|
|
87
|
+
- **Liveblocks**, **Hocuspocus**, **Tiptap Hub** — commercial providers with auth + history
|
|
88
|
+
- **Custom** — talk directly to Yjs sync protocol over any transport
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { IndexeddbPersistence } from 'y-indexeddb';
|
|
92
|
+
new IndexeddbPersistence('zflow-room', ydoc); // offline-first
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Naming rooms
|
|
96
|
+
|
|
97
|
+
By default everyone using the public demo server shares the room `'my-room'`. Pick something specific:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const ROOM = `zflow:${projectId}:${graphId}`;
|
|
101
|
+
const provider = new WebsocketProvider('wss://your-relay/ws', ROOM, ydoc);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or use a hash in the URL so users can share links:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const ROOM = location.hash.slice(1) || 'default';
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Hosting your own relay
|
|
111
|
+
|
|
112
|
+
The simplest setup:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install -g @y/y-websocket-server
|
|
116
|
+
PORT=1234 npx y-websocket-server
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
new WebsocketProvider('ws://your-server:1234', 'my-room', ydoc);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
For production, put it behind nginx/Caddy with TLS and authentication. Yjs has no built-in auth — that's the provider's job.
|
|
124
|
+
|
|
125
|
+
## Local persistence
|
|
126
|
+
|
|
127
|
+
Combine multiple providers — they all sync the same `Y.Doc`:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
new IndexeddbPersistence('local-cache', ydoc);
|
|
131
|
+
new WebsocketProvider('wss://...', 'room', ydoc);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Now your app works offline: writes go to IndexedDB immediately, then sync to the websocket when online.
|
|
135
|
+
|
|
136
|
+
## Handling auth
|
|
137
|
+
|
|
138
|
+
The adapter doesn't know about auth — that's between you and your provider. Pattern:
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
const session = await getUser();
|
|
142
|
+
const provider = new WebsocketProvider('wss://...', ROOM, ydoc, {
|
|
143
|
+
params: { token: session.jwt },
|
|
144
|
+
});
|
|
145
|
+
provider.awareness.setLocalStateField('user', {
|
|
146
|
+
id: session.id,
|
|
147
|
+
name: session.name,
|
|
148
|
+
color: session.color,
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Your relay server validates the JWT, refuses connection if invalid.
|
|
153
|
+
|
|
154
|
+
## Conflict resolution
|
|
155
|
+
|
|
156
|
+
Yjs is a CRDT — it merges all concurrent changes automatically, without ever asking. Specific behaviors:
|
|
157
|
+
|
|
158
|
+
- Two users edit the same node title → last write wins (per field)
|
|
159
|
+
- Two users move the same node → last position wins (you'll see it snap once)
|
|
160
|
+
- Two users delete the same node → both deletes are idempotent
|
|
161
|
+
- Two users add nodes simultaneously → both nodes appear (no conflict)
|
|
162
|
+
- One user deletes a node while another adds an edge to it → the edge ends up dangling (Yjs doesn't enforce referential integrity)
|
|
163
|
+
|
|
164
|
+
For graph integrity guarantees, you'd need to layer your own validation on top.
|
|
165
|
+
|
|
166
|
+
## Limits and quirks
|
|
167
|
+
|
|
168
|
+
- **No history replay yet.** The adapter syncs current state. To play back history, use Yjs' `UndoManager` or a provider with versioning (Liveblocks, Hocuspocus).
|
|
169
|
+
- **Position throttling is 30 Hz** — if you drag a node very fast, peers see it jump. Tweak the throttle in `src/adapters/yjs.js` if you need higher rate.
|
|
170
|
+
- **No selection sync.** Each user has their own selection. The remote cursor only shows their pointer position.
|
|
171
|
+
- **Memory leak risk with very long sessions.** Yjs accumulates operations. Periodically snapshot (`Y.encodeStateAsUpdate`) and start fresh if your sessions span weeks.
|
|
172
|
+
- **Frames and sticky notes don't sync yet.** TODO.
|
|
173
|
+
|
|
174
|
+
## Example: complete multi-user editor
|
|
175
|
+
|
|
176
|
+
See [examples/multiplayer.html](../examples/multiplayer.html) for a working demo. Open it in two tabs, add nodes, drag them — they sync.
|
|
177
|
+
|
|
178
|
+
## Next
|
|
179
|
+
|
|
180
|
+
→ [Recipes](./07-recipes.md) — concrete worked examples
|