@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
5
5
  "main": "dist/cjs/no.js",
6
6
  "module": "dist/esm/no.js",
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: <script> blocks and on* event handlers.
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
- return svg
41
- .replace(/<script[\s\S]*?<\/script>/gi, "")
42
- .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s/>]*)/gi, "")
43
- .replace(/\s+(?:href|xlink:href)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, "");
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.
@@ -126,7 +126,7 @@ for (const method of HTTP_METHODS) {
126
126
  }
127
127
 
128
128
  const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
129
- if (_config.debug && headersAttr) {
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 savedRetries = _config.retries;
138
- const savedRetryDelay = _config.retryDelay;
139
- _config.retries = retryCount;
140
- _config.retryDelay = retryDelay;
141
- let data;
142
- try {
143
- data = await _doFetch(
144
- resolvedUrl,
145
- method,
146
- reqBody,
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);
@@ -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
  }
@@ -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 data = evaluate(value, createContext()) || {};
15
+ const initialState = evaluate(value, createContext()) || {};
16
16
  const parent = el.parentElement ? findContext(el.parentElement) : null;
17
- const ctx = createContext(data, parent);
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)) ctx.$set(k, v);
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", data);
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) return html;
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.fetch / window.localStorage remain accessible via the window object.
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') return globalThis[node.name];
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
- Object.assign(obj, _evalNode(prop.value, scope));
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) => String(v ?? "").replace(/\n/g, "<br>");
23
+ _filters.nl2br = (v) =>
24
+ String(v ?? "")
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
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.0",
261
+ version: "1.10.1",
253
262
  };
254
263
 
255
264
  export default NoJS;