webspresso 0.0.112 → 0.0.113

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.
@@ -84,9 +84,28 @@ class ClientRequestError extends Error {
84
84
  this.status === 408;
85
85
  this.isDropped = Boolean(details.isDropped);
86
86
  this.isSuperseded = Boolean(details.isSuperseded);
87
+ this.dispatched =
88
+ details.dispatched !== undefined
89
+ ? Boolean(details.dispatched)
90
+ : !this.isDropped && (this.kind !== 'canceled' || Boolean(details.cause));
91
+ this.outcomeUnknown =
92
+ details.outcomeUnknown !== undefined
93
+ ? Boolean(details.outcomeUnknown)
94
+ : MUTATION_METHODS.has(this.method) &&
95
+ this.dispatched &&
96
+ !this.isDropped &&
97
+ (this.kind === 'timeout' || this.kind === 'network' || this.kind === 'canceled');
98
+ this.outcome = this.outcomeUnknown
99
+ ? 'unknown'
100
+ : !this.dispatched || this.isDropped
101
+ ? 'not_sent'
102
+ : 'failed';
87
103
  this.serverRollbackGuaranteed = false;
88
104
  if (this.isCanceled) {
89
105
  this.note = 'Client request cancellation/abort does not roll back server-side execution.';
106
+ } else if (this.outcomeUnknown) {
107
+ this.note =
108
+ 'Mutation request was dispatched before response delivery failed; the server may have committed the operation.';
90
109
  }
91
110
  if (details.cause) {
92
111
  this.cause = details.cause;
@@ -599,6 +618,110 @@ function abortableSleep(ms, signals = []) {
599
618
  });
600
619
  }
601
620
 
