basic filewatcher using websocket

This commit is contained in:
Nystik
2026-03-22 14:56:05 +01:00
parent be0792dab7
commit c5f5bec324
8 changed files with 342 additions and 11 deletions

120
server/watcher.js Normal file
View File

@@ -0,0 +1,120 @@
const chokidar = require("chokidar");
const path = require("path");
const fs = require("fs");
// Per-vault chokidar watchers
// Map<vaultId, { watcher, listeners: Set<fn>, vaultPath }>
const vaultWatchers = new Map();
function startWatching(vaultId, vaultPath) {
if (vaultWatchers.has(vaultId)) {
return vaultWatchers.get(vaultId);
}
const watcher = chokidar.watch(vaultPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100,
},
ignored: [
/(^|[\/\\])\.git([\/\\]|$)/, // .git directories
],
});
const entry = { watcher, listeners: new Set(), vaultPath };
function emit(type, fullPath, stat) {
const rel = path.relative(vaultPath, fullPath).replace(/\\/g, "/");
const event = { type, path: rel };
if (stat) {
event.stat = {
size: stat.size,
mtime: stat.mtimeMs,
ctime: stat.ctimeMs,
};
}
for (const fn of entry.listeners) {
try {
fn(event);
} catch (e) {
console.error("[watcher] Listener error:", e);
}
}
}
watcher
.on("add", (fullPath) => {
try {
const stat = fs.statSync(fullPath);
emit("created", fullPath, stat);
} catch {
emit("created", fullPath, null);
}
})
.on("change", (fullPath) => {
try {
const stat = fs.statSync(fullPath);
emit("modified", fullPath, stat);
} catch {
emit("modified", fullPath, null);
}
})
.on("unlink", (fullPath) => {
emit("deleted", fullPath, null);
})
.on("addDir", (fullPath) => {
// Skip vault root itself
if (path.resolve(fullPath) === path.resolve(vaultPath)) return;
emit("folder-created", fullPath, null);
})
.on("unlinkDir", (fullPath) => {
emit("deleted", fullPath, null);
})
.on("error", (err) => {
console.error(`[watcher] Error on vault "${vaultId}":`, err.message);
});
vaultWatchers.set(vaultId, entry);
console.log(`[watcher] Started watching vault: ${vaultId}`);
return entry;
}
function stopWatching(vaultId) {
const entry = vaultWatchers.get(vaultId);
if (entry) {
entry.watcher.close();
entry.listeners.clear();
vaultWatchers.delete(vaultId);
console.log(`[watcher] Stopped watching vault: ${vaultId}`);
}
}
function addListener(vaultId, fn) {
const entry = vaultWatchers.get(vaultId);
if (entry) {
entry.listeners.add(fn);
}
}
function removeListener(vaultId, fn) {
const entry = vaultWatchers.get(vaultId);
if (entry) {
entry.listeners.delete(fn);
// Stop watching if no listeners remain
if (entry.listeners.size === 0) {
stopWatching(vaultId);
}
}
}
module.exports = { startWatching, stopWatching, addListener, removeListener };

View File

