@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,257 @@
|
|
|
1
|
+
# 2 · The Runtime
|
|
2
|
+
|
|
3
|
+
This is the part most graph libraries don't have, and it's what makes zflow useful for real applications.
|
|
4
|
+
|
|
5
|
+
## The core idea
|
|
6
|
+
|
|
7
|
+
A **graph** is data. A **runtime** turns that data into computation.
|
|
8
|
+
|
|
9
|
+
When you call `flow.run()`, the runtime walks every node in **topological order** and calls its `execute(ctx, inputs)` function. The return value becomes the node's output. Outputs flow through edges to downstream nodes as their inputs.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
[Source: random] → [×2] → [if >100] ─ ok ─→ [save]
|
|
13
|
+
└ bad ─→ [alert]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
If you give each box an `execute`, that diagram becomes a running program. That's it.
|
|
17
|
+
|
|
18
|
+
## Minimum viable example
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
flow.registerKind({
|
|
22
|
+
name: 'gen',
|
|
23
|
+
nin: 0, nout: 1,
|
|
24
|
+
portOut: ['value'],
|
|
25
|
+
execute: () => ({ value: Math.random() * 100 }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
flow.registerKind({
|
|
29
|
+
name: 'log',
|
|
30
|
+
nin: 1, nout: 0,
|
|
31
|
+
portIn: ['value'],
|
|
32
|
+
execute: (ctx, ins) => { console.log('got', ins.value); },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const a = flow.addNode({ kind: 'gen' });
|
|
36
|
+
const b = flow.addNode({ kind: 'log' });
|
|
37
|
+
flow.addEdge({ from: a, to: b });
|
|
38
|
+
|
|
39
|
+
await flow.run();
|
|
40
|
+
// console: "got 73.2"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How values flow through edges
|
|
44
|
+
|
|
45
|
+
The runtime expects `execute` to return **either** a primitive **or** an object whose keys match the kind's `portOut` labels:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
// Single output, one downstream port: return either form is fine.
|
|
49
|
+
execute: () => 42 // primitive
|
|
50
|
+
execute: () => ({ value: 42 }) // object with 'value'
|
|
51
|
+
execute: () => ({ result: 42 }) // works if portOut: ['result']
|
|
52
|
+
|
|
53
|
+
// Multi-output (conditional routing): emit only the branch you want.
|
|
54
|
+
execute: (ctx, ins) => ins.x > 0
|
|
55
|
+
? { positive: ins.x } // only this key is set → only 'positive' edge fires
|
|
56
|
+
: { negative: ins.x };
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The downstream node receives values keyed by **its** `portIn` labels:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
flow.registerKind({
|
|
63
|
+
name: 'add',
|
|
64
|
+
nin: 2, nout: 1,
|
|
65
|
+
portIn: ['a', 'b'],
|
|
66
|
+
portOut: ['sum'],
|
|
67
|
+
execute: (ctx, ins) => ({ sum: ins.a + ins.b }),
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If no port labels are declared, inputs are exposed as `in0`, `in1`, `in2`, etc. (also indexable by number: `ins[0]`).
|
|
72
|
+
|
|
73
|
+
## The `ctx` object
|
|
74
|
+
|
|
75
|
+
Every `execute` receives a context with:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
execute: async (ctx, ins) => {
|
|
79
|
+
ctx.nodeId // this node's id
|
|
80
|
+
ctx.signal // AbortSignal — abort if flow.stop() is called
|
|
81
|
+
ctx.params // params set via flow.setNodeParams(id, params)
|
|
82
|
+
ctx.emit(value) // intermediate emission (for streaming)
|
|
83
|
+
ctx.setProgress(p) // 0..1 → drawn as a bar inside the node
|
|
84
|
+
ctx.log(...args) // emits a 'node:log' event
|
|
85
|
+
ctx.metric(v) // push a number to the live sparkline
|
|
86
|
+
ctx.get(otherId) // read the latest value of another node
|
|
87
|
+
return { ... }; // final output
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Async, retry, abort
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
flow.registerKind({
|
|
95
|
+
name: 'fetch-user',
|
|
96
|
+
retry: { n: 3, delay: 500 }, // 3 attempts, 500ms between
|
|
97
|
+
execute: async (ctx, ins) => {
|
|
98
|
+
ctx.setProgress(0.1);
|
|
99
|
+
const r = await fetch(`/api/users/${ins.id}`, { signal: ctx.signal });
|
|
100
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
101
|
+
ctx.setProgress(0.9);
|
|
102
|
+
return { user: await r.json() };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const p = flow.run();
|
|
107
|
+
setTimeout(() => flow.stop(), 5000); // give up after 5s
|
|
108
|
+
await p;
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If a node throws after exhausting retries, its status becomes `'error'` and the runtime emits `node:error`. By default it continues with other nodes; set `flow.options.stopOnError = true` to abort.
|
|
112
|
+
|
|
113
|
+
## Streaming nodes (`async function*`)
|
|
114
|
+
|
|
115
|
+
A node can emit **multiple values over time** by returning an async generator:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
flow.registerKind({
|
|
119
|
+
name: 'tick',
|
|
120
|
+
nin: 0, nout: 1,
|
|
121
|
+
execute: async function* (ctx) {
|
|
122
|
+
for (let i = 0; i < 10; i++) {
|
|
123
|
+
if (ctx.signal.aborted) return;
|
|
124
|
+
yield { count: i };
|
|
125
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Each `yield` propagates through downstream nodes **before** the next yield runs. So a `tick → log` graph prints 10 times, not 1.
|
|
132
|
+
|
|
133
|
+
## Conditional routing
|
|
134
|
+
|
|
135
|
+
Declare `portOut` labels and emit only the branches you want:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
flow.registerKind({
|
|
139
|
+
name: 'threshold',
|
|
140
|
+
nin: 1, nout: 2,
|
|
141
|
+
portIn: ['value'],
|
|
142
|
+
portOut: ['high', 'low'],
|
|
143
|
+
execute: (ctx, ins) => ins.value > 100
|
|
144
|
+
? { high: ins.value } // 'low' edge does NOT fire
|
|
145
|
+
: { low: ins.value }, // 'high' edge does NOT fire
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Downstream nodes on the **non-firing** branch don't execute that tick. This is how you build if/else flow.
|
|
150
|
+
|
|
151
|
+
## Run modes
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
await flow.run(); // whole graph
|
|
155
|
+
await flow.runFrom(nodeId); // only nodeId and its descendants
|
|
156
|
+
await flow.runFrame(frameId); // only nodes inside a frame
|
|
157
|
+
await flow.run({ filter: (id) => ... }); // arbitrary predicate
|
|
158
|
+
|
|
159
|
+
flow.startLoop(500); // re-run every 500ms forever
|
|
160
|
+
flow.stopLoop();
|
|
161
|
+
flow.stop(); // abort current run
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Memoization
|
|
165
|
+
|
|
166
|
+
If your inputs don't change, why re-compute?
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
flow.setMemoization(true);
|
|
170
|
+
|
|
171
|
+
// First run executes everything
|
|
172
|
+
await flow.run();
|
|
173
|
+
|
|
174
|
+
// Second run skips nodes whose inputs hash matches the previous run
|
|
175
|
+
await flow.run(); // → most nodes hit cache; only changed ones re-run
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Uses FNV-1a 32-bit hash (~80µs for ~1k-entry inputs). The cache invalidates per-node when its hash changes.
|
|
179
|
+
|
|
180
|
+
## Step-through debugging
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
flow.setBreakpoint(suspectNodeId);
|
|
184
|
+
|
|
185
|
+
flow.on('run:paused', ({ nodeId }) => {
|
|
186
|
+
console.log('paused at', nodeId);
|
|
187
|
+
// Inspect:
|
|
188
|
+
console.log('inputs:', flow._values.get(predecessor));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await flow.run(); // pauses at breakpoint
|
|
192
|
+
// In response to a UI button:
|
|
193
|
+
flow.stepOver(); // execute the breakpoint node and pause again at next
|
|
194
|
+
flow.resume(); // exit debug mode, continue normally
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Or step through everything:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
flow.setStepMode(true);
|
|
201
|
+
await flow.run(); // pauses before every node
|
|
202
|
+
flow.stepOver(); // advance one at a time
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Driving the runtime from external state
|
|
206
|
+
|
|
207
|
+
Sometimes the graph has source nodes whose values you want to inject from outside:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
const sourceId = flow.addNode({ kind: 'gen' });
|
|
211
|
+
|
|
212
|
+
flow.setNodeInput(sourceId, { value: 42 }); // injects value
|
|
213
|
+
await flow.run();
|
|
214
|
+
// gen's execute didn't run; the injected value flows downstream
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This is how UI controls (sliders, text inputs) drive a running graph.
|
|
218
|
+
|
|
219
|
+
## Reading the result
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
await flow.run();
|
|
223
|
+
flow.getNodeValue(sinkId); // → { received: 42 }
|
|
224
|
+
flow._values.get(sinkId); // → same thing
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Events
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
flow.on('run:start', ({ order }) => {});
|
|
231
|
+
flow.on('run:done', ({ executed, errors, values }) => {});
|
|
232
|
+
flow.on('run:paused', ({ nodeId }) => {});
|
|
233
|
+
flow.on('node:exec', ({ id, inputs }) => {});
|
|
234
|
+
flow.on('node:emit', ({ id, outputs }) => {}); // includes streaming emissions
|
|
235
|
+
flow.on('node:done', ({ id, outputs }) => {});
|
|
236
|
+
flow.on('node:error', ({ id, error }) => {});
|
|
237
|
+
flow.on('node:retry', ({ id, attempt, error }) => {});
|
|
238
|
+
flow.on('node:cached', ({ id }) => {}); // memoization skip
|
|
239
|
+
flow.on('node:log', ({ id, args }) => {}); // from ctx.log()
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Visual cues automatic during a run
|
|
243
|
+
|
|
244
|
+
| What you see | Why |
|
|
245
|
+
| ----------------------------------------- | ------------------------------------------ |
|
|
246
|
+
| Blue pulsing border on a node | status === 'running' |
|
|
247
|
+
| Floating bubble with the emitted value | per emission, auto |
|
|
248
|
+
| Edge thickens + glows blue | data is currently flowing through it |
|
|
249
|
+
| Progress bar inside node | `ctx.setProgress(p)` was called |
|
|
250
|
+
| Sparkline at bottom of node | numeric outputs accumulate over time |
|
|
251
|
+
| Status dot top-right (green/red/blue) | `status` field |
|
|
252
|
+
|
|
253
|
+
You can set `flow.setRunStepDelay(ms)` to pause between nodes so propagation is **visible**. Default 250ms — set to 0 for production speed.
|
|
254
|
+
|
|
255
|
+
## Next
|
|
256
|
+
|
|
257
|
+
→ [Designing Kinds](./03-kinds.md) — schemas, retry, and patterns for executable nodes
|
package/docs/03-kinds.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# 3 · Designing Kinds
|
|
2
|
+
|
|
3
|
+
Kinds are the API surface your end-users see. Treat them like the public API of your app — name them well, give them sensible defaults, validate their inputs.
|
|
4
|
+
|
|
5
|
+
## Full kind spec
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
flow.registerKind({
|
|
9
|
+
// ── Visual ────────────────────────────────────────────────────────────
|
|
10
|
+
name: 'http-get', // required, unique
|
|
11
|
+
color: '#5b8def', // CSS hex, used for header + ports
|
|
12
|
+
badge: 'H', // 1-2 chars or emoji, shown top-left
|
|
13
|
+
w: 200, h: 80, // default size in world units
|
|
14
|
+
shape: 'rect', // 'rect' | 'diamond' | 'ellipse' | 'hexagon' | 'circle' | 'round' | 'subroutine'
|
|
15
|
+
|
|
16
|
+
// ── Ports ─────────────────────────────────────────────────────────────
|
|
17
|
+
nin: 1, nout: 2, // number of input and output ports
|
|
18
|
+
portIn: ['url'], // labels (used as keys in execute's `ins`)
|
|
19
|
+
portOut: ['body', 'error'], // labels for outputs
|
|
20
|
+
|
|
21
|
+
// ── Schema (optional) ─────────────────────────────────────────────────
|
|
22
|
+
inputs: [{ name: 'url', type: 'string', required: true }],
|
|
23
|
+
outputs: [{ name: 'body', type: 'string' },
|
|
24
|
+
{ name: 'error', type: 'string' }],
|
|
25
|
+
|
|
26
|
+
// ── Runtime ───────────────────────────────────────────────────────────
|
|
27
|
+
execute: async (ctx, ins) => { /* ... */ },
|
|
28
|
+
retry: { n: 3, delay: 500 }, // automatic retry policy
|
|
29
|
+
|
|
30
|
+
// ── HTML overlay (alternative to canvas) ──────────────────────────────
|
|
31
|
+
html: false,
|
|
32
|
+
template: null, // see below
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Every field is optional except `name`. Defaults are sane.
|
|
37
|
+
|
|
38
|
+
## Naming conventions that won't bite you later
|
|
39
|
+
|
|
40
|
+
- Use **kebab-case** for `name`: `http-get`, `sql-query`, `email-send`
|
|
41
|
+
- Reserve `process` / `input` / `output` for the built-ins
|
|
42
|
+
- Group with prefixes: `db-read`, `db-write`, `db-migrate`
|
|
43
|
+
- The `name` is what shows up in `flow.toJSON()` — once you ship, **don't rename**, or old saved graphs break. Add a new kind and deprecate the old one.
|
|
44
|
+
|
|
45
|
+
## Ports: labels vs indices
|
|
46
|
+
|
|
47
|
+
Without `portIn`/`portOut`, inputs are addressable only by index:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
flow.registerKind({ name: 'foo', nin: 2 /* no portIn */, execute: (ctx, ins) => {
|
|
51
|
+
ins[0]; // first input
|
|
52
|
+
ins[1]; // second
|
|
53
|
+
ins.in0; // alias
|
|
54
|
+
ins.in1;
|
|
55
|
+
}});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
With labels, you also get named access:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
flow.registerKind({ name: 'foo', nin: 2, portIn: ['user', 'token'], execute: (ctx, ins) => {
|
|
62
|
+
ins.user;
|
|
63
|
+
ins.token;
|
|
64
|
+
// ins[0] and ins.in0 still work
|
|
65
|
+
}});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Always declare labels** for kinds with more than one port. It makes the executor readable.
|
|
69
|
+
|
|
70
|
+
## Schema (validation at connection time)
|
|
71
|
+
|
|
72
|
+
Schemas don't change runtime behavior — they prevent invalid connections at the editor level:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
flow.registerKind({
|
|
76
|
+
name: 'add',
|
|
77
|
+
inputs: [{ name: 'a', type: 'number' }, { name: 'b', type: 'number' }],
|
|
78
|
+
outputs: [{ name: 'sum', type: 'number' }],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
flow.registerKind({
|
|
82
|
+
name: 'concat',
|
|
83
|
+
inputs: [{ name: 'text', type: 'string' }],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// In the editor, dragging from add's 'sum' port to concat's 'text' port
|
|
87
|
+
// shows a red toast "type mismatch: number → string" and rejects.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Built-in compatibility rules:
|
|
91
|
+
- Same type → ok
|
|
92
|
+
- Either side is `'any'` → ok
|
|
93
|
+
- Numeric types (`number`, `int`, `float`, `integer`) are interchangeable
|
|
94
|
+
- `string` accepts everything (string is the universal type — everything stringifies)
|
|
95
|
+
- Otherwise → mismatch
|
|
96
|
+
|
|
97
|
+
You can override the rules entirely with a custom validator:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
flow.setConnectionValidator((fromN, fp, toN, tp) => {
|
|
101
|
+
// Block self-loops
|
|
102
|
+
if (fromN === toN) return false;
|
|
103
|
+
// Otherwise allow
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Patterns: source nodes
|
|
109
|
+
|
|
110
|
+
Sources have `nin: 0`. They're triggered when `flow.run()` reaches them in topo order:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
flow.registerKind({
|
|
114
|
+
name: 'now',
|
|
115
|
+
nin: 0, nout: 1,
|
|
116
|
+
portOut: ['ms'],
|
|
117
|
+
execute: () => ({ ms: Date.now() }),
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Or driven externally:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
flow.registerKind({ name: 'input-slot', nin: 0, nout: 1 }); // no execute
|
|
125
|
+
|
|
126
|
+
const slot = flow.addNode({ kind: 'input-slot' });
|
|
127
|
+
flow.setNodeInput(slot, { value: 42 });
|
|
128
|
+
// → every downstream tick sees 42 from this slot
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Patterns: sink nodes
|
|
132
|
+
|
|
133
|
+
Sinks have `nout: 0`. They produce side effects (DB write, UI update, log):
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
flow.registerKind({
|
|
137
|
+
name: 'console-log',
|
|
138
|
+
nin: 1, nout: 0,
|
|
139
|
+
portIn: ['value'],
|
|
140
|
+
execute: (ctx, ins) => { console.log(ins.value); /* return undefined */ },
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A sink's return value (if any) is still stored in `flow._values` so debug UI can show it, but downstream nodes don't get it (there are none).
|
|
145
|
+
|
|
146
|
+
## Patterns: branching / control flow
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
flow.registerKind({
|
|
150
|
+
name: 'switch',
|
|
151
|
+
nin: 1, nout: 3,
|
|
152
|
+
portIn: ['value'],
|
|
153
|
+
portOut: ['gt100', 'gt50', 'else'],
|
|
154
|
+
execute: (ctx, ins) => {
|
|
155
|
+
if (ins.value > 100) return { gt100: ins.value };
|
|
156
|
+
if (ins.value > 50) return { gt50: ins.value };
|
|
157
|
+
return { else: ins.value };
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Downstream nodes on **non-firing** branches don't execute that tick. They sit idle.
|
|
163
|
+
|
|
164
|
+
## Patterns: stateful nodes
|
|
165
|
+
|
|
166
|
+
Need state across runs? Stash it on the node via `setNodeParams`:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
flow.registerKind({
|
|
170
|
+
name: 'counter',
|
|
171
|
+
nin: 0, nout: 1,
|
|
172
|
+
execute: (ctx) => {
|
|
173
|
+
const p = ctx.params;
|
|
174
|
+
p.count = (p.count || 0) + 1;
|
|
175
|
+
flow.setNodeParams(ctx.nodeId, p);
|
|
176
|
+
return { count: p.count };
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Or use closure-captured Maps for module-level state:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const counts = new Map();
|
|
185
|
+
flow.registerKind({
|
|
186
|
+
name: 'counter',
|
|
187
|
+
execute: (ctx) => {
|
|
188
|
+
counts.set(ctx.nodeId, (counts.get(ctx.nodeId) || 0) + 1);
|
|
189
|
+
return { count: counts.get(ctx.nodeId) };
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Patterns: streaming
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
flow.registerKind({
|
|
198
|
+
name: 'every',
|
|
199
|
+
nin: 0, nout: 1,
|
|
200
|
+
execute: async function* (ctx) {
|
|
201
|
+
while (!ctx.signal.aborted) {
|
|
202
|
+
yield { tick: Date.now() };
|
|
203
|
+
await new Promise((r) => setTimeout(r, ctx.params.intervalMs || 1000));
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const t = flow.addNode({ kind: 'every' });
|
|
209
|
+
flow.setNodeParams(t, { intervalMs: 500 });
|
|
210
|
+
flow.run(); // runs forever (until flow.stop())
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Patterns: HTML overlay nodes
|
|
214
|
+
|
|
215
|
+
When canvas isn't enough — e.g., you want a form node with input fields:
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
flow.registerKind({
|
|
219
|
+
name: 'form',
|
|
220
|
+
html: true,
|
|
221
|
+
template: `
|
|
222
|
+
<div style="padding:12px;">
|
|
223
|
+
<input class="user-input" placeholder="name" style="width:100%;padding:6px;background:#0b0f17;border:1px solid #5b8def;border-radius:4px;color:white;">
|
|
224
|
+
<button class="submit-btn" style="margin-top:8px;width:100%;padding:6px;background:#5b8def;color:white;border:0;border-radius:4px;">Submit</button>
|
|
225
|
+
</div>
|
|
226
|
+
`,
|
|
227
|
+
w: 220, h: 110, nin: 0, nout: 1,
|
|
228
|
+
execute: (ctx) => ({ value: ctx.params.lastSubmit || null }),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Hook up listeners after creating the node:
|
|
232
|
+
const id = flow.addNode({ kind: 'form', x: 0, y: 0 });
|
|
233
|
+
// Wait one frame for the DOM to mount, then:
|
|
234
|
+
requestAnimationFrame(() => {
|
|
235
|
+
const el = flow._htmlOverlays.get(id);
|
|
236
|
+
el.querySelector('.submit-btn').onclick = () => {
|
|
237
|
+
const val = el.querySelector('.user-input').value;
|
|
238
|
+
flow.setNodeParams(id, { lastSubmit: val });
|
|
239
|
+
flow.runFrom(id);
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
HTML nodes are real DOM. They participate in pan/zoom (their position is synced every frame). Events bubble normally.
|
|
245
|
+
|
|
246
|
+
## Validation in `execute`
|
|
247
|
+
|
|
248
|
+
Schemas catch wrong types at edit time, but you should still validate at runtime:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
execute: async (ctx, ins) => {
|
|
252
|
+
if (typeof ins.url !== 'string') throw new Error('url required');
|
|
253
|
+
if (!ins.url.startsWith('http')) throw new Error('url must be absolute');
|
|
254
|
+
// ...
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Errors surface in `node:error` events and color the node red.
|
|
259
|
+
|
|
260
|
+
## Documenting kinds for end-users
|
|
261
|
+
|
|
262
|
+
If your app is an editor for end-users, expose a palette / sidebar. Save kind metadata for tooltips:
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
flow.registerKind({
|
|
266
|
+
name: 'http-get',
|
|
267
|
+
meta: {
|
|
268
|
+
description: 'Send an HTTP GET request and return the body.',
|
|
269
|
+
docs: 'https://yourapp.com/docs/http-get',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// In your sidebar UI:
|
|
274
|
+
const cat = flow.kinds[flow.kindByName.get('http-get')];
|
|
275
|
+
console.log(cat.meta?.description);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
(Anything you pass to `registerKind` not in the spec list above is preserved on the kind object.)
|
|
279
|
+
|
|
280
|
+
## Next
|
|
281
|
+
|
|
282
|
+
→ [Performance at Scale](./04-performance.md) — make it fly with 50k+ nodes
|