@proveanything/smartlinks 1.3.46 → 1.4.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.
@@ -92,6 +92,12 @@ export declare namespace auth {
92
92
  /**
93
93
  * Gets current account information for the logged in user.
94
94
  * Returns user, owner, account, and location objects.
95
+ *
96
+ * Short-circuits immediately (no network request) when the SDK has no
97
+ * bearer token or API key set — the server would return 401 anyway.
98
+ * Throws a `SmartlinksApiError` with `statusCode 401` and
99
+ * `details.local = true` so callers can distinguish "never authenticated"
100
+ * from an actual server-side token rejection.
95
101
  */
96
102
  function getAccount(): Promise<AccountInfoResponse>;
97
103
  }
package/dist/api/auth.js CHANGED
@@ -1,4 +1,5 @@
1
- import { post, request, setBearerToken, getApiHeaders } from "../http";
1
+ import { post, request, setBearerToken, getApiHeaders, hasAuthCredentials } from "../http";
2
+ import { SmartlinksApiError } from "../types/error";
2
3
  /*
3
4
  user: Record<string, any>
4
5
  owner: Record<string, any>
@@ -84,8 +85,17 @@ export var auth;
84
85
  /**
85
86
  * Gets current account information for the logged in user.
86
87
  * Returns user, owner, account, and location objects.
88
+ *
89
+ * Short-circuits immediately (no network request) when the SDK has no
90
+ * bearer token or API key set — the server would return 401 anyway.
91
+ * Throws a `SmartlinksApiError` with `statusCode 401` and
92
+ * `details.local = true` so callers can distinguish "never authenticated"
93
+ * from an actual server-side token rejection.
87
94
  */
88
95
  async function getAccount() {
96
+ if (!hasAuthCredentials()) {
97
+ throw new SmartlinksApiError('Not authenticated: no bearer token or API key is set.', 401, { code: 401, errorCode: 'NOT_AUTHENTICATED', message: 'Not authenticated: no bearer token or API key is set.', details: { local: true } });
98
+ }
89
99
  return request("/public/auth/account");
90
100
  }
91
101
  auth.getAccount = getAccount;
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.3.46 | Generated: 2026-02-19T21:09:09.402Z
3
+ Version: 1.4.0 | Generated: 2026-02-20T14:52:16.722Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -108,13 +108,29 @@ Get the currently configured API base URL. Returns null if initializeApi() has n
108
108
  **isInitialized**() → `boolean`
109
109
  Returns true if initializeApi() has been called at least once. Useful for guards in widgets or shared modules that want to skip initialization when another module has already done it. ```ts if (!isInitialized()) { initializeApi({ baseURL: 'https://smartlinks.app/api/v1' }) } ```
110
110
 
