@geolonia/geonicdb-sdk 0.18.1 → 0.19.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 CHANGED
@@ -374,6 +374,54 @@ await db.connect();
374
374
  | `cacheMiss` | `CacheEvent` | Cache miss — fresh response fetched from origin |
375
375
  | `cacheInvalidated` | `CacheEvent` | Cache entry dropped via explicit `clearCache()`. WebSocket entity events do **not** trigger cache invalidation — see "Client-side Cache & Polling" |
376
376
 
377
+ ## Web Push Notifications (#3014 / #3060)
378
+
379
+ GeonicDB supports browser Web Push (VAPID) as a third NGSI subscription notification protocol
380
+ (alongside HTTP webhook and MQTT — see [SUBSCRIPTIONS.md](https://github.com/geolonia/geonicdb/blob/main/docs/SUBSCRIPTIONS.md#web-push-3014)
381
+ for the full API contract). The SDK wraps the browser Push API and the NGSI payload shape so you
382
+ don't have to.
383
+
384
+ ```javascript
385
+ // 1. Register a service worker in your app's origin.
386
+ await navigator.serviceWorker.register('/sw.js');
387
+
388
+ // 2. Subscribe — VAPID public key is auto-fetched from the deployment's discovery endpoint.
389
+ const subscription = await db.subscribeWebPush();
390
+
391
+ // 3. Create the NGSI subscription with the converted payload.
392
+ import { toNgsiv2WebPushNotification } from '@geolonia/geonicdb-sdk';
393
+
394
+ await db.request('POST', '/v2/subscriptions', {
395
+ description: 'Web Push notification',
396
+ subject: { entities: [{ type: 'Sensor' }], condition: { attrs: ['value'] } },
397
+ notification: { webpush: toNgsiv2WebPushNotification(subscription, { ttl: 86400 }) },
398
+ });
399
+ ```
400
+
401
+ For NGSI-LD, use `toNgsiLdWebPushEndpoint(subscription)` instead, which returns `{ uri, webpush }`
402
+ to spread into `notification.endpoint`.
403
+
404
+ `sw.js` receives the raw NGSI notification body in the `push` event:
405
+
406
+ ```javascript
407
+ // sw.js
408
+ self.addEventListener('push', (event) => {
409
+ const payload = event.data.json();
410
+ event.waitUntil(self.registration.showNotification('GeonicDB', { body: JSON.stringify(payload) }));
411
+ });
412
+ ```
413
+
414
+ Calling `subscribeWebPush()` again on an already-subscribed registration returns the existing
415
+ `PushSubscription` as-is if it still matches the current VAPID public key. If the deployment
416
+ rotated its VAPID keys, the stale subscription is unsubscribed and replaced with a fresh one
417
+ signed by the new key. Call `await db.unsubscribeWebPush()` to unsubscribe.
418
+
419
+ | Method | Returns | Description |
420
+ |--------|---------|--------------|
421
+ | `getWebPushVapidKey()` | `Promise<string>` | Fetch the deployment's VAPID public key (unauthenticated discovery endpoint) |
422
+ | `subscribeWebPush(options?)` | `Promise<PushSubscription>` | Subscribe via `PushManager.subscribe()`. Browser-only — throws in Node.js |
423
+ | `unsubscribeWebPush(registration?)` | `Promise<boolean>` | Unsubscribe; `false` if there was no active subscription |
424
+
377
425
  ## Client-side Cache & Polling
378
426
 
379
427
  The SDK includes an in-memory cache that handles `ETag` / `If-None-Match`
@@ -639,6 +687,21 @@ handle.stop();
639
687
  | `on(event, listener)` | `string, Function` | `this` | Register an event listener |
640
688
  | `off(event, listener)` | `string, Function` | `this` | Remove an event listener |
641
689
 
690
+ ### Web Push
691
+
692
+ | Method | Parameters | Returns | Description |
693
+ |--------|-----------|---------|-------------|
694
+ | `getWebPushVapidKey()` | — | `Promise<string>` | Fetch the deployment's VAPID public key |
695
+ | `subscribeWebPush(options?)` | `WebPushSubscribeOptions` | `Promise<PushSubscription>` | Subscribe via `PushManager.subscribe()` |
696
+ | `unsubscribeWebPush(registration?)` | `ServiceWorkerRegistration` | `Promise<boolean>` | Unsubscribe from Web Push |
697
+
698
+ #### `WebPushSubscribeOptions`
699
+
700
+ | Property | Type | Description |
701
+ |----------|------|-------------|
702
+ | `applicationServerKey` | `string` | VAPID public key (base64url). Auto-fetched via `getWebPushVapidKey()` when omitted |
703
+ | `serviceWorkerRegistration` | `ServiceWorkerRegistration` | Registration to subscribe through. Defaults to `navigator.serviceWorker.ready` |
704
+
642
705
  ## Error Handling
643
706
 
644
707
  All async methods throw typed errors extending `GeonicDBError`. Use `instanceof` to distinguish error types.
package/geonicdb.cjs CHANGED
@@ -34,7 +34,10 @@ __export(index_exports, {
34
34
  TemporaryPasswordExpiredError: () => TemporaryPasswordExpiredError,
35
35
  ValidationError: () => ValidationError,
36
36
  buildPathWithParams: () => buildPathWithParams,
37
- default: () => index_default
37
+ default: () => index_default,
38
+ toNgsiLdWebPushEndpoint: () => toNgsiLdWebPushEndpoint,
39
+ toNgsiv2WebPushNotification: () => toNgsiv2WebPushNotification,
40
+ urlBase64ToUint8Array: () => urlBase64ToUint8Array
38
41
  });
39
42
  module.exports = __toCommonJS(index_exports);
40
43
 
@@ -91,6 +94,7 @@ var DPOP_IDB_VERSION = 1;
91
94
  var SDK_CACHE_IDB_NAME = "geonicdb-sdk-cache";
92
95
  var SDK_CACHE_IDB_STORE = "responses";
93
96
  var SDK_CACHE_IDB_VERSION = 1;
97
+ var WEBPUSH_VAPID_KEY_PATH = "/.well-known/webpush-vapid-key";
94
98
 
95
99
  // src/sdk/dpop.ts
96
100
  var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
@@ -640,6 +644,10 @@ var _AuthManager = class _AuthManager {
640
644
  });
