UNPKG

kura-s3

Version:

The FileSystem API abstraction library, AWS S3 Plugin

583 lines (526 loc) 15.7 kB
import { AWSError } from "aws-sdk"; import S3, { ClientConfiguration, CompletedMultipartUpload, CompleteMultipartUploadRequest, CreateMultipartUploadRequest, DeleteObjectRequest, GetObjectRequest, ListObjectsV2Output, ListObjectsV2Request, UploadPartRequest, } from "aws-sdk/clients/s3"; import { AbstractAccessor, DIR_SEPARATOR, FileSystem, FileSystemObject, getName, INDEX_DIR_PATH, InvalidModificationError, isBlob, normalizePath, NotFoundError, NotReadableError, toArrayBuffer, toBlob, toBuffer, XHR, XHROptions, } from "kura"; import { FileSystemOptions } from "kura/lib/FileSystemOptions"; import { S3FileSystem } from "./S3FileSystem"; import { S3FileSystemOptions } from "./S3FileSystemOption"; import { getKey, getPrefix } from "./S3Util"; interface UrlCache { expirationTime: number; url: string; } const EXPIRES = 60 * 60 * 24 * 7; const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; const isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null; const isReactNative = typeof navigator !== "undefined" && navigator.product === "ReactNative"; const hasBuffer = typeof Buffer === "function"; export class S3Accessor extends AbstractAccessor { private urlCache: { [key: string]: UrlCache } = {}; public filesystem: FileSystem; public name: string; public s3: S3; constructor( private config: ClientConfiguration, private bucket: string, private rootDir: string, private s3Options?: S3FileSystemOptions ) { super(s3Options); if (!s3Options.expires) { s3Options.expires = EXPIRES; } if (!config.httpOptions) { config.httpOptions = {}; } config.maxRetries = 0; if (config.httpOptions.timeout == null) { config.httpOptions.timeout = 1000; config.httpOptions.connectTimeout = 1000; } config.s3ForcePathStyle = true; config.signatureVersion = "v4"; this.s3 = new S3(config); this.filesystem = new S3FileSystem(this); if (!this.rootDir.startsWith(DIR_SEPARATOR)) { this.rootDir = DIR_SEPARATOR + this.rootDir; } this.name = this.bucket + this.rootDir; } public createIndexDir(dirPath: string) { let indexDir = INDEX_DIR_PATH + dirPath; if (!indexDir.endsWith(DIR_SEPARATOR)) { indexDir += DIR_SEPARATOR; } return indexDir; } public async doDelete(fullPath: string, isFile: boolean) { if (!isFile) { return; } const key = this.getKey(fullPath); const params: DeleteObjectRequest = { Bucket: this.bucket, Key: key, }; try { await this.s3.deleteObject(params).promise(); } catch (err) { if (this.isNotFoundError(err)) { return; } throw new InvalidModificationError(this.name, fullPath, err); } } public async doGetObject( fullPath: string, isFile: boolean ): Promise<FileSystemObject> { if (!isFile) { return { name: getName(fullPath), fullPath, lastModified: Date.now() }; } const key = this.getKey(fullPath); if (this.s3Options.getObjectUsingListObject) { const params: ListObjectsV2Request = { Bucket: this.bucket, Prefix: key, }; let data: ListObjectsV2Output; try { data = await this.s3.listObjectsV2(params).promise(); } catch (err) { this.handleNotFoundErrorS3(fullPath, err); throw new NotReadableError(this.name, fullPath, err); } if (data.KeyCount === 0) { throw new NotFoundError(this.name, fullPath); } for (const content of data.Contents) { if (content.Key === key) { return { name: getName(fullPath), fullPath: fullPath, lastModified: content.LastModified.getTime(), size: content.Size, }; } } throw new NotFoundError(this.name, fullPath); } else { try { const data = await this.s3 .headObject({ Bucket: this.bucket, Key: key, }) .promise(); const name = key.split(DIR_SEPARATOR).pop(); return { name, fullPath: fullPath, lastModified: data.LastModified.getTime(), size: data.ContentLength, }; } catch (err) { this.handleNotFoundErrorS3(fullPath, err); throw new NotReadableError(this.name, fullPath, err); } } } public async doGetObjects(dirPath: string) { const path = normalizePath(this.rootDir + DIR_SEPARATOR + dirPath); const prefix = getPrefix(path); const params: ListObjectsV2Request = { Bucket: this.bucket, Delimiter: DIR_SEPARATOR, Prefix: prefix, ContinuationToken: null, }; const objects: FileSystemObject[] = []; await this.doReadObjectsFromS3(params, dirPath, path, objects); return objects; } public async doMakeDirectory(_fullPath: string) { // NOOP } public async doReadContent( fullPath: string ): Promise<Blob | BufferSource | string> { if (this.s3Options.methodOfDoGetContent === "xhr") { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return await this.doReadContentUsingXHR( fullPath, isBrowser ? "blob" : "arraybuffer" ); } else { return await this.doReadContentUsingGetObject(fullPath); } } public async getURL( fullPath: string, method?: "GET" | "POST" | "PUT" | "DELETE" ): Promise<string> { const keysToRemove: string[] = []; const now = Math.trunc(Date.now() / 1000); for (const [key, cache] of Object.entries(this.urlCache)) { if (cache.expirationTime <= now) { keysToRemove.push(key); } } for (const keyToRemove of keysToRemove) { delete this.urlCache[keyToRemove]; } if (!method || method === "GET") { const key = fullPath + "|get"; const cache = this.urlCache[key]; if (cache) { return cache.url; } const url = await this.getSignedUrl(fullPath, "getObject"); this.urlCache[key] = { expirationTime: now + this.s3Options.expires, url, }; return url; } else if (method === "PUT") { const key = fullPath + "|put"; const cache = this.urlCache[key]; if (cache) { return cache.url; } const url = await this.getSignedUrl(fullPath, "putObject"); this.urlCache[key] = { expirationTime: now + this.s3Options.expires, url, }; return url; } else { return null; } } protected doWriteArrayBuffer( fullPath: string, buffer: ArrayBuffer ): Promise<void> { return this.doWriteContentToS3(fullPath, buffer); } protected async doWriteBase64( fullPath: string, base64: string ): Promise<void> { return this.doWriteContentToS3(fullPath, base64); } protected doWriteBlob(fullPath: string, blob: Blob): Promise<void> { return this.doWriteContentToS3(fullPath, blob); } protected async doWriteBuffer( fullPath: string, buffer: Buffer ): Promise<void> { return this.doWriteContentToS3(fullPath, buffer); } protected initialize(options: FileSystemOptions) { this.initializeIndexOptions(options); if (options.contentsCache == null) { options.contentsCache = false; } this.initializeContentsCacheOptions(options); this.debug("S3Accessor#initialize", JSON.stringify(options)); } protected initializeIndexOptions(options: FileSystemOptions) { if (!options.index) { return; } if (options.indexOptions == null) { options.indexOptions = {}; } const indexOptions = options.indexOptions; if (indexOptions.noCache == null) { indexOptions.noCache = true; } if (indexOptions.logicalDelete == null) { indexOptions.logicalDelete = false; } } private async doReadContentUsingGetObject(fullPath: string) { try { const key = this.getKey(fullPath); const req: GetObjectRequest = { Bucket: this.bucket, Key: key, }; if (this.s3Options.noCache) { req.ResponseCacheControl = "no-cache"; req.ResponseExpires = new Date(0); } const data = await this.s3.getObject(req).promise(); return this.fromBody(data.Body); } catch (err) { this.handleNotFoundErrorS3(fullPath, err); throw new NotReadableError(this.name, fullPath, err); } } private async doReadContentUsingXHR( fullPath: string, responseType: XMLHttpRequestResponseType ) { const xhrOptions: XHROptions = { timeout: this.config.httpOptions.timeout, }; if (this.s3Options.noCache) { xhrOptions.requestHeaders["Cache-Control"] = "no-cache"; } const xhr = new XHR(this.name, fullPath, xhrOptions); const url = await this.getSignedUrl(fullPath, "getObject"); return xhr.get(url, responseType); } private async doReadObjectsFromS3( params: ListObjectsV2Request, dirPath: string, path: string, objects: FileSystemObject[] ) { let data: ListObjectsV2Output; try { data = await this.s3.listObjectsV2(params).promise(); } catch (err) { this.handleNotFoundErrorS3(dirPath, err); throw new NotReadableError(this.name, dirPath, err); } for (const content of data.CommonPrefixes) { const parts = content.Prefix.split(DIR_SEPARATOR); const name = parts[parts.length - 2]; const fullPath = normalizePath(dirPath + DIR_SEPARATOR + name); objects.push({ name: name, fullPath: fullPath, lastModified: null, size: null, }); } for (const content of data.Contents) { const parts = content.Key.split(DIR_SEPARATOR); const name = parts[parts.length - 1]; const fullPath = normalizePath(dirPath + DIR_SEPARATOR + name); objects.push({ name: name, fullPath: fullPath, lastModified: content.LastModified.getTime(), size: content.Size, }); } if (data.IsTruncated) { params.ContinuationToken = data.NextContinuationToken; await this.doReadObjectsFromS3(params, dirPath, path, objects); } } private async doWriteContentToS3( fullPath: string, content: Blob | BufferSource | string ) { const method = this.s3Options.methodOfDoPutContent; if (typeof content === "string") { if (hasBuffer) { content = await toBuffer(content); } else { content = await toBlob(content); } } if (method === "uploadPart") { content = await toArrayBuffer(content); await this.doWriteContentUsingUploadPart(fullPath, content); } else if (method === "xhr") { await this.doWriteContentUsingXHR(fullPath, content); } else if (method === "upload") { await this.doWriteContentUsingUpload(fullPath, content); } else { await this.doWriteContentUsingPutObject(fullPath, content); } } private async doWriteContentUsingPutObject( fullPath: string, content: Blob | BufferSource ) { const body = await this.toBody(content); const key = this.getKey(fullPath); const contentLength = isBlob(content) ? content.size : content.byteLength; try { await this.s3 .putObject({ Bucket: this.bucket, Key: key, Body: body, ContentLength: contentLength, }) .promise(); } catch (err) { throw new InvalidModificationError(this.name, fullPath, err); } } private async doWriteContentUsingUpload( fullPath: string, content: Blob | BufferSource ) { const body = await this.toBody(content); const key = this.getKey(fullPath); const contentLength = isBlob(content) ? content.size : content.byteLength; await this.s3 .upload({ Bucket: this.bucket, Key: key, Body: body, ContentLength: contentLength, }) .promise(); } private async doWriteContentUsingUploadPart( fullPath: string, content: Blob | ArrayBuffer ) { const key = this.getKey(fullPath); const buffer = await toArrayBuffer(content); // TODO const view = new Uint8Array(buffer); const allSize = view.byteLength; const partSize = 1024 * 1024; // 1MB chunk const multipartMap: CompletedMultipartUpload = { Parts: [], }; const createReq: CreateMultipartUploadRequest = { Bucket: this.bucket, Key: key, }; const multiPartUpload = await this.s3 .createMultipartUpload(createReq) .promise(); const uploadId = multiPartUpload.UploadId; let partNum = 0; const { ContentType, ...otherParams } = createReq; for (let rangeStart = 0; rangeStart < allSize; rangeStart += partSize) { partNum++; const end = Math.min(rangeStart + partSize, allSize); const chunk = view.slice(rangeStart, end); const partParams: UploadPartRequest = { Body: chunk, PartNumber: partNum, UploadId: uploadId, ...otherParams, }; const uploadPart = await this.s3.uploadPart(partParams).promise(); multipartMap.Parts[partNum - 1] = { ETag: uploadPart.ETag, PartNumber: partNum, }; } const completeReq: CompleteMultipartUploadRequest = { ...otherParams, MultipartUpload: multipartMap, UploadId: uploadId, }; await this.s3.completeMultipartUpload(completeReq).promise(); } private async doWriteContentUsingXHR( fullPath: string, content: Blob | BufferSource ) { try { const url = await this.getSignedUrl(fullPath, "putObject"); const xhr = new XHR(this.name, fullPath, { timeout: this.config.httpOptions.timeout, }); if (isBlob(content) || ArrayBuffer.isView(content)) { await xhr.put(url, content); } else { const view = new Uint8Array(content); await xhr.put(url, view); } } catch (err) { throw new InvalidModificationError(this.name, fullPath, err); } } private async fromBody(body: any): Promise<BufferSource | Blob | string> { if (isReactNative) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return await toArrayBuffer(body); } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return body; } } private getKey(fullPath: string) { const path = normalizePath(this.rootDir + DIR_SEPARATOR + fullPath); const key = getKey(path); return key; } private async getSignedUrl( fullPath: string, operation: "getObject" | "putObject" ) { const key = this.getKey(fullPath); const url = await this.s3.getSignedUrlPromise(operation, { Bucket: this.bucket, Key: key, Expires: this.s3Options.expires, }); return url; } private handleNotFoundErrorS3(fullPath: string, err: any) { if (this.isNotFoundError(err)) { throw new NotFoundError(this.name, fullPath, err); } } private isNotFoundError(err: any) { if (!err) { return false; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const awsError: AWSError = err; if (awsError.statusCode === 404) { return true; } return false; } private async toBody(content: Blob | BufferSource) { if (isNode) { return toBuffer(content); } if (isReactNative) { if (hasBuffer) { return toBuffer(content); } else { return toArrayBuffer(content); } } return toBlob(content); } }