@xen-orchestra/web-core 0.26.1 → 0.27.0

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.
@@ -0,0 +1,294 @@
1
+ import type { ResourceContext, UseRemoteResource } from '@core/packages/remote-resource/types.ts'
2
+ import type { VoidFunction } from '@core/types/utility.type.ts'
3
+ import { ifElse } from '@core/utils/if-else.utils.ts'
4
+ import { type MaybeRef, noop, useTimeoutPoll } from '@vueuse/core'
5
+ import { merge } from 'lodash-es'
6
+ import readNDJSONStream from 'ndjson-readablestream'
7
+ import {
8
+ computed,
9
+ type ComputedRef,
10
+ type EffectScope,
11
+ getCurrentScope,
12
+ type MaybeRefOrGetter,
13
+ onScopeDispose,
14
+ reactive,
15
+ type Ref,
16
+ ref,
17
+ toRef,
18
+ toValue,
19
+ watch,
20
+ } from 'vue'
21
+
22
+ const DEFAULT_CACHE_DURATION_MS = 10_000
23
+
24
+ const DEFAULT_POLLING_INTERVAL_MS = 30_000
25
+
26
+ export function defineRemoteResource<
27
+ TData,
28
+ TState extends object = { data: Ref<TData> },
29
+ TArgs extends any[] = [],
30
+ >(config: {
31
+ url: string | ((...args: TArgs) => string)
32
+ initialData: () => TData
33
+ state?: (data: Ref<NoInfer<TData>>, context: ResourceContext<TArgs>) => TState
34
+ onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
35
+ cacheDurationMs?: number
36
+ pollingIntervalMs?: number
37
+ stream?: boolean
38
+ }): UseRemoteResource<TState, TArgs>
39
+
40
+ export function defineRemoteResource<TData, TState extends object, TArgs extends any[] = []>(config: {
41
+ url: string | ((...args: TArgs) => string)
42
+ state?: (data: Ref<TData | undefined>, context: ResourceContext<TArgs>) => TState
43
+ onDataReceived?: (data: Ref<TData | undefined>, receivedData: any) => void
44
+ cacheDurationMs?: number
45
+ pollingIntervalMs?: number
46
+ stream?: boolean
47
+ }): UseRemoteResource<TState, TArgs>
48
+
49
+ export function defineRemoteResource<
50
+ TData,
51
+ TState extends object = { data: Ref<TData> },
52
+ TArgs extends any[] = [],
53
+ >(config: {
54
+ url: string | ((...args: TArgs) => string)
55
+ initialData?: () => TData
56
+ state?: (data: Ref<TData>, context: ResourceContext<TArgs>) => TState
57
+ onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
58
+ cacheDurationMs?: number
59
+ pollingIntervalMs?: number
60
+ stream?: boolean
61
+ }) {
62
+ const cache = new Map<
63
+ string,
64
+ {
65
+ count: number
66
+ pause: VoidFunction
67
+ resume: VoidFunction
68
+ state: object
69
+ isReady: Ref<boolean>
70
+ isFetching: Ref<boolean>
71
+ lastError: Ref<Error | undefined>
72
+ hasError: ComputedRef<boolean>
73
+ }
74
+ >()
75
+
76
+ const buildUrl = typeof config.url === 'string' ? () => config.url as string : config.url
77
+
78
+ const buildData = config.initialData ?? (() => undefined as TData | undefined)
79
+
80
+ const buildState = config.state ?? ((data: Ref<TData>) => ({ data }))
81
+
82
+ const cacheDuration = config.cacheDurationMs ?? DEFAULT_CACHE_DURATION_MS
83
+
84
+ const pollingInterval = config.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS
85
+
86
+ const onDataReceived =
87
+ config.onDataReceived ??
88
+ ((data: Ref<TData>, receivedData: any) => {
89
+ if (!config.stream || data.value === undefined) {
90
+ data.value = receivedData
91
+ return
92
+ }
93
+
94
+ if (Array.isArray(data.value) && Array.isArray(receivedData)) {
95
+ data.value.push(...receivedData)
96
+ return
97
+ }
98
+
99
+ merge(data.value, receivedData)
100
+ })
101
+
102
+ function subscribeToUrl(url: string) {
103
+ const entry = cache.get(url)
104
+
105
+ if (!entry) {
106
+ return
107
+ }
108
+
109
+ entry.count += 1
110
+
111
+ if (entry.count === 1) {
112
+ entry.resume()
113
+ }
114
+ }
115
+
116
+ function unsubscribeFromUrl(url: string) {
117
+ const entry = cache.get(url)
118
+
119
+ if (!entry) {
120
+ return
121
+ }
122
+
123
+ entry.count -= 1
124
+
125
+ if (entry.count > 0) {
126
+ return
127
+ }
128
+
129
+ entry.pause()
130
+
131
+ setTimeout(() => {
132
+ cache.delete(url)
133
+ }, cacheDuration)
134
+ }
135
+
136
+ function registerUrl(url: string, context: ResourceContext<TArgs>) {
137
+ if (cache.has(url)) {
138
+ return
139
+ }
140
+
141
+ const isReady = ref(false)
142
+
143
+ const isFetching = ref(false)
144
+
145
+ const lastError = ref<Error>()
146
+
147
+ const hasError = computed(() => lastError.value !== undefined)
148
+
149
+ const data = ref(buildData()) as Ref<TData>
150
+
151
+ async function execute() {
152
+ try {
153
+ isFetching.value = true
154
+
155
+ const response = await fetch(url)
156
+
157
+ if (!response.ok) {
158
+ lastError.value = Error(`Failed to fetch: ${response.statusText}`)
159
+ return
160
+ }
161
+
162
+ if (!response.body) {
163
+ return
164
+ }
165
+
166
+ if (config.stream) {
167
+ for await (const event of readNDJSONStream(response.body)) {
168
+ onDataReceived(data, event)
169
+ }
170
+ } else {
171
+ onDataReceived(data, await response.json())
172
+ }
173
+
174
+ isReady.value = true
175
+ } catch (error) {
176
+ lastError.value = error instanceof Error ? error : new Error(String(error))
177
+ } finally {
178
+ isFetching.value = false
179
+ }
180
+ }
181
+
182
+ let pause: VoidFunction = noop
183
+ let resume: VoidFunction = execute
184
+
185
+ if (pollingInterval > 0) {
186
+ const timeoutPoll = useTimeoutPoll(execute, pollingInterval, {
187
+ immediateCallback: true,
188
+ immediate: false,
189
+ })
190
+
191
+ pause = timeoutPoll.pause
192
+ resume = timeoutPoll.resume
193
+ }
194
+
195
+ const state = buildState(data, context)
196
+
197
+ cache.set(url, {
198
+ count: 0,
199
+ pause,
200
+ resume,
201
+ state,
202
+ isReady,
203
+ isFetching,
204
+ lastError,
205
+ hasError,
206
+ })
207
+ }
208
+
209
+ function initializeUrl(url: ComputedRef<string>, context: ResourceContext<TArgs>) {
210
+ watch(
211
+ url,
212
+ (toUrl, fromUrl) => {
213
+ registerUrl(toUrl, context)
214
+
215
+ if (context.isEnabled.value) {
216
+ subscribeToUrl(toUrl)
217
+ }
218
+
219
+ if (fromUrl) {
220
+ unsubscribeFromUrl(fromUrl)
221
+ }
222
+ },
223
+ { immediate: true }
224
+ )
225
+ }
226
+
227
+ return function useRemoteResource(
228
+ optionsOrParentContext?: { isEnabled?: MaybeRef<boolean>; scope?: EffectScope },
229
+ ...args: { [K in keyof TArgs]: MaybeRefOrGetter<TArgs[K]> }
230
+ ) {
231
+ const scope = optionsOrParentContext?.scope ?? getCurrentScope()
232
+
233
+ if (!scope) {
234
+ throw new Error('No effect scope found. Please provide a scope or use this function within a Vue component.')
235
+ }
236
+
237
+ const isEnabled = toRef(optionsOrParentContext?.isEnabled ?? true)
238
+
239
+ return scope.run(() => {
240
+ const url = computed(() => buildUrl(...(args.map(arg => toValue(arg)) as TArgs)))
241
+
242
+ onScopeDispose(() => {
243
+ unsubscribeFromUrl(url.value)
244
+ })
245
+
246
+ const context: ResourceContext<TArgs> = {
247
+ scope,
248
+ args,
249
+ isReady: computed(() => cache.get(url.value)?.isReady.value ?? false),
250
+ isFetching: computed(() => cache.get(url.value)?.isFetching?.value ?? false),
251
+ lastError: computed(() => cache.get(url.value)?.lastError.value),
252
+ hasError: computed(() => cache.get(url.value)?.hasError.value ?? false),
253
+ isEnabled,
254
+ enable: () => {
255
+ isEnabled.value = true
256
+ },
257
+ disable: () => {
258
+ isEnabled.value = false
259
+ },
260
+ forceReload: () => {
261
+ cache.get(url.value)?.pause()
262
+ cache.get(url.value)?.resume()
263
+ },
264
+ }
265
+
266
+ initializeUrl(url, context)
267
+
268
+ const state = reactive({} as TState)
269
+
270
+ ifElse(
271
+ isEnabled,
272
+ () => subscribeToUrl(url.value),
273
+ () => unsubscribeFromUrl(url.value)
274
+ )
275
+
276
+ watch(
277
+ url,
278
+ () => {
279
+ Object.assign(state, cache.get(url.value)!.state)
280
+ },
281
+ { immediate: true }
282
+ )
283
+
284
+ return {
285
+ ...Object.fromEntries(
286
+ Object.entries(state).map(([key, value]) =>
287
+ typeof value === 'function' ? [key, value] : [key, toRef(state, key as any)]
288
+ )
289
+ ),
290
+ $context: context,
291
+ }
292
+ })!
293
+ }
294
+ }
@@ -0,0 +1,28 @@
1
+ import type { MaybeRef } from '@vueuse/core'
2
+ import type { MaybeRefOrGetter } from '@vueuse/shared'
3
+ import type { ComputedRef, EffectScope, Ref, ToRef } from 'vue'
4
+
5
+ export type ResourceContext<TArgs extends any[]> = {
6
+ scope: EffectScope
7
+ args: { [K in keyof TArgs]: MaybeRefOrGetter<TArgs[K]> }
8
+ isReady: ComputedRef<boolean>
9
+ isFetching: ComputedRef<boolean>
10
+ hasError: ComputedRef<boolean>
11
+ lastError: ComputedRef<Error | undefined>
12
+ isEnabled: Ref<boolean>
13
+ enable: () => void
14
+ disable: () => void
15
+ forceReload: () => void
16
+ }
17
+
18
+ export type UseRemoteResource<TState, TArgs extends any[]> = (
19
+ optionsOrParentContext?: {
20
+ isEnabled?: MaybeRef<boolean>
21
+ scope?: EffectScope
22
+ },
23
+ ...args: { [K in keyof TArgs]: MaybeRefOrGetter<TArgs[K]> }
24
+ ) => {
25
+ [K in keyof TState]: TState[K] extends (...args: any[]) => any ? TState[K] : ToRef<TState[K]>
26
+ } & {
27
+ $context: ResourceContext<TArgs>
28
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xen-orchestra/web-core",
3
3
  "type": "module",
4
- "version": "0.26.1",
4
+ "version": "0.27.0",
5
5
  "private": false,
6
6
  "exports": {
7
7
  "./*": {
@@ -26,7 +26,7 @@
26
26
  "human-format": "^1.2.1",
27
27
  "iterable-backoff": "^0.1.0",
28
28
  "lodash-es": "^4.17.21",
29
- "ndjson-readablestream": "^1.2.0",
29
+ "ndjson-readablestream": "^1.3.0",
30
30
  "placement.js": "^1.0.0-beta.5",
31
31
  "simple-icons": "^14.14.0",
32
32
  "vue-echarts": "^6.6.8"