@rebasepro/client 0.12.1-canary.gf5f1d39 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +3 -5
- package/dist/index.d.ts +11 -7
- package/dist/index.es.js +121 -20
- package/dist/index.es.js.map +1 -1
- package/dist/websocket.d.ts +19 -0
- package/package.json +8 -8
- package/src/auth.ts +30 -5
- package/src/client-close.test.ts +80 -0
- package/src/collection.test.ts +50 -10
- package/src/collection.ts +7 -1
- package/src/data-proxy.test.ts +22 -6
- package/src/functions.ts +9 -1
- package/src/index.ts +25 -11
- package/src/offline-idb-store.test.ts +23 -4
- package/src/realtime-concurrent-subscribe.test.ts +102 -0
- package/src/realtime-optout.test.ts +38 -4
- package/src/transport.ts +43 -0
- package/src/websocket.ts +82 -12
package/dist/websocket.d.ts
CHANGED
|
@@ -27,6 +27,17 @@ export declare class RebaseWebSocketClient {
|
|
|
27
27
|
private channelHandlers;
|
|
28
28
|
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
29
29
|
private closedByCaller;
|
|
30
|
+
/**
|
|
31
|
+
* Set when the backoff budget ran out, cleared by anything that earns a
|
|
32
|
+
* fresh one.
|
|
33
|
+
*
|
|
34
|
+
* Unlike {@link closedByCaller} this is not final — nobody *asked* for the
|
|
35
|
+
* socket to stay down. Five attempts with exponential backoff is about a
|
|
36
|
+
* minute, which a laptop lid, a wifi handover or a backend rollout all
|
|
37
|
+
* exceed routinely; treating that as permanent meant realtime silently
|
|
38
|
+
* stopped for the rest of the page's life, with a reload the only cure.
|
|
39
|
+
*/
|
|
40
|
+
private gaveUp;
|
|
30
41
|
/**
|
|
31
42
|
* Whether a socket exists at all (open or still opening).
|
|
32
43
|
*
|
|
@@ -68,6 +79,13 @@ export declare class RebaseWebSocketClient {
|
|
|
68
79
|
* re-entrant, since the reconnect path has always called it.
|
|
69
80
|
*/
|
|
70
81
|
ensureConnected(): void;
|
|
82
|
+
/**
|
|
83
|
+
* The browser says the network is back — the usual reason the budget ran
|
|
84
|
+
* out in the first place. Registered lazily so a Node client, or a page
|
|
85
|
+
* that never subscribes, adds no listener.
|
|
86
|
+
*/
|
|
87
|
+
private installOnlineListener;
|
|
88
|
+
private onlineListener;
|
|
71
89
|
/**
|
|
72
90
|
* Authenticate the WebSocket connection
|
|
73
91
|
*/
|
|
@@ -97,6 +115,7 @@ export declare class RebaseWebSocketClient {
|
|
|
97
115
|
private resubscribeAfterAuthRefresh;
|
|
98
116
|
private handleWebSocketMessage;
|
|
99
117
|
private ensureAuthenticated;
|
|
118
|
+
private runAuthentication;
|
|
100
119
|
reauthenticate(): Promise<void>;
|
|
101
120
|
/**
|
|
102
121
|
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/client",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"description": "HTTP SDK client for the Rebase custom backend",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,23 +29,23 @@
|
|
|
29
29
|
"./package.json": "./package.json"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@rebasepro/common": "0.
|
|
33
|
-
"@rebasepro/
|
|
34
|
-
"@rebasepro/
|
|
32
|
+
"@rebasepro/common": "0.13.0",
|
|
33
|
+
"@rebasepro/utils": "0.13.0",
|
|
34
|
+
"@rebasepro/types": "0.13.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@jest/globals": "^30.4.1",
|
|
38
38
|
"@types/jest": "^30.0.0",
|
|
39
|
-
"@types/node": "^
|
|
39
|
+
"@types/node": "^26.1.2",
|
|
40
40
|
"@types/ws": "^8.18.1",
|
|
41
41
|
"cross-env": "^10.1.0",
|
|
42
42
|
"fake-indexeddb": "^6.2.5",
|
|
43
43
|
"jest": "^30.4.2",
|
|
44
|
-
"ts-jest": "^29.4.
|
|
44
|
+
"ts-jest": "^29.4.12",
|
|
45
45
|
"tsd": "^0.33.0",
|
|
46
46
|
"typescript": "^6.0.3",
|
|
47
|
-
"vite": "^8.
|
|
48
|
-
"ws": "^8.21.
|
|
47
|
+
"vite": "^8.1.5",
|
|
48
|
+
"ws": "^8.21.1"
|
|
49
49
|
},
|
|
50
50
|
"files": [
|
|
51
51
|
"dist",
|
package/src/auth.ts
CHANGED
|
@@ -4,11 +4,6 @@ import type { AuthChangeEvent, RebaseSession, AuthTokens, DeviceSession, User }
|
|
|
4
4
|
// Re-export canonical types so `import { RebaseSession } from "@rebasepro/client"` keeps working
|
|
5
5
|
export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
|
|
6
6
|
|
|
7
|
-
/** @deprecated Use `User` from `@rebasepro/types` instead. */
|
|
8
|
-
export type RebaseUser = User;
|
|
9
|
-
/** @deprecated Use `AuthTokens` from `@rebasepro/types` instead. */
|
|
10
|
-
export type RebaseTokens = AuthTokens;
|
|
11
|
-
|
|
12
7
|
/** Minimal, non-sensitive user profile returned by {@link findUserByEmail}. */
|
|
13
8
|
export interface PublicUserProfile {
|
|
14
9
|
uid: string;
|
|
@@ -268,6 +263,30 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
|
268
263
|
refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
|
|
269
264
|
}
|
|
270
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Stop the scheduled token refresh, leaving the session itself alone.
|
|
268
|
+
*
|
|
269
|
+
* This is teardown, not sign-out. `scheduleRefresh` arms an ordinary
|
|
270
|
+
* `setTimeout` up to a token lifetime away, and it is not `unref`'d — so on
|
|
271
|
+
* Node it holds the event loop open by itself. `client.close()` promised
|
|
272
|
+
* that "a script that does not call this will not exit on its own", which
|
|
273
|
+
* was true, while the converse it plainly implies was not: a signed-in
|
|
274
|
+
* client that closed its socket still hung, because this timer outlived it.
|
|
275
|
+
* Any script, cron handler or job that signs in hit that.
|
|
276
|
+
*
|
|
277
|
+
* Deliberately does NOT clear the session, touch storage, or emit
|
|
278
|
+
* SIGNED_OUT. Closing a client is not the user signing out — `signOut()`
|
|
279
|
+
* POSTs /logout and revokes the whole sign-in, which is the wrong hammer
|
|
280
|
+
* (see `abandonSessionLocally`) — and a persisted session must still be
|
|
281
|
+
* there for the next client to restore.
|
|
282
|
+
*/
|
|
283
|
+
function stopAutoRefresh() {
|
|
284
|
+
if (refreshTimeout) {
|
|
285
|
+
clearTimeout(refreshTimeout);
|
|
286
|
+
refreshTimeout = null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
271
290
|
function handleAuthResponse(data: { tokens: AuthTokens, user: Record<string, unknown> }, event?: AuthChangeEvent): RebaseSession {
|
|
272
291
|
const user: User = mapRawUser(data.user);
|
|
273
292
|
const session: RebaseSession = {
|
|
@@ -796,6 +815,7 @@ refreshToken: session.refreshToken };
|
|
|
796
815
|
signInWithSlack,
|
|
797
816
|
signInWithSpotify,
|
|
798
817
|
signOut,
|
|
818
|
+
stopAutoRefresh,
|
|
799
819
|
refreshSession,
|
|
800
820
|
handleUnauthorized,
|
|
801
821
|
getUser,
|
|
@@ -815,6 +835,11 @@ refreshToken: session.refreshToken };
|
|
|
815
835
|
getAuthConfig,
|
|
816
836
|
getSession,
|
|
817
837
|
onAuthStateChange,
|
|
838
|
+
// A client that neither persists sessions nor uses cookie auth has
|
|
839
|
+
// nowhere to restore one from, so "no session in memory" is the final
|
|
840
|
+
// answer rather than a reason to ask the server. See the docblock on
|
|
841
|
+
// `AuthClient.canRestoreSession`.
|
|
842
|
+
canRestoreSession: () => persistSession || authFlowMode === "cookie",
|
|
818
843
|
isInitialized: () => isInitialized
|
|
819
844
|
};
|
|
820
845
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { createRebaseClient } from "./index";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `close()` has one job: leave nothing behind that can keep a process alive.
|
|
6
|
+
*
|
|
7
|
+
* The realtime socket is the obvious such handle and was the only one this
|
|
8
|
+
* method released. The scheduled token refresh is the other, and it is easy to
|
|
9
|
+
* miss because it only exists once somebody signs in: `scheduleRefresh` arms a
|
|
10
|
+
* plain `setTimeout` for roughly the access token's lifetime, and it is not
|
|
11
|
+
* `unref`'d. So a signed-in Node client — a seed script, a cron handler, a job,
|
|
12
|
+
* an SSR request — released its socket, called `close()` exactly as documented,
|
|
13
|
+
* and still hung until the token would have expired.
|
|
14
|
+
*
|
|
15
|
+
* Found by `e2e/tests/client-sdk-e2e.ts`, which reported every check green and
|
|
16
|
+
* then never exited. Pinned here rather than only there because that suite needs
|
|
17
|
+
* Docker and a real Postgres, and this costs nothing.
|
|
18
|
+
*
|
|
19
|
+
* The assertion is the pending timer count, not a spy on `stopAutoRefresh`:
|
|
20
|
+
* what matters is that no handle survives, not which internal call removed it.
|
|
21
|
+
*/
|
|
22
|
+
describe("client.close()", () => {
|
|
23
|
+
const realFetch = globalThis.fetch;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
globalThis.fetch = realFetch;
|
|
27
|
+
jest.useRealTimers();
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** A login response shaped exactly as `handleAuthResponse` reads it. */
|
|
32
|
+
function loginResponse(accessTokenExpiresAt: number) {
|
|
33
|
+
return new Response(
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
tokens: { accessToken: "access-token",
|
|
36
|
+
refreshToken: "refresh-token",
|
|
37
|
+
accessTokenExpiresAt },
|
|
38
|
+
user: { uid: "user-1",
|
|
39
|
+
email: "user@example.test" }
|
|
40
|
+
}),
|
|
41
|
+
{ status: 200,
|
|
42
|
+
headers: { "content-type": "application/json" } }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it("clears the scheduled token refresh, so a signed-in script can exit", async () => {
|
|
47
|
+
jest.useFakeTimers();
|
|
48
|
+
|
|
49
|
+
// An hour out, so the refresh is scheduled rather than fired
|
|
50
|
+
// immediately — `scheduleRefresh` runs the refresh inline when the
|
|
51
|
+
// delay has already elapsed, which would arm no timer and let a broken
|
|
52
|
+
// `close()` pass this test.
|
|
53
|
+
const expiresAt = Date.now() + 60 * 60 * 1000;
|
|
54
|
+
globalThis.fetch = jest.fn(async () => loginResponse(expiresAt)) as unknown as typeof fetch;
|
|
55
|
+
|
|
56
|
+
// `realtime: false` so the socket cannot account for a pending handle:
|
|
57
|
+
// the only thing this test may observe is the refresh timer.
|
|
58
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:9999",
|
|
59
|
+
realtime: false });
|
|
60
|
+
|
|
61
|
+
await client.auth.signInWithEmail("user@example.test", "password");
|
|
62
|
+
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
63
|
+
|
|
64
|
+
client.close();
|
|
65
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is safe on a client that never signed in, and safe to call twice", () => {
|
|
69
|
+
jest.useFakeTimers();
|
|
70
|
+
|
|
71
|
+
const client = createRebaseClient({ baseUrl: "http://localhost:9999",
|
|
72
|
+
realtime: false });
|
|
73
|
+
|
|
74
|
+
expect(() => {
|
|
75
|
+
client.close();
|
|
76
|
+
client.close();
|
|
77
|
+
}).not.toThrow();
|
|
78
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/collection.test.ts
CHANGED
|
@@ -236,18 +236,58 @@ hasMore: false }
|
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
/**
|
|
240
|
+
* `listen()` branches on `client.count` being present, and consumers such
|
|
241
|
+
* as EntitiesCount do the same. Asserting `typeof client.count ===
|
|
242
|
+
* "function"` on an object literal the factory just built cannot fail;
|
|
243
|
+
* what can regress is the branch downstream of it — whether the
|
|
244
|
+
* authoritative total actually reaches the subscriber, or whether the
|
|
245
|
+
* heuristic row count silently stands in for it.
|
|
246
|
+
*/
|
|
247
|
+
describe("listen() total comes from count()", () => {
|
|
248
|
+
/** A WS double that hands the collection callback one page of rows. */
|
|
249
|
+
function wsDeliveringRows(rows: Record<string, unknown>[]) {
|
|
250
|
+
let emit: ((rows: Record<string, unknown>[]) => void) | undefined;
|
|
251
|
+
const ws = {
|
|
252
|
+
listenCollection: jest.fn((_params: unknown, onUpdate: any) => {
|
|
253
|
+
emit = onUpdate;
|
|
254
|
+
return () => undefined;
|
|
255
|
+
})
|
|
256
|
+
};
|
|
257
|
+
return { ws: ws as any,
|
|
258
|
+
deliver: () => emit!(rows) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
it("reports the authoritative count, not the number of rows delivered", async () => {
|
|
262
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 137 });
|
|
263
|
+
const { ws, deliver } = wsDeliveringRows([{ id: "1" }, { id: "2" }]);
|
|
264
|
+
|
|
265
|
+
const client = createCollectionClient(transport, "products", ws);
|
|
266
|
+
const updates: any[] = [];
|
|
267
|
+
client.listen!({ limit: 2 }, (res) => { updates.push(res); });
|
|
268
|
+
deliver();
|
|
269
|
+
await new Promise(r => setImmediate(r));
|
|
270
|
+
|
|
271
|
+
expect(updates).toHaveLength(1);
|
|
272
|
+
expect(updates[0].meta.total).toBe(137);
|
|
273
|
+
// 0 + 2 < 137, so the page is not the end of the collection.
|
|
274
|
+
expect(updates[0].meta.hasMore).toBe(true);
|
|
244
275
|
});
|
|
245
276
|
|
|
246
|
-
it("
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
277
|
+
it("falls back to the row count when the count request fails", async () => {
|
|
278
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(new Error("boom"));
|
|
279
|
+
const { ws, deliver } = wsDeliveringRows([{ id: "1" }, { id: "2" }]);
|
|
280
|
+
|
|
281
|
+
const client = createCollectionClient(transport, "products", ws);
|
|
282
|
+
const updates: any[] = [];
|
|
283
|
+
client.listen!({ limit: 2 }, (res) => { updates.push(res); });
|
|
284
|
+
deliver();
|
|
285
|
+
await new Promise(r => setImmediate(r));
|
|
286
|
+
|
|
287
|
+
expect(updates).toHaveLength(1);
|
|
288
|
+
expect(updates[0].meta.total).toBe(2);
|
|
289
|
+
// A full page with no authoritative total: assume there is more.
|
|
290
|
+
expect(updates[0].meta.hasMore).toBe(true);
|
|
251
291
|
});
|
|
252
292
|
});
|
|
253
293
|
});
|
package/src/collection.ts
CHANGED
|
@@ -189,7 +189,13 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
|
|
|
189
189
|
const countParams: FindParams<M> = {
|
|
190
190
|
...params,
|
|
191
191
|
limit: undefined,
|
|
192
|
-
offset: undefined
|
|
192
|
+
offset: undefined,
|
|
193
|
+
// A count reads no relation data, so `include` can only add
|
|
194
|
+
// joins — and a join that does not match drops rows, which
|
|
195
|
+
// would make the total disagree with the `find()` it describes.
|
|
196
|
+
// Same reasoning as limit/offset: parameters that cannot affect
|
|
197
|
+
// the answer are not forwarded.
|
|
198
|
+
include: undefined
|
|
193
199
|
};
|
|
194
200
|
const qs = buildQueryString(countParams);
|
|
195
201
|
const raw = await transport.request<{ count: number }>(basePath + "/count" + qs, { method: "GET" });
|
package/src/data-proxy.test.ts
CHANGED
|
@@ -32,12 +32,28 @@ describe("client.data proxy — strict mode (collections dictionary provided)",
|
|
|
32
32
|
expect(typeof accessor.create).toBe("function");
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it("resolves a known camelCase key (blogPosts)
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
it("resolves a known camelCase key (blogPosts) to the dictionary's slug", async () => {
|
|
36
|
+
// The accessor is always "defined"; the whole job of the dictionary is
|
|
37
|
+
// that `blogPosts` addresses `blog-posts` on the wire — and NOT the
|
|
38
|
+
// `blog_posts` that the untyped snake_case fallback would produce.
|
|
39
|
+
// A client of its own: the shared one above captured `globalThis.fetch`
|
|
40
|
+
// when this file was collected, which is before `beforeAll` swaps it.
|
|
41
|
+
const fetchMock = jest.fn<typeof fetch>(async () => new Response("{}"));
|
|
42
|
+
const wired = createRebaseClient({
|
|
43
|
+
baseUrl: "http://localhost:3000",
|
|
44
|
+
fetch: fetchMock as typeof fetch,
|
|
45
|
+
collections: { ...COLLECTIONS }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const products = (wired.data as Record<string, any>)["products"];
|
|
49
|
+
await products.find();
|
|
50
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain("/data/products");
|
|
51
|
+
|
|
52
|
+
const blogPosts = (wired.data as Record<string, any>)["blogPosts"];
|
|
53
|
+
await blogPosts.find();
|
|
54
|
+
const url = String(fetchMock.mock.calls[1][0]);
|
|
55
|
+
expect(url).toContain("/data/blog-posts");
|
|
56
|
+
expect(url).not.toContain("blog_posts");
|
|
41
57
|
});
|
|
42
58
|
|
|
43
59
|
it("throws RebaseClientError on unknown key with suggestion", () => {
|
package/src/functions.ts
CHANGED
|
@@ -55,7 +55,15 @@ export function createFunctionsClient(transport: Transport): FunctionsClient {
|
|
|
55
55
|
options?: FunctionInvokeOptions
|
|
56
56
|
): Promise<T> {
|
|
57
57
|
const method = options?.method ?? "POST";
|
|
58
|
-
|
|
58
|
+
// A `path` that starts the query or fragment is appended as-is. Only a
|
|
59
|
+
// real sub-path gets a separator: inserting one before `?days=30` asks
|
|
60
|
+
// for `/functions/dashboard-stats/?days=30`, and the trailing slash
|
|
61
|
+
// misses the route, so a function that exists answers 404 — and the
|
|
62
|
+
// caller sees it as the backend being down rather than as a bad URL.
|
|
63
|
+
const rawPath = options?.path;
|
|
64
|
+
const subPath = rawPath
|
|
65
|
+
? (/^[?#]/.test(rawPath) ? rawPath : `/${rawPath.replace(/^\//, "")}`)
|
|
66
|
+
: "";
|
|
59
67
|
const routePath = `/functions/${encodeURIComponent(name)}${subPath}`;
|
|
60
68
|
|
|
61
69
|
const init: RequestInit = { method };
|
package/src/index.ts
CHANGED
|
@@ -60,9 +60,10 @@ export { QueryBuilder, or, and, cond } from "@rebasepro/common";
|
|
|
60
60
|
// Auth: session/token types, config, and the pluggable storage strategies.
|
|
61
61
|
export { createCookieStorage, createMemoryStorage } from "./auth";
|
|
62
62
|
export type { AuthConfig, AuthStorage, CookieStorageOptions, CreateAuthOptions } from "./auth";
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// `User` is re-exported alongside the session types because `client.auth` hands
|
|
64
|
+
// one back and a browser app installs `@rebasepro/client` only — `@rebasepro/types`
|
|
65
|
+
// is a transitive dependency there, not something a consumer can import from.
|
|
66
|
+
export type { User, RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
|
|
66
67
|
|
|
67
68
|
// Control-plane client option/DTO types (the client instance exposes the impls).
|
|
68
69
|
export type { CreateAdminOptions } from "./admin";
|
|
@@ -208,11 +209,17 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
|
|
|
208
209
|
channel: (name: string, options?: ChannelOptions) => RebaseRealtimeChannel;
|
|
209
210
|
};
|
|
210
211
|
/**
|
|
211
|
-
* Release
|
|
212
|
+
* Release everything this client holds that can keep a process alive: the
|
|
213
|
+
* realtime socket and its reconnect timer, channel presence heartbeats, the
|
|
214
|
+
* offline manager, and the scheduled token refresh.
|
|
212
215
|
*
|
|
213
|
-
*
|
|
214
|
-
* call this will not exit
|
|
215
|
-
*
|
|
216
|
+
* Each of those keeps the Node event loop alive on its own, so a script
|
|
217
|
+
* that does not call this will not exit — and, until the refresh timer was
|
|
218
|
+
* included, one that *did* call it still would not if it had signed in.
|
|
219
|
+
*
|
|
220
|
+
* Safe when realtime was never started (`realtime: false`), safe when
|
|
221
|
+
* signed out, and safe to call twice. It does not sign the user out: a
|
|
222
|
+
* persisted session survives for the next client to restore.
|
|
216
223
|
*/
|
|
217
224
|
close: () => void;
|
|
218
225
|
storage: StorageSource;
|
|
@@ -547,11 +554,11 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
547
554
|
}
|
|
548
555
|
},
|
|
549
556
|
/**
|
|
550
|
-
* Release
|
|
557
|
+
* Release every handle that can keep a process alive — see the
|
|
558
|
+
* `close` docblock on the client interface.
|
|
551
559
|
*
|
|
552
|
-
*
|
|
553
|
-
* and
|
|
554
|
-
* was never started, and safe to call twice.
|
|
560
|
+
* Safe to call when realtime was never started, safe when signed out,
|
|
561
|
+
* and safe to call twice.
|
|
555
562
|
*/
|
|
556
563
|
close: () => {
|
|
557
564
|
// Channels hold presence heartbeat timers, which would otherwise
|
|
@@ -565,12 +572,19 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
|
|
|
565
572
|
// The offline retry timer is unref'd but the `online` listener is
|
|
566
573
|
// not, and neither should outlive the client.
|
|
567
574
|
offlineManager?.dispose();
|
|
575
|
+
// The scheduled token refresh is a plain setTimeout up to a token
|
|
576
|
+
// lifetime away, and not unref'd — so on Node it holds the event
|
|
577
|
+
// loop open all by itself. Without this, closing a SIGNED-IN client
|
|
578
|
+
// released the socket and the process still never exited, which is
|
|
579
|
+
// the opposite of what this method exists to guarantee.
|
|
580
|
+
auth.stopAutoRefresh();
|
|
568
581
|
},
|
|
569
582
|
setToken: transport.setToken,
|
|
570
583
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
571
584
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
572
585
|
resolveToken: transport.resolveToken,
|
|
573
586
|
baseUrl: transport.baseUrl,
|
|
587
|
+
apiPath: transport.apiPath,
|
|
574
588
|
collection,
|
|
575
589
|
call: async <T = unknown>(endpoint: string, payload?: unknown): Promise<T> => {
|
|
576
590
|
const prefix = endpoint.startsWith("/") ? "" : "/";
|
|
@@ -189,9 +189,28 @@ async function clearOfflineQueue(): Promise<void> {
|
|
|
189
189
|
describe("two tabs over one database", () => {
|
|
190
190
|
beforeEach(clearOfflineQueue);
|
|
191
191
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Wait until the other tab has caught up.
|
|
194
|
+
*
|
|
195
|
+
* BroadcastChannel delivery is asynchronous and the receiving tab then does
|
|
196
|
+
* its own IndexedDB work, so there is no fixed number of turns that is
|
|
197
|
+
* both enough and not wasteful. Draining three macrotasks was enough on an
|
|
198
|
+
* idle machine and not enough when the other package suites run alongside
|
|
199
|
+
* this one — which is how this file failed under `pnpm -r test` while
|
|
200
|
+
* passing every time on its own.
|
|
201
|
+
*
|
|
202
|
+
* Polling a condition is what the test actually means, and it costs one
|
|
203
|
+
* turn in the common case rather than three.
|
|
204
|
+
*/
|
|
205
|
+
const settleUntil = async (done: () => boolean | Promise<boolean>, deadlineMs = 5_000) => {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
for (;;) {
|
|
208
|
+
if (await done()) return;
|
|
209
|
+
if (Date.now() - started > deadlineMs) {
|
|
210
|
+
throw new Error(`condition never held within ${deadlineMs}ms`);
|
|
211
|
+
}
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
213
|
+
}
|
|
195
214
|
};
|
|
196
215
|
|
|
197
216
|
function createServer() {
|
|
@@ -252,7 +271,7 @@ describe("two tabs over one database", () => {
|
|
|
252
271
|
|
|
253
272
|
server.state.online = false;
|
|
254
273
|
await postsA.create({ title: "from tab A" }, "xt1");
|
|
255
|
-
await
|
|
274
|
+
await settleUntil(async () => (await postsB.find()).data.some((r) => r.id === "xt1"));
|
|
256
275
|
|
|
257
276
|
// Without cross-tab propagation this tab would be showing a list that
|
|
258
277
|
// is already wrong, and would keep showing it until a refetch.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A socket that opens on the next tick and answers `AUTHENTICATE` the way the
|
|
5
|
+
* server does: one `AUTH_SUCCESS` carrying the frame's own `requestId`.
|
|
6
|
+
*/
|
|
7
|
+
function fakeSocket() {
|
|
8
|
+
const sent: Record<string, unknown>[] = [];
|
|
9
|
+
|
|
10
|
+
class FakeWS {
|
|
11
|
+
static readonly OPEN = 1;
|
|
12
|
+
readyState = 1;
|
|
13
|
+
onopen: (() => void) | null = null;
|
|
14
|
+
onclose: (() => void) | null = null;
|
|
15
|
+
onerror: (() => void) | null = null;
|
|
16
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(public url: string) {
|
|
19
|
+
setTimeout(() => this.onopen?.(), 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
send(raw: string) {
|
|
23
|
+
const message = JSON.parse(raw) as Record<string, unknown>;
|
|
24
|
+
sent.push(message);
|
|
25
|
+
if (message.type === "AUTHENTICATE") {
|
|
26
|
+
setTimeout(() => this.onmessage?.({
|
|
27
|
+
data: JSON.stringify({ type: "AUTH_SUCCESS",
|
|
28
|
+
requestId: message.requestId })
|
|
29
|
+
}), 0);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
close() { /* noop */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { FakeWS: FakeWS as unknown as typeof WebSocket,
|
|
37
|
+
sent };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("concurrent subscriptions on a cold socket", () => {
|
|
41
|
+
|
|
42
|
+
beforeEach(() => jest.useFakeTimers());
|
|
43
|
+
afterEach(() => jest.useRealTimers());
|
|
44
|
+
|
|
45
|
+
it("sends every subscribe when several are queued before authentication", async () => {
|
|
46
|
+
// The Kanban board's shape: one subscription per column, all created in
|
|
47
|
+
// the same tick, all queued because the socket is still connecting.
|
|
48
|
+
//
|
|
49
|
+
// `ensureAuthenticated` read its in-flight guard, then awaited
|
|
50
|
+
// `getAuthToken()` before publishing it — so every caller that arrived
|
|
51
|
+
// during that gap saw no attempt in progress and started one of its
|
|
52
|
+
// own. Each attempt registered under `auth_${Date.now()}`, with no
|
|
53
|
+
// random suffix, so within a millisecond they collided in the
|
|
54
|
+
// `pendingRequests` map and only the last registration was ever
|
|
55
|
+
// settled. The rest awaited forever, and the `subscribe_collection`
|
|
56
|
+
// frames behind them were never sent: the board's columns reported
|
|
57
|
+
// "Subscription timed out" thirty seconds later.
|
|
58
|
+
const { RebaseWebSocketClient } = await import("./websocket");
|
|
59
|
+
const { FakeWS, sent } = fakeSocket();
|
|
60
|
+
|
|
61
|
+
// The socket authenticates itself on open, which would settle auth
|
|
62
|
+
// before anything queued got a chance to race. In a real page load the
|
|
63
|
+
// auth controller is still restoring the session at that point, so the
|
|
64
|
+
// first read yields nothing and the queued frames are what end up
|
|
65
|
+
// driving authentication — each of them separately.
|
|
66
|
+
let firstRead = true;
|
|
67
|
+
const getAuthToken = async () => {
|
|
68
|
+
if (firstRead) {
|
|
69
|
+
firstRead = false;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// Reading a token is asynchronous — this is the window during
|
|
73
|
+
// which concurrent callers used to each start their own attempt.
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
75
|
+
return "token";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const client = new RebaseWebSocketClient({
|
|
79
|
+
websocketUrl: "ws://localhost:1234",
|
|
80
|
+
WebSocket: FakeWS,
|
|
81
|
+
getAuthToken
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const columns = ["open", "in_progress", "waiting", "resolved", "closed"];
|
|
85
|
+
for (const column of columns) {
|
|
86
|
+
client.listenCollection(
|
|
87
|
+
{ path: "tickets",
|
|
88
|
+
filter: { status: ["==", column] } } as never,
|
|
89
|
+
() => undefined,
|
|
90
|
+
() => undefined
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await jest.advanceTimersByTimeAsync(50);
|
|
95
|
+
|
|
96
|
+
const subscribed = sent.filter(m => m.type === "subscribe_collection");
|
|
97
|
+
expect(subscribed).toHaveLength(columns.length);
|
|
98
|
+
|
|
99
|
+
// And exactly one authentication for all of them.
|
|
100
|
+
expect(sent.filter(m => m.type === "AUTHENTICATE")).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -213,17 +213,51 @@ describe("realtime opt-out", () => {
|
|
|
213
213
|
expect(opened).toHaveLength(0);
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("signing in does not by itself open a socket", () => {
|
|
216
|
+
it("signing in does not by itself open a socket", async () => {
|
|
217
217
|
// Authenticating is not a request for realtime. If it dialled, every
|
|
218
218
|
// app with a login would lose lazy connect at the moment of login.
|
|
219
|
+
// Merely registering a listener does not show that: the SIGNED_IN
|
|
220
|
+
// branch that could dial is only reached by an actual sign-in.
|
|
219
221
|
const { FakeWebSocket, opened } = trackingWebSocket();
|
|
220
222
|
globalThis.WebSocket = FakeWebSocket;
|
|
221
223
|
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
const loginBody = {
|
|
225
|
+
tokens: {
|
|
226
|
+
accessToken: "jwt",
|
|
227
|
+
refreshToken: "refresh",
|
|
228
|
+
accessTokenExpiresAt: Date.now() + 3600_000
|
|
229
|
+
},
|
|
230
|
+
user: { uid: "usr_1",
|
|
231
|
+
email: "u@example.com",
|
|
232
|
+
displayName: "U",
|
|
233
|
+
photoURL: null,
|
|
234
|
+
roles: ["user"],
|
|
235
|
+
providerId: "local",
|
|
236
|
+
isAnonymous: false }
|
|
237
|
+
};
|
|
238
|
+
const fetchMock = jest.fn(async () => ({
|
|
239
|
+
ok: true,
|
|
240
|
+
status: 200,
|
|
241
|
+
text: async () => JSON.stringify(loginBody),
|
|
242
|
+
json: async () => loginBody,
|
|
243
|
+
headers: new Headers()
|
|
244
|
+
})) as unknown as typeof globalThis.fetch;
|
|
245
|
+
|
|
246
|
+
const client = createRebaseClient({
|
|
247
|
+
baseUrl: "http://localhost:3000/api",
|
|
248
|
+
fetch: fetchMock
|
|
249
|
+
});
|
|
225
250
|
|
|
251
|
+
const events: string[] = [];
|
|
252
|
+
client.auth.onAuthStateChange((event) => { events.push(event); });
|
|
253
|
+
|
|
254
|
+
const result = await client.auth.signInWithEmail("u@example.com", "pw");
|
|
255
|
+
|
|
256
|
+
expect(result.user.uid).toBe("usr_1");
|
|
257
|
+
expect(events).toEqual(["SIGNED_IN"]);
|
|
226
258
|
expect(opened).toHaveLength(0);
|
|
259
|
+
|
|
260
|
+
client.close();
|
|
227
261
|
});
|
|
228
262
|
|
|
229
263
|
it("leaves listen() absent so callers can feature-detect, and says why via the query builder", () => {
|