From 555dd652d5941e3581e88bf491815b9fa36caab6 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Thu, 14 Mar 2019 13:13:53 -0400 Subject: [PATCH 1/8] feat: add fs.promises --- src/PromisifiedFS.js | 180 ++++++++++++++ src/__tests__/fallback.spec.js | 4 +- src/__tests__/fs.promises.spec.js | 389 ++++++++++++++++++++++++++++++ src/index.js | 284 +++------------------- 4 files changed, 609 insertions(+), 248 deletions(-) create mode 100644 src/PromisifiedFS.js create mode 100755 src/__tests__/fs.promises.spec.js diff --git a/src/PromisifiedFS.js b/src/PromisifiedFS.js new file mode 100644 index 0000000..50fd58b --- /dev/null +++ b/src/PromisifiedFS.js @@ -0,0 +1,180 @@ +const { encode, decode } = require("isomorphic-textencoder"); +const debounce = require("just-debounce-it"); + +const Stat = require("./Stat.js"); +const CacheFS = require("./CacheFS.js"); +const { ENOENT, ENOTEMPTY } = require("./errors.js"); +const IdbBackend = require("./IdbBackend.js"); +const HttpBackend = require("./HttpBackend.js") + +const path = require("./path.js"); +const clock = require("./clock.js"); + +function cleanParams(filepath, opts) { + // normalize paths + filepath = path.normalize(filepath); + // strip out callbacks + if (typeof opts === "undefined" || typeof opts === "function") { + opts = {}; + } + // expand string options to encoding options + if (typeof opts === "string") { + opts = { + encoding: opts, + }; + } + return [filepath, opts]; +} + +function cleanParams2(oldFilepath, newFilepath) { + // normalize paths + oldFilepath = path.normalize(oldFilepath); + newFilepath = path.normalize(newFilepath); + return [oldFilepath, newFilepath]; +} + +module.exports = class PromisifiedFS { + constructor(name, { wipe, url } = {}) { + this._backend = new IdbBackend(name); + this._cache = new CacheFS(name); + this.saveSuperblock = debounce(() => { + this._saveSuperblock(); + }, 500); + if (url) { + this._fallback = new HttpBackend(url) + } + if (wipe) { + this.superblockPromise = this._wipe(); + } else { + this.superblockPromise = this._loadSuperblock(); + } + // Needed so things don't break if you destructure fs and pass individual functions around + this.readFile = this.readFile.bind(this) + this.writeFile = this.writeFile.bind(this) + this.unlink = this.unlink.bind(this) + this.readdir = this.readdir.bind(this) + this.mkdir = this.mkdir.bind(this) + this.rmdir = this.rmdir.bind(this) + this.rename = this.rename.bind(this) + this.stat = this.stat.bind(this) + this.lstat = this.lstat.bind(this) + this.readlink = this.readlink.bind(this) + this.symlink = this.symlink.bind(this) + } + _wipe() { + return this._backend.wipe().then(() => { + if (this._fallback) { + return this._fallback.loadSuperblock().then(text => { + if (text) { + this._cache.loadSuperBlock(text) + } + }) + } + }).then(() => this._saveSuperblock()); + } + _saveSuperblock() { + return this._backend.saveSuperblock(this._cache._root); + } + _loadSuperblock() { + return this._backend.loadSuperblock().then(root => { + if (root) { + this._cache.loadSuperBlock(root); + } else if (this._fallback) { + return this._fallback.loadSuperblock().then(text => { + if (text) { + this._cache.loadSuperBlock(text) + } + }) + } + }); + } + async readFile(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + const { encoding } = opts; + if (encoding && encoding !== 'utf8') throw new Error('Only "utf8" encoding is supported in readFile'); + await this.superblockPromise + const stat = this._cache.stat(filepath); + let data = await this._backend.readFile(stat.ino) + if (!data && this._fallback) { + data = await this._fallback.readFile(filepath) + } + if (data && encoding === "utf8") { + data = decode(data); + } + return data; + } + async writeFile(filepath, data, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + const { mode, encoding = "utf8" } = opts; + if (typeof data === "string") { + if (encoding !== "utf8") { + return cb(new Error('Only "utf8" encoding is supported in writeFile')); + } + data = encode(data); + } + await this.superblockPromise + let stat = this._cache.writeFile(filepath, data, { mode }); + await this._backend.writeFile(stat.ino, data) + return null + } + async unlink(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + await this.superblockPromise + let stat = this._cache.stat(filepath); + this._cache.unlink(filepath); + await this._backend.unlink(stat.ino) + return null + } + async readdir(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + await this.superblockPromise + let data = this._cache.readdir(filepath); + return data + } + async mkdir(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + const { mode = 0o777 } = opts; + await this.superblockPromise + await this._cache.mkdir(filepath, { mode }); + return null + } + async rmdir(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + // Never allow deleting the root directory. + if (filepath === "/") { + throw new ENOTEMPTY(); + } + await this.superblockPromise + this._cache.rmdir(filepath); + return null; + } + async rename(oldFilepath, newFilepath) { + ;[oldFilepath, newFilepath] = cleanParams2(oldFilepath, newFilepath); + await this.superblockPromise + this._cache.rename(oldFilepath, newFilepath); + return null; + } + async stat(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + await this.superblockPromise + let data = this._cache.stat(filepath); + return new Stat(data); + } + async lstat(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + await this.superblockPromise + let data = this._cache.lstat(filepath); + return new Stat(data); + } + async readlink(filepath, opts) { + ;[filepath, opts] = cleanParams(filepath, opts); + await this.superblockPromise + return this._cache.readlink(filepath); + } + async symlink(target, filepath) { + ;[target, filepath] = cleanParams2(target, filepath); + await this.superblockPromise + this._cache.symlink(target, filepath); + return null; + } +} diff --git a/src/__tests__/fallback.spec.js b/src/__tests__/fallback.spec.js index 1451a50..c178e86 100644 --- a/src/__tests__/fallback.spec.js +++ b/src/__tests__/fallback.spec.js @@ -4,10 +4,10 @@ const fs = new FS("fallbackfs", { wipe: true, url: 'http://localhost:9876/base/s describe("http fallback", () => { it("sanity check", () => { - expect(fs._fallback).not.toBeFalsy() + expect(fs.promises._fallback).not.toBeFalsy() }) it("loads", (done) => { - fs.superblockPromise.then(() => { + fs.promises.superblockPromise.then(() => { done() }).catch(err => { expect(err).toBe(null) diff --git a/src/__tests__/fs.promises.spec.js b/src/__tests__/fs.promises.spec.js new file mode 100755 index 0000000..9f034df --- /dev/null +++ b/src/__tests__/fs.promises.spec.js @@ -0,0 +1,389 @@ +import FS from "../index.js"; + +const fs = new FS("testfs", { wipe: true }).promises; + +const HELLO = new Uint8Array([72, 69, 76, 76, 79]); + +describe("fs.promises module", () => { + describe("mkdir", () => { + it("root directory already exists", (done) => { + fs.mkdir("/").catch(err => { + expect(err).not.toBe(null); + console.log(err) + expect(err.code).toEqual("EEXIST"); + done(); + }); + }); + it("create empty directory", done => { + fs.mkdir("/mkdir-test") + .then(() => { + fs.stat("/mkdir-test").then(stat => { + done(); + }); + }) + .catch(err => { + expect(err.code).toEqual("EEXIST"); + done(); + }); + }); + }); + + describe("writeFile", () => { + it("create file", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-uint8.txt", HELLO).then(() => { + fs.stat("/writeFile/writeFile-uint8.txt").then(stats => { + expect(stats.size).toEqual(5); + done(); + }); + }); + }); + }); + it("create file (from string)", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-string.txt", "HELLO").then(() => { + fs.stat("/writeFile/writeFile-string.txt").then(stats => { + expect(stats.size).toEqual(5); + done(); + }); + }); + }); + }); + it("write file perserves old inode", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-inode.txt", "HELLO").then(() => { + fs.stat("/writeFile/writeFile-inode.txt").then(stats => { + let inode = stats.ino; + fs.writeFile("/writeFile/writeFile-inode.txt", "WORLD").then(() => { + fs.stat("/writeFile/writeFile-inode.txt").then(stats => { + expect(stats.ino).toEqual(inode); + done(); + }); + }); + }); + }); + }); + }); + }); + + describe("readFile", () => { + it("read non-existant file throws", done => { + fs.readFile("/readFile/non-existant.txt").catch(err => { + expect(err).not.toBe(null); + done(); + }); + }); + it("read file", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-uint8.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-uint8.txt").then(data => { + // instanceof comparisons on Uint8Array's retrieved from IDB are broken in Safari Mobile 11.x (source: https://github.com/dfahlander/Dexie.js/issues/656#issuecomment-391866600) + expect([...data]).toEqual([...HELLO]); + done(); + }); + }); + }); + }); + it("read file (encoding shorthand)", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-encoding-shorthand.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-encoding-shorthand.txt", "utf8").then(data => { + expect(data).toEqual("HELLO"); + done(); + }); + }); + }); + }); + it("read file (encoding longhand)", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-encoding-longhand.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-encoding-longhand.txt", { encoding: "utf8" }).then(data => { + expect(data).toEqual("HELLO"); + done(); + }); + }); + }); + }); + }); + + describe("readdir", () => { + it("read non-existant dir returns undefined", done => { + fs.readdir("/readdir/non-existant").catch(err => { + expect(err).not.toBe(null); + done(); + }); + }); + it("read root directory", done => { + fs.mkdir("/readdir").finally(() => { + fs.readdir("/").then(data => { + expect(data.includes("readdir")).toBe(true); + done(); + }); + }); + }); + it("read child directory", done => { + fs.mkdir("/readdir").finally(() => { + fs.writeFile("/readdir/1.txt", "").then(() => { + fs.readdir("/readdir").then(data => { + expect(data).toEqual(["1.txt"]) + done(); + }); + }); + }); + }); + }); + + describe("rmdir", () => { + it("delete root directory fails", done => { + fs.rmdir("/").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOTEMPTY"); + done(); + }); + }); + it("delete non-existant directory fails", done => { + fs.rmdir("/rmdir/non-existant").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOENT"); + done(); + }); + }); + it("delete non-empty directory fails", done => { + fs.mkdir("/rmdir").finally(() => { + fs.mkdir("/rmdir/not-empty").finally(() => { + fs.writeFile("/rmdir/not-empty/file.txt", "").then(() => { + + fs.rmdir("/rmdir/not-empty").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOTEMPTY"); + done(); + }); + }) + }) + }) + }); + it("delete empty directory", done => { + fs.mkdir("/rmdir").finally(() => { + fs.mkdir("/rmdir/empty").finally(() => { + fs.readdir("/rmdir").then(data => { + let originalSize = data.length; + fs.rmdir("/rmdir/empty").then(() => { + fs.readdir("/rmdir").then(data => { + expect(data.length === originalSize - 1); + expect(data.includes("empty")).toBe(false); + done(); + }); + }); + }); + }); + }); + }); + }); + + describe("unlink", () => { + it("create and delete file", done => { + fs.mkdir("/unlink").finally(() => { + fs.writeFile("/unlink/file.txt", "").then(() => { + fs.readdir("/unlink").then(data => { + let originalSize = data.length; + fs.unlink("/unlink/file.txt").then(() => { + fs.readdir("/unlink").then(data => { + expect(data.length).toBe(originalSize - 1) + expect(data.includes("file.txt")).toBe(false); + fs.readFile("/unlink/file.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("rename", () => { + it("create and rename file", done => { + fs.mkdir("/rename").finally(() => { + fs.writeFile("/rename/a.txt", "").then(() => { + fs.rename("/rename/a.txt", "/rename/b.txt").then(() => { + fs.readdir("/rename").then(data => { + expect(data.includes("a.txt")).toBe(false); + expect(data.includes("b.txt")).toBe(true); + fs.readFile("/rename/a.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + fs.readFile("/rename/b.txt", "utf8").then(data => { + expect(data).toBe("") + done(); + }); + }); + }); + }); + }); + }); + }); + it("create and rename directory", done => { + fs.mkdir("/rename").finally(() => { + fs.mkdir("/rename/a").finally(() => { + fs.writeFile("/rename/a/file.txt", "").then(() => { + fs.rename("/rename/a", "/rename/b").then(() => { + fs.readdir("/rename").then(data => { + expect(data.includes("a")).toBe(false); + expect(data.includes("b")).toBe(true); + fs.readFile("/rename/a/file.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + fs.readFile("/rename/b/file.txt", "utf8").then(data => { + expect(data).toBe("") + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("symlink", () => { + it("symlink a file and read/write to it", done => { + fs.mkdir("/symlink").finally(() => { + fs.writeFile("/symlink/a.txt", "hello").then(() => { + fs.symlink("/symlink/a.txt", "/symlink/b.txt").then(() => { + fs.readFile("/symlink/b.txt", "utf8").then(data => { + expect(data).toBe("hello") + fs.writeFile("/symlink/b.txt", "world").then(() => { + fs.readFile("/symlink/a.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + it("symlink a file and read/write to it (relative)", done => { + fs.mkdir("/symlink").finally(() => { + fs.writeFile("/symlink/a.txt", "hello").then(() => { + fs.symlink("a.txt", "/symlink/b.txt").then(() => { + fs.readFile("/symlink/b.txt", "utf8").then(data => { + expect(data).toBe("hello") + fs.writeFile("/symlink/b.txt", "world").then(() => { + fs.readFile("/symlink/a.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + it("symlink a directory and read/write to it", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/a").finally(() => { + fs.writeFile("/symlink/a/file.txt", "data").then(() => { + fs.symlink("/symlink/a", "/symlink/b").then(() => { + fs.readdir("/symlink/b").then(data => { + expect(data.includes("file.txt")).toBe(true); + fs.readFile("/symlink/b/file.txt", "utf8").then(data => { + expect(data).toBe("data") + fs.writeFile("/symlink/b/file2.txt", "world").then(() => { + fs.readFile("/symlink/a/file2.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + }); + }); + it("symlink a directory and read/write to it (relative)", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/a").finally(() => { + fs.mkdir("/symlink/b").finally(() => { + fs.writeFile("/symlink/a/file.txt", "data").then(() => { + fs.symlink("../a", "/symlink/b/c").then(() => { + fs.readdir("/symlink/b/c").then(data => { + expect(data.includes("file.txt")).toBe(true); + fs.readFile("/symlink/b/c/file.txt", "utf8").then(data => { + expect(data).toBe("data") + fs.writeFile("/symlink/b/c/file2.txt", "world").then(() => { + fs.readFile("/symlink/a/file2.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + }); + }); + }); + it("unlink doesn't follow symlinks", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/del").finally(() => { + fs.writeFile("/symlink/del/file.txt", "data").then(() => { + fs.symlink("/symlink/del/file.txt", "/symlink/del/file2.txt").then(() => { + fs.readdir("/symlink/del").then(data => { + expect(data.includes("file.txt")).toBe(true) + expect(data.includes("file2.txt")).toBe(true) + fs.unlink("/symlink/del/file2.txt").then(data => { + fs.readdir("/symlink/del").then(data => { + expect(data.includes("file.txt")).toBe(true) + expect(data.includes("file2.txt")).toBe(false) + done(); + }); + }); + }); + }); + }); + }); + }); + }); + it("lstat doesn't follow symlinks", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/lstat").finally(() => { + fs.writeFile("/symlink/lstat/file.txt", "data").then(() => { + fs.symlink("/symlink/lstat/file.txt", "/symlink/lstat/file2.txt").then(() => { + fs.stat("/symlink/lstat/file2.txt").then(stat => { + expect(stat.isFile()).toBe(true) + expect(stat.isSymbolicLink()).toBe(false) + fs.lstat("/symlink/lstat/file2.txt").then(stat => { + expect(stat.isFile()).toBe(false) + expect(stat.isSymbolicLink()).toBe(true) + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("readlink", () => { + it("readlink returns the target path", done => { + fs.mkdir("/readlink").finally(() => { + fs.writeFile("/readlink/a.txt", "hello").then(() => { + fs.symlink("/readlink/a.txt", "/readlink/b.txt").then(() => { + fs.readlink("/readlink/b.txt", "utf8").then(data => { + expect(data).toBe("/readlink/a.txt") + done(); + }); + }); + }); + }); + }); + }); + +}); diff --git a/src/index.js b/src/index.js index a4abbaa..e27e74b 100755 --- a/src/index.js +++ b/src/index.js @@ -1,285 +1,77 @@ const once = require("just-once"); -const { encode, decode } = require("isomorphic-textencoder"); -const debounce = require("just-debounce-it"); -const path = require("./path.js"); -const Stat = require("./Stat.js"); -const CacheFS = require("./CacheFS.js"); -const { ENOENT, ENOTEMPTY } = require("./errors.js"); -const IdbBackend = require("./IdbBackend.js"); -const HttpBackend = require("./HttpBackend.js") -const clock = require("./clock.js"); +const PromisifiedFS = require('./PromisifiedFS'); + +function wrapCallback (opts, cb) { + if (typeof opts === "function") { + cb = opts; + } + const _cb = cb; + cb = once((...args) => { + _cb(...args); + }); + const resolve = (...args) => cb(null, ...args) + return [resolve, cb]; +} module.exports = class FS { - constructor(name, { wipe, url } = {}) { - this._backend = new IdbBackend(name); - this._cache = new CacheFS(name); - this.saveSuperblock = debounce(() => { - this._saveSuperblock(); - }, 500); - if (url) { - this._fallback = new HttpBackend(url) - } - if (wipe) { - this.superblockPromise = this._wipe(); - } else { - this.superblockPromise = this._loadSuperblock(); - } + constructor(...args) { + this.promises = new PromisifiedFS(...args) // Needed so things don't break if you destructure fs and pass individual functions around this.readFile = this.readFile.bind(this) this.writeFile = this.writeFile.bind(this) this.unlink = this.unlink.bind(this) + this.readdir = this.readdir.bind(this) this.mkdir = this.mkdir.bind(this) this.rmdir = this.rmdir.bind(this) - this.readdir = this.readdir.bind(this) this.rename = this.rename.bind(this) this.stat = this.stat.bind(this) this.lstat = this.lstat.bind(this) this.readlink = this.readlink.bind(this) this.symlink = this.symlink.bind(this) } - _cleanParams(filepath, opts, cb, stopClock = null, save = false) { - filepath = path.normalize(filepath); - if (typeof opts === "function") { - cb = opts; - opts = {}; - } - if (typeof opts === "string") { - opts = { - encoding: opts, - }; - } - const _cb = cb; - cb = once((...args) => { - if (stopClock) stopClock(); - if (save) this.saveSuperblock(); - _cb(...args); - }); - return [filepath, opts, cb]; - } - _cleanParams2(oldFilepath, newFilepath, cb, stopClock = null, save = false) { - oldFilepath = path.normalize(oldFilepath); - newFilepath = path.normalize(newFilepath); - const _cb = cb; - cb = once((...args) => { - if (stopClock) stopClock(); - if (save) this.saveSuperblock(); - _cb(...args); - }); - return [oldFilepath, newFilepath, cb]; - } - _wipe() { - return this._backend.wipe().then(() => { - if (this._fallback) { - return this._fallback.loadSuperblock().then(text => { - if (text) { - this._cache.loadSuperBlock(text) - } - }) - } - }).then(() => this._saveSuperblock()); - } - _saveSuperblock() { - return this._backend.saveSuperblock(this._cache._root); - } - _loadSuperblock() { - return this._backend.loadSuperblock().then(root => { - if (root) { - this._cache.loadSuperBlock(root); - } else if (this._fallback) { - return this._fallback.loadSuperblock().then(text => { - if (text) { - this._cache.loadSuperBlock(text) - } - }) - } - }); - } readFile(filepath, opts, cb) { - const stopClock = clock(`readFile ${filepath}`); - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb, stopClock); - - const { encoding } = opts; - this.superblockPromise - .then(() => { - let stat - try { - stat = this._cache.stat(filepath); - } catch (err) { - return cb(err); - } - this._backend.readFile(stat.ino) - .then(data => { - if (data || !this._fallback) { - return data - } else { - return this._fallback.readFile(filepath) - } - }) - .then(data => { - if (data) { - if (encoding === "utf8") { - data = decode(data); - } - } - cb(null, data); - }) - .catch(err => { - console.log("ERROR: readFile: stat data out of sync with db:", filepath); - }); - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.readFile(filepath, opts).then(resolve).catch(reject) } writeFile(filepath, data, opts, cb) { - let stop = clock(`writeFile ${filepath}`); - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb, stop, true); - - const { mode, encoding = "utf8" } = opts; - if (typeof data === "string") { - if (encoding !== "utf8") { - return cb(new Error('Only "utf8" encoding is supported in writeFile')); - } - data = encode(data); - } - this.superblockPromise - .then(() => { - let stat - try { - stat = this._cache.writeFile(filepath, data, { mode }); - } catch (err) { - return cb(err); - } - this._backend.writeFile(stat.ino, data) - .then(() => cb(null)) - .catch(err => cb(err)); - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.writeFile(filepath, data, opts).then(resolve).catch(reject); } unlink(filepath, opts, cb) { - let stop = clock(`unlink ${filepath}`); - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb, stop, true); - this.superblockPromise - .then(() => { - let stat - try { - stat = this._cache.stat(filepath); - this._cache.unlink(filepath); - } catch (err) { - return cb(err); - } - this._backend.unlink(stat.ino) - .then(() => cb(null)) - .catch(cb); - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.unlink(filepath, opts).then(resolve).catch(reject); } readdir(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb); - this.superblockPromise - .then(() => { - try { - let data = this._cache.readdir(filepath); - return cb(null, data); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.readdir(filepath, opts).then(resolve).catch(reject); } mkdir(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb, null, true); - const { mode = 0o777 } = opts; - this.superblockPromise - .then(() => { - try { - this._cache.mkdir(filepath, { mode }); - return cb(null); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.mkdir(filepath, opts).then(resolve).catch(reject) } rmdir(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb, null, true); - // Never allow deleting the root directory. - if (filepath === "/") { - return cb(new ENOTEMPTY()); - } - this.superblockPromise - .then(() => { - try { - this._cache.rmdir(filepath); - return cb(null); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.rmdir(filepath, opts).then(resolve).catch(reject) } rename(oldFilepath, newFilepath, cb) { - [oldFilepath, newFilepath, cb] = this._cleanParams2(oldFilepath, newFilepath, cb, null, true); - this.superblockPromise - .then(() => { - try { - this._cache.rename(oldFilepath, newFilepath); - return cb(null); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(cb); + this.promises.rename(oldFilepath, newFilepath).then(resolve).catch(reject) } stat(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb); - this.superblockPromise - .then(() => { - try { - let data = this._cache.stat(filepath); - return cb(null, new Stat(data)); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.stat(filepath).then(resolve).catch(reject); } lstat(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb); - this.superblockPromise - .then(() => { - try { - let data = this._cache.lstat(filepath); - return cb(null, new Stat(data)); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.lstat(filepath).then(resolve).catch(reject); } readlink(filepath, opts, cb) { - [filepath, opts, cb] = this._cleanParams(filepath, opts, cb); - this.superblockPromise - .then(() => { - try { - let data = this._cache.readlink(filepath); - return cb(null, data); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(opts, cb); + this.promises.readlink(filepath).then(resolve).catch(reject); } symlink(target, filepath, cb) { - [target, filepath, cb] = this._cleanParams2(target, filepath, cb, null, true); - this.superblockPromise - .then(() => { - try { - this._cache.symlink(target, filepath); - return cb(null); - } catch (err) { - return cb(err); - } - }) - .catch(cb); + const [resolve, reject] = wrapCallback(cb); + this.promises.symlink(target, filepath).then(resolve).catch(reject); } } From f6d2ac9a712d6c75859536b470e0be286f832000 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Sun, 17 Mar 2019 22:16:26 -0400 Subject: [PATCH 2/8] cleanup --- src/PromisifiedFS.js | 13 +++++-------- src/index.js | 5 +---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/PromisifiedFS.js b/src/PromisifiedFS.js index 50fd58b..cc8a355 100644 --- a/src/PromisifiedFS.js +++ b/src/PromisifiedFS.js @@ -28,9 +28,7 @@ function cleanParams(filepath, opts) { function cleanParams2(oldFilepath, newFilepath) { // normalize paths - oldFilepath = path.normalize(oldFilepath); - newFilepath = path.normalize(newFilepath); - return [oldFilepath, newFilepath]; + return [path.normalize(oldFilepath), path.normalize(newFilepath)]; } module.exports = class PromisifiedFS { @@ -113,14 +111,14 @@ module.exports = class PromisifiedFS { data = encode(data); } await this.superblockPromise - let stat = this._cache.writeFile(filepath, data, { mode }); + const stat = this._cache.writeFile(filepath, data, { mode }); await this._backend.writeFile(stat.ino, data) return null } async unlink(filepath, opts) { ;[filepath, opts] = cleanParams(filepath, opts); await this.superblockPromise - let stat = this._cache.stat(filepath); + const stat = this._cache.stat(filepath); this._cache.unlink(filepath); await this._backend.unlink(stat.ino) return null @@ -128,8 +126,7 @@ module.exports = class PromisifiedFS { async readdir(filepath, opts) { ;[filepath, opts] = cleanParams(filepath, opts); await this.superblockPromise - let data = this._cache.readdir(filepath); - return data + return this._cache.readdir(filepath); } async mkdir(filepath, opts) { ;[filepath, opts] = cleanParams(filepath, opts); @@ -157,7 +154,7 @@ module.exports = class PromisifiedFS { async stat(filepath, opts) { ;[filepath, opts] = cleanParams(filepath, opts); await this.superblockPromise - let data = this._cache.stat(filepath); + const data = this._cache.stat(filepath); return new Stat(data); } async lstat(filepath, opts) { diff --git a/src/index.js b/src/index.js index e27e74b..70e3c38 100755 --- a/src/index.js +++ b/src/index.js @@ -6,10 +6,7 @@ function wrapCallback (opts, cb) { if (typeof opts === "function") { cb = opts; } - const _cb = cb; - cb = once((...args) => { - _cb(...args); - }); + cb = once(cb); const resolve = (...args) => cb(null, ...args) return [resolve, cb]; } From fae4441be42e5bd058d2037fa5449807267dd391 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Thu, 14 Mar 2019 14:17:47 -0400 Subject: [PATCH 3/8] chore: rename _backend to _idb and _fallback to _http --- src/PromisifiedFS.js | 28 ++++++++++++++-------------- src/__tests__/fallback.spec.js | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/PromisifiedFS.js b/src/PromisifiedFS.js index cc8a355..6b777df 100644 --- a/src/PromisifiedFS.js +++ b/src/PromisifiedFS.js @@ -33,13 +33,13 @@ function cleanParams2(oldFilepath, newFilepath) { module.exports = class PromisifiedFS { constructor(name, { wipe, url } = {}) { - this._backend = new IdbBackend(name); + this._idb = new IdbBackend(name); this._cache = new CacheFS(name); this.saveSuperblock = debounce(() => { this._saveSuperblock(); }, 500); if (url) { - this._fallback = new HttpBackend(url) + this._http = new HttpBackend(url) } if (wipe) { this.superblockPromise = this._wipe(); @@ -60,9 +60,9 @@ module.exports = class PromisifiedFS { this.symlink = this.symlink.bind(this) } _wipe() { - return this._backend.wipe().then(() => { - if (this._fallback) { - return this._fallback.loadSuperblock().then(text => { + return this._idb.wipe().then(() => { + if (this._http) { + return this._http.loadSuperblock().then(text => { if (text) { this._cache.loadSuperBlock(text) } @@ -71,14 +71,14 @@ module.exports = class PromisifiedFS { }).then(() => this._saveSuperblock()); } _saveSuperblock() { - return this._backend.saveSuperblock(this._cache._root); + return this._idb.saveSuperblock(this._cache._root); } _loadSuperblock() { - return this._backend.loadSuperblock().then(root => { + return this._idb.loadSuperblock().then(root => { if (root) { this._cache.loadSuperBlock(root); - } else if (this._fallback) { - return this._fallback.loadSuperblock().then(text => { + } else if (this._http) { + return this._http.loadSuperblock().then(text => { if (text) { this._cache.loadSuperBlock(text) } @@ -92,9 +92,9 @@ module.exports = class PromisifiedFS { if (encoding && encoding !== 'utf8') throw new Error('Only "utf8" encoding is supported in readFile'); await this.superblockPromise const stat = this._cache.stat(filepath); - let data = await this._backend.readFile(stat.ino) - if (!data && this._fallback) { - data = await this._fallback.readFile(filepath) + let data = await this._idb.readFile(stat.ino) + if (!data && this._http) { + data = await this._http.readFile(filepath) } if (data && encoding === "utf8") { data = decode(data); @@ -112,7 +112,7 @@ module.exports = class PromisifiedFS { } await this.superblockPromise const stat = this._cache.writeFile(filepath, data, { mode }); - await this._backend.writeFile(stat.ino, data) + await this._idb.writeFile(stat.ino, data) return null } async unlink(filepath, opts) { @@ -120,7 +120,7 @@ module.exports = class PromisifiedFS { await this.superblockPromise const stat = this._cache.stat(filepath); this._cache.unlink(filepath); - await this._backend.unlink(stat.ino) + await this._idb.unlink(stat.ino) return null } async readdir(filepath, opts) { diff --git a/src/__tests__/fallback.spec.js b/src/__tests__/fallback.spec.js index c178e86..036ec49 100644 --- a/src/__tests__/fallback.spec.js +++ b/src/__tests__/fallback.spec.js @@ -4,7 +4,7 @@ const fs = new FS("fallbackfs", { wipe: true, url: 'http://localhost:9876/base/s describe("http fallback", () => { it("sanity check", () => { - expect(fs.promises._fallback).not.toBeFalsy() + expect(fs.promises._http).not.toBeFalsy() }) it("loads", (done) => { fs.promises.superblockPromise.then(() => { From 046126a3872d6cc21e3bd10eb8aa600fc0a8b768 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Thu, 14 Mar 2019 14:31:15 -0400 Subject: [PATCH 4/8] chore: rename superblockPromise to _init() --- src/PromisifiedFS.js | 37 +++++++++++++++++++--------------- src/__tests__/fallback.spec.js | 2 +- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/PromisifiedFS.js b/src/PromisifiedFS.js index 6b777df..20178a4 100644 --- a/src/PromisifiedFS.js +++ b/src/PromisifiedFS.js @@ -35,17 +35,14 @@ module.exports = class PromisifiedFS { constructor(name, { wipe, url } = {}) { this._idb = new IdbBackend(name); this._cache = new CacheFS(name); + this._opts = { wipe, url }; this.saveSuperblock = debounce(() => { this._saveSuperblock(); }, 500); if (url) { this._http = new HttpBackend(url) } - if (wipe) { - this.superblockPromise = this._wipe(); - } else { - this.superblockPromise = this._loadSuperblock(); - } + this._initPromise = this._init() // Needed so things don't break if you destructure fs and pass individual functions around this.readFile = this.readFile.bind(this) this.writeFile = this.writeFile.bind(this) @@ -59,6 +56,14 @@ module.exports = class PromisifiedFS { this.readlink = this.readlink.bind(this) this.symlink = this.symlink.bind(this) } + async _init() { + if (this._initPromise) return this._initPromise + if (this._opts.wipe) { + await this._wipe(); + } else { + await this._loadSuperblock(); + } + } _wipe() { return this._idb.wipe().then(() => { if (this._http) { @@ -87,10 +92,10 @@ module.exports = class PromisifiedFS { }); } async readFile(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); const { encoding } = opts; if (encoding && encoding !== 'utf8') throw new Error('Only "utf8" encoding is supported in readFile'); - await this.superblockPromise const stat = this._cache.stat(filepath); let data = await this._idb.readFile(stat.ino) if (!data && this._http) { @@ -102,6 +107,7 @@ module.exports = class PromisifiedFS { return data; } async writeFile(filepath, data, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); const { mode, encoding = "utf8" } = opts; if (typeof data === "string") { @@ -110,67 +116,66 @@ module.exports = class PromisifiedFS { } data = encode(data); } - await this.superblockPromise const stat = this._cache.writeFile(filepath, data, { mode }); await this._idb.writeFile(stat.ino, data) return null } async unlink(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); - await this.superblockPromise const stat = this._cache.stat(filepath); this._cache.unlink(filepath); await this._idb.unlink(stat.ino) return null } async readdir(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); - await this.superblockPromise return this._cache.readdir(filepath); } async mkdir(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); const { mode = 0o777 } = opts; - await this.superblockPromise await this._cache.mkdir(filepath, { mode }); return null } async rmdir(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); // Never allow deleting the root directory. if (filepath === "/") { throw new ENOTEMPTY(); } - await this.superblockPromise this._cache.rmdir(filepath); return null; } async rename(oldFilepath, newFilepath) { + await this._init() ;[oldFilepath, newFilepath] = cleanParams2(oldFilepath, newFilepath); - await this.superblockPromise this._cache.rename(oldFilepath, newFilepath); return null; } async stat(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); - await this.superblockPromise const data = this._cache.stat(filepath); return new Stat(data); } async lstat(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); - await this.superblockPromise let data = this._cache.lstat(filepath); return new Stat(data); } async readlink(filepath, opts) { + await this._init() ;[filepath, opts] = cleanParams(filepath, opts); - await this.superblockPromise return this._cache.readlink(filepath); } async symlink(target, filepath) { + await this._init() ;[target, filepath] = cleanParams2(target, filepath); - await this.superblockPromise this._cache.symlink(target, filepath); return null; } diff --git a/src/__tests__/fallback.spec.js b/src/__tests__/fallback.spec.js index 036ec49..f09f901 100644 --- a/src/__tests__/fallback.spec.js +++ b/src/__tests__/fallback.spec.js @@ -7,7 +7,7 @@ describe("http fallback", () => { expect(fs.promises._http).not.toBeFalsy() }) it("loads", (done) => { - fs.promises.superblockPromise.then(() => { + fs.promises._init().then(() => { done() }).catch(err => { expect(err).toBe(null) From c8ba1482cc9116ea02372ec568a6e9ca9212bb42 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Sun, 17 Mar 2019 22:34:50 -0400 Subject: [PATCH 5/8] polyfill Promise.finally in tests --- src/__tests__/fs.promises.spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/__tests__/fs.promises.spec.js b/src/__tests__/fs.promises.spec.js index 9f034df..b645067 100755 --- a/src/__tests__/fs.promises.spec.js +++ b/src/__tests__/fs.promises.spec.js @@ -4,6 +4,12 @@ const fs = new FS("testfs", { wipe: true }).promises; const HELLO = new Uint8Array([72, 69, 76, 76, 79]); +if (!Promise.prototype.finally) { + Promise.prototype.finally = function (onFinally) { + this.then(onFinally, onFinally); + } +} + describe("fs.promises module", () => { describe("mkdir", () => { it("root directory already exists", (done) => { From 44a57a76a23798fd141ff30b446282c78bbc2bbd Mon Sep 17 00:00:00 2001 From: William Hilton Date: Sun, 17 Mar 2019 22:37:32 -0400 Subject: [PATCH 6/8] remove stray console.log --- src/__tests__/fs.promises.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/fs.promises.spec.js b/src/__tests__/fs.promises.spec.js index b645067..3b07509 100755 --- a/src/__tests__/fs.promises.spec.js +++ b/src/__tests__/fs.promises.spec.js @@ -15,7 +15,6 @@ describe("fs.promises module", () => { it("root directory already exists", (done) => { fs.mkdir("/").catch(err => { expect(err).not.toBe(null); - console.log(err) expect(err.code).toEqual("EEXIST"); done(); }); From 06d963d5eaf0f45d1cff3c6c52882ae3da72caf6 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Sun, 17 Mar 2019 22:47:46 -0400 Subject: [PATCH 7/8] docs: update README --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7cd28cc..55c8be4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ I wanted to see if I could make something faster than [BrowserFS](https://github ## Comparison with other libraries This library does not even come close to implementing the full [`fs`](https://nodejs.org/api/fs.html) API. -Instead, it only implements [the subset used by isomorphic-git 'fs' plugin interface](https://isomorphic-git.org/docs/en/plugin_fs). +Instead, it only implements [the subset used by isomorphic-git 'fs' plugin interface](https://isomorphic-git.org/docs/en/plugin_fs) plus the [`fs.promises`](https://nodejs.org/dist/latest-v10.x/docs/api/fs.html#fs_fs_promises_api) versions of those functions. Unlike BrowserFS, which has a dozen backends and is highly configurable, `lightning-fs` has a single configuration that should Just Work for most users. @@ -131,7 +131,19 @@ The included methods are: ### `fs.lstat(filepath, opts?, cb)` -Alias to `fs.stat` for now until symlinks are supported. +Like `fs.stat` except that paths to symlinks return the symlink stats not the file stats of the symlink's target. + +### `fs.symlink(target, filepath, cb)` + +Create a symlink at `filepath` that points to `target`. + +### `fs.readlink(filepath, opts?, cb)` + +Read the target of a symlink. + +### `fs.promises` + +All the same functions as above, but instead of passing a callback they return a promise. ## License From 72e8b21c3264d3e418812ebe03f159ef6877a0aa Mon Sep 17 00:00:00 2001 From: William Hilton Date: Sun, 17 Mar 2019 23:04:10 -0400 Subject: [PATCH 8/8] omg you did NOT see that --- src/PromisifiedFS.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/PromisifiedFS.js b/src/PromisifiedFS.js index 20178a4..43eaa1b 100644 --- a/src/PromisifiedFS.js +++ b/src/PromisifiedFS.js @@ -112,12 +112,13 @@ module.exports = class PromisifiedFS { const { mode, encoding = "utf8" } = opts; if (typeof data === "string") { if (encoding !== "utf8") { - return cb(new Error('Only "utf8" encoding is supported in writeFile')); + throw new Error('Only "utf8" encoding is supported in writeFile'); } data = encode(data); } const stat = this._cache.writeFile(filepath, data, { mode }); await this._idb.writeFile(stat.ino, data) + this.saveSuperblock(); return null } async unlink(filepath, opts) { @@ -126,6 +127,7 @@ module.exports = class PromisifiedFS { const stat = this._cache.stat(filepath); this._cache.unlink(filepath); await this._idb.unlink(stat.ino) + this.saveSuperblock(); return null } async readdir(filepath, opts) { @@ -138,6 +140,7 @@ module.exports = class PromisifiedFS { ;[filepath, opts] = cleanParams(filepath, opts); const { mode = 0o777 } = opts; await this._cache.mkdir(filepath, { mode }); + this.saveSuperblock(); return null } async rmdir(filepath, opts) { @@ -148,12 +151,14 @@ module.exports = class PromisifiedFS { throw new ENOTEMPTY(); } this._cache.rmdir(filepath); + this.saveSuperblock(); return null; } async rename(oldFilepath, newFilepath) { await this._init() ;[oldFilepath, newFilepath] = cleanParams2(oldFilepath, newFilepath); this._cache.rename(oldFilepath, newFilepath); + this.saveSuperblock(); return null; } async stat(filepath, opts) { @@ -177,6 +182,7 @@ module.exports = class PromisifiedFS { await this._init() ;[target, filepath] = cleanParams2(target, filepath); this._cache.symlink(target, filepath); + this.saveSuperblock(); return null; } }