Created
January 20, 2026 21:26
-
-
Save andy0130tw/9e524c97198d3c2adf36e07bac6cee96 to your computer and use it in GitHub Desktop.
IndexedDB as a filesystem
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Buffer } from 'buffer' | |
| ;(globalThis as any).Buffer = Buffer | |
| import git from 'isomorphic-git' | |
| import http from 'isomorphic-git/http/web' | |
| function idbRequestToPromise<T>(req: IDBRequest<T>) { | |
| return new Promise<T>((resolve, reject) => { | |
| req.onsuccess = evt => resolve((evt.target as IDBRequest<T>).result) | |
| req.onerror = evt => reject((evt.target as IDBRequest<T>).error) | |
| }) | |
| } | |
| function idbTransactionToPromise(tx: IDBTransaction) { | |
| return new Promise<Event>((resolve, reject) => { | |
| tx.oncomplete = evt => resolve(evt) | |
| tx.onabort = evt => reject({ type: 'abort', event: (evt.target! as IDBTransaction).error }) | |
| tx.onerror = evt => reject({ type: 'error', event: (evt.target! as IDBTransaction).error }) | |
| }) | |
| } | |
| async function createDatabase() { | |
| await indexedDB.databases().then(dbinfos => { | |
| if (!dbinfos.some(dbinfo => dbinfo.name === 'inbrowserfs')) { | |
| return | |
| } | |
| console.warn('deleting previous db') | |
| const deleteOldReq = indexedDB.deleteDatabase('inbrowserfs') | |
| deleteOldReq.onblocked = evt => { debugger; throw (evt.target as IDBOpenDBRequest).error } | |
| return idbRequestToPromise(deleteOldReq) | |
| }) | |
| console.log('open db') | |
| const dbOpenRequest = indexedDB.open('inbrowserfs', 1) | |
| dbOpenRequest.onupgradeneeded = evt => { | |
| console.log('onupgradeneeded') | |
| const db = (evt.target! as IDBOpenDBRequest).result | |
| db.createObjectStore('meta', { keyPath: 'name' }) | |
| db.createObjectStore('inode', { keyPath: 'ino', autoIncrement: true }) | |
| const direntStore = db.createObjectStore('dirent', { keyPath: ['parent', 'name'] }) | |
| direntStore.createIndex('dir', 'parent') | |
| const contentStore = db.createObjectStore('content', { keyPath: ['ino', 'partid'] }) | |
| contentStore.createIndex('ino', 'ino') | |
| } | |
| dbOpenRequest.onblocked = evt => { | |
| console.warn('dbopen onblocked', evt) | |
| } | |
| dbOpenRequest.onerror = evt => { | |
| debugger | |
| throw (evt.target! as IDBOpenDBRequest).error | |
| } | |
| const db = await idbRequestToPromise(dbOpenRequest) | |
| .catch(err => { | |
| debugger | |
| throw err | |
| }) | |
| console.log('db created') | |
| db.onversionchange = evt => { | |
| console.log('db onversionchange') | |
| ;(evt.target as IDBDatabase).close() | |
| } | |
| const tx = db.transaction(['meta', 'inode', 'dirent', 'content'], 'readwrite') | |
| const sb = tx.objectStore('meta') | |
| await idbRequestToPromise(sb.add({ name: 'block-size', content: 512 })) | |
| const si = tx.objectStore('inode') | |
| await idbRequestToPromise(si.add({ ino: 2, type: 'dir' })) | |
| await idbRequestToPromise(si.add({ ino: 3, type: 'file', stype: 'inline', content: 'asd' })) | |
| await idbRequestToPromise(si.add({ ino: 4, type: 'file' })) | |
| await idbRequestToPromise(si.add({ ino: 5, type: 'dir' })) | |
| const sd = tx.objectStore('dirent') | |
| await idbRequestToPromise(sd.add({ parent: 2, name: 'asd', ino: 3 })) | |
| await idbRequestToPromise(sd.add({ parent: 2, name: 'fgh', ino: 5 })) | |
| await idbRequestToPromise(sd.add({ parent: 5, name: 'foo', ino: 4 })) | |
| await idbTransactionToPromise(tx) | |
| return db | |
| } | |
| function toPathSegments(s: string): [string[], string] { | |
| if (!s.startsWith('/')) throw new Error(`illegal abs path "${s}"!`) | |
| if (s === '/') return [[], '/'] | |
| const segs = s.split('/').slice(1) | |
| return [segs.slice(0, -1), segs.at(-1)!] | |
| } | |
| class MyFSError extends Error { | |
| constructor( | |
| readonly code: string, message?: string) { | |
| super(message) | |
| } | |
| } | |
| function makeMyFSErrorClass(code: string, defaultMessage: string) { | |
| return { | |
| [code]: class extends MyFSError { | |
| constructor(message?: string) { super(code, message ?? defaultMessage) } | |
| } | |
| }[code] | |
| } | |
| const EEXIST = makeMyFSErrorClass('EEXIST', 'Path already exist') | |
| const ENOENT = makeMyFSErrorClass('ENOENT', 'No such file') | |
| const ENOTDIR = makeMyFSErrorClass('ENOTDIR', 'Not a dir') | |
| const ENOTEMPTY = makeMyFSErrorClass('ENOTEMPTY', 'Dir not empty') | |
| const ETIMEDOUT = makeMyFSErrorClass('ETIMEDOUT', 'Timed out') | |
| const EISDIR = makeMyFSErrorClass('EISDIR', 'Is a dir') | |
| class MyFileSystem { | |
| constructor(readonly db: IDBDatabase) {} | |
| async _resolvePath(segs: string[], si: IDBObjectStore, sd: IDBObjectStore): Promise<number> { | |
| let ino = 2 | |
| for (const seg of segs) { | |
| const pdir = await idbRequestToPromise(sd.get([ino, seg])) | |
| if (!pdir) { | |
| throw new ENOENT() | |
| } | |
| const isDir = await idbRequestToPromise(si.get(ino)).then(x => x?.type === 'dir') | |
| if (!isDir) { | |
| throw new ENOTDIR() | |
| } | |
| ino = pdir.ino | |
| } | |
| return ino | |
| } | |
| async readFile(path: string, options?: { encoding?: string }) { | |
| console.log('readFile', path, options) | |
| const tx = this.db.transaction(['inode', 'dirent'], 'readonly') | |
| const si = tx.objectStore('inode') | |
| const sd = tx.objectStore('dirent') | |
| if (path == '/') { | |
| throw new EISDIR() | |
| } | |
| const [_segs, fname] = toPathSegments(path) | |
| const fino = await this._resolvePath([..._segs, fname], si, sd) | |
| const f = await idbRequestToPromise(si.get(fino)) | |
| if (!f) { | |
| throw new ENOENT() | |
| } | |
| if (f.type === 'dir') { | |
| throw new EISDIR() | |
| } | |
| const tx2 = this.db.transaction(['content'], 'readonly') | |
| const sc = tx2.objectStore('content') | |
| const sci = sc.index('ino') | |
| const parts = await idbRequestToPromise(sci.getAll(fino)) | |
| const content = parts[0].content | |
| if (options?.encoding === 'utf8' || options?.encoding === 'utf-8') { | |
| return new TextDecoder().decode(content) | |
| } | |
| return content | |
| } | |
| async writeFile(path: string, content: string | Uint8Array, _options: string | { encoding?: string }) { | |
| const [segs, fname] = toPathSegments(path) | |
| const tx = this.db.transaction(['inode', 'dirent', 'content'], 'readwrite') | |
| const si = tx.objectStore('inode') | |
| const sd = tx.objectStore('dirent') | |
| const pdir = await this._resolvePath(segs, si, sd) | |
| const pino = await idbRequestToPromise(si.get(pdir)) | |
| let newIno | |
| const oldf = await idbRequestToPromise(sd.get([pino.ino, fname])) | |
| if (oldf) { | |
| // FIXME: overwrite old file | |
| newIno = oldf.ino | |
| } else { | |
| const si2 = tx.objectStore('inode') | |
| newIno = await idbRequestToPromise(si2.add({ type: 'file' })) | |
| const sd2 = tx.objectStore('dirent') | |
| await idbRequestToPromise(sd2.add({ parent: pino.ino, name: fname, ino: newIno })) | |
| } | |
| const tx2 = this.db.transaction(['content'], 'readwrite') | |
| const sc = tx2.objectStore('content') | |
| if (typeof content === 'string') { | |
| content = new TextEncoder().encode(content) | |
| } | |
| await idbRequestToPromise(sc.put({ino: newIno, partid: 0, content})) | |
| // TODO: update inode metadata | |
| await idbTransactionToPromise(tx2) | |
| } | |
| async unlink(...args) { | |
| console.log('unlink', args) | |
| } | |
| async readdir(path: string) { | |
| console.log('readdir', path) | |
| const [segs, fname] = toPathSegments(path) | |
| const tx = this.db.transaction(['inode', 'dirent'], 'readonly') | |
| const si = tx.objectStore('inode') | |
| const sd = tx.objectStore('dirent') | |
| const pdir = await this._resolvePath(segs, si, sd) | |
| const pino = await idbRequestToPromise(si.get(pdir)) | |
| if (pino.type !== 'dir') { | |
| throw new ENOENT() | |
| } | |
| const res = await idbRequestToPromise(sd.get([pino.ino, fname])) | |
| if (!res) { | |
| throw new ENOENT() | |
| } | |
| const sdi = sd.index('dir') | |
| const ks = await idbRequestToPromise(sdi.getAllKeys(res.ino)).then(ks => { | |
| return (ks as [number, string][]).map(([, name]) => name) | |
| }) | |
| return ks | |
| } | |
| async mkdir(path: string) { | |
| console.log('mkdir', path) | |
| const [segs, fname] = toPathSegments(path) | |
| const tx = this.db.transaction(['inode', 'dirent'], 'readwrite') | |
| const si = tx.objectStore('inode') | |
| const sd = tx.objectStore('dirent') | |
| const pdir = await this._resolvePath(segs, si, sd) | |
| const pino = await idbRequestToPromise(si.get(pdir)) | |
| if (pino.type !== 'dir') { | |
| throw new ENOTDIR() | |
| } | |
| if (await idbRequestToPromise(sd.get([pino.ino, fname]))) { | |
| throw new EEXIST() | |
| } | |
| const newIno = await idbRequestToPromise(si.add({ type: 'dir' })) | |
| await idbRequestToPromise(sd.add({ parent: pino.ino, name: fname, ino: newIno })) | |
| await idbTransactionToPromise(tx) | |
| } | |
| async rmdir(...args) { | |
| console.log('rmdir', args) | |
| } | |
| async stat(path: string) { | |
| console.log('stat', path) | |
| const [segs, fname] = toPathSegments(path) | |
| const tx = this.db.transaction(['inode', 'dirent'], 'readonly') | |
| const si = tx.objectStore('inode') | |
| const sd = tx.objectStore('dirent') | |
| const pdir = await this._resolvePath(segs, si, sd) | |
| const pino = await idbRequestToPromise(si.get(pdir)) | |
| if (pino.type !== 'dir') { | |
| throw new ENOENT() | |
| } | |
| const res = await idbRequestToPromise(sd.get([pino.ino, fname])) as { ino: number, type: string } | |
| if (!res) { | |
| throw new ENOENT() | |
| } | |
| return { | |
| atime: 0, | |
| mtime: 0, | |
| ctime: 0, | |
| isFile() { return res.type === 'file' }, | |
| isDirectory() { return res.type === 'dir' }, | |
| } | |
| } | |
| async lstat(path: string) { | |
| console.log('lstat', path) | |
| // FIXME: this is lame | |
| return this.stat(path) | |
| } | |
| async readlink(...args) { | |
| console.log('readlink', args) | |
| } | |
| async symlink(...args) { | |
| console.log('symlink', args) | |
| throw 1 | |
| } | |
| } | |
| async function main() { | |
| const db = await createDatabase() | |
| const myfs = new MyFileSystem(db) | |
| const fs = { promises: myfs } | |
| await git.clone({ | |
| fs, | |
| http, | |
| dir: "/runtime", | |
| corsProxy: 'https://cors.isomorphic-git.org', | |
| url: 'https://github.com/observablehq/runtime', | |
| ref: 'main', | |
| // singleBranch: true, | |
| // depth: 10 | |
| }) | |
| console.log('done!') | |
| console.log(await git.log({fs, dir: '/runtime'})) | |
| } | |
| main() | |
| export default {} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment