@arkstack/realtime 0.18.12 → 0.19.1

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.
@@ -23,7 +23,16 @@ interface RealtimeNotification {
23
23
  read_at: string | null;
24
24
  created_at: string;
25
25
  }
26
- type RealtimeTransportName = 'pusher' | 'firebase';
26
+ /**
27
+ * Client transports, matching the server's `drivers.realtime.transport`.
28
+ *
29
+ * - `pusher`: Pusher Channels, or a self-hosted Pusher-protocol server via `wsHost`.
30
+ * - `firebase`: Firebase Cloud Messaging foreground messages.
31
+ * - `firebase-database`: Firebase Realtime Database channels.
32
+ * - `polling`: nothing is pushed; use {@link RealtimeClient.onPoll} to refetch.
33
+ * - `memory`: an in-process bus, for tests.
34
+ */
35
+ type RealtimeTransportName = 'pusher' | 'firebase' | 'firebase-database' | 'polling' | 'memory';
27
36
  type NotificationHandler = (notification: RealtimeNotification) => void;
28
37
  /** Handles an arbitrary realtime event payload. */
29
38
  type RealtimeEventHandler<Payload = unknown> = (payload: Payload) => void;
@@ -58,6 +67,16 @@ interface RealtimeTransport {
58
67
  * Disconnect from a connected realtime channel
59
68
  */
60
69
  disconnect(): void | Promise<void>;
70
+ /**
71
+ * This device's push registration token, when the transport can issue one.
72
+ */
73
+ deviceToken?(): Promise<string | null>;
74
+ /**
75
+ * Set by transports that push nothing; clients should poll this often instead.
76
+ */
77
+ polling?: {
78
+ interval: number;
79
+ };
61
80
  }
62
81
  interface PusherClientConfig {
63
82
  key: string;
@@ -80,6 +99,11 @@ interface PusherClientConfig {
80
99
  params?: Record<string, string>;
81
100
  };
82
101
  forceTLS?: boolean;
102
+ /** A self-hosted Pusher-protocol server such as soketi. */
103
+ wsHost?: string;
104
+ wsPort?: number;
105
+ wssPort?: number;
106
+ enabledTransports?: Array<'ws' | 'wss'>;
83
107
  }
