Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 913c14bfcf | |||
| 2ce056f401 | |||
| 1c77a94b96 | |||
| 097ea96e99 | |||
| 5a6d9a2575 | |||
| 7a7ee041a3 | |||
| 0dc4eaba52 | |||
| 31ab561859 | |||
| a5b4fedf9f | |||
| b84a50f87d | |||
| 2ae297e5fa | |||
| 9130613941 |
59
changelog.md
59
changelog.md
@@ -1,5 +1,64 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-08 - 6.1.0 - feat(watcher.node)
|
||||||
|
Add automatic restart, periodic health checks, and safe event emission to Node watcher; improve logging and stat handling
|
||||||
|
|
||||||
|
- NodeWatcher: introduced safeEmit() to isolate subscriber errors and prevent watcher crashes
|
||||||
|
- Auto-restart on failure with exponential backoff (1s → 30s) and up to 3 retry attempts per watched base path
|
||||||
|
- Periodic health checks (every 30s) to detect missing watched paths and trigger automatic restarts
|
||||||
|
- Handle unexpected FSWatcher 'close' events and restart watchers when they close silently
|
||||||
|
- Verbose lifecycle logging with `[smartwatch]` prefix for start/stop/health/restart events
|
||||||
|
- Clear restart tracking and stop health checks on watcher.stop() to ensure clean shutdown
|
||||||
|
- Improved statSafe() to normalize followSymlinks logic and log non-ENO errors as warnings
|
||||||
|
- Updated readme.hints.md documenting the new robustness features (v6.1.0+)
|
||||||
|
|
||||||
|
## 2025-12-08 - 6.0.0 - BREAKING CHANGE(watchers)
|
||||||
|
Replace polling-based write stabilization with debounce-based event coalescing and simplify watcher options
|
||||||
|
|
||||||
|
- Remove polling-based WriteStabilizer (ts/utils/write-stabilizer.ts) and related waitForWriteFinish logic
|
||||||
|
- Introduce debounce-based coalescing (debounceMs) for Node and Deno watchers (ts/watchers/watcher.node.ts, ts/watchers/watcher.deno.ts)
|
||||||
|
- Change IWatcherOptions: remove stabilityThreshold/pollInterval/maxWaitTime and add debounceMs
|
||||||
|
- Default watcher options updated to use debounceMs = 100
|
||||||
|
- Node/Deno watchers now skip editor temp files earlier, debounce duplicate events, and emit events directly (no size polling)
|
||||||
|
- Updated default watcher creation in Smartwatch to pass debounceMs
|
||||||
|
- Update package.json build script to run 'tsbuild tsfolders'
|
||||||
|
|
||||||
|
## 2025-12-08 - 5.1.0 - feat(watchers)
|
||||||
|
Improve write stabilization and ignore temporary editor files
|
||||||
|
|
||||||
|
- Add maxWaitTime option (ms) to IWatcherOptions and WriteStabilizer to cap how long stabilization will wait (default: 1000ms).
|
||||||
|
- WriteStabilizer: reduce default stabilityThreshold from 300ms to 100ms and track write start time to enforce maxWaitTime and avoid indefinite polling.
|
||||||
|
- Node and Deno watchers: detect and ignore common temporary/editor files (e.g. *.tmp.*, *.swp, *.swx, trailing ~, .#*) to prevent spurious events from atomic saves.
|
||||||
|
- Node watcher: treat rename (atomic-save) events as already-complete files and emit add/change immediately without stabilization.
|
||||||
|
- Deno watcher: use the configured maxWaitTime and polling-based stabilization for modify events to ensure consistent behavior across runtimes.
|
||||||
|
|
||||||
|
## 2025-11-30 - 5.0.0 - BREAKING CHANGE(@push.rocks/smartwatch)
|
||||||
|
Rename package and update branding/docs: switch from @push.rocks/smartchok to @push.rocks/smartwatch, update repository/homepage/bugs URLs and author, and refresh README examples and install instructions.
|
||||||
|
|
||||||
|
- Package name changed from @push.rocks/smartchok to @push.rocks/smartwatch in package.json
|
||||||
|
- Repository, homepage and issue URLs updated to point to the new smartwatch repository
|
||||||
|
- Author changed to Task Venture Capital GmbH in package metadata
|
||||||
|
- README updated: install commands, import examples and references now use @push.rocks/smartwatch
|
||||||
|
- Documentation text and branding updated throughout README (project name, internal references)
|
||||||
|
|
||||||
|
## 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)
|
## 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
|
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
|
||||||
|
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartchok",
|
"name": "@push.rocks/smartwatch",
|
||||||
"version": "3.0.0",
|
"version": "6.1.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
|
"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",
|
||||||
@@ -8,19 +8,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(npm run prepareTest && tstest test/)",
|
"test": "(npm run prepareTest && tstest test/)",
|
||||||
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
"prepareTest": "(rm -f ./test/assets/hi.txt)",
|
||||||
"build": "tsbuild",
|
"build": "tsbuild tsfolders",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.foss.global/push.rocks/smartchok.git"
|
"url": "https://code.foss.global/push.rocks/smartwatch.git"
|
||||||
},
|
},
|
||||||
"author": "Lossless GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitlab.com/push.rocks/smartchok/issues"
|
"url": "https://gitlab.com/push.rocks/smartwatch/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartchok",
|
"homepage": "https://code.foss.global/push.rocks/smartwatch",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
The module now uses native file watching APIs instead of chokidar, providing cross-runtime support for Node.js, Deno, and Bun.
|
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
|
### Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -64,6 +71,37 @@ The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
|
|||||||
- **Deno**: Works on all versions with `Deno.watchFs()`
|
- **Deno**: Works on all versions with `Deno.watchFs()`
|
||||||
- **Bun**: Uses Node.js compatibility layer
|
- **Bun**: Uses Node.js compatibility layer
|
||||||
|
|
||||||
|
### Robustness Features (v6.1.0+)
|
||||||
|
|
||||||
|
The Node.js watcher includes automatic recovery mechanisms:
|
||||||
|
|
||||||
|
**Auto-restart on failure:**
|
||||||
|
- Watchers automatically restart when errors occur
|
||||||
|
- Exponential backoff (1s → 30s max)
|
||||||
|
- Maximum 3 retry attempts before giving up
|
||||||
|
|
||||||
|
**Health check monitoring:**
|
||||||
|
- 30-second periodic health checks
|
||||||
|
- Detects when watched paths disappear
|
||||||
|
- Triggers automatic restart when issues detected
|
||||||
|
|
||||||
|
**Error isolation:**
|
||||||
|
- Subscriber errors don't crash the watcher
|
||||||
|
- All events emitted via `safeEmit()` with try-catch
|
||||||
|
|
||||||
|
**Verbose logging:**
|
||||||
|
- All lifecycle events logged with `[smartwatch]` prefix
|
||||||
|
- Helps debug watcher issues in production
|
||||||
|
|
||||||
|
Example log output:
|
||||||
|
```
|
||||||
|
[smartwatch] Starting watcher for 1 base path(s)...
|
||||||
|
[smartwatch] Started watching: ./test/assets/
|
||||||
|
[smartwatch] Starting health check (every 30s)
|
||||||
|
[smartwatch] Watcher started with 1 active watcher(s)
|
||||||
|
[smartwatch] Health check: 1 watchers active
|
||||||
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
100
readme.md
100
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartwatch
|
# @push.rocks/smartwatch
|
||||||
|
|
||||||
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
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -16,11 +16,14 @@ pnpm add @push.rocks/smartwatch
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
🔍 **Glob Pattern Support** - Watch files using glob patterns like `**/*.ts` or `src/**/*.js`
|
🌐 **Cross-Runtime** — Works seamlessly on Node.js 20+, Deno, and Bun
|
||||||
📡 **RxJS Observables** - Subscribe to file system events using reactive streams
|
🔍 **Glob Pattern Support** — Watch files using familiar patterns like `**/*.ts` and `src/**/*.{js,jsx}`
|
||||||
🔄 **Dynamic Watching** - Add or remove watch patterns at runtime
|
📡 **RxJS Observables** — Subscribe to file system events using reactive streams
|
||||||
⚡ **Chokidar 5.x** - Built on the latest chokidar with improved performance
|
🔄 **Dynamic Watching** — Add or remove watch patterns at runtime
|
||||||
🎯 **TypeScript First** - Full TypeScript support with comprehensive type definitions
|
⚡ **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
|
## Usage
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ const changeObservable = await watcher.getObservableFor('change');
|
|||||||
changeObservable.subscribe({
|
changeObservable.subscribe({
|
||||||
next: ([path, stats]) => {
|
next: ([path, stats]) => {
|
||||||
console.log(`File changed: ${path}`);
|
console.log(`File changed: ${path}`);
|
||||||
|
console.log(`New size: ${stats?.size} bytes`);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error(`Error: ${err}`);
|
console.error(`Error: ${err}`);
|
||||||
@@ -72,15 +76,14 @@ unlinkObservable.subscribe(([path]) => {
|
|||||||
### Supported Events
|
### Supported Events
|
||||||
|
|
||||||
| Event | Description |
|
| Event | Description |
|
||||||
|-------|-------------|
|
|-------------|--------------------------------|
|
||||||
| `add` | File has been added |
|
| `add` | File has been added |
|
||||||
| `addDir` | Directory has been added |
|
| `addDir` | Directory has been added |
|
||||||
| `change` | File has been changed |
|
| `change` | File has been modified |
|
||||||
| `unlink` | File has been removed |
|
| `unlink` | File has been removed |
|
||||||
| `unlinkDir` | Directory has been removed |
|
| `unlinkDir` | Directory has been removed |
|
||||||
| `error` | Error occurred |
|
| `error` | Error occurred |
|
||||||
| `ready` | Initial scan complete |
|
| `ready` | Initial scan complete |
|
||||||
| `raw` | Raw event from the underlying watcher |
|
|
||||||
|
|
||||||
### Dynamic Watch Management
|
### Dynamic Watch Management
|
||||||
|
|
||||||
@@ -152,12 +155,38 @@ watchProject();
|
|||||||
|
|
||||||
## How It Works
|
## 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.
|
smartwatch uses native file watching APIs for each runtime:
|
||||||
|
|
||||||
When you provide glob patterns:
|
| Runtime | API Used |
|
||||||
1. **Base path extraction** - smartwatch extracts the static base path from each pattern
|
|-----------------|----------------------------------|
|
||||||
2. **Efficient watching** - chokidar watches the base directories
|
| **Node.js 20+** | `fs.watch({ recursive: true })` |
|
||||||
3. **Pattern filtering** - Events are filtered through picomatch matchers before being emitted
|
| **Deno** | `Deno.watchFs()` |
|
||||||
|
| **Bun** | Node.js compatibility layer |
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
Native file watching APIs don't support glob patterns directly, so smartwatch 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
|
||||||
|
|
||||||
|
smartwatch 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
|
## API Reference
|
||||||
|
|
||||||
@@ -171,37 +200,62 @@ new Smartwatch(patterns: string[])
|
|||||||
|
|
||||||
Creates a new Smartwatch 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
|
#### Methods
|
||||||
|
|
||||||
| Method | Returns | Description |
|
| Method | Returns | Description |
|
||||||
|--------|---------|-------------|
|
|----------------------------------------|------------------------------------------------|------------------------------------------------|
|
||||||
| `start()` | `Promise<void>` | Starts watching for file changes |
|
| `start()` | `Promise<void>` | Starts watching for file changes |
|
||||||
| `stop()` | `Promise<void>` | Stops the file watcher |
|
| `stop()` | `Promise<void>` | Stops the file watcher and cleans up resources |
|
||||||
| `add(patterns: string[])` | `void` | Adds patterns to watch |
|
| `add(patterns: string[])` | `void` | Adds additional patterns to watch |
|
||||||
| `remove(pattern: string)` | `void` | Removes a pattern from watching |
|
| `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 |
|
| `getObservableFor(event: TFsEvent)` | `Promise<Observable<[string, Stats]>>` | Returns an RxJS observable for the specified event |
|
||||||
|
|
||||||
#### Properties
|
#### Properties
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
|----------|------|-------------|
|
|----------|-------------------------------------------|--------------------------|
|
||||||
| `status` | `'idle' \| 'starting' \| 'watching'` | Current watcher status |
|
| `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 smartwatch?
|
||||||
|
|
||||||
|
| Feature | smartwatch | 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, smartwatch has you covered.
|
||||||
|
|
||||||
## License and Legal Information
|
## 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.
|
**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
|
### 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
|
### Company Information
|
||||||
|
|
||||||
Task Venture Capital GmbH
|
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.
|
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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartchok',
|
name: '@push.rocks/smartwatch',
|
||||||
version: '3.0.0',
|
version: '6.1.0',
|
||||||
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,8 +132,7 @@ export class Smartwatch {
|
|||||||
basePaths: watchPaths,
|
basePaths: watchPaths,
|
||||||
depth: 4,
|
depth: 4,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
stabilityThreshold: 300,
|
debounceMs: 100
|
||||||
pollInterval: 100
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to watcher events and dispatch to appropriate subjects
|
// Subscribe to watcher events and dispatch to appropriate subjects
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,5 @@ export const defaultWatcherOptions: IWatcherOptions = {
|
|||||||
basePaths: [],
|
basePaths: [],
|
||||||
depth: 4,
|
depth: 4,
|
||||||
followSymlinks: false,
|
followSymlinks: false,
|
||||||
stabilityThreshold: 300,
|
debounceMs: 100
|
||||||
pollInterval: 100
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ export interface IWatcherOptions {
|
|||||||
depth: number;
|
depth: number;
|
||||||
/** Whether to follow symbolic links */
|
/** Whether to follow symbolic links */
|
||||||
followSymlinks: boolean;
|
followSymlinks: boolean;
|
||||||
/** Stability threshold for write detection (ms) */
|
/** Debounce time in ms - events for the same file within this window are coalesced */
|
||||||
stabilityThreshold: number;
|
debounceMs: number;
|
||||||
/** Poll interval for write detection (ms) */
|
|
||||||
pollInterval: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -65,15 +65,27 @@ export class DenoWatcher implements IWatcher {
|
|||||||
private watcher: ReturnType<typeof Deno.watchFs> | null = null;
|
private watcher: ReturnType<typeof Deno.watchFs> | null = null;
|
||||||
private watchedFiles: Set<string> = new Set();
|
private watchedFiles: Set<string> = new Set();
|
||||||
private _isWatching = false;
|
private _isWatching = false;
|
||||||
private abortController: AbortController | null = null;
|
|
||||||
private recentEvents: Map<string, number> = new Map();
|
// Debounce: pending emits per file path
|
||||||
private throttleMs = 50;
|
private pendingEmits: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||||
private pendingWrites: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
||||||
|
|
||||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||||
|
|
||||||
constructor(private options: IWatcherOptions) {}
|
constructor(private options: IWatcherOptions) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a temporary file created by editors
|
||||||
|
*/
|
||||||
|
private isTemporaryFile(filePath: string): boolean {
|
||||||
|
const basename = filePath.split('/').pop() || '';
|
||||||
|
// Editor temp files: *.tmp.*, *.swp, *.swx, *~, .#*
|
||||||
|
if (basename.includes('.tmp.')) return true;
|
||||||
|
if (basename.endsWith('.swp') || basename.endsWith('.swx')) return true;
|
||||||
|
if (basename.endsWith('~')) return true;
|
||||||
|
if (basename.startsWith('.#')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
get isWatching(): boolean {
|
get isWatching(): boolean {
|
||||||
return this._isWatching;
|
return this._isWatching;
|
||||||
}
|
}
|
||||||
@@ -84,8 +96,6 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.abortController = new AbortController();
|
|
||||||
|
|
||||||
// Start watching all base paths
|
// Start watching all base paths
|
||||||
this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true });
|
this.watcher = Deno.watchFs(this.options.basePaths, { recursive: true });
|
||||||
this._isWatching = true;
|
this._isWatching = true;
|
||||||
@@ -109,11 +119,11 @@ export class DenoWatcher implements IWatcher {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
this._isWatching = false;
|
this._isWatching = false;
|
||||||
|
|
||||||
// Cancel all pending write stabilizations
|
// Cancel all pending debounced emits
|
||||||
for (const timeout of this.pendingWrites.values()) {
|
for (const timeout of this.pendingEmits.values()) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
this.pendingWrites.clear();
|
this.pendingEmits.clear();
|
||||||
|
|
||||||
// Close the watcher
|
// Close the watcher
|
||||||
if (this.watcher) {
|
if (this.watcher) {
|
||||||
@@ -122,7 +132,6 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.watchedFiles.clear();
|
this.watchedFiles.clear();
|
||||||
this.recentEvents.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +149,7 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const filePath of event.paths) {
|
for (const filePath of event.paths) {
|
||||||
await this.handleDenoEvent(event.kind, filePath);
|
this.handleDenoEvent(event.kind, filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -151,46 +160,56 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a Deno file system event
|
* Handle a Deno file system event - debounce and normalize
|
||||||
*/
|
*/
|
||||||
private async handleDenoEvent(
|
private handleDenoEvent(
|
||||||
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other',
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<void> {
|
): void {
|
||||||
// Ignore 'access' events (just reading the file)
|
// Ignore 'access' events (just reading the file)
|
||||||
if (kind === 'access') {
|
if (kind === 'access') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle duplicate events
|
// Skip temporary files created by editors (atomic saves)
|
||||||
if (!this.shouldEmit(filePath, kind)) {
|
if (this.isTemporaryFile(filePath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce: cancel any pending emit for this file
|
||||||
|
const existing = this.pendingEmits.get(filePath);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule debounced emit
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingEmits.delete(filePath);
|
||||||
|
this.emitFileEvent(filePath, kind);
|
||||||
|
}, this.options.debounceMs);
|
||||||
|
|
||||||
|
this.pendingEmits.set(filePath, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit the actual file event after debounce
|
||||||
|
*/
|
||||||
|
private async emitFileEvent(
|
||||||
|
filePath: string,
|
||||||
|
kind: 'create' | 'modify' | 'remove' | 'access' | 'any' | 'other'
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (kind === 'create') {
|
if (kind === 'create') {
|
||||||
const stats = await this.statSafe(filePath);
|
const stats = await this.statSafe(filePath);
|
||||||
if (stats) {
|
if (stats) {
|
||||||
// Wait for write to stabilize
|
|
||||||
await this.waitForWriteFinish(filePath);
|
|
||||||
const finalStats = await this.statSafe(filePath);
|
|
||||||
|
|
||||||
if (finalStats) {
|
|
||||||
this.watchedFiles.add(filePath);
|
this.watchedFiles.add(filePath);
|
||||||
const eventType: TWatchEventType = finalStats.isDirectory() ? 'addDir' : 'add';
|
const eventType: TWatchEventType = stats.isDirectory() ? 'addDir' : 'add';
|
||||||
this.events$.next({ type: eventType, path: filePath, stats: finalStats });
|
this.events$.next({ type: eventType, path: filePath, stats });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (kind === 'modify') {
|
} else if (kind === 'modify') {
|
||||||
const stats = await this.statSafe(filePath);
|
const stats = await this.statSafe(filePath);
|
||||||
if (stats && !stats.isDirectory()) {
|
if (stats && !stats.isDirectory()) {
|
||||||
// Wait for write to stabilize
|
this.events$.next({ type: 'change', path: filePath, stats });
|
||||||
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') {
|
} else if (kind === 'remove') {
|
||||||
const wasDirectory = this.isKnownDirectory(filePath);
|
const wasDirectory = this.isKnownDirectory(filePath);
|
||||||
@@ -205,43 +224,6 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* Scan directory and emit 'add' events for existing files
|
||||||
*/
|
*/
|
||||||
@@ -253,6 +235,12 @@ export class DenoWatcher implements IWatcher {
|
|||||||
try {
|
try {
|
||||||
for await (const entry of Deno.readDir(dirPath)) {
|
for await (const entry of Deno.readDir(dirPath)) {
|
||||||
const fullPath = `${dirPath}/${entry.name}`;
|
const fullPath = `${dirPath}/${entry.name}`;
|
||||||
|
|
||||||
|
// Skip temp files during initial scan too
|
||||||
|
if (this.isTemporaryFile(fullPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const stats = await this.statSafe(fullPath);
|
const stats = await this.statSafe(fullPath);
|
||||||
|
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
@@ -299,31 +287,4 @@ export class DenoWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
return false;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
import type { IWatcher, IWatcherOptions, IWatchEvent, TWatchEventType } from './interfaces.js';
|
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
|
* Node.js/Bun file watcher using native fs.watch API
|
||||||
@@ -11,17 +10,129 @@ export class NodeWatcher implements IWatcher {
|
|||||||
private watchers: Map<string, fs.FSWatcher> = new Map();
|
private watchers: Map<string, fs.FSWatcher> = new Map();
|
||||||
private watchedFiles: Set<string> = new Set();
|
private watchedFiles: Set<string> = new Set();
|
||||||
private _isWatching = false;
|
private _isWatching = false;
|
||||||
private writeStabilizer: WriteStabilizer;
|
|
||||||
private recentEvents: Map<string, number> = new Map();
|
// Debounce: pending emits per file path
|
||||||
private throttleMs = 50;
|
private pendingEmits: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
|
||||||
|
// Restart tracking
|
||||||
|
private restartDelays: Map<string, number> = new Map();
|
||||||
|
private restartAttempts: Map<string, number> = new Map();
|
||||||
|
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
private static readonly MAX_RETRIES = 3;
|
||||||
|
private static readonly INITIAL_RESTART_DELAY = 1000;
|
||||||
|
private static readonly MAX_RESTART_DELAY = 30000;
|
||||||
|
private static readonly HEALTH_CHECK_INTERVAL = 30000;
|
||||||
|
|
||||||
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
public readonly events$ = new smartrx.rxjs.Subject<IWatchEvent>();
|
||||||
|
|
||||||
constructor(private options: IWatcherOptions) {
|
constructor(private options: IWatcherOptions) {}
|
||||||
this.writeStabilizer = new WriteStabilizer(
|
|
||||||
options.stabilityThreshold,
|
/**
|
||||||
options.pollInterval
|
* Safely emit an event, catching any subscriber errors
|
||||||
);
|
*/
|
||||||
|
private safeEmit(event: IWatchEvent): void {
|
||||||
|
try {
|
||||||
|
this.events$.next(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[smartwatch] Subscriber threw error (isolated):', error);
|
||||||
|
// Don't let subscriber errors kill the watcher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart a watcher after an error with exponential backoff
|
||||||
|
*/
|
||||||
|
private async restartWatcher(basePath: string, error: Error): Promise<void> {
|
||||||
|
const attempts = (this.restartAttempts.get(basePath) || 0) + 1;
|
||||||
|
this.restartAttempts.set(basePath, attempts);
|
||||||
|
|
||||||
|
console.log(`[smartwatch] Watcher error for ${basePath}: ${error.message}`);
|
||||||
|
console.log(`[smartwatch] Restart attempt ${attempts}/${NodeWatcher.MAX_RETRIES}`);
|
||||||
|
|
||||||
|
if (attempts > NodeWatcher.MAX_RETRIES) {
|
||||||
|
console.error(`[smartwatch] Max retries exceeded for ${basePath}, giving up`);
|
||||||
|
this.safeEmit({
|
||||||
|
type: 'error',
|
||||||
|
path: basePath,
|
||||||
|
error: new Error(`Max restart retries (${NodeWatcher.MAX_RETRIES}) exceeded`)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close failed watcher
|
||||||
|
const oldWatcher = this.watchers.get(basePath);
|
||||||
|
if (oldWatcher) {
|
||||||
|
try {
|
||||||
|
oldWatcher.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
this.watchers.delete(basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
const delay = this.restartDelays.get(basePath) || NodeWatcher.INITIAL_RESTART_DELAY;
|
||||||
|
console.log(`[smartwatch] Waiting ${delay}ms before restart...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
this.restartDelays.set(basePath, Math.min(delay * 2, NodeWatcher.MAX_RESTART_DELAY));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.watchPath(basePath, 0);
|
||||||
|
console.log(`[smartwatch] Successfully restarted watcher for ${basePath}`);
|
||||||
|
this.restartDelays.set(basePath, NodeWatcher.INITIAL_RESTART_DELAY);
|
||||||
|
this.restartAttempts.set(basePath, 0);
|
||||||
|
} catch (restartError) {
|
||||||
|
console.error(`[smartwatch] Restart failed for ${basePath}:`, restartError);
|
||||||
|
this.restartWatcher(basePath, restartError as Error); // Recursive retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic health checks to detect silent failures
|
||||||
|
*/
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
console.log('[smartwatch] Starting health check (every 30s)');
|
||||||
|
this.healthCheckInterval = setInterval(async () => {
|
||||||
|
console.log(`[smartwatch] Health check: ${this.watchers.size} watchers active`);
|
||||||
|
for (const [basePath] of this.watchers) {
|
||||||
|
const stats = await this.statSafe(basePath);
|
||||||
|
if (!stats && this._isWatching) {
|
||||||
|
console.error(`[smartwatch] Health check failed: ${basePath} no longer exists`);
|
||||||
|
this.safeEmit({
|
||||||
|
type: 'error',
|
||||||
|
path: basePath,
|
||||||
|
error: new Error('Watched path no longer exists')
|
||||||
|
});
|
||||||
|
this.restartWatcher(basePath, new Error('Watched path disappeared'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, NodeWatcher.HEALTH_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop health check interval
|
||||||
|
*/
|
||||||
|
private stopHealthCheck(): void {
|
||||||
|
if (this.healthCheckInterval) {
|
||||||
|
clearInterval(this.healthCheckInterval);
|
||||||
|
this.healthCheckInterval = null;
|
||||||
|
console.log('[smartwatch] Stopped health check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is a temporary file created by editors
|
||||||
|
*/
|
||||||
|
private isTemporaryFile(filePath: string): boolean {
|
||||||
|
const basename = path.basename(filePath);
|
||||||
|
// Editor temp files: *.tmp.*, *.swp, *.swx, *~, .#*
|
||||||
|
if (basename.includes('.tmp.')) return true;
|
||||||
|
if (basename.endsWith('.swp') || basename.endsWith('.swx')) return true;
|
||||||
|
if (basename.endsWith('~')) return true;
|
||||||
|
if (basename.startsWith('.#')) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isWatching(): boolean {
|
get isWatching(): boolean {
|
||||||
@@ -33,6 +144,8 @@ export class NodeWatcher implements IWatcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[smartwatch] Starting watcher for ${this.options.basePaths.length} base path(s)...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start watching each base path
|
// Start watching each base path
|
||||||
for (const basePath of this.options.basePaths) {
|
for (const basePath of this.options.basePaths) {
|
||||||
@@ -41,30 +154,50 @@ export class NodeWatcher implements IWatcher {
|
|||||||
|
|
||||||
this._isWatching = true;
|
this._isWatching = true;
|
||||||
|
|
||||||
|
// Start health check monitoring
|
||||||
|
this.startHealthCheck();
|
||||||
|
|
||||||
// Perform initial scan to emit 'add' events for existing files
|
// Perform initial scan to emit 'add' events for existing files
|
||||||
for (const basePath of this.options.basePaths) {
|
for (const basePath of this.options.basePaths) {
|
||||||
await this.scanDirectory(basePath, 0);
|
await this.scanDirectory(basePath, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit ready event
|
// Emit ready event
|
||||||
this.events$.next({ type: 'ready', path: '' });
|
this.safeEmit({ type: 'ready', path: '' });
|
||||||
|
console.log(`[smartwatch] Watcher started with ${this.watchers.size} active watcher(s)`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.events$.next({ type: 'error', path: '', error });
|
console.error('[smartwatch] Failed to start watcher:', error);
|
||||||
|
this.safeEmit({ type: 'error', path: '', error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
|
console.log('[smartwatch] Stopping watcher...');
|
||||||
this._isWatching = false;
|
this._isWatching = false;
|
||||||
this.writeStabilizer.cancelAll();
|
|
||||||
|
// Stop health check monitoring
|
||||||
|
this.stopHealthCheck();
|
||||||
|
|
||||||
|
// Cancel all pending debounced emits
|
||||||
|
for (const timeout of this.pendingEmits.values()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
this.pendingEmits.clear();
|
||||||
|
|
||||||
// Close all watchers
|
// Close all watchers
|
||||||
for (const [watchPath, watcher] of this.watchers) {
|
for (const [watchPath, watcher] of this.watchers) {
|
||||||
|
console.log(`[smartwatch] Closing watcher for: ${watchPath}`);
|
||||||
watcher.close();
|
watcher.close();
|
||||||
}
|
}
|
||||||
this.watchers.clear();
|
this.watchers.clear();
|
||||||
this.watchedFiles.clear();
|
this.watchedFiles.clear();
|
||||||
this.recentEvents.clear();
|
|
||||||
|
// Clear restart tracking state
|
||||||
|
this.restartDelays.clear();
|
||||||
|
this.restartAttempts.clear();
|
||||||
|
|
||||||
|
console.log('[smartwatch] Watcher stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,31 +227,67 @@ export class NodeWatcher implements IWatcher {
|
|||||||
);
|
);
|
||||||
|
|
||||||
watcher.on('error', (error) => {
|
watcher.on('error', (error) => {
|
||||||
this.events$.next({ type: 'error', path: watchPath, error });
|
console.error(`[smartwatch] FSWatcher error event on ${watchPath}:`, error);
|
||||||
|
this.safeEmit({ type: 'error', path: watchPath, error });
|
||||||
|
if (this._isWatching) {
|
||||||
|
this.restartWatcher(watchPath, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle 'close' event - fs.watch can close without error
|
||||||
|
watcher.on('close', () => {
|
||||||
|
console.warn(`[smartwatch] FSWatcher closed unexpectedly for ${watchPath}`);
|
||||||
|
if (this._isWatching) {
|
||||||
|
this.restartWatcher(watchPath, new Error('Watcher closed unexpectedly'));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watchers.set(watchPath, watcher);
|
this.watchers.set(watchPath, watcher);
|
||||||
|
console.log(`[smartwatch] Started watching: ${watchPath}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.events$.next({ type: 'error', path: watchPath, error });
|
console.error(`[smartwatch] Failed to watch path ${watchPath}:`, error);
|
||||||
|
this.safeEmit({ type: 'error', path: watchPath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle raw fs.watch events and normalize them
|
* Handle raw fs.watch events - debounce and normalize them
|
||||||
*/
|
*/
|
||||||
private async handleFsEvent(
|
private handleFsEvent(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
eventType: 'rename' | 'change' | string
|
eventType: 'rename' | 'change' | string
|
||||||
): Promise<void> {
|
): void {
|
||||||
const fullPath = path.join(basePath, filename);
|
const fullPath = path.join(basePath, filename);
|
||||||
|
|
||||||
// Throttle duplicate events
|
// Skip temporary files created by editors (atomic saves)
|
||||||
if (!this.shouldEmit(fullPath, eventType)) {
|
if (this.isTemporaryFile(fullPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce: cancel any pending emit for this file
|
||||||
|
const existing = this.pendingEmits.get(fullPath);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule debounced emit
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingEmits.delete(fullPath);
|
||||||
|
this.emitFileEvent(fullPath, eventType);
|
||||||
|
}, this.options.debounceMs);
|
||||||
|
|
||||||
|
this.pendingEmits.set(fullPath, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit the actual file event after debounce
|
||||||
|
*/
|
||||||
|
private async emitFileEvent(
|
||||||
|
fullPath: string,
|
||||||
|
eventType: 'rename' | 'change' | string
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const stats = await this.statSafe(fullPath);
|
const stats = await this.statSafe(fullPath);
|
||||||
|
|
||||||
@@ -129,33 +298,23 @@ export class NodeWatcher implements IWatcher {
|
|||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
if (!this.watchedFiles.has(fullPath)) {
|
if (!this.watchedFiles.has(fullPath)) {
|
||||||
this.watchedFiles.add(fullPath);
|
this.watchedFiles.add(fullPath);
|
||||||
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
this.safeEmit({ type: 'addDir', path: fullPath, stats });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Wait for write to stabilize before emitting
|
|
||||||
try {
|
|
||||||
const stableStats = await this.writeStabilizer.waitForWriteFinish(fullPath);
|
|
||||||
const wasWatched = this.watchedFiles.has(fullPath);
|
const wasWatched = this.watchedFiles.has(fullPath);
|
||||||
this.watchedFiles.add(fullPath);
|
this.watchedFiles.add(fullPath);
|
||||||
this.events$.next({
|
this.safeEmit({
|
||||||
type: wasWatched ? 'change' : 'add',
|
type: wasWatched ? 'change' : 'add',
|
||||||
path: fullPath,
|
path: fullPath,
|
||||||
stats: stableStats
|
stats
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
// File was deleted during stabilization
|
|
||||||
if (this.watchedFiles.has(fullPath)) {
|
|
||||||
this.watchedFiles.delete(fullPath);
|
|
||||||
this.events$.next({ type: 'unlink', path: fullPath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File doesn't exist - it was deleted
|
// File doesn't exist - it was deleted
|
||||||
if (this.watchedFiles.has(fullPath)) {
|
if (this.watchedFiles.has(fullPath)) {
|
||||||
const wasDir = this.isKnownDirectory(fullPath);
|
const wasDir = this.isKnownDirectory(fullPath);
|
||||||
this.watchedFiles.delete(fullPath);
|
this.watchedFiles.delete(fullPath);
|
||||||
this.events$.next({
|
this.safeEmit({
|
||||||
type: wasDir ? 'unlinkDir' : 'unlink',
|
type: wasDir ? 'unlinkDir' : 'unlink',
|
||||||
path: fullPath
|
path: fullPath
|
||||||
});
|
});
|
||||||
@@ -164,28 +323,22 @@ export class NodeWatcher implements IWatcher {
|
|||||||
} else if (eventType === 'change') {
|
} else if (eventType === 'change') {
|
||||||
// File was modified
|
// File was modified
|
||||||
if (stats && !stats.isDirectory()) {
|
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);
|
const wasWatched = this.watchedFiles.has(fullPath);
|
||||||
if (!wasWatched) {
|
if (!wasWatched) {
|
||||||
// This is actually an 'add' - file wasn't being watched before
|
// This is actually an 'add' - file wasn't being watched before
|
||||||
this.watchedFiles.add(fullPath);
|
this.watchedFiles.add(fullPath);
|
||||||
this.events$.next({ type: 'add', path: fullPath, stats: stableStats });
|
this.safeEmit({ type: 'add', path: fullPath, stats });
|
||||||
} else {
|
} else {
|
||||||
this.events$.next({ type: 'change', path: fullPath, stats: stableStats });
|
this.safeEmit({ type: 'change', path: fullPath, stats });
|
||||||
}
|
}
|
||||||
} catch {
|
} else if (!stats && this.watchedFiles.has(fullPath)) {
|
||||||
// File was deleted during write
|
// File was deleted
|
||||||
if (this.watchedFiles.has(fullPath)) {
|
|
||||||
this.watchedFiles.delete(fullPath);
|
this.watchedFiles.delete(fullPath);
|
||||||
this.events$.next({ type: 'unlink', path: fullPath });
|
this.safeEmit({ type: 'unlink', path: fullPath });
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.events$.next({ type: 'error', path: fullPath, error });
|
this.safeEmit({ type: 'error', path: fullPath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +355,12 @@ export class NodeWatcher implements IWatcher {
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
|
|
||||||
|
// Skip temp files during initial scan too
|
||||||
|
if (this.isTemporaryFile(fullPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const stats = await this.statSafe(fullPath);
|
const stats = await this.statSafe(fullPath);
|
||||||
|
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
@@ -210,16 +369,16 @@ export class NodeWatcher implements IWatcher {
|
|||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
this.watchedFiles.add(fullPath);
|
this.watchedFiles.add(fullPath);
|
||||||
this.events$.next({ type: 'addDir', path: fullPath, stats });
|
this.safeEmit({ type: 'addDir', path: fullPath, stats });
|
||||||
await this.scanDirectory(fullPath, depth + 1);
|
await this.scanDirectory(fullPath, depth + 1);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
this.watchedFiles.add(fullPath);
|
this.watchedFiles.add(fullPath);
|
||||||
this.events$.next({ type: 'add', path: fullPath, stats });
|
this.safeEmit({ type: 'add', path: fullPath, stats });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
if (error.code !== 'ENOENT' && error.code !== 'EACCES') {
|
||||||
this.events$.next({ type: 'error', path: dirPath, error });
|
this.safeEmit({ type: 'error', path: dirPath, error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,12 +388,16 @@ export class NodeWatcher implements IWatcher {
|
|||||||
*/
|
*/
|
||||||
private async statSafe(filePath: string): Promise<fs.Stats | null> {
|
private async statSafe(filePath: string): Promise<fs.Stats | null> {
|
||||||
try {
|
try {
|
||||||
if (this.options.followSymlinks) {
|
return await (this.options.followSymlinks
|
||||||
return await fs.promises.stat(filePath);
|
? fs.promises.stat(filePath)
|
||||||
} else {
|
: fs.promises.lstat(filePath));
|
||||||
return await fs.promises.lstat(filePath);
|
} catch (error: any) {
|
||||||
|
// Only silently return null for expected "file doesn't exist" errors
|
||||||
|
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
// Log other errors (permission, I/O) but still return null
|
||||||
|
console.warn(`[smartwatch] statSafe warning for ${filePath}: ${error.code} - ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,31 +414,4 @@ export class NodeWatcher implements IWatcher {
|
|||||||
}
|
}
|
||||||
return false;
|
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