webspresso 0.0.109 → 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.
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultStorage } = require('./storage');
4
+ const { isArray, setElementBusyState } = require('./utils');
5
+
3
6
  /**
4
7
  * Webspresso Native HTML Form Layer & Typed Form Handle
5
8
  *
@@ -13,6 +16,40 @@
13
16
  * - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
14
17
  */
15
18
 
19
+ /**
20
+ * Validate that a redirect URL uses a safe protocol (http/https) or relative path
21
+ * to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
22
+ *
23
+ * @param {string} target
24
+ * @returns {boolean}
25
+ */
26
+ function isSafeRedirectUrl(target) {
27
+ if (!target || typeof target !== 'string') return false;
28
+ const trimmed = target.trim();
29
+ if (!trimmed) return false;
30
+
31
+ // Block control characters
32
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
33
+
34
+ // Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
35
+ if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
36
+ return false;
37
+ }
38
+
39
+ // Relative path starting with single slash (e.g. /dashboard)
40
+ if (trimmed.startsWith('/')) {
41
+ return true;
42
+ }
43
+
44
+ // Absolute URL: MUST explicitly parse as http: or https:
45
+ try {
46
+ const parsed = new URL(trimmed);
47
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
16
53
  let errorIdSeq = 0;
17
54
 
18
55
  /**
@@ -166,12 +203,12 @@ function parseFormElement(formEl, submitterOrOptions = null) {
166
203
  function appendValue(normName, val, forceArray) {
167
204
  if (val === undefined) return;
168
205
  if (forceArray) {
169
- if (!Array.isArray(data[normName])) {
206
+ if (!isArray(data[normName])) {
170
207
  data[normName] = [];
171
208
  }
172
209
  data[normName].push(val);
173
210
  } else if (normName in data) {
174
- if (!Array.isArray(data[normName])) {
211
+ if (!isArray(data[normName])) {
175
212
  data[normName] = [data[normName]];
176
213
  }
177
214
  data[normName].push(val);
@@ -290,7 +327,7 @@ function parseFormElement(formEl, submitterOrOptions = null) {
290
327
  formData = new FormData();
291
328
  for (const [key, val] of Object.entries(data)) {
292
329
  if (val === undefined) continue;
293
- if (Array.isArray(val)) {
330
+ if (isArray(val)) {
294
331
  for (const item of val) {
295
332
  if (item === undefined) continue;
296
333
  if (typeof Blob !== 'undefined' && item instanceof Blob) {
@@ -380,11 +417,15 @@ function computeFormDirty(formEl, initialSnapshot) {
380
417
  */
381
418
  function createFormHandle(formEl, options = {}) {
382
419
  if (formEl && formEl.__wFormHandle) {
420
+ if (options.services && typeof formEl.__wFormHandle.__setServices === 'function') {
421
+ formEl.__wFormHandle.__setServices(options.services);
422
+ }
383
423
  return formEl.__wFormHandle;
384
424
  }
385
425
 
386
426
  const api = options.api;
387
427
  const debug = options.debug || null;
428
+ let services = options.services || null;
388
429
  const formName =
389
430
  (formEl && formEl.getAttribute && formEl.getAttribute('data-w-form')) ||
390
431
  (formEl && formEl.getAttribute && formEl.getAttribute('name')) ||
@@ -401,6 +442,89 @@ function createFormHandle(formEl, options = {}) {
401
442
  const errorListeners = new Set();
402
443
  const stateListeners = new Set();
403
444
 
445
+ // Form draft persistence setup (sessionStorage by default, or localStorage)
446
+ const rawDraftAttr = formEl
447
+ ? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
448
+ : null;
449
+ const isDraftEnabled =
450
+ options.persistDraft !== undefined
451
+ ? Boolean(options.persistDraft)
452
+ : rawDraftAttr !== null && rawDraftAttr !== 'false';
453
+
454
+ let draftStorageType = 'session';
455
+ if (typeof options.persistDraft === 'string') {
456
+ draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
457
+ } else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
458
+ draftStorageType = 'local';
459
+ }
460
+
461
+ const draftKey =
462
+ options.draftKey ||
463
+ (formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
464
+ `form_draft:${formName || 'anon'}`;
465
+
466
+ const draftStorage =
467
+ (options.storage && options.storage[draftStorageType]) ||
468
+ (draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
469
+
470
+ function extractDraftValues(el) {
471
+ if (!el || typeof el.querySelectorAll !== 'function') return {};
472
+ const { data } = parseFormElement(el);
473
+ const safeData = {};
474
+ for (const [key, val] of Object.entries(data)) {
475
+ const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
476
+ if (!ctrl) {
477
+ safeData[key] = val;
478
+ continue;
479
+ }
480
+ const type = (ctrl.type || '').toLowerCase();
481
+ if (type === 'password' || type === 'file') continue;
482
+ if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
483
+ safeData[key] = val;
484
+ }
485
+ return safeData;
486
+ }
487
+
488
+ function saveDraft() {
489
+ if (!formEl || !isDraftEnabled) return false;
490
+ const draftPayload = extractDraftValues(formEl);
491
+ return draftStorage.set(draftKey, draftPayload);
492
+ }
493
+
494
+ function restoreDraft() {
495
+ if (!formEl || !isDraftEnabled) return false;
496
+ const saved = draftStorage.get(draftKey, null);
497
+ if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
498
+ fill(saved, { baseline: false });
499
+ if (typeof formEl.dispatchEvent === 'function') {
500
+ const CustomEventCtor =
501
+ (formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
502
+ (typeof CustomEvent !== 'undefined' ? CustomEvent : null);
503
+ if (CustomEventCtor) {
504
+ formEl.dispatchEvent(
505
+ new CustomEventCtor('w-form:draft-restored', {
506
+ bubbles: true,
507
+ cancelable: true,
508
+ detail: { form: handle, draft: saved, key: draftKey },
509
+ })
510
+ );
511
+ }
512
+ }
513
+ return true;
514
+ }
515
+ return false;
516
+ }
517
+
518
+ function clearDraft() {
519
+ if (!isDraftEnabled) return false;
520
+ return draftStorage.remove(draftKey);
521
+ }
522
+
523
+ function hasDraft() {
524
+ if (!isDraftEnabled) return false;
525
+ return draftStorage.has(draftKey);
526
+ }
527
+
404
528
  function notifyStateChange() {
405
529
  for (const fn of stateListeners) {
406
530
  try {
@@ -496,32 +620,32 @@ function createFormHandle(formEl, options = {}) {
496
620
  * Toggle pending state on the form and submit buttons to prevent double submissions.
497
621
  */
498
622
  function setPendingState(nextPending, activeSubmitter = null) {
499
- pending = Boolean(nextPending);
623
+ const next = Boolean(nextPending);
624
+ if (pending === next) return;
625
+ pending = next;
500
626
  if (!formEl) return;
501
627
 
502
628
  if (pending) {
503
629
  formEl.setAttribute('aria-busy', 'true');
504
630
  formEl.setAttribute('data-w-pending', 'true');
631
+ formEl.setAttribute('data-w-busy', 'true');
505
632
  } else {
506
633
  formEl.removeAttribute('aria-busy');
507
634
  formEl.removeAttribute('data-w-pending');
635
+ formEl.removeAttribute('data-w-busy');
508
636
  }
509
637
 
510
638
  const submitButtons = formEl.querySelectorAll
511
639
  ? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
512
640
  : [];
641
+ let submitterHandled = false;
513
642
  for (let i = 0; i < submitButtons.length; i++) {
514
643
  const btn = submitButtons[i];
515
- if (btn.hasAttribute('data-w-disabled')) continue; // Managed by controller binding
516
- if (pending) {
517
- btn.__wWasDisabled = Boolean(btn.disabled);
518
- btn.disabled = true;
519
- } else if (!btn.__wWasDisabled) {
520
- btn.disabled = false;
521
- }
644
+ if (btn === activeSubmitter) submitterHandled = true;
645
+ setElementBusyState(btn, pending);
522
646
  }
523
- if (activeSubmitter && !activeSubmitter.hasAttribute('data-w-disabled')) {
524
- activeSubmitter.disabled = pending;
647
+ if (activeSubmitter && !submitterHandled) {
648
+ setElementBusyState(activeSubmitter, pending);
525
649
  }
526
650
  notifyStateChange();
527
651
  }
@@ -615,7 +739,7 @@ function createFormHandle(formEl, options = {}) {
615
739
  if (type === 'file') continue; // Browser security prevents setting file input value
616
740
 
617
741
  if (type === 'checkbox') {
618
- if (Array.isArray(val)) {
742
+ if (isArray(val)) {
619
743
  ctrl.checked = val.map(String).includes(String(ctrl.value));
620
744
  } else {
621
745
  ctrl.checked = Boolean(val);
@@ -624,7 +748,7 @@ function createFormHandle(formEl, options = {}) {
624
748
  ctrl.checked = String(val) === String(ctrl.value);
625
749
  } else if (ctrl.tagName === 'SELECT') {
626
750
  if (ctrl.multiple) {
627
- const arr = Array.isArray(val) ? val.map(String) : [String(val)];
751
+ const arr = isArray(val) ? val.map(String) : [String(val)];
628
752
  const opts = ctrl.options || [];
629
753
  for (let j = 0; j < opts.length; j++) {
630
754
  opts[j].selected = arr.includes(String(opts[j].value));
@@ -799,10 +923,23 @@ function createFormHandle(formEl, options = {}) {
799
923
  initialSnapshot = captureInitialFormSnapshot(formEl);
800
924
  }
801
925
 
926
+ if (isDraftEnabled) {
927
+ clearDraft();
928
+ }
929
+
802
930
  // Invoke registered success hooks. Note: Any background fragment refresh failure
803
- // is handled by the fragment coordinator and NEVER puts this form into validation error state!
931
+ // or side-effect service error in onSuccess NEVER puts a succeeded form into validation error
932
+ // state or makes the completed mutation appear rolled back!
933
+ let hookError = null;
804
934
  for (const listener of successListeners) {
805
- await listener(responseData, { values: payloadData, form: handle });
935
+ try {
936
+ await listener(responseData, { values: payloadData, form: handle, services });
937
+ } catch (listenerErr) {
938
+ if (!hookError) hookError = listenerErr;
939
+ if (debug && typeof debug.trackCleanupError === 'function') {
940
+ debug.trackCleanupError(`form_${formName}`, formName, 'form:onSuccess', listenerErr);
941
+ }
942
+ }
806
943
  }
807
944
 
808
945
  // Explicit redirect behavior
@@ -810,7 +947,7 @@ function createFormHandle(formEl, options = {}) {
810
947
  submitOptions.redirectOnSuccess ||
811
948
  (formEl && formEl.getAttribute('data-w-redirect')) ||
812
949
  null;
813
- if (redirectTarget) {
950
+ if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
814
951
  if (typeof options.navigate === 'function') {
815
952
  options.navigate(redirectTarget);
816
953
  } else if (typeof window !== 'undefined' && window.location) {
@@ -828,7 +965,7 @@ function createFormHandle(formEl, options = {}) {
828
965
  }
829
966
  }
830
967
 
831
- return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
968
+ return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
832
969
  } catch (err) {
833
970
  if (err && err.kind === 'validation') {
834
971
  fieldErrors = { ...(err.fieldErrors || {}) };
@@ -843,7 +980,7 @@ function createFormHandle(formEl, options = {}) {
843
980
 
844
981
  for (const listener of errorListeners) {
845
982
  try {
846
- await listener(err, { values: payloadData, form: handle });
983
+ await listener(err, { values: payloadData, form: handle, services });
847
984
  } catch (_e) {
848
985
  // Ignore
849
986
  }
@@ -859,6 +996,7 @@ function createFormHandle(formEl, options = {}) {
859
996
  }
860
997
 
861
998
  // Listen to native submit and input/change events on `formEl`
999
+ let draftDebounceTimer = null;
862
1000
  const onDomInput = (evt) => {
863
1001
  dirty = computeFormDirty(formEl, initialSnapshot);
864
1002
  const target = evt && evt.target;
@@ -873,6 +1011,12 @@ function createFormHandle(formEl, options = {}) {
873
1011
  }
874
1012
  }
875
1013
  }
1014
+ if (isDraftEnabled) {
1015
+ if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
1016
+ draftDebounceTimer = setTimeout(() => {
1017
+ saveDraft();
1018
+ }, 150);
1019
+ }
876
1020
  notifyStateChange();
877
1021
  };
878
1022
 
@@ -900,9 +1044,17 @@ function createFormHandle(formEl, options = {}) {
900
1044
  // Initialize hidden state on empty error placeholders
901
1045
  syncErrorsToDom(false);
902
1046
 
903
- const handle = {
1047
+ let handle = null;
1048
+
1049
+ handle = {
904
1050
  name: formName,
905
1051
  el: formEl,
1052
+ get services() {
1053
+ return services;
1054
+ },
1055
+ __setServices(nextServices) {
1056
+ if (nextServices) services = nextServices;
1057
+ },
906
1058
  get pending() {
907
1059
  return pending;
908
1060
  },
@@ -938,6 +1090,12 @@ function createFormHandle(formEl, options = {}) {
938
1090
  return parseFormElement(formEl, submitter).data;
939
1091
  },
940
1092
  fill,
1093
+ saveDraft,
1094
+ restoreDraft,
1095
+ clearDraft,
1096
+ get hasDraft() {
1097
+ return hasDraft();
1098
+ },
941
1099
  setAction,
942
1100
  setRouteParams,
943
1101
  setDisabled,
@@ -1009,6 +1167,10 @@ function createFormHandle(formEl, options = {}) {
1009
1167
  debug.recordEvent('form:bind', { name: formName });
1010
1168
  }
1011
1169
 
1170
+ if (isDraftEnabled) {
1171
+ restoreDraft();
1172
+ }
1173
+
1012
1174
  return handle;
1013
1175
  }
1014
1176
 
@@ -1017,5 +1179,6 @@ module.exports = {
1017
1179
  isControlDisabled,
1018
1180
  coerceScalarControlValue,
1019
1181
  createFormHandle,
1182
+ isSafeRedirectUrl,
1020
1183
  };
1021
1184