@rebasepro/client 0.9.0 → 0.9.1-canary.09aaf62

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Broadcast channels and presence, as an SDK surface.
3
+ *
4
+ * The realtime engine has supported `join_channel`, `broadcast`,
5
+ * `presence_track`, `presence_untrack` and `presence_state` for a while, but
6
+ * the client only recognised those types well enough to send them
7
+ * fire-and-forget: there were no methods to call and no way to receive channel
8
+ * or broadcast events, since `on()` handles only connect / disconnect /
9
+ * reconnect / error. Anything wanting presence therefore opened a *second*
10
+ * socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the
11
+ * reconnect backoff, and the presence heartbeat — a couple of hundred lines
12
+ * per app, all of it duplicating this package.
13
+ *
14
+ * Two protocol details this hides, because both are easy to get wrong and
15
+ * neither is discoverable from the message list:
16
+ *
17
+ * - **A joining client is told only about its own join.** The `presence_diff`
18
+ * it receives after `presence_track` contains just itself. The existing
19
+ * roster arrives only in response to an explicit `presence_state` request,
20
+ * so `join()` sends one.
21
+ * - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A
22
+ * client that tracks once and goes quiet silently vanishes from everyone
23
+ * else's roster while still sitting in the document, so `track()` starts a
24
+ * heartbeat and `leave()` stops it.
25
+ */
26
+ /** Presence state keyed by the server's client id. */
27
+ export type PresenceState = Record<string, Record<string, unknown>>;
28
+ export interface PresenceDiff {
29
+ joins: PresenceState;
30
+ leaves: PresenceState;
31
+ }
32
+ export interface BroadcastEvent {
33
+ event: string;
34
+ payload: unknown;
35
+ }
36
+ /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */
37
+ export interface ChannelTransport {
38
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
39
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
40
+ onReconnect(handler: () => void): () => void;
41
+ }
42
+ export declare class RebaseRealtimeChannel {
43
+ readonly name: string;
44
+ private transport;
45
+ private presenceHandlers;
46
+ private broadcastHandlers;
47
+ private unsubscribers;
48
+ /** Last known roster, kept so handlers always get a full picture. */
49
+ private presences;
50
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
51
+ private trackedState;
52
+ private heartbeat;
53
+ private joined;
54
+ constructor(name: string, transport: ChannelTransport);
55
+ /**
56
+ * Join the channel and ask for the current roster.
57
+ *
58
+ * Called automatically by `track`, `broadcast`, `onPresence` and
59
+ * `onBroadcast`; calling it directly is only needed to start receiving
60
+ * before there is anything to send.
61
+ */
62
+ join(): Promise<void>;
63
+ private rejoin;
64
+ /**
65
+ * Publish this client's presence state, and keep publishing it.
66
+ *
67
+ * Calling `track` again replaces the state (and restarts the heartbeat),
68
+ * which is how you update e.g. a cursor position.
69
+ */
70
+ track(state: Record<string, unknown>): Promise<void>;
71
+ /** Stop publishing presence, without leaving the channel. */
72
+ untrack(): Promise<void>;
73
+ /**
74
+ * Observe the roster. The handler fires immediately with what is already
75
+ * known, then on every change.
76
+ */
77
+ onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void;
78
+ /** Send a broadcast. The sender does not receive its own message. */
79
+ broadcast(event: string, payload: unknown): Promise<void>;
80
+ /** Observe broadcasts. Pass an event name to filter. */
81
+ onBroadcast(handler: (event: BroadcastEvent) => void): () => void;
82
+ onBroadcast(event: string, handler: (payload: unknown) => void): () => void;
83
+ /** Leave the channel and release every listener and timer. */
84
+ leave(): Promise<void>;
85
+ private stopHeartbeat;
86
+ /** Fold an incoming frame into the roster and fan it out. */
87
+ private handle;
88
+ private emitPresence;
89
+ }
@@ -2,12 +2,46 @@ import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from
2
2
  export { RebaseApiError } from "@rebasepro/types";
3
3
  export type { RebaseErrorInit } from "@rebasepro/types";
