zubbl-sdk 1.1.16 → 1.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/client.d.ts +51 -0
- package/dist/types/core/config.d.ts +9 -3
- package/dist/types/core/config.original.d.ts +14 -0
- package/dist/types/core/http.d.ts +5 -4
- package/dist/types/core/http.original.d.ts +4 -0
- package/dist/types/core/telemetry.d.ts +2 -2
- package/dist/types/features/getTiles.d.ts +3 -2
- package/dist/types/features/identifyUser.d.ts +2 -1
- package/dist/types/index.d.ts +24 -24
- package/dist/zubbl-sdk.cjs.js +392 -448
- package/dist/zubbl-sdk.cjs.js.map +1 -1
- package/dist/zubbl-sdk.esm.js +392 -446
- package/dist/zubbl-sdk.esm.js.map +1 -1
- package/dist/zubbl-sdk.umd.js +393 -449
- package/dist/zubbl-sdk.umd.js.map +1 -1
- package/package.json +1 -1
package/dist/zubbl-sdk.umd.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
(function (global, factory) {
|
|
2
2
|
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('crypto')) :
|
|
3
3
|
typeof define === 'function' && define.amd ? define(['exports', 'crypto'], factory) :
|
|
4
|
-
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ZubblSdk = {}
|
|
5
|
-
})(this, (function (exports
|
|
4
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ZubblSdk = {}));
|
|
5
|
+
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
7
|
// src/core/config.ts
|
|
8
8
|
// -----------------------------------------------------------
|
|
@@ -15,21 +15,9 @@
|
|
|
15
15
|
const isWorker = typeof self !== "undefined" &&
|
|
16
16
|
typeof self.importScripts === "function" &&
|
|
17
17
|
!isNode;
|
|
18
|
-
|
|
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) {
|
|
18
|
+
function assertBrowserSafety(cfg) {
|
|
30
19
|
if (isBrowser || isWorker) {
|
|
31
|
-
const key =
|
|
32
|
-
// Simple rule: if the key looks like a secret (sk_ or very long)
|
|
20
|
+
const key = cfg.apiKey ?? "";
|
|
33
21
|
const looksSecret = key.startsWith("sk_") || key.length > 40;
|
|
34
22
|
if (looksSecret) {
|
|
35
23
|
throw new Error("[Zubbl SDK] Secret or server key detected in browser environment. " +
|
|
@@ -37,290 +25,40 @@
|
|
|
37
25
|
}
|
|
38
26
|
}
|
|
39
27
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
28
|
+
/**
|
|
29
|
+
* Creates an isolated configuration store.
|
|
30
|
+
* Each ZubblClient instance owns its own store.
|
|
31
|
+
*/
|
|
32
|
+
function createConfigStore(initial) {
|
|
33
|
+
let current = {
|
|
34
|
+
apiKey: null,
|
|
35
|
+
tenantId: null,
|
|
36
|
+
appId: null,
|
|
37
|
+
baseUrl: "https://api.zubbl.com/api",
|
|
38
|
+
injectWorkerHeaders: false,
|
|
39
|
+
workerSecret: null,
|
|
40
|
+
...initial,
|
|
63
41
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(),
|
|
77
|
-
};
|
|
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: [] };
|
|
42
|
+
function init(partial) {
|
|
43
|
+
current = { ...current, ...partial };
|
|
44
|
+
assertBrowserSafety(current);
|
|
92
45
|
}
|
|
93
|
-
|
|
94
|
-
return
|
|
46
|
+
function get() {
|
|
47
|
+
return current;
|
|
95
48
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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";
|
|
49
|
+
function buildHeaders(extra = {}) {
|
|
50
|
+
if (!current.apiKey || !current.tenantId || !current.appId) {
|
|
51
|
+
throw new Error("[Zubbl SDK] Not initialized – call init() first.");
|
|
122
52
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
185
|
-
|
|
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);
|
|
197
|
-
});
|
|
198
|
-
}
|
|
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");
|
|
216
|
-
}
|
|
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
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
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,
|
|
53
|
+
return {
|
|
54
|
+
Authorization: `Bearer ${current.apiKey}`,
|
|
55
|
+
"X-Tenant-Id": current.tenantId,
|
|
56
|
+
"X-App-Id": current.appId,
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
...extra,
|
|
285
59
|
};
|
|
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
60
|
}
|
|
61
|
+
return { init, get, buildHeaders };
|
|
324
62
|
}
|
|
325
63
|
|
|
326
64
|
// src/core/storage.ts
|
|
@@ -521,202 +259,408 @@
|
|
|
521
259
|
};
|
|
522
260
|
}
|
|
523
261
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
262
|
+
// src/core/context.ts
|
|
263
|
+
// -----------------------------------------------------------
|
|
264
|
+
// Adaptive Context Engine — Geo + Stability Enrichment + Persistence
|
|
265
|
+
// -----------------------------------------------------------
|
|
266
|
+
const MAX_HISTORY = 100;
|
|
267
|
+
const CACHE_KEY = "zubbl-stability";
|
|
268
|
+
let state = loadStability() ?? {
|
|
269
|
+
routes: [],
|
|
270
|
+
geo: "unknown",
|
|
271
|
+
stability: 1.0,
|
|
272
|
+
lastGeoChange: Date.now(),
|
|
273
|
+
};
|
|
274
|
+
// -----------------------------------------------------------
|
|
275
|
+
// 💾 Persistence Helpers
|
|
276
|
+
// -----------------------------------------------------------
|
|
277
|
+
function loadStability() {
|
|
278
|
+
if (!isBrowser || typeof localStorage === "undefined")
|
|
279
|
+
return null;
|
|
534
280
|
try {
|
|
535
|
-
const
|
|
536
|
-
|
|
281
|
+
const raw = localStorage.getItem(CACHE_KEY);
|
|
282
|
+
if (!raw)
|
|
283
|
+
return null;
|
|
284
|
+
const parsed = JSON.parse(raw);
|
|
285
|
+
if (!parsed.geo || !parsed.stability)
|
|
286
|
+
return null;
|
|
287
|
+
return { ...parsed, routes: [] };
|
|
537
288
|
}
|
|
538
|
-
catch
|
|
539
|
-
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
540
291
|
}
|
|
541
292
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
293
|
+
function saveStability() {
|
|
294
|
+
if (!isBrowser || typeof localStorage === "undefined")
|
|
295
|
+
return;
|
|
296
|
+
try {
|
|
297
|
+
const payload = {
|
|
298
|
+
geo: state.geo,
|
|
299
|
+
stability: state.stability,
|
|
300
|
+
lastGeoChange: state.lastGeoChange,
|
|
301
|
+
};
|
|
302
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
|
549
303
|
}
|
|
550
|
-
|
|
551
|
-
|
|
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");
|
|
304
|
+
catch {
|
|
305
|
+
/* ignore */
|
|
566
306
|
}
|
|
567
|
-
return data;
|
|
568
307
|
}
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
308
|
+
// -----------------------------------------------------------
|
|
309
|
+
// 🌍 Geo Detection (Node, Browser, Worker)
|
|
310
|
+
// -----------------------------------------------------------
|
|
311
|
+
function detectGeo() {
|
|
312
|
+
try {
|
|
313
|
+
if (isBrowser && typeof window !== "undefined" && window.navigator?.language) {
|
|
314
|
+
return window.navigator.language.split("-")[1] || "unknown";
|
|
315
|
+
}
|
|
316
|
+
if (isWorker && typeof self !== "undefined" && self.navigator?.language) {
|
|
317
|
+
return self.navigator.language.split("-")[1] || "unknown";
|
|
318
|
+
}
|
|
319
|
+
if (isNode && typeof process !== "undefined") {
|
|
320
|
+
const envGeo = process.env.ZUBBL_GEO || process.env.GEO;
|
|
321
|
+
if (envGeo && /^[A-Z]{2}$/i.test(envGeo))
|
|
322
|
+
return envGeo.toUpperCase();
|
|
323
|
+
return "GB";
|
|
324
|
+
}
|
|
325
|
+
return "unknown";
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
console.warn("[Zubbl SDK][Geo Detect] Failed:", err);
|
|
329
|
+
return "unknown";
|
|
576
330
|
}
|
|
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
331
|
}
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
if (
|
|
597
|
-
|
|
332
|
+
// -----------------------------------------------------------
|
|
333
|
+
// 🧭 Update Geo & Stability (with persistence)
|
|
334
|
+
// -----------------------------------------------------------
|
|
335
|
+
function updateGeo(geoHint) {
|
|
336
|
+
const newGeo = geoHint || detectGeo();
|
|
337
|
+
if (newGeo !== state.geo) {
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
const elapsed = (now - state.lastGeoChange) / 1000;
|
|
340
|
+
state.stability = Math.max(0.5, Math.min(1.0, elapsed / 3600));
|
|
341
|
+
state.geo = newGeo;
|
|
342
|
+
state.lastGeoChange = now;
|
|
343
|
+
saveStability();
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
state.stability = Math.min(1.0, state.stability + 0.01);
|
|
347
|
+
saveStability();
|
|
598
348
|
}
|
|
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
349
|
}
|
|
610
|
-
// ---------------------------------------------------------
|
|
611
|
-
// Exposed Helpers
|
|
612
|
-
// ---------------------------------------------------------
|
|
613
|
-
const storage = createStorage();
|
|
614
|
-
|
|
615
350
|
// -----------------------------------------------------------
|
|
616
|
-
//
|
|
351
|
+
// 🧮 Record Context Samples
|
|
617
352
|
// -----------------------------------------------------------
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
return Math.random() < sampleRatio;
|
|
353
|
+
function recordContextSample(path, method, status, latency) {
|
|
354
|
+
state.routes.push({ path, method, ts: Date.now(), status, latency });
|
|
355
|
+
if (state.routes.length > MAX_HISTORY)
|
|
356
|
+
state.routes.shift();
|
|
357
|
+
updateGeo(); // auto-refresh geo info
|
|
624
358
|
}
|
|
625
359
|
// -----------------------------------------------------------
|
|
626
|
-
//
|
|
360
|
+
// 📦 Get Adaptive Headers
|
|
627
361
|
// -----------------------------------------------------------
|
|
628
|
-
function
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
362
|
+
function getAdaptiveHeaders() {
|
|
363
|
+
const currentGeo = detectGeo();
|
|
364
|
+
updateGeo(currentGeo);
|
|
365
|
+
const summary = btoa(JSON.stringify({
|
|
366
|
+
env: isNode ? "node" : isBrowser ? "browser" : "worker",
|
|
367
|
+
routeCount: state.routes.length,
|
|
368
|
+
lastGeo: state.geo,
|
|
369
|
+
avgLatency: state.routes.reduce((a, b) => a + b.latency, 0) /
|
|
370
|
+
Math.max(1, state.routes.length),
|
|
371
|
+
}));
|
|
372
|
+
return {
|
|
373
|
+
"X-Zubbl-Context-Summary": summary,
|
|
374
|
+
"X-Zubbl-Geo-Stability": `${state.geo}/${state.stability.toFixed(2)}`,
|
|
635
375
|
};
|
|
636
|
-
buffer.push(enriched);
|
|
637
|
-
if (!debounceTimer) {
|
|
638
|
-
debounceTimer = setTimeout(flushBuffer, 200);
|
|
639
|
-
}
|
|
640
376
|
}
|
|
641
377
|
// -----------------------------------------------------------
|
|
642
|
-
//
|
|
378
|
+
// 🌍 Initialize Geo Context Immediately
|
|
643
379
|
// -----------------------------------------------------------
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
380
|
+
updateGeo(process.env.ZUBBL_GEO);
|
|
381
|
+
|
|
382
|
+
// -----------------------------------------------------------
|
|
383
|
+
// 🔐 Secure Signing & Nonce Generator
|
|
384
|
+
// -----------------------------------------------------------
|
|
385
|
+
// -----------------------------------------------------------
|
|
386
|
+
// Generate UUIDv4 Nonce
|
|
387
|
+
// -----------------------------------------------------------
|
|
388
|
+
function generateNonce() {
|
|
389
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
|
|
390
|
+
const r = (Math.random() * 16) | 0;
|
|
391
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
392
|
+
return v.toString(16);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
// -----------------------------------------------------------
|
|
396
|
+
// Attach signing headers to a request
|
|
397
|
+
// -----------------------------------------------------------
|
|
398
|
+
async function attachSigningHeaders(method, url, body, secretKey) {
|
|
399
|
+
const nonce = generateNonce();
|
|
400
|
+
// Compute canonical string (deterministic)
|
|
401
|
+
const bodyHash = "";
|
|
402
|
+
`${method.toUpperCase()}\n${url}\n${nonce}\n${bodyHash}`;
|
|
403
|
+
// ⚙️ Always include nonce even if secret is missing
|
|
404
|
+
{
|
|
405
|
+
return { "X-Zubbl-Nonce": nonce };
|
|
648
406
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/core/http.ts
|
|
410
|
+
// -----------------------------------------------------------
|
|
411
|
+
// HTTP utility (instance-safe)
|
|
412
|
+
// -----------------------------------------------------------
|
|
413
|
+
/**
|
|
414
|
+
* Performs a safe HTTP request with adaptive + signing headers.
|
|
415
|
+
* NOTE: no global getConfig() dependency — caller must provide fully resolved URL + headers.
|
|
416
|
+
*/
|
|
417
|
+
async function httpRequest(url, options = {}) {
|
|
418
|
+
// 🔧 Default timeout lowered for SDK-12 hardening
|
|
419
|
+
const controller = new AbortController();
|
|
420
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
660
421
|
try {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
headers
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
422
|
+
const finalOptions = {
|
|
423
|
+
...options,
|
|
424
|
+
headers: {
|
|
425
|
+
...options.headers,
|
|
426
|
+
...getAdaptiveHeaders(),
|
|
427
|
+
},
|
|
428
|
+
signal: controller.signal,
|
|
429
|
+
};
|
|
430
|
+
attachSigningHeaders(finalOptions.headers);
|
|
431
|
+
// Record adaptive context sample
|
|
432
|
+
recordContextSample();
|
|
433
|
+
const response = await fetch(url, finalOptions);
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
console.warn(`[Zubbl SDK][HTTP] ${response.status} on ${url}`);
|
|
436
|
+
}
|
|
437
|
+
return response;
|
|
668
438
|
}
|
|
669
439
|
catch (err) {
|
|
670
|
-
console.
|
|
440
|
+
console.error("[Zubbl SDK][HTTP] Request failed:", err?.message);
|
|
441
|
+
throw err;
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
clearTimeout(timeout);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/features/identifyUser.ts
|
|
449
|
+
// -----------------------------------------------------------
|
|
450
|
+
// Zubbl SDK — Instance-safe identifyUser feature
|
|
451
|
+
// -----------------------------------------------------------
|
|
452
|
+
const USER_CACHE_KEY = "external_user_id";
|
|
453
|
+
const USER_TTL = 24 * 60 * 60; // 24h in seconds
|
|
454
|
+
/**
|
|
455
|
+
* Identify or auto-create a user by email/name.
|
|
456
|
+
* Instance-safe: requires store (from createConfigStore()) passed in.
|
|
457
|
+
* Idempotent: returns cached UUID if available.
|
|
458
|
+
*/
|
|
459
|
+
async function identifyUserFeature(store, { email, name }) {
|
|
460
|
+
if (!email)
|
|
461
|
+
throw new Error("email is required");
|
|
462
|
+
const config = store.get();
|
|
463
|
+
const storage = createStorage();
|
|
464
|
+
// ⚡ Step 1 — Check cache
|
|
465
|
+
const cached = storage.get(USER_CACHE_KEY);
|
|
466
|
+
if (cached?.id) {
|
|
467
|
+
return { external_user_id: cached.id, cached: true };
|
|
468
|
+
}
|
|
469
|
+
// ⚙️ Step 2 — Prepare headers and request backend
|
|
470
|
+
const adaptiveHeaders = getAdaptiveHeaders();
|
|
471
|
+
const geoHeaders = await getGeoHeaders();
|
|
472
|
+
const headers = store.buildHeaders({
|
|
473
|
+
"Content-Type": "application/json",
|
|
474
|
+
...adaptiveHeaders,
|
|
475
|
+
...geoHeaders,
|
|
476
|
+
});
|
|
477
|
+
const resp = await httpRequest(`${config.baseUrl}/sdk/identify`, {
|
|
478
|
+
method: "POST",
|
|
479
|
+
headers,
|
|
480
|
+
body: JSON.stringify({ email, name }),
|
|
481
|
+
});
|
|
482
|
+
const data = await resp.json().catch(() => ({}));
|
|
483
|
+
const { external_user_id } = data;
|
|
484
|
+
if (!external_user_id) {
|
|
485
|
+
throw new Error("Backend did not return external_user_id");
|
|
671
486
|
}
|
|
487
|
+
// 🗃️ Step 3 — Cache for 24 h
|
|
488
|
+
storage.set(USER_CACHE_KEY, { id: external_user_id }, USER_TTL);
|
|
489
|
+
return { external_user_id, cached: false };
|
|
672
490
|
}
|
|
491
|
+
|
|
492
|
+
// src/features/getTiles.ts
|
|
673
493
|
// -----------------------------------------------------------
|
|
674
|
-
//
|
|
494
|
+
// Zubbl SDK — Instance-safe getTiles feature
|
|
675
495
|
// -----------------------------------------------------------
|
|
676
|
-
|
|
496
|
+
const TILE_CACHE_KEY = "tiles_cache";
|
|
497
|
+
const TILE_TTL_DEFAULT = 10 * 60; // 10 min in seconds
|
|
498
|
+
/**
|
|
499
|
+
* Fetch effective tiles with caching, ETag, and background refresh.
|
|
500
|
+
* Instance-safe: requires store (from createConfigStore()) passed in.
|
|
501
|
+
*/
|
|
502
|
+
async function getTilesFeature(store, { external_user_id, app_id = null, ttlSeconds = TILE_TTL_DEFAULT, }) {
|
|
503
|
+
if (!external_user_id)
|
|
504
|
+
throw new Error("external_user_id is required");
|
|
505
|
+
const config = store.get();
|
|
506
|
+
const storage = createStorage();
|
|
507
|
+
const cached = storage.get(TILE_CACHE_KEY);
|
|
508
|
+
const isFresh = cached && cached.expiresAt > Date.now();
|
|
509
|
+
// ⚡ Return cached data immediately if valid
|
|
510
|
+
if (isFresh) {
|
|
511
|
+
backgroundRefresh(store, external_user_id, app_id, ttlSeconds);
|
|
512
|
+
return { data: cached.data, fromCache: true };
|
|
513
|
+
}
|
|
677
514
|
try {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
...extra,
|
|
515
|
+
const adaptiveHeaders = getAdaptiveHeaders();
|
|
516
|
+
const geoHeaders = await getGeoHeaders();
|
|
517
|
+
const headers = store.buildHeaders({
|
|
518
|
+
"X-External-User-Id": external_user_id,
|
|
519
|
+
...adaptiveHeaders,
|
|
520
|
+
...geoHeaders,
|
|
685
521
|
});
|
|
522
|
+
if (cached?.etag)
|
|
523
|
+
headers["If-None-Match"] = cached.etag;
|
|
524
|
+
const params = new URLSearchParams({ external_user_id });
|
|
525
|
+
params.set("app_id", app_id || config.appId);
|
|
526
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/tiles/effective?${params.toString()}`;
|
|
527
|
+
const resp = await httpRequest(url, { headers });
|
|
528
|
+
if (resp.status === 304 && cached?.data) {
|
|
529
|
+
cached.expiresAt = Date.now() + ttlSeconds * 1000;
|
|
530
|
+
storage.set(TILE_CACHE_KEY, cached, ttlSeconds);
|
|
531
|
+
return { data: cached.data, fromCache: true, status: 304 };
|
|
532
|
+
}
|
|
533
|
+
const data = await resp.json();
|
|
534
|
+
const etag = resp.headers.get("ETag") || undefined;
|
|
535
|
+
const entry = {
|
|
536
|
+
data,
|
|
537
|
+
etag,
|
|
538
|
+
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
539
|
+
};
|
|
540
|
+
storage.set(TILE_CACHE_KEY, entry, ttlSeconds);
|
|
541
|
+
return { data, fromCache: false, status: resp.status };
|
|
686
542
|
}
|
|
687
543
|
catch (err) {
|
|
688
|
-
|
|
544
|
+
if (cached?.data) {
|
|
545
|
+
return { data: cached.data, fromCache: true, error: String(err) };
|
|
546
|
+
}
|
|
547
|
+
throw err;
|
|
689
548
|
}
|
|
690
549
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
550
|
+
/**
|
|
551
|
+
* Background refresh (non-blocking).
|
|
552
|
+
*/
|
|
553
|
+
async function backgroundRefresh(store, external_user_id, app_id, ttlSeconds) {
|
|
554
|
+
try {
|
|
555
|
+
await getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Silently ignore background refresh errors
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/client.ts
|
|
563
|
+
// -----------------------------------------------------------
|
|
564
|
+
// Zubbl SDK — Final Instance-based Client (Feature Delegation)
|
|
565
|
+
// -----------------------------------------------------------
|
|
566
|
+
/**
|
|
567
|
+
* Creates a self-contained, instance-based Zubbl client.
|
|
568
|
+
* Each instance owns its own config store and initialization state.
|
|
569
|
+
*/
|
|
570
|
+
function createZubblClient(cfg) {
|
|
571
|
+
const store = createConfigStore();
|
|
572
|
+
const storage = createStorage();
|
|
573
|
+
let initialized = false;
|
|
574
|
+
// ---------------------------------------------------------
|
|
575
|
+
// Initialization
|
|
576
|
+
// ---------------------------------------------------------
|
|
577
|
+
async function init() {
|
|
578
|
+
if (initialized)
|
|
579
|
+
return;
|
|
580
|
+
// Initialize per-client config
|
|
581
|
+
store.init(cfg);
|
|
582
|
+
// Optional: GEO bootstrap
|
|
583
|
+
try {
|
|
584
|
+
const geo = await getGeoInfo();
|
|
585
|
+
console.log(`[Zubbl SDK] 🌍 GEO detected: ${geo.country} (ASN ${geo.asn || "?"})`);
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
console.warn("[Zubbl SDK] GEO init failed:", err?.message);
|
|
589
|
+
}
|
|
590
|
+
initialized = true;
|
|
591
|
+
}
|
|
592
|
+
function assertInitialized() {
|
|
593
|
+
if (!initialized) {
|
|
594
|
+
throw new Error("Zubbl client not initialized — call client.init() first");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// ---------------------------------------------------------
|
|
598
|
+
// User + Tile Operations (delegated to feature modules)
|
|
599
|
+
// ---------------------------------------------------------
|
|
600
|
+
async function identifyUser({ email, name }) {
|
|
601
|
+
assertInitialized();
|
|
602
|
+
return identifyUserFeature(store, { email, name });
|
|
603
|
+
}
|
|
604
|
+
async function getTiles({ external_user_id, app_id = null, ttlSeconds, }) {
|
|
605
|
+
assertInitialized();
|
|
606
|
+
return getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
|
|
607
|
+
}
|
|
608
|
+
// ---------------------------------------------------------
|
|
609
|
+
// Enforcement API (kept inline)
|
|
610
|
+
// ---------------------------------------------------------
|
|
611
|
+
async function enforce({ external_user_id, app_id = null, }) {
|
|
612
|
+
assertInitialized();
|
|
613
|
+
const config = store.get();
|
|
614
|
+
const headers = store.buildHeaders({
|
|
615
|
+
"X-External-User-Id": external_user_id,
|
|
616
|
+
});
|
|
617
|
+
const resp = await fetch(`${config.baseUrl?.replace(/\/$/, "")}/sdk/test-enforcement`, {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers,
|
|
620
|
+
});
|
|
621
|
+
const json = await resp.json().catch(() => ({}));
|
|
622
|
+
return { decision: "allow", status: resp.status, data: json };
|
|
623
|
+
}
|
|
624
|
+
// ---------------------------------------------------------
|
|
625
|
+
// Public API
|
|
626
|
+
// ---------------------------------------------------------
|
|
627
|
+
return {
|
|
628
|
+
init,
|
|
629
|
+
identifyUser,
|
|
630
|
+
getTiles,
|
|
631
|
+
enforce,
|
|
632
|
+
storage,
|
|
633
|
+
};
|
|
704
634
|
}
|
|
705
635
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
636
|
+
// src/index.ts — Backward-compatible singleton API
|
|
637
|
+
let defaultClient = null;
|
|
638
|
+
async function init(config) {
|
|
639
|
+
if (!defaultClient)
|
|
640
|
+
defaultClient = createZubblClient(config);
|
|
641
|
+
return defaultClient.init();
|
|
642
|
+
}
|
|
643
|
+
async function identifyUser(...args) {
|
|
644
|
+
if (!defaultClient)
|
|
645
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
646
|
+
return defaultClient.identifyUser(...args);
|
|
647
|
+
}
|
|
648
|
+
async function getTiles(...args) {
|
|
649
|
+
if (!defaultClient)
|
|
650
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
651
|
+
return defaultClient.getTiles(...args);
|
|
652
|
+
}
|
|
653
|
+
async function enforce(...args) {
|
|
654
|
+
if (!defaultClient)
|
|
655
|
+
throw new Error("Zubbl SDK not initialized — call init() first");
|
|
656
|
+
return defaultClient.enforce(...args);
|
|
657
|
+
}
|
|
712
658
|
|
|
659
|
+
exports.createZubblClient = createZubblClient;
|
|
713
660
|
exports.enforce = enforce;
|
|
714
|
-
exports.getConfig = getConfig;
|
|
715
661
|
exports.getTiles = getTiles;
|
|
716
|
-
exports.httpRequest = httpRequest;
|
|
717
662
|
exports.identifyUser = identifyUser;
|
|
718
663
|
exports.init = init;
|
|
719
|
-
exports.storage = storage;
|
|
720
664
|
|
|
721
665
|
}));
|
|
722
666
|
//# sourceMappingURL=zubbl-sdk.umd.js.map
|