browserfs
Version:
A filesystem in your browser!
257 lines (235 loc) • 8.51 kB
text/typescript
import {BFSOneArgCallback, BFSCallback, FileSystemOptions} from '../core/file_system';
import {AsyncKeyValueROTransaction, AsyncKeyValueRWTransaction, AsyncKeyValueStore, AsyncKeyValueFileSystem} from '../generic/key_value_filesystem';
import {ApiError, ErrorCode} from '../core/api_error';
import global from '../core/global';
import {arrayBuffer2Buffer, buffer2ArrayBuffer, deprecationMessage} from '../core/util';
/**
* Get the indexedDB constructor for the current browser.
* @hidden
*/
const indexedDB: IDBFactory = global.indexedDB ||
(<any> global).mozIndexedDB ||
(<any> global).webkitIndexedDB ||
global.msIndexedDB;
/**
* Converts a DOMException or a DOMError from an IndexedDB event into a
* standardized BrowserFS API error.
* @hidden
*/
function convertError(e: {name: string}, message: string = e.toString()): ApiError {
switch (e.name) {
case "NotFoundError":
return new ApiError(ErrorCode.ENOENT, message);
case "QuotaExceededError":
return new ApiError(ErrorCode.ENOSPC, message);
default:
// The rest do not seem to map cleanly to standard error codes.
return new ApiError(ErrorCode.EIO, message);
}
}
/**
* Produces a new onerror handler for IDB. Our errors are always fatal, so we
* handle them generically: Call the user-supplied callback with a translated
* version of the error, and let the error bubble up.
* @hidden
*/
function onErrorHandler(cb: (e: ApiError) => void, code: ErrorCode = ErrorCode.EIO, message: string | null = null): (e?: any) => void {
return function(e?: any): void {
// Prevent the error from canceling the transaction.
e.preventDefault();
cb(new ApiError(code, message !== null ? message : undefined));
};
}
/**
* @hidden
*/
export class IndexedDBROTransaction implements AsyncKeyValueROTransaction {
constructor(public tx: IDBTransaction, public store: IDBObjectStore) { }
public get(key: string, cb: BFSCallback<Buffer>): void {
try {
const r: IDBRequest = this.store.get(key);
r.onerror = onErrorHandler(cb);
r.onsuccess = (event) => {
// IDB returns the value 'undefined' when you try to get keys that
// don't exist. The caller expects this behavior.
const result: any = (<any> event.target).result;
if (result === undefined) {
cb(null, result);
} else {
// IDB data is stored as an ArrayBuffer
cb(null, arrayBuffer2Buffer(result));
}
};
} catch (e) {
cb(convertError(e));
}
}
}
/**
* @hidden
*/
export class IndexedDBRWTransaction extends IndexedDBROTransaction implements AsyncKeyValueRWTransaction, AsyncKeyValueROTransaction {
constructor(tx: IDBTransaction, store: IDBObjectStore) {
super(tx, store);
}
public put(key: string, data: Buffer, overwrite: boolean, cb: BFSCallback<boolean>): void {
try {
const arraybuffer = buffer2ArrayBuffer(data);
let r: IDBRequest;
if (overwrite) {
r = this.store.put(arraybuffer, key);
} else {
// 'add' will never overwrite an existing key.
r = this.store.add(arraybuffer, key);
}
// XXX: NEED TO RETURN FALSE WHEN ADD HAS A KEY CONFLICT. NO ERROR.
r.onerror = onErrorHandler(cb);
r.onsuccess = (event) => {
cb(null, true);
};
} catch (e) {
cb(convertError(e));
}
}
public del(key: string, cb: BFSOneArgCallback): void {
try {
// NOTE: IE8 has a bug with identifiers named 'delete' unless used as a string
// like this.
// http://stackoverflow.com/a/26479152
const r: IDBRequest = this.store['delete'](key);
r.onerror = onErrorHandler(cb);
r.onsuccess = (event) => {
cb();
};
} catch (e) {
cb(convertError(e));
}
}
public commit(cb: BFSOneArgCallback): void {
// Return to the event loop to commit the transaction.
setTimeout(cb, 0);
}
public abort(cb: BFSOneArgCallback): void {
let _e: ApiError | null = null;
try {
this.tx.abort();
} catch (e) {
_e = convertError(e);
} finally {
cb(_e);
}
}
}
export class IndexedDBStore implements AsyncKeyValueStore {
private db: IDBDatabase;
constructor(cb: BFSCallback<IndexedDBStore>, private storeName: string = 'browserfs') {
const openReq: IDBOpenDBRequest = indexedDB.open(this.storeName, 1);
openReq.onupgradeneeded = (event) => {
const db: IDBDatabase = (<any> event.target).result;
// Huh. This should never happen; we're at version 1. Why does another
// database exist?
if (db.objectStoreNames.contains(this.storeName)) {
db.deleteObjectStore(this.storeName);
}
db.createObjectStore(this.storeName);
};
openReq.onsuccess = (event) => {
this.db = (<any> event.target).result;
cb(null, this);
};
openReq.onerror = onErrorHandler(cb, ErrorCode.EACCES);
}
public name(): string {
return IndexedDBFileSystem.Name + " - " + this.storeName;
}
public clear(cb: BFSOneArgCallback): void {
try {
const tx = this.db.transaction(this.storeName, 'readwrite'),
objectStore = tx.objectStore(this.storeName),
r: IDBRequest = objectStore.clear();
r.onsuccess = (event) => {
// Use setTimeout to commit transaction.
setTimeout(cb, 0);
};
r.onerror = onErrorHandler(cb);
} catch (e) {
cb(convertError(e));
}
}
public beginTransaction(type: 'readonly'): AsyncKeyValueROTransaction;
public beginTransaction(type: 'readwrite'): AsyncKeyValueRWTransaction;
public beginTransaction(type: string = 'readonly'): AsyncKeyValueROTransaction {
const tx = this.db.transaction(this.storeName, type),
objectStore = tx.objectStore(this.storeName);
if (type === 'readwrite') {
return new IndexedDBRWTransaction(tx, objectStore);
} else if (type === 'readonly') {
return new IndexedDBROTransaction(tx, objectStore);
} else {
throw new ApiError(ErrorCode.EINVAL, 'Invalid transaction type.');
}
}
}
/**
* Configuration options for the IndexedDB file system.
*/
export interface IndexedDBFileSystemOptions {
// The name of this file system. You can have multiple IndexedDB file systems operating
// at once, but each must have a different name.
storeName?: string;
}
/**
* A file system that uses the IndexedDB key value file system.
*/
export default class IndexedDBFileSystem extends AsyncKeyValueFileSystem {
public static readonly Name = "IndexedDB";
public static readonly Options: FileSystemOptions = {
storeName: {
type: "string",
optional: true,
description: "The name of this file system. You can have multiple IndexedDB file systems operating at once, but each must have a different name."
}
};
/**
* Constructs an IndexedDB file system with the given options.
*/
public static Create(opts: IndexedDBFileSystemOptions, cb: BFSCallback<IndexedDBFileSystem>): void {
// tslint:disable-next-line:no-unused-new
new IndexedDBFileSystem(cb, opts.storeName, false);
// tslint:enable-next-line:no-unused-new
}
public static isAvailable(): boolean {
// In Safari's private browsing mode, indexedDB.open returns NULL.
// In Firefox, it throws an exception.
// In Chrome, it "just works", and clears the database when you leave the page.
// Untested: Opera, IE.
try {
return typeof indexedDB !== 'undefined' && null !== indexedDB.open("__browserfs_test__");
} catch (e) {
return false;
}
}
/**
* **Deprecated. Use IndexedDB.Create() method instead.**
*
* Constructs an IndexedDB file system.
* @param cb Called once the database is instantiated and ready for use.
* Passes an error if there was an issue instantiating the database.
* @param storeName The name of this file system. You can have
* multiple IndexedDB file systems operating at once, but each must have
* a different name.
*/
constructor(cb: BFSCallback<IndexedDBFileSystem>, storeName?: string, deprecateMsg: boolean = true) {
super();
this.store = new IndexedDBStore((e): void => {
if (e) {
cb(e);
} else {
this.init(this.store, (e?) => {
cb(e, this);
});
}
}, storeName);
deprecationMessage(deprecateMsg, IndexedDBFileSystem.Name, {storeName: storeName});
}
}