UNPKG

browserfs

Version:

A filesystem in your browser!

487 lines (451 loc) 17.4 kB
import preload_file = require('../generic/preload_file'); import file_system = require('../core/file_system'); import {ApiError, ErrorCode} from '../core/api_error'; import {FileFlag, ActionType} from '../core/file_flag'; import {default as Stats, FileType} from '../core/node_fs_stats'; import file = require('../core/file'); import path = require('path'); import global = require('../core/global'); import async = require('async'); import {buffer2ArrayBuffer, arrayBuffer2Buffer} from '../core/util'; function isDirectoryEntry(entry: Entry): entry is DirectoryEntry { return entry.isDirectory; } var _getFS: (type:number, size:number, successCallback: FileSystemCallback, errorCallback?: ErrorCallback) => void = global.webkitRequestFileSystem || global.requestFileSystem || null; function _requestQuota(type: number, size: number, success: (size: number) => void, errorCallback: ErrorCallback) { // We cast navigator and window to '<any>' because everything here is // nonstandard functionality, despite the fact that Chrome has the only // implementation of the HTML5FS and is likely driving the standardization // process. Thus, these objects defined off of navigator and window are not // present in the DefinitelyTyped TypeScript typings for FileSystem. if (typeof (<any> navigator)['webkitPersistentStorage'] !== 'undefined') { switch(type) { case global.PERSISTENT: (<any> navigator).webkitPersistentStorage.requestQuota(size, success, errorCallback); break; case global.TEMPORARY: (<any> navigator).webkitTemporaryStorage.requestQuota(size, success, errorCallback); break default: errorCallback(new TypeError(`Invalid storage type: ${type}`)); break; } } else { (<any> global).webkitStorageInfo.requestQuota(type, size, success, errorCallback); } } function _toArray(list?: any[]): any[] { return Array.prototype.slice.call(list || [], 0); } // A note about getFile and getDirectory options: // These methods are called at numerous places in this file, and are passed // some combination of these two options: // - create: If true, the entry will be created if it doesn't exist. // If false, an error will be thrown if it doesn't exist. // - exclusive: If true, only create the entry if it doesn't already exist, // and throw an error if it does. export class HTML5FSFile extends preload_file.PreloadFile<HTML5FS> implements file.File { constructor(_fs: HTML5FS, _path: string, _flag: FileFlag, _stat: Stats, contents?: NodeBuffer) { super(_fs, _path, _flag, _stat, contents); } public sync(cb: (e?: ApiError) => void): void { if (this.isDirty()) { // Don't create the file (it should already have been created by `open`) var opts = { create: false }; var _fs = this._fs; var success: FileEntryCallback = (entry) => { entry.createWriter((writer) => { var buffer = this.getBuffer(); var blob = new Blob([buffer2ArrayBuffer(buffer)]); var length = blob.size; writer.onwriteend = () => { writer.onwriteend = null; writer.truncate(length); this.resetDirty(); cb(); }; writer.onerror = (err: DOMError) => { cb(_fs.convert(err, this.getPath(), false)); }; writer.write(blob); }); }; var error = (err: DOMError) => { cb(_fs.convert(err, this.getPath(), false)); }; _fs.fs.root.getFile(this.getPath(), opts, success, error); } else { cb(); } } public close(cb: (e?: ApiError) => void): void { this.sync(cb); } } export default class HTML5FS extends file_system.BaseFileSystem implements file_system.FileSystem { private size: number; private type: number; // HTML5File reaches into HTML5FS. :/ public fs: FileSystem; /** * Arguments: * - type: PERSISTENT or TEMPORARY * - size: storage quota to request, in megabytes. Allocated value may be less. */ constructor(size: number = 5, type: number = global.PERSISTENT) { super(); // Convert MB to bytes. this.size = 1024 * 1024 * size; this.type = type; } public getName(): string { return 'HTML5 FileSystem'; } public static isAvailable(): boolean { return _getFS != null; } public isReadOnly(): boolean { return false; } public supportsSymlinks(): boolean { return false; } public supportsProps(): boolean { return false; } public supportsSynch(): boolean { return false; } /** * Converts the given DOMError into an appropriate ApiError. * Full list of values here: * https://developer.mozilla.org/en-US/docs/Web/API/DOMError */ public convert(err: DOMError, p: string, expectedDir: boolean): ApiError { switch (err.name) { /* The user agent failed to create a file or directory due to the existence of a file or directory with the same path. */ case "PathExistsError": return ApiError.EEXIST(p); /* The operation failed because it would cause the application to exceed its storage quota. */ case 'QuotaExceededError': return ApiError.FileError(ErrorCode.ENOSPC, p); /* A required file or directory could not be found at the time an operation was processed. */ case 'NotFoundError': return ApiError.ENOENT(p); /* This is a security error code to be used in situations not covered by any other error codes. - A required file was unsafe for access within a Web application - Too many calls are being made on filesystem resources */ case 'SecurityError': return ApiError.FileError(ErrorCode.EACCES, p); /* The modification requested was illegal. Examples of invalid modifications include moving a directory into its own child, moving a file into its parent directory without changing its name, or copying a directory to a path occupied by a file. */ case 'InvalidModificationError': return ApiError.FileError(ErrorCode.EPERM, p); /* The user has attempted to look up a file or directory, but the Entry found is of the wrong type [e.g. is a DirectoryEntry when the user requested a FileEntry]. */ case 'TypeMismatchError': return ApiError.FileError(expectedDir ? ErrorCode.ENOTDIR : ErrorCode.EISDIR, p); /* A path or URL supplied to the API was malformed. */ case "EncodingError": /* An operation depended on state cached in an interface object, but that state that has changed since it was read from disk. */ case "InvalidStateError": /* The user attempted to write to a file or directory which could not be modified due to the state of the underlying filesystem. */ case "NoModificationAllowedError": default: return ApiError.FileError(ErrorCode.EINVAL, p); } } /** * Nonstandard * Requests a storage quota from the browser to back this FS. */ public allocate(cb: (e?: ApiError) => void = function(){}): void { var success = (fs: FileSystem): void => { this.fs = fs; cb() }; var error = (err: DOMException): void => { cb(this.convert(err, "/", true)); }; if (this.type === global.PERSISTENT) { _requestQuota(this.type, this.size, (granted: number) => { _getFS(this.type, granted, success, error); }, error); } else { _getFS(this.type, this.size, success, error); } } /** * Nonstandard * Deletes everything in the FS. Used for testing. * Karma clears the storage after you quit it but not between runs of the test * suite, and the tests expect an empty FS every time. */ public empty(mainCb: (e?: ApiError) => void): void { // Get a list of all entries in the root directory to delete them this._readdir('/', (err: ApiError, entries?: Entry[]): void => { if (err) { console.error('Failed to empty FS'); mainCb(err); } else { // Called when every entry has been operated on var finished = (er: any): void => { if (err) { console.error("Failed to empty FS"); mainCb(err); } else { mainCb(); } }; // Removes files and recursively removes directories var deleteEntry = (entry: Entry, cb: (e?: any) => void): void => { var succ = () => { cb(); }; var error = (err: DOMException) => { cb(this.convert(err, entry.fullPath, !entry.isDirectory)); }; if (isDirectoryEntry(entry)) { entry.removeRecursively(succ, error); } else { entry.remove(succ, error); } }; // Loop through the entries and remove them, then call the callback // when they're all finished. async.each(entries, deleteEntry, finished); } }); } public rename(oldPath: string, newPath: string, cb: (e?: ApiError) => void): void { var semaphore: number = 2, successCount: number = 0, root: DirectoryEntry = this.fs.root, currentPath: string = oldPath, error = (err: DOMException): void => { if (--semaphore <= 0) { cb(this.convert(err, currentPath, false)); } }, success = (file: Entry): void => { if (++successCount === 2) { return cb(new ApiError(ErrorCode.EINVAL, "Something was identified as both a file and a directory. This should never happen.")); } // SPECIAL CASE: If newPath === oldPath, and the path exists, then // this operation trivially succeeds. if (oldPath === newPath) { return cb(); } // Get the new parent directory. currentPath = path.dirname(newPath); root.getDirectory(currentPath, {}, (parentDir: DirectoryEntry): void => { currentPath = path.basename(newPath); file.moveTo(parentDir, currentPath, (entry: Entry): void => { cb(); }, (err: DOMException): void => { // SPECIAL CASE: If oldPath is a directory, and newPath is a // file, rename should delete the file and perform the move. if (file.isDirectory) { currentPath = newPath; // Unlink only works on files. Try to delete newPath. this.unlink(newPath, (e?): void => { if (e) { // newPath is probably a directory. error(err); } else { // Recur, now that newPath doesn't exist. this.rename(oldPath, newPath, cb); } }); } else { error(err); } }); }, error); }; // We don't know if oldPath is a *file* or a *directory*, and there's no // way to stat items. So launch both requests, see which one succeeds. root.getFile(oldPath, {}, success, error); root.getDirectory(oldPath, {}, success, error); } public stat(path: string, isLstat: boolean, cb: (err: ApiError, stat?: Stats) => void): void { // Throw an error if the entry doesn't exist, because then there's nothing // to stat. var opts = { create: false }; // Called when the path has been successfully loaded as a file. var loadAsFile = (entry: FileEntry): void => { var fileFromEntry = (file: File): void => { var stat = new Stats(FileType.FILE, file.size); cb(null, stat); }; entry.file(fileFromEntry, failedToLoad); }; // Called when the path has been successfully loaded as a directory. var loadAsDir = (dir: DirectoryEntry): void => { // Directory entry size can't be determined from the HTML5 FS API, and is // implementation-dependant anyway, so a dummy value is used. var size = 4096; var stat = new Stats(FileType.DIRECTORY, size); cb(null, stat); }; // Called when the path couldn't be opened as a directory or a file. var failedToLoad = (err: DOMException): void => { cb(this.convert(err, path, false /* Unknown / irrelevant */)); }; // Called when the path couldn't be opened as a file, but might still be a // directory. var failedToLoadAsFile = (): void => { this.fs.root.getDirectory(path, opts, loadAsDir, failedToLoad); }; // No method currently exists to determine whether a path refers to a // directory or a file, so this implementation tries both and uses the first // one that succeeds. this.fs.root.getFile(path, opts, loadAsFile, failedToLoadAsFile); } public open(p: string, flags: FileFlag, mode: number, cb: (err: ApiError, fd?: file.File) => any): void { var error = (err: DOMError): void => { if (err.name === 'InvalidModificationError' && flags.isExclusive()) { cb(ApiError.EEXIST(p)); } else { cb(this.convert(err, p, false)); } }; this.fs.root.getFile(p, { create: flags.pathNotExistsAction() === ActionType.CREATE_FILE, exclusive: flags.isExclusive() }, (entry: FileEntry): void => { // Try to fetch corresponding file. entry.file((file: File): void => { var reader = new FileReader(); reader.onloadend = (event: Event): void => { var bfs_file = this._makeFile(p, flags, file, <ArrayBuffer> reader.result); cb(null, bfs_file); }; reader.onerror = (ev: Event) => { error(reader.error); }; reader.readAsArrayBuffer(file); }, error); }, error); } /** * Returns a BrowserFS object representing the type of a Dropbox.js stat object */ private _statType(stat: Entry): FileType { return stat.isFile ? FileType.FILE : FileType.DIRECTORY; } /** * Returns a BrowserFS object representing a File, created from the data * returned by calls to the Dropbox API. */ private _makeFile(path: string, flag: FileFlag, stat: File, data: ArrayBuffer = new ArrayBuffer(0)): HTML5FSFile { var stats = new Stats(FileType.FILE, stat.size); var buffer = arrayBuffer2Buffer(data); return new HTML5FSFile(this, path, flag, stats, buffer); } /** * Delete a file or directory from the file system * isFile should reflect which call was made to remove the it (`unlink` or * `rmdir`). If this doesn't match what's actually at `path`, an error will be * returned */ private _remove(path: string, cb: (e?: ApiError) => void, isFile: boolean): void { var success = (entry: Entry): void => { var succ = () => { cb(); }; var err = (err: DOMException) => { cb(this.convert(err, path, !isFile)); }; entry.remove(succ, err); }; var error = (err: DOMException): void => { cb(this.convert(err, path, !isFile)); }; // Deleting the entry, so don't create it var opts = { create: false }; if (isFile) { this.fs.root.getFile(path, opts, success, error); } else { this.fs.root.getDirectory(path, opts, success, error); } } public unlink(path: string, cb: (e?: ApiError) => void): void { this._remove(path, cb, true); } public rmdir(path: string, cb: (e?: ApiError) => void): void { // Check if directory is non-empty, first. this.readdir(path, (e, files?) => { if (e) { cb(e); } else if (files.length > 0) { cb(ApiError.ENOTEMPTY(path)); } else { this._remove(path, cb, false); } }); } public mkdir(path: string, mode: number, cb: (e?: ApiError) => void): void { // Create the directory, but throw an error if it already exists, as per // mkdir(1) var opts = { create: true, exclusive: true }; var success = (dir: DirectoryEntry): void => { cb(); }; var error = (err: DOMException): void => { cb(this.convert(err, path, true)); }; this.fs.root.getDirectory(path, opts, success, error); } /** * Returns an array of `FileEntry`s. Used internally by empty and readdir. */ private _readdir(path: string, cb: (e: ApiError, entries?: Entry[]) => void): void { var error = (err: DOMException): void => { cb(this.convert(err, path, true)); }; // Grab the requested directory. this.fs.root.getDirectory(path, { create: false }, (dirEntry: DirectoryEntry) => { var reader = dirEntry.createReader(); var entries: Entry[] = []; // Call the reader.readEntries() until no more results are returned. var readEntries = () => { reader.readEntries(((results) => { if (results.length) { entries = entries.concat(_toArray(results)); readEntries(); } else { cb(null, entries); } }), error); }; readEntries(); }, error); } /** * Map _readdir's list of `FileEntry`s to their names and return that. */ public readdir(path: string, cb: (err: ApiError, files?: string[]) => void): void { this._readdir(path, (e: ApiError, entries?: Entry[]): void => { if (e) { return cb(e); } var rv: string[] = []; for (var i = 0; i < entries.length; i++) { rv.push(entries[i].name); } cb(null, rv); }); } }