84
108
  interface FirebaseClientConfig {
85
109
  apiKey: string;
@@ -93,6 +117,29 @@ interface FirebaseClientConfig {
93
117
  /** Web push VAPID key used when requesting a messaging token. */
94
118
  vapidKey?: string;
95
119
  }
120
+ interface FirebaseDatabaseClientConfig {
121
+ apiKey: string;
122
+ projectId: string;
123
+ databaseURL: string;
124
+ appId?: string;
125
+ authDomain?: string;
126
+ /** Database path that holds channel events (default `arkstack/channels`). */
127
+ root?: string;
128
+ /**
129
+ * Your API's base URL; channels are authorized at `{apiBase}/realtime/auth`.
130
+ * Ignored when {@link authEndpoint} is set.
131
+ */
132
+ apiBase?: string;
133
+ /** Endpoint that grants channel access and returns a Firebase custom token. */
134
+ authEndpoint?: string;
135
+ auth?: {
136
+ headers?: Record<string, string>;
137
+ };
138
+ }
139
+ interface PollingClientConfig {
140
+ /** How often, in milliseconds, {@link RealtimeClient.onPoll} handlers run (default 5000). */
141
+ interval?: number;
142
+ }
96
143
  interface RealtimeConfig {
97
144
  transport?: RealtimeTransportName;
98
145
  /** Event name broadcasts are published under (default `notification`). */
@@ -101,6 +148,8 @@ interface RealtimeConfig {
101
148
  channelPrefix?: string;
102
149
  pusher?: PusherClientConfig;
103
150
  firebase?: FirebaseClientConfig;
151
+ 'firebase-database'?: FirebaseDatabaseClientConfig;
152
+ polling?: PollingClientConfig;
104
153
  /** Inject a transport directly — bypasses the built-ins (tests, custom backends). */
105
154
  transportFactory?: () => RealtimeTransport | Promise<RealtimeTransport>;
106
155
  }
@@ -126,6 +175,11 @@ declare class RealtimeClient {
126
175
  channelFor(userId: string | number): string;
127
176
  private transport;
128
177
  private resolveTransport;
178
+ private required;
179
+ /**
180
+ * The configured transport name.
181
+ */
182
+ get transportName(): RealtimeTransportName;
129
183
  /**
130
184
  * Subscribe to a channel. Returns a function that unsubscribes.
131
185
  *
@@ -175,10 +229,27 @@ declare class RealtimeClient {
175
229
  * @param handler Called with each incoming notification.
176
230
  */
177
231
  forUser(userId: string | number, handler: NotificationHandler): Promise<() => void>;
232
+ /**
233
+ * Run `handler` on an interval while the transport is `polling`, so a view
234
+ * can refetch what it would otherwise receive live. Does nothing on push
235
+ * transports. Returns a function that stops it.
236
+ *
237
+ * @param handler
238
+ * @param interval Overrides the transport's advertised interval, in milliseconds.
239
+ * @returns
240
+ */
241
+ onPoll(handler: () => void, interval?: number): Promise<() => void>;
242
+ /**
243
+ * This device's push registration token, for registering with your API.
244
+ * Null when the transport cannot issue one.
245
+ *
246
+ * @returns
247
+ */
248
+ deviceToken(): Promise<string | null>;
178
249
  /** Tear down the underlying transport connection. */
179
250
  disconnect(): Promise<void>;
180
251
  }
181
252
  /** Create a new {@link RealtimeClient}. */
182
253
  declare const createRealtime: (config?: RealtimeConfig) => RealtimeClient;
183
254
  //#endregion
184
- export { PusherClientConfig as a, RealtimeNotification as c, RealtimeTransportName as d, NotificationHandler as i, RealtimeSubscription as l, createRealtime as n, RealtimeConfig as o, FirebaseClientConfig as r, RealtimeEventHandler as s, RealtimeClient as t, RealtimeTransport as u };
255
+ export { NotificationHandler as a, RealtimeConfig as c, RealtimeSubscription as d, RealtimeTransport as f, FirebaseDatabaseClientConfig as i, RealtimeEventHandler as l, createRealtime as n, PollingClientConfig as o, RealtimeTransportName as p, FirebaseClientConfig as r, PusherClientConfig as s, RealtimeClient as t, RealtimeNotification as u };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as PusherClientConfig, c as RealtimeNotification, d as RealtimeTransportName, i as NotificationHandler, l as RealtimeSubscription, n as createRealtime, o as RealtimeConfig, r as FirebaseClientConfig, s as RealtimeEventHandler, t as RealtimeClient, u as RealtimeTransport } from "./RealtimeClient-CzrWnLtj.js";
1
+ import { a as NotificationHandler, c as RealtimeConfig, d as RealtimeSubscription, f as RealtimeTransport, i as FirebaseDatabaseClientConfig, l as RealtimeEventHandler, n as createRealtime, o as PollingClientConfig, p as RealtimeTransportName, r as FirebaseClientConfig, s as PusherClientConfig, t as RealtimeClient, u as RealtimeNotification } from "./RealtimeClient-MI224djP.js";
2
2
  //#region src/transports/pusher.d.ts
3
3
  /**
4
4
  * Realtime transport backed by [pusher-js](https://github.com/pusher/pusher-js).
@@ -20,6 +20,47 @@ declare const createPusherTransport: (config: PusherClientConfig) => Promise<Rea
20
20
  */
21
21
  declare const createFirebaseTransport: (config: FirebaseClientConfig) => Promise<RealtimeTransport>;
22
22
  //#endregion
23
+ //#region src/transports/firebase-database.d.ts
24
+ /**
25
+ * Realtime transport backed by [Firebase Realtime Database](https://firebase.google.com/docs/database/web/start).
26
+ * `firebase` is an optional peer dependency imported lazily.
27
+ *
28
+ * Each subscription first asks the API to authorize the channel, which grants
29
+ * this user read access and returns a custom token to sign in with. Only events
30
+ * written after the transport connected are delivered.
31
+ *
32
+ * @param config
33
+ * @returns
34
+ */
35
+ declare const createFirebaseDatabaseTransport: (config: FirebaseDatabaseClientConfig) => Promise<RealtimeTransport>;
36
+ //#endregion
37
+ //#region src/transports/polling.d.ts
38
+ /**
39
+ * A transport that receives nothing. Subscriptions are accepted and stay
40
+ * silent; use {@link RealtimeClient.onPoll} to refetch on the advertised interval.
41
+ *
42
+ * @param config
43
+ * @returns
44
+ */
45
+ declare const createPollingTransport: (config?: PollingClientConfig) => RealtimeTransport;
46
+ //#endregion
47
+ //#region src/transports/memory.d.ts
48
+ /**
49
+ * Deliver an event to every in-process subscriber of `channel`, as a server
50
+ * broadcast would.
51
+ *
52
+ * @param channel
53
+ * @param event
54
+ * @param payload
55
+ */
56
+ declare const emitMemoryEvent: (channel: string, event: string, payload: unknown) => void;
57
+ /**
58
+ * An in-process transport for tests. Feed it with {@link emitMemoryEvent}.
59
+ *
60
+ * @returns
61
+ */
62
+ declare const createMemoryTransport: () => RealtimeTransport;
63
+ //#endregion
23
64
  //#region src/supersede.d.ts
24
65
  /**
25
66
  * Fold an incoming notification into a list, honouring tag supersession.
@@ -46,4 +87,4 @@ declare const createFirebaseTransport: (config: FirebaseClientConfig) => Promise
46
87
  */
47
88
  declare const supersede: (list: RealtimeNotification[], incoming: RealtimeNotification, limit?: number) => RealtimeNotification[];
48
89
  //#endregion
49
- export { type FirebaseClientConfig, type NotificationHandler, type PusherClientConfig, RealtimeClient, type RealtimeConfig, type RealtimeEventHandler, type RealtimeNotification, type RealtimeSubscription, type RealtimeTransport, type RealtimeTransportName, createFirebaseTransport, createPusherTransport, createRealtime, supersede };
90
+ export { type FirebaseClientConfig, type FirebaseDatabaseClientConfig, type NotificationHandler, type PollingClientConfig, type PusherClientConfig, RealtimeClient, type RealtimeConfig, type RealtimeEventHandler, type RealtimeNotification, type RealtimeSubscription, type RealtimeTransport, type RealtimeTransportName, createFirebaseDatabaseTransport, createFirebaseTransport, createMemoryTransport, createPollingTransport, createPusherTransport, createRealtime, emitMemoryEvent, supersede };
package/dist/index.js CHANGED
@@ -42,14 +42,39 @@ var RealtimeClient = class {
42
42
  }
43
43
  async resolveTransport() {
44
44
  if (this.config.transportFactory) return await this.config.transportFactory();
45
- if (this.config.transport === "firebase") {
46
- if (!this.config.firebase) throw new Error("Realtime: `firebase` config is required for the Firebase transport");
47
- const { createFirebaseTransport } = await Promise.resolve().then(() => firebase_exports);
48
- return await createFirebaseTransport(this.config.firebase);
45
+ switch (this.transportName) {
46
+ case "firebase": {
47
+ const { createFirebaseTransport } = await Promise.resolve().then(() => firebase_exports);
48
+ return await createFirebaseTransport(this.required("firebase"));
49
+ }
50
+ case "firebase-database": {
51
+ const { createFirebaseDatabaseTransport } = await Promise.resolve().then(() => firebase_database_exports);
52
+ return await createFirebaseDatabaseTransport(this.required("firebase-database"));
53
+ }
54
+ case "polling": {
55
+ const { createPollingTransport } = await Promise.resolve().then(() => polling_exports);
56
+ return createPollingTransport(this.config.polling);
57
+ }
58
+ case "memory": {
59
+ const { createMemoryTransport } = await Promise.resolve().then(() => memory_exports);
60
+ return createMemoryTransport();
61
+ }
62
+ default: {
63
+ const { createPusherTransport } = await Promise.resolve().then(() => pusher_exports);
64
+ return await createPusherTransport(this.required("pusher"));
65
+ }
49
66
  }
50
- if (!this.config.pusher) throw new Error("Realtime: `pusher` config is required for the Pusher transport");
51
- const { createPusherTransport } = await Promise.resolve().then(() => pusher_exports);
52
- return await createPusherTransport(this.config.pusher);
67
+ }
68
+ required(name) {
69
+ const config = this.config[name];
70
+ if (!config) throw new Error(`Realtime: \`${name}\` config is required for the ${name} transport`);
71
+ return config;
72
+ }
73
+ /**
74
+ * The configured transport name.
75
+ */
76
+ get transportName() {
77
+ return this.config.transport ?? "pusher";
53
78
  }
54
79
  /**
55
80
  * Subscribe to a channel. Returns a function that unsubscribes.
@@ -117,6 +142,30 @@ var RealtimeClient = class {
117
142
  async forUser(userId, handler) {
118
143
  return await this.subscribe(this.channelFor(userId), handler);
119
144
  }
145
+ /**
146
+ * Run `handler` on an interval while the transport is `polling`, so a view
147
+ * can refetch what it would otherwise receive live. Does nothing on push
148
+ * transports. Returns a function that stops it.
149
+ *
150
+ * @param handler
151
+ * @param interval Overrides the transport's advertised interval, in milliseconds.
152
+ * @returns
153
+ */
154
+ async onPoll(handler, interval) {
155
+ const transport = await this.transport();
156
+ if (!transport.polling) return () => {};
157
+ const timer = setInterval(handler, interval ?? transport.polling.interval);
158
+ return () => clearInterval(timer);
159
+ }
160
+ /**
161
+ * This device's push registration token, for registering with your API.
162
+ * Null when the transport cannot issue one.
163
+ *
164
+ * @returns
165
+ */
166
+ async deviceToken() {
167
+ return await (await this.transport()).deviceToken?.() ?? null;
168
+ }
120
169
  /** Tear down the underlying transport connection. */
121
170
  async disconnect() {
122
171
  if (!this.transportPromise) return;
@@ -145,7 +194,13 @@ const createPusherTransport = async (config) => {
145
194
  cluster: config.cluster ?? "mt1",
146
195
  forceTLS: config.forceTLS ?? true,
147
196
  authEndpoint: config.authEndpoint ?? `${config.apiBase}/realtime/auth`,
148
- auth: config.auth
197
+ auth: config.auth,
198
+ ...config.wsHost ? {
199
+ wsHost: config.wsHost,
200
+ wsPort: config.wsPort,
201
+ wssPort: config.wssPort ?? config.wsPort,
202
+ enabledTransports: config.enabledTransports ?? ["ws", "wss"]
203
+ } : {}
149
204
  });
150
205
  const channels = /* @__PURE__ */ new Map();
151
206
  return {
@@ -252,6 +307,10 @@ const createFirebaseTransport = async (config) => {
252
307
  });
253
308
  await databaseApi.remove(eventReference);
254
309
  },
310
+ async deviceToken() {
311
+ const getToken = messagingMod.getToken;
312
+ return await getToken(messaging, config.vapidKey ? { vapidKey: config.vapidKey } : void 0) || null;
313
+ },
255
314
  disconnect() {
256
315
  messageUnsubscribers.forEach((off) => off());
257
316
  messageUnsubscribers.clear();
@@ -261,4 +320,192 @@ const createFirebaseTransport = async (config) => {
261
320
  };
262
321
  };
263
322
  //#endregion
264
- export { RealtimeClient, createFirebaseTransport, createPusherTransport, createRealtime, supersede };
323
+ //#region src/transports/firebase-database.ts
324
+ var firebase_database_exports = /* @__PURE__ */ __exportAll({ createFirebaseDatabaseTransport: () => createFirebaseDatabaseTransport });
325
+ const APP_NAME = "arkstack-realtime-database";
326
+ /**
327
+ * Encode a channel name into a single Realtime Database key, matching the server.
328
+ *
329
+ * @param channel
330
+ * @returns
331
+ */
332
+ const channelKey = (channel) => encodeURIComponent(channel).replace(/\./g, "%2E");
333
+ /**
334
+ * The `uid` claim of a Firebase custom token.
335
+ *
336
+ * @param token
337
+ * @returns
338
+ */
339
+ const tokenUid = (token) => {
340
+ try {
341
+ const segment = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
342
+ return JSON.parse(atob(segment)).uid ?? null;
343
+ } catch {
344
+ return null;
345
+ }
346
+ };
347
+ /**
348
+ * Realtime transport backed by [Firebase Realtime Database](https://firebase.google.com/docs/database/web/start).
349
+ * `firebase` is an optional peer dependency imported lazily.
350
+ *
351
+ * Each subscription first asks the API to authorize the channel, which grants
352
+ * this user read access and returns a custom token to sign in with. Only events
353
+ * written after the transport connected are delivered.
354
+ *
355
+ * @param config
356
+ * @returns
357
+ */
358
+ const createFirebaseDatabaseTransport = async (config) => {
359
+ const [appMod, authMod, databaseMod] = await Promise.all([
360
+ import("firebase/app"),
361
+ import("firebase/auth"),
362
+ import("firebase/database")
363
+ ]).catch(() => {
364
+ throw new Error("The \"firebase\" package is required for the Firebase database transport. Install it with `npm i firebase`.");
365
+ });
366
+ const app = appMod.getApps().find((existing) => existing.name === APP_NAME) ?? appMod.initializeApp({
367
+ apiKey: config.apiKey,
368
+ authDomain: config.authDomain,
369
+ projectId: config.projectId,
370
+ appId: config.appId,
371
+ databaseURL: config.databaseURL
372
+ }, APP_NAME);
373
+ const auth = authMod.getAuth(app);
374
+ const database = databaseMod.getDatabase(app, config.databaseURL);
375
+ const root = config.root ?? "arkstack/channels";
376
+ const endpoint = config.authEndpoint ?? `${config.apiBase}/realtime/auth`;
377
+ const listeners = /* @__PURE__ */ new Set();
378
+ const offset = await new Promise((resolve) => {
379
+ databaseMod.onValue(databaseMod.ref(database, ".info/serverTimeOffset"), (snapshot) => resolve(Number(snapshot.val()) || 0), { onlyOnce: true });
380
+ });
381
+ const since = Date.now() + offset;
382
+ let signing = Promise.resolve();
383
+ const authorize = async (channel) => {
384
+ const response = await fetch(endpoint, {
385
+ method: "POST",
386
+ headers: {
387
+ "content-type": "application/json",
388
+ accept: "application/json",
389
+ ...config.auth?.headers
390
+ },
391
+ body: JSON.stringify({ channel_name: channel })
392
+ });
393
+ if (!response.ok) throw new Error(`Realtime: channel "${channel}" was not authorized (${response.status})`);
394
+ const { token } = await response.json();
395
+ signing = signing.then(async () => {
396
+ if (auth.currentUser?.uid !== tokenUid(token)) await authMod.signInWithCustomToken(auth, token);
397
+ });
398
+ await signing;
399
+ };
400
+ return {
401
+ async subscribe(channel, event, handler) {
402
+ await authorize(channel);
403
+ const off = databaseMod.onChildAdded(databaseMod.query(databaseMod.ref(database, `${root}/${channelKey(channel)}`), databaseMod.orderByChild("at"), databaseMod.startAt(since)), (snapshot) => {
404
+ const value = snapshot.val();
405
+ if (value?.event === event) handler(value.payload);
406
+ });
407
+ listeners.add(off);
408
+ return {
409
+ channel,
410
+ unsubscribe() {
411
+ off();
412
+ listeners.delete(off);
413
+ }
414
+ };
415
+ },
416
+ async disconnect() {
417
+ listeners.forEach((off) => off());
418
+ listeners.clear();
419
+ await authMod.signOut(auth);
420
+ }
421
+ };
422
+ };
423
+ //#endregion
424
+ //#region src/transports/polling.ts
425
+ var polling_exports = /* @__PURE__ */ __exportAll({ createPollingTransport: () => createPollingTransport });
426
+ /**
427
+ * A transport that receives nothing. Subscriptions are accepted and stay
428
+ * silent; use {@link RealtimeClient.onPoll} to refetch on the advertised interval.
429
+ *
430
+ * @param config
431
+ * @returns
432
+ */
433
+ const createPollingTransport = (config = {}) => ({
434
+ polling: { interval: config.interval ?? 5e3 },
435
+ subscribe(channel) {
436
+ return {
437
+ channel,
438
+ unsubscribe() {}
439
+ };
440
+ },
441
+ disconnect() {}
442
+ });
443
+ //#endregion
444
+ //#region src/transports/memory.ts
445
+ var memory_exports = /* @__PURE__ */ __exportAll({
446
+ createMemoryTransport: () => createMemoryTransport,
447
+ emitMemoryEvent: () => emitMemoryEvent
448
+ });
449
+ const BUS_KEY = Symbol.for("arkstack.realtime.client-memory");
450
+ /**
451
+ * The in-process bus, kept on `globalThis` so every module copy shares it.
452
+ *
453
+ * @returns
454
+ */
455
+ const bus = () => {
456
+ const store = globalThis;
457
+ return store[BUS_KEY] ??= /* @__PURE__ */ new Map();
458
+ };
459
+ /**
460
+ * Deliver an event to every in-process subscriber of `channel`, as a server
461
+ * broadcast would.
462
+ *
463
+ * @param channel
464
+ * @param event
465
+ * @param payload
466
+ */
467
+ const emitMemoryEvent = (channel, event, payload) => {
468
+ bus().get(channel)?.forEach((listener) => {
469
+ if (listener.event === event) listener.handler(payload);
470
+ });
471
+ };
472
+ /**
473
+ * An in-process transport for tests. Feed it with {@link emitMemoryEvent}.
474
+ *
475
+ * @returns
476
+ */
477
+ const createMemoryTransport = () => {
478
+ const own = /* @__PURE__ */ new Set();
479
+ return {
480
+ subscribe(channel, event, handler) {
481
+ const listener = {
482
+ event,
483
+ handler
484
+ };
485
+ const listeners = bus().get(channel) ?? /* @__PURE__ */ new Set();
486
+ const entry = {
487
+ channel,
488
+ listener
489
+ };
490
+ listeners.add(listener);
491
+ bus().set(channel, listeners);
492
+ own.add(entry);
493
+ return {
494
+ channel,
495
+ unsubscribe() {
496
+ listeners.delete(listener);
497
+ own.delete(entry);
498
+ }
499
+ };
500
+ },
501
+ trigger(channel, event, payload) {
502
+ emitMemoryEvent(channel, event, payload);
503
+ },
504
+ disconnect() {
505
+ own.forEach(({ channel, listener }) => bus().get(channel)?.delete(listener));
506
+ own.clear();
507
+ }
508
+ };
509
+ };
510
+ //#endregion
511
+ export { RealtimeClient, createFirebaseDatabaseTransport, createFirebaseTransport, createMemoryTransport, createPollingTransport, createPusherTransport, createRealtime, emitMemoryEvent, supersede };
@@ -1,4 +1,4 @@
1
- import { c as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-CzrWnLtj.js";
1
+ import { t as RealtimeClient, u as RealtimeNotification } from "../RealtimeClient-MI224djP.js";
2
2
  //#region src/react/index.d.ts
3
3
  interface UseNotificationsOptions {
4
4
  /** Cap the number of retained notifications (newest kept). Default: unbounded. */
@@ -1,4 +1,4 @@
1
- import { c as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-CzrWnLtj.js";
1
+ import { t as RealtimeClient, u as RealtimeNotification } from "../RealtimeClient-MI224djP.js";
2
2
  import { ComputedRef, Ref } from "vue";
3
3
  //#region src/vue/index.d.ts
4
4
  interface UseNotificationsOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/realtime",
3
- "version": "0.18.12",
3
+ "version": "0.19.1",
4
4
  "type": "module",
5
5
  "description": "Client for consuming Arkstack realtime notifications (Pusher/Firebase), with framework-agnostic core plus React and Vue bindings.",
6
6
  "homepage": "https://arkstack.toneflix.net/guide/notifications",