@morscherlab/mint-sdk 1.1.9 → 1.1.11

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 (28) hide show
  1. package/dist/__tests__/components/AppTopBarPresence.test.d.ts +1 -0
  2. package/dist/__tests__/composables/usePresenceHeartbeat.test.d.ts +1 -0
  3. package/dist/components/AutoGroupModal.adapter.d.ts +12 -1
  4. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  5. package/dist/components/index.js +1 -1
  6. package/dist/{components-BbtUpCTv.js → components-DjJsjKqe.js} +43 -18
  7. package/dist/components-DjJsjKqe.js.map +1 -0
  8. package/dist/composables/index.d.ts +1 -0
  9. package/dist/composables/index.js +2 -2
  10. package/dist/composables/usePresenceHeartbeat.d.ts +44 -0
  11. package/dist/index.js +3 -3
  12. package/dist/install.js +1 -1
  13. package/dist/{useProtocolTemplates-BWfNaynA.js → useProtocolTemplates-CIzDGj2R.js} +112 -10
  14. package/dist/useProtocolTemplates-CIzDGj2R.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/__tests__/components/AppTopBarPresence.test.ts +47 -0
  17. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +31 -1
  18. package/src/__tests__/components/SampleSelector.test.ts +35 -0
  19. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +31 -2
  20. package/src/__tests__/composables/usePresenceHeartbeat.test.ts +147 -0
  21. package/src/components/AppTopBar.vue +6 -0
  22. package/src/components/AutoGroupModal.adapter.ts +52 -1
  23. package/src/components/AutoGroupModal.vue +6 -4
  24. package/src/composables/index.ts +7 -0
  25. package/src/composables/useAutoGroup.ts +11 -7
  26. package/src/composables/usePresenceHeartbeat.ts +194 -0
  27. package/dist/components-BbtUpCTv.js.map +0 -1
  28. package/dist/useProtocolTemplates-BWfNaynA.js.map +0 -1
