UNPKG

file-box

Version:

Pack a File into Box for easy move/transfer between servers no matter of where it is.(local path, remote url, or cloud storage)

765 lines (764 loc) 23.7 kB
/** * File Box * https://github.com/huan/file-box * * 2018 Huan LI <zixia@zixia.net> */ /* eslint no-use-before-define: off */ import * as FS from 'fs'; import * as PATH from 'path'; import * as URL from 'url'; import mime from 'mime'; import { PassThrough, } from 'stream'; import { instanceToClass, looseInstanceOfClass, interfaceOfClass, } from 'clone-class'; import { VERSION, } from './config.js'; import { FileBoxType, } from './file-box.type.js'; import { dataUrlToBase64, httpHeaderToFileName, httpHeadHeader, httpStream, streamToBuffer, } from './misc.js'; import { bufferToQrValue, qrValueToStream, } from './qrcode.js'; import { sizedChunkTransformer, } from './pure-functions/sized-chunk-transformer.js'; const EMPTY_META_DATA = Object.freeze({}); const UNKNOWN_SIZE = -1; let interfaceOfFileBox = (_) => false; let looseInstanceOfFileBox = (_) => false; class FileBox { /** * * Static Properties * */ static version = VERSION; /** * Symbol.hasInstance: instanceof * * @link https://www.keithcirkel.co.uk/metaprogramming-in-es6-symbols/ */ static [Symbol.hasInstance](lho) { return this.validInterface(lho); } /** * Check if obj satisfy FileBox interface */ static valid(target) { return this.validInstance(target) || this.validInterface(target); } /** * Check if obj satisfy FileBox interface */ static validInterface(target) { return interfaceOfFileBox(target); } /** * loose check instance of FileBox */ static validInstance(target) { return looseInstanceOfFileBox(target); } /** * fromUrl() */ static fromUrl(url, nameOrOptions, headers) { let name; let size; if (typeof nameOrOptions === 'object') { headers = nameOrOptions.headers; name = nameOrOptions.name; size = nameOrOptions.size; } else { name = nameOrOptions; } if (!name) { const parsedUrl = new URL.URL(url); name = parsedUrl.pathname; } const options = { headers, name, size, type: FileBoxType.Url, url, }; return new this(options); } /** * Alias for `FileBox.fromFile()` * * @alias fromFile */ static fromFile(path, name) { if (!name) { name = PATH.parse(path).base; } const options = { name, path, type: FileBoxType.File, }; return new this(options); } /** * TODO: add `FileBoxStreamOptions` with `size` support (@huan, 202111) */ static fromStream(stream, name) { const options = { name: name || 'stream.dat', stream, type: FileBoxType.Stream, }; return new this(options); } static fromBuffer(buffer, name) { const options = { buffer, name: name || 'buffer.dat', type: FileBoxType.Buffer, }; return new this(options); } /** * @param base64 * @param name the file name of the base64 data */ static fromBase64(base64, name) { const options = { base64, name: name || 'base64.dat', type: FileBoxType.Base64, }; return new this(options); } /** * dataURL: `data:image/png;base64,${base64Text}`, */ static fromDataURL(dataUrl, name) { return this.fromBase64(dataUrlToBase64(dataUrl), name || 'data-url.dat'); } /** * * @param qrCode the value of the QR Code. For example: `https://github.com` */ static fromQRCode(qrCode) { const options = { name: 'qrcode.png', qrCode, type: FileBoxType.QRCode, }; return new this(options); } static uuidToStream; static uuidFromStream; /** * @param uuid the UUID of the file. For example: `6f88b03c-1237-4f46-8db2-98ef23200551` * @param name the name of the file. For example: `video.mp4` */ static fromUuid(uuid, nameOrOptions) { let name; let size; if (typeof nameOrOptions === 'object') { name = nameOrOptions.name; size = nameOrOptions.size; } else { name = nameOrOptions; } const options = { name: name || `${uuid}.dat`, size, type: FileBoxType.Uuid, uuid, }; return new this(options); } /** * @deprecated use `setUuidLoader()` instead */ static setUuidResolver(loader) { console.error('FileBox.sxetUuidResolver() is deprecated. Use `setUuidLoader()` instead.\n', new Error().stack); return this.setUuidLoader(loader); } static setUuidLoader(loader) { if (Object.prototype.hasOwnProperty.call(this, 'uuidToStream')) { throw new Error('this FileBox has been set resolver before, can not set twice'); } this.uuidToStream = loader; } /** * @deprecated use `setUuidSaver()` instead */ static setUuidRegister() { console.error('FileBox.setUuidRegister() is deprecated. Use `setUuidSaver()` instead.\n', new Error().stack); } static setUuidSaver(saver) { if (Object.prototype.hasOwnProperty.call(this, 'uuidFromStream')) { throw new Error('this FileBox has been set register before, can not set twice'); } this.uuidFromStream = saver; } /** * * @static * @param {(FileBoxJsonObject | string)} obj * @returns {FileBox} */ static fromJSON(obj) { if (typeof obj === 'string') { obj = JSON.parse(obj); } /** * Huan(202111): compatible with old FileBox.toJSON() key: `boxType` * this is a breaking change made by v1.0 * * convert `obj.boxType` to `obj.type` * (will be removed after Dec 31, 2022) */ if (!obj.type && 'boxType' in obj) { obj.type = obj['boxType']; } let fileBox; switch (obj.type) { case FileBoxType.Base64: fileBox = FileBox.fromBase64(obj.base64, obj.name); break; case FileBoxType.Url: fileBox = FileBox.fromUrl(obj.url, { name: obj.name, size: obj.size, }); break; case FileBoxType.QRCode: fileBox = FileBox.fromQRCode(obj.qrCode); break; case FileBoxType.Uuid: fileBox = FileBox.fromUuid(obj.uuid, { name: obj.name, size: obj.size, }); break; default: throw new Error(`unknown filebox json object{type}: ${JSON.stringify(obj)}`); } if (obj.metadata) { fileBox.metadata = obj.metadata; } return fileBox; } /** * * Instance Properties * */ version = VERSION; /** * We are using a getter for `type` is because * getter name can be enumurated by the `Object.hasOwnProperties()`* * but property name can not. * * * required by `validInterface()` */ _type; get type() { return this._type; } /** * the Content-Length of the file * `SIZE_UNKNOWN(-1)` means unknown * * @example * ```ts * const fileBox = FileBox.fromUrl('http://example.com/image.png') * await fileBox.ready() * console.log(fileBox.size) * // > 102400 <- this is the size of the remote image.png * ``` */ _size; get size() { if (this._size) { return this._size; } return UNKNOWN_SIZE; } /** * /** * @deprecated: use `mediaType` instead. will be removed after Dec 31, 2022 */ mimeType = 'application/unknown'; /** * (Internet) Media Type is the proper technical term of `MIME Type` * @see https://stackoverflow.com/a/9277778/1123955 * * @example 'text/plain' */ _mediaType; get mediaType() { if (this._mediaType) { return this._mediaType; } return 'application/unknown'; } _name; get name() { return this._name; } _metadata; get metadata() { if (this._metadata) { return this._metadata; } return EMPTY_META_DATA; } set metadata(data) { if (this._metadata) { throw new Error('metadata can not be modified after set'); } this._metadata = { ...data }; Object.freeze(this._metadata); } /** * Lazy load data: (can be serialized to JSON) * Do not read file to Buffer until there's a consumer. */ base64; remoteUrl; qrCode; uuid; /** * Can not be serialized to JSON */ buffer; localPath; stream; headers; constructor(options) { // Only keep `basename` in this.name this._name = PATH.basename(options.name); this._type = options.type; /** * Unknown file type MIME: `'application/unknown'` * @see https://stackoverflow.com/a/6080707/1123955 */ this._mediaType = mime.getType(this.name) ?? undefined; switch (options.type) { case FileBoxType.Buffer: this.buffer = options.buffer; this._size = options.buffer.length; break; case FileBoxType.File: if (!options.path) { throw new Error('no path'); } this.localPath = options.path; this._size = FS.statSync(this.localPath).size; break; case FileBoxType.Url: if (!options.url) { throw new Error('no url'); } this.remoteUrl = options.url; if (options.headers) { this.headers = options.headers; } if (options.size) { this._size = options.size; } else { /** * Add a background task to fetch remote file name & size * * TODO: how to improve it? */ // this.syncUrlMetadata().catch(console.error) } break; case FileBoxType.Stream: this.stream = options.stream; if (options.size) { this._size = options.size; } break; case FileBoxType.QRCode: if (!options.qrCode) { throw new Error('no QR Code'); } this.qrCode = options.qrCode; break; case FileBoxType.Base64: if (!options.base64) { throw new Error('no Base64 data'); } this.base64 = options.base64; this._size = Buffer.byteLength(options.base64, 'base64'); break; case FileBoxType.Uuid: if (!options.uuid) { throw new Error('no UUID data'); } this.uuid = options.uuid; if (options.size) { this._size = options.size; } break; default: throw new Error(`unknown options(type): ${JSON.stringify(options)}`); } } async ready() { switch (this.type) { case FileBoxType.Url: await this._syncUrlMetadata(); break; case FileBoxType.QRCode: if (this.size === UNKNOWN_SIZE) { this._size = (await this.toBuffer()).length; } break; default: break; } } /** * @todo use http.get/gets instead of Request */ async _syncUrlMetadata() { /** * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition * > Content-Disposition: attachment; filename="cool.html" */ if (this.type !== FileBoxType.Url) { throw new Error('type is not Url'); } if (!this.remoteUrl) { throw new Error('no url'); } const headers = await httpHeadHeader(this.remoteUrl); const httpFilename = httpHeaderToFileName(headers); if (httpFilename) { this._name = httpFilename; } if (!this.name) { throw new Error('NONAME'); } const httpMediaType = headers['content-type'] || (httpFilename && mime.getType(httpFilename)); if (httpMediaType) { this._mediaType = httpMediaType; } if (headers['content-length']) { this._size = Number(headers['content-length']); } } /** * * toXXX methods * */ toString() { return [ 'FileBox#', FileBoxType[this.type], '<', this.name, '>', ].join(''); } toJSON() { const objCommon = { metadata: this.metadata, name: this.name, }; if (typeof this.size !== 'undefined') { objCommon.size = this.size; } let obj; switch (this.type) { case FileBoxType.Url: { if (!this.remoteUrl) { throw new Error('no url'); } const objUrl = { headers: this.headers, type: FileBoxType.Url, url: this.remoteUrl, }; obj = { ...objCommon, ...objUrl, }; break; } case FileBoxType.QRCode: { if (!this.qrCode) { throw new Error('no qr code'); } const objQRCode = { qrCode: this.qrCode, type: FileBoxType.QRCode, }; obj = { ...objCommon, ...objQRCode, }; break; } case FileBoxType.Base64: { if (!this.base64) { throw new Error('no base64 data'); } const objBase64 = { base64: this.base64, type: FileBoxType.Base64, }; obj = { ...objCommon, ...objBase64, }; break; } case FileBoxType.Uuid: { if (!this.uuid) { throw new Error('no uuid data'); } const objUuid = { type: FileBoxType.Uuid, uuid: this.uuid, }; obj = { ...objCommon, ...objUuid, }; break; } default: void this.type; throw new Error('FileBox.toJSON() can only work on limited FileBoxType(s). See: <https://github.com/huan/file-box/issues/25>'); } /** * Huan(202111): compatible with old FileBox.toJSON() key: `boxType` * this is a breaking change made by v1.0 * * save `obj.type` a copy to `obj.boxType` * (will be removed after Dec 31, 2022) */ obj['boxType'] = obj.type; return obj; } async toStream() { let stream; switch (this.type) { case FileBoxType.Buffer: stream = this._transformBufferToStream(); break; case FileBoxType.File: stream = this._transformFileToStream(); break; case FileBoxType.Url: stream = await this._transformUrlToStream(); break; case FileBoxType.Stream: if (!this.stream) { throw new Error('no stream'); } /** * Huan(202109): the stream.destroyed will not be `true` * when we have read all the data * after we change some code. * The reason is unbase64 : this.base64, type : FileBoxType.Base64,known... so we change to check `readable` */ if (!this.stream.readable) { throw new Error('The stream is not readable. Maybe has already been consumed, and now it was drained. See: https://github.com/huan/file-box/issues/50'); } stream = this.stream; break; case FileBoxType.QRCode: if (!this.qrCode) { throw new Error('no QR Code'); } stream = await this._transformQRCodeToStream(); break; case FileBoxType.Base64: if (!this.base64) { throw new Error('no base64 data'); } stream = this._transformBase64ToStream(); break; case FileBoxType.Uuid: { if (!this.uuid) { throw new Error('no uuid data'); } const FileBoxKlass = instanceToClass(this, FileBox); if (typeof FileBoxKlass.uuidToStream !== 'function') { throw new Error('need to call FileBox.setUuidLoader() to set UUID loader first.'); } stream = await FileBoxKlass.uuidToStream.call(this, this.uuid); break; } default: throw new Error('not supported FileBoxType: ' + FileBoxType[this.type]); } return stream; } /** * https://stackoverflow.com/a/16044400/1123955 */ _transformBufferToStream(buffer) { const bufferStream = new PassThrough(); bufferStream.end(buffer || this.buffer); /** * Use small `chunks` with `toStream()` #44 * https://github.com/huan/file-box/issues/44 */ return bufferStream.pipe(sizedChunkTransformer()); } _transformBase64ToStream() { if (!this.base64) { throw new Error('no base64 data'); } const buffer = Buffer.from(this.base64, 'base64'); return this._transformBufferToStream(buffer); } _transformFileToStream() { if (!this.localPath) { throw new Error('no url(path)'); } return FS.createReadStream(this.localPath); } async _transformUrlToStream() { return new Promise((resolve, reject) => { if (this.remoteUrl) { httpStream(this.remoteUrl, this.headers) .then(resolve) .catch(reject); } else { reject(new Error('no url')); } }); } async _transformQRCodeToStream() { if (!this.qrCode) { throw new Error('no QR Code Value found'); } const stream = qrValueToStream(this.qrCode); return stream; } /** * save file * * @param filePath save file */ async toFile(filePath, overwrite = false) { if (this.type === FileBoxType.Url) { if (!this.mediaType || !this.name) { await this._syncUrlMetadata(); } } const fullFilePath = PATH.resolve(filePath || this.name); const exist = FS.existsSync(fullFilePath); if (exist && !overwrite) { throw new Error(`FileBox.toFile(${fullFilePath}): file exist. use FileBox.toFile(${fullFilePath}, true) to force overwrite.`); } const writeStream = FS.createWriteStream(fullFilePath); /** * Huan(202109): make sure the file can be opened for writting * before we pipe the stream to it */ await new Promise((resolve, reject) => writeStream .once('open', resolve) .once('error', reject)); /** * Start pipe */ await new Promise((resolve, reject) => { writeStream .once('close', resolve) .once('error', reject); this.pipe(writeStream); }); } async toBase64() { if (this.type === FileBoxType.Base64) { if (!this.base64) { throw new Error('no base64 data'); } return this.base64; } const buffer = await this.toBuffer(); return buffer.toString('base64'); } /** * dataUrl: `data:image/png;base64,${base64Text}', */ async toDataURL() { const base64Text = await this.toBase64(); if (!this.mediaType) { throw new Error('no mediaType found'); } const dataUrl = [ 'data:', this.mediaType, ';base64,', base64Text, ].join(''); return dataUrl; } async toBuffer() { if (this.type === FileBoxType.Buffer) { if (!this.buffer) { throw new Error('no buffer!'); } return this.buffer; } const stream = new PassThrough(); this.pipe(stream); const buffer = await streamToBuffer(stream); return buffer; } async toQRCode() { if (this.type === FileBoxType.QRCode) { if (!this.qrCode) { throw new Error('no QR Code!'); } return this.qrCode; } const buf = await this.toBuffer(); const qrValue = await bufferToQrValue(buf); return qrValue; } async toUuid() { if (this.type === FileBoxType.Uuid) { if (!this.uuid) { throw new Error('no uuid found for a UUID type file box!'); } return this.uuid; } const FileBoxKlass = instanceToClass(this, FileBox); if (typeof FileBoxKlass.uuidFromStream !== 'function') { throw new Error('need to use FileBox.setUuidSaver() before dealing with UUID'); } const stream = new PassThrough(); this.pipe(stream); return FileBoxKlass.uuidFromStream.call(this, stream); } /** * * toXXX methods END * */ pipe(destination) { this.toStream().then(stream => { stream.on('error', e => { console.info('error:', e); destination.emit('error', e); }); return stream.pipe(destination); }).catch(e => destination.emit('error', e)); return destination; } } /** * Huan(202110): lazy initialize `interfaceOfClass(FileBox)` * because we only can reference a class after its declaration */ interfaceOfFileBox = interfaceOfClass(FileBox)(); looseInstanceOfFileBox = looseInstanceOfClass(FileBox); export { FileBox, }; //# sourceMappingURL=file-box.js.map