@agent-native/core 0.116.0 → 0.117.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.
Files changed (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +4 -1
  4. package/corpus/core/src/client/analytics.ts +7 -0
  5. package/corpus/core/src/client/use-db-sync.ts +354 -15
  6. package/corpus/core/src/collab/client.ts +26 -2
  7. package/corpus/core/src/db/ddl-guard.ts +49 -10
  8. package/corpus/core/src/deploy/build.ts +25 -2
  9. package/corpus/core/src/realtime-protocol.ts +92 -0
  10. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  11. package/corpus/core/src/server/poll-events.ts +29 -8
  12. package/corpus/core/src/server/poll.ts +1123 -865
  13. package/corpus/core/src/server/realtime-token.ts +106 -0
  14. package/corpus/core/src/server/sentry-config.ts +40 -0
  15. package/corpus/core/src/server/short-lived-token.ts +156 -0
  16. package/corpus/core/src/server/sse.ts +15 -0
  17. package/corpus/core/src/server/ssr-handler.ts +9 -3
  18. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  22. package/corpus/templates/content/app/i18n-data.ts +90 -0
  23. package/dist/client/analytics.d.ts +10 -0
  24. package/dist/client/analytics.d.ts.map +1 -1
  25. package/dist/client/analytics.js.map +1 -1
  26. package/dist/client/use-db-sync.d.ts +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +315 -15
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/client.d.ts.map +1 -1
  31. package/dist/collab/client.js +26 -2
  32. package/dist/collab/client.js.map +1 -1
  33. package/dist/db/ddl-guard.d.ts +7 -2
  34. package/dist/db/ddl-guard.d.ts.map +1 -1
  35. package/dist/db/ddl-guard.js +21 -9
  36. package/dist/db/ddl-guard.js.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +25 -2
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/notifications/routes.d.ts +3 -3
  41. package/dist/observability/routes.d.ts +5 -5
  42. package/dist/progress/routes.d.ts +1 -1
  43. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  44. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  45. package/dist/realtime-protocol.d.ts +40 -0
  46. package/dist/realtime-protocol.d.ts.map +1 -0
  47. package/dist/realtime-protocol.js +69 -0
  48. package/dist/realtime-protocol.js.map +1 -0
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +3 -3
  51. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  52. package/dist/server/core-routes-plugin.js +3 -0
  53. package/dist/server/core-routes-plugin.js.map +1 -1
  54. package/dist/server/poll-events.d.ts +7 -1
  55. package/dist/server/poll-events.d.ts.map +1 -1
  56. package/dist/server/poll-events.js +24 -7
  57. package/dist/server/poll-events.js.map +1 -1
  58. package/dist/server/poll.d.ts +223 -42
  59. package/dist/server/poll.d.ts.map +1 -1
  60. package/dist/server/poll.js +849 -737
  61. package/dist/server/poll.js.map +1 -1
  62. package/dist/server/realtime-token.d.ts +34 -0
  63. package/dist/server/realtime-token.d.ts.map +1 -0
  64. package/dist/server/realtime-token.js +85 -0
  65. package/dist/server/realtime-token.js.map +1 -0
  66. package/dist/server/sentry-config.d.ts +12 -0
  67. package/dist/server/sentry-config.d.ts.map +1 -1
  68. package/dist/server/sentry-config.js +34 -0
  69. package/dist/server/sentry-config.js.map +1 -1
  70. package/dist/server/short-lived-token.d.ts +46 -0
  71. package/dist/server/short-lived-token.d.ts.map +1 -1
  72. package/dist/server/short-lived-token.js +104 -0
  73. package/dist/server/short-lived-token.js.map +1 -1
  74. package/dist/server/sse.d.ts +1 -1
  75. package/dist/server/sse.d.ts.map +1 -1
  76. package/dist/server/sse.js +4 -0
  77. package/dist/server/sse.js.map +1 -1
  78. package/dist/server/ssr-handler.d.ts.map +1 -1
  79. package/dist/server/ssr-handler.js +5 -3
  80. package/dist/server/ssr-handler.js.map +1 -1
  81. package/dist/server/transcribe-voice.d.ts +1 -1
  82. package/package.json +4 -1
  83. package/src/client/analytics.ts +7 -0
  84. package/src/client/use-db-sync.ts +354 -15
  85. package/src/collab/client.ts +26 -2
  86. package/src/db/ddl-guard.ts +49 -10
  87. package/src/deploy/build.ts +25 -2
  88. package/src/realtime-protocol.ts +92 -0
  89. package/src/server/core-routes-plugin.ts +7 -0
  90. package/src/server/poll-events.ts +29 -8
  91. package/src/server/poll.ts +1123 -865
  92. package/src/server/realtime-token.ts +106 -0
  93. package/src/server/sentry-config.ts +40 -0
  94. package/src/server/short-lived-token.ts +156 -0
  95. package/src/server/sse.ts +15 -0
  96. package/src/server/ssr-handler.ts +9 -3
package/corpus/README.md CHANGED
@@ -28,6 +28,6 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
 
29
29
  ## Generated Counts
30
30
 
31
- - core files: 1529
31
+ - core files: 1531
32
32
  - toolkit files: 145
33
33
  - template files: 6410
@@ -1,5 +1,20 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.117.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d73eda3: Realtime sync: framework prerequisites for the hosted Realtime Sync Gateway. All new behavior is opt-in — apps without hosted-realtime config are unchanged.
8
+ - Refactor `poll.ts` into an `AppSyncState` class holding all previously module-global change-tracking state (version counter, ring buffer, poll emitter, watermarks, and the access cache). Module-level exports (`recordChange`, `getVersion`, `getPollEmitter`, `getChangesSinceForUser`, `canSeeChangeForUser`, `createPollHandler`, `invalidateCollabAccessCache`) delegate to a lazily-created default instance bound to the process DB, so self-hosted apps are unchanged. `createPollHandler` and `createPollEventsHandler` accept an optional injected `AppSyncState`.
9
+ - `AppSyncState` accepts an injected DB accessor, Postgres check, and access resolver, and exposes `getCombinedChangesSinceForUser`/`checkExternalDbChanges`/`persistSyncEvent` for reuse. `ddl-guard` helpers accept a `dialectIsPostgres` override so injected per-app clients get the guarded Postgres DDL path regardless of the process-global DB.
10
+ - The per-user access cache key now includes the active `orgId`, so a decision cached in one org is never reused under another org's session.
11
+ - Add `readMinSyncEventVersion()` (oldest retained durable version) for stale-cursor detection, and an opt-in `deterministicEventIds` mode so multiple processes detecting the same out-of-band write collapse to one durable row. Both off/unused by default.
12
+ - New public export subpaths: `./server/poll`, `./server/sse`, `./server/short-lived-token`.
13
+ - New realtime subscribe tokens: `signRealtimeSubscribeToken`/`verifyRealtimeSubscribeToken` — per-project HMAC key, identity-bearing claims (`owner`/`orgId` required), `projectId` channel binding, and a `typ` discriminator so they are not interchangeable with media tokens. Existing `signShortLivedToken`/`verifyShortLivedToken` are untouched.
14
+ - New session-gated, same-origin endpoint `GET /_agent-native/realtime-token` mounted by core-routes. Fail-closed: responds 404 unless the app is provisioned with a per-project signing secret (`AGENT_NATIVE_REALTIME_HMAC_SECRET`) and a Builder project id; responses are `Cache-Control: private, no-store`.
15
+ - New shared realtime wire protocol (`realtime-protocol.ts`, re-exported from `./server/sse`): named SSE `handshake`/`token` control frames; data/batch frames unchanged.
16
+ - Client transport (`useDbSync`/`subscribeSyncEvents`) gains an opt-in hosted-gateway mode, enabled only when the SSR config sets `realtime.transport = "hosted"` with an explicit gateway URL: token mint/rotation, jittered reconnects, and automatic health-gated fallback to the app's own `/poll` + `/events`. `onSseStateChange` callbacks now also receive the negotiated capability list (optional second parameter; existing callbacks are unaffected), which collab uses to keep its fast presence cadence on `no-awareness` streams.
17
+
3
18
  ## 0.116.0
4
19
 
5
20
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.116.0",
3
+ "version": "0.117.0",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -68,6 +68,9 @@
68
68
  "./server/agent-discovery": "./dist/server/agent-discovery.js",
69
69
  "./server/request-context": "./dist/server/request-context.js",
70
70
  "./server/design-token-utils": "./dist/server/design-token-utils.js",
71
+ "./server/poll": "./dist/server/poll.js",
72
+ "./server/sse": "./dist/server/sse.js",
73
+ "./server/short-lived-token": "./dist/server/short-lived-token.js",
71
74
  "./integrations": "./dist/integrations/index.js",
72
75
  "./integrations/runtime": "./dist/integrations/runtime.js",
73
76
  "./ingestion": "./dist/ingestion/index.js",
@@ -50,6 +50,13 @@ declare global {
50
50
  __AGENT_NATIVE_CONFIG__?: {
51
51
  sentryDsn?: string;
52
52
  sentryEnvironment?: string;
53
+ /**
54
+ * Hosted Realtime Gateway config. Impersonal (same for every visitor),
55
+ * so it is safe inside the CDN-cached SSR shell — unlike the per-user
56
+ * subscribe token, which is minted client-side after load. Absent when
57
+ * the app uses the in-process (local) transport.
58
+ */
59
+ realtime?: { transport?: string; gatewayBaseUrl?: string };
53
60
  };
54
61
  }
55
62
  }
