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

fix: handle error and other edge cases. Handle offline state better. #135

Merged
merged 2 commits into from
Apr 18, 2024
Merged
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
96 changes: 65 additions & 31 deletions src/upchunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import xhr from 'xhr';
/* tslint:disable-next-line no-duplicate-imports */
import type { XhrUrlConfig, XhrHeaders, XhrResponse } from 'xhr';

type XhrResponseLike = Partial<XhrResponse> & Pick<XhrResponse, 'statusCode'>;

const DEFAULT_CHUNK_SIZE = 30720;
const DEFAULT_MAX_CHUNK_SIZE = 512000; // in kB
const DEFAULT_MIN_CHUNK_SIZE = 256; // in kB
Expand Down Expand Up @@ -150,20 +152,20 @@ type UploadPredOptions = {
attemptCount: number;
};
const isSuccessfulChunkUpload = (
res: XhrResponse | undefined,
res: XhrResponseLike | undefined,
_options?: any
): res is XhrResponse =>
!!res && SUCCESSFUL_CHUNK_UPLOAD_CODES.includes(res.statusCode);

const isRetriableChunkUpload = (
res: XhrResponse | undefined,
res: XhrResponseLike | undefined,
{ retryCodes = TEMPORARY_ERROR_CODES }: UploadPredOptions
) => !res || retryCodes.includes(res.statusCode);

