@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.
Files changed (191) hide show
  1. package/dist/{chunk-SGWPW2AO.cjs → chunk-2A74VRAF.cjs} +18 -18
  2. package/dist/{chunk-SGWPW2AO.cjs.map → chunk-2A74VRAF.cjs.map} +1 -1
  3. package/dist/{chunk-LILQJ3UZ.cjs → chunk-3ENGUBJP.cjs} +1 -1
  4. package/dist/chunk-3ENGUBJP.cjs.map +1 -0
  5. package/dist/{chunk-AWDHRIL3.cjs → chunk-4ST2BOWJ.cjs} +75 -75
  6. package/dist/{chunk-AWDHRIL3.cjs.map → chunk-4ST2BOWJ.cjs.map} +1 -1
  7. package/dist/{chunk-SFJRMBJC.js → chunk-5SVNA7IU.js} +2 -2
  8. package/dist/{chunk-OAWFYZZT.js → chunk-7DOSAJDK.js} +3 -3
  9. package/dist/{chunk-J4T5ZBWG.cjs → chunk-BZRP54IO.cjs} +19 -19
  10. package/dist/{chunk-J4T5ZBWG.cjs.map → chunk-BZRP54IO.cjs.map} +1 -1
  11. package/dist/{chunk-BZF63JXP.js → chunk-CRJVO4KO.js} +6 -6
  12. package/dist/{chunk-5RIHLXEP.cjs → chunk-DDVQ5NKK.cjs} +33 -33
  13. package/dist/{chunk-5RIHLXEP.cjs.map → chunk-DDVQ5NKK.cjs.map} +1 -1
  14. package/dist/{chunk-HR6F6IP4.js → chunk-DIKJLAMY.js} +78 -43
  15. package/dist/chunk-DIKJLAMY.js.map +1 -0
  16. package/dist/{chunk-VO2ZONMR.js → chunk-FNTFWP2A.js} +3 -3
  17. package/dist/{chunk-2OXJ6NHI.cjs → chunk-GJZ42TTG.cjs} +7 -7
  18. package/dist/{chunk-2OXJ6NHI.cjs.map → chunk-GJZ42TTG.cjs.map} +1 -1
  19. package/dist/{chunk-JIZJDAXI.cjs → chunk-GXRVBKYX.cjs} +12 -12
  20. package/dist/{chunk-JIZJDAXI.cjs.map → chunk-GXRVBKYX.cjs.map} +1 -1
  21. package/dist/{chunk-F7AD5CUU.js → chunk-H3N4P3CG.js} +3 -3
  22. package/dist/chunk-H3N4P3CG.js.map +1 -0
  23. package/dist/{chunk-VCPTU4IW.js → chunk-HWER6KGU.js} +7 -7
  24. package/dist/{chunk-4SR2QHMB.js → chunk-ITVWX5HX.js} +3 -3
  25. package/dist/{chunk-FFXNRJ5S.cjs → chunk-LOBR7IC6.cjs} +6 -6
  26. package/dist/chunk-LOBR7IC6.cjs.map +1 -0
  27. package/dist/{chunk-72DF3QTA.cjs → chunk-MJQ263KS.cjs} +14 -14
  28. package/dist/{chunk-72DF3QTA.cjs.map → chunk-MJQ263KS.cjs.map} +1 -1
  29. package/dist/{chunk-FTOVZ7O2.js → chunk-NPO6CHCR.js} +7 -7
  30. package/dist/{chunk-XV5SXALY.js → chunk-NT5FX7GZ.js} +3 -3
  31. package/dist/{chunk-UCV4LFUG.cjs → chunk-NY75FKN6.cjs} +40 -40
  32. package/dist/{chunk-UCV4LFUG.cjs.map → chunk-NY75FKN6.cjs.map} +1 -1
  33. package/dist/{chunk-4UNQMYP7.cjs → chunk-O32Y4E4R.cjs} +3 -3
  34. package/dist/{chunk-4UNQMYP7.cjs.map → chunk-O32Y4E4R.cjs.map} +1 -1
  35. package/dist/{chunk-CLQEBWV7.js → chunk-QE55ROZW.js} +4 -4
  36. package/dist/{chunk-WHJGRRK2.js → chunk-QW7SW5HY.js} +52 -43
  37. package/dist/chunk-QW7SW5HY.js.map +1 -0
  38. package/dist/{chunk-JGXAVNZQ.js → chunk-R6CKL7IR.js} +2464 -1886
  39. package/dist/chunk-R6CKL7IR.js.map +1 -0
  40. package/dist/{chunk-L5CI57RM.cjs → chunk-T3BF6JTQ.cjs} +52 -43
  41. package/dist/chunk-T3BF6JTQ.cjs.map +1 -0
  42. package/dist/{chunk-G43CEISB.js → chunk-TA3VJ2QO.js} +4 -4
  43. package/dist/{chunk-7L2RVINX.cjs → chunk-TRS4CIAS.cjs} +35 -35
  44. package/dist/{chunk-7L2RVINX.cjs.map → chunk-TRS4CIAS.cjs.map} +1 -1
  45. package/dist/{chunk-DMXB3VE2.cjs → chunk-U3YLML53.cjs} +1837 -1259
  46. package/dist/chunk-U3YLML53.cjs.map +1 -0
  47. package/dist/{chunk-3T4SMJM5.cjs → chunk-VDKNUC3N.cjs} +181 -146
  48. package/dist/chunk-VDKNUC3N.cjs.map +1 -0
  49. package/dist/{chunk-RR3Z52X5.cjs → chunk-W3QCFQ56.cjs} +42 -42
  50. package/dist/{chunk-RR3Z52X5.cjs.map → chunk-W3QCFQ56.cjs.map} +1 -1
  51. package/dist/{chunk-VYEDTVIZ.js → chunk-XJPMTJAR.js} +4 -4
  52. package/dist/{chunk-6GT557KN.js → chunk-XKIA4XVE.js} +1 -1
  53. package/dist/chunk-XKIA4XVE.js.map +1 -0
  54. package/dist/components/case-studies/index.cjs +11 -11
  55. package/dist/components/case-studies/index.js +5 -5
  56. package/dist/components/chat/index.cjs +5 -5
  57. package/dist/components/chat/index.js +4 -4
  58. package/dist/components/contact/index.cjs +6 -6
  59. package/dist/components/contact/index.js +5 -5
  60. package/dist/components/docs/index.cjs +8 -8
  61. package/dist/components/docs/index.js +7 -7
  62. package/dist/components/embeds/index.cjs +6 -6
  63. package/dist/components/embeds/index.js +5 -5
  64. package/dist/components/faq/index.cjs +7 -7
  65. package/dist/components/faq/index.js +6 -6
  66. package/dist/components/features/index.cjs +5 -5
  67. package/dist/components/features/index.js +4 -4
  68. package/dist/components/help-center-pages/index.cjs +24 -24
  69. package/dist/components/help-center-pages/index.js +15 -15
  70. package/dist/components/index.cjs +169 -165
  71. package/dist/components/index.cjs.map +1 -1
  72. package/dist/components/index.js +17 -13
  73. package/dist/components/index.js.map +1 -1
  74. package/dist/components/navigation/app-header.d.ts +8 -0
  75. package/dist/components/navigation/app-header.d.ts.map +1 -1
  76. package/dist/components/navigation/header-button.d.ts +5 -1
  77. package/dist/components/navigation/header-button.d.ts.map +1 -1
  78. package/dist/components/navigation/header.d.ts.map +1 -1
  79. package/dist/components/navigation/index.cjs +9 -5
  80. package/dist/components/navigation/index.cjs.map +1 -1
  81. package/dist/components/navigation/index.d.ts +4 -0
  82. package/dist/components/navigation/index.d.ts.map +1 -1
  83. package/dist/components/navigation/index.js +8 -4
  84. package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
  85. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  86. package/dist/components/navigation/ticket-alerts-button.d.ts +29 -0
  87. package/dist/components/navigation/ticket-alerts-button.d.ts.map +1 -0
  88. package/dist/components/navigation/top-navigation.d.ts +5 -1
  89. package/dist/components/navigation/top-navigation.d.ts.map +1 -1
  90. package/dist/components/navigation/unread-dot.d.ts +19 -0
  91. package/dist/components/navigation/unread-dot.d.ts.map +1 -0
  92. package/dist/components/onboarding-guides/index.cjs +9 -9
  93. package/dist/components/onboarding-guides/index.js +8 -8
  94. package/dist/components/related-content/index.cjs +7 -7
  95. package/dist/components/related-content/index.js +6 -6
  96. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  97. package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
  98. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +1 -9
  99. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  100. package/dist/components/tickets/hooks/use-tickets-list.d.ts +0 -7
  101. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  102. package/dist/components/tickets/index.cjs +15 -8
  103. package/dist/components/tickets/index.cjs.map +1 -1
  104. package/dist/components/tickets/index.d.ts +2 -1
  105. package/dist/components/tickets/index.d.ts.map +1 -1
  106. package/dist/components/tickets/index.js +14 -7
  107. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  108. package/dist/components/tickets/ticket-live-provider.d.ts +58 -0
  109. package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -0
  110. package/dist/components/tickets/types.d.ts +58 -11
  111. package/dist/components/tickets/types.d.ts.map +1 -1
  112. package/dist/components/ui/button/button.d.ts +6 -5
  113. package/dist/components/ui/button/button.d.ts.map +1 -1
  114. package/dist/components/ui/file-manager/index.cjs +50 -50
  115. package/dist/components/ui/file-manager/index.js +1 -1
  116. package/dist/components/ui/index.cjs +5 -5
  117. package/dist/components/ui/index.js +4 -4
  118. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  119. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  120. package/dist/contexts/index.cjs +2 -2
  121. package/dist/contexts/index.js +1 -1
  122. package/dist/hooks/index.cjs +3 -3
  123. package/dist/hooks/index.js +2 -2
  124. package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
  125. package/dist/index.cjs +11 -5
  126. package/dist/index.cjs.map +1 -1
  127. package/dist/index.js +10 -4
  128. package/dist/types/navigation.d.ts +10 -0
  129. package/dist/types/navigation.d.ts.map +1 -1
  130. package/dist/utils/index.cjs +171 -0
  131. package/dist/utils/index.cjs.map +1 -1
  132. package/dist/utils/index.d.ts +1 -0
  133. package/dist/utils/index.d.ts.map +1 -1
  134. package/dist/utils/index.js +171 -1
  135. package/dist/utils/index.js.map +1 -1
  136. package/dist/utils/sse-subscription.d.ts +60 -0
  137. package/dist/utils/sse-subscription.d.ts.map +1 -0
  138. package/package.json +1 -1
  139. package/src/components/features/time-tracker/time-tracker-header-button.tsx +1 -1
  140. package/src/components/navigation/app-header.tsx +36 -20
  141. package/src/components/navigation/header-button.tsx +18 -2
  142. package/src/components/navigation/header-mingo-button.tsx +2 -2
  143. package/src/components/navigation/header-skeleton.tsx +4 -4
  144. package/src/components/navigation/header.tsx +41 -21
  145. package/src/components/navigation/index.ts +4 -0
  146. package/src/components/navigation/mingo-ai-button.tsx +23 -22
  147. package/src/components/navigation/mobile-burger-menu.tsx +3 -3
  148. package/src/components/navigation/navigation-sidebar-item.tsx +2 -3
  149. package/src/components/navigation/ticket-alerts-button.tsx +61 -0
  150. package/src/components/navigation/top-navigation.tsx +17 -1
  151. package/src/components/navigation/unread-dot.tsx +34 -0
  152. package/src/components/tickets/.types.md +1 -2
  153. package/src/components/tickets/help-center-card.tsx +16 -0
  154. package/src/components/tickets/help-center-list.tsx +6 -6
  155. package/src/components/tickets/hooks/.use-ticket-engagements.md +3 -12
  156. package/src/components/tickets/hooks/.use-tickets-list.md +0 -2
  157. package/src/components/tickets/hooks/use-ticket-actions.ts +9 -0
  158. package/src/components/tickets/hooks/use-ticket-engagements.ts +4 -13
  159. package/src/components/tickets/hooks/use-tickets-list.ts +2 -13
  160. package/src/components/tickets/index.ts +10 -0
  161. package/src/components/tickets/ticket-detail-drawer.tsx +45 -13
  162. package/src/components/tickets/ticket-live-provider.tsx +474 -0
  163. package/src/components/tickets/types.ts +62 -11
  164. package/src/components/ui/button/button.tsx +165 -133
  165. package/src/contexts/chat-runtime-context.tsx +8 -0
  166. package/src/hooks/ui/use-header-height.ts +3 -1
  167. package/src/stories/TopNavigation.stories.tsx +20 -1
  168. package/src/types/navigation.ts +10 -0
  169. package/src/utils/index.ts +12 -0
  170. package/src/utils/sse-subscription.ts +276 -0
  171. package/dist/chunk-3T4SMJM5.cjs.map +0 -1
  172. package/dist/chunk-6GT557KN.js.map +0 -1
  173. package/dist/chunk-DMXB3VE2.cjs.map +0 -1
  174. package/dist/chunk-F7AD5CUU.js.map +0 -1
  175. package/dist/chunk-FFXNRJ5S.cjs.map +0 -1
  176. package/dist/chunk-HR6F6IP4.js.map +0 -1
  177. package/dist/chunk-JGXAVNZQ.js.map +0 -1
  178. package/dist/chunk-L5CI57RM.cjs.map +0 -1
  179. package/dist/chunk-LILQJ3UZ.cjs.map +0 -1
  180. package/dist/chunk-WHJGRRK2.js.map +0 -1
  181. /package/dist/{chunk-SFJRMBJC.js.map → chunk-5SVNA7IU.js.map} +0 -0
  182. /package/dist/{chunk-OAWFYZZT.js.map → chunk-7DOSAJDK.js.map} +0 -0
  183. /package/dist/{chunk-BZF63JXP.js.map → chunk-CRJVO4KO.js.map} +0 -0
  184. /package/dist/{chunk-VO2ZONMR.js.map → chunk-FNTFWP2A.js.map} +0 -0
  185. /package/dist/{chunk-VCPTU4IW.js.map → chunk-HWER6KGU.js.map} +0 -0
  186. /package/dist/{chunk-4SR2QHMB.js.map → chunk-ITVWX5HX.js.map} +0 -0
  187. /package/dist/{chunk-FTOVZ7O2.js.map → chunk-NPO6CHCR.js.map} +0 -0
  188. /package/dist/{chunk-XV5SXALY.js.map → chunk-NT5FX7GZ.js.map} +0 -0
  189. /package/dist/{chunk-CLQEBWV7.js.map → chunk-QE55ROZW.js.map} +0 -0
  190. /package/dist/{chunk-G43CEISB.js.map → chunk-TA3VJ2QO.js.map} +0 -0
  191. /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
+ }