UNPKG

@flystorage/file-storage

Version:

File-storage abstraction: multiple filesystems, one API.

389 lines (388 loc) 16.1 kB
import { Readable } from 'stream'; import { checksumFromStream } from './checksum-from-stream.js'; import { PathNormalizerV1 } from './path-normalizer.js'; import { TextEncoder } from 'util'; import { ChecksumIsNotAvailable, errorToMessage, isFileWasNotFound, UnableToCheckDirectoryExistence, UnableToCheckFileExistence, UnableToCopyFile, UnableToCreateDirectory, UnableToDeleteDirectory, UnableToDeleteFile, UnableToGetChecksum, UnableToGetFileSize, UnableToGetLastModified, UnableToGetMimeType, UnableToGetPublicUrl, UnableToGetStat, UnableToGetTemporaryUrl, UnableToGetVisibility, UnableToListDirectory, UnableToMoveFile, UnableToPrepareUploadRequest, UnableToReadFile, UnableToSetVisibility, UnableToWriteFile, } from './errors.js'; import { PassThrough } from 'node:stream'; export function isFile(stat) { return stat.isFile; } export function isDirectory(stat) { return stat.isDirectory; } export class DirectoryListing { listing; path; deep; constructor(listing, path, deep) { this.listing = listing; this.path = path; this.deep = deep; } async toArray(sorted = true) { const items = []; for await (const item of this.listing) { items.push(item); } return sorted ? items.sort((a, b) => naturalSorting.compare(a.path, b.path)) : items; } filter(filter) { const listing = this.listing; const filtered = (async function* () { for await (const entry of listing) { if (filter(entry)) { yield entry; } } })(); return new DirectoryListing(filtered, this.path, this.deep); } async *[Symbol.asyncIterator]() { try { for await (const item of this.listing) { yield item; } } catch (error) { throw UnableToListDirectory.because(errorToMessage(error), { cause: error, context: { path: this.path, deep: this.deep } }); } } } export function toReadable(contents) { if (contents instanceof Readable) { return contents; } return Readable.from(contents); } const naturalSorting = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', }); function instrumentAbortSignal(options) { let abortSignal = options.abortSignal; if (options.timeout !== undefined) { const timeoutAbort = AbortSignal.timeout(options.timeout); if (options.abortSignal) { const originalAbortSignal = options.abortSignal; abortSignal = AbortSignal.any([ originalAbortSignal, timeoutAbort, ]); } else { abortSignal = timeoutAbort; } } if (abortSignal?.aborted) { throw abortSignal.reason; } return { ...options, abortSignal }; } export class FileStorage { adapter; pathNormalizer; options; constructor(adapter, pathNormalizer = new PathNormalizerV1(), options = {}) { this.adapter = adapter; this.pathNormalizer = pathNormalizer; this.options = options; } async write(path, contents, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.visibility, ...this.options.writes, ...options }); try { const body = toReadable(contents); await this.adapter.write(this.pathNormalizer.normalizePath(path), body, options); await closeReadable(body); } catch (error) { throw UnableToWriteFile.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async read(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { const stream = Readable.from(await this.adapter.read(this.pathNormalizer.normalizePath(path), options)); const streamOut = new PassThrough(); stream.on('error', (error) => { stream.unpipe(streamOut); streamOut.destroy(isFileWasNotFound(error) ? UnableToReadFile.becauseFileWasNotFound(error) : error); }); stream.pipe(streamOut); return streamOut; } catch (error) { if (isFileWasNotFound(error)) { throw UnableToReadFile.becauseFileWasNotFound(error); } throw UnableToReadFile.because(errorToMessage(error), { cause: error, context: { path } }); } } async readToString(path, options = {}) { return await readableToString(await this.read(path, options)); } async readToUint8Array(path, options = {}) { return await readableToUint8Array(await this.read(path, options)); } async readToBuffer(path, options = {}) { return Buffer.from(await this.readToUint8Array(path, options)); } async deleteFile(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { await this.adapter.deleteFile(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToDeleteFile.because(errorToMessage(error), { cause: error, context: { path } }); } } async createDirectory(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.visibility, ...options }); try { return await this.adapter.createDirectory(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToCreateDirectory.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async deleteDirectory(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.deleteDirectory(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToDeleteDirectory.because(errorToMessage(error), { cause: error, context: { path } }); } } async stat(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.stat(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetStat.because(errorToMessage(error), { cause: error, context: { path } }); } } async moveFile(from, to, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.visibility, ...this.options.moves, ...options }); try { await this.adapter.moveFile(this.pathNormalizer.normalizePath(from), this.pathNormalizer.normalizePath(to), options); } catch (error) { throw UnableToMoveFile.because(errorToMessage(error), { cause: error, context: { from, to } }); } } async copyFile(from, to, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.visibility, ...this.options.copies, ...options }); try { await this.adapter.copyFile(this.pathNormalizer.normalizePath(from), this.pathNormalizer.normalizePath(to), options); } catch (error) { throw UnableToCopyFile.because(errorToMessage(error), { cause: error, context: { from, to } }); } } async changeVisibility(path, visibility, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.changeVisibility(this.pathNormalizer.normalizePath(path), visibility, options); } catch (error) { throw UnableToSetVisibility.because(errorToMessage(error), { cause: error, context: { path, visibility } }); } } async visibility(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.visibility(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetVisibility.because(errorToMessage(error), { cause: error, context: { path } }); } } async fileExists(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.fileExists(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToCheckFileExistence.because(errorToMessage(error), { cause: error, context: { path } }); } } list(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); const adapterOptions = { ...options, deep: options.deep ?? false, }; return new DirectoryListing(this.adapter.list(this.pathNormalizer.normalizePath(path), adapterOptions), path, adapterOptions.deep); } async statFile(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); const stat = await this.stat(path, options); if (isFile(stat)) { return stat; } throw UnableToGetStat.noFileStatResolved({ context: { path } }); } async directoryExists(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.directoryExists(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToCheckDirectoryExistence.because(errorToMessage(error), { cause: error, context: { path } }); } } async publicUrl(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.publicUrls, ...options }); try { return await this.adapter.publicUrl(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetPublicUrl.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async temporaryUrl(path, options) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.temporaryUrls, ...options }); try { return await this.adapter.temporaryUrl(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetTemporaryUrl.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async prepareUpload(path, options) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.uploadRequest, ...options }); if (this.options.preparedUploadStrategy !== undefined) { try { return this.options.preparedUploadStrategy.prepareUpload(path, options); } catch (error) { throw UnableToPrepareUploadRequest.because(errorToMessage(error), { cause: error, context: { path, options } }); } } if (typeof this.adapter.prepareUpload !== 'function') { throw new Error('The used adapter does not support prepared uploads.'); } try { return await this.adapter.prepareUpload(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToPrepareUploadRequest.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async checksum(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.checksums, ...options }); try { return await this.adapter.checksum(this.pathNormalizer.normalizePath(path), options); } catch (error) { if (ChecksumIsNotAvailable.isErrorOfType(error)) { return this.calculateChecksum(path, options); } throw UnableToGetChecksum.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async mimeType(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...this.options.mimeTypes, ...options }); try { return await this.adapter.mimeType(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetMimeType.because(errorToMessage(error), { cause: error, context: { path, options } }); } } async lastModified(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.lastModified(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetLastModified.because(errorToMessage(error), { cause: error, context: { path } }); } } async fileSize(path, options = {}) { options = instrumentAbortSignal({ ...this.options.timeout, ...options }); try { return await this.adapter.fileSize(this.pathNormalizer.normalizePath(path), options); } catch (error) { throw UnableToGetFileSize.because(errorToMessage(error), { cause: error, context: { path } }); } } async calculateChecksum(path, options) { try { return await checksumFromStream(await this.read(path, options), options); } catch (error) { throw UnableToGetChecksum.because(errorToMessage(error), { cause: error, context: { path, options } }); } } } export function normalizeExpiryToDate(expiresAt) { return expiresAt instanceof Date ? expiresAt : new Date(expiresAt); } export function normalizeExpiryToMilliseconds(expiresAt) { return expiresAt instanceof Date ? expiresAt.getTime() : expiresAt; } export async function closeReadable(body) { if (body.closed || body.destroyed) { return; } await new Promise((resolve, reject) => { body.on('error', reject); body.on('close', (err) => { err ? reject(err) : resolve(); }); body.destroy(); }); } const decoder = new TextDecoder(); export async function readableToString(stream) { const contents = decoder.decode(await readableToUint8Array(stream)); await closeReadable(stream); return contents; } export async function readableToBuffer(stream) { return new Promise((resolve, reject) => { const buffers = []; stream.on('data', chunk => buffers.push(Buffer.from(chunk))); stream.on('end', () => resolve(Buffer.concat(buffers))); stream.on('finish', () => resolve(Buffer.concat(buffers))); stream.on('error', err => reject(err)); }); } const encoder = new TextEncoder(); export function readableToUint8Array(stream) { return new Promise((resolve, reject) => { const parts = []; stream.on('data', (chunk) => { const type = typeof chunk; if (type === 'string') { chunk = encoder.encode(chunk); } else if (type === 'number') { chunk = new Uint8Array([chunk]); } parts.push(chunk); }); stream.on('error', reject); stream.on('end', () => resolve(concatUint8Arrays(parts))); }); } function concatUint8Arrays(input) { const length = input.reduce((l, a) => l + (a.byteLength), 0); const output = new Uint8Array(length); let position = 0; input.forEach(i => { output.set(i, position); position += i.byteLength; }); return output; } export class PreparedUploadsAreNotSupported { prepareUpload() { throw new Error('The used adapter does not support prepared uploads.'); } }