browserfs
Version:
A filesystem in your browser!
895 lines (825 loc) • 26 kB
text/typescript
import {BaseFileSystem, FileSystem, BFSOneArgCallback, BFSCallback, FileSystemOptions} from '../core/file_system';
import {ApiError, ErrorCode} from '../core/api_error';
import {FileFlag} from '../core/file_flag';
import {buffer2ArrayBuffer, arrayBuffer2Buffer, emptyBuffer, deprecationMessage} from '../core/util';
import {File, BaseFile} from '../core/file';
import {default as Stats} from '../core/node_fs_stats';
import PreloadFile from '../generic/preload_file';
import global from '../core/global';
import fs from '../core/node_fs';
/**
* @hidden
*/
declare const importScripts: Function;
/**
* @hidden
*/
interface IBrowserFSMessage {
browserfsMessage: boolean;
}
/**
* @hidden
*/
enum SpecialArgType {
// Callback
CB,
// File descriptor
FD,
// API error
API_ERROR,
// Stats object
STATS,
// Initial probe for file system information.
PROBE,
// FileFlag object.
FILEFLAG,
// Buffer object.
BUFFER,
// Generic Error object.
ERROR
}
/**
* @hidden
*/
interface ISpecialArgument {
type: SpecialArgType;
}
/**
* @hidden
*/
interface IProbeResponse extends ISpecialArgument {
isReadOnly: boolean;
supportsLinks: boolean;
supportsProps: boolean;
}
/**
* @hidden
*/
interface ICallbackArgument extends ISpecialArgument {
// The callback ID.
id: number;
}
/**
* Converts callback arguments into ICallbackArgument objects, and back
* again.
* @hidden
*/
class CallbackArgumentConverter {
private _callbacks: { [id: number]: Function } = {};
private _nextId: number = 0;
public toRemoteArg(cb: Function): ICallbackArgument {
const id = this._nextId++;
this._callbacks[id] = cb;
return {
type: SpecialArgType.CB,
id: id
};
}
public toLocalArg(id: number): Function {
const cb = this._callbacks[id];
delete this._callbacks[id];
return cb;
}
}
/**
* @hidden
*/
interface IFileDescriptorArgument extends ISpecialArgument {
// The file descriptor's id on the remote side.
id: number;
// The entire file's data, as an array buffer.
data: ArrayBuffer;
// The file's stat object, as an array buffer.
stat: ArrayBuffer;
// The path to the file.
path: string;
// The flag of the open file descriptor.
flag: string;
}
/**
* @hidden
*/
class FileDescriptorArgumentConverter {
private _fileDescriptors: { [id: number]: File } = {};
private _nextId: number = 0;
public toRemoteArg(fd: File, p: string, flag: FileFlag, cb: BFSCallback<IFileDescriptorArgument>): void {
const id = this._nextId++;
let data: ArrayBuffer;
let stat: ArrayBuffer;
this._fileDescriptors[id] = fd;
// Extract needed information asynchronously.
fd.stat((err, stats) => {
if (err) {
cb(err);
} else {
stat = bufferToTransferrableObject(stats!.toBuffer());
// If it's a readable flag, we need to grab contents.
if (flag.isReadable()) {
fd.read(Buffer.alloc(stats!.size), 0, stats!.size, 0, (err?: ApiError | null, bytesRead?: number, buff?: Buffer) => {
if (err) {
cb(err);
} else {
data = bufferToTransferrableObject(buff!);
cb(null, {
type: SpecialArgType.FD,
id: id,
data: data,
stat: stat,
path: p,
flag: flag.getFlagString()
});
}
});
} else {
// File is not readable, which means writing to it will append or
// truncate/replace existing contents. Return an empty arraybuffer.
cb(null, {
type: SpecialArgType.FD,
id: id,
data: new ArrayBuffer(0),
stat: stat,
path: p,
flag: flag.getFlagString()
});
}
}
});
}
public applyFdAPIRequest(request: IAPIRequest, cb: BFSOneArgCallback): void {
const fdArg = <IFileDescriptorArgument> request.args[0];
this._applyFdChanges(fdArg, (err, fd?) => {
if (err) {
cb(err);
} else {
// Apply method on now-changed file descriptor.
(<any> fd)[request.method]((e?: ApiError) => {
if (request.method === 'close') {
delete this._fileDescriptors[fdArg.id];
}
cb(e);
});
}
});
}
private _applyFdChanges(remoteFd: IFileDescriptorArgument, cb: BFSCallback<File>): void {
const fd = this._fileDescriptors[remoteFd.id],
data = transferrableObjectToBuffer(remoteFd.data),
remoteStats = Stats.fromBuffer(transferrableObjectToBuffer(remoteFd.stat));
// Write data if the file is writable.
const flag = FileFlag.getFileFlag(remoteFd.flag);
if (flag.isWriteable()) {
// Appendable: Write to end of file.
// Writeable: Replace entire contents of file.
fd.write(data, 0, data.length, flag.isAppendable() ? fd.getPos()! : 0, (e?: ApiError | null) => {
function applyStatChanges() {
// Check if mode changed.
fd.stat((e, stats?) => {
if (e) {
cb(e);
} else {
if (stats!.mode !== remoteStats.mode) {
fd.chmod(remoteStats.mode, (e: any) => {
cb(e, fd);
});
} else {
cb(e, fd);
}
}
});
}
if (e) {
cb(e);
} else {
// If writeable & not appendable, we need to ensure file contents are
// identical to those from the remote FD. Thus, we truncate to the
// length of the remote file.
if (!flag.isAppendable()) {
fd.truncate(data.length, () => {
applyStatChanges();
});
} else {
applyStatChanges();
}
}
});
} else {
cb(null, fd);
}
}
}
/**
* @hidden
*/
interface IAPIErrorArgument extends ISpecialArgument {
// The error object, as an array buffer.
errorData: ArrayBuffer;
}
/**
* @hidden
*/
function apiErrorLocal2Remote(e: ApiError): IAPIErrorArgument {
return {
type: SpecialArgType.API_ERROR,
errorData: bufferToTransferrableObject(e.writeToBuffer())
};
}
/**
* @hidden
*/
function apiErrorRemote2Local(e: IAPIErrorArgument): ApiError {
return ApiError.fromBuffer(transferrableObjectToBuffer(e.errorData));
}
/**
* @hidden
*/
interface IErrorArgument extends ISpecialArgument {
// The name of the error (e.g. 'TypeError').
name: string;
// The message associated with the error.
message: string;
// The stack associated with the error.
stack: string;
}
/**
* @hidden
*/
function errorLocal2Remote(e: Error): IErrorArgument {
return {
type: SpecialArgType.ERROR,
name: e.name,
message: e.message,
stack: e.stack!
};
}
/**
* @hidden
*/
function errorRemote2Local(e: IErrorArgument): Error {
let cnstr: {
new (msg: string): Error;
} = global[e.name];
if (typeof(cnstr) !== 'function') {
cnstr = Error;
}
const err = new cnstr(e.message);
err.stack = e.stack;
return err;
}
/**
* @hidden
*/
interface IStatsArgument extends ISpecialArgument {
// The stats object as an array buffer.
statsData: ArrayBuffer;
}
/**
* @hidden
*/
function statsLocal2Remote(stats: Stats): IStatsArgument {
return {
type: SpecialArgType.STATS,
statsData: bufferToTransferrableObject(stats.toBuffer())
};
}
/**
* @hidden
*/
function statsRemote2Local(stats: IStatsArgument): Stats {
return Stats.fromBuffer(transferrableObjectToBuffer(stats.statsData));
}
/**
* @hidden
*/
interface IFileFlagArgument extends ISpecialArgument {
flagStr: string;
}
/**
* @hidden
*/
function fileFlagLocal2Remote(flag: FileFlag): IFileFlagArgument {
return {
type: SpecialArgType.FILEFLAG,
flagStr: flag.getFlagString()
};
}
/**
* @hidden
*/
function fileFlagRemote2Local(remoteFlag: IFileFlagArgument): FileFlag {
return FileFlag.getFileFlag(remoteFlag.flagStr);
}
/**
* @hidden
*/
interface IBufferArgument extends ISpecialArgument {
data: ArrayBuffer;
}
/**
* @hidden
*/
function bufferToTransferrableObject(buff: Buffer): ArrayBuffer {
return buffer2ArrayBuffer(buff);
}
/**
* @hidden
*/
function transferrableObjectToBuffer(buff: ArrayBuffer): Buffer {
return arrayBuffer2Buffer(buff);
}
/**
* @hidden
*/
function bufferLocal2Remote(buff: Buffer): IBufferArgument {
return {
type: SpecialArgType.BUFFER,
data: bufferToTransferrableObject(buff)
};
}
/**
* @hidden
*/
function bufferRemote2Local(buffArg: IBufferArgument): Buffer {
return transferrableObjectToBuffer(buffArg.data);
}
/**
* @hidden
*/
interface IAPIRequest extends IBrowserFSMessage {
method: string;
args: Array<number | string | ISpecialArgument>;
}
/**
* @hidden
*/
function isAPIRequest(data: any): data is IAPIRequest {
return data && typeof data === 'object' && data.hasOwnProperty('browserfsMessage') && data['browserfsMessage'];
}
/**
* @hidden
*/
interface IAPIResponse extends IBrowserFSMessage {
cbId: number;
args: Array<number | string | ISpecialArgument>;
}
/**
* @hidden
*/
function isAPIResponse(data: any): data is IAPIResponse {
return data && typeof data === 'object' && data.hasOwnProperty('browserfsMessage') && data['browserfsMessage'];
}
/**
* Represents a remote file in a different worker/thread.
*/
class WorkerFile extends PreloadFile<WorkerFS> {
private _remoteFdId: number;
constructor(_fs: WorkerFS, _path: string, _flag: FileFlag, _stat: Stats, remoteFdId: number, contents?: Buffer) {
super(_fs, _path, _flag, _stat, contents);
this._remoteFdId = remoteFdId;
}
public getRemoteFdId() {
return this._remoteFdId;
}
/**
* @hidden
*/
public toRemoteArg(): IFileDescriptorArgument {
return <IFileDescriptorArgument> {
type: SpecialArgType.FD,
id: this._remoteFdId,
data: bufferToTransferrableObject(this.getBuffer()),
stat: bufferToTransferrableObject(this.getStats().toBuffer()),
path: this.getPath(),
flag: this.getFlag().getFlagString()
};
}
public sync(cb: BFSOneArgCallback): void {
this._syncClose('sync', cb);
}
public close(cb: BFSOneArgCallback): void {
this._syncClose('close', cb);
}
private _syncClose(type: string, cb: BFSOneArgCallback): void {
if (this.isDirty()) {
(<WorkerFS> this._fs).syncClose(type, this, (e?: ApiError) => {
if (!e) {
this.resetDirty();
}
cb(e);
});
} else {
cb();
}
}
}
export interface WorkerFSOptions {
// The target worker that you want to connect to, or the current worker if in a worker context.
worker: Worker;
}
/**
* WorkerFS lets you access a BrowserFS instance that is running in a different
* JavaScript context (e.g. access BrowserFS in one of your WebWorkers, or
* access BrowserFS running on the main page from a WebWorker).
*
* For example, to have a WebWorker access files in the main browser thread,
* do the following:
*
* MAIN BROWSER THREAD:
*
* ```javascript
* // Listen for remote file system requests.
* BrowserFS.FileSystem.WorkerFS.attachRemoteListener(webWorkerObject);
* ```
*
* WEBWORKER THREAD:
*
* ```javascript
* // Set the remote file system as the root file system.
* BrowserFS.configure({ fs: "WorkerFS", options: { worker: self }}, function(e) {
* // Ready!
* });
* ```
*
* Note that synchronous operations are not permitted on the WorkerFS, regardless
* of the configuration option of the remote FS.
*/
export default class WorkerFS extends BaseFileSystem implements FileSystem {
public static readonly Name = "WorkerFS";
public static readonly Options: FileSystemOptions = {
worker: {
type: "object",
description: "The target worker that you want to connect to, or the current worker if in a worker context.",
validator: function(v: object, cb: BFSOneArgCallback): void {
// Check for a `postMessage` function.
if ((<any> v)['postMessage']) {
cb();
} else {
cb(new ApiError(ErrorCode.EINVAL, `option must be a Web Worker instance.`));
}
}
}
};
public static Create(opts: WorkerFSOptions, cb: BFSCallback<WorkerFS>): void {
const fs = new WorkerFS(opts.worker, false);
fs.initialize(() => {
cb(null, fs);
});
}
public static isAvailable(): boolean {
return typeof(importScripts) !== 'undefined' || typeof(Worker) !== 'undefined';
}
/**
* Attaches a listener to the remote worker for file system requests.
*/
public static attachRemoteListener(worker: Worker) {
const fdConverter = new FileDescriptorArgumentConverter();
function argLocal2Remote(arg: any, requestArgs: any[], cb: BFSCallback<any>): void {
switch (typeof arg) {
case 'object':
if (arg instanceof Stats) {
cb(null, statsLocal2Remote(arg));
} else if (arg instanceof ApiError) {
cb(null, apiErrorLocal2Remote(arg));
} else if (arg instanceof BaseFile) {
// Pass in p and flags from original request.
cb(null, fdConverter.toRemoteArg(<File> arg, requestArgs[0], requestArgs[1], cb));
} else if (arg instanceof FileFlag) {
cb(null, fileFlagLocal2Remote(arg));
} else if (arg instanceof Buffer) {
cb(null, bufferLocal2Remote(arg));
} else if (arg instanceof Error) {
cb(null, errorLocal2Remote(arg));
} else {
cb(null, arg);
}
break;
default:
cb(null, arg);
break;
}
}
function argRemote2Local(arg: any, fixedRequestArgs: any[]): any {
if (!arg) {
return arg;
}
switch (typeof arg) {
case 'object':
if (typeof arg['type'] === 'number') {
const specialArg = <ISpecialArgument> arg;
switch (specialArg.type) {
case SpecialArgType.CB:
const cbId = (<ICallbackArgument> arg).id;
return function() {
let i: number;
const fixedArgs = new Array(arguments.length);
let message: IAPIResponse,
countdown = arguments.length;
function abortAndSendError(err: ApiError) {
if (countdown > 0) {
countdown = -1;
message = {
browserfsMessage: true,
cbId: cbId,
args: [apiErrorLocal2Remote(err)]
};
worker.postMessage(message);
}
}
for (i = 0; i < arguments.length; i++) {
// Capture i and argument.
((i: number, arg: any) => {
argLocal2Remote(arg, fixedRequestArgs, (err, fixedArg?) => {
fixedArgs[i] = fixedArg;
if (err) {
abortAndSendError(err);
} else if (--countdown === 0) {
message = {
browserfsMessage: true,
cbId: cbId,
args: fixedArgs
};
worker.postMessage(message);
}
});
})(i, arguments[i]);
}
if (arguments.length === 0) {
message = {
browserfsMessage: true,
cbId: cbId,
args: fixedArgs
};
worker.postMessage(message);
}
};
case SpecialArgType.API_ERROR:
return apiErrorRemote2Local(<IAPIErrorArgument> specialArg);
case SpecialArgType.STATS:
return statsRemote2Local(<IStatsArgument> specialArg);
case SpecialArgType.FILEFLAG:
return fileFlagRemote2Local(<IFileFlagArgument> specialArg);
case SpecialArgType.BUFFER:
return bufferRemote2Local(<IBufferArgument> specialArg);
case SpecialArgType.ERROR:
return errorRemote2Local(<IErrorArgument> specialArg);
default:
// No idea what this is.
return arg;
}
} else {
return arg;
}
default:
return arg;
}
}
worker.addEventListener('message', (e: MessageEvent) => {
const request: object = e.data;
if (isAPIRequest(request)) {
const args = request.args,
fixedArgs = new Array<any>(args.length);
switch (request.method) {
case 'close':
case 'sync':
(() => {
// File descriptor-relative methods.
const remoteCb = <ICallbackArgument> args[1];
fdConverter.applyFdAPIRequest(request, (err?: ApiError) => {
// Send response.
const response: IAPIResponse = {
browserfsMessage: true,
cbId: remoteCb.id,
args: err ? [apiErrorLocal2Remote(err)] : []
};
worker.postMessage(response);
});
})();
break;
case 'probe':
(() => {
const rootFs = <FileSystem> fs.getRootFS(),
remoteCb = <ICallbackArgument> args[1],
probeResponse: IProbeResponse = {
type: SpecialArgType.PROBE,
isReadOnly: rootFs.isReadOnly(),
supportsLinks: rootFs.supportsLinks(),
supportsProps: rootFs.supportsProps()
},
response: IAPIResponse = {
browserfsMessage: true,
cbId: remoteCb.id,
args: [probeResponse]
};
worker.postMessage(response);
})();
break;
default:
// File system methods.
for (let i = 0; i < args.length; i++) {
fixedArgs[i] = argRemote2Local(args[i], fixedArgs);
}
const rootFS = fs.getRootFS();
(<Function> (<any> rootFS)[request.method]).apply(rootFS, fixedArgs);
break;
}
}
});
}
private _worker: Worker;
private _callbackConverter = new CallbackArgumentConverter();
private _isInitialized: boolean = false;
private _isReadOnly: boolean = false;
private _supportLinks: boolean = false;
private _supportProps: boolean = false;
/**
* **Deprecated. Please use WorkerFS.Create() method instead.**
*
* Constructs a new WorkerFS instance that connects with BrowserFS running on
* the specified worker.
*/
constructor(worker: Worker, deprecateMsg = true) {
super();
this._worker = worker;
deprecationMessage(deprecateMsg, WorkerFS.Name, {worker: "Web Worker instance"});
this._worker.addEventListener('message', (e: MessageEvent) => {
const resp: object = e.data;
if (isAPIResponse(resp)) {
let i: number;
const args = resp.args;
const fixedArgs = new Array(args.length);
// Dispatch event to correct id.
for (i = 0; i < fixedArgs.length; i++) {
fixedArgs[i] = this._argRemote2Local(args[i]);
}
this._callbackConverter.toLocalArg(resp.cbId).apply(null, fixedArgs);
}
});
}
public getName(): string {
return WorkerFS.Name;
}
/**
* **Deprecated. Please use WorkerFS.Create() method to construct and initialize WorkerFS instances.**
*
* Called once both local and remote sides are set up.
*/
public initialize(cb: () => void): void {
if (!this._isInitialized) {
const message: IAPIRequest = {
browserfsMessage: true,
method: 'probe',
args: [this._argLocal2Remote(emptyBuffer()), this._callbackConverter.toRemoteArg((probeResponse: IProbeResponse) => {
this._isInitialized = true;
this._isReadOnly = probeResponse.isReadOnly;
this._supportLinks = probeResponse.supportsLinks;
this._supportProps = probeResponse.supportsProps;
cb();
})]
};
this._worker.postMessage(message);
} else {
cb();
}
}
public isReadOnly(): boolean { return this._isReadOnly; }
public supportsSynch(): boolean { return false; }
public supportsLinks(): boolean { return this._supportLinks; }
public supportsProps(): boolean { return this._supportProps; }
public rename(oldPath: string, newPath: string, cb: BFSOneArgCallback): void {
this._rpc('rename', arguments);
}
public stat(p: string, isLstat: boolean, cb: BFSCallback<Stats>): void {
this._rpc('stat', arguments);
}
public open(p: string, flag: FileFlag, mode: number, cb: BFSCallback<File>): void {
this._rpc('open', arguments);
}
public unlink(p: string, cb: Function): void {
this._rpc('unlink', arguments);
}
public rmdir(p: string, cb: Function): void {
this._rpc('rmdir', arguments);
}
public mkdir(p: string, mode: number, cb: Function): void {
this._rpc('mkdir', arguments);
}
public readdir(p: string, cb: BFSCallback<string[]>): void {
this._rpc('readdir', arguments);
}
public exists(p: string, cb: (exists: boolean) => void): void {
this._rpc('exists', arguments);
}
public realpath(p: string, cache: { [path: string]: string }, cb: BFSCallback<string>): void {
this._rpc('realpath', arguments);
}
public truncate(p: string, len: number, cb: Function): void {
this._rpc('truncate', arguments);
}
public readFile(fname: string, encoding: string, flag: FileFlag, cb: BFSCallback<any>): void {
this._rpc('readFile', arguments);
}
public writeFile(fname: string, data: any, encoding: string, flag: FileFlag, mode: number, cb: BFSOneArgCallback): void {
this._rpc('writeFile', arguments);
}
public appendFile(fname: string, data: any, encoding: string, flag: FileFlag, mode: number, cb: BFSOneArgCallback): void {
this._rpc('appendFile', arguments);
}
public chmod(p: string, isLchmod: boolean, mode: number, cb: Function): void {
this._rpc('chmod', arguments);
}
public chown(p: string, isLchown: boolean, uid: number, gid: number, cb: Function): void {
this._rpc('chown', arguments);
}
public utimes(p: string, atime: Date, mtime: Date, cb: Function): void {
this._rpc('utimes', arguments);
}
public link(srcpath: string, dstpath: string, cb: Function): void {
this._rpc('link', arguments);
}
public symlink(srcpath: string, dstpath: string, type: string, cb: Function): void {
this._rpc('symlink', arguments);
}
public readlink(p: string, cb: Function): void {
this._rpc('readlink', arguments);
}
public syncClose(method: string, fd: File, cb: BFSOneArgCallback): void {
this._worker.postMessage(<IAPIRequest> {
browserfsMessage: true,
method: method,
args: [(<WorkerFile> fd).toRemoteArg(), this._callbackConverter.toRemoteArg(cb)]
});
}
private _argRemote2Local(arg: any): any {
if (!arg) {
return arg;
}
switch (typeof arg) {
case 'object':
if (typeof arg['type'] === 'number') {
const specialArg = <ISpecialArgument> arg;
switch (specialArg.type) {
case SpecialArgType.API_ERROR:
return apiErrorRemote2Local(<IAPIErrorArgument> specialArg);
case SpecialArgType.FD:
const fdArg = <IFileDescriptorArgument> specialArg;
return new WorkerFile(this, fdArg.path, FileFlag.getFileFlag(fdArg.flag), Stats.fromBuffer(transferrableObjectToBuffer(fdArg.stat)), fdArg.id, transferrableObjectToBuffer(fdArg.data));
case SpecialArgType.STATS:
return statsRemote2Local(<IStatsArgument> specialArg);
case SpecialArgType.FILEFLAG:
return fileFlagRemote2Local(<IFileFlagArgument> specialArg);
case SpecialArgType.BUFFER:
return bufferRemote2Local(<IBufferArgument> specialArg);
case SpecialArgType.ERROR:
return errorRemote2Local(<IErrorArgument> specialArg);
default:
return arg;
}
} else {
return arg;
}
default:
return arg;
}
}
private _rpc(methodName: string, args: IArguments) {
const fixedArgs = new Array(args.length);
for (let i = 0; i < args.length; i++) {
fixedArgs[i] = this._argLocal2Remote(args[i]);
}
const message: IAPIRequest = {
browserfsMessage: true,
method: methodName,
args: fixedArgs
};
this._worker.postMessage(message);
}
/**
* Converts a local argument into a remote argument. Public so WorkerFile objects can call it.
*/
private _argLocal2Remote(arg: any): any {
if (!arg) {
return arg;
}
switch (typeof arg) {
case "object":
if (arg instanceof Stats) {
return statsLocal2Remote(arg);
} else if (arg instanceof ApiError) {
return apiErrorLocal2Remote(arg);
} else if (arg instanceof WorkerFile) {
return (<WorkerFile> arg).toRemoteArg();
} else if (arg instanceof FileFlag) {
return fileFlagLocal2Remote(arg);
} else if (arg instanceof Buffer) {
return bufferLocal2Remote(arg);
} else if (arg instanceof Error) {
return errorLocal2Remote(arg);
} else {
return "Unknown argument";
}
case "function":
return this._callbackConverter.toRemoteArg(arg);
default:
return arg;
}
}
}