browserfs
Version:
A filesystem in your browser!
533 lines (493 loc) • 18.7 kB
text/typescript
import PreloadFile from '../generic/preload_file';
import {BaseFileSystem, FileSystem as IFileSystem, BFSOneArgCallback, BFSCallback, FileSystemOptions} from '../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 as IFile} from '../core/file';
import * as path from 'path';
import global from '../core/global';
import {each as asyncEach} from 'async';
import {buffer2ArrayBuffer, arrayBuffer2Buffer, deprecationMessage} from '../core/util';
/**
* @hidden
*/
function isDirectoryEntry(entry: Entry): entry is DirectoryEntry {
return entry.isDirectory;
}
/**
* @hidden
*/
const _getFS: (type: number, size: number, successCallback: FileSystemCallback, errorCallback?: ErrorCallback) => void = global.webkitRequestFileSystem || global.requestFileSystem || null;
/**
* @hidden
*/
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);
}
}
/**
* @hidden
*/
function _toArray(list?: any[]): any[] {
return Array.prototype.slice.call(list || [], 0);
}
/**
* Converts the given DOMError into an appropriate ApiError.
* @url https://developer.mozilla.org/en-US/docs/Web/API/DOMError
* @hidden
*/
function convertError(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);
}
}
// 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 PreloadFile<HTML5FS> implements IFile {
private _entry: FileEntry;
constructor(fs: HTML5FS, entry: FileEntry, path: string, flag: FileFlag, stat: Stats, contents?: Buffer) {
super(fs, path, flag, stat, contents);
this._entry = entry;
}
public sync(cb: BFSOneArgCallback): void {
if (!this.isDirty()) {
return cb();
}
this._entry.createWriter((writer) => {
const buffer = this.getBuffer();
const blob = new Blob([buffer2ArrayBuffer(buffer)]);
const length = blob.size;
writer.onwriteend = (err?: any) => {
writer.onwriteend = <any> null;
writer.onerror = <any> null;
writer.truncate(length);
this.resetDirty();
cb();
};
writer.onerror = (err: any) => {
cb(convertError(err, this.getPath(), false));
};
writer.write(blob);
});
}
public close(cb: BFSOneArgCallback): void {
this.sync(cb);
}
}
export interface HTML5FSOptions {
// storage quota to request, in megabytes. Allocated value may be less.
size?: number;
// window.PERSISTENT or window.TEMPORARY. Defaults to PERSISTENT.
type?: number;
}
/**
* A read-write filesystem backed by the HTML5 FileSystem API.
*
* As the HTML5 FileSystem is only implemented in Blink, this interface is
* only available in Chrome.
*/
export default class HTML5FS extends BaseFileSystem implements IFileSystem {
public static readonly Name = "HTML5FS";
public static readonly Options: FileSystemOptions = {
size: {
type: "number",
optional: true,
description: "Storage quota to request, in megabytes. Allocated value may be less. Defaults to 5."
},
type: {
type: "number",
optional: true,
description: "window.PERSISTENT or window.TEMPORARY. Defaults to PERSISTENT."
}
};
/**
* Creates an HTML5FS instance with the given options.
*/
public static Create(opts: HTML5FSOptions, cb: BFSCallback<HTML5FS>): void {
const fs = new HTML5FS(opts.size, opts.type, false);
fs.allocate((e) => e ? cb(e) : cb(null, fs), false);
}
public static isAvailable(): boolean {
return !!_getFS;
}
// HTML5File reaches into HTML5FS. :/
public fs: FileSystem;
private size: number;
private type: number;
/**
* **Deprecated. Please use HTML5FS.Create() method instead.**
*
* Creates a new HTML5 FileSystem-backed BrowserFS file system of the given size
* and storage type.
*
* **IMPORTANT**: You must call `allocate` on the resulting object before the file system
* can be used.
*
* @param size storage quota to request, in megabytes. Allocated value may be less.
* @param type window.PERSISTENT or window.TEMPORARY. Defaults to PERSISTENT.
*/
constructor(size: number = 5, type: number = global.PERSISTENT, deprecateMsg = true) {
super();
// Convert MB to bytes.
this.size = 1024 * 1024 * size;
this.type = type;
deprecationMessage(deprecateMsg, HTML5FS.Name, {size: size, type: type});
}
public getName(): string {
return HTML5FS.Name;
}
public isReadOnly(): boolean {
return false;
}
public supportsSymlinks(): boolean {
return false;
}
public supportsProps(): boolean {
return false;
}
public supportsSynch(): boolean {
return false;
}
/**
* **Deprecated. Please use Create() method instead to create and allocate an HTML5FS.**
*
* Requests a storage quota from the browser to back this FS.
* Must be called before file system can be used!
*/
public allocate(cb: BFSOneArgCallback = () => {/*nop*/}, deprecateMsg = true): void {
if (deprecateMsg) {
console.warn(`[HTML5FS] HTML5FS.allocate() is deprecated and will be removed in the next major release. Please use 'HTML5FS.Create({type: ${this.type}, size: ${this.size}}, cb)' to create and allocate HTML5FS instances.`);
}
const success = (fs: FileSystem): void => {
this.fs = fs;
cb();
};
const error = (err: DOMException): void => {
cb(convertError(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);
}
}
/**
* 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: BFSOneArgCallback): 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
const finished = (er: any): void => {
if (err) {
console.error("Failed to empty FS");
mainCb(err);
} else {
mainCb();
}
};
// Removes files and recursively removes directories
const deleteEntry = (entry: Entry, cb: (e?: any) => void): void => {
const succ = () => {
cb();
};
const error = (err: DOMException) => {
cb(convertError(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.
asyncEach(entries!, deleteEntry, finished);
}
});
}
public rename(oldPath: string, newPath: string, cb: BFSOneArgCallback): void {
let semaphore: number = 2;
let successCount: number = 0;
const root: DirectoryEntry = this.fs.root;
let currentPath: string = oldPath;
const error = (err: DOMException): void => {
if (--semaphore <= 0) {
cb(convertError(err, currentPath, false));
}
};
const 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: BFSCallback<Stats>): void {
// Throw an error if the entry doesn't exist, because then there's nothing
// to stat.
const opts = {
create: false
};
// Called when the path has been successfully loaded as a file.
const loadAsFile = (entry: FileEntry): void => {
const fileFromEntry = (file: File): void => {
const 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.
const 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.
const size = 4096;
const stat = new Stats(FileType.DIRECTORY, size);
cb(null, stat);
};
// Called when the path couldn't be opened as a directory or a file.
const failedToLoad = (err: DOMException): void => {
cb(convertError(err, path, false /* Unknown / irrelevant */));
};
// Called when the path couldn't be opened as a file, but might still be a
// directory.
const 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: BFSCallback<IFile>): void {
// XXX: err is a DOMError
const error = (err: any): void => {
if (err.name === 'InvalidModificationError' && flags.isExclusive()) {
cb(ApiError.EEXIST(p));
} else {
cb(convertError(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 => {
const reader = new FileReader();
reader.onloadend = (event: Event): void => {
const bfsFile = this._makeFile(p, entry, flags, file, <ArrayBuffer> reader.result);
cb(null, bfsFile);
};
reader.onerror = (ev: Event) => {
error(reader.error);
};
reader.readAsArrayBuffer(file);
}, error);
}, error);
}
public unlink(path: string, cb: BFSOneArgCallback): void {
this._remove(path, cb, true);
}
public rmdir(path: string, cb: BFSOneArgCallback): 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: BFSOneArgCallback): void {
// Create the directory, but throw an error if it already exists, as per
// mkdir(1)
const opts = {
create: true,
exclusive: true
};
const success = (dir: DirectoryEntry): void => {
cb();
};
const error = (err: DOMException): void => {
cb(convertError(err, path, true));
};
this.fs.root.getDirectory(path, opts, success, error);
}
/**
* Map _readdir's list of `FileEntry`s to their names and return that.
*/
public readdir(path: string, cb: BFSCallback<string[]>): void {
this._readdir(path, (e: ApiError, entries?: Entry[]): void => {
if (entries) {
const rv: string[] = [];
for (const entry of entries) {
rv.push(entry.name);
}
cb(null, rv);
} else {
return cb(e);
}
});
}
/**
* Returns a BrowserFS object representing a File.
*/
private _makeFile(path: string, entry: FileEntry, flag: FileFlag, stat: File, data: ArrayBuffer = new ArrayBuffer(0)): HTML5FSFile {
const stats = new Stats(FileType.FILE, stat.size);
const buffer = arrayBuffer2Buffer(data);
return new HTML5FSFile(this, entry, path, flag, stats, buffer);
}
/**
* Returns an array of `FileEntry`s. Used internally by empty and readdir.
*/
private _readdir(path: string, cb: BFSCallback<Entry[]>): void {
const error = (err: DOMException): void => {
cb(convertError(err, path, true));
};
// Grab the requested directory.
this.fs.root.getDirectory(path, { create: false }, (dirEntry: DirectoryEntry) => {
const reader = dirEntry.createReader();
let entries: Entry[] = [];
// Call the reader.readEntries() until no more results are returned.
const readEntries = () => {
reader.readEntries(((results) => {
if (results.length) {
entries = entries.concat(_toArray(results));
readEntries();
} else {
cb(null, entries);
}
}), error);
};
readEntries();
}, error);
}
/**
* 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: BFSOneArgCallback, isFile: boolean): void {
const success = (entry: Entry): void => {
const succ = () => {
cb();
};
const err = (err: DOMException) => {
cb(convertError(err, path, !isFile));
};
entry.remove(succ, err);
};
const error = (err: DOMException): void => {
cb(convertError(err, path, !isFile));
};
// Deleting the entry, so don't create it
const opts = {
create: false
};
if (isFile) {
this.fs.root.getFile(path, opts, success, error);
} else {
this.fs.root.getDirectory(path, opts, success, error);
}
}
}