621
+ function parseXhrHeaders(rawHeaders) {
622
+ const map = new Map();
623
+ if (typeof rawHeaders === 'string' && rawHeaders) {
624
+ const lines = rawHeaders.trim().split(/[\r\n]+/);
625
+ for (let i = 0; i < lines.length; i++) {
626
+ const idx = lines[i].indexOf(':');
627
+ if (idx > 0) {
628
+ const k = lines[i].slice(0, idx).trim().toLowerCase();
629
+ const v = lines[i].slice(idx + 1).trim();
630
+ map.set(k, v);
631
+ }
632
+ }
633
+ }
634
+ return {
635
+ get(name) {
636
+ return map.get(String(name || '').toLowerCase()) ?? null;
637
+ },
638
+ has(name) {
639
+ return map.has(String(name || '').toLowerCase());
640
+ },
641
+ };
642
+ }
643
+
644
+ function executeXhrUpload({ url, method, headers, body, credentials, signal, onUploadProgress }) {
645
+ return new Promise((resolve, reject) => {
646
+ const xhr = new XMLHttpRequest();
647
+ xhr.open(method, url, true);
648
+ if (credentials === 'include' || credentials === 'same-origin') {
649
+ xhr.withCredentials = credentials === 'include';
650
+ }
651
+ if (headers && typeof headers === 'object') {
652
+ for (const [k, v] of Object.entries(headers)) {
653
+ if (v !== undefined && v !== null) {
654
+ xhr.setRequestHeader(k, String(v));
655
+ }
656
+ }
657
+ }
658
+
659
+ if (xhr.upload && typeof onUploadProgress === 'function') {
660
+ xhr.upload.onprogress = (evt) => {
661
+ const lengthComputable = Boolean(evt && evt.lengthComputable);
662
+ const loaded = evt && typeof evt.loaded === 'number' ? evt.loaded : 0;
663
+ const total = lengthComputable && evt && typeof evt.total === 'number' ? evt.total : 0;
664
+ const percent = lengthComputable && total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : null;
665
+ try {
666
+ onUploadProgress({ loaded, total, percent, lengthComputable });
667
+ } catch (_e) {}
668
+ };
669
+ }
670
+
671
+ const onAbort = () => {
672
+ try {
673
+ xhr.abort();
674
+ } catch (_e) {}
675
+ };
676
+ if (signal) {
677
+ if (signal.aborted) {
678
+ const abortErr = new Error('The operation was aborted.');
679
+ abortErr.name = 'AbortError';
680
+ reject(abortErr);
681
+ return;
682
+ }
683
+ signal.addEventListener('abort', onAbort, { once: true });
684
+ }
685
+
686
+ const cleanup = () => {
687
+ if (signal) {
688
+ try {
689
+ signal.removeEventListener('abort', onAbort);
690
+ } catch (_e) {}
691
+ }
692
+ };
693
+
694
+ xhr.onload = () => {
695
+ cleanup();
696
+ const status = xhr.status === 1223 ? 204 : xhr.status;
697
+ const rawText = typeof xhr.responseText === 'string' ? xhr.responseText : '';
698
+ const headerObj = parseXhrHeaders(xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '');
699
+ resolve({
700
+ ok: status >= 200 && status < 300,
701
+ status,
702
+ statusText: xhr.statusText || '',
703
+ headers: headerObj,
704
+ text: async () => rawText,
705
+ json: async () => (rawText ? JSON.parse(rawText) : null),
706
+ });
707
+ };
708
+
709
+ xhr.onerror = () => {
710
+ cleanup();
711
+ reject(new TypeError('Network request failed'));
712
+ };
713
+
714
+ xhr.onabort = () => {
715
+ cleanup();
716
+ const abortErr = new Error('The operation was aborted.');
717
+ abortErr.name = 'AbortError';
718
+ reject(abortErr);
719
+ };
720
+
721
+ xhr.send(body === undefined ? null : body);
722
+ });
723
+ }
724
+
602
725
  /**
603
726
  * Create a Typed API Client instance.
604
727
  *
@@ -675,6 +798,8 @@ function createApiClient(config = {}) {
675
798
  method: upperMethod,
676
799
  url: requestUrl,
677
800
  key: requestKey,
801
+ dispatched: false,
802
+ outcomeUnknown: false,
678
803
  });
679
804
  }
680
805
 
@@ -883,13 +1008,25 @@ function createApiClient(config = {}) {
883
1008
  try {
884
1009
  let response;
885
1010
  try {
886
- response = await fetchImpl(requestUrl, {
887
- method: upperMethod,
888
- headers,
889
- body: serializedBody,
890
- credentials: options.credentials || 'same-origin',
891
- signal: abortController.signal,
892
- });
1011
+ if (typeof options.onUploadProgress === 'function' && typeof XMLHttpRequest !== 'undefined') {
1012
+ response = await executeXhrUpload({
1013
+ url: requestUrl,
1014
+ method: upperMethod,
1015
+ headers,
1016
+ body: serializedBody,
1017
+ credentials: options.credentials || 'same-origin',
1018
+ signal: abortController.signal,
1019
+ onUploadProgress: options.onUploadProgress,
1020
+ });
1021
+ } else {
1022
+ response = await fetchImpl(requestUrl, {
1023
+ method: upperMethod,
1024
+ headers,
1025
+ body: serializedBody,
1026
+ credentials: options.credentials || 'same-origin',
1027
+ signal: abortController.signal,
1028
+ });
1029
+ }
893
1030
  } catch (fetchErr) {
894
1031
  if (timeoutTimer) {
895
1032
  clearTimeout(timeoutTimer);
@@ -914,6 +1051,7 @@ function createApiClient(config = {}) {
914
1051
  url: requestUrl,
915
1052
  key: requestKey,
916
1053
  isTimedOut: true,
1054
+ dispatched: true,
917
1055
  cause: fetchErr,
918
1056
  }
919
1057
  );
@@ -947,6 +1085,7 @@ function createApiClient(config = {}) {
947
1085
  url: requestUrl,
948
1086
  key: requestKey,
949
1087
  isSuperseded: superseded,
1088
+ dispatched: true,
950
1089
  cause: fetchErr,
951
1090
  }
952
1091
  );
@@ -962,6 +1101,7 @@ function createApiClient(config = {}) {
962
1101
  method: upperMethod,
963
1102
  url: requestUrl,
964
1103
  key: requestKey,
1104
+ dispatched: true,
965
1105
  cause: fetchErr,
966
1106
  }
967
1107
  );
@@ -367,6 +367,8 @@ function defineController(definition = {}) {
367
367
  tasks: definition.tasks && typeof definition.tasks === 'object' ? definition.tasks : {},
368
368
  connect: typeof definition.connect === 'function' ? definition.connect : null,
369
369
  disconnect: typeof definition.disconnect === 'function' ? definition.disconnect : null,
370
+ beforePatch: typeof definition.beforePatch === 'function' ? definition.beforePatch : null,
371
+ afterPatch: typeof definition.afterPatch === 'function' ? definition.afterPatch : null,
370
372
  onError: typeof definition.onError === 'function' ? definition.onError : null,
371
373
  persist: definition.persist || null,
372
374
  watch: definition.watch && typeof definition.watch === 'object' ? definition.watch : null,
@@ -738,19 +740,26 @@ function createControllerRuntime(options = {}) {
738
740
  const signalPrefix = `__w_inst_${instanceId}.task_${taskKey}`;
739
741
 
740
742
  // React Query-style automatic revalidation options for tasks
743
+ const isMutationTask = taskOpts.mutation === true || taskOpts.kind === 'mutation';
741
744
  const staleTimeMs = parseDurationMs(taskOpts.staleTime, 0);
742
- const refetchOnMountMode = parseRefetchMode(
743
- taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
744
- false
745
- );
746
- const refetchOnFocusMode = parseRefetchMode(
747
- taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
748
- false
749
- );
750
- const refetchOnReconnectMode = parseRefetchMode(taskOpts.refetchOnReconnect, false);
751
- const refetchIntervalMs = parseDurationMs(taskOpts.refetchInterval, 0);
745
+ const refetchOnMountMode = isMutationTask
746
+ ? false
747
+ : parseRefetchMode(
748
+ taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
749
+ false
750
+ );
751
+ const refetchOnFocusMode = isMutationTask
752
+ ? false
753
+ : parseRefetchMode(
754
+ taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
755
+ false
756
+ );
757
+ const refetchOnReconnectMode = isMutationTask
758
+ ? false
759
+ : parseRefetchMode(taskOpts.refetchOnReconnect, false);
760
+ const refetchIntervalMs = isMutationTask ? 0 : parseDurationMs(taskOpts.refetchInterval, 0);
752
761
  const refetchIntervalInBg =
753
- parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
762
+ !isMutationTask && parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
754
763
 
755
764
  let currentRunVersion = 0;
756
765
  let activeRuns = 0;
@@ -1507,6 +1516,15 @@ function createControllerRuntime(options = {}) {
1507
1516
  const inst = fragments && fragments.getInstance ? fragments.getInstance(idOrName) : null;
1508
1517
  return inst && inst.input ? { ...inst.input } : {};
1509
1518
  },
1519
+ get inputs() {
1520
+ if (!fragments || typeof fragments.getInstances !== 'function') return {};
1521
+ const list = fragments.getInstances(idOrName);
1522
+ const map = {};
1523
+ for (const inst of list) {
1524
+ map[inst.instanceId] = inst.input ? { ...inst.input } : {};
1525
+ }
1526
+ return map;
1527
+ },
1510
1528
  refresh: (input, opts) => {
1511
1529
  checkFragmentPersistenceConflict(idOrName, input);
1512
1530
  if (!fragments) {
@@ -1797,6 +1815,33 @@ function createControllerRuntime(options = {}) {
1797
1815
  get isConnected() {
1798
1816
  return isConnected();
1799
1817
  },
1818
+ invokeBeforePatch(meta = {}) {
1819
+ if (!isConnected() || !reg.definition.beforePatch) return;
1820
+ try {
1821
+ const result = reg.definition.beforePatch(ctx, meta);
1822
+ if (result && typeof result.then === 'function') {
1823
+ // beforePatch must be synchronous; warn in dev and do not block morph
1824
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
1825
+ console.warn(`[webspresso] Controller "${controllerName}" beforePatch() returned a Promise; beforePatch hooks are synchronous and are not awaited.`);
1826
+ }
1827
+ }
1828
+ } catch (err) {
1829
+ handleControllerError(err, { phase: 'beforePatch' });
1830
+ }
1831
+ },
1832
+ invokeAfterPatch(meta = {}) {
1833
+ if (!isConnected() || !reg.definition.afterPatch) return;
1834
+ try {
1835
+ const res = reg.definition.afterPatch(ctx, meta);
1836
+ if (res && typeof res.then === 'function') {
1837
+ res.catch((err) => {
1838
+ handleControllerError(err, { phase: 'afterPatch' });
1839
+ });
1840
+ }
1841
+ } catch (err) {
1842
+ handleControllerError(err, { phase: 'afterPatch' });
1843
+ }
1844
+ },
1800
1845
  destroy() {
1801
1846
  if (!connected) return;
1802
1847
  connected = false;
@@ -2021,6 +2066,82 @@ function createControllerRuntime(options = {}) {
2021
2066
  return mounted;
2022
2067
  }
2023
2068
 
2069
+ const patchCycleTracker = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
2070
+
2071
+ function collectAffectedInstances(fragmentEl) {
2072
+ if (!fragmentEl) return [];
2073
+ const affected = [];
2074
+ for (const inst of activeInstances.values()) {
2075
+ if (!inst || !inst.el || !inst.isConnected) continue;
2076
+ if (
2077
+ inst.el === fragmentEl ||
2078
+ (typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el)) ||
2079
+ (typeof inst.el.contains === 'function' && inst.el.contains(fragmentEl))
2080
+ ) {
2081
+ affected.push(inst);
2082
+ }
2083
+ }
2084
+ // Sort deterministically: outer ancestors first, inner descendants second
2085
+ affected.sort((a, b) => {
2086
+ if (a.el === b.el) return 0;
2087
+ if (typeof a.el.contains === 'function' && a.el.contains(b.el)) return -1;
2088
+ if (typeof b.el.contains === 'function' && b.el.contains(a.el)) return 1;
2089
+ if (typeof a.el.compareDocumentPosition === 'function') {
2090
+ const pos = a.el.compareDocumentPosition(b.el);
2091
+ if (pos & 4) return -1; // DOCUMENT_POSITION_FOLLOWING
2092
+ if (pos & 2) return 1; // DOCUMENT_POSITION_PRECEDING
2093
+ }
2094
+ return 0;
2095
+ });
2096
+ return affected;
2097
+ }
2098
+
2099
+ function notifyBeforePatch(fragmentEl, meta = {}) {
2100
+ const affected = collectAffectedInstances(fragmentEl);
2101
+ for (let i = 0; i < affected.length; i++) {
2102
+ const inst = affected[i];
2103
+ inst.invokeBeforePatch({
2104
+ ...meta,
2105
+ isFragmentRoot: inst.el === fragmentEl,
2106
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2107
+ });
2108
+ }
2109
+ }
2110
+
2111
+ function notifyAfterPatch(fragmentEl, meta = {}) {
2112
+ if (patchCycleTracker && fragmentEl) {
2113
+ const now = Date.now();
2114
+ const prev = patchCycleTracker.get(fragmentEl) || { count: 0, lastAt: 0 };
2115
+ const count = now - prev.lastAt < 100 ? prev.count + 1 : 1;
2116
+ patchCycleTracker.set(fragmentEl, { count, lastAt: now });
2117
+ if (count > 5) {
2118
+ const msg = `Possible patch hook refresh loop detected on fragment "${meta.fragmentName || meta.fragmentId || 'unknown'}" (${count} rapid patch cycles).`;
2119
+ if (debug && typeof debug.recordEvent === 'function') {
2120
+ debug.recordEvent('patch:loop-warning', {
2121
+ code: 'PATCH_HOOK_LOOP_DETECTED',
2122
+ fragmentId: meta.fragmentId || null,
2123
+ fragmentName: meta.fragmentName || null,
2124
+ count,
2125
+ message: msg,
2126
+ });
2127
+ } else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
2128
+ console.warn(`[webspresso] ${msg}`);
2129
+ }
2130
+ }
2131
+ }
2132
+
2133
+ const affected = collectAffectedInstances(fragmentEl);
2134
+ // Reverse order for afterPatch: inner descendants first, outer ancestors second
2135
+ for (let i = affected.length - 1; i >= 0; i--) {
2136
+ const inst = affected[i];
2137
+ inst.invokeAfterPatch({
2138
+ ...meta,
2139
+ isFragmentRoot: inst.el === fragmentEl,
2140
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2141
+ });
2142
+ }
2143
+ }
2144
+
2024
2145
  return {
2025
2146
  register,
2026
2147
  services,
@@ -2034,6 +2155,8 @@ function createControllerRuntime(options = {}) {
2034
2155
  mountSubtree,
2035
2156
  syncTree: mountSubtree,
2036
2157
  unmountSubtree,
2158
+ notifyBeforePatch,
2159
+ notifyAfterPatch,
2037
2160
  mountControllerOnElement,
2038
2161
  getInstanceForElement(el, name) {
2039
2162
  const map = elementInstances.get(el);
@@ -75,6 +75,33 @@ function setupReduxDevToolsBridge(runtime) {
75
75
  }
76
76
  }
77
77
 
78
+ function escapeHtml(val) {
79
+ return String(val ?? '')
80
+ .replace(/&/g, '&amp;')
81
+ .replace(/</g, '&lt;')
82
+ .replace(/>/g, '&gt;')
83
+ .replace(/"/g, '&quot;')
84
+ .replace(/'/g, '&#39;');
85
+ }
86
+
87
+ function sanitizeHref(url, fallback = '#') {
88
+ if (!url || typeof url !== 'string') return fallback;
89
+ const trimmed = url.trim();
90
+ if (!trimmed || /[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return fallback;
91
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//') && !trimmed.startsWith('/\\')) {
92
+ return escapeHtml(trimmed);
93
+ }
94
+ try {
95
+ const parsed = new URL(trimmed);
96
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
97
+ return escapeHtml(parsed.href);
98
+ }
99
+ } catch {
100
+ return fallback;
101
+ }
102
+ return fallback;
103
+ }
104
+
78
105
  /**
79
106
  * Format JSON with syntax-colored HTML or safe text.
80
107
  */
