Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to TypeScript: Upload, StreamSource #734

Merged
merged 7 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions lib/browser/sources/StreamSource.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,35 @@
import type { FileSource } from '../../options.js'

function len(blobOrArray): number {
function len(blobOrArray: StreamSource['_buffer']): number {
if (blobOrArray === undefined) return 0
if (blobOrArray.size !== undefined) return blobOrArray.size
if (blobOrArray instanceof Blob) return blobOrArray.size
return blobOrArray.length
}

/*
Typed arrays and blobs don't have a concat method.
This function helps StreamSource accumulate data to reach chunkSize.
*/
function concat(a, b) {
if (a.concat) {
// Is `a` an Array?
return a.concat(b)
function concat<T extends StreamSource['_buffer']>(a: T, b: T): T {
if (Array.isArray(a) && Array.isArray(b)) {
return a.concat(b) as T
}
if (a instanceof Blob) {
return new Blob([a, b], { type: a.type })
if (a instanceof Blob && b instanceof Blob) {
return new Blob([a, b], { type: a.type }) as T
}
if (a.set) {
// Is `a` a typed array?
const c = new a.constructor(a.length + b.length)
if (a instanceof Uint8Array && b instanceof Uint8Array) {
const c = new Uint8Array(a.length + b.length)
c.set(a)
c.set(b, a.length)
return c
return c as T
}
throw new Error('Unknown data type')
}

export default class StreamSource implements FileSource {
_reader: Pick<ReadableStreamDefaultReader, 'read'>
_reader: Pick<ReadableStreamDefaultReader<StreamSource['_buffer']>, 'read'>

_buffer: Blob | undefined
_buffer: Blob | Uint8Array | number[] | undefined

// _bufferOffset defines at which position the content of _buffer (if it is set)
// is located in the view of the entire stream. It does not mean at which offset
Expand Down
8 changes: 5 additions & 3 deletions lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ export interface UploadOptions {
fingerprint: (file: UploadInput, options: UploadOptions) => Promise<string | null>
uploadSize: number | null

onProgress: ((bytesSent: number, bytesTotal: number) => void) | null
onChunkComplete: ((chunkSize: number, bytesAccepted: number, bytesTotal: number) => void) | null
onProgress: ((bytesSent: number, bytesTotal: number | null) => void) | null
onChunkComplete:
| ((chunkSize: number, bytesAccepted: number, bytesTotal: number | null) => void)
| null
onSuccess: (() => void) | null
onError: ((error: Error | DetailedError) => void) | null
onShouldRetry:
Expand All @@ -67,7 +69,7 @@ export interface UploadOptions {
fileReader: FileReader
httpStack: HttpStack

protocol: string
protocol: typeof PROTOCOL_TUS_V1 | typeof PROTOCOL_IETF_DRAFT_03
}

export interface UrlStorage {
Expand Down
92 changes: 71 additions & 21 deletions lib/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const defaultOptions = {
fileReader: null,
httpStack: null,

protocol: PROTOCOL_TUS_V1,
protocol: PROTOCOL_TUS_V1 as UploadOptions['protocol'],
}

export default class BaseUpload {
Expand Down Expand Up @@ -269,6 +269,11 @@ export default class BaseUpload {
? this._parallelUploadUrls.length
: this.options.parallelUploads

if (this._size === null) {
this._emitError(new Error('tus: Expected _size to be set'))
return
}

// The input file will be split into multiple slices which are uploaded in separate
// requests. Here we get the start and end position for the slices.
const partsBoundaries =
Expand Down Expand Up @@ -316,9 +321,13 @@ export default class BaseUpload {
onError: reject,
// Based in the progress for this partial upload, calculate the progress
// for the entire final upload.
onProgress: (newPartProgress) => {
onProgress: (newPartProgress: number) => {
totalProgress = totalProgress - lastPartProgress + newPartProgress
lastPartProgress = newPartProgress
if (totalSize === null) {
this._emitError(new Error('tus: Expected totalSize to be set'))
return
}
this._emitProgress(totalProgress, totalSize)
},
// Wait until every partial upload has an upload URL, so we can add
Expand Down Expand Up @@ -350,6 +359,10 @@ export default class BaseUpload {
// creating the final upload.
Promise.all(uploads)
.then(() => {
if (this.options.endpoint === null) {
this._emitError(new Error('tus: Expected options.endpoint to be set'))
return
}
req = this._openRequest('POST', this.options.endpoint)
// @ts-expect-error We know that _parallelUploadUrls is defined
req.setHeader('Upload-Concat', `final;${this._parallelUploadUrls.join(' ')}`)
Expand All @@ -363,6 +376,10 @@ export default class BaseUpload {
return this._sendRequest(req)
})
.then((res) => {
if (res === undefined) {
this._emitError(new Error('tus: Expected res to be defined'))
return
}
if (!inStatusCategory(res.getStatus(), 200)) {
this._emitHttpError(req, res, 'tus: unexpected response while creating upload')
return
Expand All @@ -374,6 +391,11 @@ export default class BaseUpload {
return
}

if (this.options.endpoint === null) {
this._emitError(new Error('tus: Expeced endpoint to be defined.'))
return
}

this.url = resolveUrl(this.options.endpoint, location)
log(`Created upload at ${this.url}`)

Expand Down Expand Up @@ -517,10 +539,10 @@ export default class BaseUpload {
* data may not have been accepted by the server yet.
*
* @param {number} bytesSent Number of bytes sent to the server.
* @param {number} bytesTotal Total number of bytes to be sent to the server.
* @param {number|null} bytesTotal Total number of bytes to be sent to the server.
* @api private
*/
_emitProgress(bytesSent, bytesTotal) {
_emitProgress(bytesSent: number, bytesTotal: number | null): void {
if (typeof this.options.onProgress === 'function') {
this.options.onProgress(bytesSent, bytesTotal)
}
Expand All @@ -532,10 +554,10 @@ export default class BaseUpload {
* @param {number} chunkSize Size of the chunk that was accepted by the server.
* @param {number} bytesAccepted Total number of bytes that have been
* accepted by the server.
* @param {number} bytesTotal Total number of bytes to be sent to the server.
* @param {number|null} bytesTotal Total number of bytes to be sent to the server.
* @api private
*/
_emitChunkComplete(chunkSize, bytesAccepted, bytesTotal) {
_emitChunkComplete(chunkSize: number, bytesAccepted: number, bytesTotal: number | null): void {
if (typeof this.options.onChunkComplete === 'function') {
this.options.onChunkComplete(chunkSize, bytesAccepted, bytesTotal)
}
Expand All @@ -557,9 +579,12 @@ export default class BaseUpload {
const req = this._openRequest('POST', this.options.endpoint)

if (this.options.uploadLengthDeferred) {
req.setHeader('Upload-Defer-Length', 1)
req.setHeader('Upload-Defer-Length', '1')
} else {
req.setHeader('Upload-Length', this._size)
if (this._size === null) {
this._emitError(new Error('tus: expected _size to be set'))
}
req.setHeader('Upload-Length', `${this._size}`)
}

// Add metadata if values have been added
Expand All @@ -568,7 +593,7 @@ export default class BaseUpload {
req.setHeader('Upload-Metadata', metadata)
}

let promise: Promise<HttpResponse>
let promise: Promise<HttpResponse | undefined>
if (this.options.uploadDataDuringCreation && !this.options.uploadLengthDeferred) {
this._offset = 0
promise = this._addChunkToRequest(req)
Expand All @@ -581,6 +606,11 @@ export default class BaseUpload {

promise
.then((res) => {
if (res === undefined) {
this._emitError(new Error('tus: Expected res to be set'))
return
}

if (!inStatusCategory(res.getStatus(), 200)) {
this._emitHttpError(req, res, 'tus: unexpected response while creating upload')
return
Expand All @@ -592,6 +622,11 @@ export default class BaseUpload {
return
}

if (this.options.endpoint === null) {
this._emitError(new Error('tus: Expected options.endpoint to be set'))
return
}

this.url = resolveUrl(this.options.endpoint, location)
log(`Created upload at ${this.url}`)

Expand Down Expand Up @@ -628,6 +663,10 @@ export default class BaseUpload {
* @api private
*/
_resumeUpload() {
if (this.url === null) {
this._emitError(new Error('tus: Expected url to be set'))
return
}
const req = this._openRequest('HEAD', this.url)
const promise = this._sendRequest(req)

Expand Down Expand Up @@ -728,6 +767,10 @@ export default class BaseUpload {

let req: HttpRequest

if (this.url === null) {
this._emitError(new Error('tus: Expected url to be set'))
return
}
// Some browser and servers may not support the PATCH method. For those
// cases, you can tell tus-js-client to use a POST request with the
// X-HTTP-Method-Override header for simulating a PATCH request.
Expand All @@ -743,6 +786,10 @@ export default class BaseUpload {

promise
.then((res) => {
if (res === undefined) {
this._emitError(new Error('tus: Expected res to be defined'))
return
}
if (!inStatusCategory(res.getStatus(), 200)) {
this._emitHttpError(req, res, 'tus: unexpected response while uploading chunk')
return
Expand Down Expand Up @@ -797,7 +844,7 @@ export default class BaseUpload {
// upload size and can tell the tus server.
if (this.options.uploadLengthDeferred && done) {
this._size = this._offset + valueSize
req.setHeader('Upload-Length', this._size)
req.setHeader('Upload-Length', `${this._size}`)
}

// The specified uploadSize might not match the actual amount of data that a source
Expand Down Expand Up @@ -859,7 +906,7 @@ export default class BaseUpload {
*
* @api private
*/
_openRequest(method, url) {
_openRequest(method: string, url: string) {
const req = openRequest(method, url, this.options)
this._req = req
return req
Expand Down Expand Up @@ -928,7 +975,7 @@ export default class BaseUpload {
}
}

function encodeMetadata(metadata) {
function encodeMetadata(metadata: Record<string, string>) {
return Object.entries(metadata)
.map(([key, value]) => `${key} ${Base64.encode(String(value))}`)
.join(',')
Expand All @@ -940,7 +987,7 @@ function encodeMetadata(metadata) {
*
* @api private
*/
function inStatusCategory(status, category) {
function inStatusCategory(status: number, category: 100 | 200 | 300 | 400 | 500): boolean {
return status >= category && status < category + 100
}

Expand All @@ -951,7 +998,7 @@ function inStatusCategory(status, category) {
*
* @api private
*/
function openRequest(method, url, options) {
function openRequest(method: string, url: string, options: UploadOptions) {
const req = options.httpStack.createRequest(method, url)

if (options.protocol === PROTOCOL_IETF_DRAFT_03) {
Expand Down Expand Up @@ -1023,18 +1070,19 @@ function isOnline(): boolean {
*
* @api private
*/
function shouldRetry(err, retryAttempt, options) {
function shouldRetry(err: Error | DetailedError, retryAttempt: number, options: UploadOptions) {
// We only attempt a retry if
// - retryDelays option is set
// - we didn't exceed the maxium number of retries, yet, and
// - this error was caused by a request or it's response and
// - the error is server error (i.e. not a status 4xx except a 409 or 423) or
// a onShouldRetry is specified and returns true
// - the browser does not indicate that we are offline
const isNetworkError = 'originalRequest' in err && err.originalRequest != null
if (
options.retryDelays == null ||
retryAttempt >= options.retryDelays.length ||
err.originalRequest == null
!isNetworkError
) {
return false
}
Expand All @@ -1051,7 +1099,7 @@ function shouldRetry(err, retryAttempt, options) {
* @param {DetailedError} err
* @returns {boolean}
*/
function defaultOnShouldRetry(err) {
function defaultOnShouldRetry(err: DetailedError): boolean {
const status = err.originalResponse ? err.originalResponse.getStatus() : 0
return (!inStatusCategory(status, 400) || status === 409 || status === 423) && isOnline()
}
Expand All @@ -1062,22 +1110,24 @@ function defaultOnShouldRetry(err) {
* header with the value /upload/abc, the resolved URL will be:
* http://example.com/upload/abc
*/
function resolveUrl(origin, link) {
function resolveUrl(origin: string, link: string): string {
return new URL(link, origin).toString()
}

type Part = { start: number; end: number }

/**
* Calculate the start and end positions for the parts if an upload
* is split into multiple parallel requests.
*
* @param {number} totalSize The byte size of the upload, which will be split.
* @param {number} partCount The number in how many parts the upload will be split.
* @return {object[]}
* @return {Part[]}
* @api private
*/
function splitSizeIntoParts(totalSize, partCount) {
function splitSizeIntoParts(totalSize: number, partCount: number): Part[] {
const partSize = Math.floor(totalSize / partCount)
const parts: { start: number; end: number }[] = []
const parts: Part[] = []

for (let i = 0; i < partCount; i++) {
parts.push({
Expand Down
Loading
Loading