@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/package.json +4 -1
- package/corpus/core/src/client/analytics.ts +7 -0
- package/corpus/core/src/client/use-db-sync.ts +354 -15
- package/corpus/core/src/collab/client.ts +26 -2
- package/corpus/core/src/db/ddl-guard.ts +49 -10
- package/corpus/core/src/deploy/build.ts +25 -2
- package/corpus/core/src/realtime-protocol.ts +92 -0
- package/corpus/core/src/server/core-routes-plugin.ts +7 -0
- package/corpus/core/src/server/poll-events.ts +29 -8
- package/corpus/core/src/server/poll.ts +1123 -865
- package/corpus/core/src/server/realtime-token.ts +106 -0
- package/corpus/core/src/server/sentry-config.ts +40 -0
- package/corpus/core/src/server/short-lived-token.ts +156 -0
- package/corpus/core/src/server/sse.ts +15 -0
- package/corpus/core/src/server/ssr-handler.ts +9 -3
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
- package/corpus/templates/content/app/i18n-data.ts +90 -0
- package/dist/client/analytics.d.ts +10 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +1 -1
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +315 -15
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +26 -2
- package/dist/collab/client.js.map +1 -1
- package/dist/db/ddl-guard.d.ts +7 -2
- package/dist/db/ddl-guard.d.ts.map +1 -1
- package/dist/db/ddl-guard.js +21 -9
- package/dist/db/ddl-guard.js.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +25 -2
- package/dist/deploy/build.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/realtime-protocol.d.ts +40 -0
- package/dist/realtime-protocol.d.ts.map +1 -0
- package/dist/realtime-protocol.js +69 -0
- package/dist/realtime-protocol.js.map +1 -0
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/poll-events.d.ts +7 -1
- package/dist/server/poll-events.d.ts.map +1 -1
- package/dist/server/poll-events.js +24 -7
- package/dist/server/poll-events.js.map +1 -1
- package/dist/server/poll.d.ts +223 -42
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +849 -737
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +34 -0
- package/dist/server/realtime-token.d.ts.map +1 -0
- package/dist/server/realtime-token.js +85 -0
- package/dist/server/realtime-token.js.map +1 -0
- package/dist/server/sentry-config.d.ts +12 -0
- package/dist/server/sentry-config.d.ts.map +1 -1
- package/dist/server/sentry-config.js +34 -0
- package/dist/server/sentry-config.js.map +1 -1
- package/dist/server/short-lived-token.d.ts +46 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +104 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +4 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +5 -3
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +4 -1
- package/src/client/analytics.ts +7 -0
- package/src/client/use-db-sync.ts +354 -15
- package/src/collab/client.ts +26 -2
- package/src/db/ddl-guard.ts +49 -10
- package/src/deploy/build.ts +25 -2
- package/src/realtime-protocol.ts +92 -0
- package/src/server/core-routes-plugin.ts +7 -0
- package/src/server/poll-events.ts +29 -8
- package/src/server/poll.ts +1123 -865
- package/src/server/realtime-token.ts +106 -0
- package/src/server/sentry-config.ts +40 -0
- package/src/server/short-lived-token.ts +156 -0
- package/src/server/sse.ts +15 -0
- package/src/server/ssr-handler.ts +9 -3
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -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
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "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
|
-
|
|
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?: (
|
|
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?.(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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?: (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|