@proveanything/smartlinks 1.3.45 → 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.45 | Generated: 2026-02-19T14:43:39.335Z
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
 
@@ -87,7 +87,10 @@ Return whether proxy mode is currently enabled.
87
87
  extraHeaders?: Record<string, string>
88
88
  iframeAutoResize?: boolean // default true when in iframe
89
89
  logger?: Logger // optional console-like or function to enable verbose logging
90
- }) → `void`
90
+ /**
91
+ * When true, bypasses the idempotency guard and forces a full re-initialization.
92
+ * Use only when you intentionally need to reset all SDK state (e.g. in tests or
93
+ * when switching accounts) → `void`
91
94
  Call this once (e.g. at app startup) to configure baseURL/auth.
92
95
 
93
96
  **setNgrokSkipBrowserWarning**(flag: boolean) → `void`
@@ -102,13 +105,32 @@ Allows setting the bearerToken at runtime (e.g. after login/logout).
102
105
  **getBaseURL**() → `string | null`
103
106
  Get the currently configured API base URL. Returns null if initializeApi() has not been called yet.
104
107
 
108
+ **isInitialized**() → `boolean`
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
+
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
+
105
127
  **proxyUploadFormData**(path: string,
106
128
  formData: FormData,
107
129
  onProgress?: (percent: number) → `void`
108
130
  Upload a FormData payload via proxy with progress events using chunked postMessage. Parent is expected to implement the counterpart protocol.
109
131
 
110
132
  **request**(path: string) → `Promise<T>`
111
- 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.
112
134
 
113
135
  **post**(path: string,
114
136
  body: any,
@@ -4122,7 +4144,7 @@ Tries to register a new user account. Can return a bearer token, or a Firebase t
4122
4144
  Admin: Get a user bearer token (impersonation/automation). POST /admin/auth/userToken All fields are optional; at least one identifier should be provided.
4123
4145
 
4124
4146
  **getAccount**() → `Promise<AccountInfoResponse>`
4125
- 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.
4126
4148
 
4127
4149
  ### authKit
4128
4150
 
package/dist/http.d.ts CHANGED
@@ -16,6 +16,13 @@ export declare function initializeApi(options: {
16
16
  extraHeaders?: Record<string, string>;
17
17
  iframeAutoResize?: boolean;
18
18
  logger?: Logger;
19
+ /**
20
+ * When true, bypasses the idempotency guard and forces a full re-initialization.
21
+ * Use only when you intentionally need to reset all SDK state (e.g. in tests or
22
+ * when switching accounts). In normal application code, prefer letting the guard
23
+ * protect runtime state such as login tokens.
24
+ */
25
+ force?: boolean;
19
26
  }): void;
20
27
  /** Enable/disable automatic "ngrok-skip-browser-warning" header. */
21
28
  export declare function setNgrokSkipBrowserWarning(flag: boolean): void;
@@ -30,15 +37,101 @@ export declare function setBearerToken(token: string | undefined): void;
30
37
  * Returns null if initializeApi() has not been called yet.
31
38
  */
32
39
  export declare function getBaseURL(): string | null;
40
+ /**
41
+ * Returns true if initializeApi() has been called at least once.
42
+ * Useful for guards in widgets or shared modules that want to skip
43
+ * initialization when another module has already done it.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * if (!isInitialized()) {
48
+ * initializeApi({ baseURL: 'https://smartlinks.app/api/v1' })
49
+ * }
50
+ * ```
51
+ */
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;
33
118
  /**
34
119
  * Upload a FormData payload via proxy with progress events using chunked postMessage.
35
120
  * Parent is expected to implement the counterpart protocol.
36
121
  */
37
122
  export declare function proxyUploadFormData<T>(path: string, formData: FormData, onProgress?: (percent: number) => void): Promise<T>;
38
123
  /**
39
- * Internal helper that performs a GET request to \`\${baseURL}\${path}\`,
124
+ * Internal helper that performs a GET request to `${baseURL}${path}`,
40
125
  * injecting headers for apiKey or bearerToken if present.
41
- * 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.
42
135
  */
43
136
  export declare function request<T>(path: string): Promise<T>;
44
137
  /**
package/dist/http.js CHANGED
@@ -13,13 +13,135 @@ 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;
20
21
  let proxyMode = false;
21
22
  let ngrokSkipBrowserWarning = false;
22
23
  let extraHeadersGlobal = {};
24
+ /** Whether initializeApi has been successfully called at least once. */
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
+ }
23
145
  let logger;
