@arkstack/realtime 0.17.22 → 0.17.24

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
@@ -44,6 +44,28 @@ unsubscribe();
44
44
  await realtime.disconnect();
45
45
  ```
46
46
 
47
+ ### Client events
48
+
49
+ Pusher private and presence channels can send ephemeral client events (often
50
+ called whispers). Subscribe to the channel before sending an event:
51
+
52
+ ```ts
53
+ const stopTyping = await realtime.listenForWhisper(
54
+ 'private-room.7',
55
+ 'typing',
56
+ ({ userId }) => console.log(`${userId} is typing`),
57
+ );
58
+
59
+ await realtime.whisper('private-room.7', 'typing', { userId: user.id });
60
+ stopTyping();
61
+ ```
62
+
63
+ The `client-` prefix is added automatically. Pusher sends client events over its
64
+ private/presence channel. Firebase uses Realtime Database to publish the same
65
+ ephemeral events across connected clients. Use `listen(channel, event, handler)`
66
+ and `trigger(channel, event, payload)` when you need the lower-level APIs with
67
+ exact event names.
68
+
47
69
  Each `notification` matches the payload broadcast by the server:
48
70
 
49
71
  ```ts
@@ -115,10 +137,16 @@ const realtime = createRealtime({
115
137
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
116
138
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
117
139
  messagingSenderId: import.meta.env.VITE_FIREBASE_SENDER_ID,
140
+ databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
118
141
  },
119
142
  });
