18.5 KB633 lines
Blame
1/**
2 * This software contains information and intellectual property that is
3 * confidential and proprietary to Facebook, Inc. and its affiliates.
4 *
5 * @generated
6 */
7
8/* eslint-disable */
9
10/*
11 * This file is synced between fbcode/eden/fs/facebook/prototypes/node-edenfs-notifications-client/index.js.
12 * The authoritative copy is the one in eden/fs/.
13 * Use `yarn sync-edenfs-notifications` to perform the sync.
14 *
15 * This file is intended to be self contained so it may be copied/referenced from other extensions,
16 * which is why it should not import anything and why it reimplements many types.
17 */
18
19/**
20 * JavaScript interface for EdenFS CLI notify endpoint
21 *
22 * This module provides a JavaScript wrapper around the EdenFS CLI notify commands,
23 * allowing you to monitor filesystem changes in EdenFS mounts.
24 *
25 * @author cqd
26 * @version 1.0.0
27 *
28 * @format
29 * @flow
30 * @ts-check
31 */
32
33const {spawn, execFile} = require('child_process');
34const {EventEmitter} = require('events');
35const path = require('path');
36
37/**
38 * EdenFS Notifications Client
39 * Provides methods to interact with EdenFS notifications via the EdenFS CLI
40 */
41class EdenFSNotificationsClient extends EventEmitter {
42 /** @type {string} */
43 mountPoint;
44 /** @type {number} */
45 timeout;
46 /** @type {string} */
47 edenBinaryPath;
48
49 DEFAULT_EDENFS_RECONNECT_DELAY_MS = 100;
50 MAXIMUM_EDENFS_RECONNECT_DELAY_MS = 60 * 1000;
51
52 constructor(options) {
53 super();
54 this.mountPoint = options?.mountPoint ?? null;
55 this.timeout = options?.timeout ?? 30000; // 30 seconds default timeout
56 this.edenBinaryPath = options?.edenBinaryPath ?? 'eden';
57 }
58
59 /**
60 * Get the current EdenFS status
61 * @returns {boolean} Edenfs running/not running
62 */
63 async getStatus(options = {}) {
64 const args = ['status'];
65
66 if (options.useCase) {
67 args.push('--use-case', options.useCase);
68 } else {
69 args.push('--use-case', 'node-client');
70 }
71
72 return new Promise((resolve, reject) => {
73 execFile(this.edenBinaryPath, args, {timeout: this.timeout}, (error, stdout, stderr) => {
74 if (error) {
75 reject(
76 new Error(
77 `Failed to get status: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`,
78 ),
79 );
80 return;
81 }
82
83 try {
84 const result = stdout.trim();
85 resolve(result);
86 } catch (parseError) {
87 reject(new Error(`Failed to parse response: ${parseError.message}\nStdout: ${stdout}`));
88 }
89 });
90 });
91 }
92
93 /**
94 * Wait until EdenFS is ready
95 * @returns {Promise<boolean>} True=Healthy, False=Timeout
96 */
97 async waitReady(options={}) {
98 const maxDelay = this.MAXIMUM_EDENFS_RECONNECT_DELAY_MS;
99 let delay = this.DEFAULT_EDENFS_RECONNECT_DELAY_MS;
100 const start = Date.now();
101 const deadline = start + (options.timeout ?? this.timeout);
102
103 // Helper: sleep for ms
104 const sleep = ms => new Promise(res => setTimeout(res, ms));
105
106 // If timeout=0, wait forever
107 while (options.timeout == 0 || Date.now() < deadline) {
108 try {
109 const status = await this.getStatus({useCase: options.useCase ?? undefined});
110 // Consider any truthy/non-empty status string as "healthy"
111 if (status && typeof status === 'string' && status.trim().length > 0) {
112 return true;
113 }
114 } catch (e) {
115 // Swallow and retry with backoff
116 }
117
118 // Exponential backoff (capped)
119 await sleep(delay);
120 delay = Math.min(delay * 2, maxDelay);
121 }
122
123 return false;
124
125 }
126
127 /**
128 * Get the current EdenFS journal position
129 * @param {Object} options - Options for changes-since command
130 * @param {string} options.useCase - Use case for the command
131 * @param {string} options.mountPoint - Path to the mount point (optional if set in constructor)
132 * @returns {Promise<JournalPosition|string>} Journal position
133 */
134 async getPosition(options = {}) {
135 const mountPoint = options?.mountPoint ?? this.mountPoint;
136 const args = ['notify', 'get-position'];
137
138 if (options.useCase) {
139 args.push('--use-case', options.useCase);
140 } else {
141 args.push('--use-case', 'node-client');
142 }
143
144 if (mountPoint) {
145 args.push(mountPoint);
146 }
147
148 return new Promise((resolve, reject) => {
149 execFile(this.edenBinaryPath, args, {timeout: this.timeout}, (error, stdout, stderr) => {
150 if (error) {
151 reject(
152 new Error(
153 `Failed to get position: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`,
154 ),
155 );
156 return;
157 }
158
159 try {
160 const result = stdout.trim();
161 resolve(result);
162 } catch (parseError) {
163 reject(new Error(`Failed to parse response: ${parseError.message}\nStdout: ${stdout}`));
164 }
165 });
166 });
167 }
168
169 /**
170 * Get changes since a specific journal position
171 * @param {Object} options - Options for changes-since command
172 * @param {string} options.position - Journal position to start from
173 * @param {string} options.useCase - Use case for the command
174 * @param {string} options.mountPoint - Path to the mount point (optional if set in constructor)
175 * @param {string} options.relativeRoot - Relative root to scope results
176 * @param {boolean} options.includeVcsRoots - Include VCS roots in output
177 * @param {string[]} options.includedRoots - Included roots in output
178 * @param {string[]} options.excludedRoots - Excluded roots in output
179 * @param {string[]} options.includedSuffixes - Included suffixes in output
180 * @param {string[]} options.excludedSuffixes - Excluded suffixes in output
181 * @param {boolean} options.json - Return JSON format (default: true)
182 * @param {string[]} options.deferredStates - States to wait for deassertion
183 * @returns {Promise<Object|string>} Changes since position
184 */
185 async getChangesSince(options = {}) {
186 const mountPoint = options?.mountPoint ?? this.mountPoint;
187 const args = ['notify', 'changes-since'];
188
189 if (options.useCase) {
190 args.push('--use-case', options.useCase);
191 } else {
192 args.push('--use-case', 'node-client');
193 }
194
195 if (options.position) {
196 args.push(
197 '--position',
198 typeof options.position === 'string' ? options.position : JSON.stringify(options.position),
199 );
200 }
201
202 if (options.relativeRoot) {
203 args.push('--relative-root', options.relativeRoot);
204 }
205
206 if (options.includeVcsRoots) {
207 args.push('--include-vcs-roots');
208 }
209
210 if (options.includedRoots) {
211 options.includedRoots.forEach(root => {
212 args.push('--included-roots', root);
213 });
214 }
215
216 if (options.excludedRoots) {
217 options.excludedRoots.forEach(root => {
218 args.push('--excluded-roots', root);
219 });
220 }
221
222 if (options.includedSuffixes) {
223 options.includedSuffixes.forEach(suffix => {
224 args.push('--included-suffixes', suffix);
225 });
226 }
227
228 if (options.excludedSuffixes) {
229 options.excludedSuffixes.forEach(suffix => {
230 args.push('--excluded-suffixes', suffix);
231 });
232 }
233
234 if (options.deferredStates) {
235 options.deferredStates.forEach(state => {
236 args.push('--deferred-states', state);
237 });
238 }
239
240 args.push('--json');
241 args.push('--formatted-position');
242
243 if (mountPoint) {
244 args.push(mountPoint);
245 }
246
247 return new Promise((resolve, reject) => {
248 execFile(this.edenBinaryPath, args, {timeout: this.timeout}, (error, stdout, stderr) => {
249 if (error) {
250 reject(new Error(`Failed to get changes: ${error.message}\nStderr: ${stderr}`));
251 return;
252 }
253
254 try {
255 const result = JSON.parse(stdout.trim());
256 resolve(result);
257 } catch (parseError) {
258 reject(new Error(`Failed to parse response: ${parseError.message}`));
259 }
260 });
261 });
262 }
263
264 /**
265 * Subscribe to filesystem changes
266 * @param {Object} options - Options for subscription
267 * @param {string} options.position - Journal position to start from (optional)
268 * @param {string} options.useCase - Use case for the command
269 * @param {string} options.mountPoint - Path to the mount point (optional if set in constructor)
270 * @param {number} options.throttle - Throttle in milliseconds between events (default: 0)
271 * @param {string} options.relativeRoot - Relative root to scope results
272 * @param {boolean} options.includeVcsRoots - Include VCS roots in output
273 * @param {string[]} options.includedRoots - Included roots in output
274 * @param {string[]} options.excludedRoots - Excluded roots in output
275 * @param {string[]} options.includedSuffixes - Included suffixes in output
276 * @param {string[]} options.excludedSuffixes - Excluded suffixes in output
277 * @param {string[]} options.deferredStates - States to wait for deassertion
278 * @param {CommandCallback} callback
279 * @returns {EdenFSSubscription} Subscription object
280 */
281 subscribe(options = {}, callback = () => {}) {
282 options['edenBinaryPath'] = this.edenBinaryPath;
283 let sub = new EdenFSSubscription(this, options, callback);
284 sub.on('change', change => {
285 callback(null, change);
286 });
287 sub.on('error', error => {
288 callback(error, null);
289 });
290 sub.on('close', () => {
291 // Received when the underlying gets killed, pass double null to indicate
292 // this since no error or message is available
293 callback(null, null);
294 });
295 return sub;
296 }
297
298 /**
299 * Enter a specific state
300 * @param {string} state - State name to enter
301 * @param {Object} options - Options for enterState command
302 * @param {number} [options.duration] - Duration in seconds to maintain state
303 * @param {string} [options.useCase] - Use case for the command
304 * @param {string} options.mountPoint - Path to the mount point (optional if set in constructor)
305 * @returns {Promise<void>}
306 */
307 async enterState(state, options = {}) {
308 const mountPoint = options?.mountPoint ?? this.mountPoint;
309 if (!state || typeof state !== 'string') {
310 throw new Error('State name must be a non-empty string');
311 }
312
313 const args = ['notify', 'enter-state', state];
314 if (options.duration !== undefined) {
315 args.push('--duration', options.duration.toString());
316 }
317
318 if (options.useCase) {
319 args.push('--use-case', options.useCase);
320 } else {
321 args.push('--use-case', 'node-client');
322 }
323
324 if (mountPoint) {
325 args.push(mountPoint);
326 }
327
328 return new Promise((resolve, reject) => {
329 execFile(this.edenBinaryPath, args, {timeout: this.timeout}, (error, stdout, stderr) => {
330 if (error) {
331 reject(
332 new Error(
333 `Failed to enter state: ${error.message}\nStdout: ${stdout}\nStderr: ${stderr}`,
334 ),
335 );
336 return;
337 }
338 resolve();
339 });
340 });
341 }
342}
343
344/**
345 * EdenFS Subscription
346 * Handles real-time filesystem change notifications
347 */
348class EdenFSSubscription extends EventEmitter {
349 /** @type {EdenFSNotificationsClient} */
350 client;
351 /** @type {Object} */
352 options;
353 /** @type {any} */
354 process;
355 /** @type {string} */
356 edenBinaryPath;
357 /** @type {string} */
358 errData;
359 /** @type {NodeJS.Timeout | null} */
360 killTimeout;
361
362 constructor(client, options = {}) {
363 super();
364 this.client = client;
365 this.options = options;
366 this.process = null;
367 this.edenBinaryPath = options?.edenBinaryPath ?? 'eden';
368 this.errData = '';
369 this.killTimeout = null;
370 }
371
372 /**
373 * Start the subscription
374 * @returns {Promise<void>}
375 */
376 async start() {
377 const mountPoint = this.options.mountPoint || this.client.mountPoint;
378 const args = ['notify', 'changes-since', '--subscribe', '--json', '--formatted-position'];
379
380 if (this.options.useCase) {
381 args.push('--use-case', this.options.useCase);
382 } else {
383 args.push('--use-case', 'node-client');
384 }
385
386 if (this.options.position) {
387 args.push(
388 '--position',
389 typeof this.options.position === 'string'
390 ? this.options.position
391 : JSON.stringify(this.options.position),
392 );
393 }
394
395 if (this.options.throttle !== undefined) {
396 args.push('--throttle', this.options.throttle.toString());
397 }
398
399 if (this.options.relativeRoot) {
400 args.push('--relative-root', this.options.relativeRoot);
401 }
402
403 if (this.options.includeVcsRoots) {
404 args.push('--include-vcs-roots');
405 }
406
407 if (this.options.includedRoots) {
408 this.options.includedRoots.forEach(root => {
409 args.push('--included-roots', root);
410 });
411 }
412
413 if (this.options.excludedRoots) {
414 this.options.excludedRoots.forEach(root => {
415 args.push('--excluded-roots', root);
416 });
417 }
418
419 if (this.options.includedSuffixes) {
420 this.options.includedSuffixes.forEach(suffix => {
421 args.push('--included-suffixes', suffix);
422 });
423 }
424
425 if (this.options.excludedSuffixes) {
426 this.options.excludedSuffixes.forEach(suffix => {
427 args.push('--excluded-suffixes', suffix);
428 });
429 }
430
431 if (this.options.deferredStates) {
432 this.options.deferredStates.forEach(state => {
433 args.push('--deferred-states', state);
434 });
435 }
436
437 if (mountPoint) {
438 args.push(mountPoint);
439 }
440
441 return new Promise((resolve, reject) => {
442 this.process = spawn(this.edenBinaryPath, args, {
443 stdio: ['pipe', 'pipe', 'pipe'],
444 });
445
446 let buffer = '';
447
448 const readline = require('readline');
449 const rl = readline.createInterface({input: this.process.stdout});
450
451 rl.on('line', line => {
452 if (line.trim()) {
453 try {
454 const event = JSON.parse(line);
455 this.emit('change', event);
456 } catch (error) {
457 this.emit('error', new Error(`Failed to parse event ${line}: ${error.message}`));
458 }
459 }
460 });
461
462 this.process.stderr.on('data', data => {
463 this.errData += data.toString() + '\n';
464 });
465
466 this.process.on('close', (code, signal) => {
467 if (code !== 0 && code !== null) {
468 this.emit(
469 'error',
470 new Error(`EdenFS process exited with code ${code}\nstderr: ${this.errData}`),
471 );
472 } else if (signal !== null && signal !== 'SIGTERM') {
473 this.emit('error', new Error(`EdenFS process killed with signal ${signal}`));
474 } else {
475 this.emit('close');
476 }
477 });
478
479 this.process.on('error', error => {
480 this.emit('error', error);
481 reject(error);
482 });
483
484 this.process.on('spawn', () => {
485 resolve();
486 });
487
488 this.process.on('exit', (code, signal) => {
489 if (this.killTimeout !== null) {
490 clearTimeout(this.killTimeout);
491 this.killTimeout = null;
492 }
493 this.emit('exit');
494 });
495 });
496 }
497
498 /**
499 * Stop the subscription
500 */
501 stop() {
502 if (this.process) {
503 this.process.kill('SIGTERM');
504 this.killTimeout = setTimeout(() => {
505 this.process.kill('SIGKILL');
506 }, 500);
507 }
508 }
509}
510
511/**
512 * Utility functions for working with EdenFS notify data
513 */
514class EdenFSUtils {
515 /**
516 * Convert byte array path to string
517 * @param {number[]} pathBytes - Array of byte values representing a path
518 * @returns {string} Path string
519 */
520 static bytesToPath(pathBytes) {
521 return Buffer.from(pathBytes).toString('utf8');
522 }
523
524 /**
525 * Convert byte array to hex string
526 * @param {number[]} bytes - Array of byte values
527 * @returns {string} Hexadecimal string
528 */
529 static bytesToHex(bytes) {
530 return Buffer.from(bytes).toString('hex');
531 }
532
533 /**
534 * Extract file type from change
535 * @param {Object} change - change object
536 * @returns {{string}} File Type
537 */
538 static extractFileType(smallChange) {
539 if (smallChange.Added && smallChange.Added.file_type) {
540 return smallChange.Added.file_type;
541 } else if (smallChange.Modified && smallChange.Modified.file_type) {
542 return smallChange.Modified.file_type;
543 } else if (smallChange.Removed && smallChange.Removed.file_type) {
544 return smallChange.Removed.file_type;
545 } else if (smallChange.Renamed) {
546 return smallChange.Renamed.file_type;
547 } else if (smallChange.Replaced) {
548 return smallChange.Replaced.file_type;
549 }
550 }
551
552 /**
553 * Extract file path(s) from change
554 * @param {Object} change - change object
555 * @returns {{string, string | undefined}} First file path, and possible second file path
556 */
557 static extractPath(smallChange) {
558 if (smallChange.Added && smallChange.Added.path) {
559 return [this.bytesToPath(smallChange.Added.path), undefined];
560 } else if (smallChange.Modified && smallChange.Modified.path) {
561 return [this.bytesToPath(smallChange.Modified.path), undefined];
562 } else if (smallChange.Removed && smallChange.Removed.path) {
563 return [this.bytesToPath(smallChange.Removed.path), undefined];
564 } else if (smallChange.Renamed) {
565 return [this.bytesToPath(smallChange.Renamed.from), this.bytesToPath(smallChange.Renamed.to)];
566 } else if (smallChange.Replaced) {
567 return [
568 this.bytesToPath(smallChange.Replaced.from),
569 this.bytesToPath(smallChange.Replaced.to),
570 ];
571 } else {
572 return ['', undefined];
573 }
574 }
575
576 /**
577 * Extract file paths from changes
578 * @param {Object[]} changes - Array of change objects
579 * @returns {string[]} Array of file paths
580 */
581 static extractPaths(changes) {
582 const paths = [];
583
584 changes.forEach(change => {
585 if (change.SmallChange) {
586 let [path1, path2] = this.extractPath(change.SmallChange);
587 if (path1) {
588 paths.push(path1);
589 }
590 if (path2) {
591 paths.push(path2);
592 }
593 }
594 });
595
596 return paths;
597 }
598
599 /**
600 * Get change type from a change object
601 * @param {Object} change - Change object
602 * @returns {string} Change type
603 */
604 static getChangeType(change) {
605 if (change.SmallChange) {
606 const smallChange = change.SmallChange;
607
608 if (smallChange.Added) return 'added';
609 if (smallChange.Modified) return 'modified';
610 if (smallChange.Removed) return 'removed';
611 if (smallChange.Renamed) return 'renamed';
612 if (smallChange.Replaced) return 'replaced';
613 } else if (change.LargeChange) {
614 const largeChange = change.LargeChange;
615 if (largeChange.DirectoryRenamed) return 'directory renamed';
616 if (largeChange.CommitTransition) return 'commit transition';
617 if (largeChange.LostChange) return 'lost change';
618 } else if (change.StateChange) {
619 const stateChange = change.StateChange;
620 if (stateChange.StateEntered) return 'state entered';
621 if (stateChange.StateLeft) return 'state left';
622 }
623
624 return 'unknown';
625 }
626}
627
628module.exports = {
629 EdenFSNotificationsClient,
630 EdenFSSubscription,
631 EdenFSUtils,
632};
633