@rebasepro/client 0.13.0 → 0.13.1-canary.g3660bd5

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
@@ -238,35 +238,48 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
238
238
  /**
239
239
  * Derive a WebSocket URL from an HTTP base URL.
240
240
  * `http://` → `ws://`, `https://` → `wss://`.
241
+ *
242
+ * A backend mounted under a path is the reason `baseUrl` accepts one, so the
243
+ * path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
244
+ * relative one — resolved through `.origin` — so one deployment dialled two
245
+ * different sockets depending on whether its config said `"/backend"` or
246
+ * `"https://app.example.com/backend"`.
247
+ *
248
+ * Returns `""` when there is nothing to resolve against: a relative `baseUrl`
249
+ * outside a browser has no origin, and inventing one would dial somewhere
250
+ * arbitrary. The caller warns rather than leaving that silent.
241
251
  */
242
252
  function deriveWebSocketUrl(baseUrl?: string): string {
253
+ const toWsProtocol = (url: string): string => {
254
+ const secure = /^(https|wss):/i.test(url);
255
+ return url
256
+ .replace(/^https?:\/\//i, secure ? "wss://" : "ws://")
257
+ .replace(/^wss?:\/\//i, secure ? "wss://" : "ws://")
258
+ .replace(/\/$/, "");
259
+ };
260
+
243
261
  if (typeof window !== "undefined") {
244
- let absoluteUrl = "";
262
+ let absoluteUrl: string;
245
263
  if (!baseUrl) {
246
264
  absoluteUrl = window.location.origin;
247
265
  } else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) {
248
266
  absoluteUrl = baseUrl;
249
267
  } else {
250
268
  try {
251
- absoluteUrl = new URL(baseUrl, window.location.href).origin;
269
+ const resolved = new URL(baseUrl, window.location.href);
270
+ absoluteUrl = resolved.origin + resolved.pathname;
252
271
  } catch {
253
272
  absoluteUrl = window.location.origin;
254
273
  }
255
274
  }
256
- const protocol = absoluteUrl.startsWith("https:") || absoluteUrl.startsWith("wss:") ? "wss:" : "ws:";
257
- return absoluteUrl
258
- .replace(/^https?:\/\//i, `${protocol}//`)
259
- .replace(/^wss?:\/\//i, `${protocol}//`)
260
- .replace(/\/$/, "");
275
+ return toWsProtocol(absoluteUrl);
261
276
  }
262
277
 
263
278
  if (!baseUrl) return "";
264
279
  if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) {
265
280
  return "";
266
281
  }
267
- return baseUrl
268
- .replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://")
269
- .replace(/\/$/, "");
282
+ return toWsProtocol(baseUrl);
270
283
  }
271
284
 
272
285
  export function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB> {
@@ -332,6 +345,24 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
332
345
  ? (options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl))
333
346
  : undefined;
334
347
 
348
+ // Realtime is on unless it was switched off, so "on, but no URL could be
349
+ // derived" is a misconfiguration and not a choice. It used to be silent:
350
+ // the client simply had no socket, `observe()` quietly degraded to a
351
+ // one-shot fetch, and `realtime.channel()` blamed `realtime: false` — an
352
+ // option the caller had not passed.
353
+ const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
354
+ const unreachableReason =
355
+ "no WebSocket URL could be derived from baseUrl " +
356
+ `${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin ` +
357
+ "to resolve a relative URL against. Pass an absolute `baseUrl`, set `websocketUrl` " +
358
+ "explicitly, or pass `realtime: false` to say this was intended.";
359
+ if (realtimeUnreachable) {
360
+ console.warn(
361
+ `[Rebase] Realtime is enabled but ${unreachableReason} ` +
362
+ "Live queries will fall back to a single fetch and channels will throw."
363
+ );
364
+ }
365
+
335
366
  let ws: RebaseWebSocketClient | undefined;
336
367
  /** One channel object per name — see `realtime.channel`. */
337
368
  const realtimeChannels = new Map<string, RebaseRealtimeChannel>();
@@ -529,13 +560,15 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
529
560
  * cut off the others.
530
561
  */
531
562
  channel: (name: string, options?: ChannelOptions): RebaseRealtimeChannel => {
532
- // Only `realtime: false` gets here — a hard opt-out, so this
533
- // stays an error. Being merely *unconnected* does not: the
534
- // socket opens on the first channel operation, which is the
535
- // whole point of asking for a channel before you use one.
563
+ // Being merely *unconnected* is not an error: the socket opens
564
+ // on the first channel operation, which is the whole point of
565
+ // asking for a channel before you use one. Having no socket at
566
+ // all is, and there are two reasons for it — say which.
536
567
  if (!ws) {
537
568
  throw new RebaseClientError(
538
- "Realtime is disabled on this client (realtime: false), so channels are unavailable."
569
+ realtimeUnreachable
570
+ ? `Realtime is enabled but ${unreachableReason}`
571
+ : "Realtime is disabled on this client (realtime: false), so channels are unavailable."
539
572
  );
540
573
  }
541
574
  let existing = realtimeChannels.get(name);
@@ -10,7 +10,8 @@ import {
10
10
  runLocalQuery,
11
11
  sortRows
12
12
  } from "./offline-query";
13
- import { EntityRelation } from "@rebasepro/types";
13
+ import { DEFAULT_LIST_LIMIT, EntityRelation } from "@rebasepro/types";
14
+ import { resolveFindWindow } from "@rebasepro/common";
14
15
  import type { FindParams } from "./transport";
15
16
 
16
17
  type Row = Record<string, unknown>;
@@ -172,12 +173,37 @@ describe("local query engine", () => {
172
173
 
173
174
  describe("pagination", () => {
174
175
  it("resolves page over offset the way the server does", () => {
175
- expect(resolvePagination()).toEqual({ limit: 20, offset: 0 });
176
176
  expect(resolvePagination({ limit: 10, offset: 30 })).toEqual({ limit: 10, offset: 30 });
177
177
  expect(resolvePagination({ limit: 10, page: 3 })).toEqual({ limit: 10, offset: 20 });
178
178
  // `page` wins over `offset`, and the first page is not negative.
179
179
  expect(resolvePagination({ limit: 10, page: 1, offset: 99 })).toEqual({ limit: 10, offset: 0 });
180
180
  });
181
+
182
+ it("defaults to the same page size the server would apply", () => {
183
+ // Asserted against the constant, not against a number written here.
184
+ // This test used to say `{ limit: 20 }` — restating a default this
185
+ // module had invented, while `/api/data` paged by 50. The local
186
+ // answer and the network answer to one `observe()` were therefore
187
+ // different lengths, and the test agreed with the wrong one.
188
+ expect(resolvePagination()).toEqual({ limit: DEFAULT_LIST_LIMIT, offset: 0 });
189
+ });
190
+
191
+ it("agrees with the shared resolver on every shape of window", () => {
192
+ // The one that matters: the local evaluator and every other
193
+ // transport must resolve identical windows, or a cached list and a
194
+ // fetched list disagree about which rows page two holds.
195
+ for (const params of [
196
+ undefined,
197
+ { limit: 10 },
198
+ { offset: 30 },
199
+ { page: 4 },
200
+ { limit: 25, page: 2 },
201
+ { limit: 10, page: 1, offset: 99 }
202
+ ]) {
203
+ const { limit, offset } = resolveFindWindow(params);
204
+ expect(resolvePagination(params)).toEqual({ limit, offset });
205
+ }
206
+ });
181
207
  });
182
208
 
183
209
  describe("runLocalQuery", () => {
@@ -9,6 +9,7 @@ import {
9
9
  toCanonicalOp
10
10
  } from "@rebasepro/types";
11
11
  import { FindParams } from "./transport";
12
+ import { resolveFindWindow } from "@rebasepro/common";
12
13
 
13
14
  /**
14
15
  * A local evaluator for `FindParams`, so cached rows can answer a query the
@@ -38,8 +39,15 @@ const collator = typeof Intl !== "undefined" && typeof Intl.Collator === "functi
38
39
  ? new Intl.Collator(undefined, { numeric: false, sensitivity: "variant" })
39
40
  : undefined;
40
41
 
41
- /** The server's page size when the caller does not ask for one. */
42
- export const DEFAULT_PAGE_SIZE = 20;
42
+ /**
43
+ * The server's page size when the caller does not ask for one.
44
+ *
45
+ * Re-exported rather than redeclared. This was its own `= 20` — a third
46
+ * constant of this name in the workspace, next to `@rebasepro/common`'s 200 and
47
+ * the 50 the REST layer actually applies — and a local copy of a number that
48
+ * belongs to another process is a number that goes stale silently.
49
+ */
50
+ export { DEFAULT_LIST_LIMIT as DEFAULT_PAGE_SIZE } from "@rebasepro/types";
43
51
 
44
52
  function isNullish(value: unknown): boolean {
45
53
  return value === null || value === undefined;
@@ -311,12 +319,17 @@ function tiebreak(a: Record<string, unknown>, b: Record<string, unknown>): numbe
311
319
  return cmp ?? 0;
312
320
  }
313
321
 
314
- /** Resolve `page`/`offset`/`limit` the way the server does. */
322
+ /**
323
+ * Resolve `page`/`offset`/`limit` the way the server does.
324
+ *
325
+ * It did not: this defaulted an absent limit to 20 while `/api/data` pages by
326
+ * 50, so the same `observe()` answered with 20 rows from the local database and
327
+ * 50 from the network — a list that changed length depending on which side
328
+ * answered, with `page` striding differently on each. Delegated now, so the
329
+ * sentence above is true by construction rather than by agreement.
330
+ */
315
331
  export function resolvePagination(params?: FindParams): { limit: number; offset: number } {
316
- const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
317
- const offset = params?.page != null
318
- ? Math.max(0, (params.page - 1) * limit)
319
- : (params?.offset ?? 0);
332
+ const { limit, offset } = resolveFindWindow(params);
320
333
  return { limit, offset };
321
334
  }
322
335
 
package/src/offline.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  isExactlyEvaluable,
17
17
  matchesParams,
18
+ resolvePagination,
18
19
  runLocalQuery,
19
20
  sortRows
20
21
  } from "./offline-query";
@@ -942,7 +943,7 @@ export class OfflineManager {
942
943
  if (!state) {
943
944
  return {
944
945
  data: [],
945
- meta: { total: 0, limit: params?.limit ?? 20, offset: params?.offset ?? 0, hasMore: false },
946
+ meta: { ...resolvePagination(params), total: 0, hasMore: false },
946
947
  fromCache: true,
947
948
  hasPendingWrites: false,
948
949
  partial: true
@@ -1150,12 +1151,13 @@ export class OfflineManager {
1150
1151
  }
1151
1152
 
1152
1153
  private recordSnapshot(slug: string, params: FindParams | undefined, result: FindResult<AnyRow>): QuerySnapshot {
1153
- const meta = result.meta ?? { total: result.data?.length ?? 0, limit: 20, offset: 0, hasMore: false };
1154
+ const window = resolvePagination(params);
1155
+ const meta = result.meta ?? { total: result.data?.length ?? 0, ...window, hasMore: false };
1154
1156
  const snapshot: QuerySnapshot = {
1155
1157
  ids: (result.data ?? []).map((row) => row.id as string | number).filter((id) => id !== undefined),
1156
1158
  total: meta.total ?? result.data?.length ?? 0,
1157
- limit: meta.limit ?? params?.limit ?? 20,
1158
- offset: meta.offset ?? params?.offset ?? 0,
1159
+ limit: meta.limit ?? window.limit,
1160
+ offset: meta.offset ?? window.offset,
1159
1161
  hasMore: meta.hasMore ?? false
1160
1162
  };
1161
1163
  const state = this.collectionState(slug);
@@ -50,7 +50,7 @@ describe("realtime opt-out", () => {
50
50
  const { FakeWebSocket, opened } = trackingWebSocket();
51
51
  globalThis.WebSocket = FakeWebSocket;
52
52
 
53
- createRebaseClient({ baseUrl: "http://localhost:3000/api" });
53
+ createRebaseClient({ baseUrl: "http://localhost:3000" });
54
54
 
55
55
  expect(opened).toHaveLength(0);
56
56
  });
@@ -59,7 +59,7 @@ describe("realtime opt-out", () => {
59
59
  const { FakeWebSocket, opened } = trackingWebSocket();
60
60
  globalThis.WebSocket = FakeWebSocket;
61
61
 
62
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
62
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
63
63
 
64
64
  // Asking for the channel is not using it.
65
65
  const channel = client.realtime.channel("doc:1");
@@ -73,7 +73,7 @@ describe("realtime opt-out", () => {
73
73
  const { FakeWebSocket, opened } = trackingWebSocket();
74
74
  globalThis.WebSocket = FakeWebSocket;
75
75
 
76
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
76
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
77
77
  expect(opened).toHaveLength(0);
78
78
 
79
79
  client.collection("posts").listen!(undefined, () => { /* noop */ });
@@ -85,7 +85,7 @@ describe("realtime opt-out", () => {
85
85
  const { FakeWebSocket, opened } = trackingWebSocket();
86
86
  globalThis.WebSocket = FakeWebSocket;
87
87
 
88
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
88
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
89
89
 
90
90
  client.collection("posts").listen!(undefined, () => { /* noop */ });
91
91
  client.collection("authors").listen!(undefined, () => { /* noop */ });
@@ -102,7 +102,7 @@ describe("realtime opt-out", () => {
102
102
  const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
103
103
  const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* capture */ });
104
104
 
105
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
105
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
106
106
  void client.realtime.channel("doc:1"); // obtained, never used
107
107
 
108
108
  expect(opened).toHaveLength(0);
@@ -118,7 +118,7 @@ describe("realtime opt-out", () => {
118
118
  delete globalThis.WebSocket;
119
119
  const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* capture */ });
120
120
 
121
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
121
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
122
122
  expect(warn).not.toHaveBeenCalled();
123
123
 
124
124
  void client.realtime.channel("doc:1").join();
@@ -134,7 +134,7 @@ describe("realtime opt-out", () => {
134
134
  globalThis.WebSocket = FakeWebSocket;
135
135
 
136
136
  const client = createRebaseClient({
137
- baseUrl: "http://localhost:3000/api",
137
+ baseUrl: "http://localhost:3000",
138
138
  realtime: false
139
139
  });
140
140
 
@@ -148,7 +148,7 @@ describe("realtime opt-out", () => {
148
148
  globalThis.WebSocket = FakeWebSocket;
149
149
 
150
150
  createRebaseClient({
151
- baseUrl: "http://localhost:3000/api",
151
+ baseUrl: "http://localhost:3000",
152
152
  websocketUrl: "ws://localhost:3000",
153
153
  realtime: false
154
154
  });
@@ -160,7 +160,7 @@ describe("realtime opt-out", () => {
160
160
  const { FakeWebSocket, opened, closed } = trackingWebSocket();
161
161
  globalThis.WebSocket = FakeWebSocket;
162
162
 
163
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
163
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
164
164
  client.collection("posts").listen!(undefined, () => { /* noop */ });
165
165
  expect(opened).toHaveLength(1);
166
166
 
@@ -176,7 +176,7 @@ describe("realtime opt-out", () => {
176
176
  const { FakeWebSocket, opened } = trackingWebSocket();
177
177
  globalThis.WebSocket = FakeWebSocket;
178
178
 
179
- const client = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
179
+ const client = createRebaseClient({ baseUrl: "http://localhost:3000" });
180
180
  client.collection("posts").listen!(undefined, () => { /* noop */ });
181
181
  expect(opened).toHaveLength(1);
182
182
 
@@ -190,10 +190,10 @@ describe("realtime opt-out", () => {
190
190
  const { FakeWebSocket } = trackingWebSocket();
191
191
  globalThis.WebSocket = FakeWebSocket;
192
192
 
193
- const offline = createRebaseClient({ baseUrl: "http://localhost:3000/api", realtime: false });
193
+ const offline = createRebaseClient({ baseUrl: "http://localhost:3000", realtime: false });
194
194
  expect(() => offline.close()).not.toThrow();
195
195
 
196
- const live = createRebaseClient({ baseUrl: "http://localhost:3000/api" });
196
+ const live = createRebaseClient({ baseUrl: "http://localhost:3000" });
197
197
  live.close();
198
198
  expect(() => live.close()).not.toThrow();
199
199
  });
@@ -205,7 +205,7 @@ describe("realtime opt-out", () => {
205
205
  globalThis.WebSocket = FakeWebSocket;
206
206
 
207
207
  const client = createRebaseClient({
208
- baseUrl: "http://localhost:3000/api",
208
+ baseUrl: "http://localhost:3000",
209
209
  realtime: false
210
210
  });
211
211
 
@@ -244,7 +244,7 @@ isAnonymous: false }
244
244
  })) as unknown as typeof globalThis.fetch;
245
245
 
246
246
  const client = createRebaseClient({
247
- baseUrl: "http://localhost:3000/api",
247
+ baseUrl: "http://localhost:3000",
248
248
  fetch: fetchMock
249
249
  });
250
250
 
@@ -265,7 +265,7 @@ isAnonymous: false }
265
265
  globalThis.WebSocket = FakeWebSocket;
266
266
 
267
267
  const client = createRebaseClient({
268
- baseUrl: "http://localhost:3000/api",
268
+ baseUrl: "http://localhost:3000",
269
269
  realtime: false
270
270
  });
271
271
 
@@ -0,0 +1,92 @@
1
+ import { jest } from "@jest/globals";
2
+ import { RebaseWebSocketClient } from "./websocket";
3
+ import { or, cond } from "@rebasepro/common";
4
+
5
+ /**
6
+ * Two subscriptions are the same subscription only when they ask for the same
7
+ * rows. The key that decides this was built from a hand-listed subset of the
8
+ * props, so any field left off it made two different queries collide — and the
9
+ * second listener was handed the first one's rows, which is worse than not
10
+ * subscribing at all.
11
+ */
12
+
13
+ function fakeSocket() {
14
+ const sent: Record<string, unknown>[] = [];
15
+
16
+ class FakeWS {
17
+ static readonly OPEN = 1;
18
+ readyState = 1;
19
+ onopen: (() => void) | null = null;
20
+ onclose: (() => void) | null = null;
21
+ onerror: (() => void) | null = null;
22
+ onmessage: ((event: { data: string }) => void) | null = null;
23
+
24
+ constructor(public url: string) {
25
+ setTimeout(() => this.onopen?.(), 0);
26
+ }
27
+
28
+ send(raw: string) {
29
+ const message = JSON.parse(raw) as Record<string, unknown>;
30
+ sent.push(message);
31
+ if (message.type === "AUTHENTICATE") {
32
+ setTimeout(() => this.onmessage?.({
33
+ data: JSON.stringify({ type: "AUTH_SUCCESS", requestId: message.requestId })
34
+ }), 0);
35
+ }
36
+ }
37
+
38
+ close() { /* noop */ }
39
+ }
40
+
41
+ return { FakeWS: FakeWS as unknown as typeof WebSocket, sent };
42
+ }
43
+
44
+ function subscribeFrames(sent: Record<string, unknown>[]) {
45
+ return sent.filter((m) => m.type === "subscribe_collection");
46
+ }
47
+
48
+ describe("collection subscription identity", () => {
49
+ beforeEach(() => jest.useFakeTimers());
50
+ afterEach(() => jest.useRealTimers());
51
+
52
+ async function twoSubscriptions(
53
+ a: Record<string, unknown>,
54
+ b: Record<string, unknown>
55
+ ) {
56
+ const { FakeWS, sent } = fakeSocket();
57
+ const client = new RebaseWebSocketClient({
58
+ websocketUrl: "ws://localhost:1234",
59
+ WebSocket: FakeWS,
60
+ getAuthToken: async () => "token"
61
+ });
62
+ client.listenCollection({ path: "posts", ...a } as never, () => {});
63
+ client.listenCollection({ path: "posts", ...b } as never, () => {});
64
+ await jest.advanceTimersByTimeAsync(50);
65
+ return subscribeFrames(sent);
66
+ }
67
+
68
+ it("treats different offsets as different subscriptions", async () => {
69
+ // Page one and page two of the same live list. Sharing a subscription
70
+ // here shows page one's rows on page two.
71
+ const frames = await twoSubscriptions({ limit: 10, offset: 0 }, { limit: 10, offset: 10 });
72
+ expect(frames).toHaveLength(2);
73
+ });
74
+
75
+ it("treats different logical groups as different subscriptions", async () => {
76
+ const frames = await twoSubscriptions(
77
+ { logical: or(cond("status", "==", "draft")) },
78
+ { logical: or(cond("status", "==", "published")) }
79
+ );
80
+ expect(frames).toHaveLength(2);
81
+ });
82
+
83
+ it("still shares one subscription for genuinely identical queries", async () => {
84
+ // The de-duplication itself is the point of the key: two components
85
+ // watching the same list must not open two server subscriptions.
86
+ const frames = await twoSubscriptions(
87
+ { limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) },
88
+ { limit: 10, offset: 10, logical: or(cond("status", "==", "draft")) }
89
+ );
90
+ expect(frames).toHaveLength(1);
91
+ });
92
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from "@jest/globals";
1
+ import { describe, it, expect, afterEach, jest } from "@jest/globals";
2
2
  import { createTransport } from "./transport";
3
3
 
4
4
  /**
@@ -51,3 +51,51 @@ describe("transport baseUrl resolution", () => {
51
51
  expect(createTransport({}).baseUrl).toBe("");
52
52
  });
53
53
  });
54
+
55
+ describe("baseUrl that already contains the apiPath", () => {
56
+ /**
57
+ * The docblock on `baseUrl` says this "silently builds `/api/api/…` and
58
+ * every request 404s" — a failure mode understood well enough to be written
59
+ * down, and still left to be discovered at runtime. This package's own
60
+ * tests configured it that way a dozen times over, which is about as clear
61
+ * a signal as a trap gets.
62
+ */
63
+ it("warns rather than silently building /api/api", () => {
64
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
65
+ createTransport({ baseUrl: "http://localhost:3000/api" });
66
+
67
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("/api/api"));
68
+ warn.mockRestore();
69
+ });
70
+
71
+ it("warns for a custom apiPath too, and tolerates a trailing slash", () => {
72
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
73
+ createTransport({ baseUrl: "https://api.example.com/v2/", apiPath: "/v2" });
74
+
75
+ expect(warn).toHaveBeenCalledTimes(1);
76
+ warn.mockRestore();
77
+ });
78
+
79
+ it("says nothing about a baseUrl that merely ends in a similar word", () => {
80
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
81
+ createTransport({ baseUrl: "https://rapid.example.com" });
82
+ createTransport({ baseUrl: "https://example.com/myapi" });
83
+ createTransport({ baseUrl: "https://example.com" });
84
+
85
+ expect(warn).not.toHaveBeenCalled();
86
+ warn.mockRestore();
87
+ });
88
+ });
89
+
90
+ describe("storageUrlOrigin that already contains the apiPath", () => {
91
+ it("warns, because storage composes it the same way baseUrl is composed", () => {
92
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
93
+ createTransport({
94
+ baseUrl: "https://example.com",
95
+ storageUrlOrigin: "https://files.example.com/api"
96
+ });
97
+
98
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("storageUrlOrigin"));
99
+ warn.mockRestore();
100
+ });
101
+ });
package/src/transport.ts CHANGED
@@ -259,6 +259,28 @@ function resolveBaseUrl(configured?: string): string {
259
259
  export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
260
260
  const fetchFn = config.fetch || globalThis.fetch;
261
261
  const apiPath = config.apiPath || "/api";
262
+
263
+ // `apiPath` is appended to `baseUrl`, so a `baseUrl` that already ends in it
264
+ // builds `/api/api/…` and every request 404s. That was documented on
265
+ // `baseUrl` and left to be discovered at runtime — including by this
266
+ // package's own tests, which configured it that way a dozen times. A 404 on
267
+ // every call looks like a server that is down, not like a doubled path.
268
+ // `storageUrlOrigin` is checked alongside it because `storage.ts` composes
269
+ // it the same way — `${storageUrlOrigin ?? baseUrl}${apiPath}` — and its own
270
+ // docblock carries the same "no path" caveat.
271
+ for (const field of ["baseUrl", "storageUrlOrigin"] as const) {
272
+ const value = config[field];
273
+ if (!value || !apiPath) continue;
274
+ const trimmed = value.replace(/\/+$/, "");
275
+ if (!trimmed.endsWith(apiPath)) continue;
276
+ console.warn(
277
+ `[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ` +
278
+ `${JSON.stringify(apiPath)}, which is appended to it — requests will go to ` +
279
+ `${trimmed}${apiPath}/… and 404. Pass the origin only ` +
280
+ `(${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set ` +
281
+ "`apiPath` if the server really does mount the API one level deeper."
282
+ );
283
+ }
262
284
  let token = config.token;
263
285
  let tokenGetter: (() => Promise<string | null>) | undefined;
264
286
  let onUnauthorizedHandler = config.onUnauthorized;
@@ -0,0 +1,97 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createRebaseClient } from "./index";
3
+
4
+ /**
5
+ * Where the realtime socket dials, given a `baseUrl`.
6
+ *
7
+ * The socket URL is derived rather than configured, so every disagreement
8
+ * between how it is derived and where the API actually lives shows up only as
9
+ * realtime not working — never as an error naming a cause.
10
+ */
11
+
12
+ function setWindow(origin: string | undefined) {
13
+ if (origin === undefined) {
14
+ delete (globalThis as never as { window?: unknown }).window;
15
+ return;
16
+ }
17
+ (globalThis as never as { window: unknown }).window = {
18
+ location: { origin, href: origin + "/" }
19
+ };
20
+ }
21
+
22
+ function trackingWebSocket() {
23
+ const opened: string[] = [];
24
+ class FakeWebSocket {
25
+ static readonly OPEN = 1;
26
+ readyState = 0;
27
+ onopen: (() => void) | null = null;
28
+ onclose: (() => void) | null = null;
29
+ onerror: (() => void) | null = null;
30
+ onmessage: (() => void) | null = null;
31
+ constructor(public url: string) { opened.push(url); }
32
+ close() { /* no-op */ }
33
+ send() { /* no-op */ }
34
+ }
35
+ return { FakeWebSocket: FakeWebSocket as unknown as typeof WebSocket, opened };
36
+ }
37
+
38
+ describe("realtime socket URL", () => {
39
+ const originalWebSocket = globalThis.WebSocket;
40
+
41
+ afterEach(() => {
42
+ globalThis.WebSocket = originalWebSocket;
43
+ setWindow(undefined);
44
+ jest.restoreAllMocks();
45
+ });
46
+
47
+ it("keeps the path of a relative baseUrl, as it does for an absolute one", () => {
48
+ // A backend mounted under a path is the reason `baseUrl` accepts one.
49
+ // Resolving the relative form to `.origin` dropped it, so the same
50
+ // deployment dialled two different URLs depending on whether its
51
+ // baseUrl was written as "/backend" or "https://app.example.com/backend".
52
+ const { FakeWebSocket, opened } = trackingWebSocket();
53
+ globalThis.WebSocket = FakeWebSocket;
54
+ setWindow("https://app.example.com");
55
+
56
+ const relative = createRebaseClient({ baseUrl: "/backend" });
57
+ relative.collection("posts").listen!(undefined, () => {});
58
+
59
+ const absolute = createRebaseClient({ baseUrl: "https://app.example.com/backend" });
60
+ absolute.collection("posts").listen!(undefined, () => {});
61
+
62
+ expect(opened[0]).toBe("wss://app.example.com/backend");
63
+ expect(opened[0]).toBe(opened[1]);
64
+ });
65
+
66
+ it("dials the page origin when no baseUrl is set in a browser", () => {
67
+ const { FakeWebSocket, opened } = trackingWebSocket();
68
+ globalThis.WebSocket = FakeWebSocket;
69
+ setWindow("https://app.example.com");
70
+
71
+ const client = createRebaseClient({});
72
+ client.collection("posts").listen!(undefined, () => {});
73
+
74
+ expect(opened[0]).toBe("wss://app.example.com");
75
+ });
76
+
77
+ it("says why realtime is unavailable when no URL could be derived", () => {
78
+ // Node, with a relative baseUrl there is nothing to resolve against.
79
+ // The client silently had no socket, and `realtime.channel()` then
80
+ // blamed `realtime: false` — an option the caller never passed.
81
+ setWindow(undefined);
82
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
83
+ const client = createRebaseClient({ baseUrl: "/backend" });
84
+
85
+ expect(client.ws).toBeUndefined();
86
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("websocketUrl"));
87
+ expect(() => client.realtime.channel("room")).toThrow(/no WebSocket URL could be derived/i);
88
+ // It may still *suggest* `realtime: false` as the way to make this
89
+ // deliberate; what it must not do is claim that is what happened.
90
+ expect(() => client.realtime.channel("room")).not.toThrow(/disabled on this client/);
91
+ });
92
+
93
+ it("still blames the opt-out when realtime really was switched off", () => {
94
+ const client = createRebaseClient({ baseUrl: "https://api.example.com", realtime: false });
95
+ expect(() => client.realtime.channel("room")).toThrow(/realtime: false/);
96
+ });
97
+ });