Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5b4fedf9f | |||
| b84a50f87d | |||
| 2ae297e5fa | |||
| 9130613941 | |||
| fdd6281f0b | |||
| 0f17be179c |
29
changelog.md
29
changelog.md
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-11-30 - 4.0.1 - fix(readme)
|
||||
Update README: refine description and clarify trademark/legal information
|
||||
|
||||
- Refined project description and tagline for clarity and brevity (adds lightweight wording and an emoji).
|
||||
- Updated Trademark section to explicitly mention third-party trademarks, add guidance about usage and approval, and clarify that trademarks are not covered by the MIT license.
|
||||
- Minor legal/company wording and formatting fixes (e.g. 'District Court' capitalization and contact sentence tweaks).
|
||||
- General README wording and formatting improvements for clearer instructions and feature descriptions.
|
||||
|
||||
## 2025-11-30 - 4.0.0 - BREAKING CHANGE(watchers)
|
||||
Replace chokidar with native platform watchers and add cross-runtime support (Node.js, Deno, Bun); introduce write stabilization and internal glob matching
|
||||
|
||||
- Replaced chokidar-based implementation with native file watching APIs (Node.js fs.watch, Deno.watchFs).
|
||||
- Added platform-specific watchers: NodeWatcher and DenoWatcher (Bun uses Node compatibility).
|
||||
- Implemented polling-based write stabilization (awaitWriteFinish replacement) to avoid duplicate events during writes.
|
||||
- Keep glob pattern support by matching events internally using picomatch; base-path extraction used to limit watch scope.
|
||||
- API/runtime requirement increased: Node.js >= 20.0.0 is required for native recursive fs.watch.
|
||||
- Package/documentation name and examples updated to @push.rocks/smartchok and export the Smartwatch class.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
package.json
16
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartchok",
|
||||
"version": "1.2.0",
|
||||
"version": "4.0.1",
|
||||
"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"
|
||||
}
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -1,33 +1,89 @@
|
||||
# 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.
|
||||
|
||||
### Exported Class
|
||||
|
||||
The package exports the `Smartwatch` class (not `Smartchok`):
|
||||
```typescript
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
140
readme.md
140
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartchok
|
||||
|
||||
A smart wrapper for chokidar 5.x with glob pattern support, RxJS observable integration, and enhanced file watching features.
|
||||
A lightweight, cross-runtime file watcher with glob pattern support for **Node.js**, **Deno**, and **Bun**. Zero heavyweight dependencies — just native file watching APIs for maximum performance. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -16,21 +16,24 @@ pnpm add @push.rocks/smartchok
|
||||
|
||||
## Features
|
||||
|
||||
🔍 **Glob Pattern Support** - Watch files using glob patterns like `**/*.ts` or `src/**/*.js`
|
||||
📡 **RxJS Observables** - Subscribe to file system events using reactive streams
|
||||
🔄 **Dynamic Watching** - Add or remove watch patterns at runtime
|
||||
⚡ **Chokidar 5.x** - Built on the latest chokidar with improved performance
|
||||
🎯 **TypeScript First** - Full TypeScript support with comprehensive type definitions
|
||||
🌐 **Cross-Runtime** — Works seamlessly on Node.js 20+, Deno, and Bun
|
||||
🔍 **Glob Pattern Support** — Watch files using familiar patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||
📡 **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||
🔄 **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||
⚡ **Native Performance** — Uses `fs.watch()` on Node.js/Bun and `Deno.watchFs()` on Deno
|
||||
✨ **Write Stabilization** — Built-in debouncing prevents duplicate events during file writes
|
||||
🎯 **TypeScript First** — Full TypeScript support with comprehensive type definitions
|
||||
📦 **Minimal Footprint** — No chokidar, no FSEvents bindings — just ~500 lines of focused code
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
import { Smartchok } from '@push.rocks/smartchok';
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
|
||||
// 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
|
||||
]);
|
||||
@@ -50,6 +53,7 @@ const changeObservable = await watcher.getObservableFor('change');
|
||||
changeObservable.subscribe({
|
||||
next: ([path, stats]) => {
|
||||
console.log(`File changed: ${path}`);
|
||||
console.log(`New size: ${stats?.size} bytes`);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Error: ${err}`);
|
||||
@@ -71,23 +75,22 @@ unlinkObservable.subscribe(([path]) => {
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `add` | File has been added |
|
||||
| `addDir` | Directory has been added |
|
||||
| `change` | File has been changed |
|
||||
| `unlink` | File has been removed |
|
||||
| `unlinkDir` | Directory has been removed |
|
||||
| `error` | Error occurred |
|
||||
| `ready` | Initial scan complete |
|
||||
| `raw` | Raw event from the underlying watcher |
|
||||
| Event | Description |
|
||||
|-------------|--------------------------------|
|
||||
| `add` | File has been added |
|
||||
| `addDir` | Directory has been added |
|
||||
| `change` | File has been modified |
|
||||
| `unlink` | File has been removed |
|
||||
| `unlinkDir` | Directory has been removed |
|
||||
| `error` | Error occurred |
|
||||
| `ready` | Initial scan complete |
|
||||
|
||||
### Dynamic Watch Management
|
||||
|
||||
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 +110,11 @@ await watcher.stop();
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
import { Smartchok } from '@push.rocks/smartchok';
|
||||
import { Smartwatch } from '@push.rocks/smartchok';
|
||||
|
||||
async function watchProject() {
|
||||
// Initialize with patterns
|
||||
const watcher = new Smartchok([
|
||||
const watcher = new Smartwatch([
|
||||
'./src/**/*.ts',
|
||||
'./package.json'
|
||||
]);
|
||||
@@ -152,56 +155,107 @@ 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.
|
||||
smartchok uses native file watching APIs for each runtime:
|
||||
|
||||
When you provide glob patterns:
|
||||
1. **Base path extraction** - smartchok 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
|
||||
| Runtime | API Used |
|
||||
|-----------------|----------------------------------|
|
||||
| **Node.js 20+** | `fs.watch({ recursive: true })` |
|
||||
| **Deno** | `Deno.watchFs()` |
|
||||
| **Bun** | Node.js compatibility layer |
|
||||
|
||||
### Under the Hood
|
||||
|
||||
Native file watching APIs don't support glob patterns directly, so smartchok handles pattern matching internally:
|
||||
|
||||
1. **Base path extraction** — Extracts the static portion from each glob pattern (e.g., `./src/` from `./src/**/*.ts`)
|
||||
2. **Efficient watching** — Native watchers monitor only the base directories
|
||||
3. **Pattern filtering** — Events are filtered through [picomatch](https://github.com/micromatch/picomatch) matchers before emission
|
||||
4. **Event deduplication** — Built-in throttling prevents duplicate events from rapid file operations
|
||||
|
||||
### Write Stabilization
|
||||
|
||||
smartchok includes built-in write stabilization (similar to chokidar's `awaitWriteFinish`). When a file is being written, events are held until the file size stabilizes, preventing multiple events for a single write operation.
|
||||
|
||||
Default settings:
|
||||
- **Stability threshold**: 300ms
|
||||
- **Poll interval**: 100ms
|
||||
|
||||
## Requirements
|
||||
|
||||
| Runtime | Version |
|
||||
|-----------------|----------------------------------------|
|
||||
| **Node.js** | 20+ (required for native recursive watching) |
|
||||
| **Deno** | Any version with `Deno.watchFs()` support |
|
||||
| **Bun** | Uses Node.js compatibility |
|
||||
|
||||
## 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.
|
||||
|
||||
**Parameters:**
|
||||
- `patterns` — Array of glob patterns to watch (e.g., `['./src/**/*.ts', './config/*.json']`)
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||
| `stop()` | `Promise<void>` | Stops the file watcher |
|
||||
| `add(patterns: string[])` | `void` | Adds patterns to watch |
|
||||
| `remove(pattern: string)` | `void` | Removes a pattern from watching |
|
||||
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||
| Method | Returns | Description |
|
||||
|----------------------------------------|------------------------------------------------|------------------------------------------------|
|
||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||
| `stop()` | `Promise<void>` | Stops the file watcher and cleans up resources |
|
||||
| `add(patterns: string[])` | `void` | Adds additional patterns to watch |
|
||||
| `remove(pattern: string)` | `void` | Removes a pattern from the watch list |
|
||||
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||
| Property | Type | Description |
|
||||
|----------|-------------------------------------------|--------------------------|
|
||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
type TFsEvent = 'add' | 'addDir' | 'change' | 'error' | 'unlink' | 'unlinkDir' | 'ready' | 'raw';
|
||||
type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||
```
|
||||
|
||||
## Why smartchok?
|
||||
|
||||
| Feature | smartchok | chokidar |
|
||||
|-------------------------|----------------------|--------------------|
|
||||
| Native API | ✅ Direct `fs.watch` | ❌ FSEvents bindings |
|
||||
| Cross-runtime | ✅ Node, Deno, Bun | ❌ Node only |
|
||||
| Dependencies | 4 small packages | ~20 packages |
|
||||
| Write stabilization | ✅ Built-in | ✅ Built-in |
|
||||
| Glob support | ✅ picomatch | ✅ anymatch |
|
||||
| Bundle size | ~15KB | ~200KB+ |
|
||||
|
||||
If you need a lightweight file watcher without native compilation headaches, smartchok has you covered.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
18
test/test.ts
18
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();
|
||||
|
||||
@@ -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: '4.0.1',
|
||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './smartchok.classes.smartchok.js';
|
||||
export * from './smartwatch.classes.smartwatch.js';
|
||||
|
||||
@@ -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<string, (path: string) => boolean> = new Map();
|
||||
private watchingDeferred = plugins.smartpromise.defer<void>(); // used to run things when watcher is initialized
|
||||
private eventObservablemap = new plugins.smartrx.Observablemap(); // register one observable per event
|
||||
private watchingDeferred = plugins.smartpromise.defer<void>();
|
||||
|
||||
// Event subjects for each event type
|
||||
private eventSubjects: Map<TFsEvent, plugins.smartrx.rxjs.Subject<[string, plugins.fs.Stats | undefined]>> = 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<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
|
||||
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
|
||||
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<void>
|
||||
*/
|
||||
public start(): Promise<void> {
|
||||
const done = plugins.smartpromise.defer<void>();
|
||||
public async start(): Promise<void> {
|
||||
this.status = 'starting';
|
||||
|
||||
|
||||
// Store original glob patterns and create matchers
|
||||
this.globPatterns = this.watchStringmap.getStringArray();
|
||||
const basePaths = new Set<string>();
|
||||
|
||||
|
||||
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)) {
|
||||
@@ -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,
|
||||
}
|
||||
97
ts/utils/write-stabilizer.ts
Normal file
97
ts/utils/write-stabilizer.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface IPendingWrite {
|
||||
lastSize: number;
|
||||
lastChange: number;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
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<string, IPendingWrite>();
|
||||
|
||||
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<fs.Stats> {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
33
ts/watchers/index.ts
Normal file
33
ts/watchers/index.ts
Normal file
@@ -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<IWatcher> {
|
||||
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
|
||||
};
|
||||
47
ts/watchers/interfaces.ts
Normal file
47
ts/watchers/interfaces.ts
Normal file
@@ -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<void>;
|
||||
/** Stop watching and clean up */
|
||||
stop(): Promise<void>;
|
||||
/** Whether the watcher is currently active */
|
||||
readonly isWatching: boolean;
|
||||
/** Subject that emits watch events */
|
||||
readonly events$: smartrx.rxjs.Subject<IWatchEvent>;
|
||||
}
|
||||
329
ts/watchers/watcher.deno.ts
Normal file
329
ts/watchers/watcher.deno.ts
Normal file
@@ -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<ReturnType<typeof Deno.stat>>): 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<typeof Deno.watchFs> | null = null;
|
||||
private watchedFiles: Set<string> = new Set();
|
||||
private _isWatching = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private recentEvents: Map<string, number> = new Map();
|
||||
private throttleMs = 50;
|
||||
private pendingWrites: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<any | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
281
ts/watchers/watcher.node.ts
Normal file
281
ts/watchers/watcher.node.ts
Normal file
@@ -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<string, fs.FSWatcher> = new Map();
|
||||
private watchedFiles: Set<string> = new Set();
|
||||
private _isWatching = false;
|
||||
private writeStabilizer: WriteStabilizer;
|
||||
private recentEvents: Map<string, number> = new Map();
|
||||
private throttleMs = 50;
|
||||
|
||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||
|
||||
constructor(private options: IWatcherOptions) {
|
||||
this.writeStabilizer = new WriteStabilizer(
|
||||
options.stabilityThreshold,
|
||||
options.pollInterval
|
||||
);
|
||||
}
|
||||
|
||||
get isWatching(): boolean {
|
||||
return this._isWatching;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<fs.Stats | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user