@quietsapa/qsl 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/src/core.js ADDED
@@ -0,0 +1,1156 @@
1
+ export default {
2
+ /**
3
+ * Constants
4
+ */
5
+ VERSION: '0.1.0',
6
+ PREFIX: 'qsl-',
7
+ FLOW_TYPE: {
8
+ DEFAULT: 'default',
9
+ ORDERED: 'ordered',
10
+ },
11
+ FLOW_STATE: {
12
+ READY: 'READY',
13
+ RUNNING: 'RUNNING',
14
+ COMPLETED: 'COMPLETED'
15
+ },
16
+ FLOW_OPTIONS: {
17
+ delay: 0,
18
+ priority: 0,
19
+ between: null,
20
+ trigger: null,
21
+ condition: null,
22
+ beforeStart: null,
23
+ onComplete: null,
24
+ group: null,
25
+ paused: false,
26
+ preload: false,
27
+ fireEvents: true,
28
+ depends: []
29
+ },
30
+ EVENTS: {
31
+ STARTED: 'QSL:started',
32
+ COMPLETED: 'QSL:completed',
33
+ ERROR: 'QSL:error',
34
+ FLOW_STARTED: 'QSL:flow:started',
35
+ FLOW_COMPLETED: 'QSL:flow:completed',
36
+ ALL_COMPLETED: 'QSL:all:completed',
37
+ SKIPPED: 'QSL:skipped',
38
+ DOMREADY: 'QSL:domready',
39
+ LOADED: 'QSL:loaded',
40
+ },
41
+ LIFECYCLE: {
42
+ DOMREADY: document.readyState === 'interactive' || document.readyState === 'complete',
43
+ LOADED: false,
44
+ },
45
+ CALLBACK: 'QSLReady',
46
+ /**
47
+ * Properties
48
+ */
49
+ types: new Map(), // Map to store custom resource types
50
+ flows: new Map(), // Map to store flows
51
+ flowOptions: new Map(), // Map to store flow options
52
+ flowGroups: new Map(), // Map to store flow groups
53
+ currentProcessPerFlow: new Map(), // Map to store current process per flow (flowId -> processId)
54
+
55
+ pendingFlows: new Set(), // Set to store pending flows
56
+ pendingProcesses: new Set(), // Set to store pending processes
57
+ completedProcesses: new Set(), // Set to store completed processes
58
+ loadActions: new Set(), // Set to store before load triggers
59
+ initActions: new Set(), // Set to store init triggers
60
+ addProcessFilters: new Set(), // Set to store add triggers
61
+ completedFlowsActions: new Set(), // Set to store completed flows actions
62
+ flowIdFilters: new Set(), // Set to store flowId filters
63
+ conditionHandlers: new Set(), // Set to store condition handlers
64
+ triggerHandlers: new Set(), // Set to store trigger handlers
65
+ processCompleteActions: new Set(), // Set to store process completion actions
66
+ allCompleteActions: new Set(), // Set to store all flows completion actions
67
+ resetActions: new Set(), // Set to store reset actions
68
+ handlerCallbacksFilters: new Set(), // Set to store handler callbacks filters
69
+
70
+ onAllComplete: null, // Callback for all flows completion
71
+ globalResolve: null, // Global resolve function
72
+ logger: null, // Logger instance
73
+
74
+ hasStarted: false, // Flag to check if QSL has started,
75
+ eventsEnabled: false, // Flag to check if events are enabled
76
+ initialized: false, // Flag to check if QSL is initialized
77
+ completing: false, // Flag to check if QSL is completing
78
+ autoReset: true, // Whether reset() runs automatically once every flow completes
79
+
80
+ globalBetween: 0, // Global delay between processes
81
+
82
+ /**
83
+ * Initialize the QSL library.
84
+ * @returns {Promise<this>}
85
+ */
86
+ async init() {
87
+ if (this.initialized) return this;
88
+
89
+ /* Check if QSL is already initialized */
90
+ window.__QSL__ = window.__QSL__ || this;
91
+
92
+ /* Check DOMContentLoaded state */
93
+ if (document.readyState === 'interactive' || document.readyState === 'complete') {
94
+ this.LIFECYCLE.DOMREADY = true;
95
+ } else {
96
+ document.addEventListener('DOMContentLoaded', () => this.LIFECYCLE.DOMREADY = true, { once: true });
97
+ }
98
+
99
+ /* Check window load state */
100
+ if (document.readyState === 'complete') {
101
+ this.LIFECYCLE.LOADED = true;
102
+ } else {
103
+ window.addEventListener('load', () => this.LIFECYCLE.LOADED = true, { once: true });
104
+ }
105
+
106
+ /* Register default type: console */
107
+ this.registerType('console', (process) => {
108
+ return new Promise((resolve) => {
109
+ process.onBeforeStart?.();
110
+ setTimeout(() => {
111
+ if ( process.message ) this.log( process.message, { timestamp: Date.now() } );
112
+ process.onComplete?.();
113
+ resolve();
114
+ }, process.delay || 0);
115
+ });
116
+ });
117
+
118
+ /**
119
+ * Custom event listeners for logging and error handling
120
+ */
121
+ window.addEventListener('QSL:log', (e) => this.log(e.detail.type, e.detail.config.id));
122
+ window.addEventListener('QSL:error', (e) => this.error(e.detail.type, e.detail.error, e.detail.config.id));
123
+
124
+ /**
125
+ * Load init triggers
126
+ */
127
+ if ( this.initActions.size ) {
128
+ for ( const cb of this.initActions ) {
129
+ if ( typeof cb === 'function' ) await cb.call(this);
130
+ }
131
+ }
132
+
133
+ this.initialized = true;
134
+
135
+ /**
136
+ * Check if async loading is enabled
137
+ */
138
+ const url = document.currentScript ? new URL(document.currentScript.src, document.baseURI) : null;
139
+ if (!url || url.searchParams.get('async') !== 'true') return this;
140
+
141
+ /**
142
+ * Callback on async loading with default callback name
143
+ */
144
+ const callback = url.searchParams.get('callback') || this.CALLBACK;
145
+ if (typeof window[callback] === 'function') window[callback]();
146
+
147
+ /**
148
+ * Return QSL instance for chaining
149
+ */
150
+ return this;
151
+
152
+ },
153
+
154
+ /**
155
+ * Register a plugin to QSL.
156
+ *
157
+ * @param {Function} plugin - The plugin function to register.
158
+ * @param {...any} args - Additional arguments passed to the plugin.
159
+ * @returns {this}
160
+ */
161
+ use(plugin, ...args) {
162
+ if (typeof plugin === 'function') plugin(this, ...args);
163
+ return this;
164
+ },
165
+
166
+ /**
167
+ * Add a process config to a flow.
168
+ * Handles process-level dependencies separately from flow-level logic.
169
+ *
170
+ * @param {Object} config - The process configuration object.
171
+ * @param {string|boolean|null} [flowId=null] - The flow ID or true for ordered, or null for default.
172
+ * @returns {this}
173
+ */
174
+ add(config, flowId = null) {
175
+ if (!config || typeof config !== 'object') return this;
176
+
177
+ /* Generate ID and default to process state */
178
+ config.id = config.id ? this.PREFIX + config.id : this.PREFIX + Math.random().toString(36).slice(2);
179
+ config.skipped = false;
180
+
181
+ /* Sanitize config and set default type */
182
+ if (!config.type) config.type = 'console';
183
+
184
+ /* Register process-level dependencies */
185
+ if (Array.isArray(config.depends)) config.depends = [...new Set(config.depends)];
186
+
187
+ /* Filter flowId and config by addProcessFilters */
188
+ if ( this.addProcessFilters.size ) {
189
+ for ( const cb of this.addProcessFilters ) {
190
+ if ( typeof cb === 'function' ) [flowId, config] = cb.call(this, flowId, config);
191
+ }
192
+ }
193
+
194
+ /* Store flowId on process for quick lookup */
195
+ const normalizedFlowId = this.normalizeFlowId(flowId);
196
+ config.flowId = normalizedFlowId;
197
+
198
+ /* Add to flow for flow-level execution */
199
+ this.getOrCreateFlow(normalizedFlowId).push(config);
200
+
201
+ /**
202
+ * Return QSL instance for chaining
203
+ */
204
+ return this;
205
+ },
206
+
207
+ /**
208
+ * Start loading all flows.
209
+ *
210
+ * @param {Object} [options={}]
211
+ * @param {number|boolean} [options.between=false] - Global delay between processes.
212
+ * @returns {Promise<this>}
213
+ */
214
+ async load({ between = false } = {}) {
215
+ if (this.hasStarted) return;
216
+ this.hasStarted = true;
217
+
218
+ /**
219
+ * Set global between
220
+ */
221
+ this.globalBetween = between;
222
+
223
+ /**
224
+ * Call load actions
225
+ */
226
+ for ( const cb of this.loadActions ) {
227
+ if (typeof cb === 'function') cb.call(this);
228
+ }
229
+
230
+ /**
231
+ * Return global promise
232
+ */
233
+ return new Promise((resolve) => {
234
+ if (!this.flows.size) {
235
+ this.reset();
236
+ return resolve();
237
+ }
238
+
239
+ this.globalResolve = resolve;
240
+ this.processFlows();
241
+ });
242
+
243
+ },
244
+
245
+ /**
246
+ * Reset all internal state and clear all flows/options.
247
+ *
248
+ * @returns {void}
249
+ */
250
+ reset() {
251
+ /**
252
+ * Call reset actions before clearing
253
+ */
254
+ if (this.resetActions.size) {
255
+ for (const action of this.resetActions) {
256
+ if (typeof action === 'function') action.call(this);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Clear run state only. Plugin registrations (types, condition and
262
+ * trigger handlers, lifecycle hooks) survive, so processes added after
263
+ * a completed run still behave the same way. Use destroy() to tear the
264
+ * whole instance down.
265
+ */
266
+ this.flows.clear();
267
+ this.flowOptions.clear();
268
+ this.flowGroups.clear();
269
+ this.pendingFlows.clear();
270
+ this.pendingProcesses.clear();
271
+ this.completedProcesses.clear();
272
+ this.currentProcessPerFlow.clear();
273
+ this.onAllComplete = null;
274
+ this.globalResolve = null;
275
+ this.hasStarted = false;
276
+ this.globalBetween = 0;
277
+
278
+ this.log('RESET');
279
+
280
+ /**
281
+ * Return QSL instance for chaining
282
+ */
283
+ return this;
284
+ },
285
+
286
+ /**
287
+ * Tear the instance down completely: run state, plugin registrations and
288
+ * registered types. After this, init() has to run again.
289
+ *
290
+ * @returns {this}
291
+ */
292
+ destroy() {
293
+ this.reset();
294
+
295
+ this.types.clear();
296
+ this.initActions.clear();
297
+ this.loadActions.clear();
298
+ this.addProcessFilters.clear();
299
+ this.completedFlowsActions.clear();
300
+ this.flowIdFilters.clear();
301
+ this.conditionHandlers.clear();
302
+ this.triggerHandlers.clear();
303
+ this.processCompleteActions.clear();
304
+ this.allCompleteActions.clear();
305
+ this.resetActions.clear();
306
+ this.handlerCallbacksFilters.clear();
307
+
308
+ this.logger = null;
309
+ this.eventsEnabled = false;
310
+ this.initialized = false;
311
+
312
+ /**
313
+ * Return QSL instance for chaining
314
+ */
315
+ return this;
316
+ },
317
+
318
+ /**
319
+ * Set the logger instance.
320
+ *
321
+ * @param {Object|Function} logger - Logger instance or function.
322
+ * @returns {this}
323
+ */
324
+ setLogger(logger) {
325
+ if (logger && ( typeof logger === 'function' || typeof logger === 'object' ) ) this.logger = logger;
326
+
327
+ /**
328
+ * Return QSL instance for chaining
329
+ */
330
+ return this;
331
+ },
332
+
333
+ /**
334
+ * Set a callback to run when all flows/processes are complete.
335
+ *
336
+ * @param {Function} callback - The callback function.
337
+ * @returns {this}
338
+ */
339
+ setOnAllComplete(callback) {
340
+ this.onAllComplete = typeof callback === 'function' ? callback : null;
341
+
342
+ /**
343
+ * Return QSL instance for chaining
344
+ */
345
+ return this;
346
+ },
347
+
348
+ /**
349
+ * Enable DOM events for process/flow lifecycle.
350
+ * @returns {this}
351
+ */
352
+ useEvents() {
353
+ this.eventsEnabled = true;
354
+
355
+ /**
356
+ * Return QSL instance for chaining
357
+ */
358
+ return this;
359
+ },
360
+
361
+ /**
362
+ * Log a message using the custom logger if set.
363
+ *
364
+ * @param {string} type - Log type.
365
+ * @param {...any} args - Additional log arguments.
366
+ */
367
+ log(type, ...args) {
368
+ if (this.logger && typeof this.logger.log === 'function') this.logger.log(type, ...args);
369
+ },
370
+
371
+ /**
372
+ * Log an error using the custom logger if set.
373
+ *
374
+ * @param {string} type - Error type.
375
+ * @param {...any} args - Additional error arguments.
376
+ */
377
+ error(type, ...args) {
378
+ if (this.logger && typeof this.logger.error === 'function') this.logger.error(type, ...args);
379
+ },
380
+
381
+ /**
382
+ * Fire a DOM event if events are enabled.
383
+ *
384
+ * @param {string} event - Event name.
385
+ * @param {Object} detail - Event detail object.
386
+ */
387
+ fire(event, detail) {
388
+ if (this.eventsEnabled && this.EVENTS[event]) {
389
+ window.dispatchEvent(new CustomEvent(this.EVENTS[event], { detail }));
390
+ }
391
+ },
392
+
393
+ /**
394
+ * Normalize the flow ID.
395
+ *
396
+ * @param {string|boolean|null} flowId - The flow ID or true for ordered, or null for default.
397
+ * @returns {string} Normalized flow ID.
398
+ */
399
+ normalizeFlowId(flowId) {
400
+ if (flowId === true) {
401
+ flowId = this.FLOW_TYPE.ORDERED;
402
+ } else if (typeof flowId !== 'string') {
403
+ flowId = this.FLOW_TYPE.DEFAULT;
404
+ }
405
+ return flowId;
406
+ },
407
+
408
+ /**
409
+ * Register a custom resource type handler.
410
+ *
411
+ * @param {string} type - Resource type name.
412
+ * @param {Function} handler - Handler function for the type.
413
+ * @returns {this}
414
+ */
415
+ registerType(type, handler) {
416
+ if (typeof type !== 'string' || typeof handler !== 'function') return this;
417
+ this.types.set(type, handler);
418
+
419
+ /**
420
+ * Return QSL instance for chaining
421
+ */
422
+ return this;
423
+ },
424
+
425
+ /**
426
+ * Register multiple custom resource types.
427
+ *
428
+ * @param {Array<{type: string, handler: Function}>|Object} types - Array or object of type definitions.
429
+ * @returns {this}
430
+ */
431
+ registerTypes(types) {
432
+ if (!types || typeof types !== 'object') return this;
433
+
434
+ /* Accept both [{ type, handler }] and { name: handler } */
435
+ const list = Array.isArray(types)
436
+ ? types
437
+ : Object.entries(types).map(([type, handler]) => (
438
+ typeof handler === 'function' ? { type, handler } : handler
439
+ ));
440
+
441
+ for (const type of list) {
442
+ if (type) this.registerType(type.type, type.handler);
443
+ }
444
+
445
+ /**
446
+ * Return QSL instance for chaining
447
+ */
448
+ return this;
449
+ },
450
+
451
+ /**
452
+ * Pause all flows in a group.
453
+ *
454
+ * @param {string} group - Group name.
455
+ * @returns {this}
456
+ */
457
+ pauseGroup(group) {
458
+ const flowGroup = this.flowGroups.get(group);
459
+ if (!flowGroup) return this;
460
+ for (const flowId of flowGroup) {
461
+ this.setFlowOptions({ paused: true }, flowId);
462
+ }
463
+
464
+ /**
465
+ * Return QSL instance for chaining
466
+ */
467
+ return this;
468
+ },
469
+
470
+ /**
471
+ * Run all flows in a group.
472
+ *
473
+ * @param {string} group - Group name.
474
+ * @returns {this}
475
+ */
476
+ runGroup(group) {
477
+ const flowGroup = this.flowGroups.get(group);
478
+ if (!flowGroup) return this;
479
+ for (const flowId of flowGroup) {
480
+ this.setFlowOptions({ paused: false }, flowId);
481
+ this.runFlow(flowId);
482
+ }
483
+
484
+ /**
485
+ * Return QSL instance for chaining
486
+ */
487
+ return this;
488
+ },
489
+
490
+ /**
491
+ * Run a specific flow by id.
492
+ *
493
+ * @param {string} flowId - Flow ID.
494
+ * @param {boolean} [withTrigger=false] - Whether to run with trigger logic.
495
+ * @returns {this}
496
+ */
497
+ runFlow(flowId, withTrigger = false) {
498
+ const processes = this.flows.get(flowId);
499
+ const options = this.flowOptions.get(flowId);
500
+ if (!processes || !options || options.status !== this.FLOW_STATE.READY) return this;
501
+ this.processFlows(flowId, withTrigger);
502
+
503
+ /**
504
+ * Return QSL instance for chaining
505
+ */
506
+ return this;
507
+ },
508
+
509
+ /**
510
+ * Get or create a flow.
511
+ *
512
+ * @param {string} flowId - Flow ID.
513
+ * @returns {Array} The flow's process array.
514
+ */
515
+ getOrCreateFlow(flowId) {
516
+ if (!this.flows.has(flowId)) {
517
+ this.flows.set(flowId, []);
518
+ this.flowOptions.set(flowId, {
519
+ ...this.FLOW_OPTIONS,
520
+ ordered: flowId === this.FLOW_TYPE.ORDERED,
521
+ status: this.FLOW_STATE.READY
522
+ });
523
+ }
524
+ return this.flows.get(flowId);
525
+ },
526
+
527
+ /**
528
+ * Set or update options for a flow.
529
+ * If depends is set, pauses the flow until dependencies are resolved.
530
+ *
531
+ * @param {Object} options - Flow options.
532
+ * @param {string|boolean|null} [flowId=null] - Flow ID.
533
+ * @returns {this}
534
+ */
535
+ setFlowOptions(options = {}, flowId = null) {
536
+ /**
537
+ * Normalize flow ID
538
+ */
539
+ flowId = this.normalizeFlowId(flowId);
540
+
541
+ /**
542
+ * Get or create flow
543
+ */
544
+ this.getOrCreateFlow(flowId);
545
+
546
+ if (!options || typeof options !== 'object') return this;
547
+ const prevOptions = this.flowOptions.get(flowId);
548
+
549
+ /**
550
+ * Pause flow if it has dependencies
551
+ */
552
+ if (Array.isArray(options.depends) && options.depends.length) {
553
+ options.depends = [...new Set(options.depends)];
554
+ options.paused = true;
555
+ this.pendingFlows.add(flowId);
556
+ }
557
+
558
+ /**
559
+ * Grouping flows
560
+ */
561
+ if (options.group) {
562
+ if (!this.flowGroups.has(options.group)) this.flowGroups.set(options.group, new Set());
563
+ this.flowGroups.get(options.group).add(flowId);
564
+ }
565
+
566
+ /**
567
+ * Update options partially with fallbacks to previous state
568
+ */
569
+ this.flowOptions.set(flowId, { ...prevOptions, ...options });
570
+
571
+ /**
572
+ * Return QSL instance for chaining
573
+ */
574
+ return this;
575
+ },
576
+
577
+ /**
578
+ * Check all pending flows and run those whose dependencies are now resolved.
579
+ *
580
+ * @returns {void}
581
+ */
582
+ checkPendingFlows() {
583
+ if (!this.pendingFlows.size) return;
584
+ for (const pendingFlowId of this.pendingFlows) {
585
+ const pendingOptions = this.flowOptions.get(pendingFlowId);
586
+ if (
587
+ pendingOptions &&
588
+ pendingOptions.depends &&
589
+ pendingOptions.depends.every(depId => {
590
+ const depOpt = this.flowOptions.get(this.normalizeFlowId(depId));
591
+ return depOpt && depOpt.status === this.FLOW_STATE.COMPLETED;
592
+ })
593
+ ) {
594
+ this.setFlowOptions({ paused: false }, pendingFlowId);
595
+ this.pendingFlows.delete(pendingFlowId);
596
+ this.runFlow(pendingFlowId);
597
+ }
598
+ }
599
+ },
600
+
601
+ /**
602
+ * Check all pending processes and run those whose dependencies are now resolved.
603
+ *
604
+ * @returns {void}
605
+ */
606
+ checkPendingProcesses() {
607
+ if (!this.pendingProcesses.size) return;
608
+ for (const process of this.pendingProcesses) {
609
+ const missingDeps = process.depends.filter(depId => {
610
+ const hasProcess = Array.from(this.flows.values()).some(flow =>
611
+ flow.some(p => p.id === this.PREFIX + depId)
612
+ );
613
+ return !this.completedProcesses.has(this.PREFIX + depId) && !hasProcess;
614
+ });
615
+ if (missingDeps.length) {
616
+ this.pendingProcesses.delete(process);
617
+ this.log('DEP_NOT_FOUND', process.id, `${missingDeps.join(', ')}`);
618
+ process._depsResolved = true;
619
+ process._triggered = true;
620
+ if (process._waitResolve) process._waitResolve();
621
+ continue;
622
+ }
623
+ const allDepsCompleted = process.depends.every(depId => this.completedProcesses.has(this.PREFIX + depId));
624
+ if (allDepsCompleted) {
625
+ this.pendingProcesses.delete(process);
626
+ process._depsResolved = true;
627
+ if (process._triggered && process._waitResolve) process._waitResolve();
628
+ }
629
+ }
630
+ },
631
+
632
+ /**
633
+ * Check if all flows/processes are complete and resolve global promise if so.
634
+ *
635
+ * @returns {Promise<void>}
636
+ */
637
+ async maybeComplete() {
638
+ if ( ! this.hasStarted ) return;
639
+
640
+ /**
641
+ * Check for pending flows missed dependencies
642
+ */
643
+ if ( this.pendingFlows.size ) {
644
+ for (const pendingFlowId of this.pendingFlows) {
645
+ const pendingOptions = this.flowOptions.get(pendingFlowId);
646
+ if (
647
+ pendingOptions &&
648
+ pendingOptions.depends &&
649
+ pendingOptions.depends.some(depId => !this.flowOptions.has(this.normalizeFlowId(depId)))
650
+ ) {
651
+ this.setFlowOptions({ status: this.FLOW_STATE.COMPLETED }, pendingFlowId);
652
+ this.pendingFlows.delete(pendingFlowId);
653
+ this.log('FLOW_DEP_SKIPPED', { flow: pendingFlowId, depends: `${pendingOptions.depends.filter(depId => !this.flowOptions.has(this.normalizeFlowId(depId))).join(', ')}` });
654
+ }
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Check completed flows
660
+ */
661
+ let flowsDone = this.flowOptions.size ? Array.from(this.flowOptions.values()).every(opt => opt.status === this.FLOW_STATE.COMPLETED) : false;
662
+ if ( ! flowsDone ) return;
663
+
664
+ let maybeComplete = true;
665
+
666
+ /*
667
+ * Filter maybeComplete by completedFlowsActions
668
+ */
669
+ if ( this.flows.size && this.completedFlowsActions.size ) {
670
+ for ( const cb of this.completedFlowsActions ) {
671
+ maybeComplete = cb.call(this, flowsDone, this.flows, this.flowOptions);
672
+ }
673
+ }
674
+
675
+ if ( ! maybeComplete ) return;
676
+
677
+ if (this.completing) return;
678
+ this.completing = true;
679
+
680
+ this.log('ALL_COMPLETED');
681
+ this.fire('ALL_COMPLETED');
682
+
683
+ this.onAllComplete?.();
684
+ if (this.globalResolve !== null) this.globalResolve();
685
+
686
+ /**
687
+ * Call all flows completion actions
688
+ */
689
+ if (this.allCompleteActions.size) {
690
+ for (const action of this.allCompleteActions) {
691
+ if (typeof action === 'function') action.call(this);
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Reset run state unless the caller opted out (e.g. for debugging)
697
+ */
698
+ if (this.autoReset) this.reset();
699
+ this.completing = false;
700
+ },
701
+
702
+ /**
703
+ * Process all flows and their dependencies.
704
+ *
705
+ * @param {string|null} [flowId=null] - Specific flow ID or null for all.
706
+ * @param {boolean} [withTrigger=false] - Whether to run with trigger logic.
707
+ * @returns {this}
708
+ */
709
+ processFlows(flowId = null, withTrigger = false) {
710
+ if (!this.flows.size) return;
711
+
712
+ /**
713
+ * Run all flows if no specific flowId is provided
714
+ */
715
+ let flowIds = flowId ? [flowId] : Array.from(this.flows.keys());
716
+
717
+ /**
718
+ * Filter flowIds by flowIdFilters
719
+ */
720
+ if ( flowIds.length && this.flowIdFilters.size ) {
721
+ for ( const cb of this.flowIdFilters ) {
722
+ if ( typeof cb === 'function' ) flowIds = cb.call(this, flowIds);
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Sort flows: flows without triggers first, then by priority
728
+ */
729
+ flowIds.sort((a, b) => {
730
+ const aOpts = this.flowOptions.get(a);
731
+ const bOpts = this.flowOptions.get(b);
732
+ const aHasTrigger = aOpts?.trigger != null;
733
+ const bHasTrigger = bOpts?.trigger != null;
734
+
735
+ if (aHasTrigger && !bHasTrigger) return 1;
736
+ if (!aHasTrigger && bHasTrigger) return -1;
737
+
738
+ const aPriority = aOpts?.priority || 0;
739
+ const bPriority = bOpts?.priority || 0;
740
+ return bPriority - aPriority;
741
+ });
742
+
743
+ for (const fid of flowIds) {
744
+ const options = this.flowOptions.get(fid);
745
+
746
+ /**
747
+ * Skip if missed options or flow is already completed
748
+ */
749
+ if (!options || options.status === this.FLOW_STATE.COMPLETED) continue;
750
+
751
+ /**
752
+ * Flow-level conditions
753
+ */
754
+ if ( this.getConditionStatus(options.condition) ) {
755
+ this.setFlowOptions({ status: this.FLOW_STATE.COMPLETED }, fid);
756
+ continue;
757
+ }
758
+
759
+ /**
760
+ * Only process if not paused
761
+ */
762
+ if (options.paused) continue;
763
+
764
+ /**
765
+ * Logic for trigger on flow level
766
+ */
767
+ if ( flowId === null || ( flowId && ! withTrigger ) ) {
768
+ if (options.trigger != null) {
769
+ const trigger = this.getTriggerFunction(options.trigger, options);
770
+ if (trigger) {
771
+ this.setFlowOptions({ paused: true }, fid);
772
+ let triggered = false;
773
+ trigger(() => {
774
+ if (triggered) return;
775
+ triggered = true;
776
+ this.setFlowOptions({ paused: false }, fid);
777
+ this.runFlow(fid, true);
778
+ });
779
+ continue;
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Set flow to running state
786
+ */
787
+ this.setFlowOptions({ status: this.FLOW_STATE.RUNNING }, fid);
788
+
789
+ /**
790
+ * Run flows in parallel
791
+ */
792
+ (async () => {
793
+
794
+ options.beforeStart?.();
795
+
796
+ /**
797
+ * Delay per flow
798
+ */
799
+ if (options.delay && options.delay > 0) {
800
+ await new Promise(res => setTimeout(res, options.delay));
801
+ }
802
+
803
+ /**
804
+ * Sort processes by priority
805
+ */
806
+ const processes = (this.flows.get(fid) || []).slice();
807
+ processes.sort((a, b) => (b.priority || 0) - (a.priority || 0));
808
+
809
+ /**
810
+ * Preload scripts / styles
811
+ */
812
+ if ( (options.preload || options.prefetch ) && ! options.trigger ) {
813
+ for (const p of processes) {
814
+ if (p.trigger) continue;
815
+ if ((p.type === 'script' && p.src) || (p.type === 'style' && p.href)) {
816
+ try {
817
+ const link = document.createElement('link');
818
+ link.rel = options.preload ? 'preload' : 'prefetch';
819
+ link.href = p.src || p.href;
820
+ link.as = p.type === 'script' ? 'script' : 'style';
821
+ if (p.crossOrigin) link.crossOrigin = p.crossOrigin;
822
+ document.head.appendChild(link);
823
+ } catch (e) {
824
+ this.error('PRELOAD_ERROR', e, p.id);
825
+ }
826
+ }
827
+ }
828
+ }
829
+
830
+ const between = (options.between !== undefined && options.between !== null)
831
+ ? options.between
832
+ : this.globalBetween;
833
+
834
+ if (options.ordered) {
835
+ /**
836
+ * Ordered: await previous process
837
+ */
838
+ let prev = Promise.resolve();
839
+ processes.forEach((process, idx) => {
840
+ prev = prev.then(async () => {
841
+ if (idx > 0 && between) await new Promise(res => setTimeout(res, between));
842
+ await this.run(process);
843
+ });
844
+ });
845
+ await prev;
846
+ } else {
847
+ /**
848
+ * Unordered: run all processes in parallel
849
+ */
850
+ const promises = processes.map(async (process, idx) => {
851
+ if (idx > 0 && between) await new Promise(res => setTimeout(res, between));
852
+ return this.run(process);
853
+ });
854
+ await Promise.all(promises);
855
+ }
856
+
857
+ /**
858
+ * Set flow to completed state
859
+ */
860
+ this.setFlowOptions({ status: this.FLOW_STATE.COMPLETED }, fid);
861
+ options.onComplete?.();
862
+
863
+ /**
864
+ * Check for pending flows
865
+ */
866
+ this.checkPendingFlows();
867
+
868
+ /**
869
+ * Maybe complete the loading
870
+ */
871
+ this.maybeComplete();
872
+
873
+ })();
874
+ }
875
+
876
+ /**
877
+ * Cover skipped/empty batches that never start an async flow.
878
+ */
879
+ this.checkPendingFlows();
880
+ this.maybeComplete();
881
+ },
882
+
883
+ /**
884
+ * Execute a process based on its type.
885
+ *
886
+ * @param {Object} process - The process object.
887
+ * @returns {Promise<any>}
888
+ */
889
+ async run(process) {
890
+ /**
891
+ * Skip if already running or completed
892
+ */
893
+ if (process._running || this.getConditionStatus(process.condition)) return;
894
+ process._running = true;
895
+
896
+ /**
897
+ * Setup state
898
+ */
899
+ if (!process._waitPromise) {
900
+ process._triggered = false;
901
+ process._depsResolved = false;
902
+ process._waitPromise = new Promise(res => process._waitResolve = res);
903
+ }
904
+
905
+ /**
906
+ * Trigger logic for process
907
+ */
908
+ if (process.trigger != null) {
909
+ const trigger = this.getTriggerFunction(process.trigger, process);
910
+ if (trigger) {
911
+ trigger(() => {
912
+ process._triggered = true;
913
+ if (process._depsResolved) process._waitResolve();
914
+ });
915
+ } else {
916
+ process._triggered = true;
917
+ }
918
+ } else {
919
+ process._triggered = true;
920
+ }
921
+
922
+ /**
923
+ * Check dependencies
924
+ */
925
+ if (Array.isArray(process.depends) && process.depends.length) {
926
+ const missingDeps = process.depends.filter(depId => {
927
+ const hasProcess = Array.from(this.flows.values()).some(flow =>
928
+ flow.some(p => p.id === this.PREFIX + depId)
929
+ );
930
+ return !this.completedProcesses.has(this.PREFIX + depId) && !hasProcess;
931
+ });
932
+ if (missingDeps.length) {
933
+ this.log('DEP_NOT_FOUND', process.id, `${missingDeps.join(', ')}`);
934
+ process._depsResolved = true;
935
+ process._triggered = true;
936
+ if (process._waitResolve) process._waitResolve();
937
+ } else {
938
+ const allDepsCompleted = process.depends.every(depId => this.completedProcesses.has(this.PREFIX + depId));
939
+ if (!allDepsCompleted) {
940
+ this.pendingProcesses.add(process);
941
+ } else {
942
+ process._depsResolved = true;
943
+ if (process._triggered) process._waitResolve();
944
+ }
945
+ }
946
+ } else {
947
+ process._depsResolved = true;
948
+ if (process._triggered) process._waitResolve();
949
+ }
950
+
951
+ /**
952
+ * Wait for both trigger and dependencies to be resolved
953
+ */
954
+ await process._waitPromise;
955
+
956
+ return this.execute(process);
957
+ },
958
+
959
+ /**
960
+ * Check the condition option and return true if the condition fails.
961
+ *
962
+ * @param {string|Function|boolean} conditionOption - The condition option.
963
+ * @returns {boolean} True if the condition fails, false otherwise.
964
+ */
965
+ getConditionStatus(opt) {
966
+ if (opt == null) return false;
967
+
968
+ if (Array.isArray(opt)) {
969
+ return opt.some(c => this.getConditionStatus(c));
970
+ }
971
+
972
+ if (typeof opt === 'object' && opt.operator) {
973
+ const { operator, conditions } = opt;
974
+ if (!Array.isArray(conditions)) return false;
975
+
976
+ if (operator === 'or') {
977
+ return conditions.every(c => this.getConditionStatus(c));
978
+ } else if (operator === 'and') {
979
+ return conditions.some(c => this.getConditionStatus(c));
980
+ }
981
+ return false;
982
+ }
983
+
984
+ if (typeof opt === 'function' && !opt()) return true;
985
+ if (typeof opt === 'boolean' && !opt) return true;
986
+
987
+ if (this.conditionHandlers.size) {
988
+ for (const h of this.conditionHandlers) {
989
+ if (typeof h === 'function') {
990
+ const r = h.call(this, opt);
991
+ if (r === true || r === false) return r;
992
+ }
993
+ }
994
+ }
995
+
996
+ return false;
997
+ },
998
+
999
+ /**
1000
+ * Parse and return a trigger function based on the trigger option.
1001
+ *
1002
+ * @param {string|Function|boolean} opt - The trigger option.
1003
+ * @param {Object} o - The associated process or flow object.
1004
+ * @returns {Function|null} The trigger function or null if no trigger function is found.
1005
+ */
1006
+ getTriggerFunction(opt, o) {
1007
+ if (opt == null) return null;
1008
+
1009
+ if (typeof opt === 'function') {
1010
+ return (cb) => opt(cb);
1011
+ }
1012
+
1013
+ /**
1014
+ * Return trigger function for array of triggers
1015
+ */
1016
+ if (Array.isArray(opt)) {
1017
+ return (cb) => {
1018
+ const vd = opt.map(t => this.getTriggerFunction(t, o)).filter(tF => tF);
1019
+ if (vd.length === 0) {
1020
+ cb();
1021
+ return;
1022
+ }
1023
+ let f = 0;
1024
+ const fCb = () => { if (++f === vd.length) cb(); };
1025
+ vd.forEach(tF => tF(fCb));
1026
+ };
1027
+ }
1028
+
1029
+ /**
1030
+ * Return trigger function for object with operator and triggers
1031
+ */
1032
+ if (typeof opt === 'object' && opt.operator) {
1033
+ const { operator, triggers } = opt;
1034
+ if (!Array.isArray(triggers)) return null;
1035
+
1036
+ if (operator === 'or') {
1037
+ return (cb) => {
1038
+ const vd = triggers.map(t => this.getTriggerFunction(t, o)).filter(tF => tF);
1039
+ if (vd.length === 0) {
1040
+ cb();
1041
+ return;
1042
+ }
1043
+ let f = false;
1044
+ const fCb = () => { if (!f) { f = true; cb(); } };
1045
+ vd.forEach(tF => tF(fCb));
1046
+ };
1047
+ } else if (operator === 'and') {
1048
+ return (cb) => {
1049
+ const vd = triggers.map(t => this.getTriggerFunction(t, o)).filter(tF => tF);
1050
+ if (vd.length === 0) {
1051
+ cb();
1052
+ return;
1053
+ }
1054
+ let f = 0;
1055
+ const fCb = () => { if (++f === vd.length) cb(); };
1056
+ vd.forEach(tF => tF(fCb));
1057
+ };
1058
+ }
1059
+ return null;
1060
+ }
1061
+
1062
+ /**
1063
+ * Return trigger function from trigger handlers
1064
+ */
1065
+ if (this.triggerHandlers.size) {
1066
+ for (const h of this.triggerHandlers) {
1067
+ if (typeof h === 'function') {
1068
+ const r = h.call(this, opt, o);
1069
+ if (r && typeof r === 'function') return r;
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Return interaction trigger function if opt is true or 'interaction'
1076
+ */
1077
+ if (opt === true || opt === 'interaction') {
1078
+ return (cb) => this.waitForInteraction(cb);
1079
+ }
1080
+
1081
+ return null;
1082
+ },
1083
+
1084
+ /**
1085
+ * Wait for user interaction before running a callback (for interaction phase).
1086
+ * This is the default fallback trigger.
1087
+ *
1088
+ * @param {Function} cb - The callback to run after user interaction.
1089
+ * @returns {void}
1090
+ */
1091
+ waitForInteraction(cb) {
1092
+ ['click', 'keydown', 'wheel', 'mousedown', 'mousemove', 'touchstart'].forEach(e => window.addEventListener(e, () => cb(), { once: true, passive: true }));
1093
+ },
1094
+
1095
+ /**
1096
+ * Execute the process based on its type.
1097
+ *
1098
+ * @param {Object} process - The process object.
1099
+ * @returns {Promise<any>}
1100
+ */
1101
+ async execute(process) {
1102
+ const finishProcess = async () => {
1103
+ /**
1104
+ * Call process completion actions
1105
+ */
1106
+ if (this.processCompleteActions.size) {
1107
+ for (const action of this.processCompleteActions) {
1108
+ if (typeof action === 'function') action.call(this, process);
1109
+ }
1110
+ }
1111
+ this.completedProcesses.add(process.id);
1112
+ process._running = false;
1113
+ this.checkPendingProcesses();
1114
+ };
1115
+ if (process.skipped) {
1116
+ this.fire('SKIPPED', { ...process, id: process.id });
1117
+ finishProcess();
1118
+ return;
1119
+ }
1120
+ const handler = this.types.get(process.type);
1121
+ if (!handler) {
1122
+ this.log('UNKNOWN_TYPE', process.type, process.id);
1123
+ finishProcess();
1124
+ return;
1125
+ }
1126
+ this.fire('STARTED', { ...process, id: process.id });
1127
+
1128
+ /**
1129
+ * Build handler callbacks from filters
1130
+ */
1131
+ const callbacks = {};
1132
+ if (this.handlerCallbacksFilters.size) {
1133
+ for (const filter of this.handlerCallbacksFilters) {
1134
+ if (typeof filter === 'function') {
1135
+ const pluginCallbacks = filter.call(this, process);
1136
+ if (pluginCallbacks && typeof pluginCallbacks === 'object') {
1137
+ Object.assign(callbacks, pluginCallbacks);
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * Execute handler and handle completion or error
1145
+ */
1146
+ return handler(process, callbacks)
1147
+ .then(() => {
1148
+ this.fire('COMPLETED', { ...process, id: process.id });
1149
+ finishProcess();
1150
+ })
1151
+ .catch(() => {
1152
+ this.fire('ERROR', { ...process, id: process.id });
1153
+ finishProcess();
1154
+ });
1155
+ }
1156
+ };