@fyresmith/hive-server 1.0.1-3

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/index.js ADDED
@@ -0,0 +1,129 @@
1
+ import dotenv from 'dotenv';
2
+ import { createServer } from 'http';
3
+ import { fileURLToPath } from 'url';
4
+ import express from 'express';
5
+ import { Server } from 'socket.io';
6
+ import authRoutes from './routes/auth.js';
7
+ import * as vault from './lib/vaultManager.js';
8
+ import { attachHandlers } from './lib/socketHandler.js';
9
+ import { startYjsServer, getActiveRooms, forceCloseRoom, getRoomStatus } from './lib/yjsServer.js';
10
+
11
+ const REQUIRED = [
12
+ 'VAULT_PATH',
13
+ 'JWT_SECRET',
14
+ 'DISCORD_CLIENT_ID',
15
+ 'DISCORD_CLIENT_SECRET',
16
+ 'DISCORD_REDIRECT_URI',
17
+ 'DISCORD_GUILD_ID',
18
+ ];
19
+
20
+ function validateEnv() {
21
+ for (const key of REQUIRED) {
22
+ if (!process.env[key]) {
23
+ throw new Error(`[startup] Missing required env var: ${key}`);
24
+ }
25
+ }
26
+
27
+ const port = parseInt(process.env.PORT ?? '3000', 10);
28
+ const yjsPort = parseInt(process.env.YJS_PORT ?? '3001', 10);
29
+ if (!Number.isInteger(port) || port <= 0) {
30
+ throw new Error('[startup] PORT must be a positive integer');
31
+ }
32
+ if (!Number.isInteger(yjsPort) || yjsPort <= 0) {
33
+ throw new Error('[startup] YJS_PORT must be a positive integer');
34
+ }
35
+
36
+ return { port };
37
+ }
38
+
39
+ /**
40
+ * Start Hive server runtime.
41
+ *
42
+ * @param {{ envFile?: string, quiet?: boolean }} [options]
43
+ */
44
+ export async function startHiveServer(options = {}) {
45
+ const { envFile, quiet = false } = options;
46
+
47
+ dotenv.config(
48
+ envFile
49
+ ? { path: envFile, override: true }
50
+ : undefined
51
+ );
52
+
53
+ const { port } = validateEnv();
54
+
55
+ const app = express();
56
+ const httpServer = createServer(app);
57
+
58
+ const io = new Server(httpServer, {
59
+ cors: {
60
+ origin: '*',
61
+ methods: ['GET', 'POST'],
62
+ },
63
+ });
64
+
65
+ app.use(express.json());
66
+
67
+ // Auth routes
68
+ app.use('/auth', authRoutes);
69
+
70
+ // Health check
71
+ app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
72
+ app.get('/rooms', (req, res) => res.json({ rooms: getRoomStatus() }));
73
+
74
+ function broadcastFileUpdated(relPath, hash, excludeSocketId) {
75
+ io.sockets.sockets.forEach((sock) => {
76
+ if (sock.id !== excludeSocketId) {
77
+ sock.emit('file-updated', { relPath, hash });
78
+ }
79
+ });
80
+ }
81
+
82
+ attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
83
+ startYjsServer(broadcastFileUpdated);
84
+
85
+ // Chokidar watch for external (non-plugin) changes
86
+ vault.initWatcher((relPath, event) => {
87
+ const docName = encodeURIComponent(relPath);
88
+ if (getActiveRooms().has(docName)) {
89
+ if (!quiet) {
90
+ console.log(`[chokidar] Ignoring external change to active room: ${relPath}`);
91
+ }
92
+ return;
93
+ }
94
+ if (!quiet) {
95
+ console.log(`[chokidar] External ${event}: ${relPath}`);
96
+ }
97
+ io.emit('external-update', { relPath, event });
98
+ });
99
+
100
+ await new Promise((resolve, reject) => {
101
+ httpServer.once('error', reject);
102
+ httpServer.listen(port, () => resolve());
103
+ });
104
+
105
+ if (!quiet) {
106
+ console.log(`[server] Hive server listening on port ${port}`);
107
+ console.log(`[server] Vault: ${process.env.VAULT_PATH}`);
108
+ }
109
+
110
+ return {
111
+ app,
112
+ io,
113
+ httpServer,
114
+ port,
115
+ close: () => new Promise((resolve, reject) => {
116
+ httpServer.close((err) => {
117
+ if (err) reject(err);
118
+ else resolve();
119
+ });
120
+ }),
121
+ };
122
+ }
123
+
124
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
125
+ startHiveServer().catch((err) => {
126
+ console.error(err?.message ?? err);
127
+ process.exit(1);
128
+ });
129
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,50 @@
1
+ import jwt from 'jsonwebtoken';
2
+
3
+ /**
4
+ * Verify a JWT and return the decoded payload.
5
+ * Throws if the token is invalid or expired.
6
+ * @param {string} token
7
+ * @returns {object} decoded payload
8
+ */
9
+ export function verifyToken(token) {
10
+ return jwt.verify(token, process.env.JWT_SECRET);
11
+ }
12
+
13
+ /**
14
+ * Express middleware — reads Authorization: Bearer <token> header.
15
+ */
16
+ export function expressMiddleware(req, res, next) {
17
+ const auth = req.headers.authorization;
18
+ if (!auth?.startsWith('Bearer ')) {
19
+ return res.status(401).json({ error: 'Unauthorized' });
20
+ }
21
+ try {
22
+ req.user = verifyToken(auth.slice(7));
23
+ next();
24
+ } catch (err) {
25
+ res.status(401).json({ error: 'Invalid token' });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Socket.IO middleware — reads token from socket.handshake.auth.token.
31
+ */
32
+ export function socketMiddleware(socket, next) {
33
+ const token = socket.handshake.auth?.token;
34
+ if (!token) return next(new Error('No token'));
35
+ try {
36
+ socket.user = verifyToken(token);
37
+ next();
38
+ } catch (err) {
39
+ next(new Error('Invalid token'));
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Verify a JWT for the y-websocket WS handshake.
45
+ * @param {string} token
46
+ * @returns {object} decoded payload
47
+ */
48
+ export function verifyWsToken(token) {
49
+ return verifyToken(token);
50
+ }
@@ -0,0 +1,226 @@
1
+ import { socketMiddleware } from './auth.js';
2
+ import * as vault from './vaultManager.js';
3
+
4
+ /**
5
+ * presenceByFile: file path → Set of socket IDs
6
+ * @type {Map<string, Set<string>>}
7
+ */
8
+ const presenceByFile = new Map();
9
+
10
+ /**
11
+ * socketToFiles: socket ID → Set of file paths (for cleanup on disconnect)
12
+ * @type {Map<string, Set<string>>}
13
+ */
14
+ const socketToFiles = new Map();
15
+
16
+ /**
17
+ * userBySocket: socket ID → user object
18
+ * @type {Map<string, object>}
19
+ */
20
+ const userBySocket = new Map();
21
+
22
+ function respond(cb, payload) {
23
+ if (typeof cb === 'function') cb(payload);
24
+ }
25
+
26
+ function rejectPath(cb, relPath) {
27
+ console.warn(`[socket] Rejected disallowed path: ${String(relPath)}`);
28
+ respond(cb, { ok: false, error: 'Path not allowed' });
29
+ }
30
+
31
+ function isAllowedPath(relPath) {
32
+ return typeof relPath === 'string' && vault.isAllowed(relPath);
33
+ }
34
+
35
+ function normalizeHexColor(color) {
36
+ if (typeof color !== 'string') return null;
37
+ const trimmed = color.trim();
38
+ return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
39
+ }
40
+
41
+ /**
42
+ * Attach all Socket.IO event handlers.
43
+ *
44
+ * @param {import('socket.io').Server} io
45
+ * @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
46
+ * @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
47
+ * @param {(relPath: string) => Promise<void>} forceCloseRoom
48
+ */
49
+ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
50
+ // Auth middleware for every socket connection
51
+ io.use(socketMiddleware);
52
+
53
+ io.on('connection', (socket) => {
54
+ const user = socket.user;
55
+ console.log(`[socket] Connected: ${user.username} (${socket.id})`);
56
+
57
+ userBySocket.set(socket.id, user);
58
+ socketToFiles.set(socket.id, new Set());
59
+
60
+ // Notify others this user joined
61
+ socket.broadcast.emit('user-joined', { user });
62
+
63
+ // -----------------------------------------------------------------------
64
+ // vault-sync-request
65
+ // -----------------------------------------------------------------------
66
+ socket.on('vault-sync-request', async (cb) => {
67
+ try {
68
+ const manifest = await vault.getManifest();
69
+ respond(cb, { ok: true, manifest });
70
+ } catch (err) {
71
+ console.error('[socket] vault-sync-request error:', err);
72
+ respond(cb, { ok: false, error: err.message });
73
+ }
74
+ });
75
+
76
+ // -----------------------------------------------------------------------
77
+ // file-read
78
+ // -----------------------------------------------------------------------
79
+ socket.on('file-read', async (relPath, cb) => {
80
+ if (!isAllowedPath(relPath)) {
81
+ rejectPath(cb, relPath);
82
+ return;
83
+ }
84
+ try {
85
+ const content = await vault.readFile(relPath);
86
+ const hash = vault.hashContent(content);
87
+ respond(cb, { ok: true, content, hash });
88
+ } catch (err) {
89
+ console.error(`[socket] file-read error (${relPath}):`, err);
90
+ respond(cb, { ok: false, error: err.message });
91
+ }
92
+ });
93
+
94
+ // -----------------------------------------------------------------------
95
+ // file-write
96
+ // -----------------------------------------------------------------------
97
+ socket.on('file-write', async (payload, cb) => {
98
+ const relPath = payload?.relPath;
99
+ const content = payload?.content;
100
+ if (!isAllowedPath(relPath) || typeof content !== 'string') {
101
+ rejectPath(cb, relPath);
102
+ return;
103
+ }
104
+ try {
105
+ await vault.writeFile(relPath, content);
106
+ const hash = vault.hashContent(content);
107
+ socket.broadcast.emit('file-updated', { relPath, hash, user });
108
+ respond(cb, { ok: true, hash });
109
+ console.log(`[socket] file-write: ${relPath} by ${user.username}`);
110
+ } catch (err) {
111
+ console.error(`[socket] file-write error (${relPath}):`, err);
112
+ respond(cb, { ok: false, error: err.message });
113
+ }
114
+ });
115
+
116
+ // -----------------------------------------------------------------------
117
+ // file-create
118
+ // -----------------------------------------------------------------------
119
+ socket.on('file-create', async (payload, cb) => {
120
+ const relPath = payload?.relPath;
121
+ const content = payload?.content;
122
+ if (!isAllowedPath(relPath) || typeof content !== 'string') {
123
+ rejectPath(cb, relPath);
124
+ return;
125
+ }
126
+ try {
127
+ await vault.writeFile(relPath, content);
128
+ io.emit('file-created', { relPath, user });
129
+ respond(cb, { ok: true });
130
+ console.log(`[socket] file-create: ${relPath} by ${user.username}`);
131
+ } catch (err) {
132
+ console.error(`[socket] file-create error (${relPath}):`, err);
133
+ respond(cb, { ok: false, error: err.message });
134
+ }
135
+ });
136
+
137
+ // -----------------------------------------------------------------------
138
+ // file-delete
139
+ // -----------------------------------------------------------------------
140
+ socket.on('file-delete', async (relPath, cb) => {
141
+ if (!isAllowedPath(relPath)) {
142
+ rejectPath(cb, relPath);
143
+ return;
144
+ }
145
+ try {
146
+ // Force-close active Yjs room first
147
+ const docName = encodeURIComponent(relPath);
148
+ if (getActiveRooms().has(docName)) {
149
+ await forceCloseRoom(relPath);
150
+ }
151
+ await vault.deleteFile(relPath);
152
+ io.emit('file-deleted', { relPath, user });
153
+ respond(cb, { ok: true });
154
+ console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
155
+ } catch (err) {
156
+ console.error(`[socket] file-delete error (${relPath}):`, err);
157
+ respond(cb, { ok: false, error: err.message });
158
+ }
159
+ });
160
+
161
+ // -----------------------------------------------------------------------
162
+ // file-rename
163
+ // -----------------------------------------------------------------------
164
+ socket.on('file-rename', async (payload, cb) => {
165
+ const oldPath = payload?.oldPath;
166
+ const newPath = payload?.newPath;
167
+ if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
168
+ rejectPath(cb, `${String(oldPath)} -> ${String(newPath)}`);
169
+ return;
170
+ }
171
+ try {
172
+ // Force-close active Yjs room for old path
173
+ const docName = encodeURIComponent(oldPath);
174
+ if (getActiveRooms().has(docName)) {
175
+ await forceCloseRoom(oldPath);
176
+ }
177
+ await vault.renameFile(oldPath, newPath);
178
+ io.emit('file-renamed', { oldPath, newPath, user });
179
+ respond(cb, { ok: true });
180
+ console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
181
+ } catch (err) {
182
+ console.error(`[socket] file-rename error (${oldPath}):`, err);
183
+ respond(cb, { ok: false, error: err.message });
184
+ }
185
+ });
186
+
187
+ // -----------------------------------------------------------------------
188
+ // presence-file-opened
189
+ // -----------------------------------------------------------------------
190
+ socket.on('presence-file-opened', (payload) => {
191
+ const relPath = typeof payload === 'string' ? payload : payload?.relPath;
192
+ const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
193
+ if (!isAllowedPath(relPath)) return;
194
+ if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
195
+ presenceByFile.get(relPath).add(socket.id);
196
+ socketToFiles.get(socket.id)?.add(relPath);
197
+ const presenceUser = color ? { ...user, color } : user;
198
+ socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
199
+ });
200
+
201
+ // -----------------------------------------------------------------------
202
+ // presence-file-closed
203
+ // -----------------------------------------------------------------------
204
+ socket.on('presence-file-closed', (relPath) => {
205
+ if (!isAllowedPath(relPath)) return;
206
+ presenceByFile.get(relPath)?.delete(socket.id);
207
+ socketToFiles.get(socket.id)?.delete(relPath);
208
+ socket.broadcast.emit('presence-file-closed', { relPath, user });
209
+ });
210
+
211
+ // -----------------------------------------------------------------------
212
+ // disconnect
213
+ // -----------------------------------------------------------------------
214
+ socket.on('disconnect', () => {
215
+ console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
216
+ const openFiles = socketToFiles.get(socket.id) ?? new Set();
217
+ for (const relPath of openFiles) {
218
+ presenceByFile.get(relPath)?.delete(socket.id);
219
+ socket.broadcast.emit('presence-file-closed', { relPath, user });
220
+ }
221
+ socketToFiles.delete(socket.id);
222
+ userBySocket.delete(socket.id);
223
+ socket.broadcast.emit('user-left', { user });
224
+ });
225
+ });
226
+ }
@@ -0,0 +1,258 @@
1
+ import { createHash } from 'crypto';
2
+ import { resolve, join, sep, extname, relative, dirname } from 'path';
3
+ import { readFile, writeFile as fsWriteFile, rename, unlink, mkdir, stat, readdir } from 'fs/promises';
4
+ import { existsSync } from 'fs';
5
+ import chokidar from 'chokidar';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Deny / allow lists
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const DENY_PREFIXES = ['.obsidian', 'Attachments', '.git'];
12
+ const DENY_FILES = ['.DS_Store', 'Thumbs.db'];
13
+ // Keep synced content text-based to ensure hash/read/write semantics stay safe.
14
+ const ALLOW_EXTS = new Set(['.md', '.canvas']);
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internals
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** @type {string} */
21
+ let VAULT_ROOT;
22
+
23
+ function getVaultRoot() {
24
+ if (!VAULT_ROOT) {
25
+ if (!process.env.VAULT_PATH) throw new Error('VAULT_PATH env var is required');
26
+ VAULT_ROOT = resolve(process.env.VAULT_PATH);
27
+ }
28
+ return VAULT_ROOT;
29
+ }
30
+
31
+ /**
32
+ * mtime+size → hash cache so we avoid rehashing unchanged files.
33
+ * @type {Map<string, {mtime: number, size: number, hash: string}>}
34
+ */
35
+ const manifestCache = new Map();
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Path helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Resolve a relative path against VAULT_ROOT and assert no traversal.
43
+ * @param {string} relPath
44
+ * @returns {string} absolute path
45
+ */
46
+ export function safePath(relPath) {
47
+ const root = getVaultRoot();
48
+ const abs = resolve(root, relPath);
49
+ if (!abs.startsWith(root + sep) && abs !== root) {
50
+ throw new Error(`Path traversal: ${relPath}`);
51
+ }
52
+ return abs;
53
+ }
54
+
55
+ /**
56
+ * Returns true if the relative path matches a deny prefix or deny filename.
57
+ * @param {string} relPath forward-slash relative path
58
+ */
59
+ export function isDenied(relPath) {
60
+ const normalised = relPath.replace(/\\/g, '/');
61
+ const parts = normalised.split('/');
62
+ const base = parts[parts.length - 1];
63
+ if (DENY_FILES.includes(base)) return true;
64
+ for (const prefix of DENY_PREFIXES) {
65
+ if (normalised === prefix || normalised.startsWith(prefix + '/')) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Returns true if the path is allowed to sync (extension + not denied).
72
+ * @param {string} relPath
73
+ */
74
+ export function isAllowed(relPath) {
75
+ if (isDenied(relPath)) return false;
76
+ return ALLOW_EXTS.has(extname(relPath).toLowerCase());
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Content hashing
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * SHA-256 hex digest of a UTF-8 string.
85
+ * @param {string} str
86
+ * @returns {string}
87
+ */
88
+ export function hashContent(str) {
89
+ return createHash('sha256').update(str, 'utf-8').digest('hex');
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Recursive directory walk
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Walk VAULT_ROOT recursively, returning all allowed relative paths.
98
+ * @returns {Promise<string[]>}
99
+ */
100
+ async function walkVault() {
101
+ const root = getVaultRoot();
102
+ const results = [];
103
+
104
+ async function walk(dir) {
105
+ let entries;
106
+ try {
107
+ entries = await readdir(dir, { withFileTypes: true });
108
+ } catch {
109
+ return;
110
+ }
111
+ for (const entry of entries) {
112
+ const absPath = join(dir, entry.name);
113
+ const relPath = relative(root, absPath).replace(/\\/g, '/');
114
+ if (entry.isDirectory()) {
115
+ if (!isDenied(relPath + '/')) {
116
+ await walk(absPath);
117
+ }
118
+ } else if (entry.isFile()) {
119
+ if (isAllowed(relPath)) {
120
+ results.push(relPath);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ await walk(root);
127
+ return results;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Manifest
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Returns the full vault manifest. Uses mtime+size first; only recomputes
136
+ * SHA-256 when mtime or size changes.
137
+ * @returns {Promise<Array<{path: string, hash: string, mtime: number, size: number}>>}
138
+ */
139
+ export async function getManifest() {
140
+ const paths = await walkVault();
141
+ const manifest = [];
142
+
143
+ for (const relPath of paths) {
144
+ const abs = safePath(relPath);
145
+ let fileStat;
146
+ try {
147
+ fileStat = await stat(abs);
148
+ } catch {
149
+ continue;
150
+ }
151
+ const mtime = fileStat.mtimeMs;
152
+ const size = fileStat.size;
153
+
154
+ const cached = manifestCache.get(relPath);
155
+ let hash;
156
+ if (cached && cached.mtime === mtime && cached.size === size) {
157
+ hash = cached.hash;
158
+ } else {
159
+ try {
160
+ const content = await readFile(abs, 'utf-8');
161
+ hash = hashContent(content);
162
+ } catch {
163
+ continue;
164
+ }
165
+ manifestCache.set(relPath, { mtime, size, hash });
166
+ }
167
+
168
+ manifest.push({ path: relPath, hash, mtime, size });
169
+ }
170
+
171
+ return manifest;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // File operations — all paths go through safePath
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Read a file as UTF-8.
180
+ * @param {string} relPath
181
+ * @returns {Promise<string>}
182
+ */
183
+ export async function readFile_(relPath) {
184
+ const abs = safePath(relPath);
185
+ return readFile(abs, 'utf-8');
186
+ }
187
+ // Export under the spec name
188
+ export { readFile_ as readFile };
189
+
190
+ /**
191
+ * Atomically write content to a file (write .tmp → rename).
192
+ * @param {string} relPath
193
+ * @param {string} content
194
+ */
195
+ export async function writeFile(relPath, content) {
196
+ const abs = safePath(relPath);
197
+ const tmp = abs + '.tmp';
198
+ await mkdir(dirname(abs), { recursive: true });
199
+ await fsWriteFile(tmp, content, 'utf-8');
200
+ await rename(tmp, abs);
201
+ // Invalidate cache entry so next manifest reflects new content
202
+ manifestCache.delete(relPath);
203
+ }
204
+
205
+ /**
206
+ * Delete a file.
207
+ * @param {string} relPath
208
+ */
209
+ export async function deleteFile(relPath) {
210
+ const abs = safePath(relPath);
211
+ await unlink(abs);
212
+ manifestCache.delete(relPath);
213
+ }
214
+
215
+ /**
216
+ * Rename a file.
217
+ * @param {string} oldRelPath
218
+ * @param {string} newRelPath
219
+ */
220
+ export async function renameFile(oldRelPath, newRelPath) {
221
+ const oldAbs = safePath(oldRelPath);
222
+ const newAbs = safePath(newRelPath);
223
+ await mkdir(dirname(newAbs), { recursive: true });
224
+ await rename(oldAbs, newAbs);
225
+ manifestCache.delete(oldRelPath);
226
+ manifestCache.delete(newRelPath);
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Chokidar watcher
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Start watching VAULT_ROOT for external file changes.
235
+ * @param {(relPath: string, event: string) => void} onExternalChange
236
+ * @returns {chokidar.FSWatcher}
237
+ */
238
+ export function initWatcher(onExternalChange) {
239
+ const root = getVaultRoot();
240
+ const watcher = chokidar.watch(root, {
241
+ ignoreInitial: true,
242
+ persistent: true,
243
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
244
+ });
245
+
246
+ const handler = (event) => (absPath) => {
247
+ const relPath = relative(root, absPath).replace(/\\/g, '/');
248
+ if (!isAllowed(relPath)) return;
249
+ onExternalChange(relPath, event);
250
+ };
251
+
252
+ watcher.on('add', handler('add'));
253
+ watcher.on('change', handler('change'));
254
+ watcher.on('unlink', handler('unlink'));
255
+
256
+ console.log(`[vault] Watching: ${root}`);
257
+ return watcher;
258
+ }