Compare commits

..

4 Commits
v1.1.1 ... main

Author SHA1 Message Date
fdd6281f0b v3.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-30 03:04:49 +00:00
0f17be179c 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 2025-11-30 03:04:49 +00:00
aab3ce213b v1.2.0
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-29 21:05:51 +00:00
6d02ef3ddc feat(core): Migrate to chokidar 5.x, add picomatch filtering and update test/dev dependencies 2025-11-29 21:05:51 +00:00
16 changed files with 3799 additions and 2628 deletions

View File

@@ -1,5 +1,26 @@
# 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

View File

@@ -8,9 +8,9 @@
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartchok",
"description": "A smart wrapper for chokidar to facilitate file watching with enhanced features.",
"npmPackagename": "@push.rocks/smartchokidar",
"gitrepo": "smartwatch",
"description": "A smart wrapper for chokidar 5.x with glob pattern support and enhanced file watching features.",
"npmPackagename": "@push.rocks/smartwatch",
"license": "MIT",
"keywords": [
"file watching",

View File

@@ -1,8 +1,8 @@
{
"name": "@push.rocks/smartchok",
"version": "1.1.1",
"version": "3.0.0",
"private": false,
"description": "A smart wrapper for chokidar 4.x with glob pattern support and enhanced features.",
"description": "A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
@@ -21,20 +21,22 @@
"url": "https://gitlab.com/push.rocks/smartchok/issues"
},
"homepage": "https://code.foss.global/push.rocks/smartchok",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@push.rocks/lik": "^6.0.2",
"@push.rocks/smartpromise": "^4.0.2",
"@push.rocks/smartrx": "^3.0.2",
"chokidar": "^4.0.3",
"picomatch": "^4.0.2"
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartenv": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrx": "^3.0.10",
"picomatch": "^4.0.3"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.66",
"@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^1.0.77",
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@push.rocks/smartfile": "^11.0.4",
"@push.rocks/tapbundle": "^5.0.8",
"@types/node": "^20.11.8"
"@types/node": "^24.10.1"
},
"files": [
"ts/**/*",
@@ -54,15 +56,18 @@
"type": "module",
"keywords": [
"file watching",
"chokidar",
"filesystem",
"observable",
"typescript",
"node.js",
"deno",
"bun",
"cross-runtime",
"development tool",
"file system events",
"real-time",
"watch files"
"watch files",
"glob patterns"
],
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}

