lulz 1.0.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/CHICKEN.md +156 -0
- package/README.md +285 -0
- package/examples.js +317 -0
- package/index.js +21 -0
- package/package.json +20 -0
- package/src/flow.js +313 -0
- package/src/nodes.js +520 -0
- package/test.js +430 -0
package/examples.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lulz Examples
|
|
3
|
+
*
|
|
4
|
+
* Various patterns and use cases
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
flow,
|
|
9
|
+
subflow,
|
|
10
|
+
compose,
|
|
11
|
+
Inject,
|
|
12
|
+
Debug,
|
|
13
|
+
Function as Fn,
|
|
14
|
+
Change,
|
|
15
|
+
Switch,
|
|
16
|
+
Template,
|
|
17
|
+
Delay,
|
|
18
|
+
Join,
|
|
19
|
+
Split,
|
|
20
|
+
} from './index.js';
|
|
21
|
+
|
|
22
|
+
// Custom logger to capture output
|
|
23
|
+
const logs = [];
|
|
24
|
+
const logger = (...args) => {
|
|
25
|
+
logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' '));
|
|
26
|
+
console.log(...args);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ============================================================
|
|
30
|
+
// EXAMPLE 1: Basic pipe connection
|
|
31
|
+
// ============================================================
|
|
32
|
+
|
|
33
|
+
console.log('\n=== Example 1: Basic Inject → Debug ===\n');
|
|
34
|
+
|
|
35
|
+
const example1 = flow([
|
|
36
|
+
[Inject({ payload: 'Hello FlowGraph!', once: true }), Debug({ name: 'output', logger })],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
example1.start();
|
|
40
|
+
|
|
41
|
+
// ============================================================
|
|
42
|
+
// EXAMPLE 2: Series processing with [a, b, c] syntax
|
|
43
|
+
// ============================================================
|
|
44
|
+
|
|
45
|
+
console.log('\n=== Example 2: Series Processing ===\n');
|
|
46
|
+
|
|
47
|
+
// Each step adds to the message
|
|
48
|
+
function addStep(name) {
|
|
49
|
+
return function(options) {
|
|
50
|
+
return (send, packet) => {
|
|
51
|
+
const steps = packet.steps || [];
|
|
52
|
+
logger(`[${name}] Processing...`);
|
|
53
|
+
send({ ...packet, steps: [...steps, name] });
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const example2 = flow([
|
|
59
|
+
['input', [addStep('validate'), addStep('transform'), addStep('enrich')], Debug({ name: 'result', logger })],
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
example2.inject('input', { payload: { data: 'test' } });
|
|
63
|
+
|
|
64
|
+
// ============================================================
|
|
65
|
+
// EXAMPLE 3: Fan-out (parallel processing)
|
|
66
|
+
// ============================================================
|
|
67
|
+
|
|
68
|
+
console.log('\n=== Example 3: Fan-out Processing ===\n');
|
|
69
|
+
|
|
70
|
+
function processA(options) {
|
|
71
|
+
return (send, packet) => {
|
|
72
|
+
logger('[A] Fast processing');
|
|
73
|
+
send({ ...packet, processedBy: 'A' });
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function processB(options) {
|
|
78
|
+
return (send, packet) => {
|
|
79
|
+
logger('[B] Detailed processing');
|
|
80
|
+
send({ ...packet, processedBy: 'B', extra: 'details' });
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const example3 = flow([
|
|
85
|
+
['input', processA, processB, 'output'], // Both receive same input
|
|
86
|
+
['output', Debug({ name: 'fan-out-result', logger })],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
example3.inject('input', { payload: 'parallel test' });
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// EXAMPLE 4: Mixed pre-configured and auto-configured functions
|
|
93
|
+
// ============================================================
|
|
94
|
+
|
|
95
|
+
console.log('\n=== Example 4: Pre-configured Functions ===\n');
|
|
96
|
+
|
|
97
|
+
function multiplier(options) {
|
|
98
|
+
const factor = options.factor || 1;
|
|
99
|
+
return (send, packet) => {
|
|
100
|
+
send({ ...packet, payload: packet.payload * factor });
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const example4 = flow([
|
|
105
|
+
['input', [
|
|
106
|
+
multiplier({ factor: 2 }), // Pre-configured: ×2
|
|
107
|
+
multiplier, // Auto-configured with {}: ×1
|
|
108
|
+
multiplier({ factor: 5 }), // Pre-configured: ×5
|
|
109
|
+
], Debug({ name: 'multiplied', logger })],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
example4.inject('input', { payload: 10 }); // 10 → 20 → 20 → 100
|
|
113
|
+
|
|
114
|
+
// ============================================================
|
|
115
|
+
// EXAMPLE 5: Conditional routing with Switch
|
|
116
|
+
// ============================================================
|
|
117
|
+
|
|
118
|
+
console.log('\n=== Example 5: Conditional Routing ===\n');
|
|
119
|
+
|
|
120
|
+
const example5 = flow([
|
|
121
|
+
['input', Switch({
|
|
122
|
+
property: 'payload.temperature',
|
|
123
|
+
rules: [
|
|
124
|
+
{ type: 'gte', value: 30 },
|
|
125
|
+
]
|
|
126
|
+
}), 'hot'],
|
|
127
|
+
|
|
128
|
+
['input', Switch({
|
|
129
|
+
property: 'payload.temperature',
|
|
130
|
+
rules: [
|
|
131
|
+
{ type: 'lt', value: 30 },
|
|
132
|
+
]
|
|
133
|
+
}), 'cold'],
|
|
134
|
+
|
|
135
|
+
['hot', Debug({ name: '🔥 HOT', logger })],
|
|
136
|
+
['cold', Debug({ name: '❄️ COLD', logger })],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
example5.inject('input', { payload: { temperature: 35, city: 'Phoenix' } });
|
|
140
|
+
example5.inject('input', { payload: { temperature: 15, city: 'Seattle' } });
|
|
141
|
+
|
|
142
|
+
// ============================================================
|
|
143
|
+
// EXAMPLE 6: Template rendering
|
|
144
|
+
// ============================================================
|
|
145
|
+
|
|
146
|
+
console.log('\n=== Example 6: Template Rendering ===\n');
|
|
147
|
+
|
|
148
|
+
const example6 = flow([
|
|
149
|
+
['input', Template({
|
|
150
|
+
template: '{{payload.name}} from {{payload.city}} says: "{{payload.message}}"'
|
|
151
|
+
}), Debug({ name: 'message', logger })],
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
example6.inject('input', {
|
|
155
|
+
payload: {
|
|
156
|
+
name: 'Alice',
|
|
157
|
+
city: 'Wonderland',
|
|
158
|
+
message: 'Down the rabbit hole!'
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ============================================================
|
|
163
|
+
// EXAMPLE 7: Subflow embedding
|
|
164
|
+
// ============================================================
|
|
165
|
+
|
|
166
|
+
console.log('\n=== Example 7: Subflow (Reusable Component) ===\n');
|
|
167
|
+
|
|
168
|
+
// Create a reusable "sanitizer" subflow
|
|
169
|
+
const sanitizer = subflow([
|
|
170
|
+
['in', Fn({
|
|
171
|
+
func: (msg) => ({
|
|
172
|
+
...msg,
|
|
173
|
+
payload: String(msg.payload).trim().toLowerCase()
|
|
174
|
+
})
|
|
175
|
+
}), 'out'],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
// Use it in main flow
|
|
179
|
+
const example7 = flow([
|
|
180
|
+
['input', 'process'],
|
|
181
|
+
['process', Debug({ name: 'sanitized', logger })],
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// Wire up subflow
|
|
185
|
+
example7.pipes['input'].connect(sanitizer._input);
|
|
186
|
+
sanitizer._output.connect(example7.pipes['process']);
|
|
187
|
+
|
|
188
|
+
example7.inject('input', { payload: ' HELLO WORLD ' });
|
|
189
|
+
|
|
190
|
+
// ============================================================
|
|
191
|
+
// EXAMPLE 8: Blog builder (original use case)
|
|
192
|
+
// ============================================================
|
|
193
|
+
|
|
194
|
+
console.log('\n=== Example 8: Blog Builder Pattern ===\n');
|
|
195
|
+
|
|
196
|
+
// Simulated producer functions
|
|
197
|
+
function socket(channel) {
|
|
198
|
+
return (send) => {
|
|
199
|
+
logger(`[socket] Listening on: ${channel}`);
|
|
200
|
+
// Simulate receiving data
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
send({ payload: { type: 'new-post', id: 123 }, channel });
|
|
203
|
+
}, 50);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function watch(folder) {
|
|
208
|
+
return (send) => {
|
|
209
|
+
logger(`[watch] Watching: ${folder}`);
|
|
210
|
+
// Simulate file change
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
send({ payload: { type: 'file-changed', path: `${folder}/image.png` }, folder });
|
|
213
|
+
}, 75);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Processing functions
|
|
218
|
+
function cover(options) {
|
|
219
|
+
return (send, packet) => {
|
|
220
|
+
logger('[cover] Generating cover image...');
|
|
221
|
+
send({ ...packet, cover: true });
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function audio(options) {
|
|
226
|
+
return (send, packet) => {
|
|
227
|
+
logger('[audio] Processing audio...');
|
|
228
|
+
send({ ...packet, audio: true });
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function post(options) {
|
|
233
|
+
return (send, packet) => {
|
|
234
|
+
logger('[post] Building post...');
|
|
235
|
+
send({ ...packet, built: true });
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function assets(options) {
|
|
240
|
+
return (send, packet) => {
|
|
241
|
+
logger('[assets] Processing asset:', packet.payload?.path);
|
|
242
|
+
send(packet);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pagerizer(options) {
|
|
247
|
+
return (send, packet) => {
|
|
248
|
+
logger('[pagerizer] Updating pagination...');
|
|
249
|
+
send({ ...packet, paginated: true });
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const blogBuilder = flow([
|
|
254
|
+
[socket('post'), 'post'], // Socket events → post pipe
|
|
255
|
+
[watch('assets'), 'asset'], // File watcher → asset pipe
|
|
256
|
+
['post', Debug({ name: 'new-post', logger })], // Log new posts
|
|
257
|
+
['asset', assets], // Process assets
|
|
258
|
+
['post', cover, audio, post, 'updated'], // Fan-out: cover, audio, post all run
|
|
259
|
+
['updated', pagerizer, Debug({ name: 'updated', logger })],
|
|
260
|
+
], { username: 'alice' });
|
|
261
|
+
|
|
262
|
+
blogBuilder.start();
|
|
263
|
+
|
|
264
|
+
// ============================================================
|
|
265
|
+
// EXAMPLE 9: Series with mixed fan-out
|
|
266
|
+
// ============================================================
|
|
267
|
+
|
|
268
|
+
console.log('\n=== Example 9: Complex Pipeline ===\n');
|
|
269
|
+
|
|
270
|
+
function validate(options) {
|
|
271
|
+
return (send, packet) => {
|
|
272
|
+
if (packet.payload) {
|
|
273
|
+
logger('[validate] ✓ Valid');
|
|
274
|
+
send(packet);
|
|
275
|
+
} else {
|
|
276
|
+
logger('[validate] ✗ Invalid - dropped');
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function enrichA(options) {
|
|
282
|
+
return (send, packet) => {
|
|
283
|
+
logger('[enrichA] Adding metadata A');
|
|
284
|
+
send({ ...packet, metaA: true });
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function enrichB(options) {
|
|
289
|
+
return (send, packet) => {
|
|
290
|
+
logger('[enrichB] Adding metadata B');
|
|
291
|
+
send({ ...packet, metaB: true });
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function finalize(options) {
|
|
296
|
+
return (send, packet) => {
|
|
297
|
+
logger('[finalize] Completing...');
|
|
298
|
+
send({ ...packet, finalized: true });
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Complex pipeline: validate → (enrichA + enrichB in parallel) → finalize
|
|
303
|
+
const example9 = flow([
|
|
304
|
+
['input', [validate], 'validated'], // Series: just validate
|
|
305
|
+
['validated', enrichA, enrichB, 'enriched'], // Fan-out: both enrichers
|
|
306
|
+
['enriched', [finalize], Debug({ name: 'final', logger })], // Series: finalize
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
example9.inject('input', { payload: { data: 'important' } });
|
|
310
|
+
|
|
311
|
+
// ============================================================
|
|
312
|
+
// Summary
|
|
313
|
+
// ============================================================
|
|
314
|
+
|
|
315
|
+
setTimeout(() => {
|
|
316
|
+
console.log('\n=== All Examples Complete ===\n');
|
|
317
|
+
}, 200);
|
package/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lulz - A reactive dataflow system
|
|
3
|
+
*
|
|
4
|
+
* Inspired by FFmpeg filtergraph notation and Node-RED
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { flow, subflow, compose, makeNode, makePipe, isOuter, isInner } from './src/flow.js';
|
|
8
|
+
export {
|
|
9
|
+
Inject,
|
|
10
|
+
Debug,
|
|
11
|
+
Function,
|
|
12
|
+
Change,
|
|
13
|
+
Switch,
|
|
14
|
+
Template,
|
|
15
|
+
Delay,
|
|
16
|
+
Join,
|
|
17
|
+
Split,
|
|
18
|
+
getProperty,
|
|
19
|
+
setProperty,
|
|
20
|
+
deleteProperty,
|
|
21
|
+
} from './src/nodes.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lulz",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A reactive dataflow system inspired by Visual Programming, FFmpeg filtergraph and Node-RED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test",
|
|
9
|
+
"examples": "node examples.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dataflow",
|
|
13
|
+
"reactive",
|
|
14
|
+
"flow",
|
|
15
|
+
"pipeline",
|
|
16
|
+
"node-red"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/flow.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lulz - A reactive dataflow system inspired by FFmpeg filtergraph
|
|
3
|
+
*
|
|
4
|
+
* Syntax:
|
|
5
|
+
* [source, ...transforms, destination]
|
|
6
|
+
*
|
|
7
|
+
* Where:
|
|
8
|
+
* - source: string (pipe name) or producer function
|
|
9
|
+
* - transforms: functions (outer or inner) or arrays for series
|
|
10
|
+
* - destination: string (pipe name) or consumer function
|
|
11
|
+
*
|
|
12
|
+
* Series processing:
|
|
13
|
+
* ['in', [a, b, c], 'out'] - a→b→c in sequence
|
|
14
|
+
*
|
|
15
|
+
* Fan-out (parallel):
|
|
16
|
+
* ['in', a, b, c, 'out'] - all receive same packet, all send to out
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Distinguish outer (regular fn) from inner (arrow fn)
|
|
20
|
+
function isOuter(fn) {
|
|
21
|
+
return typeof fn === 'function' && fn.hasOwnProperty('prototype');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isInner(fn) {
|
|
25
|
+
return typeof fn === 'function' && !fn.hasOwnProperty('prototype');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSeriesArray(item) {
|
|
29
|
+
return Array.isArray(item) && item.every(el => typeof el === 'function');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a processing node
|
|
34
|
+
*/
|
|
35
|
+
function makeNode(name, fn) {
|
|
36
|
+
const outputs = new Set();
|
|
37
|
+
|
|
38
|
+
function send(packet) {
|
|
39
|
+
for (const out of outputs) {
|
|
40
|
+
out.receive(packet);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function receive(packet) {
|
|
45
|
+
fn(send, packet);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
receive.connect = (next) => {
|
|
49
|
+
outputs.add(next);
|
|
50
|
+
return next;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
receive.disconnect = (next) => {
|
|
54
|
+
outputs.delete(next);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
receive.receive = receive;
|
|
58
|
+
Object.defineProperty(receive, 'name', { value: name, writable: false });
|
|
59
|
+
receive.outputs = outputs;
|
|
60
|
+
receive._isNode = true;
|
|
61
|
+
|
|
62
|
+
return receive;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a pass-through pipe
|
|
67
|
+
*/
|
|
68
|
+
function makePipe(name) {
|
|
69
|
+
return makeNode(name, (send, packet) => send(packet));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a series chain: a→b→c
|
|
74
|
+
* Returns { input, output } nodes
|
|
75
|
+
*/
|
|
76
|
+
function buildSeries(fns, context, prepareFunction) {
|
|
77
|
+
if (fns.length === 0) {
|
|
78
|
+
const passthrough = makeNode('passthrough', (send, packet) => send(packet));
|
|
79
|
+
return { input: passthrough, output: passthrough };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nodes = fns.map((fn, i) => {
|
|
83
|
+
const inner = prepareFunction(fn);
|
|
84
|
+
return makeNode(fn.name || `series-${i}`, inner);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Chain them: node[0] → node[1] → ... → node[n-1]
|
|
88
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
89
|
+
nodes[i].connect(nodes[i + 1]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
input: nodes[0],
|
|
94
|
+
output: nodes[nodes.length - 1]
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Main flow builder
|
|
100
|
+
*/
|
|
101
|
+
function flow(graph, context = {}) {
|
|
102
|
+
const pipes = {};
|
|
103
|
+
const nodes = [];
|
|
104
|
+
const producers = [];
|
|
105
|
+
|
|
106
|
+
function getPipe(name) {
|
|
107
|
+
if (!pipes[name]) {
|
|
108
|
+
pipes[name] = makePipe(name);
|
|
109
|
+
}
|
|
110
|
+
return pipes[name];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Convert any function to a ready-to-use inner function
|
|
114
|
+
function prepareFunction(fn) {
|
|
115
|
+
if (isOuter(fn)) {
|
|
116
|
+
// Factory function: call with empty options, bind context
|
|
117
|
+
const bound = fn.bind({ context });
|
|
118
|
+
return bound({});
|
|
119
|
+
} else {
|
|
120
|
+
// Arrow (inner) function - wrap to provide context
|
|
121
|
+
return (send, packet) => {
|
|
122
|
+
// Create a this-like object for arrow functions via closure
|
|
123
|
+
fn(send, packet);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Process each line in the graph
|
|
129
|
+
for (const line of graph) {
|
|
130
|
+
if (!Array.isArray(line) || line.length < 2) continue;
|
|
131
|
+
|
|
132
|
+
const elements = [...line];
|
|
133
|
+
const first = elements[0];
|
|
134
|
+
const last = elements[elements.length - 1];
|
|
135
|
+
const middle = elements.slice(1, -1);
|
|
136
|
+
|
|
137
|
+
// === SOURCE ===
|
|
138
|
+
let source;
|
|
139
|
+
if (typeof first === 'string') {
|
|
140
|
+
source = getPipe(first);
|
|
141
|
+
} else if (typeof first === 'function') {
|
|
142
|
+
// Producer function
|
|
143
|
+
const producerFn = isOuter(first) ? first.bind({ context })({}) : first;
|
|
144
|
+
const node = makeNode(first.name || 'producer', (send, packet) => send(packet));
|
|
145
|
+
|
|
146
|
+
// Store producer for later activation
|
|
147
|
+
producers.push({ fn: producerFn, node });
|
|
148
|
+
source = node;
|
|
149
|
+
} else if (first?._isFlow) {
|
|
150
|
+
// Subflow as source
|
|
151
|
+
source = first._output || getPipe('__subflow_out__');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// === DESTINATION ===
|
|
155
|
+
let dest;
|
|
156
|
+
if (typeof last === 'string') {
|
|
157
|
+
dest = getPipe(last);
|
|
158
|
+
} else if (typeof last === 'function') {
|
|
159
|
+
const inner = prepareFunction(last);
|
|
160
|
+
dest = makeNode(last.name || 'sink', inner);
|
|
161
|
+
nodes.push(dest);
|
|
162
|
+
} else if (last?._isFlow) {
|
|
163
|
+
// Subflow as destination
|
|
164
|
+
dest = last._input || getPipe('__subflow_in__');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// === MIDDLE (transforms) ===
|
|
168
|
+
if (middle.length === 0) {
|
|
169
|
+
// Direct connection
|
|
170
|
+
source.connect(dest);
|
|
171
|
+
} else {
|
|
172
|
+
// Check if we have series arrays or fan-out
|
|
173
|
+
const hasSeries = middle.some(isSeriesArray);
|
|
174
|
+
|
|
175
|
+
if (middle.length === 1 && isSeriesArray(middle[0])) {
|
|
176
|
+
// Pure series: ['in', [a, b, c], 'out']
|
|
177
|
+
const { input, output } = buildSeries(middle[0], context, prepareFunction);
|
|
178
|
+
source.connect(input);
|
|
179
|
+
output.connect(dest);
|
|
180
|
+
nodes.push(input);
|
|
181
|
+
} else {
|
|
182
|
+
// Fan-out or mixed
|
|
183
|
+
for (const item of middle) {
|
|
184
|
+
if (isSeriesArray(item)) {
|
|
185
|
+
// Series within fan-out: ['in', a, [b, c, d], e, 'out']
|
|
186
|
+
const { input, output } = buildSeries(item, context, prepareFunction);
|
|
187
|
+
source.connect(input);
|
|
188
|
+
output.connect(dest);
|
|
189
|
+
nodes.push(input);
|
|
190
|
+
} else if (typeof item === 'function') {
|
|
191
|
+
// Regular transform in fan-out
|
|
192
|
+
const inner = prepareFunction(item);
|
|
193
|
+
const node = makeNode(item.name || 'transform', inner);
|
|
194
|
+
source.connect(node);
|
|
195
|
+
node.connect(dest);
|
|
196
|
+
nodes.push(node);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Create the flow object
|
|
204
|
+
const flowObj = {
|
|
205
|
+
pipes,
|
|
206
|
+
nodes,
|
|
207
|
+
producers,
|
|
208
|
+
_isFlow: true,
|
|
209
|
+
|
|
210
|
+
// Start all producers
|
|
211
|
+
start() {
|
|
212
|
+
for (const { fn, node } of producers) {
|
|
213
|
+
fn((packet) => node.receive(packet));
|
|
214
|
+
}
|
|
215
|
+
return this;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// Stop all producers (if they return cleanup functions)
|
|
219
|
+
stop() {
|
|
220
|
+
// TODO: implement cleanup
|
|
221
|
+
return this;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Inject a packet into a pipe
|
|
225
|
+
inject(pipeName, packet) {
|
|
226
|
+
const pipe = pipes[pipeName];
|
|
227
|
+
if (pipe) {
|
|
228
|
+
pipe.receive(packet);
|
|
229
|
+
} else {
|
|
230
|
+
console.warn(`Pipe "${pipeName}" not found`);
|
|
231
|
+
}
|
|
232
|
+
return this;
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// Get a pipe for external connection
|
|
236
|
+
getPipe(name) {
|
|
237
|
+
return getPipe(name);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
// For subflow embedding
|
|
241
|
+
_input: pipes['in'],
|
|
242
|
+
_output: pipes['out'],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return flowObj;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create a subflow that can be embedded in other flows
|
|
250
|
+
*/
|
|
251
|
+
function subflow(graph, context = {}) {
|
|
252
|
+
// Ensure the subflow has 'in' and 'out' pipes
|
|
253
|
+
const flowObj = flow(graph, context);
|
|
254
|
+
|
|
255
|
+
// Mark as subflow
|
|
256
|
+
flowObj._isSubflow = true;
|
|
257
|
+
flowObj._input = flowObj.getPipe('in');
|
|
258
|
+
flowObj._output = flowObj.getPipe('out');
|
|
259
|
+
|
|
260
|
+
return flowObj;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Compose multiple flows
|
|
265
|
+
*/
|
|
266
|
+
function compose(...flows) {
|
|
267
|
+
// Connect flows in sequence
|
|
268
|
+
for (let i = 0; i < flows.length - 1; i++) {
|
|
269
|
+
const current = flows[i];
|
|
270
|
+
const next = flows[i + 1];
|
|
271
|
+
|
|
272
|
+
if (current._output && next._input) {
|
|
273
|
+
current._output.connect(next._input);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
_isFlow: true,
|
|
279
|
+
_isComposed: true,
|
|
280
|
+
flows,
|
|
281
|
+
_input: flows[0]?._input,
|
|
282
|
+
_output: flows[flows.length - 1]?._output,
|
|
283
|
+
|
|
284
|
+
start() {
|
|
285
|
+
for (const f of flows) {
|
|
286
|
+
if (f.start) f.start();
|
|
287
|
+
}
|
|
288
|
+
return this;
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
stop() {
|
|
292
|
+
for (const f of flows) {
|
|
293
|
+
if (f.stop) f.stop();
|
|
294
|
+
}
|
|
295
|
+
return this;
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
inject(pipeName, packet) {
|
|
299
|
+
flows[0]?.inject(pipeName, packet);
|
|
300
|
+
return this;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export {
|
|
306
|
+
flow,
|
|
307
|
+
subflow,
|
|
308
|
+
compose,
|
|
309
|
+
makeNode,
|
|
310
|
+
makePipe,
|
|
311
|
+
isOuter,
|
|
312
|
+
isInner,
|
|
313
|
+
};
|