@morscherlab/mint-sdk 1.1.24 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) 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-DoS2fzyz.js → SettingsModal-BgKY93zh.js} +5 -4
  5. package/dist/SettingsModal-BgKY93zh.js.map +1 -0
  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 +29 -10
  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-CW3hE1T3.js → components-DkHddRMl.js} +39 -40
  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 +43 -19
  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 +16 -10
  55. package/src/components/JobsStatusTray.vue +22 -28
  56. package/src/components/SettingsModal.schema.ts +3 -0
  57. package/src/composables/index.ts +0 -7
  58. package/src/composables/useAuth.ts +0 -3
  59. package/src/composables/useEventStream.ts +314 -0
  60. package/src/composables/useGeneratedAnalysis.ts +40 -109
  61. package/src/composables/useJobsStatusTray.ts +17 -184
  62. package/src/composables/usePluginClient.ts +45 -249
  63. package/src/stores/auth.ts +0 -1
  64. package/src/types/auth.ts +0 -1
  65. package/dist/SettingsModal-DoS2fzyz.js.map +0 -1
  66. package/dist/__tests__/composables/usePluginConfig.test.d.ts +0 -15
  67. package/dist/components-CW3hE1T3.js.map +0 -1
  68. package/dist/composables/usePluginConfig.d.ts +0 -25
  69. package/dist/composables-CkXDe7nm.js.map +0 -1
  70. package/dist/useApi-CDBq6z23.js.map +0 -1
  71. package/dist/useControlSchema-BhoJzWzx.js.map +0 -1
  72. package/dist/useProtocolTemplates-DHwSveE6.js.map +0 -1
  73. package/src/__tests__/composables/usePluginConfig.test.ts +0 -497
  74. package/src/composables/usePluginConfig.ts +0 -250
@@ -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
+ }
@@ -8,10 +8,8 @@ import {
8
8
  type ShallowRef,
9
9
  } from 'vue'
10
10
 
11
- import type {
12
- JobsStatusTrayAdapter,
13
- PluginJobCenterSource,
14
- } from './useJobsStatusTray'
11
+ import type { PluginJobCenterSource } from './useJobsStatusTray'
12
+ import { useEventStream, type EventStreamMessage } from './useEventStream'
15
13
  import { normalizeJobState } from '../jobs'