111
+ **hasAuthCredentials**() → `boolean`
112
+ Returns true if the SDK currently has any auth credential set (bearer token or API key). Use this as a cheap pre-flight check before calling endpoints that require authentication, to avoid issuing a network request that you already know will return a 401. ```ts if (hasAuthCredentials()) { const account = await auth.getAccount() } ```
113
+
114
+ **configureSdkCache**(options: {
115
+ enabled?: boolean
116
+ ttlMs?: number
117
+ maxEntries?: number
118
+ persistence?: 'none' | 'indexeddb'
119
+ persistenceTtlMs?: number
120
+ serveStaleOnOffline?: boolean
121
+ }) → `void`
122
+ Configure the SDK's built-in in-memory GET cache. The cache is transparent — it sits inside the HTTP layer and requires no changes to your existing API calls. All GET requests benefit automatically. Per-resource rules (collections/products → 1 h, proofs → 30 s, etc.) override this value. in-memory only (`'none'`, default). Ignored in Node.js. fallback, from the original fetch time (default: 7 days). `SmartlinksOfflineError` with stale data instead of propagating the network error. ```ts // Enable IndexedDB persistence for offline support configureSdkCache({ persistence: 'indexeddb' }) // Disable cache entirely in test environments configureSdkCache({ enabled: false }) ```
123
+
124
+ **invalidateCache**(urlPattern?: string) → `void`
125
+ Manually invalidate entries in the SDK's GET cache. *contains* this string is removed. Omit (or pass `undefined`) to wipe the entire cache. ```ts invalidateCache() // clear everything invalidateCache('/collection/abc123') // one specific collection invalidateCache('/product/') // all product responses ```
126
+
111
127
  **proxyUploadFormData**(path: string,
112
128
  formData: FormData,
113
129
  onProgress?: (percent: number) → `void`
114
130
  Upload a FormData payload via proxy with progress events using chunked postMessage. Parent is expected to implement the counterpart protocol.
115
131
 
116
132
  **request**(path: string) → `Promise<T>`
117
- Internal helper that performs a GET request to \`\${baseURL}\${path}\`, injecting headers for apiKey or bearerToken if present. Returns the parsed JSON as T, or throws an Error.
133
+ Internal helper that performs a GET request to `${baseURL}${path}`, injecting headers for apiKey or bearerToken if present. Cache pipeline (when caching is not skipped): L1 hit → return from memory (no I/O) L2 hit → return from IndexedDB, promote to L1 (no network) Miss → fetch from network, store in L1 + L2 Offline → serve stale L2 entry via SmartlinksOfflineError (if persistence enabled) Concurrent identical GETs share one in-flight promise (deduplication). Node-safe: IndexedDB calls are no-ops when IDB is unavailable.
118
134
 
119
135
  **post**(path: string,
120
136
  body: any,
@@ -4128,7 +4144,7 @@ Tries to register a new user account. Can return a bearer token, or a Firebase t
4128
4144
  Admin: Get a user bearer token (impersonation/automation). POST /admin/auth/userToken All fields are optional; at least one identifier should be provided.
4129
4145
 
4130
4146
  **getAccount**() → `Promise<AccountInfoResponse>`
4131
- Gets current account information for the logged in user. Returns user, owner, account, and location objects.
4147
+ Gets current account information for the logged in user. Returns user, owner, account, and location objects. Short-circuits immediately (no network request) when the SDK has no bearer token or API key set — the server would return 401 anyway. Throws a `SmartlinksApiError` with `statusCode 401` and `details.local = true` so callers can distinguish "never authenticated" from an actual server-side token rejection.
4132
4148
 
4133
4149
  ### authKit
4134
4150
 
package/dist/http.d.ts CHANGED
@@ -50,15 +50,88 @@ export declare function getBaseURL(): string | null;
50
50
  * ```
51
51
  */
52
52
  export declare function isInitialized(): boolean;
53
+ /**
54
+ * Returns true if the SDK currently has any auth credential set (bearer token
55
+ * or API key). Use this as a cheap pre-flight check before calling endpoints
56
+ * that require authentication, to avoid issuing a network request that you
57
+ * already know will return a 401.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * if (hasAuthCredentials()) {
62
+ * const account = await auth.getAccount()
63
+ * }
64
+ * ```
65
+ */
66
+ export declare function hasAuthCredentials(): boolean;
67
+ /**
68
+ * Configure the SDK's built-in in-memory GET cache.
69
+ *
70
+ * The cache is transparent — it sits inside the HTTP layer and requires no
71
+ * changes to your existing API calls. All GET requests benefit automatically.
72
+ *
73
+ * @param options.enabled - Turn caching on/off entirely (default: `true`)
74
+ * @param options.ttlMs - Default time-to-live in milliseconds (default: `60_000`).
75
+ * Per-resource rules (collections/products → 1 h,
76
+ * proofs → 30 s, etc.) override this value.
77
+ * @param options.maxEntries - L1 LRU eviction threshold (default: `200`)
78
+ * @param options.persistence - Enable IndexedDB L2 cache (`'indexeddb'`) or keep
79
+ * in-memory only (`'none'`, default). Ignored in Node.js.
80
+ * @param options.persistenceTtlMs - How long L2 entries are eligible as an offline stale
81
+ * fallback, from the original fetch time (default: 7 days).
82
+ * @param options.serveStaleOnOffline - When `true` (default) and persistence is on, throw
83
+ * `SmartlinksOfflineError` with stale data instead of
84
+ * propagating the network error.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * // Enable IndexedDB persistence for offline support
89
+ * configureSdkCache({ persistence: 'indexeddb' })
90
+ *
91
+ * // Disable cache entirely in test environments
92
+ * configureSdkCache({ enabled: false })
93
+ * ```
94
+ */
95
+ export declare function configureSdkCache(options: {
96
+ enabled?: boolean;
97
+ ttlMs?: number;
98
+ maxEntries?: number;
99
+ persistence?: 'none' | 'indexeddb';
100
+ persistenceTtlMs?: number;
101
+ serveStaleOnOffline?: boolean;
102
+ }): void;
103
+ /**
104
+ * Manually invalidate entries in the SDK's GET cache.
105
+ *
106
+ * @param urlPattern - Optional substring match. Every cache entry whose key
107
+ * *contains* this string is removed. Omit (or pass `undefined`) to wipe the
108
+ * entire cache.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * invalidateCache() // clear everything
113
+ * invalidateCache('/collection/abc123') // one specific collection
114
+ * invalidateCache('/product/') // all product responses
115
+ * ```
116
+ */
117
+ export declare function invalidateCache(urlPattern?: string): void;
53
118
  /**
54
119
  * Upload a FormData payload via proxy with progress events using chunked postMessage.
55
120
  * Parent is expected to implement the counterpart protocol.
56
121
  */
57
122
  export declare function proxyUploadFormData<T>(path: string, formData: FormData, onProgress?: (percent: number) => void): Promise<T>;
58
123
  /**
59
- * Internal helper that performs a GET request to \`\${baseURL}\${path}\`,
124
+ * Internal helper that performs a GET request to `${baseURL}${path}`,
60
125
  * injecting headers for apiKey or bearerToken if present.
61
- * Returns the parsed JSON as T, or throws an Error.
126
+ *
127
+ * Cache pipeline (when caching is not skipped):
128
+ * L1 hit → return from memory (no I/O)
129
+ * L2 hit → return from IndexedDB, promote to L1 (no network)
130
+ * Miss → fetch from network, store in L1 + L2
131
+ * Offline → serve stale L2 entry via SmartlinksOfflineError (if persistence enabled)
132
+ *
133
+ * Concurrent identical GETs share one in-flight promise (deduplication).
134
+ * Node-safe: IndexedDB calls are no-ops when IDB is unavailable.
62
135
  */
63
136
  export declare function request<T>(path: string): Promise<T>;
64
137
  /**
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
@@ -0,0 +1,36 @@
1
+ export interface PersistentCacheEntry {
2
+ /** The cached response data. */
3
+ data: any;
4
+ /** Unix ms timestamp of when the data was originally fetched from the network. */
5
+ timestamp: number;
6
+ /** Unix ms timestamp of when this entry was written to IndexedDB. */
7
+ persistedAt: number;
8
+ }
9
+ /**
10
+ * Returns true only in environments where IndexedDB is genuinely available.
11
+ * False in Node.js, and in some private-browsing contexts that stub indexedDB
12
+ * but throw on open.
13
+ */
14
+ export declare function isIdbAvailable(): boolean;
15
+ /**
16
+ * Read an entry from IndexedDB.
17
+ * Returns `null` when IDB is unavailable, the key doesn't exist, or on any error.
18
+ * Safe to call in Node.js — returns null immediately.
19
+ */
20
+ export declare function idbGet(key: string): Promise<PersistentCacheEntry | null>;
21
+ /**
22
+ * Write an entry to IndexedDB.
23
+ * Fails silently on quota exceeded, private browsing, or any other error.
24
+ * Safe to call in Node.js — no-ops immediately.
25
+ */
26
+ export declare function idbSet(key: string, entry: PersistentCacheEntry): Promise<void>;
27
+ /**
28
+ * Delete a single entry from IndexedDB.
29
+ * Safe to call in Node.js — no-ops immediately.
30
+ */
31
+ export declare function idbDelete(key: string): Promise<void>;
32
+ /**
33
+ * Clear all IDB entries, or only those whose key contains `pattern`.
34
+ * Safe to call in Node.js — no-ops immediately.
35
+ */
36
+ export declare function idbClear(pattern?: string): Promise<void>;
@@ -0,0 +1,178 @@
1
+ // src/persistentCache.ts
2
+ // IndexedDB-backed L2 cache for the SDK's HTTP layer.
3
+ //
4
+ // Every exported function is Node-safe: all IDB access is guarded by
5
+ // isIdbAvailable() and wrapped in try/catch so that failures in private-
6
+ // browsing, quota-exceeded situations, or server-side environments are always
7
+ // silent — they never propagate errors to the caller.
8
+ const DB_NAME = 'smartlinks-sdk-cache';
9
+ const STORE_NAME = 'responses';
10
+ const DB_VERSION = 1;
11
+ let dbPromise = null;
12
+ /**
13
+ * Returns true only in environments where IndexedDB is genuinely available.
14
+ * False in Node.js, and in some private-browsing contexts that stub indexedDB
15
+ * but throw on open.
16
+ */
17
+ export function isIdbAvailable() {
18
+ try {
19
+ return typeof indexedDB !== 'undefined' && indexedDB !== null;
20
+ }
21
+ catch (_a) {
22
+ return false;
23
+ }
24
+ }
25
+ function openDb() {
26
+ if (dbPromise)
27
+ return dbPromise;
28
+ dbPromise = new Promise((resolve, reject) => {
29
+ try {
30
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
31
+ req.onupgradeneeded = (event) => {
32
+ const db = event.target.result;
33
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
34
+ db.createObjectStore(STORE_NAME);
35
+ }
36
+ };
37
+ req.onsuccess = (event) => {
38
+ resolve(event.target.result);
39
+ };
40
+ req.onerror = () => {
41
+ dbPromise = null;
42
+ reject(req.error);
43
+ };
44
+ req.onblocked = () => {
45
+ dbPromise = null;
46
+ reject(new Error('[smartlinks] IndexedDB open blocked'));
47
+ };
48
+ }
49
+ catch (err) {
50
+ dbPromise = null;
51
+ reject(err);
52
+ }
53
+ });
54
+ return dbPromise;
55
+ }
56
+ /**
57
+ * Read an entry from IndexedDB.
58
+ * Returns `null` when IDB is unavailable, the key doesn't exist, or on any error.
59
+ * Safe to call in Node.js — returns null immediately.
60
+ */
61
+ export async function idbGet(key) {
62
+ if (!isIdbAvailable())
63
+ return null;
64
+ try {
65
+ const db = await openDb();
66
+ return new Promise((resolve) => {
67
+ try {
68
+ const tx = db.transaction(STORE_NAME, 'readonly');
69
+ const req = tx.objectStore(STORE_NAME).get(key);
70
+ req.onsuccess = () => { var _a; return resolve((_a = req.result) !== null && _a !== void 0 ? _a : null); };
71
+ req.onerror = () => resolve(null);
72
+ }
73
+ catch (_a) {
74
+ resolve(null);
75
+ }
76
+ });
77
+ }
78
+ catch (_a) {
79
+ return null;
80
+ }
81
+ }
82
+ /**
83
+ * Write an entry to IndexedDB.
84
+ * Fails silently on quota exceeded, private browsing, or any other error.
85
+ * Safe to call in Node.js — no-ops immediately.
86
+ */
87
+ export async function idbSet(key, entry) {
88
+ if (!isIdbAvailable())
89
+ return;
90
+ try {
91
+ const db = await openDb();
92
+ await new Promise((resolve) => {
93
+ try {
94
+ const tx = db.transaction(STORE_NAME, 'readwrite');
95
+ tx.objectStore(STORE_NAME).put(entry, key);
96
+ tx.oncomplete = () => resolve();
97
+ tx.onerror = () => resolve(); // quota exceeded etc — fail silently
98
+ tx.onabort = () => resolve();
99
+ }
100
+ catch (_a) {
101
+ resolve();
102
+ }
103
+ });
104
+ }
105
+ catch (_a) {
106
+ // Fail silently — IDB persistence is best-effort
107
+ }
108
+ }
109
+ /**
110
+ * Delete a single entry from IndexedDB.
111
+ * Safe to call in Node.js — no-ops immediately.
112
+ */
113
+ export async function idbDelete(key) {
114
+ if (!isIdbAvailable())
115
+ return;
116
+ try {
117
+ const db = await openDb();
118
+ await new Promise((resolve) => {
119
+ try {
120
+ const tx = db.transaction(STORE_NAME, 'readwrite');
121
+ tx.objectStore(STORE_NAME).delete(key);
122
+ tx.oncomplete = () => resolve();
123
+ tx.onerror = () => resolve();
124
+ tx.onabort = () => resolve();
125
+ }
126
+ catch (_a) {
127
+ resolve();
128
+ }
129
+ });
130
+ }
131
+ catch (_a) { }
132
+ }
133
+ /**
134
+ * Clear all IDB entries, or only those whose key contains `pattern`.
135
+ * Safe to call in Node.js — no-ops immediately.
136
+ */
137
+ export async function idbClear(pattern) {
138
+ if (!isIdbAvailable())
139
+ return;
140
+ try {
141
+ const db = await openDb();
142
+ if (!pattern) {
143
+ await new Promise((resolve) => {
144
+ try {
145
+ const tx = db.transaction(STORE_NAME, 'readwrite');
146
+ tx.objectStore(STORE_NAME).clear();
147
+ tx.oncomplete = () => resolve();
148
+ tx.onerror = () => resolve();
149
+ tx.onabort = () => resolve();
150
+ }
151
+ catch (_a) {
152
+ resolve();
153
+ }
154
+ });
155
+ return;
156
+ }
157
+ // Pattern-based: enumerate all keys and delete matching ones.
158
+ await new Promise((resolve) => {
159
+ try {
160
+ const tx = db.transaction(STORE_NAME, 'readwrite');
161
+ const store = tx.objectStore(STORE_NAME);
162
+ const req = store.getAllKeys();
163
+ req.onsuccess = () => {
164
+ for (const key of req.result) {
165
+ if (key.includes(pattern))
166
+ store.delete(key);
167
+ }
168
+ resolve();
169
+ };
170
+ req.onerror = () => resolve();
171
+ }
172
+ catch (_a) {
173
+ resolve();
174
+ }
175
+ });
176
+ }
177
+ catch (_a) { }
178
+ }
@@ -73,3 +73,39 @@ export declare class SmartlinksApiError extends Error {
73
73
  */
74
74
  toJSON(): Record<string, any>;
75
75
  }
