@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.
@@ -0,0 +1,245 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createRebaseClient } from "./index";
3
+
4
+ /**
5
+ * A WebSocket stand-in that records construction. The real socket keeps the Node
6
+ * event loop alive, which is what makes a one-shot script hang; here we only
7
+ * need to know whether one would have been opened at all.
8
+ */
9
+ function trackingWebSocket() {
10
+ const opened: string[] = [];
11
+ const closed: string[] = [];
12
+
13
+ class FakeWebSocket {
14
+ static readonly OPEN = 1;
15
+ readyState = 0;
16
+ onopen: (() => void) | null = null;
17
+ onclose: (() => void) | null = null;
18
+ onerror: (() => void) | null = null;
19
+ onmessage: (() => void) | null = null;
20
+
21
+ constructor(public url: string) {
22
+ opened.push(url);
23
+ }
24
+
25
+ close() {
26
+ closed.push(this.url);
27
+ }
28
+
29
+ send() { /* no-op */ }
30
+ }
31
+
32
+ return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened, closed };
33
+ }
34
+
35
+ describe("realtime opt-out", () => {
36
+ const original = globalThis.WebSocket;
37
+
38
+ afterEach(() => {
39
+ globalThis.WebSocket = original;
40
+ jest.restoreAllMocks();
41
+ });
42
+
43
+ it("opens NO socket on construction, even with realtime enabled", () => {
44
+ // Changed deliberately. Constructing a client is not a statement that
45
+ // the app wants a socket — `createRebaseClient` builds one whenever
46
+ // realtime is not explicitly disabled, so dialling here cost a
47
+ // connection on every page load of every app that merely *might*
48
+ // subscribe later. Anonymous-first apps paid it on every visit to
49
+ // authenticate with nothing.
50
+ const { FakeWebSocket, opened } = trackingWebSocket();
51
+ globalThis.WebSocket = FakeWebSocket;
52
+
53
+ createRebaseClient({ baseUrl: "http://localhost:3000/api" });
54
+
55
+ expect(opened).toHaveLength(0);
56
+ });
57
+
58
+ it("opens the socket on the first channel operation", () => {
59
+ const { FakeWebSocket, opened } = trackingWebSocket();
60
+ globalThis.WebSocket = FakeWebSocket;
61
+
62
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
63
+
64
+ // Asking for the channel is not using it.
65
+ const channel = client.realtime.channel("doc:1");
66
+ expect(opened).toHaveLength(0);
67
+
68
+ void channel.join();
69
+ expect(opened).toHaveLength(1);
70
+ });
71
+
72
+ it("opens the socket on the first collection subscription", () => {
73
+ const { FakeWebSocket, opened } = trackingWebSocket();
74
+ globalThis.WebSocket = FakeWebSocket;
75
+
76
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
77
+ expect(opened).toHaveLength(0);
78
+
79
+ client.collection("posts").listen!(undefined, () => { /* noop */ });
80
+
81
+ expect(opened).toHaveLength(1);
82
+ });
83
+
84
+ it("opens exactly one socket however many things subscribe", () => {
85
+ const { FakeWebSocket, opened } = trackingWebSocket();
86
+ globalThis.WebSocket = FakeWebSocket;
87
+
88
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
89
+
90
+ client.collection("posts").listen!(undefined, () => { /* noop */ });
91
+ client.collection("authors").listen!(undefined, () => { /* noop */ });
92
+ void client.realtime.channel("doc:1").join();
93
+
94
+ expect(opened).toHaveLength(1);
95
+ });
96
+
97
+ it("stays silent for an anonymous client that never subscribes", () => {
98
+ // The motivating case: most page loads of an anonymous-first app.
99
+ // No socket, and no console noise either.
100
+ const { FakeWebSocket, opened } = trackingWebSocket();
101
+ globalThis.WebSocket = FakeWebSocket;
102
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
103
+ const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* capture */ });
104
+
105
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
106
+ void client.realtime.channel("doc:1"); // obtained, never used
107
+
108
+ expect(opened).toHaveLength(0);
109
+ expect(warn).not.toHaveBeenCalled();
110
+ expect(debug).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("says nothing about a missing WebSocket until something needs one", () => {
114
+ // The environment warning used to fire on construction, so a Node
115
+ // process that never subscribed was told off for a socket it did not
116
+ // want.
117
+ // @ts-expect-error deliberately removing the global
118
+ delete globalThis.WebSocket;
119
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
120
+
121
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
122
+ expect(warn).not.toHaveBeenCalled();
123
+
124
+ void client.realtime.channel("doc:1").join();
125
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/WebSocket is not defined/));
126
+
127
+ // ...and only once, however many times it is asked.
128
+ void client.realtime.channel("doc:2").join();
129
+ expect(warn).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it("opens no socket when realtime is disabled", () => {
133
+ const { FakeWebSocket, opened } = trackingWebSocket();
134
+ globalThis.WebSocket = FakeWebSocket;
135
+
136
+ const client = createRebaseClient({
137
+ baseUrl: "http://localhost:3000/api",
138
+ realtime: false
139
+ });
140
+
141
+ // The socket is what keeps a CLI / cron / ETL process alive past its work.
142
+ expect(opened).toHaveLength(0);
143
+ expect(client.ws).toBeUndefined();
144
+ });
145
+
146
+ it("opens no socket when realtime is disabled even if a websocketUrl is given", () => {
147
+ const { FakeWebSocket, opened } = trackingWebSocket();
148
+ globalThis.WebSocket = FakeWebSocket;
149
+
150
+ createRebaseClient({
151
+ baseUrl: "http://localhost:3000/api",
152
+ websocketUrl: "ws://localhost:3000",
153
+ realtime: false
154
+ });
155
+
156
+ expect(opened).toHaveLength(0);
157
+ });
158
+
159
+ it("close() releases a socket that was opened", () => {
160
+ const { FakeWebSocket, opened, closed } = trackingWebSocket();
161
+ globalThis.WebSocket = FakeWebSocket;
162
+
163
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
164
+ client.collection("posts").listen!(undefined, () => { /* noop */ });
165
+ expect(opened).toHaveLength(1);
166
+
167
+ client.close();
168
+
169
+ expect(closed).toHaveLength(1);
170
+ });
171
+
172
+ it("close() is final — a later subscribe does not redial", () => {
173
+ // Otherwise one queued frame could reopen the socket that close() just
174
+ // released, and the Node process this method exists to let exit would
175
+ // stay alive anyway.
176
+ const { FakeWebSocket, opened } = trackingWebSocket();
177
+ globalThis.WebSocket = FakeWebSocket;
178
+
179
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
180
+ client.collection("posts").listen!(undefined, () => { /* noop */ });
181
+ expect(opened).toHaveLength(1);
182
+
183
+ client.close();
184
+ client.collection("authors").listen!(undefined, () => { /* noop */ });
185
+
186
+ expect(opened).toHaveLength(1);
187
+ });
188
+
189
+ it("close() is safe when realtime was never started, and when called twice", () => {
190
+ const { FakeWebSocket } = trackingWebSocket();
191
+ globalThis.WebSocket = FakeWebSocket;
192
+
193
+ const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
194
+ expect(() => offline.close()).not.toThrow();
195
+
196
+ const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
197
+ live.close();
198
+ expect(() => live.close()).not.toThrow();
199
+ });
200
+
201
+ it("still refuses channels when realtime is disabled", () => {
202
+ // `realtime: false` remains a hard opt-out. Only the *default* changed
203
+ // — from "connected eagerly" to "connected on use".
204
+ const { FakeWebSocket, opened } = trackingWebSocket();
205
+ globalThis.WebSocket = FakeWebSocket;
206
+
207
+ const client = createRebaseClient({
208
+ baseUrl: "http://localhost:3000/api",
209
+ realtime: false
210
+ });
211
+
212
+ expect(() => client.realtime.channel("doc:1")).toThrow(/realtime: false/);
213
+ expect(opened).toHaveLength(0);
214
+ });
215
+
216
+ it("signing in does not by itself open a socket", () => {
217
+ // Authenticating is not a request for realtime. If it dialled, every
218
+ // app with a login would lose lazy connect at the moment of login.
219
+ const { FakeWebSocket, opened } = trackingWebSocket();
220
+ globalThis.WebSocket = FakeWebSocket;
221
+
222
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
223
+ // Drive the auth-state path the way a sign-in would.
224
+ client.auth.onAuthStateChange(() => { /* noop */ });
225
+
226
+ expect(opened).toHaveLength(0);
227
+ });
228
+
229
+ it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
230
+ const { FakeWebSocket } = trackingWebSocket();
231
+ globalThis.WebSocket = FakeWebSocket;
232
+
233
+ const client = createRebaseClient({
234
+ baseUrl: "http://localhost:3000/api",
235
+ realtime: false
236
+ });
237
+
238
+ // `listen` stays undefined rather than becoming a throwing stub: the
239
+ // optional type is what makes `if (client.listen)` work and what makes
240
+ // TypeScript reject a bare call.
241
+ expect(client.collection("posts").listen).toBeUndefined();
242
+ expect(() => client.data.posts.include("author").listen(() => { /* noop */ }))
243
+ .toThrow(/realtime: false/);
244
+ });
245
+ });
@@ -0,0 +1,254 @@
1
+ import { RebaseWebSocketClient } from "./websocket";
2
+
3
+ /**
4
+ * Realtime patches name a row by address; the cached rows are columns only.
5
+ *
6
+ * The SDK deliberately holds no collection config — a BaaS caller declares
7
+ * nothing — so it cannot derive an address by itself, and used to just read
8
+ * `row.id`. For a table keyed on anything else that is `undefined`: deletes
9
+ * matched no cached row and removed nothing, and updates matched none either,
10
+ * so every edit was prepended as a duplicate of the row it was editing.
11
+ *
12
+ * The server now sends the key columns with the patch, and they are used here.
13
+ */
14
+ describe("collection_patch — cached rows are matched by derived address", () => {
15
+ const SKU_PKS = [{ fieldName: "sku",
16
+ type: "string" as const }];
17
+
18
+ /**
19
+ * A client with one collection subscription holding `cached`, wired to the
20
+ * backend subscription id the patches below use.
21
+ */
22
+ const setup = (cached: Record<string, unknown>[]) => {
23
+ const client = new RebaseWebSocketClient({ url: "ws://localhost:1234" });
24
+ const updates: Record<string, unknown>[][] = [];
25
+
26
+ const internals = client as unknown as {
27
+ backendToCollectionKey: Map<string, string>;
28
+ collectionSubscriptions: Map<string, unknown>;
29
+ handleWebSocketMessage: (m: unknown) => void;
30
+ };
31
+
32
+ internals.backendToCollectionKey.set("backend-1", "sub-key");
33
+ internals.collectionSubscriptions.set("sub-key", {
34
+ backendSubscriptionId: "backend-1",
35
+ callbacks: new Map([["cb", { onUpdate: (rows: Record<string, unknown>[]) => updates.push(rows) }]]),
36
+ props: { path: "sku_items" },
37
+ latestData: cached,
38
+ isInitialDataReceived: true
39
+ });
40
+
41
+ const patch = (message: Record<string, unknown>) =>
42
+ internals.handleWebSocketMessage({ type: "collection_patch",
43
+ subscriptionId: "backend-1",
44
+ ...message });
45
+
46
+ const update = (rows: Record<string, unknown>[], pks?: unknown) =>
47
+ internals.handleWebSocketMessage({ type: "collection_update",
48
+ subscriptionId: "backend-1",
49
+ rows,
50
+ pks });
51
+
52
+ return { patch,
53
+ update,
54
+ updates,
55
+ sub: () => internals.collectionSubscriptions.get("sub-key") as { latestData: Record<string, unknown>[] } };
56
+ };
57
+
58
+ it("removes the deleted row a `sku` key names", () => {
59
+ const { patch, sub } = setup([
60
+ { sku: "ABC-1",
61
+ label: "Widget" },
62
+ { sku: "ABC-2",
63
+ label: "Gadget" }
64
+ ]);
65
+
66
+ patch({ id: "ABC-1",
67
+ row: null,
68
+ pks: SKU_PKS });
69
+
70
+ expect(sub().latestData).toEqual([{ sku: "ABC-2",
71
+ label: "Gadget" }]);
72
+ });
73
+
74
+ it("replaces the row the patch names, not whichever row is first", () => {
75
+ // Both sides of the old comparison stringified to "undefined", so it
76
+ // matched at index 0 every time: editing the second row overwrote the
77
+ // first one, in place, with the second one's values.
78
+ const { patch, sub } = setup([
79
+ { sku: "ABC-1",
80
+ label: "Widget" },
81
+ { sku: "ABC-2",
82
+ label: "Gadget" }
83
+ ]);
84
+
85
+ patch({ id: "ABC-2",
86
+ row: { sku: "ABC-2",
87
+ label: "Gadget v2" },
88
+ pks: SKU_PKS });
89
+
90
+ expect(sub().latestData).toEqual([
91
+ { sku: "ABC-1",
92
+ label: "Widget" },
93
+ { sku: "ABC-2",
94
+ label: "Gadget v2" }
95
+ ]);
96
+ });
97
+
98
+ it("prepends a row that really is new", () => {
99
+ const { patch, sub } = setup([{ sku: "ABC-2",
100
+ label: "Gadget" }]);
101
+
102
+ patch({ id: "ABC-9",
103
+ row: { sku: "ABC-9",
104
+ label: "New" },
105
+ pks: SKU_PKS });
106
+
107
+ expect(sub().latestData).toEqual([
108
+ { sku: "ABC-9",
109
+ label: "New" },
110
+ { sku: "ABC-2",
111
+ label: "Gadget" }
112
+ ]);
113
+ });
114
+
115
+ it("matches a composite key by its joined address", () => {
116
+ const { patch, sub } = setup([
117
+ { tenant_id: 1,
118
+ user_id: 2,
119
+ role: "admin" },
120
+ { tenant_id: 1,
121
+ user_id: 3,
122
+ role: "viewer" }
123
+ ]);
124
+
125
+ patch({
126
+ id: "1:::3",
127
+ row: { tenant_id: 1,
128
+ user_id: 3,
129
+ role: "owner" },
130
+ pks: [{ fieldName: "tenant_id",
131
+ type: "number" }, { fieldName: "user_id",
132
+ type: "number" }]
133
+ });
134
+
135
+ expect(sub().latestData).toEqual([
136
+ { tenant_id: 1,
137
+ user_id: 2,
138
+ role: "admin" },
139
+ { tenant_id: 1,
140
+ user_id: 3,
141
+ role: "owner" }
142
+ ]);
143
+ });
144
+
145
+ it("carries the patch's keys into the refetch merge, preserving unchanged references", () => {
146
+ // Phase 2 of every patch is a full refetch (`collection_update`), whose
147
+ // merge keeps the cached object for rows that did not change so React
148
+ // sees the same reference. That matching needs an address too — the
149
+ // keys learned from the patch are remembered on the subscription.
150
+ const unchanged = { sku: "ABC-1",
151
+ label: "Widget" };
152
+ const { patch, update, sub } = setup([unchanged, { sku: "ABC-2",
153
+ label: "Gadget" }]);
154
+
155
+ patch({ id: "ABC-2",
156
+ row: { sku: "ABC-2",
157
+ label: "Gadget v2" },
158
+ pks: SKU_PKS });
159
+ update([{ sku: "ABC-1",
160
+ label: "Widget" }, { sku: "ABC-2",
161
+ label: "Gadget v3" }], SKU_PKS);
162
+
163
+ expect(sub().latestData[0]).toBe(unchanged);
164
+ expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
165
+ label: "Gadget v3" });
166
+ });
167
+
168
+ it("learns the keys from the rows themselves, for a change that sends no patch", () => {
169
+ // A write from outside the API — psql, a cron job — reaches subscribers
170
+ // through CDC, which invalidates and refetches without ever sending a
171
+ // patch. Keys learned only from patches would never arrive, and every
172
+ // row of the refetch would be a new reference: the whole table
173
+ // re-renders on any external write.
174
+ const unchanged = { sku: "ABC-1",
175
+ label: "Widget" };
176
+ const { update, sub } = setup([unchanged, { sku: "ABC-2",
177
+ label: "Gadget" }]);
178
+
179
+ update([{ sku: "ABC-1",
180
+ label: "Widget" }, { sku: "ABC-2",
181
+ label: "Gadget v2" }], SKU_PKS);
182
+
183
+ expect(sub().latestData[0]).toBe(unchanged);
184
+ expect(sub().latestData[1]).toEqual({ sku: "ABC-2",
185
+ label: "Gadget v2" });
186
+ });
187
+
188
+ it("addresses an `id`-keyed collection from the keys like any other", () => {
189
+ // `id` is not special: the server reports it as the key column, and it
190
+ // is derived through the same path as a `sku` or a composite.
191
+ const { patch, sub } = setup([{ id: 1,
192
+ name: "Camera" }, { id: 2,
193
+ name: "Lens" }]);
194
+
195
+ patch({ id: "1",
196
+ row: null,
197
+ pks: [{ fieldName: "id",
198
+ type: "number" }] });
199
+
200
+ expect(sub().latestData).toEqual([{ id: 2,
201
+ name: "Lens" }]);
202
+ });
203
+
204
+ it("recognises nothing when the server resolved no keys, rather than guessing at `id`", () => {
205
+ // A table with no primary key and no `id` column has no address. The
206
+ // server says so by sending no keys, and inventing one from a column
207
+ // that merely looks like an id would address rows that are not there.
208
+ const { patch, sub } = setup([
209
+ { id: "batch-42",
210
+ name: "Camera" },
211
+ { id: "batch-42",
212
+ name: "Lens" }
213
+ ]);
214
+
215
+ patch({ id: "batch-42",
216
+ row: null });
217
+
218
+ // Nothing removed: the patch names a row this client cannot identify.
219
+ expect(sub().latestData).toHaveLength(2);
220
+ });
221
+
222
+ it("prefers the real key over a column merely named `id`", () => {
223
+ // `event_id` is the key; `id` is ordinary data, and ordinary data has no
224
+ // uniqueness to borrow — both rows here carry the same external ref.
225
+ // Matching on it finds the first, so editing the party rewrote the
226
+ // launch with the party's values.
227
+ const { patch, sub } = setup([
228
+ { event_id: 7,
229
+ id: "batch-42",
230
+ name: "Launch" },
231
+ { event_id: 8,
232
+ id: "batch-42",
233
+ name: "Party" }
234
+ ]);
235
+
236
+ patch({
237
+ id: "8",
238
+ row: { event_id: 8,
239
+ id: "batch-42",
240
+ name: "Party v2" },
241
+ pks: [{ fieldName: "event_id",
242
+ type: "number" }]
243
+ });
244
+
245
+ expect(sub().latestData).toEqual([
246
+ { event_id: 7,
247
+ id: "batch-42",
248
+ name: "Launch" },
249
+ { event_id: 8,
250
+ id: "batch-42",
251
+ name: "Party v2" }
252
+ ]);
253
+ });
254
+ });
@@ -131,7 +131,10 @@ export class SDKQueryBuilder<M extends Record<string, unknown> = Record<string,
131
131
  */
