zubbl-sdk 1.1.16 → 1.1.19
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/types/client.d.ts +51 -0
- package/dist/types/core/config.d.ts +9 -3
- package/dist/types/core/config.original.d.ts +14 -0
- package/dist/types/core/http.d.ts +5 -4
- package/dist/types/core/http.original.d.ts +4 -0
- package/dist/types/core/telemetry.d.ts +2 -2
- package/dist/types/features/getTiles.d.ts +3 -2
- package/dist/types/features/identifyUser.d.ts +2 -1
- package/dist/types/index.d.ts +24 -24
- package/dist/zubbl-sdk.cjs.js +392 -448
- package/dist/zubbl-sdk.cjs.js.map +1 -1
- package/dist/zubbl-sdk.esm.js +392 -446
- package/dist/zubbl-sdk.esm.js.map +1 -1
- package/dist/zubbl-sdk.umd.js +393 -449
- package/dist/zubbl-sdk.umd.js.map +1 -1
- package/package.json +1 -1
package/dist/zubbl-sdk.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'crypto';
|
|
2
2
|
|
|
3
3
|
// src/core/config.ts
|
|
4
4
|
// -----------------------------------------------------------
|
|
@@ -11,21 +11,9 @@ const isNode = typeof process !== "undefined" &&
|
|
|
11
11
|
const isWorker = typeof self !== "undefined" &&
|
|
12
12
|
typeof self.importScripts === "function" &&
|
|
13
13
|
!isNode;
|
|
14
|
-
|
|
15
|
-
apiKey: null,
|
|
16
|
-
tenantId: null,
|
|
17
|
-
appId: null,
|
|
18
|
-
baseUrl: "https://api.zubbl.com/api",
|
|
19
|
-
injectWorkerHeaders: false,
|
|
20
|
-
workerSecret: null,
|
|
21
|
-
};
|
|
22
|
-
// -----------------------------------------------------------
|
|
23
|
-
// Browser safety check
|
|
24
|
-
// -----------------------------------------------------------
|
|
25
|
-
function assertBrowserSafety(config) {
|
|
14
|
+
function assertBrowserSafety(cfg) {
|
|
26
15
|
if (isBrowser || isWorker) {
|
|
27
|
-
const key =
|
|
28
|
-
// Simple rule: if the key looks like a secret (sk_ or very long)
|
|
16
|
+
const key = cfg.apiKey ?? "";
|
|
29
17
|
const looksSecret = key.startsWith("sk_") || key.length > 40;
|
|
30
18
|
if (looksSecret) {
|
|
31
19
|
throw new Error("[Zubbl SDK] Secret or server key detected in browser environment. " +
|
|
@@ -33,290 +21,40 @@ function assertBrowserSafety(config) {
|
|
|
33
21
|
}
|
|
34
22
|
}
|
|
35
23
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function buildHeaders(extra = {}) {
|
|
50
|
-
if (!config.apiKey || !config.tenantId || !config.appId) {
|
|
51
|
-
throw new Error("[Zubbl SDK] Not initialized – call init() first.");
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
Authorization: `Bearer ${config.apiKey}`,
|
|
55
|
-
"X-Tenant-Id": config.tenantId,
|
|
56
|
-
"X-App-Id": config.appId,
|
|
57
|
-
"Content-Type": "application/json",
|
|
58
|
-
...extra,
|
|
24
|
+
/**
|
|
25
|
+
* Creates an isolated configuration store.
|
|
26
|
+
* Each ZubblClient instance owns its own store.
|
|
27
|
+
*/
|
|
28
|
+
function createConfigStore(initial) {
|
|
29
|
+
let current = {
|
|
30
|
+
apiKey: null,
|
|
31
|
+
tenantId: null,
|
|
32
|
+
appId: null,
|
|
33
|
+
baseUrl: "https://api.zubbl.com/api",
|
|
34
|
+
injectWorkerHeaders: false,
|
|
35
|
+
workerSecret: null,
|
|
36
|
+
...initial,
|
|
59
37
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// -----------------------------------------------------------
|
|
64
|
-
// Adaptive Context Engine — Geo + Stability Enrichment + Persistence
|
|
65
|
-
// -----------------------------------------------------------
|
|
66
|
-
const MAX_HISTORY = 100;
|
|
67
|
-
const CACHE_KEY = "zubbl-stability";
|
|
68
|
-
let state = loadStability() ?? {
|
|
69
|
-
routes: [],
|
|
70
|
-
geo: "unknown",
|
|
71
|
-
stability: 1.0,
|
|
72
|
-
lastGeoChange: Date.now(),
|
|
73
|
-
};
|
|
74
|
-
// -----------------------------------------------------------
|
|
75
|
-
// 💾 Persistence Helpers
|
|
76
|
-
// -----------------------------------------------------------
|
|
77
|
-
function loadStability() {
|
|
78
|
-
if (!isBrowser || typeof localStorage === "undefined")
|
|
79
|
-
return null;
|
|
80
|
-
try {
|
|
81
|
-
const raw = localStorage.getItem(CACHE_KEY);
|
|
82
|
-
if (!raw)
|
|
83
|
-
return null;
|
|
84
|
-
const parsed = JSON.parse(raw);
|
|
85
|
-
if (!parsed.geo || !parsed.stability)
|
|
86
|
-
return null;
|
|
87
|
-
return { ...parsed, routes: [] };
|
|
38
|
+
function init(partial) {
|
|
39
|
+
current = { ...current, ...partial };
|
|
40
|
+
assertBrowserSafety(current);
|
|
88
41
|
}
|
|
89
|
-
|
|
90
|
-
return
|
|
42
|
+
function get() {
|
|
43
|
+
return current;
|
|
91
44
|
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return;
|
|
96
|
-
try {
|
|
97
|
-
const payload = {
|
|
98
|
-
geo: state.geo,
|
|
99
|
-
stability: state.stability,
|
|
100
|
-
lastGeoChange: state.lastGeoChange,
|
|
101
|
-
};
|
|
102
|
-
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
/* ignore */
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
// -----------------------------------------------------------
|
|
109
|
-
// 🌍 Geo Detection (Node, Browser, Worker)
|
|
110
|
-
// -----------------------------------------------------------
|
|
111
|
-
function detectGeo() {
|
|
112
|
-
try {
|
|
113
|
-
if (isBrowser && typeof window !== "undefined" && window.navigator?.language) {
|
|
114
|
-
return window.navigator.language.split("-")[1] || "unknown";
|
|
115
|
-
}
|
|
116
|
-
if (isWorker && typeof self !== "undefined" && self.navigator?.language) {
|
|
117
|
-
return self.navigator.language.split("-")[1] || "unknown";
|
|
45
|
+
function buildHeaders(extra = {}) {
|
|
46
|
+
if (!current.apiKey || !current.tenantId || !current.appId) {
|
|
47
|
+
throw new Error("[Zubbl SDK] Not initialized – call init() first.");
|
|
118
48
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return "unknown";
|
|
126
|
-
}
|
|
127
|
-
catch (err) {
|
|
128
|
-
console.warn("[Zubbl SDK][Geo Detect] Failed:", err);
|
|
129
|
-
return "unknown";
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// -----------------------------------------------------------
|
|
133
|
-
// 🧭 Update Geo & Stability (with persistence)
|
|
134
|
-
// -----------------------------------------------------------
|
|
135
|
-
function updateGeo(geoHint) {
|
|
136
|
-
const newGeo = geoHint || detectGeo();
|
|
137
|
-
if (newGeo !== state.geo) {
|
|
138
|
-
const now = Date.now();
|
|
139
|
-
const elapsed = (now - state.lastGeoChange) / 1000;
|
|
140
|
-
state.stability = Math.max(0.5, Math.min(1.0, elapsed / 3600));
|
|
141
|
-
state.geo = newGeo;
|
|
142
|
-
state.lastGeoChange = now;
|
|
143
|
-
saveStability();
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
state.stability = Math.min(1.0, state.stability + 0.01);
|
|
147
|
-
saveStability();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// -----------------------------------------------------------
|
|
151
|
-
// 🧮 Record Context Samples
|
|
152
|
-
// -----------------------------------------------------------
|
|
153
|
-
function recordContextSample(path, method, status, latency) {
|
|
154
|
-
state.routes.push({ path, method, ts: Date.now(), status, latency });
|
|
155
|
-
if (state.routes.length > MAX_HISTORY)
|
|
156
|
-
state.routes.shift();
|
|
157
|
-
updateGeo(); // auto-refresh geo info
|
|
158
|
-
}
|
|
159
|
-
// -----------------------------------------------------------
|
|
160
|
-
// 📦 Get Adaptive Headers
|
|
161
|
-
// -----------------------------------------------------------
|
|
162
|
-
function getAdaptiveHeaders() {
|
|
163
|
-
const currentGeo = detectGeo();
|
|
164
|
-
updateGeo(currentGeo);
|
|
165
|
-
const summary = btoa(JSON.stringify({
|
|
166
|
-
env: isNode ? "node" : isBrowser ? "browser" : "worker",
|
|
167
|
-
routeCount: state.routes.length,
|
|
168
|
-
lastGeo: state.geo,
|
|
169
|
-
avgLatency: state.routes.reduce((a, b) => a + b.latency, 0) /
|
|
170
|
-
Math.max(1, state.routes.length),
|
|
171
|
-
}));
|
|
172
|
-
return {
|
|
173
|
-
"X-Zubbl-Context-Summary": summary,
|
|
174
|
-
"X-Zubbl-Geo-Stability": `${state.geo}/${state.stability.toFixed(2)}`,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
// -----------------------------------------------------------
|
|
178
|
-
// 🌍 Initialize Geo Context Immediately
|
|
179
|
-
// -----------------------------------------------------------
|
|
180
|
-
updateGeo(process.env.ZUBBL_GEO);
|
|
181
|
-
|
|
182
|
-
// -----------------------------------------------------------
|
|
183
|
-
// 🔐 Secure Signing & Nonce Generator
|
|
184
|
-
// -----------------------------------------------------------
|
|
185
|
-
// -----------------------------------------------------------
|
|
186
|
-
// Generate UUIDv4 Nonce
|
|
187
|
-
// -----------------------------------------------------------
|
|
188
|
-
function generateNonce() {
|
|
189
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
|
|
190
|
-
const r = (Math.random() * 16) | 0;
|
|
191
|
-
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
192
|
-
return v.toString(16);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
// -----------------------------------------------------------
|
|
196
|
-
// Compute SHA256 HMAC signature
|
|
197
|
-
// -----------------------------------------------------------
|
|
198
|
-
async function signPayload(payload, secret) {
|
|
199
|
-
if (isNode) {
|
|
200
|
-
// ✅ Node.js native HMAC
|
|
201
|
-
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
|
202
|
-
}
|
|
203
|
-
// ✅ Browser / Worker using WebCrypto
|
|
204
|
-
if (typeof window !== "undefined" && window.crypto?.subtle) {
|
|
205
|
-
const key = await window.crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
206
|
-
const sig = await window.crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
|
207
|
-
return Array.from(new Uint8Array(sig))
|
|
208
|
-
.map(b => b.toString(16).padStart(2, "0"))
|
|
209
|
-
.join("");
|
|
210
|
-
}
|
|
211
|
-
throw new Error("No crypto implementation available in this environment");
|
|
212
|
-
}
|
|
213
|
-
// -----------------------------------------------------------
|
|
214
|
-
// Attach signing headers to a request
|
|
215
|
-
// -----------------------------------------------------------
|
|
216
|
-
async function attachSigningHeaders(method, url, body, secretKey) {
|
|
217
|
-
const nonce = generateNonce();
|
|
218
|
-
// Compute canonical string (deterministic)
|
|
219
|
-
const bodyHash = body
|
|
220
|
-
? crypto.createHash("sha256").update(body).digest("hex")
|
|
221
|
-
: "";
|
|
222
|
-
const canonical = `${method.toUpperCase()}\n${url}\n${nonce}\n${bodyHash}`;
|
|
223
|
-
// ⚙️ Always include nonce even if secret is missing
|
|
224
|
-
if (!secretKey) {
|
|
225
|
-
return { "X-Zubbl-Nonce": nonce };
|
|
226
|
-
}
|
|
227
|
-
const signature = await signPayload(canonical, secretKey);
|
|
228
|
-
return {
|
|
229
|
-
"X-Zubbl-Nonce": nonce,
|
|
230
|
-
"X-Zubbl-Signature": signature,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// src/core/http.ts
|
|
235
|
-
// -----------------------------------------------------------
|
|
236
|
-
// Isomorphic HTTP transport for Node, Browser, and Workers
|
|
237
|
-
// Includes adaptive context, telemetry, signing & safety timeout
|
|
238
|
-
// -----------------------------------------------------------
|
|
239
|
-
async function httpRequest(url, options = {}) {
|
|
240
|
-
// 🔧 Default timeout lowered for SDK-12 hardening
|
|
241
|
-
const { timeoutMs = 250, ...fetchOpts } = options;
|
|
242
|
-
// ---------------------------------------------------------
|
|
243
|
-
// Polyfill fetch for Node
|
|
244
|
-
// ---------------------------------------------------------
|
|
245
|
-
let fetchImpl;
|
|
246
|
-
if (isNode) {
|
|
247
|
-
const { fetch } = await import('undici');
|
|
248
|
-
// @ts-expect-error Node native fetch lacks duplex definition in undici types
|
|
249
|
-
fetchImpl = fetch;
|
|
250
|
-
}
|
|
251
|
-
else if (isBrowser || isWorker) {
|
|
252
|
-
fetchImpl = fetch;
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
console.warn("[Zubbl SDK][HTTP] Unsupported runtime environment");
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
const controller = new AbortController();
|
|
259
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
260
|
-
const start = performance.now();
|
|
261
|
-
try {
|
|
262
|
-
const config = getConfig();
|
|
263
|
-
const bodyText = fetchOpts.body && typeof fetchOpts.body === "string"
|
|
264
|
-
? fetchOpts.body
|
|
265
|
-
: fetchOpts.body
|
|
266
|
-
? JSON.stringify(fetchOpts.body)
|
|
267
|
-
: "";
|
|
268
|
-
// ---------------------------------------------------------
|
|
269
|
-
// 🔐 Signing headers (adds X-Zubbl-Nonce and X-Zubbl-Signature)
|
|
270
|
-
// ---------------------------------------------------------
|
|
271
|
-
const signingHeaders = await attachSigningHeaders(fetchOpts.method ?? "GET", url, bodyText, config.workerSecret ?? null);
|
|
272
|
-
// ---------------------------------------------------------
|
|
273
|
-
// 🧠 Adaptive context headers
|
|
274
|
-
// ---------------------------------------------------------
|
|
275
|
-
const adaptiveHeaders = getAdaptiveHeaders();
|
|
276
|
-
// ✅ Merge headers cleanly — final order matters
|
|
277
|
-
fetchOpts.headers = {
|
|
278
|
-
...(fetchOpts.headers || {}),
|
|
279
|
-
...adaptiveHeaders,
|
|
280
|
-
...signingHeaders,
|
|
49
|
+
return {
|
|
50
|
+
Authorization: `Bearer ${current.apiKey}`,
|
|
51
|
+
"X-Tenant-Id": current.tenantId,
|
|
52
|
+
"X-App-Id": current.appId,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
...extra,
|
|
281
55
|
};
|
|
282
|
-
// ---------------------------------------------------------
|
|
283
|
-
// Perform request
|
|
284
|
-
// ---------------------------------------------------------
|
|
285
|
-
const response = await fetchImpl(url, { ...fetchOpts, signal: controller.signal });
|
|
286
|
-
const latencyMs = performance.now() - start;
|
|
287
|
-
const status = response?.status ?? 0;
|
|
288
|
-
// -----------------------------------------------------
|
|
289
|
-
// Telemetry hook — record runtime discovery + metrics
|
|
290
|
-
// -----------------------------------------------------
|
|
291
|
-
await Promise.resolve().then(function () { return telemetry; }).then(({ recordRequestTelemetry, logRuntimeDiscovery }) => {
|
|
292
|
-
logRuntimeDiscovery(url);
|
|
293
|
-
recordRequestTelemetry(url, fetchOpts.method ?? "GET", status, latencyMs);
|
|
294
|
-
});
|
|
295
|
-
// -----------------------------------------------------
|
|
296
|
-
// Adaptive context sample recording
|
|
297
|
-
// -----------------------------------------------------
|
|
298
|
-
recordContextSample(url, fetchOpts.method ?? "GET", status, latencyMs);
|
|
299
|
-
// -----------------------------------------------------
|
|
300
|
-
// Error handling / Soft fail behaviour
|
|
301
|
-
// -----------------------------------------------------
|
|
302
|
-
if (!response.ok) {
|
|
303
|
-
const text = await response.text().catch(() => "");
|
|
304
|
-
console.warn(`[Zubbl SDK][HTTP] Non-OK response ${status}: ${text}`);
|
|
305
|
-
return response; // still return so host can inspect
|
|
306
|
-
}
|
|
307
|
-
return response;
|
|
308
|
-
}
|
|
309
|
-
catch (err) {
|
|
310
|
-
const latencyMs = performance.now() - start;
|
|
311
|
-
const reason = err?.name === "AbortError"
|
|
312
|
-
? `timeout (${timeoutMs}ms)`
|
|
313
|
-
: err?.message || "unknown network error";
|
|
314
|
-
console.warn(`[Zubbl SDK][HTTP] Request failed: ${reason}`, { url, latencyMs });
|
|
315
|
-
return null; // 🧩 Never throw to host app
|
|
316
|
-
}
|
|
317
|
-
finally {
|
|
318
|
-
clearTimeout(timeout);
|
|
319
56
|
}
|
|
57
|
+
return { init, get, buildHeaders };
|
|
320
58
|
}
|
|
321
59
|
|
|
322
60
|
// src/core/storage.ts
|
|
@@ -517,194 +255,402 @@ async function getGeoHeaders() {
|
|
|
517
255
|
};
|
|
518
256
|
}
|
|
519
257
|
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
258
|
+
// src/core/context.ts
|
|
259
|
+
// -----------------------------------------------------------
|
|
260
|
+
// Adaptive Context Engine — Geo + Stability Enrichment + Persistence
|
|
261
|
+
// -----------------------------------------------------------
|
|
262
|
+
const MAX_HISTORY = 100;
|
|
263
|
+
const CACHE_KEY = "zubbl-stability";
|
|
264
|
+
let state = loadStability() ?? {
|
|
265
|
+
routes: [],
|
|
266
|
+
geo: "unknown",
|
|
267
|
+
stability: 1.0,
|
|
268
|
+
lastGeoChange: Date.now(),
|
|
269
|
+
};
|
|
270
|
+
// -----------------------------------------------------------
|
|
271
|
+
// 💾 Persistence Helpers
|
|
272
|
+
// -----------------------------------------------------------
|
|
273
|
+
function loadStability() {
|
|
274
|
+
if (!isBrowser || typeof localStorage === "undefined")
|
|
275
|
+
return null;
|
|
530
276
|
try {
|
|
531
|
-
const
|
|
532
|
-
|
|
277
|
+
const raw = localStorage.getItem(CACHE_KEY);
|
|
278
|
+
if (!raw)
|
|
279
|
+
return null;
|
|
280
|
+
const parsed = JSON.parse(raw);
|
|
281
|
+
if (!parsed.geo || !parsed.stability)
|
|
282
|
+
return null;
|
|
283
|
+
return { ...parsed, routes: [] };
|
|
533
284
|
}
|
|
534
|
-
catch
|
|
535
|
-
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
536
287
|
}
|
|
537
288
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
289
|
+
function saveStability() {
|
|
290
|
+
if (!isBrowser || typeof localStorage === "undefined")
|
|
291
|
+
return;
|
|
292
|
+
try {
|
|
293
|
+
const payload = {
|
|
294
|
+
geo: state.geo,
|
|
295
|
+
stability: state.stability,
|
|
296
|
+
lastGeoChange: state.lastGeoChange,
|
|
297
|
+
};
|
|
298
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
|
545
299
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const headers = buildHeaders({
|
|
549
|
-
"Content-Type": "application/json",
|
|
550
|
-
...adaptiveHeaders,
|
|
551
|
-
...geoHeaders,
|
|
552
|
-
});
|
|
553
|
-
const body = JSON.stringify({ email, name });
|
|
554
|
-
const resp = await httpRequest(`${config.baseUrl}/sdk/identify`, {
|
|
555
|
-
method: "POST",
|
|
556
|
-
headers,
|
|
557
|
-
body,
|
|
558
|
-
});
|
|
559
|
-
const data = await resp.json().catch(() => ({}));
|
|
560
|
-
if (!data.external_user_id) {
|
|
561
|
-
console.warn("[Zubbl SDK] identifyUser: missing external_user_id");
|
|
300
|
+
catch {
|
|
301
|
+
/* ignore */
|
|
562
302
|
}
|
|
563
|
-
return data;
|
|
564
303
|
}
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
304
|
+
// -----------------------------------------------------------
|
|
305
|
+
// 🌍 Geo Detection (Node, Browser, Worker)
|
|
306
|
+
// -----------------------------------------------------------
|
|
307
|
+
function detectGeo() {
|
|
308
|
+
try {
|
|
309
|
+
if (isBrowser && typeof window !== "undefined" && window.navigator?.language) {
|
|
310
|
+
return window.navigator.language.split("-")[1] || "unknown";
|
|
311
|
+
}
|
|
312
|
+
if (isWorker && typeof self !== "undefined" && self.navigator?.language) {
|
|
313
|
+
return self.navigator.language.split("-")[1] || "unknown";
|
|
314
|
+
}
|
|
315
|
+
if (isNode && typeof process !== "undefined") {
|
|
316
|
+
const envGeo = process.env.ZUBBL_GEO || process.env.GEO;
|
|
317
|
+
if (envGeo && /^[A-Z]{2}$/i.test(envGeo))
|
|
318
|
+
return envGeo.toUpperCase();
|
|
319
|
+
return "GB";
|
|
320
|
+
}
|
|
321
|
+
return "unknown";
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.warn("[Zubbl SDK][Geo Detect] Failed:", err);
|
|
325
|
+
return "unknown";
|
|
572
326
|
}
|
|
573
|
-
if (!external_user_id)
|
|
574
|
-
throw new Error("external_user_id is required");
|
|
575
|
-
const adaptiveHeaders = getAdaptiveHeaders();
|
|
576
|
-
const geoHeaders = await getGeoHeaders();
|
|
577
|
-
const headers = buildHeaders({
|
|
578
|
-
"X-External-User-Id": external_user_id,
|
|
579
|
-
...adaptiveHeaders,
|
|
580
|
-
...geoHeaders,
|
|
581
|
-
});
|
|
582
|
-
const params = new URLSearchParams({ external_user_id });
|
|
583
|
-
params.set("app_id", app_id || config.appId);
|
|
584
|
-
const resp = await httpRequest(`${config.baseUrl.replace(/\/$/, "")}/tiles/effective?${params.toString()}`, { method: "GET", headers });
|
|
585
|
-
return resp.json();
|
|
586
327
|
}
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
if (
|
|
593
|
-
|
|
328
|
+
// -----------------------------------------------------------
|
|
329
|
+
// 🧭 Update Geo & Stability (with persistence)
|
|
330
|
+
// -----------------------------------------------------------
|
|
331
|
+
function updateGeo(geoHint) {
|
|
332
|
+
const newGeo = geoHint || detectGeo();
|
|
333
|
+
if (newGeo !== state.geo) {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
const elapsed = (now - state.lastGeoChange) / 1000;
|
|
336
|
+
state.stability = Math.max(0.5, Math.min(1.0, elapsed / 3600));
|
|
337
|
+
state.geo = newGeo;
|
|
338
|
+
state.lastGeoChange = now;
|
|
339
|
+
saveStability();
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
state.stability = Math.min(1.0, state.stability + 0.01);
|
|
343
|
+
saveStability();
|
|
594
344
|
}
|
|
595
|
-
const adaptiveHeaders = getAdaptiveHeaders();
|
|
596
|
-
const geoHeaders = await getGeoHeaders();
|
|
597
|
-
const headers = buildHeaders({
|
|
598
|
-
"X-External-User-Id": external_user_id,
|
|
599
|
-
...adaptiveHeaders,
|
|
600
|
-
...geoHeaders,
|
|
601
|
-
});
|
|
602
|
-
const resp = await httpRequest(`${config.baseUrl.replace(/\/$/, "")}/sdk/test-enforcement`, { method: "POST", headers });
|
|
603
|
-
const json = await resp.json().catch(() => ({}));
|
|
604
|
-
return { decision: "allow", status: resp.status, data: json };
|
|
605
345
|
}
|
|
606
|
-
// ---------------------------------------------------------
|
|
607
|
-
// Exposed Helpers
|
|
608
|
-
// ---------------------------------------------------------
|
|
609
|
-
const storage = createStorage();
|
|
610
|
-
|
|
611
346
|
// -----------------------------------------------------------
|
|
612
|
-
//
|
|
347
|
+
// 🧮 Record Context Samples
|
|
613
348
|
// -----------------------------------------------------------
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return Math.random() < sampleRatio;
|
|
349
|
+
function recordContextSample(path, method, status, latency) {
|
|
350
|
+
state.routes.push({ path, method, ts: Date.now(), status, latency });
|
|
351
|
+
if (state.routes.length > MAX_HISTORY)
|
|
352
|
+
state.routes.shift();
|
|
353
|
+
updateGeo(); // auto-refresh geo info
|
|
620
354
|
}
|
|
621
355
|
// -----------------------------------------------------------
|
|
622
|
-
//
|
|
356
|
+
// 📦 Get Adaptive Headers
|
|
623
357
|
// -----------------------------------------------------------
|
|
624
|
-
function
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
358
|
+
function getAdaptiveHeaders() {
|
|
359
|
+
const currentGeo = detectGeo();
|
|
360
|
+
updateGeo(currentGeo);
|
|
361
|
+
const summary = btoa(JSON.stringify({
|
|
362
|
+
env: isNode ? "node" : isBrowser ? "browser" : "worker",
|
|
363
|
+
routeCount: state.routes.length,
|
|
364
|
+
lastGeo: state.geo,
|
|
365
|
+
avgLatency: state.routes.reduce((a, b) => a + b.latency, 0) /
|
|
366
|
+
Math.max(1, state.routes.length),
|
|
367
|
+
}));
|
|
368
|
+
return {
|
|
369
|
+
"X-Zubbl-Context-Summary": summary,
|
|
370
|
+
"X-Zubbl-Geo-Stability": `${state.geo}/${state.stability.toFixed(2)}`,
|
|
631
371
|
};
|
|
632
|
-
buffer.push(enriched);
|
|
633
|
-
if (!debounceTimer) {
|
|
634
|
-
debounceTimer = setTimeout(flushBuffer, 200);
|
|
635
|
-
}
|
|
636
372
|
}
|
|
637
373
|
// -----------------------------------------------------------
|
|
638
|
-
//
|
|
374
|
+
// 🌍 Initialize Geo Context Immediately
|
|
639
375
|
// -----------------------------------------------------------
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
376
|
+
updateGeo(process.env.ZUBBL_GEO);
|
|
377
|
+
|
|
378
|
+
// -----------------------------------------------------------
|
|
379
|
+
// 🔐 Secure Signing & Nonce Generator
|
|
380
|
+
// -----------------------------------------------------------
|
|
381
|
+
// -----------------------------------------------------------
|
|
382
|
+
// Generate UUIDv4 Nonce
|
|
383
|
+
// -----------------------------------------------------------
|
|
384
|
+
function generateNonce() {
|
|
385
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
|
|
386
|
+
const r = (Math.random() * 16) | 0;
|
|
387
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
388
|
+
return v.toString(16);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
// -----------------------------------------------------------
|
|
392
|
+
// Attach signing headers to a request
|
|
393
|
+
// -----------------------------------------------------------
|
|
394
|
+
async function attachSigningHeaders(method, url, body, secretKey) {
|
|
395
|
+
const nonce = generateNonce();
|
|
396
|
+
// Compute canonical string (deterministic)
|
|
397
|
+
const bodyHash = "";
|
|
398
|
+
`${method.toUpperCase()}\n${url}\n${nonce}\n${bodyHash}`;
|
|
399
|
+
// ⚙️ Always include nonce even if secret is missing
|
|
400
|
+
{
|
|
401
|
+
return { "X-Zubbl-Nonce": nonce };
|
|
644
402
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/core/http.ts
|
|
406
|
+
// -----------------------------------------------------------
|
|
407
|
+
// HTTP utility (instance-safe)
|
|
408
|
+
// -----------------------------------------------------------
|
|
409
|
+
/**
|
|
410
|
+
* Performs a safe HTTP request with adaptive + signing headers.
|
|
411
|
+
* NOTE: no global getConfig() dependency — caller must provide fully resolved URL + headers.
|
|
412
|
+
*/
|
|
413
|
+
async function httpRequest(url, options = {}) {
|
|
414
|
+
// 🔧 Default timeout lowered for SDK-12 hardening
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
656
417
|
try {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
headers
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
418
|
+
const finalOptions = {
|
|
419
|
+
...options,
|
|
420
|
+
headers: {
|
|
421
|
+
...options.headers,
|
|
422
|
+
...getAdaptiveHeaders(),
|
|
423
|
+
},
|
|
424
|
+
signal: controller.signal,
|
|
425
|
+
};
|
|
426
|
+
attachSigningHeaders(finalOptions.headers);
|
|
427
|
+
// Record adaptive context sample
|
|
428
|
+
recordContextSample();
|
|
429
|
+
const response = await fetch(url, finalOptions);
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
console.warn(`[Zubbl SDK][HTTP] ${response.status} on ${url}`);
|
|
432
|
+
}
|
|
433
|
+
return response;
|
|
664
434
|
}
|
|
665
435
|
catch (err) {
|
|
666
|
-
console.
|
|
436
|
+
console.error("[Zubbl SDK][HTTP] Request failed:", err?.message);
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
clearTimeout(timeout);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/features/identifyUser.ts
|
|
445
|
+
// -----------------------------------------------------------
|
|
446
|
+
// Zubbl SDK — Instance-safe identifyUser feature
|
|
447
|
+
// -----------------------------------------------------------
|
|
448
|
+
const USER_CACHE_KEY = "external_user_id";
|
|
449
|
+
const USER_TTL = 24 * 60 * 60; // 24h in seconds
|
|
450
|
+
/**
|
|
451
|
+
* Identify or auto-create a user by email/name.
|
|
452
|
+
* Instance-safe: requires store (from createConfigStore()) passed in.
|
|
453
|
+
* Idempotent: returns cached UUID if available.
|
|
454
|
+
*/
|
|
455
|
+
async function identifyUserFeature(store, { email, name }) {
|
|
456
|
+
if (!email)
|
|
457
|
+
throw new Error("email is required");
|
|
458
|
+
const config = store.get();
|
|
459
|
+
const storage = createStorage();
|
|
460
|
+
// ⚡ Step 1 — Check cache
|
|
461
|
+
const cached = storage.get(USER_CACHE_KEY);
|
|
462
|
+
if (cached?.id) {
|
|
463
|
+
return { external_user_id: cached.id, cached: true };
|
|
464
|
+
}
|
|
465
|
+
// ⚙️ Step 2 — Prepare headers and request backend
|
|
466
|
+
const adaptiveHeaders = getAdaptiveHeaders();
|
|
467
|
+
const geoHeaders = await getGeoHeaders();
|
|
468
|
+
const headers = store.buildHeaders({
|
|
469
|
+
"Content-Type": "application/json",
|
|
470
|
+
...adaptiveHeaders,
|
|
471
|
+
...geoHeaders,
|
|
472
|
+
});
|
|
473
|
+
const resp = await httpRequest(`${config.baseUrl}/sdk/identify`, {
|
|
474
|
+
method: "POST",
|
|
475
|
+
headers,
|
|
476
|
+
body: JSON.stringify({ email, name }),
|
|
477
|
+
});
|
|
478
|
+
const data = await resp.json().catch(() => ({}));
|
|
479
|
+
const { external_user_id } = data;
|
|
480
|
+
if (!external_user_id) {
|
|
481
|
+
throw new Error("Backend did not return external_user_id");
|
|
667
482
|
}
|
|
483
|
+
// 🗃️ Step 3 — Cache for 24 h
|
|
484
|
+
storage.set(USER_CACHE_KEY, { id: external_user_id }, USER_TTL);
|
|
485
|
+
return { external_user_id, cached: false };
|
|
668
486
|
}
|
|
487
|
+
|
|
488
|
+
// src/features/getTiles.ts
|
|
669
489
|
// -----------------------------------------------------------
|
|
670
|
-
//
|
|
490
|
+
// Zubbl SDK — Instance-safe getTiles feature
|
|
671
491
|
// -----------------------------------------------------------
|
|
672
|
-
|
|
492
|
+
const TILE_CACHE_KEY = "tiles_cache";
|
|
493
|
+
const TILE_TTL_DEFAULT = 10 * 60; // 10 min in seconds
|
|
494
|
+
/**
|
|
495
|
+
* Fetch effective tiles with caching, ETag, and background refresh.
|
|
496
|
+
* Instance-safe: requires store (from createConfigStore()) passed in.
|
|
497
|
+
*/
|
|
498
|
+
async function getTilesFeature(store, { external_user_id, app_id = null, ttlSeconds = TILE_TTL_DEFAULT, }) {
|
|
499
|
+
if (!external_user_id)
|
|
500
|
+
throw new Error("external_user_id is required");
|
|
501
|
+
const config = store.get();
|
|
502
|
+
const storage = createStorage();
|
|
503
|
+
const cached = storage.get(TILE_CACHE_KEY);
|
|
504
|
+
const isFresh = cached && cached.expiresAt > Date.now();
|
|
505
|
+
// ⚡ Return cached data immediately if valid
|
|
506
|
+
if (isFresh) {
|
|
507
|
+
backgroundRefresh(store, external_user_id, app_id, ttlSeconds);
|
|
508
|
+
return { data: cached.data, fromCache: true };
|
|
509
|
+
}
|
|
673
510
|
try {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
...extra,
|
|
511
|
+
const adaptiveHeaders = getAdaptiveHeaders();
|
|
512
|
+
const geoHeaders = await getGeoHeaders();
|
|
513
|
+
const headers = store.buildHeaders({
|
|
514
|
+
"X-External-User-Id": external_user_id,
|
|
515
|
+
...adaptiveHeaders,
|
|
516
|
+
...geoHeaders,
|
|
681
517
|
});
|
|
518
|
+
if (cached?.etag)
|
|
519
|
+
headers["If-None-Match"] = cached.etag;
|
|
520
|
+
const params = new URLSearchParams({ external_user_id });
|
|
521
|
+
params.set("app_id", app_id || config.appId);
|
|
522
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/tiles/effective?${params.toString()}`;
|
|
523
|
+
const resp = await httpRequest(url, { headers });
|
|
524
|
+
if (resp.status === 304 && cached?.data) {
|
|
525
|
+
cached.expiresAt = Date.now() + ttlSeconds * 1000;
|
|
526
|
+
storage.set(TILE_CACHE_KEY, cached, ttlSeconds);
|
|
527
|
+
return { data: cached.data, fromCache: true, status: 304 };
|
|
528
|
+
}
|
|
529
|
+
const data = await resp.json();
|
|
530
|
+
const etag = resp.headers.get("ETag") || undefined;
|
|
531
|
+
const entry = {
|
|
532
|
+
data,
|
|
533
|
+
etag,
|
|
534
|
+
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
535
|
+
};
|
|
536
|
+
storage.set(TILE_CACHE_KEY, entry, ttlSeconds);
|
|
537
|
+
return { data, fromCache: false, status: resp.status };
|
|
682
538
|
}
|
|
683
539
|
catch (err) {
|
|
684
|
-
|
|
540
|
+
if (cached?.data) {
|
|
541
|
+
return { data: cached.data, fromCache: true, error: String(err) };
|
|
542
|
+
}
|
|
543
|
+
throw err;
|
|
685
544
|
}
|
|
686
545
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
546
|
+
/**
|
|
547
|
+
* Background refresh (non-blocking).
|
|
548
|
+
*/
|
|
549
|
+
async function backgroundRefresh(store, external_user_id, app_id, ttlSeconds) {
|
|
550
|
+
try {
|
|
551
|
+
await getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
// Silently ignore background refresh errors
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/client.ts
|
|
559
|
+
// -----------------------------------------------------------
|
|
560
|
+
// Zubbl SDK — Final Instance-based Client (Feature Delegation)
|
|
561
|
+
// -----------------------------------------------------------
|
|
562
|
+
/**
|
|
563
|
+
* Creates a self-contained, instance-based Zubbl client.
|
|
564
|
+
* Each instance owns its own config store and initialization state.
|
|
565
|
+
*/
|
|
566
|
+
function createZubblClient(cfg) {
|
|
567
|
+
const store = createConfigStore();
|
|
568
|
+
const storage = createStorage();
|
|
569
|
+
let initialized = false;
|
|
570
|
+
// ---------------------------------------------------------
|
|
571
|
+
// Initialization
|
|
572
|
+
// ---------------------------------------------------------
|
|
573
|
+
async function init() {
|
|
574
|
+
if (initialized)
|
|
575
|
+
return;
|
|
576
|
+
// Initialize per-client config
|
|
577
|
+
store.init(cfg);
|
|
578
|
+
// Optional: GEO bootstrap
|
|
579
|
+
try {
|
|
580
|
+
const geo = await getGeoInfo();
|
|
581
|
+
console.log(`[Zubbl SDK] 🌍 GEO detected: ${geo.country} (ASN ${geo.asn || "?"})`);
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
console.warn("[Zubbl SDK] GEO init failed:", err?.message);
|
|
585
|
+
}
|
|
586
|
+
initialized = true;
|
|
587
|
+
}
|
|
588
|
+
function assertInitialized() {
|
|
589
|
+
if (!initialized) {
|
|
590
|
+
throw new Error("Zubbl client not initialized — call client.init() first");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// ---------------------------------------------------------
|
|
594
|
+
// User + Tile Operations (delegated to feature modules)
|
|
595
|
+
// ---------------------------------------------------------
|
|
596
|
+
async function identifyUser({ email, name }) {
|
|
597
|
+
assertInitialized();
|
|
598
|
+
return identifyUserFeature(store, { email, name });
|
|
599
|
+
}
|
|
600
|
+
async function getTiles({ external_user_id, app_id = null, ttlSeconds, }) {
|
|
601
|
+
assertInitialized();
|
|
602
|
+
return getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
|
|
603
|
+
}
|
|
604
|
+
// ---------------------------------------------------------
|
|
605
|
+
// Enforcement API (kept inline)
|
|
606
|
+
// ---------------------------------------------------------
|
|
607
|
+
async function enforce({ external_user_id, app_id = null, }) {
|
|
608
|
+
assertInitialized();
|
|
609
|
+
const config = store.get();
|
|
610
|
+
const headers = store.buildHeaders({
|
|
611
|
+
"X-External-User-Id": external_user_id,
|
|
612
|
+
});
|
|
613
|
+
const resp = await fetch(`${config.baseUrl?.replace(/\/$/, "")}/sdk/test-enforcement`, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers,
|
|
616
|
+
});
|
|
617
|
+
const json = await resp.json().catch(() => ({}));
|
|
618
|
+
return { decision: "allow", status: resp.status, data: json };
|
|
619
|
+
}
|
|
620
|
+
// ---------------------------------------------------------
|
|
621
|
+
// Public API
|
|
622
|
+
// ---------------------------------------------------------
|
|
623
|
+
return {
|
|
624
|
+
init,
|
|
625
|
+
identifyUser,
|
|
626
|
+
getTiles,
|
|
627
|
+
enforce,
|
|
628
|
+
storage,
|
|
629
|
+
};
|
|
700
630
|
}
|
|
701
631
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
632
|
+
// src/index.ts — Backward-compatible singleton API
|
|
633
|
+
let defaultClient = null;
|
|
634
|
+
async function init(config) {
|
|
635
|
+
if (!defaultClient)
|
|
636
|
+
defaultClient = createZubblClient(config);
|
|
637
|
+
return defaultClient.init();
|
|
638
|
+
}
|
|
639
|
+
async function identifyUser(...args) {
|
|
640
|
+
if (!defaultClient)
|
|
641
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
642
|
+
return defaultClient.identifyUser(...args);
|
|
643
|
+
}
|
|
644
|
+
async function getTiles(...args) {
|
|
645
|
+
if (!defaultClient)
|
|
646
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
647
|
+
return defaultClient.getTiles(...args);
|
|
648
|
+
}
|
|
649
|
+
async function enforce(...args) {
|
|
650
|
+
if (!defaultClient)
|
|
651
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
652
|
+
return defaultClient.enforce(...args);
|
|
653
|
+
}
|
|
708
654
|
|
|
709
|
-
export {
|
|
655
|
+
export { createZubblClient, enforce, getTiles, identifyUser, init };
|
|
710
656
|
//# sourceMappingURL=zubbl-sdk.esm.js.map
|