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.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: [] };
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return null;
|
|
42
|
+
function init(partial) {
|
|
43
|
+
current = { ...current, ...partial };
|
|
44
|
+
assertBrowserSafety(current);
|
|
95
45
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (!isBrowser || typeof localStorage === "undefined")
|
|
99
|
-
return;
|
|
100
|
-
try {
|
|
101
|
-
const payload = {
|
|
102
|
-
geo: state.geo,
|
|
103
|
-
stability: state.stability,
|
|
104
|
-
lastGeoChange: state.lastGeoChange,
|
|
105
|
-
};
|
|
106
|
-
localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
/* ignore */
|
|
46
|
+
function get() {
|
|
47
|
+
return current;
|
|
110
48
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// -----------------------------------------------------------
|
|
115
|
-
function detectGeo() {
|
|
116
|
-
try {
|
|
117
|
-
if (isBrowser && typeof window !== "undefined" && window.navigator?.language) {
|
|
118
|
-
return window.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.");
|
|
119
52
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return envGeo.toUpperCase();
|
|
127
|
-
return "GB";
|
|
128
|
-
}
|
|
129
|
-
return "unknown";
|
|
130
|
-
}
|
|
131
|
-
catch (err) {
|
|
132
|
-
console.warn("[Zubbl SDK][Geo Detect] Failed:", err);
|
|
133
|
-
return "unknown";
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
// -----------------------------------------------------------
|
|
137
|
-
// 🧭 Update Geo & Stability (with persistence)
|
|
138
|
-
// -----------------------------------------------------------
|
|
139
|
-
function updateGeo(geoHint) {
|
|
140
|
-
const newGeo = geoHint || detectGeo();
|
|
141
|
-
if (newGeo !== state.geo) {
|
|
142
|
-
const now = Date.now();
|
|
143
|
-
const elapsed = (now - state.lastGeoChange) / 1000;
|
|
144
|
-
state.stability = Math.max(0.5, Math.min(1.0, elapsed / 3600));
|
|
145
|
-
state.geo = newGeo;
|
|
146
|
-
state.lastGeoChange = now;
|
|
147
|
-
saveStability();
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
state.stability = Math.min(1.0, state.stability + 0.01);
|
|
151
|
-
saveStability();
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// -----------------------------------------------------------
|
|
155
|
-
// 🧮 Record Context Samples
|
|
156
|
-
// -----------------------------------------------------------
|
|
157
|
-
function recordContextSample(path, method, status, latency) {
|
|
158
|
-
state.routes.push({ path, method, ts: Date.now(), status, latency });
|
|
159
|
-
if (state.routes.length > MAX_HISTORY)
|
|
160
|
-
state.routes.shift();
|
|
161
|
-
updateGeo(); // auto-refresh geo info
|
|
162
|
-
}
|
|
163
|
-
// -----------------------------------------------------------
|
|
164
|
-
// 📦 Get Adaptive Headers
|
|
165
|
-
// -----------------------------------------------------------
|
|
166
|
-
function getAdaptiveHeaders() {
|
|
167
|
-
const currentGeo = detectGeo();
|
|
168
|
-
updateGeo(currentGeo);
|
|
169
|
-
const summary = btoa(JSON.stringify({
|
|
170
|
-
env: isNode ? "node" : isBrowser ? "browser" : "worker",
|
|
171
|
-
routeCount: state.routes.length,
|
|
172
|
-
lastGeo: state.geo,
|
|
173
|
-
avgLatency: state.routes.reduce((a, b) => a + b.latency, 0) /
|
|
174
|
-
Math.max(1, state.routes.length),
|
|
175
|
-
}));
|
|
176
|
-
return {
|
|
177
|
-
"X-Zubbl-Context-Summary": summary,
|
|
178
|
-
"X-Zubbl-Geo-Stability": `${state.geo}/${state.stability.toFixed(2)}`,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
// -----------------------------------------------------------
|
|
182
|
-
// 🌍 Initialize Geo Context Immediately
|
|
183
|
-
// -----------------------------------------------------------
|
|
184
|
-
updateGeo(process.env.ZUBBL_GEO);
|
|
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,14 +259,327 @@
|
|
|
521
259
|
};
|
|
522
260
|
}
|
|
523
261
|
|
|
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;
|
|
280
|
+
try {
|
|
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: [] };
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
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));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* ignore */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
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";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
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();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// -----------------------------------------------------------
|
|
351
|
+
// 🧮 Record Context Samples
|
|
352
|
+
// -----------------------------------------------------------
|
|
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
|
|
358
|
+
}
|
|
359
|
+
// -----------------------------------------------------------
|
|
360
|
+
// 📦 Get Adaptive Headers
|
|
361
|
+
// -----------------------------------------------------------
|
|
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)}`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
// -----------------------------------------------------------
|
|
378
|
+
// 🌍 Initialize Geo Context Immediately
|
|
379
|
+
// -----------------------------------------------------------
|
|
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 };
|
|
406
|
+
}
|
|
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);
|
|
421
|
+
try {
|
|
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;
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
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");
|
|
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 };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/features/getTiles.ts
|
|
493
|
+
// -----------------------------------------------------------
|
|
494
|
+
// Zubbl SDK — Instance-safe getTiles feature
|
|
495
|
+
// -----------------------------------------------------------
|
|
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
|
+
}
|
|
514
|
+
try {
|
|
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,
|
|
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 };
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
if (cached?.data) {
|
|
545
|
+
return { data: cached.data, fromCache: true, error: String(err) };
|
|
546
|
+
}
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
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
|
+
|
|
524
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
|
+
*/
|
|
525
570
|
function createZubblClient(cfg) {
|
|
526
|
-
|
|
571
|
+
const store = createConfigStore();
|
|
527
572
|
const storage = createStorage();
|
|
573
|
+
let initialized = false;
|
|
574
|
+
// ---------------------------------------------------------
|
|
575
|
+
// Initialization
|
|
576
|
+
// ---------------------------------------------------------
|
|
528
577
|
async function init() {
|
|
529
578
|
if (initialized)
|
|
530
579
|
return;
|
|
531
|
-
|
|
580
|
+
// Initialize per-client config
|
|
581
|
+
store.init(cfg);
|
|
582
|
+
// Optional: GEO bootstrap
|
|
532
583
|
try {
|
|
533
584
|
const geo = await getGeoInfo();
|
|
534
585
|
console.log(`[Zubbl SDK] 🌍 GEO detected: ${geo.country} (ASN ${geo.asn || "?"})`);
|
|
@@ -543,55 +594,36 @@
|
|
|
543
594
|
throw new Error("Zubbl client not initialized — call client.init() first");
|
|
544
595
|
}
|
|
545
596
|
}
|
|
597
|
+
// ---------------------------------------------------------
|
|
598
|
+
// User + Tile Operations (delegated to feature modules)
|
|
599
|
+
// ---------------------------------------------------------
|
|
546
600
|
async function identifyUser({ email, name }) {
|
|
547
601
|
assertInitialized();
|
|
548
|
-
|
|
549
|
-
const geoHeaders = await getGeoHeaders();
|
|
550
|
-
const headers = buildHeaders({
|
|
551
|
-
"Content-Type": "application/json",
|
|
552
|
-
...adaptiveHeaders,
|
|
553
|
-
...geoHeaders,
|
|
554
|
-
});
|
|
555
|
-
const body = JSON.stringify({ email, name });
|
|
556
|
-
const resp = await httpRequest(`${cfg.baseUrl}/sdk/identify`, {
|
|
557
|
-
method: "POST",
|
|
558
|
-
headers,
|
|
559
|
-
body,
|
|
560
|
-
});
|
|
561
|
-
return resp.json().catch(() => ({}));
|
|
602
|
+
return identifyUserFeature(store, { email, name });
|
|
562
603
|
}
|
|
563
|
-
async function getTiles({ external_user_id, app_id = null }) {
|
|
604
|
+
async function getTiles({ external_user_id, app_id = null, ttlSeconds, }) {
|
|
564
605
|
assertInitialized();
|
|
565
|
-
|
|
566
|
-
throw new Error("external_user_id is required");
|
|
567
|
-
const adaptiveHeaders = getAdaptiveHeaders();
|
|
568
|
-
const geoHeaders = await getGeoHeaders();
|
|
569
|
-
const headers = buildHeaders({
|
|
570
|
-
"X-External-User-Id": external_user_id,
|
|
571
|
-
...adaptiveHeaders,
|
|
572
|
-
...geoHeaders,
|
|
573
|
-
});
|
|
574
|
-
const params = new URLSearchParams({ external_user_id });
|
|
575
|
-
params.set("app_id", app_id || cfg.appId);
|
|
576
|
-
const resp = await httpRequest(`${cfg.baseUrl?.replace(/\/$/, "")}/tiles/effective?${params.toString()}`, { method: "GET", headers });
|
|
577
|
-
return resp.json();
|
|
606
|
+
return getTilesFeature(store, { external_user_id, app_id, ttlSeconds });
|
|
578
607
|
}
|
|
579
|
-
|
|
608
|
+
// ---------------------------------------------------------
|
|
609
|
+
// Enforcement API (kept inline)
|
|
610
|
+
// ---------------------------------------------------------
|
|
611
|
+
async function enforce({ external_user_id, app_id = null, }) {
|
|
580
612
|
assertInitialized();
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
const headers = buildHeaders({
|
|
613
|
+
const config = store.get();
|
|
614
|
+
const headers = store.buildHeaders({
|
|
584
615
|
"X-External-User-Id": external_user_id,
|
|
585
|
-
...adaptiveHeaders,
|
|
586
|
-
...geoHeaders,
|
|
587
616
|
});
|
|
588
|
-
const resp = await
|
|
617
|
+
const resp = await fetch(`${config.baseUrl?.replace(/\/$/, "")}/sdk/test-enforcement`, {
|
|
589
618
|
method: "POST",
|
|
590
619
|
headers,
|
|
591
620
|
});
|
|
592
621
|
const json = await resp.json().catch(() => ({}));
|
|
593
622
|
return { decision: "allow", status: resp.status, data: json };
|
|
594
623
|
}
|
|
624
|
+
// ---------------------------------------------------------
|
|
625
|
+
// Public API
|
|
626
|
+
// ---------------------------------------------------------
|
|
595
627
|
return {
|
|
596
628
|
init,
|
|
597
629
|
identifyUser,
|
|
@@ -624,104 +656,6 @@
|
|
|
624
656
|
return defaultClient.enforce(...args);
|
|
625
657
|
}
|
|
626
658
|
|
|
627
|
-
// -----------------------------------------------------------
|
|
628
|
-
// Zubbl Telemetry Core (Endpoint Discovery)
|
|
629
|
-
// -----------------------------------------------------------
|
|
630
|
-
// Local telemetry buffer
|
|
631
|
-
let sampleRatio = 0.1; // 1 in 10 by default
|
|
632
|
-
let buffer = [];
|
|
633
|
-
let debounceTimer = null;
|
|
634
|
-
function shouldSample() {
|
|
635
|
-
return Math.random() < sampleRatio;
|
|
636
|
-
}
|
|
637
|
-
// -----------------------------------------------------------
|
|
638
|
-
// Emit telemetry events
|
|
639
|
-
// -----------------------------------------------------------
|
|
640
|
-
function emitEndpoint(payload) {
|
|
641
|
-
if (!shouldSample())
|
|
642
|
-
return;
|
|
643
|
-
const enriched = {
|
|
644
|
-
...payload,
|
|
645
|
-
ts: Date.now(),
|
|
646
|
-
source: isBrowser ? "browser" : isNode ? "node" : "worker",
|
|
647
|
-
};
|
|
648
|
-
buffer.push(enriched);
|
|
649
|
-
if (!debounceTimer) {
|
|
650
|
-
debounceTimer = setTimeout(flushBuffer, 200);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
// -----------------------------------------------------------
|
|
654
|
-
// Flush logic (batch send)
|
|
655
|
-
// -----------------------------------------------------------
|
|
656
|
-
async function flushBuffer() {
|
|
657
|
-
if (buffer.length === 0) {
|
|
658
|
-
debounceTimer = null;
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
const batch = [...buffer];
|
|
662
|
-
buffer = [];
|
|
663
|
-
debounceTimer = null;
|
|
664
|
-
const config = getConfig();
|
|
665
|
-
const url = `${config.baseUrl.replace(/\/$/, "")}/sdk/log`;
|
|
666
|
-
const headers = {
|
|
667
|
-
"Content-Type": "application/json",
|
|
668
|
-
Authorization: `Bearer ${config.apiKey}`,
|
|
669
|
-
"X-Tenant-Id": config.tenantId ?? "",
|
|
670
|
-
"X-App-Id": config.appId ?? "",
|
|
671
|
-
};
|
|
672
|
-
try {
|
|
673
|
-
const resp = await fetch(url, {
|
|
674
|
-
method: "POST",
|
|
675
|
-
headers,
|
|
676
|
-
body: JSON.stringify(batch),
|
|
677
|
-
});
|
|
678
|
-
const text = await resp.text();
|
|
679
|
-
console.log("[Zubbl SDK][Telemetry] Flushed", batch.length, "events →", resp.status, text);
|
|
680
|
-
}
|
|
681
|
-
catch (err) {
|
|
682
|
-
console.warn("[Zubbl SDK][Telemetry] Failed to send batch:", err.message);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// -----------------------------------------------------------
|
|
686
|
-
// Helper for HTTP wrapping
|
|
687
|
-
// -----------------------------------------------------------
|
|
688
|
-
async function recordRequestTelemetry(url, method, status, latency, extra = {}) {
|
|
689
|
-
try {
|
|
690
|
-
const path = new URL(url).pathname;
|
|
691
|
-
emitEndpoint({
|
|
692
|
-
path,
|
|
693
|
-
method,
|
|
694
|
-
status,
|
|
695
|
-
latency,
|
|
696
|
-
...extra,
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
catch (err) {
|
|
700
|
-
console.warn("[Zubbl SDK][Telemetry] Failed to record telemetry", err);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
// -----------------------------------------------------------
|
|
704
|
-
// Runtime Endpoint Discovery
|
|
705
|
-
// -----------------------------------------------------------
|
|
706
|
-
let hasLoggedDiscovery = false;
|
|
707
|
-
function logRuntimeDiscovery(url) {
|
|
708
|
-
if (hasLoggedDiscovery)
|
|
709
|
-
return; // only once per runtime
|
|
710
|
-
hasLoggedDiscovery = true;
|
|
711
|
-
const runtime = isNode ? "Node" : isBrowser ? "Browser" : isWorker ? "Worker" : "Unknown";
|
|
712
|
-
const time = new Date().toISOString();
|
|
713
|
-
console.log(`[Zubbl SDK][Discovery] Runtime: ${runtime}`);
|
|
714
|
-
console.log(`[Zubbl SDK][Discovery] Timestamp: ${time}`);
|
|
715
|
-
console.log(`[Zubbl SDK][Discovery] Initial endpoint: ${url}`);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
var telemetry = /*#__PURE__*/Object.freeze({
|
|
719
|
-
__proto__: null,
|
|
720
|
-
emitEndpoint: emitEndpoint,
|
|
721
|
-
logRuntimeDiscovery: logRuntimeDiscovery,
|
|
722
|
-
recordRequestTelemetry: recordRequestTelemetry
|
|
723
|
-
});
|
|
724
|
-
|
|
725
659
|
exports.createZubblClient = createZubblClient;
|
|
726
660
|
exports.enforce = enforce;
|
|
727
661
|
exports.getTiles = getTiles;
|