@morscherlab/mint-sdk 1.1.13 → 1.1.14
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/dist/{SettingsModal-D7LFUokT.js → SettingsModal-Brvcqz4a.js} +3 -3
- package/dist/{SettingsModal-D7LFUokT.js.map → SettingsModal-Brvcqz4a.js.map} +1 -1
- package/dist/__tests__/components/RuntimeUpdateNotice.test.d.ts +1 -0
- package/dist/__tests__/composables/useRuntimeAlignment.test.d.ts +1 -0
- package/dist/components/RuntimeUpdateNotice.vue.d.ts +9 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +4 -4
- package/dist/{components-DjJsjKqe.js → components-DIciVip2.js} +87 -23
- package/dist/components-DIciVip2.js.map +1 -0
- package/dist/composables/index.d.ts +2 -1
- package/dist/composables/index.js +6 -6
- package/dist/composables/usePlatformContext.d.ts +3 -0
- package/dist/composables/usePresenceHeartbeat.d.ts +5 -0
- package/dist/composables/useRuntimeAlignment.d.ts +44 -0
- package/dist/{composables-CRR_JxQD.js → composables-CPv3k1Yh.js} +3 -3
- package/dist/{composables-CRR_JxQD.js.map → composables-CPv3k1Yh.js.map} +1 -1
- package/dist/index.js +8 -8
- package/dist/install.js +3 -3
- package/dist/styles.css +27 -0
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Cr-48-u2.js → templates-Dyyjr6wD.js} +2 -2
- package/dist/{templates-Cr-48-u2.js.map → templates-Dyyjr6wD.js.map} +1 -1
- package/dist/types/platform.d.ts +2 -0
- package/dist/{useControlSchema-BMVb2XIQ.js → useControlSchema-B6-PO0F6.js} +13 -14
- package/dist/{useControlSchema-BMVb2XIQ.js.map → useControlSchema-B6-PO0F6.js.map} +1 -1
- package/dist/{useFormBuilder-DG3HOFGA.js → useFormBuilder-Pgb9Td6h.js} +2 -2
- package/dist/{useFormBuilder-DG3HOFGA.js.map → useFormBuilder-Pgb9Td6h.js.map} +1 -1
- package/dist/{useProtocolTemplates-CIzDGj2R.js → useProtocolTemplates-CYCnwGtY.js} +112 -5
- package/dist/useProtocolTemplates-CYCnwGtY.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/RuntimeUpdateNotice.test.ts +14 -0
- package/src/__tests__/composables/usePresenceHeartbeat.test.ts +17 -0
- package/src/__tests__/composables/useRuntimeAlignment.test.ts +145 -0
- package/src/components/AppTopBar.vue +14 -0
- package/src/components/RuntimeUpdateNotice.vue +51 -0
- package/src/components/index.ts +1 -0
- package/src/composables/index.ts +8 -0
- package/src/composables/usePresenceHeartbeat.ts +8 -1
- package/src/composables/useRuntimeAlignment.ts +184 -0
- package/src/types/platform.ts +2 -0
- package/dist/components-DjJsjKqe.js.map +0 -1
- package/dist/useProtocolTemplates-CIzDGj2R.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import RuntimeUpdateNotice from '../../components/RuntimeUpdateNotice.vue'
|
|
5
|
+
|
|
6
|
+
describe('RuntimeUpdateNotice', () => {
|
|
7
|
+
it('should offer one explicit reload action when an update is available', async () => {
|
|
8
|
+
const wrapper = mount(RuntimeUpdateNotice, { props: { visible: true } })
|
|
9
|
+
|
|
10
|
+
await wrapper.get('button').trigger('click')
|
|
11
|
+
|
|
12
|
+
expect(wrapper.emitted('reload')).toHaveLength(1)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -117,6 +117,23 @@ describe('usePresenceHeartbeat', () => {
|
|
|
117
117
|
wrapper.unmount()
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
+
it('should forward the platform revision response to the runtime observer', async () => {
|
|
121
|
+
const onResponse = vi.fn()
|
|
122
|
+
const response = { frontend_revision: 'sha256:new' }
|
|
123
|
+
const { wrapper } = mountHeartbeat({
|
|
124
|
+
scope: 'LEAF',
|
|
125
|
+
enabled: true,
|
|
126
|
+
storage: new MemoryStorage(),
|
|
127
|
+
createClientId: () => 'leaf-tab',
|
|
128
|
+
send: vi.fn().mockResolvedValue(response),
|
|
129
|
+
onResponse,
|
|
130
|
+
})
|
|
131
|
+
await nextTick()
|
|
132
|
+
|
|
133
|
+
expect(onResponse).toHaveBeenCalledWith(response)
|
|
134
|
+
wrapper.unmount()
|
|
135
|
+
})
|
|
136
|
+
|
|
120
137
|
it('should keep a page-local id when sessionStorage is unavailable', async () => {
|
|
121
138
|
const storage = {
|
|
122
139
|
getItem: vi.fn(() => { throw new Error('blocked') }),
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { defineComponent, nextTick } from 'vue'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
|
|
5
|
+
import { useRuntimeAlignment } from '../../composables/useRuntimeAlignment'
|
|
6
|
+
|
|
7
|
+
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem'> {
|
|
8
|
+
private readonly values = new Map<string, string>()
|
|
9
|
+
|
|
10
|
+
getItem(key: string): string | null {
|
|
11
|
+
return this.values.get(key) ?? null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setItem(key: string, value: string): void {
|
|
15
|
+
this.values.set(key, value)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mountRuntime(options: Parameters<typeof useRuntimeAlignment>[0]) {
|
|
20
|
+
let runtime: ReturnType<typeof useRuntimeAlignment> | undefined
|
|
21
|
+
const wrapper = mount(defineComponent({
|
|
22
|
+
setup() {
|
|
23
|
+
runtime = useRuntimeAlignment(options)
|
|
24
|
+
return () => null
|
|
25
|
+
},
|
|
26
|
+
}))
|
|
27
|
+
return { wrapper, get runtime() { return runtime! } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('useRuntimeAlignment', () => {
|
|
31
|
+
afterEach(() => vi.restoreAllMocks())
|
|
32
|
+
|
|
33
|
+
it('should offer a reload when a visible tab observes a different revision', async () => {
|
|
34
|
+
const replace = vi.fn()
|
|
35
|
+
const { runtime } = mountRuntime({
|
|
36
|
+
currentRevision: 'sha256:old',
|
|
37
|
+
visibility: () => 'visible',
|
|
38
|
+
replace,
|
|
39
|
+
storage: new MemoryStorage(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
runtime.observe({ frontend_revision: 'sha256:new' })
|
|
43
|
+
await nextTick()
|
|
44
|
+
|
|
45
|
+
expect(runtime.updateAvailable.value).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should reload a hidden stale tab automatically', () => {
|
|
49
|
+
const replace = vi.fn()
|
|
50
|
+
const { runtime } = mountRuntime({
|
|
51
|
+
currentRevision: 'sha256:old',
|
|
52
|
+
visibility: () => 'hidden',
|
|
53
|
+
replace,
|
|
54
|
+
storage: new MemoryStorage(),
|
|
55
|
+
currentUrl: () => 'https://mint.example/leaf/analysis',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
runtime.observe({ frontend_revision: 'sha256:new' })
|
|
59
|
+
|
|
60
|
+
expect(replace).toHaveBeenCalledWith(
|
|
61
|
+
'https://mint.example/leaf/analysis?__mint_revision=sha256%3Anew',
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should reload before reuse when a tab became stale while hidden', () => {
|
|
66
|
+
const replace = vi.fn()
|
|
67
|
+
let state: DocumentVisibilityState = 'visible'
|
|
68
|
+
let visibilityListener: EventListener | undefined
|
|
69
|
+
const runtime = mountRuntime({
|
|
70
|
+
currentRevision: 'sha256:before-hide',
|
|
71
|
+
visibility: () => state,
|
|
72
|
+
replace,
|
|
73
|
+
storage: new MemoryStorage(),
|
|
74
|
+
currentUrl: () => 'https://mint.example/leaf/analysis',
|
|
75
|
+
visibilityTarget: {
|
|
76
|
+
addEventListener: (_type, listener) => { visibilityListener = listener },
|
|
77
|
+
removeEventListener: () => {},
|
|
78
|
+
},
|
|
79
|
+
}).runtime
|
|
80
|
+
state = 'hidden'
|
|
81
|
+
visibilityListener?.(new Event('visibilitychange'))
|
|
82
|
+
state = 'visible'
|
|
83
|
+
|
|
84
|
+
runtime.observe({ frontend_revision: 'sha256:after-hide' })
|
|
85
|
+
|
|
86
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should make only one recovery reload for the same missing chunk', () => {
|
|
90
|
+
const replace = vi.fn()
|
|
91
|
+
const storage = new MemoryStorage()
|
|
92
|
+
const first = mountRuntime({
|
|
93
|
+
currentRevision: 'sha256:old',
|
|
94
|
+
visibility: () => 'visible',
|
|
95
|
+
replace,
|
|
96
|
+
storage,
|
|
97
|
+
currentUrl: () => 'https://mint.example/',
|
|
98
|
+
})
|
|
99
|
+
const second = mountRuntime({
|
|
100
|
+
currentRevision: 'sha256:old',
|
|
101
|
+
visibility: () => 'visible',
|
|
102
|
+
replace,
|
|
103
|
+
storage,
|
|
104
|
+
currentUrl: () => 'https://mint.example/',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
first.runtime.recoverMissingChunk()
|
|
108
|
+
second.runtime.recoverMissingChunk()
|
|
109
|
+
|
|
110
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should make only one recovery reload when session storage is unavailable', () => {
|
|
114
|
+
const replace = vi.fn()
|
|
115
|
+
const runtime = mountRuntime({
|
|
116
|
+
currentRevision: 'sha256:no-storage',
|
|
117
|
+
visibility: () => 'visible',
|
|
118
|
+
replace,
|
|
119
|
+
storage: null,
|
|
120
|
+
currentUrl: () => 'https://mint.example/',
|
|
121
|
+
}).runtime
|
|
122
|
+
|
|
123
|
+
runtime.recoverMissingChunk()
|
|
124
|
+
runtime.recoverMissingChunk()
|
|
125
|
+
|
|
126
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should not repeat chunk recovery after the cache-busted page reloads', () => {
|
|
130
|
+
const replace = vi.fn()
|
|
131
|
+
const runtime = mountRuntime({
|
|
132
|
+
currentRevision: 'sha256:already-reloaded',
|
|
133
|
+
visibility: () => 'visible',
|
|
134
|
+
replace,
|
|
135
|
+
storage: null,
|
|
136
|
+
currentUrl: () => (
|
|
137
|
+
'https://mint.example/?__mint_revision=sha256%3Aalready-reloaded'
|
|
138
|
+
),
|
|
139
|
+
}).runtime
|
|
140
|
+
|
|
141
|
+
runtime.recoverMissingChunk()
|
|
142
|
+
|
|
143
|
+
expect(replace).not.toHaveBeenCalled()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -21,8 +21,13 @@ import AppTopBarPillNavInternal from './internal/AppTopBarPillNavInternal.vue'
|
|
|
21
21
|
import AppAvatarMenu from './AppAvatarMenu.vue'
|
|
22
22
|
import AppPluginSwitcher from './AppPluginSwitcher.vue'
|
|
23
23
|
import PluginIcon from './PluginIcon.vue'
|
|
24
|
+
import RuntimeUpdateNotice from './RuntimeUpdateNotice.vue'
|
|
24
25
|
import { usePlatformContext } from '../composables/usePlatformContext'
|
|
25
26
|
import { usePresenceHeartbeat } from '../composables/usePresenceHeartbeat'
|
|
27
|
+
import {
|
|
28
|
+
getInjectedFrontendRevision,
|
|
29
|
+
useRuntimeAlignment,
|
|
30
|
+
} from '../composables/useRuntimeAlignment'
|
|
26
31
|
import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
|
|
27
32
|
import {
|
|
28
33
|
currentItemIdFromLocation,
|
|
@@ -117,9 +122,13 @@ const settingsOpen = ref(false)
|
|
|
117
122
|
const { isIntegrated, plugin } = usePlatformContext()
|
|
118
123
|
const isStandalone = computed(() => !isIntegrated.value)
|
|
119
124
|
const presenceScope = computed(() => plugin.value?.id || plugin.value?.name || '')
|
|
125
|
+
const runtimeAlignment = useRuntimeAlignment({
|
|
126
|
+
currentRevision: getInjectedFrontendRevision(),
|
|
127
|
+
})
|
|
120
128
|
usePresenceHeartbeat({
|
|
121
129
|
enabled: isIntegrated,
|
|
122
130
|
scope: presenceScope,
|
|
131
|
+
onResponse: runtimeAlignment.observe,
|
|
123
132
|
})
|
|
124
133
|
const appExperiment = inject(APP_EXPERIMENT_KEY, null)
|
|
125
134
|
const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
|
|
@@ -423,6 +432,11 @@ const effectiveCurrentPageSelectorId = computed(() =>
|
|
|
423
432
|
</div>
|
|
424
433
|
</header>
|
|
425
434
|
|
|
435
|
+
<RuntimeUpdateNotice
|
|
436
|
+
:visible="runtimeAlignment.updateAvailable.value"
|
|
437
|
+
@reload="runtimeAlignment.reload"
|
|
438
|
+
/>
|
|
439
|
+
|
|
426
440
|
<SettingsModal
|
|
427
441
|
v-if="showSettings && settingsOpen"
|
|
428
442
|
v-model="settingsOpen"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RefreshCw } from 'lucide-vue-next'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
visible: boolean
|
|
6
|
+
}>()
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
reload: []
|
|
10
|
+
}>()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div v-if="visible" class="mint-runtime-update" role="status">
|
|
15
|
+
<RefreshCw :size="16" aria-hidden="true" />
|
|
16
|
+
<span>A new version is ready.</span>
|
|
17
|
+
<button type="button" @click="emit('reload')">
|
|
18
|
+
Reload now
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.mint-runtime-update {
|
|
25
|
+
position: fixed;
|
|
26
|
+
right: 20px;
|
|
27
|
+
bottom: 20px;
|
|
28
|
+
z-index: 1200;
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 10px;
|
|
32
|
+
padding: 10px 12px;
|
|
33
|
+
color: var(--mint-text-primary, #172033);
|
|
34
|
+
background: var(--mint-bg-primary, #fff);
|
|
35
|
+
border: 1px solid var(--mint-border-default, #d9e1ec);
|
|
36
|
+
border-radius: 10px;
|
|
37
|
+
box-shadow: 0 10px 28px rgb(15 23 42 / 16%);
|
|
38
|
+
font-size: 13px;
|
|
39
|
+
font-weight: 500;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.mint-runtime-update button {
|
|
43
|
+
padding: 5px 9px;
|
|
44
|
+
color: #fff;
|
|
45
|
+
background: var(--mint-color-primary, #0f9f8f);
|
|
46
|
+
border: 0;
|
|
47
|
+
border-radius: 6px;
|
|
48
|
+
font: inherit;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
}
|
|
51
|
+
</style>
|
package/src/components/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export { default as FileUploader } from './FileUploader.vue'
|
|
|
28
28
|
// Feedback components
|
|
29
29
|
export { default as AlertBox } from './AlertBox.vue'
|
|
30
30
|
export { default as AppToastContainer } from './AppToastContainer.vue'
|
|
31
|
+
export { default as RuntimeUpdateNotice } from './RuntimeUpdateNotice.vue'
|
|
31
32
|
|
|
32
33
|
// Action components
|
|
33
34
|
export { default as ActionMenu } from './ActionMenu.vue'
|
package/src/composables/index.ts
CHANGED
|
@@ -15,8 +15,16 @@ export {
|
|
|
15
15
|
usePresenceHeartbeat,
|
|
16
16
|
type PresenceHeartbeatOptions,
|
|
17
17
|
type PresenceHeartbeatPayload,
|
|
18
|
+
type PresenceHeartbeatResponse,
|
|
18
19
|
type UsePresenceHeartbeatReturn,
|
|
19
20
|
} from './usePresenceHeartbeat'
|
|
21
|
+
export {
|
|
22
|
+
getInjectedFrontendRevision,
|
|
23
|
+
useRuntimeAlignment,
|
|
24
|
+
type RuntimeAlignmentOptions,
|
|
25
|
+
type RuntimeRevisionResponse,
|
|
26
|
+
type UseRuntimeAlignmentReturn,
|
|
27
|
+
} from './useRuntimeAlignment'
|
|
20
28
|
export {
|
|
21
29
|
useForm,
|
|
22
30
|
type ValidationRule,
|
|
@@ -22,6 +22,10 @@ export interface PresenceHeartbeatPayload {
|
|
|
22
22
|
scope: string
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface PresenceHeartbeatResponse {
|
|
26
|
+
frontend_revision?: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
interface PresenceDocument {
|
|
26
30
|
readonly visibilityState: DocumentVisibilityState
|
|
27
31
|
addEventListener(type: 'visibilitychange', listener: EventListener): void
|
|
@@ -48,6 +52,8 @@ export interface PresenceHeartbeatOptions {
|
|
|
48
52
|
createClientId?: () => string
|
|
49
53
|
/** Test or custom transport seam. Defaults to authenticated POST /api/presence/heartbeat. */
|
|
50
54
|
send?: (payload: PresenceHeartbeatPayload) => Promise<unknown> | unknown
|
|
55
|
+
/** Observe the authenticated response without adding another poller. */
|
|
56
|
+
onResponse?: (response: PresenceHeartbeatResponse | unknown) => void
|
|
51
57
|
/** Test timing seam. Defaults to the browser scheduler. */
|
|
52
58
|
setInterval?: (callback: () => void, intervalMs: number) => unknown
|
|
53
59
|
/** Test timing seam. Defaults to the browser scheduler. */
|
|
@@ -146,7 +152,8 @@ export function usePresenceHeartbeat(
|
|
|
146
152
|
if (!shouldRun() || sending) return
|
|
147
153
|
sending = true
|
|
148
154
|
try {
|
|
149
|
-
await send({ client_id: clientId, scope: scope.value })
|
|
155
|
+
const response = await send({ client_id: clientId, scope: scope.value })
|
|
156
|
+
options.onResponse?.(response)
|
|
150
157
|
} catch {
|
|
151
158
|
// Presence is advisory and must never break plugin or platform UI.
|
|
152
159
|
} finally {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onMounted,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
readonly,
|
|
6
|
+
ref,
|
|
7
|
+
toValue,
|
|
8
|
+
type MaybeRefOrGetter,
|
|
9
|
+
type Ref,
|
|
10
|
+
} from 'vue'
|
|
11
|
+
|
|
12
|
+
const RUNTIME_RELOAD_KEY_PREFIX = 'mint:runtime-reload:'
|
|
13
|
+
const REVISION_QUERY_KEY = '__mint_revision'
|
|
14
|
+
const inMemoryReloadGuards = new Set<string>()
|
|
15
|
+
|
|
16
|
+
interface RuntimeStorage {
|
|
17
|
+
getItem(key: string): string | null
|
|
18
|
+
setItem(key: string, value: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RuntimeEventTarget {
|
|
22
|
+
addEventListener(type: 'vite:preloadError', listener: EventListener): void
|
|
23
|
+
removeEventListener(type: 'vite:preloadError', listener: EventListener): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RuntimeVisibilityTarget {
|
|
27
|
+
addEventListener(type: 'visibilitychange', listener: EventListener): void
|
|
28
|
+
removeEventListener(type: 'visibilitychange', listener: EventListener): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RuntimeRevisionResponse {
|
|
32
|
+
frontend_revision?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RuntimeAlignmentOptions {
|
|
36
|
+
/** Revision embedded in the HTML that booted this tab. */
|
|
37
|
+
currentRevision?: MaybeRefOrGetter<string | null | undefined>
|
|
38
|
+
/** Browser visibility seam. */
|
|
39
|
+
visibility?: () => DocumentVisibilityState
|
|
40
|
+
/** Current location seam. */
|
|
41
|
+
currentUrl?: () => string
|
|
42
|
+
/** Location replacement seam. */
|
|
43
|
+
replace?: (url: string) => void
|
|
44
|
+
/** Per-tab recovery guard storage. */
|
|
45
|
+
storage?: RuntimeStorage | null
|
|
46
|
+
/** Vite preload error source. */
|
|
47
|
+
eventTarget?: RuntimeEventTarget | null
|
|
48
|
+
/** Browser visibility event source. */
|
|
49
|
+
visibilityTarget?: RuntimeVisibilityTarget | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UseRuntimeAlignmentReturn {
|
|
53
|
+
updateAvailable: Readonly<Ref<boolean>>
|
|
54
|
+
nextRevision: Readonly<Ref<string | null>>
|
|
55
|
+
observe: (response: RuntimeRevisionResponse | unknown) => void
|
|
56
|
+
reload: () => void
|
|
57
|
+
recoverMissingChunk: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function browserStorage(): RuntimeStorage | null {
|
|
61
|
+
try {
|
|
62
|
+
return globalThis.sessionStorage ?? null
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function browserEventTarget(): RuntimeEventTarget | null {
|
|
69
|
+
return typeof globalThis.window === 'undefined' ? null : globalThis.window
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function browserVisibilityTarget(): RuntimeVisibilityTarget | null {
|
|
73
|
+
return typeof globalThis.document === 'undefined' ? null : globalThis.document
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function responseRevision(response: RuntimeRevisionResponse | unknown): string | null {
|
|
77
|
+
if (!response || typeof response !== 'object') return null
|
|
78
|
+
const value = (response as RuntimeRevisionResponse).frontend_revision
|
|
79
|
+
return typeof value === 'string' && value ? value : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Keep a loaded MINT frontend aligned with the backend revision serving its scope. */
|
|
83
|
+
export function useRuntimeAlignment(
|
|
84
|
+
options: RuntimeAlignmentOptions = {},
|
|
85
|
+
): UseRuntimeAlignmentReturn {
|
|
86
|
+
const storage = options.storage === undefined ? browserStorage() : options.storage
|
|
87
|
+
const eventTarget = options.eventTarget === undefined ? browserEventTarget() : options.eventTarget
|
|
88
|
+
const visibilityTarget = options.visibilityTarget === undefined
|
|
89
|
+
? browserVisibilityTarget()
|
|
90
|
+
: options.visibilityTarget
|
|
91
|
+
const visibility = options.visibility ?? (() => globalThis.document?.visibilityState ?? 'visible')
|
|
92
|
+
const currentUrl = options.currentUrl ?? (() => globalThis.location?.href ?? '/')
|
|
93
|
+
const replace = options.replace ?? ((url: string) => globalThis.location?.replace(url))
|
|
94
|
+
const initialRevision = computed(() => toValue(options.currentRevision) ?? null)
|
|
95
|
+
const baselineRevision = ref<string | null>(initialRevision.value)
|
|
96
|
+
const nextRevision = ref<string | null>(null)
|
|
97
|
+
const updateAvailable = computed(() => nextRevision.value !== null)
|
|
98
|
+
let wasHidden = false
|
|
99
|
+
|
|
100
|
+
function reloadTo(revision: string, guardKind: 'revision' | 'chunk'): void {
|
|
101
|
+
const guardKey = `${RUNTIME_RELOAD_KEY_PREFIX}${guardKind}:${revision}`
|
|
102
|
+
const url = new URL(currentUrl(), globalThis.location?.origin ?? 'http://localhost')
|
|
103
|
+
if (url.searchParams.get(REVISION_QUERY_KEY) === revision) return
|
|
104
|
+
if (inMemoryReloadGuards.has(guardKey)) return
|
|
105
|
+
try {
|
|
106
|
+
if (storage?.getItem(guardKey)) {
|
|
107
|
+
inMemoryReloadGuards.add(guardKey)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
storage?.setItem(guardKey, '1')
|
|
111
|
+
} catch {
|
|
112
|
+
// A blocked sessionStorage must not block recovery.
|
|
113
|
+
}
|
|
114
|
+
inMemoryReloadGuards.add(guardKey)
|
|
115
|
+
url.searchParams.set(REVISION_QUERY_KEY, revision)
|
|
116
|
+
replace(url.toString())
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function observe(response: RuntimeRevisionResponse | unknown): void {
|
|
120
|
+
const revision = responseRevision(response)
|
|
121
|
+
if (!revision) return
|
|
122
|
+
const current = initialRevision.value ?? baselineRevision.value
|
|
123
|
+
if (!current) {
|
|
124
|
+
baselineRevision.value = revision
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (revision === current) {
|
|
128
|
+
wasHidden = false
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (wasHidden || visibility() === 'hidden') {
|
|
132
|
+
wasHidden = false
|
|
133
|
+
reloadTo(revision, 'revision')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
nextRevision.value = revision
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reload(): void {
|
|
140
|
+
if (nextRevision.value) reloadTo(nextRevision.value, 'revision')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function recoverMissingChunk(): void {
|
|
144
|
+
reloadTo(baselineRevision.value ?? initialRevision.value ?? 'unknown', 'chunk')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handlePreloadError(event: Event): void {
|
|
148
|
+
event.preventDefault()
|
|
149
|
+
recoverMissingChunk()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleVisibilityChange(): void {
|
|
153
|
+
if (visibility() === 'hidden') wasHidden = true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onMounted(() => {
|
|
157
|
+
eventTarget?.addEventListener('vite:preloadError', handlePreloadError)
|
|
158
|
+
visibilityTarget?.addEventListener('visibilitychange', handleVisibilityChange)
|
|
159
|
+
})
|
|
160
|
+
onScopeDispose(() => {
|
|
161
|
+
eventTarget?.removeEventListener('vite:preloadError', handlePreloadError)
|
|
162
|
+
visibilityTarget?.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
updateAvailable: readonly(updateAvailable),
|
|
167
|
+
nextRevision: readonly(nextRevision),
|
|
168
|
+
observe,
|
|
169
|
+
reload,
|
|
170
|
+
recoverMissingChunk,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read the revision injected into the HTML that started this browser tab. */
|
|
175
|
+
export function getInjectedFrontendRevision(): string | null {
|
|
176
|
+
if (typeof globalThis.window === 'undefined') return null
|
|
177
|
+
const runtimeWindow = globalThis.window as typeof globalThis.window & {
|
|
178
|
+
__MINT_FRONTEND_REVISION__?: string
|
|
179
|
+
__MINT_PLATFORM__?: { plugin?: { frontend_revision?: string } }
|
|
180
|
+
}
|
|
181
|
+
return runtimeWindow.__MINT_PLATFORM__?.plugin?.frontend_revision
|
|
182
|
+
?? runtimeWindow.__MINT_FRONTEND_REVISION__
|
|
183
|
+
?? null
|
|
184
|
+
}
|
package/src/types/platform.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface PluginInfo {
|
|
|
13
13
|
api_prefix: string
|
|
14
14
|
/** Settings route selected by the platform for this plugin runtime. */
|
|
15
15
|
settings_api?: 'plugin' | 'platform'
|
|
16
|
+
/** Content identity of the HTML that booted this integrated plugin tab. */
|
|
17
|
+
frontend_revision?: string
|
|
16
18
|
nav_items?: PluginNavItem[]
|
|
17
19
|
settings?: PluginSettings
|
|
18
20
|
}
|