@morscherlab/mint-sdk 1.5.0-beta.16 → 1.5.0-beta.18
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.
|
@@ -35,10 +35,10 @@ export type RealtimeHandler = (event: RealtimeEvent) => void;
|
|
|
35
35
|
export interface UseRealtimeOptions {
|
|
36
36
|
/**
|
|
37
37
|
* The caller loads all its data through a client that records
|
|
38
|
-
* `X-MINT-Event-Seq`: `useApi()
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* not set it. When every caller of the tab sets it, the stream opens with the
|
|
38
|
+
* `X-MINT-Event-Seq`: `useApi()`, a `createClient` client (it sends each
|
|
39
|
+
* request through `useApi()`) or the platform's own client. Responses of a
|
|
40
|
+
* plain `fetch` or another HTTP client are not recorded, so a caller that
|
|
41
|
+
* loads through them must not set it. When every caller of the tab sets it, the stream opens with the
|
|
42
42
|
* lowest recorded sequence since the page loaded, and the server sends
|
|
43
43
|
* `resync` only when a topic changed since then, instead of one at each open.
|
|
44
44
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useRealtime-t7Qg2a5h.js","names":[],"sources":["../src/stores/auth.ts","../src/composables/useRealtime.ts"],"sourcesContent":["import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport type { UserInfo } from '../types'\n\n/** localStorage key of the platform session token. The platform owns the stored value. */\nexport const AUTH_TOKEN_STORAGE_KEY = 'mint-auth-token'\n/** localStorage key of the platform session token expiry (ISO 8601). */\nexport const AUTH_EXPIRES_STORAGE_KEY = 'mint-auth-expires'\n\nfunction readStoredItem(key: string): string | null {\n try {\n const storage = globalThis.localStorage\n return typeof storage?.getItem === 'function' ? storage.getItem(key) : null\n } catch {\n return null\n }\n}\n\n/**\n * Read-only session bridge between the platform and SDK code.\n *\n * SDK clients read the session token from here. Only the platform writes the\n * session, through `@morscherlab/mint-sdk/host`. The SDK never writes or removes\n * the stored platform token.\n */\nexport const useAuthStore = defineStore('mint-auth', () => {\n const token = ref<string | null>(null)\n const tokenExpires = ref<Date | null>(null)\n const userInfo = ref<UserInfo | null>(null)\n const authRequired = ref(true)\n const isInitialized = ref(false)\n\n const isAuthenticated = computed(() => {\n if (!authRequired.value) return true\n if (!token.value) return false\n return !(tokenExpires.value && tokenExpires.value < new Date())\n })\n\n /** Read the stored platform session token, once per page. */\n function initialize() {\n const storedToken = readStoredItem(AUTH_TOKEN_STORAGE_KEY)\n const storedExpires = readStoredItem(AUTH_EXPIRES_STORAGE_KEY)\n const expires = storedExpires ? new Date(storedExpires) : null\n\n if (storedToken && !(expires && expires <= new Date())) {\n token.value = storedToken\n tokenExpires.value = expires\n }\n\n isInitialized.value = true\n }\n\n /** Forget the session in memory after the server rejected its token. */\n function clearToken() {\n token.value = null\n tokenExpires.value = null\n userInfo.value = null\n }\n\n return {\n token,\n tokenExpires,\n userInfo,\n authRequired,\n isInitialized,\n isAuthenticated,\n initialize,\n clearToken,\n }\n})\n","import {\n getCurrentScope,\n onScopeDispose,\n readonly,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref,\n} from 'vue'\n\n/**\n * One event of the platform's realtime stream (`GET /api/events`).\n *\n * Platform events carry no entity data: refetch the entity through REST.\n * `type` is `resync` (refetch everything you show), the audit action of a\n * committed write, such as `experiment.update` or `analysis.artifact_create`,\n * or the `\"<plugin>.<event>\"` name of a plugin event, which carries `payload`.\n * Only `usePresence` receives `presence.changed`.\n */\nexport interface RealtimeEvent {\n type: string\n id?: string\n /**\n * `experiment:{id}`, `project:{id}`, `experiments`, `user:{id}`, or a plugin's\n * `plugin:{name}` / `plugin:{name}:experiment:{id}`; absent on `resync`.\n */\n topic?: string\n /** A plugin event: the JSON payload the plugin published (at most 16 KiB). */\n payload?: unknown\n /** Changed fields of an update, when the write names them. */\n fields?: string[] | null\n actor_id?: string | null\n request_id?: string | null\n at?: string\n /** `presence.changed`: the topic's viewers (at most 20, `editing` first). */\n viewers?: { user_id: string, activity: string }[]\n /** `presence.changed`: the number of viewers, also those beyond `viewers`. */\n count?: number\n}\n\nexport type RealtimeHandler = (event: RealtimeEvent) => void\n\nexport interface UseRealtimeOptions {\n /**\n * The caller loads all its data through a client that records\n * `X-MINT-Event-Seq`: `useApi()` or the platform's own client. Responses of\n * a `createClient` client (plain `fetch`) and of `getApiClient()` before the\n * first `useApi()` are not recorded, so a caller that loads through them must\n * not set it. When every caller of the tab sets it, the stream opens with the\n * lowest recorded sequence since the page loaded, and the server sends\n * `resync` only when a topic changed since then, instead of one at each open.\n */\n cursor?: boolean\n}\n\nexport interface UseRealtimeReturn {\n /** True while the shared stream is open. */\n connected: Readonly<Ref<boolean>>\n /** Unsubscribe; also runs when the calling component or effect scope is disposed. */\n stop: () => void\n}\n\nconst EVENTS_URL = '/api/events'\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\n/** Merges the subscriptions of components that mount together into one connection. */\nconst TOPIC_DEBOUNCE_MS = 50\nconst RESYNC: RealtimeEvent = { type: 'resync' }\n\ninterface Listener {\n topics: () => string[]\n handler: RealtimeHandler\n /** Receives `presence.changed`; a write listener would refetch on each viewer change. */\n presence: boolean\n /** Its data loads record their event cursor (see `UseRealtimeOptions.cursor`). */\n cursor: boolean\n}\n\ninterface EventCursor {\n seq: number\n boot: string\n}\n\nconst listeners = new Set<Listener>()\n/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */\nconst presence = new Map<string, RealtimeEvent>()\nconst connected = ref(false)\nlet source: EventSource | null = null\nlet sourceTopics = ''\nlet failures = 0\nlet syncTimer: ReturnType<typeof setTimeout> | null = null\nlet retryTimer: ReturnType<typeof setTimeout> | null = null\n/** The lowest `X-MINT-Event-Seq` of the responses since the page loaded, and their boot id (`''` once two differ). */\nlet tracked: EventCursor | null = null\n/** The cursor the current stream was opened with. */\nlet streamCursor: EventCursor | null = null\n\n/** @internal Record the `X-MINT-Event-Seq` / `X-MINT-Boot-Id` headers of one platform response. */\nexport function noteEventSeq(seq: string | null | undefined, bootId: string | null | undefined): void {\n const value = Number(seq)\n // No header: the response tells nothing (an older platform, another origin).\n if (!seq || !bootId || !Number.isSafeInteger(value)) return\n // A request that started before the stream's cursor, or on another process,\n // may miss an event the server did not check: refetch.\n if (streamCursor && (value < streamCursor.seq || bootId !== streamCursor.boot)) {\n // Moved, so each late condition refetches once instead of on every refetch response.\n streamCursor = { seq: value, boot: bootId }\n dispatch(RESYNC)\n }\n // Sequences of two processes do not compare: '' never matches, so every later open resyncs.\n tracked = tracked\n ? { seq: Math.min(tracked.seq, value), boot: tracked.boot === bootId ? tracked.boot : '' }\n : { seq: value, boot: bootId }\n}\n\nfunction dispatch(event: RealtimeEvent): void {\n const isPresence = event.type === 'presence.changed'\n if (isPresence && event.topic) presence.set(event.topic, event)\n for (const listener of [...listeners]) {\n if (isPresence && !listener.presence) continue\n if (event.type === 'resync' || (event.topic && listener.topics().includes(event.topic))) {\n listener.handler(event)\n }\n }\n}\n\nfunction close(): void {\n source?.close()\n source = null\n sourceTopics = ''\n presence.clear()\n streamCursor = null\n connected.value = false\n}\n\nfunction open(topics: string): void {\n const cursor = tracked && [...listeners].every(listener => listener.cursor) ? tracked : null\n const query = cursor ? `&since=${cursor.seq}&boot=${encodeURIComponent(cursor.boot)}` : ''\n const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}${query}`)\n source = stream\n sourceTopics = topics\n streamCursor = cursor\n stream.onopen = () => {\n connected.value = true\n // The server subscribes only now: a write between a caller's REST load (or\n // the close of the previous stream) and this point sent no event. With a\n // cursor the server sends `resync` when needed; otherwise every caller refetches.\n // A caller that did not opt in may have joined while the stream connected.\n if (!cursor || ![...listeners].every(listener => listener.cursor)) dispatch(RESYNC)\n failures = 0\n }\n stream.onmessage = (message) => {\n dispatch(JSON.parse(message.data) as RealtimeEvent)\n }\n stream.onerror = () => {\n // EventSource retries a dropped connection itself but gives up after an\n // HTTP error, so one backoff path handles both.\n if (source !== stream) return\n close()\n // A topic change during the wait does not shorten it: the retry reads the topics when it runs.\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = null\n retryTimer = setTimeout(() => {\n retryTimer = null\n sync()\n }, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures))\n failures += 1\n }\n}\n\nfunction sync(): void {\n syncTimer = null\n const topics = [...new Set([...listeners].flatMap(listener => listener.topics()))].sort().join(',')\n if (source && topics === sourceTopics) return\n close()\n if (topics) open(topics)\n}\n\nfunction schedule(): void {\n if (retryTimer !== null) return\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS)\n}\n\n/**\n * Subscribe to topics of the platform's realtime stream.\n *\n * Every caller in a tab shares one `EventSource`, opened on the union of\n * their topics and reopened when that union changes. `handler` receives the\n * write events of the caller's own topics (not `presence.changed`, which goes\n * to `usePresence`) plus every `resync`: one comes from the\n * server when this tab fell behind, and one follows each open of the stream:\n * the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for\n * a changed topic union. With `options.cursor` set by every caller, the open\n * sends no `resync` of its own; the server sends one when a topic changed since\n * the oldest recorded REST load. On `resync`, refetch. The union of all callers' topics\n * in a tab may hold at most 50 topics; the platform refuses a larger stream.\n *\n * The stream authenticates with the session cookie, so it works in the\n * platform and in plugin frontends served by it. It exists only when the\n * platform's `realtime` feature flag is on; check that flag first.\n *\n * A plugin topic reaches a caller whose role may use the plugin; an\n * experiment plugin topic also needs a caller who may view the experiment.\n *\n * @example\n * useRealtime(() => [`experiment:${id.value}`], () => refetch())\n * @example\n * useRealtime(['plugin:drp'], (event) => {\n * if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress\n * })\n */\nexport function useRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n options: UseRealtimeOptions = {},\n): UseRealtimeReturn {\n return subscribeRealtime(topics, handler, false, options.cursor ?? false)\n}\n\n/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */\nexport function currentPresence(topic: string): RealtimeEvent | undefined {\n return presence.get(topic)\n}\n\n/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */\nexport function subscribeRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n withPresence: boolean,\n cursor = false,\n): UseRealtimeReturn {\n // A presence caller does not refetch on resync, so it never needs the client-side one.\n const listener: Listener = { topics: () => toValue(topics), handler, presence: withPresence, cursor: cursor || withPresence }\n listeners.add(listener)\n const stopWatch = watch(() => listener.topics().join(','), () => schedule())\n schedule()\n\n function stop(): void {\n stopWatch()\n if (listeners.delete(listener)) schedule()\n }\n\n if (getCurrentScope()) onScopeDispose(stop)\n return { connected: readonly(connected), stop }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useRealtime-t7Qg2a5h.js","names":[],"sources":["../src/stores/auth.ts","../src/composables/useRealtime.ts"],"sourcesContent":["import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport type { UserInfo } from '../types'\n\n/** localStorage key of the platform session token. The platform owns the stored value. */\nexport const AUTH_TOKEN_STORAGE_KEY = 'mint-auth-token'\n/** localStorage key of the platform session token expiry (ISO 8601). */\nexport const AUTH_EXPIRES_STORAGE_KEY = 'mint-auth-expires'\n\nfunction readStoredItem(key: string): string | null {\n try {\n const storage = globalThis.localStorage\n return typeof storage?.getItem === 'function' ? storage.getItem(key) : null\n } catch {\n return null\n }\n}\n\n/**\n * Read-only session bridge between the platform and SDK code.\n *\n * SDK clients read the session token from here. Only the platform writes the\n * session, through `@morscherlab/mint-sdk/host`. The SDK never writes or removes\n * the stored platform token.\n */\nexport const useAuthStore = defineStore('mint-auth', () => {\n const token = ref<string | null>(null)\n const tokenExpires = ref<Date | null>(null)\n const userInfo = ref<UserInfo | null>(null)\n const authRequired = ref(true)\n const isInitialized = ref(false)\n\n const isAuthenticated = computed(() => {\n if (!authRequired.value) return true\n if (!token.value) return false\n return !(tokenExpires.value && tokenExpires.value < new Date())\n })\n\n /** Read the stored platform session token, once per page. */\n function initialize() {\n const storedToken = readStoredItem(AUTH_TOKEN_STORAGE_KEY)\n const storedExpires = readStoredItem(AUTH_EXPIRES_STORAGE_KEY)\n const expires = storedExpires ? new Date(storedExpires) : null\n\n if (storedToken && !(expires && expires <= new Date())) {\n token.value = storedToken\n tokenExpires.value = expires\n }\n\n isInitialized.value = true\n }\n\n /** Forget the session in memory after the server rejected its token. */\n function clearToken() {\n token.value = null\n tokenExpires.value = null\n userInfo.value = null\n }\n\n return {\n token,\n tokenExpires,\n userInfo,\n authRequired,\n isInitialized,\n isAuthenticated,\n initialize,\n clearToken,\n }\n})\n","import {\n getCurrentScope,\n onScopeDispose,\n readonly,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref,\n} from 'vue'\n\n/**\n * One event of the platform's realtime stream (`GET /api/events`).\n *\n * Platform events carry no entity data: refetch the entity through REST.\n * `type` is `resync` (refetch everything you show), the audit action of a\n * committed write, such as `experiment.update` or `analysis.artifact_create`,\n * or the `\"<plugin>.<event>\"` name of a plugin event, which carries `payload`.\n * Only `usePresence` receives `presence.changed`.\n */\nexport interface RealtimeEvent {\n type: string\n id?: string\n /**\n * `experiment:{id}`, `project:{id}`, `experiments`, `user:{id}`, or a plugin's\n * `plugin:{name}` / `plugin:{name}:experiment:{id}`; absent on `resync`.\n */\n topic?: string\n /** A plugin event: the JSON payload the plugin published (at most 16 KiB). */\n payload?: unknown\n /** Changed fields of an update, when the write names them. */\n fields?: string[] | null\n actor_id?: string | null\n request_id?: string | null\n at?: string\n /** `presence.changed`: the topic's viewers (at most 20, `editing` first). */\n viewers?: { user_id: string, activity: string }[]\n /** `presence.changed`: the number of viewers, also those beyond `viewers`. */\n count?: number\n}\n\nexport type RealtimeHandler = (event: RealtimeEvent) => void\n\nexport interface UseRealtimeOptions {\n /**\n * The caller loads all its data through a client that records\n * `X-MINT-Event-Seq`: `useApi()`, a `createClient` client (it sends each\n * request through `useApi()`) or the platform's own client. Responses of a\n * plain `fetch` or another HTTP client are not recorded, so a caller that\n * loads through them must not set it. When every caller of the tab sets it, the stream opens with the\n * lowest recorded sequence since the page loaded, and the server sends\n * `resync` only when a topic changed since then, instead of one at each open.\n */\n cursor?: boolean\n}\n\nexport interface UseRealtimeReturn {\n /** True while the shared stream is open. */\n connected: Readonly<Ref<boolean>>\n /** Unsubscribe; also runs when the calling component or effect scope is disposed. */\n stop: () => void\n}\n\nconst EVENTS_URL = '/api/events'\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\n/** Merges the subscriptions of components that mount together into one connection. */\nconst TOPIC_DEBOUNCE_MS = 50\nconst RESYNC: RealtimeEvent = { type: 'resync' }\n\ninterface Listener {\n topics: () => string[]\n handler: RealtimeHandler\n /** Receives `presence.changed`; a write listener would refetch on each viewer change. */\n presence: boolean\n /** Its data loads record their event cursor (see `UseRealtimeOptions.cursor`). */\n cursor: boolean\n}\n\ninterface EventCursor {\n seq: number\n boot: string\n}\n\nconst listeners = new Set<Listener>()\n/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */\nconst presence = new Map<string, RealtimeEvent>()\nconst connected = ref(false)\nlet source: EventSource | null = null\nlet sourceTopics = ''\nlet failures = 0\nlet syncTimer: ReturnType<typeof setTimeout> | null = null\nlet retryTimer: ReturnType<typeof setTimeout> | null = null\n/** The lowest `X-MINT-Event-Seq` of the responses since the page loaded, and their boot id (`''` once two differ). */\nlet tracked: EventCursor | null = null\n/** The cursor the current stream was opened with. */\nlet streamCursor: EventCursor | null = null\n\n/** @internal Record the `X-MINT-Event-Seq` / `X-MINT-Boot-Id` headers of one platform response. */\nexport function noteEventSeq(seq: string | null | undefined, bootId: string | null | undefined): void {\n const value = Number(seq)\n // No header: the response tells nothing (an older platform, another origin).\n if (!seq || !bootId || !Number.isSafeInteger(value)) return\n // A request that started before the stream's cursor, or on another process,\n // may miss an event the server did not check: refetch.\n if (streamCursor && (value < streamCursor.seq || bootId !== streamCursor.boot)) {\n // Moved, so each late condition refetches once instead of on every refetch response.\n streamCursor = { seq: value, boot: bootId }\n dispatch(RESYNC)\n }\n // Sequences of two processes do not compare: '' never matches, so every later open resyncs.\n tracked = tracked\n ? { seq: Math.min(tracked.seq, value), boot: tracked.boot === bootId ? tracked.boot : '' }\n : { seq: value, boot: bootId }\n}\n\nfunction dispatch(event: RealtimeEvent): void {\n const isPresence = event.type === 'presence.changed'\n if (isPresence && event.topic) presence.set(event.topic, event)\n for (const listener of [...listeners]) {\n if (isPresence && !listener.presence) continue\n if (event.type === 'resync' || (event.topic && listener.topics().includes(event.topic))) {\n listener.handler(event)\n }\n }\n}\n\nfunction close(): void {\n source?.close()\n source = null\n sourceTopics = ''\n presence.clear()\n streamCursor = null\n connected.value = false\n}\n\nfunction open(topics: string): void {\n const cursor = tracked && [...listeners].every(listener => listener.cursor) ? tracked : null\n const query = cursor ? `&since=${cursor.seq}&boot=${encodeURIComponent(cursor.boot)}` : ''\n const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}${query}`)\n source = stream\n sourceTopics = topics\n streamCursor = cursor\n stream.onopen = () => {\n connected.value = true\n // The server subscribes only now: a write between a caller's REST load (or\n // the close of the previous stream) and this point sent no event. With a\n // cursor the server sends `resync` when needed; otherwise every caller refetches.\n // A caller that did not opt in may have joined while the stream connected.\n if (!cursor || ![...listeners].every(listener => listener.cursor)) dispatch(RESYNC)\n failures = 0\n }\n stream.onmessage = (message) => {\n dispatch(JSON.parse(message.data) as RealtimeEvent)\n }\n stream.onerror = () => {\n // EventSource retries a dropped connection itself but gives up after an\n // HTTP error, so one backoff path handles both.\n if (source !== stream) return\n close()\n // A topic change during the wait does not shorten it: the retry reads the topics when it runs.\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = null\n retryTimer = setTimeout(() => {\n retryTimer = null\n sync()\n }, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures))\n failures += 1\n }\n}\n\nfunction sync(): void {\n syncTimer = null\n const topics = [...new Set([...listeners].flatMap(listener => listener.topics()))].sort().join(',')\n if (source && topics === sourceTopics) return\n close()\n if (topics) open(topics)\n}\n\nfunction schedule(): void {\n if (retryTimer !== null) return\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS)\n}\n\n/**\n * Subscribe to topics of the platform's realtime stream.\n *\n * Every caller in a tab shares one `EventSource`, opened on the union of\n * their topics and reopened when that union changes. `handler` receives the\n * write events of the caller's own topics (not `presence.changed`, which goes\n * to `usePresence`) plus every `resync`: one comes from the\n * server when this tab fell behind, and one follows each open of the stream:\n * the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for\n * a changed topic union. With `options.cursor` set by every caller, the open\n * sends no `resync` of its own; the server sends one when a topic changed since\n * the oldest recorded REST load. On `resync`, refetch. The union of all callers' topics\n * in a tab may hold at most 50 topics; the platform refuses a larger stream.\n *\n * The stream authenticates with the session cookie, so it works in the\n * platform and in plugin frontends served by it. It exists only when the\n * platform's `realtime` feature flag is on; check that flag first.\n *\n * A plugin topic reaches a caller whose role may use the plugin; an\n * experiment plugin topic also needs a caller who may view the experiment.\n *\n * @example\n * useRealtime(() => [`experiment:${id.value}`], () => refetch())\n * @example\n * useRealtime(['plugin:drp'], (event) => {\n * if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress\n * })\n */\nexport function useRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n options: UseRealtimeOptions = {},\n): UseRealtimeReturn {\n return subscribeRealtime(topics, handler, false, options.cursor ?? false)\n}\n\n/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */\nexport function currentPresence(topic: string): RealtimeEvent | undefined {\n return presence.get(topic)\n}\n\n/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */\nexport function subscribeRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n withPresence: boolean,\n cursor = false,\n): UseRealtimeReturn {\n // A presence caller does not refetch on resync, so it never needs the client-side one.\n const listener: Listener = { topics: () => toValue(topics), handler, presence: withPresence, cursor: cursor || withPresence }\n listeners.add(listener)\n const stopWatch = watch(() => listener.topics().join(','), () => schedule())\n schedule()\n\n function stop(): void {\n stopWatch()\n if (listeners.delete(listener)) schedule()\n }\n\n if (getCurrentScope()) onScopeDispose(stop)\n return { connected: readonly(connected), stop }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.18",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec Infrastructure for Next-generation Toolkits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -205,6 +205,38 @@ describe('useRealtime', () => {
|
|
|
205
205
|
})
|
|
206
206
|
|
|
207
207
|
describe('with the event cursor', () => {
|
|
208
|
+
it('should record the sequence of a createClient response', async () => {
|
|
209
|
+
const { useRealtime } = await loadRealtime()
|
|
210
|
+
const { default: axios } = await import('axios')
|
|
211
|
+
const { createPinia, setActivePinia } = await import('pinia')
|
|
212
|
+
const { createClient } = await import('../../composables/createClient')
|
|
213
|
+
setActivePinia(createPinia())
|
|
214
|
+
const adapter = axios.defaults.adapter
|
|
215
|
+
axios.defaults.adapter = async config => ({
|
|
216
|
+
data: {}, status: 200, statusText: 'OK', config,
|
|
217
|
+
headers: { 'x-mint-event-seq': '7', 'x-mint-boot-id': 'boot-a' },
|
|
218
|
+
})
|
|
219
|
+
const client = createClient(
|
|
220
|
+
{
|
|
221
|
+
schemaVersion: 2,
|
|
222
|
+
plugin: { routesPrefix: '/p', apiPrefix: '/api/p', type: 'analysis' },
|
|
223
|
+
settings: { modelName: 'Settings', valueSchema: { type: 'object', properties: {} }, formSchema: { groups: [] } },
|
|
224
|
+
endpoints: [],
|
|
225
|
+
hash: 'sha256:test',
|
|
226
|
+
},
|
|
227
|
+
{ endpoints: { health: { method: 'get', path: '/health' } } },
|
|
228
|
+
)
|
|
229
|
+
const scope = effectScope()
|
|
230
|
+
scope.run(() => useRealtime(['plugin:p'], vi.fn(), { cursor: true }))
|
|
231
|
+
|
|
232
|
+
await client.health()
|
|
233
|
+
axios.defaults.adapter = adapter
|
|
234
|
+
vi.runAllTimers()
|
|
235
|
+
|
|
236
|
+
expect(FakeEventSource.instances[0].cursor).toEqual(['7', 'boot-a'])
|
|
237
|
+
scope.stop()
|
|
238
|
+
})
|
|
239
|
+
|
|
208
240
|
it('should open with since and boot and send no resync of its own when every caller opts in', async () => {
|
|
209
241
|
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
210
242
|
const handler = vi.fn()
|
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { dirname, join, resolve } from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { beforeAll, describe, expect, it } from 'vitest'
|
|
7
7
|
import { compile } from 'vue'
|
|
8
8
|
import {
|
|
9
9
|
DiagnosticCategory,
|
|
@@ -47,12 +47,14 @@ const CORE_USAGE_NAMES = [
|
|
|
47
47
|
'useJobsEndpoint',
|
|
48
48
|
]
|
|
49
49
|
|
|
50
|
+
let frontendManifest: FrontendManifest | undefined
|
|
51
|
+
|
|
50
52
|
function extractFrontendManifest(): FrontendManifest {
|
|
51
|
-
|
|
53
|
+
frontendManifest ??= JSON.parse(execFileSync('bun', ['run', 'scripts/extract-docs.ts'], {
|
|
52
54
|
cwd: SDK_ROOT,
|
|
53
55
|
encoding: 'utf8',
|
|
54
|
-
})
|
|
55
|
-
return
|
|
56
|
+
})) as FrontendManifest
|
|
57
|
+
return frontendManifest
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const ROOT_COMPONENTS_INDEX = join(SDK_ROOT, 'src', 'components', 'index.ts')
|
|
@@ -199,6 +201,9 @@ function missingFromManifest(expected: string[], actual: string[]): string[] {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
describe('frontend docs catalog coverage', () => {
|
|
204
|
+
// The extraction starts a bun process, which took 5 to 10 s on a loaded CI runner: run it once, outside the test timeout.
|
|
205
|
+
beforeAll(() => { extractFrontendManifest() }, 60_000)
|
|
206
|
+
|
|
202
207
|
it('documents every public component export', () => {
|
|
203
208
|
const manifest = extractFrontendManifest()
|
|
204
209
|
const publicComponents = parseComponentExports()
|
|
@@ -44,10 +44,10 @@ export type RealtimeHandler = (event: RealtimeEvent) => void
|
|
|
44
44
|
export interface UseRealtimeOptions {
|
|
45
45
|
/**
|
|
46
46
|
* The caller loads all its data through a client that records
|
|
47
|
-
* `X-MINT-Event-Seq`: `useApi()
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* not set it. When every caller of the tab sets it, the stream opens with the
|
|
47
|
+
* `X-MINT-Event-Seq`: `useApi()`, a `createClient` client (it sends each
|
|
48
|
+
* request through `useApi()`) or the platform's own client. Responses of a
|
|
49
|
+
* plain `fetch` or another HTTP client are not recorded, so a caller that
|
|
50
|
+
* loads through them must not set it. When every caller of the tab sets it, the stream opens with the
|
|
51
51
|
* lowest recorded sequence since the page loaded, and the server sends
|
|
52
52
|
* `resync` only when a topic changed since then, instead of one at each open.
|
|
53
53
|
*/
|