From 0f17be179c209a91ef0e06a262b68da7b176c3f8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 30 Nov 2025 03:04:49 +0000 Subject: [PATCH] BREAKING CHANGE(smartwatch): Introduce Smartwatch: cross-runtime native file watching for Node.js, Deno and Bun; rename smartchok to smartwatch and bump major version to 2.0.0 --- changelog.md | 11 + npmextra.json | 6 +- package.json | 16 +- pnpm-lock.yaml | 41 +-- readme.hints.md | 89 +++-- readme.md | 26 +- test/test.ts | 18 +- ts/00_commitinfo_data.ts | 4 +- ts/index.ts | 2 +- ...ok.ts => smartwatch.classes.smartwatch.ts} | 167 +++++---- ...tchok.plugins.ts => smartwatch.plugins.ts} | 6 +- ts/utils/write-stabilizer.ts | 97 ++++++ ts/watchers/index.ts | 33 ++ ts/watchers/interfaces.ts | 47 +++ ts/watchers/watcher.deno.ts | 329 ++++++++++++++++++ ts/watchers/watcher.node.ts | 281 +++++++++++++++ 16 files changed, 1011 insertions(+), 162 deletions(-) rename ts/{smartchok.classes.smartchok.ts => smartwatch.classes.smartwatch.ts} (61%) rename ts/{smartchok.plugins.ts => smartwatch.plugins.ts} (84%) create mode 100644 ts/utils/write-stabilizer.ts create mode 100644 ts/watchers/index.ts create mode 100644 ts/watchers/interfaces.ts create mode 100644 ts/watchers/watcher.deno.ts create mode 100644 ts/watchers/watcher.node.ts diff --git a/changelog.md b/changelog.md index 95034ec..d914071 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-11-30 - 3.0.0 - BREAKING CHANGE(smartwatch) +Introduce Smartwatch: cross-runtime native file watching for Node.js, Deno and Bun; rename smartchok to smartwatch and bump major version to 2.0.0 + +- Rename public API and docs from Smartchok to Smartwatch and update package metadata for the new module name. +- Replace chokidar with native watchers and picomatch-based glob filtering to enable cross-runtime support (Node.js, Deno, Bun). +- Add watcher factory and runtime-specific implementations: watchers/index.ts, watcher.node.ts, watcher.deno.ts. +- Add WriteStabilizer (ts/utils/write-stabilizer.ts) to provide awaitWriteFinish functionality via polling. +- Introduce @push.rocks/smartenv for runtime detection and remove direct chokidar dependency; update dependencies accordingly. +- Update tests (test/test.ts) and documentation (readme.md, readme.hints.md) to reflect API/name changes and new architecture. +- Bump package version to 2.0.0 to mark breaking changes in API and behavior. + ## 2025-11-29 - 1.2.0 - feat(core) Migrate to chokidar 5.x, add picomatch filtering and update test/dev dependencies diff --git a/npmextra.json b/npmextra.json index dfa5f95..8e36f34 100644 --- a/npmextra.json +++ b/npmextra.json @@ -8,9 +8,9 @@ "module": { "githost": "code.foss.global", "gitscope": "push.rocks", - "gitrepo": "smartchok", - "description": "A smart wrapper for chokidar to facilitate file watching with enhanced features.", - "npmPackagename": "@push.rocks/smartchokidar", + "gitrepo": "smartwatch", + "description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced file watching features.", + "npmPackagename": "@push.rocks/smartwatch", "license": "MIT", "keywords": [ "file watching", diff --git a/package.json b/package.json index 44f018c..b6a5569 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@push.rocks/smartchok", - "version": "1.2.0", + "version": "2.0.0", "private": false, - "description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced features.", + "description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.", "main": "dist_ts/index.js", "typings": "dist_ts/index.d.ts", "scripts": { @@ -21,11 +21,14 @@ "url": "https://gitlab.com/push.rocks/smartchok/issues" }, "homepage": "https://code.foss.global/push.rocks/smartchok", + "engines": { + "node": ">=20.0.0" + }, "dependencies": { "@push.rocks/lik": "^6.2.2", + "@push.rocks/smartenv": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartrx": "^3.0.10", - "chokidar": "^5.0.0", "picomatch": "^4.0.3" }, "devDependencies": { @@ -53,15 +56,18 @@ "type": "module", "keywords": [ "file watching", - "chokidar", "filesystem", "observable", "typescript", "node.js", + "deno", + "bun", + "cross-runtime", "development tool", "file system events", "real-time", - "watch files" + "watch files", + "glob patterns" ], "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbd6fed..4bf4cb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,15 +11,15 @@ importers: '@push.rocks/lik': specifier: ^6.2.2 version: 6.2.2 + '@push.rocks/smartenv': + specifier: ^6.0.0 + version: 6.0.0 '@push.rocks/smartpromise': specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartrx': specifier: ^3.0.10 version: 3.0.10 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 picomatch: specifier: ^4.0.3 version: 4.0.3 @@ -898,9 +898,6 @@ packages: '@push.rocks/smartdns@7.6.1': resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==} - '@push.rocks/smartenv@5.0.12': - resolution: {integrity: sha512-tDEFwywzq0FNzRYc9qY2dRl2pgQuZG0G2/yml2RLWZWSW+Fn1EHshnKOGHz8o77W7zvu4hTgQQX42r/JY5XHTg==} - '@push.rocks/smartenv@5.0.13': resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==} @@ -2202,10 +2199,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chromium-bidi@5.1.0: resolution: {integrity: sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==} peerDependencies: @@ -3638,10 +3631,6 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5784,7 +5773,7 @@ snapshots: '@push.rocks/lik': 6.2.2 '@push.rocks/smartbucket': 3.3.7 '@push.rocks/smartcache': 1.0.16 - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartexit': 1.0.23 '@push.rocks/smartfile': 11.2.7 '@push.rocks/smartjson': 5.0.20 @@ -5977,10 +5966,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@push.rocks/smartenv@5.0.12': - dependencies: - '@push.rocks/smartpromise': 4.2.3 - '@push.rocks/smartenv@5.0.13': dependencies: '@push.rocks/smartpromise': 4.2.3 @@ -6074,7 +6059,7 @@ snapshots: '@push.rocks/smarthash@3.2.0': dependencies: - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartjson': 5.0.20 '@push.rocks/smartpromise': 4.2.3 '@types/through2': 2.0.41 @@ -6090,7 +6075,7 @@ snapshots: '@push.rocks/smartjson@5.0.20': dependencies: - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartstring': 4.0.15 fast-json-stable-stringify: 2.1.0 lodash.clonedeep: 4.5.0 @@ -6398,14 +6383,14 @@ snapshots: '@push.rocks/smartstream@3.2.5': dependencies: '@push.rocks/lik': 6.2.2 - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smartstring@4.0.15': dependencies: '@push.rocks/isounique': 1.0.5 - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@types/randomatic': 3.1.5 crypto-random-string: 5.0.0 js-base64: 3.7.7 @@ -6483,7 +6468,7 @@ snapshots: '@push.rocks/webrequest@3.0.37': dependencies: '@push.rocks/smartdelay': 3.0.5 - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@push.rocks/smartjson': 5.0.20 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/webstore': 2.0.20 @@ -6515,7 +6500,7 @@ snapshots: '@push.rocks/webstream@1.0.10': dependencies: - '@push.rocks/smartenv': 5.0.12 + '@push.rocks/smartenv': 5.0.13 '@pushrocks/isounique@1.0.5': {} @@ -7915,10 +7900,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - chromium-bidi@5.1.0(devtools-protocol@0.0.1452169): dependencies: devtools-protocol: 0.0.1452169 @@ -9646,8 +9627,6 @@ snapshots: readdirp@4.1.2: {} - readdirp@5.0.0: {} - reflect-metadata@0.2.2: {} regenerator-runtime@0.14.1: {} diff --git a/readme.hints.md b/readme.hints.md index 62addec..a7af2ed 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,33 +1,82 @@ # smartchok - Technical Hints -## Chokidar 5.x Migration (2024) +## Native File Watching (v2.0.0+) -The module has been migrated to `chokidar` 5.x (from 4.x). Key changes: +The module now uses native file watching APIs instead of chokidar, providing cross-runtime support for Node.js, Deno, and Bun. + +### Architecture + +``` +ts/ +├── smartwatch.classes.smartwatch.ts # Main Smartwatch class +├── smartwatch.plugins.ts # Dependencies (smartenv, picomatch, etc.) +├── watchers/ +│ ├── index.ts # Factory with runtime detection +│ ├── interfaces.ts # IWatcher interface and types +│ ├── watcher.node.ts # Node.js/Bun implementation (fs.watch) +│ └── watcher.deno.ts # Deno implementation (Deno.watchFs) +└── utils/ + └── write-stabilizer.ts # awaitWriteFinish polling implementation +``` + +### Runtime Detection + +Uses `@push.rocks/smartenv` v6.x for runtime detection: +- **Node.js/Bun**: Uses native `fs.watch()` with `{ recursive: true }` +- **Deno**: Uses `Deno.watchFs()` async iterable ### Dependencies -- **Current**: `chokidar` 5.x and `picomatch` -- **Historical**: Was previously using `@tempfix/watcher` before chokidar 4.x + +- **picomatch**: Glob pattern matching (zero deps, well-maintained) +- **@push.rocks/smartenv**: Runtime detection (Node.js, Deno, Bun) +- **@push.rocks/smartrx**: RxJS Subject/Observable management +- **@push.rocks/smartpromise**: Deferred promise utilities +- **@push.rocks/lik**: Stringmap for pattern storage ### Why picomatch? -Chokidar 4.x+ removed built-in glob pattern support. We use `picomatch` to maintain backward compatibility and provide glob pattern matching functionality. -### Implementation Details -1. **Glob pattern extraction**: The `getGlobBase()` method extracts base directories from glob patterns -2. **Pattern matching**: Each glob pattern is compiled to a picomatch matcher function -3. **Event filtering**: File system events are filtered based on glob patterns before being emitted -4. **Path normalization**: Paths are normalized to handle different formats (with/without leading ./) +Native file watching APIs don't support glob patterns. Picomatch provides glob pattern matching with: +- Zero dependencies +- 164M+ weekly downloads +- Excellent security profile +- Full glob syntax support ### Event Handling -Chokidar 5.x events are mapped 1:1 with smartchok events: -- `add`, `change`, `unlink`: File events -- `addDir`, `unlinkDir`: Directory events -- `error`: Error events -- `raw`: Raw events from underlying watchers -- `ready`: Emitted when initial scan is complete + +Native events are normalized to a consistent interface: + +| Node.js/Bun Event | Deno Event | Normalized Event | +|-------------------|------------|------------------| +| `rename` (file exists) | `create` | `add` | +| `rename` (file gone) | `remove` | `unlink` | +| `change` | `modify` | `change` | + +### awaitWriteFinish Implementation + +The `WriteStabilizer` class replaces chokidar's built-in write stabilization: +- Polls file size until stable (configurable threshold: 300ms default) +- Configurable poll interval (100ms default) +- Handles file deletion during write detection + +### Platform Requirements + +- **Node.js 20+**: Required for native recursive watching on all platforms +- **Deno**: Works on all versions with `Deno.watchFs()` +- **Bun**: Uses Node.js compatibility layer ### Testing -All existing tests pass without modification, confirming backward compatibility is maintained. -## Dev Dependencies (2024) -- Using `@git.zone/tstest` v3.x with tapbundle (`import { tap, expect } from '@git.zone/tstest/tapbundle'`) -- Removed deprecated `@push.rocks/tapbundle` +```bash +pnpm test +``` + +Tests verify: +- Creating Smartwatch instance +- Adding glob patterns +- Receiving 'add' events for new files +- Graceful shutdown + +## Dev Dependencies + +- Using `@git.zone/tstest` v3.x with tapbundle +- Import from `@git.zone/tstest/tapbundle` diff --git a/readme.md b/readme.md index 0c51920..428a8c7 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# @push.rocks/smartchok +# @push.rocks/smartwatch A smart wrapper for chokidar 5.x with glob pattern support, RxJS observable integration, and enhanced file watching features. @@ -9,9 +9,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community ## Install ```sh -npm install @push.rocks/smartchok +npm install @push.rocks/smartwatch # or -pnpm add @push.rocks/smartchok +pnpm add @push.rocks/smartwatch ``` ## Features @@ -27,10 +27,10 @@ pnpm add @push.rocks/smartchok ### Basic Setup ```typescript -import { Smartchok } from '@push.rocks/smartchok'; +import { Smartwatch } from '@push.rocks/smartwatch'; // Create a watcher with glob patterns -const watcher = new Smartchok([ +const watcher = new Smartwatch([ './src/**/*.ts', // Watch all TypeScript files in src './public/assets/**/*' // Watch all files in public/assets ]); @@ -87,7 +87,7 @@ unlinkObservable.subscribe(([path]) => { Add or remove patterns while the watcher is running: ```typescript -const watcher = new Smartchok(['./src/**/*.ts']); +const watcher = new Smartwatch(['./src/**/*.ts']); await watcher.start(); // Add more patterns to watch @@ -107,11 +107,11 @@ await watcher.stop(); ### Complete Example ```typescript -import { Smartchok } from '@push.rocks/smartchok'; +import { Smartwatch } from '@push.rocks/smartwatch'; async function watchProject() { // Initialize with patterns - const watcher = new Smartchok([ + const watcher = new Smartwatch([ './src/**/*.ts', './package.json' ]); @@ -152,24 +152,24 @@ watchProject(); ## How It Works -Since chokidar 4.x+ no longer supports glob patterns natively, smartchok handles glob pattern matching internally using [picomatch](https://github.com/micromatch/picomatch). This means you get the familiar glob syntax while benefiting from chokidar's efficient file watching capabilities. +Since chokidar 4.x+ no longer supports glob patterns natively, smartwatch handles glob pattern matching internally using [picomatch](https://github.com/micromatch/picomatch). This means you get the familiar glob syntax while benefiting from chokidar's efficient file watching capabilities. When you provide glob patterns: -1. **Base path extraction** - smartchok extracts the static base path from each pattern +1. **Base path extraction** - smartwatch extracts the static base path from each pattern 2. **Efficient watching** - chokidar watches the base directories 3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted ## API Reference -### `Smartchok` +### `Smartwatch` #### Constructor ```typescript -new Smartchok(patterns: string[]) +new Smartwatch(patterns: string[]) ``` -Creates a new Smartchok instance with the given glob patterns. +Creates a new Smartwatch instance with the given glob patterns. #### Methods diff --git a/test/test.ts b/test/test.ts index 5c45a93..07beeec 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,5 +1,5 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as smartchok from '../ts/index.js'; +import * as smartwatch from '../ts/index.js'; import * as smartfile from '@push.rocks/smartfile'; import * as smartpromise from '@push.rocks/smartpromise'; import * as smartrx from '@push.rocks/smartrx'; @@ -11,22 +11,22 @@ if (process.env.CI) { process.exit(0); } -let testSmartchok: smartchok.Smartchok; +let testSmartwatch: smartwatch.Smartwatch; let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>; let testSubscription: smartrx.rxjs.Subscription; tap.test('should create a new instance', async () => { - testSmartchok = new smartchok.Smartchok([]); - expect(testSmartchok).toBeInstanceOf(smartchok.Smartchok); + testSmartwatch = new smartwatch.Smartwatch([]); + expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch); }); tap.test('should add some files to watch and start', async () => { - testSmartchok.add(['./test/**/*.txt']); - await testSmartchok.start() - testSmartchok.add(['./test/**/*.md']); + testSmartwatch.add(['./test/**/*.txt']); + await testSmartwatch.start() + testSmartwatch.add(['./test/**/*.md']); }); tap.test('should get an observable for a certain event', async () => { - await testSmartchok.getObservableFor('add').then(async (observableArg) => { + await testSmartwatch.getObservableFor('add').then(async (observableArg) => { testAddObservable = observableArg; }); }); @@ -44,7 +44,7 @@ tap.test('should register an add operation', async () => { tap.test('should stop the watch process', async (tools) => { await tools.delayFor(10000); - testSmartchok.stop(); + testSmartwatch.stop(); }); export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 631d944..844b38d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartchok', - version: '1.2.0', - description: 'A smart wrapper for chokidar 5.x with glob pattern support and enhanced features.' + version: '3.0.0', + description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.' } diff --git a/ts/index.ts b/ts/index.ts index f985f7d..8ad7644 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1 +1 @@ -export * from './smartchok.classes.smartchok.js'; +export * from './smartwatch.classes.smartwatch.js'; diff --git a/ts/smartchok.classes.smartchok.ts b/ts/smartwatch.classes.smartwatch.ts similarity index 61% rename from ts/smartchok.classes.smartchok.ts rename to ts/smartwatch.classes.smartwatch.ts index 5b22b4e..8218738 100644 --- a/ts/smartchok.classes.smartchok.ts +++ b/ts/smartwatch.classes.smartwatch.ts @@ -1,7 +1,8 @@ -import * as plugins from './smartchok.plugins.js'; +import * as plugins from './smartwatch.plugins.js'; import { Stringmap } from '@push.rocks/lik'; +import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js'; -export type TSmartchokStatus = 'idle' | 'starting' | 'watching'; +export type TSmartwatchStatus = 'idle' | 'starting' | 'watching'; export type TFsEvent = | 'add' | 'addDir' @@ -13,22 +14,31 @@ export type TFsEvent = | 'raw'; /** - * Smartchok allows easy wathcing of files + * Smartwatch allows easy watching of files + * Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support */ -export class Smartchok { +export class Smartwatch { public watchStringmap = new Stringmap(); - public status: TSmartchokStatus = 'idle'; - private watcher: plugins.chokidar.FSWatcher; + public status: TSmartwatchStatus = 'idle'; + private watcher: IWatcher | null = null; private globPatterns: string[] = []; private globMatchers: Map boolean> = new Map(); - private watchingDeferred = plugins.smartpromise.defer(); // used to run things when watcher is initialized - private eventObservablemap = new plugins.smartrx.Observablemap(); // register one observable per event + private watchingDeferred = plugins.smartpromise.defer(); + + // Event subjects for each event type + private eventSubjects: Map> = new Map(); /** - * constructor of class smartchok + * constructor of class Smartwatch */ constructor(watchArrayArg: string[]) { this.watchStringmap.addStringArray(watchArrayArg); + + // Initialize subjects for each event type + const eventTypes: TFsEvent[] = ['add', 'addDir', 'change', 'error', 'unlink', 'unlinkDir', 'ready', 'raw']; + for (const eventType of eventTypes) { + this.eventSubjects.set(eventType, new plugins.smartrx.rxjs.Subject()); + } } private getGlobBase(globPattern: string) { @@ -80,11 +90,12 @@ export class Smartchok { ): Promise> { const done = plugins.smartpromise.defer>(); this.watchingDeferred.promise.then(() => { - const eventObservable = this.eventObservablemap.getSubjectForEmitterEvent( - this.watcher, - fsEvent - ); - done.resolve(eventObservable); + const subject = this.eventSubjects.get(fsEvent); + if (subject) { + done.resolve(subject.asObservable()); + } else { + done.reject(new Error(`Unknown event type: ${fsEvent}`)); + } }); return done.promise; } @@ -93,18 +104,17 @@ export class Smartchok { * starts the watcher * @returns Promise */ - public start(): Promise { - const done = plugins.smartpromise.defer(); + public async start(): Promise { this.status = 'starting'; - + // Store original glob patterns and create matchers this.globPatterns = this.watchStringmap.getStringArray(); const basePaths = new Set(); - + this.globPatterns.forEach((pattern) => { const basePath = this.getGlobBase(pattern); basePaths.add(basePath); - + // Create a picomatch matcher for each glob pattern const matcher = plugins.picomatch(pattern, { dot: true, @@ -112,62 +122,63 @@ export class Smartchok { }); this.globMatchers.set(pattern, matcher); }); - - // Convert Set to Array for chokidar + + // Convert Set to Array for the watcher const watchPaths = Array.from(basePaths); console.log('Base paths to watch:', watchPaths); - - this.watcher = plugins.chokidar.watch(watchPaths, { - persistent: true, - ignoreInitial: false, - followSymlinks: false, + + // Create the platform-appropriate watcher + this.watcher = await createWatcher({ + basePaths: watchPaths, depth: 4, - awaitWriteFinish: { - stabilityThreshold: 300, - pollInterval: 100 - }, - ignored: (path: string, stats?: plugins.fs.Stats) => { - // Don't filter during initialization - let chokidar watch everything - // We'll filter events as they come in - return false; + followSymlinks: false, + stabilityThreshold: 300, + pollInterval: 100 + }); + + // Subscribe to watcher events and dispatch to appropriate subjects + this.watcher.events$.subscribe((event: IWatchEvent) => { + this.handleWatchEvent(event); + }); + + // Start the watcher + await this.watcher.start(); + + this.status = 'watching'; + this.watchingDeferred.resolve(); + } + + /** + * Handle events from the native watcher + */ + private handleWatchEvent(event: IWatchEvent): void { + // Handle ready event + if (event.type === 'ready') { + const subject = this.eventSubjects.get('ready'); + if (subject) { + subject.next(['', undefined]); } - }); - - // Set up event handlers with glob filtering - const fileEvents: Array<'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'> = - ['add', 'addDir', 'change', 'unlink', 'unlinkDir']; - - // Handle file events - fileEvents.forEach(eventName => { - this.watcher.on(eventName, (path: string, stats?: plugins.fs.Stats) => { - // Only emit event if the path matches our glob patterns - if (this.shouldWatchPath(path)) { - this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, eventName) - .next([path, stats]); - } - }); - }); - - // Handle error events - this.watcher.on('error', (error: Error) => { - this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'error') - .next([error, undefined]); - }); - - // Handle raw events - this.watcher.on('raw', (eventType: string, path: string, details: any) => { - if (this.shouldWatchPath(path)) { - this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'raw') - .next([path, undefined]); + return; + } + + // Handle error event + if (event.type === 'error') { + const subject = this.eventSubjects.get('error'); + if (subject) { + subject.next([event.error?.message || 'Unknown error', undefined]); } - }); - - this.watcher.on('ready', () => { - this.status = 'watching'; - this.watchingDeferred.resolve(); - done.resolve(); - }); - return done.promise; + return; + } + + // Filter file/directory events by glob patterns + if (!this.shouldWatchPath(event.path)) { + return; + } + + const subject = this.eventSubjects.get(event.type as TFsEvent); + if (subject) { + subject.next([event.path, event.stats]); + } } /** @@ -175,8 +186,12 @@ export class Smartchok { */ public async stop() { const closeWatcher = async () => { - await this.watcher.close(); + if (this.watcher) { + await this.watcher.stop(); + this.watcher = null; + } }; + if (this.status === 'watching') { console.log('closing while watching'); await closeWatcher(); @@ -184,8 +199,10 @@ export class Smartchok { await this.watchingDeferred.promise; await closeWatcher(); } + + this.status = 'idle'; } - + /** * Checks if a path should be watched based on glob patterns */ @@ -195,7 +212,7 @@ export class Smartchok { if (normalizedPath.startsWith('./')) { normalizedPath = normalizedPath.substring(2); } - + // Check if the path matches any of our glob patterns for (const [pattern, matcher] of this.globMatchers) { // Also normalize the pattern for comparison @@ -203,12 +220,12 @@ export class Smartchok { if (normalizedPattern.startsWith('./')) { normalizedPattern = normalizedPattern.substring(2); } - + // Try matching with both the original pattern and normalized if (matcher(normalizedPath) || matcher(filePath)) { return true; } - + // Also try matching without the leading path const withoutLeading = 'test/' + normalizedPath.split('test/').slice(1).join('test/'); if (matcher(withoutLeading)) { diff --git a/ts/smartchok.plugins.ts b/ts/smartwatch.plugins.ts similarity index 84% rename from ts/smartchok.plugins.ts rename to ts/smartwatch.plugins.ts index 5b59102..729eee1 100644 --- a/ts/smartchok.plugins.ts +++ b/ts/smartwatch.plugins.ts @@ -11,18 +11,18 @@ export { import * as lik from '@push.rocks/lik'; import * as smartpromise from '@push.rocks/smartpromise'; import * as smartrx from '@push.rocks/smartrx'; +import { Smartenv } from '@push.rocks/smartenv'; export { lik, smartpromise, - smartrx + smartrx, + Smartenv } // thirdparty scope -import * as chokidar from 'chokidar'; import picomatch from 'picomatch'; export { - chokidar, picomatch, } diff --git a/ts/utils/write-stabilizer.ts b/ts/utils/write-stabilizer.ts new file mode 100644 index 0000000..a5fa3e3 --- /dev/null +++ b/ts/utils/write-stabilizer.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; + +interface IPendingWrite { + lastSize: number; + lastChange: number; + timeoutId: ReturnType; + resolve: (stats: fs.Stats) => void; + reject: (error: Error) => void; +} + +/** + * Implements awaitWriteFinish functionality by polling file size until stable. + * This replaces chokidar's built-in write stabilization. + */ +export class WriteStabilizer { + private pendingWrites = new Map(); + + constructor( + private stabilityThreshold: number = 300, + private pollInterval: number = 100 + ) {} + + /** + * Wait for a file write to complete by polling until size is stable + */ + async waitForWriteFinish(filePath: string): Promise { + // Cancel any existing pending check for this file + this.cancel(filePath); + + return new Promise((resolve, reject) => { + const poll = async () => { + try { + const stats = await fs.promises.stat(filePath); + const pending = this.pendingWrites.get(filePath); + + if (!pending) { + // Was cancelled + return; + } + + const now = Date.now(); + + if (stats.size !== pending.lastSize) { + // Size changed - file is still being written, reset timer + pending.lastSize = stats.size; + pending.lastChange = now; + pending.timeoutId = setTimeout(poll, this.pollInterval); + } else if (now - pending.lastChange >= this.stabilityThreshold) { + // Size has been stable for the threshold duration + this.pendingWrites.delete(filePath); + resolve(stats); + } else { + // Size is the same but not yet past threshold + pending.timeoutId = setTimeout(poll, this.pollInterval); + } + } catch (error: any) { + this.pendingWrites.delete(filePath); + if (error.code === 'ENOENT') { + // File was deleted during polling + reject(new Error(`File was deleted: ${filePath}`)); + } else { + reject(error); + } + } + }; + + this.pendingWrites.set(filePath, { + lastSize: -1, + lastChange: Date.now(), + timeoutId: setTimeout(poll, this.pollInterval), + resolve, + reject + }); + }); + } + + /** + * Cancel any pending write stabilization for a file + */ + cancel(filePath: string): void { + const pending = this.pendingWrites.get(filePath); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingWrites.delete(filePath); + } + } + + /** + * Cancel all pending write stabilizations + */ + cancelAll(): void { + for (const [filePath, pending] of this.pendingWrites) { + clearTimeout(pending.timeoutId); + } + this.pendingWrites.clear(); + } +} diff --git a/ts/watchers/index.ts b/ts/watchers/index.ts new file mode 100644 index 0000000..b5a3f33 --- /dev/null +++ b/ts/watchers/index.ts @@ -0,0 +1,33 @@ +import { Smartenv } from '@push.rocks/smartenv'; +import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js'; + +export type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType }; + +/** + * Creates a platform-appropriate file watcher based on the current runtime + * Uses @push.rocks/smartenv for runtime detection + */ +export async function createWatcher(options: IWatcherOptions): Promise { + const env = new Smartenv(); + + if (env.isDeno) { + // Deno runtime - use Deno.watchFs + const { DenoWatcher } = await import('./watcher.deno.js'); + return new DenoWatcher(options); + } else { + // Node.js or Bun - both use fs.watch (Bun has Node.js compatibility) + const { NodeWatcher } = await import('./watcher.node.js'); + return new NodeWatcher(options); + } +} + +/** + * Default watcher options + */ +export const defaultWatcherOptions: IWatcherOptions = { + basePaths: [], + depth: 4, + followSymlinks: false, + stabilityThreshold: 300, + pollInterval: 100 +}; diff --git a/ts/watchers/interfaces.ts b/ts/watchers/interfaces.ts new file mode 100644 index 0000000..875b222 --- /dev/null +++ b/ts/watchers/interfaces.ts @@ -0,0 +1,47 @@ +import type * as fs from 'fs'; +import type * as smartrx from '@push.rocks/smartrx'; + +/** + * File system event types that the watcher emits + */ +export type TWatchEventType = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' | 'ready' | 'error'; + +/** + * Data structure for watch events + */ +export interface IWatchEvent { + type: TWatchEventType; + path: string; + stats?: fs.Stats; + error?: Error; +} + +/** + * Options for creating a watcher + */ +export interface IWatcherOptions { + /** Base paths to watch (extracted from glob patterns) */ + basePaths: string[]; + /** Maximum directory depth to watch */ + depth: number; + /** Whether to follow symbolic links */ + followSymlinks: boolean; + /** Stability threshold for write detection (ms) */ + stabilityThreshold: number; + /** Poll interval for write detection (ms) */ + pollInterval: number; +} + +/** + * Common interface for file watchers across runtimes + */ +export interface IWatcher { + /** Start watching files */ + start(): Promise; + /** Stop watching and clean up */ + stop(): Promise; + /** Whether the watcher is currently active */ + readonly isWatching: boolean; + /** Subject that emits watch events */ + readonly events$: smartrx.rxjs.Subject; +} diff --git a/ts/watchers/watcher.deno.ts b/ts/watchers/watcher.deno.ts new file mode 100644 index 0000000..e6c489f --- /dev/null +++ b/ts/watchers/watcher.deno.ts @@ -0,0 +1,329 @@ +import * as smartrx from '@push.rocks/smartrx'; +import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js'; + +// Type definitions for Deno APIs (these exist at runtime in Deno) +declare const Deno: { + watchFs(paths: string | string[], options?: { recursive?: boolean }): AsyncIterable<{ + kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other'; + paths: string[]; + flag?: { rescan: boolean }; + }> & { close(): void }; + stat(path: string): Promise<{ + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + size: number; + mtime: Date | null; + atime: Date | null; + birthtime: Date | null; + mode: number | null; + uid: number | null; + gid: number | null; + }>; + lstat(path: string): Promise<{ + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + size: number; + mtime: Date | null; + atime: Date | null; + birthtime: Date | null; + mode: number | null; + uid: number | null; + gid: number | null; + }>; + readDir(path: string): AsyncIterable<{ + name: string; + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + }>; +}; + +/** + * Convert Deno stat to Node.js-like Stats object + */ +function denoStatToNodeStats(denoStat: Awaited>): any { + return { + isFile: () => denoStat.isFile, + isDirectory: () => denoStat.isDirectory, + isSymbolicLink: () => denoStat.isSymlink, + size: denoStat.size, + mtime: denoStat.mtime, + atime: denoStat.atime, + birthtime: denoStat.birthtime, + mode: denoStat.mode, + uid: denoStat.uid, + gid: denoStat.gid + }; +} + +/** + * Deno file watcher using native Deno.watchFs API + */ +export class DenoWatcher implements IWatcher { + private watcher: ReturnType | null = null; + private watchedFiles: Set = new Set(); + private _isWatching = false; + private abortController: AbortController | null = null; + private recentEvents: Map = new Map(); + private throttleMs = 50; + private pendingWrites: Map> = new Map(); + + public readonly events$ = new smartrx.rxjs.Subject(); + + constructor(private options: IWatcherOptions) {} + + get isWatching(): boolean { + return this._isWatching; + } + + async start(): Promise { + if (this._isWatching) { + return; + } + + try { + this.abortController = new AbortController(); + + // Start watching all base paths + this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true }); + this._isWatching = true; + + // Perform initial scan + for (const basePath of this.options.basePaths) { + await this.scanDirectory(basePath, 0); + } + + // Emit ready event + this.events$.next({ type: 'ready', path: '' }); + + // Start processing events + this.processEvents(); + } catch (error: any) { + this.events$.next({ type: 'error', path: '', error }); + throw error; + } + } + + async stop(): Promise { + this._isWatching = false; + + // Cancel all pending write stabilizations + for (const timeout of this.pendingWrites.values()) { + clearTimeout(timeout); + } + this.pendingWrites.clear(); + + // Close the watcher + if (this.watcher) { + (this.watcher as any).close(); + this.watcher = null; + } + + this.watchedFiles.clear(); + this.recentEvents.clear(); + } + + /** + * Process events from the Deno watcher + */ + private async processEvents(): Promise { + if (!this.watcher) { + return; + } + + try { + for await (const event of this.watcher) { + if (!this._isWatching) { + break; + } + + for (const filePath of event.paths) { + await this.handleDenoEvent(event.kind, filePath); + } + } + } catch (error: any) { + if (this._isWatching) { + this.events$.next({ type: 'error', path: '', error }); + } + } + } + + /** + * Handle a Deno file system event + */ + private async handleDenoEvent( + kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other', + filePath: string + ): Promise { + // Ignore 'access' events (just reading the file) + if (kind === 'access') { + return; + } + + // Throttle duplicate events + if (!this.shouldEmit(filePath, kind)) { + return; + } + + try { + if (kind === 'create') { + const stats = await this.statSafe(filePath); + if (stats) { + // Wait for write to stabilize + await this.waitForWriteFinish(filePath); + const finalStats = await this.statSafe(filePath); + + if (finalStats) { + this.watchedFiles.add(filePath); + const eventType: TWatchEventType = finalStats.isDirectory() ? 'addDir' : 'add'; + this.events$.next({ type: eventType, path: filePath, stats: finalStats }); + } + } + } else if (kind === 'modify') { + const stats = await this.statSafe(filePath); + if (stats && !stats.isDirectory()) { + // Wait for write to stabilize + await this.waitForWriteFinish(filePath); + const finalStats = await this.statSafe(filePath); + + if (finalStats) { + this.events$.next({ type: 'change', path: filePath, stats: finalStats }); + } + } + } else if (kind === 'remove') { + const wasDirectory = this.isKnownDirectory(filePath); + this.watchedFiles.delete(filePath); + this.events$.next({ + type: wasDirectory ? 'unlinkDir' : 'unlink', + path: filePath + }); + } + } catch (error: any) { + this.events$.next({ type: 'error', path: filePath, error }); + } + } + + /** + * Wait for file write to complete (polling-based) + */ + private async waitForWriteFinish(filePath: string): Promise { + return new Promise((resolve) => { + let lastSize = -1; + let lastChange = Date.now(); + + const poll = async () => { + try { + const stats = await this.statSafe(filePath); + if (!stats) { + resolve(); + return; + } + + const now = Date.now(); + if (stats.size !== lastSize) { + lastSize = stats.size; + lastChange = now; + this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval)); + } else if (now - lastChange >= this.options.stabilityThreshold) { + this.pendingWrites.delete(filePath); + resolve(); + } else { + this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval)); + } + } catch { + this.pendingWrites.delete(filePath); + resolve(); + } + }; + + this.pendingWrites.set(filePath, setTimeout(poll, this.options.pollInterval)); + }); + } + + /** + * Scan directory and emit 'add' events for existing files + */ + private async scanDirectory(dirPath: string, depth: number): Promise { + if (depth > this.options.depth) { + return; + } + + try { + for await (const entry of Deno.readDir(dirPath)) { + const fullPath = `${dirPath}/${entry.name}`; + const stats = await this.statSafe(fullPath); + + if (!stats) { + continue; + } + + if (entry.isDirectory) { + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'addDir', path: fullPath, stats }); + await this.scanDirectory(fullPath, depth + 1); + } else if (entry.isFile) { + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'add', path: fullPath, stats }); + } + } + } catch (error: any) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') { + this.events$.next({ type: 'error', path: dirPath, error }); + } + } + } + + /** + * Safely stat a path, returning null if it doesn't exist + */ + private async statSafe(filePath: string): Promise { + try { + const statFn = this.options.followSymlinks ? Deno.stat : Deno.lstat; + const denoStats = await statFn(filePath); + return denoStatToNodeStats(denoStats); + } catch { + return null; + } + } + + /** + * Check if a path was known to be a directory + */ + private isKnownDirectory(filePath: string): boolean { + for (const watched of this.watchedFiles) { + if (watched.startsWith(filePath + '/')) { + return true; + } + } + return false; + } + + /** + * Throttle duplicate events + */ + private shouldEmit(filePath: string, eventType: string): boolean { + const key = `${filePath}:${eventType}`; + const now = Date.now(); + const lastEmit = this.recentEvents.get(key); + + if (lastEmit && now - lastEmit < this.throttleMs) { + return false; + } + + this.recentEvents.set(key, now); + + // Clean up old entries periodically + if (this.recentEvents.size > 1000) { + const cutoff = now - this.throttleMs * 2; + for (const [k, time] of this.recentEvents) { + if (time < cutoff) { + this.recentEvents.delete(k); + } + } + } + + return true; + } +} diff --git a/ts/watchers/watcher.node.ts b/ts/watchers/watcher.node.ts new file mode 100644 index 0000000..376a7ee --- /dev/null +++ b/ts/watchers/watcher.node.ts @@ -0,0 +1,281 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as smartrx from '@push.rocks/smartrx'; +import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js'; +import { WriteStabilizer } from '../utils/write-stabilizer.js'; + +/** + * Node.js/Bun file watcher using native fs.watch API + */ +export class NodeWatcher implements IWatcher { + private watchers: Map = new Map(); + private watchedFiles: Set = new Set(); + private _isWatching = false; + private writeStabilizer: WriteStabilizer; + private recentEvents: Map = new Map(); + private throttleMs = 50; + + public readonly events$ = new smartrx.rxjs.Subject(); + + constructor(private options: IWatcherOptions) { + this.writeStabilizer = new WriteStabilizer( + options.stabilityThreshold, + options.pollInterval + ); + } + + get isWatching(): boolean { + return this._isWatching; + } + + async start(): Promise { + if (this._isWatching) { + return; + } + + try { + // Start watching each base path + for (const basePath of this.options.basePaths) { + await this.watchPath(basePath, 0); + } + + this._isWatching = true; + + // Perform initial scan to emit 'add' events for existing files + for (const basePath of this.options.basePaths) { + await this.scanDirectory(basePath, 0); + } + + // Emit ready event + this.events$.next({ type: 'ready', path: '' }); + } catch (error: any) { + this.events$.next({ type: 'error', path: '', error }); + throw error; + } + } + + async stop(): Promise { + this._isWatching = false; + this.writeStabilizer.cancelAll(); + + // Close all watchers + for (const [watchPath, watcher] of this.watchers) { + watcher.close(); + } + this.watchers.clear(); + this.watchedFiles.clear(); + this.recentEvents.clear(); + } + + /** + * Start watching a path (file or directory) + */ + private async watchPath(watchPath: string, depth: number): Promise { + if (depth > this.options.depth) { + return; + } + + try { + const stats = await this.statSafe(watchPath); + if (!stats) { + return; + } + + if (stats.isDirectory()) { + // Watch the directory with recursive option (Node.js 20+ supports this on all platforms) + const watcher = fs.watch( + watchPath, + { recursive: true, persistent: true }, + (eventType, filename) => { + if (filename) { + this.handleFsEvent(watchPath, filename, eventType); + } + } + ); + + watcher.on('error', (error) => { + this.events$.next({ type: 'error', path: watchPath, error }); + }); + + this.watchers.set(watchPath, watcher); + } + } catch (error: any) { + this.events$.next({ type: 'error', path: watchPath, error }); + } + } + + /** + * Handle raw fs.watch events and normalize them + */ + private async handleFsEvent( + basePath: string, + filename: string, + eventType: 'rename' | 'change' | string + ): Promise { + const fullPath = path.join(basePath, filename); + + // Throttle duplicate events + if (!this.shouldEmit(fullPath, eventType)) { + return; + } + + try { + const stats = await this.statSafe(fullPath); + + if (eventType === 'rename') { + // 'rename' can mean add or unlink - check if file exists + if (stats) { + // File exists - it's either a new file or was renamed to this location + if (stats.isDirectory()) { + if (!this.watchedFiles.has(fullPath)) { + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'addDir', path: fullPath, stats }); + } + } else { + // Wait for write to stabilize before emitting + try { + const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath); + const wasWatched = this.watchedFiles.has(fullPath); + this.watchedFiles.add(fullPath); + this.events$.next({ + type: wasWatched ? 'change' : 'add', + path: fullPath, + stats: stableStats + }); + } catch { + // File was deleted during stabilization + if (this.watchedFiles.has(fullPath)) { + this.watchedFiles.delete(fullPath); + this.events$.next({ type: 'unlink', path: fullPath }); + } + } + } + } else { + // File doesn't exist - it was deleted + if (this.watchedFiles.has(fullPath)) { + const wasDir = this.isKnownDirectory(fullPath); + this.watchedFiles.delete(fullPath); + this.events$.next({ + type: wasDir ? 'unlinkDir' : 'unlink', + path: fullPath + }); + } + } + } else if (eventType === 'change') { + // File was modified + if (stats && !stats.isDirectory()) { + try { + const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath); + // Check if this is a new file (not yet in watchedFiles) + const wasWatched = this.watchedFiles.has(fullPath); + if (!wasWatched) { + // This is actually an 'add' - file wasn't being watched before + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'add', path: fullPath, stats: stableStats }); + } else { + this.events$.next({ type: 'change', path: fullPath, stats: stableStats }); + } + } catch { + // File was deleted during write + if (this.watchedFiles.has(fullPath)) { + this.watchedFiles.delete(fullPath); + this.events$.next({ type: 'unlink', path: fullPath }); + } + } + } + } + } catch (error: any) { + this.events$.next({ type: 'error', path: fullPath, error }); + } + } + + /** + * Scan directory and emit 'add' events for existing files + */ + private async scanDirectory(dirPath: string, depth: number): Promise { + if (depth > this.options.depth) { + return; + } + + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const stats = await this.statSafe(fullPath); + + if (!stats) { + continue; + } + + if (entry.isDirectory()) { + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'addDir', path: fullPath, stats }); + await this.scanDirectory(fullPath, depth + 1); + } else if (entry.isFile()) { + this.watchedFiles.add(fullPath); + this.events$.next({ type: 'add', path: fullPath, stats }); + } + } + } catch (error: any) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') { + this.events$.next({ type: 'error', path: dirPath, error }); + } + } + } + + /** + * Safely stat a path, returning null if it doesn't exist + */ + private async statSafe(filePath: string): Promise { + try { + if (this.options.followSymlinks) { + return await fs.promises.stat(filePath); + } else { + return await fs.promises.lstat(filePath); + } + } catch { + return null; + } + } + + /** + * Check if a path was known to be a directory (for proper unlink event type) + */ + private isKnownDirectory(filePath: string): boolean { + // Check if any watched files are children of this path + for (const watched of this.watchedFiles) { + if (watched.startsWith(filePath + path.sep)) { + return true; + } + } + return false; + } + + /** + * Throttle duplicate events + */ + private shouldEmit(filePath: string, eventType: string): boolean { + const key = `${filePath}:${eventType}`; + const now = Date.now(); + const lastEmit = this.recentEvents.get(key); + + if (lastEmit && now - lastEmit < this.throttleMs) { + return false; + } + + this.recentEvents.set(key, now); + + // Clean up old entries periodically + if (this.recentEvents.size > 1000) { + const cutoff = now - this.throttleMs * 2; + for (const [k, time] of this.recentEvents) { + if (time < cutoff) { + this.recentEvents.delete(k); + } + } + } + + return true; + } +}