skipper-better-s3
Version:
A better approach to Amazon S3 file management for Skipper
137 lines (117 loc) • 5.31 kB
JavaScript
/**
* skipper-better-s3
*
* @author Robert Rossmann <rr.rossmann@me.com>
* @copyright 2015 Robert Rossmann
* @license http://choosealicense.com/licenses/bsd-3-clause BSD-3-Clause License
*/
const WritableStream = require('stream').Writable
const path = require('path')
const AWS = require('aws-sdk')
const mime = require('mime')
const merge = require('semantic-merge')
const uuid = require('uuid')
const local = require('local-scope')()
const Hasher = require('./hasher')
module.exports = class Uploader extends WritableStream {
constructor(opts) {
// Initialise this writable stream in object mode
super({ objectMode: true })
const scope = local(this)
// Save configuration options and the S3 client to local scope
scope.opts = opts
scope.client = new AWS.S3(opts.service)
}
_write(inStream, encoding, done) {
const scope = local(this)
// If there is a path property, this is an fs read stream
// If there is no path, but fd, this is a standard http upload stream
// If the `Key` has been provided during upload request via `s3params`, we should use that
// instead of `fd`, because this is very likely a standalone upload request, not Skipper
// If even the `Key` property is missing, fall back to a generated name
// (Both path and fd could be missing if we upload i.e. a zlib or crypto stream and the
// originator did not read the documentation about having to provide this property 😀)
const name = inStream.path
? path.parse(inStream.path).base
: inStream.fd || scope.opts.request.Key || uuid.v4()
const parts = path.parse(name)
// If we got a dirname in config options, and it is not already in the fd, add it
// Skipper adds dirname for regular uploads, but for generic streams, we need to take care of
// this ourselves
if (scope.opts.dirname && parts.dir !== scope.opts.dirname) {
parts.dir = scope.opts.dirname
}
// fd is S3-specific, so always treat the parts as unix-style paths (which S3 uses)
const fd = path.posix.format(parts)
const type = mime.getType(name)
const outStream = new Hasher()
const params = merge({ Body: outStream, ContentType: type })
.and(scope.opts.request)
.and({ Key: fd })
.excluding('onProgress')
.into({})
const request = scope.client.upload(params, scope.opts.options, (err, data) => {
// If there has been an error with the upload, return immediately, otherwise the extra
// object used below will be undefined and we will throw a completely unrelated error
if (err) {
return done(err)
}
// Attach the response data to the stream - Skipper packages this in its response to the
// caller for further reuse
inStream.extra = data
// The ETag returned in the response is enclosed in double-quotes - I find this quite
// irritating since I usually only care about the actual value - let's fix that once and
// for all, for everyone!
inStream.extra.ETag = inStream.extra.ETag.replace(/"/g, '')
// Get the md5 hash we computed for this file stream and export it in the extras
inStream.extra.md5 = outStream.md5('hex')
// For non-http uploads, the fd and content-type are not available anywhere, so let's put
// them to the extra as well, for convenience
inStream.extra.fd = fd
inStream.extra.ContentType = type
return done(null, data)
})
// Fix the content type header on the request in case it was incorrectly detected
// Wrong detection can happen when uploading via curl, for example.
// Note that headers will only be provided for http uploads, not for fs read streams etc.
if (inStream.headers instanceof Object) {
inStream.headers['content-type'] = type
}
// Calculate md5 of the data being uploaded
// We use this because the original assumption about the ETag being an md5 hash is only true
// while doing single-part uploads of files smaller than 5GB. If it's larger or if the upload
// is a multipart upload, the ETag is computed differently.
inStream.pipe(outStream)
// Attach progress handler, if provided
if (typeof scope.opts.request.onProgress === 'function') {
this.trackProgress(request, scope.opts.request.onProgress)
}
}
trackProgress(request, handler) {
// If there's no handler provided, do not attach any listeners
if (typeof handler !== 'function') {
return
}
// Generate a per-request unique ID
const id = uuid.v4()
request.on('httpUploadProgress', progress => {
// Prepare Skipper-specific progress structure
const written = progress.loaded
// S3 docs say that progress.total may not always be present, so let's have some fallback
const total = progress.total || request.body.byteCount || null
const data = {
id,
fd: request.body.fd,
name: request.body.name,
written,
total,
// If total is null, performing bitwise-or will change the result to 0
// It also rounds down to an integer, which is kinda nice
// eslint-disable-next-line no-bitwise
percent: written / total * 100 | 0,
}
handler(data)
})
}
}