@morscherlab/mint-sdk 1.1.25 → 1.2.0-beta.2

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 (72) hide show
  1. package/README.md +9 -7
  2. package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DyPAUCLw.js} +3 -3
  3. package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DyPAUCLw.js.map} +1 -1
  4. package/dist/{SettingsModal-BW3Ar-uy.js → SettingsModal-BgKY93zh.js} +4 -4
  5. package/dist/{SettingsModal-BW3Ar-uy.js.map → SettingsModal-BgKY93zh.js.map} +1 -1
  6. package/dist/__tests__/composables/useEventStream.test.d.ts +1 -0
  7. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  8. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  9. package/dist/components/JobsStatusTray.vue.d.ts +2 -9
  10. package/dist/components/SampleLegend.vue.d.ts +1 -1
  11. package/dist/components/SampleSelector.vue.d.ts +1 -1
  12. package/dist/components/SettingsModal.vue.d.ts +1 -1
  13. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
  14. package/dist/components/index.js +4 -4
  15. package/dist/{components-DQt0tcpY.js → components-DkHddRMl.js} +10 -16
  16. package/dist/components-DkHddRMl.js.map +1 -0
  17. package/dist/composables/index.d.ts +2 -3
  18. package/dist/composables/index.js +8 -8
  19. package/dist/composables/useEventStream.d.ts +43 -0
  20. package/dist/composables/useGeneratedAnalysis.d.ts +1 -3
  21. package/dist/composables/useJobsStatusTray.d.ts +1 -25
  22. package/dist/composables/usePluginClient.d.ts +3 -18
  23. package/dist/{composables-CkXDe7nm.js → composables-C_i90sGS.js} +32 -308
  24. package/dist/composables-C_i90sGS.js.map +1 -0
  25. package/dist/{experiment-Y9Iz7KYi.js → experiment-Cq145oEW.js} +2 -2
  26. package/dist/{experiment-Y9Iz7KYi.js.map → experiment-Cq145oEW.js.map} +1 -1
  27. package/dist/index.js +12 -12
  28. package/dist/install.js +4 -4
  29. package/dist/stores/auth.d.ts +0 -6
  30. package/dist/stores/index.js +2 -2
  31. package/dist/templates/index.js +2 -2
  32. package/dist/{templates-CovvRZ-9.js → templates-DsP51i-S.js} +2 -2
  33. package/dist/{templates-CovvRZ-9.js.map → templates-DsP51i-S.js.map} +1 -1
  34. package/dist/types/auth.d.ts +0 -1
  35. package/dist/{useApi-CDBq6z23.js → useApi-J6zFkHmn.js} +1 -2
  36. package/dist/useApi-J6zFkHmn.js.map +1 -0
  37. package/dist/{useControlSchema-BhoJzWzx.js → useControlSchema-BDx2Olha.js} +239 -103
  38. package/dist/useControlSchema-BDx2Olha.js.map +1 -0
  39. package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-CV4iNfMS.js} +2 -2
  40. package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-CV4iNfMS.js.map} +1 -1
  41. package/dist/{useFormBuilder-DiEcDis2.js → useFormBuilder-BLxG3BOX.js} +2 -2
  42. package/dist/{useFormBuilder-DiEcDis2.js.map → useFormBuilder-BLxG3BOX.js.map} +1 -1
  43. package/dist/{useProtocolTemplates-DHwSveE6.js → useProtocolTemplates-CS_SQl6U.js} +22 -113
  44. package/dist/useProtocolTemplates-CS_SQl6U.js.map +1 -0
  45. package/package.json +1 -1
  46. package/src/__tests__/components/GeneratedPluginView.test.ts +0 -5
  47. package/src/__tests__/components/JobsStatusTray.test.ts +20 -18
  48. package/src/__tests__/composables/useAuth.test.ts +23 -0
  49. package/src/__tests__/composables/useEventStream.test.ts +140 -0
  50. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +103 -0
  51. package/src/__tests__/composables/useJobsStatusTray.test.ts +25 -161
  52. package/src/__tests__/composables/usePluginClient.test.ts +8 -13
  53. package/src/__tests__/docs/frontendDocsCatalog.test.ts +22 -1
  54. package/src/components/JobsStatusTray.story.vue +6 -10
  55. package/src/components/JobsStatusTray.vue +1 -16
  56. package/src/composables/index.ts +0 -7
  57. package/src/composables/useAuth.ts +0 -3
  58. package/src/composables/useEventStream.ts +314 -0
  59. package/src/composables/useGeneratedAnalysis.ts +40 -109
  60. package/src/composables/useJobsStatusTray.ts +17 -184
  61. package/src/composables/usePluginClient.ts +45 -249
  62. package/src/stores/auth.ts +0 -1
  63. package/src/types/auth.ts +0 -1
  64. package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
  65. package/dist/components-DQt0tcpY.js.map +0 -1
  66. package/dist/composables/usePluginConfig.d.ts +0 -25
  67. package/dist/composables-CkXDe7nm.js.map +0 -1
  68. package/dist/useApi-CDBq6z23.js.map +0 -1
  69. package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
  70. package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
  71. package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
  72. package/src/composables/usePluginConfig.ts +0 -250
