@erickxavier/no-js 1.10.1 → 1.11.0

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/src/evaluate.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // EXPRESSION EVALUATOR
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
5
+ import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers, _globals } from "./globals.js";
6
6
  import { _i18n } from "./i18n.js";
7
7
  import { _collectKeys } from "./context.js";
8
8
 
@@ -1311,6 +1311,11 @@ export function evaluate(expr, ctx) {
1311
1311
  if (!("$i18n" in scope)) scope.$i18n = _i18n;
1312
1312
  if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1313
1313
  if (!("$form" in scope)) scope.$form = ctx.$form || null;
1314
+ // Inject plugin globals (cannot shadow local or core $ variables)
1315
+ for (const gk in _globals) {
1316
+ const key = "$" + gk;
1317
+ if (!(key in scope)) scope[key] = _globals[gk];
1318
+ }
1314
1319
 
1315
1320
  // Parse expression into AST (cached)
1316
1321
  let ast = _exprCache.get(mainExpr);
@@ -1347,6 +1352,11 @@ export function _execStatement(expr, ctx, extraVars = {}) {
1347
1352
  if (!("$router" in scope)) scope.$router = _routerInstance;
1348
1353
  if (!("$i18n" in scope)) scope.$i18n = _i18n;
1349
1354
  if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1355
+ // Inject plugin globals (before extraVars so $event etc. take priority)
1356
+ for (const gk in _globals) {
1357
+ const key = "$" + gk;
1358
+ if (!(key in scope)) scope[key] = _globals[gk];
1359
+ }
1350
1360
  Object.assign(scope, extraVars);
1351
1361
 
1352
1362
  // Snapshot context chain values for write-back comparison
package/src/fetch.js CHANGED
@@ -2,9 +2,61 @@
2
2
  // FETCH HELPER, URL RESOLUTION & CACHE
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _config, _interceptors, _cache } from "./globals.js";
5
+ import { _config, _interceptors, _cache, _plugins, _SENSITIVE_HEADERS, _SENSITIVE_RESPONSE_HEADERS, _log, _warn, _CANCEL, _RESPOND, _REPLACE } from "./globals.js";
6
6
 
7
7
  const _MAX_CACHE = 200;
8
+ const _INTERCEPTOR_TIMEOUT = 5000;
9
+ let _interceptorDepth = 0;
10
+ const _MAX_INTERCEPTOR_DEPTH = 1;
11
+ const _responseOriginals = new WeakMap();
12
+
13
+ function _withTimeout(promise, ms, label) {
14
+ let id;
15
+ return Promise.race([
16
+ promise.finally(() => clearTimeout(id)),
17
+ new Promise((_, reject) => {
18
+ id = setTimeout(() => reject(new Error(label)), ms);
19
+ }),
20
+ ]);
21
+ }
22
+
23
+ function _isSensitiveHeader(name) {
24
+ return _SENSITIVE_HEADERS.has(name.toLowerCase()) || /^x-(auth|api)-/i.test(name);
25
+ }
26
+
27
+ // URL param redaction helper
28
+ function _redactUrlParams(url) {
29
+ try {
30
+ const u = new URL(url, "http://localhost");
31
+ for (const key of [...u.searchParams.keys()]) {
32
+ if (/token|key|secret|auth|password|credential/i.test(key)) {
33
+ u.searchParams.set(key, "[REDACTED]");
34
+ }
35
+ }
36
+ // Return just the path+search if it was a relative URL
37
+ return url.startsWith("http") ? u.href : u.pathname + u.search;
38
+ } catch {
39
+ return url;
40
+ }
41
+ }
42
+
43
+ // Response redaction helper for untrusted interceptors
44
+ function _redactResponse(response) {
45
+ const redactedHeaders = new Headers(response.headers);
46
+ for (const h of _SENSITIVE_RESPONSE_HEADERS) {
47
+ redactedHeaders.delete(h);
48
+ }
49
+ const redactedUrl = _redactUrlParams(response.url);
50
+ const redacted = Object.freeze({
51
+ status: response.status,
52
+ ok: response.ok,
53
+ statusText: response.statusText,
54
+ headers: redactedHeaders,
55
+ url: redactedUrl,
56
+ });
57
+ _responseOriginals.set(redacted, response);
58
+ return redacted;
59
+ }
8
60
 
9
61
  export function resolveUrl(url, el) {
10
62
  if (
@@ -66,11 +118,65 @@ export async function _doFetch(
66
118
  _config.csrf.token || "";
67
119
  }
68
120
 
69
- // Request interceptors
70
- for (const fn of _interceptors.request) {
71
- opts = fn(fullUrl, opts) || opts;
121
+ // ── Request interceptors ──
122
+ // Strip sensitive headers before passing to interceptors
123
+ const sensitiveHeaders = {};
124
+ for (const key of Object.keys(opts.headers)) {
125
+ if (_isSensitiveHeader(key)) {
126
+ sensitiveHeaders[key] = opts.headers[key];
127
+ delete opts.headers[key];
128
+ }
129
+ }
130
+
131
+ _interceptorDepth++;
132
+ try {
133
+ if (_interceptorDepth <= _MAX_INTERCEPTOR_DEPTH) {
134
+ for (let i = 0; i < _interceptors.request.length; i++) {
135
+ const entry = _interceptors.request[i];
136
+ const fn = entry.fn ?? entry;
137
+ const isTrusted = entry.pluginName && _plugins.get(entry.pluginName)?.options?.trusted === true;
138
+
139
+ const interceptorOpts = isTrusted
140
+ ? { ...opts, headers: { ...opts.headers, ...sensitiveHeaders } }
141
+ : { ...opts, headers: { ...opts.headers } };
142
+
143
+ const result = await _withTimeout(
144
+ Promise.resolve(fn(fullUrl, interceptorOpts)),
145
+ _INTERCEPTOR_TIMEOUT,
146
+ "Interceptor timeout",
147
+ ).catch(e => {
148
+ _warn(`Request interceptor [${i}] error:`, e.message);
149
+ return undefined;
150
+ });
151
+
152
+ if (result && result[_CANCEL]) {
153
+ _log("Request cancelled by interceptor", i);
154
+ throw new DOMException("Request cancelled by interceptor", "AbortError");
155
+ }
156
+ if (result && result[_RESPOND] !== undefined) {
157
+ _log("Request short-circuited by interceptor", i);
158
+ return result[_RESPOND];
159
+ }
160
+ if (result && typeof result === "object" && !result[_CANCEL] && result[_RESPOND] === undefined) {
161
+ if (result.headers && typeof result.headers === "object") {
162
+ const safeHeaders = { ...opts.headers };
163
+ for (const [key, value] of Object.entries(result.headers)) {
164
+ if (!_isSensitiveHeader(key)) {
165
+ safeHeaders[key] = value;
166
+ }
167
+ }
168
+ opts.headers = safeHeaders;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ } finally {
174
+ _interceptorDepth--;
72
175
  }
73
176
 
177
+ // Re-apply sensitive headers after interceptor chain
178
+ Object.assign(opts.headers, sensitiveHeaders);
179
+
74
180
  // Retry logic
75
181
  const maxRetries = retries !== undefined ? retries : (_config.retries || 0);
76
182
  let lastError;
@@ -97,8 +203,33 @@ export async function _doFetch(
97
203
  clearTimeout(timeout);
98
204
 
99
205
  // Response interceptors
100
- for (const fn of _interceptors.response) {
101
- response = fn(response, fullUrl) || response;
206
+ for (let i = 0; i < _interceptors.response.length; i++) {
207
+ const entry = _interceptors.response[i];
208
+ const fn = entry.fn ?? entry;
209
+ const isTrusted = entry.pluginName && _plugins.get(entry.pluginName)?.options?.trusted === true;
210
+
211
+ const interceptorResponse = isTrusted ? response : _redactResponse(response);
212
+ const interceptorUrl = isTrusted ? fullUrl : _redactUrlParams(fullUrl);
213
+
214
+ const result = await _withTimeout(
215
+ Promise.resolve(fn(interceptorResponse, interceptorUrl)),
216
+ _INTERCEPTOR_TIMEOUT,
217
+ "Response interceptor timeout",
218
+ ).catch(e => {
219
+ _warn(`Response interceptor [${i}] error:`, e.message);
220
+ return undefined;
221
+ });
222
+
223
+ if (result && result[_REPLACE] !== undefined) {
224
+ _log("Response replaced by interceptor", i);
225
+ return result[_REPLACE];
226
+ }
227
+ if (result) response = result;
228
+ }
229
+
230
+ // Unwrap redacted shell back to real Response for data extraction
231
+ if (_responseOriginals.has(response)) {
232
+ response = _responseOriginals.get(response);
102
233
  }
103
234
 
104
235
  if (!response.ok) {
package/src/globals.js CHANGED
@@ -12,7 +12,7 @@ export const _config = {
12
12
  csrf: null,
13
13
  cache: { strategy: "none", ttl: 300000 },
14
14
  templates: { cache: true },
15
- router: { useHash: false, base: "/", scrollBehavior: "top", templates: "pages", ext: ".tpl" },
15
+ router: { useHash: false, base: "/", scrollBehavior: "top", templates: "pages", ext: ".tpl", focusBehavior: "none" },
16
16
  i18n: { defaultLocale: "en", fallbackLocale: "en", detectBrowser: false, loadPath: null, ns: [], cache: true, persist: false },
17
17
  debug: false,
18
18
  devtools: false,
@@ -33,6 +33,26 @@ export const _cache = new Map();
33
33
  export const _refs = {};
34
34
  export let _routerInstance = null;
35
35
 
36
+ // ─── Plugin system shared state ─────────────────────────────────────────────
37
+ export const _plugins = new Map(); // name → { plugin, options }
38
+ export const _globals = Object.create(null); // name → reactive value (prototype-free)
39
+ export const _globalOwners = Object.create(null); // name → plugin name (collision tracking)
40
+ export let _disposing = false;
41
+ // Internal: used by index.js dispose() only — plugins receive the NoJS API, not module imports
42
+ export function _setDisposing(v) { _disposing = v; }
43
+ export let _currentPluginName = null;
44
+ export function _setCurrentPluginName(v) { _currentPluginName = v; }
45
+
46
+ export const _SENSITIVE_HEADERS = new Set([
47
+ 'authorization', 'x-api-key', 'x-auth-token', 'cookie',
48
+ 'proxy-authorization', 'set-cookie', 'x-csrf-token',
49
+ ]);
50
+
51
+ export const _SENSITIVE_RESPONSE_HEADERS = new Set([
52
+ 'set-cookie', 'x-csrf-token', 'x-auth-token',
53
+ 'www-authenticate', 'proxy-authenticate',
54
+ ]);
55
+
36
56
  // ─── Lifecycle: tracks the element being processed by processElement ────────
37
57
  // Used by ctx.$watch and _onDispose to transparently tag watchers/disposers
38
58
  // with the owning DOM element — no changes needed in directive files.
@@ -103,3 +123,8 @@ export function _onDispose(fn) {
103
123
  export function _emitEvent(name, data) {
104
124
  (_eventBus[name] || []).forEach((fn) => fn(data));
105
125
  }
126
+
127
+ // ─── Plugin sentinel symbols ────────────────────────────────────────────────
128
+ export const _CANCEL = Symbol("nojs.cancel");
129
+ export const _RESPOND = Symbol("nojs.respond");
130
+ export const _REPLACE = Symbol("nojs.replace");
package/src/index.js CHANGED
@@ -17,6 +17,17 @@ import {
17
17
  _log,
18
18
  _warn,
19
19
  _notifyStoreWatchers,
20
+ _plugins,
21
+ _globals,
22
+ _globalOwners,
23
+ _disposing,
24
+ _setDisposing,
25
+ _currentPluginName,
26
+ _setCurrentPluginName,
27
+ _emitEvent,
28
+ _CANCEL,
29
+ _RESPOND,
30
+ _REPLACE,
20
31
  } from "./globals.js";
21
32
  import { _i18n, _loadI18nForLocale } from "./i18n.js";
22
33
  import { createContext } from "./context.js";
@@ -24,7 +35,7 @@ import { evaluate, resolve } from "./evaluate.js";
24
35
  import { findContext, _loadRemoteTemplates, _loadRemoteTemplatesPhase1, _loadRemoteTemplatesPhase2, _processTemplateIncludes } from "./dom.js";
25
36
  import { registerDirective, processTree } from "./registry.js";
26
37
  import { _createRouter } from "./router.js";
27
- import { initDevtools, _devtoolsEmit } from "./devtools.js";
38
+ import { initDevtools, destroyDevtools, _devtoolsEmit } from "./devtools.js";
28
39
 
29
40
  // Side-effect imports: register built-in filters
30
41
  import "./filters.js";
@@ -41,6 +52,47 @@ import "./directives/refs.js";
41
52
  import "./directives/validation.js";
42
53
  import "./directives/i18n.js";
43
54
  import "./directives/dnd.js";
55
+ import "./directives/head.js";
56
+
57
+ // Lock core directives — plugins can only register NEW names
58
+ import { _freezeDirectives } from "./registry.js";
59
+ _freezeDirectives();
60
+
61
+ // ═══════════════════════════════════════════════════════════════════════
62
+ // PLUGIN SYSTEM INTERNALS
63
+ // ═══════════════════════════════════════════════════════════════════════
64
+
65
+ let _initPromise = null;
66
+
67
+ // Keep in sync with context.js proxy handler $xxx variables.
68
+ // Any new $xxx context variable requires adding xxx to this list.
69
+ const _RESERVED_GLOBAL_NAMES = new Set([
70
+ "store", "route", "router", "i18n", "refs", "form", "parent",
71
+ "watch", "set", "notify", "raw", "isProxy", "listeners",
72
+ "app", "config", "env", "debug", "version", "plugins", "globals",
73
+ "el", "event", "self", "this", "super", "window", "document",
74
+ "toString", "valueOf", "hasOwnProperty",
75
+ ]);
76
+
77
+ const _DANGEROUS_REFS = typeof window !== "undefined"
78
+ ? new Set([eval, Function, window.eval, window.Function].filter(Boolean))
79
+ : new Set();
80
+
81
+ function _isUnsafeGlobalValue(value) {
82
+ return _DANGEROUS_REFS.has(value);
83
+ }
84
+
85
+ function _deepCheckUnsafe(obj, seen = new Set()) {
86
+ if (!obj || typeof obj !== "object" || seen.has(obj)) return;
87
+ seen.add(obj);
88
+ for (const val of Object.values(obj)) {
89
+ if (_isUnsafeGlobalValue(val)) {
90
+ _warn("NoJS.global(): value contains a forbidden reference (eval/Function).");
91
+ throw new Error("unsafe_global");
92
+ }
93
+ if (val && typeof val === "object") _deepCheckUnsafe(val, seen);
94
+ }
95
+ }
44
96
 
45
97
  // ═══════════════════════════════════════════════════════════════════════
46
98
  // PUBLIC API
@@ -128,45 +180,221 @@ const NoJS = {
128
180
  }
129
181
  },
130
182
 
131
- async init(root) {
132
- if (typeof document === "undefined") return;
133
- if (NoJS._initialized) return;
134
- NoJS._initialized = true;
135
- root = root || document.body;
136
- _log("Initializing...");
137
-
138
- // Load external locale files (blocking — translations must be available for first paint)
139
- if (_config.i18n.loadPath) {
140
- const locales = new Set([_i18n.locale, _config.i18n.fallbackLocale]);
141
- await Promise.all([...locales].map((l) => _loadI18nForLocale(l)));
183
+ // ─── Plugin registration ──────────────────────────────────────────────
184
+ use(plugin, options = {}) {
185
+ if (_disposing) {
186
+ _warn("Cannot install plugins during dispose.");
187
+ return;
142
188
  }
143
189
 
144
- // Inline template includes (e.g. skeletons) — synchronous, before any fetch
145
- _processTemplateIncludes(root);
190
+ // Normalize function shorthand (named functions only)
191
+ if (typeof plugin === "function") {
192
+ if (!plugin.name || plugin.name === "anonymous") {
193
+ _warn('Plugin must have a unique, non-empty name. Use { name: "my-plugin", install: fn }.');
194
+ return;
195
+ }
196
+ plugin = { name: plugin.name, install: plugin };
197
+ }
146
198
 
147
- // Determine active route path for phase 1 prioritization
148
- const defaultRoutePath = _getDefaultRoutePath();
199
+ // Validate name
200
+ if (!plugin.name || typeof plugin.name !== "string" || plugin.name === "anonymous") {
201
+ _warn('Plugin must have a unique, non-empty name.');
202
+ return;
203
+ }
149
204
 
150
- // Phase 1 (blocking): priority + non-route + default route templates
151
- await _loadRemoteTemplatesPhase1(defaultRoutePath);
205
+ // Duplicate detection with object identity comparison
206
+ if (_plugins.has(plugin.name)) {
207
+ const existing = _plugins.get(plugin.name);
208
+ if (existing.plugin !== plugin) {
209
+ _warn(`Plugin "${plugin.name}" name collision: a different plugin with this name is already installed.`);
210
+ }
211
+ return;
212
+ }
152
213
 
153
- // Check for route-view outlets to activate router
154
- if (document.querySelector("[route-view]")) {
155
- setRouterInstance(_createRouter());
214
+ // Log declared capabilities in debug mode
215
+ if (plugin.capabilities && _config.debug) {
216
+ _log(`Plugin "${plugin.name}" declares capabilities:`, plugin.capabilities);
156
217
  }
157
218
 
158
- processTree(root); // first paint happens here
219
+ // Warn on trusted access
220
+ if (options.trusted === true) {
221
+ _warn(`WARNING: Plugin "${plugin.name}" installed with trusted access to sensitive HTTP headers.`);
222
+ }
159
223
 
160
- // Init router after tree is processed
161
- if (_routerInstance) await _routerInstance.init();
224
+ // Set current plugin name for interceptor tracking
225
+ _setCurrentPluginName(plugin.name);
226
+ try {
227
+ plugin.install(NoJS, options);
228
+ } finally {
229
+ _setCurrentPluginName(null);
230
+ }
162
231
 
163
- _log("Initialized.");
232
+ _plugins.set(plugin.name, { plugin, options });
164
233
 
165
- // Phase 2 (non-blocking): background preload remaining route templates
166
- _loadRemoteTemplatesPhase2();
234
+ // If already initialized and plugin has init, await then call
235
+ if (_initPromise && plugin.init) {
236
+ _initPromise.then(() => plugin.init(NoJS)).catch(e => _warn(`Plugin "${plugin.name}" init error:`, e.message));
237
+ }
167
238
 
168
- // DevTools integration
169
- initDevtools(NoJS);
239
+ _log(`Plugin "${plugin.name}" installed.`);
240
+ },
241
+
242
+ // ─── Plugin globals ───────────────────────────────────────────────────
243
+ global(name, value) {
244
+ if (typeof name !== "string" || !name) {
245
+ _warn("NoJS.global() requires a non-empty string name.");
246
+ return;
247
+ }
248
+
249
+ // Block prototype pollution vectors
250
+ if (name === "__proto__" || name === "constructor" || name === "prototype") {
251
+ _warn(`NoJS.global(): "${name}" is a forbidden name.`);
252
+ return;
253
+ }
254
+
255
+ // Block reserved names
256
+ if (_RESERVED_GLOBAL_NAMES.has(name)) {
257
+ _warn(`NoJS.global(): "${name}" is reserved and cannot be used.`);
258
+ return;
259
+ }
260
+
261
+ // Validate identifier characters
262
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
263
+ _warn(`NoJS.global(): "${name}" is not a valid identifier.`);
264
+ return;
265
+ }
266
+
267
+ // Block dangerous function references
268
+ if (_isUnsafeGlobalValue(value)) {
269
+ _warn(`NoJS.global(): value for "${name}" is a forbidden reference (eval/Function).`);
270
+ return;
271
+ }
272
+
273
+ // Warn on overwrite by a different plugin
274
+ if (name in _globals && _globalOwners[name] && _globalOwners[name] !== _currentPluginName) {
275
+ _warn(`Global "$${name}" owned by "${_globalOwners[name]}" is being overwritten.`);
276
+ }
277
+
278
+ // Sanitize object values to strip __proto__ keys
279
+ if (value && typeof value === "object" && !value.__isProxy) {
280
+ try {
281
+ value = JSON.parse(JSON.stringify(value));
282
+ } catch {
283
+ // Non-serializable objects — check for dangerous function references
284
+ try {
285
+ _deepCheckUnsafe(value);
286
+ } catch (safetyErr) {
287
+ if (safetyErr.message === "unsafe_global") return;
288
+ // Other errors pass through
289
+ }
290
+ }
291
+ // Wrap in reactive context for deep reactivity
292
+ value = createContext(value);
293
+ }
294
+
295
+ _globals[name] = value;
296
+ if (_currentPluginName) _globalOwners[name] = _currentPluginName;
297
+
298
+ // Notify all store watchers since globals are scope-wide
299
+ _notifyStoreWatchers();
300
+
301
+ _devtoolsEmit("global:set", { name, hasValue: value != null });
302
+ _log(`Global "$${name}" registered.`);
303
+ },
304
+
305
+ // ─── App teardown ─────────────────────────────────────────────────────
306
+ async dispose() {
307
+ _setDisposing(true);
308
+ try {
309
+ // Dispose plugins in reverse installation order
310
+ const entries = [..._plugins.entries()].reverse();
311
+ for (const [name, { plugin }] of entries) {
312
+ if (plugin.dispose) {
313
+ try {
314
+ let timeoutId;
315
+ await Promise.race([
316
+ Promise.resolve(plugin.dispose(NoJS)).finally(() => clearTimeout(timeoutId)),
317
+ new Promise((_, reject) => {
318
+ timeoutId = setTimeout(() => reject(new Error("Dispose timeout")), 3000);
319
+ }),
320
+ ]);
321
+ } catch (e) {
322
+ _warn(`Plugin "${name}" dispose error:`, e.message);
323
+ }
324
+ }
325
+ }
326
+ _plugins.clear();
327
+ for (const k in _globals) delete _globals[k];
328
+ for (const k in _globalOwners) delete _globalOwners[k];
329
+
330
+ // Clear interceptors
331
+ _interceptors.request.length = 0;
332
+ _interceptors.response.length = 0;
333
+
334
+ // Destroy router listeners
335
+ if (_routerInstance && _routerInstance.destroy) {
336
+ _routerInstance.destroy();
337
+ }
338
+ setRouterInstance(null);
339
+
340
+ // Clean up devtools listener
341
+ destroyDevtools();
342
+
343
+ _initPromise = null;
344
+ _log("Disposed.");
345
+ } finally {
346
+ _setDisposing(false);
347
+ }
348
+ },
349
+
350
+ // ─── Init (Promise-based lifecycle) ───────────────────────────────────
351
+ async init(root) {
352
+ if (typeof document === "undefined") return;
353
+ if (_initPromise) return _initPromise;
354
+ _initPromise = (async () => {
355
+ root = root || document.body;
356
+ _log("Initializing...");
357
+
358
+ // Load external locale files (blocking — translations must be available for first paint)
359
+ if (_config.i18n.loadPath) {
360
+ const locales = new Set([_i18n.locale, _config.i18n.fallbackLocale]);
361
+ await Promise.all([...locales].map((l) => _loadI18nForLocale(l)));
362
+ }
363
+
364
+ // Inline template includes (e.g. skeletons) — synchronous, before any fetch
365
+ _processTemplateIncludes(root);
366
+
367
+ // Determine active route path for phase 1 prioritization
368
+ const defaultRoutePath = _getDefaultRoutePath();
369
+
370
+ // Phase 1 (blocking): priority + non-route + default route templates
371
+ await _loadRemoteTemplatesPhase1(defaultRoutePath);
372
+
373
+ // Check for route-view outlets to activate router
374
+ if (document.querySelector("[route-view]")) {
375
+ setRouterInstance(_createRouter());
376
+ }
377
+
378
+ processTree(root); // ← first paint happens here
379
+
380
+ // Init router after tree is processed
381
+ if (_routerInstance) await _routerInstance.init();
382
+
383
+ _log("Initialized.");
384
+
385
+ // Phase 2 (non-blocking): background preload remaining route templates
386
+ _loadRemoteTemplatesPhase2();
387
+
388
+ // DevTools integration
389
+ initDevtools(NoJS);
390
+
391
+ // Plugin init hooks
392
+ for (const [, { plugin }] of _plugins) {
393
+ if (plugin.init) await plugin.init(NoJS);
394
+ }
395
+ _emitEvent("plugins:ready");
396
+ })();
397
+ return _initPromise;
170
398
  },
171
399
 
172
400
  // Register custom directive
@@ -230,9 +458,13 @@ const NoJS = {
230
458
  };
231
459
  },
232
460
 
233
- // Request interceptors
461
+ // Request/response interceptors (with plugin tracking)
234
462
  interceptor(type, fn) {
235
- if (_interceptors[type]) _interceptors[type].push(fn);
463
+ if (_interceptors[type]) {
464
+ _interceptors[type].push(
465
+ _currentPluginName ? { fn, pluginName: _currentPluginName } : fn
466
+ );
467
+ }
236
468
  },
237
469
 
238
470
  // Access global stores
@@ -258,7 +490,19 @@ const NoJS = {
258
490
  resolve,
259
491
 
260
492
  // Version
261
- version: "1.10.1",
493
+ version: "1.11.0",
262
494
  };
263
495
 
496
+ // Expose sentinel symbols as read-only properties
497
+ Object.defineProperty(NoJS, "CANCEL", { value: _CANCEL, writable: false, configurable: false });
498
+ Object.defineProperty(NoJS, "RESPOND", { value: _RESPOND, writable: false, configurable: false });
499
+ Object.defineProperty(NoJS, "REPLACE", { value: _REPLACE, writable: false, configurable: false });
500
+
501
+ // Backward-compat: _initialized getter/setter (tests use `NoJS._initialized = false` to reset)
502
+ Object.defineProperty(NoJS, "_initialized", {
503
+ get() { return _initPromise !== null; },
504
+ set(v) { if (!v) _initPromise = null; },
505
+ configurable: true,
506
+ });
507
+
264
508
  export default NoJS;
package/src/registry.js CHANGED
@@ -2,17 +2,28 @@
2
2
  // DIRECTIVE REGISTRY & DOM PROCESSING
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _currentEl, _setCurrentEl, _storeWatchers } from "./globals.js";
5
+ import { _currentEl, _setCurrentEl, _storeWatchers, _warn } from "./globals.js";
6
6
  import { _i18nListeners } from "./i18n.js";
7
7
  import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
8
8
 
9
9
  const _directives = new Map();
10
+ let _frozen = false;
11
+ const _coreDirectives = new Set();
10
12
 
11
13
  export function registerDirective(name, handler) {
14
+ if (_frozen && _coreDirectives.has(name)) {
15
+ _warn(`Cannot override core directive "${name}".`);
16
+ return;
17
+ }
12
18
  _directives.set(name, {
13
19
  priority: handler.priority ?? 50,
14
20
  init: handler.init,
15
21
  });
22
+ if (!_frozen) _coreDirectives.add(name);
23
+ }
24
+
25
+ export function _freezeDirectives() {
26
+ _frozen = true;
16
27
  }
17
28
 
18
29
  function _matchDirective(attrName) {