-
Notifications
You must be signed in to change notification settings - Fork 320
/
Copy pathupload.js
456 lines (371 loc) · 13.2 KB
/
upload.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
/* global window */
import fingerprint from "./fingerprint";
import DetailedError from "./error";
import extend from "extend";
// 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 Base64 from "./node/base64";
import * as Storage from "./node/storage";
const defaultOptions = {
endpoint: "",
fingerprint,
resume: true,
onProgress: null,
onChunkComplete: null,
onSuccess: null,
onError: null,
headers: {},
chunkSize: Infinity,
withCredentials: false,
uploadUrl: null,
uploadSize: null,
overridePatchMethod: false,
retryDelays: null
};
class Upload {
constructor(file, options) {
this.options = extend(true, {}, defaultOptions, options);
// The underlying File/Blob object
this.file = file;
// The URL against which the file will be uploaded
this.url = null;
// The underlying XHR object for the current PATCH request
this._xhr = null;
// The fingerpinrt for the current file (set after start())
this._fingerprint = null;
// The offset used in the current PATCH request
this._offset = null;
// True if the current PATCH request has been aborted
this._aborted = false;
// The file's size in bytes
this._size = null;
// The Source object which will wrap around the given file and provides us
// with a unified interface for getting its size and slice chunks from its
// content allowing us to easily handle Files, Blobs, Buffers and Streams.
this._source = null;
// The current count of attempts which have been made. Null indicates none.
this._retryAttempt = 0;
// The timeout's ID which is used to delay the next retry
this._retryTimeout = null;
// The offset of the remote upload before the latest attempt was started.
this._offsetBeforeRetry = 0;
}
start() {
let file = this.file;
if (!file) {
this._emitError(new Error("tus: no file or stream to upload provided"));
return;
}
if (!this.options.endpoint) {
this._emitError(new Error("tus: no endpoint provided"));
return;
}
let source = this._source = getSource(file, this.options.chunkSize);
// Firstly, check if the caller has supplied a manual upload size or else
// we will use the calculated size by the source object.
if (this.options.uploadSize != null) {
let size = +this.options.uploadSize;
if (isNaN(size)) {
throw new Error("tus: cannot convert `uploadSize` option into a number");
}
this._size = size;
} else {
let size = source.size;
// The size property will be null if we cannot calculate the file's size,
// for example if you handle a stream.
if (size == null) {
throw new Error("tus: cannot automatically derive upload's size from input and must be specified manually using the `uploadSize` option");
}
this._size = size;
}
let retryDelays = this.options.retryDelays;
if (retryDelays != null) {
if (Object.prototype.toString.call(retryDelays) !== "[object Array]") {
throw new Error("tus: the `retryDelays` option must either be an array or null");
} else {
let errorCallback = this.options.onError;
this.options.onError = (err) => {
// Restore the original error callback which may have been set.
this.options.onError = errorCallback;
// We will reset the attempt counter if
// - we were already able to connect to the server (offset != null) and
// - we were able to upload a small chunk of data to the server
let shouldResetDelays = this._offset != null && (this._offset > this._offsetBeforeRetry);
if (shouldResetDelays) {
this._retryAttempt = 0;
}
let isOnline = true;
if (typeof window !== "undefined" &&
"navigator" in window &&
window.navigator.onLine === false) {
isOnline = false;
}
// We only attempt a retry if
// - we didn't exceed the maxium number of retries, yet, and
// - this error was caused by a request or it's response and
// - the browser does not indicate that we are offline
let shouldRetry = this._retryAttempt < retryDelays.length &&
err.originalRequest != null &&
isOnline;
if (!shouldRetry) {
this._emitError(err);
return;
}
let delay = retryDelays[this._retryAttempt++];
this._offsetBeforeRetry = this._offset;
this.options.uploadUrl = this.url;
this._retryTimeout = setTimeout(() => {
this.start();
}, delay);
};
}
}
// A URL has manually been specified, so we try to resume
if (this.options.uploadUrl != null) {
this.url = this.options.uploadUrl;
this._resumeUpload();
return;
}
// Try to find the endpoint for the file in the storage
if (this.options.resume) {
this._fingerprint = this.options.fingerprint(file);
let resumedUrl = Storage.getItem(this._fingerprint);
if (resumedUrl != null) {
this.url = resumedUrl;
this._resumeUpload();
return;
}
}
// 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;
}
if (this._retryTimeout != null) {
clearTimeout(this._retryTimeout);
this._retryTimeout = null;
}
}
_emitXhrError(xhr, err, causingErr) {
this._emitError(new DetailedError(err, causingErr, xhr));
}
_emitError(err) {
if (typeof this.options.onError === "function") {
this.options.onError(err);
} else {
throw err;
}
}
_emitSuccess() {
if (typeof this.options.onSuccess === "function") {
this.options.onSuccess();
}
}
/**
* Publishes notification when data has been sent to the server. This
* 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.
*/
_emitProgress(bytesSent, bytesTotal) {
if (typeof this.options.onProgress === "function") {
this.options.onProgress(bytesSent, bytesTotal);
}
}
/**
* Publishes notification when a chunk of data has been sent to the server
* and accepted by the server.
* @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.
*/
_emitChunkComplete(chunkSize, bytesAccepted, bytesTotal) {
if (typeof this.options.onChunkComplete === "function") {
this.options.onChunkComplete(chunkSize, bytesAccepted, bytesTotal);
}
}
/**
* Set the headers used in the request and the withCredentials property
* as defined in the options
*
* @param {XMLHttpRequest} xhr
*/
_setupXHR(xhr) {
xhr.setRequestHeader("Tus-Resumable", "1.0.0");
let headers = this.options.headers;
for (let name in headers) {
xhr.setRequestHeader(name, headers[name]);
}
xhr.withCredentials = this.options.withCredentials;
}
/**
* Create a new upload using the creation extension by sending a POST
* request to the endpoint. After successful creation the file will be
* uploaded
*
* @api private
*/
_createUpload() {
let xhr = newRequest();
xhr.open("POST", this.options.endpoint, true);
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
this._emitXhrError(xhr, new Error("tus: unexpected response while creating upload"));
return;
}
this.url = resolveUrl(this.options.endpoint, xhr.getResponseHeader("Location"));
if (this.options.resume) {
Storage.setItem(this._fingerprint, this.url);
}
this._offset = 0;
this._startUpload();
};
xhr.onerror = (err) => {
this._emitXhrError(xhr, new Error("tus: failed to create upload"), err);
};
this._setupXHR(xhr);
xhr.setRequestHeader("Upload-Length", this._size);
// Add metadata if values have been added
var metadata = encodeMetadata(this.options.metadata);
if (metadata !== "") {
xhr.setRequestHeader("Upload-Metadata", metadata);
}
xhr.send(null);
}
/*
* Try to resume an existing upload. First a HEAD request will be sent
* to retrieve the offset. If the request fails a new upload will be
* created. In the case of a successful response the file will be uploaded.
*
* @api private
*/
_resumeUpload() {
let xhr = newRequest();
xhr.open("HEAD", this.url, true);
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
if (this.options.resume) {
// Remove stored fingerprint and corresponding endpoint,
// since the file can not be found
Storage.removeItem(this._fingerprint);
}
// Try to create a new upload
this.url = null;
this._createUpload();
return;
}
let offset = parseInt(xhr.getResponseHeader("Upload-Offset"), 10);
if (isNaN(offset)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing offset value"));
return;
}
let length = parseInt(xhr.getResponseHeader("Upload-Length"), 10);
if (isNaN(length)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing length value"));
return;
}
// Upload has already been completed and we do not need to send additional
// data to the server
if (offset === length) {
this._emitProgress(length, length);
this._emitSuccess();
return;
}
this._offset = offset;
this._startUpload();
};
xhr.onerror = (err) => {
this._emitXhrError(xhr, new Error("tus: failed to resume upload"), err);
};
this._setupXHR(xhr);
xhr.send(null);
}
/**
* Start uploading the file using PATCH requests. The file will be divided
* into chunks as specified in the chunkSize option. During the upload
* the onProgress event handler may be invoked multiple times.
*
* @api private
*/
_startUpload() {
let xhr = this._xhr = newRequest();
// 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.
if (this.options.overridePatchMethod) {
xhr.open("POST", this.url, true);
xhr.setRequestHeader("X-HTTP-Method-Override", "PATCH");
} else {
xhr.open("PATCH", this.url, true);
}
xhr.onload = () => {
if (!(xhr.status >= 200 && xhr.status < 300)) {
this._emitXhrError(xhr, new Error("tus: unexpected response while uploading chunk"));
return;
}
let offset = parseInt(xhr.getResponseHeader("Upload-Offset"), 10);
if (isNaN(offset)) {
this._emitXhrError(xhr, new Error("tus: invalid or missing offset value"));
return;
}
this._emitProgress(offset, this._size);
this._emitChunkComplete(offset - this._offset, offset, this._size);
this._offset = offset;
if (offset == this._size) {
// Yay, finally done :)
this._emitSuccess();
this._source.close();
return;
}
this._startUpload();
};
xhr.onerror = (err) => {
// Don't emit an error if the upload was aborted manually
if (this._aborted) {
return;
}
this._emitXhrError(xhr, new Error("tus: failed to upload chunk at offset " + this._offset), err);
};
// Test support for progress events before attaching an event listener
if ("upload" in xhr) {
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) {
return;
}
this._emitProgress(start + e.loaded, this._size);
};
}
this._setupXHR(xhr);
xhr.setRequestHeader("Upload-Offset", this._offset);
xhr.setRequestHeader("Content-Type", "application/offset+octet-stream");
let start = this._offset;
let end = this._offset + this.options.chunkSize;
// The specified chunkSize may be Infinity or the calcluated end position
// may exceed the file's size. In both cases, we limit the end position to
// the input's total size for simpler calculations and correctness.
if (end === Infinity || end > this._size) {
end = this._size;
}
xhr.send(this._source.slice(start, end));
}
}
function encodeMetadata(metadata) {
if (!Base64.isSupported) {
return "";
}
var encoded = [];
for (var key in metadata) {
encoded.push(key + " " + Base64.encode(metadata[key]));
}
return encoded.join(",");
}
Upload.defaultOptions = defaultOptions;
export default Upload;