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.
- package/dist/types/client.d.ts +27 -4
- 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 +20 -2
- package/dist/zubbl-sdk.cjs.js +362 -428
- package/dist/zubbl-sdk.cjs.js.map +1 -1
- package/dist/zubbl-sdk.esm.js +362 -428
- package/dist/zubbl-sdk.esm.js.map +1 -1
- package/dist/zubbl-sdk.umd.js +363 -429
- package/dist/zubbl-sdk.umd.js.map +1 -1
- package/package.json +1 -1
package/dist/zubbl-sdk.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'crypto';
|
|
2
2
|
|
|
3
3
|
// src/core/config.ts
|
|
4
4
|
// -----------------------------------------------------------
|
|
@@ -11,21 +11,9 @@ const isNode = typeof process !== "undefined" &&
|
|
|
11
11
|
const isWorker = typeof self !== "undefined" &&
|
|
12
12
|
typeof self.importScripts === "function" &&
|
|
13
13
|
!isNode;
|
|
14
|
-
|
|
15
|
-
apiKey: null,
|
|
16
|
-
tenantId: null,
|
|
17
|
-
appId: null,
|
|
18
|
-
baseUrl: "https://api.zubbl.com/api",
|
|
19
|
-
injectWorkerHeaders: false,
|
|
20
|
-
workerSecret: null,
|
|
21
|
-
};
|
|
22
|
-
// -----------------------------------------------------------
|
|
23
|
-
// Browser safety check
|
|
24
|
-
// -----------------------------------------------------------
|
|
25
|
-
function assertBrowserSafety(config) {
|
|
14
|
+
function assertBrowserSafety(cfg) {
|
|
26
15
|
if (isBrowser || isWorker) {
|
|
27
|
-
const key =
|
|
28
|
-
// Simple rule: if the key looks like a secret (sk_ or very long)
|
|
16
|
+
const key = cfg.apiKey ?? "";
|
|
29
17
|
const looksSecret = key.startsWith("sk_") || key.length > 40;
|
|
30
18
|
if (looksSecret) {
|
|
31
19
|
throw new Error("[Zubbl SDK] Secret or server key detected in browser environment. " +
|
|
@@ -33,290 +21,40 @@ function assertBrowserSafety(config) {
|
|
|
33
21
|
}
|
|
34
22
|
}
|
|
35
23
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function buildHeaders(extra = {}) {
|
|
50
|
-
if (!config.apiKey || !config.tenantId || !config.appId) {
|
|
51
|
-
throw new Error("[Zubbl SDK] Not initialized – call init() first.");
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
Authorization: `Bearer ${config.apiKey}`,
|
|
55
|
-
"X-Tenant-Id": config.tenantId,
|
|
56
|
-
"X-App-Id": config.appId,
|
|
57
|
-
"Content-Type": "application/json",
|
|
58
|
-
...extra,
|
|
24
|
+
/**
|
|
25
|
+
* Creates an isolated configuration store.
|
|
26
|
+
* Each ZubblClient instance owns its own store.
|
|
27
|
+
*/
|
|
28
|
+
function createConfigStore(initial) {
|
|
29
|
+
let current = {
|
|
30
|
+
apiKey: null,
|
|
31
|
+
tenantId: null,
|
|
32
|
+
appId: null,
|
|
33
|
+
baseUrl: "https://api.zubbl.com/api",
|
|
34
|
+
injectWorkerHeaders: false,
|
|
35
|
+
workerSecret: null,
|
|
36
|
+
...initial,
|
|
59
37
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// -----------------------------------------------------------
|
|
64
|
-
// Adaptive Context Engine — Geo + Stability Enrichment + Persistence
|
|
65
|
-
// -----------------------------------------------------------
|
|
66
|
-
const MAX_HISTORY = 100;
|
|
67
|
-
const CACHE_KEY = "zubbl-stability";
|
|
68
|
-
let state = loadStability() ?? {
|
|
69
|
-
routes: [],
|
|
70
|
-
geo: "unknown",
|
|
71
|
-
stability: 1.0,
|
|
72
|
-
lastGeoChange: Date.now(),
|
|
73
|
-
};
|
|
74
|
-
// -----------------------------------------------------------
|
|
75
|
-
// 💾 Persistence Helpers
|
|
76
|
-
// -----------------------------------------------------------
|
|
77
|
-
function loadStability() {
|
|
78
|
-
if (!isBrowser || typeof localStorage === "undefined")
|
|
79
|
-
return null;
|
|
80
|
-
try {
|
|
81
|
-
const raw = localStorage.getItem(CACHE_KEY);
|
|
82
|
-
if (!raw)
|
|
83
|
-
return null;
|
|
84
|
-
const parsed = JSON.parse(raw);
|
|
85
|
-
if (!parsed.geo || !parsed.stability)
|
|
86
|
-
return null;
|
|
87
|
-
return { ...parsed, routes: [] };
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
return null;
|
|
38
|
+
function init(partial) {
|
|
39
|
+
current = { ...current, ...partial };
|
|
40
|
+
assertBrowserSafety(current);
|
|
91
41
|
}
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
+
// ---------------------------------------------------------
|
|
605
|
+
// Enforcement API (kept inline)
|
|
606
|
+
// ---------------------------------------------------------
|
|
607
|
+
async function enforce({ external_user_id, app_id = null, }) {
|
|
576
608
|
assertInitialized();
|
|
577
|
-
const
|
|
578
|
-
const
|
|
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
|
|
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
|