@erickxavier/no-js 1.0.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/dom.js ADDED
@@ -0,0 +1,232 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // DOM HELPERS & REMOTE TEMPLATES
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+
5
+ import { _config, _log, _warn } from "./globals.js";
6
+ import { createContext } from "./context.js";
7
+ import { resolveUrl } from "./fetch.js";
8
+
9
+ // ─── Template HTML cache: url → html string ────────────────────────────────
10
+ // Avoids re-fetching the same .tpl file on repeat navigation.
11
+ // Controlled by _config.templates.cache (default: true).
12
+ export const _templateHtmlCache = new Map();
13
+
14
+ export function findContext(el) {
15
+ let node = el;
16
+ while (node) {
17
+ if (node.__ctx) return node.__ctx;
18
+ node = node.parentElement;
19
+ }
20
+ return createContext();
21
+ }
22
+
23
+ export function _clearDeclared(el) {
24
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT);
25
+ while (walker.nextNode()) walker.currentNode.__declared = false;
26
+ }
27
+
28
+ export function _cloneTemplate(id) {
29
+ if (!id) return null;
30
+ const cleanId = id.startsWith("#") ? id.slice(1) : id;
31
+ const tpl = document.getElementById(cleanId);
32
+ if (!tpl) return null;
33
+ return tpl.content ? tpl.content.cloneNode(true) : null;
34
+ }
35
+
36
+ // Simple HTML sanitizer
37
+ export function _sanitizeHtml(html) {
38
+ if (!_config.sanitize) return html;
39
+ const safe = html
40
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
41
+ .replace(/on\w+\s*=/gi, "data-blocked=")
42
+ .replace(/javascript:/gi, "");
43
+ return safe;
44
+ }
45
+
46
+ // Resolve a template src path.
47
+ // - "./foo.tpl" → relative to the parent template's folder (__srcBase)
48
+ // - "/foo.tpl" → absolute from server root (kept as-is for fetch)
49
+ // - "foo.tpl" → relative to page URL (kept as-is for fetch)
50
+ function _resolveTemplateSrc(src, tpl) {
51
+ if (src.startsWith("./")) {
52
+ // Walk up to find the nearest ancestor with __srcBase
53
+ let node = tpl.parentNode;
54
+ while (node) {
55
+ if (node.__srcBase) {
56
+ return node.__srcBase + src.slice(2);
57
+ }
58
+ node = node.parentNode;
59
+ }
60
+ // No ancestor base found — strip "./" and let fetch resolve from page
61
+ return src.slice(2);
62
+ }
63
+ // Absolute or plain relative — use the existing resolveUrl logic
64
+ return resolveUrl(src, tpl);
65
+ }
66
+
67
+ export async function _loadRemoteTemplates(root) {
68
+ const scope = root || document;
69
+ const templates = scope.querySelectorAll("template[src]");
70
+ _log("[LRT] called on", scope === document ? "document" : scope.nodeName || "fragment", "— found", templates.length, "template[src]", [...templates].map(t => t.getAttribute("src")));
71
+ if (!templates.length) return;
72
+ const promises = [...templates].map(async (tpl) => {
73
+ if (tpl.__srcLoaded) { _log("[LRT] SKIP (already loaded):", tpl.getAttribute("src")); return; }
74
+ tpl.__srcLoaded = true;
75
+ const src = tpl.getAttribute("src");
76
+ const resolvedUrl = _resolveTemplateSrc(src, tpl);
77
+ // Track the folder of this template so children can use "./" paths
78
+ const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
79
+ try {
80
+ const useCache = _config.templates.cache !== false;
81
+ let html;
82
+ if (useCache && _templateHtmlCache.has(resolvedUrl)) {
83
+ html = _templateHtmlCache.get(resolvedUrl);
84
+ _log("[LRT] CACHE HIT:", resolvedUrl);
85
+ } else {
86
+ const res = await fetch(resolvedUrl);
87
+ html = await res.text();
88
+ if (useCache) _templateHtmlCache.set(resolvedUrl, html);
89
+ }
90
+ tpl.innerHTML = html;
91
+ // Stamp the base folder onto the content so nested templates inherit it
92
+ if (tpl.content) {
93
+ tpl.content.__srcBase = baseFolder;
94
+ }
95
+ _log("Loaded remote template:", src, "→", resolvedUrl);
96
+ // Recursively load nested remote templates
97
+ await _loadRemoteTemplates(tpl.content || tpl);
98
+ // Non-route templates are content-includes: replace them with
99
+ // their loaded content so it actually renders (template elements
100
+ // are inert — the browser never displays their .content).
101
+ if (!tpl.hasAttribute("route") && tpl.parentNode) {
102
+ // Transfer __srcBase to a wrapper so descendants keep the reference
103
+ const frag = tpl.content;
104
+ const children = [...frag.childNodes];
105
+ const parent = tpl.parentNode;
106
+ const ref = tpl.nextSibling;
107
+ parent.removeChild(tpl);
108
+ for (const child of children) {
109
+ if (child.nodeType === 1) child.__srcBase = child.__srcBase || baseFolder;
110
+ parent.insertBefore(child, ref);
111
+ }
112
+ }
113
+ } catch (e) {
114
+ _warn("Failed to load template:", src, e.message);
115
+ }
116
+ });
117
+ await Promise.all(promises);
118
+ }
119
+
120
+ // ─── Single-element loader (core primitive) ────────────────────────────────
121
+ export async function _loadTemplateElement(tpl) {
122
+ const src = tpl.getAttribute("src");
123
+ if (tpl.__srcLoaded) { _log("[LTE] SKIP (already loaded):", src); return; }
124
+ _log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
125
+ tpl.__srcLoaded = true;
126
+ const resolvedUrl = _resolveTemplateSrc(src, tpl);
127
+ const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
128
+
129
+ // Synchronously insert loading placeholder before the fetch begins
130
+ let loadingMarker = null;
131
+ const loadingId = tpl.getAttribute("loading");
132
+ if (loadingId && tpl.parentNode) {
133
+ const cleanId = loadingId.startsWith("#") ? loadingId.slice(1) : loadingId;
134
+ const source = document.getElementById(cleanId);
135
+ if (source && source.content) {
136
+ loadingMarker = document.createElement("span");
137
+ loadingMarker.style.cssText = "display:contents";
138
+ loadingMarker.appendChild(source.content.cloneNode(true));
139
+ tpl.parentNode.insertBefore(loadingMarker, tpl);
140
+ }
141
+ }
142
+
143
+ try {
144
+ const useCache = _config.templates.cache !== false;
145
+ let html;
146
+ if (useCache && _templateHtmlCache.has(resolvedUrl)) {
147
+ html = _templateHtmlCache.get(resolvedUrl);
148
+ _log("[LTE] CACHE HIT:", resolvedUrl);
149
+ } else {
150
+ const res = await fetch(resolvedUrl);
151
+ html = await res.text();
152
+ if (useCache) _templateHtmlCache.set(resolvedUrl, html);
153
+ }
154
+ tpl.innerHTML = html;
155
+ if (tpl.content) {
156
+ tpl.content.__srcBase = baseFolder;
157
+ }
158
+ _log("Loaded remote template:", src, "→", resolvedUrl);
159
+ // Route templates: defer nested loading until after DOM insertion
160
+ // (ensures loading="#id" placeholder lookup via getElementById works).
161
+ // Content-include templates: load nested ones now.
162
+ if (!tpl.hasAttribute("route")) {
163
+ await _loadRemoteTemplates(tpl.content || tpl);
164
+ }
165
+ // Remove loading placeholder once real content is ready
166
+ if (loadingMarker) loadingMarker.remove();
167
+ // Non-route templates are content-includes: inject content inline
168
+ if (!tpl.hasAttribute("route") && tpl.parentNode) {
169
+ const frag = tpl.content;
170
+ const children = [...frag.childNodes];
171
+ const parent = tpl.parentNode;
172
+ const ref = tpl.nextSibling;
173
+ parent.removeChild(tpl);
174
+ for (const child of children) {
175
+ if (child.nodeType === 1) child.__srcBase = child.__srcBase || baseFolder;
176
+ parent.insertBefore(child, ref);
177
+ }
178
+ }
179
+ } catch (e) {
180
+ if (loadingMarker) loadingMarker.remove();
181
+ _warn("Failed to load template:", src, e.message);
182
+ }
183
+ }
184
+
185
+ // ─── Inline template includes (template[include="id"]) ────────────────────
186
+ // Synchronously clones a named inline template into every
187
+ // <template include="id"> placeholder. Called before any async fetch
188
+ // so skeleton/include content is stamped into the DOM immediately.
189
+ export function _processTemplateIncludes(root) {
190
+ const scope = root || document;
191
+ scope.querySelectorAll("template[include]").forEach((tpl) => {
192
+ const id = tpl.getAttribute("include");
193
+ const source = document.getElementById(id.startsWith("#") ? id.slice(1) : id);
194
+ if (!source || !source.content) return;
195
+ tpl.replaceWith(source.content.cloneNode(true));
196
+ });
197
+ }
198
+
199
+ // ─── Phase 1 loader (priority + eager non-route + active route) ────────────
200
+ export async function _loadRemoteTemplatesPhase1(defaultRoutePath) {
201
+ const all = [...document.querySelectorAll("template[src]")];
202
+
203
+ // Phase 0: lazy="priority" templates — load first, regardless of route type
204
+ const phase0 = all.filter(
205
+ (tpl) => !tpl.__srcLoaded && tpl.getAttribute("lazy") === "priority"
206
+ );
207
+ await Promise.all(phase0.map(_loadTemplateElement));
208
+
209
+ // Phase 1: non-route templates + the route matching defaultRoutePath
210
+ // Skip lazy="ondemand" and lazy="priority" (already handled above)
211
+ const phase1 = all.filter((tpl) => {
212
+ if (tpl.__srcLoaded) return false;
213
+ const lazy = tpl.getAttribute("lazy");
214
+ if (lazy === "ondemand" || lazy === "priority") return false;
215
+ const isRoute = tpl.hasAttribute("route");
216
+ if (!isRoute) return true; // content-include: always Phase 1
217
+ // Route template: only include if it matches the current default path
218
+ return defaultRoutePath != null && tpl.getAttribute("route") === defaultRoutePath;
219
+ });
220
+ await Promise.all(phase1.map(_loadTemplateElement));
221
+ }
222
+
223
+ // ─── Phase 2 loader (background preload of remaining route templates) ──────
224
+ export function _loadRemoteTemplatesPhase2() {
225
+ const all = [...document.querySelectorAll("template[src]")];
226
+ const phase2 = all.filter((tpl) => {
227
+ if (tpl.__srcLoaded) return false;
228
+ if (tpl.getAttribute("lazy") === "ondemand") return false;
229
+ return tpl.hasAttribute("route");
230
+ });
231
+ return Promise.all(phase2.map(_loadTemplateElement));
232
+ }
@@ -0,0 +1,298 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // EXPRESSION EVALUATOR
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+
5
+ import { _stores, _routerInstance, _filters, _warn, _config } from "./globals.js";
6
+ import { _i18n } from "./i18n.js";
7
+ import { _collectKeys } from "./context.js";
8
+
9
+ const _exprCache = new Map();
10
+
11
+ // CSP-safe expression evaluator (no new Function / eval)
12
+ // Handles dot-notation paths, basic comparisons, boolean operators, negation, and literals.
13
+ function _cspSafeEval(expr, keys, vals) {
14
+ const scope = {};
15
+ for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[i];
16
+
17
+ function resolvePath(path, obj) {
18
+ return path.split(".").reduce((o, k) => o?.[k], obj);
19
+ }
20
+
21
+ function parseValue(token) {
22
+ const t = token.trim();
23
+ if (t === "true") return true;
24
+ if (t === "false") return false;
25
+ if (t === "null") return null;
26
+ if (t === "undefined") return undefined;
27
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
28
+ if (/^(['"`]).*\1$/.test(t)) return t.slice(1, -1);
29
+ // Treat as property path resolved from scope
30
+ return resolvePath(t, scope);
31
+ }
32
+
33
+ const trimmed = expr.trim();
34
+
35
+ // Handle ternary: condition ? trueExpr : falseExpr
36
+ const ternaryMatch = trimmed.match(/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/);
37
+ if (ternaryMatch) {
38
+ const cond = _cspSafeEval(ternaryMatch[1].trim(), keys, vals);
39
+ return cond
40
+ ? _cspSafeEval(ternaryMatch[2].trim(), keys, vals)
41
+ : _cspSafeEval(ternaryMatch[3].trim(), keys, vals);
42
+ }
43
+
44
+ // Handle logical OR (||)
45
+ if (trimmed.includes("||")) {
46
+ const parts = trimmed.split("||");
47
+ for (const part of parts) {
48
+ const val = _cspSafeEval(part.trim(), keys, vals);
49
+ if (val) return val;
50
+ }
51
+ return _cspSafeEval(parts[parts.length - 1].trim(), keys, vals);
52
+ }
53
+
54
+ // Handle logical AND (&&)
55
+ if (trimmed.includes("&&")) {
56
+ const parts = trimmed.split("&&");
57
+ let last;
58
+ for (const part of parts) {
59
+ last = _cspSafeEval(part.trim(), keys, vals);
60
+ if (!last) return last;
61
+ }
62
+ return last;
63
+ }
64
+
65
+ // Handle comparisons: ===, !==, ==, !=, >=, <=, >, <
66
+ const cmpMatch = trimmed.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
67
+ if (cmpMatch) {
68
+ const left = parseValue(cmpMatch[1]);
69
+ const right = parseValue(cmpMatch[3]);
70
+ switch (cmpMatch[2]) {
71
+ case "===": return left === right;
72
+ case "!==": return left !== right;
73
+ case "==": return left == right;
74
+ case "!=": return left != right;
75
+ case ">=": return left >= right;
76
+ case "<=": return left <= right;
77
+ case ">": return left > right;
78
+ case "<": return left < right;
79
+ }
80
+ }
81
+
82
+ // Handle negation
83
+ if (trimmed.startsWith("!")) {
84
+ return !_cspSafeEval(trimmed.slice(1).trim(), keys, vals);
85
+ }
86
+
87
+ return parseValue(trimmed);
88
+ }
89
+
90
+ // Parse pipe syntax: "expr | filter1 | filter2:arg"
91
+ function _parsePipes(exprStr) {
92
+ // Don't split on || (logical OR)
93
+ const parts = [];
94
+ let current = "";
95
+ let depth = 0;
96
+ let inStr = false;
97
+ let strChar = "";
98
+ for (let i = 0; i < exprStr.length; i++) {
99
+ const ch = exprStr[i];
100
+ if (inStr) {
101
+ current += ch;
102
+ if (ch === strChar && exprStr[i - 1] !== "\\") inStr = false;
103
+ continue;
104
+ }
105
+ if (ch === "'" || ch === '"' || ch === "`") {
106
+ inStr = true;
107
+ strChar = ch;
108
+ current += ch;
109
+ continue;
110
+ }
111
+ if (ch === "(" || ch === "[" || ch === "{") {
112
+ depth++;
113
+ current += ch;
114
+ continue;
115
+ }
116
+ if (ch === ")" || ch === "]" || ch === "}") {
117
+ depth--;
118
+ current += ch;
119
+ continue;
120
+ }
121
+ if (
122
+ ch === "|" &&
123
+ depth === 0 &&
124
+ exprStr[i + 1] !== "|" &&
125
+ exprStr[i - 1] !== "|"
126
+ ) {
127
+ parts.push(current.trim());
128
+ current = "";
129
+ continue;
130
+ }
131
+ current += ch;
132
+ }
133
+ parts.push(current.trim());
134
+ return parts;
135
+ }
136
+
137
+ function _applyFilter(value, filterStr) {
138
+ const colonIdx = filterStr.indexOf(":");
139
+ let name, argStr;
140
+ if (colonIdx === -1) {
141
+ name = filterStr.trim();
142
+ argStr = null;
143
+ } else {
144
+ name = filterStr.substring(0, colonIdx).trim();
145
+ argStr = filterStr.substring(colonIdx + 1).trim();
146
+ }
147
+ const fn = _filters[name];
148
+ if (!fn) {
149
+ _warn(`Unknown filter: ${name}`);
150
+ return value;
151
+ }
152
+ // Parse args: split by comma but respect quotes
153
+ const args = argStr ? _parseFilterArgs(argStr) : [];
154
+ return fn(value, ...args);
155
+ }
156
+
157
+ function _parseFilterArgs(str) {
158
+ const args = [];
159
+ let current = "";
160
+ let inStr = false;
161
+ let strChar = "";
162
+ for (const ch of str) {
163
+ if (inStr) {
164
+ if (ch === strChar) {
165
+ inStr = false;
166
+ continue;
167
+ }
168
+ current += ch;
169
+ continue;
170
+ }
171
+ if (ch === "'" || ch === '"') {
172
+ inStr = true;
173
+ strChar = ch;
174
+ continue;
175
+ }
176
+ if (ch === ",") {
177
+ args.push(current.trim());
178
+ current = "";
179
+ continue;
180
+ }
181
+ current += ch;
182
+ }
183
+ if (current.trim()) args.push(current.trim());
184
+ // Try to parse numbers
185
+ return args.map((a) => {
186
+ const n = Number(a);
187
+ return isNaN(n) ? a : n;
188
+ });
189
+ }
190
+
191
+ export function evaluate(expr, ctx) {
192
+ if (expr == null || expr === "") return undefined;
193
+ try {
194
+ const pipes = _parsePipes(expr);
195
+ const mainExpr = pipes[0];
196
+ const { keys, vals } = _collectKeys(ctx);
197
+
198
+ // Add special variables
199
+ const specialKeys = [
200
+ "$store",
201
+ "$route",
202
+ "$router",
203
+ "$i18n",
204
+ "$refs",
205
+ "$form",
206
+ ];
207
+ for (const sk of specialKeys) {
208
+ if (!keys.includes(sk)) {
209
+ keys.push(sk);
210
+ vals[sk] = ctx[sk];
211
+ }
212
+ }
213
+
214
+ const keyArr = keys;
215
+ const valArr = keyArr.map((k) => vals[k]);
216
+
217
+ let result;
218
+ if (_config.csp === "strict") {
219
+ result = _cspSafeEval(mainExpr, keyArr, valArr);
220
+ } else {
221
+ let cacheKey = mainExpr + "|" + keyArr.join(",");
222
+ let fn = _exprCache.get(cacheKey);
223
+ if (!fn) {
224
+ fn = new Function(...keyArr, `return (${mainExpr})`);
225
+ _exprCache.set(cacheKey, fn);
226
+ }
227
+ result = fn(...valArr);
228
+ }
229
+
230
+ // Apply filters
231
+ for (let i = 1; i < pipes.length; i++) {
232
+ result = _applyFilter(result, pipes[i]);
233
+ }
234
+
235
+ return result;
236
+ } catch (e) {
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ // Execute a statement (for on:* handlers)
242
+ export function _execStatement(expr, ctx, extraVars = {}) {
243
+ try {
244
+ const { keys, vals } = _collectKeys(ctx);
245
+ // Add special vars
246
+ const specials = {
247
+ $store: _stores,
248
+ $route: _routerInstance?.current,
249
+ $router: _routerInstance,
250
+ $i18n: _i18n,
251
+ $refs: ctx.$refs,
252
+ };
253
+ Object.assign(specials, extraVars);
254
+ for (const [k, v] of Object.entries(specials)) {
255
+ if (!keys.includes(k)) {
256
+ keys.push(k);
257
+ vals[k] = v;
258
+ }
259
+ }
260
+
261
+ const keyArr = [...keys];
262
+ const valArr = keyArr.map((k) => vals[k]);
263
+
264
+ // Build setters to write back state through the full context chain.
265
+ // For each key in any ancestor context, find the owning context at runtime
266
+ // and call $set on it — so mutations inside `each` loops correctly
267
+ // propagate back to parent state (e.g. cart updated from a loop's on:click).
268
+ const chainKeys = new Set();
269
+ let _wCtx = ctx;
270
+ while (_wCtx && _wCtx.__isProxy) {
271
+ for (const k of Object.keys(_wCtx.__raw)) chainKeys.add(k);
272
+ _wCtx = _wCtx.$parent;
273
+ }
274
+ const setters = [...chainKeys]
275
+ .map(
276
+ (k) =>
277
+ `{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){_c.$set('${k}',typeof ${k}!=='undefined'?${k}:_c.__raw['${k}']);break;}_c=_c.$parent;}}`,
278
+ )
279
+ .join("\n");
280
+
281
+ const fn = new Function("__ctx", ...keyArr, `${expr};\n${setters}`);
282
+ fn(ctx, ...valArr);
283
+ } catch (e) {
284
+ _warn("Expression error:", expr, e.message);
285
+ }
286
+ }
287
+
288
+ export function resolve(path, ctx) {
289
+ return path.split(".").reduce((o, k) => o?.[k], ctx);
290
+ }
291
+
292
+ // Interpolate strings like "/users/{user.id}?q={search}"
293
+ export function _interpolate(str, ctx) {
294
+ return str.replace(/\{([^}]+)\}/g, (_, expr) => {
295
+ const val = evaluate(expr.trim(), ctx);
296
+ return val != null ? val : "";
297
+ });
298
+ }
package/src/fetch.js ADDED
@@ -0,0 +1,173 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // FETCH HELPER, URL RESOLUTION & CACHE
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+
5
+ import { _config, _interceptors, _cache } from "./globals.js";
6
+
7
+ export function resolveUrl(url, el) {
8
+ if (
9
+ url.startsWith("http://") ||
10
+ url.startsWith("https://") ||
11
+ url.startsWith("//")
12
+ )
13
+ return url;
14
+ let node = el;
15
+ while (node) {
16
+ const base = node.getAttribute?.("base");
17
+ if (base) return base.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "");
18
+ node = node.parentElement;
19
+ }
20
+ if (_config.baseApiUrl)
21
+ return (
22
+ _config.baseApiUrl.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "")
23
+ );
24
+ return url;
25
+ }
26
+
27
+ export async function _doFetch(
28
+ url,
29
+ method = "GET",
30
+ body = null,
31
+ extraHeaders = {},
32
+ el = null,
33
+ externalSignal = null,
34
+ ) {
35
+ const fullUrl = resolveUrl(url, el);
36
+ let opts = {
37
+ method: method.toUpperCase(),
38
+ headers: { ...(_config.headers || {}), ...extraHeaders },
39
+ credentials: _config.credentials,
40
+ };
41
+
42
+ if (body && method !== "GET") {
43
+ if (typeof body === "string") {
44
+ try {
45
+ JSON.parse(body);
46
+ opts.headers["Content-Type"] = "application/json";
47
+ opts.body = body;
48
+ } catch {
49
+ opts.body = body;
50
+ }
51
+ } else if (body instanceof FormData) {
52
+ opts.body = body;
53
+ } else {
54
+ opts.headers["Content-Type"] = "application/json";
55
+ opts.body = JSON.stringify(body);
56
+ }
57
+ }
58
+
59
+ // CSRF
60
+ if (_config.csrf && method !== "GET") {
61
+ opts.headers[_config.csrf.header || "X-CSRF-Token"] =
62
+ _config.csrf.token || "";
63
+ }
64
+
65
+ // Request interceptors
66
+ for (const fn of _interceptors.request) {
67
+ opts = fn(fullUrl, opts) || opts;
68
+ }
69
+
70
+ // Retry logic
71
+ const maxRetries = _config.retries || 0;
72
+ let lastError;
73
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
74
+ try {
75
+ const controller = new AbortController();
76
+ const timeout = setTimeout(
77
+ () => controller.abort(),
78
+ _config.timeout || 10000,
79
+ );
80
+ // Wire external abort signal (switchMap) to internal controller
81
+ if (externalSignal) {
82
+ if (externalSignal.aborted) {
83
+ clearTimeout(timeout);
84
+ throw new DOMException("Aborted", "AbortError");
85
+ }
86
+ externalSignal.addEventListener("abort", () => controller.abort(), {
87
+ once: true,
88
+ });
89
+ }
90
+ opts.signal = controller.signal;
91
+
92
+ let response = await fetch(fullUrl, opts);
93
+ clearTimeout(timeout);
94
+
95
+ // Response interceptors
96
+ for (const fn of _interceptors.response) {
97
+ response = fn(response, fullUrl) || response;
98
+ }
99
+
100
+ if (!response.ok) {
101
+ const errBody = await response.json().catch(() => ({}));
102
+ const err = new Error(errBody.message || `HTTP ${response.status}`);
103
+ err.status = response.status;
104
+ err.body = errBody;
105
+ throw err;
106
+ }
107
+
108
+ const text = await response.text();
109
+ try {
110
+ return JSON.parse(text);
111
+ } catch {
112
+ return text;
113
+ }
114
+ } catch (e) {
115
+ if (e.name === "AbortError") throw e; // Don't retry aborted requests
116
+ lastError = e;
117
+ if (attempt < maxRetries) {
118
+ await new Promise((r) => setTimeout(r, _config.retryDelay || 1000));
119
+ }
120
+ }
121
+ }
122
+ throw lastError;
123
+ }
124
+
125
+ export function _cacheGet(key, strategy) {
126
+ if (strategy === "none") return null;
127
+ if (strategy === "memory") {
128
+ const entry = _cache.get(key);
129
+ if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000))
130
+ return entry.data;
131
+ return null;
132
+ }
133
+ const store =
134
+ strategy === "local"
135
+ ? localStorage
136
+ : strategy === "session"
137
+ ? sessionStorage
138
+ : null;
139
+ if (!store) return null;
140
+ try {
141
+ const raw = store.getItem("nojs_cache_" + key);
142
+ if (!raw) return null;
143
+ const entry = JSON.parse(raw);
144
+ if (Date.now() - entry.time < (_config.cache.ttl || 300000))
145
+ return entry.data;
146
+ store.removeItem("nojs_cache_" + key);
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+ return null;
151
+ }
152
+
153
+ export function _cacheSet(key, data, strategy) {
154
+ if (strategy === "none") return;
155
+ const entry = { data, time: Date.now() };
156
+ if (strategy === "memory") {
157
+ _cache.set(key, entry);
158
+ return;
159
+ }
160
+ const store =
161
+ strategy === "local"
162
+ ? localStorage
163
+ : strategy === "session"
164
+ ? sessionStorage
165
+ : null;
166
+ if (store) {
167
+ try {
168
+ store.setItem("nojs_cache_" + key, JSON.stringify(entry));
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ }
173
+ }