webspresso 0.0.109 → 0.0.110

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.
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultStorage } = require('./storage');
4
+
3
5
  /**
4
6
  * Webspresso Native HTML Form Layer & Typed Form Handle
5
7
  *
@@ -13,6 +15,40 @@
13
15
  * - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
14
16
  */
15
17
 
18
+ /**
19
+ * Validate that a redirect URL uses a safe protocol (http/https) or relative path
20
+ * to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
21
+ *
22
+ * @param {string} target
23
+ * @returns {boolean}
24
+ */
25
+ function isSafeRedirectUrl(target) {
26
+ if (!target || typeof target !== 'string') return false;
27
+ const trimmed = target.trim();
28
+ if (!trimmed) return false;
29
+
30
+ // Block control characters
31
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
32
+
33
+ // Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
34
+ if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
35
+ return false;
36
+ }
37
+
38
+ // Relative path starting with single slash (e.g. /dashboard)
39
+ if (trimmed.startsWith('/')) {
40
+ return true;
41
+ }
42
+
43
+ // Absolute URL: MUST explicitly parse as http: or https:
44
+ try {
45
+ const parsed = new URL(trimmed);
46
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
16
52
  let errorIdSeq = 0;
17
53
 
18
54
  /**
@@ -401,6 +437,89 @@ function createFormHandle(formEl, options = {}) {
401
437
  const errorListeners = new Set();
402
438
  const stateListeners = new Set();
403
439
 
440
+ // Form draft persistence setup (sessionStorage by default, or localStorage)
441
+ const rawDraftAttr = formEl
442
+ ? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
443
+ : null;
444
+ const isDraftEnabled =
445
+ options.persistDraft !== undefined
446
+ ? Boolean(options.persistDraft)
447
+ : rawDraftAttr !== null && rawDraftAttr !== 'false';
448
+
449
+ let draftStorageType = 'session';
450
+ if (typeof options.persistDraft === 'string') {
451
+ draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
452
+ } else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
453
+ draftStorageType = 'local';
454
+ }
455
+
456
+ const draftKey =
457
+ options.draftKey ||
458
+ (formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
459
+ `form_draft:${formName || 'anon'}`;
460
+
461
+ const draftStorage =
462
+ (options.storage && options.storage[draftStorageType]) ||
463
+ (draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
464
+
465
+ function extractDraftValues(el) {
466
+ if (!el || typeof el.querySelectorAll !== 'function') return {};
467
+ const { data } = parseFormElement(el);
468
+ const safeData = {};
469
+ for (const [key, val] of Object.entries(data)) {
470
+ const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
471
+ if (!ctrl) {
472
+ safeData[key] = val;
473
+ continue;
474
+ }
475
+ const type = (ctrl.type || '').toLowerCase();
476
+ if (type === 'password' || type === 'file') continue;
477
+ if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
478
+ safeData[key] = val;
479
+ }
480
+ return safeData;
481
+ }
482
+
483
+ function saveDraft() {
484
+ if (!formEl || !isDraftEnabled) return false;
485
+ const draftPayload = extractDraftValues(formEl);
486
+ return draftStorage.set(draftKey, draftPayload);
487
+ }
488
+
489
+ function restoreDraft() {
490
+ if (!formEl || !isDraftEnabled) return false;
491
+ const saved = draftStorage.get(draftKey, null);
492
+ if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
493
+ fill(saved, { baseline: false });
494
+ if (typeof formEl.dispatchEvent === 'function') {
495
+ const CustomEventCtor =
496
+ (formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
497
+ (typeof CustomEvent !== 'undefined' ? CustomEvent : null);
498
+ if (CustomEventCtor) {
499
+ formEl.dispatchEvent(
500
+ new CustomEventCtor('w-form:draft-restored', {
501
+ bubbles: true,
502
+ cancelable: true,
503
+ detail: { form: handle, draft: saved, key: draftKey },
504
+ })
505
+ );
506
+ }
507
+ }
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+
513
+ function clearDraft() {
514
+ if (!isDraftEnabled) return false;
515
+ return draftStorage.remove(draftKey);
516
+ }
517
+
518
+ function hasDraft() {
519
+ if (!isDraftEnabled) return false;
520
+ return draftStorage.has(draftKey);
521
+ }
522
+
404
523
  function notifyStateChange() {
405
524
  for (const fn of stateListeners) {
406
525
  try {
@@ -799,6 +918,10 @@ function createFormHandle(formEl, options = {}) {
799
918
  initialSnapshot = captureInitialFormSnapshot(formEl);
800
919
  }
801
920
 
921
+ if (isDraftEnabled) {
922
+ clearDraft();
923
+ }
924
+
802
925
  // Invoke registered success hooks. Note: Any background fragment refresh failure
803
926
  // is handled by the fragment coordinator and NEVER puts this form into validation error state!
804
927
  for (const listener of successListeners) {
@@ -810,7 +933,7 @@ function createFormHandle(formEl, options = {}) {
810
933
  submitOptions.redirectOnSuccess ||
811
934
  (formEl && formEl.getAttribute('data-w-redirect')) ||
812
935
  null;
813
- if (redirectTarget) {
936
+ if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
814
937
  if (typeof options.navigate === 'function') {
815
938
  options.navigate(redirectTarget);
816
939
  } else if (typeof window !== 'undefined' && window.location) {
@@ -859,6 +982,7 @@ function createFormHandle(formEl, options = {}) {
859
982
  }
860
983
 
861
984
  // Listen to native submit and input/change events on `formEl`
985
+ let draftDebounceTimer = null;
862
986
  const onDomInput = (evt) => {
863
987
  dirty = computeFormDirty(formEl, initialSnapshot);
864
988
  const target = evt && evt.target;
@@ -873,6 +997,12 @@ function createFormHandle(formEl, options = {}) {
873
997
  }
874
998
  }
875
999
  }
1000
+ if (isDraftEnabled) {
1001
+ if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
1002
+ draftDebounceTimer = setTimeout(() => {
1003
+ saveDraft();
1004
+ }, 150);
1005
+ }
876
1006
  notifyStateChange();
877
1007
  };
878
1008
 
@@ -900,7 +1030,9 @@ function createFormHandle(formEl, options = {}) {
900
1030
  // Initialize hidden state on empty error placeholders
901
1031
  syncErrorsToDom(false);
902
1032
 
903
- const handle = {
1033
+ let handle = null;
1034
+
1035
+ handle = {
904
1036
  name: formName,
905
1037
  el: formEl,
906
1038
  get pending() {
@@ -938,6 +1070,12 @@ function createFormHandle(formEl, options = {}) {
938
1070
  return parseFormElement(formEl, submitter).data;
939
1071
  },
940
1072
  fill,
1073
+ saveDraft,
1074
+ restoreDraft,
1075
+ clearDraft,
1076
+ get hasDraft() {
1077
+ return hasDraft();
1078
+ },
941
1079
  setAction,
942
1080
  setRouteParams,
943
1081
  setDisabled,
@@ -1009,6 +1147,10 @@ function createFormHandle(formEl, options = {}) {
1009
1147
  debug.recordEvent('form:bind', { name: formName });
1010
1148
  }
1011
1149
 
1150
+ if (isDraftEnabled) {
1151
+ restoreDraft();
1152
+ }
1153
+
1012
1154
  return handle;
1013
1155
  }
1014
1156
 
@@ -1017,5 +1159,6 @@ module.exports = {
1017
1159
  isControlDisabled,
1018
1160
  coerceScalarControlValue,
1019
1161
  createFormHandle,
1162
+ isSafeRedirectUrl,
1020
1163
  };
1021
1164
 
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultStorage } = require('./storage');
4
+
3
5
  /**
4
6
  * Webspresso Client-Side SSR Fragment Coordinator & Invalidation Engine
5
7
  *
@@ -106,6 +108,7 @@ function createFragmentCoordinator(options = {}) {
106
108
  const endpoint = options.endpoint || DEFAULT_FRAGMENT_ENDPOINT;
107
109
  const datastar = options.datastar || options.adapter;
108
110
  const debug = options.debug || null;
111
+ const defaultTimeout = typeof options.timeout === 'number' ? options.timeout : 15000;
109
112
 
110
113
  // Map<instanceId, { instanceId, name, pagePath, input, dependsOn: Set<string>, version: number, el: Element, abortController: AbortController|null }>
111
114
  const instances = new Map();
@@ -151,25 +154,49 @@ function createFragmentCoordinator(options = {}) {
151
154
  const input = parseFragmentInputAttr(el.getAttribute('data-w-fragment-input'));
152
155
  const dependsOn = new Set(parseDependsOnAttr(el.getAttribute('data-w-depends-on')));
153
156
 
157
+ // Fragment input persistence setup
158
+ const rawPersistInput =
159
+ el.getAttribute('data-w-persist-input') ||
160
+ (el.getAttribute('data-w-persist') === 'input' ? 'session' : null);
161
+ const isInputPersistEnabled = Boolean(rawPersistInput && rawPersistInput !== 'false');
162
+ const inputStorageType = rawPersistInput && rawPersistInput.toLowerCase() === 'local' ? 'local' : 'session';
163
+ const inputStorageKey =
164
+ el.getAttribute('data-w-persist-key') ||
165
+ el.getAttribute('data-w-input-key') ||
166
+ `frag_input:${name}`;
167
+
168
+ let effectiveInput = input;
169
+ if (isInputPersistEnabled) {
170
+ const store = inputStorageType === 'local' ? defaultStorage.local : defaultStorage.session;
171
+ if (!el.hasAttribute('data-w-fragment-input') || Object.keys(effectiveInput).length === 0) {
172
+ const stored = store.get(inputStorageKey, null);
173
+ if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
174
+ effectiveInput = stored;
175
+ }
176
+ }
177
+ }
178
+
154
179
  const existing = instances.get(instanceId);
155
180
  if (existing) {
156
181
  existing.name = name;
157
182
  existing.pagePath = pagePath;
158
183
  existing.el = el;
159
184
  existing.dependsOn = dependsOn;
160
- if (el.hasAttribute('data-w-fragment-input')) {
161
- existing.input = input;
185
+ if (el.hasAttribute('data-w-fragment-input') || isInputPersistEnabled) {
186
+ existing.input = effectiveInput;
162
187
  }
188
+ existing.persistInput = isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null;
163
189
  } else {
164
190
  instances.set(instanceId, {
165
191
  instanceId,
166
192
  name,
167
193
  pagePath,
168
- input,
194
+ input: effectiveInput,
169
195
  dependsOn,
170
196
  version: 0,
171
197
  el,
172
198
  abortController: null,
199
+ persistInput: isInputPersistEnabled ? { type: inputStorageType, key: inputStorageKey } : null,
173
200
  });
174
201
  }
175
202
  }
@@ -229,6 +256,10 @@ function createFragmentCoordinator(options = {}) {
229
256
  ) {
230
257
  inst.input = fragmentPayload.input;
231
258
  inst.el.setAttribute('data-w-fragment-input', JSON.stringify(fragmentPayload.input));
259
+ if (inst.persistInput) {
260
+ const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
261
+ store.set(inst.persistInput.key, fragmentPayload.input);
262
+ }
232
263
  }
233
264
 
234
265
  if (datastar && typeof datastar.patchFragmentElement === 'function') {
@@ -266,7 +297,7 @@ function createFragmentCoordinator(options = {}) {
266
297
  * Execute a network refresh for a list of target fragment descriptors:
267
298
  * `Array<{ instanceId, name, pagePath, input, reqVersion }>`
268
299
  */
269
- async function executeBatchRefresh(targets, chainIds = [], abortSignal = null) {
300
+ async function executeBatchRefresh(targets, chainIds = [], abortSignal = null, batchTimeout = defaultTimeout) {
270
301
  if (!targets || targets.length === 0) {
271
302
  return { refreshed: [], skipped: true };
272
303
  }
@@ -312,6 +343,27 @@ function createFragmentCoordinator(options = {}) {
312
343
  });
313
344
  }
314
345
 
346
+ const batchAbortController = new AbortController();
347
+ let timeoutTimer = null;
348
+ let timedOut = false;
349
+ const timeoutMs = typeof batchTimeout === 'number' ? batchTimeout : defaultTimeout;
350
+
351
+ if (timeoutMs > 0) {
352
+ timeoutTimer = setTimeout(() => {
353
+ timedOut = true;
354
+ batchAbortController.abort();
355
+ }, timeoutMs);
356
+ }
357
+
358
+ const onCallerAbort = () => batchAbortController.abort();
359
+ if (abortSignal) {
360
+ if (abortSignal.aborted) {
361
+ batchAbortController.abort();
362
+ } else {
363
+ abortSignal.addEventListener('abort', onCallerAbort, { once: true });
364
+ }
365
+ }
366
+
315
367
  let response;
316
368
  try {
317
369
  response = await fetchImpl(endpoint, {
@@ -319,11 +371,45 @@ function createFragmentCoordinator(options = {}) {
319
371
  headers,
320
372
  body: JSON.stringify(requestPayload),
321
373
  credentials: 'same-origin',
322
- signal: abortSignal || undefined,
374
+ signal: batchAbortController.signal,
323
375
  });
324
376
  } catch (err) {
377
+ if (timeoutTimer) {
378
+ clearTimeout(timeoutTimer);
379
+ timeoutTimer = null;
380
+ }
381
+ if (abortSignal) {
382
+ try {
383
+ abortSignal.removeEventListener('abort', onCallerAbort);
384
+ } catch (_e) {}
385
+ }
386
+
387
+ if (timedOut) {
388
+ if (debug) {
389
+ debug.trackRequestEnd(reqId, {
390
+ status: 'failed',
391
+ errorKind: 'timeout',
392
+ });
393
+ }
394
+ const timeoutError = new Error(`Fragment refresh timed out after ${timeoutMs}ms.`);
395
+ timeoutError.code = 'FRAGMENT_REFRESH_TIMEOUT';
396
+ timeoutError.isTimedOut = true;
397
+ lastFailedBatch = { targets, chainIds };
398
+ for (const cid of chainIds) {
399
+ if (debug) {
400
+ debug.recordChainRefresh(cid, {
401
+ fragmentIds: targets.map((t) => t.instanceId),
402
+ status: 'failed',
403
+ error: timeoutError,
404
+ });
405
+ }
406
+ }
407
+ throw timeoutError;
408
+ }
409
+
325
410
  const isAbort =
326
411
  (abortSignal && abortSignal.aborted) ||
412
+ batchAbortController.signal.aborted ||
327
413
  (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR'));
328
414
 
329
415
  if (debug) {
@@ -333,7 +419,7 @@ function createFragmentCoordinator(options = {}) {
333
419
  });
334
420
  }
335
421
  if (isAbort) {
336
- return { refreshed: [], canceled: true };
422
+ return { ok: false, refreshed: [], canceled: true };
337
423
  }
338
424
  lastFailedBatch = { targets, chainIds };
339
425
  for (const cid of chainIds) {
@@ -346,6 +432,16 @@ function createFragmentCoordinator(options = {}) {
346
432
  }
347
433
  }
348
434
  throw err;
435
+ } finally {
436
+ if (timeoutTimer) {
437
+ clearTimeout(timeoutTimer);
438
+ timeoutTimer = null;
439
+ }
440
+ if (abortSignal) {
441
+ try {
442
+ abortSignal.removeEventListener('abort', onCallerAbort);
443
+ } catch (_e) {}
444
+ }
349
445
  }
350
446
 
351
447
  let body = null;
@@ -429,6 +525,7 @@ function createFragmentCoordinator(options = {}) {
429
525
  }
430
526
 
431
527
  return {
528
+ ok: true,
432
529
  refreshed: refreshedIds,
433
530
  errors: [],
434
531
  timings: {
@@ -452,7 +549,7 @@ function createFragmentCoordinator(options = {}) {
452
549
  }
453
550
 
454
551
  if (pendingTags.size === 0) {
455
- return { refreshed: [], skipped: true };
552
+ return { ok: true, refreshed: [], skipped: true };
456
553
  }
457
554
 
458
555
  const tagsToFlush = new Set(pendingTags);
@@ -487,7 +584,7 @@ function createFragmentCoordinator(options = {}) {
487
584
  });
488
585
  }
489
586
  }
490
- return { refreshed: [], skipped: true };
587
+ return { ok: true, refreshed: [], skipped: true };
491
588
  }
