metro-memory-fs: add watch()

Reviewed By: rubennorte

Differential Revision: D7443797

fbshipit-source-id: 2ffdfb3649caf057c42313e36e9ff35e70f4f759
This commit is contained in:
Jean Lauliac 2018-04-04 02:50:20 -07:00 committed by Facebook Github Bot
parent 72a66fa8e4
commit 6d8b06dcd9
2 changed files with 202 additions and 8 deletions

View File

@ -367,6 +367,57 @@ it('able to list files of a directory', () => {
expect(fs.readdirSync('/baz')).toEqual(['foo.txt', 'bar.txt', 'glo.txt']); expect(fs.readdirSync('/baz')).toEqual(['foo.txt', 'bar.txt', 'glo.txt']);
}); });
describe('watch', () => {
it('reports changed files', () => {
const changedPaths = [];
fs.writeFileSync('/foo.txt', '');
fs.writeFileSync('/bar.txt', '');
const watcher = collectWatchEvents('/', {}, changedPaths);
fs.writeFileSync('/foo.txt', 'test');
fs.writeFileSync('/bar.txt', 'tadam');
expect(changedPaths).toEqual([
['change', 'foo.txt'],
['change', 'bar.txt'],
]);
watcher.close();
});
it('does not report nested changed files if non-recursive', () => {
const changedPaths = [];
fs.mkdirSync('/foo');
fs.writeFileSync('/foo/bar.txt', '');
const watcher = collectWatchEvents('/', {}, changedPaths);
fs.writeFileSync('/foo/bar.txt', 'test');
expect(changedPaths).toEqual([]);
watcher.close();
});
it('does report nested changed files if recursive', () => {
const changedPaths = [];
fs.mkdirSync('/foo');
fs.writeFileSync('/foo/bar.txt', '');
const watcher = collectWatchEvents('/', {recursive: true}, changedPaths);
fs.writeFileSync('/foo/bar.txt', 'test');
expect(changedPaths).toEqual([['change', 'foo/bar.txt']]);
watcher.close();
});
it('reports created files', () => {
const changedPaths = [];
const watcher = collectWatchEvents('/', {}, changedPaths);
const fd = fs.openSync('/foo.txt', 'w');
expect(changedPaths).toEqual([['rename', 'foo.txt']]);
fs.closeSync(fd);
watcher.close();
});
function collectWatchEvents(entPath, options, events) {
return fs.watch(entPath, options, (eventName, filePath) => {
events.push([eventName, filePath]);
});
}
});
it('throws when trying to read inexistent file', () => { it('throws when trying to read inexistent file', () => {
expectFsError('ENOENT', () => fs.readFileSync('/foo.txt')); expectFsError('ENOENT', () => fs.readFileSync('/foo.txt'));
}); });

View File

