@ragable/sdk 0.6.13 → 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
@@ -2294,6 +2294,32 @@ var RagableBrowserDatabaseClient = class {
2294
2294
  return payload;
2295
2295
  });
2296
2296
  });
2297
+ /**
2298
+ * Postgres `LISTEN` / `NOTIFY` realtime via server-proxied SSE.
2299
+ * Channels must be lowercase identifiers: `[a-z_][a-z0-9_]*` (max 63 chars).
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
+ */
2315
+ __publicField(this, "realtime", {
2316
+ subscribe: (params) => subscribeBrowserRealtime(
2317
+ this.options,
2318
+ this.ragableAuth,
2319
+ this.fetchImpl,
2320
+ params
2321
+ )
2322
+ });
2297
2323
  this.fetchImpl = bindFetch(options.fetch);
2298
2324
  }
2299
2325
  /** @internal Called by RagableBrowser to share the Transport instance. */
@@ -2307,6 +2333,190 @@ var RagableBrowserDatabaseClient = class {
2307
2333
  return new Headers(this.options.headers);
2308
2334
  }
2309
2335
  };
2336
+ function followAbortSignal(parent, child) {
2337
+ if (!parent) return;
2338
+ if (parent.aborted) {
2339
+ child.abort();
2340
+ return;
2341
+ }
2342
+ parent.addEventListener("abort", () => child.abort(), { once: true });
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
+ }
2349
+ async function subscribeBrowserRealtime(options, ragableAuth, fetchImpl, params) {
2350
+ const gid = requireAuthGroupId(options);
2351
+ const databaseInstanceId = params.databaseInstanceId?.trim() || options.databaseInstanceId?.trim();
2352
+ if (!databaseInstanceId) {
2353
+ throw new RagableError(
2354
+ "realtime.subscribe requires databaseInstanceId in params or on createBrowserClient({ databaseInstanceId })",
2355
+ 400,
2356
+ { code: "SDK_MISSING_DATABASE_INSTANCE_ID" }
2357
+ );
2358
+ }
2359
+ if (!Array.isArray(params.channels) || params.channels.length === 0) {
2360
+ throw new RagableError(
2361
+ "realtime.subscribe requires a non-empty channels array",
2362
+ 400,
2363
+ { code: "SDK_REALTIME_CHANNELS_REQUIRED" }
2364
+ );
2365
+ }
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;
2382
+ }
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
+ }
2401
+ );
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;
2421
+ try {
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();
2432
+ if (evt.type === "realtime:ready") {
2433
+ const ch = evt.channels;
2434
+ setStatus("connected");
2435
+ params.onReady?.(
2436
+ Array.isArray(ch) ? ch.map((c) => String(c)) : []
2437
+ );
2438
+ } else if (evt.type === "notify") {
2439
+ params.onNotify?.({
2440
+ channel: String(evt.channel ?? ""),
2441
+ payload: evt.payload === void 0 || evt.payload === null ? null : String(evt.payload),
2442
+ processId: Number(evt.processId ?? 0)
2443
+ });
2444
+ } else if (evt.type === "realtime:error") {
2445
+ params.onError?.(String(evt.message ?? "Realtime error"));
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
+ }
2468
+ }
2469
+ return "stream_ended";
2470
+ } finally {
2471
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
2472
+ streamReader?.releaseLock();
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");
2517
+ })();
2518
+ return subscription;
2519
+ }
2310
2520
  var RagableBrowserAgentsClient = class {
2311
2521
  constructor(options) {
2312
2522
  this.options = options;