@@ -83,13 +110,11 @@ function formatJsonHtml(data) {
83
110
  if (data === null) return '<span style="color:#f43f5e">null</span>';
84
111
  try {
85
112
  const jsonStr = JSON.stringify(data, null, 2);
86
- return jsonStr
87
- .replace(/&/g, '&amp;')
88
- .replace(/</g, '&lt;')
89
- .replace(/>/g, '&gt;')
90
- .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => {
113
+ return escapeHtml(jsonStr).replace(
114
+ /(&quot;(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\&])*&quot;(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
115
+ (match) => {
91
116
  let cls = 'color:#38bdf8;'; // number
92
- if (/^"/.test(match)) {
117
+ if (/^&quot;/.test(match)) {
93
118
  if (/:$/.test(match)) {
94
119
  cls = 'color:#a78bfa;font-weight:600;'; // key
95
120
  } else {
@@ -101,9 +126,10 @@ function formatJsonHtml(data) {
101
126
  cls = 'color:#f43f5e;'; // null
102
127
  }
103
128
  return `<span style="${cls}">${match}</span>`;
104
- });
129
+ }
130
+ );
105
131
  } catch (_) {
106
- return String(data);
132
+ return escapeHtml(data);
107
133
  }
108
134
  }
109
135
 
@@ -549,12 +575,12 @@ function mountDevtool(runtime, options = {}) {
549
575
  (c) => `
550
576
  <div
551
577
  class="ws-card ws-controller-item"
552
- data-inst-id="${c.instanceId}"
578
+ data-inst-id="${escapeHtml(c.instanceId)}"
553
579
  style="cursor:pointer;padding:8px 10px;margin-bottom:6px;${
554
580
  c.instanceId === current.instanceId ? 'border-color:#f59e0b;background:#27272a;' : ''
555
581
  }">
556
- <div style="font-weight:700;color:#f4f4f5;">${c.name || 'anonymous'}</div>
557
- <div style="font-size:10px;color:#a1a1aa;">&lt;${c.tagName}&gt; • ${c.instanceId}</div>
582
+ <div style="font-weight:700;color:#f4f4f5;">${escapeHtml(c.name || 'anonymous')}</div>
583
+ <div style="font-size:10px;color:#a1a1aa;">&lt;${escapeHtml(c.tagName)}&gt; • ${escapeHtml(c.instanceId)}</div>
558
584
  </div>
559
585
  `
560
586
  )
@@ -565,10 +591,10 @@ function mountDevtool(runtime, options = {}) {
565
591
  <div style="overflow-y:auto;padding-right:4px;">
566
592
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
567
593
  <div>
568
- <h3 style="font-size:14px;font-weight:700;color:#fff;">${current.name || 'anonymous'}</h3>
569
- <p style="font-size:10px;color:#71717a;">Instance: ${current.instanceId} • Connected: ${current.isConnected ? '✅' : '❌'}</p>
594
+ <h3 style="font-size:14px;font-weight:700;color:#fff;">${escapeHtml(current.name || 'anonymous')}</h3>
595
+ <p style="font-size:10px;color:#71717a;">Instance: ${escapeHtml(current.instanceId)} • Connected: ${current.isConnected ? '✅' : '❌'}</p>
570
596
  </div>
571
- <button class="ws-btn ws-btn-sm" id="ws-btn-highlight-ctrl" data-inst-id="${current.instanceId}">
597
+ <button class="ws-btn ws-btn-sm" id="ws-btn-highlight-ctrl" data-inst-id="${escapeHtml(current.instanceId)}">
572
598
  🎯 Highlight in DOM
573
599
  </button>
574
600
  </div>
@@ -577,7 +603,7 @@ function mountDevtool(runtime, options = {}) {
577
603
  <div class="ws-card">
578
604
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
579
605
  <strong style="color:#fbbf24;font-size:11px;">REACTIVE STATE</strong>
580
- <button class="ws-btn ws-btn-sm" id="ws-btn-copy-state" data-inst-id="${current.instanceId}">📋 Copy</button>
606
+ <button class="ws-btn ws-btn-sm" id="ws-btn-copy-state" data-inst-id="${escapeHtml(current.instanceId)}">📋 Copy</button>
581
607
  </div>
582
608
  <div class="ws-code-block">${formatJsonHtml(current.state)}</div>
583
609
  </div>
@@ -614,14 +640,14 @@ function mountDevtool(runtime, options = {}) {
614
640
  <div class="ws-card" style="margin-bottom:10px;">
615
641
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
616
642
  <div>
617
- <strong style="color:#38bdf8;font-size:12px;">${f.name}</strong>
618
- <span style="font-size:10px;color:#71717a;margin-left:6px;">#${f.instanceId}</span>
643
+ <strong style="color:#38bdf8;font-size:12px;">${escapeHtml(f.name)}</strong>
644
+ <span style="font-size:10px;color:#71717a;margin-left:6px;">#${escapeHtml(f.instanceId)}</span>
619
645
  </div>
620
646
  <div style="display:flex;gap:6px;">
621
- <button class="ws-btn ws-btn-sm" id="ws-btn-highlight-frag" data-inst-id="${f.instanceId}">
647
+ <button class="ws-btn ws-btn-sm" id="ws-btn-highlight-frag" data-inst-id="${escapeHtml(f.instanceId)}">
622
648
  🎯 Highlight
623
649
  </button>
624
- <button class="ws-btn ws-btn-primary ws-btn-sm" id="ws-btn-refresh-frag" data-frag-name="${f.name}">
650
+ <button class="ws-btn ws-btn-primary ws-btn-sm" id="ws-btn-refresh-frag" data-frag-name="${escapeHtml(f.name)}">
625
651
  🔄 Refresh
626
652
  </button>
627
653
  </div>
@@ -631,13 +657,13 @@ function mountDevtool(runtime, options = {}) {
631
657
  <span style="color:#a1a1aa;">Depends On Tags:</span>
632
658
  ${
633
659
  f.dependsOn.length > 0
634
- ? f.dependsOn.map((t) => `<span class="ws-badge ws-badge-info" style="margin-left:4px;">${t}</span>`).join('')
660
+ ? f.dependsOn.map((t) => `<span class="ws-badge ws-badge-info" style="margin-left:4px;">${escapeHtml(t)}</span>`).join('')
635
661
  : '<span style="color:#71717a;margin-left:4px;">(none)</span>'
636
662
  }
637
663
  </div>
638
664
  <div>
639
665
  <span style="color:#a1a1aa;">Version:</span>
640
- <span style="color:#f4f4f5;margin-left:4px;">${f.version}</span>
666
+ <span style="color:#f4f4f5;margin-left:4px;">${escapeHtml(f.version)}</span>
641
667
  </div>
642
668
  </div>
643
669
  ${
@@ -674,11 +700,11 @@ function mountDevtool(runtime, options = {}) {
674
700
  (r) => `
675
701
  <div class="ws-card" style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;margin-bottom:6px;">
676
702
  <div>
677
- <span class="ws-badge ws-badge-${r.method === 'GET' ? 'info' : 'warning'}">${r.method}</span>
678
- <span style="font-weight:600;margin-left:6px;">${r.url}</span>
703
+ <span class="ws-badge ws-badge-${r.method === 'GET' ? 'info' : 'warning'}">${escapeHtml(r.method)}</span>
704
+ <span style="font-weight:600;margin-left:6px;">${escapeHtml(r.url)}</span>
679
705
  </div>
680
706
  <div style="font-size:10px;color:#a1a1aa;">
681
- Policy: <span style="color:#38bdf8;">${r.policy || 'none'}</span> • Status: <span style="color:#34d399;">${r.status}</span>
707
+ Policy: <span style="color:#38bdf8;">${escapeHtml(r.policy || 'none')}</span> • Status: <span style="color:#34d399;">${escapeHtml(r.status)}</span>
682
708
  </div>
683
709
  </div>
684
710
  `
@@ -696,7 +722,7 @@ function mountDevtool(runtime, options = {}) {
696
722
  .map(
697
723
  (c) => `
698
724
  <div class="ws-card" style="padding:10px;margin-bottom:6px;">
699
- <div style="font-weight:600;color:#f4f4f5;font-size:11px;">Chain: ${c.chainId}</div>
725
+ <div style="font-weight:600;color:#f4f4f5;font-size:11px;">Chain: ${escapeHtml(c.chainId)}</div>
700
726
  <div class="ws-code-block" style="margin-top:4px;padding:6px;">${formatJsonHtml(c)}</div>
701
727
  </div>
702
728
  `
@@ -718,8 +744,8 @@ function mountDevtool(runtime, options = {}) {
718
744
  .map(
719
745
  (t) => `
720
746
  <div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #27272a;font-size:11px;">
721
- <span>${t.fragmentName || t.instanceId}</span>
722
- <span style="color:#fbbf24;">patch: ${t.patchMs}ms ${t.loadMs ? `(load: ${t.loadMs}ms, render: ${t.renderMs}ms)` : ''}</span>
747
+ <span>${escapeHtml(t.fragmentName || t.instanceId)}</span>
748
+ <span style="color:#fbbf24;">patch: ${escapeHtml(t.patchMs)}ms ${t.loadMs ? `(load: ${escapeHtml(t.loadMs)}ms, render: ${escapeHtml(t.renderMs)}ms)` : ''}</span>
723
749
  </div>
724
750
  `
725
751
  )
@@ -758,7 +784,7 @@ function mountDevtool(runtime, options = {}) {
758
784
  .map(
759
785
  (e) => `
760
786
  <div class="ws-card" style="margin-bottom:6px;">
761
- <div style="font-weight:600;color:#38bdf8;font-size:11px;">${e.key}</div>
787
+ <div style="font-weight:600;color:#38bdf8;font-size:11px;">${escapeHtml(e.key)}</div>
762
788
  <div class="ws-code-block" style="margin-top:4px;padding:6px;">${formatJsonHtml(
763
789
  (() => {
764
790
  try {
@@ -784,7 +810,7 @@ function mountDevtool(runtime, options = {}) {
784
810
  .map(
785
811
  (e) => `
786
812
  <div class="ws-card" style="margin-bottom:6px;">
787
- <div style="font-weight:600;color:#a78bfa;font-size:11px;">${e.key}</div>
813
+ <div style="font-weight:600;color:#a78bfa;font-size:11px;">${escapeHtml(e.key)}</div>
788
814
  <div class="ws-code-block" style="margin-top:4px;padding:6px;">${formatJsonHtml(
789
815
  (() => {
790
816
  try {
@@ -815,7 +841,7 @@ function mountDevtool(runtime, options = {}) {
815
841
  <div style="font-size:10px;color:#a1a1aa;margin-top:2px;">Visual database browser, query runner, and table inspector.</div>
816
842
  <div style="font-size:9px;color:#71717a;margin-top:4px;">Run in terminal: <code>npx webspresso studio</code> (port 4567)</div>
817
843
  </div>
818
- <a href="${studioUrl}" target="_blank" rel="noopener noreferrer" class="ws-btn ws-btn-primary" id="ws-btn-open-studio" style="text-decoration:none;">
844
+ <a href="${sanitizeHref(studioUrl)}" target="_blank" rel="noopener noreferrer" class="ws-btn ws-btn-primary" id="ws-btn-open-studio" style="text-decoration:none;">
819
845
  Open DB Studio ↗
820
846
  </a>
821
847
  </div>
@@ -829,8 +855,8 @@ function mountDevtool(runtime, options = {}) {
829
855
  <div style="font-weight:700;color:#38bdf8;font-size:13px;">⚙️ Admin Panel SPA</div>
830
856
  <div style="font-size:10px;color:#a1a1aa;margin-top:2px;">Mithril.js model CRUD and staff administration cockpit.</div>
831
857
  </div>
832
- <a href="${features.adminPath}" target="_blank" rel="noopener noreferrer" class="ws-btn" id="ws-btn-open-admin" style="text-decoration:none;">
833
- Open ${features.adminPath} ↗
858
+ <a href="${sanitizeHref(features.adminPath)}" target="_blank" rel="noopener noreferrer" class="ws-btn" id="ws-btn-open-admin" style="text-decoration:none;">
859
+ Open ${escapeHtml(features.adminPath)} ↗
834
860
  </a>
835
861
  </div>
836
862
  `
@@ -918,7 +944,7 @@ function mountDevtool(runtime, options = {}) {
918
944
  <!-- Footer -->
919
945
  <div class="ws-footer">
920
946
  <span>Shortcuts: <strong>Alt + W</strong> to toggle • Console: <code>window.__WEBSPRESSO_DEVTOOLS__</code></span>
921
- <span style="color:#a1a1aa;">Webspresso v${PKG_VERSION}</span>
947
+ <span style="color:#a1a1aa;">Webspresso v${escapeHtml(PKG_VERSION)}</span>
922
948
  </div>
923
949
  </div>
924
950
  `;