@erickxavier/no-js 1.9.1 → 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 +2 -1
- package/src/animations.js +22 -7
- package/src/context.js +9 -1
- package/src/devtools.js +23 -0
- package/src/directives/binding.js +66 -1
- package/src/directives/conditionals.js +8 -2
- package/src/directives/events.js +4 -0
- package/src/directives/http.js +27 -19
- package/src/directives/i18n.js +2 -2
- package/src/directives/loops.js +171 -6
- package/src/directives/state.js +39 -9
- package/src/directives/styling.js +1 -1
- package/src/dom.js +58 -7
- package/src/evaluate.js +210 -46
- package/src/fetch.js +16 -3
- package/src/filters.js +6 -1
- package/src/globals.js +20 -0
- package/src/index.js +15 -1
- package/src/router.js +22 -5
package/src/directives/state.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// DIRECTIVES: state, store, computed, watch
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _stores, _log, _watchExpr } from "../globals.js";
|
|
5
|
+
import { _stores, _log, _warn, _watchExpr } from "../globals.js";
|
|
6
6
|
import { createContext } from "../context.js";
|
|
7
7
|
import { evaluate, _execStatement } from "../evaluate.js";
|
|
8
8
|
import { findContext } from "../dom.js";
|
|
@@ -12,14 +12,18 @@ 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
|
|
21
21
|
const persist = el.getAttribute("persist");
|
|
22
22
|
const persistKey = el.getAttribute("persist-key");
|
|
23
|
+
if (persist && !persistKey) {
|
|
24
|
+
_warn(`persist="${persist}" requires a persist-key attribute. State will not be persisted.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
23
27
|
if (persist && persistKey) {
|
|
24
28
|
const store =
|
|
25
29
|
persist === "localStorage"
|
|
@@ -28,21 +32,47 @@ registerDirective("state", {
|
|
|
28
32
|
? sessionStorage
|
|
29
33
|
: null;
|
|
30
34
|
if (store) {
|
|
35
|
+
const persistFieldsAttr = el.getAttribute("persist-fields");
|
|
36
|
+
const persistFields = persistFieldsAttr
|
|
37
|
+
? new Set(persistFieldsAttr.split(",").map((f) => f.trim()))
|
|
38
|
+
: null;
|
|
31
39
|
try {
|
|
32
40
|
const saved = store.getItem("nojs_state_" + persistKey);
|
|
33
41
|
if (saved) {
|
|
34
42
|
const parsed = JSON.parse(saved);
|
|
35
|
-
|
|
43
|
+
const schemaCheck = el.hasAttribute("persist-schema");
|
|
44
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
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
|
+
}
|
|
55
|
+
}
|
|
36
56
|
}
|
|
37
57
|
} catch {
|
|
38
58
|
/* ignore */
|
|
39
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
|
+
|
|
40
69
|
ctx.$watch(() => {
|
|
41
70
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
const raw = ctx.__raw;
|
|
72
|
+
const data = persistFields
|
|
73
|
+
? Object.fromEntries(Object.entries(raw).filter(([k]) => persistFields.has(k)))
|
|
74
|
+
: raw;
|
|
75
|
+
store.setItem("nojs_state_" + persistKey, JSON.stringify(data));
|
|
46
76
|
} catch {
|
|
47
77
|
/* ignore */
|
|
48
78
|
}
|
|
@@ -50,7 +80,7 @@ registerDirective("state", {
|
|
|
50
80
|
}
|
|
51
81
|
}
|
|
52
82
|
|
|
53
|
-
_log("state",
|
|
83
|
+
_log("state", initialState);
|
|
54
84
|
},
|
|
55
85
|
});
|
|
56
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
|
@@ -33,14 +33,51 @@ export function _cloneTemplate(id) {
|
|
|
33
33
|
return tpl.content ? tpl.content.cloneNode(true) : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// Structural HTML sanitizer — uses DOMParser to parse the markup before cleaning.
|
|
37
|
+
// Regex-based sanitizers are bypassable via SVG/MathML event handlers, nested
|
|
38
|
+
// srcdoc attributes, and HTML entity encoding (e.g. javascript:).
|
|
39
|
+
// DOMParser resolves entities and builds a real DOM tree, making all vectors
|
|
40
|
+
// uniformly detectable by a single attribute-name/value check.
|
|
41
|
+
//
|
|
42
|
+
// Custom hook: set _config.sanitizeHtml to a function to plug in an external
|
|
43
|
+
// sanitizer (e.g. DOMPurify) without bundling it as a hard dependency.
|
|
44
|
+
const _BLOCKED_TAGS = new Set([
|
|
45
|
+
'script', 'style', 'iframe', 'object', 'embed',
|
|
46
|
+
'base', 'form', 'meta', 'link', 'noscript',
|
|
47
|
+
]);
|
|
48
|
+
|
|
37
49
|
export function _sanitizeHtml(html) {
|
|
38
|
-
if (!_config.sanitize)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
54
|
+
if (typeof _config.sanitizeHtml === 'function') return _config.sanitizeHtml(html);
|
|
55
|
+
|
|
56
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
57
|
+
|
|
58
|
+
function _clean(node) {
|
|
59
|
+
for (const child of [...node.childNodes]) {
|
|
60
|
+
if (child.nodeType !== 1) continue; // text and comment nodes are safe
|
|
61
|
+
if (_BLOCKED_TAGS.has(child.tagName.toLowerCase())) {
|
|
62
|
+
child.remove();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const attr of [...child.attributes]) {
|
|
66
|
+
const n = attr.name.toLowerCase();
|
|
67
|
+
const v = attr.value.toLowerCase().trimStart();
|
|
68
|
+
const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href';
|
|
69
|
+
const isDangerousScheme = v.startsWith('javascript:') || v.startsWith('vbscript:');
|
|
70
|
+
const isDangerousData = isUrlAttr && v.startsWith('data:') && !/^data:image\//.test(v);
|
|
71
|
+
if (n.startsWith('on') || isDangerousScheme || isDangerousData) {
|
|
72
|
+
child.removeAttribute(attr.name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
_clean(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_clean(doc.body);
|
|
80
|
+
return doc.body.innerHTML;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
// Resolve a template src path.
|
|
@@ -64,6 +101,18 @@ function _resolveTemplateSrc(src, tpl) {
|
|
|
64
101
|
return resolveUrl(src, tpl);
|
|
65
102
|
}
|
|
66
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
|
+
|
|
67
116
|
export async function _loadRemoteTemplates(root) {
|
|
68
117
|
const scope = root || document;
|
|
69
118
|
const templates = scope.querySelectorAll("template[src]");
|
|
@@ -74,6 +123,7 @@ export async function _loadRemoteTemplates(root) {
|
|
|
74
123
|
tpl.__srcLoaded = true;
|
|
75
124
|
const src = tpl.getAttribute("src");
|
|
76
125
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
126
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
77
127
|
// Track the folder of this template so children can use "./" paths
|
|
78
128
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
79
129
|
try {
|
|
@@ -129,6 +179,7 @@ export async function _loadTemplateElement(tpl) {
|
|
|
129
179
|
_log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
|
|
130
180
|
tpl.__srcLoaded = true;
|
|
131
181
|
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
182
|
+
_warnIfInsecureTemplateUrl(resolvedUrl, src);
|
|
132
183
|
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
133
184
|
|
|
134
185
|
// Synchronously insert loading placeholder before the fetch begins
|
package/src/evaluate.js
CHANGED
|
@@ -2,12 +2,36 @@
|
|
|
2
2
|
// EXPRESSION EVALUATOR
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
|
|
5
|
+
import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
|
|
6
6
|
import { _i18n } from "./i18n.js";
|
|
7
7
|
import { _collectKeys } from "./context.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
9
|
+
function _makeCache() {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
return {
|
|
12
|
+
get(k) {
|
|
13
|
+
if (!map.has(k)) return undefined;
|
|
14
|
+
// Move to end so this entry is the most-recently-used
|
|
15
|
+
const v = map.get(k);
|
|
16
|
+
map.delete(k);
|
|
17
|
+
map.set(k, v);
|
|
18
|
+
return v;
|
|
19
|
+
},
|
|
20
|
+
has(k) { return map.has(k); },
|
|
21
|
+
set(k, v) {
|
|
22
|
+
const max = _config.exprCacheSize;
|
|
23
|
+
if (map.has(k)) {
|
|
24
|
+
map.delete(k); // refresh position before re-inserting
|
|
25
|
+
} else if (map.size >= max) {
|
|
26
|
+
map.delete(map.keys().next().value); // evict LRU (insertion-order first)
|
|
27
|
+
}
|
|
28
|
+
map.set(k, v);
|
|
29
|
+
},
|
|
30
|
+
get size() { return map.size; },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export const _exprCache = _makeCache();
|
|
34
|
+
export const _stmtCache = _makeCache();
|
|
11
35
|
|
|
12
36
|
// ── Tokenizer ──────────────────────────────────────────────────────────
|
|
13
37
|
|
|
@@ -707,7 +731,149 @@ const _SAFE_GLOBALS = {
|
|
|
707
731
|
Error, Symbol, console,
|
|
708
732
|
};
|
|
709
733
|
|
|
710
|
-
|
|
734
|
+
// Explicit allow-list for browser globals accessible in template expressions.
|
|
735
|
+
// Using an allow-list (opt-in) rather than a deny-list (opt-out) ensures that
|
|
736
|
+
// network and storage APIs — fetch, XMLHttpRequest, localStorage, sessionStorage,
|
|
737
|
+
// WebSocket, indexedDB — are unreachable from template code by default, closing
|
|
738
|
+
// the surface where interpolated external data could trigger unintended requests.
|
|
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
|
+
|
|
863
|
+
const _BROWSER_GLOBALS = new Set([
|
|
864
|
+
'window', 'document', 'console', 'location', 'history',
|
|
865
|
+
'navigator', 'screen', 'performance', 'crypto',
|
|
866
|
+
// setTimeout/setInterval allow deferred execution from template expressions;
|
|
867
|
+
// necessary for legitimate use cases (e.g. debounce patterns in event handlers).
|
|
868
|
+
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
|
|
869
|
+
'requestAnimationFrame', 'cancelAnimationFrame',
|
|
870
|
+
// alert/confirm/prompt are included for completeness and backward compatibility
|
|
871
|
+
// (e.g. confirm dialogs before delete). They are discouraged in production UIs —
|
|
872
|
+
// prefer custom modal components for a better user experience.
|
|
873
|
+
'alert', 'confirm', 'prompt',
|
|
874
|
+
'CustomEvent', 'Event', 'URL', 'URLSearchParams',
|
|
875
|
+
'FormData', 'FileReader', 'Blob', 'Promise',
|
|
876
|
+
]);
|
|
711
877
|
|
|
712
878
|
function _evalNode(node, scope) {
|
|
713
879
|
try {
|
|
@@ -721,8 +887,14 @@ function _evalNode(node, scope) {
|
|
|
721
887
|
case 'Identifier':
|
|
722
888
|
if (node.name in scope) return scope[node.name];
|
|
723
889
|
if (node.name in _SAFE_GLOBALS) return _SAFE_GLOBALS[node.name];
|
|
724
|
-
|
|
725
|
-
|
|
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
|
+
}
|
|
726
898
|
return undefined;
|
|
727
899
|
|
|
728
900
|
case 'Forbidden':
|
|
@@ -873,7 +1045,12 @@ function _evalNode(node, scope) {
|
|
|
873
1045
|
for (let i = 0; i < node.properties.length; i++) {
|
|
874
1046
|
const prop = node.properties[i];
|
|
875
1047
|
if (prop.spread) {
|
|
876
|
-
|
|
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
|
+
}
|
|
877
1054
|
} else {
|
|
878
1055
|
const key = prop.computed ? _evalNode(prop.key, scope) : prop.key;
|
|
879
1056
|
if (_FORBIDDEN_PROPS[key]) continue;
|
|
@@ -1006,8 +1183,7 @@ function _execStmtNode(node, scope) {
|
|
|
1006
1183
|
// so error-boundary directives can catch the error
|
|
1007
1184
|
if (node.type === "CallExpr" && node.callee.type === "Identifier") {
|
|
1008
1185
|
const name = node.callee.name;
|
|
1009
|
-
if (!(name in scope) && !(name in _SAFE_GLOBALS) &&
|
|
1010
|
-
(typeof globalThis === "undefined" || !(name in globalThis))) {
|
|
1186
|
+
if (!(name in scope) && !(name in _SAFE_GLOBALS) && !_BROWSER_GLOBALS.has(name)) {
|
|
1011
1187
|
throw new ReferenceError(name + " is not defined");
|
|
1012
1188
|
}
|
|
1013
1189
|
}
|
|
@@ -1124,25 +1300,17 @@ export function evaluate(expr, ctx) {
|
|
|
1124
1300
|
const mainExpr = pipes[0];
|
|
1125
1301
|
const { keys, vals } = _collectKeys(ctx);
|
|
1126
1302
|
|
|
1127
|
-
//
|
|
1128
|
-
const specialKeys = [
|
|
1129
|
-
"$store",
|
|
1130
|
-
"$route",
|
|
1131
|
-
"$router",
|
|
1132
|
-
"$i18n",
|
|
1133
|
-
"$refs",
|
|
1134
|
-
"$form",
|
|
1135
|
-
];
|
|
1136
|
-
for (const sk of specialKeys) {
|
|
1137
|
-
if (!keys.includes(sk)) {
|
|
1138
|
-
keys.push(sk);
|
|
1139
|
-
vals[sk] = ctx[sk];
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// Build scope object from keys/vals
|
|
1303
|
+
// Build scope from cache without mutating it
|
|
1144
1304
|
const scope = {};
|
|
1145
1305
|
for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
|
|
1306
|
+
// Add special variables to scope only (never to the shared cache),
|
|
1307
|
+
// preserving any same-named local context vars already in scope
|
|
1308
|
+
if (!("$store" in scope)) scope.$store = _stores;
|
|
1309
|
+
if (!("$route" in scope)) scope.$route = _routerInstance?.current;
|
|
1310
|
+
if (!("$router" in scope)) scope.$router = _routerInstance;
|
|
1311
|
+
if (!("$i18n" in scope)) scope.$i18n = _i18n;
|
|
1312
|
+
if (!("$refs" in scope)) scope.$refs = ctx.$refs;
|
|
1313
|
+
if (!("$form" in scope)) scope.$form = ctx.$form || null;
|
|
1146
1314
|
|
|
1147
1315
|
// Parse expression into AST (cached)
|
|
1148
1316
|
let ast = _exprCache.get(mainExpr);
|
|
@@ -1170,25 +1338,16 @@ export function evaluate(expr, ctx) {
|
|
|
1170
1338
|
export function _execStatement(expr, ctx, extraVars = {}) {
|
|
1171
1339
|
try {
|
|
1172
1340
|
const { keys, vals } = _collectKeys(ctx);
|
|
1173
|
-
// Add special vars
|
|
1174
|
-
const specials = {
|
|
1175
|
-
$store: _stores,
|
|
1176
|
-
$route: _routerInstance?.current,
|
|
1177
|
-
$router: _routerInstance,
|
|
1178
|
-
$i18n: _i18n,
|
|
1179
|
-
$refs: ctx.$refs,
|
|
1180
|
-
};
|
|
1181
|
-
Object.assign(specials, extraVars);
|
|
1182
|
-
for (const [k, v] of Object.entries(specials)) {
|
|
1183
|
-
if (!keys.includes(k)) {
|
|
1184
|
-
keys.push(k);
|
|
1185
|
-
vals[k] = v;
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
1341
|
|
|
1189
|
-
// Build scope
|
|
1342
|
+
// Build scope from cache without mutating it, then add special vars and extraVars
|
|
1190
1343
|
const scope = {};
|
|
1191
1344
|
for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
|
|
1345
|
+
if (!("$store" in scope)) scope.$store = _stores;
|
|
1346
|
+
if (!("$route" in scope)) scope.$route = _routerInstance?.current;
|
|
1347
|
+
if (!("$router" in scope)) scope.$router = _routerInstance;
|
|
1348
|
+
if (!("$i18n" in scope)) scope.$i18n = _i18n;
|
|
1349
|
+
if (!("$refs" in scope)) scope.$refs = ctx.$refs;
|
|
1350
|
+
Object.assign(scope, extraVars);
|
|
1192
1351
|
|
|
1193
1352
|
// Snapshot context chain values for write-back comparison
|
|
1194
1353
|
const chainKeys = new Set();
|
|
@@ -1227,10 +1386,11 @@ export function _execStatement(expr, ctx, extraVars = {}) {
|
|
|
1227
1386
|
}
|
|
1228
1387
|
}
|
|
1229
1388
|
|
|
1230
|
-
// Write back new variables created during execution
|
|
1389
|
+
// Write back new variables created during execution.
|
|
1390
|
+
// Skip extraVars keys (e.g. __val, $el, $event) — they are execution-local
|
|
1391
|
+
// and must not be persisted to the reactive context.
|
|
1231
1392
|
for (const k in scope) {
|
|
1232
|
-
if (k.startsWith("$") || chainKeys.has(k)) continue;
|
|
1233
|
-
if (k in vals) continue;
|
|
1393
|
+
if (k.startsWith("$") || chainKeys.has(k) || k in extraVars) continue;
|
|
1234
1394
|
ctx.$set(k, scope[k]);
|
|
1235
1395
|
}
|
|
1236
1396
|
|
|
@@ -1254,9 +1414,13 @@ export function resolve(path, ctx) {
|
|
|
1254
1414
|
}
|
|
1255
1415
|
|
|
1256
1416
|
// Interpolate strings like "/users/{user.id}?q={search}"
|
|
1417
|
+
// Note: interpolated values are encoded with encodeURIComponent, which encodes
|
|
1418
|
+
// "/" as "%2F". Path segments that intentionally contain "/" must be passed
|
|
1419
|
+
// as pre-encoded strings or concatenated outside of {} placeholders.
|
|
1257
1420
|
export function _interpolate(str, ctx) {
|
|
1258
1421
|
return str.replace(/\{([^}]+)\}/g, (_, expr) => {
|
|
1259
1422
|
const val = evaluate(expr.trim(), ctx);
|
|
1260
|
-
|
|
1423
|
+
if (val == null) return "";
|
|
1424
|
+
return encodeURIComponent(String(val));
|
|
1261
1425
|
});
|
|
1262
1426
|
}
|
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,6 +17,10 @@ export const _config = {
|
|
|
17
17
|
debug: false,
|
|
18
18
|
devtools: false,
|
|
19
19
|
sanitize: true,
|
|
20
|
+
dangerouslyDisableSanitize: false,
|
|
21
|
+
sanitizeHtml: null,
|
|
22
|
+
exprCacheSize: 500,
|
|
23
|
+
maxEventListeners: 100,
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
export const _interceptors = { request: [], response: [] };
|
|
@@ -68,6 +72,22 @@ export function _watchExpr(expr, ctx, fn) {
|
|
|
68
72
|
});
|
|
69
73
|
if (typeof expr === "string" && expr.includes("$store")) {
|
|
70
74
|
_storeWatchers.add(fn);
|
|
75
|
+
fn._el = _currentEl;
|
|
76
|
+
// Self-cleanup when the element is removed without going through dispose
|
|
77
|
+
const el = _currentEl;
|
|
78
|
+
if (el && el.parentElement) {
|
|
79
|
+
const ro = new MutationObserver(() => {
|
|
80
|
+
if (!el.isConnected) {
|
|
81
|
+
_storeWatchers.delete(fn);
|
|
82
|
+
unwatch();
|
|
83
|
+
ro.disconnect();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// subtree: false — we only care about direct children of parentElement being removed
|
|
87
|
+
ro.observe(el.parentElement, { childList: true, subtree: false });
|
|
88
|
+
// Also disconnect via the normal disposal path to avoid a dangling MO
|
|
89
|
+
_onDispose(() => ro.disconnect());
|
|
90
|
+
}
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
_routerInstance,
|
|
16
16
|
setRouterInstance,
|
|
17
17
|
_log,
|
|
18
|
+
_warn,
|
|
18
19
|
_notifyStoreWatchers,
|
|
19
20
|
} from "./globals.js";
|
|
20
21
|
import { _i18n, _loadI18nForLocale } from "./i18n.js";
|
|
@@ -87,7 +88,14 @@ const NoJS = {
|
|
|
87
88
|
_warn("csp config option removed — No.JS is now CSP-safe by default");
|
|
88
89
|
delete opts.csp;
|
|
89
90
|
}
|
|
91
|
+
if (opts.exprCacheSize !== undefined) {
|
|
92
|
+
const n = parseInt(opts.exprCacheSize);
|
|
93
|
+
opts.exprCacheSize = (Number.isFinite(n) && n > 0) ? n : 500;
|
|
94
|
+
}
|
|
90
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
|
+
}
|
|
91
99
|
if (opts.headers)
|
|
92
100
|
_config.headers = { ...prevHeaders, ...opts.headers };
|
|
93
101
|
if (opts.csrf) _config.csrf = opts.csrf;
|
|
@@ -210,6 +218,12 @@ const NoJS = {
|
|
|
210
218
|
// Event bus
|
|
211
219
|
on(event, fn) {
|
|
212
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
|
+
}
|
|
213
227
|
_eventBus[event].push(fn);
|
|
214
228
|
return () => {
|
|
215
229
|
_eventBus[event] = _eventBus[event].filter((f) => f !== fn);
|
|
@@ -244,7 +258,7 @@ const NoJS = {
|
|
|
244
258
|
resolve,
|
|
245
259
|
|
|
246
260
|
// Version
|
|
247
|
-
version: "1.
|
|
261
|
+
version: "1.10.1",
|
|
248
262
|
};
|
|
249
263
|
|
|
250
264
|
export default NoJS;
|