4
4
  export interface RebaseClientConfig {
5
+ /**
6
+ * Origin of the Rebase server — scheme, host and port **only**.
7
+ *
8
+ * {@link apiPath} is appended to this, so do not include it here:
9
+ * `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
10
+ * silently builds `/api/api/…` and every request 404s. Omit entirely for
11
+ * same-origin requests from the browser.
12
+ */
5
13
  baseUrl?: string;
14
+ /**
15
+ * Bearer token sent as `Authorization` on every request.
16
+ *
17
+ * In the browser this is the signed-in user's access token, so row-level
18
+ * security applies. Server-side callers — scripts, cron jobs, ETL — pass the
19
+ * service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
20
+ * and **bypasses RLS**: there is no user to constrain those queries, so scope
21
+ * them explicitly.
22
+ */
6
23
  token?: string;
24
+ /**
25
+ * Path the API is mounted under, appended to {@link baseUrl}.
26
+ * Defaults to `"/api"`; override only if the server mounts it elsewhere.
27
+ */
7
28
  apiPath?: string;
8
29
  fetch?: typeof globalThis.fetch;
9
30
  onUnauthorized?: () => Promise<boolean>;
10
31
  websocketUrl?: string;
32
+ /**
33
+ * Open the realtime WebSocket. **Defaults to `true`.**
34
+ *
35
+ * The socket connects as soon as the client is constructed and keeps the
36
+ * Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
37
+ * exit on its own. Set this to `false` for any process that reads or writes
38
+ * and then terminates — `.listen()` and `.listenById()` then throw instead
39
+ * of silently doing nothing.
40
+ *
41
+ * Long-lived processes that do want realtime can instead call
42
+ * `client.close()` when shutting down.
43
+ */
44
+ realtime?: boolean;
11
45
  }
12
46
  /**
13
47
  * Re-export from `@rebasepro/types` for backward compatibility.
@@ -14,7 +14,7 @@ export interface RebaseWebSocketConfig {
14
14
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
15
15
  * manages this internally (exposed as `client.ws`, typed by the minimal
16
16
  * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
17
- * package root only because the `@rebasepro/client-postgresql` driver
17
+ * package root only because the `@rebasepro/client-postgres` driver
18
18
  * instantiates it directly; its surface may change without a major bump.
19
19
  */