120
143
  ```
121
144
 
145
+ Firebase client events are written below `arkstack/client-events` in Realtime
146
+ Database and removed immediately after publishing. Configure Firebase Security
147
+ Rules for the channels your authenticated clients may read and write. Override
148
+ the root with `firebase.clientEventsPath` when needed.
149
+
122
150
  ## Custom transport
123
151
 
124
152
  Provide `transportFactory` to bridge any backend (a raw WebSocket, SSE, a test double, …):
@@ -146,6 +174,9 @@ const realtime = createRealtime({ transportFactory: () => transport });
146
174
 
147
175
  - `createRealtime(config)` — create a `RealtimeClient`. Config: `transport` (`'pusher'` | `'firebase'`), `event` (default `notification`), `channelPrefix` (default `user.`), `pusher`/`firebase` credentials, or a custom `transportFactory`.
148
176
  - `client.subscribe(channel, handler)` / `client.forUser(userId, handler)` — subscribe; returns an unsubscribe function.
177
+ - `client.listen(channel, event, handler)` — listen for an arbitrary event.
178
+ - `client.listenForWhisper(channel, event, handler)` / `client.whisper(channel, event, payload)` — receive and send Pusher client events.
179
+ - `client.trigger(channel, event, payload)` — emit an exact event name through a transport that supports it.
149
180
  - `client.channelFor(userId)` — the per-user channel name.
150
181
  - `client.disconnect()` — tear down the transport connection.
151
182
  - `@arkstack/realtime/react` — `useNotifications(client, channel, { limit? })` → `{ notifications, latest, clear }`.
@@ -17,6 +17,8 @@ interface RealtimeNotification {
17
17
  }
18
18
  type RealtimeTransportName = 'pusher' | 'firebase';
19
19
  type NotificationHandler = (notification: RealtimeNotification) => void;
20
+ /** Handles an arbitrary realtime event payload. */
21
+ type RealtimeEventHandler<Payload = unknown> = (payload: Payload) => void;
20
22
  /** A live subscription to one channel; call `unsubscribe()` to stop listening. */
21
23
  interface RealtimeSubscription {
22
24
  channel: string;
@@ -28,7 +30,25 @@ interface RealtimeSubscription {
28
30
  * supplied via {@link RealtimeConfig.transportFactory} for custom backends/tests.
29
31
  */
30
32
  interface RealtimeTransport {
31
- subscribe(channel: string, event: string, handler: NotificationHandler): RealtimeSubscription | Promise<RealtimeSubscription>;
33
+ /**
34
+ * Subscribe to a realtime channel
35
+ *
36
+ * @param channel
37
+ * @param event
38
+ * @param handler
39
+ */
40
+ subscribe(channel: string, event: string, handler: RealtimeEventHandler): RealtimeSubscription | Promise<RealtimeSubscription>;
41
+ /**
42
+ * Emit an event from the connected client, when supported by the transport.
43
+ *
44
+ * @param channel
45
+ * @param event
46
+ * @param payload
47
+ */
48
+ trigger?(channel: string, event: string, payload: unknown): void | Promise<void>;
49
+ /**
50
+ * Disconnect from a connected realtime channel
51
+ */
32
52
  disconnect(): void | Promise<void>;
33
53
  }
34
54
  interface PusherClientConfig {
@@ -58,6 +78,10 @@ interface FirebaseClientConfig {
58
78
  projectId: string;
59
79
  appId: string;
60
80
  messagingSenderId: string;
81
+ /** Realtime Database URL. Uses the Firebase project's default database when omitted. */
82
+ databaseURL?: string;
83
+ /** Root path used for ephemeral client events (default `arkstack/client-events`). */
84
+ clientEventsPath?: string;
61
85
  /** Web push VAPID key used when requesting a messaging token. */
62
86
  vapidKey?: string;
63
87
  }
@@ -85,7 +109,12 @@ declare class RealtimeClient {
85
109
  private readonly event;
86
110
  private readonly channelPrefix;
87
111
  constructor(config?: RealtimeConfig);
88
- /** The channel name a given user's notifications are broadcast on. */
112
+ /**
113
+ * The channel name a given user's notifications are broadcast on.
114
+ *
115
+ * @param userId
116
+ * @returns
117
+ */
89
118
  channelFor(userId: string | number): string;
90
119
  private transport;
91
120
  private resolveTransport;
@@ -96,6 +125,41 @@ declare class RealtimeClient {
96
125
  * @param handler Called with each incoming notification.
97
126
  */
98
127
  subscribe(channel: string, handler: NotificationHandler): Promise<() => void>;
128
+ /**
129
+ * Listen for an arbitrary event on a channel.
130
+ *
131
+ * @param channel
132
+ * @param event
133
+ * @param handler
134
+ * @returns
135
+ */
136
+ listen<Payload = unknown>(channel: string, event: string, handler: RealtimeEventHandler<Payload>): Promise<() => void>;
137
+ /**
138
+ * Listen for a client event (`client-{event}`).
139
+ *
140
+ * @param channel
141
+ * @param event
142
+ * @param handler
143
+ * @returns
144
+ */
145
+ listenForWhisper<Payload = unknown>(channel: string, event: string, handler: RealtimeEventHandler<Payload>): Promise<() => void>;
146
+ /**
147
+ * Emit a client event (`client-{event}`) on a subscribed channel.
148
+ *
149
+ * @param channel
150
+ * @param event
151
+ * @param payload
152
+ */
153
+ whisper<Payload = unknown>(channel: string, event: string, payload: Payload): Promise<void>;
154
+ /**
155
+ * Emit an event through a transport that supports client-originated events.
156
+ *
157
+ * @param channel
158
+ * @param event
159
+ * @param payload
160
+ */
161
+ trigger<Payload = unknown>(channel: string, event: string, payload: Payload): Promise<void>;
162
+ private clientEventName;
99
163
  /**
100
164
  * Subscribe to a user's channel (`{channelPrefix}{userId}`).
101
165
  *
@@ -106,7 +170,7 @@ declare class RealtimeClient {
106
170
  /** Tear down the underlying transport connection. */
107
171
  disconnect(): Promise<void>;
108
172
  }
109
- /** Create a {@link RealtimeClient}. */
173
+ /** Create a new {@link RealtimeClient}. */
110
174
  declare const createRealtime: (config?: RealtimeConfig) => RealtimeClient;
111
175
  //#endregion
112
- export { PusherClientConfig as a, RealtimeSubscription as c, NotificationHandler as i, RealtimeTransport as l, createRealtime as n, RealtimeConfig as o, FirebaseClientConfig as r, RealtimeNotification as s, RealtimeClient as t, RealtimeTransportName as u };
176
+ 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 };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as PusherClientConfig, c as RealtimeSubscription, i as NotificationHandler, l as RealtimeTransport, n as createRealtime, o as RealtimeConfig, r as FirebaseClientConfig, s as RealtimeNotification, t as RealtimeClient, u as RealtimeTransportName } from "./RealtimeClient-BlV9xSHd.js";
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-CpYmOGZa.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,4 +20,4 @@ declare const createPusherTransport: (config: PusherClientConfig) => Promise<Rea
20
20
  */
21
21
  declare const createFirebaseTransport: (config: FirebaseClientConfig) => Promise<RealtimeTransport>;
22
22
  //#endregion
23
- export { type FirebaseClientConfig, type NotificationHandler, type PusherClientConfig, RealtimeClient, type RealtimeConfig, type RealtimeNotification, type RealtimeSubscription, type RealtimeTransport, type RealtimeTransportName, createFirebaseTransport, createPusherTransport, createRealtime };
23
+ export { type FirebaseClientConfig, type NotificationHandler, type PusherClientConfig, RealtimeClient, type RealtimeConfig, type RealtimeEventHandler, type RealtimeNotification, type RealtimeSubscription, type RealtimeTransport, type RealtimeTransportName, createFirebaseTransport, createPusherTransport, createRealtime };
package/dist/index.js CHANGED
@@ -26,7 +26,12 @@ var RealtimeClient = class {
26
26
  this.event = config.event ?? "notification";
27
27
  this.channelPrefix = config.channelPrefix ?? "user.";
28
28
  }
29
- /** The channel name a given user's notifications are broadcast on. */
29
+ /**
30
+ * The channel name a given user's notifications are broadcast on.
31
+ *
32
+ * @param userId
33
+ * @returns
34
+ */
30
35
  channelFor(userId) {
31
36
  return `${this.channelPrefix}${userId}`;
32
37
  }
@@ -52,10 +57,57 @@ var RealtimeClient = class {
52
57
  * @param handler Called with each incoming notification.
53
58
  */
54
59
  async subscribe(channel, handler) {
55
- const subscription = await (await this.transport()).subscribe(channel, this.event, handler);
60
+ return await this.listen(channel, this.event, handler);
61
+ }
62
+ /**
63
+ * Listen for an arbitrary event on a channel.
64
+ *
65
+ * @param channel
66
+ * @param event
67
+ * @param handler
68
+ * @returns
69
+ */
70
+ async listen(channel, event, handler) {
71
+ const subscription = await (await this.transport()).subscribe(channel, event, handler);
56
72
  return () => subscription.unsubscribe();
57
73
  }
58
74
  /**
75
+ * Listen for a client event (`client-{event}`).
76
+ *
77
+ * @param channel
78
+ * @param event
79
+ * @param handler
80
+ * @returns
81
+ */
82
+ async listenForWhisper(channel, event, handler) {
83
+ return await this.listen(channel, this.clientEventName(event), handler);
84
+ }
85
+ /**
86
+ * Emit a client event (`client-{event}`) on a subscribed channel.
87
+ *
88
+ * @param channel
89
+ * @param event
90
+ * @param payload
91
+ */
92
+ async whisper(channel, event, payload) {
93
+ await this.trigger(channel, this.clientEventName(event), payload);
94
+ }
95
+ /**
96
+ * Emit an event through a transport that supports client-originated events.
97
+ *
98
+ * @param channel
99
+ * @param event
100
+ * @param payload
101
+ */
102
+ async trigger(channel, event, payload) {
103
+ const transport = await this.transport();
104
+ if (!transport.trigger) throw new Error("Realtime: the configured transport does not support client events");
105
+ await transport.trigger(channel, event, payload);
106
+ }
107
+ clientEventName(event) {
108
+ return event.startsWith("client-") ? event : `client-${event}`;
109
+ }
110
+ /**
59
111
  * Subscribe to a user's channel (`{channelPrefix}{userId}`).
60
112
  *
61
113
  * @param userId The user id.
@@ -71,7 +123,7 @@ var RealtimeClient = class {
71
123
  this.transportPromise = void 0;
72
124
  }
73
125
  };
74
- /** Create a {@link RealtimeClient}. */
126
+ /** Create a new {@link RealtimeClient}. */
75
127
  const createRealtime = (config = {}) => new RealtimeClient(config);
76
128
  //#endregion
77
129
  //#region src/transports/pusher.ts
@@ -94,20 +146,36 @@ const createPusherTransport = async (config) => {
94
146
  authEndpoint: config.authEndpoint ?? `${config.apiBase}/realtime/auth`,
95
147
  auth: config.auth
96
148
  });
149
+ const channels = /* @__PURE__ */ new Map();
97
150
  return {
98
151
  subscribe(channel, event, handler) {
99
- const subscription = client.subscribe(channel);
152
+ const active = channels.get(channel);
153
+ const subscription = active?.channel ?? client.subscribe(channel);
100
154
  const listener = (data) => handler(data);
155
+ channels.set(channel, {
156
+ channel: subscription,
157
+ subscriptions: (active?.subscriptions ?? 0) + 1
158
+ });
101
159
  subscription.bind(event, listener);
102
160
  return {
103
161
  channel,
104
162
  unsubscribe() {
105
163
  subscription.unbind(event, listener);
106
- client.unsubscribe(channel);
164
+ const current = channels.get(channel);
165
+ if (!current || current.subscriptions <= 1) {
166
+ channels.delete(channel);
167
+ client.unsubscribe(channel);
168
+ } else current.subscriptions--;
107
169
  }
108
170
  };
109
171
  },
172
+ trigger(channel, event, payload) {
173
+ const subscription = channels.get(channel)?.channel;
174
+ if (!subscription) throw new Error(`Realtime: subscribe to channel "${channel}" before triggering client events`);
175
+ if (!subscription.trigger(event, payload)) throw new Error(`Realtime: failed to trigger client event "${event}" on channel "${channel}"`);
176
+ },
110
177
  disconnect() {
178
+ channels.clear();
111
179
  client.disconnect();
112
180
  }
113
181
  };
@@ -115,6 +183,10 @@ const createPusherTransport = async (config) => {
115
183
  //#endregion
116
184
  //#region src/transports/firebase.ts
117
185
  var firebase_exports = /* @__PURE__ */ __exportAll({ createFirebaseTransport: () => createFirebaseTransport });
186
+ const pathSegment = (value) => encodeURIComponent(value).replace(/\./g, "%2E");
187
+ const isFirebaseClientEvent = (value) => {
188
+ return typeof value === "object" && value !== null && "sender" in value && "payload" in value;
189
+ };
118
190
  /**
119
191
  * Realtime transport backed by [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging/js/receive)
120
192
  * foreground messages. `firebase` is an optional peer dependency imported lazily.
@@ -123,30 +195,68 @@ var firebase_exports = /* @__PURE__ */ __exportAll({ createFirebaseTransport: ()
123
195
  * messages are matched by `event` and the JSON-encoded payload is parsed back.
124
196
  */
125
197
  const createFirebaseTransport = async (config) => {
126
- const [appMod, messagingMod] = await Promise.all([import("firebase/app"), import("firebase/messaging")]).catch(() => {
198
+ const [appMod, messagingMod, databaseMod] = await Promise.all([
199
+ import("firebase/app"),
200
+ import("firebase/messaging"),
201
+ import("firebase/database")
202
+ ]).catch(() => {
127
203
  throw new Error("The \"firebase\" package is required for the Firebase transport. Install it with `npm i firebase`.");
128
204
  });
129
205
  const app = appMod.initializeApp({
130
206
  apiKey: config.apiKey,
131
207
  projectId: config.projectId,
132
208
  appId: config.appId,
133
- messagingSenderId: config.messagingSenderId
209
+ messagingSenderId: config.messagingSenderId,
210
+ databaseURL: config.databaseURL
134
211
  });
135
212
  const messaging = messagingMod.getMessaging(app);
136
213
  const onMessage = messagingMod.onMessage;
214
+ const messageUnsubscribers = /* @__PURE__ */ new Set();
215
+ const databaseUnsubscribers = /* @__PURE__ */ new Set();
216
+ const databaseApi = databaseMod;
217
+ const database = databaseApi.getDatabase(app, config.databaseURL);
218
+ const clientEventsPath = config.clientEventsPath ?? "arkstack/client-events";
219
+ const clientId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
220
+ const clientEventReference = (channel, event) => databaseApi.ref(database, `${clientEventsPath}/${pathSegment(channel)}/${pathSegment(event)}`);
137
221
  return {
138
222
  subscribe(channel, event, handler) {
223
+ const off = onMessage(messaging, (payload) => {
224
+ if (payload.data?.event !== event || !payload.data.payload) return;
225
+ try {
226
+ handler(JSON.parse(payload.data.payload));
227
+ } catch {}
228
+ });
229
+ messageUnsubscribers.add(off);
230
+ const offClientEvent = event.startsWith("client-") ? databaseApi.onChildAdded(clientEventReference(channel, event), (snapshot) => {
231
+ const clientEvent = snapshot.val();
232
+ if (isFirebaseClientEvent(clientEvent) && clientEvent.sender !== clientId) handler(clientEvent.payload);
233
+ }) : void 0;
234
+ if (offClientEvent) databaseUnsubscribers.add(offClientEvent);
139
235
  return {
140
236
  channel,
141
- unsubscribe: onMessage(messaging, (payload) => {
142
- if (payload.data?.event !== event || !payload.data.payload) return;
143
- try {
144
- handler(JSON.parse(payload.data.payload));
145
- } catch {}
146
- })
237
+ unsubscribe() {
238
+ off();
239
+ messageUnsubscribers.delete(off);
240
+ if (offClientEvent) {
241
+ offClientEvent();
242
+ databaseUnsubscribers.delete(offClientEvent);
243
+ }
244
+ }
147
245
  };
148
246
  },
149
- disconnect() {}
247
+ async trigger(channel, event, payload) {
248
+ const eventReference = await databaseApi.push(clientEventReference(channel, event), {
249
+ sender: clientId,
250
+ payload
251
+ });
252
+ await databaseApi.remove(eventReference);
253
+ },
254
+ disconnect() {
255
+ messageUnsubscribers.forEach((off) => off());
256
+ messageUnsubscribers.clear();
257
+ databaseUnsubscribers.forEach((off) => off());
258
+ databaseUnsubscribers.clear();
259
+ }
150
260
  };
151
261
  };
152
262
  //#endregion
@@ -1,4 +1,4 @@
1
- import { s as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-BlV9xSHd.js";
1
+ import { c as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-CpYmOGZa.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 { s as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-BlV9xSHd.js";
1
+ import { c as RealtimeNotification, t as RealtimeClient } from "../RealtimeClient-CpYmOGZa.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.17.22",
3
+ "version": "0.17.24",
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",