@ -15,8 +15,11 @@ const constants = require('constants');
const path = require('path'); const path = require('path');
const stream = require('stream'); const stream = require('stream');
const {EventEmitter} = require('events');
type NodeBase = {| type NodeBase = {|
id: number, id: number,
watchers: Array<NodeWatcher>,
|}; |};
type DirectoryNode = {| type DirectoryNode = {|
@ -39,6 +42,11 @@ type SymbolicLinkNode = {|
type EntityNode = DirectoryNode | FileNode | SymbolicLinkNode; type EntityNode = DirectoryNode | FileNode | SymbolicLinkNode;
type NodeWatcher = {
recursive: boolean,
listener: (eventType: 'change' | 'rename', filePath: string) => void,
};
type Encoding = type Encoding =
| 'ascii' | 'ascii'
| 'base64' | 'base64'
@ -52,11 +60,13 @@ type Encoding =
type Resolution = {| type Resolution = {|
+basename: string, +basename: string,
+dirNode: DirectoryNode, +dirNode: DirectoryNode,
+dirPath: Array<[string, EntityNode]>,
+node: ?EntityNode, +node: ?EntityNode,
+realpath: string, +realpath: string,
|}; |};
type Descriptor = {| type Descriptor = {|
+nodePath: Array<[string, EntityNode]>,
+node: FileNode, +node: FileNode,
+readable: boolean, +readable: boolean,
+writable: boolean, +writable: boolean,
@ -185,6 +195,8 @@ class MemoryFs {
} }
closeSync = (fd: number): void => { closeSync = (fd: number): void => {
const desc = this._getDesc(fd);
this._emitFileChange(desc.nodePath.slice(), {eventType: 'change'});
this._fds.delete(fd); this._fds.delete(fd);
}; };
@ -389,9 +401,10 @@ class MemoryFs {
throw makeError('EEXIST', filePath, 'directory or file already exists'); throw makeError('EEXIST', filePath, 'directory or file already exists');
} }
dirNode.entries.set(basename, { dirNode.entries.set(basename, {
type: 'symbolicLink',
id: this._getId(), id: this._getId(),
target: pathStr(target), target: pathStr(target),
type: 'symbolicLink',
watchers: [],
}); });
}; };
@ -512,8 +525,49 @@ class MemoryFs {
return st; return st;
}; };
watch = (
filePath: string | Buffer,
options?:
| {
encoding?: Encoding,
recursive?: boolean,
persistent?: boolean,
}
| Encoding,
listener?: (
eventType: 'rename' | 'change',
filePath: ?string | Buffer,
) => mixed,
) => {
filePath = pathStr(filePath);
const {node} = this._resolve(filePath);
if (node == null) {
throw makeError('ENOENT', filePath, 'no such file or directory');
}
let encoding, recursive, persistent;
if (typeof options === 'string') {
encoding = options;
} else if (options != null) {
({encoding, recursive, persistent} = options);
}
const watcher = new FSWatcher(node, {
encoding: encoding != null ? encoding : 'utf8',
recursive: recursive != null ? recursive : false,
persistent: persistent != null ? persistent : false,
});
if (listener != null) {
watcher.on('change', listener);
}
return watcher;
};
_makeDir() { _makeDir() {
return {type: 'directory', id: this._getId(), entries: new Map()}; return {
entries: new Map(),
id: this._getId(),
type: 'directory',
watchers: [],
};
} }
_getId() { _getId() {
@ -530,13 +584,21 @@ class MemoryFs {
} }
const {writable = false, readable = false} = spec; const {writable = false, readable = false} = spec;
const {exclusive, mustExist, truncate} = spec; const {exclusive, mustExist, truncate} = spec;
let {dirNode, node, basename} = this._resolve(filePath); let {dirNode, node, basename, dirPath} = this._resolve(filePath);
let nodePath;
if (node == null) { if (node == null) {
if (mustExist) { if (mustExist) {
throw makeError('ENOENT', filePath, 'no such file or directory'); throw makeError('ENOENT', filePath, 'no such file or directory');
} }
node = {type: 'file', id: this._getId(), content: new Buffer(0)}; node = {
content: new Buffer(0),
id: this._getId(),
type: 'file',
watchers: [],
};
dirNode.entries.set(basename, node); dirNode.entries.set(basename, node);
nodePath = dirPath.concat([[basename, node]]);
this._emitFileChange(nodePath.slice(), {eventType: 'rename'});
} else { } else {
if (exclusive) { if (exclusive) {
throw makeError('EEXIST', filePath, 'directory or file already exists'); throw makeError('EEXIST', filePath, 'directory or file already exists');
@ -547,8 +609,15 @@ class MemoryFs {
if (truncate) { if (truncate) {
node.content = new Buffer(0); node.content = new Buffer(0);
} }
nodePath = dirPath.concat([[basename, node]]);
} }
return this._getFd(filePath, {node, position: 0, writable, readable}); return this._getFd(filePath, {
nodePath,
node,
position: 0,
readable,
writable,
});
} }
/** /**
@ -593,9 +662,21 @@ class MemoryFs {
const {nodePath} = context; const {nodePath} = context;
return { return {
realpath: drive + nodePath.map(x => x[0]).join(path.sep), realpath: drive + nodePath.map(x => x[0]).join(path.sep),
dirNode: (nodePath[nodePath.length - 2][1]: $FlowFixMe), dirNode: (() => {
const dirNode =
nodePath.length >= 2
? nodePath[nodePath.length - 2][1]
: context.node;
if (dirNode == null || dirNode.type !== 'directory') {
throw new Error('failed to resolve');
}
return dirNode;
})(),
node: context.node, node: context.node,
basename: (nodePath[nodePath.length - 1][0]: $FlowFixMe), basename: nullthrows(nodePath[nodePath.length - 1][0]),
dirPath: nodePath
.slice(0, -1)
.map(nodePair => [nodePair[0], nullthrows(nodePair[1])]),
}; };
} }
@ -687,6 +768,26 @@ class MemoryFs {
} }
return desc; return desc;
} }
_emitFileChange(
nodePath: Array<[string, EntityNode]>,
options: {eventType: 'rename' | 'change'},
): void {
const node = nodePath.pop();
let filePath = node[0];
let recursive = false;
while (nodePath.length > 0) {
const dirNode = nodePath.pop();
for (const watcher of dirNode[1].watchers) {
if (recursive && !watcher.recursive) {
continue;
}
watcher.listener(options.eventType, filePath);
}
filePath = path.join(dirNode[0], filePath);
recursive = true;
}
}
} }
class Stats { class Stats {
@ -870,6 +971,41 @@ class WriteFileStream extends stream.Writable {
} }
} }
class FSWatcher extends EventEmitter {
_encoding: Encoding;
_node: EntityNode;
_nodeWatcher: NodeWatcher;
constructor(
node: EntityNode,
options: {encoding: Encoding, recursive: boolean, persistent: boolean},
) {
super();
this._encoding = options.encoding;
this._nodeWatcher = {
recursive: options.recursive,
listener: this._listener,
};
node.watchers.push(this._nodeWatcher);
this._node = node;
}
close() {
this._node.watchers.splice(this._node.watchers.indexOf(this._nodeWatcher));
}
_listener = (eventType, filePath: string) => {
const encFilePath =
this._encoding === 'buffer' ? Buffer.from(filePath, 'utf8') : filePath;
try {
this.emit('change', eventType, encFilePath);
} catch (error) {
this.close();
this.emit('error', error);
}
};
}
function checkPathLength(entNames, filePath) { function checkPathLength(entNames, filePath) {
if (entNames.length > 32) { if (entNames.length > 32) {
throw makeError( throw makeError(
@ -888,7 +1024,7 @@ function pathStr(filePath: string | Buffer): string {
return filePath.toString('utf8'); return filePath.toString('utf8');
} }
function makeError(code, filePath, message) { function makeError(code: string, filePath: ?string, message: string) {
const err: $FlowFixMe = new Error( const err: $FlowFixMe = new Error(
filePath != null filePath != null
? `${code}: \`${filePath}\`: ${message}` ? `${code}: \`${filePath}\`: ${message}`
@ -900,4 +1036,11 @@ function makeError(code, filePath, message) {
return err; return err;
} }
function nullthrows<T>(x: ?T): T {
if (x == null) {
throw new Error('item was null or undefined');
}
return x;
}
module.exports = MemoryFs; module.exports = MemoryFs;