76
+ /**
77
+ * Thrown when a GET request fails due to network unavailability AND the
78
+ * persistent cache (IndexedDB) has a previously stored response for that
79
+ * resource. The `staleData` property contains the cached payload so the
80
+ * application can render in a degraded/offline state.
81
+ *
82
+ * Only thrown when persistence is enabled:
83
+ * `configureSdkCache({ persistence: 'indexeddb' })`
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * import { SmartlinksOfflineError } from '@proveanything/smartlinks'
88
+ *
89
+ * try {
90
+ * const data = await collection.get('abc123')
91
+ * } catch (err) {
92
+ * if (err instanceof SmartlinksOfflineError) {
93
+ * showOfflineBanner()
94
+ * renderWithData(err.staleData) // use the cached payload
95
+ * }
96
+ * }
97
+ * ```
98
+ */
99
+ export declare class SmartlinksOfflineError extends Error {
100
+ /** The stale cached payload available when the network request failed. */
101
+ readonly staleData: any;
102
+ /** Unix ms timestamp of when the stale data was originally fetched from the server. */
103
+ readonly cachedAt: number;
104
+ constructor(message: string,
105
+ /** The stale cached payload available when the network request failed. */
106
+ staleData: any,
107
+ /** Unix ms timestamp of when the stale data was originally fetched from the server. */
108
+ cachedAt: number);
109
+ /** Age of the stale data in milliseconds at the time this error was thrown. */
110
+ get staleAgeMs(): number;
111
+ }
@@ -96,3 +96,45 @@ export class SmartlinksApiError extends Error {
96
96
  };
