@relayfile/local-mount 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,8 +70,8 @@ By default, `launchOnMount` keeps the mount and project directory in sync contin
70
70
  interface AutoSyncOptions {
71
71
  /** Full-reconcile interval as a safety net. Default: 10_000 ms. */
72
72
  scanIntervalMs?: number;
73
- /** chokidar `awaitWriteFinish` stability threshold. Default: 200 ms. */
74
- writeFinishMs?: number;
73
+ /** Per-path event debounce in ms. Default: 50 ms. */
74
+ debounceMs?: number;
75
75
  /** Invoked on sync errors. Defaults to swallowing them. */
76
76
  onError?: (err: Error) => void;
77
77
  }
@@ -91,11 +91,11 @@ Control it from `launchOnMount`:
91
91
  launchOnMount({ /* ... */, autoSync: false });
92
92
 
93
93
  // Tune it.
94
- launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, writeFinishMs: 100 } });
94
+ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } });
95
95
  ```
96
96
 
97
97
  How it works:
98
- - chokidar watches both the mount and the project tree
98
+ - [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
99
99
  - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
100
100
  - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
101
101
 
@@ -5,7 +5,8 @@ export interface AutoSyncContext {
5
5
  /**
6
6
  * Directory-only ignore patterns (ending in `/`) must only match when the
7
7
  * path is a directory. Callers that know the path's type pass `isDirectory`;
8
- * callers that don't (chokidar's prune filter) should check both forms.
8
+ * callers that don't should omit the second argument and fall back to the
9
+ * file-form check.
9
10
  */
10
11
  isIgnored: (relPosix: string, isDirectory?: boolean) => boolean;
11
12
  isReadonly: (relPosix: string) => boolean;
@@ -14,8 +15,11 @@ export interface AutoSyncContext {
14
15
  export interface AutoSyncOptions {
15
16
  /** Full-reconcile interval as a safety net. Default: 10_000ms. */
16
17
  scanIntervalMs?: number;
17
- /** chokidar awaitWriteFinish stabilityThreshold in ms. Default: 200. */
18
- writeFinishMs?: number;
18
+ /**
19
+ * Per-path event debounce in ms. Rapid watcher events for the same path
20
+ * are coalesced into a single sync. Default: 50.
21
+ */
22
+ debounceMs?: number;
19
23
  /** Invoked on errors during sync — logged by default consumer. */
20
24
  onError?: (err: Error) => void;
21
25
  }
package/dist/auto-sync.js CHANGED
@@ -1,15 +1,17 @@
1
1
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
- import chokidar from 'chokidar';
3
+ import watcher from '@parcel/watcher';
4
4
  export function startAutoSync(ctx, opts = {}) {
5
5
  const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
6
- const writeFinishMs = opts.writeFinishMs ?? 200;
6
+ const debounceMs = opts.debounceMs ?? 50;
7
7
  const onError = opts.onError ?? (() => { });
8
8
  const state = new Map();
9
9
  primeState(state, ctx);
10
10
  let syncing = false;
11
11
  let pending = false;
12
+ let stopped = false;
12
13
  let totalChanges = 0;
14
+ const pendingDebounces = new Map();
13
15
  const runReconcile = async () => {
14
16
  if (syncing) {
15
17
  pending = true;
@@ -54,32 +56,68 @@ export function startAutoSync(ctx, opts = {}) {
54
56
  onError(err);
55
57
  }
56
58
  };
57
- const makeWatcher = (root) => {
58
- const watcher = chokidar.watch(root, {
59
- ignoreInitial: true,
60
- persistent: true,
61
- followSymlinks: false,
62
- awaitWriteFinish: {
63
- stabilityThreshold: writeFinishMs,
64
- pollInterval: 50,
65
- },
66
- ignored: (candidate, stats) => shouldChokidarIgnore(candidate, root, ctx, stats),
67
- });
68
- const onEvent = (p) => syncPathFromRoot(root, p);
69
- watcher.on('add', onEvent);
70
- watcher.on('change', onEvent);
71
- watcher.on('unlink', onEvent);
72
- watcher.on('error', (err) => onError(err));
73
- const ready = new Promise((resolve) => {
74
- watcher.once('ready', () => resolve());
75
- });
76
- return { watcher, ready };
59
+ const schedulePathSync = (root, absPath) => {
60
+ // Once stop() has begun, refuse to schedule new timers. Watcher
61
+ // callbacks can still fire during the unsubscribe await (native
62
+ // backends deliver queued events asynchronously); without this guard
63
+ // those events would create timers that outlive stop() and do file
64
+ // work against a mount the caller may have already cleaned up.
65
+ if (stopped)
66
+ return;
67
+ // Coalesce bursts of events for the same path. The reconcile path
68
+ // re-checks content via mtime+bytes, so a partial-write event that
69
+ // races a later write is harmless.
70
+ const existing = pendingDebounces.get(absPath);
71
+ if (existing)
72
+ clearTimeout(existing);
73
+ const t = setTimeout(() => {
74
+ pendingDebounces.delete(absPath);
75
+ syncPathFromRoot(root, absPath);
76
+ }, debounceMs);
77
+ pendingDebounces.set(absPath, t);
77
78
  };
78
- const mount = makeWatcher(ctx.realMountDir);
79
- const project = makeWatcher(ctx.realProjectDir);
80
- const mountWatcher = mount.watcher;
81
- const projectWatcher = project.watcher;
82
- const watchersReady = Promise.all([mount.ready, project.ready]);
79
+ const ignoreGlobs = buildIgnoreGlobs(ctx);
80
+ const subscribeTo = (root) => watcher.subscribe(root, (err, events) => {
81
+ if (err) {
82
+ onError(err);
83
+ return;
84
+ }
85
+ for (const ev of events) {
86
+ schedulePathSync(root, ev.path);
87
+ }
88
+ }, { ignore: ignoreGlobs });
89
+ let mountSub;
90
+ let projectSub;
91
+ // Subscribe in parallel but track each outcome independently. With
92
+ // Promise.all, a failure on one side would reject before the other's
93
+ // assignment ran and leak the succeeded subscription. allSettled lets us
94
+ // tear down whichever fulfilled before re-throwing the first failure.
95
+ const watchersReady = (async () => {
96
+ const [mountResult, projectResult] = await Promise.allSettled([
97
+ subscribeTo(ctx.realMountDir),
98
+ subscribeTo(ctx.realProjectDir),
99
+ ]);
100
+ if (mountResult.status === 'fulfilled')
101
+ mountSub = mountResult.value;
102
+ if (projectResult.status === 'fulfilled')
103
+ projectSub = projectResult.value;
104
+ if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') {
105
+ return;
106
+ }
107
+ await Promise.allSettled([
108
+ mountSub?.unsubscribe(),
109
+ projectSub?.unsubscribe(),
110
+ ]);
111
+ mountSub = undefined;
112
+ projectSub = undefined;
113
+ throw mountResult.status === 'rejected'
114
+ ? mountResult.reason
115
+ : projectResult.reason;
116
+ })();
117
+ // If subscription setup fails, surface via onError rather than an unhandled
118
+ // rejection. stop() still awaits the same promise and will observe the
119
+ // rejection after the cleanup above has already run.
120
+ watchersReady.catch((err) => onError(err));
83
121
  const interval = setInterval(() => {
84
122
  void runReconcile();
85
123
  }, scanIntervalMs);
@@ -87,8 +125,27 @@ export function startAutoSync(ctx, opts = {}) {
87
125
  interval.unref?.();
88
126
  return {
89
127
  async stop() {
128
+ // Flip the flag first so any watcher callbacks delivered during the
129
+ // awaits below refuse to schedule new timers.
130
+ stopped = true;
90
131
  clearInterval(interval);
91
- await Promise.all([mountWatcher.close(), projectWatcher.close()]);
132
+ try {
133
+ await watchersReady;
134
+ }
135
+ catch {
136
+ // Setup failed and already cleaned up any partial subscription;
137
+ // mountSub / projectSub were reset to undefined before the throw.
138
+ }
139
+ await Promise.allSettled([
140
+ mountSub?.unsubscribe(),
141
+ projectSub?.unsubscribe(),
142
+ ]);
143
+ // Clear debounces *after* unsubscribe resolves: any timer scheduled
144
+ // between stop() being called and the watcher actually quiescing is
145
+ // gathered here, so none fire after stop() returns.
146
+ for (const t of pendingDebounces.values())
147
+ clearTimeout(t);
148
+ pendingDebounces.clear();
92
149
  // Drain any pending work so callers can rely on "stopped means quiesced".
93
150
  await runReconcile();
94
151
  },
@@ -99,6 +156,23 @@ export function startAutoSync(ctx, opts = {}) {
99
156
  },
100
157
  };
101
158
  }
159
+ function buildIgnoreGlobs(ctx) {
160
+ // @parcel/watcher matches globs against absolute paths via globset. For each
161
+ // excluded directory name, ignore both the directory itself and everything
162
+ // beneath it, anywhere under the watched root. The `isExcluded` predicate is
163
+ // driven by a Set of directory names, so we probe a small set of common
164
+ // exclusions rather than introspecting it. The in-handler `isSyncCandidate`
165
+ // filter is authoritative — this is just a perf hint so the watcher doesn't
166
+ // recurse into heavy trees like node_modules or .git.
167
+ const globs = [];
168
+ const candidates = ['.git', 'node_modules', 'dist', 'build', '.next', '.cache'];
169
+ for (const name of candidates) {
170
+ if (ctx.isExcluded(name)) {
171
+ globs.push(`**/${name}`, `**/${name}/**`);
172
+ }
173
+ }
174
+ return globs;
175
+ }
102
176
  function primeState(state, ctx) {
103
177
  // Record current mtimes for every file that exists in both trees with the
104
178
  // same content. Files that differ are left out so the first reconcile sees
@@ -443,24 +517,3 @@ function walk(root, ctx, visit) {
443
517
  }
444
518
  }
445
519
  }
446
- function shouldChokidarIgnore(candidate, root, ctx, stats) {
447
- if (candidate === root)
448
- return false;
449
- const rel = path.relative(root, candidate);
450
- if (rel === '' || rel.startsWith('..'))
451
- return false;
452
- const relPosix = rel.split(path.sep).join('/');
453
- if (ctx.isExcluded(relPosix))
454
- return true;
455
- if (ctx.isReservedFile(relPosix))
456
- return true;
457
- // chokidar calls this filter twice: first without stats (pre-stat prune),
458
- // then again with stats once it knows the entry type. Only apply the
459
- // directory-form match when we have stats confirming it's a directory,
460
- // otherwise a directory-only pattern like `cache/` would wrongly prune a
461
- // same-named file.
462
- if (stats) {
463
- return ctx.isIgnored(relPosix, stats.isDirectory());
464
- }
465
- return ctx.isIgnored(relPosix);
466
- }
@@ -14,8 +14,8 @@ export interface SymlinkMountHandle {
14
14
  syncBack(): Promise<number>;
15
15
  /**
16
16
  * Start bidirectional auto-sync: watches both the mount and project trees
17
- * with chokidar and runs a full reconcile every `scanIntervalMs` as a
18
- * safety net. Returns a handle you must `stop()` before teardown.
17
+ * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
18
+ * as a safety net. Returns a handle you must `stop()` before teardown.
19
19
  */
20
20
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
21
21
  cleanup(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "prepublishOnly": "npm run build"
16
16
  },
17
17
  "dependencies": {
18
- "chokidar": "^4.0.3",
18
+ "@parcel/watcher": "^2.5.6",
19
19
  "ignore": "^7.0.5"
20
20
  },
21
21
  "devDependencies": {