Skip to content

Commit

Permalink
Move to TypeScript: Upload, StreamSource (#734)
Browse files Browse the repository at this point in the history
* browser StreamSource TS

* TS upload and options

* Remove whitespace

* Fix type of default options

* Only pass strings to `HttpRequest#setHeader` (#709)

* setHeader value arg should be string

* Explicit casting of header values, number to string

* Fix syntax

* Fix lint

---------

Co-Authored-By: Marius Kleidl <[email protected]>

* Fix error emission

Co-Authored-By: Marius Kleidl <[email protected]>

* Allows `bytesTotal` to be null for progress events

Co-Authored-By: Marius Kleidl <[email protected]>

---------

Co-authored-by: Matthew Holloway <Matthew Holloway>
Co-authored-by: Marius Kleidl <[email protected]>
Co-authored-by: dragan_d_dragon <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent 2e79a9c commit f97916d
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 64 deletions.
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

0 comments on commit f97916d

Please sign in to comment.