132
132
  listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void {
133
133
  if (!this.collection.listen) {
134
- throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
134
+ throw new Error(
135
+ "Listen is only available when RebaseClient is configured with a websocketUrl, " +
136
+ "and not when it was created with realtime: false."
137
+ );
135
138
  }
136
139
  return this.collection.listen(this.params, onUpdate, onError);
137
140
  }
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, afterEach } from "@jest/globals";
2
+ import { createTransport } from "./transport";
3
+
4
+ /**
5
+ * `baseUrl` is optional because the common production shape is a Rebase backend
6
+ * serving its own SPA: the API is the page's origin. These pin the resolution,
7
+ * because getting it wrong pushes apps into baking an absolute host — which
8
+ * breaks the moment a custom domain points at the same app, and which CORS
9
+ * cannot repair (a SameSite=Lax auth cookie is not sent cross-site either).
10
+ */
11
+ const setWindow = (origin?: string) => {
12
+ if (origin === undefined) { delete (globalThis as never as { window?: unknown }).window; return; }
13
+ (globalThis as never as { window: unknown }).window = { location: { origin, href: origin + "/" } };
14
+ };
15
+
16
+ afterEach(() => setWindow(undefined));
17
+
18
+ describe("transport baseUrl resolution", () => {
19
+ it("uses the page origin when unset in a browser", () => {
20
+ setWindow("https://dadaki.com");
21
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
22
+ });
23
+
24
+ it("gives callers something they can build an absolute URL from", () => {
25
+ setWindow("https://dadaki.com");
26
+ const t = createTransport({});
27
+ // The empty-string version of this threw, which is what drove apps to
28
+ // bake a hostname into the bundle.
29
+ expect(() => new URL(`${t.baseUrl}/api/functions/doc-content/get`)).not.toThrow();
30
+ });
31
+
32
+ it("follows the page, so a second hostname on the same app just works", () => {
33
+ setWindow("https://dadaki.apps.rebase.pro");
34
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.apps.rebase.pro");
35
+ setWindow("https://dadaki.com");
36
+ expect(createTransport({}).baseUrl).toBe("https://dadaki.com");
37
+ });
38
+
39
+ it("still honours an explicit baseUrl, which is what dev needs", () => {
40
+ setWindow("https://dadaki.com");
41
+ expect(createTransport({ baseUrl: "http://localhost:3001" }).baseUrl).toBe("http://localhost:3001");
42
+ });
43
+
44
+ it("strips a trailing slash so joins do not double up", () => {
45
+ setWindow(undefined);
46
+ expect(createTransport({ baseUrl: "https://api.example.com/" }).baseUrl).toBe("https://api.example.com");
47
+ });
48
+
49
+ it("stays relative off-browser, where there is no origin to borrow", () => {
50
+ setWindow(undefined);
51
+ expect(createTransport({}).baseUrl).toBe("");
52
+ });
53
+ });
package/src/transport.ts CHANGED
@@ -9,12 +9,46 @@ export { RebaseApiError } from "@rebasepro/types";
9
9
  export type { RebaseErrorInit } from "@rebasepro/types";
