@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/README.md +68 -6
- package/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +4 -4
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +4 -4
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +4 -4
- package/package.json +1 -1
- package/src/animations.js +11 -7
- package/src/context.js +6 -1
- package/src/devtools.js +34 -6
- package/src/directives/binding.js +13 -3
- package/src/directives/dnd.js +11 -2
- package/src/directives/events.js +1 -0
- package/src/directives/head.js +142 -0
- package/src/directives/http.js +1 -5
- package/src/directives/i18n.js +2 -1
- package/src/directives/loops.js +4 -2
- package/src/directives/refs.js +9 -0
- package/src/directives/state.js +3 -2
- package/src/directives/validation.js +34 -13
- package/src/dom.js +50 -1
- package/src/evaluate.js +11 -1
- package/src/fetch.js +137 -6
- package/src/globals.js +26 -1
- package/src/index.js +276 -32
- package/src/registry.js +12 -1
- package/src/router.js +39 -8
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
|
-
|
|
71
|
-
|
|
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 (
|
|
101
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
//
|
|
145
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
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
|
-
//
|
|
154
|
-
if (
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
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
|
-
|
|
232
|
+
_plugins.set(plugin.name, { plugin, options });
|
|
164
233
|
|
|
165
|
-
//
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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])
|
|
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.
|
|
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) {
|