@@ -1,25 +1,41 @@
const { WebSocketServer } = require("ws");
const url = require("url");
const config = require("./config");
const watcher = require("./watcher");
//currently unused
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("connection", (ws) => {
console.log("[ws] Client connected");
wss.on("connection", (ws, req) => {
const params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault");
ws.on("message", (data) => {
// TODO: handle watch/unwatch subscriptions from client
const msg = JSON.parse(data);
console.log("[ws] Received:", msg);
});
if (!vaultId || !config.getVaultPath(vaultId)) {
ws.close(4001, "Invalid or missing vault ID");
return;
}
const vaultPath = config.getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${vaultId}`);
// Start watching this vault (no-op if already watching)
watcher.startWatching(vaultId, vaultPath);
// Per-client listener that forwards events over WebSocket
const listener = (event) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(event));
}
};
watcher.addListener(vaultId, listener);
ws.on("close", () => {
console.log("[ws] Client disconnected");
console.log(`[ws] Client disconnected from vault: ${vaultId}`);
watcher.removeListener(vaultId, listener);
});
});
// TODO: maybe integrate chokidar file watching and broadcast changes
return wss;
}

View File

@@ -0,0 +1,31 @@
// Shared echo suppression for file watcher.
// fs operations mark paths as "locally modified" so the watcher client
// can skip events that originated from this client.
const ECHO_SUPPRESS_MS = 1500;
const recentOps = new Map(); // normalized path -> timestamp
function normalize(p) {
return (p || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "");
}
export function markLocalOp(path) {
recentOps.set(normalize(path), Date.now());
}
export function isRecentLocalOp(path) {
const norm = normalize(path);
const ts = recentOps.get(norm);
if (!ts) return false;
if (Date.now() - ts < ECHO_SUPPRESS_MS) {
return true;
}
recentOps.delete(norm);
return false;
}

View File

@@ -4,6 +4,7 @@ import { transport } from "./transport.js";
import { createFsPromises } from "./promises.js";
import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js";
import { constants } from "./constants.js";
const metadataCache = new MetadataCache();
@@ -12,6 +13,7 @@ const contentCache = new ContentCache();
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch);
export const fsShim = {
promises: fsPromises,
@@ -29,6 +31,7 @@ export const fsShim = {
_metadataCache: metadataCache,
_contentCache: contentCache,
_watcherClient: watcherClient,
async _init(basePath) {
const tree = await transport.fetchTree(basePath);

View File

@@ -1,3 +1,5 @@
import { markLocalOp } from "./echo-guard.js";
export function createFsPromises(metadataCache, contentCache, transport) {
return {
async stat(path) {
@@ -85,6 +87,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
encoding = encoding?.encoding;
}
markLocalOp(path);
contentCache.set(path, data);
const size =
@@ -110,6 +113,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async appendFile(path, data, encoding) {
markLocalOp(path);
contentCache.invalidate(path);
await transport.appendFile(path, data);
@@ -119,6 +123,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async unlink(path) {
markLocalOp(path);
contentCache.delete(path);
metadataCache.delete(path);
@@ -126,6 +131,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async rename(oldPath, newPath) {
markLocalOp(oldPath);
markLocalOp(newPath);
const content = contentCache.get(oldPath);
if (content !== null) {
@@ -142,12 +149,14 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
markLocalOp(path);
metadataCache.set(path, { type: "directory" });
await transport.mkdir(path, recursive);
},
async rmdir(path) {
markLocalOp(path);
metadataCache.delete(path);
await transport.rmdir(path);
},
@@ -156,6 +165,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
markLocalOp(path);
metadataCache.delete(path);
contentCache.delete(path);
@@ -163,6 +173,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
},
async copyFile(src, dest) {
markLocalOp(dest);
await transport.copyFile(src, dest);
const meta = await transport.stat(dest);

View File

@@ -1,3 +1,5 @@
import { markLocalOp } from "./echo-guard.js";
export function createFsSync(metadataCache, contentCache, transport) {
return {
existsSync(path) {
@@ -64,6 +66,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
encoding = encoding?.encoding;
}
markLocalOp(path);
contentCache.set(path, data);
const size =
@@ -87,6 +90,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
},
unlinkSync(path) {
markLocalOp(path);
contentCache.delete(path);
metadataCache.delete(path);

View File

@@ -0,0 +1,141 @@
// Client-side WebSocket file watcher.
// Connects to the server's /ws endpoint, receives file change events,
// updates the metadata/content caches, and dispatches to fs.watch listeners
// so Obsidian's vault picks them up automatically.
import { isRecentLocalOp } from "./echo-guard.js";
const RECONNECT_DELAY = 2000;
export function createWatcherClient(metadataCache, contentCache, fsWatch) {
let ws = null;
let vaultId = null;
let reconnectTimer = null;
function connect(vault) {
vaultId = vault;
if (!vaultId) {
console.warn("[watcher] No vault ID, skipping WebSocket connection");
return;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws?vault=${encodeURIComponent(vaultId)}`;
try {
ws = new WebSocket(url);
} catch (e) {
console.error("[watcher] Failed to create WebSocket:", e);
scheduleReconnect();
return;
}
ws.onopen = () => {
console.log("[watcher] Connected to file watcher");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleEvent(msg);
} catch (e) {
console.error("[watcher] Failed to parse message:", e);
}
};
ws.onclose = () => {
console.log("[watcher] Disconnected");
ws = null;
scheduleReconnect();
};
ws.onerror = (e) => {
console.error("[watcher] WebSocket error:", e);
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (vaultId) {
console.log("[watcher] Reconnecting...");
connect(vaultId);
}
}, RECONNECT_DELAY);
}
function handleEvent(msg) {
const { type, path, stat } = msg;
if (!type || !path) return;
// Suppress echo from our own operations
if (isRecentLocalOp(path)) {
return;
}
switch (type) {
case "created":
if (stat) {
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("created", path);
break;
case "folder-created":
metadataCache.set(path, { type: "directory" });
fsWatch._dispatch("folder-created", path);
break;
case "modified":
if (stat) {
metadataCache.set(path, {
type: "file",
size: stat.size,
mtime: stat.mtime,
ctime: stat.ctime,
});
}
contentCache.invalidate(path);
fsWatch._dispatch("modified", path);
break;
case "deleted":
metadataCache.delete(path);
contentCache.invalidate(path);
fsWatch._dispatch("deleted", path);
break;
default:
console.warn("[watcher] Unknown event type:", type);
}
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // prevent reconnect
ws.close();
ws = null;
}
}
return {
connect,
disconnect,
};
}

View File

@@ -259,4 +259,9 @@ window.__currentVaultId =
installRequestUrlShim();
// Connect file watcher WebSocket after everything is initialized
if (window.__currentVaultId) {
fsShim._watcherClient.connect(window.__currentVaultId);
}
console.log("[ignis] Shim loader initialized");