@proveanything/smartlinks 1.3.46 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -13,7 +13,8 @@ var __rest = (this && this.__rest) || function (s, e) {
13
13
  }
14
14
  return t;
15
15
  };
16
- import { SmartlinksApiError } from "./types/error";
16
+ import { SmartlinksApiError, SmartlinksOfflineError } from "./types/error";
17
+ import { idbGet, idbSet, idbClear } from './persistentCache';
17
18
  let baseURL = null;
18
19
  let apiKey = undefined;
19
20
  let bearerToken = undefined;
@@ -22,6 +23,125 @@ let ngrokSkipBrowserWarning = false;
22
23
  let extraHeadersGlobal = {};
23
24
  /** Whether initializeApi has been successfully called at least once. */
24
25
  let initialized = false;
26
+ const httpCache = new Map();
27
+ let cacheEnabled = true;
28
+ /** Default TTL used when no per-resource rule matches (milliseconds). */
29
+ let cacheDefaultTtlMs = 60000; // 60 seconds
30
+ /** Maximum number of entries before the oldest (LRU) entry is evicted. */
31
+ let cacheMaxEntries = 200;
32
+ /** Persistence backend for the L2 cache. 'none' (default) disables IndexedDB persistence. */
33
+ let cachePersistence = 'none';
34
+ /**
35
+ * How long L2 (IndexedDB) entries are considered valid as an offline stale fallback,
36
+ * measured from the original network fetch time (default: 7 days).
37
+ * This is independent of the in-memory TTL — it only governs whether a stale
38
+ * L2 entry is served when the network is unavailable.
39
+ */
40
+ let cachePersistenceTtlMs = 7 * 24 * 60 * 60000;
41
+ /** When true (default), serve stale L2 data via SmartlinksOfflineError on network failure. */
42
+ let cacheServeStaleOnOffline = true;
43
+ /**
44
+ * Per-resource TTL overrides — checked in order, first match wins.
45
+ *
46
+ * Rules are intentionally specific: they match the collection/product/variant
47
+ * *resource itself* (list or detail) but NOT sub-resources nested beneath them
48
+ * (assets, forms, jobs, app-data, etc.). The regex requires that nothing except
49
+ * an optional query-string follows the matched segment, e.g.:
50
+ * ✅ /public/collection/abc123
51
+ * ✅ /public/collection (list)
52
+ * ❌ /public/collection/abc123/asset/xyz → falls to default TTL
53
+ * ❌ /public/collection/abc123/form/xyz → falls to default TTL
54
+ *
55
+ * More-specific / shorter TTLs are listed first so they cannot be shadowed.
56
+ */
57
+ const CACHE_TTL_RULES = [
58
+ // Sub-resources that change frequently — short TTLs, listed first
59
+ { pattern: /\/proof\/[^/]*(\?.*)?$/i, ttlMs: 30000 },
60
+ { pattern: /\/attestation\/[^/]*(\?.*)?$/i, ttlMs: 2 * 60000 },
61
+ // Slow-changing top-level resources — long TTLs, matched only when path ends at the ID
62
+ { pattern: /\/product\/[^/]*(\?.*)?$/i, ttlMs: 60 * 60000 },
63
+ { pattern: /\/variant\/[^/]*(\?.*)?$/i, ttlMs: 60 * 60000 },
64
+ { pattern: /\/collection\/[^/]*(\?.*)?$/i, ttlMs: 60 * 60000 }, // 1 hour
65
+ ];
66
+ function getTtlForPath(path) {
67
+ for (const rule of CACHE_TTL_RULES) {
68
+ if (rule.pattern.test(path))
69
+ return rule.ttlMs;
70
+ }
71
+ return cacheDefaultTtlMs;
72
+ }
73
+ /** Returns true when this path must always bypass the cache. */
74
+ function shouldSkipCache(path) {
75
+ if (!cacheEnabled)
76
+ return true;
77
+ // Never cache auth endpoints — they deal with tokens and session state.
78
+ if (/\/auth\//i.test(path))
79
+ return true;
80
+ return false;
81
+ }
82
+ /** Evict the oldest (LRU) entry when the cache is at capacity. */
83
+ function evictLruIfNeeded() {
84
+ while (httpCache.size >= cacheMaxEntries) {
85
+ const firstKey = httpCache.keys().next().value;
86
+ if (firstKey !== undefined)
87
+ httpCache.delete(firstKey);
88
+ else
89
+ break;
90
+ }
91
+ }
92
+ /**
93
+ * Return cached data for a key if it exists and is within TTL.
94
+ * Promotes the hit to MRU position. Returns null when missing, expired, or in-flight.
95
+ */
96
+ function getHttpCacheHit(cacheKey, ttlMs) {
97
+ const entry = httpCache.get(cacheKey);
98
+ if (!entry || entry.promise)
99
+ return null;
100
+ if (Date.now() - entry.timestamp > ttlMs) {
101
+ httpCache.delete(cacheKey);
102
+ return null;
103
+ }
104
+ // Promote to MRU (delete + re-insert at tail of Map)
105
+ httpCache.delete(cacheKey);
106
+ httpCache.set(cacheKey, entry);
107
+ return entry.data;
108
+ }
109
+ /** Store a resolved response in the cache at MRU position. */
110
+ function setHttpCacheEntry(cacheKey, data) {
111
+ httpCache.delete(cacheKey); // ensure insertion at MRU tail
112
+ evictLruIfNeeded();
113
+ httpCache.set(cacheKey, { data, timestamp: Date.now() });
114
+ }
115
+ /**
116
+ * Auto-invalidate all cached GET entries whose key contains `path`.
117
+ * Called automatically after any mutating request (POST / PUT / PATCH / DELETE).
118
+ * Also sweeps the L2 (IndexedDB) cache when persistence is enabled.
119
+ */
120
+ function invalidateCacheForPath(path) {
121
+ for (const key of httpCache.keys()) {
122
+ if (key.includes(path))
123
+ httpCache.delete(key);
124
+ }
125
+ if (cachePersistence !== 'none')
126
+ idbClear(path).catch(() => { });
127
+ }
128
+ /** Build the lookup key for a given request path. */
129
+ function buildCacheKey(path) {
130
+ return proxyMode ? `proxy:${path}` : `${baseURL}${path}`;
131
+ }
132
+ /**
133
+ * Returns true when an error indicates a network-level failure (no connectivity,
134
+ * DNS failure, etc.) rather than an HTTP-level error response.
135
+ * Also returns true when navigator.onLine is explicitly false.
136
+ * Node-safe: navigator is guarded before access.
137
+ */
138
+ function isNetworkError(err) {
139
+ // navigator is not available in Node.js — guard before access
140
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
141
+ return true;
142
+ // fetch() throws TypeError on network failure; SmartlinksApiError is not a TypeError
143
+ return err instanceof TypeError;
144
+ }
25
145
  let logger;
26
146
  function logDebug(...args) {
27
147
  if (!logger)
@@ -209,6 +329,12 @@ export function initializeApi(options) {
209
329
  if (iframe.isIframe() && options.iframeAutoResize !== false) {
210
330
  iframe.enableAutoIframeResize();
211
331
  }
332
+ // Clear both cache tiers on forced re-initialization so stale data
333
+ // from the previous configuration cannot bleed through.
334
+ if (options.force) {
335
+ httpCache.clear();
336
+ idbClear().catch(() => { });
337
+ }
212
338
  logger = options.logger;
213
339
  initialized = true;
214
340
  logDebug('[smartlinks] initializeApi', {
@@ -256,6 +382,92 @@ export function getBaseURL() {
256
382
  export function isInitialized() {
257
383
  return initialized;
258
384
  }
385
+ /**
386
+ * Returns true if the SDK currently has any auth credential set (bearer token
387
+ * or API key). Use this as a cheap pre-flight check before calling endpoints
388
+ * that require authentication, to avoid issuing a network request that you
389
+ * already know will return a 401.
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * if (hasAuthCredentials()) {
394
+ * const account = await auth.getAccount()
395
+ * }
396
+ * ```
397
+ */
398
+ export function hasAuthCredentials() {
399
+ return !!(bearerToken || apiKey);
400
+ }
401
+ /**
402
+ * Configure the SDK's built-in in-memory GET cache.
403
+ *
404
+ * The cache is transparent — it sits inside the HTTP layer and requires no
405
+ * changes to your existing API calls. All GET requests benefit automatically.
406
+ *
407
+ * @param options.enabled - Turn caching on/off entirely (default: `true`)
408
+ * @param options.ttlMs - Default time-to-live in milliseconds (default: `60_000`).
409
+ * Per-resource rules (collections/products → 1 h,
410
+ * proofs → 30 s, etc.) override this value.
411
+ * @param options.maxEntries - L1 LRU eviction threshold (default: `200`)
412
+ * @param options.persistence - Enable IndexedDB L2 cache (`'indexeddb'`) or keep
413
+ * in-memory only (`'none'`, default). Ignored in Node.js.
414
+ * @param options.persistenceTtlMs - How long L2 entries are eligible as an offline stale
415
+ * fallback, from the original fetch time (default: 7 days).
416
+ * @param options.serveStaleOnOffline - When `true` (default) and persistence is on, throw
417
+ * `SmartlinksOfflineError` with stale data instead of
418
+ * propagating the network error.
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * // Enable IndexedDB persistence for offline support
423
+ * configureSdkCache({ persistence: 'indexeddb' })
424
+ *
425
+ * // Disable cache entirely in test environments
426
+ * configureSdkCache({ enabled: false })
427
+ * ```
428
+ */
429
+ export function configureSdkCache(options) {
430
+ if (options.enabled !== undefined)
431
+ cacheEnabled = options.enabled;
432
+ if (options.ttlMs !== undefined)
433
+ cacheDefaultTtlMs = options.ttlMs;
434
+ if (options.maxEntries !== undefined)
435
+ cacheMaxEntries = options.maxEntries;
436
+ if (options.persistence !== undefined)
437
+ cachePersistence = options.persistence;
438
+ if (options.persistenceTtlMs !== undefined)
439
+ cachePersistenceTtlMs = options.persistenceTtlMs;
440
+ if (options.serveStaleOnOffline !== undefined)
441
+ cacheServeStaleOnOffline = options.serveStaleOnOffline;
442
+ }
443
+ /**
444
+ * Manually invalidate entries in the SDK's GET cache.
445
+ *
446
+ * @param urlPattern - Optional substring match. Every cache entry whose key
447
+ * *contains* this string is removed. Omit (or pass `undefined`) to wipe the
448
+ * entire cache.
449
+ *
450
+ * @example
451
+ * ```ts
452
+ * invalidateCache() // clear everything
453
+ * invalidateCache('/collection/abc123') // one specific collection
454
+ * invalidateCache('/product/') // all product responses
455
+ * ```
456
+ */
457
+ export function invalidateCache(urlPattern) {
458
+ if (!urlPattern) {
459
+ httpCache.clear();
460
+ if (cachePersistence !== 'none')
461
+ idbClear().catch(() => { });
462
+ return;
463
+ }
464
+ for (const key of httpCache.keys()) {
465
+ if (key.includes(urlPattern))
466
+ httpCache.delete(key);
467
+ }
468
+ if (cachePersistence !== 'none')
469
+ idbClear(urlPattern).catch(() => { });
470
+ }
259
471
  // Map of pending proxy requests: id -> {resolve, reject}
260
472
  const proxyPending = {};
261
473
  function generateProxyId() {
@@ -449,49 +661,114 @@ export async function proxyUploadFormData(path, formData, onProgress) {
449
661
  return done;
450
662
  }
451
663
  /**
452
- * Internal helper that performs a GET request to \`\${baseURL}\${path}\`,
664
+ * Internal helper that performs a GET request to `${baseURL}${path}`,
453
665
  * injecting headers for apiKey or bearerToken if present.
454
- * Returns the parsed JSON as T, or throws an Error.
666
+ *
667
+ * Cache pipeline (when caching is not skipped):
668
+ * L1 hit → return from memory (no I/O)
669
+ * L2 hit → return from IndexedDB, promote to L1 (no network)
670
+ * Miss → fetch from network, store in L1 + L2
671
+ * Offline → serve stale L2 entry via SmartlinksOfflineError (if persistence enabled)
672
+ *
673
+ * Concurrent identical GETs share one in-flight promise (deduplication).
674
+ * Node-safe: IndexedDB calls are no-ops when IDB is unavailable.
455
675
  */
456
676
  export async function request(path) {
457
- if (proxyMode) {
458
- logDebug('[smartlinks] GET via proxy', { path });
459
- return proxyRequest("GET", path);
460
- }
461
- if (!baseURL) {
462
- throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
677
+ const skipCache = shouldSkipCache(path);
678
+ const cacheKey = buildCacheKey(path);
679
+ const ttl = skipCache ? 0 : getTtlForPath(path);
680
+ if (!skipCache) {
681
+ // 1. L1 hit — return from memory immediately
682
+ const l1 = getHttpCacheHit(cacheKey, ttl);
683
+ if (l1 !== null) {
684
+ logDebug('[smartlinks] GET cache hit (L1)', { path });
685
+ return l1;
686
+ }
687
+ // 2. In-flight deduplication — share an already-pending promise
688
+ const inflight = httpCache.get(cacheKey);
689
+ if (inflight === null || inflight === void 0 ? void 0 : inflight.promise) {
690
+ logDebug('[smartlinks] GET in-flight dedup', { path });
691
+ return inflight.promise;
692
+ }
463
693
  }
464
- const url = `${baseURL}${path}`;
465
- const headers = { "Content-Type": "application/json" };
466
- if (apiKey)
467
- headers["X-API-Key"] = apiKey;
468
- if (bearerToken)
469
- headers["AUTHORIZATION"] = `Bearer ${bearerToken}`;
470
- if (ngrokSkipBrowserWarning)
471
- headers["ngrok-skip-browser-warning"] = "true";
472
- for (const [k, v] of Object.entries(extraHeadersGlobal))
473
- headers[k] = v;
474
- logDebug('[smartlinks] GET fetch', { url, headers: redactHeaders(headers) });
475
- const response = await fetch(url, {
476
- method: "GET",
477
- headers,
478
- });
479
- logDebug('[smartlinks] GET response', { url, status: response.status, ok: response.ok });
480
- if (!response.ok) {
481
- // Try to parse error response body and normalize it
482
- let responseBody;
694
+ // 3. Build the fetch promise.
695
+ // The IIFE starts synchronously until its first `await`, then the outer
696
+ // code registers it as the in-flight entry before the await resolves.
697
+ const fetchPromise = (async () => {
698
+ // 3a. L2 (IndexedDB) check — warms L1 from persistent storage without a
699
+ // network round-trip. First await → in-flight registration runs before this resolves.
700
+ if (!skipCache && cachePersistence !== 'none') {
701
+ const l2 = await idbGet(cacheKey);
702
+ if (l2 && Date.now() - l2.timestamp <= ttl) {
703
+ logDebug('[smartlinks] GET cache hit (L2)', { path });
704
+ setHttpCacheEntry(cacheKey, l2.data);
705
+ return l2.data;
706
+ }
707
+ }
708
+ // 3b. Network fetch
483
709
  try {
484
- responseBody = await response.json();
710
+ let data;
711
+ if (proxyMode) {
712
+ logDebug('[smartlinks] GET via proxy', { path });
713
+ data = await proxyRequest("GET", path);
714
+ }
715
+ else {
716
+ if (!baseURL) {
717
+ throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
718
+ }
719
+ const url = `${baseURL}${path}`;
720
+ const headers = { "Content-Type": "application/json" };
721
+ if (apiKey)
722
+ headers["X-API-Key"] = apiKey;
723
+ if (bearerToken)
724
+ headers["AUTHORIZATION"] = `Bearer ${bearerToken}`;
725
+ if (ngrokSkipBrowserWarning)
726
+ headers["ngrok-skip-browser-warning"] = "true";
727
+ for (const [k, v] of Object.entries(extraHeadersGlobal))
728
+ headers[k] = v;
729
+ logDebug('[smartlinks] GET fetch', { url, headers: redactHeaders(headers) });
730
+ const response = await fetch(url, { method: "GET", headers });
731
+ logDebug('[smartlinks] GET response', { url, status: response.status, ok: response.ok });
732
+ if (!response.ok) {
733
+ let responseBody;
734
+ try {
735
+ responseBody = await response.json();
736
+ }
737
+ catch (_a) {
738
+ responseBody = null;
739
+ }
740
+ const errBody = normalizeErrorResponse(responseBody, response.status);
741
+ throw new SmartlinksApiError(`Error ${errBody.code}: ${errBody.message}`, response.status, errBody, url);
742
+ }
743
+ data = (await response.json());
744
+ }
745
+ // Persist to L2 on success (fire-and-forget — never blocks the caller)
746
+ if (!skipCache && cachePersistence !== 'none') {
747
+ idbSet(cacheKey, { data, timestamp: Date.now(), persistedAt: Date.now() }).catch(() => { });
748
+ }
749
+ return data;
485
750
  }
486
- catch (_a) {
487
- // Failed to parse JSON, use status code only
488
- responseBody = null;
751
+ catch (fetchErr) {
752
+ // 3c. Offline fallback: when the network fails, serve stale L2 data if
753
+ // it's still within the persistence TTL window.
754
+ if (!skipCache && cachePersistence !== 'none' && cacheServeStaleOnOffline && isNetworkError(fetchErr)) {
755
+ const l2 = await idbGet(cacheKey);
756
+ if (l2 && Date.now() - l2.timestamp <= cachePersistenceTtlMs) {
757
+ logDebug('[smartlinks] GET offline fallback (L2)', { path, cachedAt: l2.timestamp });
758
+ throw new SmartlinksOfflineError('Network unavailable — serving cached data from persistent storage.', l2.data, l2.timestamp);
759
+ }
760
+ }
761
+ throw fetchErr;
489
762
  }
490
- const errBody = normalizeErrorResponse(responseBody, response.status);
491
- const message = `Error ${errBody.code}: ${errBody.message}`;
492
- throw new SmartlinksApiError(message, response.status, errBody, url);
763
+ })();
764
+ // Register the in-flight promise so concurrent identical GETs share it.
765
+ // On resolve promote to L1; on reject → remove so the next call retries.
766
+ if (!skipCache) {
767
+ evictLruIfNeeded();
768
+ httpCache.set(cacheKey, { data: null, timestamp: Date.now(), promise: fetchPromise });
769
+ fetchPromise.then((data) => setHttpCacheEntry(cacheKey, data), () => httpCache.delete(cacheKey));
493
770
  }
494
- return (await response.json());
771
+ return fetchPromise;
495
772
  }
496
773
  /**
497
774
  * Internal helper that performs a POST request to `${baseURL}${path}`,
@@ -502,7 +779,9 @@ export async function request(path) {
502
779
  export async function post(path, body, extraHeaders) {
503
780
  if (proxyMode) {
504
781
  logDebug('[smartlinks] POST via proxy', { path, body: safeBodyPreview(body) });
505
- return proxyRequest("POST", path, body, extraHeaders);
782
+ const result = await proxyRequest("POST", path, body, extraHeaders);
783
+ invalidateCacheForPath(path);
784
+ return result;
506
785
  }
507
786
  if (!baseURL) {
508
787
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -541,7 +820,9 @@ export async function post(path, body, extraHeaders) {
541
820
  const message = `Error ${errBody.code}: ${errBody.message}`;
542
821
  throw new SmartlinksApiError(message, response.status, errBody, url);
543
822
  }
544
- return (await response.json());
823
+ const postResult = (await response.json());
824
+ invalidateCacheForPath(path);
825
+ return postResult;
545
826
  }
546
827
  /**
547
828
  * Internal helper that performs a PUT request to `${baseURL}${path}`,
@@ -552,7 +833,9 @@ export async function post(path, body, extraHeaders) {
552
833
  export async function put(path, body, extraHeaders) {
553
834
  if (proxyMode) {
554
835
  logDebug('[smartlinks] PUT via proxy', { path, body: safeBodyPreview(body) });
555
- return proxyRequest("PUT", path, body, extraHeaders);
836
+ const result = await proxyRequest("PUT", path, body, extraHeaders);
837
+ invalidateCacheForPath(path);
838
+ return result;
556
839
  }
557
840
  if (!baseURL) {
558
841
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -591,7 +874,9 @@ export async function put(path, body, extraHeaders) {
591
874
  const message = `Error ${errBody.code}: ${errBody.message}`;
592
875
  throw new SmartlinksApiError(message, response.status, errBody, url);
593
876
  }
594
- return (await response.json());
877
+ const putResult = (await response.json());
878
+ invalidateCacheForPath(path);
879
+ return putResult;
595
880
  }
596
881
  /**
597
882
  * Internal helper that performs a PATCH request to `${baseURL}${path}`,
@@ -602,7 +887,9 @@ export async function put(path, body, extraHeaders) {
602
887
  export async function patch(path, body, extraHeaders) {
603
888
  if (proxyMode) {
604
889
  logDebug('[smartlinks] PATCH via proxy', { path, body: safeBodyPreview(body) });
605
- return proxyRequest("PATCH", path, body, extraHeaders);
890
+ const result = await proxyRequest("PATCH", path, body, extraHeaders);
891
+ invalidateCacheForPath(path);
892
+ return result;
606
893
  }
607
894
  if (!baseURL) {
608
895
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -641,7 +928,9 @@ export async function patch(path, body, extraHeaders) {
641
928
  const message = `Error ${errBody.code}: ${errBody.message}`;
642
929
  throw new SmartlinksApiError(message, response.status, errBody, url);
643
930
  }
644
- return (await response.json());
931
+ const patchResult = (await response.json());
932
+ invalidateCacheForPath(path);
933
+ return patchResult;
645
934
  }
646
935
  /**
647
936
  * Internal helper that performs a request to `${baseURL}${path}` with custom options,
@@ -649,52 +938,115 @@ export async function patch(path, body, extraHeaders) {
649
938
  * Returns the parsed JSON as T, or throws an Error.
650
939
  */
651
940
  export async function requestWithOptions(path, options) {
652
- if (proxyMode) {
653
- logDebug('[smartlinks] requestWithOptions via proxy', { path, method: options.method || 'GET' });
654
- return proxyRequest(options.method || "GET", path, options.body, options.headers, options);
655
- }
656
- if (!baseURL) {
657
- throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
658
- }
659
- const url = `${baseURL}${path}`;
660
- // Safely merge headers, converting Headers/init to Record<string, string>
661
- let extraHeaders = {};
662
- if (options.headers) {
663
- if (options.headers instanceof Headers) {
664
- options.headers.forEach((value, key) => {
665
- extraHeaders[key] = value;
666
- });
667
- }
668
- else if (Array.isArray(options.headers)) {
669
- for (const [key, value] of options.headers) {
670
- extraHeaders[key] = value;
671
- }
941
+ const method = (options.method || 'GET').toUpperCase();
942
+ const isGet = method === 'GET';
943
+ const skipCache = isGet ? shouldSkipCache(path) : true;
944
+ const cacheKey = buildCacheKey(path);
945
+ const ttl = !skipCache ? getTtlForPath(path) : 0;
946
+ if (!skipCache) {
947
+ // L1 hit
948
+ const cached = getHttpCacheHit(cacheKey, ttl);
949
+ if (cached !== null) {
950
+ logDebug('[smartlinks] GET cache hit (requestWithOptions/L1)', { path });
951
+ return cached;
672
952
  }
673
- else {
674
- extraHeaders = Object.assign({}, options.headers);
953
+ // In-flight dedup
954
+ const inflight = httpCache.get(cacheKey);
955
+ if (inflight === null || inflight === void 0 ? void 0 : inflight.promise) {
956
+ logDebug('[smartlinks] GET in-flight dedup (requestWithOptions)', { path });
957
+ return inflight.promise;
675
958
  }
676
959
  }
677
- const headers = Object.assign(Object.assign(Object.assign(Object.assign({ "Content-Type": "application/json" }, (apiKey ? { "X-API-Key": apiKey } : {})), (bearerToken ? { "AUTHORIZATION": `Bearer ${bearerToken}` } : {})), (ngrokSkipBrowserWarning ? { "ngrok-skip-browser-warning": "true" } : {})), extraHeaders);
678
- // Merge global custom headers (do not override existing keys from options.headers)
679
- for (const [k, v] of Object.entries(extraHeadersGlobal))
680
- if (!(k in headers))
681
- headers[k] = v;
682
- logDebug('[smartlinks] requestWithOptions fetch', { url, method: options.method || 'GET', headers: redactHeaders(headers), body: safeBodyPreview(options.body) });
683
- const response = await fetch(url, Object.assign(Object.assign({}, options), { headers }));
684
- logDebug('[smartlinks] requestWithOptions response', { url, status: response.status, ok: response.ok });
685
- if (!response.ok) {
686
- let responseBody;
960
+ const fetchPromise = (async () => {
961
+ // L2 (IndexedDB) check for GETs first await, so in-flight registration runs before it resolves
962
+ if (!skipCache && cachePersistence !== 'none') {
963
+ const l2 = await idbGet(cacheKey);
964
+ if (l2 && Date.now() - l2.timestamp <= ttl) {
965
+ logDebug('[smartlinks] GET cache hit (requestWithOptions/L2)', { path });
966
+ setHttpCacheEntry(cacheKey, l2.data);
967
+ return l2.data;
968
+ }
969
+ }
687
970
  try {
688
- responseBody = await response.json();
971
+ if (proxyMode) {
972
+ logDebug('[smartlinks] requestWithOptions via proxy', { path, method: options.method || 'GET' });
973
+ const result = await proxyRequest(options.method || "GET", path, options.body, options.headers, options);
974
+ if (!isGet) {
975
+ invalidateCacheForPath(path);
976
+ }
977
+ else if (!skipCache && cachePersistence !== 'none') {
978
+ idbSet(cacheKey, { data: result, timestamp: Date.now(), persistedAt: Date.now() }).catch(() => { });
979
+ }
980
+ return result;
981
+ }
982
+ if (!baseURL) {
983
+ throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
984
+ }
985
+ const url = `${baseURL}${path}`;
986
+ // Safely merge headers, converting Headers/init to Record<string, string>
987
+ let extraHeaders = {};
988
+ if (options.headers) {
989
+ if (options.headers instanceof Headers) {
990
+ options.headers.forEach((value, key) => {
991
+ extraHeaders[key] = value;
992
+ });
993
+ }
994
+ else if (Array.isArray(options.headers)) {
995
+ for (const [key, value] of options.headers) {
996
+ extraHeaders[key] = value;
997
+ }
998
+ }
999
+ else {
1000
+ extraHeaders = Object.assign({}, options.headers);
1001
+ }
1002
+ }
1003
+ const headers = Object.assign(Object.assign(Object.assign(Object.assign({ "Content-Type": "application/json" }, (apiKey ? { "X-API-Key": apiKey } : {})), (bearerToken ? { "AUTHORIZATION": `Bearer ${bearerToken}` } : {})), (ngrokSkipBrowserWarning ? { "ngrok-skip-browser-warning": "true" } : {})), extraHeaders);
1004
+ // Merge global custom headers (do not override existing keys from options.headers)
1005
+ for (const [k, v] of Object.entries(extraHeadersGlobal))
1006
+ if (!(k in headers))
1007
+ headers[k] = v;
1008
+ logDebug('[smartlinks] requestWithOptions fetch', { url, method: options.method || 'GET', headers: redactHeaders(headers), body: safeBodyPreview(options.body) });
1009
+ const response = await fetch(url, Object.assign(Object.assign({}, options), { headers }));
1010
+ logDebug('[smartlinks] requestWithOptions response', { url, status: response.status, ok: response.ok });
1011
+ if (!response.ok) {
1012
+ let responseBody;
1013
+ try {
1014
+ responseBody = await response.json();
1015
+ }
1016
+ catch (_a) {
1017
+ responseBody = null;
1018
+ }
1019
+ const errBody = normalizeErrorResponse(responseBody, response.status);
1020
+ throw new SmartlinksApiError(`Error ${errBody.code}: ${errBody.message}`, response.status, errBody, url);
1021
+ }
1022
+ const rwoResult = (await response.json());
1023
+ if (!isGet) {
1024
+ invalidateCacheForPath(path);
1025
+ }
1026
+ else if (!skipCache && cachePersistence !== 'none') {
1027
+ idbSet(cacheKey, { data: rwoResult, timestamp: Date.now(), persistedAt: Date.now() }).catch(() => { });
1028
+ }
1029
+ return rwoResult;
689
1030
  }
690
- catch (_a) {
691
- responseBody = null;
1031
+ catch (fetchErr) {
1032
+ // Offline fallback for GETs
1033
+ if (isGet && !skipCache && cachePersistence !== 'none' && cacheServeStaleOnOffline && isNetworkError(fetchErr)) {
1034
+ const l2 = await idbGet(cacheKey);
1035
+ if (l2 && Date.now() - l2.timestamp <= cachePersistenceTtlMs) {
1036
+ logDebug('[smartlinks] GET offline fallback (requestWithOptions/L2)', { path, cachedAt: l2.timestamp });
1037
+ throw new SmartlinksOfflineError('Network unavailable — serving cached data from persistent storage.', l2.data, l2.timestamp);
1038
+ }
1039
+ }
1040
+ throw fetchErr;
692
1041
  }
693
- const errBody = normalizeErrorResponse(responseBody, response.status);
694
- const message = `Error ${errBody.code}: ${errBody.message}`;
695
- throw new SmartlinksApiError(message, response.status, errBody, url);
1042
+ })();
1043
+ // Register in-flight for GET requests
1044
+ if (!skipCache) {
1045
+ evictLruIfNeeded();
1046
+ httpCache.set(cacheKey, { data: null, timestamp: Date.now(), promise: fetchPromise });
1047
+ fetchPromise.then((data) => setHttpCacheEntry(cacheKey, data), () => httpCache.delete(cacheKey));
696
1048
  }
697
- return (await response.json());
1049
+ return fetchPromise;
698
1050
  }
699
1051
  /**
700
1052
  * Internal helper that performs a DELETE request to `${baseURL}${path}`,
@@ -704,7 +1056,9 @@ export async function requestWithOptions(path, options) {
704
1056
  export async function del(path, extraHeaders) {
705
1057
  if (proxyMode) {
706
1058
  logDebug('[smartlinks] DELETE via proxy', { path });
707
- return proxyRequest("DELETE", path, undefined, extraHeaders);
1059
+ const result = await proxyRequest("DELETE", path, undefined, extraHeaders);
1060
+ invalidateCacheForPath(path);
1061
+ return result;
708
1062
  }
709
1063
  if (!baseURL) {
710
1064
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -739,9 +1093,9 @@ export async function del(path, extraHeaders) {
739
1093
  throw new SmartlinksApiError(message, response.status, errBody, url);
740
1094
  }
741
1095
  // If the response is empty, just return undefined
742
- if (response.status === 204)
743
- return undefined;
744
- return (await response.json());
1096
+ const delResult = response.status === 204 ? undefined : (await response.json());
1097
+ invalidateCacheForPath(path);
1098
+ return delResult;
745
1099
  }
746
1100
  /**
747
1101
  * Returns the common headers used for API requests, including apiKey and bearerToken if set.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { initializeApi, isInitialized, request, sendCustomProxyMessage } from "./http";
1
+ export { initializeApi, isInitialized, hasAuthCredentials, configureSdkCache, invalidateCache, request, sendCustomProxyMessage } from "./http";
2
2
  export * from "./api";
3
3
  export * from "./types";
4
4
  export { iframe } from "./iframe";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  // Top-level entrypoint of the npm package. Re-export initializeApi + all namespaces.
3
- export { initializeApi, isInitialized, request, sendCustomProxyMessage } from "./http";
3
+ export { initializeApi, isInitialized, hasAuthCredentials, configureSdkCache, invalidateCache, request, sendCustomProxyMessage } from "./http";
4
4
  export * from "./api";
5
5
  export * from "./types";
6
6
  // Iframe namespace