16
14
  import type {
17
15
  GeneratedAnalysisJob,
@@ -104,8 +102,6 @@ export interface UsePluginJobsReturn extends PluginJobCenterSource {
104
102
  pause: () => void
105
103
  resume: () => void
106
104
  dispose: () => void
107
- /** @deprecated Pass the runtime itself to SDK job UI as its source. */
108
- jobsAdapter: JobsStatusTrayAdapter
109
105
  }
110
106
 
111
107
  export type UseGeneratedAnalysisReturn = UsePluginJobsReturn
@@ -125,6 +121,10 @@ interface PluginJobWaiter {
125
121
  abort?: () => void
126
122
  }
127
123
 
124
+ type PluginJobStreamData = GeneratedAnalysisJobInput | {
125
+ jobs: GeneratedAnalysisJobInput[]
126
+ }
127
+
128
128
  class GeneratedAnalysisRequestError extends Error {
129
129
  constructor(
130
130
  readonly status: number,
@@ -200,10 +200,6 @@ function createPluginJobs(
200
200
  const error = shallowRef<string | null>(null)
201
201
  const status = shallowRef<'idle' | 'initializing' | 'ready' | 'paused' | 'error' | 'disposed'>('idle')
202
202
  let pollTimer: ReturnType<typeof setTimeout> | undefined
203
- let streamReconnectTimer: ReturnType<typeof setTimeout> | undefined
204
- let streamController: AbortController | undefined
205
- let streamConnected = false
206
- let streamConnecting = false
207
203
  let disposed = false
208
204
  let paused = false
209
205
  let initialized = false
@@ -217,6 +213,17 @@ function createPluginJobs(
217
213
  const resultRetryAttempts = new Map<string, number>()
218
214
  const resultRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
219
215
  const downloadRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
216
+ const jobStream = useEventStream<PluginJobStreamData>(`${baseUrl}/jobs/events`, {
217
+ transport,
218
+ immediate: false,
219
+ reconnectDelayMs: streamReconnectDelay,
220
+ parseJson: true,
221
+ errorLabel: 'Job stream',
222
+ onOpen: clearPoll,
223
+ onMessage: applyJobStreamMessage,
224
+ onError: schedulePollForActiveJobs,
225
+ onClose: schedulePollForActiveJobs,
226
+ })
220
227
 
221
228
  const latestResult = computed<GeneratedAnalysisResult | null>(() => {
222
229
  const job = currentCompletedJob()
@@ -242,7 +249,7 @@ function createPluginJobs(
242
249
  if (disposed) return
243
250
  initialized = true
244
251
  status.value = paused ? 'paused' : 'ready'
245
- if (streamEnabled && !paused) void connectJobStream()
252
+ if (streamEnabled && !paused) jobStream.start()
246
253
  })().catch((cause: unknown) => {
247
254
  if (!disposed) {
248
255
  error.value = errorMessage(cause, 'Failed to initialize generated analysis')
@@ -303,7 +310,7 @@ function createPluginJobs(
303
310
  )
304
311
  }
305
312
  const job = upsertJob(jobInput, definitionId)
306
- if (!streamConnected) schedulePoll()
313
+ if (!jobStream.isConnected.value) schedulePoll()
307
314
  return job
308
315
  } catch (cause) {
309
316
  error.value = errorMessage(cause, 'Failed to submit analysis')
@@ -466,7 +473,7 @@ function createPluginJobs(
466
473
  }
467
474
  replaceJobs(payload.jobs)
468
475
  error.value = null
469
- if (!paused && !streamConnected && jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) {
476
+ if (!paused && !jobStream.isConnected.value && jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) {
470
477
  schedulePoll()
471
478
  }
472
479
  return jobs.value
@@ -540,12 +547,12 @@ function createPluginJobs(
540
547
  waiting.add(waiter)
541
548
  jobWaiters.set(jobId, waiting)
542
549
  if (!paused) {
543
- if (streamEnabled) void connectJobStream()
544
- if (!streamConnected) schedulePoll()
550
+ if (streamEnabled) jobStream.start()
551
+ if (!jobStream.isConnected.value) schedulePoll()
545
552
  }
546
553
  void getJob(jobId, { signal: options.signal }).catch((cause: unknown) => {
547
554
  if (current && isRetryableJobLookupError(cause)) {
548
- if (!streamConnected) schedulePoll()
555
+ if (!jobStream.isConnected.value) schedulePoll()
549
556
  return
550
557
  }
551
558
  removeJobWaiter(jobId, waiter)
@@ -895,7 +902,7 @@ function createPluginJobs(
895
902
  }
896
903
 
897
904
  function schedulePoll(): void {
898
- if (disposed || paused || streamConnected || pollTimer !== undefined) return
905
+ if (disposed || paused || jobStream.isConnected.value || pollTimer !== undefined) return
899
906
  pollTimer = setTimeout(async () => {
900
907
  pollTimer = undefined
901
908
  if (disposed) return
@@ -908,85 +915,26 @@ function createPluginJobs(
908
915
  }, pollInterval)
909
916
  }
910
917
 
911
- async function connectJobStream(): Promise<void> {
912
- if (disposed || paused || streamConnecting || streamConnected) return
913
- streamConnecting = true
914
- streamController = new AbortController()
915
- try {
916
- const response = await transport(`${baseUrl}/jobs/events`, {
917
- method: 'GET',
918
- headers: { Accept: 'text/event-stream' },
919
- signal: streamController.signal,
920
- })
921
- if (!response.ok) throw new Error(`Job stream failed (${response.status})`)
922
- if (!response.body) throw new Error('Job stream response has no readable body')
923
- streamConnected = true
924
- if (pollTimer !== undefined) {
925
- clearTimeout(pollTimer)
926
- pollTimer = undefined
927
- }
928
- await consumeJobStream(response.body)
929
- } catch (cause) {
930
- if (!isAbortError(cause) && !disposed) {
931
- if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
932
- }
933
- } finally {
934
- streamConnected = false
935
- streamConnecting = false
936
- streamController = undefined
937
- if (!disposed && !paused) scheduleStreamReconnect()
938
- }
918
+ function clearPoll(): void {
919
+ if (pollTimer === undefined) return
920
+ clearTimeout(pollTimer)
921
+ pollTimer = undefined
939
922
  }
940
923
 
941
- async function consumeJobStream(body: ReadableStream<Uint8Array>): Promise<void> {
942
- const reader = body.getReader()
943
- const decoder = new TextDecoder()
944
- let buffer = ''
945
- try {
946
- while (!disposed) {
947
- const { done, value } = await reader.read()
948
- buffer += decoder.decode(value, { stream: !done })
949
- const blocks = buffer.split(/\r?\n\r?\n/)
950
- buffer = blocks.pop() ?? ''
951
- for (const block of blocks) applyJobStreamEvent(block)
952
- if (done) {
953
- if (buffer.trim()) applyJobStreamEvent(buffer)
954
- return
955
- }
956
- }
957
- } finally {
958
- reader.releaseLock()
959
- }
924
+ function schedulePollForActiveJobs(): void {
925
+ if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
960
926
  }
961
927
 
962
- function applyJobStreamEvent(block: string): void {
963
- let event = 'message'
964
- const data: string[] = []
965
- for (const line of block.split(/\r?\n/)) {
966
- if (!line || line.startsWith(':')) continue
967
- if (line.startsWith('event:')) event = line.slice('event:'.length).trim()
968
- if (line.startsWith('data:')) data.push(line.slice('data:'.length).trimStart())
969
- }
970
- if (!data.length) return
971
- const payload = JSON.parse(data.join('\n')) as (
972
- GeneratedAnalysisJobInput | { jobs: GeneratedAnalysisJobInput[] }
973
- )
974
- if (event === 'snapshot' && 'jobs' in payload) {
928
+ function applyJobStreamMessage(message: EventStreamMessage<PluginJobStreamData>): void {
929
+ if (message.event === 'snapshot' && 'jobs' in message.data) {
930
+ const payload = message.data
975
931
  replaceJobs(payload.jobs)
976
- } else if (event === 'job' || event === 'terminal') {
977
- upsertJob(payload as GeneratedAnalysisJobInput)
932
+ } else if (message.event === 'job' || message.event === 'terminal') {
933
+ upsertJob(message.data as GeneratedAnalysisJobInput)
978
934
  }
979
935
  error.value = null
980
936
  }
981
937
 
982
- function scheduleStreamReconnect(): void {
983
- if (disposed || paused || streamReconnectTimer !== undefined) return
984
- streamReconnectTimer = setTimeout(() => {
985
- streamReconnectTimer = undefined
986
- void connectJobStream()
987
- }, streamReconnectDelay)
988
- }
989
-
990
938
  async function cancelJob(job: GeneratedAnalysisJob | string): Promise<GeneratedAnalysisJob> {
991
939
  const jobId = typeof job === 'string' ? job : job.job_id
992
940
  const revision = beginJobOperation(jobId)
@@ -1024,25 +972,16 @@ function createPluginJobs(
1024
972
  return payload.deleted
1025
973
  }
1026
974
 
1027
- const jobsAdapter: JobsStatusTrayAdapter = {
1028
- listJobs: async () => ({ jobs: await refreshJobs() }),
1029
- cancelJob: async job => cancelJob(job as GeneratedAnalysisJob),
1030
- deleteJob: async job => deleteJob(job as GeneratedAnalysisJob),
1031
- clearFinishedJobs: async () => clearFinishedJobs(),
1032
- }
1033
975
  function pause(): void {
1034
976
  if (disposed || paused) return
1035
977
  paused = true
1036
978
  status.value = 'paused'
1037
- if (pollTimer !== undefined) clearTimeout(pollTimer)
1038
- if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
1039
- pollTimer = undefined
1040
- streamReconnectTimer = undefined
979
+ clearPoll()
1041
980
  for (const jobId of resultRetryTimers.keys()) clearResultRetry(jobId)
1042
981
  for (const jobId of downloadRefreshTimers.keys()) {
1043
982
  clearManagedDownloadRefresh(jobId)
1044
983
  }
1045
- streamController?.abort()
984
+ jobStream.stop()
1046
985
  }
1047
986
 
1048
987
  function resume(): void {
@@ -1053,7 +992,7 @@ function createPluginJobs(
1053
992
  for (const [jobId, result] of Object.entries(resultCache.value)) {
1054
993
  scheduleManagedDownloadRefresh(jobId, managedDownloadRefreshDelay(result))
1055
994
  }
1056
- if (streamEnabled) void connectJobStream()
995
+ if (streamEnabled) jobStream.start()
1057
996
  else if (jobs.value.some(job => ACTIVE_STATUSES.has(job.status))) schedulePoll()
1058
997
  }
1059
998
 
@@ -1062,10 +1001,7 @@ function createPluginJobs(
1062
1001
  disposed = true
1063
1002
  paused = true
1064
1003
  status.value = 'disposed'
1065
- if (pollTimer !== undefined) clearTimeout(pollTimer)
1066
- if (streamReconnectTimer !== undefined) clearTimeout(streamReconnectTimer)
1067
- pollTimer = undefined
1068
- streamReconnectTimer = undefined
1004
+ clearPoll()
1069
1005
  for (const timer of resultRetryTimers.values()) clearTimeout(timer)
1070
1006
  resultRetryTimers.clear()
1071
1007
  resultRetryAttempts.clear()
@@ -1075,7 +1011,7 @@ function createPluginJobs(
1075
1011
  for (const jobId of jobWaiters.keys()) rejectJobWaiters(jobId, cause)
1076
1012
  jobRevisions.clear()
1077
1013
  pendingJobOperations.clear()
1078
- streamController?.abort()
1014
+ jobStream.stop()
1079
1015
  }
1080
1016
 
1081
1017
  async function request<T>(path: string, init: RequestInit): Promise<T> {
@@ -1129,7 +1065,6 @@ function createPluginJobs(
1129
1065
  pause,
1130
1066
  resume,
1131
1067
  dispose,
1132
- jobsAdapter,
1133
1068
  }
1134
1069
  }
1135
1070
 
@@ -1229,10 +1164,6 @@ function createGeneratedSessionStorage(candidate?: Storage): GeneratedSessionSto
1229
1164
  }
1230
1165
  }
1231
1166
 
1232
- function isAbortError(error: unknown): boolean {
1233
- return error instanceof DOMException && error.name === 'AbortError'
1234
- }
1235
-
1236
1167
  function isSessionNotFoundError(error: unknown): boolean {
1237
1168
  return error instanceof GeneratedAnalysisRequestError
1238
1169
  && error.status === 404