zubbl-sdk 1.1.15 → 1.1.16

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