diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c2794bd..c73297f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,11 +97,31 @@ jobs: name: prebuilds path: prebuilds + build-wasm: + runs-on: ubuntu-latest + container: + image: emscripten/emsdk + steps: + - uses: actions/checkout@v3 + - run: corepack enable + - uses: actions/setup-node@v3 + with: + cache: yarn + node-version: 18 + - run: yarn --frozen-lockfile + - run: make wasm + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: wasm + path: build/Release/watcher.wasm + release: runs-on: ubuntu-latest needs: - build - build-freebsd + - build-wasm steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -111,7 +131,9 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v3 - name: Build npm packages - run: node scripts/build-npm.js + run: | + node scripts/build-npm.js + node scripts/build-wasm.js - name: Publish to npm env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bd7c057..47c4a501 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,6 @@ on: push: branches: [master] pull_request: - branches: [master] jobs: test: @@ -51,6 +50,7 @@ jobs: watchman -v - run: yarn --frozen-lockfile - run: yarn test + test-freebsd: runs-on: macos-latest steps: @@ -68,3 +68,18 @@ jobs: run: | yarn --frozen-lockfile yarn test + + test-wasm: + runs-on: ubuntu-latest + container: + image: emscripten/emsdk + steps: + - uses: actions/checkout@v3 + - run: corepack enable + - uses: actions/setup-node@v3 + with: + cache: yarn + node-version: 18 + - run: yarn --frozen-lockfile + - run: make wasm-debug + - run: TEST_WASM=1 yarn test diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..be4821e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +NODE_PATH := $(dir $(shell which node)) +HEADERS_URL := $(shell node -p "process.release.headersUrl") +NODE_VERSION := $(shell node -p "process.version") + +INCS_Debug := \ + -Ibuild/node-$(NODE_VERSION)/include/node \ + -I$(shell node -p "require('node-addon-api').include_dir") + +SRC := src/binding.cc src/Watcher.cc src/Backend.cc src/DirTree.cc src/Glob.cc src/Debounce.cc src/shared/BruteForceBackend.cc src/unix/legacy.cc src/wasm/WasmBackend.cc +FLAGS := $(INCS_Debug) \ + -Oz \ + -flto \ + -fwasm-exceptions \ + -DNAPI_HAS_THREADS=1 \ + -sEXPORTED_FUNCTIONS="['_napi_register_wasm_v1', '_wasm_backend_event_handler', '_malloc', '_free', '_on_timeout']" \ + -sERROR_ON_UNDEFINED_SYMBOLS=0 \ + -s INITIAL_MEMORY=524288000 + +build/node-headers.tar.gz: + curl $(HEADERS_URL) -o build/node-headers.tar.gz + +build/node-$(NODE_VERSION): build/node-headers.tar.gz + tar -xvf build/node-headers.tar.gz -C build + touch build/node-$(NODE_VERSION) + +build/Release/watcher.wasm: build/node-$(NODE_VERSION) $(SRC) + mkdir -p build/Release + em++ $(FLAGS) -sDECLARE_ASM_MODULE_EXPORTS=0 -o build/Release/watcher.js $(SRC) + +build/Debug/watcher.wasm: build/node-$(NODE_VERSION) $(SRC) + mkdir -p build/Debug + em++ -g $(FLAGS) -o build/Debug/watcher.js $(SRC) + +wasm: build/Release/watcher.wasm +wasm-debug: build/Debug/watcher.wasm + +.PHONY: wasm wasm-debug diff --git a/binding.gyp b/binding.gyp index 2f592db9..7073ba67 100644 --- a/binding.gyp +++ b/binding.gyp @@ -3,7 +3,7 @@ { "target_name": "watcher", "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], - "sources": [ "src/binding.cc", "src/Watcher.cc", "src/Backend.cc", "src/DirTree.cc", "src/Glob.cc" ], + "sources": [ "src/binding.cc", "src/Watcher.cc", "src/Backend.cc", "src/DirTree.cc", "src/Glob.cc", "src/Debounce.cc" ], "include_dirs" : [" { - return binding.writeSnapshot( - path.resolve(dir), - path.resolve(snapshot), - normalizeOptions(dir, opts), - ); -}; - -exports.getEventsSince = (dir, snapshot, opts) => { - return binding.getEventsSince( - path.resolve(dir), - path.resolve(snapshot), - normalizeOptions(dir, opts), - ); -}; - -exports.subscribe = async (dir, fn, opts) => { - dir = path.resolve(dir); - opts = normalizeOptions(dir, opts); - await binding.subscribe(dir, fn, opts); - - return { - unsubscribe() { - return binding.unsubscribe(dir, fn, opts); - }, - }; -}; - -exports.unsubscribe = (dir, fn, opts) => { - return binding.unsubscribe( - path.resolve(dir), - fn, - normalizeOptions(dir, opts), - ); -}; +const wrapper = createWrapper(binding); +exports.writeSnapshot = wrapper.writeSnapshot; +exports.getEventsSince = wrapper.getEventsSince; +exports.subscribe = wrapper.subscribe; +exports.unsubscribe = wrapper.unsubscribe; diff --git a/package.json b/package.json index f15ecc39..0c5f709d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "husky": "^7.0.2", "lint-staged": "^11.1.2", "mocha": "^9.1.1", + "napi-wasm": "^1.1.0", "prebuildify": "^5.0.1", "prettier": "^2.3.2" }, diff --git a/scripts/build-wasm.js b/scripts/build-wasm.js new file mode 100644 index 00000000..00b28537 --- /dev/null +++ b/scripts/build-wasm.js @@ -0,0 +1,49 @@ +const exec = require('child_process').execSync; +const fs = require('fs'); +const pkg = require('../package.json'); + +const dir = `${__dirname}/..`; +try { + fs.mkdirSync(dir + '/npm/wasm'); +} catch (err) { } + +let dts = fs.readFileSync(`${dir}/index.d.ts`, 'utf8'); +dts += ` +/** Initializes the web assembly module. */ +export default function init(input?: string | URL | Request): Promise; +`; +fs.writeFileSync(`${dir}/npm/wasm/index.d.ts`, dts); + +let readme = fs.readFileSync(`${dir}/README.md`, 'utf8'); +readme = readme.replace('# ⚡️ Lightning CSS', '# ⚡️ lightningcss-wasm'); +fs.writeFileSync(`${dir}/npm/wasm/README.md`, readme); + +let js = fs.readFileSync(`${dir}/wasm/index.mjs`, 'utf8'); +js = js.replace('../build/Debug/watcher.wasm', 'watcher.wasm'); +js = js.replace('../wrapper.js', './wrapper.js'); +fs.writeFileSync(`${dir}/npm/wasm/index.mjs`, js); + +fs.copyFileSync(`${dir}/wrapper.js`, `${dir}/npm/wasm/wrapper.js`); +fs.copyFileSync(`${dir}/wasm/watcher.wasm`, `${dir}/npm/wasm/watcher.wasm`); + +let wasmPkg = { ...pkg }; +wasmPkg.name = '@parcel/watcher-wasm'; +wasmPkg.main = 'index.mjs'; +wasmPkg.module = 'index.mjs'; +wasmPkg.types = 'index.d.ts'; +wasmPkg.sideEffects = false; +wasmPkg.files = ['*.js', '*.mjs', '*.d.ts', '*.wasm']; +wasmPkg.dependencies = { + 'napi-wasm': pkg.devDependencies['napi-wasm'], + 'is-glob': pkg.dependencies['is-glob'], + 'micromatch': pkg.dependencies['micromatch'] +}; +delete wasmPkg.exports; +delete wasmPkg.binary; +delete wasmPkg['lint-staged']; +delete wasmPkg.husky; +delete wasmPkg.devDependencies; +delete wasmPkg.optionalDependencies; +delete wasmPkg.targets; +delete wasmPkg.scripts; +fs.writeFileSync(`${dir}/npm/wasm/package.json`, JSON.stringify(wasmPkg, false, 2) + '\n'); diff --git a/src/Backend.cc b/src/Backend.cc index 2cd8a80b..042b3040 100644 --- a/src/Backend.cc +++ b/src/Backend.cc @@ -13,6 +13,9 @@ #ifdef KQUEUE #include "kqueue/KqueueBackend.hh" #endif +#ifdef __wasm32__ +#include "wasm/WasmBackend.hh" +#endif #include "shared/BruteForceBackend.hh" #include "Backend.hh" @@ -49,6 +52,11 @@ std::shared_ptr getBackend(std::string backend) { return std::make_shared(); } #endif + #ifdef __wasm32__ + if (backend == "wasm" || backend == "default") { + return std::make_shared(); + } + #endif if (backend == "brute-force" || backend == "default") { return std::make_shared(); } @@ -79,20 +87,33 @@ void removeShared(Backend *backend) { break; } } + + // Free up memory. + if (sharedBackends.size() == 0) { + sharedBackends.rehash(0); + } } void Backend::run() { - mThread = std::thread([this] () { + #ifndef __wasm32__ + mThread = std::thread([this] () { + try { + start(); + } catch (std::exception &err) { + handleError(err); + } + }); + + if (mThread.joinable()) { + mStartedSignal.wait(); + } + #else try { start(); } catch (std::exception &err) { handleError(err); } - }); - - if (mThread.joinable()) { - mStartedSignal.wait(); - } + #endif } void Backend::notifyStarted() { @@ -104,15 +125,17 @@ void Backend::start() { } Backend::~Backend() { - // Wait for thread to stop - if (mThread.joinable()) { - // If the backend is being destroyed from the thread itself, detach, otherwise join. - if (mThread.get_id() == std::this_thread::get_id()) { - mThread.detach(); - } else { - mThread.join(); + #ifndef __wasm32__ + // Wait for thread to stop + if (mThread.joinable()) { + // If the backend is being destroyed from the thread itself, detach, otherwise join. + if (mThread.get_id() == std::this_thread::get_id()) { + mThread.detach(); + } else { + mThread.join(); + } } - } + #endif } void Backend::watch(Watcher &watcher) { diff --git a/src/Debounce.cc b/src/Debounce.cc new file mode 100644 index 00000000..be07e782 --- /dev/null +++ b/src/Debounce.cc @@ -0,0 +1,113 @@ +#include "Debounce.hh" + +#ifdef __wasm32__ +extern "C" void on_timeout(void *ctx) { + Debounce *debounce = (Debounce *)ctx; + debounce->notify(); +} +#endif + +std::shared_ptr Debounce::getShared() { + static std::weak_ptr sharedInstance; + std::shared_ptr shared = sharedInstance.lock(); + if (!shared) { + shared = std::make_shared(); + sharedInstance = shared; + } + + return shared; +} + +Debounce::Debounce() { + mRunning = true; + #ifndef __wasm32__ + mThread = std::thread([this] () { + loop(); + }); + #endif +} + +Debounce::~Debounce() { + mRunning = false; + #ifndef __wasm32__ + mWaitSignal.notify(); + mThread.join(); + #endif +} + +void Debounce::add(void *key, std::function cb) { + std::unique_lock lock(mMutex); + mCallbacks.emplace(key, cb); +} + +void Debounce::remove(void *key) { + std::unique_lock lock(mMutex); + mCallbacks.erase(key); +} + +void Debounce::trigger() { + std::unique_lock lock(mMutex); + #ifdef __wasm32__ + notifyIfReady(); + #else + mWaitSignal.notify(); + #endif +} + +#ifndef __wasm32__ +void Debounce::loop() { + while (mRunning) { + mWaitSignal.wait(); + if (!mRunning) { + break; + } + + notifyIfReady(); + } +} +#endif + +void Debounce::notifyIfReady() { + if (!mRunning) { + return; + } + + // If we haven't seen an event in more than the maximum wait time, notify callbacks immediately + // to ensure that we don't wait forever. Otherwise, wait for the minimum wait time and batch + // subsequent fast changes. This also means the first file change in a batch is notified immediately, + // separately from the rest of the batch. This seems like an acceptable tradeoff if the common case + // is that only a single file was updated at a time. + auto time = std::chrono::steady_clock::now(); + if ((time - mLastTime) > std::chrono::milliseconds(MAX_WAIT_TIME)) { + mLastTime = time; + notify(); + } else { + wait(); + } +} + +void Debounce::wait() { + #ifdef __wasm32__ + clear_timeout(mTimeout); + mTimeout = set_timeout(MIN_WAIT_TIME, this); + #else + auto status = mWaitSignal.waitFor(std::chrono::milliseconds(MIN_WAIT_TIME)); + if (mRunning && (status == std::cv_status::timeout)) { + notify(); + } + #endif +} + +void Debounce::notify() { + std::unique_lock lock(mMutex); + + mLastTime = std::chrono::steady_clock::now(); + for (auto it = mCallbacks.begin(); it != mCallbacks.end(); it++) { + auto cb = it->second; + cb(); + } + + #ifndef __wasm32__ + mWaitSignal.reset(); + #endif +} diff --git a/src/Debounce.hh b/src/Debounce.hh index ed395397..a17fdef7 100644 --- a/src/Debounce.hh +++ b/src/Debounce.hh @@ -3,96 +3,47 @@ #include #include +#include #include "Signal.hh" #define MIN_WAIT_TIME 50 #define MAX_WAIT_TIME 500 +#ifdef __wasm32__ +extern "C" { + int set_timeout(int ms, void *ctx); + void clear_timeout(int timeout); + void on_timeout(void *ctx); +}; +#endif + class Debounce { public: - static std::shared_ptr getShared() { - static std::weak_ptr sharedInstance; - std::shared_ptr shared = sharedInstance.lock(); - if (!shared) { - shared = std::make_shared(); - sharedInstance = shared; - } - - return shared; - } - - Debounce() { - mRunning = true; - mThread = std::thread([this] () { - loop(); - }); - } + static std::shared_ptr getShared(); - ~Debounce() { - mRunning = false; - mWaitSignal.notify(); - mThread.join(); - } + Debounce(); + ~Debounce(); - void add(void *key, std::function cb) { - std::unique_lock lock(mMutex); - mCallbacks.emplace(key, cb); - } + void add(void *key, std::function cb); + void remove(void *key); + void trigger(); + void notify(); - void remove(void *key) { - std::unique_lock lock(mMutex); - mCallbacks.erase(key); - } - - void trigger() { - std::unique_lock lock(mMutex); - mWaitSignal.notify(); - } - private: bool mRunning; std::mutex mMutex; - Signal mWaitSignal; - std::thread mThread; + #ifdef __wasm32__ + int mTimeout; + #else + Signal mWaitSignal; + std::thread mThread; + #endif std::unordered_map> mCallbacks; std::chrono::time_point mLastTime; - void loop() { - while (mRunning) { - mWaitSignal.wait(); - if (!mRunning) { - break; - } - - // If we haven't seen an event in more than the maximum wait time, notify callbacks immediately - // to ensure that we don't wait forever. Otherwise, wait for the minimum wait time and batch - // subsequent fast changes. This also means the first file change in a batch is notified immediately, - // separately from the rest of the batch. This seems like an acceptable tradeoff if the common case - // is that only a single file was updated at a time. - auto time = std::chrono::steady_clock::now(); - if ((time - mLastTime) > std::chrono::milliseconds(MAX_WAIT_TIME)) { - mLastTime = time; - notify(); - } else { - auto status = mWaitSignal.waitFor(std::chrono::milliseconds(MIN_WAIT_TIME)); - if (mRunning && (status == std::cv_status::timeout)) { - mLastTime = std::chrono::steady_clock::now(); - notify(); - } - } - } - } - - void notify() { - std::unique_lock lock(mMutex); - - for (auto it = mCallbacks.begin(); it != mCallbacks.end(); it++) { - auto cb = it->second; - cb(); - } - - mWaitSignal.reset(); - } + void loop(); + void notifyIfReady(); + void wait(); }; #endif diff --git a/src/DirTree.cc b/src/DirTree.cc index c27349fb..ac17c15c 100644 --- a/src/DirTree.cc +++ b/src/DirTree.cc @@ -1,4 +1,5 @@ #include "DirTree.hh" +#include static std::mutex mDirCacheMutex; static std::unordered_map> dirTreeCache; @@ -8,6 +9,11 @@ struct DirTreeDeleter { std::lock_guard lock(mDirCacheMutex); dirTreeCache.erase(tree->root); delete tree; + + // Free up memory. + if (dirTreeCache.size() == 0) { + dirTreeCache.rehash(0); + } } }; @@ -28,11 +34,11 @@ std::shared_ptr DirTree::getCached(std::string root) { return tree; } -DirTree::DirTree(std::string root, std::istream &stream) : root(root), isComplete(true) { +DirTree::DirTree(std::string root, FILE *f) : root(root), isComplete(true) { size_t size; - if (stream >> size) { + if (fscanf(f, "%zu", &size)) { for (size_t i = 0; i < size; i++) { - DirEntry entry(stream); + DirEntry entry(f); entries.emplace(entry.path, entry); } } @@ -92,19 +98,19 @@ void DirTree::remove(std::string path) { entries.erase(path); } -void DirTree::write(std::ostream &stream) { +void DirTree::write(FILE *f) { std::lock_guard lock(mMutex); - stream << entries.size() << "\n"; + fprintf(f, "%zu\n", entries.size()); for (auto it = entries.begin(); it != entries.end(); it++) { - it->second.write(stream); + it->second.write(f); } } void DirTree::getChanges(DirTree *snapshot, EventList &events) { std::lock_guard lock(mMutex); std::lock_guard snapshotLock(snapshot->mMutex); - + for (auto it = entries.begin(); it != entries.end(); it++) { auto found = snapshot->entries.find(it->first); if (found == snapshot->entries.end()) { @@ -129,18 +135,18 @@ DirEntry::DirEntry(std::string p, uint64_t t, bool d) { state = NULL; } -DirEntry::DirEntry(std::istream &stream) { +DirEntry::DirEntry(FILE *f) { size_t size; - - if (stream >> size) { + if (fscanf(f, "%zu", &size)) { path.resize(size); - if (stream.read(&path[0], size)) { - stream >> mtime; - stream >> isDir; + if (fread(&path[0], sizeof(char), size, f)) { + int d = 0; + fscanf(f, "%" PRIu64 " %d\n", &mtime, &d); + isDir = d == 1; } } } -void DirEntry::write(std::ostream &stream) const { - stream << path.size() << path << mtime << " " << isDir << "\n"; +void DirEntry::write(FILE *f) const { + fprintf(f, "%zu%s%" PRIu64 " %d\n", path.size(), path.c_str(), mtime, isDir); } diff --git a/src/DirTree.hh b/src/DirTree.hh index b18d1f81..328f4699 100644 --- a/src/DirTree.hh +++ b/src/DirTree.hh @@ -3,8 +3,6 @@ #include #include -#include -#include #include #include "Event.hh" @@ -21,8 +19,8 @@ struct DirEntry { mutable void *state; DirEntry(std::string p, uint64_t t, bool d); - DirEntry(std::istream &stream); - void write(std::ostream &stream) const; + DirEntry(FILE *f); + void write(FILE *f) const; bool operator==(const DirEntry &other) const { return path == other.path; } @@ -32,12 +30,12 @@ class DirTree { public: static std::shared_ptr getCached(std::string root); DirTree(std::string root) : root(root), isComplete(false) {} - DirTree(std::string root, std::istream &stream); + DirTree(std::string root, FILE *f); DirEntry *add(std::string path, uint64_t mtime, bool isDir); DirEntry *find(std::string path); DirEntry *update(std::string path, uint64_t mtime); void remove(std::string path); - void write(std::ostream &stream); + void write(FILE *f); void getChanges(DirTree *snapshot, EventList &events); std::mutex mMutex; diff --git a/src/Event.hh b/src/Event.hh index e673859e..629dfdf6 100644 --- a/src/Event.hh +++ b/src/Event.hh @@ -2,6 +2,8 @@ #define EVENT_H #include +#include +#include "wasm/include.h" #include #include #include diff --git a/src/Glob.cc b/src/Glob.cc index c8747877..a4a17224 100644 --- a/src/Glob.cc +++ b/src/Glob.cc @@ -1,15 +1,22 @@ #include "Glob.hh" -#include -Glob::Glob(std::string raw) : Glob(raw, std::regex(raw)) - { } +#ifdef __wasm32__ +extern "C" bool wasm_regex_match(const char *s, const char *regex); +#endif -Glob::Glob(std::string raw, std::regex regex) - : mHash(std::hash()(raw)), - mRegex(regex), - mRaw(raw) - { } +Glob::Glob(std::string raw) { + mRaw = raw; + mHash = std::hash()(raw); + #ifndef __wasm32__ + mRegex = std::regex(raw); + #endif +} bool Glob::isIgnored(std::string relative_path) const { - return std::regex_match(relative_path, mRegex); + // Use native JS regex engine for wasm to reduce binary size. + #ifdef __wasm32__ + return wasm_regex_match(relative_path.c_str(), mRaw.c_str()); + #else + return std::regex_match(relative_path, mRegex); + #endif } diff --git a/src/Glob.hh b/src/Glob.hh index 1c491317..6e049e6c 100644 --- a/src/Glob.hh +++ b/src/Glob.hh @@ -6,11 +6,12 @@ struct Glob { std::size_t mHash; - std::regex mRegex; std::string mRaw; + #ifndef __wasm32__ + std::regex mRegex; + #endif Glob(std::string raw); - Glob(std::string raw, std::regex regex); bool operator==(const Glob &other) const { return mHash == other.mHash; diff --git a/src/PromiseRunner.hh b/src/PromiseRunner.hh index 064768f3..4ca3bb66 100644 --- a/src/PromiseRunner.hh +++ b/src/PromiseRunner.hh @@ -1,8 +1,9 @@ #ifndef PROMISE_RUNNER_H #define PROMISE_RUNNER_H -#include #include +#include "wasm/include.h" +#include using namespace Napi; @@ -12,7 +13,7 @@ public: Promise::Deferred deferred; PromiseRunner(Env env) : env(env), deferred(Promise::Deferred::New(env)) { - napi_status status = napi_create_async_work(env, nullptr, env.Undefined(), + napi_status status = napi_create_async_work(env, nullptr, env.Undefined(), onExecute, onWorkComplete, this, &work); if (status != napi_ok) { work = nullptr; diff --git a/src/Watcher.cc b/src/Watcher.cc index 2bae611a..36c85b28 100644 --- a/src/Watcher.cc +++ b/src/Watcher.cc @@ -35,6 +35,11 @@ void removeShared(Watcher *watcher) { break; } } + + // Free up memory. + if (sharedWatchers.size() == 0) { + sharedWatchers.rehash(0); + } } Watcher::Watcher(std::string dir, std::unordered_set ignorePaths, std::unordered_set ignoreGlobs) diff --git a/src/binding.cc b/src/binding.cc index 46571949..18e757e5 100644 --- a/src/binding.cc +++ b/src/binding.cc @@ -1,7 +1,7 @@ #include -#include -#include #include +#include "wasm/include.h" +#include #include "Glob.hh" #include "Event.hh" #include "Backend.hh" @@ -40,7 +40,7 @@ std::unordered_set getIgnoreGlobs(Env env, Value opts) { Value item = items.Get(Number::New(env, i)); if (item.IsString()) { auto key = item.As().Utf8Value(); - result.emplace(key, std::regex(key.c_str())); + result.emplace(key); } } } diff --git a/src/shared/BruteForceBackend.cc b/src/shared/BruteForceBackend.cc index a8f44327..28b5533c 100644 --- a/src/shared/BruteForceBackend.cc +++ b/src/shared/BruteForceBackend.cc @@ -1,5 +1,4 @@ #include -#include #include "../DirTree.hh" #include "../Event.hh" #include "./BruteForceBackend.hh" @@ -19,18 +18,24 @@ std::shared_ptr BruteForceBackend::getTree(Watcher &watcher, bool shoul void BruteForceBackend::writeSnapshot(Watcher &watcher, std::string *snapshotPath) { std::unique_lock lock(mMutex); auto tree = getTree(watcher); - std::ofstream ofs(*snapshotPath); - tree->write(ofs); + FILE *f = fopen(snapshotPath->c_str(), "w"); + if (!f) { + throw std::runtime_error(std::string("Unable to open snapshot file: ") + strerror(errno)); + } + + tree->write(f); + fclose(f); } void BruteForceBackend::getEventsSince(Watcher &watcher, std::string *snapshotPath) { std::unique_lock lock(mMutex); - std::ifstream ifs(*snapshotPath); - if (ifs.fail()) { - return; + FILE *f = fopen(snapshotPath->c_str(), "r"); + if (!f) { + throw std::runtime_error(std::string("Unable to open snapshot file: ") + strerror(errno)); } - DirTree snapshot{watcher.mDir, ifs}; + DirTree snapshot{watcher.mDir, f}; auto now = getTree(watcher); now->getChanges(&snapshot, watcher.mEvents); + fclose(f); } diff --git a/src/wasm/WasmBackend.cc b/src/wasm/WasmBackend.cc new file mode 100644 index 00000000..a1118c2c --- /dev/null +++ b/src/wasm/WasmBackend.cc @@ -0,0 +1,132 @@ +#include +#include "WasmBackend.hh" + +#define CONVERT_TIME(ts) ((uint64_t)ts.tv_sec * 1000000000 + ts.tv_nsec) + +void WasmBackend::start() { + notifyStarted(); +} + +void WasmBackend::subscribe(Watcher &watcher) { + // Build a full directory tree recursively, and watch each directory. + std::shared_ptr tree = getTree(watcher); + + for (auto it = tree->entries.begin(); it != tree->entries.end(); it++) { + if (it->second.isDir) { + watchDir(watcher, it->second.path, tree); + } + } +} + +void WasmBackend::watchDir(Watcher &watcher, std::string path, std::shared_ptr tree) { + int wd = wasm_backend_add_watch(path.c_str(), (void *)this); + std::shared_ptr sub = std::make_shared(); + sub->tree = tree; + sub->path = path; + sub->watcher = &watcher; + mSubscriptions.emplace(wd, sub); +} + +extern "C" void wasm_backend_event_handler(void *backend, int wd, int type, char *filename) { + WasmBackend *b = (WasmBackend *)(backend); + b->handleEvent(wd, type, filename); +} + +void WasmBackend::handleEvent(int wd, int type, char *filename) { + // Find the subscriptions for this watch descriptor + auto range = mSubscriptions.equal_range(wd); + std::unordered_set> set; + for (auto it = range.first; it != range.second; it++) { + set.insert(it->second); + } + + for (auto it = set.begin(); it != set.end(); it++) { + if (handleSubscription(type, filename, *it)) { + (*it)->watcher->notify(); + } + } +} + +bool WasmBackend::handleSubscription(int type, char *filename, std::shared_ptr sub) { + // Build full path and check if its in our ignore list. + Watcher *watcher = sub->watcher; + std::string path = std::string(sub->path); + + if (filename[0] != '\0') { + path += "/" + std::string(filename); + } + + if (watcher->isIgnored(path)) { + return false; + } + + if (type == 1) { + struct stat st; + stat(path.c_str(), &st); + sub->tree->update(path, CONVERT_TIME(st.st_mtim)); + watcher->mEvents.update(path); + } else if (type == 2) { + // Determine if this is a create or delete depending on if the file exists or not. + struct stat st; + if (lstat(path.c_str(), &st)) { + // If the entry being deleted/moved is a directory, remove it from the list of subscriptions + DirEntry *entry = sub->tree->find(path); + if (!entry) { + return false; + } + + if (entry->isDir) { + std::string pathStart = path + DIR_SEP; + for (auto it = mSubscriptions.begin(); it != mSubscriptions.end();) { + if (it->second->path == path || it->second->path.rfind(pathStart, 0) == 0) { + wasm_backend_remove_watch(it->first); + it = mSubscriptions.erase(it); + } else { + ++it; + } + } + + // Remove all sub-entries + for (auto it = sub->tree->entries.begin(); it != sub->tree->entries.end();) { + if (it->first.rfind(pathStart, 0) == 0) { + watcher->mEvents.remove(it->first); + it = sub->tree->entries.erase(it); + } else { + it++; + } + } + } + + watcher->mEvents.remove(path); + sub->tree->remove(path); + } else if (sub->tree->find(path)) { + sub->tree->update(path, CONVERT_TIME(st.st_mtim)); + watcher->mEvents.update(path); + } else { + watcher->mEvents.create(path); + + // If this is a create, check if it's a directory and start watching if it is. + DirEntry *entry = sub->tree->add(path, CONVERT_TIME(st.st_mtim), S_ISDIR(st.st_mode)); + if (entry->isDir) { + watchDir(*watcher, path, sub->tree); + } + } + } + + return true; +} + +void WasmBackend::unsubscribe(Watcher &watcher) { + // Find any subscriptions pointing to this watcher, and remove them. + for (auto it = mSubscriptions.begin(); it != mSubscriptions.end();) { + if (it->second->watcher == &watcher) { + if (mSubscriptions.count(it->first) == 1) { + wasm_backend_remove_watch(it->first); + } + + it = mSubscriptions.erase(it); + } else { + it++; + } + } +} diff --git a/src/wasm/WasmBackend.hh b/src/wasm/WasmBackend.hh new file mode 100644 index 00000000..2a25e80d --- /dev/null +++ b/src/wasm/WasmBackend.hh @@ -0,0 +1,34 @@ +#ifndef WASM_H +#define WASM_H + +#include +#include "../shared/BruteForceBackend.hh" +#include "../DirTree.hh" + +extern "C" { + int wasm_backend_add_watch(const char *filename, void *backend); + void wasm_backend_remove_watch(int wd); + void wasm_backend_event_handler(void *backend, int wd, int type, char *filename); +}; + +struct WasmSubscription { + std::shared_ptr tree; + std::string path; + Watcher *watcher; +}; + +class WasmBackend : public BruteForceBackend { +public: + void start() override; + void subscribe(Watcher &watcher) override; + void unsubscribe(Watcher &watcher) override; + void handleEvent(int wd, int type, char *filename); +private: + int mWasm; + std::unordered_multimap> mSubscriptions; + + void watchDir(Watcher &watcher, std::string path, std::shared_ptr tree); + bool handleSubscription(int type, char *filename, std::shared_ptr sub); +}; + +#endif diff --git a/src/wasm/include.h b/src/wasm/include.h new file mode 100644 index 00000000..60e4d657 --- /dev/null +++ b/src/wasm/include.h @@ -0,0 +1,74 @@ +/* +Copyright Node.js contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +*/ + +// Node does not include the headers for these functions when compiling for WASM, so add them here. +#ifdef __wasm32__ +extern "C" { +NAPI_EXTERN napi_status NAPI_CDECL +napi_create_threadsafe_function(napi_env env, + napi_value func, + napi_value async_resource, + napi_value async_resource_name, + size_t max_queue_size, + size_t initial_thread_count, + void* thread_finalize_data, + napi_finalize thread_finalize_cb, + void* context, + napi_threadsafe_function_call_js call_js_cb, + napi_threadsafe_function* result); + +NAPI_EXTERN napi_status NAPI_CDECL napi_get_threadsafe_function_context( + napi_threadsafe_function func, void** result); + +NAPI_EXTERN napi_status NAPI_CDECL +napi_call_threadsafe_function(napi_threadsafe_function func, + void* data, + napi_threadsafe_function_call_mode is_blocking); + +NAPI_EXTERN napi_status NAPI_CDECL +napi_acquire_threadsafe_function(napi_threadsafe_function func); + +NAPI_EXTERN napi_status NAPI_CDECL napi_release_threadsafe_function( + napi_threadsafe_function func, napi_threadsafe_function_release_mode mode); + +NAPI_EXTERN napi_status NAPI_CDECL +napi_unref_threadsafe_function(napi_env env, napi_threadsafe_function func); + +NAPI_EXTERN napi_status NAPI_CDECL +napi_ref_threadsafe_function(napi_env env, napi_threadsafe_function func); + +NAPI_EXTERN napi_status NAPI_CDECL +napi_create_async_work(napi_env env, + napi_value async_resource, + napi_value async_resource_name, + napi_async_execute_callback execute, + napi_async_complete_callback complete, + void* data, + napi_async_work* result); +NAPI_EXTERN napi_status NAPI_CDECL napi_delete_async_work(napi_env env, + napi_async_work work); +NAPI_EXTERN napi_status NAPI_CDECL napi_queue_async_work(napi_env env, + napi_async_work work); +NAPI_EXTERN napi_status NAPI_CDECL napi_cancel_async_work(napi_env env, + napi_async_work work); +} +#endif diff --git a/test/since.js b/test/since.js index d68a97db..547b9c33 100644 --- a/test/since.js +++ b/test/since.js @@ -1,8 +1,11 @@ -const watcher = require('../'); +const watcherNative = require('../'); const assert = require('assert'); const fs = require('fs-extra'); const path = require('path'); +let watcher = watcherNative; +let watcherWasm; + const snapshotPath = path.join(__dirname, 'snapshot.txt'); const tmpDir = path.join( fs.realpathSync(require('os').tmpdir()), @@ -19,6 +22,10 @@ if (process.platform === 'darwin') { backends = ['windows', 'watchman']; } +if (process.env.TEST_WASM) { + backends = ['wasm']; +} + let c = 0; const getFilename = (...dir) => path.join(tmpDir, ...dir, `test${c++}${Math.random().toString(31).slice(2)}`); @@ -48,6 +55,18 @@ describe('since', () => { backends.forEach((backend) => { describe(backend, () => { + before(async function () { + if (backend === 'wasm') { + if (!watcherWasm) { + watcherWasm = await import('../wasm/index.mjs'); + await watcherWasm.default(); + } + watcher = watcherWasm; + } else { + watcher = watcherNative; + } + }); + describe('files', () => { it('should emit when a file is created', async function () { this.timeout(5000); diff --git a/test/watcher.js b/test/watcher.js index d574048a..ee7dd62e 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -1,10 +1,12 @@ -const watcher = require('../'); +const watcherNative = require('../'); const assert = require('assert'); const fs = require('fs-extra'); const path = require('path'); const {execSync} = require('child_process'); const {Worker} = require('worker_threads'); +let watcher = watcherNative; + let backends = []; if (process.platform === 'darwin') { backends = ['fs-events', 'kqueue', 'watchman']; @@ -16,6 +18,10 @@ if (process.platform === 'darwin') { backends = ['kqueue']; } +if (process.env.TEST_WASM) { + backends = ['wasm']; +} + describe('watcher', () => { backends.forEach((backend) => { describe(backend, () => { @@ -52,6 +58,13 @@ describe('watcher', () => { let ignoreDir, ignoreFile, ignoreGlobDir, fileToRename, dirToRename, sub; before(async () => { + if (backend === 'wasm') { + watcher = await import('../wasm/index.mjs'); + await watcher.default(); + } else { + watcher = watcherNative; + } + tmpDir = path.join( fs.realpathSync(require('os').tmpdir()), Math.random().toString(31).slice(2), @@ -123,6 +136,11 @@ describe('watcher', () => { }); it('should emit when a file is renamed only changing case', async () => { + if (backend === 'wasm') { + // WASM backend doesn't handle macOS case-insensitive filesystem. + return; + } + let f1 = getFilename(); let f2 = path.join(path.dirname(f1), path.basename(f1).toUpperCase()); fs.writeFile(f1, 'hello world'); @@ -192,7 +210,7 @@ describe('watcher', () => { }); it('should handle when the directory to watch is deleted', async () => { - if (backend === 'watchman') { + if (backend === 'watchman' || backend === 'wasm') { // Watchman doesn't handle this correctly return; } @@ -331,7 +349,7 @@ describe('watcher', () => { }); it('should emit when a sub-directory is deleted with directories inside', async () => { - if (backend === 'watchman') { + if (backend === 'watchman' || backend === 'wasm') { // It seems that watchman emits the second delete event before the // first create event when rapidly renaming a directory and one of // its child so our test is failing in that case. @@ -481,7 +499,7 @@ describe('watcher', () => { assert.deepEqual(res, [{type: 'update', path: f1}]); }); - if (backend !== 'fs-events') { + if (backend !== 'fs-events' && backend !== 'wasm') { it('should ignore files that are created and deleted rapidly', async () => { let f1 = getFilename(); let f2 = getFilename(); @@ -777,41 +795,43 @@ describe('watcher', () => { }); }); - describe('worker threads', () => { - it('should support worker threads', async function () { - let worker = new Worker(` - const {parentPort} = require('worker_threads'); - const {tmpDir, backend, modulePath} = require('worker_threads').workerData; - const watcher = require(modulePath); - async function run() { - let sub = await watcher.subscribe(tmpDir, async (err, events) => { - await sub.unsubscribe(); - parentPort.postMessage('success'); - }, {backend}); - parentPort.postMessage('ready'); - } + if (backend !== 'wasm') { + describe('worker threads', () => { + it('should support worker threads', async function () { + let worker = new Worker(` + const {parentPort} = require('worker_threads'); + const {tmpDir, backend, modulePath} = require('worker_threads').workerData; + const watcher = require(modulePath); + async function run() { + let sub = await watcher.subscribe(tmpDir, async (err, events) => { + await sub.unsubscribe(); + parentPort.postMessage('success'); + }, {backend}); + parentPort.postMessage('ready'); + } + + run(); + `, {eval: true, workerData: {tmpDir, backend, modulePath: require.resolve('../')}}); + + await new Promise((resolve, reject) => { + worker.once('message', resolve); + worker.once('error', reject); + }); - run(); - `, {eval: true, workerData: {tmpDir, backend, modulePath: require.resolve('../')}}); + let workerPromise = new Promise((resolve, reject) => { + worker.once('message', resolve); + worker.once('error', reject); + }); - await new Promise((resolve, reject) => { - worker.once('message', resolve); - worker.once('error', reject); - }); + let f = getFilename(); + fs.writeFile(f, 'hello world'); + let [res] = await Promise.all([nextEvent(), workerPromise]); + assert.deepEqual(res, [{type: 'create', path: f}]); - let workerPromise = new Promise((resolve, reject) => { - worker.once('message', resolve); - worker.once('error', reject); + await worker.terminate(); }); - - let f = getFilename(); - fs.writeFile(f, 'hello world'); - let [res] = await Promise.all([nextEvent(), workerPromise]); - assert.deepEqual(res, [{type: 'create', path: f}]); - - await worker.terminate(); }); - }); + } describe('ignore', () => { it('should ignore a directory', async () => { diff --git a/wasm/index.mjs b/wasm/index.mjs new file mode 100644 index 00000000..e44c6ae5 --- /dev/null +++ b/wasm/index.mjs @@ -0,0 +1,364 @@ +import { Environment, napi } from 'napi-wasm'; +import fs from 'fs'; +import Path from 'path'; +import micromatch from 'micromatch'; +import isGlob from 'is-glob'; +import { createWrapper } from '../wrapper.js'; + +let wrapper, env; +let encoder = new TextEncoder; + +let constants = { + O_ACCMODE: 0o00000003, + O_RDONLY: 0, + O_WRONLY: 0o00000001, + O_RDWR: 0o00000002, + O_CREAT: 0o00000100, + O_EXCL: 0o00000200, + O_NOCTTY: 0o00000400, + O_TRUNC: 0o00001000, + O_APPEND: 0o00002000, + O_NONBLOCK: 0o00004000, + O_SYNC: 0o00010000, + FASYNC: 0o00020000, + O_DIRECT: 0o00040000, + O_LARGEFILE: 0o00100000, + O_DIRECTORY: 0o00200000, + O_NOFOLLOW: 0o00400000, + O_NOATIME: 0o01000000, + O_CLOEXEC: 0o02000000 +}; + +napi.napi_get_last_error_info = () => {}; + +const fds = new Map(); +const dirs = new Map(); +const regexCache = new Map(); +const watches = [null]; + +const wasm_env = { + __syscall_newfstatat(dirfd, path, buf, flags) { + let dir = dirfd === -100 ? process.cwd() : fds.get(dirfd).path; + let p = Path.resolve(dir, env.getString(path)); + let nofollow = flags & 256; + try { + let stat = nofollow ? fs.lstatSync(p, {bigint: true}) : fs.statSync(p, {bigint: true}); + return writeStat(stat, buf); + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + }, + __syscall_lstat64(path, buf) { + let p = env.getString(path); + try { + let stat = fs.lstatSync(p, {bigint: true}); + return writeStat(stat, buf); + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + }, + __syscall_fstat64(fd, buf) { + try { + let stat = fs.fstatSync(fd, {bigint: true}); + return writeStat(stat, buf); + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + }, + __syscall_stat64(path, buf) { + let p = env.getString(path); + try { + let stat = fs.statSync(p, {bigint: true}); + return writeStat(stat, buf); + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + }, + __syscall_getdents64(fd, dirp, count) { + let p = fds.get(fd).path; + let dir = dirs.get(fd); + let entries = dir?.entries; + if (!entries) { + try { + entries = fs.readdirSync(p, {withFileTypes: true}); + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + } + + let start = dirp; + let i = dir?.index || 0; + for (; i < entries.length; i++) { + let entry = entries[i]; + let type = entry.isFIFO() ? 1 + : entry.isCharacterDevice() ? 2 + : entry.isDirectory() ? 4 + : entry.isBlockDevice() ? 6 + : entry.isFile() ? 8 + : entry.isSymbolicLink() ? 10 + : entry.isSocket() ? 12 + : 0; + let len = align(utf8Length(entry.name) + 20, 8); + if ((dirp - start + len) > count) { + break; + } + + // Write a linux_dirent64 struct into wasm memory. + env.u64[dirp >> 3] = 1n; // ino + env.u64[(dirp + 8) >> 3] = BigInt((dirp - start) + len); // offset + env.u16[(dirp + 16) >> 1] = len; + env.memory[dirp + 18] = type; + let {written} = encoder.encodeInto(entry.name, env.memory.subarray(dirp + 19)); + env.memory[dirp + 19 + written] = 0; // null terminate + dirp += len; + } + + dirs.set(fd, {index: i, entries}); + return dirp - start; + }, + __syscall_openat(dirfd, path, flags, mode) { + // Convert flags to Node values. + let f = 0; + for (let c in constants) { + if (flags & constants[c]) { + f |= fs.constants[c] || 0; + } + } + let dir = dirfd === -100 ? process.cwd() : fds.get(dirfd)?.path; + if (!dir) { + env.i32[env.instance.exports.__errno_location >> 2] = 9970; // ENOTDIR + return -1; + } + let p = Path.resolve(dir, env.getString(path)); + try { + let fd = fs.openSync(p, f); + fds.set(fd, {path: p, flags}); + return fd; + } catch (err) { + env.i32[env.instance.exports.__errno_location >> 2] = err.errno; + return -1; + } + }, + __syscall_fcntl64(fd, cmd) { + switch (cmd) { + case 3: + return fds.get(fd).flags; + case 2: + return 0; + default: + throw new Error('Unknown fcntl64 call: ' + cmd); + } + }, + __syscall_ioctl() {}, + emscripten_resize_heap() { + return 0; + }, + abort() {}, + wasm_backend_add_watch(filename, backend) { + let path = env.getString(filename); + let watch = fs.watch(path, {encoding: 'buffer'}, (eventType, filename) => { + if (filename) { + let type = eventType === 'change' ? 1 : 2; + let fptr = env.instance.exports.malloc(filename.byteLength + 1); + env.memory.set(filename, fptr); + env.memory[fptr + filename.byteLength] = 0; + env.instance.exports.wasm_backend_event_handler(backend, wd, type, fptr); + env.instance.exports.free(fptr); + } + }); + + let wd = watches.length; + watches.push(watch); + return wd; + }, + wasm_backend_remove_watch(wd) { + watches[wd].close(); + watches[wd] = undefined; + }, + set_timeout(ms, ctx) { + return setTimeout(() => { + env.instance.exports.on_timeout(ctx); + }, ms); + }, + clear_timeout(t) { + clearTimeout(t); + }, + emscripten_date_now() { + return Date.now(); + }, + _emscripten_get_now_is_monotonic() { + return true; + }, + emscripten_get_now() { + return performance.now(); + }, + wasm_regex_match(string, regex) { + let re = regexCache.get(regex); + if (!re) { + re = new RegExp(env.getString(regex)); + regexCache.set(regex, re); + } + return re.test(env.getString(string)) ? 1 : 0; + } +}; + +const wasi = { + fd_close(fd) { + fs.closeSync(fd); + fds.delete(fd); + dirs.delete(fd); + return 0; + }, + fd_seek(fd, offset_low, offset_high, whence, newOffset) { + return 0; + }, + fd_write(fd, iov, iovcnt, pnum) { + let buffers = []; + for (let i = 0; i < iovcnt; i++) { + let ptr = env.u32[iov >> 2]; + let len = env.u32[(iov + 4) >> 2]; + iov += 8; + buffers.push(env.memory.subarray(ptr, ptr + len)); + } + let wrote = fs.writevSync(fd, buffers); + env.u32[pnum >> 2] = wrote; + return 0; + }, + fd_read(fd, iov, iovcnt, pnum) { + let buffers = []; + for (let i = 0; i < iovcnt; i++) { + let ptr = env.u32[iov >> 2]; + let len = env.u32[(iov + 4) >> 2]; + iov += 8; + buffers.push(env.memory.subarray(ptr, ptr + len)); + } + + let read = fs.readvSync(fd, buffers); + env.u32[pnum >> 2] = read; + return 0; + } +}; + +function writeStat(stat, buf) { + env.i32[buf >> 2] = Number(stat.dev); + env.i32[(buf + 4) >> 2] = Number(stat.mode); + env.u32[(buf + 8) >> 2] = Number(stat.nlink); + env.i32[(buf + 12) >> 2] = Number(stat.uid); + env.i32[(buf + 16) >> 2] = Number(stat.gid); + env.i32[(buf + 20) >> 2] = Number(stat.rdev); + env.u64[(buf + 24) >> 3] = stat.size; + env.i32[(buf + 32) >> 2] = Number(stat.blksize); + env.i32[(buf + 36) >> 2] = Number(stat.blocks); + env.u64[(buf + 40) >> 3] = stat.atimeMs; + env.u32[(buf + 48) >> 2] = Number(stat.atimeNs); + env.u64[(buf + 56) >> 3] = stat.mtimeMs; + env.u32[(buf + 64) >> 2] = Number(stat.mtimeNs); + env.u64[(buf + 72) >> 3] = stat.ctimeMs; + env.u32[(buf + 80) >> 2] = Number(stat.ctimeNs); + env.u64[(buf + 88) >> 3] = stat.ino; + return 0; +} + +function utf8Length(string) { + let len = 0; + for (let i = 0; i < string.length; i++) { + let c = string.charCodeAt(i); + + if (c >= 0xd800 && c <= 0xdbff && i < string.length - 1) { + let c2 = string.charCodeAt(++i); + if ((c2 & 0xfc00) === 0xdc00) { + c = ((c & 0x3ff) << 10) + (c2 & 0x3ff) + 0x10000; + } else { + // unmatched surrogate. + i--; + } + } + + if ((c & 0xffffff80) === 0) { + len++; + } else if ((c & 0xfffff800) === 0) { + len += 2; + } else if ((c & 0xffff0000) === 0) { + len += 3; + } else if ((c & 0xffe00000) === 0) { + len += 4; + } + } + return len; +} + +function align(len, p) { + return Math.ceil(len / p) * p; +} + +export default async function init(input) { + input = input ?? new URL('../build/Debug/watcher.wasm', import.meta.url); + if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) { + input = fetchOrReadFromFs(input); + } + + const { instance } = await load(await input, { + napi, + env: wasm_env, + wasi_snapshot_preview1: wasi + }); + + env = new Environment(instance); + wrapper = createWrapper(env.exports); +} + +async function load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } +} + +async function fetchOrReadFromFs(inputPath) { + try { + const fs = await import('fs'); + return fs.readFileSync(inputPath); + } catch { + return fetch(inputPath); + } +} + +export function writeSnapshot(dir, snapshot, opts) { + return wrapper.writeSnapshot(dir, snapshot, opts); +} + +export function getEventsSince(dir, snapshot, opts) { + return wrapper.getEventsSince(dir, snapshot, opts); +} + +export function subscribe(dir, fn, opts) { + return wrapper.subscribe(dir, fn, opts); +} + +export function unsubscribe(dir, fn, opts) { + return wrapper.unsubscribe(dir, fn, opts); +} diff --git a/wrapper.js b/wrapper.js new file mode 100644 index 00000000..496d56bc --- /dev/null +++ b/wrapper.js @@ -0,0 +1,77 @@ +const path = require('path'); +const micromatch = require('micromatch'); +const isGlob = require('is-glob'); + +function normalizeOptions(dir, opts = {}) { + const { ignore, ...rest } = opts; + + if (Array.isArray(ignore)) { + opts = { ...rest }; + + for (const value of ignore) { + if (isGlob(value)) { + if (!opts.ignoreGlobs) { + opts.ignoreGlobs = []; + } + + const regex = micromatch.makeRe(value, { + // We set `dot: true` to workaround an issue with the + // regular expression on Linux where the resulting + // negative lookahead `(?!(\\/|^)` was never matching + // in some cases. See also https://bit.ly/3UZlQDm + dot: true, + // C++ does not support lookbehind regex patterns, they + // were only added later to JavaScript engines + // (https://bit.ly/3V7S6UL) + lookbehinds: false + }); + opts.ignoreGlobs.push(regex.source); + } else { + if (!opts.ignorePaths) { + opts.ignorePaths = []; + } + + opts.ignorePaths.push(path.resolve(dir, value)); + } + } + } + + return opts; +} + +exports.createWrapper = (binding) => { + return { + writeSnapshot(dir, snapshot, opts) { + return binding.writeSnapshot( + path.resolve(dir), + path.resolve(snapshot), + normalizeOptions(dir, opts), + ); + }, + getEventsSince(dir, snapshot, opts) { + return binding.getEventsSince( + path.resolve(dir), + path.resolve(snapshot), + normalizeOptions(dir, opts), + ); + }, + async subscribe(dir, fn, opts) { + dir = path.resolve(dir); + opts = normalizeOptions(dir, opts); + await binding.subscribe(dir, fn, opts); + + return { + unsubscribe() { + return binding.unsubscribe(dir, fn, opts); + }, + }; + }, + unsubscribe(dir, fn, opts) { + return binding.unsubscribe( + path.resolve(dir), + fn, + normalizeOptions(dir, opts), + ); + } + }; +}; diff --git a/yarn.lock b/yarn.lock index a4091190..cb5f111d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -793,6 +793,11 @@ nanoid@3.1.23: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== +napi-wasm@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/napi-wasm/-/napi-wasm-1.1.0.tgz#bbe617823765ae9c1bc12ff5942370eae7b2ba4e" + integrity sha512-lHwIAJbmLSjF9VDRm9GoVOy9AGp3aIvkjv+Kvz9h16QR3uSVYH78PNQUnT2U4X53mhlnV2M7wrhibQ3GHicDmg== + node-abi@^3.3.0: version "3.45.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.45.0.tgz#f568f163a3bfca5aacfce1fbeee1fa2cc98441f5"