@knime/hub-features 1.3.1 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @knime/hub-features
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 14f573e: add useDownloadArtifact composable that can be used to download artifacts from the hub and returns a reactive state over ongoing downloads that are being prepared for download
8
+
3
9
  ## 1.3.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knime/hub-features",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Vue components & composables for shared hub features",
5
5
  "homepage": "https://knime.github.io/webapps-common/",
6
6
  "license": "GPL 3 and Additional Permissions according to Sec. 7 (SEE the file LICENSE)",
@@ -0,0 +1 @@
1
+ export const DEFAULT_API_BASE_URL = "/_/api";
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from "./rfcErrors";
2
2
  export * from "./useFileUpload";
3
+ export * from "./useDownloadArtifact";
@@ -0,0 +1 @@
1
+ export * from "./useDownloadArtifact";
@@ -0,0 +1,317 @@
1
+ import { computed, ref } from "vue";
2
+ import { FetchError, type FetchOptions } from "ofetch";
3
+
4
+ import { sleep } from "@knime/utils";
5
+
6
+ import { DEFAULT_API_BASE_URL } from "../common/constants";
7
+ import { getFetchClient } from "../common/ofetchClient";
8
+ import { rfcErrors } from "../rfcErrors";
9
+
10
+ const DEFAULT_MAX_RETRIES = null;
11
+ const DEFAULT_POLLING_INTERVAL = 2000;
12
+
13
+ type ArtifactStatusResponse = {
14
+ downloadId?: string;
15
+ status?: string;
16
+ statusMessage?: string;
17
+ lastUpdated?: string;
18
+ downloadUrl?: string;
19
+ };
20
+
21
+ type RequestDownloadResponse = {
22
+ downloadId: string;
23
+ };
24
+
25
+ /**
26
+ * The version of the artifact to download.
27
+ */
28
+ type ItemVersion = number | "current-state" | "most-recent";
29
+
30
+ /**
31
+ * Tracks the state of each concurrent download.
32
+ */
33
+ type DownloadItem = {
34
+ downloadId: string;
35
+ itemId: string;
36
+ status: "READY" | "IN_PROGRESS" | "FAILED" | "CANCELLED";
37
+ version?: ItemVersion;
38
+ downloadUrl?: string;
39
+ failureDetails?: string;
40
+ };
41
+
42
+ export type UseDownloadArtifactOptions = {
43
+ /**
44
+ * The maximum number of times to poll for the download URL. Default is `null` (no limit).
45
+ */
46
+ maxRetries?: number;
47
+ /**
48
+ * The interval in milliseconds between each poll. Default is `2000` ms.
49
+ */
50
+ pollingInterval?: number;
51
+ /**
52
+ * Custom options to pass to the fetch client.
53
+ * @example { baseURL: "/_/api" }
54
+ */
55
+ customFetchClientOptions?: FetchOptions;
56
+ };
57
+
58
+ /**
59
+ * This composable supports multiple concurrent downloads. Each time you call ```start```, the following behavior applies:
60
+ *
61
+ * 1. **Immediate-ready artifact**: If the first status check for the artifact returns `READY`, then there's no polling and no need
62
+ * to add it to `downloadItems`. The download URL is opened in the same tab immediately.
63
+ *
64
+ * 2. **Not-ready artifact**: If the artifact is not yet ready (e.g. needs zipping), then we add a record to `downloadItems` with status `IN_PROGRESS`
65
+ * and repeatedly poll until it becomes `READY`, the polling times out, or the user aborts.
66
+ *
67
+ * **Usage**:
68
+ * ```
69
+ * const { start, cancel, downloadItems } = useDownloadArtifact({ ...options });
70
+ *
71
+ * // start a download
72
+ * await start({ itemId: "1234", version: 1 });
73
+ *
74
+ * // if needed, cancel it:
75
+ * cancel("1234");
76
+ *
77
+ * // downloadItems (as a computed array) will reflect the current statuses.
78
+ * ```
79
+ */
80
+ export const useDownloadArtifact = (
81
+ options: UseDownloadArtifactOptions = {},
82
+ ) => {
83
+ const downloadItems = ref<Record<string, DownloadItem>>({});
84
+ const abortControllers: Record<string, AbortController> = {};
85
+
86
+ const $ofetch = getFetchClient(options.customFetchClientOptions);
87
+ const baseUrl =
88
+ options.customFetchClientOptions?.baseURL ?? DEFAULT_API_BASE_URL;
89
+
90
+ // Use fallback values if none are provided
91
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
92
+ const pollingInterval = options.pollingInterval ?? DEFAULT_POLLING_INTERVAL;
93
+
94
+ const cancel = (downloadId: string) => {
95
+ const itemController = abortControllers[downloadId];
96
+ if (itemController) {
97
+ itemController.abort();
98
+ delete abortControllers[downloadId];
99
+ }
100
+ };
101
+
102
+ const removeItem = (downloadId: string) => {
103
+ cancel(downloadId);
104
+ delete downloadItems.value[downloadId];
105
+ };
106
+
107
+ const fetchDownloadStatus = ({
108
+ downloadId,
109
+ signal,
110
+ }: {
111
+ downloadId: string;
112
+ signal?: AbortSignal;
113
+ }) => {
114
+ return $ofetch<ArtifactStatusResponse>(
115
+ `${baseUrl}/downloads/${downloadId}/status`,
116
+ {
117
+ method: "GET",
118
+ signal,
119
+ },
120
+ );
121
+ };
122
+
123
+ /**
124
+ * Makes a request to the download status endpoint for the given download item.
125
+ * Returns a boolean that is true if a terminal state has been reached (download is ready,
126
+ * download failed, or user aborted download)
127
+ * @param downloadId the `downloadId`
128
+ * @param signal the abort signal passed to the request to be able to abort it
129
+ * @returns a boolean indicating whether a terminal state has been reached
130
+ */
131
+ const handlePollingResult = async ({
132
+ downloadId,
133
+ signal,
134
+ }: {
135
+ downloadId: string;
136
+ signal: AbortSignal;
137
+ }) => {
138
+ try {
139
+ const statusResponse = await fetchDownloadStatus({
140
+ downloadId,
141
+ signal,
142
+ });
143
+
144
+ if (statusResponse?.status === "READY" && statusResponse?.downloadUrl) {
145
+ downloadItems.value[downloadId].status = "READY";
146
+ downloadItems.value[downloadId].downloadUrl =
147
+ statusResponse.downloadUrl;
148
+ window.open(statusResponse.downloadUrl, "_parent");
149
+ return true;
150
+ } else if (statusResponse?.status === "FAILED") {
151
+ let details = "Download failed";
152
+ if (statusResponse?.statusMessage) {
153
+ details += `: ${statusResponse.statusMessage}`;
154
+ }
155
+ throw new Error(details);
156
+ }
157
+ } catch (error: unknown) {
158
+ consola.error("Error fetching status:", error);
159
+ // https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
160
+ const isAbortError =
161
+ error instanceof DOMException && error.name === "AbortError";
162
+
163
+ if (!isAbortError) {
164
+ downloadItems.value[downloadId].status = "FAILED";
165
+ downloadItems.value[downloadId].failureDetails =
166
+ error instanceof Error ? error.message : (error as string);
167
+ }
168
+ return true;
169
+ }
170
+ return false;
171
+ };
172
+
173
+ const pollDownloadItem = async ({
174
+ itemId,
175
+ downloadId,
176
+ version,
177
+ }: {
178
+ itemId: string;
179
+ downloadId: string;
180
+ version?: ItemVersion;
181
+ }) => {
182
+ const controller = new AbortController();
183
+ abortControllers[downloadId] = controller;
184
+
185
+ const signal = controller.signal;
186
+
187
+ downloadItems.value[downloadId] = {
188
+ itemId,
189
+ ...(version && { version }),
190
+ downloadId,
191
+ status: "IN_PROGRESS",
192
+ };
193
+
194
+ const abortListener = () => {
195
+ controller.signal.removeEventListener("abort", abortListener);
196
+ if (!downloadItems.value[downloadId]) {
197
+ return;
198
+ }
199
+ downloadItems.value[downloadId].status = "CANCELLED";
200
+ downloadItems.value[downloadId].failureDetails =
201
+ "Download was cancelled manually";
202
+ };
203
+ controller.signal.addEventListener("abort", abortListener);
204
+
205
+ let tries = 0;
206
+ // eslint-disable-next-line no-unmodified-loop-condition
207
+ while (maxRetries === null || tries < maxRetries) {
208
+ tries++;
209
+ await sleep(pollingInterval);
210
+
211
+ const isTerminalState = await handlePollingResult({ downloadId, signal });
212
+
213
+ if (isTerminalState) {
214
+ break;
215
+ }
216
+ }
217
+
218
+ delete abortControllers[downloadId];
219
+
220
+ if (maxRetries !== null && tries >= maxRetries) {
221
+ downloadItems.value[downloadId].status = "FAILED";
222
+ downloadItems.value[downloadId].failureDetails = "Download timed out";
223
+ }
224
+ };
225
+
226
+ const start = async ({
227
+ itemId,
228
+ version,
229
+ }: {
230
+ itemId: string;
231
+ version?: ItemVersion;
232
+ }) => {
233
+ try {
234
+ const response = await $ofetch<RequestDownloadResponse>(
235
+ `${baseUrl}/repository/${itemId}/artifact`,
236
+ {
237
+ method: "GET",
238
+ query: { version },
239
+ },
240
+ );
241
+ const downloadId = response.downloadId;
242
+
243
+ const downloadStatusResponse = await fetchDownloadStatus({ downloadId });
244
+
245
+ if (
246
+ // download is ready immediately, no polling needed
247
+ downloadStatusResponse?.status === "READY" &&
248
+ downloadStatusResponse?.downloadUrl
249
+ ) {
250
+ window.open(downloadStatusResponse.downloadUrl, "_parent");
251
+ } else {
252
+ await pollDownloadItem({
253
+ itemId,
254
+ version,
255
+ downloadId,
256
+ });
257
+ }
258
+ } catch (error: unknown) {
259
+ // here only errors thrown by the initial requests are caught;
260
+ // errors occurring while polling will be handled in pollDownloadItem
261
+ if (error instanceof FetchError) {
262
+ throw rfcErrors.tryParse(error);
263
+ }
264
+ throw error;
265
+ }
266
+ };
267
+
268
+ const totalItemsBeingZipped = computed(
269
+ () =>
270
+ Object.values(downloadItems.value).filter(
271
+ ({ status }) => status === "IN_PROGRESS",
272
+ ).length,
273
+ );
274
+
275
+ const resetState = () => {
276
+ for (const [downloadId, controller] of Object.entries(abortControllers)) {
277
+ controller?.abort();
278
+ delete abortControllers[downloadId];
279
+ }
280
+ downloadItems.value = {};
281
+ };
282
+
283
+ const downloadItemsArray = computed(() => Object.values(downloadItems.value));
284
+
285
+ return {
286
+ /**
287
+ * Initiates a download of the given itemId and version.
288
+ */
289
+ start,
290
+
291
+ /**
292
+ * Cancels the ongoing download for a given itemId (if any).
293
+ * Sets the status in `downloadItems` to `CANCELLED`.
294
+ */
295
+ cancel,
296
+
297
+ /**
298
+ * Removes item from `downloadItems`.
299
+ */
300
+ removeItem,
301
+
302
+ /**
303
+ * A computed array of all current items that are being zipped and prepared for download.
304
+ */
305
+ downloadItems: downloadItemsArray,
306
+
307
+ /**
308
+ * The total number of zipping items in progress.
309
+ */
310
+ totalItemsBeingZipped,
311
+
312
+ /**
313
+ * Resets the state of the composable, clearing all downloads and controllers.
314
+ */
315
+ resetState,
316
+ };
317
+ };
@@ -4,13 +4,12 @@ import { FetchError, type FetchOptions } from "ofetch";
4
4
  import { useUploadManager } from "@knime/components";
5
5
  import { getFileMimeType, knimeFileFormats, promise } from "@knime/utils";
6
6
 
7
+ import { DEFAULT_API_BASE_URL } from "../common/constants";
7
8
  import { getFetchClient } from "../common/ofetchClient";
8
9
  import { rfcErrors } from "../rfcErrors";
9
10
 
10
11
  const DEFAULT_MAX_UPLOAD_QUEUE_SIZE = 10;
11
12
 
12
- const DEFAULT_API_BASE_URL = "/_/api";
13
-
14
13
  const DEFAULT_RETRY_DELAY_MS = 50;
15
14
 
16
15
  type UseFileUploadOptions = {