@ragable/sdk 0.6.14 → 0.6.15

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.mjs CHANGED
@@ -2298,6 +2298,20 @@ var RagableBrowserDatabaseClient = class {
2298
2298
  * Postgres `LISTEN` / `NOTIFY` realtime via server-proxied SSE.
2299
2299
  * Channels must be lowercase identifiers: `[a-z_][a-z0-9_]*` (max 63 chars).
2300
2300
  */
2301
+ /**
2302
+ * Postgres `LISTEN` / `NOTIFY` realtime via server-proxied SSE.
2303
+ *
2304
+ * Returns a `BrowserRealtimeSubscription` with:
2305
+ * - `unsubscribe()` — permanently close the subscription (stops reconnects).
2306
+ * - `status` — current connection state: `"connecting"` | `"connected"` | `"reconnecting"` | `"disconnected"`.
2307
+ *
2308
+ * The subscription automatically reconnects with exponential backoff when the
2309
+ * stream drops. Server heartbeats (every 15 s) are monitored — if none arrive
2310
+ * within `heartbeatTimeoutMs` (default 45 s), the connection is treated as dead
2311
+ * and a reconnect is triggered. Auth errors (401/403/404) are non-retryable.
2312
+ *
2313
+ * Channel names must be lowercase identifiers: `[a-z_][a-z0-9_]*` (max 63 chars).
2314
+ */
2301
2315
  __publicField(this, "realtime", {
2302
2316
  subscribe: (params) => subscribeBrowserRealtime(
2303
2317
  this.options,
@@ -2327,9 +2341,13 @@ function followAbortSignal(parent, child) {
2327
2341
  }
2328
2342
  parent.addEventListener("abort", () => child.abort(), { once: true });
2329
2343
  }
2344
+ function backoffDelay(attempt, baseMs, maxMs) {
2345
+ const exp = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
2346
+ const jitter = exp * (0.5 + Math.random() * 0.5);
2347
+ return Math.round(jitter);
2348
+ }
2330
2349
  async function subscribeBrowserRealtime(options, ragableAuth, fetchImpl, params) {
2331
2350
  const gid = requireAuthGroupId(options);
2332
- const token = await resolveDatabaseAuthBearer(options, ragableAuth);
2333
2351
  const databaseInstanceId = params.databaseInstanceId?.trim() || options.databaseInstanceId?.trim();
2334
2352
  if (!databaseInstanceId) {
2335
2353
  throw new RagableError(
@@ -2345,41 +2363,75 @@ async function subscribeBrowserRealtime(options, ragableAuth, fetchImpl, params)
2345
2363
  { code: "SDK_REALTIME_CHANNELS_REQUIRED" }
2346
2364
  );
2347
2365
  }
2348
- const ac = new AbortController();
2349
- followAbortSignal(params.signal, ac);
2350
- const headers = new Headers(options.headers);
2351
- headers.set("Authorization", `Bearer ${token}`);
2352
- headers.set("Content-Type", "application/json");
2353
- const response = await fetchImpl(
2354
- `${normalizeBrowserApiBase()}/auth-groups/${gid}/data/realtime/stream`,
2355
- {
2356
- method: "POST",
2357
- headers,
2358
- body: JSON.stringify({
2359
- databaseInstanceId,
2360
- channels: params.channels
2361
- }),
2362
- signal: ac.signal
2366
+ const maxAttempts = params.maxReconnectAttempts ?? Infinity;
2367
+ const baseDelay = params.reconnectBaseDelayMs ?? 1e3;
2368
+ const maxDelay = params.reconnectMaxDelayMs ?? 3e4;
2369
+ const heartbeatTimeout = params.heartbeatTimeoutMs ?? 45e3;
2370
+ const lifecycleAc = new AbortController();
2371
+ followAbortSignal(params.signal, lifecycleAc);
2372
+ let currentStatus = "connecting";
2373
+ const setStatus = (s) => {
2374
+ if (s === currentStatus) return;
2375
+ currentStatus = s;
2376
+ params.onStatusChange?.(s);
2377
+ };
2378
+ const subscription = {
2379
+ unsubscribe: () => lifecycleAc.abort(),
2380
+ get status() {
2381
+ return currentStatus;
2363
2382
  }
2364
- );
2365
- const payload = await parseMaybeJsonBody(response);
2366
- if (!response.ok) {
2367
- const message = extractErrorMessage(payload, response.statusText);
2368
- throw new RagableError(message, response.status, payload);
2369
- }
2370
- const streamBody = response.body;
2371
- if (!streamBody) {
2372
- throw new RagableError(
2373
- "Realtime stream has no body",
2374
- 502,
2375
- { code: "SDK_REALTIME_NO_BODY" }
2383
+ };
2384
+ setStatus("connecting");
2385
+ async function connectOnce(signal) {
2386
+ const token = await resolveDatabaseAuthBearer(options, ragableAuth);
2387
+ const headers = new Headers(options.headers);
2388
+ headers.set("Authorization", `Bearer ${token}`);
2389
+ headers.set("Content-Type", "application/json");
2390
+ const response = await fetchImpl(
2391
+ `${normalizeBrowserApiBase()}/auth-groups/${gid}/data/realtime/stream`,
2392
+ {
2393
+ method: "POST",
2394
+ headers,
2395
+ body: JSON.stringify({
2396
+ databaseInstanceId,
2397
+ channels: params.channels
2398
+ }),
2399
+ signal
2400
+ }
2376
2401
  );
2377
- }
2378
- void (async () => {
2402
+ if (!response.ok) {
2403
+ const payload = await parseMaybeJsonBody(response);
2404
+ const message = extractErrorMessage(payload, response.statusText);
2405
+ throw new RagableError(message, response.status, payload);
2406
+ }
2407
+ const streamBody = response.body;
2408
+ if (!streamBody) {
2409
+ throw new RagableError("Realtime stream has no body", 502, {
2410
+ code: "SDK_REALTIME_NO_BODY"
2411
+ });
2412
+ }
2413
+ let heartbeatTimer = null;
2414
+ const resetHeartbeatTimer = () => {
2415
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
2416
+ heartbeatTimer = setTimeout(() => {
2417
+ streamReader?.cancel().catch(() => void 0);
2418
+ }, heartbeatTimeout);
2419
+ };
2420
+ let streamReader = null;
2379
2421
  try {
2380
- for await (const evt of readSseStream(streamBody)) {
2422
+ streamReader = streamBody.getReader();
2423
+ const decoder = new TextDecoder();
2424
+ let buffer = "";
2425
+ resetHeartbeatTimer();
2426
+ const processEvent = (evt) => {
2427
+ if (evt.type === "realtime:heartbeat") {
2428
+ resetHeartbeatTimer();
2429
+ return;
2430
+ }
2431
+ resetHeartbeatTimer();
2381
2432
  if (evt.type === "realtime:ready") {
2382
2433
  const ch = evt.channels;
2434
+ setStatus("connected");
2383
2435
  params.onReady?.(
2384
2436
  Array.isArray(ch) ? ch.map((c) => String(c)) : []
2385
2437
  );
@@ -2392,15 +2444,78 @@ async function subscribeBrowserRealtime(options, ragableAuth, fetchImpl, params)
2392
2444
  } else if (evt.type === "realtime:error") {
2393
2445
  params.onError?.(String(evt.message ?? "Realtime error"));
2394
2446
  }
2447
+ };
2448
+ while (true) {
2449
+ const { done, value } = await streamReader.read();
2450
+ if (done) break;
2451
+ buffer += decoder.decode(value, { stream: true });
2452
+ let boundary = buffer.indexOf("\n\n");
2453
+ while (boundary !== -1) {
2454
+ const block = buffer.slice(0, boundary);
2455
+ buffer = buffer.slice(boundary + 2);
2456
+ for (const line of block.split("\n")) {
2457
+ const dataPrefix = "data: ";
2458
+ if (!line.startsWith(dataPrefix)) continue;
2459
+ const json = line.slice(dataPrefix.length).trim();
2460
+ if (!json || json === "[DONE]") continue;
2461
+ try {
2462
+ processEvent(JSON.parse(json));
2463
+ } catch {
2464
+ }
2465
+ }
2466
+ boundary = buffer.indexOf("\n\n");
2467
+ }
2395
2468
  }
2396
- } catch (e) {
2397
- if (e.name === "AbortError") return;
2398
- params.onError?.(e.message);
2469
+ return "stream_ended";
2470
+ } finally {
2471
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
2472
+ streamReader?.releaseLock();
2399
2473
  }
2474
+ }
2475
+ void (async () => {
2476
+ let attempt = 0;
2477
+ while (!lifecycleAc.signal.aborted) {
2478
+ const iterAc = new AbortController();
2479
+ followAbortSignal(lifecycleAc.signal, iterAc);
2480
+ try {
2481
+ const result = await connectOnce(iterAc.signal);
2482
+ if (lifecycleAc.signal.aborted) break;
2483
+ if (result === "stream_ended") {
2484
+ attempt++;
2485
+ }
2486
+ } catch (e) {
2487
+ if (lifecycleAc.signal.aborted) break;
2488
+ if (e.name === "AbortError") break;
2489
+ const status = e.status;
2490
+ if (status === 400 || status === 401 || status === 403 || status === 404) {
2491
+ params.onError?.(e.message);
2492
+ break;
2493
+ }
2494
+ attempt++;
2495
+ }
2496
+ if (lifecycleAc.signal.aborted) break;
2497
+ if (attempt > maxAttempts) {
2498
+ params.onError?.(`Realtime: gave up after ${maxAttempts} reconnect attempts`);
2499
+ break;
2500
+ }
2501
+ const delay = backoffDelay(attempt, baseDelay, maxDelay);
2502
+ setStatus("reconnecting");
2503
+ params.onDisconnect?.({ attempt, retryInMs: delay });
2504
+ await new Promise((r) => {
2505
+ const timer = setTimeout(r, delay);
2506
+ const onAbort = () => {
2507
+ clearTimeout(timer);
2508
+ r();
2509
+ };
2510
+ lifecycleAc.signal.addEventListener("abort", onAbort, { once: true });
2511
+ });
2512
+ if (lifecycleAc.signal.aborted) break;
2513
+ setStatus("connecting");
2514
+ params.onReconnect?.({ attempt });
2515
+ }
2516
+ setStatus("disconnected");
2400
2517
  })();
2401
- return {
2402
- unsubscribe: () => ac.abort()
2403
- };
2518
+ return subscription;
2404
2519
  }
2405
2520
  var RagableBrowserAgentsClient = class {
2406
2521
  constructor(options) {