492
589
 
493
590
  // Parent/Child deduplication: if parent & child are both invalidated, parent refresh covers child
@@ -602,7 +699,16 @@ function createFragmentCoordinator(options = {}) {
602
699
  }
603
700
 
604
701
  if (matched.length === 0) {
605
- return { refreshed: [], skipped: true };
702
+ const skippedRes = { ok: true, refreshed: [], skipped: true };
703
+ if (typeof refreshOpts.onSuccess === 'function') {
704
+ await refreshOpts.onSuccess(skippedRes);
705
+ }
706
+ if (typeof refreshOpts.onSettled === 'function') {
707
+ await refreshOpts.onSettled(skippedRes, null);
708
+ } else if (typeof refreshOpts.onComplete === 'function') {
709
+ await refreshOpts.onComplete(skippedRes, null);
710
+ }
711
+ return skippedRes;
606
712
  }
607
713
 
608
714
  const deduped = deduplicateParentChildFragments(matched);
@@ -627,6 +733,10 @@ function createFragmentCoordinator(options = {}) {
627
733
  if (inst.el && typeof inst.el.setAttribute === 'function') {
628
734
  inst.el.setAttribute('data-w-fragment-input', JSON.stringify(inst.input));
629
735
  }
736
+ if (inst.persistInput) {
737
+ const store = inst.persistInput.type === 'local' ? defaultStorage.local : defaultStorage.session;
738
+ store.set(inst.persistInput.key, inst.input);
739
+ }
630
740
  }
