webspresso 0.0.112 → 0.0.114

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.
Files changed (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
@@ -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,114 @@ 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
+ if (xhr.status === 0) {
697
+ reject(new TypeError('Network request failed'));
698
+ return;
699
+ }
700
+ const status = xhr.status === 1223 ? 204 : xhr.status;
701
+ const rawText = typeof xhr.responseText === 'string' ? xhr.responseText : '';
702
+ const headerObj = parseXhrHeaders(xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '');
703
+ resolve({
704
+ ok: status >= 200 && status < 300,
705
+ status,
706
+ statusText: xhr.statusText || '',
707
+ headers: headerObj,
708
+ text: async () => rawText,
709
+ json: async () => (rawText ? JSON.parse(rawText) : null),
710
+ });
711
+ };
712
+
713
+ xhr.onerror = () => {
714
+ cleanup();
715
+ reject(new TypeError('Network request failed'));
716
+ };
717
+
718
+ xhr.onabort = () => {
719
+ cleanup();
720
+ const abortErr = new Error('The operation was aborted.');
721
+ abortErr.name = 'AbortError';
722
+ reject(abortErr);
723
+ };
724
+
725
+ xhr.send(body === undefined ? null : body);
726
+ });
727
+ }
728
+
602
729
  /**
603
730
  * Create a Typed API Client instance.
604
731
  *
@@ -675,6 +802,8 @@ function createApiClient(config = {}) {
675
802
  method: upperMethod,
676
803
  url: requestUrl,
677
804
  key: requestKey,
805
+ dispatched: false,
806
+ outcomeUnknown: false,
678
807
  });
679
808
  }
680
809
 
@@ -883,13 +1012,25 @@ function createApiClient(config = {}) {
883
1012
  try {
884
1013
  let response;
885
1014
  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
- });
1015
+ if (typeof options.onUploadProgress === 'function' && typeof XMLHttpRequest !== 'undefined') {
1016
+ response = await executeXhrUpload({
1017
+ url: requestUrl,
1018
+ method: upperMethod,
1019
+ headers,
1020
+ body: serializedBody,
1021
+ credentials: options.credentials || 'same-origin',
1022
+ signal: abortController.signal,
1023
+ onUploadProgress: options.onUploadProgress,
1024
+ });
1025
+ } else {
1026
+ response = await fetchImpl(requestUrl, {
1027
+ method: upperMethod,
1028
+ headers,
1029
+ body: serializedBody,
1030
+ credentials: options.credentials || 'same-origin',
1031
+ signal: abortController.signal,
1032
+ });
1033
+ }
893
1034
  } catch (fetchErr) {
894
1035
  if (timeoutTimer) {
895
1036
  clearTimeout(timeoutTimer);
@@ -914,6 +1055,7 @@ function createApiClient(config = {}) {
914
1055
  url: requestUrl,
915
1056
  key: requestKey,
916
1057
  isTimedOut: true,
1058
+ dispatched: true,
917
1059
  cause: fetchErr,
918
1060
  }
919
1061
  );
@@ -947,6 +1089,7 @@ function createApiClient(config = {}) {
947
1089
  url: requestUrl,
948
1090
  key: requestKey,
949
1091
  isSuperseded: superseded,
1092
+ dispatched: true,
950
1093
  cause: fetchErr,
951
1094
  }
952
1095
  );
@@ -962,6 +1105,7 @@ function createApiClient(config = {}) {
962
1105
  method: upperMethod,
963
1106
  url: requestUrl,
964
1107
  key: requestKey,
1108
+ dispatched: true,
965
1109
  cause: fetchErr,
966
1110
  }
967
1111
  );
@@ -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,117 @@ function createControllerRuntime(options = {}) {
2021
2066
  return mounted;
2022
2067
  }
2023
2068
 
2069
+ let activeHookContext = null;
2070
+
2071
+ function isInsideIgnoredSubtree(el, rootEl) {
2072
+ let cur = el && el.parentElement ? el.parentElement : null;
2073
+ while (cur) {
2074
+ if (typeof cur.hasAttribute === 'function' && cur.hasAttribute('data-w-ignore')) {
2075
+ const mode = (cur.getAttribute('data-w-ignore') || '').trim().toLowerCase();
2076
+ if (mode !== 'self') {
2077
+ return true;
2078
+ }
2079
+ }
2080
+ if (cur === rootEl) break;
2081
+ cur = cur.parentElement || null;
2082
+ }
2083
+ if (el === rootEl && el && typeof el.hasAttribute === 'function' && el.hasAttribute('data-w-ignore')) {
2084
+ const mode = (el.getAttribute('data-w-ignore') || '').trim().toLowerCase();
2085
+ if (mode === 'all' || mode === 'both') {
2086
+ return true;
2087
+ }
2088
+ }
2089
+ return false;
2090
+ }
2091
+
2092
+ function collectAffectedInstances(fragmentEl) {
2093
+ if (!fragmentEl) return [];
2094
+ const affected = [];
2095
+ for (const inst of activeInstances.values()) {
2096
+ if (!inst || !inst.el || !inst.isConnected) continue;
2097
+ if (
2098
+ inst.el === fragmentEl ||
2099
+ (typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el)) ||
2100
+ (typeof inst.el.contains === 'function' && inst.el.contains(fragmentEl))
2101
+ ) {
2102
+ if (isInsideIgnoredSubtree(inst.el, fragmentEl)) continue;
2103
+ affected.push(inst);
2104
+ }
2105
+ }
2106
+ // Sort deterministically: outer ancestors first, inner descendants second
2107
+ affected.sort((a, b) => {
2108
+ if (a.el === b.el) return 0;
2109
+ if (typeof a.el.contains === 'function' && a.el.contains(b.el)) return -1;
2110
+ if (typeof b.el.contains === 'function' && b.el.contains(a.el)) return 1;
2111
+ if (typeof a.el.compareDocumentPosition === 'function') {
2112
+ const pos = a.el.compareDocumentPosition(b.el);
2113
+ if (pos & 4) return -1; // DOCUMENT_POSITION_FOLLOWING
2114
+ if (pos & 2) return 1; // DOCUMENT_POSITION_PRECEDING
2115
+ }
2116
+ return 0;
2117
+ });
2118
+ return affected;
2119
+ }
2120
+
2121
+ function notifyBeforePatch(fragmentEl, meta = {}) {
2122
+ const affected = collectAffectedInstances(fragmentEl);
2123
+ const prevHookCtx = activeHookContext;
2124
+ activeHookContext = {
2125
+ fragmentEl,
2126
+ depth: meta && typeof meta.hookChainDepth === 'number' ? meta.hookChainDepth : 0,
2127
+ };
2128
+ try {
2129
+ for (let i = 0; i < affected.length; i++) {
2130
+ const inst = affected[i];
2131
+ inst.invokeBeforePatch({
2132
+ ...meta,
2133
+ isFragmentRoot: inst.el === fragmentEl,
2134
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2135
+ });
2136
+ }
2137
+ } finally {
2138
+ activeHookContext = prevHookCtx;
2139
+ }
2140
+ }
2141
+
2142
+ function notifyAfterPatch(fragmentEl, meta = {}) {
2143
+ const chainDepth = meta && typeof meta.hookChainDepth === 'number' ? meta.hookChainDepth : 0;
2144
+ if (chainDepth > 3) {
2145
+ const msg = `Possible patch hook refresh loop detected on fragment "${meta.fragmentName || meta.fragmentId || 'unknown'}" (causal chain depth ${chainDepth}).`;
2146
+ if (debug && typeof debug.recordEvent === 'function') {
2147
+ debug.recordEvent('patch:loop-warning', {
2148
+ code: 'PATCH_HOOK_LOOP_DETECTED',
2149
+ fragmentId: meta.fragmentId || null,
2150
+ fragmentName: meta.fragmentName || null,
2151
+ count: chainDepth,
2152
+ message: msg,
2153
+ });
2154
+ } else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
2155
+ console.warn(`[webspresso] ${msg}`);
2156
+ }
2157
+ }
2158
+
2159
+ const affected = collectAffectedInstances(fragmentEl);
2160
+ const prevHookCtx = activeHookContext;
2161
+ activeHookContext = {
2162
+ fragmentEl,
2163
+ depth: chainDepth,
2164
+ };
2165
+ try {
2166
+ // Reverse order for afterPatch: inner descendants first, outer ancestors second
2167
+ for (let i = affected.length - 1; i >= 0; i--) {
2168
+ const inst = affected[i];
2169
+ inst.invokeAfterPatch({
2170
+ ...meta,
2171
+ isFragmentRoot: inst.el === fragmentEl,
2172
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2173
+ });
2174
+ }
2175
+ } finally {
2176
+ activeHookContext = prevHookCtx;
2177
+ }
2178
+ }
2179
+
2024
2180
  return {
2025
2181
  register,
2026
2182
  services,
@@ -2034,6 +2190,11 @@ function createControllerRuntime(options = {}) {
2034
2190
  mountSubtree,
2035
2191
  syncTree: mountSubtree,
2036
2192
  unmountSubtree,
2193
+ notifyBeforePatch,
2194
+ notifyAfterPatch,
2195
+ getActivePatchHookContext() {
2196
+ return activeHookContext;
2197
+ },
2037
2198
  mountControllerOnElement,
2038
2199
  getInstanceForElement(el, name) {
2039
2200
  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
  `;