20
20
  export declare class RebaseWebSocketClient {
@@ -23,6 +23,12 @@ export declare class RebaseWebSocketClient {
23
23
  getAuthToken?: () => Promise<string | null>;
24
24
  private subscriptions;
25
25
  private listeners;
26
+ /** Channel-name → handlers, for broadcast and presence frames. */
27
+ private channelHandlers;
28
+ /** Subscribe to broadcast/presence frames for one channel. */
29
+ onChannelMessage(channel: string, handler: (message: Record<string, unknown>) => void): () => void;
30
+ /** Notified after the socket comes back, so channels can re-join. */
31
+ onReconnect(handler: () => void): () => void;
26
32
  on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void): () => boolean;
27
33
  private emit;
28
34
  private collectionSubscriptions;
@@ -35,6 +41,7 @@ export declare class RebaseWebSocketClient {
35
41
  private isConnected;
36
42
  private messageQueue;
37
43
  private requestTimeoutMs;
44
+ private subscriptionTimeoutMs;
38
45
  private reconnectTimeout;
39
46
  private isAuthenticated;
40
47
  private authPromise;
@@ -64,7 +71,11 @@ export declare class RebaseWebSocketClient {
64
71
  private handleWebSocketMessage;
65
72
  private ensureAuthenticated;
66
73
  reauthenticate(): Promise<void>;
67
- private sendMessage;
74
+ /**
75
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
76
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
77
+ */
78
+ sendMessage(message: Record<string, unknown>): Promise<unknown>;
68
79
  private doSendMessage;
69
80
  fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;
70
81
  fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
@@ -92,6 +103,19 @@ export declare class RebaseWebSocketClient {
92
103
  */
93
104
  private deepEqual;
94
105
  private normalizeForComparison;
106
+ /**
107
+ * The address of a row, for matching it against another copy of itself.
108
+ *
109
+ * A row is exactly its columns and carries no address, so it is derived
110
+ * from the key columns the server named — including the ordinary case where
111
+ * that key is `id`, which the server reports like any other.
112
+ *
113
+ * Undefined when there are no keys, which means the server could not
114
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
115
+ * column called `id` would be inventing an identity for a table that has
116
+ * none.
117
+ */
118
+ private rowAddress;
95
119
  /**
96
120
  * Merge incoming rows with cached data, preserving cached references
97
121
  * for rows whose values haven't changed. This avoids unnecessary
@@ -101,6 +125,48 @@ export declare class RebaseWebSocketClient {
101
125
  private mergeRows;
102
126
  listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
103
127
  listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
128
+ /**
129
+ * Send a `subscribe_collection` for an already-registered subscription and
130
+ * arm its watchdog.
131
+ *
132
+ * Every path that registers a collection subscription goes through here, so
133
+ * that a subscribe which never lands — a rejected send, or a server that
134
+ * never answers — always ends up in `failCollectionSubscription` rather than
135
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
136
+ */
137
+ private sendCollectionSubscribe;
138
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
139
+ private sendEntitySubscribe;
140
+ /**
141
+ * Report a subscribe failure to every listener and drop the registration.
142
+ *
143
+ * Dropping it is the point: the callbacks stay live (their components are
144
+ * still mounted and have been told), but the next `listenCollection` for
145
+ * these params finds no entry and issues a fresh subscribe instead of
146
+ * silently attaching to a dead one.
147
+ */
148
+ private failCollectionSubscription;
149
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
150
+ private failEntitySubscription;
151
+ /**
152
+ * Stop the watchdogs without failing anything — used when the socket drops,
153
+ * since the reconnect path re-subscribes everything anyway and a watchdog
154
+ * firing mid-reconnect would tear down healthy subscriptions.
155
+ */
156
+ private suspendSubscribeWatchdogs;
157
+ /**
158
+ * Arm watchdogs for subscribes that were requested while offline and have
159
+ * just been flushed to the socket. Their timers were deliberately not set at
160
+ * request time, so without this they would have no timeout at all.
161
+ */
162
+ private armPendingSubscribeWatchdogs;
163
+ private sendCollectionSubscribeWatchdog;
164
+ private sendEntitySubscribeWatchdog;
165
+ /**
166
+ * Fail every subscription that never received data. Called when reconnection
167
+ * is given up on, so views surface an error instead of spinning forever.
168
+ */
169
+ private failAllPendingSubscriptions;
104
170
  /**
105
171
  * Re-send all active subscriptions to the backend after a reconnect.
106
172
  * The server wipes subscription state when a client disconnects, so
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.9.1-canary.09aaf62",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -13,26 +13,25 @@
13
13
  "url": "https://github.com/rebasepro/rebase.git",
14
14
  "directory": "packages/client"
15
15
  },
16
- "main": "./dist/index.umd.cjs",
16
+ "main": "./dist/index.es.js",
17
17
  "module": "./dist/index.es.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "source": "src/index.ts",
20
20
  "engines": {
21
- "node": ">=14"
21
+ "node": ">=20"
22
22
  },
23
23
  "exports": {
24
24
  ".": {
25
25
  "types": "./dist/index.d.ts",
26
26
  "development": "./dist/index.es.js",
27
- "import": "./dist/index.es.js",
28
- "require": "./dist/index.umd.cjs"
27
+ "import": "./dist/index.es.js"
29
28
  },
30
29
  "./package.json": "./package.json"
31
30
  },
32
31
  "dependencies": {
33
- "@rebasepro/types": "0.9.0",
34
- "@rebasepro/utils": "0.9.0",
35
- "@rebasepro/common": "0.9.0"
32
+ "@rebasepro/common": "0.9.1-canary.09aaf62",
33
+ "@rebasepro/utils": "0.9.1-canary.09aaf62",
34
+ "@rebasepro/types": "0.9.1-canary.09aaf62"
36
35
  },
37
36
  "devDependencies": {
38
37
  "@jest/globals": "^30.4.1",
@@ -75,7 +74,7 @@
75
74
  },
76
75
  "scripts": {
77
76
  "watch": "vite build --watch",
78
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
77
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
79
78
  "test:lint": "eslint \"src/**\" --quiet",
80
79
  "test": "jest --passWithNoTests --forceExit",
81
80
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
package/src/admin.ts CHANGED
@@ -54,7 +54,7 @@ export function createAdmin(transport: Transport, options?: CreateAdminOptions)
54
54
  }
55
55
 
56
56
  async function resetPassword(userId: string, options?: { password?: string }) {
57
- return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean }>(
57
+ return transport.request<{ user: AdminUser; temporaryPassword?: string; invitationSent?: boolean; emailDeliveryFailed?: boolean }>(
58
58
  adminPath + "/users/" + encodeURIComponent(userId) + "/reset-password",
59
59
  {
60
60
  method: "POST",
package/src/api-keys.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Transport } from "./transport";
2
2
 
3
- // Re-define the types locally since they live in server-core, not in @rebasepro/types.
3
+ // Re-define the types locally since they live in server, not in @rebasepro/types.
4
4
  // These match the server-side types exactly.
5
5
 
6
6
  /** A single permission entry scoping an API key to a collection and its allowed operations. */
package/src/backups.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { Transport } from "./transport";
2
+ import type { BackupInfo, BackupDestinationKind } from "@rebasepro/types";
3
+
4
+ export interface CreateBackupsOptions {
5
+ backupsPath?: string;
6
+ }
7
+
8
+ export function createBackups(transport: Transport, options?: CreateBackupsOptions) {
9
+ const backupsPath = options?.backupsPath || "/admin/backups";
10
+
11
+ async function list(): Promise<{
12
+ backups: BackupInfo[];
13
+ destinationKind: BackupDestinationKind;
14
+ configured: boolean;
15
+ }> {
16
+ return transport.request(backupsPath, { method: "GET" });
17
+ }
18
+
19
+ /**
20
+ * Download a backup's bytes. Uses an authenticated fetch (not the JSON
21
+ * transport) so the octet-stream response comes back as a Blob.
22
+ */
23
+ async function download(key: string): Promise<Blob> {
24
+ const token = await transport.resolveToken();
25
+ // Mirror transport.request's URL construction (baseUrl + apiPath + path)
26
+ // — this endpoint returns an octet-stream, so we fetch it directly
27
+ // instead of going through the JSON transport.
28
+ const url = `${transport.baseUrl}${transport.apiPath}${backupsPath}/download?key=${encodeURIComponent(key)}`;
29
+ const res = await fetch(url, {
30
+ method: "GET",
31
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
32
+ });
33
+ if (!res.ok) {
34
+ throw new Error(`Failed to download backup (${res.status})`);
35
+ }
36
+ return res.blob();
37
+ }
38
+
39
+ return { list, download };
40
+ }
package/src/collection.ts CHANGED
@@ -70,6 +70,22 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
70
70
  return raw as M;
71
71
  },
72
72
 
73
+ async createMany(data: Partial<M>[], options?: { upsert?: boolean }) {
74
+ if (!Array.isArray(data)) {
75
+ throw new TypeError("createMany expects an array of records.");
76
+ }
77
+ if (data.length === 0) return [];
78
+
79
+ const raw = await transport.request<{ data: Record<string, unknown>[] }>(`${basePath}/bulk`, {
80
+ method: "POST",
81
+ body: JSON.stringify({
82
+ rows: data,
83
+ ...(options?.upsert ? { upsert: true } : {})
84
+ })
85
+ });
86
+ return (raw.data || []) as M[];
87
+ },
88
+
73
89
  async update(id: string | number, data: Partial<M>) {
74
90
  const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, {
75
91
  method: "PUT",
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@ import { RebaseClientError } from "./errors";
3
3
  import { createAuth, CreateAuthOptions } from "./auth";
4
4
  import { createAdmin, CreateAdminOptions } from "./admin";
5
5
  import { createCron, CreateCronOptions } from "./cron";
6
+ import { createBackups } from "./backups";
6
7
  import { createApiKeys, CreateApiKeysOptions } from "./api-keys";
7
8
  import { CollectionClient, createCollectionClient } from "./collection";
8
9
  import { createFunctionsClient } from "./functions";
9
10
  import { createStorage } from "./storage";
10
11
  import { ClientStorageSourceRegistry } from "./storage-registry";
11
12
  import { RebaseWebSocketClient } from "./websocket";
13
+ import { RebaseRealtimeChannel } from "./realtime-channel";
12
14
  import {
13
15
  DEFAULT_STORAGE_SOURCE_KEY,
14
16
  InsertOf,
@@ -59,6 +61,8 @@ export type { RebaseUser, RebaseTokens } from "./auth";
59
61
  export type { CreateAdminOptions } from "./admin";
60
62
  export type { AdminUser } from "./admin";
61
63
  export type { CreateCronOptions } from "./cron";
64
+ export { createBackups } from "./backups";
65
+ export type { CreateBackupsOptions } from "./backups";
62
66
  export type {
63
67
  ApiKeyMasked,
64
68
  ApiKeyPermission,
@@ -70,9 +74,11 @@ export type {
70
74
  export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
71
75
 
72
76
  // Realtime: the WebSocket client class is internal to `createRebaseClient()`,
73
- // but re-exported (see @internal on the class) because the `client-postgresql`
77
+ // but re-exported (see @internal on the class) because the `client-postgres`
74
78
  // driver constructs it directly. Not a stable app-facing API.
75
79
  export { RebaseWebSocketClient } from "./websocket";
80
+ export { RebaseRealtimeChannel } from "./realtime-channel";
81
+ export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport } from "./realtime-channel";
76
82
 
77
83
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
78
84
  auth?: CreateAuthOptions;
@@ -135,14 +141,24 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
135
141
  auth: ReturnType<typeof createAuth>;
136
142
  admin: ReturnType<typeof createAdmin>;
137
143
  cron: ReturnType<typeof createCron>;
144
+ backups: ReturnType<typeof createBackups>;
138
145
  apiKeys: ReturnType<typeof createApiKeys>;
139
146
  functions: ReturnType<typeof createFunctionsClient>;
140
147
  ws?: RebaseWebSocketClient;
148
+ /**
149
+ * Release the realtime socket and its reconnect timer.
150
+ *
151
+ * An open socket keeps the Node event loop alive, so a script that does not
152
+ * call this will not exit on its own. Safe when realtime was never started
153
+ * (`realtime: false`), and safe to call twice.
154
+ */
155
+ close: () => void;
141
156
  storage: StorageSource;
142
157
  storageRegistry: StorageSourceRegistry;
143
158
  createStorageSource: (storageId: string) => StorageSource;
144
159
  fetchStorageSources: () => Promise<StorageSourceDefinition[]>;
145
160
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
161
+ collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
146
162
  data: TypedDataLayer<DB>;
147
163
  };
148
164
 
@@ -187,6 +203,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
187
203
  const auth = createAuth(transport, options.auth);
188
204
  const admin = createAdmin(transport, options.admin);
189
205
  const cron = createCron(transport, options.cron);
206
+ const backups = createBackups(transport);
190
207
  const apiKeys = createApiKeys(transport, options.apiKeys);
191
208
  const storage = createStorage(transport);
192
209
  const functions = createFunctionsClient(transport);
@@ -233,9 +250,17 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
233
250
  return storageSourcesPromise;
234
251
  };
235
252
 
236
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
253
+ // Opting out has to happen before the URL is derived: `deriveWebSocketUrl`
254
+ // always produces one, so a truthy check alone can never leave the socket
255
+ // closed.
256
+ const realtimeEnabled = options.realtime !== false;
257
+ const resolvedWsUrl = realtimeEnabled
258
+ ? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
259
+ : undefined;
237
260
 
238
261
  let ws: RebaseWebSocketClient | undefined;
262
+ /** One channel object per name — see `realtime.channel`. */
263
+ const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
239
264
  if (resolvedWsUrl) {
240
265
  const wsOnUnauthorized = options.onUnauthorized || (async () => {
241
266
  try {
@@ -395,6 +420,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
395
420
  auth,
396
421
  admin,
397
422
  cron,
423
+ backups,
398
424
  apiKeys,
399
425
  functions,
400
426
  storage,
@@ -402,6 +428,44 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
402
428
  createStorageSource,
403
429
  fetchStorageSources,
404
430
  ws,
431
+ realtime: {
432
+ /**
433
+ * Join a broadcast/presence channel.
434
+ *
435
+ * Repeated calls with the same name return the same channel, so
436
+ * separate components can attach handlers without each opening its
437
+ * own membership — and `leave()` from one would otherwise silently
438
+ * cut off the others.
439
+ */
440
+ channel: (name: string): RebaseRealtimeChannel => {
441
+ if (!ws) {
442
+ throw new RebaseClientError(
443
+ "Realtime is disabled on this client (realtime: false), so channels are unavailable."
444
+ );
445
+ }
446
+ let existing = realtimeChannels.get(name);
447
+ if (!existing) {
448
+ existing = new RebaseRealtimeChannel(name, ws);
449
+ realtimeChannels.set(name, existing);
450
+ }
451
+ return existing;
452
+ }
453
+ },
454
+ /**
455
+ * Release the realtime socket and its reconnect timer.
456
+ *
457
+ * Until this returns, the open socket keeps the Node event loop alive
458
+ * and the process will not exit on its own. Safe to call when realtime
459
+ * was never started, and safe to call twice.
460
+ */
461
+ close: () => {
462
+ // Channels hold presence heartbeat timers, which would otherwise
463
+ // keep firing (and keep a Node process alive) after the socket
464
+ // they publish over is gone.
465
+ for (const channel of realtimeChannels.values()) void channel.leave();
466
+ realtimeChannels.clear();
467
+ ws?.disconnect();
468
+ },
405
469
  setToken: transport.setToken,
406
470
  setAuthTokenGetter: transport.setAuthTokenGetter,
407
471
  setOnUnauthorized: transport.setOnUnauthorized,