631
741
 
632
742
  return {
@@ -638,7 +748,45 @@ function createFragmentCoordinator(options = {}) {
638
748
  };
639
749
  });
640
750
 
641
- return executeBatchRefresh(targets, [], abortController.signal);
751
+ try {
752
+ const result = await executeBatchRefresh(targets, [], abortController.signal, refreshOpts.timeout);
753
+ const enrichedResult = { ok: true, ...result };
754
+ if (!enrichedResult.canceled && enrichedResult.ok !== false) {
755
+ if (typeof refreshOpts.onSuccess === 'function') {
756
+ await refreshOpts.onSuccess(enrichedResult);
757
+ }
758
+ }
759
+ if (typeof refreshOpts.onSettled === 'function') {
760
+ await refreshOpts.onSettled(enrichedResult, null);
761
+ } else if (typeof refreshOpts.onComplete === 'function') {
762
+ await refreshOpts.onComplete(enrichedResult, null);
763
+ }
764
+ return enrichedResult;
765
+ } catch (err) {
766
+ const failHandler = refreshOpts.onFailure || refreshOpts.onError;
767
+ if (typeof failHandler === 'function') {
768
+ try {
769
+ await failHandler(err);
770
+ } catch (callbackErr) {
771
+ throw callbackErr;
772
+ }
773
+ }
774
+ if (typeof refreshOpts.onSettled === 'function') {
775
+ await refreshOpts.onSettled(null, err);
776
+ } else if (typeof refreshOpts.onComplete === 'function') {
777
+ await refreshOpts.onComplete(null, err);
778
+ }
779
+
780
+ if (refreshOpts.throwOnError !== true && (typeof failHandler === 'function' || refreshOpts.throwOnError === false)) {
781
+ return {
782
+ ok: false,
783
+ error: err,
784
+ refreshed: [],
785
+ timings: { loadMs: 0, renderMs: 0 },
786
+ };
787
+ }
788
+ throw err;
789
+ }
642
790
  }