@@ -1,6 +1,13 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { ensureDemoModeFetchInterceptor } from "../demo/fetch-interceptor.js";
4
+ import {
5
+ parseHandshakeFrame,
6
+ parseTokenFrame,
7
+ REALTIME_PROTOCOL_VERSION,
8
+ REALTIME_SSE_HANDSHAKE_EVENT,
9
+ REALTIME_SSE_TOKEN_EVENT,
10
+ } from "../realtime-protocol.js";
4
11
  import { agentNativePath } from "./api-path.js";
5
12
  import { getBrowserTabId } from "./browser-tab-id.js";
6
13
  import {
@@ -88,6 +95,64 @@ function resolveSseUrl(sseUrl: string | false | undefined): string | false {
88
95
  return agentNativePath(sseUrl ?? "/_agent-native/events");
89
96
  }
90
97
 
98
+ // --- Hosted Realtime Gateway binding ----------------------------------------
99
+ //
100
+ // When the app is configured for the hosted gateway, the transport connects to
101
+ // the gateway (cross-origin) instead of the Netlify app, carrying a short-lived
102
+ // subscribe token minted from the app's own same-origin endpoint. All of this
103
+ // is gated on a non-null binding — apps without hosted config keep the exact
104
+ // local behavior below.
105
+
106
+ const REALTIME_GATEWAY_SSE_PATH = "/stream";
107
+ const REALTIME_GATEWAY_POLL_PATH = "/poll";
108
+ const REALTIME_TOKEN_MINT_PATH = "/_agent-native/realtime-token";
109
+ /** Consecutive gateway failures before health-gating back to the local app. */
110
+ const HOSTED_UNHEALTHY_THRESHOLD = 3;
111
+
112
+ interface RealtimeGatewayBinding {
113
+ /** Gateway SSE URL (token appended per connect). */
114
+ sseUrl: string;
115
+ /** Gateway poll URL (token appended per request). */
116
+ pollUrl: string;
117
+ /** Same-origin app endpoint that mints the subscribe token. */
118
+ tokenMintUrl: string;
119
+ }
120
+
121
+ function getRealtimeConfig():
122
+ | { transport?: string; gatewayBaseUrl?: string }
123
+ | undefined {
124
+ if (typeof window === "undefined") return undefined;
125
+ return window.__AGENT_NATIVE_CONFIG__?.realtime;
126
+ }
127
+
128
+ /**
129
+ * Resolve the hosted-gateway binding, or null to stay on the local app. Gated
130
+ * on: SSE enabled (the gateway is push-first), not embed auth (needs the
131
+ * same-origin session to mint), and `transport: "hosted"` with a base URL in
132
+ * the impersonal SSR config.
133
+ */
134
+ function resolveGatewayBinding(
135
+ localSseUrl: string | false,
136
+ ): RealtimeGatewayBinding | null {
137
+ if (localSseUrl === false) return null;
138
+ if (isEmbedAuthActive()) return null;
139
+ const config = getRealtimeConfig();
140
+ if (config?.transport !== "hosted") return null;
141
+ const base = config.gatewayBaseUrl?.replace(/\/+$/, "");
142
+ if (!base) return null;
143
+ return {
144
+ sseUrl: `${base}${REALTIME_GATEWAY_SSE_PATH}`,
145
+ pollUrl: `${base}${REALTIME_GATEWAY_POLL_PATH}`,
146
+ tokenMintUrl: agentNativePath(REALTIME_TOKEN_MINT_PATH),
147
+ };
148
+ }
149
+
150
+ /** ±20% jitter so gateway timeout/deploy-driven reconnects don't stampede. */
151
+ function applyReconnectJitter(delay: number): number {
152
+ const jitter = delay * 0.2 * (Math.random() * 2 - 1);
153
+ return Math.max(0, Math.round(delay + jitter));
154
+ }
155
+
91
156
  function normalizeEventPayload(payload: unknown): SyncEvent[] {
92
157
  if (!payload || typeof payload !== "object") return [];
93
158
  const record = payload as { type?: unknown; events?: unknown };
@@ -158,6 +223,7 @@ async function fetchPollJson<T>(
158
223
  pollUrl: string,
159
224
  since: number,
160
225
  interval: number,
226
+ token?: string,
161
227
  ): Promise<T> {
162
228
  const controller =
163
229
  typeof AbortController === "undefined" ? null : new AbortController();
@@ -165,9 +231,16 @@ async function fetchPollJson<T>(
165
231
  ? setTimeout(() => controller.abort(), getPollAbortMs(interval))
166
232
  : null;
167
233
 
234
+ // Local path stays exactly `?since=N`; the hosted gateway also carries the
235
+ // subscribe token on the query string (a cross-origin fetch can't set the
236
+ // Authorization header for the SSE sibling either, so both use the query).
237
+ const url = token
238
+ ? `${pollUrl}${pollUrl.includes("?") ? "&" : "?"}since=${since}&token=${encodeURIComponent(token)}`
239
+ : `${pollUrl}?since=${since}`;
240
+
168
241
  try {
169
242
  const res = await fetch(
170
- `${pollUrl}?since=${since}`,
243
+ url,
171
244
  controller ? { signal: controller.signal } : undefined,
172
245
  );
173
246
  if (!res.ok) throw new HttpStatusError(res.status);
@@ -215,7 +288,10 @@ interface TransportSubscription {
215
288
  * subscribers with their own fallback loops (e.g. the collab doc poll)
216
289
  * relax their cadence while the push path is healthy.
217
290
  */
218
- onSseStateChange?: (connected: boolean) => void;
291
+ onSseStateChange?: (
292
+ connected: boolean,
293
+ capabilities?: readonly string[],
294
+ ) => void;
219
295
  }
220
296
 
221
297
  class SyncTransport {
@@ -229,11 +305,148 @@ class SyncTransport {
229
305
  private authFailureUntil = 0;
230
306
  private consecutiveFailures = 0;
231
307
  private activeChatIds = new Set<string>();
308
+ // Hosted-gateway state. `mode` starts "hosted" when a binding is present and
309
+ // flips to "local" on health-gate revert; `token` is the current subscribe
310
+ // token (minted from the app, rotated over the stream), never part of any
311
+ // registry key.
312
+ private mode: "hosted" | "local";
313
+ private token: string | null = null;
314
+ private tokenMintInFlight: Promise<boolean> | null = null;
315
+ private gatewayReconnectTimer: ReturnType<typeof setTimeout> | null = null;
316
+ private capabilities: string[] = [];
232
317
 
233
318
  constructor(
234
319
  private readonly pollUrl: string,
235
320
  private readonly sseUrl: string | false,
236
- ) {}
321
+ private readonly gateway: RealtimeGatewayBinding | null = null,
322
+ ) {
323
+ this.mode = gateway ? "hosted" : "local";
324
+ }
325
+
326
+ /** Capabilities advertised by the gateway handshake (e.g. `no-awareness`). */
327
+ getCapabilities(): readonly string[] {
328
+ return this.capabilities;
329
+ }
330
+
331
+ private get activeSseUrl(): string | false {
332
+ if (this.mode === "hosted" && this.gateway) {
333
+ return this.token
334
+ ? `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`
335
+ : this.gateway.sseUrl;
336
+ }
337
+ return this.sseUrl;
338
+ }
339
+
340
+ private get activePollUrl(): string {
341
+ return this.mode === "hosted" && this.gateway
342
+ ? this.gateway.pollUrl
343
+ : this.pollUrl;
344
+ }
345
+
346
+ /**
347
+ * Mint a subscribe token from the app's same-origin endpoint.
348
+ *
349
+ * Only TERMINAL outcomes health-gate to local: 404 (gateway not provisioned)
350
+ * and 401/403 (not authorized) — retrying those for this tab is pointless.
351
+ * TRANSIENT failures (5xx/429 from a cold Netlify function, network errors)
352
+ * keep the hosted intent and ride the jittered reconnect + unhealthy-threshold
353
+ * path, so a deploy / scale-to-zero blip doesn't permanently abandon the
354
+ * gateway for the tab.
355
+ */
356
+ private mintToken(): Promise<boolean> {
357
+ if (!this.gateway || this.mode !== "hosted") return Promise.resolve(false);
358
+ if (this.tokenMintInFlight) return this.tokenMintInFlight;
359
+ const mintUrl = this.gateway.tokenMintUrl;
360
+ this.tokenMintInFlight = (async () => {
361
+ // Bound the mint like fetchPollJson bounds polls: a black-holed request
362
+ // must resolve as a transient failure, not hang tokenMintInFlight forever
363
+ // (poll() awaits this while holding inFlight, so a hung mint would stall
364
+ // the whole transport with no timer pending).
365
+ const controller =
366
+ typeof AbortController === "undefined" ? null : new AbortController();
367
+ const timeout = controller
368
+ ? setTimeout(() => controller.abort(), POLL_ABORT_MIN_MS)
369
+ : null;
370
+ try {
371
+ const res = await fetch(mintUrl, {
372
+ credentials: "same-origin",
373
+ ...(controller ? { signal: controller.signal } : {}),
374
+ });
375
+ if (res.ok) {
376
+ const data = (await res.json()) as { token?: unknown };
377
+ if (typeof data?.token === "string" && data.token) {
378
+ this.token = data.token;
379
+ // Deliberately NOT resetting consecutiveFailures here: minting
380
+ // succeeds via the app origin even when the GATEWAY is down, so a
381
+ // reset would let a mint-ok -> stream-fail loop run forever below
382
+ // the unhealthy threshold. Only real gateway connectivity (stream
383
+ // onopen / poll success) clears the count.
384
+ return true;
385
+ }
386
+ // 2xx without a token is a terminal misconfiguration.
387
+ this.revertToLocal();
388
+ return false;
389
+ }
390
+ if (res.status === 404 || res.status === 401 || res.status === 403) {
391
+ this.revertToLocal();
392
+ return false;
393
+ }
394
+ this.onGatewayTransientFailure();
395
+ return false;
396
+ } catch {
397
+ this.onGatewayTransientFailure();
398
+ return false;
399
+ } finally {
400
+ if (timeout) clearTimeout(timeout);
401
+ this.tokenMintInFlight = null;
402
+ }
403
+ })();
404
+ return this.tokenMintInFlight;
405
+ }
406
+
407
+ /**
408
+ * A transient gateway failure (mint 5xx/429, network). Keep hosted intent but
409
+ * count toward the unhealthy threshold; revert to local only once it trips.
410
+ */
411
+ private onGatewayTransientFailure(): void {
412
+ this.consecutiveFailures++;
413
+ if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
414
+ this.revertToLocal();
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Health-gate back to the app's own /poll + /events with the cursor intact
420
+ * (versionRef is untouched), so delivery stays poll-equivalent — never a
421
+ * silent stall.
422
+ */
423
+ private revertToLocal(): void {
424
+ if (this.mode === "local") return;
425
+ this.mode = "local";
426
+ this.token = null;
427
+ // The local in-process SSE path sends no handshake, so hosted capabilities
428
+ // (e.g. no-awareness) must not survive the fallback — stale caps would keep
429
+ // collab on its fast presence cadence against the local stream. Subscribers
430
+ // are re-notified via the close/connect cycle below.
431
+ this.capabilities = [];
432
+ if (this.gatewayReconnectTimer) {
433
+ clearTimeout(this.gatewayReconnectTimer);
434
+ this.gatewayReconnectTimer = null;
435
+ }
436
+ this.closeEvents();
437
+ if (!this.stopped) {
438
+ this.connectEvents();
439
+ this.schedulePoll();
440
+ }
441
+ }
442
+
443
+ private scheduleGatewayReconnect(): void {
444
+ if (this.stopped || this.gatewayReconnectTimer) return;
445
+ this.gatewayReconnectTimer = setTimeout(() => {
446
+ this.gatewayReconnectTimer = null;
447
+ if (!this.stopped && !this.eventSource) this.connectEvents();
448
+ }, applyReconnectJitter(1000));
449
+ }
237
450
 
238
451
  // -------------------------------------------------------------------------
239
452
  // Subscriber management
@@ -253,7 +466,7 @@ class SyncTransport {
253
466
  } else {
254
467
  this.reschedule();
255
468
  }
256
- sub.onSseStateChange?.(this.sseConnected);
469
+ sub.onSseStateChange?.(this.sseConnected, this.capabilities);
257
470
  }
258
471
 
259
472
  remove(id: symbol): void {
@@ -320,8 +533,18 @@ class SyncTransport {
320
533
  private setSseConnected(connected: boolean): void {
321
534
  if (this.sseConnected === connected) return;
322
535
  this.sseConnected = connected;
536
+ this.notifySseState();
537
+ }
538
+
539
+ /**
540
+ * Notify subscribers of the current SSE state AND the negotiated gateway
541
+ * capabilities. Called on connect/disconnect and again once the handshake
542
+ * arrives, so a consumer (e.g. collab) can decide — for instance — not to
543
+ * relax its presence cadence on a `no-awareness` hosted stream.
544
+ */
545
+ private notifySseState(): void {
323
546
  for (const sub of this.subscribers.values()) {
324
- sub.onSseStateChange?.(connected);
547
+ sub.onSseStateChange?.(this.sseConnected, this.capabilities);
325
548
  }
326
549
  }
327
550
 
@@ -357,10 +580,14 @@ class SyncTransport {
357
580
  // DNS blips, a struggling DB). Auth failures have their own cooldown
358
581
  // above; this covers everything else so a down server isn't hammered at
359
582
  // full cadence. Resets on the first successful poll.
360
- const delay =
583
+ const backoff =
361
584
  this.consecutiveFailures > 0
362
585
  ? Math.min(base * 2 ** Math.min(this.consecutiveFailures, 5), 300_000)
363
586
  : base;
587
+ // Jitter only for gateway-capable transports so reconnect/poll retries
588
+ // don't stampede a gateway deploy; apps with no gateway config keep the
589
+ // exact deterministic cadence.
590
+ const delay = this.gateway ? applyReconnectJitter(backoff) : backoff;
364
591
  this.timer = setTimeout(() => {
365
592
  this.timer = null;
366
593
  void this.poll();
@@ -387,7 +614,6 @@ class SyncTransport {
387
614
  private connectEvents(): void {
388
615
  if (
389
616
  this.stopped ||
390
- !this.sseUrl ||
391
617
  this.eventSource ||
392
618
  typeof EventSource === "undefined" ||
393
619
  (this.effectivePauseWhenHidden && isDocumentHidden())
@@ -395,10 +621,38 @@ class SyncTransport {
395
621
  return;
396
622
  }
397
623
 
398
- const source = new EventSource(this.sseUrl);
624
+ // Hosted gateway needs a subscribe token before the stream can open.
625
+ // EventSource can't set headers, so the token rides the connect query
626
+ // string (see activeSseUrl). Mint first, then connect.
627
+ if (this.mode === "hosted" && this.gateway && !this.token) {
628
+ void this.mintToken().then((ok) => {
629
+ if (this.stopped) return;
630
+ if (ok && !this.eventSource) {
631
+ this.connectEvents();
632
+ } else if (!ok && this.mode === "hosted") {
633
+ // Transient mint failure (terminal ones already reverted to local,
634
+ // flipping mode). Without a retry timer nothing would ever reopen
635
+ // SSE — connectEvents is only reachable from focus/visibility/run
636
+ // events — leaving the tab poll-only at the idle cadence.
637
+ this.scheduleGatewayReconnect();
638
+ }
639
+ });
640
+ return;
641
+ }
642
+
643
+ const url = this.activeSseUrl;
644
+ if (!url) return;
645
+
646
+ const source = new EventSource(url);
399
647
  this.eventSource = source;
400
648
  source.onopen = () => {
401
649
  this.setSseConnected(true);
650
+ if (this.mode === "hosted") {
651
+ // A live gateway stream is the real health signal: clear failure
652
+ // counts accumulated by mint/stream retries. Local mode keeps main's
653
+ // semantics (only a successful poll resets the poll backoff).
654
+ this.consecutiveFailures = 0;
655
+ }
402
656
  this.schedulePoll();
403
657
  };
404
658
  source.onerror = () => {
@@ -410,6 +664,19 @@ class SyncTransport {
410
664
  // we'd be stuck on polling-only forever.
411
665
  if (source.readyState === EventSource.CLOSED) {
412
666
  this.eventSource = null;
667
+ if (this.mode === "hosted" && this.gateway) {
668
+ // A closed gateway stream is most likely an expired token or a
669
+ // request-timeout/deploy cycle. Re-mint and reconnect with jitter;
670
+ // this is NOT the poll-401 cooldown path. Each closed stream counts
671
+ // toward the unhealthy threshold so a hard-down gateway (or one
672
+ // rejecting our tokens) health-gates to local instead of looping
673
+ // mint+connect forever; a successful reconnect resets the count in
674
+ // onopen above.
675
+ this.token = null;
676
+ this.onGatewayTransientFailure();
677
+ if (this.mode === "hosted") this.scheduleGatewayReconnect();
678
+ return;
679
+ }
413
680
  }
414
681
  this.schedulePoll();
415
682
  };
@@ -425,6 +692,38 @@ class SyncTransport {
425
692
  // Ignore malformed SSE frames; polling is the safety net.
426
693
  }
427
694
  };
695
+
696
+ if (this.mode === "hosted" && this.gateway) {
697
+ // Control frames ride NAMED SSE events so they never reach onmessage /
698
+ // normalizeEventPayload as spurious data events.
699
+ source.addEventListener(REALTIME_SSE_HANDSHAKE_EVENT, (e) => {
700
+ const hs = parseHandshakeFrame((e as MessageEvent).data);
701
+ if (!hs) return;
702
+ if (hs.protocol !== REALTIME_PROTOCOL_VERSION) {
703
+ // Surface an unexpected protocol rather than silently adopting its
704
+ // capabilities; keep the conservative (no advertised capabilities)
705
+ // stance so downstream (collab) does not relax on assumptions.
706
+ console.warn(
707
+ `[agent-native] unsupported realtime protocol ${hs.protocol} (expected ${REALTIME_PROTOCOL_VERSION})`,
708
+ );
709
+ return;
710
+ }
711
+ this.capabilities = hs.capabilities;
712
+ // Re-notify subscribers now that capabilities are known — the initial
713
+ // connected notification fired before the handshake arrived.
714
+ this.notifySseState();
715
+ });
716
+ source.addEventListener(REALTIME_SSE_TOKEN_EVENT, (e) => {
717
+ const frame = parseTokenFrame((e as MessageEvent).data);
718
+ if (!frame?.token) return;
719
+ this.token = frame.token;
720
+ // EventSource can't change a live stream's URL, and its auto-reconnect
721
+ // reuses the original (old-token) URL. Close and reconnect (jittered) so
722
+ // the rotated token is actually used on the next connect.
723
+ this.closeEvents();
724
+ this.scheduleGatewayReconnect();
725
+ });
726
+ }
428
727
  }
429
728
 
430
729
  /**
@@ -446,10 +745,17 @@ class SyncTransport {
446
745
  if (this.stopped || this.inFlight) return;
447
746
  this.inFlight = true;
448
747
  try {
748
+ if (this.mode === "hosted" && this.gateway && !this.token) {
749
+ // No token yet — mint before polling the gateway. A failed mint has
750
+ // already reverted us to local; a scheduled poll will pick it up.
751
+ const ok = await this.mintToken();
752
+ if (!ok || this.stopped) return;
753
+ }
449
754
  const data = await fetchPollJson<PollResponse>(
450
- this.pollUrl,
755
+ this.activePollUrl,
451
756
  this.versionRef,
452
757
  this.effectiveInterval,
758
+ this.mode === "hosted" ? (this.token ?? undefined) : undefined,
453
759
  );
454
760
  if (this.stopped) return;
455
761
  this.consecutiveFailures = 0;
@@ -459,7 +765,18 @@ class SyncTransport {
459
765
  } catch (err) {
460
766
  if (this.stopped) return;
461
767
  this.consecutiveFailures++;
462
- if (isAuthFailure(err)) {
768
+ if (this.mode === "hosted" && this.gateway) {
769
+ // Gateway auth failure → re-mint (expired/rotated token), WITHOUT
770
+ // tripping the poll-401 cooldown. Persistent failures of any kind
771
+ // health-gate back to the local app.
772
+ if (isAuthFailure(err)) {
773
+ this.token = null;
774
+ void this.mintToken();
775
+ }
776
+ if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
777
+ this.revertToLocal();
778
+ }
779
+ } else if (isAuthFailure(err)) {
463
780
  this.authFailureUntil = Date.now() + POLL_AUTH_FAILURE_COOLDOWN_MS;
464
781
  this.closeEvents();
465
782
  }
@@ -563,6 +880,10 @@ class SyncTransport {
563
880
  clearTimeout(this.timer);
564
881
  this.timer = null;
565
882
  }
883
+ if (this.gatewayReconnectTimer) {
884
+ clearTimeout(this.gatewayReconnectTimer);
885
+ this.gatewayReconnectTimer = null;
886
+ }
566
887
  window.removeEventListener("focus", this.handleFocus);
567
888
  window.removeEventListener(
568
889
  "agentNative.chatRunning",
@@ -585,11 +906,14 @@ const transportRegistry = new Map<string, SyncTransport>();
585
906
  function getOrCreateTransport(
586
907
  pollUrl: string,
587
908
  sseUrl: string | false,
909
+ gateway: RealtimeGatewayBinding | null = null,
588
910
  ): SyncTransport {
911
+ // Key on the LOCAL urls only — a transport may flip hosted→local at runtime,
912
+ // and the token must never fragment the registry, so neither is in the key.
589
913
  const key = `${pollUrl}\0${String(sseUrl)}`;
590
914
  let transport = transportRegistry.get(key);
591
915
  if (!transport) {
592
- transport = new SyncTransport(pollUrl, sseUrl);
916
+ transport = new SyncTransport(pollUrl, sseUrl, gateway);
593
917
  transportRegistry.set(key, transport);
594
918
  }
595
919
  return transport;
@@ -616,7 +940,10 @@ export interface SubscribeSyncEventsOptions {
616
940
  /** Receives every batch of change events (SSE push or poll). */
617
941
  onEvents: (events: SyncEvent[], version: number | undefined) => void;
618
942
  /** Notified when the shared SSE connection opens/closes (and once on join). */
619
- onSseStateChange?: (connected: boolean) => void;
943
+ onSseStateChange?: (
944
+ connected: boolean,
945
+ capabilities?: readonly string[],
946
+ ) => void;
620
947
  pollUrl?: string;
621
948
  sseUrl?: string | false;
622
949
  pauseWhenHidden?: boolean;
@@ -645,7 +972,11 @@ export function subscribeSyncEvents(
645
972
  ): () => void {
646
973
  const pollUrl = agentNativePath(options.pollUrl ?? "/_agent-native/poll");
647
974
  const sseUrl = resolveSseUrl(options.sseUrl);
648
- const transport = getOrCreateTransport(pollUrl, sseUrl);
975
+ const transport = getOrCreateTransport(
976
+ pollUrl,
977
+ sseUrl,
978
+ resolveGatewayBinding(sseUrl),
979
+ );
649
980
  const id = Symbol("subscribeSyncEvents");
650
981
  transport.add(id, {
651
982
  onEvents: options.onEvents,
@@ -988,7 +1319,11 @@ export function useDbSync(
988
1319
  );
989
1320
  }
990
1321
 
991
- const transport = getOrCreateTransport(pollUrl, sseUrl);
1322
+ const transport = getOrCreateTransport(
1323
+ pollUrl,
1324
+ sseUrl,
1325
+ resolveGatewayBinding(sseUrl),
1326
+ );
992
1327
  transport.add(id, {
993
1328
  onEvents,
994
1329
  pauseWhenHidden,
@@ -1094,7 +1429,11 @@ export function useScreenRefreshKey(
1094
1429
  );
1095
1430
  }
1096
1431
 
1097
- const transport = getOrCreateTransport(pollUrl, sseUrl);
1432
+ const transport = getOrCreateTransport(
1433
+ pollUrl,
1434
+ sseUrl,
1435
+ resolveGatewayBinding(sseUrl),
1436
+ );
1098
1437
  transport.add(id, {
1099
1438
  onEvents,
1100
1439
  pauseWhenHidden,
@@ -45,6 +45,7 @@ import * as Y from "yjs";
45
45
 
46
46
  import { agentNativePath } from "../client/api-path.js";
47
47
  import { subscribeSyncEvents, type SyncEvent } from "../client/use-db-sync.js";
48
+ import { REALTIME_CAP_NO_AWARENESS } from "../realtime-protocol.js";
48
49
  export {
49
50
  dedupeCollabUsersByEmail,
50
51
  emailToColor,
@@ -326,6 +327,12 @@ class CollabDocConnection {
326
327
  private pollVersion = 0;
327
328
  private lastPolledVersion = 0;
328
329
  private sseActive = false;
330
+ // Whether the active SSE stream actually forwards awareness. The hosted
331
+ // Realtime Gateway advertises `no-awareness` (it can't see the in-process
332
+ // awareness emitter), so on that transport we must NOT relax the presence
333
+ // poll cadence — otherwise remote cursors go stale. In-process SSE forwards
334
+ // awareness and sends no handshake, so this stays true there.
335
+ private sseAwarenessCovered = false;
329
336
  private sseSubscribedWithPause: boolean | null = null;
330
337
  private unsubscribeCollabEvents: (() => void) | null = null;
331
338
  private unsubscribeAwarenessEvents: (() => void) | null = null;
@@ -760,8 +767,22 @@ class CollabDocConnection {
760
767
  if (this.disposed || !this.syncActive) return;
761
768
  for (const change of events) this.handleSharedEvent(change);
762
769
  },
763
- onSseStateChange: (connected) => {
770
+ onSseStateChange: (connected, capabilities) => {
771
+ const wasActive = this.sseActive;
764
772
  this.sseActive = connected;
773
+ // Only treat SSE as covering awareness when it's connected AND does not
774
+ // advertise `no-awareness` (the hosted gateway does). Drives whether we
775
+ // relax the presence poll — see getActivePollInterval.
776
+ const awarenessCovered =
777
+ connected && !capabilities?.includes(REALTIME_CAP_NO_AWARENESS);
778
+ const coverageFlipped = awarenessCovered !== this.sseAwarenessCovered;
779
+ this.sseAwarenessCovered = awarenessCovered;
780
+ // The gateway handshake lands AFTER onopen, so a relaxed poll timer
781
+ // scheduled at connect can already be pending when `no-awareness`
782
+ // arrives. Reschedule only for that mid-connection capability flip so
783
+ // the fast presence cadence applies immediately; connect/disconnect
784
+ // transitions keep the pre-existing next-natural-tick behavior.
785
+ if (coverageFlipped && connected === wasActive) this.reschedulePoll();
765
786
  if (connected) this.consecutiveErrors = 0;
766
787
  },
767
788
  pauseWhenHidden,
@@ -811,7 +832,10 @@ class CollabDocConnection {
811
832
  }
812
833
 
813
834
  private getActivePollInterval(): number {
814
- return this.sseActive
835
+ // Relax to the slow cadence only when SSE is genuinely carrying awareness;
836
+ // on a `no-awareness` hosted stream keep the fast cadence so presence/
837
+ // cursor state doesn't go stale (the gateway doesn't forward awareness).
838
+ return this.sseActive && this.sseAwarenessCovered
815
839
  ? this.effectivePollIntervalWithSse
816
840
  : this.effectivePollInterval;
817
841
  }