First Version
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Singleton client for the `/api/file/watch` WebSocket.
|
||||
*
|
||||
* One persistent connection for the whole app. Multi-subscriber: if several
|
||||
* components ask to watch the same path, only one subscribe message is sent
|
||||
* over the wire; the OS watcher is shared. Unsubscribe ref-counts down and
|
||||
* only sends `unsubscribe` when the last consumer for a path goes away.
|
||||
*
|
||||
* Auto-reconnects on close (2 s backoff) and re-issues every active
|
||||
* subscription on reconnect, so consumers don't have to handle disconnects.
|
||||
*
|
||||
* Usage:
|
||||
* import { fileWatcher } from '../lib/file-watcher.js';
|
||||
* const unsub = await fileWatcher.watch('docs/index.md', (path) => { ... });
|
||||
* unsub(); // stop watching
|
||||
*/
|
||||
class FileWatcher {
|
||||
constructor() {
|
||||
this._ws = null;
|
||||
this._subscriptions = new Map(); // path -> Set<callback>
|
||||
this._reconnectTimer = null;
|
||||
this._connectPromise = null;
|
||||
}
|
||||
|
||||
_ensureConnected() {
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||
if (this._connectPromise) return this._connectPromise;
|
||||
|
||||
this._connectPromise = new Promise((resolve, reject) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/file/watch`);
|
||||
this._ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this._connectPromise = null;
|
||||
// Re-subscribe everything (covers both first connect and reconnect).
|
||||
for (const path of this._subscriptions.keys()) {
|
||||
ws.send(JSON.stringify({ op: 'subscribe', path }));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(e.data); } catch { return; }
|
||||
if (msg.type === 'changed') {
|
||||
const cbs = this._subscriptions.get(msg.path);
|
||||
if (cbs) cbs.forEach(cb => { try { cb(msg.path); } catch { /* swallow */ } });
|
||||
}
|
||||
// 'subscribed' / 'unsubscribed' / 'error' acks are informational;
|
||||
// we don't currently surface them to consumers.
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (this._connectPromise) {
|
||||
this._connectPromise = null;
|
||||
reject(new Error('file-watch WS error'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this._ws = null;
|
||||
this._connectPromise = null;
|
||||
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
this._ensureConnected().catch(() => { /* silent retry */ });
|
||||
}, 2000);
|
||||
};
|
||||
});
|
||||
return this._connectPromise;
|
||||
}
|
||||
|
||||
async watch(path, cb) {
|
||||
await this._ensureConnected();
|
||||
let cbs = this._subscriptions.get(path);
|
||||
const isNew = !cbs;
|
||||
if (!cbs) {
|
||||
cbs = new Set();
|
||||
this._subscriptions.set(path, cbs);
|
||||
}
|
||||
cbs.add(cb);
|
||||
if (isNew && this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ op: 'subscribe', path }));
|
||||
}
|
||||
return () => this.unwatch(path, cb);
|
||||
}
|
||||
|
||||
unwatch(path, cb) {
|
||||
const cbs = this._subscriptions.get(path);
|
||||
if (!cbs) return;
|
||||
cbs.delete(cb);
|
||||
if (cbs.size === 0) {
|
||||
this._subscriptions.delete(path);
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ op: 'unsubscribe', path }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileWatcher = new FileWatcher();
|
||||
Reference in New Issue
Block a user