643
791
 
644
792
  /**
@@ -722,6 +870,14 @@ function createFragmentCoordinator(options = {}) {
722
870
  syncDomInstances();
723
871
  return Array.from(instances.values());
724
872
  },
873
+ getStoredInput(instanceIdOrName, storageType = 'session') {
874
+ const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
875
+ return store.get(`frag_input:${instanceIdOrName}`, null);
876
+ },
877
+ clearStoredInput(instanceIdOrName, storageType = 'session') {
878
+ const store = storageType === 'local' ? defaultStorage.local : defaultStorage.session;
879
+ return store.remove(`frag_input:${instanceIdOrName}`);
880
+ },
725
881
  get hasFailedRefresh() {
726
882
  return Boolean(lastFailedBatch);
727
883
  },
@@ -3,6 +3,7 @@ export {
3
3
  ClientRequestErrorKind,
4
4
  ClientRequestError,
5
5
  ClientRequestCallOptions,
6
+ ClientRetryOptions,
6
7
  ClientApiClient,
7
8
  AppApiRouteMap,
8
9
  ClientTask,
@@ -11,12 +12,23 @@ export {
11
12
  ClientFormHandle,
12
13
  ClientFormFillOptions,
13
14
  ControllerFormsRegistry,
15
+ FragmentRefreshResult,
16
+ FragmentRefreshOptions,
14
17
  ControllerFragmentHandle,
15
18
  ControllerContext,
19
+ ControllerErrorMeta,
20
+ ControllerElement,
21
+ ControllerErrorHandler,
16
22
  ControllerActionHandler,
17
23
  ControllerComputedGetter,
18
24
  ControllerTaskFactory,
19
25
  ControllerDefinition,
26
+ WatchSource,
27
+ WatchCleanupCallback,
28
+ WatchCallback,
29
+ WatchOptions,
30
+ WatchDescriptor,
31
+ watch,
20
32
  defineController,
21
33
  deriveControllerNameFromPath,
22
34
  FragmentLoaderContext,
@@ -36,5 +48,12 @@ export {
36
48
  bootLiteClient,
37
49
  createDatastarAdapter,
38
50
  createClientDebugger,
51
+ StorageAdapter,
52
+ ClientStorage,
53
+ ControllerPersistConfig,
54
+ createClientStorage,
55
+ defaultStorage,
56
+ ClientDevtoolHandle,
57
+ mountDevtool,
39
58
  } from '../../index';
40
59
 
@@ -38,6 +38,8 @@ const {
38
38
  createControllerRuntime,
39
39
  queryScopedTargets,
40
40
  isTargetInControllerScope,
41
+ watch,
42
+ getActiveControllerContext,
41
43
  } = require('./controller');
42
44
 
43
45
  const {
@@ -60,6 +62,20 @@ const {
60
62
  summarizePayloadSafely,
61
63
  } = require('./debug');
62
64
 
65
+ const {
66
+ MemoryStorage,
67
+ StorageAdapter,
68
+ getNativeStorageBackend,
69
+ createClientStorage,
70
+ defaultStorage,
71
+ } = require('./storage');
72
+
73
+ const {
74
+ mountDevtool,
75
+ highlightElement,
76
+ setupReduxDevToolsBridge,
77
+ } = require('./devtool');
78
+
63
79
  let _serverFragments = null;
64
80
  function getServerFragments() {
65
81
  if (!_serverFragments) {
@@ -217,6 +233,7 @@ function createClientRuntime(options = {}) {
217
233
  getController: (el, name) => controllers.getInstanceForElement(el, name),
218
234
  invalidate: (tags, meta) => fragments.invalidate(tags, meta),
219
235
  refresh: (idOrName, input, opts) => fragments.refresh(idOrName, input, opts),
236
+ mountDevtool: (opts) => mountDevtool(runtime, opts),
220
237
  forms: {
221
238
  parse: parseFormElement,
222
239
  create: (formEl, formOpts = {}) =>
@@ -241,11 +258,26 @@ function createClientRuntime(options = {}) {
241
258
  */
