basic filewatcher using websocket
This commit is contained in:
parent
be0792dab7
commit
c5f5bec324
8 changed files with 342 additions and 11 deletions
120
server/watcher.js
Normal file
120
server/watcher.js
Normal 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 };
|
||||||
38
server/ws.js
38
server/ws.js
|
|
@ -1,25 +1,41 @@
|
||||||
const { WebSocketServer } = require("ws");
|
const { WebSocketServer } = require("ws");
|
||||||
|
const url = require("url");
|
||||||
|
const config = require("./config");
|
||||||
|
const watcher = require("./watcher");
|
||||||
|
|
||||||
//currently unused
|
|
||||||
function setupWebSocket(server) {
|
function setupWebSocket(server) {
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws, req) => {
|
||||||
console.log("[ws] Client connected");
|
const params = new url.URL(req.url, "http://localhost").searchParams;
|
||||||
|
const vaultId = params.get("vault");
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
if (!vaultId || !config.getVaultPath(vaultId)) {
|
||||||
// TODO: handle watch/unwatch subscriptions from client
|
ws.close(4001, "Invalid or missing vault ID");
|
||||||
const msg = JSON.parse(data);
|
return;
|
||||||
console.log("[ws] Received:", msg);
|
}
|
||||||
});
|
|
||||||
|
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", () => {
|
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;
|
return wss;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
src/shims/fs/echo-guard.js
Normal file
31
src/shims/fs/echo-guard.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { transport } from "./transport.js";
|
||||||
import { createFsPromises } from "./promises.js";
|
import { createFsPromises } from "./promises.js";
|
||||||
import { createFsSync } from "./sync.js";
|
import { createFsSync } from "./sync.js";
|
||||||
import { createFsWatch } from "./watch.js";
|
import { createFsWatch } from "./watch.js";
|
||||||
|
import { createWatcherClient } from "./watcher-client.js";
|
||||||
import { constants } from "./constants.js";
|
import { constants } from "./constants.js";
|
||||||
|
|
||||||
const metadataCache = new MetadataCache();
|
const metadataCache = new MetadataCache();
|
||||||
|
|
@ -12,6 +13,7 @@ const contentCache = new ContentCache();
|
||||||
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
const fsPromises = createFsPromises(metadataCache, contentCache, transport);
|
||||||
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
const fsSync = createFsSync(metadataCache, contentCache, transport);
|
||||||
const fsWatch = createFsWatch(transport);
|
const fsWatch = createFsWatch(transport);
|
||||||
|
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch);
|
||||||
|
|
||||||
export const fsShim = {
|
export const fsShim = {
|
||||||
promises: fsPromises,
|
promises: fsPromises,
|
||||||
|
|
@ -29,6 +31,7 @@ export const fsShim = {
|
||||||
|
|
||||||
_metadataCache: metadataCache,
|
_metadataCache: metadataCache,
|
||||||
_contentCache: contentCache,
|
_contentCache: contentCache,
|
||||||
|
_watcherClient: watcherClient,
|
||||||
|
|
||||||
async _init(basePath) {
|
async _init(basePath) {
|
||||||
const tree = await transport.fetchTree(basePath);
|
const tree = await transport.fetchTree(basePath);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
export function createFsPromises(metadataCache, contentCache, transport) {
|
export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
async stat(path) {
|
async stat(path) {
|
||||||
|
|
@ -85,6 +87,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
encoding = encoding?.encoding;
|
encoding = encoding?.encoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markLocalOp(path);
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
|
|
@ -110,6 +113,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async appendFile(path, data, encoding) {
|
async appendFile(path, data, encoding) {
|
||||||
|
markLocalOp(path);
|
||||||
contentCache.invalidate(path);
|
contentCache.invalidate(path);
|
||||||
|
|
||||||
await transport.appendFile(path, data);
|
await transport.appendFile(path, data);
|
||||||
|
|
@ -119,6 +123,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async unlink(path) {
|
async unlink(path) {
|
||||||
|
markLocalOp(path);
|
||||||
contentCache.delete(path);
|
contentCache.delete(path);
|
||||||
metadataCache.delete(path);
|
metadataCache.delete(path);
|
||||||
|
|
||||||
|
|
@ -126,6 +131,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async rename(oldPath, newPath) {
|
async rename(oldPath, newPath) {
|
||||||
|
markLocalOp(oldPath);
|
||||||
|
markLocalOp(newPath);
|
||||||
const content = contentCache.get(oldPath);
|
const content = contentCache.get(oldPath);
|
||||||
|
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
|
|
@ -142,12 +149,14 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
const recursive =
|
const recursive =
|
||||||
typeof options === "object" ? !!options.recursive : !!options;
|
typeof options === "object" ? !!options.recursive : !!options;
|
||||||
|
|
||||||
|
markLocalOp(path);
|
||||||
metadataCache.set(path, { type: "directory" });
|
metadataCache.set(path, { type: "directory" });
|
||||||
|
|
||||||
await transport.mkdir(path, recursive);
|
await transport.mkdir(path, recursive);
|
||||||
},
|
},
|
||||||
|
|
||||||
async rmdir(path) {
|
async rmdir(path) {
|
||||||
|
markLocalOp(path);
|
||||||
metadataCache.delete(path);
|
metadataCache.delete(path);
|
||||||
await transport.rmdir(path);
|
await transport.rmdir(path);
|
||||||
},
|
},
|
||||||
|
|
@ -156,6 +165,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
const recursive =
|
const recursive =
|
||||||
typeof options === "object" ? !!options.recursive : false;
|
typeof options === "object" ? !!options.recursive : false;
|
||||||
|
|
||||||
|
markLocalOp(path);
|
||||||
metadataCache.delete(path);
|
metadataCache.delete(path);
|
||||||
contentCache.delete(path);
|
contentCache.delete(path);
|
||||||
|
|
||||||
|
|
@ -163,6 +173,7 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async copyFile(src, dest) {
|
async copyFile(src, dest) {
|
||||||
|
markLocalOp(dest);
|
||||||
await transport.copyFile(src, dest);
|
await transport.copyFile(src, dest);
|
||||||
|
|
||||||
const meta = await transport.stat(dest);
|
const meta = await transport.stat(dest);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
|
|
||||||
export function createFsSync(metadataCache, contentCache, transport) {
|
export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
return {
|
return {
|
||||||
existsSync(path) {
|
existsSync(path) {
|
||||||
|
|
@ -64,6 +66,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
encoding = encoding?.encoding;
|
encoding = encoding?.encoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markLocalOp(path);
|
||||||
contentCache.set(path, data);
|
contentCache.set(path, data);
|
||||||
|
|
||||||
const size =
|
const size =
|
||||||
|
|
@ -87,6 +90,7 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkSync(path) {
|
unlinkSync(path) {
|
||||||
|
markLocalOp(path);
|
||||||
contentCache.delete(path);
|
contentCache.delete(path);
|
||||||
metadataCache.delete(path);
|
metadataCache.delete(path);
|
||||||
|
|
||||||
|
|
|
||||||
141
src/shims/fs/watcher-client.js
Normal file
141
src/shims/fs/watcher-client.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -259,4 +259,9 @@ window.__currentVaultId =
|
||||||
|
|
||||||
installRequestUrlShim();
|
installRequestUrlShim();
|
||||||
|
|
||||||
|
// Connect file watcher WebSocket after everything is initialized
|
||||||
|
if (window.__currentVaultId) {
|
||||||
|
fsShim._watcherClient.connect(window.__currentVaultId);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[ignis] Shim loader initialized");
|
console.log("[ignis] Shim loader initialized");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue