lib/project-file-watch.js
Rama main · réplica de lectura del repositorio original.
var fs = require("fs");
var path = require("path");
var resolveFilePath = require("./project-file-path").resolveFilePath;
/**
* Attach file/directory watcher engine to a project context.
*
* ctx fields:
* cwd, send, sendTo, BINARY_EXTS, FS_MAX_SIZE, IGNORED_DIRS, requestAccess, fsAsUser
*/
function attachFileWatch(ctx) {
var cwd = ctx.cwd;
var send = ctx.send;
var sendTo = ctx.sendTo;
var BINARY_EXTS = ctx.BINARY_EXTS;
var FS_MAX_SIZE = ctx.FS_MAX_SIZE;
var IGNORED_DIRS = ctx.IGNORED_DIRS;
var access = ctx.requestAccess;
var osUsers = !!ctx.osUsers;
function scopeFor(client) {
if (!access) {
if (osUsers) throw new Error("OS user identity is unavailable");
return { identity: null, projectBound: true };
}
return access.fileScope(client);
}
function checkedPath(relPath, scope) {
return resolveFilePath(cwd, relPath, scope);
}
// --- File watcher ---
// Bounded instance subscriptions belong to the authenticated socket.
var fileWatchers = new Map();
function settleFileWatchReady(entry, ready) {
if (!entry || !entry.resolveReady) return;
var resolve = entry.resolveReady;
entry.resolveReady = null;
resolve(ready);
}
function closeFileWatch(key) {
var entry = fileWatchers.get(key);
if (!entry) return;
clearTimeout(entry.debounce);
if (entry.reconcile) clearImmediate(entry.reconcile);
if (entry.pollTimer) clearInterval(entry.pollTimer);
try { entry.watcher.close(); } catch (e) {}
fileWatchers.delete(key);
settleFileWatchReady(entry, false);
}
function sendFileChanged(client, message) {
if (client && typeof sendTo === "function") {
sendTo(client, message);
} else {
send(message);
}
}
function readFileSnapshot(client, relPath) {
var scope = scopeFor(client);
var identity = scope.identity;
var absPath = checkedPath(relPath, scope);
var stat = identity ? ctx.fsAsUser("stat", { file: absPath }, identity) : fs.statSync(absPath);
var ext = path.extname(absPath).toLowerCase();
if (stat.size > FS_MAX_SIZE || BINARY_EXTS.has(ext)) return null;
if (identity) return ctx.fsAsUser("read", { file: absPath, readContent: true }, identity);
return { content: fs.readFileSync(absPath, "utf8"), size: stat.size };
}
function publishFileChanged(key, client, relPath) {
var latest = fileWatchers.get(key);
if (!latest || latest.relPath !== relPath) return;
try {
if (latest.authority !== JSON.stringify([client && client._clayUser && client._clayUser.id || null, scopeFor(client)])) { closeFileWatch(key); return; }
var snapshot = readFileSnapshot(client, relPath);
if (!snapshot) return;
if (latest.hasSnapshot && latest.content === snapshot.content && latest.size === snapshot.size) return;
latest.hasSnapshot = true;
latest.content = snapshot.content;
latest.size = snapshot.size;
sendFileChanged(client, {
type: "fs_file_changed",
instanceId: latest.instanceId,
requestId: latest.requestId,
path: relPath,
content: snapshot.content,
size: snapshot.size,
});
} catch (e) {
// Atomic saves can briefly remove the destination path between rename
// events. Keep the parent watcher alive for the next event.
if (e.code !== "ENOENT") closeFileWatch(key);
}
}
function startFileWatch(client, relPath, binding) {
// Preserve the old single-argument API for callers outside the websocket
// file browser. They share one legacy subscription.
if (typeof relPath !== "string") {
relPath = client;
client = null;
}
var absPath;
try { absPath = checkedPath(relPath, scopeFor(client)); } catch (error) { return Promise.resolve(false); }
if (binding && (!/^[A-Za-z0-9_-]{1,128}$/.test(binding.instanceId || '') || typeof binding.requestId !== 'string' || binding.requestId.length > 128)) return Promise.resolve(false);
var key = client || "_legacy";
if (binding) {
var entries = Array.from(fileWatchers.entries()).filter(function (pair) { return pair[1].client === client; });
var match = entries.find(function (pair) { return pair[1].instanceId === binding.instanceId; });
if (!match && entries.length >= 8) return Promise.resolve(false);
key = match ? match[0] : {};
}
var existing = fileWatchers.get(key);
if (existing && existing.relPath === relPath && existing.requestId === (binding ? binding.requestId : undefined)) return existing.ready;
closeFileWatch(key);
// Watch the parent directory rather than the file inode. Editors and agent
// tools commonly save with write-temp + rename; watching the old inode then
// misses later edits even though the path still exists.
var parentPath = path.dirname(absPath);
var baseName = path.basename(absPath);
var initialSnapshot = null;
try { initialSnapshot = readFileSnapshot(client, relPath); } catch (e) {
return Promise.resolve(false);
}
try {
var watcher = fs.watch(parentPath, function (eventType, filename) {
if (filename && String(filename) !== baseName) return;
var active = fileWatchers.get(key);
if (!active || active.relPath !== relPath) return;
clearTimeout(active.debounce);
active.debounce = setTimeout(function () {
publishFileChanged(key, client, relPath);
}, 200);
});
var resolveReady = null;
var ready = new Promise(function (resolve) { resolveReady = resolve; });
var entry = {
watcher: watcher,
client: client,
authority: JSON.stringify([client && client._clayUser && client._clayUser.id || null, scopeFor(client)]),
instanceId: binding ? binding.instanceId : undefined,
requestId: binding ? binding.requestId : undefined,
relPath: relPath,
debounce: null,
reconcile: null,
pollTimer: null,
ready: ready,
resolveReady: resolveReady,
hasSnapshot: !!initialSnapshot,
content: initialSnapshot ? initialSnapshot.content : null,
size: initialSnapshot ? initialSnapshot.size : null,
};
fileWatchers.set(key, entry);
// Directory events are the low-latency path, but macOS can coalesce or
// drop them under load. Periodic content reconciliation is the source of
// truth and also survives atomic replacements with identical metadata.
entry.pollTimer = setInterval(function () {
publishFileChanged(key, client, relPath);
}, 1000);
// fs.watch has no readiness event. Reconcile once on the next event-loop
// turn so a change between the initial read and native watcher activation
// cannot leave the browser showing stale content.
entry.reconcile = setImmediate(function () {
var active = fileWatchers.get(key);
if (active) active.reconcile = null;
publishFileChanged(key, client, relPath);
if (fileWatchers.get(key) === entry) settleFileWatchReady(entry, true);
});
watcher.on("error", function () { if (fileWatchers.get(key) === entry) closeFileWatch(key); });
return ready;
} catch (e) {
closeFileWatch(key);
return Promise.resolve(false);
}
}
function stopFileWatch(client, binding) {
if (arguments.length > 0) {
Array.from(fileWatchers.entries()).forEach(function (pair) {
var entry = pair[1];
if (entry.client !== client) return;
if (binding && (entry.instanceId !== binding.instanceId || entry.requestId !== binding.requestId)) return;
closeFileWatch(pair[0]);
});
if (!binding) closeFileWatch(client || "_legacy");
return;
}
var keys = Array.from(fileWatchers.keys());
for (var i = 0; i < keys.length; i++) closeFileWatch(keys[i]);
}
// Directory subscriptions are private to the requesting socket.
var dirWatchers = new Map();
function readDirectory(client, relPath) {
var scope = scopeFor(client);
var identity = scope.identity;
var absPath = checkedPath(relPath, scope);
var items = identity ? ctx.fsAsUser("list", { dir: absPath }, identity) :
fs.readdirSync(absPath, { withFileTypes: true }).map(function (item) {
return { name: item.name, isDir: item.isDirectory() };
});
return items.filter(function (item) {
return !item.isDir || !IGNORED_DIRS.has(item.name);
}).map(function (item) {
return { name: item.name, type: item.isDir ? "dir" : "file",
path: path.relative(cwd, path.join(absPath, item.name)).split(path.sep).join("/") };
});
}
function startDirWatch(client, relPath) {
var subscriptions = dirWatchers.get(client);
if (!subscriptions) { subscriptions = new Map(); dirWatchers.set(client, subscriptions); }
if (subscriptions.has(relPath)) return;
var scope;
try {
scope = scopeFor(client);
readDirectory(client, relPath);
var entry = { watcher: null, debounce: null };
entry.watcher = fs.watch(checkedPath(relPath, scope), function () {
clearTimeout(entry.debounce);
entry.debounce = setTimeout(function () {
try {
var entries = readDirectory(client, relPath);
sendTo(client, { type: "fs_dir_changed", path: relPath, entries: entries });
} catch (e) { stopDirWatch(client, relPath); }
}, 300);
});
entry.watcher.on("error", function () { stopDirWatch(client, relPath); });
subscriptions.set(relPath, entry);
} catch (e) {
if (!subscriptions.size) dirWatchers.delete(client);
}
}
function stopDirWatch(client, relPath) {
var subscriptions = dirWatchers.get(client);
var entry = subscriptions && subscriptions.get(relPath);
if (!entry) return;
clearTimeout(entry.debounce);
try { entry.watcher.close(); } catch (e) {}
subscriptions.delete(relPath);
if (!subscriptions.size) dirWatchers.delete(client);
}
function stopAllDirWatches(client) {
var clients = arguments.length ? [client] : Array.from(dirWatchers.keys());
clients.forEach(function (key) {
var subscriptions = dirWatchers.get(key);
if (subscriptions) Array.from(subscriptions.keys()).forEach(function (relPath) {
stopDirWatch(key, relPath);
});
});
}
return {
startFileWatch: startFileWatch,
stopFileWatch: stopFileWatch,
startDirWatch: startDirWatch,
stopDirWatch: stopDirWatch,
stopAllDirWatches: stopAllDirWatches,
};
}
module.exports = { attachFileWatch: attachFileWatch };