10
10
 
11
11
  export interface RebaseClientConfig {
12
+ /**
13
+ * Origin of the Rebase server — scheme, host and port **only**.
14
+ *
15
+ * {@link apiPath} is appended to this, so do not include it here:
16
+ * `"http://localhost:3001"` is correct, while `"http://localhost:3001/api"`
17
+ * silently builds `/api/api/…` and every request 404s. Omit entirely for
18
+ * same-origin requests from the browser.
19
+ */
12
20
  baseUrl?: string;
21
+ /**
22
+ * Bearer token sent as `Authorization` on every request.
23
+ *
24
+ * In the browser this is the signed-in user's access token, so row-level
25
+ * security applies. Server-side callers — scripts, cron jobs, ETL — pass the
26
+ * service key instead, which resolves to `{ uid: "service", roles: ["admin"] }`
27
+ * and **bypasses RLS**: there is no user to constrain those queries, so scope
28
+ * them explicitly.
29
+ */
13
30
  token?: string;
31
+ /**
32
+ * Path the API is mounted under, appended to {@link baseUrl}.
33
+ * Defaults to `"/api"`; override only if the server mounts it elsewhere.
34
+ */
14
35
  apiPath?: string;
15
36
  fetch?: typeof globalThis.fetch;
16
37
  onUnauthorized?: () => Promise<boolean>;
17
38
  websocketUrl?: string; // Optional real-time WebSocket connection
39
+ /**
40
+ * Open the realtime WebSocket. **Defaults to `true`.**
41
+ *
42
+ * The socket connects as soon as the client is constructed and keeps the
43
+ * Node event loop alive, so a one-shot script (CLI, cron job, ETL) will not
44
+ * exit on its own. Set this to `false` for any process that reads or writes
45
+ * and then terminates — `.listen()` and `.listenById()` then throw instead
46
+ * of silently doing nothing.
47
+ *
48
+ * Long-lived processes that do want realtime can instead call
49
+ * `client.close()` when shutting down.
50
+ */
51
+ realtime?: boolean;
18
52
  }
