Skip to content

Commit ebbcbcc

Browse files
authored
Merge pull request #1262 from streamich/fs-core-events
feat: 🎸 add event emission to fs-core
2 parents 29b912b + 5dceeca commit ebbcbcc

9 files changed

Lines changed: 349 additions & 43 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ out.bin
2626
.claude/
2727

2828
.yarn/install-state.gz
29+
30+
.docs/

packages/fs-core/src/Superblock.ts

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { PathLike } from '@jsonjoy.com/fs-node-utils/lib/types/misc';
2121
import { ERROR_CODE } from './constants';
2222
import { TFileId, StatError } from './types';
2323
import { Err, Ok, Result } from './result';
24+
import { FsEvent, FsEventType } from './watch/FsEvent';
2425

2526
const pathSep = posix ? posix.sep : sep;
2627
const pathRelative = posix ? posix.relative : relative;
@@ -101,6 +102,12 @@ export class Superblock {
101102
this.root = root;
102103
}
103104

105+
public onchange?: (event: FsEvent) => void;
106+
107+
protected emit(change: FsEvent) {
108+
if (this.onchange) this.onchange(change);
109+
}
110+
104111
createLink(): Link;
105112
createLink(parent: Link, name: string, isDirectory?: boolean, mode?: number): Link;
106113
createLink(parent?: Link, name?: string, isDirectory: boolean = false, mode?: number): Link {
@@ -132,6 +139,17 @@ export class Superblock {
132139
return false;
133140
}
134141

142+
private _emitDeleteRecursive(link: Link) {
143+
if (link.getNode().isDirectory()) {
144+
for (const [name, child] of link.children.entries()) {
145+
if (child && name !== '.' && name !== '..') {
146+
this._emitDeleteRecursive(child);
147+
}
148+
}
149+
}
150+
this.emit(new FsEvent(FsEventType.DELETE, link.steps, link.getNode()));
151+
}
152+
135153
private newInoNumber(): number {
136154
const releasedFd = this.releasedInos.pop();
137155

@@ -489,7 +507,10 @@ export class Superblock {
489507
this.fds[file.fd] = file;
490508
this.openFiles++;
491509

492-
if (flagsNum & O_TRUNC) file.truncate();
510+
if (flagsNum & O_TRUNC) {
511+
file.truncate();
512+
this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
513+
}
493514

494515
return file;
495516
}
@@ -527,6 +548,7 @@ export class Superblock {
527548
modeNum ??= 0o666;
528549

529550
link = this.createLink(dirLink, steps[steps.length - 1], false, modeNum);
551+
this.emit(new FsEvent(FsEventType.CREATE, link.steps, link.getNode()));
530552
} else throw err;
531553
}
532554

@@ -622,13 +644,15 @@ export class Superblock {
622644
if (dir2.getChild(name)) throw createError(ERROR_CODE.EEXIST, 'link', filename1, filename2);
623645
const node = link1.getNode();
624646
node.nlink++;
625-
dir2.createChild(name, node);
647+
const newLink = dir2.createChild(name, node);
648+
this.emit(new FsEvent(FsEventType.CREATE, newLink.steps, node));
626649
};
627650

628651
public readonly unlink = (filename: string) => {
629652
const link: Link = this.getLinkOrThrow(filename, 'unlink');
630653
// TODO: Check if it is file, dir, other...
631654
if (link.length) throw Error('Dir not empty...');
655+
this._emitDeleteRecursive(link);
632656
this.deleteLink(link);
633657
const node = link.getNode();
634658
node.nlink--;
@@ -661,6 +685,7 @@ export class Superblock {
661685
// Create symlink.
662686
const symlink: Link = dirLink.createChild(name);
663687
symlink.getNode().makeSymlink(targetFilename);
688+
this.emit(new FsEvent(FsEventType.CREATE, symlink.steps, symlink.getNode()));
664689
return symlink;
665690
};
666691

@@ -709,9 +734,11 @@ export class Superblock {
709734

710735
// Rename should overwrite the new path, if that exists.
711736
const name = basename(newPathFilename);
737+
const oldSteps = link.steps;
712738
link.name = name;
713739
link.steps = [...newPathDirLink.steps, name];
714740
newPathDirLink.setChild(link.getName(), link);
741+
this.emit(new FsEvent(FsEventType.MOVE, link.steps, link.getNode(), oldSteps));
715742
};
716743

717744
public readonly mkdir = (filename: string, modeNum: number): void => {
@@ -724,7 +751,8 @@ export class Superblock {
724751
if (dir.getChild(name)) throw createError(ERROR_CODE.EEXIST, 'mkdir', filename);
725752
const node = dir.getNode();
726753
if (!node.canWrite() || !node.canExecute()) throw createError(ERROR_CODE.EACCES, 'mkdir', filename);
727-
dir.createChild(name, this.createNode(constants.S_IFDIR | modeNum));
754+
const child = dir.createChild(name, this.createNode(constants.S_IFDIR | modeNum));
755+
this.emit(new FsEvent(FsEventType.CREATE, child.steps, child.getNode()));
728756
};
729757

730758
/**
@@ -759,13 +787,15 @@ export class Superblock {
759787
}
760788
created = true;
761789
curr = curr.createChild(steps[i], this.createNode(constants.S_IFDIR | modeNum));
790+
this.emit(new FsEvent(FsEventType.CREATE, curr.steps, curr.getNode()));
762791
}
763792
return created ? filename : undefined;
764793
};
765794

766795
public readonly rmdir = (filename: string, recursive: boolean = false) => {
767796
const link = this.getLinkAsDirOrThrow(filename, 'rmdir');
768797
if (link.length && !recursive) throw createError(ERROR_CODE.ENOTEMPTY, 'rmdir', filename);
798+
this._emitDeleteRecursive(link);
769799
this.deleteLink(link);
770800
};
771801

@@ -781,6 +811,7 @@ export class Superblock {
781811
}
782812
if (link.getNode().isDirectory() && !recursive) throw createError(ERROR_CODE.ERR_FS_EISDIR, 'rm', filename);
783813
if (!link.parent?.getNode().canWrite()) throw createError(ERROR_CODE.EACCES, 'rm', filename);
814+
this._emitDeleteRecursive(link);
784815
this.deleteLink(link);
785816
};
786817

@@ -802,6 +833,73 @@ export class Superblock {
802833
if (file.node.isSymlink()) {
803834
throw createError(ERROR_CODE.EBADF, 'write', file.link.getPath());
804835
}
805-
return file.write(buf, offset, length, position === -1 || typeof position !== 'number' ? undefined : position);
836+
const bytes = file.write(
837+
buf,
838+
offset,
839+
length,
840+
position === -1 || typeof position !== 'number' ? undefined : position,
841+
);
842+
if (bytes > 0) this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
843+
return bytes;
806844
}
845+
846+
public readonly ftruncate = (fd: number, len?: number): void => {
847+
const file = this.getFileByFdOrThrow(fd, 'ftruncate');
848+
file.truncate(len);
849+
this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
850+
};
851+
852+
public readonly fchmod = (fd: number, modeNum: number): void => {
853+
const file = this.getFileByFdOrThrow(fd, 'fchmod');
854+
file.chmod(modeNum);
855+
this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
856+
};
857+
858+
public readonly chmod = (filename: string, modeNum: number): void => {
859+
const link = this.getResolvedLinkOrThrow(filename, 'chmod');
860+
link.getNode().chmod(modeNum);
861+
this.emit(new FsEvent(FsEventType.MODIFY, link.steps, link.getNode()));
862+
};
863+
864+
public readonly lchmod = (filename: string, modeNum: number): void => {
865+
const link = this.getLinkOrThrow(filename, 'lchmod');
866+
link.getNode().chmod(modeNum);
867+
this.emit(new FsEvent(FsEventType.MODIFY, link.steps, link.getNode()));
868+
};
869+
870+
public readonly fchown = (fd: number, uid: number, gid: number): void => {
871+
const file = this.getFileByFdOrThrow(fd, 'fchown');
872+
file.chown(uid, gid);
873+
this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
874+
};
875+
876+
public readonly chown = (filename: string, uid: number, gid: number): void => {
877+
const link = this.getResolvedLinkOrThrow(filename, 'chown');
878+
link.getNode().chown(uid, gid);
879+
this.emit(new FsEvent(FsEventType.MODIFY, link.steps, link.getNode()));
880+
};
881+
882+
public readonly lchown = (filename: string, uid: number, gid: number): void => {
883+
const link = this.getLinkOrThrow(filename, 'lchown');
884+
link.getNode().chown(uid, gid);
885+
this.emit(new FsEvent(FsEventType.MODIFY, link.steps, link.getNode()));
886+
};
887+
888+
public readonly futimes = (fd: number, atime: number, mtime: number): void => {
889+
const file = this.getFileByFdOrThrow(fd, 'futimes');
890+
const node = file.node;
891+
node.atime = new Date(atime * 1000);
892+
node.mtime = new Date(mtime * 1000);
893+
this.emit(new FsEvent(FsEventType.MODIFY, file.link.steps, file.node));
894+
};
895+
896+
public readonly utimes = (filename: string, atime: number, mtime: number, followSymlinks: boolean = true): void => {
897+
const link = followSymlinks
898+
? this.getResolvedLinkOrThrow(filename, 'utimes')
899+
: this.getLinkOrThrow(filename, 'lutimes');
900+
const node = link.getNode();
901+
node.atime = new Date(atime * 1000);
902+
node.mtime = new Date(mtime * 1000);
903+
this.emit(new FsEvent(FsEventType.MODIFY, link.steps, node));
904+
};
807905
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Superblock } from '../Superblock';
2+
import { FsEvent, FsEventType } from '../watch/FsEvent';
3+
import { FLAGS } from '@jsonjoy.com/fs-node-utils';
4+
import { Buffer } from '@jsonjoy.com/fs-node-builtins/lib/internal/buffer';
5+
6+
const setup = () => {
7+
const sb = new Superblock();
8+
const events: FsEvent[] = [];
9+
sb.onchange = e => events.push(e);
10+
return { sb, events };
11+
};
12+
13+
describe('Superblock events', () => {
14+
it('emits CREATE on mkdir', () => {
15+
const { sb, events } = setup();
16+
sb.mkdir('/test', 0o777);
17+
expect(events.length).toBe(1);
18+
expect(events[0].type).toBe(FsEventType.CREATE);
19+
expect(events[0].steps).toEqual(['', 'test']);
20+
});
21+
22+
it('emits CREATE on file open with O_CREAT', () => {
23+
const { sb, events } = setup();
24+
sb.open('/test.txt', FLAGS.a, 0o666);
25+
expect(events.length).toBe(1);
26+
expect(events[0].type).toBe(FsEventType.CREATE);
27+
expect(events[0].steps).toEqual(['', 'test.txt']);
28+
});
29+
30+
it('emits MODIFY on write', () => {
31+
const { sb, events } = setup();
32+
const fd = sb.open('/test.txt', FLAGS.w, 0o666);
33+
sb.write(fd, Buffer.from('hi'), 0, 2, 0);
34+
expect(events.length).toBe(3);
35+
expect(events[2].type).toBe(FsEventType.MODIFY);
36+
expect(events[2].steps).toEqual(['', 'test.txt']);
37+
});
38+
39+
it('does not emit MODIFY on zero-byte write', () => {
40+
const { sb, events } = setup();
41+
const fd = sb.open('/test.txt', FLAGS.w, 0o666);
42+
const numEvents = events.length;
43+
sb.write(fd, Buffer.from(''), 0, 0, 0);
44+
expect(events.length).toBe(numEvents);
45+
});
46+
47+
it('emits MODIFY on truncate', () => {
48+
const sb = new Superblock();
49+
const fd = sb.open('/test.txt', FLAGS.w, 0o666);
50+
const events: FsEvent[] = [];
51+
sb.onchange = e => events.push(e);
52+
sb.ftruncate(fd, 10);
53+
expect(events.length).toBe(1);
54+
expect(events[0].type).toBe(FsEventType.MODIFY);
55+
expect(events[0].steps).toEqual(['', 'test.txt']);
56+
});
57+
58+
it('emits MODIFY on chmod and chown', () => {
59+
const { sb, events } = setup();
60+
sb.open('/test.txt', FLAGS.w, 0o666);
61+
events.length = 0;
62+
sb.chmod('/test.txt', 0o600);
63+
sb.chown('/test.txt', 1, 1);
64+
expect(events.length).toBe(2);
65+
expect(events[0].type).toBe(FsEventType.MODIFY);
66+
expect(events[0].steps).toEqual(['', 'test.txt']);
67+
expect(events[1].type).toBe(FsEventType.MODIFY);
68+
});
69+
70+
it('emits MODIFY on utimes and futimes', () => {
71+
const { sb, events } = setup();
72+
const fd = sb.open('/test.txt', FLAGS.w, 0o666);
73+
events.length = 0;
74+
sb.utimes('/test.txt', 1, 1);
75+
sb.futimes(fd, 2, 2);
76+
expect(events.length).toBe(2);
77+
expect(events[0].type).toBe(FsEventType.MODIFY);
78+
expect(events[0].steps).toEqual(['', 'test.txt']);
79+
expect(events[1].type).toBe(FsEventType.MODIFY);
80+
expect(events[1].steps).toEqual(['', 'test.txt']);
81+
});
82+
83+
it('emits MODIFY on lutimes of a symlink', () => {
84+
const { sb, events } = setup();
85+
sb.open('/test.txt', FLAGS.w, 0o666);
86+
sb.symlink('/test.txt', '/link');
87+
events.length = 0;
88+
sb.utimes('/link', 1, 1, false);
89+
expect(events.length).toBe(1);
90+
expect(events[0].type).toBe(FsEventType.MODIFY);
91+
expect(events[0].steps).toEqual(['', 'link']);
92+
});
93+
94+
it('emits MOVE on rename', () => {
95+
const sb = new Superblock();
96+
sb.open('/test.txt', FLAGS.w, 0o666);
97+
const events: FsEvent[] = [];
98+
sb.onchange = e => events.push(e);
99+
sb.rename('/test.txt', '/test2.txt');
100+
expect(events.length).toBe(1);
101+
expect(events[0].type).toBe(FsEventType.MOVE);
102+
expect(events[0].steps).toEqual(['', 'test2.txt']);
103+
expect(events[0].oldSteps).toEqual(['', 'test.txt']);
104+
});
105+
106+
it('emits DELETE on unlink', () => {
107+
const sb = new Superblock();
108+
sb.open('/test.txt', FLAGS.w, 0o666);
109+
const events: FsEvent[] = [];
110+
sb.onchange = e => events.push(e);
111+
sb.unlink('/test.txt');
112+
expect(events.length).toBe(1);
113+
expect(events[0].type).toBe(FsEventType.DELETE);
114+
expect(events[0].steps).toEqual(['', 'test.txt']);
115+
});
116+
117+
it('emits multiple DELETEs recursively for rmdir/rm', () => {
118+
const sb = new Superblock();
119+
sb.mkdir('/dir', 0o777);
120+
sb.mkdir('/dir/sub', 0o777);
121+
sb.open('/dir/sub/file.txt', FLAGS.w, 0o666);
122+
const events: FsEvent[] = [];
123+
sb.onchange = e => events.push(e);
124+
sb.rm('/dir', false, true);
125+
expect(events.length).toBe(3);
126+
expect(events[0].type).toBe(FsEventType.DELETE);
127+
expect(events[0].steps).toEqual(['', 'dir', 'sub', 'file.txt']);
128+
expect(events[1].type).toBe(FsEventType.DELETE);
129+
expect(events[1].steps).toEqual(['', 'dir', 'sub']);
130+
expect(events[2].type).toBe(FsEventType.DELETE);
131+
expect(events[2].steps).toEqual(['', 'dir']);
132+
});
133+
});

packages/fs-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { Node, type NodeEvent } from './Node';
66
export { Link, type LinkEvent } from './Link';
77
export { File } from './File';
88
export { Superblock } from './Superblock';
9+
export * from './watch/FsEvent';
910
export type { IProcess } from './process';
1011
export {
1112
dataToBuffer,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Node } from '../Node';
2+
3+
export const enum FsEventType {
4+
CREATE,
5+
DELETE,
6+
MODIFY,
7+
MOVE,
8+
}
9+
10+
export class FsEvent {
11+
constructor(
12+
public readonly type: FsEventType,
13+
public readonly steps: string[],
14+
public readonly node: Node,
15+
public readonly oldSteps: string[] | undefined = void 0,
16+
) {}
17+
}

packages/fs-fsa/src/CoreFileSystemSyncAccessHandle.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,8 @@ export class CoreFileSystemSyncAccessHandle implements IFileSystemSyncAccessHand
9696
}
9797

9898
try {
99-
const link = this._core.getResolvedLinkOrThrow(this._path);
100-
const node = link.getNode();
101-
node.truncate(newSize);
99+
const fd = this._ensureOpen();
100+
this._core.ftruncate(fd, newSize);
102101
} catch (error) {
103102
if (error && typeof error === 'object' && error.code === ERROR_CODE.EACCES) {
104103
throw newNotAllowedError();

packages/fs-fsa/src/CoreFileSystemWritableFileStream.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,10 @@ export class CoreFileSystemWritableFileStream extends WS implements IFileSystemW
8484
throw new DOMException('The stream is closed.', 'InvalidStateError');
8585
}
8686
try {
87-
const link = this._core.getResolvedLinkOrThrow(this._path);
88-
const node = link.getNode();
89-
node.truncate(size);
87+
if (this._fd === undefined) {
88+
throw new DOMException('The stream is not ready.', 'InvalidStateError');
89+
}
90+
this._core.ftruncate(this._fd, size);
9091
} catch (error) {
9192
if (error && typeof error === 'object' && error.code === ERROR_CODE.EACCES) {
9293
throw newNotAllowedError();

0 commit comments

Comments
 (0)