97
97
  }
98
98
  }
99
+ /**
100
+ * Thrown when a GET request fails due to network unavailability AND the
101
+ * persistent cache (IndexedDB) has a previously stored response for that
102
+ * resource. The `staleData` property contains the cached payload so the
103
+ * application can render in a degraded/offline state.
104
+ *
105
+ * Only thrown when persistence is enabled:
106
+ * `configureSdkCache({ persistence: 'indexeddb' })`
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { SmartlinksOfflineError } from '@proveanything/smartlinks'
111
+ *
112
+ * try {
113
+ * const data = await collection.get('abc123')
114
+ * } catch (err) {
115
+ * if (err instanceof SmartlinksOfflineError) {
116
+ * showOfflineBanner()
117
+ * renderWithData(err.staleData) // use the cached payload
118
+ * }
119
+ * }
120
+ * ```
121
+ */
122
+ export class SmartlinksOfflineError extends Error {
123
+ constructor(message,
124
+ /** The stale cached payload available when the network request failed. */
125
+ staleData,
126
+ /** Unix ms timestamp of when the stale data was originally fetched from the server. */
127
+ cachedAt) {
128
+ super(message);
129
+ this.staleData = staleData;
130
+ this.cachedAt = cachedAt;
131
+ this.name = 'SmartlinksOfflineError';
132
+ if (Error.captureStackTrace) {
133
+ Error.captureStackTrace(this, SmartlinksOfflineError);
134
+ }
135
+ }
136
+ /** Age of the stale data in milliseconds at the time this error was thrown. */
137
+ get staleAgeMs() {
138
+ return Date.now() - this.cachedAt;
139
+ }
140
+ }
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.3.46 | Generated: 2026-02-19T21:09:09.402Z
3
+ Version: 1.4.0 | Generated: 2026-02-20T14:52:16.722Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -108,13 +108,29 @@ Get the currently configured API base URL. Returns null if initializeApi() has n
108
108
  **isInitialized**() → `boolean`
