Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdd6281f0b | |||
| 0f17be179c | |||
| aab3ce213b | |||
| 6d02ef3ddc | |||
| 21827f06e9 | |||
| b5133c2a9c | |||
| 294420e0b2 | |||
| d7a05abfa2 | |||
| 3444b7c74d |
111
changelog.md
Normal file
111
changelog.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- Upgrade runtime dependencies: chokidar -> ^5.0.0 and picomatch -> ^4.0.3; bumped related @push.rocks packages versions.
|
||||||
|
- Upgrade devDependencies: @git.zone/tsbuild, @git.zone/tsrun and @git.zone/tstest to newer v2/v3 releases; updated @types/node.
|
||||||
|
- Updated README and readme.hints to document migration to chokidar 5.x and dev dependency changes.
|
||||||
|
- Tests updated to use @git.zone/tstest/tapbundle (import change) and test runner start changed to export default tap.start().
|
||||||
|
- Smartchok implementation updated to extract glob base paths, watch base directories and filter events via picomatch matchers (shouldWatchPath + event filtering).
|
||||||
|
- Note: ts/00_commitinfo_data.ts still references chokidar 4.x in the description and should be updated to reflect the migration.
|
||||||
|
|
||||||
|
## 2025-06-26 - 1.1.1 - fix(package.json)
|
||||||
|
Add packageManager field to package.json for pnpm configuration
|
||||||
|
|
||||||
|
- Added "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" to package.json
|
||||||
|
|
||||||
|
## 2025-06-26 - 1.1.0 - feat(Smartchok)
|
||||||
|
Migrate to chokidar 4.x with picomatch glob support, update documentation and tests
|
||||||
|
|
||||||
|
- Removed deprecated @tempfix/watcher and added chokidar and picomatch as dependencies in package.json
|
||||||
|
- Updated Smartchok class to extract base paths and apply custom glob filtering using picomatch
|
||||||
|
- Revised readme and technical hints to reflect migration to chokidar 4.x and clarify glob pattern support
|
||||||
|
- Adjusted tests and commit info to align with the updated code structure
|
||||||
|
|
||||||
|
## 2024-05-29 – 1.0.34 – general
|
||||||
|
This release improves the project description.
|
||||||
|
- update description
|
||||||
|
|
||||||
|
## 2024-05-06 – 1.0.33 – core
|
||||||
|
This release includes a mix of bug fixes and configuration updates.
|
||||||
|
- fix(core): update
|
||||||
|
- update tsconfig
|
||||||
|
- update npmextra.json: githost (recorded multiple times)
|
||||||
|
|
||||||
|
## 2024-02-29 – 1.0.32 to 1.0.28 – core fixes
|
||||||
|
Releases 1.0.32 through 1.0.28 were dedicated to routine core fixes.
|
||||||
|
(This range covers versions that only included “fix(core): update” changes.)
|
||||||
|
|
||||||
|
## 2024-01-28 – 1.0.27 – core
|
||||||
|
This release not only fixed core issues but also adjusted the organization scheme.
|
||||||
|
- fix(core): update
|
||||||
|
- switch to new org scheme (recorded twice)
|
||||||
|
|
||||||
|
## 2021-12-01 – 1.0.26 to 1.0.14 – core fixes
|
||||||
|
Releases 1.0.26 through 1.0.14 were devoted to routine core fixes.
|
||||||
|
(No additional details beyond the core update were recorded.)
|
||||||
|
|
||||||
|
## 2018-02-28 – 1.0.13 to 1.0.11 – ci updates
|
||||||
|
Releases 1.0.13 through 1.0.11 focused on updating the continuous integration configuration.
|
||||||
|
- update ci
|
||||||
|
|
||||||
|
## 2017-06-30 – 1.0.10 – general
|
||||||
|
This release delivered several improvements beyond a simple version bump.
|
||||||
|
- fix Promise issues
|
||||||
|
- update test
|
||||||
|
- update
|
||||||
|
|
||||||
|
## 2017-06-30 – 1.0.9 – general
|
||||||
|
This release addressed module loading and code hygiene.
|
||||||
|
- fix loading of rxjs
|
||||||
|
- clean up code
|
||||||
|
|
||||||
|
## 2017-06-30 – 1.0.8 – general
|
||||||
|
A targeted update to align output with expectations.
|
||||||
|
- update to wirj like expected
|
||||||
|
|
||||||
|
## 2017-04-09 – 1.0.7 – ci
|
||||||
|
An update to the continuous integration configuration.
|
||||||
|
- update ci
|
||||||
|
|
||||||
|
## 2017-04-09 – 1.0.6 – npm
|
||||||
|
This release updated extra npm configuration.
|
||||||
|
- update npmextra.json
|
||||||
|
|
||||||
|
## 2017-02-15 – 1.0.5 – general
|
||||||
|
Standardization work was undertaken with new organizational practices.
|
||||||
|
- update to new gitzone standard
|
||||||
|
|
||||||
|
## 2016-11-18 – 1.0.4 – general
|
||||||
|
This release refreshed dependency settings.
|
||||||
|
- update dependencies
|
||||||
|
|
||||||
|
## 2016-11-18 – 1.0.3 – general
|
||||||
|
Readability and developer guidance were improved.
|
||||||
|
- improve README
|
||||||
|
|
||||||
|
## 2016-11-18 – 1.0.2 – general
|
||||||
|
Minor documentation and CI configuration enhancements were added.
|
||||||
|
- add README
|
||||||
|
- Update .gitlab-ci.yml
|
||||||
|
|
||||||
|
## 2016-09-22 – 1.0.1 – general
|
||||||
|
A fix was applied to ensure the process exits correctly.
|
||||||
|
- fix process not exiting problem
|
||||||
|
|
||||||
|
## 2016-09-22 – 1.0.0 – general
|
||||||
|
The project’s initial setup was established along with CI configuration.
|
||||||
|
- add gitlab-ci
|
||||||
|
- initial
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
"module": {
|
"module": {
|
||||||
"githost": "code.foss.global",
|
"githost": "code.foss.global",
|
||||||
"gitscope": "push.rocks",
|
"gitscope": "push.rocks",
|
||||||
"gitrepo": "smartchok",
|
"gitrepo": "smartwatch",
|
||||||
"description": "A smart wrapper for chokidar to facilitate file watching with enhanced features.",
|
"description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced file watching features.",
|
||||||
"npmPackagename": "@push.rocks/smartchokidar",
|
"npmPackagename": "@push.rocks/smartwatch",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"file watching",
|
"file watching",
|
||||||
|
|||||||
39
package.json
39
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartchok",
|
"name": "@push.rocks/smartchok",
|
||||||
"version": "1.0.34",
|
"version": "3.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A smart wrapper for chokidar to facilitate file watching with enhanced features.",
|
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,27 +13,30 @@
|
|||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://gitlab.com/push.rocks/smartchok.git"
|
"url": "https://code.foss.global/push.rocks/smartchok.git"
|
||||||
},
|
},
|
||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://gitlab.com/push.rocks/smartchok#readme",
|
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/lik": "^6.0.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartpromise": "^4.0.2",
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
"@push.rocks/smartrx": "^3.0.2",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@tempfix/watcher": "^2.3.0"
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.66",
|
"@git.zone/tsbuild": "^3.1.2",
|
||||||
"@git.zone/tsrun": "^1.2.44",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tstest": "^1.0.77",
|
"@git.zone/tstest": "^3.1.3",
|
||||||
"@push.rocks/smartfile": "^11.0.4",
|
"@push.rocks/smartfile": "^11.0.4",
|
||||||
"@push.rocks/tapbundle": "^5.0.8",
|
"@types/node": "^24.10.1"
|
||||||
"@types/node": "^20.11.8"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
@@ -53,14 +56,18 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"file watching",
|
"file watching",
|
||||||
"chokidar",
|
|
||||||
"filesystem",
|
"filesystem",
|
||||||
"observable",
|
"observable",
|
||||||
"typescript",
|
"typescript",
|
||||||
"node.js",
|
"node.js",
|
||||||
|
"deno",
|
||||||
|
"bun",
|
||||||
|
"cross-runtime",
|
||||||
"development tool",
|
"development tool",
|
||||||
"file system events",
|
"file system events",
|
||||||
"real-time",
|
"real-time",
|
||||||
"watch files"
|
"watch files",
|
||||||
]
|
"glob patterns"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||||
}
|
}
|
||||||
|
|||||||
13225
pnpm-lock.yaml
generated
13225
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +1,82 @@
|
|||||||
|
# smartchok - Technical Hints
|
||||||
|
|
||||||
|
## Native File Watching (v2.0.0+)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- **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?
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|||||||
224
readme.md
224
readme.md
@@ -1,117 +1,191 @@
|
|||||||
# @push.rocks/smartchok
|
# @push.rocks/smartwatch
|
||||||
smart wrapper for chokidar
|
|
||||||
|
A smart wrapper for chokidar 5.x with glob pattern support, RxJS observable integration, and enhanced file watching features.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Install the package by running the following command in your terminal:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install @push.rocks/smartchok --save
|
npm install @push.rocks/smartwatch
|
||||||
|
# or
|
||||||
|
pnpm add @push.rocks/smartwatch
|
||||||
```
|
```
|
||||||
|
|
||||||
This command adds `@push.rocks/smartchok` to your project's dependencies, ensuring that your project can use its functionality and that it will be installed when running `npm install` in your project root.
|
## 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
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The `@push.rocks/smartchok` package provides a convenient and smart wrapper around the popular `chokidar` library for file watching with enhanced features such as observable support for filesystem events. This guide will introduce you to the usage of `@push.rocks/smartchok`, leveraging TypeScript for type safety and better developer experience.
|
|
||||||
|
|
||||||
### Setting Up Your Project
|
|
||||||
|
|
||||||
To use `@push.rocks/smartchok`, ensure your project is set up to use TypeScript and ECMAScript modules (ESM). You need to have a `tsconfig.json` file at the root of your project with the following minimum settings:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "esnext",
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"declaration": true,
|
|
||||||
"outDir": "./dist",
|
|
||||||
"strict": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This configuration enables TypeScript compilation targeting the latest ECMAScript standards and includes all TypeScript files (`*.ts`) in your `src` directory.
|
|
||||||
|
|
||||||
### Basic Setup
|
### Basic Setup
|
||||||
|
|
||||||
To start using `smartchok`, first import it into your TypeScript file:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Smartchok } from '@push.rocks/smartchok';
|
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||||
```
|
|
||||||
|
|
||||||
#### Initializing Smartchok
|
// Create a watcher with glob patterns
|
||||||
|
const watcher = new Smartwatch([
|
||||||
Create an instance of `Smartchok` by specifying an array of glob patterns to watch:
|
'./src/**/*.ts', // Watch all TypeScript files in src
|
||||||
|
'./public/assets/**/*' // Watch all files in public/assets
|
||||||
```typescript
|
|
||||||
const smartchokInstance = new Smartchok([
|
|
||||||
'./src/**/*.ts', // Watch all TypeScript files in the src directory
|
|
||||||
'./public/assets/**/*' // Watch all files in the public/assets directory
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Start watching
|
||||||
|
await watcher.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Adding and Removing Files Dynamically
|
### Subscribing to File Events
|
||||||
|
|
||||||
You can dynamically add and remove paths from being watched by using the `add` and `remove` methods:
|
Use RxJS observables to react to file system changes:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Add additional files or patterns
|
// Get an observable for file changes
|
||||||
smartchokInstance.add(['./tests/**/*.spec.ts']);
|
const changeObservable = await watcher.getObservableFor('change');
|
||||||
|
|
||||||
// Remove specific patterns from watch
|
|
||||||
smartchokInstance.remove('./src/**/*.test.ts');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Handling Filesystem Events
|
|
||||||
|
|
||||||
`smartchok` leverages RxJS observables to notify about filesystem events. This allows you to respond to various events such as file additions, changes, and deletions with ease.
|
|
||||||
|
|
||||||
First, start the watcher:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await smartchokInstance.start();
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, subscribe to an event:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const changeObservable = await smartchokInstance.getObservableFor('change');
|
|
||||||
|
|
||||||
changeObservable.subscribe({
|
changeObservable.subscribe({
|
||||||
next: ([path, stats]) => {
|
next: ([path, stats]) => {
|
||||||
console.log(`File changed: ${path}`);
|
console.log(`File changed: ${path}`);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error(`An error occurred: ${err}`);
|
console.error(`Error: ${err}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for new files
|
||||||
|
const addObservable = await watcher.getObservableFor('add');
|
||||||
|
addObservable.subscribe(([path]) => {
|
||||||
|
console.log(`File added: ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for deleted files
|
||||||
|
const unlinkObservable = await watcher.getObservableFor('unlink');
|
||||||
|
unlinkObservable.subscribe(([path]) => {
|
||||||
|
console.log(`File deleted: ${path}`);
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported events include 'add', 'change', 'unlink' (for deletions), and others. Refer to the chokidar documentation for a full list of events.
|
### Supported Events
|
||||||
|
|
||||||
#### Stopping the Watcher
|
| 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 |
|
||||||
|
|
||||||
To stop watching for file changes, simply call:
|
### Dynamic Watch Management
|
||||||
|
|
||||||
|
Add or remove patterns while the watcher is running:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await smartchokInstance.stop();
|
const watcher = new Smartwatch(['./src/**/*.ts']);
|
||||||
|
await watcher.start();
|
||||||
|
|
||||||
|
// Add more patterns to watch
|
||||||
|
watcher.add(['./tests/**/*.spec.ts', './config/*.json']);
|
||||||
|
|
||||||
|
// Remove a pattern
|
||||||
|
watcher.remove('./src/**/*.test.ts');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Usage
|
### Stopping the Watcher
|
||||||
|
|
||||||
Beyond the basics, `smartchok` allows for more complex monitoring scenarios, such as debounced notifications for rapid changes, filtering events, and integrating with other observables for complex asynchronous workflows.
|
```typescript
|
||||||
|
// Stop watching when done
|
||||||
|
await watcher.stop();
|
||||||
|
```
|
||||||
|
|
||||||
### Conclusion
|
### Complete Example
|
||||||
|
|
||||||
`@push.rocks/smartchok` provides a robust, observable-based wrapper around `chokidar`, making it an excellent choice for projects requiring efficient and flexible file monitoring. Its integration with RxJS opens up a wide array of possibilities for handling file system events in a reactive manner, making your code more concise, readable, and maintainable.
|
```typescript
|
||||||
|
import { Smartwatch } from '@push.rocks/smartwatch';
|
||||||
|
|
||||||
By following the guidelines provided in this document, you should now be equipped to integrate `@push.rocks/smartchok` into your TypeScript project, enhancing its capabilities with efficient file system monitoring.
|
async function watchProject() {
|
||||||
|
// Initialize with patterns
|
||||||
|
const watcher = new Smartwatch([
|
||||||
|
'./src/**/*.ts',
|
||||||
|
'./package.json'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Start the watcher
|
||||||
|
await watcher.start();
|
||||||
|
console.log('👀 Watching for changes...');
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
const changes = await watcher.getObservableFor('change');
|
||||||
|
changes.subscribe(([path, stats]) => {
|
||||||
|
console.log(`📝 Modified: ${path}`);
|
||||||
|
console.log(` Size: ${stats?.size ?? 'unknown'} bytes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to new files
|
||||||
|
const additions = await watcher.getObservableFor('add');
|
||||||
|
additions.subscribe(([path]) => {
|
||||||
|
console.log(`✨ New file: ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to deletions
|
||||||
|
const deletions = await watcher.getObservableFor('unlink');
|
||||||
|
deletions.subscribe(([path]) => {
|
||||||
|
console.log(`🗑️ Deleted: ${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('\n🛑 Stopping watcher...');
|
||||||
|
await watcher.stop();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watchProject();
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
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** - 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
|
||||||
|
|
||||||
|
### `Smartwatch`
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new Smartwatch(patterns: string[])
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a new Smartwatch instance with the given glob patterns.
|
||||||
|
|
||||||
|
#### 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 |
|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
70
readme.plan.md
Normal file
70
readme.plan.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Migration Plan: smartchok to Chokidar 4.x
|
||||||
|
|
||||||
|
Command to reread CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
|
||||||
|
|
||||||
|
## MIGRATION COMPLETED ✅
|
||||||
|
|
||||||
|
All phases of the migration have been successfully completed:
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
- **Current dependency**: `@tempfix/watcher` v2.3.0 (a fork of fabiospampinato/watcher)
|
||||||
|
- **Target**: Chokidar v4.0.3
|
||||||
|
- **Major challenge**: Chokidar 4.x removed glob support, but smartchok heavily uses glob patterns
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
### Phase 1: Preparation
|
||||||
|
1. Install chokidar 4.x and glob library for pattern matching
|
||||||
|
- `pnpm install chokidar@^4.0.3`
|
||||||
|
- `pnpm install picomatch` (for glob pattern matching)
|
||||||
|
- `pnpm uninstall @tempfix/watcher`
|
||||||
|
|
||||||
|
### Phase 2: Code Changes
|
||||||
|
|
||||||
|
#### 2.1 Update Plugin Imports (ts/smartchok.plugins.ts)
|
||||||
|
- Remove `@tempfix/watcher` import
|
||||||
|
- Add `chokidar` import
|
||||||
|
- Add `picomatch` for glob pattern matching
|
||||||
|
|
||||||
|
#### 2.2 Update Smartchok Class (ts/smartchok.classes.smartchok.ts)
|
||||||
|
- Replace watcher initialization with chokidar
|
||||||
|
- Implement custom glob filtering using picomatch
|
||||||
|
- Update event mapping to match chokidar's event names
|
||||||
|
- Adjust watcher options to match chokidar's API
|
||||||
|
|
||||||
|
#### 2.3 Handle Glob Patterns
|
||||||
|
Since chokidar 4.x removed glob support, we need to:
|
||||||
|
- Parse glob patterns to extract base directories
|
||||||
|
- Use chokidar to watch base directories
|
||||||
|
- Use picomatch to filter events based on glob patterns
|
||||||
|
- Ensure backward compatibility with existing API
|
||||||
|
|
||||||
|
#### 2.4 Event Mapping
|
||||||
|
Map chokidar events to existing smartchok events:
|
||||||
|
- Keep existing event names for backward compatibility
|
||||||
|
- Ensure all current functionality is preserved
|
||||||
|
|
||||||
|
### Phase 3: Testing
|
||||||
|
1. Run existing tests to ensure backward compatibility
|
||||||
|
2. Add new tests for glob pattern handling
|
||||||
|
3. Test edge cases with complex glob patterns
|
||||||
|
4. Verify performance with large file sets
|
||||||
|
|
||||||
|
### Phase 4: Documentation
|
||||||
|
1. Update readme.md to reflect the change to chokidar 4.x
|
||||||
|
2. Document any API changes (if any)
|
||||||
|
3. Update version number in package.json
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Key Differences to Handle:
|
||||||
|
1. **Glob Support**: Implement custom glob filtering layer
|
||||||
|
2. **API Changes**: Adapt initialization and option passing
|
||||||
|
3. **Event Names**: Map between different event naming conventions
|
||||||
|
4. **Minimum Node Version**: Ensure compatibility with Node 14+
|
||||||
|
|
||||||
|
### Risk Mitigation:
|
||||||
|
- Maintain backward compatibility with existing API
|
||||||
|
- Extensive testing with current test suite
|
||||||
|
- Consider keeping a legacy branch if breaking changes are unavoidable
|
||||||
22
test/test.ts
22
test/test.ts
@@ -1,5 +1,5 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
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 smartfile from '@push.rocks/smartfile';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
@@ -11,22 +11,22 @@ if (process.env.CI) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let testSmartchok: smartchok.Smartchok;
|
let testSmartwatch: smartwatch.Smartwatch;
|
||||||
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
|
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
|
||||||
let testSubscription: smartrx.rxjs.Subscription;
|
let testSubscription: smartrx.rxjs.Subscription;
|
||||||
tap.test('should create a new instance', async () => {
|
tap.test('should create a new instance', async () => {
|
||||||
testSmartchok = new smartchok.Smartchok([]);
|
testSmartwatch = new smartwatch.Smartwatch([]);
|
||||||
expect(testSmartchok).toBeInstanceOf(smartchok.Smartchok);
|
expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should add some files to watch and start', async () => {
|
tap.test('should add some files to watch and start', async () => {
|
||||||
testSmartchok.add(['./test/**/*.txt']);
|
testSmartwatch.add(['./test/**/*.txt']);
|
||||||
await testSmartchok.start()
|
await testSmartwatch.start()
|
||||||
testSmartchok.add(['./test/**/*.md']);
|
testSmartwatch.add(['./test/**/*.md']);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should get an observable for a certain event', async () => {
|
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;
|
testAddObservable = observableArg;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -44,7 +44,7 @@ tap.test('should register an add operation', async () => {
|
|||||||
|
|
||||||
tap.test('should stop the watch process', async (tools) => {
|
tap.test('should stop the watch process', async (tools) => {
|
||||||
await tools.delayFor(10000);
|
await tools.delayFor(10000);
|
||||||
testSmartchok.stop();
|
testSmartwatch.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* autocreated commitinfo by @pushrocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartchok',
|
name: '@push.rocks/smartchok',
|
||||||
version: '1.0.34',
|
version: '3.0.0',
|
||||||
description: 'A smart wrapper for chokidar to facilitate file watching with enhanced features.'
|
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,131 +0,0 @@
|
|||||||
import * as plugins from './smartchok.plugins.js';
|
|
||||||
import { Stringmap } from '@push.rocks/lik';
|
|
||||||
|
|
||||||
export type TSmartchokStatus = 'idle' | 'starting' | 'watching';
|
|
||||||
export type TFsEvent =
|
|
||||||
| 'add'
|
|
||||||
| 'addDir'
|
|
||||||
| 'change'
|
|
||||||
| 'error'
|
|
||||||
| 'unlink'
|
|
||||||
| 'unlinkDir'
|
|
||||||
| 'ready'
|
|
||||||
| 'raw';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smartchok allows easy wathcing of files
|
|
||||||
*/
|
|
||||||
export class Smartchok {
|
|
||||||
public watchStringmap = new Stringmap();
|
|
||||||
public status: TSmartchokStatus = 'idle';
|
|
||||||
private watcher: plugins.watcher;
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* constructor of class smartchok
|
|
||||||
*/
|
|
||||||
constructor(watchArrayArg: string[]) {
|
|
||||||
this.watchStringmap.addStringArray(watchArrayArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getGlobBase(globPattern: string) {
|
|
||||||
// Characters that mark the beginning of a glob pattern
|
|
||||||
const globChars = ['*', '?', '[', ']', '{', '}'];
|
|
||||||
|
|
||||||
// Find the index of the first glob character
|
|
||||||
const firstGlobCharIndex = globPattern.split('').findIndex((char) => globChars.includes(char));
|
|
||||||
|
|
||||||
// If no glob characters are found, return the entire string
|
|
||||||
if (firstGlobCharIndex === -1) {
|
|
||||||
return globPattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the substring up to the first glob character
|
|
||||||
const basePathPortion = globPattern.substring(0, firstGlobCharIndex);
|
|
||||||
|
|
||||||
// Find the last slash before the glob pattern starts
|
|
||||||
const lastSlashIndex = basePathPortion.lastIndexOf('/');
|
|
||||||
|
|
||||||
// If there is no slash, return the basePathPortion as is
|
|
||||||
if (lastSlashIndex === -1) {
|
|
||||||
return basePathPortion;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the base path up to and including the last slash
|
|
||||||
return basePathPortion.substring(0, lastSlashIndex + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* adds files to the list of watched files
|
|
||||||
*/
|
|
||||||
public add(pathArrayArg: string[]) {
|
|
||||||
this.watchStringmap.addStringArray(pathArrayArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* removes files from the list of watched files
|
|
||||||
*/
|
|
||||||
public remove(pathArg: string) {
|
|
||||||
this.watchStringmap.removeString(pathArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* gets an observable for a certain event
|
|
||||||
*/
|
|
||||||
public getObservableFor(
|
|
||||||
fsEvent: TFsEvent
|
|
||||||
): 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);
|
|
||||||
});
|
|
||||||
return done.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* starts the watcher
|
|
||||||
* @returns Promise<void>
|
|
||||||
*/
|
|
||||||
public start(): Promise<void> {
|
|
||||||
const done = plugins.smartpromise.defer<void>();
|
|
||||||
this.status = 'starting';
|
|
||||||
this.watcher = new plugins.watcher(
|
|
||||||
this.watchStringmap.getStringArray().map((string) => {
|
|
||||||
const result = this.getGlobBase(string);
|
|
||||||
console.log(`Watching ${result} for changes`);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
depth: 4,
|
|
||||||
recursive: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.watcher.on('ready', () => {
|
|
||||||
this.status = 'watching';
|
|
||||||
this.watchingDeferred.resolve();
|
|
||||||
done.resolve();
|
|
||||||
});
|
|
||||||
return done.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* stop the watcher process if watching
|
|
||||||
*/
|
|
||||||
public async stop() {
|
|
||||||
const closeWatcher = async () => {
|
|
||||||
await this.watcher.close();
|
|
||||||
};
|
|
||||||
if (this.status === 'watching') {
|
|
||||||
console.log('closing while watching');
|
|
||||||
await closeWatcher();
|
|
||||||
} else if (this.status === 'starting') {
|
|
||||||
await this.watchingDeferred.promise;
|
|
||||||
await closeWatcher();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
237
ts/smartwatch.classes.smartwatch.ts
Normal file
237
ts/smartwatch.classes.smartwatch.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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 TSmartwatchStatus = 'idle' | 'starting' | 'watching';
|
||||||
|
export type TFsEvent =
|
||||||
|
| 'add'
|
||||||
|
| 'addDir'
|
||||||
|
| 'change'
|
||||||
|
| 'error'
|
||||||
|
| 'unlink'
|
||||||
|
| 'unlinkDir'
|
||||||
|
| 'ready'
|
||||||
|
| 'raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smartwatch allows easy watching of files
|
||||||
|
* Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support
|
||||||
|
*/
|
||||||
|
export class Smartwatch {
|
||||||
|
public watchStringmap = new Stringmap();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
// Event subjects for each event type
|
||||||
|
private eventSubjects: Map<TFsEvent, plugins.smartrx.rxjs.Subject<[string, plugins.fs.Stats | undefined]>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
// Characters that mark the beginning of a glob pattern
|
||||||
|
const globChars = ['*', '?', '[', ']', '{', '}'];
|
||||||
|
|
||||||
|
// Find the index of the first glob character
|
||||||
|
const firstGlobCharIndex = globPattern.split('').findIndex((char) => globChars.includes(char));
|
||||||
|
|
||||||
|
// If no glob characters are found, return the entire string
|
||||||
|
if (firstGlobCharIndex === -1) {
|
||||||
|
return globPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the substring up to the first glob character
|
||||||
|
const basePathPortion = globPattern.substring(0, firstGlobCharIndex);
|
||||||
|
|
||||||
|
// Find the last slash before the glob pattern starts
|
||||||
|
const lastSlashIndex = basePathPortion.lastIndexOf('/');
|
||||||
|
|
||||||
|
// If there is no slash, return the basePathPortion as is
|
||||||
|
if (lastSlashIndex === -1) {
|
||||||
|
return basePathPortion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the base path up to and including the last slash
|
||||||
|
return basePathPortion.substring(0, lastSlashIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* adds files to the list of watched files
|
||||||
|
*/
|
||||||
|
public add(pathArrayArg: string[]) {
|
||||||
|
this.watchStringmap.addStringArray(pathArrayArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* removes files from the list of watched files
|
||||||
|
*/
|
||||||
|
public remove(pathArg: string) {
|
||||||
|
this.watchStringmap.removeString(pathArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets an observable for a certain event
|
||||||
|
*/
|
||||||
|
public getObservableFor(
|
||||||
|
fsEvent: TFsEvent
|
||||||
|
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
|
||||||
|
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
|
||||||
|
this.watchingDeferred.promise.then(() => {
|
||||||
|
const subject = this.eventSubjects.get(fsEvent);
|
||||||
|
if (subject) {
|
||||||
|
done.resolve(subject.asObservable());
|
||||||
|
} else {
|
||||||
|
done.reject(new Error(`Unknown event type: ${fsEvent}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* starts the watcher
|
||||||
|
* @returns Promise<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,
|
||||||
|
basename: false
|
||||||
|
});
|
||||||
|
this.globMatchers.set(pattern, matcher);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert Set to Array for the watcher
|
||||||
|
const watchPaths = Array.from(basePaths);
|
||||||
|
console.log('Base paths to watch:', watchPaths);
|
||||||
|
|
||||||
|
// Create the platform-appropriate watcher
|
||||||
|
this.watcher = await createWatcher({
|
||||||
|
basePaths: watchPaths,
|
||||||
|
depth: 4,
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle error event
|
||||||
|
if (event.type === 'error') {
|
||||||
|
const subject = this.eventSubjects.get('error');
|
||||||
|
if (subject) {
|
||||||
|
subject.next([event.error?.message || 'Unknown error', undefined]);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stop the watcher process if watching
|
||||||
|
*/
|
||||||
|
public async stop() {
|
||||||
|
const closeWatcher = async () => {
|
||||||
|
if (this.watcher) {
|
||||||
|
await this.watcher.stop();
|
||||||
|
this.watcher = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.status === 'watching') {
|
||||||
|
console.log('closing while watching');
|
||||||
|
await closeWatcher();
|
||||||
|
} else if (this.status === 'starting') {
|
||||||
|
await this.watchingDeferred.promise;
|
||||||
|
await closeWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a path should be watched based on glob patterns
|
||||||
|
*/
|
||||||
|
private shouldWatchPath(filePath: string): boolean {
|
||||||
|
// Normalize the path - remove leading ./ if present
|
||||||
|
let normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
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
|
||||||
|
let normalizedPattern = pattern;
|
||||||
|
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)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,18 @@ export {
|
|||||||
import * as lik from '@push.rocks/lik';
|
import * as lik from '@push.rocks/lik';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import { Smartenv } from '@push.rocks/smartenv';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
lik,
|
lik,
|
||||||
smartpromise,
|
smartpromise,
|
||||||
smartrx
|
smartrx,
|
||||||
|
Smartenv
|
||||||
}
|
}
|
||||||
|
|
||||||
// thirdparty scope
|
// thirdparty scope
|
||||||
// @ts-nocheck
|
import picomatch from 'picomatch';
|
||||||
import watcher from '@tempfix/watcher';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
watcher,
|
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