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

feature: Support Resumability for node env #62 #73

Merged
merged 7 commits into from
Apr 24, 2019
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
demos/reactnative/.expo
lib.es5
dist
.DS_Store
26 changes: 16 additions & 10 deletions lib/browser/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,23 @@ try {

export const canStoreURLs = hasStorage;

export function setItem(key, value) {
if (!hasStorage) return;
return localStorage.setItem(key, value);
}
class LocalStorage {
setItem(key, value, cb) {
if (!hasStorage) return cb();
cb(null, localStorage.setItem(key, value));
}

export function getItem(key) {
if (!hasStorage) return;
return localStorage.getItem(key);
getItem(key, cb) {
if (!hasStorage) return cb();
cb(null, localStorage.getItem(key));
}

removeItem(key, cb) {
if (!hasStorage) return cb();
cb(null, localStorage.removeItem(key));
}
}

export function removeItem(key) {
if (!hasStorage) return;
return localStorage.removeItem(key);
export function getStorage() {
return new LocalStorage();
Acconut marked this conversation as resolved.
Show resolved Hide resolved
}
31 changes: 31 additions & 0 deletions lib/fingerprint.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import isReactNative from "./node/isReactNative";

/**
* Generate a fingerprint for a file which will be used the store the endpoint
*
* @param {File} file
* @return {String}
*/
export default function fingerprint(file, options) {
if (isReactNative) {
return reactNativeFingerprint(file, options);
}

return [
"tus",
file.name,
Expand All @@ -14,3 +20,28 @@ export default function fingerprint(file, options) {
options.endpoint
].join("-");
}

function reactNativeFingerprint(file, options) {
let exifHash = file.exif ? hashCode(JSON.stringify(file.exif)) : "noexif";
Acconut marked this conversation as resolved.
Show resolved Hide resolved
return [
"tus",
file.name || "noname",
file.size || "nosize",
exifHash,
options.endpoint
].join("/");
}

function hashCode(str) {
// from https://stackoverflow.com/a/8831937/151666
var hash = 0;
if (str.length === 0) {
return hash;
}
for (var i = 0; i < str.length; i++) {
var char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
22 changes: 12 additions & 10 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
/* global window */
import Upload from "./upload";
import {canStoreURLs} from "./node/storage";
import * as storage from "./node/storage";

const {defaultOptions} = Upload;
let isSupported;

const moduleExport = {
Upload,
canStoreURLs: storage.canStoreURLs,
defaultOptions
};

if (typeof window !== "undefined") {
// Browser environment using XMLHttpRequest
const {XMLHttpRequest, Blob} = window;

isSupported = (
moduleExport.isSupported = (
XMLHttpRequest &&
Blob &&
typeof Blob.prototype.slice === "function"
);
} else {
// Node.js environment using http module
isSupported = true;
moduleExport.isSupported = true;
// make FileStorage module available as it will not be set by default.
moduleExport.FileStorage = storage.FileStorage;
}

// The usage of the commonjs exporting syntax instead of the new ECMAScript
// one is actually inteded and prevents weird behaviour if we are trying to
// import this module in another module using Babel.
module.exports = {
Upload,
isSupported,
canStoreURLs,
defaultOptions
};
module.exports = moduleExport;
3 changes: 3 additions & 0 deletions lib/node/isReactNative.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const isReactNative = false;

export default isReactNative;
108 changes: 102 additions & 6 deletions lib/node/storage.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,111 @@
/* eslint no-unused-vars: 0 */
import { readFile, writeFile } from "fs";
import * as lockfile from "proper-lockfile";

export const canStoreURLs = false;

export function setItem(key, value) {
export const canStoreURLs = true;

export function getStorage() {
// don't support storage by default.
return null;
}

export function getItem(key) {

}
export class FileStorage {
constructor(filePath) {
this.path = filePath;
}

setItem(key, value, cb) {
lockfile.lock(this.path, this._lockfileOptions(), (err, release) => {
if (err) {
return cb(err);
}

cb = this._releaseAndCb(release, cb);
this._getData((err, data) => {
if (err) {
return cb(err);
}

data[key] = value;
this._writeData(data, (err) => cb(err));
});
});
}

getItem(key, cb) {
this._getData((err, data) => {
if (err) {
return cb(err);
}
cb(null, data[key]);
});
}

removeItem(key, cb) {
lockfile.lock(this.path, this._lockfileOptions(), (err, release) => {
if (err) {
return cb(err);
}

cb = this._releaseAndCb(release, cb);
this._getData((err, data) => {
if (err) {
return cb(err);
}

delete data[key];
this._writeData(data, (err) => cb(err));
});
});
}

_lockfileOptions() {
return {
realpath: false,
retries: {
retries: 5,
minTimeout: 20
}
};
}

_releaseAndCb(release, cb) {
return (err) => {
if (err) {
// @TODO consider combining error from release callback
Acconut marked this conversation as resolved.
Show resolved Hide resolved
release(() => cb(err));
return;
}

release(cb);
};
}

export function removeItem(key) {
_writeData(data, cb) {
const opts = {
encoding: "utf8",
mode: 0o660,
flag: "w"
};
writeFile(this.path, JSON.stringify(data), opts, (err) => cb(err));
}

_getData(cb) {
readFile(this.path, "utf8", (err, data) => {
if (err) {
// return empty data if file does not exist
err.code === "ENOENT" ? cb(null, {}) : cb(err);
return;
} else {
try {
data = !data.trim().length ? {} : JSON.parse(data);
} catch (error) {
cb(error);
return;
}
cb(null, data);
}
});
}
}
68 changes: 48 additions & 20 deletions lib/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { Base64 } from "js-base64";

// We import the files used inside the Node environment which are rewritten
// for browsers using the rules defined in the package.json
import {newRequest, resolveUrl} from "./node/request";
import {getSource} from "./node/source";
import * as Storage from "./node/storage";
import { newRequest, resolveUrl } from "./node/request";
import { getSource } from "./node/source";
import { getStorage } from "./node/storage";

const defaultOptions = {
endpoint: null,
Expand All @@ -26,13 +26,18 @@ const defaultOptions = {
overridePatchMethod: false,
retryDelays: null,
removeFingerprintOnSuccess: false,
uploadLengthDeferred: false
uploadLengthDeferred: false,
urlStorage: null,
fileReader: null
};

class Upload {
constructor(file, options) {
this.options = extend(true, {}, defaultOptions, options);

// The storage module used to store URLs
this._storage = this.options.urlStorage;

// The underlying File/Blob object
this.file = file;

Expand Down Expand Up @@ -82,10 +87,15 @@ class Upload {
return;
}

if (this.options.resume && this._storage == null) {
this._storage = getStorage();
}

if (this._source) {
this._start(this._source);
} else {
getSource(file, this.options.chunkSize, (err, source) => {
const fileReader = this.options.fileReader || getSource;
fileReader(file, this.options.chunkSize, (err, source) => {
if (err) {
this._emitError(err);
return;
Expand Down Expand Up @@ -193,34 +203,44 @@ class Upload {
}

// Try to find the endpoint for the file in the storage
if (this.options.resume) {
if (this._hasStorage()) {
this._fingerprint = this.options.fingerprint(file, this.options);
let resumedUrl = Storage.getItem(this._fingerprint);
this._storage.getItem(this._fingerprint, (err, resumedUrl) => {
if (err) {
this._emitError(err);
return;
}

if (resumedUrl != null) {
this.url = resumedUrl;
this._resumeUpload();
return;
}
if (resumedUrl != null) {
this.url = resumedUrl;
this._resumeUpload();
} else {
this._createUpload();
}
});
} else {
// An upload has not started for the file yet, so we start a new one
this._createUpload();
}

// An upload has not started for the file yet, so we start a new one
this._createUpload();
}

abort() {
if (this._xhr !== null) {
this._xhr.abort();
this._source.close();
this._aborted = true;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure this was a bug, since it should always set _aborted to true after abort is called

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! That would not have been a problem before this PR but might causes issues now 👍

}
this._aborted = true;

if (this._retryTimeout != null) {
clearTimeout(this._retryTimeout);
this._retryTimeout = null;
}
}

_hasStorage() {
return this.options.resume && this._storage;
}

_emitXhrError(xhr, err, causingErr) {
this._emitError(new DetailedError(err, causingErr, xhr));
}
Expand Down Expand Up @@ -322,8 +342,12 @@ class Upload {
return;
}

if (this.options.resume) {
Storage.setItem(this._fingerprint, this.url);
if (this._hasStorage()) {
this._storage.setItem(this._fingerprint, this.url, (err) => {
if (err) {
this._emitError(err);
}
});
}

this._offset = 0;
Expand Down Expand Up @@ -363,10 +387,14 @@ class Upload {

xhr.onload = () => {
if (!inStatusCategory(xhr.status, 200)) {
if (this.options.resume && inStatusCategory(xhr.status, 400)) {
if (this.options.resume && this._storage && inStatusCategory(xhr.status, 400)) {
Acconut marked this conversation as resolved.
Show resolved Hide resolved
// Remove stored fingerprint and corresponding endpoint,
// on client errors since the file can not be found
Storage.removeItem(this._fingerprint);
this._storage.removeItem(this._fingerprint, (err) => {
if (err) {
this._emitError(err);
}
});
}

// If the upload is locked (indicated by the 423 Locked status code), we
Expand Down
Loading