zubbl-sdk 1.1.18 → 1.1.20

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