@@ -14,7 +14,6 @@ import {
14
14
  uploadPluginEndpoint,
15
15
  useCurrentExperiment,
16
16
  usePluginEventStream,
17
- usePluginClient,
18
17
  usePluginSettings,
19
18
  type PluginContract,
20
19
  } from '../../composables/usePluginClient'
@@ -80,7 +79,13 @@ function pendingEventStreamResponse(): {
80
79
  })
81
80
  return {
82
81
  response: new Response(body, { status: 200 }),
83
- close: () => streamController.close(),
82
+ close: () => {
83
+ try {
84
+ streamController.close()
85
+ } catch {
86
+ // stop() may already have cancelled the reader.
87
+ }
88
+ },
84
89
  }
85
90
  }
86
91
 
@@ -1396,11 +1401,7 @@ describe('usePluginClient', () => {
1396
1401
 
1397
1402
  try {
1398
1403
  stream.start()
1399
- await Promise.resolve()
1400
- await Promise.resolve()
1401
- await Promise.resolve()
1402
-
1403
- expect(onOpen).toHaveBeenCalledOnce()
1404
+ await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce())
1404
1405
  } finally {
1405
1406
  stopWatching()
1406
1407
  stream.stop()
@@ -1684,12 +1685,6 @@ describe('usePluginClient', () => {
1684
1685
  expect(requestConfigs).toHaveLength(0)
1685
1686
  })
1686
1687
 
1687
- it('returns the provided client identity from usePluginClient', () => {
1688
- const client = createPluginClient(contract, { endpoints: {} })
1689
-
1690
- expect(usePluginClient(client)).toBe(client)
1691
- })
1692
-
1693
1688
  it('loads the current experiment from URL conventions', async () => {
1694
1689
  window.history.replaceState({}, '', '/analysis?experimentId=42')
1695
1690
  const current = useCurrentExperiment<{ called: string }>({ immediate: false })
@@ -271,6 +271,18 @@ describe('frontend docs catalog coverage', () => {
271
271
  })
272
272
  })
273
273
 
274
+ it('documents only the source-driven JobsStatusTray props', () => {
275
+ const manifest = extractFrontendManifest()
276
+ const jobsStatusTray = manifest.categories.components.find(item => item.name === 'JobsStatusTray')
277
+
278
+ expect(jobsStatusTray?.props?.map(prop => prop.name).sort()).toEqual([
279
+ 'loadOnMount',
280
+ 'source',
281
+ 'teleportTo',
282
+ 'title',
283
+ ])
284
+ })
285
+
274
286
  it('re-exports every public component from the root SDK entrypoint', () => {
275
287
  const publicComponents = parseComponentExports()
276
288
  const rootComponents = parseRootComponentExports()
@@ -295,6 +307,7 @@ describe('frontend docs catalog coverage', () => {
295
307
  const rootIndex = readFileSync(join(SDK_ROOT, 'src', 'index.ts'), 'utf8')
296
308
  const componentsIndex = readFileSync(join(SDK_ROOT, 'src', 'components', 'index.ts'), 'utf8')
297
309
  const composablesIndex = readFileSync(join(SDK_ROOT, 'src', 'composables', 'index.ts'), 'utf8')
310
+ const composableExports = composablesIndex.replace(/from\s+['"][^'"]+['"]/g, '')
298
311
 
299
312
  const retiredNames = [
300
313
  'ToastNotification',
@@ -306,12 +319,20 @@ describe('frontend docs catalog coverage', () => {
306
319
  'AppPillNav',
307
320
  'usePluginApi',
308
321
  'UsePluginApiOptions',
322
+ 'useJobsStatusTray',
323
+ 'UseJobsStatusTrayOptions',
324
+ 'UseJobsStatusTrayReturn',
325
+ 'JobsStatusTrayAdapter',
326
+ 'JobStreamMessage',
327
+ 'usePluginConfig',
328
+ 'UsePluginConfigReturn',
329
+ 'usePluginClient',
309
330
  ]
310
331
 
311
332
  for (const name of retiredNames) {
312
333
  expect(rootIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
313
334
  expect(componentsIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
314
- expect(composablesIndex).not.toMatch(new RegExp(`\\b${name}\\b`))
335
+ expect(composableExports).not.toMatch(new RegExp(`\\b${name}\\b`))
315
336
  }
316
337
  })
317
338
 
@@ -54,10 +54,8 @@ const explicitActionJobs = [
54
54
  job('awaiting_input', 'J-201', { can_cancel: true }),
55
55
  job('completed', 'J-200', { can_delete: false }),
56
56
  ]
57
- const adapter = {
58
- cancelJob: async () => undefined,
59
- deleteJob: async () => true,
60
- }
57
+ const cancelJob = async () => undefined
58
+ const deleteJob = async () => true
61
59
 
62
60
  function openJob(jobId: string) {
63
61
  console.info('open', jobId)
@@ -78,8 +76,7 @@ function initState() {
78
76
  <div style="padding: 2rem; min-height: 520px;">
79
77
  <JobsStatusTray
80
78
  :title="state.title"
81
- :jobs="state.jobs"
82
- :adapter="adapter"
79
+ :source="{ jobs: state.jobs, cancelJob, deleteJob }"
83
80
  :load-on-mount="false"
84
81
  :teleport-to="false"
85
82
  >
@@ -99,7 +96,7 @@ function initState() {
99
96
  <Variant title="Awaiting input">
100
97
  <div style="padding: 2rem; min-height: 520px;">
101
98
  <JobsStatusTray
102
- :jobs="[job('awaiting_input', 'J-301')]"
99
+ :source="{ jobs: [job('awaiting_input', 'J-301')] }"
103
100
  :load-on-mount="false"
104
101
  :teleport-to="false"
105
102
  />
@@ -109,8 +106,7 @@ function initState() {
109
106
  <Variant title="Explicit actions">
110
107
  <div style="padding: 2rem; min-height: 520px;">
111
108
  <JobsStatusTray
112
- :jobs="explicitActionJobs"
113
- :adapter="adapter"
109
+ :source="{ jobs: explicitActionJobs, cancelJob, deleteJob }"
114
110
  :load-on-mount="false"
115
111
  :teleport-to="false"
116
112
  />
@@ -120,7 +116,7 @@ function initState() {
120
116
  <Variant title="Empty">
121
117
  <div style="padding: 2rem; min-height: 520px;">
122
118
  <JobsStatusTray
123
- :jobs="[]"
119
+ :source="{ jobs: [] }"
124
120
  :load-on-mount="false"
125
121
  :teleport-to="false"
126
122
  />
@@ -15,24 +15,12 @@ import {
15
15
  } from '../jobs'
16
16
  import {
17
17
  usePluginJobCenter,
18
- type JobsStatusTrayAdapter,
19
18
  type PluginJobCenterSource,
20
19
  } from '../composables/useJobsStatusTray'
21
- import type { UsePluginEventStreamReturn } from '../composables/usePluginClient'
22
- import type {
23
- JobState,
24
- JobStateInput,
25
- JobStreamData,
26
- } from '../types/jobs'
20
+ import type { JobState } from '../types/jobs'
27
21
 
28
22
  interface Props {
29
23
  source?: PluginJobCenterSource
30
- /** @deprecated Pass a source instead. */
31
- jobs?: readonly JobStateInput[]
32
- /** @deprecated Pass source actions instead. */
33
- adapter?: JobsStatusTrayAdapter
34
- /** @deprecated Event streams belong to the source runtime. */
35
- eventStream?: UsePluginEventStreamReturn<JobStreamData>
36
24
  loadOnMount?: boolean
37
25
  title?: string
38
26
  teleportTo?: string | HTMLElement | false
@@ -61,9 +49,6 @@ const panelRef = ref<HTMLElement | null>(null)
61
49
  const teleportTarget = computed(() => props.teleportTo || 'body')
62
50
  const tray = usePluginJobCenter({
63
51
  source: toRef(props, 'source'),
64
- jobs: toRef(props, 'jobs'),
65
- adapter: toRef(props, 'adapter'),
66
- eventStream: toRef(props, 'eventStream'),
67
52
  loadOnMount: props.loadOnMount,
68
53
  })
69
54
  const canClear = computed(() => (
@@ -162,7 +162,6 @@ export {
162
162
  type DilutionPreset,
163
163
  type UseReagentSeriesReturn,
164
164
  } from './useReagentSeries'
165
- export { usePluginConfig, type UsePluginConfigReturn } from './usePluginConfig'
166
165
  export {
167
166
  useExperimentSelector,
168
167
  type UseExperimentSelectorOptions,
@@ -488,7 +487,6 @@ export {
488
487
  pluginFormDataFromPayload,
489
488
  resolvePluginBaseUrl,
490
489
  uploadPluginEndpoint,
491
- usePluginClient,
492
490
  usePluginEventStream,
493
491
  usePluginSettings,
494
492
  useCurrentExperiment,
@@ -517,14 +515,9 @@ export {
517
515
  export {
518
516
  combinePluginJobSources,
519
517
  usePluginJobCenter,
520
- useJobsStatusTray,
521
518
  type CombinedPluginJobCenterSource,
522
519
  type PluginJobCenterSource,
523
- type JobsStatusTrayAdapter,
524
520
  type JobsStatusTrayTab,
525
- type JobStreamMessage,
526
521
  type UsePluginJobCenterOptions,
527
522
  type UsePluginJobCenterReturn,
528
- type UseJobsStatusTrayOptions,
529
- type UseJobsStatusTrayReturn,
530
523
  } from './useJobsStatusTray'
@@ -93,7 +93,6 @@ export function useAuth(): UseAuthReturn {
93
93
  passkey_enabled: boolean
94
94
  passkey_registered?: boolean
95
95
  registration_enabled?: boolean
96
- database_mode?: string
97
96
  access?: {
98
97
  experiment_visibility_mode?: 'open' | 'restricted'
99
98
  experimentVisibilityMode?: 'open' | 'restricted'
@@ -105,7 +104,6 @@ export function useAuth(): UseAuthReturn {
105
104
  passkeyEnabled: response.data.passkey_enabled,
106
105
  passkeyRegistered: response.data.passkey_registered ?? false,
107
106
  registrationEnabled: response.data.registration_enabled ?? false,
108
- databaseMode: response.data.database_mode ?? 'none',
109
107
  experimentVisibilityMode:
110
108
  response.data.access?.experiment_visibility_mode
111
109
  ?? response.data.access?.experimentVisibilityMode
@@ -121,7 +119,6 @@ export function useAuth(): UseAuthReturn {
121
119
  passkeyEnabled: false,
122
120
  passkeyRegistered: false,
123
121
  registrationEnabled: false,
124
- databaseMode: 'none',
125
122
  experimentVisibilityMode: 'open',
126
123
  }
127
124
  }
@@ -0,0 +1,314 @@
1
+ import {
2
+ getCurrentInstance,
3
+ getCurrentScope,
4
+ onMounted,
5
+ onScopeDispose,
6
+ ref,
7
+ shallowRef,
8
+ type Ref,
9
+ } from 'vue'
10
+
11
+ export type EventStreamTransport = (
12
+ input: RequestInfo | URL,
13
+ init?: RequestInit,
14
+ ) => Promise<Response>
15
+
16
+ export interface EventStreamMessage<TData = string> {
17
+ event: string
18
+ data: TData
19
+ id?: string
20
+ retry?: number
21
+ raw: string
22
+ }
23
+
24
+ export interface EventStreamOptions<TData = string> {
25
+ transport?: EventStreamTransport
26
+ headers?: HeadersInit
27
+ credentials?: RequestCredentials
28
+ immediate?: boolean
29
+ reconnect?: boolean
30
+ reconnectDelayMs?: number
31
+ parseJson?: boolean
32
+ shouldReconnect?: (cause?: unknown) => boolean
33
+ onOpen?: () => void
34
+ onMessage?: (message: EventStreamMessage<TData>) => void
35
+ onError?: (cause: unknown) => void
36
+ onClose?: (cause?: unknown) => void
37
+ errorLabel?: string
38
+ }
39
+
40
+ export interface UseEventStreamReturn<TData = string> {
41
+ isConnected: Ref<boolean>
42
+ isConnecting: Ref<boolean>
43
+ error: Ref<string | null>
44
+ lastMessage: Ref<EventStreamMessage<TData> | null>
45
+ lastEventId: Ref<string | null>
46
+ start: () => void
47
+ stop: () => void
48
+ }
49
+
50
+ interface ParsedEventStreamBlock<TData> {
51
+ message?: EventStreamMessage<TData>
52
+ id?: string
53
+ retry?: number
54
+ }
55
+
56
+ class EventStreamResponseError extends Error {
57
+ constructor(readonly response: Response, label: string) {
58
+ super(`${label} failed with HTTP ${response.status}`)
59
+ this.name = 'EventStreamResponseError'
60
+ }
61
+ }
62
+
63
+ /** Parse one complete SSE block without owning connection policy. */
64
+ export function parseEventStreamBlock<TData>(
65
+ block: string,
66
+ parseJson: boolean,
67
+ ): ParsedEventStreamBlock<TData> | null {
68
+ let event = 'message'
69
+ let id: string | undefined
70
+ let retry: number | undefined
71
+ const dataLines: string[] = []
72
+
73
+ for (const line of block.split(/\r\n|\r|\n/)) {
74
+ if (!line || line.startsWith(':')) continue
75
+ const separator = line.indexOf(':')
76
+ const field = separator === -1 ? line : line.slice(0, separator)
77
+ const value = separator === -1
78
+ ? ''
79
+ : line.slice(separator + 1).replace(/^ /, '')
80
+
81
+ if (field === 'event') event = value || 'message'
82
+ if (field === 'data') dataLines.push(value)
83
+ if (field === 'id' && !value.includes('\0')) id = value
84
+ if (field === 'retry' && /^\d+$/.test(value)) retry = Number(value)
85
+ }
86
+
87
+ if (!dataLines.length && id === undefined && retry === undefined) return null
88
+ const data = dataLines.join('\n')
89
+ return {
90
+ message: dataLines.length
91
+ ? {
92
+ event,
93
+ data: (parseJson && data ? JSON.parse(data) : data) as TData,
94
+ id,
95
+ retry,
96
+ raw: block,
97
+ }
98
+ : undefined,
99
+ id,
100
+ retry,
101
+ }
102
+ }
103
+
104
+ /** Shared fetch-based SSE lifecycle with cancellation and reconnect safety. */
105
+ export function useEventStream<TData = string>(
106
+ url: string | (() => string),
107
+ options: EventStreamOptions<TData> = {},
108
+ ): UseEventStreamReturn<TData> {
109
+ const isConnected = ref(false)
110
+ const isConnecting = ref(false)
111
+ const error = ref<string | null>(null)
112
+ const lastMessage = shallowRef<EventStreamMessage<TData> | null>(null)
113
+ const lastEventId = ref<string | null>(null)
114
+ let controller: AbortController | null = null
115
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
116
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
117
+ let reconnectDelayMs = options.reconnectDelayMs ?? 2_000
118
+ let stopped = true
119
+ let generation = 0
120
+
121
+ function clearReconnectTimer(): void {
122
+ if (reconnectTimer === null) return
123
+ clearTimeout(reconnectTimer)
124
+ reconnectTimer = null
125
+ }
126
+
127
+ function ownsConnection(
128
+ connectionGeneration: number,
129
+ connectionController: AbortController,
130
+ ): boolean {
131
+ return !stopped
132
+ && generation === connectionGeneration
133
+ && controller === connectionController
134
+ }
135
+
136
+ function scheduleReconnect(connectionGeneration: number, cause?: unknown): void {
137
+ if (stopped || generation !== connectionGeneration) return
138
+ if (options.reconnect === false || options.shouldReconnect?.(cause) === false) {
139
+ stopped = true
140
+ return
141
+ }
142
+ clearReconnectTimer()
143
+ reconnectTimer = setTimeout(() => {
144
+ reconnectTimer = null
145
+ if (stopped || generation !== connectionGeneration) return
146
+ void connect(connectionGeneration)
147
+ }, reconnectDelayMs)
148
+ }
149
+
150
+ function dispatchBlock(
151
+ block: string,
152
+ connectionGeneration: number,
153
+ connectionController: AbortController,
154
+ ): void {
155
+ const parsed = parseEventStreamBlock<TData>(block, options.parseJson ?? false)
156
+ if (!parsed || !ownsConnection(connectionGeneration, connectionController)) return
157
+ if (parsed.id !== undefined) {
158
+ lastEventId.value = parsed.id
159
+ if (!ownsConnection(connectionGeneration, connectionController)) return
160
+ }
161
+ if (parsed.retry !== undefined) reconnectDelayMs = parsed.retry
162
+ const message = parsed.message
163
+ if (!message || !ownsConnection(connectionGeneration, connectionController)) return
164
+ lastMessage.value = message
165
+ if (!ownsConnection(connectionGeneration, connectionController)) return
166
+ options.onMessage?.(message)
167
+ }
168
+
169
+ async function consume(
170
+ body: ReadableStream<Uint8Array>,
171
+ connectionGeneration: number,
172
+ connectionController: AbortController,
173
+ ): Promise<void> {
174
+ const connectionReader = body.getReader()
175
+ reader = connectionReader
176
+ const decoder = new TextDecoder()
177
+ let buffer = ''
178
+ try {
179
+ for (;;) {
180
+ const { value, done } = await connectionReader.read()
181
+ if (!ownsConnection(connectionGeneration, connectionController)) return
182
+ buffer += decoder.decode(value, { stream: !done })
183
+
184
+ let boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
185
+ while (boundary?.index !== undefined) {
186
+ const block = buffer.slice(0, boundary.index)
187
+ buffer = buffer.slice(boundary.index + boundary[0].length)
188
+ dispatchBlock(block, connectionGeneration, connectionController)
189
+ if (!ownsConnection(connectionGeneration, connectionController)) return
190
+ boundary = /(?:\r\n|\r|\n){2}/.exec(buffer)
191
+ }
192
+
193
+ if (!done) continue
194
+ if (buffer) dispatchBlock(buffer, connectionGeneration, connectionController)
195
+ return
196
+ }
197
+ } finally {
198
+ if (reader === connectionReader) reader = null
199
+ connectionReader.releaseLock()
200
+ }
201
+ }
202
+
203
+ async function connect(connectionGeneration: number): Promise<void> {
204
+ if (stopped || generation !== connectionGeneration) return
205
+ controller?.abort()
206
+ const connectionController = new AbortController()
207
+ controller = connectionController
208
+ isConnecting.value = true
209
+ if (!ownsConnection(connectionGeneration, connectionController)) return
210
+ error.value = null
211
+ if (!ownsConnection(connectionGeneration, connectionController)) return
212
+
213
+ let cause: unknown
214
+ try {
215
+ const headers = new Headers(options.headers)
216
+ headers.set('Accept', 'text/event-stream')
217
+ if (lastEventId.value && !headers.has('Last-Event-ID')) {
218
+ headers.set('Last-Event-ID', lastEventId.value)
219
+ }
220
+ const transport = options.transport ?? globalThis.fetch?.bind(globalThis)
221
+ if (!transport) throw new Error('[MINT SDK] fetch is required for event streams.')
222
+ const response = await transport(typeof url === 'function' ? url() : url, {
223
+ method: 'GET',
224
+ headers,
225
+ credentials: options.credentials ?? 'same-origin',
226
+ signal: connectionController.signal,
227
+ })
228
+ if (!ownsConnection(connectionGeneration, connectionController)) return
229
+ if (!response.ok) {
230
+ throw new EventStreamResponseError(response, options.errorLabel ?? 'Event stream')
231
+ }
232
+ if (!response.body) {
233
+ throw new Error(`${options.errorLabel ?? 'Event stream'} response did not include a readable body.`)
234
+ }
235
+
236
+ isConnected.value = true
237
+ if (!ownsConnection(connectionGeneration, connectionController)) return
238
+ isConnecting.value = false
239
+ if (!ownsConnection(connectionGeneration, connectionController)) return
240
+ options.onOpen?.()
241
+ if (!ownsConnection(connectionGeneration, connectionController)) return
242
+ await consume(response.body, connectionGeneration, connectionController)
243
+ } catch (errorCause) {
244
+ cause = errorCause
245
+ if (
246
+ ownsConnection(connectionGeneration, connectionController)
247
+ && !isAbortError(errorCause)
248
+ ) {
249
+ error.value = errorCause instanceof Error
250
+ ? errorCause.message
251
+ : `${options.errorLabel ?? 'Event stream'} failed`
252
+ if (ownsConnection(connectionGeneration, connectionController)) {
253
+ options.onError?.(errorCause)
254
+ }
255
+ }
256
+ } finally {
257
+ if (ownsConnection(connectionGeneration, connectionController)) {
258
+ isConnecting.value = false
259
+ if (!ownsConnection(connectionGeneration, connectionController)) return
260
+ isConnected.value = false
261
+ if (!ownsConnection(connectionGeneration, connectionController)) return
262
+ controller = null
263
+ options.onClose?.(cause)
264
+ if (!stopped && generation === connectionGeneration) {
265
+ scheduleReconnect(connectionGeneration, cause)
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ function start(): void {
272
+ if (!stopped) return
273
+ stopped = false
274
+ clearReconnectTimer()
275
+ generation += 1
276
+ void connect(generation)
277
+ }
278
+
279
+ function stop(): void {
280
+ if (stopped) return
281
+ stopped = true
282
+ generation += 1
283
+ clearReconnectTimer()
284
+ controller?.abort()
285
+ controller = null
286
+ if (reader) void reader.cancel().catch(() => undefined)
287
+ reader = null
288
+ isConnecting.value = false
289
+ isConnected.value = false
290
+ }
291
+
292
+ const currentInstance = getCurrentInstance()
293
+ if (currentInstance && options.immediate !== false && typeof window === 'undefined') {
294
+ onMounted(start)
295
+ }
296
+ if (getCurrentScope()) onScopeDispose(stop)
297
+ if (options.immediate !== false && (typeof window !== 'undefined' || !currentInstance)) {
298
+ start()
299
+ }
300
+
301
+ return {
302
+ isConnected,
303
+ isConnecting,
304
+ error,
305
+ lastMessage,
306
+ lastEventId,
307
+ start,
308
+ stop,
309
+ }
310
+ }
311
+
312
+ function isAbortError(cause: unknown): boolean {
313
+ return cause instanceof DOMException && cause.name === 'AbortError'
314
+ }