@flamingo-stack/openframe-frontend-core 0.0.503 → 0.0.504-snapshot.20260801181709
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-SGWPW2AO.cjs → chunk-2A74VRAF.cjs} +18 -18
- package/dist/{chunk-SGWPW2AO.cjs.map → chunk-2A74VRAF.cjs.map} +1 -1
- package/dist/{chunk-LILQJ3UZ.cjs → chunk-3ENGUBJP.cjs} +1 -1
- package/dist/chunk-3ENGUBJP.cjs.map +1 -0
- package/dist/{chunk-AWDHRIL3.cjs → chunk-4ST2BOWJ.cjs} +75 -75
- package/dist/{chunk-AWDHRIL3.cjs.map → chunk-4ST2BOWJ.cjs.map} +1 -1
- package/dist/{chunk-SFJRMBJC.js → chunk-5SVNA7IU.js} +2 -2
- package/dist/{chunk-OAWFYZZT.js → chunk-7DOSAJDK.js} +3 -3
- package/dist/{chunk-J4T5ZBWG.cjs → chunk-BZRP54IO.cjs} +19 -19
- package/dist/{chunk-J4T5ZBWG.cjs.map → chunk-BZRP54IO.cjs.map} +1 -1
- package/dist/{chunk-BZF63JXP.js → chunk-CRJVO4KO.js} +6 -6
- package/dist/{chunk-5RIHLXEP.cjs → chunk-DDVQ5NKK.cjs} +33 -33
- package/dist/{chunk-5RIHLXEP.cjs.map → chunk-DDVQ5NKK.cjs.map} +1 -1
- package/dist/{chunk-HR6F6IP4.js → chunk-DIKJLAMY.js} +78 -43
- package/dist/chunk-DIKJLAMY.js.map +1 -0
- package/dist/{chunk-VO2ZONMR.js → chunk-FNTFWP2A.js} +3 -3
- package/dist/{chunk-2OXJ6NHI.cjs → chunk-GJZ42TTG.cjs} +7 -7
- package/dist/{chunk-2OXJ6NHI.cjs.map → chunk-GJZ42TTG.cjs.map} +1 -1
- package/dist/{chunk-JIZJDAXI.cjs → chunk-GXRVBKYX.cjs} +12 -12
- package/dist/{chunk-JIZJDAXI.cjs.map → chunk-GXRVBKYX.cjs.map} +1 -1
- package/dist/{chunk-F7AD5CUU.js → chunk-H3N4P3CG.js} +3 -3
- package/dist/chunk-H3N4P3CG.js.map +1 -0
- package/dist/{chunk-VCPTU4IW.js → chunk-HWER6KGU.js} +7 -7
- package/dist/{chunk-4SR2QHMB.js → chunk-ITVWX5HX.js} +3 -3
- package/dist/{chunk-FFXNRJ5S.cjs → chunk-LOBR7IC6.cjs} +6 -6
- package/dist/chunk-LOBR7IC6.cjs.map +1 -0
- package/dist/{chunk-72DF3QTA.cjs → chunk-MJQ263KS.cjs} +14 -14
- package/dist/{chunk-72DF3QTA.cjs.map → chunk-MJQ263KS.cjs.map} +1 -1
- package/dist/{chunk-FTOVZ7O2.js → chunk-NPO6CHCR.js} +7 -7
- package/dist/{chunk-XV5SXALY.js → chunk-NT5FX7GZ.js} +3 -3
- package/dist/{chunk-UCV4LFUG.cjs → chunk-NY75FKN6.cjs} +40 -40
- package/dist/{chunk-UCV4LFUG.cjs.map → chunk-NY75FKN6.cjs.map} +1 -1
- package/dist/{chunk-4UNQMYP7.cjs → chunk-O32Y4E4R.cjs} +3 -3
- package/dist/{chunk-4UNQMYP7.cjs.map → chunk-O32Y4E4R.cjs.map} +1 -1
- package/dist/{chunk-CLQEBWV7.js → chunk-QE55ROZW.js} +4 -4
- package/dist/{chunk-WHJGRRK2.js → chunk-QW7SW5HY.js} +52 -43
- package/dist/chunk-QW7SW5HY.js.map +1 -0
- package/dist/{chunk-JGXAVNZQ.js → chunk-R6CKL7IR.js} +2464 -1886
- package/dist/chunk-R6CKL7IR.js.map +1 -0
- package/dist/{chunk-L5CI57RM.cjs → chunk-T3BF6JTQ.cjs} +52 -43
- package/dist/chunk-T3BF6JTQ.cjs.map +1 -0
- package/dist/{chunk-G43CEISB.js → chunk-TA3VJ2QO.js} +4 -4
- package/dist/{chunk-7L2RVINX.cjs → chunk-TRS4CIAS.cjs} +35 -35
- package/dist/{chunk-7L2RVINX.cjs.map → chunk-TRS4CIAS.cjs.map} +1 -1
- package/dist/{chunk-DMXB3VE2.cjs → chunk-U3YLML53.cjs} +1837 -1259
- package/dist/chunk-U3YLML53.cjs.map +1 -0
- package/dist/{chunk-3T4SMJM5.cjs → chunk-VDKNUC3N.cjs} +181 -146
- package/dist/chunk-VDKNUC3N.cjs.map +1 -0
- package/dist/{chunk-RR3Z52X5.cjs → chunk-W3QCFQ56.cjs} +42 -42
- package/dist/{chunk-RR3Z52X5.cjs.map → chunk-W3QCFQ56.cjs.map} +1 -1
- package/dist/{chunk-VYEDTVIZ.js → chunk-XJPMTJAR.js} +4 -4
- package/dist/{chunk-6GT557KN.js → chunk-XKIA4XVE.js} +1 -1
- package/dist/chunk-XKIA4XVE.js.map +1 -0
- package/dist/components/case-studies/index.cjs +11 -11
- package/dist/components/case-studies/index.js +5 -5
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/index.js +4 -4
- package/dist/components/contact/index.cjs +6 -6
- package/dist/components/contact/index.js +5 -5
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +6 -6
- package/dist/components/embeds/index.js +5 -5
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/index.cjs +5 -5
- package/dist/components/features/index.js +4 -4
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +169 -165
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +8 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/header-button.d.ts +5 -1
- package/dist/components/navigation/header-button.d.ts.map +1 -1
- package/dist/components/navigation/header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +9 -5
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +4 -0
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +8 -4
- package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/ticket-alerts-button.d.ts +29 -0
- package/dist/components/navigation/ticket-alerts-button.d.ts.map +1 -0
- package/dist/components/navigation/top-navigation.d.ts +5 -1
- package/dist/components/navigation/top-navigation.d.ts.map +1 -1
- package/dist/components/navigation/unread-dot.d.ts +19 -0
- package/dist/components/navigation/unread-dot.d.ts.map +1 -0
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/related-content/index.js +6 -6
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +1 -9
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +0 -7
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +15 -8
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +2 -1
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +14 -7
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-live-provider.d.ts +58 -0
- package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +58 -11
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/button/button.d.ts +6 -5
- package/dist/components/ui/button/button.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +50 -50
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +5 -5
- package/dist/components/ui/index.js +4 -4
- package/dist/contexts/chat-runtime-context.d.ts +8 -0
- package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
- package/dist/index.cjs +11 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -4
- package/dist/types/navigation.d.ts +10 -0
- package/dist/types/navigation.d.ts.map +1 -1
- package/dist/utils/index.cjs +171 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +171 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/sse-subscription.d.ts +60 -0
- package/dist/utils/sse-subscription.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/features/time-tracker/time-tracker-header-button.tsx +1 -1
- package/src/components/navigation/app-header.tsx +36 -20
- package/src/components/navigation/header-button.tsx +18 -2
- package/src/components/navigation/header-mingo-button.tsx +2 -2
- package/src/components/navigation/header-skeleton.tsx +4 -4
- package/src/components/navigation/header.tsx +41 -21
- package/src/components/navigation/index.ts +4 -0
- package/src/components/navigation/mingo-ai-button.tsx +23 -22
- package/src/components/navigation/mobile-burger-menu.tsx +3 -3
- package/src/components/navigation/navigation-sidebar-item.tsx +2 -3
- package/src/components/navigation/ticket-alerts-button.tsx +61 -0
- package/src/components/navigation/top-navigation.tsx +17 -1
- package/src/components/navigation/unread-dot.tsx +34 -0
- package/src/components/tickets/.types.md +1 -2
- package/src/components/tickets/help-center-card.tsx +16 -0
- package/src/components/tickets/help-center-list.tsx +6 -6
- package/src/components/tickets/hooks/.use-ticket-engagements.md +3 -12
- package/src/components/tickets/hooks/.use-tickets-list.md +0 -2
- package/src/components/tickets/hooks/use-ticket-actions.ts +9 -0
- package/src/components/tickets/hooks/use-ticket-engagements.ts +4 -13
- package/src/components/tickets/hooks/use-tickets-list.ts +2 -13
- package/src/components/tickets/index.ts +10 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +45 -13
- package/src/components/tickets/ticket-live-provider.tsx +474 -0
- package/src/components/tickets/types.ts +62 -11
- package/src/components/ui/button/button.tsx +165 -133
- package/src/contexts/chat-runtime-context.tsx +8 -0
- package/src/hooks/ui/use-header-height.ts +3 -1
- package/src/stories/TopNavigation.stories.tsx +20 -1
- package/src/types/navigation.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/sse-subscription.ts +276 -0
- package/dist/chunk-3T4SMJM5.cjs.map +0 -1
- package/dist/chunk-6GT557KN.js.map +0 -1
- package/dist/chunk-DMXB3VE2.cjs.map +0 -1
- package/dist/chunk-F7AD5CUU.js.map +0 -1
- package/dist/chunk-FFXNRJ5S.cjs.map +0 -1
- package/dist/chunk-HR6F6IP4.js.map +0 -1
- package/dist/chunk-JGXAVNZQ.js.map +0 -1
- package/dist/chunk-L5CI57RM.cjs.map +0 -1
- package/dist/chunk-LILQJ3UZ.cjs.map +0 -1
- package/dist/chunk-WHJGRRK2.js.map +0 -1
- /package/dist/{chunk-SFJRMBJC.js.map → chunk-5SVNA7IU.js.map} +0 -0
- /package/dist/{chunk-OAWFYZZT.js.map → chunk-7DOSAJDK.js.map} +0 -0
- /package/dist/{chunk-BZF63JXP.js.map → chunk-CRJVO4KO.js.map} +0 -0
- /package/dist/{chunk-VO2ZONMR.js.map → chunk-FNTFWP2A.js.map} +0 -0
- /package/dist/{chunk-VCPTU4IW.js.map → chunk-HWER6KGU.js.map} +0 -0
- /package/dist/{chunk-4SR2QHMB.js.map → chunk-ITVWX5HX.js.map} +0 -0
- /package/dist/{chunk-FTOVZ7O2.js.map → chunk-NPO6CHCR.js.map} +0 -0
- /package/dist/{chunk-XV5SXALY.js.map → chunk-NT5FX7GZ.js.map} +0 -0
- /package/dist/{chunk-CLQEBWV7.js.map → chunk-QE55ROZW.js.map} +0 -0
- /package/dist/{chunk-G43CEISB.js.map → chunk-TA3VJ2QO.js.map} +0 -0
- /package/dist/{chunk-VYEDTVIZ.js.map → chunk-XJPMTJAR.js.map} +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch-based SSE subscription primitive.
|
|
3
|
+
*
|
|
4
|
+
* Why not `EventSource`: cross-origin embedders authenticate via the
|
|
5
|
+
* `embedAuthedFetch` adapter's HEADERS (OpenFrame bearer), which
|
|
6
|
+
* `EventSource` cannot carry. This reads `response.body` from a normal
|
|
7
|
+
* authed fetch and parses standard `event:`/`data:` frames.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle (never-terminating by design):
|
|
10
|
+
* - Infinite reconnects with capped exponential backoff (~30s max +
|
|
11
|
+
* jitter), reset on a successful open. Do NOT copy the terminating
|
|
12
|
+
* `maxRetries → exhausted` model of the hub's server-side
|
|
13
|
+
* `RealtimeRetryManager` — a long outage must self-heal when the
|
|
14
|
+
* backend returns, because there is NO polling fallback behind this.
|
|
15
|
+
* - Liveness by silence: the server keepalives every ~15s; any bytes
|
|
16
|
+
* (INCLUDING `: keepalive` comment lines — they reset the timer
|
|
17
|
+
* before frame parsing drops them) count as life. Silence beyond
|
|
18
|
+
* `silenceTimeoutMs` (default 45s = 3× keepalive) aborts + reconnects.
|
|
19
|
+
* - Terminal responses (NO retry): any 4xx except 408/429 — the
|
|
20
|
+
* `x-block-layer` header, when readable, is logged for attribution
|
|
21
|
+
* only, never used as a retry gate (proxies may strip it); and
|
|
22
|
+
* 204 → a distinct `no-stream` status (the caller decides when to
|
|
23
|
+
* try again). Backoff-retry is for transport errors, 408/429, and 5xx.
|
|
24
|
+
*
|
|
25
|
+
* THE common SSE client for lib + hosts — exported from the `utils`
|
|
26
|
+
* barrel. Consumers: `TicketLiveProvider` (lib) and the hub's
|
|
27
|
+
* workflow/invocation stream hooks (which replaced the hub's old
|
|
28
|
+
* EventSource-based `sse-client.ts`). Finite streams (workflows,
|
|
29
|
+
* invocations) end with a terminal event and a server-side close —
|
|
30
|
+
* their handlers MUST call `close()` on the terminal event, or the
|
|
31
|
+
* never-terminating reconnect loop re-opens the finished stream.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { embedAuthedFetch } from './embed-authed-fetch'
|
|
35
|
+
|
|
36
|
+
/** Transport-level status. Server-sent `status` FRAMES (`subscribed` /
|
|
37
|
+
* `retrying` / `reconnect_failed`) arrive via `onEvent('status', …)` —
|
|
38
|
+
* callers derive their "connected" notion from those, not from these. */
|
|
39
|
+
export type SseTransportStatus =
|
|
40
|
+
| 'connecting'
|
|
41
|
+
| 'open'
|
|
42
|
+
| 'reconnecting'
|
|
43
|
+
| 'no-stream'
|
|
44
|
+
| 'terminal'
|
|
45
|
+
| 'closed'
|
|
46
|
+
|
|
47
|
+
export interface SseSubscriptionOptions {
|
|
48
|
+
url: string
|
|
49
|
+
/** Defaults to `embedAuthedFetch` (adapter headers + credentials). */
|
|
50
|
+
fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>
|
|
51
|
+
/** Called per parsed frame: `eventName` from `event:` (default
|
|
52
|
+
* 'message'), `data` JSON-parsed when possible (raw string otherwise). */
|
|
53
|
+
onEvent: (eventName: string, data: unknown) => void
|
|
54
|
+
onStatusChange?: (status: SseTransportStatus) => void
|
|
55
|
+
/** Silence threshold before the connection is presumed dead. MUST be
|
|
56
|
+
* ≥2.5× the server keepalive cadence or jitter causes false-disconnect
|
|
57
|
+
* churn (each one costs a full server invocation). */
|
|
58
|
+
silenceTimeoutMs?: number
|
|
59
|
+
maxBackoffMs?: number
|
|
60
|
+
initialBackoffMs?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SseSubscription {
|
|
64
|
+
/** Permanently stop — no further reconnects, no callbacks. */
|
|
65
|
+
close: () => void
|
|
66
|
+
/** Drop the current connection (if any) and reconnect immediately,
|
|
67
|
+
* resetting backoff. No-op after `close()`. */
|
|
68
|
+
reconnectNow: () => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_SILENCE_TIMEOUT_MS = 45_000
|
|
72
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000
|
|
73
|
+
const DEFAULT_INITIAL_BACKOFF_MS = 1_000
|
|
74
|
+
|
|
75
|
+
export function createSseSubscription(options: SseSubscriptionOptions): SseSubscription {
|
|
76
|
+
const {
|
|
77
|
+
url,
|
|
78
|
+
fetchImpl = embedAuthedFetch,
|
|
79
|
+
onEvent,
|
|
80
|
+
onStatusChange,
|
|
81
|
+
silenceTimeoutMs = DEFAULT_SILENCE_TIMEOUT_MS,
|
|
82
|
+
maxBackoffMs = DEFAULT_MAX_BACKOFF_MS,
|
|
83
|
+
initialBackoffMs = DEFAULT_INITIAL_BACKOFF_MS,
|
|
84
|
+
} = options
|
|
85
|
+
|
|
86
|
+
let closed = false
|
|
87
|
+
let attempt = 0
|
|
88
|
+
/** Connection generation — bumped by each connect() and by
|
|
89
|
+
* reconnectNow(). A stale loop (aborted, still unwinding) compares
|
|
90
|
+
* its captured generation before scheduling a reconnect, so an
|
|
91
|
+
* explicit reconnect can never race a zombie loop into two
|
|
92
|
+
* concurrent connections. */
|
|
93
|
+
let generation = 0
|
|
94
|
+
let abortController: AbortController | null = null
|
|
95
|
+
let retryTimerId: ReturnType<typeof setTimeout> | null = null
|
|
96
|
+
let silenceTimerId: ReturnType<typeof setTimeout> | null = null
|
|
97
|
+
|
|
98
|
+
const setStatus = (status: SseTransportStatus) => {
|
|
99
|
+
if (closed && status !== 'closed') return
|
|
100
|
+
onStatusChange?.(status)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const clearTimers = () => {
|
|
104
|
+
if (retryTimerId) {
|
|
105
|
+
clearTimeout(retryTimerId)
|
|
106
|
+
retryTimerId = null
|
|
107
|
+
}
|
|
108
|
+
if (silenceTimerId) {
|
|
109
|
+
clearTimeout(silenceTimerId)
|
|
110
|
+
silenceTimerId = null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const armSilenceTimer = () => {
|
|
115
|
+
if (silenceTimerId) clearTimeout(silenceTimerId)
|
|
116
|
+
silenceTimerId = setTimeout(() => {
|
|
117
|
+
// Dead-air: the read loop is stuck on a connection that will never
|
|
118
|
+
// produce again. Abort → the loop's catch schedules a reconnect.
|
|
119
|
+
abortController?.abort()
|
|
120
|
+
}, silenceTimeoutMs)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const scheduleReconnect = () => {
|
|
124
|
+
if (closed || retryTimerId) return
|
|
125
|
+
setStatus('reconnecting')
|
|
126
|
+
const backoff = Math.min(maxBackoffMs, initialBackoffMs * 2 ** attempt)
|
|
127
|
+
const jitter = backoff * 0.25 * Math.random()
|
|
128
|
+
attempt += 1
|
|
129
|
+
retryTimerId = setTimeout(() => {
|
|
130
|
+
retryTimerId = null
|
|
131
|
+
void connect()
|
|
132
|
+
}, backoff + jitter)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parseFrame = (rawFrame: string) => {
|
|
136
|
+
let eventName = 'message'
|
|
137
|
+
const dataLines: string[] = []
|
|
138
|
+
for (const line of rawFrame.split('\n')) {
|
|
139
|
+
if (line.startsWith(':')) continue // comment (keepalive) — liveness only
|
|
140
|
+
if (line.startsWith('event:')) {
|
|
141
|
+
eventName = line.slice(6).trim() || 'message'
|
|
142
|
+
} else if (line.startsWith('data:')) {
|
|
143
|
+
dataLines.push(line.slice(5).trimStart())
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (dataLines.length === 0) return
|
|
147
|
+
const rawData = dataLines.join('\n')
|
|
148
|
+
let data: unknown = rawData
|
|
149
|
+
try {
|
|
150
|
+
data = JSON.parse(rawData)
|
|
151
|
+
} catch {
|
|
152
|
+
// Non-JSON data frame — deliver the raw string.
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
onEvent(eventName, data)
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error('[sse-subscription] onEvent handler threw:', err)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const connect = async (): Promise<void> => {
|
|
162
|
+
if (closed) return
|
|
163
|
+
const gen = ++generation
|
|
164
|
+
setStatus('connecting')
|
|
165
|
+
abortController = new AbortController()
|
|
166
|
+
|
|
167
|
+
let response: Response
|
|
168
|
+
try {
|
|
169
|
+
response = await fetchImpl(url, {
|
|
170
|
+
method: 'GET',
|
|
171
|
+
// Explicit Accept — embedAuthedFetch otherwise injects
|
|
172
|
+
// `Content-Type: application/json` defaults meant for POSTs.
|
|
173
|
+
headers: { Accept: 'text/event-stream' },
|
|
174
|
+
signal: abortController.signal,
|
|
175
|
+
})
|
|
176
|
+
} catch {
|
|
177
|
+
// Network error / abort — transport-level, retryable.
|
|
178
|
+
if (!closed && gen === generation) scheduleReconnect()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (closed || gen !== generation) return
|
|
182
|
+
|
|
183
|
+
if (response.status === 204) {
|
|
184
|
+
// Contract: nothing to stream for this identity. Caller decides
|
|
185
|
+
// when to try again — no retry loop here.
|
|
186
|
+
setStatus('no-stream')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500
|
|
191
|
+
if (retryable) {
|
|
192
|
+
scheduleReconnect()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
// Terminal 4xx. x-block-layer (proxy surface block) is logged for
|
|
196
|
+
// attribution when readable — never gates the decision.
|
|
197
|
+
const blockLayer = response.headers.get('x-block-layer')
|
|
198
|
+
console.warn(
|
|
199
|
+
`[sse-subscription] terminal ${response.status} for ${url}${blockLayer ? ` (x-block-layer: ${blockLayer})` : ''} — not retrying`,
|
|
200
|
+
)
|
|
201
|
+
setStatus('terminal')
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (!response.body) {
|
|
205
|
+
scheduleReconnect()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Successful open — reset backoff, start liveness accounting.
|
|
210
|
+
attempt = 0
|
|
211
|
+
setStatus('open')
|
|
212
|
+
armSilenceTimer()
|
|
213
|
+
|
|
214
|
+
const reader = response.body.getReader()
|
|
215
|
+
const decoder = new TextDecoder()
|
|
216
|
+
let buffer = ''
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
const { done, value } = await reader.read()
|
|
220
|
+
if (closed || gen !== generation) return
|
|
221
|
+
if (done) break
|
|
222
|
+
// ANY bytes are liveness — including comment keepalives, which
|
|
223
|
+
// the frame parser below will drop.
|
|
224
|
+
armSilenceTimer()
|
|
225
|
+
buffer += decoder.decode(value, { stream: true })
|
|
226
|
+
// Frames are separated by a blank line (\n\n).
|
|
227
|
+
for (;;) {
|
|
228
|
+
const boundary = buffer.indexOf('\n\n')
|
|
229
|
+
if (boundary === -1) break
|
|
230
|
+
const rawFrame = buffer.slice(0, boundary)
|
|
231
|
+
buffer = buffer.slice(boundary + 2)
|
|
232
|
+
parseFrame(rawFrame)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Aborted (silence timer / reconnectNow) or stream error — fall
|
|
237
|
+
// through to reconnect.
|
|
238
|
+
} finally {
|
|
239
|
+
if (silenceTimerId) {
|
|
240
|
+
clearTimeout(silenceTimerId)
|
|
241
|
+
silenceTimerId = null
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
reader.releaseLock()
|
|
245
|
+
} catch {
|
|
246
|
+
// Already released.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Server closed the stream (e.g. Vercel maxDuration) — reconnect.
|
|
251
|
+
if (!closed && gen === generation) scheduleReconnect()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
void connect()
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
close: () => {
|
|
258
|
+
if (closed) return
|
|
259
|
+
closed = true
|
|
260
|
+
generation += 1
|
|
261
|
+
clearTimers()
|
|
262
|
+
abortController?.abort()
|
|
263
|
+
onStatusChange?.('closed')
|
|
264
|
+
},
|
|
265
|
+
reconnectNow: () => {
|
|
266
|
+
if (closed) return
|
|
267
|
+
// Invalidate the current loop BEFORE aborting so its unwinding
|
|
268
|
+
// never schedules a competing (backoff) reconnect.
|
|
269
|
+
generation += 1
|
|
270
|
+
clearTimers()
|
|
271
|
+
attempt = 0
|
|
272
|
+
abortController?.abort()
|
|
273
|
+
void connect()
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|