@rebasepro/client 0.12.0 → 0.12.1-canary.g181d0fe

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.
@@ -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
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g181d0fe",
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.12.0",
33
- "@rebasepro/types": "0.12.0",
34
- "@rebasepro/utils": "0.12.0"
32
+ "@rebasepro/common": "0.12.1-canary.g181d0fe",
33
+ "@rebasepro/utils": "0.12.1-canary.g181d0fe",
34
+ "@rebasepro/types": "0.12.1-canary.g181d0fe"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
38
38
  "@types/jest": "^30.0.0",
39
- "@types/node": "^25.9.3",
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.11",
44
+ "ts-jest": "^29.4.12",
45
45
  "tsd": "^0.33.0",
46
46
  "typescript": "^6.0.3",
47
- "vite": "^8.0.16",
48
- "ws": "^8.21.0"
47
+ "vite": "^8.1.5",
48
+ "ws": "^8.21.1"
49
49
  },
50
50
  "files": [
51
51
  "dist",
package/src/auth.ts CHANGED
@@ -815,6 +815,11 @@ refreshToken: session.refreshToken };
815
815
  getAuthConfig,
816
816
  getSession,
817
817
  onAuthStateChange,
818
+ // A client that neither persists sessions nor uses cookie auth has
819
+ // nowhere to restore one from, so "no session in memory" is the final
820
+ // answer rather than a reason to ask the server. See the docblock on
821
+ // `AuthClient.canRestoreSession`.
822
+ canRestoreSession: () => persistSession || authFlowMode === "cookie",
818
823
  isInitialized: () => isInitialized
819
824
  };
820
825
  }
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
- const subPath = options?.path ? `/${options.path.replace(/^\//, "")}` : "";
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
@@ -571,6 +571,7 @@ export function createRebaseClient<DB = Record<string, unknown>>(options: Create
571
571
  setOnUnauthorized: transport.setOnUnauthorized,
572
572
  resolveToken: transport.resolveToken,
573
573
  baseUrl: transport.baseUrl,
574
+ apiPath: transport.apiPath,
574
575
  collection,
575
576
  call: async <T = unknown>(endpoint: string, payload?: unknown): Promise<T> => {
576
577
  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
- /** BroadcastChannel delivery is asynchronous; give it a macrotask or two. */
193
- const settle = async () => {
194
- for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 0));
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 settle();
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.
package/src/websocket.ts CHANGED
@@ -97,6 +97,18 @@ export class RebaseWebSocketClient {
97
97
  /** Set by `close()`. Blocks any later operation from silently redialling. */
98
98
  private closedByCaller = false;
99
99
 
100
+ /**
101
+ * Set when the backoff budget ran out, cleared by anything that earns a
102
+ * fresh one.
103
+ *
104
+ * Unlike {@link closedByCaller} this is not final — nobody *asked* for the
105
+ * socket to stay down. Five attempts with exponential backoff is about a
106
+ * minute, which a laptop lid, a wifi handover or a backend rollout all
107
+ * exceed routinely; treating that as permanent meant realtime silently
108
+ * stopped for the rest of the page's life, with a reload the only cure.
109
+ */
110
+ private gaveUp = false;
111
+
100
112
  /**
101
113
  * Whether a socket exists at all (open or still opening).
102
114
  *
@@ -249,10 +261,35 @@ export class RebaseWebSocketClient {
249
261
  }
250
262
  return;
251
263
  }
264
+ this.installOnlineListener();
252
265
  if (this.ws || this.reconnectTimeout) return;
266
+ // A caller asking for a connection is a fresh reason to try, so it also
267
+ // buys a fresh backoff budget. Without this, the first `subscribe`
268
+ // after a give-up would exhaust the counter on attempt one.
269
+ if (this.gaveUp) {
270
+ this.gaveUp = false;
271
+ this.reconnectAttempts = 0;
272
+ }
253
273
  this.initWebSocket();
254
274
  }
255
275
 
276
+ /**
277
+ * The browser says the network is back — the usual reason the budget ran
278
+ * out in the first place. Registered lazily so a Node client, or a page
279
+ * that never subscribes, adds no listener.
280
+ */
281
+ private installOnlineListener() {
282
+ if (this.onlineListener || typeof window === "undefined" || typeof window.addEventListener !== "function") return;
283
+ this.onlineListener = () => {
284
+ if (this.closedByCaller || !this.gaveUp) return;
285
+ console.debug("Network is back — retrying the realtime connection");
286
+ this.ensureConnected();
287
+ };
288
+ window.addEventListener("online", this.onlineListener);
289
+ }
290
+
291
+ private onlineListener: (() => void) | null = null;
292
+
256
293
  /**
257
294
  * Authenticate the WebSocket connection
258
295
  */
@@ -325,6 +362,10 @@ export class RebaseWebSocketClient {
325
362
  */
326
363
  public disconnect(permanent = false): void {
327
364
  if (permanent) this.closedByCaller = true;
365
+ if (permanent && this.onlineListener && typeof window !== "undefined") {
366
+ window.removeEventListener("online", this.onlineListener);
367
+ this.onlineListener = null;
368
+ }
328
369
  this.isAuthenticated = false;
329
370
  this.authPromise = null;
330
371
  if (this.reconnectTimeout) {
@@ -354,7 +395,10 @@ export class RebaseWebSocketClient {
354
395
  }
355
396
 
356
397
  try {
357
- this.ws = new this.WebSocketConstructor(this.websocketUrl);
398
+ // Captured so each handler can tell "my socket" from a later one:
399
+ // a close arriving after a redial must not clear the new socket.
400
+ const socket = new this.WebSocketConstructor(this.websocketUrl);
401
+ this.ws = socket;
358
402
 
359
403
  this.ws!.onopen = async () => {
360
404
  console.debug("Connected to PostgreSQL backend");
@@ -403,6 +447,11 @@ export class RebaseWebSocketClient {
403
447
 
404
448
  this.ws!.onclose = () => {
405
449
  console.debug("Disconnected from PostgreSQL backend");
450
+ // Release the dead socket. `ensureConnected` returns early
451
+ // while `this.ws` is set, so holding a closed one made the
452
+ // "give up after N attempts" state permanent: nothing could
453
+ // ever redial, not even a fresh `subscribe`.
454
+ if (this.ws === socket) this.ws = null;
406
455
  this.isConnected = false;
407
456
  this.isAuthenticated = false;
408
457
  this.authPromise = null;
@@ -451,6 +500,7 @@ export class RebaseWebSocketClient {
451
500
  console.error("Max reconnection attempts reached");
452
501
  // Nothing will re-subscribe now, so stop every subscription that
453
502
  // never loaded from spinning forever.
503
+ this.gaveUp = true;
454
504
  this.failAllPendingSubscriptions(
455
505
  new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" })
456
506
  );