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/red-lib.js ADDED
@@ -0,0 +1,595 @@
1
+ /**
2
+ * lulz - Node-RED Style Library
3
+ *
4
+ * Core nodes inspired by Node-RED.
5
+ *
6
+ * Naming Convention:
7
+ * - Factory functions: lowercase (e.g., delay, inject, debug)
8
+ * - Called with options: delay({ delay: 1000 })
9
+ * - Called without: delay → uses sane defaults or warns
10
+ */
11
+
12
+
13
+ // ─────────────────────────────────────────────────────────────
14
+ // Property Helpers
15
+ // ─────────────────────────────────────────────────────────────
16
+
17
+ export const getProperty = (obj, path) => {
18
+ if (!path) return obj;
19
+ const parts = path.split('.');
20
+ let current = obj;
21
+ for (const part of parts) {
22
+ if (current === null || current === undefined) return undefined;
23
+ current = current[part];
24
+ }
25
+ return current;
26
+ };
27
+
28
+ export const setProperty = (obj, path, value) => {
29
+ if (!path) return;
30
+ const parts = path.split('.');
31
+ let current = obj;
32
+ for (let i = 0; i < parts.length - 1; i++) {
33
+ const part = parts[i];
34
+ if (current[part] === undefined) current[part] = {};
35
+ current = current[part];
36
+ }
37
+ current[parts[parts.length - 1]] = value;
38
+ };
39
+
40
+ export const deleteProperty = (obj, path) => {
41
+ if (!path) return;
42
+ const parts = path.split('.');
43
+ let current = obj;
44
+ for (let i = 0; i < parts.length - 1; i++) {
45
+ const part = parts[i];
46
+ if (current[part] === undefined) return;
47
+ current = current[part];
48
+ }
49
+ delete current[parts[parts.length - 1]];
50
+ };
51
+
52
+
53
+ // ─────────────────────────────────────────────────────────────
54
+ // Inject - Produces packets
55
+ // ─────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Inject node - Produces packets on schedule or trigger
59
+ *
60
+ * @param {Object} options
61
+ * @param {*} options.payload - Value to inject (or function for dynamic)
62
+ * @param {string} options.topic - Message topic
63
+ * @param {number} options.interval - Repeat interval in ms
64
+ * @param {boolean} options.once - Inject once on start (default: true)
65
+ * @param {number} options.onceDelay - Delay before first inject
66
+ */
67
+ export function inject(options = {}) {
68
+ const {
69
+ payload = () => Date.now(),
70
+ topic = '',
71
+ interval = null,
72
+ once = true,
73
+ onceDelay = 0,
74
+ } = options;
75
+
76
+ return (send) => {
77
+ const timers = [];
78
+
79
+ const emit = () => {
80
+ const value = typeof payload === 'function' ? payload() : payload;
81
+ send({ payload: value, topic });
82
+ };
83
+
84
+ if (once) {
85
+ if (onceDelay > 0) {
86
+ timers.push(setTimeout(emit, onceDelay));
87
+ } else {
88
+ setImmediate(emit);
89
+ }
90
+ }
91
+
92
+ if (interval && interval > 0) {
93
+ timers.push(setInterval(emit, interval));
94
+ }
95
+
96
+ // Cleanup function
97
+ return () => {
98
+ for (const t of timers) {
99
+ clearTimeout(t);
100
+ clearInterval(t);
101
+ }
102
+ };
103
+ };
104
+ }
105
+
106
+
107
+ // ─────────────────────────────────────────────────────────────
108
+ // Debug - Logs packets
109
+ // ─────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Debug node - Logs packets to console
113
+ *
114
+ * @param {Object} options
115
+ * @param {string} options.name - Label for output
116
+ * @param {boolean} options.active - Whether to output (default: true)
117
+ * @param {boolean} options.complete - Show complete msg (default: false)
118
+ * @param {Function} options.logger - Custom logger (default: console.log)
119
+ */
120
+ export function debug(options = {}) {
121
+ const {
122
+ name = 'debug',
123
+ active = true,
124
+ complete = false,
125
+ logger = console.log,
126
+ } = options;
127
+
128
+ return (send, packet) => {
129
+ if (active) {
130
+ const output = complete ? packet : packet?.payload;
131
+ logger(`[${name}]`, output);
132
+ }
133
+ send(packet);
134
+ };
135
+ }
136
+
137
+
138
+ // ─────────────────────────────────────────────────────────────
139
+ // Function - Execute custom code
140
+ // ─────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Function node - Execute custom JavaScript
144
+ *
145
+ * @param {Object} options
146
+ * @param {Function} options.func - Function(msg, context) → msg
147
+ * @param {Function} options.initialize - Setup function
148
+ * @param {Function} options.finalize - Cleanup function
149
+ */
150
+ export function func(options = {}) {
151
+ const {
152
+ func: fn = (msg) => msg,
153
+ initialize = null,
154
+ finalize = null,
155
+ } = options;
156
+
157
+ // Context storage
158
+ const nodeContext = {};
159
+ const flowContext = {};
160
+ const globalContext = {};
161
+
162
+ if (initialize) {
163
+ initialize({ global: globalContext, flow: flowContext, node: nodeContext });
164
+ }
165
+
166
+ return (send, packet) => {
167
+ const context = {
168
+ global: globalContext,
169
+ flow: flowContext,
170
+ node: nodeContext,
171
+ };
172
+
173
+ try {
174
+ const result = fn(packet, context);
175
+
176
+ if (result === null || result === undefined) {
177
+ return; // Drop message
178
+ }
179
+
180
+ if (Array.isArray(result)) {
181
+ for (const msg of result) {
182
+ if (msg !== null && msg !== undefined) {
183
+ send(msg);
184
+ }
185
+ }
186
+ } else {
187
+ send(result);
188
+ }
189
+ } catch (err) {
190
+ console.error('[func] Error:', err);
191
+ send({ ...packet, error: err.message });
192
+ }
193
+ };
194
+ }
195
+
196
+
197
+ // ─────────────────────────────────────────────────────────────
198
+ // Change - Modify message properties
199
+ // ─────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Change node - Set, change, delete, or move message properties
203
+ *
204
+ * @param {Object} options
205
+ * @param {Array} options.rules - Array of transformation rules
206
+ *
207
+ * Rule types:
208
+ * { type: 'set', prop: 'payload', to: value }
209
+ * { type: 'change', prop: 'payload', from: /regex/, to: 'replacement' }
210
+ * { type: 'delete', prop: 'payload' }
211
+ * { type: 'move', prop: 'payload', to: 'newProp' }
212
+ */
213
+ export function change(options = {}) {
214
+ const { rules = [] } = options;
215
+
216
+ if (rules.length === 0) {
217
+ console.warn('[change] No rules configured - pass-through mode');
218
+ }
219
+
220
+ return (send, packet) => {
221
+ let msg = { ...packet };
222
+
223
+ for (const rule of rules) {
224
+ const { type, prop, to, from } = rule;
225
+
226
+ switch (type) {
227
+ case 'set':
228
+ setProperty(msg, prop, typeof to === 'function' ? to(msg) : to);
229
+ break;
230
+
231
+ case 'change':
232
+ const current = getProperty(msg, prop);
233
+ if (typeof current === 'string') {
234
+ setProperty(msg, prop, current.replace(from, to));
235
+ }
236
+ break;
237
+
238
+ case 'delete':
239
+ deleteProperty(msg, prop);
240
+ break;
241
+
242
+ case 'move':
243
+ const value = getProperty(msg, prop);
244
+ deleteProperty(msg, prop);
245
+ setProperty(msg, to, value);
246
+ break;
247
+ }
248
+ }
249
+
250
+ send(msg);
251
+ };
252
+ }
253
+
254
+
255
+ // ─────────────────────────────────────────────────────────────
256
+ // Switch - Route messages
257
+ // ─────────────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Switch node - Route messages based on conditions
261
+ *
262
+ * @param {Object} options
263
+ * @param {string} options.property - Property to test (default: 'payload')
264
+ * @param {Array} options.rules - Array of test rules
265
+ * @param {boolean} options.checkall - Check all rules (default: false)
266
+ *
267
+ * Rule types: eq, neq, lt, gt, lte, gte, regex, true, false, null, nnull, else
268
+ */
269
+ export function switchNode(options = {}) {
270
+ const {
271
+ property = 'payload',
272
+ rules = [],
273
+ checkall = false,
274
+ } = options;
275
+
276
+ if (rules.length === 0) {
277
+ console.warn('[switch] No rules configured - pass-through mode');
278
+ }
279
+
280
+ return (send, packet) => {
281
+ const value = getProperty(packet, property);
282
+ let matched = false;
283
+
284
+ for (const rule of rules) {
285
+ let isMatch = false;
286
+
287
+ switch (rule.type) {
288
+ case 'eq': isMatch = value === rule.value; break;
289
+ case 'neq': isMatch = value !== rule.value; break;
290
+ case 'lt': isMatch = value < rule.value; break;
291
+ case 'gt': isMatch = value > rule.value; break;
292
+ case 'lte': isMatch = value <= rule.value; break;
293
+ case 'gte': isMatch = value >= rule.value; break;
294
+ case 'regex': isMatch = rule.value.test(String(value)); break;
295
+ case 'true': isMatch = value === true; break;
296
+ case 'false': isMatch = value === false; break;
297
+ case 'null': isMatch = value === null || value === undefined; break;
298
+ case 'nnull': isMatch = value !== null && value !== undefined; break;
299
+ case 'else': isMatch = !matched; break;
300
+ }
301
+
302
+ if (isMatch) {
303
+ matched = true;
304
+ send(rule.send !== undefined
305
+ ? (typeof rule.send === 'function' ? rule.send(packet) : { ...packet, ...rule.send })
306
+ : packet
307
+ );
308
+ if (!checkall) break;
309
+ }
310
+ }
311
+ };
312
+ }
313
+
314
+
315
+ // ─────────────────────────────────────────────────────────────
316
+ // Template - Render templates
317
+ // ─────────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Template node - Render mustache-style templates
321
+ *
322
+ * @param {Object} options
323
+ * @param {string} options.template - Template string with {{placeholders}}
324
+ * @param {string} options.field - Output field (default: 'payload')
325
+ */
326
+ export function template(options = {}) {
327
+ const {
328
+ template: tmpl = '{{payload}}',
329
+ field = 'payload',
330
+ } = options;
331
+
332
+ return (send, packet) => {
333
+ const output = tmpl.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
334
+ const value = getProperty(packet, path.trim());
335
+ return value !== undefined ? String(value) : '';
336
+ });
337
+
338
+ const result = { ...packet };
339
+ setProperty(result, field, output);
340
+ send(result);
341
+ };
342
+ }
343
+
344
+
345
+ // ─────────────────────────────────────────────────────────────
346
+ // Delay - Delay or rate-limit
347
+ // ─────────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Delay node - Delay or rate-limit messages
351
+ *
352
+ * @param {Object} options
353
+ * @param {number} options.delay - Delay in ms (default: 1000)
354
+ * @param {number} options.rate - Rate limit (msgs/sec)
355
+ * @param {boolean} options.drop - Drop when rate limited (default: false)
356
+ */
357
+ export function delay(options = {}) {
358
+ const {
359
+ delay: delayMs = 1000,
360
+ rate = null,
361
+ drop = false,
362
+ } = options;
363
+
364
+ const queue = [];
365
+ let processing = false;
366
+
367
+ return (send, packet) => {
368
+ if (rate) {
369
+ const interval = 1000 / rate;
370
+
371
+ if (drop && processing) return;
372
+
373
+ queue.push(packet);
374
+
375
+ if (!processing) {
376
+ processing = true;
377
+
378
+ const processQueue = () => {
379
+ if (queue.length > 0) {
380
+ send(queue.shift());
381
+ setTimeout(processQueue, interval);
382
+ } else {
383
+ processing = false;
384
+ }
385
+ };
386
+
387
+ processQueue();
388
+ }
389
+ } else {
390
+ setTimeout(() => send(packet), delayMs);
391
+ }
392
+ };
393
+ }
394
+
395
+
396
+ // ─────────────────────────────────────────────────────────────
397
+ // Split - Split messages
398
+ // ─────────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Split node - Split arrays/strings into sequences
402
+ *
403
+ * @param {Object} options
404
+ * @param {string} options.property - Property to split (default: 'payload')
405
+ * @param {string} options.delimiter - Delimiter for strings
406
+ */
407
+ export function split(options = {}) {
408
+ const {
409
+ property = 'payload',
410
+ delimiter = null,
411
+ } = options;
412
+
413
+ return (send, packet) => {
414
+ const value = getProperty(packet, property);
415
+
416
+ if (Array.isArray(value)) {
417
+ value.forEach((item, index) => {
418
+ send({
419
+ ...packet,
420
+ payload: item,
421
+ parts: { index, count: value.length }
422
+ });
423
+ });
424
+ } else if (typeof value === 'string' && delimiter) {
425
+ const parts = value.split(delimiter);
426
+ parts.forEach((item, index) => {
427
+ send({
428
+ ...packet,
429
+ payload: item,
430
+ parts: { index, count: parts.length }
431
+ });
432
+ });
433
+ } else {
434
+ send(packet);
435
+ }
436
+ };
437
+ }
438
+
439
+
440
+ // ─────────────────────────────────────────────────────────────
441
+ // Join - Join messages
442
+ // ─────────────────────────────────────────────────────────────
443
+
444
+ /**
445
+ * Join node - Join sequences of messages
446
+ *
447
+ * @param {Object} options
448
+ * @param {number} options.count - Number of messages to join
449
+ * @param {string} options.property - Property to join (default: 'payload')
450
+ * @param {Function} options.reducer - Custom reducer function
451
+ * @param {*} options.initial - Initial value
452
+ */
453
+ export function join(options = {}) {
454
+ const {
455
+ count = 0,
456
+ property = 'payload',
457
+ reducer = null,
458
+ initial = [],
459
+ } = options;
460
+
461
+ let buffer = Array.isArray(initial) ? [...initial] : initial;
462
+ let msgCount = 0;
463
+
464
+ if (count === 0) {
465
+ console.warn('[join] count=0 - messages will accumulate but never emit');
466
+ }
467
+
468
+ return (send, packet) => {
469
+ const value = getProperty(packet, property);
470
+
471
+ if (reducer) {
472
+ buffer = reducer(buffer, packet);
473
+ } else if (Array.isArray(buffer)) {
474
+ buffer.push(value);
475
+ }
476
+
477
+ msgCount++;
478
+
479
+ if (count > 0 && msgCount >= count) {
480
+ send({ payload: buffer });
481
+ buffer = Array.isArray(initial) ? [...initial] : initial;
482
+ msgCount = 0;
483
+ }
484
+ };
485
+ }
486
+
487
+
488
+ // ─────────────────────────────────────────────────────────────
489
+ // Filter - Filter messages
490
+ // ─────────────────────────────────────────────────────────────
491
+
492
+ /**
493
+ * Filter node - Filter messages based on condition
494
+ *
495
+ * @param {Object} options
496
+ * @param {Function} options.condition - Function(msg) → boolean
497
+ */
498
+ export function filter(options = {}) {
499
+ const { condition = () => true } = options;
500
+
501
+ return (send, packet) => {
502
+ if (condition(packet)) {
503
+ send(packet);
504
+ }
505
+ };
506
+ }
507
+
508
+
509
+ // ─────────────────────────────────────────────────────────────
510
+ // Link - Named links for connecting flows
511
+ // ─────────────────────────────────────────────────────────────
512
+
513
+ const linkRegistry = new Map();
514
+
515
+ /**
516
+ * Link Out - Send to named link
517
+ */
518
+ export function linkOut(options = {}) {
519
+ const { name = '' } = options;
520
+
521
+ if (!name) {
522
+ console.warn('[linkOut] No name configured');
523
+ }
524
+
525
+ return (send, packet) => {
526
+ const handlers = linkRegistry.get(name) || [];
527
+ for (const handler of handlers) {
528
+ handler(packet);
529
+ }
530
+ send(packet);
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Link In - Receive from named link
536
+ */
537
+ export function linkIn(options = {}) {
538
+ const { name = '' } = options;
539
+
540
+ if (!name) {
541
+ console.warn('[linkIn] No name configured');
542
+ }
543
+
544
+ return (send) => {
545
+ const handlers = linkRegistry.get(name) || [];
546
+ handlers.push(send);
547
+ linkRegistry.set(name, handlers);
548
+
549
+ return () => {
550
+ const h = linkRegistry.get(name) || [];
551
+ linkRegistry.set(name, h.filter(x => x !== send));
552
+ };
553
+ };
554
+ }
555
+
556
+
557
+ // ─────────────────────────────────────────────────────────────
558
+ // Catch - Error handling
559
+ // ─────────────────────────────────────────────────────────────
560
+
561
+ /**
562
+ * Catch node - Catches errors from other nodes
563
+ */
564
+ export function catchError(options = {}) {
565
+ const { scope = 'all' } = options;
566
+
567
+ return (send, packet) => {
568
+ if (packet.error) {
569
+ send(packet);
570
+ }
571
+ };
572
+ }
573
+
574
+
575
+ // ─────────────────────────────────────────────────────────────
576
+ // Status - Status reporting
577
+ // ─────────────────────────────────────────────────────────────
578
+
579
+ /**
580
+ * Status node - Reports node status
581
+ */
582
+ export function status(options = {}) {
583
+ const {
584
+ fill = 'green', // 'red', 'green', 'yellow', 'blue', 'grey'
585
+ shape = 'dot', // 'ring', 'dot'
586
+ text = ''
587
+ } = options;
588
+
589
+ return (send, packet) => {
590
+ send({
591
+ ...packet,
592
+ status: { fill, shape, text: typeof text === 'function' ? text(packet) : text }
593
+ });
594
+ };
595
+ }