19
53
 
20
54
  /**
@@ -78,6 +112,29 @@ export interface Transport {
78
112
  resolveToken: () => Promise<string | null>;
79
113
  }
80
114
 
115
+ /**
116
+ * The base every request and every caller-built URL resolves against.
117
+ *
118
+ * `baseUrl` is optional because the common production shape is a Rebase
119
+ * backend serving its own SPA, where the API is simply the page's origin.
120
+ * Leaving it unset is therefore the *correct* configuration there — and the
121
+ * one that keeps working when a second hostname (a custom domain) points at
122
+ * the same app.
123
+ *
124
+ * When unset in a browser this resolves to the page origin rather than "".
125
+ * Requests behave identically either way, but the empty string is a trap for
126
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
127
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
128
+ * which is exactly what breaks the day a custom domain is added, and which no
129
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
130
+ * not sent cross-site either.
131
+ */
132
+ function resolveBaseUrl(configured?: string): string {
133
+ if (configured) return configured.replace(/\/$/, "");
134
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
135
+ return "";
136
+ }
137
+
81
138
  export function createTransport(config: RebaseClientConfig): Transport {
82
139
  const fetchFn = config.fetch || globalThis.fetch;
83
140
  const apiPath = config.apiPath || "/api";
@@ -94,8 +151,7 @@ export function createTransport(config: RebaseClientConfig): Transport {
94
151
  }
95
152
 
96
153
  async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
97
- const base = config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
98
- const url = base + apiPath + path;
154
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
99
155
 
100
156
  let activeToken = token;
101
157
  if (tokenGetter) {
@@ -208,7 +264,7 @@ headers: retryHeaders });
208
264
  setToken(newToken: string | null) { token = newToken || undefined; },
209
265
  setAuthTokenGetter(getter: () => Promise<string | null>) { tokenGetter = getter; },
210
266
  setOnUnauthorized(handler: () => Promise<boolean>) { onUnauthorizedHandler = handler; },
211
- get baseUrl() { return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : ""; },
267
+ get baseUrl() { return resolveBaseUrl(config.baseUrl); },
212
268
  get apiPath() { return apiPath; },
213
269
  get fetchFn() { return fetchFn; },
214
270
  getHeaders: (init?: RequestInit) => getHeaders(token, init) as Record<string, string>,