5064
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,82 @@
# smartchok - Technical Hints
## Chokidar 4.x Migration (2024)
## Native File Watching (v2.0.0+)
The module has been migrated from `@tempfix/watcher` to `chokidar` 4.x. Key changes:
The module now uses native file watching APIs instead of chokidar, providing cross-runtime support for Node.js, Deno, and Bun.
### Architecture
```
ts/
├── smartwatch.classes.smartwatch.ts # Main Smartwatch class
├── smartwatch.plugins.ts # Dependencies (smartenv, picomatch, etc.)
├── watchers/
│ ├── index.ts # Factory with runtime detection
│ ├── interfaces.ts # IWatcher interface and types
│ ├── watcher.node.ts # Node.js/Bun implementation (fs.watch)
│ └── watcher.deno.ts # Deno implementation (Deno.watchFs)
└── utils/
└── write-stabilizer.ts # awaitWriteFinish polling implementation
```
### Runtime Detection
Uses `@push.rocks/smartenv` v6.x for runtime detection:
- **Node.js/Bun**: Uses native `fs.watch()` with `{ recursive: true }`
- **Deno**: Uses `Deno.watchFs()` async iterable
### Dependencies
- **Removed**: `@tempfix/watcher` (a fork of fabiospampinato/watcher)
- **Added**: `chokidar` 4.x and `picomatch`
- **picomatch**: Glob pattern matching (zero deps, well-maintained)
- **@push.rocks/smartenv**: Runtime detection (Node.js, Deno, Bun)
- **@push.rocks/smartrx**: RxJS Subject/Observable management
- **@push.rocks/smartpromise**: Deferred promise utilities
- **@push.rocks/lik**: Stringmap for pattern storage
### Why picomatch?
Chokidar 4.x removed built-in glob pattern support. We use `picomatch` to maintain backward compatibility and provide glob pattern matching functionality.
### Implementation Details
1. **Glob pattern extraction**: The `getGlobBase()` method extracts base directories from glob patterns
2. **Pattern matching**: Each glob pattern is compiled to a picomatch matcher function
3. **Event filtering**: File system events are filtered based on glob patterns before being emitted
4. **Path normalization**: Paths are normalized to handle different formats (with/without leading ./)
Native file watching APIs don't support glob patterns. Picomatch provides glob pattern matching with:
- Zero dependencies
- 164M+ weekly downloads
- Excellent security profile
- Full glob syntax support
### Event Handling
Chokidar 4.x events are mapped 1:1 with smartchok events:
- `add`, `change`, `unlink`: File events
- `addDir`, `unlinkDir`: Directory events
- `error`: Error events
- `raw`: Raw events from underlying watchers
- `ready`: Emitted when initial scan is complete
Native events are normalized to a consistent interface:
| Node.js/Bun Event | Deno Event | Normalized Event |
|-------------------|------------|------------------|
| `rename` (file exists) | `create` | `add` |
| `rename` (file gone) | `remove` | `unlink` |
| `change` | `modify` | `change` |
### awaitWriteFinish Implementation
The `WriteStabilizer` class replaces chokidar's built-in write stabilization:
- Polls file size until stable (configurable threshold: 300ms default)
- Configurable poll interval (100ms default)
- Handles file deletion during write detection
### Platform Requirements
- **Node.js 20+**: Required for native recursive watching on all platforms
- **Deno**: Works on all versions with `Deno.watchFs()`
- **Bun**: Uses Node.js compatibility layer
### Testing
All existing tests pass without modification, confirming backward compatibility is maintained.
```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
View File

@@ -1,117 +1,191 @@
# @push.rocks/smartchok
smart wrapper for chokidar
# @push.rocks/smartwatch
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 the package by running the following command in your terminal:
```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
The `@push.rocks/smartchok` package provides a convenient and smart wrapper around the popular `chokidar` library (v4.x) for file watching with enhanced features such as observable support for filesystem events and glob pattern matching support. 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
To start using `smartchok`, first import it into your TypeScript file:
```typescript
import { Smartchok } from '@push.rocks/smartchok';
```
import { Smartwatch } from '@push.rocks/smartwatch';
#### Initializing Smartchok
Create an instance of `Smartchok` by specifying an array of glob patterns to watch:
```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
// Create a watcher with glob patterns
const watcher = new Smartwatch([
'./src/**/*.ts', // Watch all TypeScript files in src
'./public/assets/**/*' // Watch all files in public/assets
]);
// 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
// Add additional files or patterns
smartchokInstance.add(['./tests/**/*.spec.ts']);
// 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');
// Get an observable for file changes
const changeObservable = await watcher.getObservableFor('change');
changeObservable.subscribe({
next: ([path, stats]) => {
console.log(`File changed: ${path}`);
},
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), 'addDir', 'unlinkDir', 'error', 'ready', and 'raw'. Since chokidar 4.x no longer supports glob patterns natively, smartchok handles glob pattern matching internally using picomatch.
### 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
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` 4.x, 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. The package maintains backward compatibility by implementing glob pattern support that was removed in chokidar 4.x.
```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

View File

@@ -1,5 +1,5 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as smartchok from '../ts/index.js';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartwatch from '../ts/index.js';
import * as smartfile from '@push.rocks/smartfile';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
@@ -11,22 +11,22 @@ if (process.env.CI) {
process.exit(0);
}
let testSmartchok: smartchok.Smartchok;
let testSmartwatch: smartwatch.Smartwatch;
let testAddObservable: smartrx.rxjs.Observable<[string, fs.Stats]>;
let testSubscription: smartrx.rxjs.Subscription;
tap.test('should create a new instance', async () => {
testSmartchok = new smartchok.Smartchok([]);
expect(testSmartchok).toBeInstanceOf(smartchok.Smartchok);
testSmartwatch = new smartwatch.Smartwatch([]);
expect(testSmartwatch).toBeInstanceOf(smartwatch.Smartwatch);
});
tap.test('should add some files to watch and start', async () => {
testSmartchok.add(['./test/**/*.txt']);
await testSmartchok.start()
testSmartchok.add(['./test/**/*.md']);
testSmartwatch.add(['./test/**/*.txt']);
await testSmartwatch.start()
testSmartwatch.add(['./test/**/*.md']);
});
tap.test('should get an observable for a certain event', async () => {
await testSmartchok.getObservableFor('add').then(async (observableArg) => {
await testSmartwatch.getObservableFor('add').then(async (observableArg) => {
testAddObservable = observableArg;
});
});
@@ -44,7 +44,7 @@ tap.test('should register an add operation', async () => {
tap.test('should stop the watch process', async (tools) => {
await tools.delayFor(10000);
testSmartchok.stop();
testSmartwatch.stop();
});
tap.start();
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartchok',
version: '1.1.1',
description: 'A smart wrapper for chokidar 4.x with glob pattern support and enhanced features.'
version: '3.0.0',
description: 'A cross-runtime file watcher with glob pattern support for Node.js, Deno, and Bun.'
}

View File

@@ -1 +1 @@
export * from './smartchok.classes.smartchok.js';
export * from './smartwatch.classes.smartwatch.js';

View File

@@ -1,7 +1,8 @@
import * as plugins from './smartchok.plugins.js';
import * as plugins from './smartwatch.plugins.js';
import { Stringmap } from '@push.rocks/lik';
import { createWatcher, type IWatcher, type IWatchEvent, type TWatchEventType } from './watchers/index.js';
export type TSmartchokStatus = 'idle' | 'starting' | 'watching';
export type TSmartwatchStatus = 'idle' | 'starting' | 'watching';
export type TFsEvent =
| 'add'
| 'addDir'
@@ -13,22 +14,31 @@ export type TFsEvent =
| 'raw';
/**
* Smartchok allows easy wathcing of files
* Smartwatch allows easy watching of files
* Uses native file watching APIs (Node.js fs.watch, Deno.watchFs) for cross-runtime support
*/
export class Smartchok {
export class Smartwatch {
public watchStringmap = new Stringmap();
public status: TSmartchokStatus = 'idle';
private watcher: plugins.chokidar.FSWatcher;
public status: TSmartwatchStatus = 'idle';
private watcher: IWatcher | null = null;
private globPatterns: string[] = [];
private globMatchers: Map<string, (path: string) => boolean> = new Map();
private watchingDeferred = plugins.smartpromise.defer<void>(); // used to run things when watcher is initialized
private eventObservablemap = new plugins.smartrx.Observablemap(); // register one observable per event
private watchingDeferred = plugins.smartpromise.defer<void>();
// Event subjects for each event type
private eventSubjects: Map<TFsEvent, plugins.smartrx.rxjs.Subject<[string, plugins.fs.Stats | undefined]>> = new Map();
/**
* constructor of class smartchok
* constructor of class Smartwatch
*/
constructor(watchArrayArg: string[]) {
this.watchStringmap.addStringArray(watchArrayArg);
// Initialize subjects for each event type
const eventTypes: TFsEvent[] = ['add', 'addDir', 'change', 'error', 'unlink', 'unlinkDir', 'ready', 'raw'];
for (const eventType of eventTypes) {
this.eventSubjects.set(eventType, new plugins.smartrx.rxjs.Subject());
}
}
private getGlobBase(globPattern: string) {
@@ -80,11 +90,12 @@ export class Smartchok {
): Promise<plugins.smartrx.rxjs.Observable<[string, plugins.fs.Stats]>> {
const done = plugins.smartpromise.defer<plugins.smartrx.rxjs.Observable<any>>();
this.watchingDeferred.promise.then(() => {
const eventObservable = this.eventObservablemap.getSubjectForEmitterEvent(
this.watcher,
fsEvent
);
done.resolve(eventObservable);
const subject = this.eventSubjects.get(fsEvent);
if (subject) {
done.resolve(subject.asObservable());
} else {
done.reject(new Error(`Unknown event type: ${fsEvent}`));
}
});
return done.promise;
}
@@ -93,8 +104,7 @@ export class Smartchok {
* starts the watcher
* @returns Promise<void>
*/
public start(): Promise<void> {
const done = plugins.smartpromise.defer<void>();
public async start(): Promise<void> {
this.status = 'starting';
// Store original glob patterns and create matchers
@@ -113,61 +123,62 @@ export class Smartchok {
this.globMatchers.set(pattern, matcher);
});
// Convert Set to Array for chokidar
// Convert Set to Array for the watcher
const watchPaths = Array.from(basePaths);
console.log('Base paths to watch:', watchPaths);
this.watcher = plugins.chokidar.watch(watchPaths, {
persistent: true,
ignoreInitial: false,
followSymlinks: false,
// Create the platform-appropriate watcher
this.watcher = await createWatcher({
basePaths: watchPaths,
depth: 4,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100
},
ignored: (path: string, stats?: plugins.fs.Stats) => {
// Don't filter during initialization - let chokidar watch everything
// We'll filter events as they come in
return false;
followSymlinks: false,
stabilityThreshold: 300,
pollInterval: 100
});
// Subscribe to watcher events and dispatch to appropriate subjects
this.watcher.events$.subscribe((event: IWatchEvent) => {
this.handleWatchEvent(event);
});
// Start the watcher
await this.watcher.start();
this.status = 'watching';
this.watchingDeferred.resolve();
}
/**
* Handle events from the native watcher
*/
private handleWatchEvent(event: IWatchEvent): void {
// Handle ready event
if (event.type === 'ready') {
const subject = this.eventSubjects.get('ready');
if (subject) {
subject.next(['', undefined]);
}
});
return;
}
// Set up event handlers with glob filtering
const fileEvents: Array<'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'> =
['add', 'addDir', 'change', 'unlink', 'unlinkDir'];
// Handle file events
fileEvents.forEach(eventName => {
this.watcher.on(eventName, (path: string, stats?: plugins.fs.Stats) => {
// Only emit event if the path matches our glob patterns
if (this.shouldWatchPath(path)) {
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, eventName)
.next([path, stats]);
}
});
});
// Handle error events
this.watcher.on('error', (error: Error) => {
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'error')
.next([error, undefined]);
});
// Handle raw events
this.watcher.on('raw', (eventType: string, path: string, details: any) => {
if (this.shouldWatchPath(path)) {
this.eventObservablemap.getSubjectForEmitterEvent(this.watcher, 'raw')
.next([path, undefined]);
// Handle error event
if (event.type === 'error') {
const subject = this.eventSubjects.get('error');
if (subject) {
subject.next([event.error?.message || 'Unknown error', undefined]);
}
});
return;
}
this.watcher.on('ready', () => {
this.status = 'watching';
this.watchingDeferred.resolve();
done.resolve();
});
return done.promise;
// Filter file/directory events by glob patterns
if (!this.shouldWatchPath(event.path)) {
return;
}
const subject = this.eventSubjects.get(event.type as TFsEvent);
if (subject) {
subject.next([event.path, event.stats]);
}
}
/**
@@ -175,8 +186,12 @@ export class Smartchok {
*/
public async stop() {
const closeWatcher = async () => {
await this.watcher.close();
if (this.watcher) {
await this.watcher.stop();
this.watcher = null;
}
};
if (this.status === 'watching') {
console.log('closing while watching');
await closeWatcher();
@@ -184,6 +199,8 @@ export class Smartchok {
await this.watchingDeferred.promise;
await closeWatcher();
}
this.status = 'idle';
}
/**

View File

@@ -11,18 +11,18 @@ export {
import * as lik from '@push.rocks/lik';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
import { Smartenv } from '@push.rocks/smartenv';
export {
lik,
smartpromise,
smartrx
smartrx,
Smartenv
}
// thirdparty scope
import * as chokidar from 'chokidar';
import picomatch from 'picomatch';
export {
chokidar,
picomatch,
}

View 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
View 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
View 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
View 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
View 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;
}
}