@@ -0,0 +1,194 @@
1
+ import {
2
+ computed,
3
+ onMounted,
4
+ onScopeDispose,
5
+ readonly,
6
+ ref,
7
+ toValue,
8
+ watch,
9
+ type MaybeRefOrGetter,
10
+ type Ref,
11
+ } from 'vue'
12
+
13
+ import { useApi } from './useApi'
14
+
15
+ const PRESENCE_CLIENT_ID_KEY = 'mint:presence:client-id'
16
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
17
+
18
+ let pageFallbackClientId: string | null = null
19
+
20
+ export interface PresenceHeartbeatPayload {
21
+ client_id: string
22
+ scope: string
23
+ }
24
+
25
+ interface PresenceDocument {
26
+ readonly visibilityState: DocumentVisibilityState
27
+ addEventListener(type: 'visibilitychange', listener: EventListener): void
28
+ removeEventListener(type: 'visibilitychange', listener: EventListener): void
29
+ }
30
+
31
+ interface PresenceStorage {
32
+ getItem(key: string): string | null
33
+ setItem(key: string, value: string): void
34
+ }
35
+
36
+ export interface PresenceHeartbeatOptions {
37
+ /** Platform, plugin id, or plugin name reported for this browser tab. */
38
+ scope: MaybeRefOrGetter<string | null | undefined>
39
+ /** Disable all traffic, including for standalone plugins. */
40
+ enabled?: MaybeRefOrGetter<boolean>
41
+ /** Heartbeat interval. Defaults to 30 seconds. */
42
+ intervalMs?: number
43
+ /** Test or host storage seam. Defaults to sessionStorage. */
44
+ storage?: PresenceStorage | null
45
+ /** Test or host document seam. Defaults to the current document. */
46
+ document?: PresenceDocument | null
47
+ /** Test seam for generating the per-tab id. */
48
+ createClientId?: () => string
49
+ /** Test or custom transport seam. Defaults to authenticated POST /api/presence/heartbeat. */
50
+ send?: (payload: PresenceHeartbeatPayload) => Promise<unknown> | unknown
51
+ /** Test timing seam. Defaults to the browser scheduler. */
52
+ setInterval?: (callback: () => void, intervalMs: number) => unknown
53
+ /** Test timing seam. Defaults to the browser scheduler. */
54
+ clearInterval?: (intervalId: unknown) => void
55
+ }
56
+
57
+ export interface UsePresenceHeartbeatReturn {
58
+ clientId: string
59
+ isRunning: Readonly<Ref<boolean>>
60
+ sendNow: () => Promise<void>
61
+ }
62
+
63
+ function defaultClientId(): string {
64
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
65
+ return globalThis.crypto.randomUUID()
66
+ }
67
+ return `presence-${Date.now()}-${Math.random().toString(36).slice(2)}`
68
+ }
69
+
70
+ function defaultStorage(): PresenceStorage | null {
71
+ try {
72
+ return globalThis.sessionStorage ?? null
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ function defaultDocument(): PresenceDocument | null {
79
+ return typeof globalThis.document === 'undefined' ? null : globalThis.document
80
+ }
81
+
82
+ /** Return the stable id shared by presence callers in the current browser tab. */
83
+ export function getPresenceClientId(
84
+ storage: PresenceStorage | null = defaultStorage(),
85
+ createClientId: () => string = defaultClientId,
86
+ ): string {
87
+ try {
88
+ const stored = storage?.getItem(PRESENCE_CLIENT_ID_KEY)
89
+ if (stored) return stored
90
+ } catch {
91
+ // Keep the tab usable when storage is blocked.
92
+ }
93
+
94
+ const nextClientId = createClientId()
95
+ try {
96
+ if (storage) {
97
+ storage.setItem(PRESENCE_CLIENT_ID_KEY, nextClientId)
98
+ return nextClientId
99
+ }
100
+ } catch {
101
+ // Fall through to the page-local fallback when storage is blocked.
102
+ }
103
+
104
+ if (!pageFallbackClientId) pageFallbackClientId = nextClientId
105
+ return pageFallbackClientId
106
+ }
107
+
108
+ /** Report one visible integrated MINT browser tab to the platform presence tracker. */
109
+ export function usePresenceHeartbeat(
110
+ options: PresenceHeartbeatOptions,
111
+ ): UsePresenceHeartbeatReturn {
112
+ const presenceDocument = options.document === undefined ? defaultDocument() : options.document
113
+ const clientId = getPresenceClientId(
114
+ options.storage === undefined ? defaultStorage() : options.storage,
115
+ options.createClientId ?? defaultClientId,
116
+ )
117
+ const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
118
+ const scheduleInterval = options.setInterval ?? ((callback: () => void, delayMs: number) => (
119
+ globalThis.setInterval(callback, delayMs)
120
+ ))
121
+ const cancelInterval = options.clearInterval ?? ((intervalId: unknown) => {
122
+ globalThis.clearInterval(intervalId as ReturnType<typeof globalThis.setInterval>)
123
+ })
124
+ if (intervalMs <= 0) throw new Error('Presence heartbeat interval must be positive')
125
+
126
+ const api = options.send ? null : useApi({ baseUrl: '/api' })
127
+ const send = options.send ?? ((payload: PresenceHeartbeatPayload) => (
128
+ api!.post('/presence/heartbeat', payload)
129
+ ))
130
+ const enabled = computed(() => toValue(options.enabled ?? true))
131
+ const scope = computed(() => toValue(options.scope)?.trim() ?? '')
132
+ const isRunning = ref(false)
133
+ let intervalId: unknown | null = null
134
+ let sending = false
135
+ let mounted = false
136
+
137
+ function isVisible(): boolean {
138
+ return presenceDocument?.visibilityState !== 'hidden'
139
+ }
140
+
141
+ function shouldRun(): boolean {
142
+ return mounted && enabled.value && Boolean(scope.value) && isVisible()
143
+ }
144
+
145
+ async function sendNow(): Promise<void> {
146
+ if (!shouldRun() || sending) return
147
+ sending = true
148
+ try {
149
+ await send({ client_id: clientId, scope: scope.value })
150
+ } catch {
151
+ // Presence is advisory and must never break plugin or platform UI.
152
+ } finally {
153
+ sending = false
154
+ }
155
+ }
156
+
157
+ function stop(): void {
158
+ if (intervalId !== null) cancelInterval(intervalId)
159
+ intervalId = null
160
+ isRunning.value = false
161
+ }
162
+
163
+ function start(): void {
164
+ stop()
165
+ if (!shouldRun()) return
166
+ isRunning.value = true
167
+ void sendNow()
168
+ intervalId = scheduleInterval(() => void sendNow(), intervalMs)
169
+ }
170
+
171
+ function handleVisibilityChange(): void {
172
+ start()
173
+ }
174
+
175
+ watch([enabled, scope], start)
176
+
177
+ onMounted(() => {
178
+ mounted = true
179
+ presenceDocument?.addEventListener('visibilitychange', handleVisibilityChange)
180
+ start()
181
+ })
182
+
183
+ onScopeDispose(() => {
184
+ mounted = false
185
+ presenceDocument?.removeEventListener('visibilitychange', handleVisibilityChange)
186
+ stop()
187
+ })
188
+
189
+ return {
190
+ clientId,
191
+ isRunning: readonly(isRunning),
192
+ sendNow,
193
+ }
194
+ }