UNPKG

file-system-access

Version:

File System Access API implementation (ponyfill) with pluggable storage adapters via IndexedDB, Cache API, in-memory etc.

360 lines (327 loc) 11.8 kB
/* global indexedDB, Blob, File, DOMException */ import { Adapter, FileSystemFileHandleAdapter, FileSystemFolderHandleAdapter, WriteChunk } from '../interfaces.js' import { errors, isChunkObject } from '../util.js' const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX, ABORT } = errors class Sink implements UnderlyingSink<WriteChunk> { private db: IDBDatabase private id: IDBValidKey private size: number private file: File private position = 0 constructor (db: IDBDatabase, id: IDBValidKey, size: number, file: File) { this.db = db this.id = id this.size = size this.file = file } async write (chunk: WriteChunk) { // Ensure file still exists before writing await new Promise<void>((resolve, reject) => { const [tx, table] = store(this.db) table.get(this.id).onsuccess = (evt) => { (evt.target as IDBRequest).result ? table.get(this.id) : reject(new DOMException(...GONE)) } tx.oncomplete = () => resolve() setupTxErrorHandler(tx, reject) }) if (isChunkObject(chunk)) { if (chunk.type === 'write') { if (typeof chunk.position === 'number' && chunk.position >= 0) { if (this.size < chunk.position) { this.file = new File( [this.file, new ArrayBuffer(chunk.position - this.size)], this.file.name, this.file ) } this.position = chunk.position } if (!('data' in chunk)) { throw new DOMException(...SYNTAX('write requires a data argument')) } chunk = chunk.data } else if (chunk.type === 'seek') { if (Number.isInteger(chunk.position) && chunk.position >= 0) { if (this.size < chunk.position) { throw new DOMException(...INVALID) } this.position = chunk.position return } else { throw new DOMException(...SYNTAX('seek requires a position argument')) } } else if (chunk.type === 'truncate') { if (Number.isInteger(chunk.size) && chunk.size >= 0) { let file = this.file file = chunk.size < this.size ? new File([file.slice(0, chunk.size)], file.name, file) : new File([file, new Uint8Array(chunk.size - this.size)], file.name, file) this.size = file.size if (this.position > file.size) { this.position = file.size } this.file = file return } else { throw new DOMException(...SYNTAX('truncate requires a size argument')) } } } chunk = new Blob([chunk]) let blob = this.file // Calc the head and tail fragments const head = blob.slice(0, this.position) const tail = blob.slice(this.position + chunk.size) // Calc the padding let padding = this.position - head.size if (padding < 0) { padding = 0 } blob = new File([ head, new Uint8Array(padding), chunk, tail ], blob.name) this.size = blob.size this.position += chunk.size this.file = blob } close () { return new Promise<void>((resolve, reject) => { const [tx, table] = store(this.db) table.get(this.id).onsuccess = (evt) => { (evt.target as IDBRequest).result ? table.put(this.file, this.id) : reject(new DOMException(...GONE)) } tx.oncomplete = () => resolve() setupTxErrorHandler(tx, reject) }) } } class FileHandle implements FileSystemFileHandleAdapter { readonly kind = 'file' readonly name: string private _db: IDBDatabase private _id: IDBValidKey writable = true readable = true constructor (db: IDBDatabase, id: IDBValidKey, name: string) { this._db = db this._id = id this.name = name } async isSameEntry (other: FileHandle) { return this._id === other._id } async getFile () { const file = await new Promise<File>((resolve, reject) => { const [tx, table] = store(this._db) setupTxErrorHandler(tx, reject) const req = table.get(this._id) req.onsuccess = evt => { tx.oncomplete = () => resolve((evt.target as IDBRequest).result) } req.onerror = evt => reject((evt.target as IDBRequest).error) }) if (!file) throw new DOMException(...GONE) return file } async createWritable (opts: FileSystemCreateWritableOptions) { let file = await this.getFile() if (!opts.keepExistingData) { file = new File([], file.name, file) } return new Sink(this._db, this._id, file.size, file) } } function store (db: IDBDatabase): [IDBTransaction, IDBObjectStore] { // @ts-ignore const tx = db.transaction('entries', 'readwrite', { durability: 'relaxed' }) return [tx, tx.objectStore('entries')] } function setupTxErrorHandler(tx: IDBTransaction, onerror: (e: any) => void) { tx.onerror = (e) => { onerror((e.target as IDBRequest).transaction?.error || (e.target as IDBRequest | IDBTransaction).error) } tx.onabort = (e) => { onerror((e.target as IDBTransaction).error || new DOMException(...ABORT)) } } function rimraf ( store: IDBObjectStore, onNonEmptyDir: () => void, result: Record<string, [id: IDBValidKey, isFile: boolean]>, toDelete?: Record<string, [id: IDBValidKey, isFile: boolean]>, recursive = true ) { for (const [id, isFile] of Object.values(toDelete || result)) { if (isFile) store.delete(id) else if (recursive) { store.get(id).onsuccess = (evt) => rimraf(store, onNonEmptyDir, (evt.target as IDBRequest).result) store.delete(id) } else { store.get(id).onsuccess = (evt) => { if (Object.keys((evt.target as IDBRequest).result).length !== 0) { onNonEmptyDir(); } else { store.delete(id) } } } } } type Entries = Record<string, [id: string, isFile: boolean]> class FolderHandle implements FileSystemFolderHandleAdapter { readonly kind = 'directory' readonly name: string private _db: IDBDatabase private _id: IDBValidKey writable = true readable = true constructor (db: IDBDatabase, id: IDBValidKey, name: string) { this._db = db this._id = id this.name = name } async * entries () { const entries = await new Promise<Entries>((resolve, reject) => { const [tx, table] = store(this._db) setupTxErrorHandler(tx, reject) const getReq = table.get(this._id) getReq.onsuccess = evt => { tx.oncomplete = () => resolve((evt.target as IDBRequest).result) } getReq.onerror = evt => reject((evt.target as IDBRequest).error) }) if (!entries) throw new DOMException(...GONE) for (const [name, [id, isFile]] of Object.entries(entries)) { yield [name, isFile ? new FileHandle(this._db, id, name) : new FolderHandle(this._db, id, name) ] as [string, FileHandle | FolderHandle] } } async isSameEntry (other: FolderHandle) { return this._id === other._id } getDirectoryHandle (name: string, opts: FileSystemGetDirectoryOptions = {}) { return new Promise<FolderHandle>((resolve, reject) => { const [tx, table] = store(this._db) setupTxErrorHandler(tx, reject) const req = table.get(this._id) req.onsuccess = () => { const entries = req.result if (!entries) return reject(new DOMException(...GONE)) const entry = entries[name] if (entry) { if (entry[1]) { // isFile? reject(new DOMException(...MISMATCH)) } else { tx.oncomplete = () => resolve(new FolderHandle(this._db, entry[0], name)) } } else { if (opts.create) { const addReq = table.add({}) addReq.onsuccess = evt => { const id = (evt.target as IDBRequest).result entries[name] = [id, false] const putReq = table.put(entries, this._id) putReq.onsuccess = () => { tx.oncomplete = () => resolve(new FolderHandle(this._db, id, name)) } putReq.onerror = evt => reject((evt.target as IDBRequest).error) } addReq.onerror = evt => reject((evt.target as IDBRequest).error) } else { reject(new DOMException(...GONE)) } } } req.onerror = evt => { reject((evt.target as IDBRequest).error) } }) } getFileHandle (name: string, opts: FileSystemGetFileOptions = {}) { return new Promise<FileHandle>((resolve, reject) => { const [tx, table] = store(this._db) setupTxErrorHandler(tx, reject) const query = table.get(this._id) query.onsuccess = () => { const entries = query.result if (!entries) return reject(new DOMException(...GONE)) const entry = entries[name] if (entry && entry[1]) tx.oncomplete = () => resolve(new FileHandle(this._db, entry[0], name)) if (entry && !entry[1]) reject(new DOMException(...MISMATCH)) if (!entry && !opts.create) reject(new DOMException(...GONE)) if (!entry && opts.create) { try { const q = table.put(new File([], name)) q.onsuccess = () => { const id = q.result entries[name] = [id, true] const query = table.put(entries, this._id) query.onsuccess = () => { tx.oncomplete = () => resolve(new FileHandle(this._db, id, name)) } query.onerror = evt => reject((evt.target as IDBRequest).error) } q.onerror = evt => reject((evt.target as IDBRequest).error) } catch (e) { reject(e); } } } query.onerror = evt => reject((evt.target as IDBRequest).error) }) } async removeEntry (name: string, opts: FileSystemRemoveOptions) { return new Promise<void>((resolve, reject) => { let nonEmptyDirFound = false const [tx, table] = store(this._db) const cwdQ = table.get(this._id) cwdQ.onsuccess = (evt) => { const cwd = cwdQ.result if (!cwd) throw new DOMException(...GONE) const toDelete = { _: cwd[name] } if (!toDelete._) { return reject(new DOMException(...GONE)) } delete cwd[name] table.put(cwd, this._id) rimraf( (evt.target as IDBRequest).source as IDBObjectStore, () => { nonEmptyDirFound = true; (evt.target as IDBRequest).transaction!.abort() }, (evt.target as IDBRequest).result, toDelete, !!opts.recursive ) } tx.oncomplete = () => resolve() setupTxErrorHandler(tx, e => reject(nonEmptyDirFound ? new DOMException(...MOD_ERR) : e)); }) } } const adapter: Adapter<void> = () => new Promise((resolve, reject) => { const request = indexedDB.open('fileSystem') request.onupgradeneeded = () => { const db = request.result db.createObjectStore('entries', { autoIncrement: true }).transaction.oncomplete = evt => { db.transaction('entries', 'readwrite').objectStore('entries').add({}) } } request.onsuccess = () => { resolve(new FolderHandle(request.result, 1, '')) } request.onerror = (ev) => { reject((ev.target as IDBOpenDBRequest).error) } }) export default adapter