webspresso 0.0.110 → 0.0.111

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.
@@ -18,7 +18,15 @@
18
18
  const { createFormHandle } = require('./forms');
19
19
  const { createApiClient } = require('./api');
20
20
  const { defaultStorage, createClientStorage } = require('./storage');
21
- const { decorateElement } = require('./utils');
21
+ const { createClientServiceRegistry } = require('./services');
22
+ const {
23
+ isArray,
24
+ isNumber,
25
+ decorateElement,
26
+ parseDurationMs,
27
+ parseRefetchMode,
28
+ setElementBusyState,
29
+ } = require('./utils');
22
30
 
23
31
  let instanceSeq = 0;
24
32
  let activeControllerContext = null;
@@ -43,7 +51,7 @@ function deepTouch(val, visited = new Set()) {
43
51
  return;
44
52
  }
45
53
  visited.add(val);
46
- if (Array.isArray(val)) {
54
+ if (isArray(val)) {
47
55
  for (let i = 0; i < val.length; i++) {
48
56
  deepTouch(val[i], visited);
49
57
  }
@@ -60,14 +68,14 @@ function deepCloneOrSnapshot(val) {
60
68
  try {
61
69
  return JSON.parse(JSON.stringify(val));
62
70
  } catch {
63
- return Array.isArray(val) ? [...val] : { ...val };
71
+ return isArray(val) ? [...val] : { ...val };
64
72
  }
65
73
  }
66
74
 
67
75
  function deepEqual(a, b) {
68
76
  if (Object.is(a, b)) return true;
69
77
  if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') return false;
70
- if (Array.isArray(a) !== Array.isArray(b)) return false;
78
+ if (isArray(a) !== isArray(b)) return false;
71
79
  const keysA = Object.keys(a);
72
80
  const keysB = Object.keys(b);
73
81
  if (keysA.length !== keysB.length) return false;
@@ -81,7 +89,7 @@ function deepEqual(a, b) {
81
89
  }
82
90
 
83
91
  function hasArrayChanged(newArr, oldArr) {
84
- if (!Array.isArray(newArr) || !Array.isArray(oldArr)) return !Object.is(newArr, oldArr);
92
+ if (!isArray(newArr) || !isArray(oldArr)) return !Object.is(newArr, oldArr);
85
93
  if (newArr.length !== oldArr.length) return true;
86
94
  for (let i = 0; i < newArr.length; i++) {
87
95
  if (!Object.is(newArr[i], oldArr[i])) return true;
@@ -108,7 +116,7 @@ function createStandaloneWatch(engine, source, callback, options = {}) {
108
116
  let getter;
109
117
  if (typeof source === 'function') {
110
118
  getter = source;
111
- } else if (Array.isArray(source)) {
119
+ } else if (isArray(source)) {
112
120
  getter = () => source.map((s) => (typeof s === 'function' ? s() : s));
113
121
  } else {
114
122
  getter = () => source;
@@ -147,14 +155,14 @@ function createStandaloneWatch(engine, source, callback, options = {}) {
147
155
  let newValue;
148
156
  try {
149
157
  newValue = getter();
150
- if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
158
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
151
159
  deepTouch(newValue);
152
160
  }
153
161
  } catch (_err) {
154
162
  return;
155
163
  }
156
164
 
157
- const isArr = Array.isArray(source);
165
+ const isArr = isArray(source);
158
166
 
159
167
  if (isFirstRun) {
160
168
  isFirstRun = false;
@@ -325,7 +333,7 @@ function defineController(definition = {}) {
325
333
  }
326
334
  if (definition.persist !== undefined && definition.persist !== null) {
327
335
  const p = definition.persist;
328
- const isArr = Array.isArray(p);
336
+ const isArr = isArray(p);
329
337
  const isStr = typeof p === 'string';
330
338
  const isObj = typeof p === 'object' && p !== null && !isArr;
331
339
  if (!isArr && !isStr && !isObj) {
@@ -381,13 +389,13 @@ function normalizePersistConfig(persistOpt, controllerName) {
381
389
  let isGlobal = false;
382
390
  let debounce = 50;
383
391
 
384
- if (Array.isArray(persistOpt)) {
392
+ if (isArray(persistOpt)) {
385
393
  keys = persistOpt.map(String);
386
394
  } else if (typeof persistOpt === 'string') {
387
395
  const s = persistOpt.trim().toLowerCase();
388
396
  storageType = s === 'session' ? 'session' : 'local';
389
397
  } else if (typeof persistOpt === 'object' && persistOpt !== null) {
390
- if (Array.isArray(persistOpt.keys)) {
398
+ if (isArray(persistOpt.keys)) {
391
399
  keys = persistOpt.keys.map(String);
392
400
  }
393
401
  if (persistOpt.storage === 'session' || persistOpt.storage === 'memory') {
@@ -399,7 +407,7 @@ function normalizePersistConfig(persistOpt, controllerName) {
399
407
  if (Boolean(persistOpt.global)) {
400
408
  isGlobal = true;
401
409
  }
402
- if (typeof persistOpt.debounce === 'number') {
410
+ if (isNumber(persistOpt.debounce)) {
403
411
  debounce = Math.max(0, persistOpt.debounce);
404
412
  }
405
413
  }
@@ -470,6 +478,14 @@ function createControllerRuntime(options = {}) {
470
478
  const debug = options.debug || null;
471
479
  const baseApiConfig = options.apiConfig || {};
472
480
  const lazyLoader = typeof options.lazyLoader === 'function' ? options.lazyLoader : null;
481
+ const services =
482
+ options.services && typeof options.services.get === 'function'
483
+ ? options.services
484
+ : createClientServiceRegistry({
485
+ root: options.root || null,
486
+ debug,
487
+ services: options.services || undefined,
488
+ });
473
489
 
474
490
  // Registered controller definitions: name -> { name, definition, sourcePath }
475
491
  const definitions = new Map();
@@ -534,7 +550,7 @@ function createControllerRuntime(options = {}) {
534
550
  };
535
551
  definitions.set(canonicalName, entry);
536
552
 
537
- if (Array.isArray(meta.aliases)) {
553
+ if (isArray(meta.aliases)) {
538
554
  for (const alias of meta.aliases) {
539
555
  if (alias && !definitions.has(alias)) {
540
556
  definitions.set(alias, entry);
@@ -587,6 +603,79 @@ function createControllerRuntime(options = {}) {
587
603
  const scopedForms = new Map();
588
604
  const scopedTasks = {};
589
605
  const scopedTasksList = [];
606
+ const busyActionCounts = new Map();
607
+ let totalBusyActions = 0;
608
+ const busySignalPrefix = `__w_inst_${instanceId}.__busy`;
609
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, false);
610
+
611
+ function syncAnyBusySignal() {
612
+ let busy = totalBusyActions > 0;
613
+ if (!busy) {
614
+ for (let i = 0; i < scopedTasksList.length; i++) {
615
+ if (scopedTasksList[i].pending) {
616
+ busy = true;
617
+ break;
618
+ }
619
+ }
620
+ }
621
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, busy);
622
+ }
623
+
624
+ function setActionBusy(actionName, delta) {
625
+ const prevCount = busyActionCounts.get(actionName) || 0;
626
+ const nextCount = Math.max(0, prevCount + delta);
627
+ if (nextCount === 0) {
628
+ busyActionCounts.delete(actionName);
629
+ } else {
630
+ busyActionCounts.set(actionName, nextCount);
631
+ }
632
+ totalBusyActions = Math.max(0, totalBusyActions + delta);
633
+ datastar.engine.signals.setValue(`${busySignalPrefix}.action_${actionName}`, nextCount > 0);
634
+ syncAnyBusySignal();
635
+ }
636
+
637
+ function isActionBusy(actionName) {
638
+ if (!actionName) return false;
639
+ datastar.engine.signals.value(`${busySignalPrefix}.action_${actionName}`);
640
+ return (busyActionCounts.get(actionName) || 0) > 0;
641
+ }
642
+
643
+ function checkIsBusy() {
644
+ datastar.engine.signals.value(`${busySignalPrefix}.any`);
645
+ if (totalBusyActions > 0) return true;
646
+ for (let i = 0; i < scopedTasksList.length; i++) {
647
+ if (scopedTasksList[i].pending) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ function isLoadingTargetActive(rawSpec) {
653
+ const spec = typeof rawSpec === 'string' ? rawSpec.trim() : '';
654
+ if (!spec || spec === 'true') {
655
+ return checkIsBusy();
656
+ }
657
+ const parts = spec.split(',').map((s) => s.trim()).filter(Boolean);
658
+ for (let i = 0; i < parts.length; i++) {
659
+ const token = parts[i];
660
+ if (reg.definition.actions && Object.prototype.hasOwnProperty.call(reg.definition.actions, token)) {
661
+ if (isActionBusy(token)) return true;
662
+ continue;
663
+ }
664
+ if (
665
+ Object.prototype.hasOwnProperty.call(scopedTasks, token) ||
666
+ (reg.definition.tasks && Object.prototype.hasOwnProperty.call(reg.definition.tasks, token))
667
+ ) {
668
+ const t = getTask(token);
669
+ if (t && t.pending) return true;
670
+ continue;
671
+ }
672
+ if (Boolean(datastar.evaluateStateExpression(state, token))) {
673
+ return true;
674
+ }
675
+ }
676
+ return false;
677
+ }
678
+
590
679
  let currentActionEl = null;
591
680
  let lastError = null;
592
681
  const dynamicErrorListeners = new Set();
@@ -647,10 +736,29 @@ function createControllerRuntime(options = {}) {
647
736
  const taskKey = taskOpts.key || `task_${++taskSeq}`;
648
737
  const signalPrefix = `__w_inst_${instanceId}.task_${taskKey}`;
649
738
 
739
+ // React Query-style automatic revalidation options for tasks
740
+ const staleTimeMs = parseDurationMs(taskOpts.staleTime, 0);
741
+ const refetchOnMountMode = parseRefetchMode(
742
+ taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
743
+ false
744
+ );
745
+ const refetchOnFocusMode = parseRefetchMode(
746
+ taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
747
+ false
748
+ );
749
+ const refetchOnReconnectMode = parseRefetchMode(taskOpts.refetchOnReconnect, false);
750
+ const refetchIntervalMs = parseDurationMs(taskOpts.refetchInterval, 0);
751
+ const refetchIntervalInBg =
752
+ parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
753
+
650
754
  let currentRunVersion = 0;
651
755
  let activeRuns = 0;
652
756
  let inFlightAbort = null;
653
757
  let queuedPromise = Promise.resolve();
758
+ let _lastUpdatedAt = 0;
759
+ let taskIntervalTimer = null;
760
+ let focusDebounceTimer = null;
761
+ const taskCleanupListeners = [];
654
762
 
655
763
  datastar.engine.signals.setValue(`${signalPrefix}.pending`, false);
656
764
  datastar.engine.signals.setValue(`${signalPrefix}.data`, null);
@@ -662,8 +770,19 @@ function createControllerRuntime(options = {}) {
662
770
  let _error = null;
663
771
  let _stale = false;
664
772
 
773
+ function isTaskStale(mode = true) {
774
+ if (!mode) return false;
775
+ if (mode === 'always') return true;
776
+ if (_lastUpdatedAt === 0) return true;
777
+ if (staleTimeMs <= 0) return true;
778
+ return Date.now() - _lastUpdatedAt >= staleTimeMs;
779
+ }
780
+
665
781
  function updateSignal(name, val) {
666
782
  datastar.engine.signals.setValue(`${signalPrefix}.${name}`, val);
783
+ if (name === 'pending') {
784
+ syncAnyBusySignal();
785
+ }
667
786
  }
668
787
 
669
788
  async function run(...args) {
@@ -722,6 +841,7 @@ function createControllerRuntime(options = {}) {
722
841
  _data = result;
723
842
  _error = null;
724
843
  _stale = false;
844
+ _lastUpdatedAt = Date.now();
725
845
  updateSignal('data', result);
726
846
  updateSignal('error', null);
727
847
  updateSignal('stale', false);
@@ -787,16 +907,103 @@ function createControllerRuntime(options = {}) {
787
907
  },
788
908
  get stale() {
789
909
  datastar.engine.signals.value(`${signalPrefix}.stale`);
790
- return _stale;
910
+ return _stale || (_lastUpdatedAt > 0 && staleTimeMs > 0 && Date.now() - _lastUpdatedAt >= staleTimeMs);
791
911
  },
912
+ get lastUpdatedAt() {
913
+ return _lastUpdatedAt;
914
+ },
915
+ isStale: () => isTaskStale(true),
792
916
  run,
793
917
  cancel,
794
918
  dispose() {
919
+ if (taskIntervalTimer) {
920
+ clearInterval(taskIntervalTimer);
921
+ taskIntervalTimer = null;
922
+ }
923
+ if (focusDebounceTimer) {
924
+ clearTimeout(focusDebounceTimer);
925
+ focusDebounceTimer = null;
926
+ }
927
+ while (taskCleanupListeners.length > 0) {
928
+ const removeFn = taskCleanupListeners.pop();
929
+ try {
930
+ removeFn();
931
+ } catch (_e) {}
932
+ }
795
933
  cancel();
796
934
  datastar.engine.signals.remove(signalPrefix);
797
935
  },
798
936
  };
799
937
 
938
+ // Set up automatic background revalidation (mount / focus / reconnect / interval)
939
+ const doc = (rootEl && rootEl.ownerDocument) || (typeof document !== 'undefined' ? document : null);
940
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
941
+
942
+ function isDocHidden() {
943
+ return Boolean(doc && doc.visibilityState === 'hidden');
944
+ }
945
+
946
+ if (refetchOnMountMode) {
947
+ Promise.resolve().then(() => {
948
+ if (isConnected() && isTaskStale(refetchOnMountMode)) {
949
+ run().catch(() => {});
950
+ }
951
+ });
952
+ }
953
+
954
+ if (refetchOnFocusMode) {
955
+ const onTaskFocus = () => {
956
+ if (!isConnected() || isDocHidden()) return;
957
+ if (focusDebounceTimer) clearTimeout(focusDebounceTimer);
958
+ focusDebounceTimer = setTimeout(() => {
959
+ focusDebounceTimer = null;
960
+ if (isConnected() && !isDocHidden() && activeRuns === 0 && isTaskStale(refetchOnFocusMode)) {
961
+ run().catch(() => {});
962
+ }
963
+ }, 20);
964
+ };
965
+ const onVisChange = () => {
966
+ if (!isDocHidden()) onTaskFocus();
967
+ };
968
+ if (doc && typeof doc.addEventListener === 'function') {
969
+ doc.addEventListener('visibilitychange', onVisChange);
970
+ taskCleanupListeners.push(() => doc.removeEventListener('visibilitychange', onVisChange));
971
+ }
972
+ if (win && typeof win.addEventListener === 'function') {
973
+ win.addEventListener('focus', onTaskFocus);
974
+ taskCleanupListeners.push(() => win.removeEventListener('focus', onTaskFocus));
975
+ }
976
+ }
977
+
978
+ if (refetchOnReconnectMode && win && typeof win.addEventListener === 'function') {
979
+ const onTaskOnline = () => {
980
+ if (isConnected() && activeRuns === 0 && isTaskStale(refetchOnReconnectMode)) {
981
+ run().catch(() => {});
982
+ }
983
+ };
984
+ win.addEventListener('online', onTaskOnline);
985
+ taskCleanupListeners.push(() => win.removeEventListener('online', onTaskOnline));
986
+ }
987
+
988
+ if (refetchIntervalMs > 0) {
989
+ taskIntervalTimer = setInterval(() => {
990
+ if (!isConnected()) {
991
+ if (taskIntervalTimer) {
992
+ clearInterval(taskIntervalTimer);
993
+ taskIntervalTimer = null;
994
+ }
995
+ return;
996
+ }
997
+ if (!refetchIntervalInBg && isDocHidden()) return;
998
+ if (activeRuns === 0) {
999
+ run().catch(() => {});
1000
+ }
1001
+ }, refetchIntervalMs);
1002
+ if (taskIntervalTimer && typeof taskIntervalTimer.unref === 'function') {
1003
+ taskIntervalTimer.unref();
1004
+ }
1005
+ }
1006
+
800
1007
  scopedTasksList.push(taskObj);
801
1008
  return taskObj;
802
1009
  }
@@ -826,7 +1033,7 @@ function createControllerRuntime(options = {}) {
826
1033
  getter = () => source(ctx);
827
1034
  } else if (typeof source === 'string') {
828
1035
  getter = () => resolveStatePath(state, source);
829
- } else if (Array.isArray(source)) {
1036
+ } else if (isArray(source)) {
830
1037
  getter = () =>
831
1038
  source.map((s) =>
832
1039
  typeof s === 'function'
@@ -876,7 +1083,7 @@ function createControllerRuntime(options = {}) {
876
1083
  let newValue;
877
1084
  try {
878
1085
  newValue = getter();
879
- if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
1086
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
880
1087
  deepTouch(newValue);
881
1088
  }
882
1089
  } catch (err) {
@@ -884,7 +1091,7 @@ function createControllerRuntime(options = {}) {
884
1091
  return;
885
1092
  }
886
1093
 
887
- const isArr = Array.isArray(source);
1094
+ const isArr = isArray(source);
888
1095
 
889
1096
  if (isFirstRun) {
890
1097
  isFirstRun = false;
@@ -1069,6 +1276,7 @@ function createControllerRuntime(options = {}) {
1069
1276
  api: scopedApi,
1070
1277
  datastar,
1071
1278
  debug,
1279
+ services,
1072
1280
  ...formOptions,
1073
1281
  });
1074
1282
  scopedForms.set(handle.name, handle);
@@ -1168,6 +1376,7 @@ function createControllerRuntime(options = {}) {
1168
1376
  return state;
1169
1377
  },
1170
1378
  api: scopedApi,
1379
+ services,
1171
1380
  forms: formsAccessor,
1172
1381
  target: targetAccessor,
1173
1382
  targets: targetsAccessor,
@@ -1201,6 +1410,10 @@ function createControllerRuntime(options = {}) {
1201
1410
  signal: lifecycleAbort.signal,
1202
1411
  isConnected,
1203
1412
  isDisposed: () => !isConnected(),
1413
+ get isBusy() {
1414
+ return checkIsBusy();
1415
+ },
1416
+ isActionBusy,
1204
1417
  get lastError() {
1205
1418
  return lastError;
1206
1419
  },
@@ -1269,7 +1482,7 @@ function createControllerRuntime(options = {}) {
1269
1482
  if (persistConfig) {
1270
1483
  const driver = resolvePersistDriver(persistConfig);
1271
1484
  const savedData = driver.get(persistConfig.storageKey, null);
1272
- if (savedData && typeof savedData === 'object' && !Array.isArray(savedData)) {
1485
+ if (savedData && typeof savedData === 'object' && !isArray(savedData)) {
1273
1486
  for (const [k, v] of Object.entries(savedData)) {
1274
1487
  if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1275
1488
  if (Object.prototype.hasOwnProperty.call(initialRawState, k)) {
@@ -1308,6 +1521,36 @@ function createControllerRuntime(options = {}) {
1308
1521
  if (typeof fn !== 'function') {
1309
1522
  return undefined;
1310
1523
  }
1524
+
1525
+ // Guard against duplicate clicks while an async action on this exact element is already in-flight
1526
+ if (
1527
+ actionEl &&
1528
+ actionEl.__wActionBusy === true &&
1529
+ (!actionEl.hasAttribute || !actionEl.hasAttribute('data-w-allow-concurrent'))
1530
+ ) {
1531
+ if (event && typeof event.preventDefault === 'function') {
1532
+ event.preventDefault();
1533
+ }
1534
+ return undefined;
1535
+ }
1536
+
1537
+ // Built-in `data-w-confirm` guard
1538
+ if (actionEl && typeof actionEl.getAttribute === 'function') {
1539
+ const confirmMsg = actionEl.getAttribute('data-w-confirm');
1540
+ if (confirmMsg !== null && confirmMsg !== '') {
1541
+ const confirmFn =
1542
+ typeof window !== 'undefined' && typeof window.confirm === 'function'
1543
+ ? window.confirm.bind(window)
1544
+ : null;
1545
+ if (confirmFn && !confirmFn(confirmMsg)) {
1546
+ if (event && typeof event.preventDefault === 'function') {
1547
+ event.preventDefault();
1548
+ }
1549
+ return undefined;
1550
+ }
1551
+ }
1552
+ }
1553
+
1311
1554
  const decoratedEl = decorateElement(actionEl);
1312
1555
  currentActionEl = decoratedEl || null;
1313
1556
  const prevContext = activeControllerContext;
@@ -1315,17 +1558,28 @@ function createControllerRuntime(options = {}) {
1315
1558
  try {
1316
1559
  const res = fn(ctx, event, decoratedEl);
1317
1560
  if (res && typeof res.then === 'function') {
1318
- return res.catch((err) => {
1319
- // Ignore canceled/superseded request errors on disconnected or superseded instances
1320
- if (err && err.isCanceled) return undefined;
1321
- handleControllerError(err, {
1322
- action: actionName,
1323
- event,
1324
- actionEl: decoratedEl,
1325
- phase: 'action',
1326
- });
1327
- throw err;
1328
- });
1561
+ setActionBusy(actionName, 1);
1562
+ setElementBusyState(actionEl, true);
1563
+ return res.then(
1564
+ (val) => {
1565
+ setActionBusy(actionName, -1);
1566
+ setElementBusyState(actionEl, false);
1567
+ return val;
1568
+ },
1569
+ (err) => {
1570
+ setActionBusy(actionName, -1);
1571
+ setElementBusyState(actionEl, false);
1572
+ // Ignore canceled/superseded request errors on disconnected or superseded instances
1573
+ if (err && err.isCanceled) return undefined;
1574
+ handleControllerError(err, {
1575
+ action: actionName,
1576
+ event,
1577
+ actionEl: decoratedEl,
1578
+ phase: 'action',
1579
+ });
1580
+ throw err;
1581
+ }
1582
+ );
1329
1583
  }
1330
1584
  return res;
1331
1585
  } catch (err) {
@@ -1353,6 +1607,7 @@ function createControllerRuntime(options = {}) {
1353
1607
  state,
1354
1608
  invokeAction,
1355
1609
  isConnected,
1610
+ isLoadingTargetActive,
1356
1611
  });
1357
1612
  domDisposers.push(unbind);
1358
1613
 
@@ -1364,7 +1619,7 @@ function createControllerRuntime(options = {}) {
1364
1619
  const formsInScope = rootEl.querySelectorAll('form[data-w-form]');
1365
1620
  for (let i = 0; i < formsInScope.length; i++) {
1366
1621
  if (isTargetInControllerScope(rootEl, formsInScope[i])) {
1367
- createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug });
1622
+ createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1368
1623
  }
1369
1624
  }
1370
1625
  }
@@ -1433,6 +1688,7 @@ function createControllerRuntime(options = {}) {
1433
1688
  name: controllerName,
1434
1689
  el: rootEl,
1435
1690
  state,
1691
+ tasks: tasksProxy,
1436
1692
  ctx,
1437
1693
  invokeAction,
1438
1694
  actions: new Proxy({}, {
@@ -1642,6 +1898,7 @@ function createControllerRuntime(options = {}) {
1642
1898
  api: defaultApi,
1643
1899
  datastar,
1644
1900
  debug,
1901
+ services,
1645
1902
  });
1646
1903
  standaloneForms.set(formEl, handle);
1647
1904
  }
@@ -1666,6 +1923,7 @@ function createControllerRuntime(options = {}) {
1666
1923
 
1667
1924
  return {
1668
1925
  register,
1926
+ services,
1669
1927
  hasController(name) {
1670
1928
  return definitions.has(name);
1671
1929
  },
@@ -8,7 +8,7 @@
8
8
  * and lifecycle cleanup errors without logging raw form values or sensitive payloads.
9
9
  */
10
10
 
11
- const { NOOP_DEBUGGER } = require('./utils');
11
+ const { isArray, isNumber, NOOP_DEBUGGER, uid } = require('./utils');
12
12
 
13
13
  const SENSITIVE_KEY_PATTERN = /password|passwd|secret|token|authorization|cookie|credit|card|cvv|ssn|api[-_]?key/i;
14
14
 
@@ -32,7 +32,7 @@ function summarizePayloadSafely(payload, includeSafePrimitives = false) {
32
32
  if (typeof Blob !== 'undefined' && payload instanceof Blob) {
33
33
  return { type: 'Blob', size: payload.size, mimeType: payload.type || 'application/octet-stream' };
34
34
  }
35
- if (Array.isArray(payload)) {
35
+ if (isArray(payload)) {
36
36
  return { type: 'Array', length: payload.length };
37
37
  }
38
38
  if (typeof payload === 'object') {
@@ -58,7 +58,7 @@ function summarizePayloadSafely(payload, includeSafePrimitives = false) {
58
58
  */
59
59
  function createActiveDebugger(options = {}) {
60
60
  let enabled = Boolean(options.enabled);
61
- const maxEvents = typeof options.maxEvents === 'number' && options.maxEvents > 0 ? options.maxEvents : 200;
61
+ const maxEvents = isNumber(options.maxEvents) && options.maxEvents > 0 ? options.maxEvents : 200;
62
62
  const customLogger = typeof options.logger === 'function' ? options.logger : null;
63
63
 
64
64
  const events = [];
@@ -73,7 +73,7 @@ function createActiveDebugger(options = {}) {
73
73
 
74
74
  function pushEvent(type, detail = {}) {
75
75
  const entry = {
76
- id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
76
+ id: uid('evt'),
77
77
  type,
78
78
  timestamp: Date.now(),
79
79
  ...detail,
@@ -192,7 +192,7 @@ function createActiveDebugger(options = {}) {
192
192
  completedAt: Date.now(),
193
193
  },
194
194
  invalidation: {
195
- tags: Array.isArray(tags) ? [...tags] : [],
195
+ tags: isArray(tags) ? [...tags] : [],
196
196
  triggeredAt: Date.now(),
197
197
  },
198
198
  refresh: null,
@@ -215,11 +215,11 @@ function createActiveDebugger(options = {}) {
215
215
  recordChainRefresh(chainId, { fragmentIds, status, loadMs, renderMs, totalServerMs, error }) {
216
216
  const chain = chainId ? causalChains.get(chainId) : null;
217
217
  const refreshData = {
218
- fragmentIds: Array.isArray(fragmentIds) ? [...fragmentIds] : [],
218
+ fragmentIds: isArray(fragmentIds) ? [...fragmentIds] : [],
219
219
  status: status || 'ok',
220
- loadMs: typeof loadMs === 'number' ? loadMs : null,
221
- renderMs: typeof renderMs === 'number' ? renderMs : null,
222
- totalServerMs: typeof totalServerMs === 'number' ? totalServerMs : null,
220
+ loadMs: isNumber(loadMs) ? loadMs : null,
221
+ renderMs: isNumber(renderMs) ? renderMs : null,
222
+ totalServerMs: isNumber(totalServerMs) ? totalServerMs : null,
223
223
  error: error ? String(error.message || error) : null,
224
224
  completedAt: Date.now(),
225
225
  };
@@ -234,9 +234,9 @@ function createActiveDebugger(options = {}) {
234
234
  const patchData = {
235
235
  instanceId,
236
236
  fragmentName: fragmentName || null,
237
- patchMs: typeof patchMs === 'number' ? patchMs : 0,
238
- loadMs: typeof loadMs === 'number' ? loadMs : null,
239
- renderMs: typeof renderMs === 'number' ? renderMs : null,
237
+ patchMs: isNumber(patchMs) ? patchMs : 0,
238
+ loadMs: isNumber(loadMs) ? loadMs : null,
239
+ renderMs: isNumber(renderMs) ? renderMs : null,
240
240
  completedAt: Date.now(),
241
241
  };
242
242
  if (chain) {