zubbl-sdk 1.1.18 → 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.
@@ -1,4 +1,4 @@
1
- import crypto from 'crypto';
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
- let config = {
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 = config.apiKey ?? "";
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
- // Public API
38
- // -----------------------------------------------------------
39
- function init$1(partial) {
40
- config = { ...config, ...partial };
41
- assertBrowserSafety(config); // 🔒 check that key is safe in browser
42
- }
43
- function getConfig() {
44
- return config;
45
- }
46
- // -----------------------------------------------------------
47
- // Header builder (used by HTTP layer)
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
- // src/core/context.ts
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: [] };
88
- }
89
- catch {
90
- return null;
38
+ function init(partial) {
39
+ current = { ...current, ...partial };
40
+ assertBrowserSafety(current);
91
41
  }
92
- }
93
- function saveStability() {
94
- if (!isBrowser || typeof localStorage === "undefined")
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 */
42
+ function get() {
43
+ return current;
106
44
  }
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";
45
+ function buildHeaders(extra = {}) {
46
+ if (!current.apiKey || !current.tenantId || !current.appId) {
47
+ throw new Error("[Zubbl SDK] Not initialized – call init() first.");
115
48
  }
116
- if (isWorker && typeof self !== "undefined" && self.navigator?.language) {
117
- return self.navigator.language.split("-")[1] || "unknown";
118
- }
119
- if (isNode && typeof process !== "undefined") {
120
- const envGeo = process.env.ZUBBL_GEO || process.env.GEO;
121
- if (envGeo && /^[A-Z]{2}$/i.test(envGeo))
122
- return envGeo.toUpperCase();
123
- return "GB";
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,14 +255,327 @@ async function getGeoHeaders() {
517
255
  };
518
256
  }
519
257
 
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;
276
+ try {
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: [] };
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
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));
299
+ }
300
+ catch {
301
+ /* ignore */
302
+ }
303
+ }
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";
326
+ }
327
+ }
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();
344
+ }
345
+ }
346
+ // -----------------------------------------------------------
347
+ // 🧮 Record Context Samples
348
+ // -----------------------------------------------------------
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
354
+ }
355
+ // -----------------------------------------------------------
356
+ // 📦 Get Adaptive Headers
357
+ // -----------------------------------------------------------
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)}`,
371
+ };
372
+ }
373
+ // -----------------------------------------------------------
374
+ // 🌍 Initialize Geo Context Immediately
375
+ // -----------------------------------------------------------
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 };
402
+ }
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);
417
+ try {
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;
434
+ }
435
+ catch (err) {
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");
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 };
486
+ }
487
+
488
+ // src/features/getTiles.ts
489
+ // -----------------------------------------------------------
490
+ // Zubbl SDK — Instance-safe getTiles feature
491
+ // -----------------------------------------------------------
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
+ }
510
+ try {
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,
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 };
538
+ }
539
+ catch (err) {
540
+ if (cached?.data) {
541
+ return { data: cached.data, fromCache: true, error: String(err) };
542
+ }
543
+ throw err;
544
+ }
545
+ }
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
+
520
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
+ */
521
566
  function createZubblClient(cfg) {
522
- let initialized = false;
567
+ const store = createConfigStore();
523
568
  const storage = createStorage();
569
+ let initialized = false;
570
+ // ---------------------------------------------------------
571
+ // Initialization
572
+ // ---------------------------------------------------------
524
573
  async function init() {
525
574
  if (initialized)
526
575
  return;
527
- init$1(cfg);
576
+ // Initialize per-client config
577
+ store.init(cfg);
578
+ // Optional: GEO bootstrap
528
579
  try {
529
580
  const geo = await getGeoInfo();
530
581
  console.log(`[Zubbl SDK] 🌍 GEO detected: ${geo.country} (ASN ${geo.asn || "?"})`);
@@ -539,55 +590,36 @@ function createZubblClient(cfg) {
539
590
  throw new Error("Zubbl client not initialized — call client.init() first");
540
591
  }
541
592
  }
593
+ // ---------------------------------------------------------
594
+ // User + Tile Operations (delegated to feature modules)
595
+ // ---------------------------------------------------------
542
596
  async function identifyUser({ email, name }) {
543
597
  assertInitialized();
544
- const adaptiveHeaders = getAdaptiveHeaders();
545
- const geoHeaders = await getGeoHeaders();
546
- const headers = buildHeaders({
547
- "Content-Type": "application/json",
548
- ...adaptiveHeaders,
549
- ...geoHeaders,
550
- });
551
- const body = JSON.stringify({ email, name });
552
- const resp = await httpRequest(`${cfg.baseUrl}/sdk/identify`, {
553
- method: "POST",
554
- headers,
555
- body,
556
- });
557
- return resp.json().catch(() => ({}));
598
+ return identifyUserFeature(store, { email, name });
558
599
  }
559
- async function getTiles({ external_user_id, app_id = null }) {
600
+ async function getTiles({ external_user_id, app_id = null, ttlSeconds, }) {
560
601
  assertInitialized();
561
- if (!external_user_id)
562
- throw new Error("external_user_id is required");
563
- const adaptiveHeaders = getAdaptiveHeaders();
564
- const geoHeaders = await getGeoHeaders();
565
- const headers = buildHeaders({
566
- "X-External-User-Id": external_user_id,
567
- ...adaptiveHeaders,
568
- ...geoHeaders,
569
- });
570
- const params = new URLSearchParams({ external_user_id });
571
- params.set("app_id", app_id || cfg.appId);
572
- const resp = await httpRequest(`${cfg.baseUrl?.replace(/\/$/, "")}/tiles/effective?${params.toString()}`, { method: "GET", headers });
573
- return resp.json();
602
+ return getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
574
603
  }
575
- async function enforce({ external_user_id, app_id = null }) {
604
+ // ---------------------------------------------------------
605
+ // Enforcement API (kept inline)
606
+ // ---------------------------------------------------------
607
+ async function enforce({ external_user_id, app_id = null, }) {
576
608
  assertInitialized();
577
- const adaptiveHeaders = getAdaptiveHeaders();
578
- const geoHeaders = await getGeoHeaders();
579
- const headers = buildHeaders({
609
+ const config = store.get();
610
+ const headers = store.buildHeaders({
580
611
  "X-External-User-Id": external_user_id,
581
- ...adaptiveHeaders,
582
- ...geoHeaders,
583
612
  });
584
- const resp = await httpRequest(`${cfg.baseUrl?.replace(/\/$/, "")}/sdk/test-enforcement`, {
613
+ const resp = await fetch(`${config.baseUrl?.replace(/\/$/, "")}/sdk/test-enforcement`, {
585
614
  method: "POST",
586
615
  headers,
587
616
  });
588
617
  const json = await resp.json().catch(() => ({}));
589
618
  return { decision: "allow", status: resp.status, data: json };
590
619
  }
620
+ // ---------------------------------------------------------
621
+ // Public API
622
+ // ---------------------------------------------------------
591
623
  return {
592
624
  init,
593
625
  identifyUser,
@@ -620,103 +652,5 @@ async function enforce(...args) {
620
652
  return defaultClient.enforce(...args);
621
653
  }
622
654
 
623
- // -----------------------------------------------------------
624
- // Zubbl Telemetry Core (Endpoint Discovery)
625
- // -----------------------------------------------------------
626
- // Local telemetry buffer
627
- let sampleRatio = 0.1; // 1 in 10 by default
628
- let buffer = [];
629
- let debounceTimer = null;
630
- function shouldSample() {
631
- return Math.random() < sampleRatio;
632
- }
633
- // -----------------------------------------------------------
634
- // Emit telemetry events
635
- // -----------------------------------------------------------
636
- function emitEndpoint(payload) {
637
- if (!shouldSample())
638
- return;
639
- const enriched = {
640
- ...payload,
641
- ts: Date.now(),
642
- source: isBrowser ? "browser" : isNode ? "node" : "worker",
643
- };
644
- buffer.push(enriched);
645
- if (!debounceTimer) {
646
- debounceTimer = setTimeout(flushBuffer, 200);
647
- }
648
- }
649
- // -----------------------------------------------------------
650
- // Flush logic (batch send)
651
- // -----------------------------------------------------------
652
- async function flushBuffer() {
653
- if (buffer.length === 0) {
654
- debounceTimer = null;
655
- return;
656
- }
657
- const batch = [...buffer];
658
- buffer = [];
659
- debounceTimer = null;
660
- const config = getConfig();
661
- const url = `${config.baseUrl.replace(/\/$/, "")}/sdk/log`;
662
- const headers = {
663
- "Content-Type": "application/json",
664
- Authorization: `Bearer ${config.apiKey}`,
665
- "X-Tenant-Id": config.tenantId ?? "",
666
- "X-App-Id": config.appId ?? "",
667
- };
668
- try {
669
- const resp = await fetch(url, {
670
- method: "POST",
671
- headers,
672
- body: JSON.stringify(batch),
673
- });
674
- const text = await resp.text();
675
- console.log("[Zubbl SDK][Telemetry] Flushed", batch.length, "events →", resp.status, text);
676
- }
677
- catch (err) {
678
- console.warn("[Zubbl SDK][Telemetry] Failed to send batch:", err.message);
679
- }
680
- }
681
- // -----------------------------------------------------------
682
- // Helper for HTTP wrapping
683
- // -----------------------------------------------------------
684
- async function recordRequestTelemetry(url, method, status, latency, extra = {}) {
685
- try {
686
- const path = new URL(url).pathname;
687
- emitEndpoint({
688
- path,
689
- method,
690
- status,
691
- latency,
692
- ...extra,
693
- });
694
- }
695
- catch (err) {
696
- console.warn("[Zubbl SDK][Telemetry] Failed to record telemetry", err);
697
- }
698
- }
699
- // -----------------------------------------------------------
700
- // Runtime Endpoint Discovery
701
- // -----------------------------------------------------------
702
- let hasLoggedDiscovery = false;
703
- function logRuntimeDiscovery(url) {
704
- if (hasLoggedDiscovery)
705
- return; // only once per runtime
706
- hasLoggedDiscovery = true;
707
- const runtime = isNode ? "Node" : isBrowser ? "Browser" : isWorker ? "Worker" : "Unknown";
708
- const time = new Date().toISOString();
709
- console.log(`[Zubbl SDK][Discovery] Runtime: ${runtime}`);
710
- console.log(`[Zubbl SDK][Discovery] Timestamp: ${time}`);
711
- console.log(`[Zubbl SDK][Discovery] Initial endpoint: ${url}`);
712
- }
713
-
714
- var telemetry = /*#__PURE__*/Object.freeze({
715
- __proto__: null,
716
- emitEndpoint: emitEndpoint,
717
- logRuntimeDiscovery: logRuntimeDiscovery,
718
- recordRequestTelemetry: recordRequestTelemetry
719
- });
720
-
721
655
  export { createZubblClient, enforce, getTiles, identifyUser, init };
722
656
  //# sourceMappingURL=zubbl-sdk.esm.js.map