@agent-native/core 0.116.0 → 0.117.1
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 +21 -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/deploy/workspace-deploy.ts +14 -4
- 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/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +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/deploy/workspace-deploy.js +14 -4
- package/dist/deploy/workspace-deploy.js.map +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/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/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/deploy/workspace-deploy.ts +14 -4
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { ensureDemoModeFetchInterceptor } from "../demo/fetch-interceptor.js";
|
|
3
|
+
import { parseHandshakeFrame, parseTokenFrame, REALTIME_PROTOCOL_VERSION, REALTIME_SSE_HANDSHAKE_EVENT, REALTIME_SSE_TOKEN_EVENT, } from "../realtime-protocol.js";
|
|
3
4
|
import { agentNativePath } from "./api-path.js";
|
|
4
5
|
import { getBrowserTabId } from "./browser-tab-id.js";
|
|
5
6
|
import { ensureEmbedAuthFetchInterceptor, isEmbedAuthActive, } from "./embed-auth.js";
|
|
@@ -39,6 +40,51 @@ function resolveSseUrl(sseUrl) {
|
|
|
39
40
|
return false;
|
|
40
41
|
return agentNativePath(sseUrl ?? "/_agent-native/events");
|
|
41
42
|
}
|
|
43
|
+
// --- Hosted Realtime Gateway binding ----------------------------------------
|
|
44
|
+
//
|
|
45
|
+
// When the app is configured for the hosted gateway, the transport connects to
|
|
46
|
+
// the gateway (cross-origin) instead of the Netlify app, carrying a short-lived
|
|
47
|
+
// subscribe token minted from the app's own same-origin endpoint. All of this
|
|
48
|
+
// is gated on a non-null binding — apps without hosted config keep the exact
|
|
49
|
+
// local behavior below.
|
|
50
|
+
const REALTIME_GATEWAY_SSE_PATH = "/stream";
|
|
51
|
+
const REALTIME_GATEWAY_POLL_PATH = "/poll";
|
|
52
|
+
const REALTIME_TOKEN_MINT_PATH = "/_agent-native/realtime-token";
|
|
53
|
+
/** Consecutive gateway failures before health-gating back to the local app. */
|
|
54
|
+
const HOSTED_UNHEALTHY_THRESHOLD = 3;
|
|
55
|
+
function getRealtimeConfig() {
|
|
56
|
+
if (typeof window === "undefined")
|
|
57
|
+
return undefined;
|
|
58
|
+
return window.__AGENT_NATIVE_CONFIG__?.realtime;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the hosted-gateway binding, or null to stay on the local app. Gated
|
|
62
|
+
* on: SSE enabled (the gateway is push-first), not embed auth (needs the
|
|
63
|
+
* same-origin session to mint), and `transport: "hosted"` with a base URL in
|
|
64
|
+
* the impersonal SSR config.
|
|
65
|
+
*/
|
|
66
|
+
function resolveGatewayBinding(localSseUrl) {
|
|
67
|
+
if (localSseUrl === false)
|
|
68
|
+
return null;
|
|
69
|
+
if (isEmbedAuthActive())
|
|
70
|
+
return null;
|
|
71
|
+
const config = getRealtimeConfig();
|
|
72
|
+
if (config?.transport !== "hosted")
|
|
73
|
+
return null;
|
|
74
|
+
const base = config.gatewayBaseUrl?.replace(/\/+$/, "");
|
|
75
|
+
if (!base)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
sseUrl: `${base}${REALTIME_GATEWAY_SSE_PATH}`,
|
|
79
|
+
pollUrl: `${base}${REALTIME_GATEWAY_POLL_PATH}`,
|
|
80
|
+
tokenMintUrl: agentNativePath(REALTIME_TOKEN_MINT_PATH),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** ±20% jitter so gateway timeout/deploy-driven reconnects don't stampede. */
|
|
84
|
+
function applyReconnectJitter(delay) {
|
|
85
|
+
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
|
86
|
+
return Math.max(0, Math.round(delay + jitter));
|
|
87
|
+
}
|
|
42
88
|
function normalizeEventPayload(payload) {
|
|
43
89
|
if (!payload || typeof payload !== "object")
|
|
44
90
|
return [];
|
|
@@ -91,13 +137,19 @@ export function isInteractionCriticalSyncEvent(event) {
|
|
|
91
137
|
INTERACTION_CRITICAL_APP_STATE_KEYS.some((key) => event.key === key ||
|
|
92
138
|
(typeof event.key === "string" && event.key.startsWith(`${key}:`)))));
|
|
93
139
|
}
|
|
94
|
-
async function fetchPollJson(pollUrl, since, interval) {
|
|
140
|
+
async function fetchPollJson(pollUrl, since, interval, token) {
|
|
95
141
|
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
96
142
|
const timeout = controller
|
|
97
143
|
? setTimeout(() => controller.abort(), getPollAbortMs(interval))
|
|
98
144
|
: null;
|
|
145
|
+
// Local path stays exactly `?since=N`; the hosted gateway also carries the
|
|
146
|
+
// subscribe token on the query string (a cross-origin fetch can't set the
|
|
147
|
+
// Authorization header for the SSE sibling either, so both use the query).
|
|
148
|
+
const url = token
|
|
149
|
+
? `${pollUrl}${pollUrl.includes("?") ? "&" : "?"}since=${since}&token=${encodeURIComponent(token)}`
|
|
150
|
+
: `${pollUrl}?since=${since}`;
|
|
99
151
|
try {
|
|
100
|
-
const res = await fetch(
|
|
152
|
+
const res = await fetch(url, controller ? { signal: controller.signal } : undefined);
|
|
101
153
|
if (!res.ok)
|
|
102
154
|
throw new HttpStatusError(res.status);
|
|
103
155
|
// Await the json before the finally so a body-stream abort doesn't
|
|
@@ -112,6 +164,7 @@ async function fetchPollJson(pollUrl, since, interval) {
|
|
|
112
164
|
class SyncTransport {
|
|
113
165
|
pollUrl;
|
|
114
166
|
sseUrl;
|
|
167
|
+
gateway;
|
|
115
168
|
subscribers = new Map();
|
|
116
169
|
versionRef = 0;
|
|
117
170
|
timer = null;
|
|
@@ -122,9 +175,145 @@ class SyncTransport {
|
|
|
122
175
|
authFailureUntil = 0;
|
|
123
176
|
consecutiveFailures = 0;
|
|
124
177
|
activeChatIds = new Set();
|
|
125
|
-
|
|
178
|
+
// Hosted-gateway state. `mode` starts "hosted" when a binding is present and
|
|
179
|
+
// flips to "local" on health-gate revert; `token` is the current subscribe
|
|
180
|
+
// token (minted from the app, rotated over the stream), never part of any
|
|
181
|
+
// registry key.
|
|
182
|
+
mode;
|
|
183
|
+
token = null;
|
|
184
|
+
tokenMintInFlight = null;
|
|
185
|
+
gatewayReconnectTimer = null;
|
|
186
|
+
capabilities = [];
|
|
187
|
+
constructor(pollUrl, sseUrl, gateway = null) {
|
|
126
188
|
this.pollUrl = pollUrl;
|
|
127
189
|
this.sseUrl = sseUrl;
|
|
190
|
+
this.gateway = gateway;
|
|
191
|
+
this.mode = gateway ? "hosted" : "local";
|
|
192
|
+
}
|
|
193
|
+
/** Capabilities advertised by the gateway handshake (e.g. `no-awareness`). */
|
|
194
|
+
getCapabilities() {
|
|
195
|
+
return this.capabilities;
|
|
196
|
+
}
|
|
197
|
+
get activeSseUrl() {
|
|
198
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
199
|
+
return this.token
|
|
200
|
+
? `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`
|
|
201
|
+
: this.gateway.sseUrl;
|
|
202
|
+
}
|
|
203
|
+
return this.sseUrl;
|
|
204
|
+
}
|
|
205
|
+
get activePollUrl() {
|
|
206
|
+
return this.mode === "hosted" && this.gateway
|
|
207
|
+
? this.gateway.pollUrl
|
|
208
|
+
: this.pollUrl;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Mint a subscribe token from the app's same-origin endpoint.
|
|
212
|
+
*
|
|
213
|
+
* Only TERMINAL outcomes health-gate to local: 404 (gateway not provisioned)
|
|
214
|
+
* and 401/403 (not authorized) — retrying those for this tab is pointless.
|
|
215
|
+
* TRANSIENT failures (5xx/429 from a cold Netlify function, network errors)
|
|
216
|
+
* keep the hosted intent and ride the jittered reconnect + unhealthy-threshold
|
|
217
|
+
* path, so a deploy / scale-to-zero blip doesn't permanently abandon the
|
|
218
|
+
* gateway for the tab.
|
|
219
|
+
*/
|
|
220
|
+
mintToken() {
|
|
221
|
+
if (!this.gateway || this.mode !== "hosted")
|
|
222
|
+
return Promise.resolve(false);
|
|
223
|
+
if (this.tokenMintInFlight)
|
|
224
|
+
return this.tokenMintInFlight;
|
|
225
|
+
const mintUrl = this.gateway.tokenMintUrl;
|
|
226
|
+
this.tokenMintInFlight = (async () => {
|
|
227
|
+
// Bound the mint like fetchPollJson bounds polls: a black-holed request
|
|
228
|
+
// must resolve as a transient failure, not hang tokenMintInFlight forever
|
|
229
|
+
// (poll() awaits this while holding inFlight, so a hung mint would stall
|
|
230
|
+
// the whole transport with no timer pending).
|
|
231
|
+
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
232
|
+
const timeout = controller
|
|
233
|
+
? setTimeout(() => controller.abort(), POLL_ABORT_MIN_MS)
|
|
234
|
+
: null;
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch(mintUrl, {
|
|
237
|
+
credentials: "same-origin",
|
|
238
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
239
|
+
});
|
|
240
|
+
if (res.ok) {
|
|
241
|
+
const data = (await res.json());
|
|
242
|
+
if (typeof data?.token === "string" && data.token) {
|
|
243
|
+
this.token = data.token;
|
|
244
|
+
// Deliberately NOT resetting consecutiveFailures here: minting
|
|
245
|
+
// succeeds via the app origin even when the GATEWAY is down, so a
|
|
246
|
+
// reset would let a mint-ok -> stream-fail loop run forever below
|
|
247
|
+
// the unhealthy threshold. Only real gateway connectivity (stream
|
|
248
|
+
// onopen / poll success) clears the count.
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
// 2xx without a token is a terminal misconfiguration.
|
|
252
|
+
this.revertToLocal();
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
if (res.status === 404 || res.status === 401 || res.status === 403) {
|
|
256
|
+
this.revertToLocal();
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
this.onGatewayTransientFailure();
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
this.onGatewayTransientFailure();
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
if (timeout)
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
this.tokenMintInFlight = null;
|
|
270
|
+
}
|
|
271
|
+
})();
|
|
272
|
+
return this.tokenMintInFlight;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* A transient gateway failure (mint 5xx/429, network). Keep hosted intent but
|
|
276
|
+
* count toward the unhealthy threshold; revert to local only once it trips.
|
|
277
|
+
*/
|
|
278
|
+
onGatewayTransientFailure() {
|
|
279
|
+
this.consecutiveFailures++;
|
|
280
|
+
if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
|
|
281
|
+
this.revertToLocal();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Health-gate back to the app's own /poll + /events with the cursor intact
|
|
286
|
+
* (versionRef is untouched), so delivery stays poll-equivalent — never a
|
|
287
|
+
* silent stall.
|
|
288
|
+
*/
|
|
289
|
+
revertToLocal() {
|
|
290
|
+
if (this.mode === "local")
|
|
291
|
+
return;
|
|
292
|
+
this.mode = "local";
|
|
293
|
+
this.token = null;
|
|
294
|
+
// The local in-process SSE path sends no handshake, so hosted capabilities
|
|
295
|
+
// (e.g. no-awareness) must not survive the fallback — stale caps would keep
|
|
296
|
+
// collab on its fast presence cadence against the local stream. Subscribers
|
|
297
|
+
// are re-notified via the close/connect cycle below.
|
|
298
|
+
this.capabilities = [];
|
|
299
|
+
if (this.gatewayReconnectTimer) {
|
|
300
|
+
clearTimeout(this.gatewayReconnectTimer);
|
|
301
|
+
this.gatewayReconnectTimer = null;
|
|
302
|
+
}
|
|
303
|
+
this.closeEvents();
|
|
304
|
+
if (!this.stopped) {
|
|
305
|
+
this.connectEvents();
|
|
306
|
+
this.schedulePoll();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
scheduleGatewayReconnect() {
|
|
310
|
+
if (this.stopped || this.gatewayReconnectTimer)
|
|
311
|
+
return;
|
|
312
|
+
this.gatewayReconnectTimer = setTimeout(() => {
|
|
313
|
+
this.gatewayReconnectTimer = null;
|
|
314
|
+
if (!this.stopped && !this.eventSource)
|
|
315
|
+
this.connectEvents();
|
|
316
|
+
}, applyReconnectJitter(1000));
|
|
128
317
|
}
|
|
129
318
|
// -------------------------------------------------------------------------
|
|
130
319
|
// Subscriber management
|
|
@@ -145,7 +334,7 @@ class SyncTransport {
|
|
|
145
334
|
else {
|
|
146
335
|
this.reschedule();
|
|
147
336
|
}
|
|
148
|
-
sub.onSseStateChange?.(this.sseConnected);
|
|
337
|
+
sub.onSseStateChange?.(this.sseConnected, this.capabilities);
|
|
149
338
|
}
|
|
150
339
|
remove(id) {
|
|
151
340
|
this.subscribers.delete(id);
|
|
@@ -208,8 +397,17 @@ class SyncTransport {
|
|
|
208
397
|
if (this.sseConnected === connected)
|
|
209
398
|
return;
|
|
210
399
|
this.sseConnected = connected;
|
|
400
|
+
this.notifySseState();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Notify subscribers of the current SSE state AND the negotiated gateway
|
|
404
|
+
* capabilities. Called on connect/disconnect and again once the handshake
|
|
405
|
+
* arrives, so a consumer (e.g. collab) can decide — for instance — not to
|
|
406
|
+
* relax its presence cadence on a `no-awareness` hosted stream.
|
|
407
|
+
*/
|
|
408
|
+
notifySseState() {
|
|
211
409
|
for (const sub of this.subscribers.values()) {
|
|
212
|
-
sub.onSseStateChange?.(
|
|
410
|
+
sub.onSseStateChange?.(this.sseConnected, this.capabilities);
|
|
213
411
|
}
|
|
214
412
|
}
|
|
215
413
|
// -------------------------------------------------------------------------
|
|
@@ -245,9 +443,13 @@ class SyncTransport {
|
|
|
245
443
|
// DNS blips, a struggling DB). Auth failures have their own cooldown
|
|
246
444
|
// above; this covers everything else so a down server isn't hammered at
|
|
247
445
|
// full cadence. Resets on the first successful poll.
|
|
248
|
-
const
|
|
446
|
+
const backoff = this.consecutiveFailures > 0
|
|
249
447
|
? Math.min(base * 2 ** Math.min(this.consecutiveFailures, 5), 300_000)
|
|
250
448
|
: base;
|
|
449
|
+
// Jitter only for gateway-capable transports so reconnect/poll retries
|
|
450
|
+
// don't stampede a gateway deploy; apps with no gateway config keep the
|
|
451
|
+
// exact deterministic cadence.
|
|
452
|
+
const delay = this.gateway ? applyReconnectJitter(backoff) : backoff;
|
|
251
453
|
this.timer = setTimeout(() => {
|
|
252
454
|
this.timer = null;
|
|
253
455
|
void this.poll();
|
|
@@ -271,16 +473,44 @@ class SyncTransport {
|
|
|
271
473
|
}
|
|
272
474
|
connectEvents() {
|
|
273
475
|
if (this.stopped ||
|
|
274
|
-
!this.sseUrl ||
|
|
275
476
|
this.eventSource ||
|
|
276
477
|
typeof EventSource === "undefined" ||
|
|
277
478
|
(this.effectivePauseWhenHidden && isDocumentHidden())) {
|
|
278
479
|
return;
|
|
279
480
|
}
|
|
280
|
-
|
|
481
|
+
// Hosted gateway needs a subscribe token before the stream can open.
|
|
482
|
+
// EventSource can't set headers, so the token rides the connect query
|
|
483
|
+
// string (see activeSseUrl). Mint first, then connect.
|
|
484
|
+
if (this.mode === "hosted" && this.gateway && !this.token) {
|
|
485
|
+
void this.mintToken().then((ok) => {
|
|
486
|
+
if (this.stopped)
|
|
487
|
+
return;
|
|
488
|
+
if (ok && !this.eventSource) {
|
|
489
|
+
this.connectEvents();
|
|
490
|
+
}
|
|
491
|
+
else if (!ok && this.mode === "hosted") {
|
|
492
|
+
// Transient mint failure (terminal ones already reverted to local,
|
|
493
|
+
// flipping mode). Without a retry timer nothing would ever reopen
|
|
494
|
+
// SSE — connectEvents is only reachable from focus/visibility/run
|
|
495
|
+
// events — leaving the tab poll-only at the idle cadence.
|
|
496
|
+
this.scheduleGatewayReconnect();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const url = this.activeSseUrl;
|
|
502
|
+
if (!url)
|
|
503
|
+
return;
|
|
504
|
+
const source = new EventSource(url);
|
|
281
505
|
this.eventSource = source;
|
|
282
506
|
source.onopen = () => {
|
|
283
507
|
this.setSseConnected(true);
|
|
508
|
+
if (this.mode === "hosted") {
|
|
509
|
+
// A live gateway stream is the real health signal: clear failure
|
|
510
|
+
// counts accumulated by mint/stream retries. Local mode keeps main's
|
|
511
|
+
// semantics (only a successful poll resets the poll backoff).
|
|
512
|
+
this.consecutiveFailures = 0;
|
|
513
|
+
}
|
|
284
514
|
this.schedulePoll();
|
|
285
515
|
};
|
|
286
516
|
source.onerror = () => {
|
|
@@ -292,6 +522,20 @@ class SyncTransport {
|
|
|
292
522
|
// we'd be stuck on polling-only forever.
|
|
293
523
|
if (source.readyState === EventSource.CLOSED) {
|
|
294
524
|
this.eventSource = null;
|
|
525
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
526
|
+
// A closed gateway stream is most likely an expired token or a
|
|
527
|
+
// request-timeout/deploy cycle. Re-mint and reconnect with jitter;
|
|
528
|
+
// this is NOT the poll-401 cooldown path. Each closed stream counts
|
|
529
|
+
// toward the unhealthy threshold so a hard-down gateway (or one
|
|
530
|
+
// rejecting our tokens) health-gates to local instead of looping
|
|
531
|
+
// mint+connect forever; a successful reconnect resets the count in
|
|
532
|
+
// onopen above.
|
|
533
|
+
this.token = null;
|
|
534
|
+
this.onGatewayTransientFailure();
|
|
535
|
+
if (this.mode === "hosted")
|
|
536
|
+
this.scheduleGatewayReconnect();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
295
539
|
}
|
|
296
540
|
this.schedulePoll();
|
|
297
541
|
};
|
|
@@ -307,6 +551,37 @@ class SyncTransport {
|
|
|
307
551
|
// Ignore malformed SSE frames; polling is the safety net.
|
|
308
552
|
}
|
|
309
553
|
};
|
|
554
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
555
|
+
// Control frames ride NAMED SSE events so they never reach onmessage /
|
|
556
|
+
// normalizeEventPayload as spurious data events.
|
|
557
|
+
source.addEventListener(REALTIME_SSE_HANDSHAKE_EVENT, (e) => {
|
|
558
|
+
const hs = parseHandshakeFrame(e.data);
|
|
559
|
+
if (!hs)
|
|
560
|
+
return;
|
|
561
|
+
if (hs.protocol !== REALTIME_PROTOCOL_VERSION) {
|
|
562
|
+
// Surface an unexpected protocol rather than silently adopting its
|
|
563
|
+
// capabilities; keep the conservative (no advertised capabilities)
|
|
564
|
+
// stance so downstream (collab) does not relax on assumptions.
|
|
565
|
+
console.warn(`[agent-native] unsupported realtime protocol ${hs.protocol} (expected ${REALTIME_PROTOCOL_VERSION})`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
this.capabilities = hs.capabilities;
|
|
569
|
+
// Re-notify subscribers now that capabilities are known — the initial
|
|
570
|
+
// connected notification fired before the handshake arrived.
|
|
571
|
+
this.notifySseState();
|
|
572
|
+
});
|
|
573
|
+
source.addEventListener(REALTIME_SSE_TOKEN_EVENT, (e) => {
|
|
574
|
+
const frame = parseTokenFrame(e.data);
|
|
575
|
+
if (!frame?.token)
|
|
576
|
+
return;
|
|
577
|
+
this.token = frame.token;
|
|
578
|
+
// EventSource can't change a live stream's URL, and its auto-reconnect
|
|
579
|
+
// reuses the original (old-token) URL. Close and reconnect (jittered) so
|
|
580
|
+
// the rotated token is actually used on the next connect.
|
|
581
|
+
this.closeEvents();
|
|
582
|
+
this.scheduleGatewayReconnect();
|
|
583
|
+
});
|
|
584
|
+
}
|
|
310
585
|
}
|
|
311
586
|
/**
|
|
312
587
|
* Advance the transport's shared version cursor. Subscribers receive the
|
|
@@ -329,7 +604,14 @@ class SyncTransport {
|
|
|
329
604
|
return;
|
|
330
605
|
this.inFlight = true;
|
|
331
606
|
try {
|
|
332
|
-
|
|
607
|
+
if (this.mode === "hosted" && this.gateway && !this.token) {
|
|
608
|
+
// No token yet — mint before polling the gateway. A failed mint has
|
|
609
|
+
// already reverted us to local; a scheduled poll will pick it up.
|
|
610
|
+
const ok = await this.mintToken();
|
|
611
|
+
if (!ok || this.stopped)
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const data = await fetchPollJson(this.activePollUrl, this.versionRef, this.effectiveInterval, this.mode === "hosted" ? (this.token ?? undefined) : undefined);
|
|
333
615
|
if (this.stopped)
|
|
334
616
|
return;
|
|
335
617
|
this.consecutiveFailures = 0;
|
|
@@ -341,7 +623,19 @@ class SyncTransport {
|
|
|
341
623
|
if (this.stopped)
|
|
342
624
|
return;
|
|
343
625
|
this.consecutiveFailures++;
|
|
344
|
-
if (
|
|
626
|
+
if (this.mode === "hosted" && this.gateway) {
|
|
627
|
+
// Gateway auth failure → re-mint (expired/rotated token), WITHOUT
|
|
628
|
+
// tripping the poll-401 cooldown. Persistent failures of any kind
|
|
629
|
+
// health-gate back to the local app.
|
|
630
|
+
if (isAuthFailure(err)) {
|
|
631
|
+
this.token = null;
|
|
632
|
+
void this.mintToken();
|
|
633
|
+
}
|
|
634
|
+
if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
|
|
635
|
+
this.revertToLocal();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
else if (isAuthFailure(err)) {
|
|
345
639
|
this.authFailureUntil = Date.now() + POLL_AUTH_FAILURE_COOLDOWN_MS;
|
|
346
640
|
this.closeEvents();
|
|
347
641
|
}
|
|
@@ -437,6 +731,10 @@ class SyncTransport {
|
|
|
437
731
|
clearTimeout(this.timer);
|
|
438
732
|
this.timer = null;
|
|
439
733
|
}
|
|
734
|
+
if (this.gatewayReconnectTimer) {
|
|
735
|
+
clearTimeout(this.gatewayReconnectTimer);
|
|
736
|
+
this.gatewayReconnectTimer = null;
|
|
737
|
+
}
|
|
440
738
|
window.removeEventListener("focus", this.handleFocus);
|
|
441
739
|
window.removeEventListener("agentNative.chatRunning", this.handleChatRunning);
|
|
442
740
|
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
@@ -448,11 +746,13 @@ class SyncTransport {
|
|
|
448
746
|
* hook instances in the same browser tab.
|
|
449
747
|
*/
|
|
450
748
|
const transportRegistry = new Map();
|
|
451
|
-
function getOrCreateTransport(pollUrl, sseUrl) {
|
|
749
|
+
function getOrCreateTransport(pollUrl, sseUrl, gateway = null) {
|
|
750
|
+
// Key on the LOCAL urls only — a transport may flip hosted→local at runtime,
|
|
751
|
+
// and the token must never fragment the registry, so neither is in the key.
|
|
452
752
|
const key = `${pollUrl}\0${String(sseUrl)}`;
|
|
453
753
|
let transport = transportRegistry.get(key);
|
|
454
754
|
if (!transport) {
|
|
455
|
-
transport = new SyncTransport(pollUrl, sseUrl);
|
|
755
|
+
transport = new SyncTransport(pollUrl, sseUrl, gateway);
|
|
456
756
|
transportRegistry.set(key, transport);
|
|
457
757
|
}
|
|
458
758
|
return transport;
|
|
@@ -485,7 +785,7 @@ export function _resetSyncTransportRegistryForTests() {
|
|
|
485
785
|
export function subscribeSyncEvents(options) {
|
|
486
786
|
const pollUrl = agentNativePath(options.pollUrl ?? "/_agent-native/poll");
|
|
487
787
|
const sseUrl = resolveSseUrl(options.sseUrl);
|
|
488
|
-
const transport = getOrCreateTransport(pollUrl, sseUrl);
|
|
788
|
+
const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
|
|
489
789
|
const id = Symbol("subscribeSyncEvents");
|
|
490
790
|
transport.add(id, {
|
|
491
791
|
onEvents: options.onEvents,
|
|
@@ -741,7 +1041,7 @@ export function useDbSync(options = {}) {
|
|
|
741
1041
|
const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
|
|
742
1042
|
subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
|
|
743
1043
|
}
|
|
744
|
-
const transport = getOrCreateTransport(pollUrl, sseUrl);
|
|
1044
|
+
const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
|
|
745
1045
|
transport.add(id, {
|
|
746
1046
|
onEvents,
|
|
747
1047
|
pauseWhenHidden,
|
|
@@ -815,7 +1115,7 @@ export function useScreenRefreshKey(options = {}) {
|
|
|
815
1115
|
const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
|
|
816
1116
|
subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
|
|
817
1117
|
}
|
|
818
|
-
const transport = getOrCreateTransport(pollUrl, sseUrl);
|
|
1118
|
+
const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
|
|
819
1119
|
transport.add(id, {
|
|
820
1120
|
onEvents,
|
|
821
1121
|
pauseWhenHidden,
|