@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.
- package/README.md +9 -7
- package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DyPAUCLw.js} +3 -3
- package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DyPAUCLw.js.map} +1 -1
- package/dist/{SettingsModal-BW3Ar-uy.js → SettingsModal-BgKY93zh.js} +4 -4
- package/dist/{SettingsModal-BW3Ar-uy.js.map → SettingsModal-BgKY93zh.js.map} +1 -1
- package/dist/__tests__/composables/useEventStream.test.d.ts +1 -0
- package/dist/components/AutoGroupModal.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/JobsStatusTray.vue.d.ts +2 -9
- package/dist/components/SampleLegend.vue.d.ts +1 -1
- package/dist/components/SampleSelector.vue.d.ts +1 -1
- package/dist/components/SettingsModal.vue.d.ts +1 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/{components-DQt0tcpY.js → components-DkHddRMl.js} +10 -16
- package/dist/components-DkHddRMl.js.map +1 -0
- package/dist/composables/index.d.ts +2 -3
- package/dist/composables/index.js +8 -8
- package/dist/composables/useEventStream.d.ts +43 -0
- package/dist/composables/useGeneratedAnalysis.d.ts +1 -3
- package/dist/composables/useJobsStatusTray.d.ts +1 -25
- package/dist/composables/usePluginClient.d.ts +3 -18
- package/dist/{composables-CkXDe7nm.js → composables-C_i90sGS.js} +32 -308
- package/dist/composables-C_i90sGS.js.map +1 -0
- package/dist/{experiment-Y9Iz7KYi.js → experiment-Cq145oEW.js} +2 -2
- package/dist/{experiment-Y9Iz7KYi.js.map → experiment-Cq145oEW.js.map} +1 -1
- package/dist/index.js +12 -12
- package/dist/install.js +4 -4
- package/dist/stores/auth.d.ts +0 -6
- package/dist/stores/index.js +2 -2
- package/dist/templates/index.js +2 -2
- package/dist/{templates-CovvRZ-9.js → templates-DsP51i-S.js} +2 -2
- package/dist/{templates-CovvRZ-9.js.map → templates-DsP51i-S.js.map} +1 -1
- package/dist/types/auth.d.ts +0 -1
- package/dist/{useApi-CDBq6z23.js → useApi-J6zFkHmn.js} +1 -2
- package/dist/useApi-J6zFkHmn.js.map +1 -0
- package/dist/{useControlSchema-BhoJzWzx.js → useControlSchema-BDx2Olha.js} +239 -103
- package/dist/useControlSchema-BDx2Olha.js.map +1 -0
- package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-CV4iNfMS.js} +2 -2
- package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-CV4iNfMS.js.map} +1 -1
- package/dist/{useFormBuilder-DiEcDis2.js → useFormBuilder-BLxG3BOX.js} +2 -2
- package/dist/{useFormBuilder-DiEcDis2.js.map → useFormBuilder-BLxG3BOX.js.map} +1 -1
- package/dist/{useProtocolTemplates-DHwSveE6.js → useProtocolTemplates-CS_SQl6U.js} +22 -113
- package/dist/useProtocolTemplates-CS_SQl6U.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/GeneratedPluginView.test.ts +0 -5
- package/src/__tests__/components/JobsStatusTray.test.ts +20 -18
- package/src/__tests__/composables/useAuth.test.ts +23 -0
- package/src/__tests__/composables/useEventStream.test.ts +140 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +103 -0
- package/src/__tests__/composables/useJobsStatusTray.test.ts +25 -161
- package/src/__tests__/composables/usePluginClient.test.ts +8 -13
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +22 -1
- package/src/components/JobsStatusTray.story.vue +6 -10
- package/src/components/JobsStatusTray.vue +1 -16
- package/src/composables/index.ts +0 -7
- package/src/composables/useAuth.ts +0 -3
- package/src/composables/useEventStream.ts +314 -0
- package/src/composables/useGeneratedAnalysis.ts +40 -109
- package/src/composables/useJobsStatusTray.ts +17 -184
- package/src/composables/usePluginClient.ts +45 -249
- package/src/stores/auth.ts +0 -1
- package/src/types/auth.ts +0 -1
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
- package/dist/components-DQt0tcpY.js.map +0 -1
- package/dist/composables/usePluginConfig.d.ts +0 -25
- package/dist/composables-CkXDe7nm.js.map +0 -1
- package/dist/useApi-CDBq6z23.js.map +0 -1
- package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
- package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
- package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
- 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: () =>
|
|
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
|
|
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(
|
|
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
|
|
58
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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 {
|
|
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(() => (
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
+
}
|