109
109
  Returns true if initializeApi() has been called at least once. Useful for guards in widgets or shared modules that want to skip initialization when another module has already done it. ```ts if (!isInitialized()) { initializeApi({ baseURL: 'https://smartlinks.app/api/v1' }) } ```
110
110
 
111
+ **hasAuthCredentials**() → `boolean`
112
+ Returns true if the SDK currently has any auth credential set (bearer token or API key). Use this as a cheap pre-flight check before calling endpoints that require authentication, to avoid issuing a network request that you already know will return a 401. ```ts if (hasAuthCredentials()) { const account = await auth.getAccount() } ```
113
+
114
+ **configureSdkCache**(options: {
115
+ enabled?: boolean
116
+ ttlMs?: number
117
+ maxEntries?: number
118
+ persistence?: 'none' | 'indexeddb'
119
+ persistenceTtlMs?: number
120
+ serveStaleOnOffline?: boolean
121
+ }) → `void`
122
+ Configure the SDK's built-in in-memory GET cache. The cache is transparent — it sits inside the HTTP layer and requires no changes to your existing API calls. All GET requests benefit automatically. Per-resource rules (collections/products → 1 h, proofs → 30 s, etc.) override this value. in-memory only (`'none'`, default). Ignored in Node.js. fallback, from the original fetch time (default: 7 days). `SmartlinksOfflineError` with stale data instead of propagating the network error. ```ts // Enable IndexedDB persistence for offline support configureSdkCache({ persistence: 'indexeddb' }) // Disable cache entirely in test environments configureSdkCache({ enabled: false }) ```
123
+
124
+ **invalidateCache**(urlPattern?: string) → `void`
125
+ Manually invalidate entries in the SDK's GET cache. *contains* this string is removed. Omit (or pass `undefined`) to wipe the entire cache. ```ts invalidateCache() // clear everything invalidateCache('/collection/abc123') // one specific collection invalidateCache('/product/') // all product responses ```
126
+
111
127
  **proxyUploadFormData**(path: string,
112
128
  formData: FormData,
113
129
  onProgress?: (percent: number) → `void`
114
130
  Upload a FormData payload via proxy with progress events using chunked postMessage. Parent is expected to implement the counterpart protocol.
115
131
 
116
132
  **request**(path: string) → `Promise<T>`
117
- Internal helper that performs a GET request to \`\${baseURL}\${path}\`, injecting headers for apiKey or bearerToken if present. Returns the parsed JSON as T, or throws an Error.
133
+ Internal helper that performs a GET request to `${baseURL}${path}`, injecting headers for apiKey or bearerToken if present. Cache pipeline (when caching is not skipped): L1 hit → return from memory (no I/O) L2 hit → return from IndexedDB, promote to L1 (no network) Miss → fetch from network, store in L1 + L2 Offline → serve stale L2 entry via SmartlinksOfflineError (if persistence enabled) Concurrent identical GETs share one in-flight promise (deduplication). Node-safe: IndexedDB calls are no-ops when IDB is unavailable.
118
134
 
119
135
  **post**(path: string,
120
136
  body: any,
@@ -4128,7 +4144,7 @@ Tries to register a new user account. Can return a bearer token, or a Firebase t
4128
4144
  Admin: Get a user bearer token (impersonation/automation). POST /admin/auth/userToken All fields are optional; at least one identifier should be provided.
4129
4145
 
4130
4146
  **getAccount**() → `Promise<AccountInfoResponse>`
4131
- Gets current account information for the logged in user. Returns user, owner, account, and location objects.
4147
+ Gets current account information for the logged in user. Returns user, owner, account, and location objects. Short-circuits immediately (no network request) when the SDK has no bearer token or API key set — the server would return 401 anyway. Throws a `SmartlinksApiError` with `statusCode 401` and `details.local = true` so callers can distinguish "never authenticated" from an actual server-side token rejection.
4132
4148
 
4133
4149
  ### authKit
4134
4150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.3.46",
3
+ "version": "1.4.0",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",