Skip to content

Commit ba2e8cf

Browse files
storage: support resumable uploads
1 parent 1cc2031 commit ba2e8cf

File tree

3 files changed

+190
-17
lines changed

3 files changed

+190
-17
lines changed

lib/common/util.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ function ApiError(errorBody) {
147147
this.errors = errorBody.errors;
148148
this.code = errorBody.code;
149149
this.message = errorBody.message;
150+
this.response = errorBody.response;
150151
}
151152

152153
util.inherits(ApiError, Error);
@@ -180,7 +181,8 @@ function handleResp(err, resp, body, callback) {
180181
callback(new ApiError({
181182
errors: [],
182183
code: resp.statusCode,
183-
message: body || 'Error during request.'
184+
message: body || 'Error during request.',
185+
response: resp
184186
}));
185187
return;
186188
}

lib/storage/file.js

Lines changed: 186 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020

2121
'use strict';
2222

23+
var bufferEqual = require('buffer-equal');
24+
var ConfigStore = require('configstore');
2325
var crypto = require('crypto');
2426
var duplexify = require('duplexify');
2527
var request = require('request');
2628
var streamEvents = require('stream-events');
29+
var through = require('through2');
2730

2831
/**
2932
* @type module:common/util
@@ -264,28 +267,195 @@ File.prototype.createReadStream = function() {
264267
*/
265268
File.prototype.createWriteStream = function(metadata) {
266269
var that = this;
270+
271+
var bufferStream = through();
272+
var configStore = new ConfigStore('gcloud-node');
267273
var dup = streamEvents(duplexify());
274+
var makeAuthorizedRequest = that.bucket.storage.makeAuthorizedRequest_;
275+
var request = require('request');
276+
var resumableUri;
277+
var retries = 0;
278+
279+
var RETRY_LIMIT = 3;
280+
281+
metadata = metadata || {};
268282

269283
dup.once('writing', function() {
270-
util.makeWritableStream(dup, {
271-
makeAuthorizedRequest: that.bucket.storage.makeAuthorizedRequest_,
272-
metadata: metadata,
273-
request: {
274-
qs: {
275-
name: that.name
276-
},
277-
uri: util.format('{base}/{bucket}/o', {
278-
base: STORAGE_UPLOAD_BASE_URL,
279-
bucket: that.bucket.name
280-
})
284+
var config = configStore.get(that.name);
285+
286+
if (config) {
287+
resumeUpload(config.uri);
288+
} else {
289+
startUpload();
290+
}
291+
});
292+
293+
function startUpload() {
294+
var headers = {};
295+
296+
if (metadata.contentType) {
297+
headers['X-Upload-Content-Type'] = metadata.contentType;
298+
}
299+
300+
makeAuthorizedRequest({
301+
method: 'POST',
302+
uri: util.format('{base}/{bucket}/o', {
303+
base: STORAGE_UPLOAD_BASE_URL,
304+
bucket: that.bucket.name
305+
}),
306+
qs: {
307+
name: that.name,
308+
uploadType: 'resumable'
309+
},
310+
headers: headers,
311+
json: metadata
312+
}, function(err, res, body) {
313+
if (err) {
314+
dup.emit('error', err);
315+
dup.end();
316+
return;
281317
}
282-
}, function(data) {
283-
that.metadata = data;
284318

285-
dup.emit('complete', data);
286-
dup.end();
319+
resumableUri = body.headers.location;
320+
configStore.set(that.name, {
321+
uri: resumableUri
322+
});
323+
resumeUpload(resumableUri, -1);
287324
});
288-
});
325+
}
326+
327+
function resumeUpload(uri, lastByteWritten) {
328+
if (util.is(lastByteWritten, 'number')) {
329+
prepareUpload(lastByteWritten);
330+
} else {
331+
getLastByteWritten(uri, prepareUpload);
332+
}
333+
334+
function prepareUpload(lastByteWritten) {
335+
makeAuthorizedRequest({
336+
method: 'PUT',
337+
uri: uri
338+
}, {
339+
onAuthorized: function (err, reqOpts) {
340+
if (err) {
341+
if (err.code === 404) {
342+
startUpload();
343+
return;
344+
}
345+
346+
if (err.code > 499 && err.code < 600 && retries <= RETRY_LIMIT) {
347+
retries++;
348+
prepareUpload(lastByteWritten);
349+
return;
350+
}
351+
352+
dup.emit('error', err);
353+
dup.end();
354+
return;
355+
}
356+
357+
sendFile(reqOpts, lastByteWritten);
358+
}
359+
});
360+
}
361+
}
362+
363+
function sendFile(reqOpts, lastByteWritten) {
364+
var startByte = lastByteWritten + 1;
365+
reqOpts.headers['Content-Range'] = 'bytes ' + startByte + '-*/*';
366+
367+
var bytesWritten = 0;
368+
var limitStream = through(function(chunk, enc, next) {
369+
// Determine if this is the same content uploaded previously.
370+
if (bytesWritten === 0) {
371+
var cachedFirstChunk = configStore.get(that.name).firstChunk;
372+
var firstChunk = chunk.slice(0, 16);
373+
374+
if (!cachedFirstChunk) {
375+
configStore.set(that.name, {
376+
uri: reqOpts.uri,
377+
firstChunk: firstChunk
378+
});
379+
} else {
380+
cachedFirstChunk = new Buffer(cachedFirstChunk);
381+
firstChunk = new Buffer(firstChunk);
382+
383+
if (!bufferEqual(cachedFirstChunk, firstChunk)) {
384+
// Different content. Start a new upload.
385+
bufferStream.unshift(chunk);
386+
bufferStream.unpipe(this);
387+
configStore.del(that.name);
388+
startUpload();
389+
return;
390+
}
391+
}
392+
}
393+
394+
var length = chunk.length;
395+
396+
if (util.is(chunk, 'string')) {
397+
length = Buffer.byteLength(chunk.length, enc);
398+
}
399+
400+
if (bytesWritten < lastByteWritten) {
401+
chunk = chunk.slice(bytesWritten - length);
402+
}
403+
404+
bytesWritten += length;
405+
406+
if (bytesWritten >= lastByteWritten) {
407+
this.push(chunk);
408+
}
409+
410+
next();
411+
});
412+
413+
bufferStream.pipe(limitStream).pipe(getStream(reqOpts));
414+
dup.setWritable(bufferStream);
415+
416+
function getStream(reqOpts) {
417+
var stream = request(reqOpts);
418+
stream.callback = util.noop;
419+
420+
stream.on('complete', function(res) {
421+
util.handleResp(null, res, res.body, function(err, data) {
422+
if (err) {
423+
dup.emit('error', err);
424+
dup.end();
425+
return;
426+
}
427+
428+
that.metadata = data;
429+
dup.emit('complete', that.metadata);
430+
431+
configStore.del(that.name);
432+
});
433+
});
434+
435+
return stream;
436+
}
437+
}
438+
439+
// If an upload to this file has previously started, this will return the last
440+
// byte written to it.
441+
function getLastByteWritten(uri, callback) {
442+
makeAuthorizedRequest({
443+
method: 'PUT',
444+
uri: uri,
445+
headers: {
446+
'Content-Length': 0,
447+
'Content-Range': 'bytes */*'
448+
}
449+
}, function(err) {
450+
if (err && err.code === 308) {
451+
// headers.range format: ##-## (e.g. 0-4915200)
452+
callback(parseInt(err.response.headers.range.split('-')[1]));
453+
return;
454+
}
455+
456+
callback(-1);
457+
});
458+
}
289459

290460
return dup;
291461
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"google storage"
4545
],
4646
"dependencies": {
47+
"buffer-equal": "0.0.1",
4748
"duplexify": "^3.1.2",
4849
"extend": "^1.3.0",
4950
"google-service-account": "^1.0.0",

0 commit comments

Comments
 (0)