@geolonia/geonicdb-sdk 0.2.1 → 0.4.0
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/README.md +118 -1
- package/geonicdb.cjs +447 -53
- package/geonicdb.cjs.map +4 -4
- package/geonicdb.iife.js +447 -53
- package/geonicdb.iife.js.map +4 -4
- package/geonicdb.mjs +447 -53
- package/geonicdb.mjs.map +4 -4
- package/package.json +1 -1
package/geonicdb.mjs
CHANGED
|
@@ -147,13 +147,178 @@ var RECONNECT_MAX_ATTEMPTS = 10;
|
|
|
147
147
|
var RECONNECT_BASE_MS = 1e3;
|
|
148
148
|
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
149
149
|
var SUB_PROTOCOL = "access_token";
|
|
150
|
+
var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
|
|
151
|
+
var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
|
|
152
|
+
|
|
153
|
+
// src/sdk/errors.ts
|
|
154
|
+
var GeonicDBError = class extends Error {
|
|
155
|
+
constructor(message, statusCode = 0) {
|
|
156
|
+
super(message);
|
|
157
|
+
/** HTTP status code (if applicable) */
|
|
158
|
+
__publicField(this, "statusCode");
|
|
159
|
+
this.name = "GeonicDBError";
|
|
160
|
+
this.statusCode = statusCode;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
var AuthenticationError = class extends GeonicDBError {
|
|
164
|
+
constructor(message = "Authentication failed") {
|
|
165
|
+
super(message, 401);
|
|
166
|
+
this.name = "AuthenticationError";
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var AuthorizationError = class extends GeonicDBError {
|
|
170
|
+
constructor(message = "Access denied") {
|
|
171
|
+
super(message, 403);
|
|
172
|
+
this.name = "AuthorizationError";
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var NotFoundError = class extends GeonicDBError {
|
|
176
|
+
constructor(message = "Not found") {
|
|
177
|
+
super(message, 404);
|
|
178
|
+
this.name = "NotFoundError";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
var ConflictError = class extends GeonicDBError {
|
|
182
|
+
constructor(message = "Conflict") {
|
|
183
|
+
super(message, 409);
|
|
184
|
+
this.name = "ConflictError";
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
var ValidationError = class extends GeonicDBError {
|
|
188
|
+
constructor(message = "Validation failed") {
|
|
189
|
+
super(message, 422);
|
|
190
|
+
this.name = "ValidationError";
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var RateLimitError = class extends GeonicDBError {
|
|
194
|
+
constructor(message = "Rate limit exceeded", retryAfter = 1) {
|
|
195
|
+
super(message, 429);
|
|
196
|
+
/** Seconds to wait before retrying (from Retry-After header) */
|
|
197
|
+
__publicField(this, "retryAfter");
|
|
198
|
+
this.name = "RateLimitError";
|
|
199
|
+
this.retryAfter = retryAfter;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var NetworkError = class extends GeonicDBError {
|
|
203
|
+
constructor(message = "Network error") {
|
|
204
|
+
super(message, 0);
|
|
205
|
+
this.name = "NetworkError";
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
function createErrorFromResponse(status, body, fallbackMessage) {
|
|
209
|
+
const message = body.detail || body.description || fallbackMessage;
|
|
210
|
+
switch (status) {
|
|
211
|
+
case 401:
|
|
212
|
+
return new AuthenticationError(message);
|
|
213
|
+
case 403:
|
|
214
|
+
return new AuthorizationError(message);
|
|
215
|
+
case 404:
|
|
216
|
+
return new NotFoundError(message);
|
|
217
|
+
case 409:
|
|
218
|
+
return new ConflictError(message);
|
|
219
|
+
case 422:
|
|
220
|
+
return new ValidationError(message);
|
|
221
|
+
case 429:
|
|
222
|
+
return new RateLimitError(message);
|
|
223
|
+
default:
|
|
224
|
+
return new GeonicDBError(message, status);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/sdk/cache.ts
|
|
229
|
+
var SdkCache = class {
|
|
230
|
+
constructor(maxEntries = SDK_CACHE_MAX_ENTRIES_DEFAULT) {
|
|
231
|
+
__publicField(this, "_store", /* @__PURE__ */ new Map());
|
|
232
|
+
__publicField(this, "_maxEntries");
|
|
233
|
+
if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
|
|
234
|
+
throw new Error(`SdkCache maxEntries must be a positive integer, got ${String(maxEntries)}`);
|
|
235
|
+
}
|
|
236
|
+
this._maxEntries = maxEntries;
|
|
237
|
+
}
|
|
238
|
+
/** Build a stable cache key. Method is upper-cased so casing differences do not produce duplicate entries. */
|
|
239
|
+
static keyFor(method, path) {
|
|
240
|
+
return `${method.toUpperCase()}:${path}`;
|
|
241
|
+
}
|
|
242
|
+
/** Returns the entry and bumps it to most-recent position, or `undefined`. */
|
|
243
|
+
get(key) {
|
|
244
|
+
const entry = this._store.get(key);
|
|
245
|
+
if (!entry) return void 0;
|
|
246
|
+
this._store.delete(key);
|
|
247
|
+
this._store.set(key, entry);
|
|
248
|
+
return entry;
|
|
249
|
+
}
|
|
250
|
+
/** Insert or replace an entry. Evicts the LRU entry when over capacity. */
|
|
251
|
+
set(key, entry) {
|
|
252
|
+
if (this._store.has(key)) {
|
|
253
|
+
this._store.delete(key);
|
|
254
|
+
}
|
|
255
|
+
this._store.set(key, entry);
|
|
256
|
+
while (this._store.size > this._maxEntries) {
|
|
257
|
+
const oldest = this._store.keys().next().value;
|
|
258
|
+
if (oldest === void 0) break;
|
|
259
|
+
this._store.delete(oldest);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Delete a specific entry. Returns whether anything was removed. */
|
|
263
|
+
delete(key) {
|
|
264
|
+
return this._store.delete(key);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Delete every entry whose key matches a predicate. Returns the array of
|
|
268
|
+
* removed keys so callers can emit `cacheInvalidated` events.
|
|
269
|
+
*/
|
|
270
|
+
deleteWhere(predicate) {
|
|
271
|
+
const removed = [];
|
|
272
|
+
for (const [key, entry] of this._store) {
|
|
273
|
+
if (predicate(key, entry)) {
|
|
274
|
+
this._store.delete(key);
|
|
275
|
+
removed.push(key);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return removed;
|
|
279
|
+
}
|
|
280
|
+
/** Drop everything. */
|
|
281
|
+
clear() {
|
|
282
|
+
this._store.clear();
|
|
283
|
+
}
|
|
284
|
+
/** Current entry count. Useful for tests / metrics. */
|
|
285
|
+
size() {
|
|
286
|
+
return this._store.size;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
var HEADERS_TO_PERSIST = [
|
|
290
|
+
"content-type",
|
|
291
|
+
"etag",
|
|
292
|
+
"last-modified",
|
|
293
|
+
"cache-control",
|
|
294
|
+
"vary",
|
|
295
|
+
"link",
|
|
296
|
+
"ngsild-results-count",
|
|
297
|
+
"fiware-total-count",
|
|
298
|
+
"x-total-count",
|
|
299
|
+
"ngsild-next",
|
|
300
|
+
"fiware-next-token"
|
|
301
|
+
];
|
|
302
|
+
function snapshotHeaders(headers) {
|
|
303
|
+
const out = {};
|
|
304
|
+
for (const name of HEADERS_TO_PERSIST) {
|
|
305
|
+
const value = headers.get(name);
|
|
306
|
+
if (value !== null) out[name] = value;
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function isCacheableMethod(method) {
|
|
311
|
+
const upper = method.toUpperCase();
|
|
312
|
+
return upper === "GET" || upper === "HEAD";
|
|
313
|
+
}
|
|
150
314
|
|
|
151
315
|
// src/sdk/auth.ts
|
|
152
316
|
var _AuthManager = class _AuthManager {
|
|
153
|
-
constructor(baseUrl, apiKey, tenant) {
|
|
317
|
+
constructor(baseUrl, apiKey, tenant, debug = false) {
|
|
154
318
|
__publicField(this, "_baseUrl");
|
|
155
319
|
__publicField(this, "_apiKey");
|
|
156
320
|
__publicField(this, "_tenant");
|
|
321
|
+
__publicField(this, "_debug");
|
|
157
322
|
__publicField(this, "_token", null);
|
|
158
323
|
__publicField(this, "_tokenExpiry", 0);
|
|
159
324
|
__publicField(this, "_tokenType", "Bearer");
|
|
@@ -164,9 +329,20 @@ var _AuthManager = class _AuthManager {
|
|
|
164
329
|
__publicField(this, "_tokenPromise", null);
|
|
165
330
|
/** Callback to emit tokenRefresh events. Set by GeonicDB class. */
|
|
166
331
|
__publicField(this, "onTokenRefresh", null);
|
|
332
|
+
/**
|
|
333
|
+
* SDK-level cache (#991 Phase A). When set, cacheable GET requests are
|
|
334
|
+
* served via ETag/304 with automatic If-None-Match negotiation, and
|
|
335
|
+
* concurrent requests to the same path are deduplicated.
|
|
336
|
+
*/
|
|
337
|
+
__publicField(this, "_cache", null);
|
|
338
|
+
/** Emitter for cacheHit / cacheMiss / cacheInvalidated events. */
|
|
339
|
+
__publicField(this, "_emitCacheEvent", null);
|
|
340
|
+
/** In-flight request map keyed by `${METHOD}:${path}` for request deduplication. */
|
|
341
|
+
__publicField(this, "_inFlight", /* @__PURE__ */ new Map());
|
|
167
342
|
this._baseUrl = baseUrl;
|
|
168
343
|
this._apiKey = apiKey;
|
|
169
344
|
this._tenant = tenant;
|
|
345
|
+
this._debug = debug;
|
|
170
346
|
if (dpopSupported) {
|
|
171
347
|
this._dpopReady = generateDPoPKeyPair().then((kp) => {
|
|
172
348
|
this._dpopKeyPair = kp;
|
|
@@ -175,8 +351,28 @@ var _AuthManager = class _AuthManager {
|
|
|
175
351
|
});
|
|
176
352
|
}
|
|
177
353
|
}
|
|
354
|
+
_log(...args) {
|
|
355
|
+
if (this._debug) console.log("[GeonicDB]", ...args);
|
|
356
|
+
}
|
|
357
|
+
/** Wire an SdkCache instance (called by GeonicDB constructor when caching is enabled). */
|
|
358
|
+
setCache(cache) {
|
|
359
|
+
this._cache = cache;
|
|
360
|
+
}
|
|
361
|
+
/** Provide the cache event emitter (forwarded to the GeonicDB EventEmitter). */
|
|
362
|
+
setCacheEventEmitter(emitter) {
|
|
363
|
+
this._emitCacheEvent = emitter;
|
|
364
|
+
}
|
|
365
|
+
/** Expose the cache for invalidation (e.g. WebSocket entity events). */
|
|
366
|
+
getCache() {
|
|
367
|
+
return this._cache;
|
|
368
|
+
}
|
|
369
|
+
/** Emit a cache event if a listener is wired. */
|
|
370
|
+
emitCacheEvent(name, payload) {
|
|
371
|
+
this._emitCacheEvent?.(name, payload);
|
|
372
|
+
}
|
|
178
373
|
/** Login with email and password (Bearer JWT). */
|
|
179
374
|
async login(email, password) {
|
|
375
|
+
this._log("login", email);
|
|
180
376
|
const headers = {
|
|
181
377
|
"Content-Type": "application/json"
|
|
182
378
|
};
|
|
@@ -188,7 +384,7 @@ var _AuthManager = class _AuthManager {
|
|
|
188
384
|
});
|
|
189
385
|
if (!res.ok) {
|
|
190
386
|
const e = await res.json().catch(() => ({}));
|
|
191
|
-
throw new
|
|
387
|
+
throw new AuthenticationError(
|
|
192
388
|
e.detail || e.description || "Login failed: " + res.status
|
|
193
389
|
);
|
|
194
390
|
}
|
|
@@ -197,6 +393,7 @@ var _AuthManager = class _AuthManager {
|
|
|
197
393
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
198
394
|
this._tokenType = "Bearer";
|
|
199
395
|
this._refreshToken = data.refreshToken;
|
|
396
|
+
this._invalidateAuthScopedState();
|
|
200
397
|
return data;
|
|
201
398
|
}
|
|
202
399
|
/**
|
|
@@ -211,6 +408,7 @@ var _AuthManager = class _AuthManager {
|
|
|
211
408
|
this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
|
|
212
409
|
this._refreshToken = opts.refreshToken || null;
|
|
213
410
|
this._tokenPromise = null;
|
|
411
|
+
this._invalidateAuthScopedState();
|
|
214
412
|
}
|
|
215
413
|
/** Clear all credentials. */
|
|
216
414
|
logout() {
|
|
@@ -218,6 +416,17 @@ var _AuthManager = class _AuthManager {
|
|
|
218
416
|
this._tokenExpiry = 0;
|
|
219
417
|
this._refreshToken = null;
|
|
220
418
|
this._tokenPromise = null;
|
|
419
|
+
this._invalidateAuthScopedState();
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Drop everything that may have been associated with the previous auth
|
|
423
|
+
* context. Called on `login()` (after token assignment), `setCredentials()`,
|
|
424
|
+
* and `logout()`. Without this, a cached body or in-flight Response from
|
|
425
|
+
* user A could be returned to user B after a credentials swap.
|
|
426
|
+
*/
|
|
427
|
+
_invalidateAuthScopedState() {
|
|
428
|
+
this._cache?.clear();
|
|
429
|
+
this._inFlight.clear();
|
|
221
430
|
}
|
|
222
431
|
/** Ensure a valid token is available, refreshing or acquiring as needed. */
|
|
223
432
|
async ensureToken() {
|
|
@@ -235,6 +444,7 @@ var _AuthManager = class _AuthManager {
|
|
|
235
444
|
return this._tokenPromise;
|
|
236
445
|
}
|
|
237
446
|
async _refreshBearerToken() {
|
|
447
|
+
this._log("refreshing Bearer token");
|
|
238
448
|
try {
|
|
239
449
|
const res = await fetch(this._baseUrl + "/auth/refresh", {
|
|
240
450
|
method: "POST",
|
|
@@ -245,7 +455,7 @@ var _AuthManager = class _AuthManager {
|
|
|
245
455
|
this._refreshToken = null;
|
|
246
456
|
this._token = null;
|
|
247
457
|
this._tokenPromise = null;
|
|
248
|
-
throw new
|
|
458
|
+
throw new AuthenticationError("Token refresh failed: " + res.status);
|
|
249
459
|
}
|
|
250
460
|
const data = await res.json();
|
|
251
461
|
this._token = data.accessToken;
|
|
@@ -274,7 +484,7 @@ var _AuthManager = class _AuthManager {
|
|
|
274
484
|
body: JSON.stringify({ api_key: this._apiKey })
|
|
275
485
|
});
|
|
276
486
|
if (!nonceRes.ok)
|
|
277
|
-
throw new
|
|
487
|
+
throw new AuthenticationError("Nonce request failed: " + nonceRes.status);
|
|
278
488
|
const nonceData = await nonceRes.json();
|
|
279
489
|
if (nonceData.dpop_nonce) this._dpopNonce = nonceData.dpop_nonce;
|
|
280
490
|
const proof = await solvePoW(nonceData.challenge, nonceData.difficulty);
|
|
@@ -287,7 +497,7 @@ var _AuthManager = class _AuthManager {
|
|
|
287
497
|
});
|
|
288
498
|
const res = await this._doTokenExchange(tokenUrl, tokenBody, this._dpopNonce);
|
|
289
499
|
if (!res.ok)
|
|
290
|
-
throw new
|
|
500
|
+
throw new AuthenticationError("Token request failed: " + res.status);
|
|
291
501
|
const newNonce = res.headers.get("DPoP-Nonce");
|
|
292
502
|
if (newNonce) this._dpopNonce = newNonce;
|
|
293
503
|
const data = await res.json();
|
|
@@ -327,20 +537,98 @@ var _AuthManager = class _AuthManager {
|
|
|
327
537
|
return this._doTokenExchange(tokenUrl, tokenBody, serverNonce, retryCount + 1);
|
|
328
538
|
}
|
|
329
539
|
}
|
|
330
|
-
throw new
|
|
331
|
-
"Token request failed: " + (errBody.error_description || errBody.error)
|
|
540
|
+
throw new GeonicDBError(
|
|
541
|
+
"Token request failed: " + (errBody.error_description || errBody.error),
|
|
542
|
+
400
|
|
332
543
|
);
|
|
333
544
|
}
|
|
334
545
|
return res;
|
|
335
546
|
}
|
|
336
547
|
/**
|
|
337
548
|
* Make an authenticated HTTP request with automatic token refresh and DPoP.
|
|
549
|
+
*
|
|
550
|
+
* When the SDK cache is enabled and the request is cacheable
|
|
551
|
+
* (GET / HEAD without a body), this method:
|
|
552
|
+
* - Sends `If-None-Match` / `If-Modified-Since` derived from a previously
|
|
553
|
+
* cached entry, if any.
|
|
554
|
+
* - Returns the cached body wrapped in a synthesized `200` Response when
|
|
555
|
+
* the server replies `304 Not Modified`.
|
|
556
|
+
* - Persists fresh `200` responses (with their ETag/Last-Modified) into
|
|
557
|
+
* the cache.
|
|
558
|
+
* - Deduplicates concurrent in-flight requests to the same path.
|
|
338
559
|
*/
|
|
339
560
|
async request(method, path, body) {
|
|
561
|
+
this._log(method, path);
|
|
562
|
+
if (!this._cache || !isCacheableMethod(method) || body !== void 0) {
|
|
563
|
+
const token = await this.ensureToken();
|
|
564
|
+
const res = await this._doAuthenticatedRequest(method, path, body, token);
|
|
565
|
+
this._log(method, path, "\u2192", res.status);
|
|
566
|
+
return res;
|
|
567
|
+
}
|
|
568
|
+
const key = SdkCache.keyFor(method, path);
|
|
569
|
+
const inFlight = this._inFlight.get(key);
|
|
570
|
+
if (inFlight) {
|
|
571
|
+
return inFlight.then((res) => res.clone());
|
|
572
|
+
}
|
|
573
|
+
const promise = this._cachedRequest(method, path, key);
|
|
574
|
+
this._inFlight.set(key, promise);
|
|
575
|
+
try {
|
|
576
|
+
return await promise;
|
|
577
|
+
} finally {
|
|
578
|
+
this._inFlight.delete(key);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async _cachedRequest(method, path, key) {
|
|
582
|
+
const cache = this._cache;
|
|
583
|
+
if (!cache) {
|
|
584
|
+
const token2 = await this.ensureToken();
|
|
585
|
+
return this._doAuthenticatedRequest(method, path, void 0, token2);
|
|
586
|
+
}
|
|
587
|
+
const cached = cache.get(key);
|
|
588
|
+
const conditional = {};
|
|
589
|
+
if (cached?.etag) conditional["If-None-Match"] = cached.etag;
|
|
590
|
+
if (cached?.lastModified) conditional["If-Modified-Since"] = cached.lastModified;
|
|
340
591
|
const token = await this.ensureToken();
|
|
341
|
-
|
|
592
|
+
const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
|
|
593
|
+
this._log(method, path, "\u2192", res.status);
|
|
594
|
+
if (res.status === 304 && cached) {
|
|
595
|
+
const refreshedHeaders = { ...cached.headers, ...snapshotHeaders(res.headers) };
|
|
596
|
+
const refreshedEtag = res.headers.get("etag") ?? cached.etag;
|
|
597
|
+
const refreshedLastModified = res.headers.get("last-modified") ?? cached.lastModified;
|
|
598
|
+
cache.set(key, {
|
|
599
|
+
...cached,
|
|
600
|
+
etag: refreshedEtag,
|
|
601
|
+
lastModified: refreshedLastModified,
|
|
602
|
+
headers: refreshedHeaders,
|
|
603
|
+
cachedAt: Date.now()
|
|
604
|
+
});
|
|
605
|
+
this.emitCacheEvent("cacheHit", { key, path });
|
|
606
|
+
const body = typeof cached.data === "string" ? cached.data : JSON.stringify(cached.data);
|
|
607
|
+
return new Response(body, { status: 200, headers: refreshedHeaders });
|
|
608
|
+
}
|
|
609
|
+
if (res.status === 200) {
|
|
610
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
611
|
+
const lastModified = res.headers.get("last-modified") ?? void 0;
|
|
612
|
+
if (etag || lastModified) {
|
|
613
|
+
const text = await res.clone().text();
|
|
614
|
+
let data = text;
|
|
615
|
+
try {
|
|
616
|
+
data = JSON.parse(text);
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
cache.set(key, {
|
|
620
|
+
etag,
|
|
621
|
+
lastModified,
|
|
622
|
+
data,
|
|
623
|
+
headers: snapshotHeaders(res.headers),
|
|
624
|
+
cachedAt: Date.now()
|
|
625
|
+
});
|
|
626
|
+
this.emitCacheEvent("cacheMiss", { key, path });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return res;
|
|
342
630
|
}
|
|
343
|
-
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0) {
|
|
631
|
+
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
|
|
344
632
|
const url = this._baseUrl + path;
|
|
345
633
|
const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
|
|
346
634
|
const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
@@ -356,7 +644,8 @@ var _AuthManager = class _AuthManager {
|
|
|
356
644
|
const headers = {
|
|
357
645
|
Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
|
|
358
646
|
"Content-Type": "application/ld+json",
|
|
359
|
-
Accept: "application/ld+json"
|
|
647
|
+
Accept: "application/ld+json",
|
|
648
|
+
...extraHeaders
|
|
360
649
|
};
|
|
361
650
|
if (dpopProof) headers["DPoP"] = dpopProof;
|
|
362
651
|
if (this._tenant) headers["Fiware-Service"] = this._tenant;
|
|
@@ -392,7 +681,7 @@ var _AuthManager = class _AuthManager {
|
|
|
392
681
|
if (retryNonce) this._dpopNonce = retryNonce;
|
|
393
682
|
const delay = parseInt(res.headers.get("Retry-After") || "1", 10) * 1e3;
|
|
394
683
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
395
|
-
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1);
|
|
684
|
+
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1, extraHeaders);
|
|
396
685
|
}
|
|
397
686
|
return res;
|
|
398
687
|
}
|
|
@@ -423,6 +712,9 @@ var WebSocketManager = class {
|
|
|
423
712
|
this._emit = emit;
|
|
424
713
|
this._wsEndpointOverride = wsEndpointOverride || null;
|
|
425
714
|
}
|
|
715
|
+
_log(...args) {
|
|
716
|
+
if (this._auth._debug) console.log("[GeonicDB:WS]", ...args);
|
|
717
|
+
}
|
|
426
718
|
/** Establish WebSocket connection (authentication is automatic). */
|
|
427
719
|
async connect() {
|
|
428
720
|
if (this._reconnectTimer) {
|
|
@@ -500,10 +792,12 @@ var WebSocketManager = class {
|
|
|
500
792
|
const endpoint = await this._discoverWsEndpoint();
|
|
501
793
|
return new Promise((resolve, reject) => {
|
|
502
794
|
const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "tenant=" + encodeURIComponent(this._tenant);
|
|
795
|
+
this._log("connecting", wsUrl);
|
|
503
796
|
const ws = new WebSocket(wsUrl, [SUB_PROTOCOL, token]);
|
|
504
797
|
this._ws = ws;
|
|
505
798
|
ws.onopen = () => {
|
|
506
799
|
if (this._ws !== ws) return;
|
|
800
|
+
this._log("connected");
|
|
507
801
|
this._reconnectAttempts = 0;
|
|
508
802
|
const isDPoP = this._auth._tokenType === "DPoP" && !!this._auth._dpopKeyPair;
|
|
509
803
|
const bindPromise = isDPoP ? createDPoPProof(this._auth._dpopKeyPair, "GET", wsUrl, null).then(
|
|
@@ -548,6 +842,14 @@ var WebSocketManager = class {
|
|
|
548
842
|
this._emit("error", new Error(msg.message));
|
|
549
843
|
return;
|
|
550
844
|
}
|
|
845
|
+
this._log("event", msg.type, msg.entityId || "");
|
|
846
|
+
if (msg.entityId && msg.data && typeof msg.data === "object") {
|
|
847
|
+
msg.entity = {
|
|
848
|
+
id: msg.entityId,
|
|
849
|
+
type: msg.entityType,
|
|
850
|
+
...msg.data
|
|
851
|
+
};
|
|
852
|
+
}
|
|
551
853
|
this._emit(msg.type, msg);
|
|
552
854
|
this._emit("message", msg);
|
|
553
855
|
};
|
|
@@ -650,19 +952,55 @@ var GeonicDB = class extends EventEmitter {
|
|
|
650
952
|
if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
|
|
651
953
|
if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
|
|
652
954
|
}
|
|
653
|
-
this._auth = new AuthManager(baseUrl, apiKey, tenant);
|
|
955
|
+
this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug);
|
|
654
956
|
this._auth.onTokenRefresh = (creds) => {
|
|
655
957
|
this.onTokenRefresh?.(creds);
|
|
656
958
|
this.emit("tokenRefresh", creds);
|
|
657
959
|
};
|
|
960
|
+
if (opts.cache !== false) {
|
|
961
|
+
const cache = new SdkCache(opts.cacheMaxEntries ?? SDK_CACHE_MAX_ENTRIES_DEFAULT);
|
|
962
|
+
this._auth.setCache(cache);
|
|
963
|
+
this._auth.setCacheEventEmitter((name, payload) => this.emit(name, payload));
|
|
964
|
+
}
|
|
658
965
|
this._ws = new WebSocketManager(
|
|
659
966
|
this._auth,
|
|
660
967
|
baseUrl,
|
|
661
968
|
tenant,
|
|
662
|
-
(event, data) =>
|
|
969
|
+
(event, data) => {
|
|
970
|
+
this.emit(event, data);
|
|
971
|
+
if (event === "entityCreated" || event === "entityUpdated" || event === "entityDeleted") {
|
|
972
|
+
this._invalidateCacheForEntityEvent(data);
|
|
973
|
+
}
|
|
974
|
+
},
|
|
663
975
|
opts.wsEndpoint
|
|
664
976
|
);
|
|
665
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Drop cache entries that may have been affected by an entity change.
|
|
980
|
+
*
|
|
981
|
+
* The SDK does not know which lists exactly contain the entity, so the
|
|
982
|
+
* conservative strategy is to drop all NGSI-LD / NGSIv2 entity-related
|
|
983
|
+
* paths. ETag re-validation on the next read brings the data back in one
|
|
984
|
+
* round trip with `If-None-Match`, so this remains efficient even when
|
|
985
|
+
* over-invalidating.
|
|
986
|
+
*/
|
|
987
|
+
_invalidateCacheForEntityEvent(event) {
|
|
988
|
+
const cache = this._auth.getCache();
|
|
989
|
+
if (!cache) return;
|
|
990
|
+
const entityId = event?.entityId;
|
|
991
|
+
const removed = cache.deleteWhere((key) => {
|
|
992
|
+
if (key.includes("/ngsi-ld/v1/entities") || key.includes("/v2/entities")) return true;
|
|
993
|
+
if (entityId && key.includes(entityId)) return true;
|
|
994
|
+
return false;
|
|
995
|
+
});
|
|
996
|
+
for (const key of removed) {
|
|
997
|
+
this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/** Drop every cached response. Useful in tests and on manual auth changes. */
|
|
1001
|
+
clearCache() {
|
|
1002
|
+
this._auth.getCache()?.clear();
|
|
1003
|
+
}
|
|
666
1004
|
// --- Authentication ---
|
|
667
1005
|
/** Login with email and password (Bearer JWT). */
|
|
668
1006
|
async login(email, password) {
|
|
@@ -687,9 +1025,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
687
1025
|
const res = await this._auth.request("POST", "/ngsi-ld/v1/entities", entity);
|
|
688
1026
|
if (!res.ok) {
|
|
689
1027
|
const e = await res.json().catch(() => ({}));
|
|
690
|
-
throw
|
|
691
|
-
e.detail || e.description || "Create failed"
|
|
692
|
-
);
|
|
1028
|
+
throw createErrorFromResponse(res.status, e, "Create failed");
|
|
693
1029
|
}
|
|
694
1030
|
return { created: true };
|
|
695
1031
|
}
|
|
@@ -701,35 +1037,88 @@ var GeonicDB = class extends EventEmitter {
|
|
|
701
1037
|
);
|
|
702
1038
|
if (!res.ok) {
|
|
703
1039
|
const e = await res.json().catch(() => ({}));
|
|
704
|
-
throw
|
|
705
|
-
e.detail || e.description || "Not found"
|
|
706
|
-
);
|
|
1040
|
+
throw createErrorFromResponse(res.status, e, "Not found");
|
|
707
1041
|
}
|
|
708
1042
|
return await res.json();
|
|
709
1043
|
}
|
|
710
1044
|
/** Query entities with optional filters. */
|
|
711
1045
|
async getEntities(params) {
|
|
712
|
-
|
|
713
|
-
if (params) {
|
|
714
|
-
const parts = [];
|
|
715
|
-
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
716
|
-
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
717
|
-
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
718
|
-
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
719
|
-
if (parts.length) qs = "?" + parts.join("&");
|
|
720
|
-
}
|
|
721
|
-
const res = await this._auth.request(
|
|
722
|
-
"GET",
|
|
723
|
-
"/ngsi-ld/v1/entities" + qs
|
|
724
|
-
);
|
|
1046
|
+
const res = await this._auth.request("GET", buildEntitiesPath(params));
|
|
725
1047
|
if (!res.ok) {
|
|
726
1048
|
const e = await res.json().catch(() => ({}));
|
|
727
|
-
throw
|
|
728
|
-
e.detail || e.description || "Query failed"
|
|
729
|
-
);
|
|
1049
|
+
throw createErrorFromResponse(res.status, e, "Query failed");
|
|
730
1050
|
}
|
|
731
1051
|
return await res.json();
|
|
732
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Poll for entity list changes using ETag-based revalidation (#991 Phase A).
|
|
1055
|
+
*
|
|
1056
|
+
* The handle's `stop()` ends the loop. Each tick performs a normal
|
|
1057
|
+
* `getEntities()` call; the SDK's cache layer issues `If-None-Match`
|
|
1058
|
+
* automatically. The poll detects "no change" by comparing the previous
|
|
1059
|
+
* ETag with the response's ETag (the cache exposes the cached ETag on 304
|
|
1060
|
+
* replays as well, so the comparison stays valid in both 200 and 304
|
|
1061
|
+
* paths).
|
|
1062
|
+
*
|
|
1063
|
+
* @example
|
|
1064
|
+
* ```typescript
|
|
1065
|
+
* const handle = db.poll({ type: 'Room' }, {
|
|
1066
|
+
* interval: 5000,
|
|
1067
|
+
* onData: (rooms) => render(rooms),
|
|
1068
|
+
* onNoChange: () => {},
|
|
1069
|
+
* });
|
|
1070
|
+
* // ...later
|
|
1071
|
+
* handle.stop();
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
poll(params, options) {
|
|
1075
|
+
const interval = options.interval ?? SDK_POLL_INTERVAL_MS_DEFAULT;
|
|
1076
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
1077
|
+
throw new Error(`poll interval must be a positive number (got ${String(interval)})`);
|
|
1078
|
+
}
|
|
1079
|
+
let stopped = false;
|
|
1080
|
+
let timer = null;
|
|
1081
|
+
let prevEtag;
|
|
1082
|
+
let prevPath;
|
|
1083
|
+
const buildPath = () => buildEntitiesPath(params);
|
|
1084
|
+
const tick = async () => {
|
|
1085
|
+
if (stopped) return;
|
|
1086
|
+
try {
|
|
1087
|
+
const path = prevPath ?? (prevPath = buildPath());
|
|
1088
|
+
const res = await this._auth.request("GET", path);
|
|
1089
|
+
if (stopped) return;
|
|
1090
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
1091
|
+
if (prevEtag !== void 0 && etag !== void 0 && prevEtag === etag) {
|
|
1092
|
+
options.onNoChange?.();
|
|
1093
|
+
} else {
|
|
1094
|
+
if (!res.ok) {
|
|
1095
|
+
throw createErrorFromResponse(res.status, {}, "Poll request failed");
|
|
1096
|
+
}
|
|
1097
|
+
const data = await res.json();
|
|
1098
|
+
prevEtag = etag;
|
|
1099
|
+
options.onData?.(data);
|
|
1100
|
+
}
|
|
1101
|
+
} catch (err) {
|
|
1102
|
+
options.onError?.(err);
|
|
1103
|
+
} finally {
|
|
1104
|
+
if (!stopped) {
|
|
1105
|
+
timer = setTimeout(() => {
|
|
1106
|
+
void tick();
|
|
1107
|
+
}, interval);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
void tick();
|
|
1112
|
+
return {
|
|
1113
|
+
stop() {
|
|
1114
|
+
stopped = true;
|
|
1115
|
+
if (timer !== null) {
|
|
1116
|
+
clearTimeout(timer);
|
|
1117
|
+
timer = null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
733
1122
|
/** Count entities matching the given filters. */
|
|
734
1123
|
async count(params) {
|
|
735
1124
|
const parts = ["count=true", "limit=0"];
|
|
@@ -742,9 +1131,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
742
1131
|
);
|
|
743
1132
|
if (!res.ok) {
|
|
744
1133
|
const e = await res.json().catch(() => ({}));
|
|
745
|
-
throw
|
|
746
|
-
e.detail || e.description || "Count failed"
|
|
747
|
-
);
|
|
1134
|
+
throw createErrorFromResponse(res.status, e, "Count failed");
|
|
748
1135
|
}
|
|
749
1136
|
const countHeader = res.headers.get("NGSILD-Results-Count");
|
|
750
1137
|
return countHeader ? parseInt(countHeader, 10) : 0;
|
|
@@ -758,9 +1145,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
758
1145
|
);
|
|
759
1146
|
if (!res.ok) {
|
|
760
1147
|
const e = await res.json().catch(() => ({}));
|
|
761
|
-
throw
|
|
762
|
-
e.detail || e.description || "Update failed"
|
|
763
|
-
);
|
|
1148
|
+
throw createErrorFromResponse(res.status, e, "Update failed");
|
|
764
1149
|
}
|
|
765
1150
|
return { updated: true };
|
|
766
1151
|
}
|
|
@@ -772,9 +1157,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
772
1157
|
);
|
|
773
1158
|
if (!res.ok) {
|
|
774
1159
|
const e = await res.json().catch(() => ({}));
|
|
775
|
-
throw
|
|
776
|
-
e.detail || e.description || "Delete failed"
|
|
777
|
-
);
|
|
1160
|
+
throw createErrorFromResponse(res.status, e, "Delete failed");
|
|
778
1161
|
}
|
|
779
1162
|
return { deleted: true };
|
|
780
1163
|
}
|
|
@@ -871,9 +1254,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
871
1254
|
const res = await this._auth.request("GET", path);
|
|
872
1255
|
if (!res.ok) {
|
|
873
1256
|
const e = await res.json().catch(() => ({}));
|
|
874
|
-
throw
|
|
875
|
-
e.detail || e.description || fallbackError
|
|
876
|
-
);
|
|
1257
|
+
throw createErrorFromResponse(res.status, e, fallbackError);
|
|
877
1258
|
}
|
|
878
1259
|
return await res.json();
|
|
879
1260
|
}
|
|
@@ -882,9 +1263,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
882
1263
|
const res = await this._auth.request("POST", path, body);
|
|
883
1264
|
if (!res.ok) {
|
|
884
1265
|
const e = await res.json().catch(() => ({}));
|
|
885
|
-
throw
|
|
886
|
-
e.detail || e.description || fallbackError
|
|
887
|
-
);
|
|
1266
|
+
throw createErrorFromResponse(res.status, e, fallbackError);
|
|
888
1267
|
}
|
|
889
1268
|
if (res.status === 204) return {};
|
|
890
1269
|
return await res.json();
|
|
@@ -898,9 +1277,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
898
1277
|
const res = await this._auth.request(method, path, body);
|
|
899
1278
|
if (!res.ok) {
|
|
900
1279
|
const e = await res.json().catch(() => ({}));
|
|
901
|
-
throw
|
|
902
|
-
e.detail || e.description || "Request failed: " + res.status
|
|
903
|
-
);
|
|
1280
|
+
throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
|
|
904
1281
|
}
|
|
905
1282
|
const ct = res.headers.get("Content-Type") || "";
|
|
906
1283
|
if (res.status === 204 || !ct) return null;
|
|
@@ -940,8 +1317,25 @@ var index_default = GeonicDB;
|
|
|
940
1317
|
if (typeof window !== "undefined") {
|
|
941
1318
|
window.GeonicDB = GeonicDB;
|
|
942
1319
|
}
|
|
1320
|
+
function buildEntitiesPath(params) {
|
|
1321
|
+
if (!params) return "/ngsi-ld/v1/entities";
|
|
1322
|
+
const parts = [];
|
|
1323
|
+
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
1324
|
+
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
1325
|
+
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
1326
|
+
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
1327
|
+
return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
|
|
1328
|
+
}
|
|
943
1329
|
export {
|
|
1330
|
+
AuthenticationError,
|
|
1331
|
+
AuthorizationError,
|
|
1332
|
+
ConflictError,
|
|
944
1333
|
GeonicDB,
|
|
1334
|
+
GeonicDBError,
|
|
1335
|
+
NetworkError,
|
|
1336
|
+
NotFoundError,
|
|
1337
|
+
RateLimitError,
|
|
1338
|
+
ValidationError,
|
|
945
1339
|
index_default as default
|
|
946
1340
|
};
|
|
947
1341
|
//# sourceMappingURL=geonicdb.mjs.map
|