browserfs
Version:
A filesystem in your browser!
409 lines (375 loc) • 12.6 kB
text/typescript
import {FileSystem, BaseFileSystem, BFSOneArgCallback, BFSCallback, FileSystemOptions} from '../core/file_system';
import InMemoryFileSystem from './InMemory';
import {ApiError, ErrorCode} from '../core/api_error';
import fs from '../core/node_fs';
import * as path from 'path';
import {mkdirpSync} from '../core/util';
/**
* Configuration options for the MountableFileSystem backend.
*/
export interface MountableFileSystemOptions {
// Locations of mount points. Can be empty.
[mountPoint: string]: FileSystem;
}
/**
* The MountableFileSystem allows you to mount multiple backend types or
* multiple instantiations of the same backend into a single file system tree.
* The file systems do not need to know about each other; all interactions are
* automatically facilitated through this interface.
*
* For example, if a file system is mounted at /mnt/blah, and a request came in
* for /mnt/blah/foo.txt, the file system would see a request for /foo.txt.
*
* You can mount file systems when you configure the file system:
* ```javascript
* BrowserFS.configure({
* fs: "MountableFileSystem",
* options: {
* '/data': { fs: 'XmlHttpRequest', options: { index: "http://mysite.com/files/index.json" } },
* '/home': { fs: 'LocalStorage' }
* }
* }, function(e) {
*
* });
* ```
*
* For advanced users, you can also mount file systems *after* MFS is constructed:
* ```javascript
* BrowserFS.FileSystem.XmlHttpRequest.Create({
* index: "http://mysite.com/files/index.json"
* }, function(e, xhrfs) {
* BrowserFS.FileSystem.MountableFileSystem.Create({
* '/data': xhrfs
* }, function(e, mfs) {
* BrowserFS.initialize(mfs);
*
* // Added after-the-fact...
* BrowserFS.FileSystem.LocalStorage.Create(function(e, lsfs) {
* mfs.mount('/home', lsfs);
* });
* });
* });
* ```
*
* Since MountableFileSystem simply proxies requests to mounted file systems, it supports all of the operations that the mounted file systems support.
*
* With no mounted file systems, `MountableFileSystem` acts as a simple `InMemory` filesystem.
*/
export default class MountableFileSystem extends BaseFileSystem implements FileSystem {
public static readonly Name = "MountableFileSystem";
public static readonly Options: FileSystemOptions = {};
/**
* Creates a MountableFileSystem instance with the given options.
*/
public static Create(opts: MountableFileSystemOptions, cb: BFSCallback<MountableFileSystem>): void {
const fs = new MountableFileSystem();
Object.keys(opts).forEach((mountPoint) => {
fs.mount(mountPoint, opts[mountPoint]);
});
cb(null, fs);
}
public static isAvailable(): boolean {
return true;
}
private mntMap: {[path: string]: FileSystem};
// Contains the list of mount points in mntMap, sorted by string length in decreasing order.
// Ensures that we scan the most specific mount points for a match first, which lets us
// nest mount points.
private mountList: string[] = [];
private rootFs: FileSystem;
/**
* Creates a new, empty MountableFileSystem.
*/
constructor() {
super();
this.mntMap = {};
// The InMemory file system serves purely to provide directory listings for
// mounted file systems.
this.rootFs = new InMemoryFileSystem();
}
/**
* Mounts the file system at the given mount point.
*/
public mount(mountPoint: string, fs: FileSystem): void {
if (mountPoint[0] !== '/') {
mountPoint = `/${mountPoint}`;
}
mountPoint = path.resolve(mountPoint);
if (this.mntMap[mountPoint]) {
throw new ApiError(ErrorCode.EINVAL, "Mount point " + mountPoint + " is already taken.");
}
mkdirpSync(mountPoint, 0x1ff, this.rootFs);
this.mntMap[mountPoint] = fs;
this.mountList.push(mountPoint);
this.mountList = this.mountList.sort((a, b) => b.length - a.length);
}
public umount(mountPoint: string): void {
if (mountPoint[0] !== '/') {
mountPoint = `/${mountPoint}`;
}
mountPoint = path.resolve(mountPoint);
if (!this.mntMap[mountPoint]) {
throw new ApiError(ErrorCode.EINVAL, "Mount point " + mountPoint + " is already unmounted.");
}
delete this.mntMap[mountPoint];
this.mountList.splice(this.mountList.indexOf(mountPoint), 1);
while (mountPoint !== '/') {
if (this.rootFs.readdirSync(mountPoint).length === 0) {
this.rootFs.rmdirSync(mountPoint);
mountPoint = path.dirname(mountPoint);
} else {
break;
}
}
}
/**
* Returns the file system that the path points to.
*/
public _getFs(path: string): {fs: FileSystem; path: string} {
const mountList = this.mountList, len = mountList.length;
for (let i = 0; i < len; i++) {
const mountPoint = mountList[i];
// We know path is normalized, so it is a substring of the mount point.
if (mountPoint.length <= path.length && path.indexOf(mountPoint) === 0) {
path = path.substr(mountPoint.length > 1 ? mountPoint.length : 0);
if (path === '') {
path = '/';
}
return {fs: this.mntMap[mountPoint], path: path};
}
}
// Query our root file system.
return {fs: this.rootFs, path: path};
}
// Global information methods
public getName(): string {
return MountableFileSystem.Name;
}
public diskSpace(path: string, cb: (total: number, free: number) => void): void {
cb(0, 0);
}
public isReadOnly(): boolean {
return false;
}
public supportsLinks(): boolean {
// I'm not ready for cross-FS links yet.
return false;
}
public supportsProps(): boolean {
return false;
}
public supportsSynch(): boolean {
return true;
}
/**
* Fixes up error messages so they mention the mounted file location relative
* to the MFS root, not to the particular FS's root.
* Mutates the input error, and returns it.
*/
public standardizeError(err: ApiError, path: string, realPath: string): ApiError {
const index = err.message.indexOf(path);
if (index !== -1) {
err.message = err.message.substr(0, index) + realPath + err.message.substr(index + path.length);
err.path = realPath;
}
return err;
}
// The following methods involve multiple file systems, and thus have custom
// logic.
// Note that we go through the Node API to use its robust default argument
// processing.
public rename(oldPath: string, newPath: string, cb: BFSOneArgCallback): void {
// Scenario 1: old and new are on same FS.
const fs1rv = this._getFs(oldPath);
const fs2rv = this._getFs(newPath);
if (fs1rv.fs === fs2rv.fs) {
return fs1rv.fs.rename(fs1rv.path, fs2rv.path, (e?: ApiError) => {
if (e) {
this.standardizeError(this.standardizeError(e, fs1rv.path, oldPath), fs2rv.path, newPath);
}
cb(e);
});
}
// Scenario 2: Different file systems.
// Read old file, write new file, delete old file.
return fs.readFile(oldPath, function(err: ApiError, data?: any) {
if (err) {
return cb(err);
}
fs.writeFile(newPath, data, function(err: ApiError) {
if (err) {
return cb(err);
}
fs.unlink(oldPath, cb);
});
});
}
public renameSync(oldPath: string, newPath: string): void {
// Scenario 1: old and new are on same FS.
const fs1rv = this._getFs(oldPath);
const fs2rv = this._getFs(newPath);
if (fs1rv.fs === fs2rv.fs) {
try {
return fs1rv.fs.renameSync(fs1rv.path, fs2rv.path);
} catch (e) {
this.standardizeError(this.standardizeError(e, fs1rv.path, oldPath), fs2rv.path, newPath);
throw e;
}
}
// Scenario 2: Different file systems.
const data = fs.readFileSync(oldPath);
fs.writeFileSync(newPath, data);
return fs.unlinkSync(oldPath);
}
public readdirSync(p: string): string[] {
const fsInfo = this._getFs(p);
// If null, rootfs did not have the directory
// (or the target FS is the root fs).
let rv: string[] | null = null;
// Mount points are all defined in the root FS.
// Ensure that we list those, too.
if (fsInfo.fs !== this.rootFs) {
try {
rv = this.rootFs.readdirSync(p);
} catch (e) {
// Ignore.
}
}
try {
const rv2 = fsInfo.fs.readdirSync(fsInfo.path);
if (rv === null) {
return rv2;
} else {
// Filter out duplicates.
return rv2.concat(rv.filter((val) => rv2.indexOf(val) === -1));
}
} catch (e) {
if (rv === null) {
throw this.standardizeError(e, fsInfo.path, p);
} else {
// The root FS had something.
return rv;
}
}
}
public readdir(p: string, cb: BFSCallback<string[]>): void {
const fsInfo = this._getFs(p);
fsInfo.fs.readdir(fsInfo.path, (err, files) => {
if (fsInfo.fs !== this.rootFs) {
try {
const rv = this.rootFs.readdirSync(p);
if (files) {
// Filter out duplicates.
files = files.concat(rv.filter((val) => files!.indexOf(val) === -1));
} else {
files = rv;
}
} catch (e) {
// Root FS and target FS did not have directory.
if (err) {
return cb(this.standardizeError(err, fsInfo.path, p));
}
}
} else if (err) {
// Root FS and target FS are the same, and did not have directory.
return cb(this.standardizeError(err, fsInfo.path, p));
}
cb(null, files);
});
}
public rmdirSync(p: string): void {
const fsInfo = this._getFs(p);
if (this._containsMountPt(p)) {
throw ApiError.ENOTEMPTY(p);
} else {
try {
fsInfo.fs.rmdirSync(fsInfo.path);
} catch (e) {
throw this.standardizeError(e, fsInfo.path, p);
}
}
}
public rmdir(p: string, cb: BFSOneArgCallback): void {
const fsInfo = this._getFs(p);
if (this._containsMountPt(p)) {
cb(ApiError.ENOTEMPTY(p));
} else {
fsInfo.fs.rmdir(fsInfo.path, (err?) => {
cb(err ? this.standardizeError(err, fsInfo.path, p) : null);
});
}
}
/**
* Returns true if the given path contains a mount point.
*/
private _containsMountPt(p: string): boolean {
const mountPoints = this.mountList, len = mountPoints.length;
for (let i = 0; i < len; i++) {
const pt = mountPoints[i];
if (pt.length >= p.length && pt.slice(0, p.length) === p) {
return true;
}
}
return false;
}
}
/**
* Tricky: Define all of the functions that merely forward arguments to the
* relevant file system, or return/throw an error.
* Take advantage of the fact that the *first* argument is always the path, and
* the *last* is the callback function (if async).
* @todo Can use numArgs to make proxying more efficient.
* @hidden
*/
function defineFcn(name: string, isSync: boolean, numArgs: number): (...args: any[]) => any {
if (isSync) {
return function(this: MountableFileSystem, ...args: any[]) {
const path = args[0];
const rv = this._getFs(path);
args[0] = rv.path;
try {
return (<any> rv.fs)[name].apply(rv.fs, args);
} catch (e) {
this.standardizeError(e, rv.path, path);
throw e;
}
};
} else {
return function(this: MountableFileSystem, ...args: any[]) {
const path = args[0];
const rv = this._getFs(path);
args[0] = rv.path;
if (typeof args[args.length - 1] === 'function') {
const cb = args[args.length - 1];
args[args.length - 1] = (...args: any[]) => {
if (args.length > 0 && args[0] instanceof ApiError) {
this.standardizeError(args[0], rv.path, path);
}
cb.apply(null, args);
};
}
return (<any> rv.fs)[name].apply(rv.fs, args);
};
}
}
/**
* @hidden
*/
const fsCmdMap = [
// 1 arg functions
['exists', 'unlink', 'readlink'],
// 2 arg functions
['stat', 'mkdir', 'realpath', 'truncate'],
// 3 arg functions
['open', 'readFile', 'chmod', 'utimes'],
// 4 arg functions
['chown'],
// 5 arg functions
['writeFile', 'appendFile']];
for (let i = 0; i < fsCmdMap.length; i++) {
const cmds = fsCmdMap[i];
for (const fnName of cmds) {
(<any> MountableFileSystem.prototype)[fnName] = defineFcn(fnName, false, i + 1);
(<any> MountableFileSystem.prototype)[fnName + 'Sync'] = defineFcn(fnName + 'Sync', true, i + 1);
}
}