lulz 1.0.2 → 2.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/src/flow.js CHANGED
@@ -1,38 +1,77 @@
1
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
2
+ * lulz - Core Flow Engine
3
+ *
4
+ * A reactive dataflow system that makes coders happy.
5
+ *
6
+ * Processing Modes (2.0):
7
+ * - Series (default): ['in', a, b, c, 'out'] → a→b→c sequential
8
+ * - Parallel (explicit): ['in', [a, b, c], 'out'] → all receive same packet
9
+ *
10
+ * Helper functions for readability:
11
+ * - series(a, b, c) → sequential processing
12
+ * - parallel(a, b, c) → fan-out processing
13
+ *
14
+ * EventEmitter API:
15
+ * - app.emit('pipeName', packet) → inject packet
16
+ * - app.on('pipeName', handler) → intercept packets
17
17
  */
18
18
 
19
- // Distinguish outer (regular fn) from inner (arrow fn)
20
- function isOuter(fn) {
21
- return typeof fn === 'function' && fn.hasOwnProperty('prototype');
22
- }
19
+ import { EventEmitter } from 'events';
20
+
21
+
22
+ // ─────────────────────────────────────────────────────────────
23
+ // Function Type Detection
24
+ // ─────────────────────────────────────────────────────────────
25
+
26
+ export const isOuter = (fn) =>
27
+ typeof fn === 'function' && fn.hasOwnProperty('prototype');
28
+
29
+ export const isInner = (fn) =>
30
+ typeof fn === 'function' && !fn.hasOwnProperty('prototype');
31
+
32
+ export const isFlow = (obj) =>
33
+ obj && obj._isFlow === true;
34
+
35
+ export const isParallel = (item) =>
36
+ Array.isArray(item) && item._parallel === true;
37
+
38
+ export const isSeriesMarker = (item) =>
39
+ Array.isArray(item) && item._series === true;
23
40
 
