@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/dist/index.es.js +93 -58
- package/dist/index.es.js.map +1 -1
- package/dist/offline-query.d.ts +18 -3
- package/package.json +4 -4
- package/src/auth-listener-errors.test.ts +57 -0
- package/src/auth.ts +10 -1
- package/src/collection-listen-meta.test.ts +105 -0
- package/src/collection-observe.test.ts +138 -0
- package/src/collection.ts +88 -46
- package/src/index.ts +48 -15
- package/src/offline-query.test.ts +28 -2
- package/src/offline-query.ts +20 -7
- package/src/offline.ts +6 -4
- package/src/realtime-optout.test.ts +15 -15
- package/src/realtime-subscription-key.test.ts +92 -0
- package/src/transport-baseurl.test.ts +49 -1
- package/src/transport.ts +22 -0
- package/src/websocket-url.test.ts +97 -0
- package/src/websocket.ts +18 -9
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
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
|
-
|
|
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", () => {
|
package/src/offline-query.ts
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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
|
-
/**
|
|
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
|
|
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: {
|
|
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
|
|
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 ??
|
|
1158
|
-
offset: meta.offset ??
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|