Skip to content

Commit eec1fc3

Browse files
committed
refactor(upload): split upload logic into separate classes for files and folders
This also refactors the uploader to support custom events allowing apps to register event listeners for reactivity. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent d366d98 commit eec1fc3

13 files changed

Lines changed: 963 additions & 668 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { AxiosRequestHeaders, AxiosResponse } from 'axios'
7+
8+
import { AxiosError } from 'axios'
9+
import { expect, test } from 'vitest'
10+
import { UploadFailedError } from './UploadFailedError.ts'
11+
12+
test('UploadFailedError - axios error but no response', () => {
13+
const cause = new AxiosError('Network error')
14+
const error = new UploadFailedError(cause)
15+
expect(error).toBeInstanceOf(Error)
16+
expect(error).toBeInstanceOf(UploadFailedError)
17+
expect(error.message).toBe('Upload has failed')
18+
expect(error.cause).toBe(cause)
19+
expect(error).toHaveProperty('__UPLOAD_FAILED__')
20+
expect(error.response).toBeUndefined()
21+
})
22+
23+
test('UploadFailedError - axios error', () => {
24+
const response = {} as AxiosResponse
25+
const cause = new AxiosError('Network error', '200', { headers: {} as AxiosRequestHeaders }, {}, response)
26+
const error = new UploadFailedError(cause)
27+
expect(error).toBeInstanceOf(Error)
28+
expect(error).toBeInstanceOf(UploadFailedError)
29+
expect(error.message).toBe('Upload has failed')
30+
expect(error.cause).toBe(cause)
31+
expect(error).toHaveProperty('__UPLOAD_FAILED__')
32+
expect(error.response).toBe(response)
33+
})
34+
35+
test('UploadFailedError - generic error', () => {
36+
const cause = new Error('Generic error')
37+
const error = new UploadFailedError(cause)
38+
expect(error).toBeInstanceOf(Error)
39+
expect(error).toBeInstanceOf(UploadFailedError)
40+
expect(error.message).toBe('Upload has failed')
41+
expect(error.cause).toBe(cause)
42+
expect(error).toHaveProperty('__UPLOAD_FAILED__')
43+
expect(error.response).toBeUndefined()
44+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { AxiosResponse } from '@nextcloud/axios'
7+
8+
import { isAxiosError } from '@nextcloud/axios'
9+
10+
export class UploadFailedError extends Error {
11+
private __UPLOAD_FAILED__ = true
12+
13+
readonly response?: AxiosResponse
14+
15+
public constructor(cause?: unknown) {
16+
super('Upload has failed', { cause })
17+
if (isAxiosError(cause) && cause.response) {
18+
this.response = cause.response
19+
}
20+
}
21+
}

lib/upload/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
export type { Eta, EtaEventsMap } from './uploader/index.ts'
7-
export type { Directory, IDirectory } from './utils/fileTree.ts'
8-
6+
export { UploadCancelledError } from './errors/UploadCancelledError.ts'
7+
export { UploadFailedError } from './errors/UploadFailedError.ts'
8+
export * from './uploader/index.ts'
99
export { getUploader } from './getUploader.ts'
10-
export { Upload, UploadStatus } from './uploader/Upload.ts'
11-
export { EtaStatus, Uploader, UploaderStatus } from './uploader/index.ts'
12-
export { getConflicts, hasConflict } from './utils/conflicts.ts'

lib/upload/uploader/Upload.ts

Lines changed: 64 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -3,125 +3,89 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { AxiosResponse } from 'axios'
6+
import type PQueue from 'p-queue'
77

8-
import { getMaxChunksSize } from '../utils/config.ts'
8+
import { TypedEventTarget } from 'typescript-event-target'
99

1010
export const UploadStatus = Object.freeze({
11+
/** The upload was initialized */
1112
INITIALIZED: 0,
12-
UPLOADING: 1,
13-
ASSEMBLING: 2,
14-
FINISHED: 3,
15-
CANCELLED: 4,
16-
FAILED: 5,
13+
/** The upload was scheduled but is not yet uploading */
14+
SCHEDULED: 1,
15+
/** The upload itself is running */
16+
UPLOADING: 2,
17+
/** Chunks are being assembled */
18+
ASSEMBLING: 3,
19+
/** The upload finished successfully */
20+
FINISHED: 4,
21+
/** The upload was cancelled by the user */
22+
CANCELLED: 5,
23+
/** The upload failed */
24+
FAILED: 6,
1725
})
1826

19-
type TUploadStatus = typeof UploadStatus[keyof typeof UploadStatus]
27+
export type TUploadStatus = typeof UploadStatus[keyof typeof UploadStatus]
2028

21-
export class Upload {
22-
private _source: string
23-
private _file: File
24-
private _isChunked: boolean
25-
private _chunks: number
26-
27-
private _size: number
28-
private _uploaded = 0
29-
private _startTime = 0
30-
31-
private _status: TUploadStatus = UploadStatus.INITIALIZED
32-
private _controller: AbortController
33-
private _response: AxiosResponse | null = null
34-
35-
constructor(source: string, chunked = false, size: number, file: File) {
36-
const chunks = Math.min(getMaxChunksSize() > 0 ? Math.ceil(size / getMaxChunksSize()) : 1, 10000)
37-
this._source = source
38-
this._isChunked = chunked && getMaxChunksSize() > 0 && chunks > 1
39-
this._chunks = this._isChunked ? chunks : 1
40-
this._size = size
41-
this._file = file
42-
this._controller = new AbortController()
43-
}
44-
45-
get source(): string {
46-
return this._source
47-
}
48-
49-
get file(): File {
50-
return this._file
51-
}
52-
53-
get isChunked(): boolean {
54-
return this._isChunked
55-
}
56-
57-
get chunks(): number {
58-
return this._chunks
59-
}
60-
61-
get size(): number {
62-
return this._size
63-
}
64-
65-
get startTime(): number {
66-
return this._startTime
67-
}
68-
69-
set response(response: AxiosResponse | null) {
70-
this._response = response
71-
}
72-
73-
get response(): AxiosResponse | null {
74-
return this._response
75-
}
76-
77-
get uploaded(): number {
78-
return this._uploaded
79-
}
29+
interface UploadEvents {
30+
finished: CustomEvent<IUpload>
31+
progress: CustomEvent<IUpload>
32+
}
8033

34+
export interface IUpload extends TypedEventTarget<UploadEvents> {
8135
/**
82-
* Update the uploaded bytes of this upload
36+
* The source of the upload
8337
*/
84-
set uploaded(length: number) {
85-
if (length >= this._size) {
86-
this._status = this._isChunked
87-
? UploadStatus.ASSEMBLING
88-
: UploadStatus.FINISHED
89-
this._uploaded = this._size
90-
return
91-
}
92-
93-
this._status = UploadStatus.UPLOADING
94-
this._uploaded = length
95-
96-
// If first progress, let's log the start time
97-
if (this._startTime === 0) {
98-
this._startTime = new Date().getTime()
99-
}
100-
}
101-
102-
get status(): TUploadStatus {
103-
return this._status
104-
}
105-
38+
readonly source: string
10639
/**
107-
* Update this upload status
40+
* Whether the upload is chunked or not
10841
*/
109-
set status(status: TUploadStatus) {
110-
this._status = status
111-
}
42+
readonly isChunked: boolean
43+
/**
44+
* The total size of the upload in bytes
45+
*/
46+
readonly totalBytes: number
47+
/**
48+
* Timestamp of when the upload started.
49+
* Will return `undefined` if the upload has not started yet.
50+
*/
51+
readonly startTime?: number
52+
/**
53+
* The number of bytes that have been uploaded so far
54+
*/
55+
readonly uploadedBytes: number
56+
/**
57+
* The current status of the upload
58+
*/
59+
readonly status: TUploadStatus
60+
/**
61+
* The internal abort signal
62+
*/
63+
readonly signal: AbortSignal
11264

11365
/**
114-
* Returns the axios cancel token source
66+
* Cancels the upload
11567
*/
68+
cancel(): void
69+
}
70+
71+
export abstract class Upload extends TypedEventTarget<UploadEvents> implements Partial<IUpload> {
72+
#abortController = new AbortController()
73+
11674
get signal(): AbortSignal {
117-
return this._controller.signal
75+
return this.#abortController.signal
11876
}
11977

12078
/**
121-
* Cancel any ongoing requests linked to this upload
79+
* Cancels the upload
12280
*/
123-
cancel() {
124-
this._controller.abort()
125-
this._status = UploadStatus.CANCELLED
81+
public cancel(): void {
82+
this.#abortController.abort()
12683
}
84+
85+
/**
86+
* Start the upload
87+
*
88+
* @param queue - The job queue. It is used to limit the number of concurrent upload jobs.
89+
*/
90+
public abstract start(queue: PQueue): Promise<void>
12791
}

0 commit comments

Comments
 (0)