UNPKG

kura

Version:

The FileSystem API abstraction library.

881 lines (780 loc) 22.9 kB
import { isBlob, isBuffer, toArrayBuffer, toBase64, toBlob, toBuffer, } from "./BinaryConverter"; import { ContentsCache } from "./ContentsCache"; import { AbstractFileError, InvalidModificationError, NoModificationAllowedError, NotFoundError, NotReadableError, } from "./FileError"; import { DataType, FileSystem } from "./filesystem"; import { DIR_SEPARATOR, INDEX_DIR_PATH, INDEX_PREFIX, INDEX_PREFIX_LEN, } from "./FileSystemConstants"; import { FileNameIndex, Record, RecordCache } from "./FileSystemIndex"; import { FileSystemObject } from "./FileSystemObject"; import { FileSystemOptions } from "./FileSystemOptions"; import { getName, getParentPath, isIllegalObject, onError, } from "./FileSystemUtil"; import { objectToText, textToObject } from "./ObjectUtil"; import { textToUint8Array, toText } from "./TextConverter"; export abstract class AbstractAccessor { protected contentsCache: ContentsCache; protected recordCache: RecordCache = {}; public abstract readonly filesystem: FileSystem; public abstract readonly name: string; constructor(public readonly options: FileSystemOptions) { this.initialize(options); } public clearContentsCache(fullPath: string) { if (this.contentsCache == null) { return; } this.contentsCache.remove(fullPath); } public async createIndexPath(fullPath: string, createDirectory: boolean) { const name = getName(fullPath); const parentPath = getParentPath(fullPath); const indexName = INDEX_PREFIX + name; let indexDir = INDEX_DIR_PATH + parentPath; if (!indexDir.endsWith(DIR_SEPARATOR)) { indexDir += DIR_SEPARATOR; } const indexPath = indexDir + indexName; if (!createDirectory) { return indexPath; } await this.makeDirectory(indexDir); return indexPath; } public async createRecord(obj: FileSystemObject) { const fullPath = obj.fullPath; const lastModified = obj.lastModified ?? Date.now(); const size = obj.size; let record: Record; try { record = await this.getRecord(fullPath); if (record.modified === lastModified) { return null; } record.size = size; record.modified = lastModified; delete record.deleted; } catch (e) { if (e instanceof NotFoundError) { record = { modified: lastModified, size }; } else if (e instanceof AbstractFileError) { throw e; } else { throw new NotReadableError(this.name, fullPath, e); } } return record; } public async delete(fullPath: string, isFile: boolean, truncate: boolean) { if (fullPath.startsWith(INDEX_DIR_PATH + "/")) { try { await this.doDelete(fullPath, isFile); } catch (e) { await this.handleWriteError(e, fullPath, isFile); } return; } if (this.options.index) { if (truncate) { await this.truncateRecord(fullPath); } else { await this.deleteRecord(fullPath, isFile); } } if (!this.options.indexOptions?.logicalDelete) { try { await this.doDelete(fullPath, isFile); } catch (e) { await this.handleWriteError(e, fullPath, isFile); } } if (isFile && this.contentsCache) { this.contentsCache.remove(fullPath); } } public async deleteRecord(fullPath: string, _isFile: boolean) { if (!this.options.index) { return; } if (fullPath === INDEX_DIR_PATH) { return; } let record: Record; try { record = await this.getRecord(fullPath); } catch (e) { if (e instanceof NotFoundError) { return; } else if (e instanceof AbstractFileError) { throw e; } throw new NotReadableError(this.name, fullPath, e); } if (record.deleted == null) { record.deleted = Date.now(); } const indexPath = await this.createIndexPath(fullPath, false); await this.doSaveRecord(indexPath, record); const indexObj = await this.doGetObject(indexPath, true); this.recordCache[indexPath] = { record, lastModified: indexObj.lastModified, }; } public async deleteRecursively(fullPath: string, truncate: boolean) { let children: FileSystemObject[]; try { children = await this.doGetObjects(fullPath); } catch (e) { await this.handleReadError(e, fullPath, false); } for (const child of children) { if (child.size == null) { await this.deleteRecursively(child.fullPath, truncate); } else { await this.delete(child.fullPath, true, truncate); } } if ( fullPath !== DIR_SEPARATOR && !(this.options?.index && fullPath === INDEX_DIR_PATH) ) { await this.delete(fullPath, false, truncate); } } public async doPutObject( obj: FileSystemObject, content?: Blob | BufferSource | string ): Promise<FileSystemObject> { const fullPath = obj.fullPath; let record: Record; try { this.debug("putObject", fullPath); if (content == null) { // Directory await this.makeDirectory(obj.fullPath); record = { modified: Date.now() }; } else { // File await this.doWriteContent(fullPath, content); obj = await this.doGetObject(fullPath, true); if (this.contentsCache) { this.contentsCache.put(obj, content); } record = await this.createRecord(obj); } } catch (e) { await this.handleWriteError(e, fullPath, obj.size != null); } if (record) { await this.saveRecord(fullPath, record); } return obj; } public async doWriteContent( fullPath: string, content: Blob | BufferSource | string ) { try { if (typeof content === "string") { await this.doWriteBase64(fullPath, content); } else if (isBlob(content)) { await this.doWriteBlob(fullPath, content); } else if (isBuffer(content)) { await this.doWriteBuffer(fullPath, content); } else if (ArrayBuffer.isView(content)) { await this.doWriteUint8Array(fullPath, content as Uint8Array); } else { await this.doWriteArrayBuffer(fullPath, content); } } catch (e) { await this.handleWriteError(e, fullPath, true); } } public async getFileNameIndex(dirPath: string) { const fileNameIndex: FileNameIndex = {}; if (dirPath === INDEX_DIR_PATH) { return fileNameIndex; } const indexDir = INDEX_DIR_PATH + (dirPath === DIR_SEPARATOR ? "" : dirPath); let objects: FileSystemObject[]; try { objects = await this.doGetObjects(indexDir); } catch (e) { if (e instanceof NotFoundError) { return fileNameIndex; } await this.handleReadError(e, indexDir, false); } if (!dirPath.endsWith("/")) { dirPath += "/"; } for (const obj of objects) { if (obj.size == null) { // folder continue; } let fullPath; try { const name = obj.name.substring(INDEX_PREFIX_LEN); fullPath = dirPath + name; const record = await this.getRecord(fullPath); fileNameIndex[name] = { ...record, fullPath, name }; } catch (e) { console.warn("getFileNameIndex", obj, e); } } return fileNameIndex; } public async getObject(fullPath: string, isFile: boolean) { this.debug("getObject", fullPath); let obj: FileSystemObject; if (this.options.index) { let record: Record; try { record = await this.getRecord(fullPath); } catch (e) { if (e instanceof NotFoundError) { try { obj = await this.doGetObject(fullPath, isFile); } catch (e) { await this.handleReadError(e, fullPath, isFile); } await this.saveRecord(fullPath, { modified: obj.lastModified, size: obj.size, }); } else { throw e; } } if (record?.deleted != null) { throw new NotFoundError(this.name, fullPath, "getObject"); } } if (!obj) { try { obj = await this.doGetObject(fullPath, isFile); } catch (e) { await this.handleReadError(e, fullPath, isFile); } } if (!(await this.beforeHead(obj))) { throw new NotFoundError(this.name, obj.fullPath, "beforeHead"); } this.afterHead(obj); return obj; } public async getObjects(dirPath: string) { try { const index = this.options.index; const objects = await this.doGetObjects(dirPath); if (index) { const newObjects: FileSystemObject[] = []; for (const obj of objects) { if (obj.fullPath === INDEX_DIR_PATH) { continue; } if (this.options.indexOptions.logicalDelete) { try { const record = await this.getRecord(obj.fullPath); if (record.deleted) { continue; } } catch (e) { if (e instanceof NotFoundError) { const record = await this.createRecord(obj); if (record) { await this.saveRecord(obj.fullPath, record); } } else { console.warn("getObjects", obj, e); } continue; } } if (!(await this.beforeHead(obj))) { continue; } this.afterHead(obj); newObjects.push(obj); } return newObjects; } else { return objects; } } catch (e) { await this.handleReadError(e, dirPath, false); } } public async getRecord(fullPath: string) { const indexPath = await this.createIndexPath(fullPath, false); let indexObj: FileSystemObject; try { indexObj = await this.doGetObject(indexPath, true); } catch (e) { if (e instanceof NotFoundError) { delete this.recordCache[fullPath]; } throw e; } const entry = this.recordCache[indexPath]; if (entry && indexObj.lastModified === entry.lastModified) { return entry.record; } const content = await this.doReadContent(indexPath); const text = await toText(content); const record: Record = textToObject(text); this.recordCache[indexPath] = { record, lastModified: indexObj.lastModified, }; return record; } public getURL( _fullPath: string, _method?: "GET" | "POST" | "PUT" | "DELETE" ): Promise<string> { throw new Error("Not implemented"); } public async makeDirectory(fullPath: string) { try { await this.doGetObject(fullPath, false); } catch (e) { if (e instanceof NotFoundError) { await this.doMakeDirectory(fullPath); } else { throw new NotReadableError(this.name, fullPath, e); } } } public async purge() { await this.deleteRecursively(DIR_SEPARATOR, true); this.recordCache = {}; if (this.contentsCache) { this.contentsCache.clear(); } } public async putObject( obj: FileSystemObject, content?: Blob | BufferSource | string ): Promise<FileSystemObject> { if (isIllegalObject(obj, this.options.index)) { const fullPath = obj.fullPath; throw new InvalidModificationError( this.name, fullPath, `illegal object ${fullPath}` ); } const fullPath = obj.fullPath; let create = false; try { obj = await this.doGetObject(fullPath, content != null); } catch (e) { if (e instanceof NotFoundError) { create = true; } else if (e instanceof AbstractFileError) { throw e; } else { throw new NotReadableError(this.name, fullPath, e); } } if (create) { await this.beforePost(obj); } else { await this.beforePut(obj); } obj = await this.doPutObject(obj, content); if (create) { this.afterPost(obj); } else { this.afterPut(obj); } return obj; } public async putText( obj: FileSystemObject, text: string ): Promise<FileSystemObject> { const u8 = textToUint8Array(text); return this.putObject(obj, u8); } public async readContent( obj: FileSystemObject, type?: DataType ): Promise<Blob | BufferSource | string> { if (isIllegalObject(obj, this.options.index)) { const fullPath = obj.fullPath; throw new InvalidModificationError( this.name, fullPath, `illegal object ${fullPath}` ); } const fullPath = obj.fullPath; this.debug("readContent", fullPath); if (!(await this.beforeGet(obj))) { throw new NotFoundError(this.name, obj.fullPath, "beforeGet"); } const content = await this.readContentInternal(obj, type); this.afterGet(obj); return content; } public async readContentInternal( obj: FileSystemObject, type?: DataType ): Promise<Blob | BufferSource | string> { const fullPath = obj.fullPath; let content: string | Blob | BufferSource; if (this.contentsCache) { content = this.contentsCache.get(fullPath); } let read = false; if (!content) { try { content = await this.doReadContent(fullPath); } catch (e) { await this.handleReadError(e, fullPath, true); } read = true; } if (type === "blob") { content = toBlob(content); } else if (type === "buffer") { content = await toBuffer(content); } else if (type === "arraybuffer") { content = await toArrayBuffer(content); } else if (type === "base64") { content = await toBase64(content); } if (this.contentsCache && read) { this.contentsCache.put(obj, content); } return content; } public async readText(obj: FileSystemObject): Promise<string> { const content = await this.readContent(obj); const text = await toText(content); return text; } public async remove(obj: FileSystemObject) { const fullPath = obj.fullPath; if (fullPath === DIR_SEPARATOR) { throw new InvalidModificationError( this.name, fullPath, "cannot remove root dir" ); } const index = this.options.index; if (index && fullPath.startsWith(INDEX_DIR_PATH + "/")) { throw new InvalidModificationError( this.name, fullPath, `cannot remove index dir` ); } const isFile = obj.size != null; if (!isFile) { // Directory try { const objects = await this.getObjects(fullPath); if (0 < objects.length) { throw new InvalidModificationError( this.name, fullPath, `directory is not empty - ${objects .map((obj) => obj.fullPath) .toString()}` ); } } catch (e) { if (e instanceof NotFoundError) { await this.handleNotFoundError(fullPath, false); return; } await this.handleReadError(e, fullPath, false); } } this.debug("remove", fullPath); await this.beforeDelete(obj); await this.delete(fullPath, isFile, false); this.afterDelete(obj); } public async removeRecursively(obj: FileSystemObject) { const fullPath = obj.fullPath; let children: FileSystemObject[]; try { children = await this.getObjects(fullPath); } catch (e) { if (e instanceof NotFoundError) { await this.handleNotFoundError(fullPath, false); return; } await this.handleReadError(e, fullPath, false); } for (const child of children) { if (child.size == null) { await this.removeRecursively(child); } else { await this.remove(child); } } if (fullPath !== DIR_SEPARATOR) { await this.remove(obj); } } public async saveRecord(fullPath: string, record: Record) { if (!this.options.index) { return; } try { const indexPath = await this.createIndexPath(fullPath, true); await this.doSaveRecord(indexPath, record); const indexObj = await this.doGetObject(indexPath, true); this.recordCache[indexPath] = { record, lastModified: indexObj.lastModified, }; return record; } catch (e) { await this.handleWriteError(e, fullPath, true); } } public async truncateRecord(fullPath: string) { if (!this.options.index) { return; } if (fullPath === INDEX_DIR_PATH) { return; } const indexPath = await this.createIndexPath(fullPath, false); try { await this.doDelete(indexPath, true); } catch (e) { onError(e); } delete this.recordCache[indexPath]; } public abstract doDelete(fullPath: string, isFile: boolean): Promise<void>; public abstract doGetObject( fullPath: string, isFile: boolean ): Promise<FileSystemObject>; public abstract doGetObjects(dirPath: string): Promise<FileSystemObject[]>; public abstract doMakeDirectory(fullPath: string): Promise<void>; public abstract doReadContent( fullPath: string ): Promise<Blob | BufferSource | string>; protected debug(title: string, value: string | FileSystemObject) { if (!this.options.verbose) { return; } if (typeof value === "string") { console.debug(`${this.name} - ${title}: fullPath=${value}`); } else { console.debug( `${this.name} - ${title}: fullPath=${value.fullPath}, lastModified=${value.lastModified}, size=${value.size}` ); } } protected async doSaveRecord(indexPath: string, record: Record) { const text = objectToText(record); const u8 = textToUint8Array(text); await this.doWriteContent(indexPath, u8); } protected async doWriteUint8Array( fullPath: string, view: Uint8Array ): Promise<void> { const buffer = await toArrayBuffer(view); await this.doWriteArrayBuffer(fullPath, buffer); } protected async handleNotFoundError(fullPath: string, isFile: boolean) { if (fullPath.startsWith(INDEX_DIR_PATH + "/")) { return; } await this.deleteRecord(fullPath, isFile); if (isFile) { this.clearContentsCache(fullPath); } } protected async handleReadError(e: any, fullPath: string, isFile: boolean) { if (e instanceof NotFoundError) { await this.handleNotFoundError(fullPath, isFile); throw e; } else if (e instanceof AbstractFileError) { throw e; } throw new NotReadableError(this.name, fullPath, e); } protected async handleWriteError(e: any, fullPath: string, isFile: boolean) { if (e instanceof NotFoundError) { await this.handleNotFoundError(fullPath, isFile); throw e; } else if (e instanceof AbstractFileError) { throw e; } throw new InvalidModificationError(this.name, fullPath, e); } protected initialize(options: FileSystemOptions) { this.initializeIndexOptions(options); if (options.contentsCache == null) { options.contentsCache = true; } this.initializeContentsCacheOptions(options); this.debug("AbstractAccessor#initialize", JSON.stringify(options)); } protected initializeContentsCacheOptions(options: FileSystemOptions) { if (!options.contentsCache) { return; } if (options.contentsCacheOptions == null) { options.contentsCacheOptions = {}; } const contentsCacheOptions = options.contentsCacheOptions; if (!(0 < contentsCacheOptions.capacity)) { contentsCacheOptions.capacity = 100 * 1024 * 1024; // 100MB } if (!(0 < contentsCacheOptions.limitSize)) { contentsCacheOptions.limitSize = 256 * 1024; // 256KB; } if (contentsCacheOptions.capacity < contentsCacheOptions.limitSize) { contentsCacheOptions.limitSize = contentsCacheOptions.capacity; } this.contentsCache = new ContentsCache(this); } protected initializeIndexOptions(options: FileSystemOptions) { if (!options.index) { return; } if (options.indexOptions == null) { options.indexOptions = {}; } const indexOptions = options.indexOptions; if (indexOptions.noCache == null) { indexOptions.noCache = false; } if (indexOptions.logicalDelete == null) { indexOptions.logicalDelete = false; } } protected abstract doWriteArrayBuffer( fullPath: string, buffer: ArrayBuffer ): Promise<void>; protected abstract doWriteBase64( fullPath: string, base64: string ): Promise<void>; protected abstract doWriteBlob(fullPath: string, blob: Blob): Promise<void>; protected abstract doWriteBuffer( fullPath: string, buffer: Buffer ): Promise<void>; private afterDelete(obj: FileSystemObject) { if (!this.options.event.postDelete) { return; } this.options.event.postDelete(obj); } private afterGet(obj: FileSystemObject) { if (!this.options.event.postGet) { return; } this.options.event.postHead(obj); } private afterHead(obj: FileSystemObject) { if (!this.options.event.postHead) { return; } this.options.event.postHead(obj); } private afterPost(obj: FileSystemObject) { if (!this.options.event.postPost) { return; } this.options.event.postPost(obj); } private afterPut(obj: FileSystemObject) { if (!this.options.event.postPut) { return; } this.options.event.postPut(obj); } // TODO 親フォルダ private async beforeDelete(obj: FileSystemObject) { if (!this.options.event.preDelete) { return; } const result = await this.options.event.preDelete(obj); if (!result) { throw new NoModificationAllowedError( this.name, obj.fullPath, "beforeDelete" ); } } // TODO 親フォルダ private async beforeGet(obj: FileSystemObject) { if (!this.options.event.preGet) { return true; } return this.options.event.preGet(obj); } private async beforeHead(obj: FileSystemObject) { if (!this.options.event.preHead) { return true; } return this.options.event.preHead(obj); } private async beforePost(obj: FileSystemObject) { if (!this.options.event.prePost) { return; } const result = await this.options.event.prePost(obj); if (!result) { throw new NoModificationAllowedError( this.name, obj.fullPath, "beforePost" ); } } private async beforePut(obj: FileSystemObject) { if (!this.options.event.prePut) { return; } const result = await this.options.event.prePut(obj); if (!result) { throw new NoModificationAllowedError( this.name, obj.fullPath, "beforePut" ); } } }