@erickxavier/no-js 1.10.0 → 1.10.1
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/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +3 -3
- package/package.json +1 -1
- package/src/devtools.js +2 -0
- package/src/directives/binding.js +30 -5
- package/src/directives/http.js +11 -19
- package/src/directives/i18n.js +2 -2
- package/src/directives/state.js +23 -4
- package/src/directives/styling.js +1 -1
- package/src/dom.js +18 -1
- package/src/evaluate.js +138 -3
- package/src/fetch.js +16 -3
- package/src/filters.js +6 -1
- package/src/globals.js +2 -0
- package/src/index.js +10 -1
package/package.json
CHANGED
package/src/devtools.js
CHANGED
|
@@ -208,6 +208,8 @@ function _handleDevtoolsCommand(event) {
|
|
|
208
208
|
break;
|
|
209
209
|
case "get:config":
|
|
210
210
|
result = { ..._config };
|
|
211
|
+
if (result.csrf) result.csrf = { ...result.csrf, token: '[REDACTED]' };
|
|
212
|
+
if (result.headers) result.headers = '[REDACTED]';
|
|
211
213
|
break;
|
|
212
214
|
case "get:routes":
|
|
213
215
|
result = _routerInstance ? _routerInstance.routes || [] : [];
|
|
@@ -35,12 +35,37 @@ registerDirective("bind-html", {
|
|
|
35
35
|
|
|
36
36
|
const _SAFE_URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "data"]);
|
|
37
37
|
|
|
38
|
-
// Strip JS vectors from raw SVG markup
|
|
38
|
+
// Strip JS vectors from raw SVG markup using DOMParser for robust sanitization.
|
|
39
|
+
// Regex-based approaches are bypassable via entity encoding and nested contexts.
|
|
39
40
|
function _sanitizeSvgContent(svg) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
42
|
+
const root = doc.documentElement;
|
|
43
|
+
// If parsing failed, DOMParser may wrap error in <parsererror> or produce a
|
|
44
|
+
// non-SVG root. In either case return an empty SVG for safety.
|
|
45
|
+
if (root.querySelector("parsererror") ||
|
|
46
|
+
root.nodeName !== "svg" ||
|
|
47
|
+
root.getElementsByTagNameNS("http://www.mozilla.org/newlayout/xml/parsererror.xml", "parsererror").length) {
|
|
48
|
+
return "<svg></svg>";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _cleanAttrs(node) {
|
|
52
|
+
for (const attr of [...node.attributes]) {
|
|
53
|
+
const name = attr.name.toLowerCase();
|
|
54
|
+
// Remove on* event handlers
|
|
55
|
+
if (name.startsWith("on")) { node.removeAttribute(attr.name); continue; }
|
|
56
|
+
// Remove javascript: in href/xlink:href
|
|
57
|
+
if ((name === "href" || name === "xlink:href") &&
|
|
58
|
+
attr.value.trim().toLowerCase().startsWith("javascript:")) {
|
|
59
|
+
node.removeAttribute(attr.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Remove script elements
|
|
64
|
+
for (const s of [...root.querySelectorAll("script")]) s.remove();
|
|
65
|
+
// Clean attributes on root and all descendants
|
|
66
|
+
_cleanAttrs(root);
|
|
67
|
+
for (const node of root.querySelectorAll("*")) _cleanAttrs(node);
|
|
68
|
+
return new XMLSerializer().serializeToString(root);
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
// Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
|
package/src/directives/http.js
CHANGED
|
@@ -126,7 +126,7 @@ for (const method of HTTP_METHODS) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
129
|
-
if (
|
|
129
|
+
if (headersAttr) {
|
|
130
130
|
for (const k of Object.keys(extraHeaders)) {
|
|
131
131
|
const lower = k.toLowerCase();
|
|
132
132
|
if (_SENSITIVE_HEADERS.has(lower) || /^x-(auth|api)-/.test(lower)) {
|
|
@@ -134,24 +134,16 @@ for (const method of HTTP_METHODS) {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
extraHeaders,
|
|
148
|
-
el,
|
|
149
|
-
_activeAbort.signal,
|
|
150
|
-
);
|
|
151
|
-
} finally {
|
|
152
|
-
_config.retries = savedRetries;
|
|
153
|
-
_config.retryDelay = savedRetryDelay;
|
|
154
|
-
}
|
|
137
|
+
const data = await _doFetch(
|
|
138
|
+
resolvedUrl,
|
|
139
|
+
method,
|
|
140
|
+
reqBody,
|
|
141
|
+
extraHeaders,
|
|
142
|
+
el,
|
|
143
|
+
_activeAbort.signal,
|
|
144
|
+
retryCount,
|
|
145
|
+
retryDelay,
|
|
146
|
+
);
|
|
155
147
|
|
|
156
148
|
// Cache response
|
|
157
149
|
if (method === "get") _cacheSet(cacheKey, data, cacheStrategy);
|
package/src/directives/i18n.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { _i18n, _watchI18n, _loadI18nNamespace, _notifyI18n } from "../i18n.js";
|
|
7
7
|
import { _watchExpr } from "../globals.js";
|
|
8
8
|
import { evaluate } from "../evaluate.js";
|
|
9
|
-
import { findContext } from "../dom.js";
|
|
9
|
+
import { findContext, _sanitizeHtml } from "../dom.js";
|
|
10
10
|
import { registerDirective, processTree } from "../registry.js";
|
|
11
11
|
|
|
12
12
|
registerDirective("t", {
|
|
@@ -25,7 +25,7 @@ registerDirective("t", {
|
|
|
25
25
|
}
|
|
26
26
|
const text = _i18n.t(key, params);
|
|
27
27
|
if (useHtml) {
|
|
28
|
-
el.innerHTML = text;
|
|
28
|
+
el.innerHTML = _sanitizeHtml(text);
|
|
29
29
|
} else {
|
|
30
30
|
el.textContent = text;
|
|
31
31
|
}
|
package/src/directives/state.js
CHANGED
|
@@ -12,9 +12,9 @@ import { _devtoolsEmit } from "../devtools.js";
|
|
|
12
12
|
registerDirective("state", {
|
|
13
13
|
priority: 0,
|
|
14
14
|
init(el, name, value) {
|
|
15
|
-
const
|
|
15
|
+
const initialState = evaluate(value, createContext()) || {};
|
|
16
16
|
const parent = el.parentElement ? findContext(el.parentElement) : null;
|
|
17
|
-
const ctx = createContext(
|
|
17
|
+
const ctx = createContext(initialState, parent);
|
|
18
18
|
el.__ctx = ctx;
|
|
19
19
|
|
|
20
20
|
// Persistence
|
|
@@ -40,13 +40,32 @@ registerDirective("state", {
|
|
|
40
40
|
const saved = store.getItem("nojs_state_" + persistKey);
|
|
41
41
|
if (saved) {
|
|
42
42
|
const parsed = JSON.parse(saved);
|
|
43
|
+
const schemaCheck = el.hasAttribute("persist-schema");
|
|
43
44
|
for (const [k, v] of Object.entries(parsed)) {
|
|
44
|
-
if (!persistFields || persistFields.has(k))
|
|
45
|
+
if (!persistFields || persistFields.has(k)) {
|
|
46
|
+
if (schemaCheck) {
|
|
47
|
+
if (!(k in initialState)) { _warn('persist-schema: ignoring unknown key "' + k + '"'); continue; }
|
|
48
|
+
if (initialState[k] !== null && v !== null && typeof v !== typeof initialState[k]) {
|
|
49
|
+
_warn('persist-schema: type mismatch for "' + k + '" (expected ' + typeof initialState[k] + ', got ' + typeof v + ')');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ctx.$set(k, v);
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
} catch {
|
|
48
58
|
/* ignore */
|
|
49
59
|
}
|
|
60
|
+
|
|
61
|
+
// Warn about potentially sensitive field names in persisted state
|
|
62
|
+
const sensitiveNames = ['token', 'password', 'secret', 'key', 'auth', 'credential', 'session'];
|
|
63
|
+
const stateKeys = Object.keys(initialState);
|
|
64
|
+
const riskyKeys = stateKeys.filter(k => sensitiveNames.some(s => k.toLowerCase().includes(s)));
|
|
65
|
+
if (riskyKeys.length > 0) {
|
|
66
|
+
_warn('State key(s) ' + riskyKeys.map(k => '"' + k + '"').join(', ') + ' may contain sensitive data. Consider using persist-fields to exclude them.');
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
ctx.$watch(() => {
|
|
51
70
|
try {
|
|
52
71
|
const raw = ctx.__raw;
|
|
@@ -61,7 +80,7 @@ registerDirective("state", {
|
|
|
61
80
|
}
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
_log("state",
|
|
83
|
+
_log("state", initialState);
|
|
65
84
|
},
|
|
66
85
|
});
|
|
67
86
|
|
|
@@ -55,7 +55,7 @@ registerDirective("class-*", {
|
|
|
55
55
|
el.classList.toggle(suffix, !!evaluate(expr, ctx));
|
|
56
56
|
}
|
|
57
57
|
_watchExpr(expr, ctx, update);
|
|
58
|
-
if (expr.includes("$i18n") || expr.includes("NoJS.locale")) _watchI18n(update);
|
|
58
|
+
if (expr.includes("$i18n") || expr.includes("NoJS.locale") || expr.includes("window.NoJS.locale")) _watchI18n(update);
|
|
59
59
|
update();
|
|
60
60
|
},
|
|
61
61
|
});
|
package/src/dom.js
CHANGED
|
@@ -47,7 +47,10 @@ const _BLOCKED_TAGS = new Set([
|
|
|
47
47
|
]);
|
|
48
48
|
|
|
49
49
|
export function _sanitizeHtml(html) {
|
|
50
|
-
if (!_config.sanitize)
|
|
50
|
+
if (_config.dangerouslyDisableSanitize || !_config.sanitize) {
|
|
51
|
+
_warn('HTML sanitization is DISABLED. This exposes your app to XSS attacks. Only disable for trusted content.');
|
|
52
|
+
return html;
|
|
53
|
+
}
|
|
51
54
|
if (typeof _config.sanitizeHtml === 'function') return _config.sanitizeHtml(html);
|
|
52
55
|
|
|
53
56
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
@@ -98,6 +101,18 @@ function _resolveTemplateSrc(src, tpl) {
|
|
|
98
101
|
return resolveUrl(src, tpl);
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// Warn when a template URL uses plain HTTP from an HTTPS page (mixed content / MITM risk).
|
|
105
|
+
// The optional pageProtocol parameter enables testing without mutating jsdom's
|
|
106
|
+
// non-configurable window.location.protocol property.
|
|
107
|
+
export function _warnIfInsecureTemplateUrl(resolvedUrl, src, pageProtocol) {
|
|
108
|
+
const proto = pageProtocol !== undefined
|
|
109
|
+
? pageProtocol
|
|
110
|
+
: (typeof window !== 'undefined' && window.location ? window.location.protocol : '');
|
|
111
|
+
if (resolvedUrl.startsWith('http://') && proto === 'https:') {
|
|
112
|
+
_warn('Template "' + src + '" is loaded over insecure HTTP from an HTTPS page. Use HTTPS to prevent tampering.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export async function _loadRemoteTemplates(root) {
|
|
102
117
|
const scope = root || document;
|
|
103
118
|
const templates = scope.querySelectorAll("template[src]");
|
|
@@ -108,6 +123,7 @@ export async function _loadRemoteTemplates(root) {
|
|
|
108
123
|
tpl.__srcLoaded = true;
|
|
109
124
|
const src = tpl.getAttribute("src");
|
|
110
125
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
126
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
111
127
|
// Track the folder of this template so children can use "./" paths
|
|
112
128
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
113
129
|
try {
|
|
@@ -163,6 +179,7 @@ export async function _loadTemplateElement(tpl) {
|
|
|
163
179
|
_log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
|
|
164
180
|
tpl.__srcLoaded = true;
|
|
165
181
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
182
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
166
183
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
167
184
|
|
|
168
185
|
// Synchronously insert loading placeholder before the fetch begins
|
package/src/evaluate.js
CHANGED
|
@@ -736,7 +736,130 @@ const _SAFE_GLOBALS = {
|
|
|
736
736
|
// network and storage APIs — fetch, XMLHttpRequest, localStorage, sessionStorage,
|
|
737
737
|
// WebSocket, indexedDB — are unreachable from template code by default, closing
|
|
738
738
|
// the surface where interpolated external data could trigger unintended requests.
|
|
739
|
-
// window
|
|
739
|
+
// window, document, and location are further wrapped in Proxy objects below
|
|
740
|
+
// to block sensitive sub-properties (fetch, cookie, navigation, etc.).
|
|
741
|
+
|
|
742
|
+
// ── Security proxies for window, document, and location ─────────────────
|
|
743
|
+
// Even though window/document are on the allow-list, we wrap them in Proxy
|
|
744
|
+
// objects that block access to sensitive sub-properties (network, storage,
|
|
745
|
+
// cookie, eval, etc.) while still allowing safe DOM / measurement APIs.
|
|
746
|
+
|
|
747
|
+
const _BLOCKED_WINDOW_PROPS = new Set([
|
|
748
|
+
'fetch', 'XMLHttpRequest', 'localStorage', 'sessionStorage',
|
|
749
|
+
'WebSocket', 'indexedDB', 'eval', 'Function', 'importScripts',
|
|
750
|
+
'open', 'postMessage',
|
|
751
|
+
]);
|
|
752
|
+
// Props on window that must return safe proxies instead of raw objects
|
|
753
|
+
const _WINDOW_PROXY_OVERRIDES = {}; // populated after proxy creation below
|
|
754
|
+
|
|
755
|
+
const _BLOCKED_DOCUMENT_PROPS = new Set([
|
|
756
|
+
'cookie', 'domain', 'write', 'writeln', 'execCommand',
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
const _safeWindow = typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined'
|
|
760
|
+
? new Proxy(globalThis.window, {
|
|
761
|
+
get(target, prop, receiver) {
|
|
762
|
+
if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return undefined;
|
|
763
|
+
if (typeof prop === 'string' && prop in _WINDOW_PROXY_OVERRIDES) return _WINDOW_PROXY_OVERRIDES[prop];
|
|
764
|
+
return Reflect.get(target, prop, receiver);
|
|
765
|
+
},
|
|
766
|
+
set(target, prop, value) {
|
|
767
|
+
// Block writes to dangerous window properties from expressions;
|
|
768
|
+
// allow writing user-defined properties (e.g. window.__myHelper)
|
|
769
|
+
if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return true;
|
|
770
|
+
if (prop === 'name' || prop === 'status') return true; // anti-exfiltration
|
|
771
|
+
target[prop] = value;
|
|
772
|
+
return true;
|
|
773
|
+
},
|
|
774
|
+
})
|
|
775
|
+
: undefined;
|
|
776
|
+
|
|
777
|
+
const _safeDocument = typeof globalThis !== 'undefined' && typeof globalThis.document !== 'undefined'
|
|
778
|
+
? new Proxy(globalThis.document, {
|
|
779
|
+
get(target, prop, receiver) {
|
|
780
|
+
if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return undefined;
|
|
781
|
+
if (prop === 'defaultView') return _safeWindow;
|
|
782
|
+
return Reflect.get(target, prop, receiver);
|
|
783
|
+
},
|
|
784
|
+
set(target, prop, value) {
|
|
785
|
+
if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return true;
|
|
786
|
+
target[prop] = value;
|
|
787
|
+
return true;
|
|
788
|
+
},
|
|
789
|
+
})
|
|
790
|
+
: undefined;
|
|
791
|
+
|
|
792
|
+
// Read-only location wrapper — exposes common getters via a plain object with
|
|
793
|
+
// property descriptors that read from the real location. Navigation methods are
|
|
794
|
+
// replaced with no-ops. Using a plain object avoids Proxy invariant violations
|
|
795
|
+
// on non-configurable properties (like location.assign).
|
|
796
|
+
const _LOCATION_READ_PROPS = [
|
|
797
|
+
'href', 'pathname', 'search', 'hash', 'origin',
|
|
798
|
+
'hostname', 'port', 'protocol', 'host',
|
|
799
|
+
];
|
|
800
|
+
const _locationNoop = () => {};
|
|
801
|
+
|
|
802
|
+
const _safeLocation = typeof globalThis !== 'undefined' && typeof globalThis.location !== 'undefined'
|
|
803
|
+
? (() => {
|
|
804
|
+
const loc = {};
|
|
805
|
+
for (const prop of _LOCATION_READ_PROPS) {
|
|
806
|
+
Object.defineProperty(loc, prop, {
|
|
807
|
+
get() { return globalThis.location[prop]; },
|
|
808
|
+
set() { /* silently ignore writes */ },
|
|
809
|
+
enumerable: true, configurable: false,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
loc.assign = _locationNoop;
|
|
813
|
+
loc.replace = _locationNoop;
|
|
814
|
+
loc.reload = _locationNoop;
|
|
815
|
+
loc.toString = () => globalThis.location.href;
|
|
816
|
+
return Object.freeze(loc);
|
|
817
|
+
})()
|
|
818
|
+
: undefined;
|
|
819
|
+
|
|
820
|
+
// Read-only history wrapper — exposes state and length as read-only getters,
|
|
821
|
+
// replaces navigation methods with no-ops. Prevents expressions from
|
|
822
|
+
// manipulating browser history (pushState, back, forward, etc.).
|
|
823
|
+
const _HISTORY_READ_PROPS = ['length', 'state', 'scrollRestoration'];
|
|
824
|
+
|
|
825
|
+
const _safeHistory = typeof globalThis !== 'undefined' && typeof globalThis.history !== 'undefined'
|
|
826
|
+
? (() => {
|
|
827
|
+
const h = {};
|
|
828
|
+
for (const prop of _HISTORY_READ_PROPS) {
|
|
829
|
+
Object.defineProperty(h, prop, {
|
|
830
|
+
get() { return globalThis.history[prop]; },
|
|
831
|
+
enumerable: true, configurable: false,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
h.pushState = _locationNoop;
|
|
835
|
+
h.replaceState = _locationNoop;
|
|
836
|
+
h.back = _locationNoop;
|
|
837
|
+
h.forward = _locationNoop;
|
|
838
|
+
h.go = _locationNoop;
|
|
839
|
+
return Object.freeze(h);
|
|
840
|
+
})()
|
|
841
|
+
: undefined;
|
|
842
|
+
|
|
843
|
+
// Navigator proxy — blocks sendBeacon (data exfiltration) and credentials
|
|
844
|
+
const _BLOCKED_NAVIGATOR_PROPS = new Set(['sendBeacon', 'credentials']);
|
|
845
|
+
const _safeNavigator = typeof globalThis !== 'undefined' && typeof globalThis.navigator !== 'undefined'
|
|
846
|
+
? new Proxy(globalThis.navigator, {
|
|
847
|
+
get(target, prop, receiver) {
|
|
848
|
+
if (typeof prop === 'string' && _BLOCKED_NAVIGATOR_PROPS.has(prop)) return undefined;
|
|
849
|
+
return Reflect.get(target, prop, receiver);
|
|
850
|
+
},
|
|
851
|
+
set() { return true; }, // navigator props are browser-enforced read-only
|
|
852
|
+
})
|
|
853
|
+
: undefined;
|
|
854
|
+
|
|
855
|
+
// Wire window.location → _safeLocation, window.document → _safeDocument,
|
|
856
|
+
// window.history → _safeHistory, window.navigator → _safeNavigator
|
|
857
|
+
// so accessing via the window proxy returns safe versions
|
|
858
|
+
if (_safeLocation) _WINDOW_PROXY_OVERRIDES.location = _safeLocation;
|
|
859
|
+
if (_safeDocument) _WINDOW_PROXY_OVERRIDES.document = _safeDocument;
|
|
860
|
+
if (_safeHistory) _WINDOW_PROXY_OVERRIDES.history = _safeHistory;
|
|
861
|
+
if (_safeNavigator) _WINDOW_PROXY_OVERRIDES.navigator = _safeNavigator;
|
|
862
|
+
|
|
740
863
|
const _BROWSER_GLOBALS = new Set([
|
|
741
864
|
'window', 'document', 'console', 'location', 'history',
|
|
742
865
|
'navigator', 'screen', 'performance', 'crypto',
|
|
@@ -764,7 +887,14 @@ function _evalNode(node, scope) {
|
|
|
764
887
|
case 'Identifier':
|
|
765
888
|
if (node.name in scope) return scope[node.name];
|
|
766
889
|
if (node.name in _SAFE_GLOBALS) return _SAFE_GLOBALS[node.name];
|
|
767
|
-
if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined')
|
|
890
|
+
if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined') {
|
|
891
|
+
if (node.name === 'window') return _safeWindow;
|
|
892
|
+
if (node.name === 'document') return _safeDocument;
|
|
893
|
+
if (node.name === 'location') return _safeLocation;
|
|
894
|
+
if (node.name === 'history') return _safeHistory;
|
|
895
|
+
if (node.name === 'navigator') return _safeNavigator;
|
|
896
|
+
return globalThis[node.name];
|
|
897
|
+
}
|
|
768
898
|
return undefined;
|
|
769
899
|
|
|
770
900
|
case 'Forbidden':
|
|
@@ -915,7 +1045,12 @@ function _evalNode(node, scope) {
|
|
|
915
1045
|
for (let i = 0; i < node.properties.length; i++) {
|
|
916
1046
|
const prop = node.properties[i];
|
|
917
1047
|
if (prop.spread) {
|
|
918
|
-
|
|
1048
|
+
const src = _evalNode(prop.value, scope);
|
|
1049
|
+
if (src && typeof src === 'object') {
|
|
1050
|
+
for (const k of Object.keys(src)) {
|
|
1051
|
+
if (!_FORBIDDEN_PROPS[k]) obj[k] = src[k];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
919
1054
|
} else {
|
|
920
1055
|
const key = prop.computed ? _evalNode(prop.key, scope) : prop.key;
|
|
921
1056
|
if (_FORBIDDEN_PROPS[key]) continue;
|
package/src/fetch.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { _config, _interceptors, _cache } from "./globals.js";
|
|
6
6
|
|
|
7
|
+
const _MAX_CACHE = 200;
|
|
8
|
+
|
|
7
9
|
export function resolveUrl(url, el) {
|
|
8
10
|
if (
|
|
9
11
|
url.startsWith("http://") ||
|
|
@@ -31,6 +33,8 @@ export async function _doFetch(
|
|
|
31
33
|
extraHeaders = {},
|
|
32
34
|
el = null,
|
|
33
35
|
externalSignal = null,
|
|
36
|
+
retries = undefined,
|
|
37
|
+
retryDelay = undefined,
|
|
34
38
|
) {
|
|
35
39
|
const fullUrl = resolveUrl(url, el);
|
|
36
40
|
let opts = {
|
|
@@ -68,7 +72,7 @@ export async function _doFetch(
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
// Retry logic
|
|
71
|
-
const maxRetries = _config.retries || 0;
|
|
75
|
+
const maxRetries = retries !== undefined ? retries : (_config.retries || 0);
|
|
72
76
|
let lastError;
|
|
73
77
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
74
78
|
try {
|
|
@@ -115,7 +119,7 @@ export async function _doFetch(
|
|
|
115
119
|
if (e.name === "AbortError") throw e; // Don't retry aborted requests
|
|
116
120
|
lastError = e;
|
|
117
121
|
if (attempt < maxRetries) {
|
|
118
|
-
await new Promise((r) => setTimeout(r, _config.retryDelay || 1000));
|
|
122
|
+
await new Promise((r) => setTimeout(r, retryDelay !== undefined ? retryDelay : (_config.retryDelay || 1000)));
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
}
|
|
@@ -126,8 +130,12 @@ export function _cacheGet(key, strategy) {
|
|
|
126
130
|
if (strategy === "none") return null;
|
|
127
131
|
if (strategy === "memory") {
|
|
128
132
|
const entry = _cache.get(key);
|
|
129
|
-
if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000))
|
|
133
|
+
if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000)) {
|
|
134
|
+
// Move to end (most-recently-used) for LRU eviction
|
|
135
|
+
_cache.delete(key);
|
|
136
|
+
_cache.set(key, entry);
|
|
130
137
|
return entry.data;
|
|
138
|
+
}
|
|
131
139
|
return null;
|
|
132
140
|
}
|
|
133
141
|
const store =
|
|
@@ -154,6 +162,11 @@ export function _cacheSet(key, data, strategy) {
|
|
|
154
162
|
if (strategy === "none") return;
|
|
155
163
|
const entry = { data, time: Date.now() };
|
|
156
164
|
if (strategy === "memory") {
|
|
165
|
+
if (_cache.has(key)) {
|
|
166
|
+
_cache.delete(key); // refresh position before re-inserting
|
|
167
|
+
} else if (_cache.size >= _MAX_CACHE) {
|
|
168
|
+
_cache.delete(_cache.keys().next().value); // evict LRU (insertion-order first)
|
|
169
|
+
}
|
|
157
170
|
_cache.set(key, entry);
|
|
158
171
|
return;
|
|
159
172
|
}
|
package/src/filters.js
CHANGED
|
@@ -20,7 +20,12 @@ _filters.slugify = (v) =>
|
|
|
20
20
|
.toLowerCase()
|
|
21
21
|
.replace(/[^a-z0-9]+/g, "-")
|
|
22
22
|
.replace(/^-|-$/g, "");
|
|
23
|
-
_filters.nl2br = (v) =>
|
|
23
|
+
_filters.nl2br = (v) =>
|
|
24
|
+
String(v ?? "")
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/\n/g, "<br>");
|
|
24
29
|
_filters.encodeUri = (v) => encodeURIComponent(String(v ?? ""));
|
|
25
30
|
|
|
26
31
|
// Numbers
|
package/src/globals.js
CHANGED
|
@@ -17,8 +17,10 @@ export const _config = {
|
|
|
17
17
|
debug: false,
|
|
18
18
|
devtools: false,
|
|
19
19
|
sanitize: true,
|
|
20
|
+
dangerouslyDisableSanitize: false,
|
|
20
21
|
sanitizeHtml: null,
|
|
21
22
|
exprCacheSize: 500,
|
|
23
|
+
maxEventListeners: 100,
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
export const _interceptors = { request: [], response: [] };
|
package/src/index.js
CHANGED
|
@@ -93,6 +93,9 @@ const NoJS = {
|
|
|
93
93
|
opts.exprCacheSize = (Number.isFinite(n) && n > 0) ? n : 500;
|
|
94
94
|
}
|
|
95
95
|
Object.assign(_config, opts);
|
|
96
|
+
if (opts.sanitize === false) {
|
|
97
|
+
_warn('sanitize:false is deprecated — use dangerouslyDisableSanitize:true to make the risk explicit.');
|
|
98
|
+
}
|
|
96
99
|
if (opts.headers)
|
|
97
100
|
_config.headers = { ...prevHeaders, ...opts.headers };
|
|
98
101
|
if (opts.csrf) _config.csrf = opts.csrf;
|
|
@@ -215,6 +218,12 @@ const NoJS = {
|
|
|
215
218
|
// Event bus
|
|
216
219
|
on(event, fn) {
|
|
217
220
|
if (!_eventBus[event]) _eventBus[event] = [];
|
|
221
|
+
if (_eventBus[event].length >= _config.maxEventListeners) {
|
|
222
|
+
_warn(
|
|
223
|
+
'MaxListenersExceeded: event "' + event + '" has ' + _eventBus[event].length +
|
|
224
|
+
' listeners (max ' + _config.maxEventListeners + '). Possible memory leak.'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
218
227
|
_eventBus[event].push(fn);
|
|
219
228
|
return () => {
|
|
220
229
|
_eventBus[event] = _eventBus[event].filter((f) => f !== fn);
|
|
@@ -249,7 +258,7 @@ const NoJS = {
|
|
|
249
258
|
resolve,
|
|
250
259
|
|
|
251
260
|
// Version
|
|
252
|
-
version: "1.10.
|
|
261
|
+
version: "1.10.1",
|
|
253
262
|
};
|
|
254
263
|
|
|
255
264
|
export default NoJS;
|