24
146
  function logDebug(...args) {
25
147
  if (!logger)
@@ -169,9 +291,32 @@ function normalizeErrorResponse(responseBody, statusCode) {
169
291
  import { iframe } from './iframe';
170
292
  export function initializeApi(options) {
171
293
  // Normalize baseURL by removing trailing slashes.
172
- baseURL = options.baseURL.replace(/\/+$/g, "");
294
+ const normalizedBaseURL = options.baseURL.replace(/\/+$/g, "");
295
+ // ------------------------------------------------------------------
296
+ // Firebase-style idempotency guard
297
+ // If we have already been initialized with the same baseURL and the
298
+ // caller is not forcing a reset, return immediately. This prevents
299
+ // any module – widget, component, or re-rendered page – from
300
+ // accidentally wiping runtime state such as a bearerToken that was
301
+ // set by auth.login() after the first initialization.
302
+ // ------------------------------------------------------------------
303
+ if (initialized && !options.force && baseURL === normalizedBaseURL) {
304
+ logDebug('[smartlinks] initializeApi: already initialized with this baseURL – skipping.', { baseURL });
305
+ return;
306
+ }
307
+ baseURL = normalizedBaseURL;
173
308
  apiKey = options.apiKey;
174
- bearerToken = options.bearerToken;
309
+ // Only overwrite bearerToken when the caller explicitly supplies one,
310
+ // OR when this is the very first initialization (start with a clean slate).
311
+ // Re-initialization calls that omit bearerToken must NOT clear a token that
312
+ // was acquired at runtime (e.g. from a successful auth.login()).
313
+ if (options.bearerToken !== undefined) {
314
+ bearerToken = options.bearerToken;
315
+ }
316
+ else if (!initialized) {
317
+ bearerToken = undefined;
318
+ }
319
+ // else: preserve the existing runtime bearerToken.
175
320
  proxyMode = !!options.proxyMode;
176
321
  // Auto-enable ngrok skip header if domain contains .ngrok.io and user did not explicitly set the flag.
177
322
  // Infer ngrok usage from common domains (.ngrok.io or .ngrok-free.dev)
@@ -184,7 +329,14 @@ export function initializeApi(options) {
184
329
  if (iframe.isIframe() && options.iframeAutoResize !== false) {
185
330
  iframe.enableAutoIframeResize();
186
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
+ }
187
338
  logger = options.logger;
339
+ initialized = true;
188
340
  logDebug('[smartlinks] initializeApi', {
189
341
  baseURL,
190
342
  proxyMode,
@@ -215,6 +367,107 @@ export function setBearerToken(token) {
215
367
  export function getBaseURL() {
216
368
  return baseURL;
217
369
  }
370
+ /**
371
+ * Returns true if initializeApi() has been called at least once.
372
+ * Useful for guards in widgets or shared modules that want to skip
373
+ * initialization when another module has already done it.
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * if (!isInitialized()) {
378
+ * initializeApi({ baseURL: 'https://smartlinks.app/api/v1' })
379
+ * }
380
+ * ```
381
+ */
382
+ export function isInitialized() {
383
+ return initialized;
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
+ }
218
471
  // Map of pending proxy requests: id -> {resolve, reject}
219
472
  const proxyPending = {};
220
473
  function generateProxyId() {
@@ -408,49 +661,114 @@ export async function proxyUploadFormData(path, formData, onProgress) {
408
661
  return done;
409
662
  }
410
663
  /**
411
- * Internal helper that performs a GET request to \`\${baseURL}\${path}\`,
664
+ * Internal helper that performs a GET request to `${baseURL}${path}`,
412
665
  * injecting headers for apiKey or bearerToken if present.
413
- * 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.
414
675
  */
415
676
  export async function request(path) {
416
- if (proxyMode) {
417
- logDebug('[smartlinks] GET via proxy', { path });
418
- return proxyRequest("GET", path);
419
- }
420
- if (!baseURL) {
421
- 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
+ }
422
693
  }
423
- const url = `${baseURL}${path}`;
424
- const headers = { "Content-Type": "application/json" };
425
- if (apiKey)
426
- headers["X-API-Key"] = apiKey;
427
- if (bearerToken)
428
- headers["AUTHORIZATION"] = `Bearer ${bearerToken}`;
429
- if (ngrokSkipBrowserWarning)
430
- headers["ngrok-skip-browser-warning"] = "true";
431
- for (const [k, v] of Object.entries(extraHeadersGlobal))
432
- headers[k] = v;
433
- logDebug('[smartlinks] GET fetch', { url, headers: redactHeaders(headers) });
434
- const response = await fetch(url, {
435
- method: "GET",
436
- headers,
437
- });
438
- logDebug('[smartlinks] GET response', { url, status: response.status, ok: response.ok });
439
- if (!response.ok) {
440
- // Try to parse error response body and normalize it
441
- 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
442
709
  try {
443
- 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;
444
750
  }
445
- catch (_a) {
446
- // Failed to parse JSON, use status code only
447
- 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;
448
762
  }
449
- const errBody = normalizeErrorResponse(responseBody, response.status);
450
- const message = `Error ${errBody.code}: ${errBody.message}`;
451
- 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));
452
770
  }
453
- return (await response.json());
771
+ return fetchPromise;
454
772
  }
455
773
  /**
456
774
  * Internal helper that performs a POST request to `${baseURL}${path}`,
@@ -461,7 +779,9 @@ export async function request(path) {
461
779
  export async function post(path, body, extraHeaders) {
462
780
  if (proxyMode) {
463
781
  logDebug('[smartlinks] POST via proxy', { path, body: safeBodyPreview(body) });
464
- return proxyRequest("POST", path, body, extraHeaders);
782
+ const result = await proxyRequest("POST", path, body, extraHeaders);
783
+ invalidateCacheForPath(path);
784
+ return result;
465
785
  }
466
786
  if (!baseURL) {
467
787
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -500,7 +820,9 @@ export async function post(path, body, extraHeaders) {
500
820
  const message = `Error ${errBody.code}: ${errBody.message}`;
501
821
  throw new SmartlinksApiError(message, response.status, errBody, url);
502
822
  }
503
- return (await response.json());
823
+ const postResult = (await response.json());
824
+ invalidateCacheForPath(path);
825
+ return postResult;
504
826
  }
505
827
  /**
506
828
  * Internal helper that performs a PUT request to `${baseURL}${path}`,
@@ -511,7 +833,9 @@ export async function post(path, body, extraHeaders) {
511
833
  export async function put(path, body, extraHeaders) {
512
834
  if (proxyMode) {
513
835
  logDebug('[smartlinks] PUT via proxy', { path, body: safeBodyPreview(body) });
514
- return proxyRequest("PUT", path, body, extraHeaders);
836
+ const result = await proxyRequest("PUT", path, body, extraHeaders);
837
+ invalidateCacheForPath(path);
838
+ return result;
515
839
  }
516
840
  if (!baseURL) {
517
841
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -550,7 +874,9 @@ export async function put(path, body, extraHeaders) {
550
874
  const message = `Error ${errBody.code}: ${errBody.message}`;
551
875
  throw new SmartlinksApiError(message, response.status, errBody, url);
552
876
  }
553
- return (await response.json());
877
+ const putResult = (await response.json());
878
+ invalidateCacheForPath(path);
879
+ return putResult;
554
880
  }
555
881
  /**
556
882
  * Internal helper that performs a PATCH request to `${baseURL}${path}`,
@@ -561,7 +887,9 @@ export async function put(path, body, extraHeaders) {
561
887
  export async function patch(path, body, extraHeaders) {
562
888
  if (proxyMode) {
563
889
  logDebug('[smartlinks] PATCH via proxy', { path, body: safeBodyPreview(body) });
564
- return proxyRequest("PATCH", path, body, extraHeaders);
890
+ const result = await proxyRequest("PATCH", path, body, extraHeaders);
891
+ invalidateCacheForPath(path);
892
+ return result;
565
893
  }
566
894
  if (!baseURL) {
567
895
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -600,7 +928,9 @@ export async function patch(path, body, extraHeaders) {
600
928
  const message = `Error ${errBody.code}: ${errBody.message}`;
601
929
  throw new SmartlinksApiError(message, response.status, errBody, url);
602
930
  }
603
- return (await response.json());
931
+ const patchResult = (await response.json());
932
+ invalidateCacheForPath(path);
933
+ return patchResult;
604
934
  }
605
935
  /**
606
936
  * Internal helper that performs a request to `${baseURL}${path}` with custom options,
@@ -608,52 +938,115 @@ export async function patch(path, body, extraHeaders) {
608
938
  * Returns the parsed JSON as T, or throws an Error.
609
939
  */
610
940
  export async function requestWithOptions(path, options) {
611
- if (proxyMode) {
612
- logDebug('[smartlinks] requestWithOptions via proxy', { path, method: options.method || 'GET' });
613
- return proxyRequest(options.method || "GET", path, options.body, options.headers, options);
614
- }
615
- if (!baseURL) {
616
- throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
617
- }
618
- const url = `${baseURL}${path}`;
619
- // Safely merge headers, converting Headers/init to Record<string, string>
620
- let extraHeaders = {};
621
- if (options.headers) {
622
- if (options.headers instanceof Headers) {
623
- options.headers.forEach((value, key) => {
624
- extraHeaders[key] = value;
625
- });
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;
626
952
  }
627
- else if (Array.isArray(options.headers)) {
628
- for (const [key, value] of options.headers) {
629
- extraHeaders[key] = value;
630
- }
631
- }
632
- else {
633
- 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;
634
958
  }
635
959
  }
636
- 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);
637
- // Merge global custom headers (do not override existing keys from options.headers)
638
- for (const [k, v] of Object.entries(extraHeadersGlobal))
639
- if (!(k in headers))
640
- headers[k] = v;
641
- logDebug('[smartlinks] requestWithOptions fetch', { url, method: options.method || 'GET', headers: redactHeaders(headers), body: safeBodyPreview(options.body) });
642
- const response = await fetch(url, Object.assign(Object.assign({}, options), { headers }));
643
- logDebug('[smartlinks] requestWithOptions response', { url, status: response.status, ok: response.ok });
644
- if (!response.ok) {
645
- 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
+ }
646
970
  try {
647
- 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;
648
1030
  }
649
- catch (_a) {
650
- 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;
651
1041
  }
652
- const errBody = normalizeErrorResponse(responseBody, response.status);
653
- const message = `Error ${errBody.code}: ${errBody.message}`;
654
- 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));
655
1048
  }
656
- return (await response.json());
1049
+ return fetchPromise;
657
1050
  }
658
1051
  /**
659
1052
  * Internal helper that performs a DELETE request to `${baseURL}${path}`,
@@ -663,7 +1056,9 @@ export async function requestWithOptions(path, options) {
663
1056
  export async function del(path, extraHeaders) {
664
1057
  if (proxyMode) {
665
1058
  logDebug('[smartlinks] DELETE via proxy', { path });
666
- return proxyRequest("DELETE", path, undefined, extraHeaders);
1059
+ const result = await proxyRequest("DELETE", path, undefined, extraHeaders);
1060
+ invalidateCacheForPath(path);
1061
+ return result;
667
1062
  }
668
1063
  if (!baseURL) {
669
1064
  throw new Error("HTTP client is not initialized. Call initializeApi(...) first.");
@@ -698,9 +1093,9 @@ export async function del(path, extraHeaders) {
698
1093
  throw new SmartlinksApiError(message, response.status, errBody, url);
699
1094
  }
700
1095
  // If the response is empty, just return undefined
701
- if (response.status === 204)
702
- return undefined;
703
- return (await response.json());
1096
+ const delResult = response.status === 204 ? undefined : (await response.json());
1097
+ invalidateCacheForPath(path);
1098
+ return delResult;
704
1099
  }
705
1100
  /**
706
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, 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, 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.45 | Generated: 2026-02-19T14:43:39.335Z
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
 
@@ -87,7 +87,10 @@ Return whether proxy mode is currently enabled.
87
87
  extraHeaders?: Record<string, string>
88
88
  iframeAutoResize?: boolean // default true when in iframe
89
89
  logger?: Logger // optional console-like or function to enable verbose logging
90
- }) → `void`
90
+ /**
91
+ * When true, bypasses the idempotency guard and forces a full re-initialization.
92
+ * Use only when you intentionally need to reset all SDK state (e.g. in tests or
93
+ * when switching accounts) → `void`
91
94
  Call this once (e.g. at app startup) to configure baseURL/auth.
92
95
 
93
96
  **setNgrokSkipBrowserWarning**(flag: boolean) → `void`
@@ -102,13 +105,32 @@ Allows setting the bearerToken at runtime (e.g. after login/logout).
102
105
  **getBaseURL**() → `string | null`
103
106
  Get the currently configured API base URL. Returns null if initializeApi() has not been called yet.
104
107
 
108
+ **isInitialized**() → `boolean`
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
+
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
+
105
127
  **proxyUploadFormData**(path: string,
106
128
  formData: FormData,
107
129
  onProgress?: (percent: number) → `void`
108
130
  Upload a FormData payload via proxy with progress events using chunked postMessage. Parent is expected to implement the counterpart protocol.
109
131
 
110
132
  **request**(path: string) → `Promise<T>`
111
- 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.
112
134
 
113
135
  **post**(path: string,
114
136
  body: any,
@@ -4122,7 +4144,7 @@ Tries to register a new user account. Can return a bearer token, or a Firebase t
4122
4144
  Admin: Get a user bearer token (impersonation/automation). POST /admin/auth/userToken All fields are optional; at least one identifier should be provided.
4123
4145
 
4124
4146
  **getAccount**() → `Promise<AccountInfoResponse>`
4125
- 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.
4126
4148
 
4127
4149
  ### authKit
4128
4150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.3.45",
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",