@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.
Files changed (42) hide show
  1. package/dist/{SettingsModal-D7LFUokT.js → SettingsModal-Brvcqz4a.js} +3 -3
  2. package/dist/{SettingsModal-D7LFUokT.js.map → SettingsModal-Brvcqz4a.js.map} +1 -1
  3. package/dist/__tests__/components/RuntimeUpdateNotice.test.d.ts +1 -0
  4. package/dist/__tests__/composables/useRuntimeAlignment.test.d.ts +1 -0
  5. package/dist/components/RuntimeUpdateNotice.vue.d.ts +9 -0
  6. package/dist/components/index.d.ts +1 -0
  7. package/dist/components/index.js +4 -4
  8. package/dist/{components-DjJsjKqe.js → components-DIciVip2.js} +87 -23
  9. package/dist/components-DIciVip2.js.map +1 -0
  10. package/dist/composables/index.d.ts +2 -1
  11. package/dist/composables/index.js +6 -6
  12. package/dist/composables/usePlatformContext.d.ts +3 -0
  13. package/dist/composables/usePresenceHeartbeat.d.ts +5 -0
  14. package/dist/composables/useRuntimeAlignment.d.ts +44 -0
  15. package/dist/{composables-CRR_JxQD.js → composables-CPv3k1Yh.js} +3 -3
  16. package/dist/{composables-CRR_JxQD.js.map → composables-CPv3k1Yh.js.map} +1 -1
  17. package/dist/index.js +8 -8
  18. package/dist/install.js +3 -3
  19. package/dist/styles.css +27 -0
  20. package/dist/templates/index.js +2 -2
  21. package/dist/{templates-Cr-48-u2.js → templates-Dyyjr6wD.js} +2 -2
  22. package/dist/{templates-Cr-48-u2.js.map → templates-Dyyjr6wD.js.map} +1 -1
  23. package/dist/types/platform.d.ts +2 -0
  24. package/dist/{useControlSchema-BMVb2XIQ.js → useControlSchema-B6-PO0F6.js} +13 -14
  25. package/dist/{useControlSchema-BMVb2XIQ.js.map → useControlSchema-B6-PO0F6.js.map} +1 -1
  26. package/dist/{useFormBuilder-DG3HOFGA.js → useFormBuilder-Pgb9Td6h.js} +2 -2
  27. package/dist/{useFormBuilder-DG3HOFGA.js.map → useFormBuilder-Pgb9Td6h.js.map} +1 -1
  28. package/dist/{useProtocolTemplates-CIzDGj2R.js → useProtocolTemplates-CYCnwGtY.js} +112 -5
  29. package/dist/useProtocolTemplates-CYCnwGtY.js.map +1 -0
  30. package/package.json +1 -1
  31. package/src/__tests__/components/RuntimeUpdateNotice.test.ts +14 -0
  32. package/src/__tests__/composables/usePresenceHeartbeat.test.ts +17 -0
  33. package/src/__tests__/composables/useRuntimeAlignment.test.ts +145 -0
  34. package/src/components/AppTopBar.vue +14 -0
  35. package/src/components/RuntimeUpdateNotice.vue +51 -0
  36. package/src/components/index.ts +1 -0
  37. package/src/composables/index.ts +8 -0
  38. package/src/composables/usePresenceHeartbeat.ts +8 -1
  39. package/src/composables/useRuntimeAlignment.ts +184 -0
  40. package/src/types/platform.ts +2 -0
  41. package/dist/components-DjJsjKqe.js.map +0 -1
  42. 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.13",
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>
@@ -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'
@@ -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
+ }
@@ -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
  }