@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.
- package/dist/api/auth.d.ts +6 -0
- package/dist/api/auth.js +11 -1
- package/dist/docs/API_SUMMARY.md +19 -3
- package/dist/http.d.ts +75 -2
- package/dist/http.js +439 -85
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/persistentCache.d.ts +36 -0
- package/dist/persistentCache.js +178 -0
- package/dist/types/error.d.ts +36 -0
- package/dist/types/error.js +42 -0
- package/docs/API_SUMMARY.md +19 -3
- package/package.json +1 -1
package/dist/api/auth.d.ts
CHANGED
|
@@ -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;
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
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
|
|
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
|
|
124
|
+
* Internal helper that performs a GET request to `${baseURL}${path}`,
|
|
60
125
|
* injecting headers for apiKey or bearerToken if present.
|
|
61
|
-
*
|
|
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
|
|
664
|
+
* Internal helper that performs a GET request to `${baseURL}${path}`,
|
|
453
665
|
* injecting headers for apiKey or bearerToken if present.
|
|
454
|
-
*
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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 (
|
|
487
|
-
//
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
674
|
-
|
|
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
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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 (
|
|
691
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
return
|
|
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
|
+
}
|
package/dist/types/error.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/types/error.js
CHANGED
|
@@ -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
|
+
}
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.
|
|
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
|
|
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
|
|