@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 +4 -4
- package/dist/auto-sync.d.ts +7 -3
- package/dist/auto-sync.js +102 -49
- package/dist/symlink-mount.d.ts +2 -2
- package/package.json +2 -2
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
|
-
/**
|
|
74
|
-
|
|
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,
|
|
94
|
+
launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } });
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
How it works:
|
|
98
|
-
-
|
|
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
|
|
package/dist/auto-sync.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
|
3
|
+
import watcher from '@parcel/watcher';
|
|
4
4
|
export function startAutoSync(ctx, opts = {}) {
|
|
5
5
|
const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
|
|
6
|
-
const
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/dist/symlink-mount.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
"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
|
-
"
|
|
18
|
+
"@parcel/watcher": "^2.5.6",
|
|
19
19
|
"ignore": "^7.0.5"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|