const isFailedChunkUpload = (
res: XhrResponse | undefined,
res: XhrResponseLike | undefined,
options: UploadPredOptions
): res is XhrResponse => {
): res is XhrResponseLike => {
return (
options.attemptCount >= options.attempts ||
!(isSuccessfulChunkUpload(res) || isRetriableChunkUpload(res, options))
Expand All @@ -175,10 +177,14 @@ const isFailedChunkUpload = (
* Validates against the 'Range' header to ensure the full chunk was processed.
*/
export const isIncompleteChunkUploadNeedingRetry = (
res: XhrResponse | undefined,
res: XhrResponseLike | undefined,
_options?: any
): res is XhrResponse => {
if (!res || !RESUME_INCOMPLETE_CODES.includes(res.statusCode) || !res.headers['range']) {
): res is XhrResponseLike => {
if (
!res ||
!RESUME_INCOMPLETE_CODES.includes(res.statusCode) ||
!res.headers?.['range']
) {
return false;
}

Expand All @@ -191,7 +197,6 @@ export const isIncompleteChunkUploadNeedingRetry = (
return endByte !== _options.currentChunkEndByte;
};


type EventName =
| 'attempt'
| 'attemptFailure'
Expand Down Expand Up @@ -245,7 +250,7 @@ export class UpChunk {
private endpointValue: string;
private totalChunks: number;
private attemptCount: number;
private offline: boolean;
private _offline: boolean;
private _paused: boolean;
private success: boolean;
private currentXhr?: XMLHttpRequest;
Expand All @@ -268,7 +273,11 @@ export class UpChunk {
this.maxFileBytes = (options.maxFileSize || 0) * 1024;
this.chunkCount = 0;
this.attemptCount = 0;
this.offline = false;
// Initialize offline to the current offline state, where
// offline is false if
// 1. we're not running in the browser (aka window is undefined) -OR-
// 2. we're not online (as advertised by navigator.onLine)
this._offline = typeof window !== 'undefined' && !window.navigator.onLine;
this._paused = false;
this.success = false;
this.nextChunkRangeStart = 0;
Expand All @@ -293,17 +302,17 @@ export class UpChunk {
// trigger events when offline/back online
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (!this.offline) {
return;
}
if (!this.offline) return;

this.offline = false;
this._offline = false;
this.dispatch('online');
this.sendChunks();
});

window.addEventListener('offline', () => {
this.offline = true;
if (this.offline) return;

this._offline = true;
this.dispatch('offline');
});
}
Expand Down Expand Up @@ -344,7 +353,9 @@ export class UpChunk {
* Subscribe to an event once
*/
public once(eventName: EventName, fn: (event: CustomEvent) => void) {
this.eventTarget.addEventListener(eventName, fn as EventListener, { once: true });
this.eventTarget.addEventListener(eventName, fn as EventListener, {
once: true,
});
}

/**
Expand All @@ -354,6 +365,10 @@ export class UpChunk {
this.eventTarget.removeEventListener(eventName, fn as EventListener);
}

public get offline() {
return this._offline;
}

public get paused() {
return this._paused;
}
Expand All @@ -375,6 +390,10 @@ export class UpChunk {
}
}

public get successfulPercentage() {
return this.nextChunkRangeStart / this.file.size;
}

/**
* Dispatch an event
*/
Expand All @@ -401,8 +420,14 @@ export class UpChunk {
if (!(this.file instanceof File)) {
throw new TypeError('file must be a File object');
}
if (this.headers && typeof this.headers !== 'function' && typeof this.headers !== 'object') {
throw new TypeError('headers must be null, an object, or a function that returns an object or a promise');
if (
this.headers &&
typeof this.headers !== 'function' &&
typeof this.headers !== 'object'
) {
throw new TypeError(
'headers must be null, an object, or a function that returns an object or a promise'
);
}
if (
!isValidChunkSize(this.chunkSize, {
Expand Down Expand Up @@ -478,25 +503,29 @@ export class UpChunk {
const beforeSend = (xhrObject: XMLHttpRequest) => {
xhrObject.upload.onprogress = (event: ProgressEvent) => {
const remainingChunks = this.totalChunks - this.chunkCount;
// const remainingBytes = this.file.size-(this.nextChunkRangeStart+event.loaded);
const percentagePerChunk =
(this.file.size - this.nextChunkRangeStart) /
this.file.size /
remainingChunks;
const successfulPercentage = this.nextChunkRangeStart / this.file.size;
const currentChunkProgress =
event.loaded / (event.total ?? this.chunkByteSize);
const chunkPercentage = currentChunkProgress * percentagePerChunk;
// NOTE: Since progress events are "eager" and do not (yet) have sufficient context
// to "know" if the request was e.g. successful, we need to "recompute"/"rewind"
// progress if/when we detect failures. See failedChunkUploadCb(), below. (CJP)
this.dispatch(
'progress',
Math.min((successfulPercentage + chunkPercentage) * 100, 100)
Math.min((this.successfulPercentage + chunkPercentage) * 100, 100)
);
};
};

return new Promise((resolve, reject) => {
this.currentXhr = xhr({ ...options, beforeSend }, (err, resp) => {
this.currentXhr = undefined;
// NOTE: For at least some `err` cases, resp will still carry information. We may want to consider passing that on somehow
// in our Promise reject (or instead of err) (CJP)
// See: https://github.com/naugtur/xhr/blob/master/index.js#L93-L100
if (err) {
return reject(err);
}
Expand All @@ -512,7 +541,9 @@ export class UpChunk {
protected async sendChunk(chunk: Blob) {
const rangeStart = this.nextChunkRangeStart;
const rangeEnd = rangeStart + chunk.size - 1;
const extraHeaders = await (typeof this.headers === 'function' ? this.headers() : this.headers);
const extraHeaders = await (typeof this.headers === 'function'
? this.headers()
: this.headers);

const headers = {
...extraHeaders,
Expand Down Expand Up @@ -574,12 +605,11 @@ export class UpChunk {
};

// What to do if a chunk upload failed, potentially after retries
const failedChunkUploadCb = async (res: XhrResponse, _chunk?: Blob) => {
const failedChunkUploadCb = async (res: XhrResponseLike, _chunk?: Blob) => {
this.dispatch('progress', Math.min(this.successfulPercentage * 100, 100));
// Side effects
this.dispatch('error', {
message: `Server responded with ${
(res as XhrResponse).statusCode
}. Stopping upload.`,
message: `Server responded with ${res.statusCode}. Stopping upload.`,
chunk: this.chunkCount,
attempts: this.attemptCount,
response: res,
Expand All @@ -591,7 +621,7 @@ export class UpChunk {
// What to do if a chunk upload failed but is retriable and hasn't exceeded retry
// count
const retriableChunkUploadCb = async (
res: XhrResponse | undefined,
res: XhrResponseLike | undefined,
_chunk?: Blob
) => {
// Side effects
Expand Down Expand Up @@ -620,13 +650,16 @@ export class UpChunk {
});
};

let res: XhrResponse | undefined;
let res: XhrResponseLike | undefined;
try {
this.attemptCount = this.attemptCount + 1;
this.lastChunkStart = new Date();
res = await this.sendChunk(chunk);
} catch (_err) {
// this type of error can happen after network disconnection on CORS setup
} catch (err: unknown) {
// Account for failed attempts due to becoming offline while making a request.
if (typeof (err as any)?.statusCode === 'number') {
res = err as XhrResponseLike;
}
}
const options = {
retryCodes: this.retryCodes,
Expand All @@ -653,7 +686,8 @@ export class UpChunk {
*/
private async sendChunks() {
// A "pending chunk" is a chunk that was unsuccessful but still retriable when
// uploading was _paused or the env is offline. Since this may be the last
// uploading was _paused or the env is offline. Since this may be the last chunk,
// we account for it outside of the loop.
if (this.pendingChunk && !(this._paused || this.offline)) {
const chunk = this.pendingChunk;
this.pendingChunk = undefined;
Expand Down
Loading