641
645
  }
642
646
  }
647
+ /** The base URL requests are sent against (trailing slash already stripped). */
648
+ get baseUrl() {
649
+ return this._baseUrl;
650
+ }
643
651
  /**
644
652
  * True when running unauthenticated.
645
653
  *
@@ -1728,6 +1736,61 @@ function createCachePersistence() {
1728
1736
  return cachePersistenceSupported ? new IdbCachePersistence() : null;
1729
1737
  }
1730
1738
 
1739
+ // src/sdk/webpush.ts
1740
+ function arrayBufferToBase64Url(buf) {
1741
+ const bytes = new Uint8Array(buf);
1742
+ let binary = "";
1743
+ for (let i = 0; i < bytes.length; i++) {
1744
+ binary += String.fromCharCode(bytes[i]);
1745
+ }
1746
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1747
+ }
1748
+ function extractKeys(subscription) {
1749
+ const endpoint = subscription.endpoint;
1750
+ let p256dh = subscription.keys?.p256dh;
1751
+ let auth = subscription.keys?.auth;
1752
+ if ((!p256dh || !auth) && typeof subscription.getKey === "function") {
1753
+ const p256dhBuf = subscription.getKey("p256dh");
1754
+ const authBuf = subscription.getKey("auth");
1755
+ if (!p256dh && p256dhBuf) p256dh = arrayBufferToBase64Url(p256dhBuf);
1756
+ if (!auth && authBuf) auth = arrayBufferToBase64Url(authBuf);
1757
+ }
1758
+ if (!endpoint || !p256dh || !auth) {
1759
+ throw new Error(
1760
+ "Invalid PushSubscription: endpoint, keys.p256dh, and keys.auth are all required"
1761
+ );
1762
+ }
1763
+ return { endpoint, keys: { p256dh, auth } };
1764
+ }
1765
+ function toNgsiv2WebPushNotification(subscription, options) {
1766
+ const { endpoint, keys } = extractKeys(subscription);
1767
+ return { url: endpoint, keys, ...options };
1768
+ }
1769
+ function toNgsiLdWebPushEndpoint(subscription, options) {
1770
+ const { endpoint, keys } = extractKeys(subscription);
1771
+ return { uri: endpoint, webpush: { keys, ...options } };
1772
+ }
1773
+ function urlBase64ToUint8Array(base64Url) {
1774
+ const padding = "=".repeat((4 - base64Url.length % 4) % 4);
1775
+ const base64 = (base64Url + padding).replace(/-/g, "+").replace(/_/g, "/");
1776
+ const raw = atob(base64);
1777
+ const output = new Uint8Array(raw.length);
1778
+ for (let i = 0; i < raw.length; i++) {
1779
+ output[i] = raw.charCodeAt(i);
1780
+ }
1781
+ return output;
1782
+ }
1783
+ function matchesApplicationServerKey(subscription, desiredKey) {
1784
+ const existingKey = subscription.options?.applicationServerKey;
1785
+ if (!existingKey) return true;
1786
+ const existingBytes = new Uint8Array(existingKey);
1787
+ if (existingBytes.length !== desiredKey.length) return false;
1788
+ for (let i = 0; i < existingBytes.length; i++) {
1789
+ if (existingBytes[i] !== desiredKey[i]) return false;
1790
+ }
1791
+ return true;
1792
+ }
1793
+
1731
1794
  // src/sdk/index.ts
1732
1795
  var GeonicDB = class extends EventEmitter {
1733
1796
  constructor(options) {
@@ -2174,6 +2237,84 @@ var GeonicDB = class extends EventEmitter {
2174
2237
  subscribe(options) {
2175
2238
  this._ws.subscribe(options);
2176
2239
  }
2240
+ // --- Web Push (#3014 / #3060) ---
2241
+ /**
2242
+ * Fetch the deployment's VAPID public key for Web Push subscriptions.
2243
+ *
2244
+ * Hits the unauthenticated discovery endpoint
2245
+ * `GET /.well-known/webpush-vapid-key` directly (not via `request()`) —
2246
+ * it needs no login/API key and predates any session setup. Throws
2247
+ * `GeonicDBError` with `statusCode: 503` if the deployment has no VAPID
2248
+ * keys configured (503 has no dedicated error class — see
2249
+ * `createErrorFromResponse()` in `./errors`).
2250
+ */
2251
+ async getWebPushVapidKey() {
2252
+ const res = await fetch(this._auth.baseUrl + WEBPUSH_VAPID_KEY_PATH);
2253
+ if (!res.ok) {
2254
+ const e = await res.json().catch(() => ({}));
2255
+ throw createErrorFromResponse(
2256
+ res.status,
2257
+ e,
2258
+ "Failed to fetch Web Push VAPID key: " + res.status
2259
+ );
2260
+ }
2261
+ const body = await res.json();
2262
+ return body.publicKey;
2263
+ }
2264
+ /**
2265
+ * Subscribe the current browser to Web Push notifications (#3014).
2266
+ *
2267
+ * Reuses an existing subscription on the service worker registration if
2268
+ * one is already active *and* still matches the resolved VAPID public key
2269
+ * — otherwise (e.g. after the deployment rotated its VAPID keys) it
2270
+ * unsubscribes the stale one and re-subscribes with the current key.
2271
+ * Fetches the deployment's VAPID public key (unless `applicationServerKey`
2272
+ * is given) and calls `PushManager.subscribe()`. Convert the result with
2273
+ * `toNgsiv2WebPushNotification()` / `toNgsiLdWebPushEndpoint()` and create
2274
+ * the NGSI subscription with `request('POST', ...)`.
2275
+ *
2276
+ * Browser-only — throws in environments without Service Worker + Push API
2277
+ * support (e.g. Node.js).
2278
+ *
2279
+ * @example
2280
+ * ```ts
2281
+ * await navigator.serviceWorker.register('/sw.js');
2282
+ * const subscription = await db.subscribeWebPush();
2283
+ * await db.request('POST', '/v2/subscriptions', {
2284
+ * description: 'Web Push notification',
2285
+ * subject: { entities: [{ type: 'Sensor' }], condition: { attrs: ['value'] } },
2286
+ * notification: { webpush: toNgsiv2WebPushNotification(subscription) },
2287
+ * });
2288
+ * ```
2289
+ */
2290
+ async subscribeWebPush(options) {
2291
+ if (typeof navigator === "undefined" || !navigator.serviceWorker || typeof window === "undefined" || !window.PushManager) {
2292
+ throw new Error(
2293
+ "subscribeWebPush() requires a browser with Service Worker + Push API support"
2294
+ );
2295
+ }
2296
+ const registration = options?.serviceWorkerRegistration ?? await navigator.serviceWorker.ready;
2297
+ const publicKey = options?.applicationServerKey ?? await this.getWebPushVapidKey();
2298
+ const applicationServerKey = urlBase64ToUint8Array(publicKey);
2299
+ const existing = await registration.pushManager.getSubscription();
2300
+ if (existing) {
2301
+ if (matchesApplicationServerKey(existing, applicationServerKey)) return existing;
2302
+ await existing.unsubscribe();
2303
+ }
2304
+ return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
2305
+ }
2306
+ /**
2307
+ * Unsubscribe the current browser from Web Push notifications, if
2308
+ * subscribed. Returns `false` (no-op) when there is no active
2309
+ * subscription or the environment has no Service Worker support.
2310
+ */
2311
+ async unsubscribeWebPush(registration) {
2312
+ if (typeof navigator === "undefined" || !navigator.serviceWorker) return false;
2313
+ const reg = registration ?? await navigator.serviceWorker.ready;
2314
+ const existing = await reg.pushManager.getSubscription();
2315
+ if (!existing) return false;
2316
+ return existing.unsubscribe();
2317
+ }
2177
2318
  };
2178
2319
  var index_default = GeonicDB;
2179
2320
  if (typeof window !== "undefined") {