242
259
  function bootClient(options = {}) {
243
260
  const runtime = createClientRuntime(options);
261
+ const shouldMountDevtools =
262
+ options.devtools === true ||
263
+ (options.devtools !== false &&
264
+ (options.debug ||
265
+ options.dev ||
266
+ (typeof window !== 'undefined' &&
267
+ (window.__WEBSPRESSO_DEVTOOLS_ENABLED__ || window.__WEBSPRESSO_DEV__)) ||
268
+ (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production')));
269
+
244
270
  if (typeof document !== 'undefined') {
271
+ const onReady = () => {
272
+ runtime.mount();
273
+ if (shouldMountDevtools) {
274
+ runtime.devtool = mountDevtool(runtime, options.devtoolsOptions || {});
275
+ }
276
+ };
245
277
  if (document.readyState === 'loading') {
246
- document.addEventListener('DOMContentLoaded', () => runtime.mount(), { once: true });
278
+ document.addEventListener('DOMContentLoaded', onReady, { once: true });
247
279
  } else {
248
- runtime.mount();
280
+ onReady();
249
281
  }
250
282
  }
251
283
  return runtime;
@@ -266,6 +298,8 @@ module.exports = {
266
298
  createControllerRuntime,
267
299
  queryScopedTargets,
268
300
  isTargetInControllerScope,
301
+ watch,
302
+ getActiveControllerContext,
269
303
 
270
304
  // Typed API Client & Request Management
271
305
  ClientRequestError,
@@ -340,8 +374,18 @@ module.exports = {
340
374
  morphDomTrees,
341
375
  parseHtmlFragment,
342
376
 
343
- // Debug & Telemetry
377
+ // Debug & Telemetry & DevTools
344
378
  createClientDebugger,
345
379
  summarizePayloadSafely,
380
+ mountDevtool,
381
+ highlightElement,
382
+ setupReduxDevToolsBridge,
383
+
384
+ // Client Storage & Persistence
385
+ MemoryStorage,
386
+ StorageAdapter,
387
+ getNativeStorageBackend,
388
+ createClientStorage,
389
+ defaultStorage,
346
390
  };
347
391
 
@@ -36,6 +36,13 @@ const {
36
36
  deduplicateParentChildFragments,
37
37
  } = require('./fragments');
38
38
 
39
+ const {
40
+ createClientStorage,
41
+ defaultStorage,
42
+ StorageAdapter,
43
+ MemoryStorage,
44
+ } = require('./storage');
45
+
39
46
  const { NOOP_DEBUGGER } = require('./utils');
40
47
 
41
48
  let _serverFragments = null;
@@ -160,5 +167,9 @@ module.exports = {
160
167
  apiResult: (data, meta) => getServerFragments().apiResult(data, meta),
161
168
  isApiResultEnvelope: (val) => getServerFragments().isApiResultEnvelope(val),
162
169
  defineFragment: (def) => getServerFragments().defineFragment(def),
170
+ createClientStorage,
171
+ defaultStorage,
172
+ StorageAdapter,
173
+ MemoryStorage,
163
174
  };
164
175