diff --git a/changelog/unreleased/enhancement-handle-postprocessing-state-via-sse b/changelog/unreleased/enhancement-handle-postprocessing-state-via-sse new file mode 100644 index 00000000000..b2600c7c251 --- /dev/null +++ b/changelog/unreleased/enhancement-handle-postprocessing-state-via-sse @@ -0,0 +1,7 @@ +Enhancement: Handle postprocessing state via Server Sent Events + +We've added the functionality to listen for events from the server that update the postprocessing state, +this allows the user to see if the postprocessing on a file is finished, without reloading the UI. + +https://github.com/owncloud/web/pull/9771 +https://github.com/owncloud/web/issues/9769 diff --git a/packages/web-client/package.json b/packages/web-client/package.json index f48a1b1ec04..0b1b0e2bd6f 100644 --- a/packages/web-client/package.json +++ b/packages/web-client/package.json @@ -22,6 +22,7 @@ "fast-xml-parser": "4.3.2", "lodash-es": "^4.17.21", "luxon": "^3.0.1", + "@microsoft/fetch-event-source": "^2.0.1", "webdav": "5.3.0" } } diff --git a/packages/web-client/src/sse.ts b/packages/web-client/src/sse.ts new file mode 100644 index 00000000000..d18970f9e29 --- /dev/null +++ b/packages/web-client/src/sse.ts @@ -0,0 +1,126 @@ +import { fetchEventSource, FetchEventSourceInit } from '@microsoft/fetch-event-source' + +export enum MESSAGE_TYPE { + NOTIFICATION = 'userlog-notification', + POSTPROCESSING_FINISHED = 'postprocessing-finished' +} + +export class RetriableError extends Error { + name = 'RetriableError' +} + +const RECONNECT_RANDOM_OFFSET = 15000 + +export class SSEAdapter implements EventSource { + url: string + fetchOptions: FetchEventSourceInit + private abortController: AbortController + private eventListenerMap: Record any)[]> + + readyState: number + readonly withCredentials: boolean + + readonly CONNECTING: 0 + readonly OPEN: 1 + readonly CLOSED: 2 + + onerror: ((this: EventSource, ev: Event) => any) | null + onmessage: ((this: EventSource, ev: MessageEvent) => any) | null + onopen: ((this: EventSource, ev: Event) => any) | null + + constructor(url: string, fetchOptions: FetchEventSourceInit) { + this.url = url + this.fetchOptions = fetchOptions + this.abortController = new AbortController() + this.eventListenerMap = {} + this.readyState = this.CONNECTING + this.connect() + } + + private connect() { + return fetchEventSource(this.url, { + openWhenHidden: true, + signal: this.abortController.signal, + fetch: this.fetchProvider.bind(this), + onopen: async () => { + const event = new Event('open') + this.onopen?.bind(this)(event) + this.readyState = this.OPEN + }, + onmessage: (msg) => { + const event = new MessageEvent('message', { data: msg.data }) + this.onmessage?.bind(this)(event) + + const type = msg.event + const eventListeners = this.eventListenerMap[type] + eventListeners?.forEach((l) => l(event)) + }, + onclose: () => { + this.readyState = this.CLOSED + throw new RetriableError() + }, + onerror: (err) => { + console.error(err) + const event = new CustomEvent('error', { detail: err }) + this.onerror?.bind(this)(event) + + /* + * Try to reconnect after 30 seconds plus random time in seconds. + * This prevents all clients try to reconnect concurrent on server error, to reduce load. + */ + return 30000 + Math.floor(Math.random() * RECONNECT_RANDOM_OFFSET) + } + }) + } + + fetchProvider(...args) { + let [resource, config] = args + config = { ...config, ...this.fetchOptions } + return window.fetch(resource, config) + } + + close() { + this.abortController.abort('closed') + } + + addEventListener(type: string, listener: (this: EventSource, event: MessageEvent) => any): void { + this.eventListenerMap[type] = this.eventListenerMap[type] || [] + this.eventListenerMap[type].push(listener) + } + + removeEventListener( + type: string, + listener: (this: EventSource, event: MessageEvent) => any + ): void { + this.eventListenerMap[type] = this.eventListenerMap[type]?.filter((func) => func !== listener) + } + + dispatchEvent(event: Event): boolean { + throw new Error('Method not implemented.') + } + + updateAccessToken(token: string) { + this.fetchOptions.headers['Authorization'] = `Bearer ${token}` + } + + updateLanguage(language: string) { + this.fetchOptions.headers['Accept-Language'] = language + + // Force reconnect, to make the language change effect instantly + this.close() + this.connect() + } +} + +let eventSource: SSEAdapter = null + +export const sse = (baseURI: string, fetchOptions: FetchEventSourceInit): EventSource => { + if (!eventSource) { + eventSource = new SSEAdapter( + new URL('ocs/v2.php/apps/notifications/api/v1/notifications/sse', baseURI).href, + fetchOptions + ) + } + + return eventSource +} diff --git a/packages/web-client/tests/unit/sse.spec.ts b/packages/web-client/tests/unit/sse.spec.ts new file mode 100644 index 00000000000..c84d1b382da --- /dev/null +++ b/packages/web-client/tests/unit/sse.spec.ts @@ -0,0 +1,106 @@ +const fetchEventSourceMock = jest.fn() +jest.mock('@microsoft/fetch-event-source', () => ({ + fetchEventSource: fetchEventSourceMock +})) + +import { SSEAdapter, sse, MESSAGE_TYPE, RetriableError } from '../../src/sse' + +const url = 'https://owncloud.test/' +describe('SSEAdapter', () => { + let mockFetch + + beforeEach(() => { + mockFetch = jest.fn() + + // Mock fetchEventSource and window.fetch + + global.window.fetch = mockFetch + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('it should initialize the SSEAdapter', () => { + const fetchOptions = { method: 'GET' } + + const sseAdapter = new SSEAdapter(url, fetchOptions) + + expect(sseAdapter.url).toBe(url) + expect(sseAdapter.fetchOptions).toBe(fetchOptions) + expect(sseAdapter.readyState).toBe(sseAdapter.CONNECTING) + }) + + test('it should call connect and set up event listeners', () => { + const fetchOptions = { method: 'GET' } + const sseAdapter = new SSEAdapter(url, fetchOptions) + + expect(fetchEventSourceMock).toHaveBeenCalledWith(url, expect.any(Object)) + expect(fetchEventSourceMock.mock.calls[0][1].onopen).toEqual(expect.any(Function)) + + fetchEventSourceMock.mock.calls[0][1].onopen() + + expect(sseAdapter.readyState).toBe(sseAdapter.OPEN) + }) + + test('it should handle onmessage events', () => { + const fetchOptions = { method: 'GET' } + const sseAdapter = new SSEAdapter(url, fetchOptions) + const message = { data: 'Message data', event: MESSAGE_TYPE.NOTIFICATION } + + const messageListener = jest.fn() + sseAdapter.addEventListener(MESSAGE_TYPE.NOTIFICATION, messageListener) + + fetchEventSourceMock.mock.calls[0][1].onmessage(message) + + expect(messageListener).toHaveBeenCalledWith(expect.any(Object)) + }) + + test('it should handle onclose events and throw RetriableError', () => { + const fetchOptions = { method: 'GET' } + const sseAdapter = new SSEAdapter(url, fetchOptions) + + expect(() => { + // Simulate onclose + fetchEventSourceMock.mock.calls[0][1].onclose() + }).toThrow(RetriableError) + }) + + test('it should call fetchProvider with fetch options', () => { + const fetchOptions = { headers: { Authorization: 'Bearer xy' } } + const sseAdapter = new SSEAdapter(url, fetchOptions) + + sseAdapter.fetchProvider(url, fetchOptions) + + expect(mockFetch).toHaveBeenCalledWith(url, { ...fetchOptions }) + }) + + test('it should update the access token in fetch options', () => { + const fetchOptions = { headers: { Authorization: 'Bearer xy' } } + const sseAdapter = new SSEAdapter(url, fetchOptions) + + const token = 'new-token' + sseAdapter.updateAccessToken(token) + + expect(sseAdapter.fetchOptions.headers.Authorization).toBe(`Bearer ${token}`) + }) + + test('it should close the SSEAdapter', () => { + const fetchOptions = { method: 'GET' } + const sseAdapter = new SSEAdapter(url, fetchOptions) + + sseAdapter.close() + + expect(sseAdapter.readyState).toBe(sseAdapter.CLOSED) + }) +}) + +describe('sse', () => { + test('it should create and return an SSEAdapter instance', () => { + const fetchOptions = { method: 'GET' } + const eventSource = sse(url, fetchOptions) + + expect(eventSource).toBeInstanceOf(SSEAdapter) + expect(eventSource.url).toBe(`${url}ocs/v2.php/apps/notifications/api/v1/notifications/sse`) + }) +}) diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 7d0f8f2133a..834fd47d5f5 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -44,6 +44,7 @@ "vue-concurrency": "4.0.1", "vue-router": "4.2.0", "vue3-gettext": "2.5.0-alpha.1", - "vuex": "4.1.0" + "vuex": "4.1.0", + "@microsoft/fetch-event-source": "^2.0.1" } } diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index b614ecd6e3c..064a9a997a1 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -26,7 +26,6 @@ export * from './service' export * from './sideBar' export * from './sort' export * from './spaces' -export * from './sse' export * from './store' export * from './upload' export * from './viewMode' diff --git a/packages/web-pkg/src/composables/sse/index.ts b/packages/web-pkg/src/composables/sse/index.ts deleted file mode 100644 index f12353de45e..00000000000 --- a/packages/web-pkg/src/composables/sse/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useServerSentEvents' diff --git a/packages/web-pkg/src/composables/sse/useServerSentEvents.ts b/packages/web-pkg/src/composables/sse/useServerSentEvents.ts deleted file mode 100644 index 247124012cf..00000000000 --- a/packages/web-pkg/src/composables/sse/useServerSentEvents.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { EventSourceMessage, fetchEventSource } from '@microsoft/fetch-event-source' -import { ref, unref, watch } from 'vue' -import { v4 as uuidV4 } from 'uuid' -import { useGettext } from 'vue3-gettext' -import { configurationManager } from '../../configuration' -import { useAccessToken } from '../authContext' -import { useStore } from '../store' - -class FatalError extends Error {} - -export interface ServerSentEventsOptions { - url: string - onOpen?: (response: Response) => void - onMessage?: (msg: EventSourceMessage) => void -} - -export const useServerSentEvents = (options: ServerSentEventsOptions) => { - const store = useStore() - const language = useGettext() - const accessToken = useAccessToken({ store }) - const ctrl = ref(new AbortController()) - const maxRetries = 3 - const retryCounter = ref(0) - - watch( - () => language.current, - () => { - unref(ctrl).abort() - } - ) - const setupServerSentEvents = () => { - if (unref(retryCounter) >= maxRetries) { - unref(ctrl).abort() - throw new Error('Too many retries') - } - const setupSSE = async () => { - retryCounter.value++ - try { - ctrl.value = new AbortController() - await fetchEventSource(new URL(options.url, configurationManager.serverUrl).href, { - signal: unref(ctrl).signal, - headers: { - Authorization: `Bearer ${accessToken.value}`, - 'Accept-Language': unref(language).current, - 'X-Request-ID': uuidV4(), - 'X-Requested-With': 'XMLHttpRequest' - }, - async onopen(response) { - if (response.status === 401) { - unref(ctrl).abort() - return - } else if (response.status >= 500 || response.status === 404) { - retryCounter.value = maxRetries - throw new FatalError() - } else { - retryCounter.value = 0 - await options.onOpen?.(response) - } - }, - onerror(err) { - if (err instanceof FatalError) { - throw err - } - }, - onmessage(msg) { - options.onMessage?.(msg) - } - }) - } catch (e) { - console.error(e) - } - } - setupSSE().then(() => { - setupServerSentEvents() - }) - } - - return setupServerSentEvents -} diff --git a/packages/web-pkg/src/services/client/client.ts b/packages/web-pkg/src/services/client/client.ts index 17eb47efa7c..71e220821d0 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -8,6 +8,8 @@ import { OwnCloudSdk } from '@ownclouders/web-client/src/types' import { ConfigurationManager } from '../../configuration' import { Store } from 'vuex' import { Language } from 'vue3-gettext' +import { FetchEventSourceInit } from '@microsoft/fetch-event-source' +import { sse } from '@ownclouders/web-client/src/sse' interface OcClient { token: string @@ -22,6 +24,17 @@ interface HttpClient { token?: string } +const createFetchOptions = (authParams: AuthParameters, language: string): FetchEventSourceInit => { + return { + headers: { + Authorization: `Bearer ${authParams.accessToken}`, + 'Accept-Language': language, + 'X-Request-ID': uuidV4(), + 'X-Requested-With': 'XMLHttpRequest' + } + } +} + const createAxiosInstance = (authParams: AuthParameters, language: string): AxiosInstance => { const auth = new Auth(authParams) const axiosClient = axios.create({ @@ -84,6 +97,13 @@ export class ClientService { return this.ocUserContextClient.graph } + public get sseAuthenticated(): EventSource { + return sse( + this.configurationManager.serverUrl, + createFetchOptions({ accessToken: this.token }, this.currentLanguage) + ) + } + public get ocsUserContext(): OCS { if (this.clientNeedsInit(this.ocUserContextClient)) { this.ocUserContextClient = this.getOcsClient({ accessToken: this.token }) diff --git a/packages/web-runtime/src/components/Topbar/Notifications.vue b/packages/web-runtime/src/components/Topbar/Notifications.vue index 26f819bf127..b75edc711f3 100644 --- a/packages/web-runtime/src/components/Topbar/Notifications.vue +++ b/packages/web-runtime/src/components/Topbar/Notifications.vue @@ -67,8 +67,8 @@ @click.prevent=" executeAction(el.app, action.link, action.type, el.notification_id) " - >{{ action.label }} + >{{ action.label }} +
import { onMounted, onUnmounted, ref, unref } from 'vue' import isEmpty from 'lodash-es/isEmpty' -import { eventBus } from '@ownclouders/web-pkg' +import { eventBus, useCapabilityCoreSSE } from '@ownclouders/web-pkg' import { ShareStatus } from '@ownclouders/web-client/src/helpers/share' import NotificationBell from './NotificationBell.vue' import { Notification } from '../../helpers/notifications' @@ -98,13 +98,12 @@ import { formatRelativeDateFromISO, useClientService, useRoute, - useStore, - useServerSentEvents, - useCapabilityCoreSSE + useStore } from '@ownclouders/web-pkg' import { useGettext } from 'vue3-gettext' import { useTask } from 'vue-concurrency' import { createFileRouteOptions } from '@ownclouders/web-pkg' +import { MESSAGE_TYPE } from '@ownclouders/web-client/src/sse' const POLLING_INTERVAL = 30000 @@ -122,35 +121,7 @@ export default { const loading = ref(false) const notificationsInterval = ref() const dropdownOpened = ref(false) - const sseEnabled = useCapabilityCoreSSE() - let setupServerSentEvents - if (unref(sseEnabled)) { - setupServerSentEvents = useServerSentEvents({ - url: 'ocs/v2.php/apps/notifications/api/v1/notifications/sse', - onOpen: (response): void => { - fetchNotificationsTask.perform() - if (!response.ok) { - console.error(`SSE notifications couldn't be set up ${response.status}`) - } - }, - onMessage: (msg): void => { - if (msg.event === 'FatalError') { - console.error(`SSE notifications error: ${msg.data}`) - return - } - const parsedData = JSON.parse(msg.data) as { type: string; data?: Notification } - if (!parsedData?.data) { - return - } - - const { data } = parsedData - if (data.notification_id) { - notifications.value = [data, ...unref(notifications)] - } - } - }) - } const formatDate = (date) => { return formatDateFromISO(date, currentLanguage) @@ -314,6 +285,18 @@ export default { } } + const onSSENotificationEvent = (event) => { + try { + const notification = JSON.parse(event.data) as Notification + if (!notification || !notification.notification_id) { + return + } + notifications.value = [notification, ...unref(notifications)] + } catch (_) { + console.error('Unable to parse sse notification data') + } + } + const hideDrop = () => { dropdownOpened.value = false } @@ -323,18 +306,26 @@ export default { } onMounted(async () => { + fetchNotificationsTask.perform() if (unref(sseEnabled)) { - await setupServerSentEvents() + clientService.sseAuthenticated.addEventListener( + MESSAGE_TYPE.NOTIFICATION, + onSSENotificationEvent + ) } else { notificationsInterval.value = setInterval(() => { fetchNotificationsTask.perform() }, POLLING_INTERVAL) - fetchNotificationsTask.perform() } }) onUnmounted(() => { - if (unref(notificationsInterval)) { + if (unref(sseEnabled)) { + clientService.sseAuthenticated.removeEventListener( + MESSAGE_TYPE.NOTIFICATION, + onSSENotificationEvent + ) + } else { clearInterval(unref(notificationsInterval)) } }) @@ -363,20 +354,24 @@ export default { max-width: 100%; max-height: 400px; } + .oc-notifications { &-item { > a { color: var(--oc-color-text-default); } } + &-loading { * { position: absolute; } + &-background { background-color: var(--oc-color-background-secondary); opacity: 0.6; } + &-spinner { top: 50%; left: 50%; @@ -384,11 +379,13 @@ export default { opacity: 1; } } + &-actions { button:not(:last-child) { margin-right: var(--oc-space-small); } } + &-link { white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 80b1a182e7d..94fa254d102 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -29,6 +29,7 @@ import { } from 'design-system/src/components/OcResourceIcon/types' import { merge } from 'lodash-es' import { AppConfigObject } from '@ownclouders/web-pkg' +import { MESSAGE_TYPE } from '@ownclouders/web-client/src/sse' /** * fetch runtime configuration, this step is optional, all later steps can use a static @@ -561,3 +562,34 @@ export const announceCustomStyles = ({ document.head.appendChild(link) }) } + +const onSSEProcessingFinishedEvent = ({ + store, + msg +}: { + store: Store + msg: MessageEvent +}): void => { + try { + const postProcessingData = JSON.parse(msg.data) + store.commit('Files/UPDATE_RESOURCE_FIELD', { + id: postProcessingData.itemid, + field: 'processing', + value: false + }) + } catch (_) { + console.error('Unable to parse sse postprocessing data') + } +} + +export const registerSSEEventListeners = ({ + store, + clientService +}: { + store: Store + clientService: ClientService +}): void => { + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.POSTPROCESSING_FINISHED, (msg) => + onSSEProcessingFinishedEvent({ store, msg }) + ) +} diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index a0ebc5bdf85..0cb26c9ac9c 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -25,7 +25,8 @@ import { announceLoadingService, announcePreviewService, announcePasswordPolicyService, - announceAdditionalTranslations + announceAdditionalTranslations, + registerSSEEventListeners } from './container/bootstrap' import { applicationStore } from './container/store' import { @@ -161,6 +162,11 @@ export const bootstrapApp = async (configurationPath: string): Promise => const clientService = app.config.globalProperties.$clientService + // Register SSE event listeners + if (store.getters.capabilities?.core?.['support-sse']) { + registerSSEEventListeners({ store, clientService }) + } + // Load spaces to make them available across the application if (store.getters.capabilities?.spaces?.enabled) { const graphClient = clientService.graphAuthenticated diff --git a/packages/web-runtime/src/pages/account.vue b/packages/web-runtime/src/pages/account.vue index 930bad28f13..8317c4ad6cc 100644 --- a/packages/web-runtime/src/pages/account.vue +++ b/packages/web-runtime/src/pages/account.vue @@ -128,6 +128,7 @@ import { SettingsBundle, LanguageOption, SettingsValue } from '../helpers/settin import { computed, defineComponent, onMounted, unref, ref } from 'vue' import { useCapabilityChangeSelfPasswordDisabled, + useCapabilityCoreSSE, useCapabilityGraphPersonalDataExport, useCapabilitySpacesEnabled, useClientService, @@ -140,6 +141,7 @@ import GdprExport from 'web-runtime/src/components/Account/GdprExport.vue' import { useConfigurationManager } from '@ownclouders/web-pkg' import { SpaceResource, isPersonalSpaceResource } from '@ownclouders/web-client/src/helpers' import { AppLoadingSpinner } from '@ownclouders/web-pkg' +import { SSEAdapter } from '@ownclouders/web-client/src/sse' export default defineComponent({ name: 'AccountPage', @@ -158,6 +160,7 @@ export default defineComponent({ const accountBundle = ref() const selectedLanguageValue = ref() const disableEmailNotificationsValue = ref() + const sseEnabled = useCapabilityCoreSSE() // FIXME: Use settings service capability when we have it const isSettingsServiceSupported = useCapabilitySpacesEnabled() @@ -301,6 +304,9 @@ export default defineComponent({ value } }) + if (unref(sseEnabled)) { + ;(clientService.sseAuthenticated as SSEAdapter).updateLanguage(language.current) + } if (unref(personalSpace)) { // update personal space name with new translation store.commit('runtime/spaces/UPDATE_SPACE_FIELD', { diff --git a/packages/web-runtime/src/services/auth/userManager.ts b/packages/web-runtime/src/services/auth/userManager.ts index 3b6cf032200..e81fe96aee2 100644 --- a/packages/web-runtime/src/services/auth/userManager.ts +++ b/packages/web-runtime/src/services/auth/userManager.ts @@ -14,6 +14,7 @@ import { Ability } from '@ownclouders/web-client/src/helpers/resource/types' import { Language } from 'vue3-gettext' import { setCurrentLanguage } from 'web-runtime/src/helpers/language' import { router } from 'web-runtime/src/router' +import { SSEAdapter } from '@ownclouders/web-client/src/sse' const postLoginRedirectUrlKey = 'oc.postLoginRedirectUrl' type UnloadReason = 'authError' | 'logout' @@ -140,6 +141,7 @@ export class UserManager extends OidcUserManager { if (!accessTokenChanged) { return this.updateAccessTokenPromise } + this.store.commit('runtime/auth/SET_ACCESS_TOKEN', accessToken) this.updateAccessTokenPromise = (async () => { @@ -156,6 +158,10 @@ export class UserManager extends OidcUserManager { }) this.initializeOwnCloudSdk(accessToken) + if (this.store.getters.capabilities?.core?.['support-sse']) { + ;(this.clientService.sseAuthenticated as SSEAdapter).updateAccessToken(accessToken) + } + if (!userKnown) { await this.fetchUserInfo() await this.updateUserAbilities(this.store.getters.user) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1773249517..26ebe92cc56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -947,6 +947,9 @@ importers: '@casl/ability': specifier: ^6.3.3 version: 6.3.3 + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 '@ownclouders/web-client': specifier: workspace:* version: 'link:' @@ -988,10 +991,10 @@ importers: version: 2.0.1(@uppy/core@3.3.0) '@uppy/tus': specifier: ^3.1.0 - version: 3.1.0(@uppy/core@3.3.0) + version: 3.1.2(@uppy/core@3.3.0) '@uppy/utils': specifier: ^5.3.0 - version: 5.3.0 + version: 5.4.0 '@uppy/xhr-upload': specifier: ^3.0.1 version: 3.3.1(@uppy/core@3.3.0) @@ -2746,7 +2749,7 @@ packages: dependencies: '@babel/core': 7.19.6 '@babel/helper-create-regexp-features-plugin': 7.19.0(@babel/core@7.19.6) - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-plugin-utils': 7.21.5 dev: true /@babel/plugin-proposal-unicode-property-regex@7.18.6(@babel/core@7.21.8): @@ -6749,7 +6752,7 @@ packages: '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.0.3) eslint: 8.25.0 eslint-scope: 5.1.1 - semver: 7.3.8 + semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript @@ -6769,7 +6772,7 @@ packages: '@typescript-eslint/typescript-estree': 5.60.0(typescript@5.0.3) eslint: 8.25.0 eslint-scope: 5.1.1 - semver: 7.3.8 + semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript @@ -6908,10 +6911,10 @@ packages: preact: 10.7.1 dev: false - /@uppy/tus@3.1.0(@uppy/core@3.3.0): - resolution: {integrity: sha512-oFSa6WtDv2yXO/U02wZ8pQEIxEdtBs5N80fi1anipiJASvNJ+pCrl812QDQLdd/roXJkg0jIEP4/8Eod2d6yGg==} + /@uppy/tus@3.1.2(@uppy/core@3.3.0): + resolution: {integrity: sha512-LTn1S90gMczlDzVh+bG+FQmbTUAn+ZgbsBbmZ3GRnuSWzKhtoc9TDfr71TG7bCD8wlCPujJ8Mr72DMySbT5+NA==} peerDependencies: - '@uppy/core': ^3.2.0 + '@uppy/core': ^3.3.0 dependencies: '@uppy/companion-client': '@github.com/owncloud/uppy/releases/download/v3.12.13-owncloud/uppy-companion-client.tgz' '@uppy/core': 3.3.0 @@ -6919,10 +6922,10 @@ packages: tus-js-client: 3.0.0 dev: false - /@uppy/utils@5.3.0: - resolution: {integrity: sha512-tPW+HtRkjanFQAa/XD2e6kjJ7ZeMHtVxEVeBeRtKTnltsCA9kNF2gDwxtUVPEWyLrKzW+CvRm60NDiOKaWUuww==} + /@uppy/utils@5.4.0: + resolution: {integrity: sha512-X9f43OvTcymhoiDEzA2CqH7W5pADTVv2bhQ/9NUaVwYRLUadDjZc27ZgnNraKSYgnOhKFJlC63nyCwcd0yaQIQ==} dependencies: - lodash.throttle: 4.1.1 + lodash: 4.17.21 dev: false /@uppy/xhr-upload@3.3.1(@uppy/core@3.3.0): @@ -18526,7 +18529,7 @@ packages: debug: 4.3.4(supports-color@6.1.0) read-package-json: 4.1.2 readdir-scoped-modules: 1.1.0 - semver: 7.3.8 + semver: 7.5.4 slide: 1.1.6 optionalDependencies: graceful-fs: 4.2.10 @@ -21412,7 +21415,7 @@ packages: espree: 9.5.2 esquery: 1.5.0 lodash: 4.17.21 - semver: 7.3.8 + semver: 7.5.4 transitivePeerDependencies: - supports-color dev: false