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.
- package/core/client/adapter/datastar.js +587 -26
- package/core/client/api.js +147 -7
- package/core/client/controller.js +134 -11
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +475 -29
- package/core/client/fragments.js +397 -25
- package/core/client/index.js +13 -1
- package/core/client/server-fragments.js +102 -36
- package/core/client/utils.js +72 -17
- package/core/http/pipeline.js +82 -1
- package/core/openapi/build-from-api-routes.js +5 -5
- package/core/orm/json-fields.js +7 -6
- package/core/orm/schema-helpers.js +18 -15
- package/core/orm/utils/nanoid.js +7 -8
- package/core/services/validator.js +12 -1
- package/core/validation/apply.js +3 -0
- package/core/validation/index.js +25 -0
- package/core/validation/zod-introspect.js +187 -0
- package/index.d.ts +98 -1
- package/package.json +9 -8
- package/plugins/mcp/providers/services.js +2 -2
- package/src/api/define-api.js +2 -0
- package/src/api/validation-i18n.js +6 -2
- package/src/bundle/index.js +67 -3
- package/src/dev/api-typegen.js +79 -22
- package/src/dev/typegen.js +61 -35
- package/src/file-router.js +24 -6
- package/src/routing/mount-discovered-routes.js +24 -6
package/core/client/api.js
CHANGED
|
@@ -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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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 =
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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);
|
package/core/client/devtool.js
CHANGED
|
@@ -75,6 +75,33 @@ function setupReduxDevToolsBridge(runtime) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function escapeHtml(val) {
|
|
79
|
+
return String(val ?? '')
|
|
80
|
+
.replace(/&/g, '&')
|
|
81
|
+
.replace(/</g, '<')
|
|
82
|
+
.replace(/>/g, '>')
|
|
83
|
+
.replace(/"/g, '"')
|
|
84
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
.replace(/>/g, '>')
|
|
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
|
+
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\&])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
|
|
115
|
+
(match) => {
|
|
91
116
|
let cls = 'color:#38bdf8;'; // number
|
|
92
|
-
if (
|
|
117
|
+
if (/^"/.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
|
|
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;"><${c.tagName}> • ${c.instanceId}</div>
|
|
582
|
+
<div style="font-weight:700;color:#f4f4f5;">${escapeHtml(c.name || 'anonymous')}</div>
|
|
583
|
+
<div style="font-size:10px;color:#a1a1aa;"><${escapeHtml(c.tagName)}> • ${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
|
`;
|