24
- function isInner(fn) {
25
- return typeof fn === 'function' && !fn.hasOwnProperty('prototype');
41
+
42
+ // ─────────────────────────────────────────────────────────────
43
+ // Processing Mode Markers
44
+ // ─────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Mark functions for parallel (fan-out) processing
48
+ * All functions receive the same packet simultaneously
49
+ */
50
+ export function parallel(...fns) {
51
+ const arr = [...fns];
52
+ arr._parallel = true;
53
+ return arr;
26
54
  }
27
55
 
28
- function isSeriesArray(item) {
29
- return Array.isArray(item) && item.every(el => typeof el === 'function');
56
+ /**
57
+ * Mark functions for series (sequential) processing
58
+ * Explicit marker, though series is the default
59
+ */
60
+ export function series(...fns) {
61
+ const arr = [...fns];
62
+ arr._series = true;
63
+ return arr;
30
64
  }
31
65
 
66
+
67
+ // ─────────────────────────────────────────────────────────────
68
+ // Node Factory
69
+ // ─────────────────────────────────────────────────────────────
70
+
32
71
  /**
33
72
  * Create a processing node
34
73
  */
35
- function makeNode(name, fn) {
74
+ export function makeNode(name, fn) {
36
75
  const outputs = new Set();
37
76
 
38
77
  function send(packet) {
@@ -41,91 +80,173 @@ function makeNode(name, fn) {
41
80
  }
42
81
  }
43
82
 
44
- function receive(packet) {
83
+ function node(packet) {
45
84
  fn(send, packet);
46
85
  }
47
86
 
48
- receive.connect = (next) => {
87
+ node.connect = function(next) {
49
88
  outputs.add(next);
50
89
  return next;
51
90
  };
52
91
 
53
- receive.disconnect = (next) => {
92
+ node.disconnect = function(next) {
54
93
  outputs.delete(next);
94
+ return next;
55
95
  };
56
96
 
57
- receive.receive = receive;
58
- Object.defineProperty(receive, 'name', { value: name, writable: false });
59
- receive.outputs = outputs;
60
- receive._isNode = true;
97
+ node.receive = node;
98
+ node.send = send;
99
+ node.nodeName = name;
100
+ node.outputs = outputs;
101
+ node._isNode = true;
61
102
 
62
- return receive;
103
+ return node;
63
104
  }
64
105
 
65
- /**
66
- * Create a pass-through pipe
67
- */
68
- function makePipe(name) {
69
- return makeNode(name, (send, packet) => send(packet));
70
- }
71
106
 
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
- }
107
+ // ─────────────────────────────────────────────────────────────
108
+ // Main Flow Builder
109
+ // ─────────────────────────────────────────────────────────────
97
110
 
98
111
  /**
99
- * Main flow builder
112
+ * Create a reactive flow from a graph definition
113
+ *
114
+ * @param {Array} graph - Array of pipeline definitions
115
+ * @param {Object} context - Shared context object
116
+ * @returns {EventEmitter} Flow instance with emit/on API
100
117
  */
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);
118
+ export function flow(graph, context = {}) {
119
+
120
+ // ─── EventEmitter Base ───
121
+ const app = new EventEmitter();
122
+ app._isFlow = true;
123
+ app._context = context;
124
+ app._pipes = {};
125
+ app._nodes = [];
126
+ app._producers = [];
127
+ app._cleanups = [];
128
+
129
+ // Store original emit for internal use
130
+ const _originalEmit = app.emit.bind(app);
131
+
132
+
133
+ // ─── Pipe Management ───
134
+
135
+ const getPipe = (name) => {
136
+ if (!app._pipes[name]) {
137
+ const node = makeNode(`pipe:${name}`, (send, packet) => {
138
+ // Notify EventEmitter listeners (use original to avoid loop)
139
+ _originalEmit(name, packet);
140
+ // Continue down the chain
141
+ send(packet);
142
+ });
143
+ app._pipes[name] = node;
109
144
  }
110
- return pipes[name];
111
- }
145
+ return app._pipes[name];
146
+ };
112
147
 
113
- // Convert any function to a ready-to-use inner function
114
- function prepareFunction(fn) {
148
+
149
+ // ─── Function Preparation ───
150
+
151
+ const prepareFunction = (fn) => {
115
152
  if (isOuter(fn)) {
116
- // Factory function: call with empty options, bind context
153
+ // Factory function: bind context, call with empty options
117
154
  const bound = fn.bind({ context });
118
155
  return bound({});
119
156
  } 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
- };
157
+ // Already an inner (arrow) function
158
+ return fn;
125
159
  }
126
- }
160
+ };
161
+
162
+
163
+ // ─── Build Series Chain ───
164
+
165
+ const buildSeries = (fns) => {
166
+ if (fns.length === 0) {
167
+ const passthrough = makeNode('passthrough', (send, packet) => send(packet));
168
+ return { input: passthrough, output: passthrough };
169
+ }
170
+
171
+ const nodes = fns.map((fn, i) => {
172
+ if (isFlow(fn)) {
173
+ // Embedded flow - return its input, wire to its output
174
+ return {
175
+ _isEmbeddedFlow: true,
176
+ flow: fn,
177
+ receive: (packet) => fn.emit('in', packet)
178
+ };
179
+ }
180
+
181
+ const inner = prepareFunction(fn);
182
+ return makeNode(fn.name || `series:${i}`, inner);
183
+ });
184
+
185
+ // Chain: node[0] → node[1] → ... → node[n-1]
186
+ for (let i = 0; i < nodes.length - 1; i++) {
187
+ const current = nodes[i];
188
+ const next = nodes[i + 1];
189
+
190
+ if (current._isEmbeddedFlow) {
191
+ // Wire flow's output to next node
192
+ current.flow.on('out', (packet) => {
193
+ if (next._isEmbeddedFlow) {
194
+ next.flow.emit('in', packet);
195
+ } else {
196
+ next.receive(packet);
197
+ }
198
+ });
199
+ } else if (next._isEmbeddedFlow) {
200
+ // Wire current node to flow's input
201
+ current.connect({
202
+ receive: (packet) => next.flow.emit('in', packet)
203
+ });
204
+ } else {
205
+ current.connect(next);
206
+ }
207
+ }
208
+
209
+ // Return input of first, output of last
210
+ const first = nodes[0];
211
+ const last = nodes[nodes.length - 1];
212
+
213
+ return {
214
+ input: first._isEmbeddedFlow
215
+ ? { receive: (packet) => first.flow.emit('in', packet) }
216
+ : first,
217
+ output: last._isEmbeddedFlow
218
+ ? { connect: (next) => last.flow.on('out', (packet) => next.receive(packet)) }
219
+ : last
220
+ };
221
+ };
222
+
223
+
224
+ // ─── Build Parallel Fan-out ───
225
+
226
+ const buildParallel = (fns, dest) => {
227
+ const nodes = [];
228
+
229
+ for (const fn of fns) {
230
+ if (isFlow(fn)) {
231
+ // Embedded flow
232
+ const adapter = {
233
+ receive: (packet) => fn.emit('in', packet),
234
+ connect: (next) => fn.on('out', (packet) => next.receive(packet))
235
+ };
236
+ nodes.push(adapter);
237
+ } else {
238
+ const inner = prepareFunction(fn);
239
+ const node = makeNode(fn.name || 'parallel', inner);
240
+ nodes.push(node);
241
+ }
242
+ }
243
+
244
+ return nodes;
245
+ };
127
246
 
128
- // Process each line in the graph
247
+
248
+ // ─── Process Graph ───
249
+
129
250
  for (const line of graph) {
130
251
  if (!Array.isArray(line) || line.length < 2) continue;
131
252
 
@@ -134,180 +255,206 @@ function flow(graph, context = {}) {
134
255
  const last = elements[elements.length - 1];
135
256
  const middle = elements.slice(1, -1);
136
257
 
137
- // === SOURCE ===
258
+ // ─── SOURCE ───
138
259
  let source;
260
+
139
261
  if (typeof first === 'string') {
140
262
  source = getPipe(first);
263
+ } else if (isFlow(first)) {
264
+ // Flow as source - create adapter
265
+ source = makeNode('flow-source', (send, packet) => send(packet));
266
+ first.on('out', (packet) => source.receive(packet));
267
+ first.start?.();
141
268
  } else if (typeof first === 'function') {
142
269
  // Producer function
143
- const producerFn = isOuter(first) ? first.bind({ context })({}) : first;
144
- const node = makeNode(first.name || 'producer', (send, packet) => send(packet));
270
+ const producerFn = isOuter(first)
271
+ ? first.bind({ context })({})
272
+ : first;
273
+
274
+ source = makeNode(first.name || 'producer', (send, packet) => send(packet));
145
275
 
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__');
276
+ app._producers.push({
277
+ fn: producerFn,
278
+ node: source
279
+ });
152
280
  }
153
281
 
154
- // === DESTINATION ===
282
+ // ─── DESTINATION ───
155
283
  let dest;
284
+
156
285
  if (typeof last === 'string') {
157
286
  dest = getPipe(last);
287
+ } else if (isFlow(last)) {
288
+ // Flow as destination
289
+ dest = {
290
+ receive: (packet) => last.emit('in', packet),
291
+ _isNode: true
292
+ };
158
293
  } else if (typeof last === 'function') {
159
294
  const inner = prepareFunction(last);
160
295
  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__');
296
+ app._nodes.push(dest);
165
297
  }
166
298
 
167
- // === MIDDLE (transforms) ===
299
+ // ─── MIDDLE (transforms) ───
300
+
168
301
  if (middle.length === 0) {
169
302
  // Direct connection
170
- source.connect(dest);
303
+ if (source.connect) source.connect(dest);
304
+
305
+ } else if (middle.length === 1 && isSeriesMarker(middle[0])) {
306
+ // Explicit series marker: ['in', series(a, b, c), 'out']
307
+ const { input, output } = buildSeries(middle[0]);
308
+ if (source.connect) source.connect(input);
309
+ if (output.connect) output.connect(dest);
310
+
311
+ } else if (middle.length === 1 && (isParallel(middle[0]) || Array.isArray(middle[0]))) {
312
+ // Explicit parallel: ['in', [a, b, c], 'out'] or ['in', parallel(a,b,c), 'out']
313
+ const fns = middle[0];
314
+ const nodes = buildParallel(fns, dest);
315
+
316
+ for (const node of nodes) {
317
+ if (source.connect) source.connect(node);
318
+ if (node.connect) node.connect(dest);
319
+ }
320
+
171
321
  } else {
172
- // Check if we have series arrays or fan-out
173
- const hasSeries = middle.some(isSeriesArray);
322
+ // Default: SERIES processing (a b c)
323
+ const allFns = [];
174
324
 
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
- }
325
+ for (const item of middle) {
326
+ if (isParallel(item) || (Array.isArray(item) && !isSeriesMarker(item))) {
327
+ // Nested parallel within series - treat as single parallel block
328
+ allFns.push({ _parallelBlock: true, fns: item });
329
+ } else if (isSeriesMarker(item)) {
330
+ // Nested explicit series
331
+ allFns.push(...item);
332
+ } else {
333
+ allFns.push(item);
198
334
  }
199
335
  }
336
+
337
+ // Build the series chain
338
+ const { input, output } = buildSeries(allFns.filter(f => !f._parallelBlock));
339
+
340
+ if (source.connect) source.connect(input);
341
+ if (output.connect) output.connect(dest);
200
342
  }
201
343
  }
202
344
 
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`);
345
+
346
+ // ─── EventEmitter Injection Setup ───
347
+
348
+ // Override emit to inject into pipes
349
+ app.emit = (event, packet) => {
350
+ // If it's a pipe name, inject the packet (which will trigger _originalEmit)
351
+ if (app._pipes[event] && packet !== undefined) {
352
+ app._pipes[event].receive(packet);
353
+ return true;
354
+ }
355
+ // For non-pipe events, use original emit
356
+ return _originalEmit(event, packet);
357
+ };
358
+
359
+
360
+ // ─── API Methods ───
361
+
362
+ app.start = () => {
363
+ for (const { fn, node } of app._producers) {
364
+ const cleanup = fn((packet) => node.receive(packet));
365
+ if (typeof cleanup === 'function') {
366
+ app._cleanups.push(cleanup);
231
367
  }
232
- return this;
233
- },
368
+ }
369
+ app.emit('start');
370
+ return app;
371
+ };
234
372
 
235
- // Get a pipe for external connection
236
- getPipe(name) {
237
- return getPipe(name);
238
- },
373
+ app.stop = () => {
374
+ for (const cleanup of app._cleanups) {
375
+ cleanup();
376
+ }
377
+ app._cleanups = [];
378
+ app.emit('stop');
379
+ return app;
380
+ };
239
381
 
240
- // For subflow embedding
241
- _input: pipes['in'],
242
- _output: pipes['out'],
382
+ app.inject = (pipeName, packet) => {
383
+ app.emit(pipeName, packet);
384
+ return app;
243
385
  };
244
386
 
245
- return flowObj;
387
+ app.pipe = (name) => getPipe(name);
388
+
389
+ // Expose for subflow wiring
390
+ app._input = getPipe('in');
391
+ app._output = getPipe('out');
392
+
393
+ return app;
246
394
  }
247
395
 
396
+
397
+ // ─────────────────────────────────────────────────────────────
398
+ // Subflow Helper
399
+ // ─────────────────────────────────────────────────────────────
400
+
248
401
  /**
249
- * Create a subflow that can be embedded in other flows
402
+ * Create a subflow - a reusable flow component
403
+ * Uses 'in' and 'out' pipes for embedding
250
404
  */
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;
405
+ export function subflow(graph, context = {}) {
406
+ const sub = flow(graph, context);
407
+ sub._isSubflow = true;
408
+ return sub;
261
409
  }
262
410
 
411
+
412
+ // ─────────────────────────────────────────────────────────────
413
+ // Compose Helper
414
+ // ─────────────────────────────────────────────────────────────
415
+
263
416
  /**
264
- * Compose multiple flows
417
+ * Compose multiple flows in sequence
265
418
  */
266
- function compose(...flows) {
267
- // Connect flows in sequence
419
+ export function compose(...flows) {
420
+ const composed = new EventEmitter();
421
+ composed._isFlow = true;
422
+ composed._isComposed = true;
423
+ composed._flows = flows;
424
+
425
+ // Wire flows together
268
426
  for (let i = 0; i < flows.length - 1; i++) {
269
427
  const current = flows[i];
270
428
  const next = flows[i + 1];
271
-
272
- if (current._output && next._input) {
273
- current._output.connect(next._input);
274
- }
429
+ current.on('out', (packet) => next.emit('in', packet));
275
430
  }
276
431
 
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;
432
+ // Expose first input, last output
433
+ composed._input = flows[0]?._input;
434
+ composed._output = flows[flows.length - 1]?._output;
435
+
436
+ composed.start = () => {
437
+ for (const f of flows) f.start?.();
438
+ return composed;
439
+ };
440
+
441
+ composed.stop = () => {
442
+ for (const f of flows) f.stop?.();
443
+ return composed;
444
+ };
445
+
446
+ // Forward 'in' to first flow
447
+ composed.emit = (event, packet) => {
448
+ if (event === 'in') {
449
+ flows[0]?.emit('in', packet);
301
450
  }
451
+ return EventEmitter.prototype.emit.call(composed, event, packet);
302
452
  };
303
- }
304
453
 
305
- export {
306
- flow,
307
- subflow,
308
- compose,
309
- makeNode,
310
- makePipe,
311
- isOuter,
312
- isInner,
313
- };
454
+ // Forward 'out' from last flow
455
+ flows[flows.length - 1]?.on('out', (packet) => {
456
+ EventEmitter.prototype.emit.call(composed, 'out', packet);
457
+ });
458
+
459
+ return composed;
460
+ }