@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.cjs
CHANGED
|
@@ -22,7 +22,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
22
22
|
// src/sdk/index.ts
|
|
23
23
|
var index_exports = {};
|
|
24
24
|
__export(index_exports, {
|
|
25
|
+
AuthenticationError: () => AuthenticationError,
|
|
26
|
+
AuthorizationError: () => AuthorizationError,
|
|
27
|
+
ConflictError: () => ConflictError,
|
|
25
28
|
GeonicDB: () => GeonicDB,
|
|
29
|
+
GeonicDBError: () => GeonicDBError,
|
|
30
|
+
NetworkError: () => NetworkError,
|
|
31
|
+
NotFoundError: () => NotFoundError,
|
|
32
|
+
RateLimitError: () => RateLimitError,
|
|
33
|
+
ValidationError: () => ValidationError,
|
|
26
34
|
default: () => index_default
|
|
27
35
|
});
|
|
28
36
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -172,13 +180,178 @@ var RECONNECT_MAX_ATTEMPTS = 10;
|
|
|
172
180
|
var RECONNECT_BASE_MS = 1e3;
|
|
173
181
|
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
174
182
|
var SUB_PROTOCOL = "access_token";
|
|
183
|
+
var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
|
|
184
|
+
var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
|
|
185
|
+
|
|
186
|
+
// src/sdk/errors.ts
|
|
187
|
+
var GeonicDBError = class extends Error {
|
|
188
|
+
constructor(message, statusCode = 0) {
|
|
189
|
+
super(message);
|
|
190
|
+
/** HTTP status code (if applicable) */
|
|
191
|
+
__publicField(this, "statusCode");
|
|
192
|
+
this.name = "GeonicDBError";
|
|
193
|
+
this.statusCode = statusCode;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
var AuthenticationError = class extends GeonicDBError {
|
|
197
|
+
constructor(message = "Authentication failed") {
|
|
198
|
+
super(message, 401);
|
|
199
|
+
this.name = "AuthenticationError";
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var AuthorizationError = class extends GeonicDBError {
|
|
203
|
+
constructor(message = "Access denied") {
|
|
204
|
+
super(message, 403);
|
|
205
|
+
this.name = "AuthorizationError";
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
var NotFoundError = class extends GeonicDBError {
|
|
209
|
+
constructor(message = "Not found") {
|
|
210
|
+
super(message, 404);
|
|
211
|
+
this.name = "NotFoundError";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
var ConflictError = class extends GeonicDBError {
|
|
215
|
+
constructor(message = "Conflict") {
|
|
216
|
+
super(message, 409);
|
|
217
|
+
this.name = "ConflictError";
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var ValidationError = class extends GeonicDBError {
|
|
221
|
+
constructor(message = "Validation failed") {
|
|
222
|
+
super(message, 422);
|
|
223
|
+
this.name = "ValidationError";
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
var RateLimitError = class extends GeonicDBError {
|
|
227
|
+
constructor(message = "Rate limit exceeded", retryAfter = 1) {
|
|
228
|
+
super(message, 429);
|
|
229
|
+
/** Seconds to wait before retrying (from Retry-After header) */
|
|
230
|
+
__publicField(this, "retryAfter");
|
|
231
|
+
this.name = "RateLimitError";
|
|
232
|
+
this.retryAfter = retryAfter;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
var NetworkError = class extends GeonicDBError {
|
|
236
|
+
constructor(message = "Network error") {
|
|
237
|
+
super(message, 0);
|
|
238
|
+
this.name = "NetworkError";
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
function createErrorFromResponse(status, body, fallbackMessage) {
|
|
242
|
+
const message = body.detail || body.description || fallbackMessage;
|
|
243
|
+
switch (status) {
|
|
244
|
+
case 401:
|
|
245
|
+
return new AuthenticationError(message);
|
|
246
|
+
case 403:
|
|
247
|
+
return new AuthorizationError(message);
|
|
248
|
+
case 404:
|
|
249
|
+
return new NotFoundError(message);
|
|
250
|
+
case 409:
|
|
251
|
+
return new ConflictError(message);
|
|
252
|
+
case 422:
|
|
253
|
+
return new ValidationError(message);
|
|
254
|
+
case 429:
|
|
255
|
+
return new RateLimitError(message);
|
|
256
|
+
default:
|
|
257
|
+
return new GeonicDBError(message, status);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/sdk/cache.ts
|
|
262
|
+
var SdkCache = class {
|
|
263
|
+
constructor(maxEntries = SDK_CACHE_MAX_ENTRIES_DEFAULT) {
|
|
264
|
+
__publicField(this, "_store", /* @__PURE__ */ new Map());
|
|
265
|
+
__publicField(this, "_maxEntries");
|
|
266
|
+
if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
|
|
267
|
+
throw new Error(`SdkCache maxEntries must be a positive integer, got ${String(maxEntries)}`);
|
|
268
|
+
}
|
|
269
|
+
this._maxEntries = maxEntries;
|
|
270
|
+
}
|
|
271
|
+
/** Build a stable cache key. Method is upper-cased so casing differences do not produce duplicate entries. */
|
|
272
|
+
static keyFor(method, path) {
|
|
273
|
+
return `${method.toUpperCase()}:${path}`;
|
|
274
|
+
}
|
|
275
|
+
/** Returns the entry and bumps it to most-recent position, or `undefined`. */
|
|
276
|
+
get(key) {
|
|
277
|
+
const entry = this._store.get(key);
|
|
278
|
+
if (!entry) return void 0;
|
|
279
|
+
this._store.delete(key);
|
|
280
|
+
this._store.set(key, entry);
|
|
281
|
+
return entry;
|
|
282
|
+
}
|
|
283
|
+
/** Insert or replace an entry. Evicts the LRU entry when over capacity. */
|
|
284
|
+
set(key, entry) {
|
|
285
|
+
if (this._store.has(key)) {
|
|
286
|
+
this._store.delete(key);
|
|
287
|
+
}
|
|
288
|
+
this._store.set(key, entry);
|
|
289
|
+
while (this._store.size > this._maxEntries) {
|
|
290
|
+
const oldest = this._store.keys().next().value;
|
|
291
|
+
if (oldest === void 0) break;
|
|
292
|
+
this._store.delete(oldest);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Delete a specific entry. Returns whether anything was removed. */
|
|
296
|
+
delete(key) {
|
|
297
|
+
return this._store.delete(key);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Delete every entry whose key matches a predicate. Returns the array of
|
|
301
|
+
* removed keys so callers can emit `cacheInvalidated` events.
|
|
302
|
+
*/
|
|
303
|
+
deleteWhere(predicate) {
|
|
304
|
+
const removed = [];
|
|
305
|
+
for (const [key, entry] of this._store) {
|
|
306
|
+
if (predicate(key, entry)) {
|
|
307
|
+
this._store.delete(key);
|
|
308
|
+
removed.push(key);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return removed;
|
|
312
|
+
}
|
|
313
|
+
/** Drop everything. */
|
|
314
|
+
clear() {
|
|
315
|
+
this._store.clear();
|
|
316
|
+
}
|
|
317
|
+
/** Current entry count. Useful for tests / metrics. */
|
|
318
|
+
size() {
|
|
319
|
+
return this._store.size;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
var HEADERS_TO_PERSIST = [
|
|
323
|
+
"content-type",
|
|
324
|
+
"etag",
|
|
325
|
+
"last-modified",
|
|
326
|
+
"cache-control",
|
|
327
|
+
"vary",
|
|
328
|
+
"link",
|
|
329
|
+
"ngsild-results-count",
|
|
330
|
+
"fiware-total-count",
|
|
331
|
+
"x-total-count",
|
|
332
|
+
"ngsild-next",
|
|
333
|
+
"fiware-next-token"
|
|
334
|
+
];
|
|
335
|
+
function snapshotHeaders(headers) {
|
|
336
|
+
const out = {};
|
|
337
|
+
for (const name of HEADERS_TO_PERSIST) {
|
|
338
|
+
const value = headers.get(name);
|
|
339
|
+
if (value !== null) out[name] = value;
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
function isCacheableMethod(method) {
|
|
344
|
+
const upper = method.toUpperCase();
|
|
345
|
+
return upper === "GET" || upper === "HEAD";
|
|
346
|
+
}
|
|
175
347
|
|
|
176
348
|
// src/sdk/auth.ts
|
|
177
349
|
var _AuthManager = class _AuthManager {
|
|
178
|
-
constructor(baseUrl, apiKey, tenant) {
|
|
350
|
+
constructor(baseUrl, apiKey, tenant, debug = false) {
|
|
179
351
|
__publicField(this, "_baseUrl");
|
|
180
352
|
__publicField(this, "_apiKey");
|
|
181
353
|
__publicField(this, "_tenant");
|
|
354
|
+
__publicField(this, "_debug");
|
|
182
355
|
__publicField(this, "_token", null);
|
|
183
356
|
__publicField(this, "_tokenExpiry", 0);
|
|
184
357
|
__publicField(this, "_tokenType", "Bearer");
|
|
@@ -189,9 +362,20 @@ var _AuthManager = class _AuthManager {
|
|
|
189
362
|
__publicField(this, "_tokenPromise", null);
|
|
190
363
|
/** Callback to emit tokenRefresh events. Set by GeonicDB class. */
|
|
191
364
|
__publicField(this, "onTokenRefresh", null);
|
|
365
|
+
/**
|
|
366
|
+
* SDK-level cache (#991 Phase A). When set, cacheable GET requests are
|
|
367
|
+
* served via ETag/304 with automatic If-None-Match negotiation, and
|
|
368
|
+
* concurrent requests to the same path are deduplicated.
|
|
369
|
+
*/
|
|
370
|
+
__publicField(this, "_cache", null);
|
|
371
|
+
/** Emitter for cacheHit / cacheMiss / cacheInvalidated events. */
|
|
372
|
+
__publicField(this, "_emitCacheEvent", null);
|
|
373
|
+
/** In-flight request map keyed by `${METHOD}:${path}` for request deduplication. */
|
|
374
|
+
__publicField(this, "_inFlight", /* @__PURE__ */ new Map());
|
|
192
375
|
this._baseUrl = baseUrl;
|
|
193
376
|
this._apiKey = apiKey;
|
|
194
377
|
this._tenant = tenant;
|
|
378
|
+
this._debug = debug;
|
|
195
379
|
if (dpopSupported) {
|
|
196
380
|
this._dpopReady = generateDPoPKeyPair().then((kp) => {
|
|
197
381
|
this._dpopKeyPair = kp;
|
|
@@ -200,8 +384,28 @@ var _AuthManager = class _AuthManager {
|
|
|
200
384
|
});
|
|
201
385
|
}
|
|
202
386
|
}
|
|
387
|
+
_log(...args) {
|
|
388
|
+
if (this._debug) console.log("[GeonicDB]", ...args);
|
|
389
|
+
}
|
|
390
|
+
/** Wire an SdkCache instance (called by GeonicDB constructor when caching is enabled). */
|
|
391
|
+
setCache(cache) {
|
|
392
|
+
this._cache = cache;
|
|
393
|
+
}
|
|
394
|
+
/** Provide the cache event emitter (forwarded to the GeonicDB EventEmitter). */
|
|
395
|
+
setCacheEventEmitter(emitter) {
|
|
396
|
+
this._emitCacheEvent = emitter;
|
|
397
|
+
}
|
|
398
|
+
/** Expose the cache for invalidation (e.g. WebSocket entity events). */
|
|
399
|
+
getCache() {
|
|
400
|
+
return this._cache;
|
|
401
|
+
}
|
|
402
|
+
/** Emit a cache event if a listener is wired. */
|
|
403
|
+
emitCacheEvent(name, payload) {
|
|
404
|
+
this._emitCacheEvent?.(name, payload);
|
|
405
|
+
}
|
|
203
406
|
/** Login with email and password (Bearer JWT). */
|
|
204
407
|
async login(email, password) {
|
|
408
|
+
this._log("login", email);
|
|
205
409
|
const headers = {
|
|
206
410
|
"Content-Type": "application/json"
|
|
207
411
|
};
|
|
@@ -213,7 +417,7 @@ var _AuthManager = class _AuthManager {
|
|
|
213
417
|
});
|
|
214
418
|
if (!res.ok) {
|
|
215
419
|
const e = await res.json().catch(() => ({}));
|
|
216
|
-
throw new
|
|
420
|
+
throw new AuthenticationError(
|
|
217
421
|
e.detail || e.description || "Login failed: " + res.status
|
|
218
422
|
);
|
|
219
423
|
}
|
|
@@ -222,6 +426,7 @@ var _AuthManager = class _AuthManager {
|
|
|
222
426
|
this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
|
|
223
427
|
this._tokenType = "Bearer";
|
|
224
428
|
this._refreshToken = data.refreshToken;
|
|
429
|
+
this._invalidateAuthScopedState();
|
|
225
430
|
return data;
|
|
226
431
|
}
|
|
227
432
|
/**
|
|
@@ -236,6 +441,7 @@ var _AuthManager = class _AuthManager {
|
|
|
236
441
|
this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
|
|
237
442
|
this._refreshToken = opts.refreshToken || null;
|
|
238
443
|
this._tokenPromise = null;
|
|
444
|
+
this._invalidateAuthScopedState();
|
|
239
445
|
}
|
|
240
446
|
/** Clear all credentials. */
|
|
241
447
|
logout() {
|
|
@@ -243,6 +449,17 @@ var _AuthManager = class _AuthManager {
|
|
|
243
449
|
this._tokenExpiry = 0;
|
|
244
450
|
this._refreshToken = null;
|
|
245
451
|
this._tokenPromise = null;
|
|
452
|
+
this._invalidateAuthScopedState();
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Drop everything that may have been associated with the previous auth
|
|
456
|
+
* context. Called on `login()` (after token assignment), `setCredentials()`,
|
|
457
|
+
* and `logout()`. Without this, a cached body or in-flight Response from
|
|
458
|
+
* user A could be returned to user B after a credentials swap.
|
|
459
|
+
*/
|
|
460
|
+
_invalidateAuthScopedState() {
|
|
461
|
+
this._cache?.clear();
|
|
462
|
+
this._inFlight.clear();
|
|
246
463
|
}
|
|
247
464
|
/** Ensure a valid token is available, refreshing or acquiring as needed. */
|
|
248
465
|
async ensureToken() {
|
|
@@ -260,6 +477,7 @@ var _AuthManager = class _AuthManager {
|
|
|
260
477
|
return this._tokenPromise;
|
|
261
478
|
}
|
|
262
479
|
async _refreshBearerToken() {
|
|
480
|
+
this._log("refreshing Bearer token");
|
|
263
481
|
try {
|
|
264
482
|
const res = await fetch(this._baseUrl + "/auth/refresh", {
|
|
265
483
|
method: "POST",
|
|
@@ -270,7 +488,7 @@ var _AuthManager = class _AuthManager {
|
|
|
270
488
|
this._refreshToken = null;
|
|
271
489
|
this._token = null;
|
|
272
490
|
this._tokenPromise = null;
|
|
273
|
-
throw new
|
|
491
|
+
throw new AuthenticationError("Token refresh failed: " + res.status);
|
|
274
492
|
}
|
|
275
493
|
const data = await res.json();
|
|
276
494
|
this._token = data.accessToken;
|
|
@@ -299,7 +517,7 @@ var _AuthManager = class _AuthManager {
|
|
|
299
517
|
body: JSON.stringify({ api_key: this._apiKey })
|
|
300
518
|
});
|
|
301
519
|
if (!nonceRes.ok)
|
|
302
|
-
throw new
|
|
520
|
+
throw new AuthenticationError("Nonce request failed: " + nonceRes.status);
|
|
303
521
|
const nonceData = await nonceRes.json();
|
|
304
522
|
if (nonceData.dpop_nonce) this._dpopNonce = nonceData.dpop_nonce;
|
|
305
523
|
const proof = await solvePoW(nonceData.challenge, nonceData.difficulty);
|
|
@@ -312,7 +530,7 @@ var _AuthManager = class _AuthManager {
|
|
|
312
530
|
});
|
|
313
531
|
const res = await this._doTokenExchange(tokenUrl, tokenBody, this._dpopNonce);
|
|
314
532
|
if (!res.ok)
|
|
315
|
-
throw new
|
|
533
|
+
throw new AuthenticationError("Token request failed: " + res.status);
|
|
316
534
|
const newNonce = res.headers.get("DPoP-Nonce");
|
|
317
535
|
if (newNonce) this._dpopNonce = newNonce;
|
|
318
536
|
const data = await res.json();
|
|
@@ -352,20 +570,98 @@ var _AuthManager = class _AuthManager {
|
|
|
352
570
|
return this._doTokenExchange(tokenUrl, tokenBody, serverNonce, retryCount + 1);
|
|
353
571
|
}
|
|
354
572
|
}
|
|
355
|
-
throw new
|
|
356
|
-
"Token request failed: " + (errBody.error_description || errBody.error)
|
|
573
|
+
throw new GeonicDBError(
|
|
574
|
+
"Token request failed: " + (errBody.error_description || errBody.error),
|
|
575
|
+
400
|
|
357
576
|
);
|
|
358
577
|
}
|
|
359
578
|
return res;
|
|
360
579
|
}
|
|
361
580
|
/**
|
|
362
581
|
* Make an authenticated HTTP request with automatic token refresh and DPoP.
|
|
582
|
+
*
|
|
583
|
+
* When the SDK cache is enabled and the request is cacheable
|
|
584
|
+
* (GET / HEAD without a body), this method:
|
|
585
|
+
* - Sends `If-None-Match` / `If-Modified-Since` derived from a previously
|
|
586
|
+
* cached entry, if any.
|
|
587
|
+
* - Returns the cached body wrapped in a synthesized `200` Response when
|
|
588
|
+
* the server replies `304 Not Modified`.
|
|
589
|
+
* - Persists fresh `200` responses (with their ETag/Last-Modified) into
|
|
590
|
+
* the cache.
|
|
591
|
+
* - Deduplicates concurrent in-flight requests to the same path.
|
|
363
592
|
*/
|
|
364
593
|
async request(method, path, body) {
|
|
594
|
+
this._log(method, path);
|
|
595
|
+
if (!this._cache || !isCacheableMethod(method) || body !== void 0) {
|
|
596
|
+
const token = await this.ensureToken();
|
|
597
|
+
const res = await this._doAuthenticatedRequest(method, path, body, token);
|
|
598
|
+
this._log(method, path, "\u2192", res.status);
|
|
599
|
+
return res;
|
|
600
|
+
}
|
|
601
|
+
const key = SdkCache.keyFor(method, path);
|
|
602
|
+
const inFlight = this._inFlight.get(key);
|
|
603
|
+
if (inFlight) {
|
|
604
|
+
return inFlight.then((res) => res.clone());
|
|
605
|
+
}
|
|
606
|
+
const promise = this._cachedRequest(method, path, key);
|
|
607
|
+
this._inFlight.set(key, promise);
|
|
608
|
+
try {
|
|
609
|
+
return await promise;
|
|
610
|
+
} finally {
|
|
611
|
+
this._inFlight.delete(key);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async _cachedRequest(method, path, key) {
|
|
615
|
+
const cache = this._cache;
|
|
616
|
+
if (!cache) {
|
|
617
|
+
const token2 = await this.ensureToken();
|
|
618
|
+
return this._doAuthenticatedRequest(method, path, void 0, token2);
|
|
619
|
+
}
|
|
620
|
+
const cached = cache.get(key);
|
|
621
|
+
const conditional = {};
|
|
622
|
+
if (cached?.etag) conditional["If-None-Match"] = cached.etag;
|
|
623
|
+
if (cached?.lastModified) conditional["If-Modified-Since"] = cached.lastModified;
|
|
365
624
|
const token = await this.ensureToken();
|
|
366
|
-
|
|
625
|
+
const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
|
|
626
|
+
this._log(method, path, "\u2192", res.status);
|
|
627
|
+
if (res.status === 304 && cached) {
|
|
628
|
+
const refreshedHeaders = { ...cached.headers, ...snapshotHeaders(res.headers) };
|
|
629
|
+
const refreshedEtag = res.headers.get("etag") ?? cached.etag;
|
|
630
|
+
const refreshedLastModified = res.headers.get("last-modified") ?? cached.lastModified;
|
|
631
|
+
cache.set(key, {
|
|
632
|
+
...cached,
|
|
633
|
+
etag: refreshedEtag,
|
|
634
|
+
lastModified: refreshedLastModified,
|
|
635
|
+
headers: refreshedHeaders,
|
|
636
|
+
cachedAt: Date.now()
|
|
637
|
+
});
|
|
638
|
+
this.emitCacheEvent("cacheHit", { key, path });
|
|
639
|
+
const body = typeof cached.data === "string" ? cached.data : JSON.stringify(cached.data);
|
|
640
|
+
return new Response(body, { status: 200, headers: refreshedHeaders });
|
|
641
|
+
}
|
|
642
|
+
if (res.status === 200) {
|
|
643
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
644
|
+
const lastModified = res.headers.get("last-modified") ?? void 0;
|
|
645
|
+
if (etag || lastModified) {
|
|
646
|
+
const text = await res.clone().text();
|
|
647
|
+
let data = text;
|
|
648
|
+
try {
|
|
649
|
+
data = JSON.parse(text);
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
cache.set(key, {
|
|
653
|
+
etag,
|
|
654
|
+
lastModified,
|
|
655
|
+
data,
|
|
656
|
+
headers: snapshotHeaders(res.headers),
|
|
657
|
+
cachedAt: Date.now()
|
|
658
|
+
});
|
|
659
|
+
this.emitCacheEvent("cacheMiss", { key, path });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return res;
|
|
367
663
|
}
|
|
368
|
-
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0) {
|
|
664
|
+
async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
|
|
369
665
|
const url = this._baseUrl + path;
|
|
370
666
|
const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
|
|
371
667
|
const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
|
|
@@ -381,7 +677,8 @@ var _AuthManager = class _AuthManager {
|
|
|
381
677
|
const headers = {
|
|
382
678
|
Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
|
|
383
679
|
"Content-Type": "application/ld+json",
|
|
384
|
-
Accept: "application/ld+json"
|
|
680
|
+
Accept: "application/ld+json",
|
|
681
|
+
...extraHeaders
|
|
385
682
|
};
|
|
386
683
|
if (dpopProof) headers["DPoP"] = dpopProof;
|
|
387
684
|
if (this._tenant) headers["Fiware-Service"] = this._tenant;
|
|
@@ -417,7 +714,7 @@ var _AuthManager = class _AuthManager {
|
|
|
417
714
|
if (retryNonce) this._dpopNonce = retryNonce;
|
|
418
715
|
const delay = parseInt(res.headers.get("Retry-After") || "1", 10) * 1e3;
|
|
419
716
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
420
|
-
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1);
|
|
717
|
+
return this._doAuthenticatedRequest(method, path, body, currentToken, retryCount + 1, extraHeaders);
|
|
421
718
|
}
|
|
422
719
|
return res;
|
|
423
720
|
}
|
|
@@ -448,6 +745,9 @@ var WebSocketManager = class {
|
|
|
448
745
|
this._emit = emit;
|
|
449
746
|
this._wsEndpointOverride = wsEndpointOverride || null;
|
|
450
747
|
}
|
|
748
|
+
_log(...args) {
|
|
749
|
+
if (this._auth._debug) console.log("[GeonicDB:WS]", ...args);
|
|
750
|
+
}
|
|
451
751
|
/** Establish WebSocket connection (authentication is automatic). */
|
|
452
752
|
async connect() {
|
|
453
753
|
if (this._reconnectTimer) {
|
|
@@ -525,10 +825,12 @@ var WebSocketManager = class {
|
|
|
525
825
|
const endpoint = await this._discoverWsEndpoint();
|
|
526
826
|
return new Promise((resolve, reject) => {
|
|
527
827
|
const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "tenant=" + encodeURIComponent(this._tenant);
|
|
828
|
+
this._log("connecting", wsUrl);
|
|
528
829
|
const ws = new WebSocket(wsUrl, [SUB_PROTOCOL, token]);
|
|
529
830
|
this._ws = ws;
|
|
530
831
|
ws.onopen = () => {
|
|
531
832
|
if (this._ws !== ws) return;
|
|
833
|
+
this._log("connected");
|
|
532
834
|
this._reconnectAttempts = 0;
|
|
533
835
|
const isDPoP = this._auth._tokenType === "DPoP" && !!this._auth._dpopKeyPair;
|
|
534
836
|
const bindPromise = isDPoP ? createDPoPProof(this._auth._dpopKeyPair, "GET", wsUrl, null).then(
|
|
@@ -573,6 +875,14 @@ var WebSocketManager = class {
|
|
|
573
875
|
this._emit("error", new Error(msg.message));
|
|
574
876
|
return;
|
|
575
877
|
}
|
|
878
|
+
this._log("event", msg.type, msg.entityId || "");
|
|
879
|
+
if (msg.entityId && msg.data && typeof msg.data === "object") {
|
|
880
|
+
msg.entity = {
|
|
881
|
+
id: msg.entityId,
|
|
882
|
+
type: msg.entityType,
|
|
883
|
+
...msg.data
|
|
884
|
+
};
|
|
885
|
+
}
|
|
576
886
|
this._emit(msg.type, msg);
|
|
577
887
|
this._emit("message", msg);
|
|
578
888
|
};
|
|
@@ -675,19 +985,55 @@ var GeonicDB = class extends EventEmitter {
|
|
|
675
985
|
if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
|
|
676
986
|
if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
|
|
677
987
|
}
|
|
678
|
-
this._auth = new AuthManager(baseUrl, apiKey, tenant);
|
|
988
|
+
this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug);
|
|
679
989
|
this._auth.onTokenRefresh = (creds) => {
|
|
680
990
|
this.onTokenRefresh?.(creds);
|
|
681
991
|
this.emit("tokenRefresh", creds);
|
|
682
992
|
};
|
|
993
|
+
if (opts.cache !== false) {
|
|
994
|
+
const cache = new SdkCache(opts.cacheMaxEntries ?? SDK_CACHE_MAX_ENTRIES_DEFAULT);
|
|
995
|
+
this._auth.setCache(cache);
|
|
996
|
+
this._auth.setCacheEventEmitter((name, payload) => this.emit(name, payload));
|
|
997
|
+
}
|
|
683
998
|
this._ws = new WebSocketManager(
|
|
684
999
|
this._auth,
|
|
685
1000
|
baseUrl,
|
|
686
1001
|
tenant,
|
|
687
|
-
(event, data) =>
|
|
1002
|
+
(event, data) => {
|
|
1003
|
+
this.emit(event, data);
|
|
1004
|
+
if (event === "entityCreated" || event === "entityUpdated" || event === "entityDeleted") {
|
|
1005
|
+
this._invalidateCacheForEntityEvent(data);
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
688
1008
|
opts.wsEndpoint
|
|
689
1009
|
);
|
|
690
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Drop cache entries that may have been affected by an entity change.
|
|
1013
|
+
*
|
|
1014
|
+
* The SDK does not know which lists exactly contain the entity, so the
|
|
1015
|
+
* conservative strategy is to drop all NGSI-LD / NGSIv2 entity-related
|
|
1016
|
+
* paths. ETag re-validation on the next read brings the data back in one
|
|
1017
|
+
* round trip with `If-None-Match`, so this remains efficient even when
|
|
1018
|
+
* over-invalidating.
|
|
1019
|
+
*/
|
|
1020
|
+
_invalidateCacheForEntityEvent(event) {
|
|
1021
|
+
const cache = this._auth.getCache();
|
|
1022
|
+
if (!cache) return;
|
|
1023
|
+
const entityId = event?.entityId;
|
|
1024
|
+
const removed = cache.deleteWhere((key) => {
|
|
1025
|
+
if (key.includes("/ngsi-ld/v1/entities") || key.includes("/v2/entities")) return true;
|
|
1026
|
+
if (entityId && key.includes(entityId)) return true;
|
|
1027
|
+
return false;
|
|
1028
|
+
});
|
|
1029
|
+
for (const key of removed) {
|
|
1030
|
+
this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/** Drop every cached response. Useful in tests and on manual auth changes. */
|
|
1034
|
+
clearCache() {
|
|
1035
|
+
this._auth.getCache()?.clear();
|
|
1036
|
+
}
|
|
691
1037
|
// --- Authentication ---
|
|
692
1038
|
/** Login with email and password (Bearer JWT). */
|
|
693
1039
|
async login(email, password) {
|
|
@@ -712,9 +1058,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
712
1058
|
const res = await this._auth.request("POST", "/ngsi-ld/v1/entities", entity);
|
|
713
1059
|
if (!res.ok) {
|
|
714
1060
|
const e = await res.json().catch(() => ({}));
|
|
715
|
-
throw
|
|
716
|
-
e.detail || e.description || "Create failed"
|
|
717
|
-
);
|
|
1061
|
+
throw createErrorFromResponse(res.status, e, "Create failed");
|
|
718
1062
|
}
|
|
719
1063
|
return { created: true };
|
|
720
1064
|
}
|
|
@@ -726,35 +1070,88 @@ var GeonicDB = class extends EventEmitter {
|
|
|
726
1070
|
);
|
|
727
1071
|
if (!res.ok) {
|
|
728
1072
|
const e = await res.json().catch(() => ({}));
|
|
729
|
-
throw
|
|
730
|
-
e.detail || e.description || "Not found"
|
|
731
|
-
);
|
|
1073
|
+
throw createErrorFromResponse(res.status, e, "Not found");
|
|
732
1074
|
}
|
|
733
1075
|
return await res.json();
|
|
734
1076
|
}
|
|
735
1077
|
/** Query entities with optional filters. */
|
|
736
1078
|
async getEntities(params) {
|
|
737
|
-
|
|
738
|
-
if (params) {
|
|
739
|
-
const parts = [];
|
|
740
|
-
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
741
|
-
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
742
|
-
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
743
|
-
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
744
|
-
if (parts.length) qs = "?" + parts.join("&");
|
|
745
|
-
}
|
|
746
|
-
const res = await this._auth.request(
|
|
747
|
-
"GET",
|
|
748
|
-
"/ngsi-ld/v1/entities" + qs
|
|
749
|
-
);
|
|
1079
|
+
const res = await this._auth.request("GET", buildEntitiesPath(params));
|
|
750
1080
|
if (!res.ok) {
|
|
751
1081
|
const e = await res.json().catch(() => ({}));
|
|
752
|
-
throw
|
|
753
|
-
e.detail || e.description || "Query failed"
|
|
754
|
-
);
|
|
1082
|
+
throw createErrorFromResponse(res.status, e, "Query failed");
|
|
755
1083
|
}
|
|
756
1084
|
return await res.json();
|
|
757
1085
|
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Poll for entity list changes using ETag-based revalidation (#991 Phase A).
|
|
1088
|
+
*
|
|
1089
|
+
* The handle's `stop()` ends the loop. Each tick performs a normal
|
|
1090
|
+
* `getEntities()` call; the SDK's cache layer issues `If-None-Match`
|
|
1091
|
+
* automatically. The poll detects "no change" by comparing the previous
|
|
1092
|
+
* ETag with the response's ETag (the cache exposes the cached ETag on 304
|
|
1093
|
+
* replays as well, so the comparison stays valid in both 200 and 304
|
|
1094
|
+
* paths).
|
|
1095
|
+
*
|
|
1096
|
+
* @example
|
|
1097
|
+
* ```typescript
|
|
1098
|
+
* const handle = db.poll({ type: 'Room' }, {
|
|
1099
|
+
* interval: 5000,
|
|
1100
|
+
* onData: (rooms) => render(rooms),
|
|
1101
|
+
* onNoChange: () => {},
|
|
1102
|
+
* });
|
|
1103
|
+
* // ...later
|
|
1104
|
+
* handle.stop();
|
|
1105
|
+
* ```
|
|
1106
|
+
*/
|
|
1107
|
+
poll(params, options) {
|
|
1108
|
+
const interval = options.interval ?? SDK_POLL_INTERVAL_MS_DEFAULT;
|
|
1109
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
1110
|
+
throw new Error(`poll interval must be a positive number (got ${String(interval)})`);
|
|
1111
|
+
}
|
|
1112
|
+
let stopped = false;
|
|
1113
|
+
let timer = null;
|
|
1114
|
+
let prevEtag;
|
|
1115
|
+
let prevPath;
|
|
1116
|
+
const buildPath = () => buildEntitiesPath(params);
|
|
1117
|
+
const tick = async () => {
|
|
1118
|
+
if (stopped) return;
|
|
1119
|
+
try {
|
|
1120
|
+
const path = prevPath ?? (prevPath = buildPath());
|
|
1121
|
+
const res = await this._auth.request("GET", path);
|
|
1122
|
+
if (stopped) return;
|
|
1123
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
1124
|
+
if (prevEtag !== void 0 && etag !== void 0 && prevEtag === etag) {
|
|
1125
|
+
options.onNoChange?.();
|
|
1126
|
+
} else {
|
|
1127
|
+
if (!res.ok) {
|
|
1128
|
+
throw createErrorFromResponse(res.status, {}, "Poll request failed");
|
|
1129
|
+
}
|
|
1130
|
+
const data = await res.json();
|
|
1131
|
+
prevEtag = etag;
|
|
1132
|
+
options.onData?.(data);
|
|
1133
|
+
}
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
options.onError?.(err);
|
|
1136
|
+
} finally {
|
|
1137
|
+
if (!stopped) {
|
|
1138
|
+
timer = setTimeout(() => {
|
|
1139
|
+
void tick();
|
|
1140
|
+
}, interval);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
void tick();
|
|
1145
|
+
return {
|
|
1146
|
+
stop() {
|
|
1147
|
+
stopped = true;
|
|
1148
|
+
if (timer !== null) {
|
|
1149
|
+
clearTimeout(timer);
|
|
1150
|
+
timer = null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
758
1155
|
/** Count entities matching the given filters. */
|
|
759
1156
|
async count(params) {
|
|
760
1157
|
const parts = ["count=true", "limit=0"];
|
|
@@ -767,9 +1164,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
767
1164
|
);
|
|
768
1165
|
if (!res.ok) {
|
|
769
1166
|
const e = await res.json().catch(() => ({}));
|
|
770
|
-
throw
|
|
771
|
-
e.detail || e.description || "Count failed"
|
|
772
|
-
);
|
|
1167
|
+
throw createErrorFromResponse(res.status, e, "Count failed");
|
|
773
1168
|
}
|
|
774
1169
|
const countHeader = res.headers.get("NGSILD-Results-Count");
|
|
775
1170
|
return countHeader ? parseInt(countHeader, 10) : 0;
|
|
@@ -783,9 +1178,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
783
1178
|
);
|
|
784
1179
|
if (!res.ok) {
|
|
785
1180
|
const e = await res.json().catch(() => ({}));
|
|
786
|
-
throw
|
|
787
|
-
e.detail || e.description || "Update failed"
|
|
788
|
-
);
|
|
1181
|
+
throw createErrorFromResponse(res.status, e, "Update failed");
|
|
789
1182
|
}
|
|
790
1183
|
return { updated: true };
|
|
791
1184
|
}
|
|
@@ -797,9 +1190,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
797
1190
|
);
|
|
798
1191
|
if (!res.ok) {
|
|
799
1192
|
const e = await res.json().catch(() => ({}));
|
|
800
|
-
throw
|
|
801
|
-
e.detail || e.description || "Delete failed"
|
|
802
|
-
);
|
|
1193
|
+
throw createErrorFromResponse(res.status, e, "Delete failed");
|
|
803
1194
|
}
|
|
804
1195
|
return { deleted: true };
|
|
805
1196
|
}
|
|
@@ -896,9 +1287,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
896
1287
|
const res = await this._auth.request("GET", path);
|
|
897
1288
|
if (!res.ok) {
|
|
898
1289
|
const e = await res.json().catch(() => ({}));
|
|
899
|
-
throw
|
|
900
|
-
e.detail || e.description || fallbackError
|
|
901
|
-
);
|
|
1290
|
+
throw createErrorFromResponse(res.status, e, fallbackError);
|
|
902
1291
|
}
|
|
903
1292
|
return await res.json();
|
|
904
1293
|
}
|
|
@@ -907,9 +1296,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
907
1296
|
const res = await this._auth.request("POST", path, body);
|
|
908
1297
|
if (!res.ok) {
|
|
909
1298
|
const e = await res.json().catch(() => ({}));
|
|
910
|
-
throw
|
|
911
|
-
e.detail || e.description || fallbackError
|
|
912
|
-
);
|
|
1299
|
+
throw createErrorFromResponse(res.status, e, fallbackError);
|
|
913
1300
|
}
|
|
914
1301
|
if (res.status === 204) return {};
|
|
915
1302
|
return await res.json();
|
|
@@ -923,9 +1310,7 @@ var GeonicDB = class extends EventEmitter {
|
|
|
923
1310
|
const res = await this._auth.request(method, path, body);
|
|
924
1311
|
if (!res.ok) {
|
|
925
1312
|
const e = await res.json().catch(() => ({}));
|
|
926
|
-
throw
|
|
927
|
-
e.detail || e.description || "Request failed: " + res.status
|
|
928
|
-
);
|
|
1313
|
+
throw createErrorFromResponse(res.status, e, "Request failed: " + res.status);
|
|
929
1314
|
}
|
|
930
1315
|
const ct = res.headers.get("Content-Type") || "";
|
|
931
1316
|
if (res.status === 204 || !ct) return null;
|
|
@@ -965,4 +1350,13 @@ var index_default = GeonicDB;
|
|
|
965
1350
|
if (typeof window !== "undefined") {
|
|
966
1351
|
window.GeonicDB = GeonicDB;
|
|
967
1352
|
}
|
|
1353
|
+
function buildEntitiesPath(params) {
|
|
1354
|
+
if (!params) return "/ngsi-ld/v1/entities";
|
|
1355
|
+
const parts = [];
|
|
1356
|
+
if (params.type) parts.push("type=" + encodeURIComponent(params.type));
|
|
1357
|
+
if (params.limit != null) parts.push("limit=" + params.limit);
|
|
1358
|
+
if (params.offset != null) parts.push("offset=" + params.offset);
|
|
1359
|
+
if (params.q) parts.push("q=" + encodeURIComponent(params.q));
|
|
1360
|
+
return parts.length > 0 ? "/ngsi-ld/v1/entities?" + parts.join("&") : "/ngsi-ld/v1/entities";
|
|
1361
|
+
}
|
|
968
1362
|
//# sourceMappingURL=geonicdb.cjs.map
|