@rebasepro/client 0.9.1-canary.fd3754b → 0.10.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/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { createFunctionsClient } from "./functions";
10
10
  import { createStorage } from "./storage";
11
11
  import { ClientStorageSourceRegistry } from "./storage-registry";
12
12
  import { RebaseWebSocketClient } from "./websocket";
13
+ import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
13
14
  import {
14
15
  DEFAULT_STORAGE_SOURCE_KEY,
15
16
  InsertOf,
@@ -76,6 +77,16 @@ export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
76
77
  // but re-exported (see @internal on the class) because the `client-postgres`
77
78
  // driver constructs it directly. Not a stable app-facing API.
78
79
  export { RebaseWebSocketClient } from "./websocket";
80
+ export { RebaseRealtimeChannel } from "./realtime-channel";
81
+ export type {
82
+ PresenceState,
83
+ PresenceDiff,
84
+ BroadcastEvent,
85
+ ChannelTransport,
86
+ ChannelOptions,
87
+ ChannelHistoryEntry,
88
+ ChannelHistoryResult
89
+ } from "./realtime-channel";
79
90
 
80
91
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
81
92
  auth?: CreateAuthOptions;
@@ -142,11 +153,38 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
142
153
  apiKeys: ReturnType<typeof createApiKeys>;
143
154
  functions: ReturnType<typeof createFunctionsClient>;
144
155
  ws?: RebaseWebSocketClient;
156
+ /**
157
+ * Broadcast and presence channels.
158
+ *
159
+ * Was missing from this type while present on the returned object, which
160
+ * made `client.realtime.channel(...)` a type error and forced every adopter
161
+ * to cast around the feature before they could reach it.
162
+ */
163
+ realtime: {
164
+ /**
165
+ * Join a broadcast/presence channel. Repeated calls with the same name
166
+ * return the same channel object. Throws only when the client was
167
+ * created with `realtime: false`.
168
+ *
169
+ * Pass `{ history: true }` to have the channel replay what it missed on
170
+ * join and on every reconnect, for channels the server retains.
171
+ */
172
+ channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
173
+ };
174
+ /**
175
+ * Release the realtime socket and its reconnect timer.
176
+ *
177
+ * An open socket keeps the Node event loop alive, so a script that does not
178
+ * call this will not exit on its own. Safe when realtime was never started
179
+ * (`realtime: false`), and safe to call twice.
180
+ */
181
+ close: () => void;
145
182
  storage: StorageSource;
146
183
  storageRegistry: StorageSourceRegistry;
147
184
  createStorageSource: (storageId: string) => StorageSource;
148
185
  fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
149
186
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
187
+ collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
150
188
  data: TypedDataLayer<DB>;
151
189
  };
152
190
 
@@ -238,9 +276,17 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
238
276
  return storageSourcesPromise;
239
277
  };
240
278
 
241
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
279
+ // Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
280
+ // always produces one, so a truthy check alone can never leave the socket
281
+ // closed.
282
+ const realtimeEnabled = options.realtime !== false;
283
+ const resolvedWsUrl = realtimeEnabled
284
+ ? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
285
+ : undefined;
242
286
 
243
287
  let ws: RebaseWebSocketClient | undefined;
288
+ /** One channel object per name — see `realtime.channel`. */
289
+ const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
244
290
  if (resolvedWsUrl) {
245
291
  const wsOnUnauthorized = options.onUnauthorized || (async () => {
246
292
  try {
@@ -268,9 +314,15 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
268
314
  auth.onAuthStateChange((event, session) => {
269
315
  if (!ws) return;
270
316
  if (event === "SIGNED_OUT") {
317
+ // Not permanent: the client stays usable, and a later subscribe
318
+ // should reconnect anonymously.
271
319
  ws.disconnect();
272
320
  } else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
273
- if (session?.accessToken) {
321
+ // Only re-authenticate a socket that already exists. Signing in
322
+ // is not a request for realtime, and dialling here would undo
323
+ // lazy connect for every app with a login. A socket opened
324
+ // later authenticates itself from `getAuthToken` on open.
325
+ if (session?.accessToken && ws.hasSocket) {
274
326
  ws.authenticate(session.accessToken).catch(console.warn);
275
327
  }
276
328
  }
@@ -408,6 +460,57 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
408
460
  createStorageSource,
409
461
  fetchStorageSources,
410
462
  ws,
463
+ realtime: {
464
+ /**
465
+ * Join a broadcast/presence channel.
466
+ *
467
+ * Repeated calls with the same name return the same channel, so
468
+ * separate components can attach handlers without each opening its
469
+ * own membership — and `leave()` from one would otherwise silently
470
+ * cut off the others.
471
+ */
472
+ channel: (name: string, options?: ChannelOptions): RebaseRealtimeChannel => {
473
+ // Only `realtime: false` gets here — a hard opt-out, so this
474
+ // stays an error. Being merely *unconnected* does not: the
475
+ // socket opens on the first channel operation, which is the
476
+ // whole point of asking for a channel before you use one.
477
+ if (!ws) {
478
+ throw new RebaseClientError(
479
+ "Realtime is disabled on this client (realtime: false), so channels are unavailable."
480
+ );
481
+ }
482
+ let existing = realtimeChannels.get(name);
483
+ if (!existing) {
484
+ existing = new RebaseRealtimeChannel(name, ws, options);
485
+ realtimeChannels.set(name, existing);
486
+ } else if (options?.history) {
487
+ // Same object by name, so options on a later call have no
488
+ // new channel to apply to. Asking for history upgrades the
489
+ // one that exists rather than being quietly ignored — but
490
+ // never the reverse, so a caller that omits the option
491
+ // cannot switch it off under one that asked for it.
492
+ existing.enableHistory();
493
+ }
494
+ return existing;
495
+ }
496
+ },
497
+ /**
498
+ * Release the realtime socket and its reconnect timer.
499
+ *
500
+ * Until this returns, the open socket keeps the Node event loop alive
501
+ * and the process will not exit on its own. Safe to call when realtime
502
+ * was never started, and safe to call twice.
503
+ */
504
+ close: () => {
505
+ // Channels hold presence heartbeat timers, which would otherwise
506
+ // keep firing (and keep a Node process alive) after the socket
507
+ // they publish over is gone.
508
+ for (const channel of realtimeChannels.values()) void channel.leave();
509
+ realtimeChannels.clear();
510
+ // Permanent: nothing queued afterwards may redial and keep the
511
+ // event loop alive, which is the reason this method exists.
512
+ ws?.disconnect(true);
513
+ },
411
514
  setToken: transport.setToken,
412
515
  setAuthTokenGetter: transport.setAuthTokenGetter,
413
516
  setOnUnauthorized: transport.setOnUnauthorized,