From 217501e180eadd1999f30733e0f13580cd1f0abf Mon Sep 17 00:00:00 2001 From: Ashcon Partovi Date: Thu, 22 Jun 2023 22:27:00 -0700 Subject: `expect().resolves` and `expect().rejects` (#3318) * Move expect and snapshots to their own files * expect().resolves and expect().rejects * Fix promise being added to unhandled rejection list * Handle timeouts in expect() * wip merge * Fix merge issue --------- Co-authored-by: Jarred Sumner Co-authored-by: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com> --- src/jsc.zig | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src/jsc.zig') diff --git a/src/jsc.zig b/src/jsc.zig index 26ad7cc5f..67cf3f05c 100644 --- a/src/jsc.zig +++ b/src/jsc.zig @@ -26,6 +26,8 @@ pub const Cloudflare = struct { pub const AttributeIterator = @import("./bun.js/api/html_rewriter.zig").AttributeIterator; }; pub const Jest = @import("./bun.js/test/jest.zig"); +pub const Expect = @import("./bun.js/test/expect.zig"); +pub const Snapshot = @import("./bun.js/test/snapshot.zig"); pub const API = struct { pub const JSBundler = @import("./bun.js/api/JSBundler.zig").JSBundler; pub const BuildArtifact = @import("./bun.js/api/JSBundler.zig").BuildArtifact; -- cgit v1.2.3 From 069b42a7cc1275969859dc60e7c303528ca2dccb Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Sat, 24 Jun 2023 03:24:34 -0300 Subject: [feat] fs.watch (#3249) * initial support * add types * fix comment * fix types * bigfix up * more fixes * fix some encoding support for watch * fix rename event * fixup * fix latin1 * add fs_events, still failing some tests * fixuup * remove unecesary check * readd tests ops * this is necessary? just testing CI/CD weird errors * just use dupe here * cleanup and fix deinit * fix zig upgrade --- packages/bun-types/fs.d.ts | 97 +++ packages/bun-types/fs/promises.d.ts | 58 ++ src/bun.js/bindings/JSSink.cpp | 2 +- src/bun.js/bindings/JSSink.h | 2 +- src/bun.js/bindings/JSSinkLookupTable.h | 2 +- .../ZigGeneratedClasses+DOMClientIsoSubspaces.h | 1 + .../bindings/ZigGeneratedClasses+DOMIsoSubspaces.h | 1 + .../ZigGeneratedClasses+lazyStructureHeader.h | 6 + .../ZigGeneratedClasses+lazyStructureImpl.h | 7 + src/bun.js/bindings/ZigGeneratedClasses.cpp | 322 ++++++++ src/bun.js/bindings/ZigGeneratedClasses.h | 56 ++ src/bun.js/bindings/generated_classes.zig | 94 +++ src/bun.js/bindings/generated_classes_list.zig | 1 + src/bun.js/event_loop.zig | 7 + src/bun.js/javascript.zig | 7 + src/bun.js/node/fs_events.zig | 609 ++++++++++++++ src/bun.js/node/node.classes.ts | 30 +- src/bun.js/node/node_fs.zig | 13 +- src/bun.js/node/node_fs_binding.zig | 2 + src/bun.js/node/node_fs_watcher.zig | 913 +++++++++++++++++++++ src/bun.js/node/types.zig | 4 + src/bun.js/webcore/encoding.zig | 15 +- src/fs.zig | 54 ++ src/http.zig | 7 +- src/js/node/fs.js | 65 +- src/js/node/fs.promises.ts | 51 ++ src/js/out/modules/node/fs.js | 50 +- src/js/out/modules/node/fs.promises.js | 2 +- src/js/private.d.ts | 86 +- src/jsc.zig | 1 + src/watcher.zig | 68 +- test/js/node/watch/fixtures/close.js | 7 + test/js/node/watch/fixtures/persistent.js | 5 + test/js/node/watch/fixtures/relative.js | 23 + test/js/node/watch/fixtures/unref.js | 7 + test/js/node/watch/fs.watch.test.js | 424 ++++++++++ 36 files changed, 3072 insertions(+), 27 deletions(-) create mode 100644 src/bun.js/node/fs_events.zig create mode 100644 src/bun.js/node/node_fs_watcher.zig create mode 100644 test/js/node/watch/fixtures/close.js create mode 100644 test/js/node/watch/fixtures/persistent.js create mode 100644 test/js/node/watch/fixtures/relative.js create mode 100644 test/js/node/watch/fixtures/unref.js create mode 100644 test/js/node/watch/fs.watch.test.js (limited to 'src/jsc.zig') diff --git a/packages/bun-types/fs.d.ts b/packages/bun-types/fs.d.ts index 14c5c1d1d..5dfb2c7f2 100644 --- a/packages/bun-types/fs.d.ts +++ b/packages/bun-types/fs.d.ts @@ -19,6 +19,7 @@ */ declare module "fs" { import * as stream from "stream"; + import type EventEmitter from "events"; import type { SystemError, ArrayBufferView } from "bun"; interface ObjectEncodingOptions { encoding?: BufferEncoding | null | undefined; @@ -3929,6 +3930,102 @@ declare module "fs" { */ recursive?: boolean; } + + export interface FSWatcher extends EventEmitter { + /** + * Stop watching for changes on the given `fs.FSWatcher`. Once stopped, the `fs.FSWatcher` object is no longer usable. + * @since v0.6.8 + */ + close(): void; + + /** + * When called, requests that the Node.js event loop not exit so long as the is active. Calling watcher.ref() multiple times will have no effect. + */ + ref(): void; + + /** + * When called, the active object will not require the Node.js event loop to remain active. If there is no other activity keeping the event loop running, the process may exit before the object's callback is invoked. Calling watcher.unref() multiple times will have no effect. + */ + unref(): void; + + /** + * events.EventEmitter + * 1. change + * 2. error + */ + addListener(event: string, listener: (...args: any[]) => void): this; + addListener(event: 'change', listener: (eventType: string, filename: string | Buffer) => void): this; + addListener(event: 'error', listener: (error: Error) => void): this; + addListener(event: 'close', listener: () => void): this; + on(event: string, listener: (...args: any[]) => void): this; + on(event: 'change', listener: (eventType: string, filename: string | Buffer) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'close', listener: () => void): this; + once(event: string, listener: (...args: any[]) => void): this; + once(event: 'change', listener: (eventType: string, filename: string | Buffer) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'close', listener: () => void): this; + prependListener(event: string, listener: (...args: any[]) => void): this; + prependListener(event: 'change', listener: (eventType: string, filename: string | Buffer) => void): this; + prependListener(event: 'error', listener: (error: Error) => void): this; + prependListener(event: 'close', listener: () => void): this; + prependOnceListener(event: string, listener: (...args: any[]) => void): this; + prependOnceListener(event: 'change', listener: (eventType: string, filename: string | Buffer) => void): this; + prependOnceListener(event: 'error', listener: (error: Error) => void): this; + prependOnceListener(event: 'close', listener: () => void): this; + } + /** + * Watch for changes on `filename`, where `filename` is either a file or a + * directory. + * + * The second argument is optional. If `options` is provided as a string, it + * specifies the `encoding`. Otherwise `options` should be passed as an object. + * + * The listener callback gets two arguments `(eventType, filename)`. `eventType`is either `'rename'` or `'change'`, and `filename` is the name of the file + * which triggered the event. + * + * On most platforms, `'rename'` is emitted whenever a filename appears or + * disappears in the directory. + * + * The listener callback is attached to the `'change'` event fired by `fs.FSWatcher`, but it is not the same thing as the `'change'` value of`eventType`. + * + * If a `signal` is passed, aborting the corresponding AbortController will close + * the returned `fs.FSWatcher`. + * @since v0.6.8 + * @param listener + */ + export function watch( + filename: PathLike, + options: + | (WatchOptions & { + encoding: 'buffer'; + }) + | 'buffer', + listener?: WatchListener + ): FSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `FSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + export function watch(filename: PathLike, options?: WatchOptions | BufferEncoding | null, listener?: WatchListener): FSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `FSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + export function watch(filename: PathLike, options: WatchOptions | string, listener?: WatchListener): FSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `FSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + */ + export function watch(filename: PathLike, listener?: WatchListener): FSWatcher; } declare module "node:fs" { diff --git a/packages/bun-types/fs/promises.d.ts b/packages/bun-types/fs/promises.d.ts index 0d71464b9..2b908fceb 100644 --- a/packages/bun-types/fs/promises.d.ts +++ b/packages/bun-types/fs/promises.d.ts @@ -26,6 +26,7 @@ declare module "fs/promises" { Abortable, RmOptions, RmDirOptions, + WatchOptions, } from "node:fs"; const constants: typeof import("node:fs")["constants"]; @@ -709,6 +710,63 @@ declare module "fs/promises" { * To remove a directory recursively, use `fs.promises.rm()` instead, with the `recursive` option set to `true`. */ function rmdir(path: PathLike, options?: RmDirOptions): Promise; + + /** + * Returns an async iterator that watches for changes on `filename`, where `filename`is either a file or a directory. + * + * ```js + * const { watch } = require('node:fs/promises'); + * + * const ac = new AbortController(); + * const { signal } = ac; + * setTimeout(() => ac.abort(), 10000); + * + * (async () => { + * try { + * const watcher = watch(__filename, { signal }); + * for await (const event of watcher) + * console.log(event); + * } catch (err) { + * if (err.name === 'AbortError') + * return; + * throw err; + * } + * })(); + * ``` + * + * On most platforms, `'rename'` is emitted whenever a filename appears or + * disappears in the directory. + * + * All the `caveats` for `fs.watch()` also apply to `fsPromises.watch()`. + * @since v0.6.8 + * @return of objects with the properties: + */ + function watch( + filename: PathLike, + options: + | (WatchOptions & { + encoding: 'buffer'; + }) + | 'buffer' + ): AsyncIterable>; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `FSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + function watch(filename: PathLike, options?: WatchOptions | BufferEncoding): AsyncIterable>; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `FSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + function watch(filename: PathLike, options: WatchOptions | string): AsyncIterable> | AsyncIterable>; } declare module "node:fs/promises" { diff --git a/src/bun.js/bindings/JSSink.cpp b/src/bun.js/bindings/JSSink.cpp index 36be334dd..4acf01ff7 100644 --- a/src/bun.js/bindings/JSSink.cpp +++ b/src/bun.js/bindings/JSSink.cpp @@ -1,6 +1,6 @@ // AUTO-GENERATED FILE. DO NOT EDIT. -// Generated by 'make generate-sink' at 2023-05-18T01:04:00.447Z +// Generated by 'make generate-sink' at 2023-06-14T21:38:04.394Z // To regenerate this file, run: // // make generate-sink diff --git a/src/bun.js/bindings/JSSink.h b/src/bun.js/bindings/JSSink.h index 5bbfab777..37c458e9b 100644 --- a/src/bun.js/bindings/JSSink.h +++ b/src/bun.js/bindings/JSSink.h @@ -1,6 +1,6 @@ // AUTO-GENERATED FILE. DO NOT EDIT. -// Generated by 'make generate-sink' at 2023-05-18T01:04:00.446Z +// Generated by 'make generate-sink' at 2023-06-14T21:38:04.394Z // #pragma once diff --git a/src/bun.js/bindings/JSSinkLookupTable.h b/src/bun.js/bindings/JSSinkLookupTable.h index a4ace6dc3..e4ed81629 100644 --- a/src/bun.js/bindings/JSSinkLookupTable.h +++ b/src/bun.js/bindings/JSSinkLookupTable.h @@ -1,4 +1,4 @@ -// Automatically generated from src/bun.js/bindings/JSSink.cpp using /Users/jarred/Code/bun/src/bun.js/WebKit/Source/JavaScriptCore/create_hash_table. DO NOT EDIT! +// Automatically generated from src/bun.js/bindings/JSSink.cpp using /home/cirospaciari/Repos/bun/src/bun.js/WebKit/Source/JavaScriptCore/create_hash_table. DO NOT EDIT! diff --git a/src/bun.js/bindings/ZigGeneratedClasses+DOMClientIsoSubspaces.h b/src/bun.js/bindings/ZigGeneratedClasses+DOMClientIsoSubspaces.h index b16febcdb..f0d491c0b 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses+DOMClientIsoSubspaces.h +++ b/src/bun.js/bindings/ZigGeneratedClasses+DOMClientIsoSubspaces.h @@ -8,6 +8,7 @@ std::unique_ptr m_clientSubspaceForExpectConstructor;std: std::unique_ptr m_clientSubspaceForExpectAnything; std::unique_ptr m_clientSubspaceForExpectStringContaining; std::unique_ptr m_clientSubspaceForExpectStringMatching; +std::unique_ptr m_clientSubspaceForFSWatcher; std::unique_ptr m_clientSubspaceForFileSystemRouter; std::unique_ptr m_clientSubspaceForFileSystemRouterConstructor;std::unique_ptr m_clientSubspaceForListener; std::unique_ptr m_clientSubspaceForMD4; diff --git a/src/bun.js/bindings/ZigGeneratedClasses+DOMIsoSubspaces.h b/src/bun.js/bindings/ZigGeneratedClasses+DOMIsoSubspaces.h index 59263e62c..02a9adbca 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses+DOMIsoSubspaces.h +++ b/src/bun.js/bindings/ZigGeneratedClasses+DOMIsoSubspaces.h @@ -8,6 +8,7 @@ std::unique_ptr m_subspaceForExpectConstructor;std::unique_ptr m_subspaceForExpectAnything; std::unique_ptr m_subspaceForExpectStringContaining; std::unique_ptr m_subspaceForExpectStringMatching; +std::unique_ptr m_subspaceForFSWatcher; std::unique_ptr m_subspaceForFileSystemRouter; std::unique_ptr m_subspaceForFileSystemRouterConstructor;std::unique_ptr m_subspaceForListener; std::unique_ptr m_subspaceForMD4; diff --git a/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureHeader.h b/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureHeader.h index 4471fbab3..ac03032e6 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureHeader.h +++ b/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureHeader.h @@ -58,6 +58,12 @@ JSC::Structure* JSExpectStringMatchingStructure() { return m_JSExpectStringMatch JSC::LazyClassStructure m_JSExpectStringMatching; bool hasJSExpectStringMatchingSetterValue { false }; mutable JSC::WriteBarrier m_JSExpectStringMatchingSetterValue; +JSC::Structure* JSFSWatcherStructure() { return m_JSFSWatcher.getInitializedOnMainThread(this); } + JSC::JSObject* JSFSWatcherConstructor() { return m_JSFSWatcher.constructorInitializedOnMainThread(this); } + JSC::JSValue JSFSWatcherPrototype() { return m_JSFSWatcher.prototypeInitializedOnMainThread(this); } + JSC::LazyClassStructure m_JSFSWatcher; + bool hasJSFSWatcherSetterValue { false }; + mutable JSC::WriteBarrier m_JSFSWatcherSetterValue; JSC::Structure* JSFileSystemRouterStructure() { return m_JSFileSystemRouter.getInitializedOnMainThread(this); } JSC::JSObject* JSFileSystemRouterConstructor() { return m_JSFileSystemRouter.constructorInitializedOnMainThread(this); } JSC::JSValue JSFileSystemRouterPrototype() { return m_JSFileSystemRouter.prototypeInitializedOnMainThread(this); } diff --git a/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureImpl.h b/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureImpl.h index 4e5a2c1fa..b3b5327a4 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureImpl.h +++ b/src/bun.js/bindings/ZigGeneratedClasses+lazyStructureImpl.h @@ -58,6 +58,12 @@ void GlobalObject::initGeneratedLazyClasses() { init.setPrototype(WebCore::JSExpectStringMatching::createPrototype(init.vm, reinterpret_cast(init.global))); init.setStructure(WebCore::JSExpectStringMatching::createStructure(init.vm, init.global, init.prototype)); + }); + m_JSFSWatcher.initLater( + [](LazyClassStructure::Initializer& init) { + init.setPrototype(WebCore::JSFSWatcher::createPrototype(init.vm, reinterpret_cast(init.global))); + init.setStructure(WebCore::JSFSWatcher::createStructure(init.vm, init.global, init.prototype)); + }); m_JSFileSystemRouter.initLater( [](LazyClassStructure::Initializer& init) { @@ -211,6 +217,7 @@ void GlobalObject::visitGeneratedLazyClasses(GlobalObject *thisObject, Visitor& thisObject->m_JSExpectAnything.visit(visitor); visitor.append(thisObject->m_JSExpectAnythingSetterValue); thisObject->m_JSExpectStringContaining.visit(visitor); visitor.append(thisObject->m_JSExpectStringContainingSetterValue); thisObject->m_JSExpectStringMatching.visit(visitor); visitor.append(thisObject->m_JSExpectStringMatchingSetterValue); + thisObject->m_JSFSWatcher.visit(visitor); visitor.append(thisObject->m_JSFSWatcherSetterValue); thisObject->m_JSFileSystemRouter.visit(visitor); visitor.append(thisObject->m_JSFileSystemRouterSetterValue); thisObject->m_JSListener.visit(visitor); visitor.append(thisObject->m_JSListenerSetterValue); thisObject->m_JSMD4.visit(visitor); visitor.append(thisObject->m_JSMD4SetterValue); diff --git a/src/bun.js/bindings/ZigGeneratedClasses.cpp b/src/bun.js/bindings/ZigGeneratedClasses.cpp index d51a1959a..e0a3f33d6 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses.cpp +++ b/src/bun.js/bindings/ZigGeneratedClasses.cpp @@ -5381,6 +5381,297 @@ void JSExpectStringMatching::visitOutputConstraintsImpl(JSCell* cell, Visitor& v } DEFINE_VISIT_OUTPUT_CONSTRAINTS(JSExpectStringMatching); +class JSFSWatcherPrototype final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static JSFSWatcherPrototype* create(JSC::VM& vm, JSGlobalObject* globalObject, JSC::Structure* structure) + { + JSFSWatcherPrototype* ptr = new (NotNull, JSC::allocateCell(vm)) JSFSWatcherPrototype(vm, globalObject, structure); + ptr->finishCreation(vm, globalObject); + return ptr; + } + + DECLARE_INFO; + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + return &vm.plainObjectSpace(); + } + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + +private: + JSFSWatcherPrototype(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM&, JSC::JSGlobalObject*); +}; + +extern "C" void FSWatcherClass__finalize(void*); + +extern "C" EncodedJSValue FSWatcherPrototype__doClose(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); +JSC_DECLARE_HOST_FUNCTION(FSWatcherPrototype__closeCallback); + +extern "C" EncodedJSValue FSWatcherPrototype__hasRef(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); +JSC_DECLARE_HOST_FUNCTION(FSWatcherPrototype__hasRefCallback); + +extern "C" EncodedJSValue FSWatcherPrototype__doRef(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); +JSC_DECLARE_HOST_FUNCTION(FSWatcherPrototype__refCallback); + +extern "C" EncodedJSValue FSWatcherPrototype__doUnref(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); +JSC_DECLARE_HOST_FUNCTION(FSWatcherPrototype__unrefCallback); + +STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSFSWatcherPrototype, JSFSWatcherPrototype::Base); + +static const HashTableValue JSFSWatcherPrototypeTableValues[] = { + { "close"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, FSWatcherPrototype__closeCallback, 0 } }, + { "hasRef"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, FSWatcherPrototype__hasRefCallback, 0 } }, + { "ref"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, FSWatcherPrototype__refCallback, 0 } }, + { "unref"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, FSWatcherPrototype__unrefCallback, 0 } } +}; + +const ClassInfo JSFSWatcherPrototype::s_info = { "FSWatcher"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSFSWatcherPrototype) }; + +JSC_DEFINE_HOST_FUNCTION(FSWatcherPrototype__closeCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + + JSFSWatcher* thisObject = jsDynamicCast(callFrame->thisValue()); + + if (UNLIKELY(!thisObject)) { + auto throwScope = DECLARE_THROW_SCOPE(vm); + return throwVMTypeError(lexicalGlobalObject, throwScope); + } + + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return FSWatcherPrototype__doClose(thisObject->wrapped(), lexicalGlobalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(FSWatcherPrototype__hasRefCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + + JSFSWatcher* thisObject = jsDynamicCast(callFrame->thisValue()); + + if (UNLIKELY(!thisObject)) { + auto throwScope = DECLARE_THROW_SCOPE(vm); + return throwVMTypeError(lexicalGlobalObject, throwScope); + } + + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return FSWatcherPrototype__hasRef(thisObject->wrapped(), lexicalGlobalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(FSWatcherPrototype__refCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + + JSFSWatcher* thisObject = jsDynamicCast(callFrame->thisValue()); + + if (UNLIKELY(!thisObject)) { + auto throwScope = DECLARE_THROW_SCOPE(vm); + return throwVMTypeError(lexicalGlobalObject, throwScope); + } + + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return FSWatcherPrototype__doRef(thisObject->wrapped(), lexicalGlobalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(FSWatcherPrototype__unrefCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + + JSFSWatcher* thisObject = jsDynamicCast(callFrame->thisValue()); + + if (UNLIKELY(!thisObject)) { + auto throwScope = DECLARE_THROW_SCOPE(vm); + return throwVMTypeError(lexicalGlobalObject, throwScope); + } + + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return FSWatcherPrototype__doUnref(thisObject->wrapped(), lexicalGlobalObject, callFrame); +} + +extern "C" void FSWatcherPrototype__listenerSetCachedValue(JSC::EncodedJSValue thisValue, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue value) +{ + auto& vm = globalObject->vm(); + auto* thisObject = jsCast(JSValue::decode(thisValue)); + thisObject->m_listener.set(vm, thisObject, JSValue::decode(value)); +} + +extern "C" EncodedJSValue FSWatcherPrototype__listenerGetCachedValue(JSC::EncodedJSValue thisValue) +{ + auto* thisObject = jsCast(JSValue::decode(thisValue)); + return JSValue::encode(thisObject->m_listener.get()); +} + +void JSFSWatcherPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) +{ + Base::finishCreation(vm); + reifyStaticProperties(vm, JSFSWatcher::info(), JSFSWatcherPrototypeTableValues, *this); + JSC_TO_STRING_TAG_WITHOUT_TRANSITION(); +} + +JSFSWatcher::~JSFSWatcher() +{ + if (m_ctx) { + FSWatcherClass__finalize(m_ctx); + } +} +void JSFSWatcher::destroy(JSCell* cell) +{ + static_cast(cell)->JSFSWatcher::~JSFSWatcher(); +} + +const ClassInfo JSFSWatcher::s_info = { "FSWatcher"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSFSWatcher) }; + +void JSFSWatcher::finishCreation(VM& vm) +{ + Base::finishCreation(vm); + ASSERT(inherits(info())); +} + +JSFSWatcher* JSFSWatcher::create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, void* ctx) +{ + JSFSWatcher* ptr = new (NotNull, JSC::allocateCell(vm)) JSFSWatcher(vm, structure, ctx); + ptr->finishCreation(vm); + return ptr; +} + +extern "C" void* FSWatcher__fromJS(JSC::EncodedJSValue value) +{ + JSC::JSValue decodedValue = JSC::JSValue::decode(value); + if (decodedValue.isEmpty() || !decodedValue.isCell()) + return nullptr; + + JSC::JSCell* cell = decodedValue.asCell(); + JSFSWatcher* object = JSC::jsDynamicCast(cell); + + if (!object) + return nullptr; + + return object->wrapped(); +} + +extern "C" bool FSWatcher__dangerouslySetPtr(JSC::EncodedJSValue value, void* ptr) +{ + JSFSWatcher* object = JSC::jsDynamicCast(JSValue::decode(value)); + if (!object) + return false; + + object->m_ctx = ptr; + return true; +} + +extern "C" const size_t FSWatcher__ptrOffset = JSFSWatcher::offsetOfWrapped(); + +void JSFSWatcher::analyzeHeap(JSCell* cell, HeapAnalyzer& analyzer) +{ + auto* thisObject = jsCast(cell); + if (void* wrapped = thisObject->wrapped()) { + // if (thisObject->scriptExecutionContext()) + // analyzer.setLabelForCell(cell, "url " + thisObject->scriptExecutionContext()->url().string()); + } + Base::analyzeHeap(cell, analyzer); +} + +JSObject* JSFSWatcher::createPrototype(VM& vm, JSDOMGlobalObject* globalObject) +{ + return JSFSWatcherPrototype::create(vm, globalObject, JSFSWatcherPrototype::createStructure(vm, globalObject, globalObject->objectPrototype())); +} + +extern "C" EncodedJSValue FSWatcher__create(Zig::GlobalObject* globalObject, void* ptr) +{ + auto& vm = globalObject->vm(); + JSC::Structure* structure = globalObject->JSFSWatcherStructure(); + JSFSWatcher* instance = JSFSWatcher::create(vm, globalObject, structure, ptr); + + return JSValue::encode(instance); +} + +template +void JSFSWatcher::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSFSWatcher* thisObject = jsCast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_listener); +} + +DEFINE_VISIT_CHILDREN(JSFSWatcher); + +template +void JSFSWatcher::visitAdditionalChildren(Visitor& visitor) +{ + JSFSWatcher* thisObject = this; + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + visitor.append(thisObject->m_listener); +} + +DEFINE_VISIT_ADDITIONAL_CHILDREN(JSFSWatcher); + +template +void JSFSWatcher::visitOutputConstraintsImpl(JSCell* cell, Visitor& visitor) +{ + JSFSWatcher* thisObject = jsCast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + thisObject->visitAdditionalChildren(visitor); +} + +DEFINE_VISIT_OUTPUT_CONSTRAINTS(JSFSWatcher); class JSFileSystemRouterPrototype final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; @@ -7654,6 +7945,9 @@ JSC_DECLARE_HOST_FUNCTION(NodeJSFSPrototype__utimesCallback); extern "C" EncodedJSValue NodeJSFSPrototype__utimesSync(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); JSC_DECLARE_HOST_FUNCTION(NodeJSFSPrototype__utimesSyncCallback); +extern "C" EncodedJSValue NodeJSFSPrototype__watch(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); +JSC_DECLARE_HOST_FUNCTION(NodeJSFSPrototype__watchCallback); + extern "C" EncodedJSValue NodeJSFSPrototype__write(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); JSC_DECLARE_HOST_FUNCTION(NodeJSFSPrototype__writeCallback); @@ -7751,6 +8045,7 @@ static const HashTableValue JSNodeJSFSPrototypeTableValues[] = { { "unlinkSync"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__unlinkSyncCallback, 1 } }, { "utimes"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__utimesCallback, 4 } }, { "utimesSync"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__utimesSyncCallback, 3 } }, + { "watch"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__watchCallback, 3 } }, { "write"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__writeCallback, 6 } }, { "writeFile"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__writeFileCallback, 4 } }, { "writeFileSync"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, NodeJSFSPrototype__writeFileSyncCallback, 3 } }, @@ -9795,6 +10090,33 @@ JSC_DEFINE_HOST_FUNCTION(NodeJSFSPrototype__utimesSyncCallback, (JSGlobalObject return NodeJSFSPrototype__utimesSync(thisObject->wrapped(), lexicalGlobalObject, callFrame); } +JSC_DEFINE_HOST_FUNCTION(NodeJSFSPrototype__watchCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + + JSNodeJSFS* thisObject = jsDynamicCast(callFrame->thisValue()); + + if (UNLIKELY(!thisObject)) { + auto throwScope = DECLARE_THROW_SCOPE(vm); + return throwVMTypeError(lexicalGlobalObject, throwScope); + } + + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + +#ifdef BUN_DEBUG + /** View the file name of the JS file that called this function + * from a debugger */ + SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); + const char* fileName = sourceOrigin.string().utf8().data(); + static const char* lastFileName = nullptr; + if (lastFileName != fileName) { + lastFileName = fileName; + } +#endif + + return NodeJSFSPrototype__watch(thisObject->wrapped(), lexicalGlobalObject, callFrame); +} + JSC_DEFINE_HOST_FUNCTION(NodeJSFSPrototype__writeCallback, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { auto& vm = lexicalGlobalObject->vm(); diff --git a/src/bun.js/bindings/ZigGeneratedClasses.h b/src/bun.js/bindings/ZigGeneratedClasses.h index 668cd3f6b..3fa0e26d2 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses.h +++ b/src/bun.js/bindings/ZigGeneratedClasses.h @@ -578,6 +578,62 @@ public: mutable JSC::WriteBarrier m_testValue; }; +class JSFSWatcher final : public JSC::JSDestructibleObject { +public: + using Base = JSC::JSDestructibleObject; + static JSFSWatcher* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::Structure* structure, void* ctx); + + DECLARE_EXPORT_INFO; + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + if constexpr (mode == JSC::SubspaceAccess::Concurrently) + return nullptr; + return WebCore::subspaceForImpl( + vm, + [](auto& spaces) { return spaces.m_clientSubspaceForFSWatcher.get(); }, + [](auto& spaces, auto&& space) { spaces.m_clientSubspaceForFSWatcher = std::forward(space); }, + [](auto& spaces) { return spaces.m_subspaceForFSWatcher.get(); }, + [](auto& spaces, auto&& space) { spaces.m_subspaceForFSWatcher = std::forward(space); }); + } + + static void destroy(JSC::JSCell*); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(static_cast(0b11101110), StructureFlags), info()); + } + + static JSObject* createPrototype(VM& vm, JSDOMGlobalObject* globalObject); + ; + + ~JSFSWatcher(); + + void* wrapped() const { return m_ctx; } + + void detach() + { + m_ctx = nullptr; + } + + static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); + static ptrdiff_t offsetOfWrapped() { return OBJECT_OFFSETOF(JSFSWatcher, m_ctx); } + + void* m_ctx { nullptr }; + + JSFSWatcher(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr) + : Base(vm, structure) + { + m_ctx = sinkPtr; + } + + void finishCreation(JSC::VM&); + + DECLARE_VISIT_CHILDREN; + template void visitAdditionalChildren(Visitor&); + DECLARE_VISIT_OUTPUT_CONSTRAINTS; + + mutable JSC::WriteBarrier m_listener; +}; + class JSFileSystemRouter final : public JSC::JSDestructibleObject { public: using Base = JSC::JSDestructibleObject; diff --git a/src/bun.js/bindings/generated_classes.zig b/src/bun.js/bindings/generated_classes.zig index 0ec65a469..74e30cd83 100644 --- a/src/bun.js/bindings/generated_classes.zig +++ b/src/bun.js/bindings/generated_classes.zig @@ -1406,6 +1406,96 @@ pub const JSExpectStringMatching = struct { } } }; +pub const JSFSWatcher = struct { + const FSWatcher = Classes.FSWatcher; + const GetterType = fn (*FSWatcher, *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + const GetterTypeWithThisValue = fn (*FSWatcher, JSC.JSValue, *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; + const SetterType = fn (*FSWatcher, *JSC.JSGlobalObject, JSC.JSValue) callconv(.C) bool; + const SetterTypeWithThisValue = fn (*FSWatcher, JSC.JSValue, *JSC.JSGlobalObject, JSC.JSValue) callconv(.C) bool; + const CallbackType = fn (*FSWatcher, *JSC.JSGlobalObject, *JSC.CallFrame) callconv(.C) JSC.JSValue; + + /// Return the pointer to the wrapped object. + /// If the object does not match the type, return null. + pub fn fromJS(value: JSC.JSValue) ?*FSWatcher { + JSC.markBinding(@src()); + return FSWatcher__fromJS(value); + } + + extern fn FSWatcherPrototype__listenerSetCachedValue(JSC.JSValue, *JSC.JSGlobalObject, JSC.JSValue) void; + + extern fn FSWatcherPrototype__listenerGetCachedValue(JSC.JSValue) JSC.JSValue; + + /// `FSWatcher.listener` setter + /// This value will be visited by the garbage collector. + pub fn listenerSetCached(thisValue: JSC.JSValue, globalObject: *JSC.JSGlobalObject, value: JSC.JSValue) void { + JSC.markBinding(@src()); + FSWatcherPrototype__listenerSetCachedValue(thisValue, globalObject, value); + } + + /// `FSWatcher.listener` getter + /// This value will be visited by the garbage collector. + pub fn listenerGetCached(thisValue: JSC.JSValue) ?JSC.JSValue { + JSC.markBinding(@src()); + const result = FSWatcherPrototype__listenerGetCachedValue(thisValue); + if (result == .zero) + return null; + + return result; + } + + /// Create a new instance of FSWatcher + pub fn toJS(this: *FSWatcher, globalObject: *JSC.JSGlobalObject) JSC.JSValue { + JSC.markBinding(@src()); + if (comptime Environment.allow_assert) { + const value__ = FSWatcher__create(globalObject, this); + std.debug.assert(value__.as(FSWatcher).? == this); // If this fails, likely a C ABI issue. + return value__; + } else { + return FSWatcher__create(globalObject, this); + } + } + + /// Modify the internal ptr to point to a new instance of FSWatcher. + pub fn dangerouslySetPtr(value: JSC.JSValue, ptr: ?*FSWatcher) bool { + JSC.markBinding(@src()); + return FSWatcher__dangerouslySetPtr(value, ptr); + } + + /// Detach the ptr from the thisValue + pub fn detachPtr(_: *FSWatcher, value: JSC.JSValue) void { + JSC.markBinding(@src()); + std.debug.assert(FSWatcher__dangerouslySetPtr(value, null)); + } + + extern fn FSWatcher__fromJS(JSC.JSValue) ?*FSWatcher; + extern fn FSWatcher__getConstructor(*JSC.JSGlobalObject) JSC.JSValue; + + extern fn FSWatcher__create(globalObject: *JSC.JSGlobalObject, ptr: ?*FSWatcher) JSC.JSValue; + + extern fn FSWatcher__dangerouslySetPtr(JSC.JSValue, ?*FSWatcher) bool; + + comptime { + if (@TypeOf(FSWatcher.finalize) != (fn (*FSWatcher) callconv(.C) void)) { + @compileLog("FSWatcher.finalize is not a finalizer"); + } + + if (@TypeOf(FSWatcher.doClose) != CallbackType) + @compileLog("Expected FSWatcher.doClose to be a callback but received " ++ @typeName(@TypeOf(FSWatcher.doClose))); + if (@TypeOf(FSWatcher.hasRef) != CallbackType) + @compileLog("Expected FSWatcher.hasRef to be a callback but received " ++ @typeName(@TypeOf(FSWatcher.hasRef))); + if (@TypeOf(FSWatcher.doRef) != CallbackType) + @compileLog("Expected FSWatcher.doRef to be a callback but received " ++ @typeName(@TypeOf(FSWatcher.doRef))); + if (@TypeOf(FSWatcher.doUnref) != CallbackType) + @compileLog("Expected FSWatcher.doUnref to be a callback but received " ++ @typeName(@TypeOf(FSWatcher.doUnref))); + if (!JSC.is_bindgen) { + @export(FSWatcher.doClose, .{ .name = "FSWatcherPrototype__doClose" }); + @export(FSWatcher.doRef, .{ .name = "FSWatcherPrototype__doRef" }); + @export(FSWatcher.doUnref, .{ .name = "FSWatcherPrototype__doUnref" }); + @export(FSWatcher.finalize, .{ .name = "FSWatcherClass__finalize" }); + @export(FSWatcher.hasRef, .{ .name = "FSWatcherPrototype__hasRef" }); + } + } +}; pub const JSFileSystemRouter = struct { const FileSystemRouter = Classes.FileSystemRouter; const GetterType = fn (*FileSystemRouter, *JSC.JSGlobalObject) callconv(.C) JSC.JSValue; @@ -2312,6 +2402,8 @@ pub const JSNodeJSFS = struct { @compileLog("Expected NodeJSFS.utimes to be a callback but received " ++ @typeName(@TypeOf(NodeJSFS.utimes))); if (@TypeOf(NodeJSFS.utimesSync) != CallbackType) @compileLog("Expected NodeJSFS.utimesSync to be a callback but received " ++ @typeName(@TypeOf(NodeJSFS.utimesSync))); + if (@TypeOf(NodeJSFS.watch) != CallbackType) + @compileLog("Expected NodeJSFS.watch to be a callback but received " ++ @typeName(@TypeOf(NodeJSFS.watch))); if (@TypeOf(NodeJSFS.write) != CallbackType) @compileLog("Expected NodeJSFS.write to be a callback but received " ++ @typeName(@TypeOf(NodeJSFS.write))); if (@TypeOf(NodeJSFS.writeFile) != CallbackType) @@ -2402,6 +2494,7 @@ pub const JSNodeJSFS = struct { @export(NodeJSFS.unlinkSync, .{ .name = "NodeJSFSPrototype__unlinkSync" }); @export(NodeJSFS.utimes, .{ .name = "NodeJSFSPrototype__utimes" }); @export(NodeJSFS.utimesSync, .{ .name = "NodeJSFSPrototype__utimesSync" }); + @export(NodeJSFS.watch, .{ .name = "NodeJSFSPrototype__watch" }); @export(NodeJSFS.write, .{ .name = "NodeJSFSPrototype__write" }); @export(NodeJSFS.writeFile, .{ .name = "NodeJSFSPrototype__writeFile" }); @export(NodeJSFS.writeFileSync, .{ .name = "NodeJSFSPrototype__writeFileSync" }); @@ -4855,6 +4948,7 @@ comptime { _ = JSExpectAnything; _ = JSExpectStringContaining; _ = JSExpectStringMatching; + _ = JSFSWatcher; _ = JSFileSystemRouter; _ = JSListener; _ = JSMD4; diff --git a/src/bun.js/bindings/generated_classes_list.zig b/src/bun.js/bindings/generated_classes_list.zig index c54965093..d90267337 100644 --- a/src/bun.js/bindings/generated_classes_list.zig +++ b/src/bun.js/bindings/generated_classes_list.zig @@ -37,4 +37,5 @@ pub const Classes = struct { pub const BuildArtifact = JSC.API.BuildArtifact; pub const BuildMessage = JSC.BuildMessage; pub const ResolveMessage = JSC.ResolveMessage; + pub const FSWatcher = JSC.Node.FSWatcher.JSObject; }; diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index 0a3459d64..a3ccd16ad 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -224,6 +224,7 @@ pub const CppTask = opaque { const ThreadSafeFunction = JSC.napi.ThreadSafeFunction; const MicrotaskForDefaultGlobalObject = JSC.MicrotaskForDefaultGlobalObject; const HotReloadTask = JSC.HotReloader.HotReloadTask; +const FSWatchTask = JSC.Node.FSWatcher.FSWatchTask; const PollPendingModulesTask = JSC.ModuleLoader.AsyncModule.Queue; // const PromiseTask = JSInternalPromise.Completion.PromiseTask; const GetAddrInfoRequestTask = JSC.DNS.GetAddrInfoRequest.Task; @@ -242,6 +243,7 @@ pub const Task = TaggedPointerUnion(.{ HotReloadTask, PollPendingModulesTask, GetAddrInfoRequestTask, + FSWatchTask, // PromiseTask, // TimeoutTasklet, }); @@ -467,6 +469,11 @@ pub const EventLoop = struct { // special case: we return return 0; }, + .FSWatchTask => { + var transform_task: *FSWatchTask = task.get(FSWatchTask).?; + transform_task.*.run(); + transform_task.deinit(); + }, @field(Task.Tag, typeBaseName(@typeName(AnyTask))) => { var any: *AnyTask = task.get(AnyTask).?; any.run(); diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index bebfbeb18..3baa25e22 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -2609,6 +2609,13 @@ pub fn NewHotReloader(comptime Ctx: type, comptime EventLoopType: type, comptime return this.tombstones.get(key); } + pub fn onError( + _: *@This(), + err: anyerror, + ) void { + Output.prettyErrorln("Watcher crashed: {s}", .{@errorName(err)}); + } + pub fn onFileUpdate( this: *@This(), events: []watcher.WatchEvent, diff --git a/src/bun.js/node/fs_events.zig b/src/bun.js/node/fs_events.zig new file mode 100644 index 000000000..a3fba5441 --- /dev/null +++ b/src/bun.js/node/fs_events.zig @@ -0,0 +1,609 @@ +const std = @import("std"); +const bun = @import("root").bun; +const Environment = bun.Environment; +const Mutex = @import("../../lock.zig").Lock; +const sync = @import("../../sync.zig"); +const Semaphore = sync.Semaphore; +const UnboundedQueue = @import("../unbounded_queue.zig").UnboundedQueue; +const TaggedPointerUnion = @import("../../tagged_pointer.zig").TaggedPointerUnion; +const string = bun.string; + +pub const CFAbsoluteTime = f64; +pub const CFTimeInterval = f64; +pub const CFArrayCallBacks = anyopaque; + +pub const FSEventStreamEventFlags = c_int; +pub const OSStatus = c_int; +pub const CFIndex = c_long; + +pub const FSEventStreamCreateFlags = u32; +pub const FSEventStreamEventId = u64; + +pub const CFStringEncoding = c_uint; + +pub const CFArrayRef = ?*anyopaque; +pub const CFAllocatorRef = ?*anyopaque; +pub const CFBundleRef = ?*anyopaque; +pub const CFDictionaryRef = ?*anyopaque; +pub const CFRunLoopRef = ?*anyopaque; +pub const CFRunLoopSourceRef = ?*anyopaque; +pub const CFStringRef = ?*anyopaque; +pub const CFTypeRef = ?*anyopaque; +pub const FSEventStreamRef = ?*anyopaque; +pub const FSEventStreamCallback = *const fn (FSEventStreamRef, ?*anyopaque, usize, ?*anyopaque, *FSEventStreamEventFlags, *FSEventStreamEventId) callconv(.C) void; + +// we only care about info and perform +pub const CFRunLoopSourceContext = extern struct { + version: CFIndex = 0, + info: *anyopaque, + retain: ?*anyopaque = null, + release: ?*anyopaque = null, + copyDescription: ?*anyopaque = null, + equal: ?*anyopaque = null, + hash: ?*anyopaque = null, + schedule: ?*anyopaque = null, + cancel: ?*anyopaque = null, + perform: *const fn (?*anyopaque) callconv(.C) void, +}; + +pub const FSEventStreamContext = extern struct { + version: CFIndex = 0, + info: ?*anyopaque = null, + pad: [3]?*anyopaque = .{ null, null, null }, +}; + +pub const kCFStringEncodingUTF8: CFStringEncoding = 0x8000100; +pub const noErr: OSStatus = 0; + +pub const kFSEventStreamCreateFlagNoDefer: c_int = 2; +pub const kFSEventStreamCreateFlagFileEvents: c_int = 16; + +pub const kFSEventStreamEventFlagEventIdsWrapped: c_int = 8; +pub const kFSEventStreamEventFlagHistoryDone: c_int = 16; +pub const kFSEventStreamEventFlagItemChangeOwner: c_int = 0x4000; +pub const kFSEventStreamEventFlagItemCreated: c_int = 0x100; +pub const kFSEventStreamEventFlagItemFinderInfoMod: c_int = 0x2000; +pub const kFSEventStreamEventFlagItemInodeMetaMod: c_int = 0x400; +pub const kFSEventStreamEventFlagItemIsDir: c_int = 0x20000; +pub const kFSEventStreamEventFlagItemModified: c_int = 0x1000; +pub const kFSEventStreamEventFlagItemRemoved: c_int = 0x200; +pub const kFSEventStreamEventFlagItemRenamed: c_int = 0x800; +pub const kFSEventStreamEventFlagItemXattrMod: c_int = 0x8000; +pub const kFSEventStreamEventFlagKernelDropped: c_int = 4; +pub const kFSEventStreamEventFlagMount: c_int = 64; +pub const kFSEventStreamEventFlagRootChanged: c_int = 32; +pub const kFSEventStreamEventFlagUnmount: c_int = 128; +pub const kFSEventStreamEventFlagUserDropped: c_int = 2; + +// Lazy function call binding. +const RTLD_LAZY = 0x1; +// Symbols exported from this image (dynamic library or bundle) +// are generally hidden and only availble to dlsym() when +// directly using the handle returned by this call to dlopen(). +const RTLD_LOCAL = 0x4; + +pub const kFSEventsModified: c_int = + kFSEventStreamEventFlagItemChangeOwner | + kFSEventStreamEventFlagItemFinderInfoMod | + kFSEventStreamEventFlagItemInodeMetaMod | + kFSEventStreamEventFlagItemModified | + kFSEventStreamEventFlagItemXattrMod; + +pub const kFSEventsRenamed: c_int = + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed; + +pub const kFSEventsSystem: c_int = + kFSEventStreamEventFlagUserDropped | + kFSEventStreamEventFlagKernelDropped | + kFSEventStreamEventFlagEventIdsWrapped | + kFSEventStreamEventFlagHistoryDone | + kFSEventStreamEventFlagMount | + kFSEventStreamEventFlagUnmount | + kFSEventStreamEventFlagRootChanged; + +var fsevents_mutex: Mutex = Mutex.init(); +var fsevents_default_loop_mutex: Mutex = Mutex.init(); +var fsevents_default_loop: ?*FSEventsLoop = null; + +fn dlsym(handle: ?*anyopaque, comptime Type: type, comptime symbol: [:0]const u8) ?Type { + if (std.c.dlsym(handle, symbol)) |ptr| { + return bun.cast(Type, ptr); + } + return null; +} + +pub const CoreFoundation = struct { + handle: ?*anyopaque, + ArrayCreate: *fn (CFAllocatorRef, [*]?*anyopaque, CFIndex, ?*CFArrayCallBacks) callconv(.C) CFArrayRef, + Release: *fn (CFTypeRef) callconv(.C) void, + + RunLoopAddSource: *fn (CFRunLoopRef, CFRunLoopSourceRef, CFStringRef) callconv(.C) void, + RunLoopGetCurrent: *fn () callconv(.C) CFRunLoopRef, + RunLoopRemoveSource: *fn (CFRunLoopRef, CFRunLoopSourceRef, CFStringRef) callconv(.C) void, + RunLoopRun: *fn () callconv(.C) void, + RunLoopSourceCreate: *fn (CFAllocatorRef, CFIndex, *CFRunLoopSourceContext) callconv(.C) CFRunLoopSourceRef, + RunLoopSourceSignal: *fn (CFRunLoopSourceRef) callconv(.C) void, + RunLoopStop: *fn (CFRunLoopRef) callconv(.C) void, + RunLoopWakeUp: *fn (CFRunLoopRef) callconv(.C) void, + StringCreateWithFileSystemRepresentation: *fn (CFAllocatorRef, [*]const u8) callconv(.C) CFStringRef, + RunLoopDefaultMode: *CFStringRef, + + pub fn get() CoreFoundation { + if (fsevents_cf) |cf| return cf; + fsevents_mutex.lock(); + defer fsevents_mutex.unlock(); + if (fsevents_cf) |cf| return cf; + + InitLibrary(); + + return fsevents_cf.?; + } + + // We Actually never deinit it + // pub fn deinit(this: *CoreFoundation) void { + // if(this.handle) | ptr| { + // this.handle = null; + // _ = std.c.dlclose(this.handle); + // } + // } + +}; + +pub const CoreServices = struct { + handle: ?*anyopaque, + FSEventStreamCreate: *fn (CFAllocatorRef, FSEventStreamCallback, *FSEventStreamContext, CFArrayRef, FSEventStreamEventId, CFTimeInterval, FSEventStreamCreateFlags) callconv(.C) FSEventStreamRef, + FSEventStreamInvalidate: *fn (FSEventStreamRef) callconv(.C) void, + FSEventStreamRelease: *fn (FSEventStreamRef) callconv(.C) void, + FSEventStreamScheduleWithRunLoop: *fn (FSEventStreamRef, CFRunLoopRef, CFStringRef) callconv(.C) void, + FSEventStreamStart: *fn (FSEventStreamRef) callconv(.C) c_int, + FSEventStreamStop: *fn (FSEventStreamRef) callconv(.C) void, + // libuv set it to -1 so the actual value is this + kFSEventStreamEventIdSinceNow: FSEventStreamEventId = 18446744073709551615, + + pub fn get() CoreServices { + if (fsevents_cs) |cs| return cs; + fsevents_mutex.lock(); + defer fsevents_mutex.unlock(); + if (fsevents_cs) |cs| return cs; + + InitLibrary(); + + return fsevents_cs.?; + } + + // We Actually never deinit it + // pub fn deinit(this: *CoreServices) void { + // if(this.handle) | ptr| { + // this.handle = null; + // _ = std.c.dlclose(this.handle); + // } + // } + +}; + +var fsevents_cf: ?CoreFoundation = null; +var fsevents_cs: ?CoreServices = null; + +fn InitLibrary() void { + const fsevents_cf_handle = std.c.dlopen("/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", RTLD_LAZY | RTLD_LOCAL); + if (fsevents_cf_handle == null) @panic("Cannot Load CoreFoundation"); + + fsevents_cf = CoreFoundation{ + .handle = fsevents_cf_handle, + .ArrayCreate = dlsym(fsevents_cf_handle, *fn (CFAllocatorRef, [*]?*anyopaque, CFIndex, ?*CFArrayCallBacks) callconv(.C) CFArrayRef, "CFArrayCreate") orelse @panic("Cannot Load CoreFoundation"), + .Release = dlsym(fsevents_cf_handle, *fn (CFTypeRef) callconv(.C) void, "CFRelease") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopAddSource = dlsym(fsevents_cf_handle, *fn (CFRunLoopRef, CFRunLoopSourceRef, CFStringRef) callconv(.C) void, "CFRunLoopAddSource") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopGetCurrent = dlsym(fsevents_cf_handle, *fn () callconv(.C) CFRunLoopRef, "CFRunLoopGetCurrent") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopRemoveSource = dlsym(fsevents_cf_handle, *fn (CFRunLoopRef, CFRunLoopSourceRef, CFStringRef) callconv(.C) void, "CFRunLoopRemoveSource") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopRun = dlsym(fsevents_cf_handle, *fn () callconv(.C) void, "CFRunLoopRun") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopSourceCreate = dlsym(fsevents_cf_handle, *fn (CFAllocatorRef, CFIndex, *CFRunLoopSourceContext) callconv(.C) CFRunLoopSourceRef, "CFRunLoopSourceCreate") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopSourceSignal = dlsym(fsevents_cf_handle, *fn (CFRunLoopSourceRef) callconv(.C) void, "CFRunLoopSourceSignal") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopStop = dlsym(fsevents_cf_handle, *fn (CFRunLoopRef) callconv(.C) void, "CFRunLoopStop") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopWakeUp = dlsym(fsevents_cf_handle, *fn (CFRunLoopRef) callconv(.C) void, "CFRunLoopWakeUp") orelse @panic("Cannot Load CoreFoundation"), + .StringCreateWithFileSystemRepresentation = dlsym(fsevents_cf_handle, *fn (CFAllocatorRef, [*]const u8) callconv(.C) CFStringRef, "CFStringCreateWithFileSystemRepresentation") orelse @panic("Cannot Load CoreFoundation"), + .RunLoopDefaultMode = dlsym(fsevents_cf_handle, *CFStringRef, "kCFRunLoopDefaultMode") orelse @panic("Cannot Load CoreFoundation"), + }; + + const fsevents_cs_handle = std.c.dlopen("/System/Library/Frameworks/CoreServices.framework/Versions/A/CoreServices", RTLD_LAZY | RTLD_LOCAL); + if (fsevents_cs_handle == null) @panic("Cannot Load CoreServices"); + + fsevents_cs = CoreServices{ + .handle = fsevents_cs_handle, + .FSEventStreamCreate = dlsym(fsevents_cs_handle, *fn (CFAllocatorRef, FSEventStreamCallback, *FSEventStreamContext, CFArrayRef, FSEventStreamEventId, CFTimeInterval, FSEventStreamCreateFlags) callconv(.C) FSEventStreamRef, "FSEventStreamCreate") orelse @panic("Cannot Load CoreServices"), + .FSEventStreamInvalidate = dlsym(fsevents_cs_handle, *fn (FSEventStreamRef) callconv(.C) void, "FSEventStreamInvalidate") orelse @panic("Cannot Load CoreServices"), + .FSEventStreamRelease = dlsym(fsevents_cs_handle, *fn (FSEventStreamRef) callconv(.C) void, "FSEventStreamRelease") orelse @panic("Cannot Load CoreServices"), + .FSEventStreamScheduleWithRunLoop = dlsym(fsevents_cs_handle, *fn (FSEventStreamRef, CFRunLoopRef, CFStringRef) callconv(.C) void, "FSEventStreamScheduleWithRunLoop") orelse @panic("Cannot Load CoreServices"), + .FSEventStreamStart = dlsym(fsevents_cs_handle, *fn (FSEventStreamRef) callconv(.C) c_int, "FSEventStreamStart") orelse @panic("Cannot Load CoreServices"), + .FSEventStreamStop = dlsym(fsevents_cs_handle, *fn (FSEventStreamRef) callconv(.C) void, "FSEventStreamStop") orelse @panic("Cannot Load CoreServices"), + }; +} + +pub const FSEventsLoop = struct { + signal_source: CFRunLoopSourceRef, + mutex: Mutex, + loop: CFRunLoopRef = null, + sem: Semaphore, + thread: std.Thread = undefined, + tasks: ConcurrentTask.Queue = ConcurrentTask.Queue{}, + watchers: bun.BabyList(?*FSEventsWatcher) = .{}, + watcher_count: u32 = 0, + fsevent_stream: FSEventStreamRef = null, + paths: ?[]?*anyopaque = null, + cf_paths: CFArrayRef = null, + has_scheduled_watchers: bool = false, + + pub const Task = struct { + ctx: ?*anyopaque, + callback: *const (fn (*anyopaque) void), + + pub fn run(this: *Task) void { + var callback = this.callback; + var ctx = this.ctx; + callback(ctx.?); + } + + pub fn New(comptime Type: type, comptime Callback: anytype) type { + return struct { + pub fn init(ctx: *Type) Task { + return Task{ + .callback = wrap, + .ctx = ctx, + }; + } + + pub fn wrap(this: ?*anyopaque) void { + @call(.always_inline, Callback, .{@ptrCast(*Type, @alignCast(@alignOf(Type), this.?))}); + } + }; + } + }; + + pub const ConcurrentTask = struct { + task: Task = undefined, + next: ?*ConcurrentTask = null, + auto_delete: bool = false, + + pub const Queue = UnboundedQueue(ConcurrentTask, .next); + + pub fn from(this: *ConcurrentTask, task: Task) *ConcurrentTask { + this.* = .{ + .task = task, + .next = null, + }; + return this; + } + }; + + pub fn CFThreadLoop(this: *FSEventsLoop) void { + bun.Output.Source.configureNamedThread("CFThreadLoop"); + + const CF = CoreFoundation.get(); + + this.loop = CF.RunLoopGetCurrent(); + + CF.RunLoopAddSource(this.loop, this.signal_source, CF.RunLoopDefaultMode.*); + + this.sem.post(); + + CF.RunLoopRun(); + CF.RunLoopRemoveSource(this.loop, this.signal_source, CF.RunLoopDefaultMode.*); + + this.loop = null; + } + + // Runs in CF thread, executed after `enqueueTaskConcurrent()` + fn CFLoopCallback(arg: ?*anyopaque) callconv(.C) void { + if (arg) |self| { + const this = bun.cast(*FSEventsLoop, self); + + var concurrent = this.tasks.popBatch(); + const count = concurrent.count; + if (count == 0) + return; + + var iter = concurrent.iterator(); + while (iter.next()) |task| { + task.task.run(); + if (task.auto_delete) bun.default_allocator.destroy(task); + } + } + } + + pub fn init() !*FSEventsLoop { + const this = bun.default_allocator.create(FSEventsLoop) catch unreachable; + + const CF = CoreFoundation.get(); + + var ctx = CFRunLoopSourceContext{ + .info = this, + .perform = CFLoopCallback, + }; + + const signal_source = CF.RunLoopSourceCreate(null, 0, &ctx); + if (signal_source == null) { + return error.FailedToCreateCoreFoudationSourceLoop; + } + + var fs_loop = FSEventsLoop{ .sem = Semaphore.init(0), .mutex = Mutex.init(), .signal_source = signal_source }; + + this.* = fs_loop; + this.thread = try std.Thread.spawn(.{}, FSEventsLoop.CFThreadLoop, .{this}); + + // sync threads + this.sem.wait(); + return this; + } + + fn enqueueTaskConcurrent(this: *FSEventsLoop, task: Task) void { + const CF = CoreFoundation.get(); + var concurrent = bun.default_allocator.create(ConcurrentTask) catch unreachable; + concurrent.auto_delete = true; + this.tasks.push(concurrent.from(task)); + CF.RunLoopSourceSignal(this.signal_source); + CF.RunLoopWakeUp(this.loop); + } + + // Runs in CF thread, when there're events in FSEventStream + fn _events_cb(_: FSEventStreamRef, info: ?*anyopaque, numEvents: usize, eventPaths: ?*anyopaque, eventFlags: *FSEventStreamEventFlags, _: *FSEventStreamEventId) callconv(.C) void { + const paths_ptr = bun.cast([*][*:0]const u8, eventPaths); + const paths = paths_ptr[0..numEvents]; + var loop = bun.cast(*FSEventsLoop, info); + const event_flags = bun.cast([*]FSEventStreamEventFlags, eventFlags); + + for (loop.watchers.slice()) |watcher| { + if (watcher) |handle| { + for (paths, 0..) |path_ptr, i| { + var flags = event_flags[i]; + var path = path_ptr[0..bun.len(path_ptr)]; + // Filter out paths that are outside handle's request + if (path.len < handle.path.len or !bun.strings.startsWith(path, handle.path)) { + continue; + } + const is_file = (flags & kFSEventStreamEventFlagItemIsDir) == 0; + + // Remove common prefix, unless the watched folder is "/" + if (!(handle.path.len == 1 and handle.path[0] == '/')) { + path = path[handle.path.len..]; + + // Ignore events with path equal to directory itself + if (path.len <= 1 and is_file) { + continue; + } + if (path.len == 0) { + // Since we're using fsevents to watch the file itself, path == handle.path, and we now need to get the basename of the file back + while (path.len > 0) { + if (bun.strings.startsWithChar(path, '/')) { + path = path[1..]; + break; + } else { + path = path[1..]; + } + } + + // Created and Removed seem to be always set, but don't make sense + flags &= ~kFSEventsRenamed; + } else { + // Skip forward slash + path = path[1..]; + } + } + + // Do not emit events from subdirectories (without option set) + if (path.len == 0 or (bun.strings.containsChar(path, '/') and !handle.recursive)) { + continue; + } + + var is_rename = true; + + if ((flags & kFSEventsRenamed) == 0) { + if ((flags & kFSEventsModified) != 0 or is_file) { + is_rename = false; + } + } + + handle.callback(handle.ctx, path, is_file, is_rename); + } + } + } + } + + // Runs on CF Thread + pub fn _schedule(this: *FSEventsLoop) void { + this.mutex.lock(); + defer this.mutex.unlock(); + this.has_scheduled_watchers = false; + + var watchers = this.watchers.slice(); + + const CF = CoreFoundation.get(); + const CS = CoreServices.get(); + + if (this.fsevent_stream) |stream| { + // Stop emitting events + CS.FSEventStreamStop(stream); + + // Release stream + CS.FSEventStreamInvalidate(stream); + CS.FSEventStreamRelease(stream); + this.fsevent_stream = null; + } + // clean old paths + if (this.paths) |p| { + this.paths = null; + bun.default_allocator.destroy(p); + } + if (this.cf_paths) |cf| { + this.cf_paths = null; + CF.Release(cf); + } + + const paths = bun.default_allocator.alloc(?*anyopaque, this.watcher_count) catch unreachable; + var count: u32 = 0; + for (watchers) |w| { + if (w) |watcher| { + const path = CF.StringCreateWithFileSystemRepresentation(null, watcher.path.ptr); + paths[count] = path; + count += 1; + } + } + + const cf_paths = CF.ArrayCreate(null, paths.ptr, count, null); + var ctx: FSEventStreamContext = .{ + .info = this, + }; + + const latency: CFAbsoluteTime = 0.05; + // Explanation of selected flags: + // 1. NoDefer - without this flag, events that are happening continuously + // (i.e. each event is happening after time interval less than `latency`, + // counted from previous event), will be deferred and passed to callback + // once they'll either fill whole OS buffer, or when this continuous stream + // will stop (i.e. there'll be delay between events, bigger than + // `latency`). + // Specifying this flag will invoke callback after `latency` time passed + // since event. + // 2. FileEvents - fire callback for file changes too (by default it is firing + // it only for directory changes). + // + const flags: FSEventStreamCreateFlags = kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagFileEvents; + + // + // NOTE: It might sound like a good idea to remember last seen StreamEventId, + // but in reality one dir might have last StreamEventId less than, the other, + // that is being watched now. Which will cause FSEventStream API to report + // changes to files from the past. + // + const ref = CS.FSEventStreamCreate(null, _events_cb, &ctx, cf_paths, CS.kFSEventStreamEventIdSinceNow, latency, flags); + + CS.FSEventStreamScheduleWithRunLoop(ref, this.loop, CF.RunLoopDefaultMode.*); + if (CS.FSEventStreamStart(ref) == 0) { + //clean in case of failure + bun.default_allocator.destroy(paths); + CF.Release(cf_paths); + CS.FSEventStreamInvalidate(ref); + CS.FSEventStreamRelease(ref); + return; + } + this.fsevent_stream = ref; + this.paths = paths; + this.cf_paths = cf_paths; + } + + fn registerWatcher(this: *FSEventsLoop, watcher: *FSEventsWatcher) void { + this.mutex.lock(); + defer this.mutex.unlock(); + if (this.watcher_count == this.watchers.len) { + this.watcher_count += 1; + this.watchers.push(bun.default_allocator, watcher) catch unreachable; + } else { + var watchers = this.watchers.slice(); + for (watchers, 0..) |w, i| { + if (w == null) { + watchers[i] = watcher; + this.watcher_count += 1; + break; + } + } + } + + if (this.has_scheduled_watchers == false) { + this.has_scheduled_watchers = true; + this.enqueueTaskConcurrent(Task.New(FSEventsLoop, _schedule).init(this)); + } + } + + fn unregisterWatcher(this: *FSEventsLoop, watcher: *FSEventsWatcher) void { + this.mutex.lock(); + defer this.mutex.unlock(); + var watchers = this.watchers.slice(); + for (watchers, 0..) |w, i| { + if (w) |item| { + if (item == watcher) { + watchers[i] = null; + // if is the last one just pop + if (i == watchers.len - 1) { + this.watchers.len -= 1; + } + this.watcher_count -= 1; + break; + } + } + } + } + + // Runs on CF loop to close the loop + fn _stop(this: *FSEventsLoop) void { + const CF = CoreFoundation.get(); + CF.RunLoopStop(this.loop); + } + fn deinit(this: *FSEventsLoop) void { + // signal close and wait + this.enqueueTaskConcurrent(Task.New(FSEventsLoop, FSEventsLoop._stop).init(this)); + this.thread.join(); + const CF = CoreFoundation.get(); + + CF.Release(this.signal_source); + this.signal_source = null; + + this.sem.deinit(); + this.mutex.deinit(); + if (this.watcher_count > 0) { + while (this.watchers.popOrNull()) |watcher| { + if (watcher) |w| { + // unlink watcher + w.loop = null; + } + } + } + + this.watchers.deinitWithAllocator(bun.default_allocator); + + bun.default_allocator.destroy(this); + } +}; + +pub const FSEventsWatcher = struct { + path: string, + callback: Callback, + loop: ?*FSEventsLoop, + recursive: bool, + ctx: ?*anyopaque, + + const Callback = *const fn (ctx: ?*anyopaque, path: string, is_file: bool, is_rename: bool) void; + + pub fn init(loop: *FSEventsLoop, path: string, recursive: bool, callback: Callback, ctx: ?*anyopaque) *FSEventsWatcher { + var this = bun.default_allocator.create(FSEventsWatcher) catch unreachable; + this.* = FSEventsWatcher{ + .path = path, + .callback = callback, + .loop = loop, + .recursive = recursive, + .ctx = ctx, + }; + + loop.registerWatcher(this); + return this; + } + + pub fn deinit(this: *FSEventsWatcher) void { + if (this.loop) |loop| { + loop.unregisterWatcher(this); + } + bun.default_allocator.destroy(this); + } +}; + +pub fn watch(path: string, recursive: bool, callback: FSEventsWatcher.Callback, ctx: ?*anyopaque) !*FSEventsWatcher { + if (fsevents_default_loop) |loop| { + return FSEventsWatcher.init(loop, path, recursive, callback, ctx); + } else { + fsevents_default_loop_mutex.lock(); + defer fsevents_default_loop_mutex.unlock(); + if (fsevents_default_loop == null) { + fsevents_default_loop = try FSEventsLoop.init(); + } + return FSEventsWatcher.init(fsevents_default_loop.?, path, recursive, callback, ctx); + } +} diff --git a/src/bun.js/node/node.classes.ts b/src/bun.js/node/node.classes.ts index f984077e4..ce35c940a 100644 --- a/src/bun.js/node/node.classes.ts +++ b/src/bun.js/node/node.classes.ts @@ -1,6 +1,34 @@ import { define } from "../scripts/class-definitions"; export default [ + define({ + name: "FSWatcher", + construct: false, + noConstructor: true, + finalize: true, + configurable: false, + klass: {}, + JSType: "0b11101110", + proto: { + ref: { + fn: "doRef", + length: 0, + }, + unref: { + fn: "doUnref", + length: 0, + }, + hasRef: { + fn: "hasRef", + length: 0, + }, + close: { + fn: "doClose", + length: 0, + }, + }, + values: ["listener"], + }), define({ name: "Timeout", construct: false, @@ -300,7 +328,7 @@ export default [ utimes: { fn: "utimes", length: 4 }, utimesSync: { fn: "utimesSync", length: 3 }, // TODO: - // watch: { fn: "watch", length: 3 }, + watch: { fn: "watch", length: 3 }, // watchFile: { fn: "watchFile", length: 3 }, writeFile: { fn: "writeFile", length: 4 }, writeFileSync: { fn: "writeFileSync", length: 3 }, diff --git a/src/bun.js/node/node_fs.zig b/src/bun.js/node/node_fs.zig index 3ea0822e6..21a65251a 100644 --- a/src/bun.js/node/node_fs.zig +++ b/src/bun.js/node/node_fs.zig @@ -34,7 +34,6 @@ const Mode = JSC.Node.Mode; const uid_t = std.os.uid_t; const gid_t = std.os.gid_t; - /// u63 to allow one null bit const ReadPosition = u63; @@ -2313,7 +2312,7 @@ pub const Arguments = struct { }; pub const UnwatchFile = void; - pub const Watch = void; + pub const Watch = JSC.Node.FSWatcher.Arguments; pub const WatchFile = void; pub const Fsync = struct { fd: FileDescriptor, @@ -2475,7 +2474,7 @@ const Return = struct { pub const Truncate = void; pub const Unlink = void; pub const UnwatchFile = void; - pub const Watch = void; + pub const Watch = JSC.JSValue; pub const WatchFile = void; pub const Utimes = void; @@ -4181,8 +4180,12 @@ pub const NodeFS = struct { return Maybe(Return.Lutimes).todo; } - pub fn watch(_: *NodeFS, _: Arguments.Watch, comptime _: Flavor) Maybe(Return.Watch) { - return Maybe(Return.Watch).todo; + pub fn watch(_: *NodeFS, args: Arguments.Watch, comptime _: Flavor) Maybe(Return.Watch) { + const watcher = args.createFSWatcher() catch |err| { + args.global_this.throwError(err, "Failed to watch filename"); + return Maybe(Return.Watch){ .result = JSC.JSValue.jsUndefined() }; + }; + return Maybe(Return.Watch){ .result = watcher }; } pub fn createReadStream(_: *NodeFS, _: Arguments.CreateReadStream, comptime _: Flavor) Maybe(Return.CreateReadStream) { return Maybe(Return.CreateReadStream).todo; diff --git a/src/bun.js/node/node_fs_binding.zig b/src/bun.js/node/node_fs_binding.zig index 74b769bf6..f178f0355 100644 --- a/src/bun.js/node/node_fs_binding.zig +++ b/src/bun.js/node/node_fs_binding.zig @@ -241,6 +241,8 @@ pub const NodeJSFS = struct { return JSC.Node.Stats.getConstructor(globalThis); } + pub const watch = callSync(.watch); + // Not implemented yet: const notimpl = fdatasync; pub const opendir = notimpl; diff --git a/src/bun.js/node/node_fs_watcher.zig b/src/bun.js/node/node_fs_watcher.zig new file mode 100644 index 000000000..397d51916 --- /dev/null +++ b/src/bun.js/node/node_fs_watcher.zig @@ -0,0 +1,913 @@ +const std = @import("std"); +const JSC = @import("root").bun.JSC; +const bun = @import("root").bun; +const Fs = @import("../../fs.zig"); +const Path = @import("../../resolver/resolve_path.zig"); +const Encoder = JSC.WebCore.Encoder; + +const FSEvents = @import("./fs_events.zig"); + +const VirtualMachine = JSC.VirtualMachine; +const EventLoop = JSC.EventLoop; +const PathLike = JSC.Node.PathLike; +const ArgumentsSlice = JSC.Node.ArgumentsSlice; +const Output = bun.Output; +const string = bun.string; +const StoredFileDescriptorType = bun.StoredFileDescriptorType; +const Environment = bun.Environment; + +pub const FSWatcher = struct { + const watcher = @import("../../watcher.zig"); + const options = @import("../../options.zig"); + pub const Watcher = watcher.NewWatcher(*FSWatcher); + const log = Output.scoped(.FSWatcher, false); + + pub const ChangeEvent = struct { + hash: Watcher.HashType = 0, + event_type: FSWatchTask.EventType = .change, + time_stamp: i64 = 0, + }; + + onAccept: std.ArrayHashMapUnmanaged(FSWatcher.Watcher.HashType, bun.BabyList(OnAcceptCallback), bun.ArrayIdentityContext, false) = .{}, + ctx: *VirtualMachine, + js_watcher: ?*JSObject = null, + watcher_instance: ?*FSWatcher.Watcher = null, + verbose: bool = false, + file_paths: bun.BabyList(string) = .{}, + entry_path: ?string = null, + entry_dir: string = "", + last_change_event: ChangeEvent = .{}, + + pub fn toJS(this: *FSWatcher) JSC.JSValue { + return if (this.js_watcher) |js| js.js_this else JSC.JSValue.jsUndefined(); + } + + pub fn eventLoop(this: FSWatcher) *EventLoop { + return this.ctx.eventLoop(); + } + + pub fn enqueueTaskConcurrent(this: FSWatcher, task: *JSC.ConcurrentTask) void { + this.eventLoop().enqueueTaskConcurrent(task); + } + + pub fn deinit(this: *FSWatcher) void { + while (this.file_paths.popOrNull()) |file_path| { + bun.default_allocator.destroy(file_path); + } + this.file_paths.deinitWithAllocator(bun.default_allocator); + if (this.entry_path) |path| { + this.entry_path = null; + bun.default_allocator.destroy(path); + } + bun.default_allocator.destroy(this); + } + + pub const FSWatchTask = struct { + ctx: *FSWatcher, + count: u8 = 0, + + entries: [8]Entry = undefined, + concurrent_task: JSC.ConcurrentTask = undefined, + + pub const EventType = enum { + rename, + change, + @"error", + abort, + }; + + pub const EventFreeType = enum { + destroy, + free, + none, + }; + + pub const Entry = struct { + file_path: string, + event_type: EventType, + free_type: EventFreeType, + }; + + pub fn append(this: *FSWatchTask, file_path: string, event_type: EventType, free_type: EventFreeType) void { + if (this.count == 8) { + this.enqueue(); + var ctx = this.ctx; + this.* = .{ + .ctx = ctx, + .count = 0, + }; + } + + this.entries[this.count] = .{ + .file_path = file_path, + .event_type = event_type, + .free_type = free_type, + }; + this.count += 1; + } + + pub fn run(this: *FSWatchTask) void { + // this runs on JS Context + if (this.ctx.js_watcher) |js_watcher| { + for (this.entries[0..this.count]) |entry| { + switch (entry.event_type) { + .rename => { + js_watcher.emit(entry.file_path, "rename"); + }, + .change => { + js_watcher.emit(entry.file_path, "change"); + }, + .@"error" => { + // file_path is the error message in this case + js_watcher.emitError(entry.file_path); + }, + .abort => { + js_watcher.emitIfAborted(); + }, + } + } + } + } + + pub fn enqueue(this: *FSWatchTask) void { + if (this.count == 0) + return; + + var that = bun.default_allocator.create(FSWatchTask) catch unreachable; + + that.* = this.*; + this.count = 0; + that.concurrent_task.task = JSC.Task.init(that); + this.ctx.enqueueTaskConcurrent(&that.concurrent_task); + } + + pub fn deinit(this: *FSWatchTask) void { + while (this.count > 0) { + this.count -= 1; + switch (this.entries[this.count].free_type) { + .destroy => bun.default_allocator.destroy(this.entries[this.count].file_path), + .free => bun.default_allocator.free(this.entries[this.count].file_path), + else => {}, + } + } + bun.default_allocator.destroy(this); + } + }; + + fn NewCallback(comptime FunctionSignature: type) type { + return union(enum) { + javascript_callback: JSC.Strong, + zig_callback: struct { + ptr: *anyopaque, + function: *const FunctionSignature, + }, + }; + } + + pub const OnAcceptCallback = NewCallback(fn ( + vm: *JSC.VirtualMachine, + specifier: []const u8, + ) void); + + fn addDirectory(ctx: *FSWatcher, fs_watcher: *FSWatcher.Watcher, fd: StoredFileDescriptorType, file_path: string, recursive: bool, buf: *[bun.MAX_PATH_BYTES + 1]u8, is_entry_path: bool) !void { + var dir_path_clone = bun.default_allocator.dupeZ(u8, file_path) catch unreachable; + + if (is_entry_path) { + ctx.entry_path = dir_path_clone; + ctx.entry_dir = dir_path_clone; + } else { + ctx.file_paths.push(bun.default_allocator, dir_path_clone) catch unreachable; + } + fs_watcher.addDirectory(fd, dir_path_clone, FSWatcher.Watcher.getHash(file_path), false) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + + var iter = (std.fs.IterableDir{ .dir = std.fs.Dir{ + .fd = fd, + } }).iterate(); + + while (iter.next() catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }) |entry| { + var parts = [2]string{ dir_path_clone, entry.name }; + var entry_path = Path.joinAbsStringBuf( + Fs.FileSystem.instance.topLevelDirWithoutTrailingSlash(), + buf, + &parts, + .auto, + ); + + buf[entry_path.len] = 0; + var entry_path_z = buf[0..entry_path.len :0]; + + var fs_info = fdFromAbsolutePathZ(entry_path_z) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + + if (fs_info.is_file) { + const file_path_clone = bun.default_allocator.dupeZ(u8, entry_path) catch unreachable; + + ctx.file_paths.push(bun.default_allocator, file_path_clone) catch unreachable; + + fs_watcher.addFile(fs_info.fd, file_path_clone, FSWatcher.Watcher.getHash(entry_path), options.Loader.file, 0, null, false) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + } else { + if (recursive) { + addDirectory(ctx, fs_watcher, fs_info.fd, entry_path, recursive, buf, false) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + } + } + } + } + + pub fn onError( + this: *FSWatcher, + err: anyerror, + ) void { + var current_task: FSWatchTask = .{ + .ctx = this, + }; + current_task.append(@errorName(err), .@"error", .none); + current_task.enqueue(); + } + + pub fn onFSEventUpdate( + ctx: ?*anyopaque, + path: string, + _: bool, + is_rename: bool, + ) void { + const this = bun.cast(*FSWatcher, ctx.?); + + var current_task: FSWatchTask = .{ + .ctx = this, + }; + defer current_task.enqueue(); + + const relative_path = bun.default_allocator.dupe(u8, path) catch unreachable; + const event_type: FSWatchTask.EventType = if (is_rename) .rename else .change; + + current_task.append(relative_path, event_type, .destroy); + } + + pub fn onFileUpdate( + this: *FSWatcher, + events: []watcher.WatchEvent, + changed_files: []?[:0]u8, + watchlist: watcher.Watchlist, + ) void { + var slice = watchlist.slice(); + const file_paths = slice.items(.file_path); + + var counts = slice.items(.count); + const kinds = slice.items(.kind); + var _on_file_update_path_buf: [bun.MAX_PATH_BYTES]u8 = undefined; + + var ctx = this.watcher_instance.?; + defer ctx.flushEvictions(); + defer Output.flush(); + + var bundler = if (@TypeOf(this.ctx.bundler) == *bun.Bundler) + this.ctx.bundler + else + &this.ctx.bundler; + + var fs: *Fs.FileSystem = bundler.fs; + + var current_task: FSWatchTask = .{ + .ctx = this, + }; + defer current_task.enqueue(); + + const time_stamp = std.time.milliTimestamp(); + const time_diff = time_stamp - this.last_change_event.time_stamp; + + for (events) |event| { + const file_path = file_paths[event.index]; + const update_count = counts[event.index] + 1; + counts[event.index] = update_count; + const kind = kinds[event.index]; + + if (comptime Environment.isDebug) { + if (this.verbose) { + Output.prettyErrorln("[watch] {s} ({s}, {})", .{ file_path, @tagName(kind), event.op }); + } + } + + switch (kind) { + .file => { + if (event.op.delete) { + ctx.removeAtIndex( + event.index, + 0, + &.{}, + .file, + ); + } + + var file_hash: FSWatcher.Watcher.HashType = FSWatcher.Watcher.getHash(file_path); + + if (event.op.write or event.op.delete or event.op.rename) { + const event_type: FSWatchTask.EventType = if (event.op.delete or event.op.rename or event.op.move_to) .rename else .change; + // skip consecutive duplicates + if ((this.last_change_event.time_stamp == 0 or time_diff > 1) or this.last_change_event.event_type != event_type and this.last_change_event.hash != file_hash) { + this.last_change_event.time_stamp = time_stamp; + this.last_change_event.event_type = event_type; + this.last_change_event.hash = file_hash; + + const relative_slice = fs.relative(this.entry_dir, file_path); + + if (this.verbose) + Output.prettyErrorln("File changed: {s}", .{relative_slice}); + + const relative_path = bun.default_allocator.dupe(u8, relative_slice) catch unreachable; + + current_task.append(relative_path, event_type, .destroy); + } + } + }, + .directory => { + // macOS should use FSEvents for directories + if (comptime Environment.isMac) { + @panic("Unexpected directory watch"); + } + + const affected = event.names(changed_files); + + for (affected) |changed_name_| { + const changed_name: []const u8 = bun.asByteSlice(changed_name_.?); + if (changed_name.len == 0 or changed_name[0] == '~' or changed_name[0] == '.') continue; + + var file_hash: FSWatcher.Watcher.HashType = 0; + const relative_slice: string = brk: { + var file_path_without_trailing_slash = std.mem.trimRight(u8, file_path, std.fs.path.sep_str); + + @memcpy(_on_file_update_path_buf[0..file_path_without_trailing_slash.len], file_path_without_trailing_slash); + + _on_file_update_path_buf[file_path_without_trailing_slash.len] = std.fs.path.sep; + + @memcpy(_on_file_update_path_buf[file_path_without_trailing_slash.len + 1 ..][0..changed_name.len], changed_name); + const path_slice = _on_file_update_path_buf[0 .. file_path_without_trailing_slash.len + changed_name.len + 1]; + file_hash = FSWatcher.Watcher.getHash(path_slice); + + const relative = fs.relative(this.entry_dir, path_slice); + + break :brk relative; + }; + + // skip consecutive duplicates + const event_type: FSWatchTask.EventType = .rename; // renaming folders, creating folder or files will be always be rename + if ((this.last_change_event.time_stamp == 0 or time_diff > 1) or this.last_change_event.event_type != event_type and this.last_change_event.hash != file_hash) { + const relative_path = bun.default_allocator.dupe(u8, relative_slice) catch unreachable; + + this.last_change_event.time_stamp = time_stamp; + this.last_change_event.event_type = event_type; + this.last_change_event.hash = file_hash; + + current_task.append(relative_path, event_type, .destroy); + + if (this.verbose) + Output.prettyErrorln(" Dir change: {s}", .{relative_path}); + } + } + + if (this.verbose and affected.len == 0) { + Output.prettyErrorln(" Dir change: {s}", .{fs.relative(this.entry_dir, file_path)}); + } + }, + } + } + } + + pub const Arguments = struct { + path: PathLike, + listener: JSC.JSValue, + global_this: JSC.C.JSContextRef, + signal: ?*JSC.AbortSignal, + persistent: bool, + recursive: bool, + encoding: JSC.Node.Encoding, + verbose: bool, + pub fn fromJS(ctx: JSC.C.JSContextRef, arguments: *ArgumentsSlice, exception: JSC.C.ExceptionRef) ?Arguments { + const vm = ctx.vm(); + const path = PathLike.fromJS(ctx, arguments, exception) orelse { + if (exception.* == null) { + JSC.throwInvalidArguments( + "filename must be a string or TypedArray", + .{}, + ctx, + exception, + ); + } + return null; + }; + + if (exception.* != null) return null; + var listener: JSC.JSValue = .zero; + var signal: ?*JSC.AbortSignal = null; + var persistent: bool = true; + var recursive: bool = false; + var encoding: JSC.Node.Encoding = .utf8; + var verbose = false; + if (arguments.nextEat()) |options_or_callable| { + + // options + if (options_or_callable.isObject()) { + if (options_or_callable.get(ctx, "persistent")) |persistent_| { + if (!persistent_.isBoolean()) { + JSC.throwInvalidArguments( + "persistent must be a boolean.", + .{}, + ctx, + exception, + ); + return null; + } + persistent = persistent_.toBoolean(); + } + + if (options_or_callable.get(ctx, "verbose")) |verbose_| { + if (!verbose_.isBoolean()) { + JSC.throwInvalidArguments( + "verbose must be a boolean.", + .{}, + ctx, + exception, + ); + return null; + } + verbose = verbose_.toBoolean(); + } + + if (options_or_callable.get(ctx, "encoding")) |encoding_| { + if (!encoding_.isString()) { + JSC.throwInvalidArguments( + "encoding must be a string.", + .{}, + ctx, + exception, + ); + return null; + } + if (JSC.Node.Encoding.fromJS(encoding_, ctx.ptr())) |node_encoding| { + encoding = node_encoding; + } else { + JSC.throwInvalidArguments( + "invalid encoding.", + .{}, + ctx, + exception, + ); + return null; + } + } + + if (options_or_callable.get(ctx, "recursive")) |recursive_| { + if (!recursive_.isBoolean()) { + JSC.throwInvalidArguments( + "recursive must be a boolean.", + .{}, + ctx, + exception, + ); + return null; + } + recursive = recursive_.toBoolean(); + } + + // abort signal + if (options_or_callable.get(ctx, "signal")) |signal_| { + if (JSC.AbortSignal.fromJS(signal_)) |signal_obj| { + //Keep it alive + signal_.ensureStillAlive(); + signal = signal_obj; + } else { + JSC.throwInvalidArguments( + "signal is not of type AbortSignal.", + .{}, + ctx, + exception, + ); + + return null; + } + } + + // listener + if (arguments.nextEat()) |callable| { + if (!callable.isCell() or !callable.isCallable(vm)) { + exception.* = JSC.toInvalidArguments("Expected \"listener\" callback to be a function", .{}, ctx).asObjectRef(); + return null; + } + listener = callable; + } + } else { + if (!options_or_callable.isCell() or !options_or_callable.isCallable(vm)) { + exception.* = JSC.toInvalidArguments("Expected \"listener\" callback to be a function", .{}, ctx).asObjectRef(); + return null; + } + listener = options_or_callable; + } + } + if (listener == .zero) { + exception.* = JSC.toInvalidArguments("Expected \"listener\" callback", .{}, ctx).asObjectRef(); + return null; + } + + return Arguments{ + .path = path, + .listener = listener, + .global_this = ctx, + .signal = signal, + .persistent = persistent, + .recursive = recursive, + .encoding = encoding, + .verbose = verbose, + }; + } + + pub fn createFSWatcher(this: Arguments) !JSC.JSValue { + const obj = try FSWatcher.init(this); + return obj.toJS(); + } + }; + + pub const JSObject = struct { + signal: ?*JSC.AbortSignal, + persistent: bool, + manager: ?*FSWatcher.Watcher, + fsevents_watcher: ?*FSEvents.FSEventsWatcher, + poll_ref: JSC.PollRef = .{}, + globalThis: ?*JSC.JSGlobalObject, + js_this: JSC.JSValue, + encoding: JSC.Node.Encoding, + closed: bool, + + pub usingnamespace JSC.Codegen.JSFSWatcher; + + pub fn getFSWatcher(this: *JSObject) *FSWatcher { + if (this.manager) |manager| return manager.ctx; + if (this.fsevents_watcher) |manager| return bun.cast(*FSWatcher, manager.ctx.?); + + @panic("No context attached to JSFSWatcher"); + } + + pub fn init(globalThis: *JSC.JSGlobalObject, manager: ?*FSWatcher.Watcher, fsevents_watcher: ?*FSEvents.FSEventsWatcher, signal: ?*JSC.AbortSignal, listener: JSC.JSValue, persistent: bool, encoding: JSC.Node.Encoding) !*JSObject { + var obj = try globalThis.allocator().create(JSObject); + obj.* = .{ + .signal = null, + .persistent = persistent, + .manager = manager, + .fsevents_watcher = fsevents_watcher, + .globalThis = globalThis, + .js_this = .zero, + .encoding = encoding, + .closed = false, + }; + const instance = obj.getFSWatcher(); + + if (persistent) { + obj.poll_ref.ref(instance.ctx); + } + + var js_this = JSObject.toJS(obj, globalThis); + JSObject.listenerSetCached(js_this, globalThis, listener); + obj.js_this = js_this; + obj.js_this.protect(); + + if (signal) |s| { + + // already aborted? + if (s.aborted()) { + obj.signal = s.ref(); + // abort next tick + var current_task: FSWatchTask = .{ + .ctx = instance, + }; + current_task.append("", .abort, .none); + current_task.enqueue(); + } else { + // watch for abortion + obj.signal = s.ref().listen(JSObject, obj, JSObject.emitAbort); + } + } + return obj; + } + + pub fn emitIfAborted(this: *JSObject) void { + if (this.signal) |s| { + if (s.aborted()) { + const err = s.abortReason(); + this.emitAbort(err); + } + } + } + + pub fn emitAbort(this: *JSObject, err: JSC.JSValue) void { + if (this.closed) return; + defer this.close(true); + + err.ensureStillAlive(); + + if (this.globalThis) |globalThis| { + if (this.js_this != .zero) { + if (JSObject.listenerGetCached(this.js_this)) |listener| { + var args = [_]JSC.JSValue{ + JSC.ZigString.static("error").toValue(globalThis), + if (err.isEmptyOrUndefinedOrNull()) JSC.WebCore.AbortSignal.createAbortError(JSC.ZigString.static("The user aborted a request"), &JSC.ZigString.Empty, globalThis) else err, + }; + _ = listener.callWithGlobalThis( + globalThis, + &args, + ); + } + } + } + } + pub fn emitError(this: *JSObject, err: string) void { + if (this.closed) return; + defer this.close(true); + + if (this.globalThis) |globalThis| { + if (this.js_this != .zero) { + if (JSObject.listenerGetCached(this.js_this)) |listener| { + var args = [_]JSC.JSValue{ + JSC.ZigString.static("error").toValue(globalThis), + JSC.ZigString.fromUTF8(err).toErrorInstance(globalThis), + }; + _ = listener.callWithGlobalThis( + globalThis, + &args, + ); + } + } + } + } + + pub fn emit(this: *JSObject, file_name: string, comptime eventType: string) void { + if (this.globalThis) |globalThis| { + if (this.js_this != .zero) { + if (JSObject.listenerGetCached(this.js_this)) |listener| { + var filename: JSC.JSValue = JSC.JSValue.jsUndefined(); + if (file_name.len > 0) { + if (this.encoding == .buffer) + filename = JSC.ArrayBuffer.createBuffer(globalThis, file_name) + else if (this.encoding == .utf8) { + filename = JSC.ZigString.fromUTF8(file_name).toValueGC(globalThis); + } else { + // convert to desired encoding + filename = Encoder.toStringAtRuntime(file_name.ptr, file_name.len, globalThis, this.encoding); + } + } + var args = [_]JSC.JSValue{ + JSC.ZigString.static(eventType).toValue(globalThis), + filename, + }; + _ = listener.callWithGlobalThis( + globalThis, + &args, + ); + } + } + } + } + + pub fn ref(this: *JSObject) void { + if (this.closed) return; + + if (!this.persistent) { + this.persistent = true; + this.poll_ref.ref(this.getFSWatcher().ctx); + } + } + + pub fn doRef(this: *JSObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + this.ref(); + return JSC.JSValue.jsUndefined(); + } + + pub fn unref(this: *JSObject) void { + if (this.persistent) { + this.persistent = false; + this.poll_ref.unref(this.getFSWatcher().ctx); + } + } + + pub fn doUnref(this: *JSObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + this.unref(); + return JSC.JSValue.jsUndefined(); + } + + pub fn hasRef(this: *JSObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + return JSC.JSValue.jsBoolean(this.persistent); + } + + pub fn close( + this: *JSObject, + emitEvent: bool, + ) void { + if (!this.closed) { + if (this.signal) |signal| { + this.signal = null; + signal.detach(this); + } + this.closed = true; + if (emitEvent) { + this.emit("", "close"); + } + + this.detach(); + } + } + + pub fn detach(this: *JSObject) void { + this.unref(); + + if (this.js_this != .zero) { + this.js_this.unprotect(); + this.js_this = .zero; + } + + this.globalThis = null; + + if (this.signal) |signal| { + this.signal = null; + signal.detach(this); + } + if (this.manager) |manager| { + var ctx = manager.ctx; + this.manager = null; + ctx.js_watcher = null; + ctx.deinit(); + manager.deinit(true); + } + + if (this.fsevents_watcher) |manager| { + var ctx = bun.cast(*FSWatcher, manager.ctx.?); + ctx.js_watcher = null; + ctx.deinit(); + manager.deinit(); + } + } + + pub fn doClose(this: *JSObject, _: *JSC.JSGlobalObject, _: *JSC.CallFrame) callconv(.C) JSC.JSValue { + this.close(true); + return JSC.JSValue.jsUndefined(); + } + + pub fn finalize(this: *JSObject) callconv(.C) void { + if (!this.closed) { + this.detach(); + } + + bun.default_allocator.destroy(this); + } + }; + + const PathResult = struct { + fd: StoredFileDescriptorType = 0, + is_file: bool = true, + }; + + fn fdFromAbsolutePathZ( + absolute_path_z: [:0]const u8, + ) !PathResult { + var stat = try bun.C.lstat_absolute(absolute_path_z); + var result = PathResult{}; + + switch (stat.kind) { + .sym_link => { + var file = try std.fs.openFileAbsoluteZ(absolute_path_z, .{ .mode = .read_only }); + result.fd = file.handle; + const _stat = try file.stat(); + + result.is_file = _stat.kind == .directory; + }, + .directory => { + const dir = (try std.fs.openIterableDirAbsoluteZ(absolute_path_z, .{ + .access_sub_paths = true, + })).dir; + result.fd = dir.fd; + result.is_file = false; + }, + else => { + const file = try std.fs.openFileAbsoluteZ(absolute_path_z, .{ .mode = .read_only }); + result.fd = file.handle; + result.is_file = true; + }, + } + return result; + } + + pub fn init(args: Arguments) !*FSWatcher { + var buf: [bun.MAX_PATH_BYTES + 1]u8 = undefined; + var slice = args.path.slice(); + if (bun.strings.startsWith(slice, "file://")) { + slice = slice[6..]; + } + var parts = [_]string{ + slice, + }; + + var file_path = Path.joinAbsStringBuf( + Fs.FileSystem.instance.top_level_dir, + &buf, + &parts, + .auto, + ); + + buf[file_path.len] = 0; + var file_path_z = buf[0..file_path.len :0]; + + var fs_type = try fdFromAbsolutePathZ(file_path_z); + + var ctx = try bun.default_allocator.create(FSWatcher); + const vm = args.global_this.bunVM(); + ctx.* = .{ + .ctx = vm, + .verbose = args.verbose, + .file_paths = bun.BabyList(string).initCapacity(bun.default_allocator, 1) catch |err| { + ctx.deinit(); + return err; + }, + }; + + if (comptime Environment.isMac) { + if (!fs_type.is_file) { + var dir_path_clone = bun.default_allocator.dupeZ(u8, file_path) catch unreachable; + ctx.entry_path = dir_path_clone; + ctx.entry_dir = dir_path_clone; + + var fsevents_watcher = FSEvents.watch(dir_path_clone, args.recursive, onFSEventUpdate, bun.cast(*anyopaque, ctx)) catch |err| { + ctx.deinit(); + return err; + }; + + ctx.js_watcher = JSObject.init(args.global_this, null, fsevents_watcher, args.signal, args.listener, args.persistent, args.encoding) catch |err| { + ctx.deinit(); + fsevents_watcher.deinit(); + return err; + }; + + return ctx; + } + } + + var fs_watcher = FSWatcher.Watcher.init( + ctx, + vm.bundler.fs, + bun.default_allocator, + ) catch |err| { + ctx.deinit(); + return err; + }; + + ctx.watcher_instance = fs_watcher; + + if (fs_type.is_file) { + var file_path_clone = bun.default_allocator.dupeZ(u8, file_path) catch unreachable; + + ctx.entry_path = file_path_clone; + ctx.entry_dir = std.fs.path.dirname(file_path_clone) orelse file_path_clone; + + fs_watcher.addFile(fs_type.fd, file_path_clone, FSWatcher.Watcher.getHash(file_path), options.Loader.file, 0, null, false) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + } else { + addDirectory(ctx, fs_watcher, fs_type.fd, file_path, args.recursive, &buf, true) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + } + + fs_watcher.start() catch |err| { + ctx.deinit(); + + fs_watcher.deinit(true); + return err; + }; + + ctx.js_watcher = JSObject.init(args.global_this, fs_watcher, null, args.signal, args.listener, args.persistent, args.encoding) catch |err| { + ctx.deinit(); + fs_watcher.deinit(true); + return err; + }; + + return ctx; + } +}; diff --git a/src/bun.js/node/types.zig b/src/bun.js/node/types.zig index e2de35706..659ac31bb 100644 --- a/src/bun.js/node/types.zig +++ b/src/bun.js/node/types.zig @@ -93,6 +93,10 @@ pub fn Maybe(comptime ResultType: type) type { return JSC.JSValue.jsUndefined(); } + if (comptime ReturnType == JSC.JSValue) { + return r; + } + if (comptime ReturnType == JSC.ArrayBuffer) { return r.toJS(globalThis, null); } diff --git a/src/bun.js/webcore/encoding.zig b/src/bun.js/webcore/encoding.zig index 5c8221128..061a25eed 100644 --- a/src/bun.js/webcore/encoding.zig +++ b/src/bun.js/webcore/encoding.zig @@ -802,7 +802,20 @@ pub const Encoder = struct { // pub fn writeUTF16AsUTF8(utf16: [*]const u16, len: usize, to: [*]u8, to_len: usize) callconv(.C) i32 { // return @intCast(i32, strings.copyUTF16IntoUTF8(to[0..to_len], []const u16, utf16[0..len], true).written); // } - + pub fn toStringAtRuntime(input: [*]const u8, len: usize, globalObject: *JSGlobalObject, encoding: JSC.Node.Encoding) JSValue { + return switch (encoding) { + .ucs2 => toString(input, len, globalObject, .utf16le), + .utf16le => toString(input, len, globalObject, .utf16le), + .utf8 => toString(input, len, globalObject, .utf8), + .ascii => toString(input, len, globalObject, .ascii), + .hex => toString(input, len, globalObject, .hex), + .base64 => toString(input, len, globalObject, .base64), + .base64url => toString(input, len, globalObject, .base64url), + .latin1 => toString(input, len, globalObject, .latin1), + // treat everything else as utf8 + else => toString(input, len, globalObject, .utf8), + }; + } pub fn toString(input_ptr: [*]const u8, len: usize, global: *JSGlobalObject, comptime encoding: JSC.Node.Encoding) JSValue { if (len == 0) return ZigString.Empty.toValue(global); diff --git a/src/fs.zig b/src/fs.zig index e87d931df..98174fac3 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1109,6 +1109,60 @@ pub const FileSystem = struct { return File{ .path = Path.init(path), .contents = file_contents }; } + pub fn kindFromAbsolute( + fs: *RealFS, + absolute_path: [:0]const u8, + existing_fd: StoredFileDescriptorType, + store_fd: bool, + ) !Entry.Cache { + var outpath: [bun.MAX_PATH_BYTES]u8 = undefined; + + var stat = try C.lstat_absolute(absolute_path); + const is_symlink = stat.kind == std.fs.File.Kind.SymLink; + var _kind = stat.kind; + var cache = Entry.Cache{ + .kind = Entry.Kind.file, + .symlink = PathString.empty, + }; + var symlink: []const u8 = ""; + + if (is_symlink) { + var file = try if (existing_fd != 0) + std.fs.File{ .handle = existing_fd } + else if (store_fd) + std.fs.openFileAbsoluteZ(absolute_path, .{ .mode = .read_only }) + else + bun.openFileForPath(absolute_path); + setMaxFd(file.handle); + + defer { + if ((!store_fd or fs.needToCloseFiles()) and existing_fd == 0) { + file.close(); + } else if (comptime FeatureFlags.store_file_descriptors) { + cache.fd = file.handle; + } + } + const _stat = try file.stat(); + + symlink = try bun.getFdPath(file.handle, &outpath); + + _kind = _stat.kind; + } + + std.debug.assert(_kind != .SymLink); + + if (_kind == .Directory) { + cache.kind = .dir; + } else { + cache.kind = .file; + } + if (symlink.len > 0) { + cache.symlink = PathString.init(try FilenameStore.instance.append([]const u8, symlink)); + } + + return cache; + } + pub fn kind( fs: *RealFS, _dir: string, diff --git a/src/http.zig b/src/http.zig index 827bfa6de..80718db2f 100644 --- a/src/http.zig +++ b/src/http.zig @@ -3238,7 +3238,12 @@ pub const Server = struct { threadlocal var filechange_buf: [32]u8 = undefined; threadlocal var filechange_buf_hinted: [32]u8 = undefined; - + pub fn onError( + _: *@This(), + err: anyerror, + ) void { + Output.prettyErrorln("Watcher crashed: {s}", .{@errorName(err)}); + } pub fn onFileUpdate( ctx: *Server, events: []watcher.WatchEvent, diff --git a/src/js/node/fs.js b/src/js/node/fs.js index f117020dd..6b0e3954e 100644 --- a/src/js/node/fs.js +++ b/src/js/node/fs.js @@ -1,3 +1,5 @@ +import { EventEmitter } from "stream"; + // Hardcoded module "node:fs" var { direct, isPromise, isCallable } = import.meta.primordials; var promises = import.meta.require("node:fs/promises"); @@ -7,6 +9,63 @@ var NativeReadable = _getNativeReadableStreamPrototype(2, Readable); // 2 means var fs = Bun.fs(); var debug = process.env.DEBUG ? console.log : () => {}; + +class FSWatcher extends EventEmitter { + #watcher; + #listener; + constructor(path, options, listener) { + super(); + + if (typeof options === "function") { + listener = options; + options = {}; + } else if (typeof options === "string") { + options = { encoding: options }; + } + + if (typeof listener !== "function") { + listener = () => {}; + } + + this.#listener = listener; + try { + this.#watcher = fs.watch(path, options || {}, this.#onEvent.bind(this)); + } catch (e) { + if (!e.message?.startsWith("FileNotFound")) { + throw e; + } + const notFound = new Error(`ENOENT: no such file or directory, watch '${path}'`); + notFound.code = "ENOENT"; + notFound.errno = -2; + notFound.path = path; + notFound.syscall = "watch"; + notFound.filename = path; + throw notFound; + } + } + + #onEvent(eventType, filenameOrError) { + if (eventType === "error" || eventType === "close") { + this.emit(eventType, filenameOrError); + } else { + this.emit("change", eventType, filenameOrError); + this.#listener(eventType, filenameOrError); + } + } + + close() { + this.#watcher?.close(); + this.#watcher = null; + } + + ref() { + this.#watcher?.ref(); + } + + unref() { + this.#watcher?.unref(); + } +} export var access = function access(...args) { callbackify(fs.accessSync, args); }, @@ -153,6 +212,9 @@ export var access = function access(...args) { rmdirSync = fs.rmdirSync.bind(fs), Dirent = fs.Dirent, Stats = fs.Stats, + watch = function watch(path, options, listener) { + return new FSWatcher(path, options, listener); + }, promises = import.meta.require("node:fs/promises"); function callbackify(fsFunction, args) { @@ -1002,7 +1064,8 @@ export default { writeSync, WriteStream, ReadStream, - + watch, + FSWatcher, [Symbol.for("::bunternal::")]: { ReadStreamClass, WriteStreamClass, diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index de802928b..7df446ccb 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -1,4 +1,5 @@ // Hardcoded module "node:fs/promises" + // Note: `constants` is injected into the top of this file declare var constants: typeof import("node:fs/promises").constants; @@ -38,6 +39,55 @@ var promisify = { }, }[notrace]; +export function watch( + filename: string | Buffer | URL, + options: { encoding?: BufferEncoding; persistent?: boolean; recursive?: boolean; signal?: AbortSignal } = {}, +) { + type Event = { + eventType: string; + filename: string | Buffer | undefined; + }; + const events: Array = []; + if (filename instanceof URL) { + throw new TypeError("Watch URLs are not supported yet"); + } else if (Buffer.isBuffer(filename)) { + filename = filename.toString(); + } else if (typeof filename !== "string") { + throw new TypeError("Expected path to be a string or Buffer"); + } + let nextEventResolve: Function | null = null; + if (typeof options === "string") { + options = { encoding: options }; + } + fs.watch(filename, options || {}, (eventType: string, filename: string | Buffer | undefined) => { + events.push({ eventType, filename }); + if (nextEventResolve) { + const resolve = nextEventResolve; + nextEventResolve = null; + resolve(); + } + }); + return { + async *[Symbol.asyncIterator]() { + let closed = false; + while (!closed) { + while (events.length) { + let event = events.shift() as Event; + if (event.eventType === "close") { + closed = true; + break; + } + if (event.eventType === "error") { + closed = true; + throw event.filename; + } + yield event; + } + await new Promise((resolve: Function) => (nextEventResolve = resolve)); + } + }, + }; +} export var access = promisify(fs.accessSync), appendFile = promisify(fs.appendFileSync), close = promisify(fs.closeSync), @@ -112,6 +162,7 @@ export default { lutimes, rm, rmdir, + watch, constants, [Symbol.for("CommonJS")]: 0, }; diff --git a/src/js/out/modules/node/fs.js b/src/js/out/modules/node/fs.js index cc1e14d2b..cc3763cfc 100644 --- a/src/js/out/modules/node/fs.js +++ b/src/js/out/modules/node/fs.js @@ -1,3 +1,4 @@ +var {EventEmitter } = import.meta.require("node:stream"); var callbackify = function(fsFunction, args) { try { const result = fsFunction.apply(fs, args.slice(0, args.length - 1)), callback = args[args.length - 1]; @@ -16,7 +17,47 @@ function createWriteStream(path, options) { return new WriteStream(path, options); } var { direct, isPromise, isCallable } = import.meta.primordials, promises = import.meta.require("node:fs/promises"), { Readable, NativeWritable, _getNativeReadableStreamPrototype, eos: eos_ } = import.meta.require("node:stream"), NativeReadable = _getNativeReadableStreamPrototype(2, Readable), fs = Bun.fs(), debug = process.env.DEBUG ? console.log : () => { -}, access = function access2(...args) { +}; + +class FSWatcher extends EventEmitter { + #watcher; + #listener; + constructor(path, options, listener) { + super(); + if (typeof options === "function") + listener = options, options = {}; + else if (typeof options === "string") + options = { encoding: options }; + if (typeof listener !== "function") + listener = () => { + }; + this.#listener = listener; + try { + this.#watcher = fs.watch(path, options || {}, this.#onEvent.bind(this)); + } catch (e) { + if (!e.message?.startsWith("FileNotFound")) + throw e; + const notFound = new Error(`ENOENT: no such file or directory, watch '${path}'`); + throw notFound.code = "ENOENT", notFound.errno = -2, notFound.path = path, notFound.syscall = "watch", notFound.filename = path, notFound; + } + } + #onEvent(eventType, filenameOrError) { + if (eventType === "error" || eventType === "close") + this.emit(eventType, filenameOrError); + else + this.emit("change", eventType, filenameOrError), this.#listener(eventType, filenameOrError); + } + close() { + this.#watcher?.close(), this.#watcher = null; + } + ref() { + this.#watcher?.ref(); + } + unref() { + this.#watcher?.unref(); + } +} +var access = function access2(...args) { callbackify(fs.accessSync, args); }, appendFile = function appendFile2(...args) { callbackify(fs.appendFileSync, args); @@ -88,7 +129,9 @@ var { direct, isPromise, isCallable } = import.meta.primordials, promises = impo callbackify(fs.utimesSync, args); }, lutimes = function lutimes2(...args) { callbackify(fs.lutimesSync, args); -}, accessSync = fs.accessSync.bind(fs), appendFileSync = fs.appendFileSync.bind(fs), closeSync = fs.closeSync.bind(fs), copyFileSync = fs.copyFileSync.bind(fs), existsSync = fs.existsSync.bind(fs), chownSync = fs.chownSync.bind(fs), chmodSync = fs.chmodSync.bind(fs), fchmodSync = fs.fchmodSync.bind(fs), fchownSync = fs.fchownSync.bind(fs), fstatSync = fs.fstatSync.bind(fs), fsyncSync = fs.fsyncSync.bind(fs), ftruncateSync = fs.ftruncateSync.bind(fs), futimesSync = fs.futimesSync.bind(fs), lchmodSync = fs.lchmodSync.bind(fs), lchownSync = fs.lchownSync.bind(fs), linkSync = fs.linkSync.bind(fs), lstatSync = fs.lstatSync.bind(fs), mkdirSync = fs.mkdirSync.bind(fs), mkdtempSync = fs.mkdtempSync.bind(fs), openSync = fs.openSync.bind(fs), readSync = fs.readSync.bind(fs), writeSync = fs.writeSync.bind(fs), readdirSync = fs.readdirSync.bind(fs), readFileSync = fs.readFileSync.bind(fs), writeFileSync = fs.writeFileSync.bind(fs), readlinkSync = fs.readlinkSync.bind(fs), realpathSync = fs.realpathSync.bind(fs), renameSync = fs.renameSync.bind(fs), statSync = fs.statSync.bind(fs), symlinkSync = fs.symlinkSync.bind(fs), truncateSync = fs.truncateSync.bind(fs), unlinkSync = fs.unlinkSync.bind(fs), utimesSync = fs.utimesSync.bind(fs), lutimesSync = fs.lutimesSync.bind(fs), rmSync = fs.rmSync.bind(fs), rmdirSync = fs.rmdirSync.bind(fs), Dirent = fs.Dirent, Stats = fs.Stats, promises = import.meta.require("node:fs/promises"), readStreamPathFastPathSymbol = Symbol.for("Bun.Node.readStreamPathFastPath"), readStreamSymbol = Symbol.for("Bun.NodeReadStream"), readStreamPathOrFdSymbol = Symbol.for("Bun.NodeReadStreamPathOrFd"), writeStreamSymbol = Symbol.for("Bun.NodeWriteStream"), writeStreamPathFastPathSymbol = Symbol.for("Bun.NodeWriteStreamFastPath"), writeStreamPathFastPathCallSymbol = Symbol.for("Bun.NodeWriteStreamFastPathCall"), kIoDone = Symbol.for("kIoDone"), defaultReadStreamOptions = { +}, accessSync = fs.accessSync.bind(fs), appendFileSync = fs.appendFileSync.bind(fs), closeSync = fs.closeSync.bind(fs), copyFileSync = fs.copyFileSync.bind(fs), existsSync = fs.existsSync.bind(fs), chownSync = fs.chownSync.bind(fs), chmodSync = fs.chmodSync.bind(fs), fchmodSync = fs.fchmodSync.bind(fs), fchownSync = fs.fchownSync.bind(fs), fstatSync = fs.fstatSync.bind(fs), fsyncSync = fs.fsyncSync.bind(fs), ftruncateSync = fs.ftruncateSync.bind(fs), futimesSync = fs.futimesSync.bind(fs), lchmodSync = fs.lchmodSync.bind(fs), lchownSync = fs.lchownSync.bind(fs), linkSync = fs.linkSync.bind(fs), lstatSync = fs.lstatSync.bind(fs), mkdirSync = fs.mkdirSync.bind(fs), mkdtempSync = fs.mkdtempSync.bind(fs), openSync = fs.openSync.bind(fs), readSync = fs.readSync.bind(fs), writeSync = fs.writeSync.bind(fs), readdirSync = fs.readdirSync.bind(fs), readFileSync = fs.readFileSync.bind(fs), writeFileSync = fs.writeFileSync.bind(fs), readlinkSync = fs.readlinkSync.bind(fs), realpathSync = fs.realpathSync.bind(fs), renameSync = fs.renameSync.bind(fs), statSync = fs.statSync.bind(fs), symlinkSync = fs.symlinkSync.bind(fs), truncateSync = fs.truncateSync.bind(fs), unlinkSync = fs.unlinkSync.bind(fs), utimesSync = fs.utimesSync.bind(fs), lutimesSync = fs.lutimesSync.bind(fs), rmSync = fs.rmSync.bind(fs), rmdirSync = fs.rmdirSync.bind(fs), Dirent = fs.Dirent, Stats = fs.Stats, watch = function watch2(path, options, listener) { + return new FSWatcher(path, options, listener); +}, promises = import.meta.require("node:fs/promises"), readStreamPathFastPathSymbol = Symbol.for("Bun.Node.readStreamPathFastPath"), readStreamSymbol = Symbol.for("Bun.NodeReadStream"), readStreamPathOrFdSymbol = Symbol.for("Bun.NodeReadStreamPathOrFd"), writeStreamSymbol = Symbol.for("Bun.NodeWriteStream"), writeStreamPathFastPathSymbol = Symbol.for("Bun.NodeWriteStreamFastPath"), writeStreamPathFastPathCallSymbol = Symbol.for("Bun.NodeWriteStreamFastPathCall"), kIoDone = Symbol.for("kIoDone"), defaultReadStreamOptions = { file: void 0, fd: void 0, flags: "r", @@ -590,6 +633,8 @@ var fs_default = { writeSync, WriteStream, ReadStream, + watch, + FSWatcher, [Symbol.for("::bunternal::")]: { ReadStreamClass, WriteStreamClass @@ -600,6 +645,7 @@ export { writeFileSync, writeFile, write, + watch, utimesSync, utimes, unlinkSync, diff --git a/src/js/out/modules/node/fs.promises.js b/src/js/out/modules/node/fs.promises.js index 2780ff166..ef3330771 100644 --- a/src/js/out/modules/node/fs.promises.js +++ b/src/js/out/modules/node/fs.promises.js @@ -1 +1 @@ -var D=Bun.fs(),B="::bunternal::",E={[B]:(S)=>{var b={[B]:function(C,J,q){var z;try{z=S.apply(D,q),q=void 0}catch(A){q=void 0,J(A);return}C(z)}}[B];return async function(...C){return await new Promise((J,q)=>{process.nextTick(b,J,q,C)})}}}[B],G=E(D.accessSync),H=E(D.appendFileSync),I=E(D.closeSync),K=E(D.copyFileSync),L=E(D.existsSync),M=E(D.chownSync),N=E(D.chmodSync),O=E(D.fchmodSync),P=E(D.fchownSync),Q=E(D.fstatSync),R=E(D.fsyncSync),T=E(D.ftruncateSync),U=E(D.futimesSync),V=E(D.lchmodSync),W=E(D.lchownSync),X=E(D.linkSync),Y=E(D.lstatSync),Z=E(D.mkdirSync),_=E(D.mkdtempSync),$=E(D.openSync),x=E(D.readSync),j=E(D.writeSync),v=E(D.readdirSync),w=E(D.readFileSync),k=E(D.writeFileSync),F=E(D.readlinkSync),h=E(D.realpathSync),g=E(D.renameSync),u=E(D.statSync),d=E(D.symlinkSync),n=E(D.truncateSync),l=E(D.unlinkSync),a=E(D.utimesSync),c=E(D.lutimesSync),t=E(D.rmSync),y=E(D.rmdirSync),p={access:G,appendFile:H,close:I,copyFile:K,exists:L,chown:M,chmod:N,fchmod:O,fchown:P,fstat:Q,fsync:R,ftruncate:T,futimes:U,lchmod:V,lchown:W,link:X,lstat:Y,mkdir:Z,mkdtemp:_,open:$,read:x,write:j,readdir:v,readFile:w,writeFile:k,readlink:F,realpath:h,rename:g,stat:u,symlink:d,truncate:n,unlink:l,utimes:a,lutimes:c,rm:t,rmdir:y,constants,[Symbol.for("CommonJS")]:0};export{k as writeFile,j as write,a as utimes,l as unlink,n as truncate,d as symlink,u as stat,y as rmdir,t as rm,g as rename,h as realpath,F as readlink,v as readdir,w as readFile,x as read,$ as open,_ as mkdtemp,Z as mkdir,c as lutimes,Y as lstat,X as link,W as lchown,V as lchmod,U as futimes,T as ftruncate,R as fsync,Q as fstat,P as fchown,O as fchmod,L as exists,p as default,K as copyFile,I as close,M as chown,N as chmod,H as appendFile,G as access}; +function H(S,C={}){const J=[];if(S instanceof URL)throw new TypeError("Watch URLs are not supported yet");else if(Buffer.isBuffer(S))S=S.toString();else if(typeof S!=="string")throw new TypeError("Expected path to be a string or Buffer");let b=null;if(typeof C==="string")C={encoding:C};return D.watch(S,C||{},(q,z)=>{if(J.push({eventType:q,filename:z}),b){const A=b;b=null,A()}}),{async*[Symbol.asyncIterator](){let q=!1;while(!q){while(J.length){let z=J.shift();if(z.eventType==="close"){q=!0;break}if(z.eventType==="error")throw q=!0,z.filename;yield z}await new Promise((z)=>b=z)}}}}var D=Bun.fs(),B="::bunternal::",G={[B]:(S)=>{var C={[B]:function(J,b,q){var z;try{z=S.apply(D,q),q=void 0}catch(A){q=void 0,b(A);return}J(z)}}[B];return async function(...J){return await new Promise((b,q)=>{process.nextTick(C,b,q,J)})}}}[B],I=G(D.accessSync),K=G(D.appendFileSync),L=G(D.closeSync),M=G(D.copyFileSync),N=G(D.existsSync),O=G(D.chownSync),P=G(D.chmodSync),Q=G(D.fchmodSync),U=G(D.fchownSync),V=G(D.fstatSync),W=G(D.fsyncSync),X=G(D.ftruncateSync),Y=G(D.futimesSync),Z=G(D.lchmodSync),_=G(D.lchownSync),$=G(D.linkSync),T=G(D.lstatSync),E=G(D.mkdirSync),j=G(D.mkdtempSync),R=G(D.openSync),k=G(D.readSync),x=G(D.writeSync),F=G(D.readdirSync),u=G(D.readFileSync),w=G(D.writeFileSync),g=G(D.readlinkSync),h=G(D.realpathSync),d=G(D.renameSync),c=G(D.statSync),v=G(D.symlinkSync),a=G(D.truncateSync),y=G(D.unlinkSync),l=G(D.utimesSync),t=G(D.lutimesSync),p=G(D.rmSync),n=G(D.rmdirSync),m={access:I,appendFile:K,close:L,copyFile:M,exists:N,chown:O,chmod:P,fchmod:Q,fchown:U,fstat:V,fsync:W,ftruncate:X,futimes:Y,lchmod:Z,lchown:_,link:$,lstat:T,mkdir:E,mkdtemp:j,open:R,read:k,write:x,readdir:F,readFile:u,writeFile:w,readlink:g,realpath:h,rename:d,stat:c,symlink:v,truncate:a,unlink:y,utimes:l,lutimes:t,rm:p,rmdir:n,watch:H,constants,[Symbol.for("CommonJS")]:0};export{w as writeFile,x as write,H as watch,l as utimes,y as unlink,a as truncate,v as symlink,c as stat,n as rmdir,p as rm,d as rename,h as realpath,g as readlink,F as readdir,u as readFile,k as read,R as open,j as mkdtemp,E as mkdir,t as lutimes,T as lstat,$ as link,_ as lchown,Z as lchmod,Y as futimes,X as ftruncate,W as fsync,V as fstat,U as fchown,Q as fchmod,N as exists,m as default,M as copyFile,L as close,O as chown,P as chmod,K as appendFile,I as access}; diff --git a/src/js/private.d.ts b/src/js/private.d.ts index b6ed64801..b689c208e 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -6,11 +6,95 @@ */ declare function $bundleError(error: string); +type BunFSWatchOptions = { encoding?: BufferEncoding; persistent?: boolean; recursive?: boolean; signal?: AbortSignal }; + +type BunWatchEventType = "rename" | "change" | "error" | "close"; +type BunWatchListener = (event: WatchEventType, filename: T | Error | undefined) => void; + +interface BunFSWatcher { + /** + * Stop watching for changes on the given `BunFSWatcher`. Once stopped, the `BunFSWatcher` object is no longer usable. + * @since v0.6.8 + */ + close(): void; + + /** + * When called, requests that the Node.js event loop not exit so long as the is active. Calling watcher.ref() multiple times will have no effect. + */ + ref(): void; + + /** + * When called, the active object will not require the Node.js event loop to remain active. If there is no other activity keeping the event loop running, the process may exit before the object's callback is invoked. Calling watcher.unref() multiple times will have no effect. + */ + unref(): void; +} +type BunFS = Omit & { + /** + * Watch for changes on `filename`, where `filename` is either a file or a + * directory. + * + * The second argument is optional. If `options` is provided as a string, it + * specifies the `encoding`. Otherwise `options` should be passed as an object. + * + * The listener callback gets two arguments `(eventType, filename)`. `eventType`is either `'rename'`, `'change', 'error' or 'close'`, and `filename` is the name of the file + * which triggered the event, the error when `eventType` is 'error' or undefined when eventType is 'close'. + * + * On most platforms, `'rename'` is emitted whenever a filename appears or + * disappears in the directory. + * + * + * If a `signal` is passed, aborting the corresponding AbortController will close + * the returned `BunFSWatcher`. + * @since v0.6.8 + * @param listener + */ + watch( + filename: string, + options: + | (WatchOptions & { + encoding: "buffer"; + }) + | "buffer", + listener?: BunWatchListener, + ): BunFSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `BunFSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + watch( + filename: string, + options?: WatchOptions | BufferEncoding | null, + listener?: BunWatchListener, + ): BunFSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `BunFSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + * @param options Either the encoding for the filename provided to the listener, or an object optionally specifying encoding, persistent, and recursive options. + * If `encoding` is not supplied, the default of `'utf8'` is used. + * If `persistent` is not supplied, the default of `true` is used. + * If `recursive` is not supplied, the default of `false` is used. + */ + watch( + filename: string, + options: BunWatchListener | string, + listener?: BunWatchListener, + ): BunFSWatcher; + /** + * Watch for changes on `filename`, where `filename` is either a file or a directory, returning an `BunFSWatcher`. + * @param filename A path to a file or directory. If a URL is provided, it must use the `file:` protocol. + */ + watch(filename: string, listener?: BunWatchListener): BunFSWatcher; +}; + declare module "bun" { var TOML: { parse(contents: string): any; }; - function fs(): typeof import("node:fs"); + function fs(): BunFS; function _Os(): typeof import("node:os"); function jest(): typeof import("bun:test"); var main: string; diff --git a/src/jsc.zig b/src/jsc.zig index 67cf3f05c..ca31d5f1a 100644 --- a/src/jsc.zig +++ b/src/jsc.zig @@ -50,6 +50,7 @@ pub const FFI = @import("./bun.js/api/ffi.zig").FFI; pub const Node = struct { pub usingnamespace @import("./bun.js/node/types.zig"); pub usingnamespace @import("./bun.js/node/node_fs.zig"); + pub usingnamespace @import("./bun.js/node/node_fs_watcher.zig"); pub usingnamespace @import("./bun.js/node/node_fs_binding.zig"); pub usingnamespace @import("./bun.js/node/node_os.zig"); pub const Syscall = @import("./bun.js/node/syscall.zig"); diff --git a/src/watcher.zig b/src/watcher.zig index 155c0b473..044770dc4 100644 --- a/src/watcher.zig +++ b/src/watcher.zig @@ -108,6 +108,10 @@ pub const INotify = struct { std.os.inotify_rm_watch(inotify_fd, wd); } + pub fn isRunning() bool { + return loaded_inotify; + } + var coalesce_interval: isize = 100_000; pub fn init() !void { std.debug.assert(!loaded_inotify); @@ -229,6 +233,10 @@ const DarwinWatcher = struct { if (fd == 0) return error.KQueueError; } + pub fn isRunning() bool { + return fd != 0; + } + pub fn stop() void { if (fd != 0) { std.os.close(fd); @@ -361,6 +369,8 @@ pub fn NewWatcher(comptime ContextType: type) type { watchloop_handle: ?std.Thread.Id = null, cwd: string, thread: std.Thread = undefined, + running: bool = true, + close_descriptors: bool = false, pub const HashType = u32; @@ -372,7 +382,9 @@ pub fn NewWatcher(comptime ContextType: type) type { pub fn init(ctx: ContextType, fs: *Fs.FileSystem, allocator: std.mem.Allocator) !*Watcher { var watcher = try allocator.create(Watcher); - try PlatformWatcher.init(); + if (!PlatformWatcher.isRunning()) { + try PlatformWatcher.init(); + } watcher.* = Watcher{ .fs = fs, @@ -393,6 +405,26 @@ pub fn NewWatcher(comptime ContextType: type) type { this.thread = try std.Thread.spawn(.{}, Watcher.watchLoop, .{this}); } + pub fn deinit(this: *Watcher, close_descriptors: bool) void { + this.mutex.lock(); + defer this.mutex.unlock(); + + this.close_descriptors = close_descriptors; + if (this.watchloop_handle != null) { + this.running = false; + } else { + if (this.close_descriptors and this.running) { + const fds = this.watchlist.items(.fd); + for (fds) |fd| { + std.os.close(fd); + } + } + this.watchlist.deinit(this.allocator); + const allocator = this.allocator; + allocator.destroy(this); + } + } + // This must only be called from the watcher thread pub fn watchLoop(this: *Watcher) !void { this.watchloop_handle = std.Thread.getCurrentId(); @@ -402,12 +434,24 @@ pub fn NewWatcher(comptime ContextType: type) type { if (FeatureFlags.verbose_watcher) Output.prettyln("Watcher started", .{}); this._watchLoop() catch |err| { - Output.prettyErrorln("Watcher crashed: {s}", .{@errorName(err)}); - this.watchloop_handle = null; PlatformWatcher.stop(); - return; + if (this.running) { + this.ctx.onError(err); + } }; + + // deinit and close descriptors if needed + if (this.close_descriptors) { + const fds = this.watchlist.items(.fd); + for (fds) |fd| { + std.os.close(fd); + } + } + this.watchlist.deinit(this.allocator); + + const allocator = this.allocator; + allocator.destroy(this); } var evict_list_i: WatchItemIndex = 0; @@ -475,7 +519,7 @@ pub fn NewWatcher(comptime ContextType: type) type { var changelist_array: [128]KEvent = std.mem.zeroes([128]KEvent); var changelist = &changelist_array; - while (true) { + while (this.running) { defer Output.flush(); var count_ = std.os.system.kevent( @@ -530,11 +574,12 @@ pub fn NewWatcher(comptime ContextType: type) type { this.mutex.lock(); defer this.mutex.unlock(); - - this.ctx.onFileUpdate(watchevents, this.changed_filepaths[0..watchevents.len], this.watchlist); + if (this.running) { + this.ctx.onFileUpdate(watchevents, this.changed_filepaths[0..watchevents.len], this.watchlist); + } } } else if (Environment.isLinux) { - restart: while (true) { + restart: while (this.running) { defer Output.flush(); var events = try INotify.read(); @@ -600,9 +645,10 @@ pub fn NewWatcher(comptime ContextType: type) type { this.mutex.lock(); defer this.mutex.unlock(); - - this.ctx.onFileUpdate(all_events[0 .. last_event_index + 1], this.changed_filepaths[0 .. name_off + 1], this.watchlist); - remaining_events -= slice.len; + if (this.running) { + this.ctx.onFileUpdate(all_events[0 .. last_event_index + 1], this.changed_filepaths[0 .. name_off + 1], this.watchlist); + remaining_events -= slice.len; + } } } } diff --git a/test/js/node/watch/fixtures/close.js b/test/js/node/watch/fixtures/close.js new file mode 100644 index 000000000..8eeeb79a3 --- /dev/null +++ b/test/js/node/watch/fixtures/close.js @@ -0,0 +1,7 @@ +import fs from "fs"; +fs.watch(import.meta.path, { signal: AbortSignal.timeout(4000) }) + .on("error", err => { + console.error(err.message); + process.exit(1); + }) + .close(); diff --git a/test/js/node/watch/fixtures/persistent.js b/test/js/node/watch/fixtures/persistent.js new file mode 100644 index 000000000..72a2b6564 --- /dev/null +++ b/test/js/node/watch/fixtures/persistent.js @@ -0,0 +1,5 @@ +import fs from "fs"; +fs.watch(import.meta.path, { persistent: false, signal: AbortSignal.timeout(4000) }).on("error", err => { + console.error(err.message); + process.exit(1); +}); diff --git a/test/js/node/watch/fixtures/relative.js b/test/js/node/watch/fixtures/relative.js new file mode 100644 index 000000000..26e09da1a --- /dev/null +++ b/test/js/node/watch/fixtures/relative.js @@ -0,0 +1,23 @@ +import fs from "fs"; +const watcher = fs.watch("relative.txt", { signal: AbortSignal.timeout(2000) }); + +watcher.on("change", function (event, filename) { + if (filename !== "relative.txt" && event !== "change") { + console.error("fail"); + clearInterval(interval); + watcher.close(); + process.exit(1); + } else { + clearInterval(interval); + watcher.close(); + } +}); +watcher.on("error", err => { + clearInterval(interval); + console.error(err.message); + process.exit(1); +}); + +const interval = setInterval(() => { + fs.writeFileSync("relative.txt", "world"); +}, 10); diff --git a/test/js/node/watch/fixtures/unref.js b/test/js/node/watch/fixtures/unref.js new file mode 100644 index 000000000..a0c506a04 --- /dev/null +++ b/test/js/node/watch/fixtures/unref.js @@ -0,0 +1,7 @@ +import fs from "fs"; +fs.watch(import.meta.path, { signal: AbortSignal.timeout(4000) }) + .on("error", err => { + console.error(err.message); + process.exit(1); + }) + .unref(); diff --git a/test/js/node/watch/fs.watch.test.js b/test/js/node/watch/fs.watch.test.js new file mode 100644 index 000000000..56e1798f1 --- /dev/null +++ b/test/js/node/watch/fs.watch.test.js @@ -0,0 +1,424 @@ +import fs from "fs"; +import path from "path"; +import { tempDirWithFiles, bunRun, bunRunAsScript } from "harness"; +import { pathToFileURL } from "bun"; + +import { describe, expect, test } from "bun:test"; +// Because macOS (and possibly other operating systems) can return a watcher +// before it is actually watching, we need to repeat the operation to avoid +// a race condition. +function repeat(fn) { + const interval = setInterval(fn, 20); + return interval; +} +const encodingFileName = `新建文夹件.txt`; +const testDir = tempDirWithFiles("watch", { + "watch.txt": "hello", + "relative.txt": "hello", + "abort.txt": "hello", + "url.txt": "hello", + [encodingFileName]: "hello", +}); + +describe("fs.watch", () => { + test("non-persistent watcher should not block the event loop", done => { + try { + // https://github.com/joyent/node/issues/2293 - non-persistent watcher should not block the event loop + bunRun(path.join(import.meta.dir, "fixtures", "persistent.js")); + done(); + } catch (e) { + done(e); + } + }); + + test("watcher should close and not block the event loop", done => { + try { + bunRun(path.join(import.meta.dir, "fixtures", "close.js")); + done(); + } catch (e) { + done(e); + } + }); + + test("unref watcher should not block the event loop", done => { + try { + bunRun(path.join(import.meta.dir, "fixtures", "unref.js")); + done(); + } catch (e) { + done(e); + } + }); + + test("should work with relative files", done => { + try { + bunRunAsScript(testDir, path.join(import.meta.dir, "fixtures", "relative.js")); + done(); + } catch (e) { + done(e); + } + }); + + test("add file/folder to folder", done => { + let count = 0; + const root = path.join(testDir, "add-directory"); + try { + fs.mkdirSync(root); + } catch {} + let err = undefined; + const watcher = fs.watch(root, { signal: AbortSignal.timeout(3000) }); + watcher.on("change", (event, filename) => { + count++; + try { + expect(event).toBe("rename"); + expect(["new-file.txt", "new-folder.txt"]).toContain(filename); + if (count >= 2) { + watcher.close(); + } + } catch (e) { + err = e; + watcher.close(); + } + }); + + watcher.on("error", e => (err = e)); + watcher.on("close", () => { + clearInterval(interval); + done(err); + }); + + const interval = repeat(() => { + fs.writeFileSync(path.join(root, "new-file.txt"), "hello"); + fs.mkdirSync(path.join(root, "new-folder.txt")); + fs.rmdirSync(path.join(root, "new-folder.txt")); + }); + }); + + test("add file/folder to subfolder", done => { + let count = 0; + const root = path.join(testDir, "add-subdirectory"); + try { + fs.mkdirSync(root); + } catch {} + const subfolder = path.join(root, "subfolder"); + fs.mkdirSync(subfolder); + const watcher = fs.watch(root, { recursive: true, signal: AbortSignal.timeout(3000) }); + let err = undefined; + watcher.on("change", (event, filename) => { + const basename = path.basename(filename); + if (basename === "subfolder") return; + count++; + try { + expect(event).toBe("rename"); + expect(["new-file.txt", "new-folder.txt"]).toContain(basename); + if (count >= 2) { + watcher.close(); + } + } catch (e) { + err = e; + watcher.close(); + } + }); + watcher.on("error", e => (err = e)); + watcher.on("close", () => { + clearInterval(interval); + done(err); + }); + + const interval = repeat(() => { + fs.writeFileSync(path.join(subfolder, "new-file.txt"), "hello"); + fs.mkdirSync(path.join(subfolder, "new-folder.txt")); + fs.rmdirSync(path.join(subfolder, "new-folder.txt")); + }); + }); + + test("should emit event when file is deleted", done => { + const testsubdir = tempDirWithFiles("subdir", { + "deleted.txt": "hello", + }); + const filepath = path.join(testsubdir, "deleted.txt"); + let err = undefined; + const watcher = fs.watch(testsubdir, function (event, filename) { + try { + expect(event).toBe("rename"); + expect(filename).toBe("deleted.txt"); + } catch (e) { + err = e; + } finally { + clearInterval(interval); + watcher.close(); + } + }); + + watcher.once("close", () => { + done(err); + }); + + const interval = repeat(() => { + fs.rmSync(filepath, { force: true }); + const fd = fs.openSync(filepath, "w"); + fs.closeSync(fd); + }); + }); + + test("should emit 'change' event when file is modified", done => { + const filepath = path.join(testDir, "watch.txt"); + + const watcher = fs.watch(filepath); + let err = undefined; + watcher.on("change", function (event, filename) { + try { + expect(event).toBe("change"); + expect(filename).toBe("watch.txt"); + } catch (e) { + err = e; + } finally { + clearInterval(interval); + watcher.close(); + } + }); + + watcher.once("close", () => { + done(err); + }); + + const interval = repeat(() => { + fs.writeFileSync(filepath, "world"); + }); + }); + + test("should error on invalid path", done => { + try { + fs.watch(path.join(testDir, "404.txt")); + done(new Error("should not reach here")); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe("ENOENT"); + expect(err.syscall).toBe("watch"); + done(); + } + }); + + const encodings = ["utf8", "buffer", "hex", "ascii", "base64", "utf16le", "ucs2", "latin1", "binary"]; + + test(`should work with encodings ${encodings.join(", ")}`, async () => { + const watchers = []; + const filepath = path.join(testDir, encodingFileName); + + const promises = []; + encodings.forEach(name => { + const encoded_filename = + name !== "buffer" ? Buffer.from(encodingFileName, "utf8").toString(name) : Buffer.from(encodingFileName); + + promises.push( + new Promise((resolve, reject) => { + watchers.push( + fs.watch(filepath, { encoding: name }, (event, filename) => { + try { + expect(event).toBe("change"); + + if (name !== "buffer") { + expect(filename).toBe(encoded_filename); + } else { + expect(filename).toBeInstanceOf(Buffer); + expect(filename.toString("utf8")).toBe(encodingFileName); + } + + resolve(); + } catch (e) { + reject(e); + } + }), + ); + }), + ); + }); + + const interval = repeat(() => { + fs.writeFileSync(filepath, "world"); + }); + + try { + await Promise.all(promises); + } finally { + clearInterval(interval); + watchers.forEach(watcher => watcher.close()); + } + }); + + test("should work with url", done => { + const filepath = path.join(testDir, "url.txt"); + try { + const watcher = fs.watch(pathToFileURL(filepath)); + let err = undefined; + watcher.on("change", function (event, filename) { + try { + expect(event).toBe("change"); + expect(filename).toBe("url.txt"); + } catch (e) { + err = e; + } finally { + clearInterval(interval); + watcher.close(); + } + }); + + watcher.once("close", () => { + done(err); + }); + + const interval = repeat(() => { + fs.writeFileSync(filepath, "world"); + }); + } catch (e) { + done(e); + } + }); + + test("Signal aborted after creating the watcher", async () => { + const filepath = path.join(testDir, "abort.txt"); + + const ac = new AbortController(); + const promise = new Promise((resolve, reject) => { + const watcher = fs.watch(filepath, { signal: ac.signal }); + watcher.once("error", err => (err.message === "The operation was aborted." ? resolve() : reject(err))); + watcher.once("close", () => reject()); + }); + await Bun.sleep(10); + ac.abort(); + await promise; + }); + + test("Signal aborted before creating the watcher", async () => { + const filepath = path.join(testDir, "abort.txt"); + + const signal = AbortSignal.abort(); + await new Promise((resolve, reject) => { + const watcher = fs.watch(filepath, { signal }); + watcher.once("error", err => (err.message === "The operation was aborted." ? resolve() : reject(err))); + watcher.once("close", () => reject()); + }); + }); +}); + +describe("fs.promises.watchFile", () => { + test("add file/folder to folder", async () => { + let count = 0; + const root = path.join(testDir, "add-promise-directory"); + try { + fs.mkdirSync(root); + } catch {} + let success = false; + let err = undefined; + try { + const ac = new AbortController(); + const watcher = fs.promises.watch(root, { signal: ac.signal }); + + const interval = repeat(() => { + fs.writeFileSync(path.join(root, "new-file.txt"), "hello"); + fs.mkdirSync(path.join(root, "new-folder.txt")); + fs.rmdirSync(path.join(root, "new-folder.txt")); + }); + + for await (const event of watcher) { + count++; + try { + expect(event.eventType).toBe("rename"); + expect(["new-file.txt", "new-folder.txt"]).toContain(event.filename); + + if (count >= 2) { + success = true; + clearInterval(interval); + ac.abort(); + } + } catch (e) { + err = e; + clearInterval(interval); + ac.abort(); + } + } + } catch (e) { + if (!success) { + throw err || e; + } + } + }); + + test("add file/folder to subfolder", async () => { + let count = 0; + const root = path.join(testDir, "add-promise-subdirectory"); + try { + fs.mkdirSync(root); + } catch {} + const subfolder = path.join(root, "subfolder"); + fs.mkdirSync(subfolder); + let success = false; + let err = undefined; + + try { + const ac = new AbortController(); + const watcher = fs.promises.watch(root, { recursive: true, signal: ac.signal }); + + const interval = repeat(() => { + fs.writeFileSync(path.join(subfolder, "new-file.txt"), "hello"); + fs.mkdirSync(path.join(subfolder, "new-folder.txt")); + fs.rmdirSync(path.join(subfolder, "new-folder.txt")); + }); + for await (const event of watcher) { + const basename = path.basename(event.filename); + if (basename === "subfolder") continue; + + count++; + try { + expect(event.eventType).toBe("rename"); + expect(["new-file.txt", "new-folder.txt"]).toContain(basename); + + if (count >= 2) { + success = true; + clearInterval(interval); + ac.abort(); + } + } catch (e) { + err = e; + clearInterval(interval); + ac.abort(); + } + } + } catch (e) { + if (!success) { + throw err || e; + } + } + }); + + test("Signal aborted after creating the watcher", async () => { + const filepath = path.join(testDir, "abort.txt"); + + const ac = new AbortController(); + const watcher = fs.promises.watch(filepath, { signal: ac.signal }); + + const promise = (async () => { + try { + for await (const _ of watcher); + } catch (e) { + expect(e.message).toBe("The operation was aborted."); + } + })(); + await Bun.sleep(10); + ac.abort(); + await promise; + }); + + test("Signal aborted before creating the watcher", async () => { + const filepath = path.join(testDir, "abort.txt"); + + const signal = AbortSignal.abort(); + const watcher = fs.promises.watch(filepath, { signal }); + await (async () => { + try { + for await (const _ of watcher); + } catch (e) { + expect(e.message).toBe("The operation was aborted."); + } + })(); + }); +}); -- cgit v1.2.3