Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* FS
* Pokemon Showdown - http://pokemonshowdown.com/
*
* An abstraction layer around Node's filesystem.
*
* Advantages:
* - write() etc do nothing in unit tests
* - paths are always relative to PS's base directory
* - Promises (seriously wtf Node Core what are you thinking)
* - PS-style API: FS("foo.txt").write("bar") for easier argument order
* - mkdirp
*
* FS is used nearly everywhere, but exceptions include:
* - crashlogger.js - in case the crash is in here
* - repl.js - which use Unix sockets out of this file's scope
* - launch script - happens before modules are loaded
* - sim/ - intended to be self-contained
*
* @author Guangcong Luo <guangcongluo@gmail.com>
* @license MIT
*/
import * as fs from 'fs';
import * as pathModule from 'path';
import { ReadStream, WriteStream } from './streams';
// not sure why it's necessary to use path.sep, but testing with Windows showed it was
const DIST = `${pathModule.sep}dist${pathModule.sep}`;
// account for pwd/dist/lib
const ROOT_PATH = pathModule.resolve(__dirname, __dirname.includes(DIST) ? '..' : '', '..');
interface PendingUpdate {
isWriting: boolean; // true: waiting on a call to FS.write, false: waiting on a throttle
pendingDataFetcher: (() => string | Buffer) | null;
pendingOptions: AnyObject | null;
throttleTime: number; // throttling until time (0 for no throttle)
throttleTimer: NodeJS.Timeout | null;
}
declare const __fsState: { pendingUpdates: Map<string, PendingUpdate> };
// config needs to be declared here since we access it as global.Config?.nofswriting
// (so we can use it without the global)
declare const global: { __fsState: typeof __fsState, Config: any };
if (!global.__fsState) {
global.__fsState = {
pendingUpdates: new Map(),
};
}
export class FSPath {
path: string;
constructor(path: string) {
this.path = pathModule.resolve(ROOT_PATH, path);
}
parentDir() {
return new FSPath(pathModule.dirname(this.path));
}
read(options: AnyObject | BufferEncoding = 'utf8'): Promise<string> {
if (typeof options !== 'string' && options.encoding === undefined) {
options.encoding = 'utf8';
}
return new Promise((resolve, reject) => {
fs.readFile(this.path, options, (err, data) => {
err ? reject(err) : resolve(data as string);
});
});
}
readSync(options: AnyObject | string = 'utf8'): string {
if (typeof options !== 'string' && options.encoding === undefined) {
options.encoding = 'utf8';
}
return fs.readFileSync(this.path, options as { encoding: 'utf8' });
}
readBuffer(options: AnyObject | BufferEncoding = {}): Promise<Buffer> {
return new Promise((resolve, reject) => {
fs.readFile(this.path, options, (err, data) => {
err ? reject(err) : resolve(data as Buffer);
});
});
}
readBufferSync(options: AnyObject | string = {}) {
return fs.readFileSync(this.path, options as { encoding: null });
}
exists(): Promise<boolean> {
return new Promise(resolve => {
fs.exists(this.path, exists => {
resolve(exists);
});
});
}
existsSync() {
return fs.existsSync(this.path);
}
readIfExists(): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(this.path, 'utf8', (err, data) => {
if (err && err.code === 'ENOENT') return resolve('');
err ? reject(err) : resolve(data);
});
});
}
readIfExistsSync() {
try {
return fs.readFileSync(this.path, 'utf8');
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
return '';
}
write(data: string | Buffer, options: AnyObject = {}) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.writeFile(this.path, data, options, err => {
err ? reject(err) : resolve();
});
});
}
writeSync(data: string | Buffer, options: AnyObject = {}) {
if (global.Config?.nofswriting) return;
return fs.writeFileSync(this.path, data, options);
}
/**
* Writes to a new file before renaming to replace an old file. If
* the process crashes while writing, the old file won't be lost.
* Does not protect against simultaneous writing; use writeUpdate
* for that.
*/
async safeWrite(data: string | Buffer, options: AnyObject = {}) {
await FS(this.path + '.NEW').write(data, options);
await FS(this.path + '.NEW').rename(this.path);
}
safeWriteSync(data: string | Buffer, options: AnyObject = {}) {
FS(this.path + '.NEW').writeSync(data, options);
FS(this.path + '.NEW').renameSync(this.path);
}
/**
* Safest way to update a file with in-memory state. Pass a callback
* that fetches the data to be written. It will write an update,
* avoiding race conditions. The callback may not necessarily be
* called, if `writeUpdate` is called many times in a short period.
*
* `options.throttle`, if it exists, will make sure updates are not
* written more than once every `options.throttle` milliseconds.
*
* No synchronous version because there's no risk of race conditions
* with synchronous code; just use `safeWriteSync`.
*/
writeUpdate(dataFetcher: () => string | Buffer, options: AnyObject = {}) {
if (global.Config?.nofswriting) return;
const pendingUpdate: PendingUpdate | undefined = __fsState.pendingUpdates.get(this.path);
const throttleTime = options.throttle ? Date.now() + options.throttle : 0;
if (pendingUpdate) {
pendingUpdate.pendingDataFetcher = dataFetcher;
pendingUpdate.pendingOptions = options;
if (pendingUpdate.throttleTimer && throttleTime < pendingUpdate.throttleTime) {
pendingUpdate.throttleTime = throttleTime;
clearTimeout(pendingUpdate.throttleTimer);
pendingUpdate.throttleTimer = setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now());
}
return;
}
if (!throttleTime) {
this.writeUpdateNow(dataFetcher, options);
return;
}
const update: PendingUpdate = {
isWriting: false,
pendingDataFetcher: dataFetcher,
pendingOptions: options,
throttleTime,
throttleTimer: setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now()),
};
__fsState.pendingUpdates.set(this.path, update);
}
writeUpdateNow(dataFetcher: () => string | Buffer, options: AnyObject) {
const throttleTime = options.throttle ? Date.now() + options.throttle : 0;
const update = {
isWriting: true,
pendingDataFetcher: null,
pendingOptions: null,
throttleTime,
throttleTimer: null,
};
__fsState.pendingUpdates.set(this.path, update);
void this.safeWrite(dataFetcher(), options).then(() => this.finishUpdate());
}
checkNextUpdate() {
const pendingUpdate = __fsState.pendingUpdates.get(this.path);
if (!pendingUpdate) throw new Error(`FS: Pending update not found`);
if (pendingUpdate.isWriting) throw new Error(`FS: Conflicting update`);
const { pendingDataFetcher: dataFetcher, pendingOptions: options } = pendingUpdate;
if (!dataFetcher || !options) {
// no pending update
__fsState.pendingUpdates.delete(this.path);
return;
}
this.writeUpdateNow(dataFetcher, options);
}
finishUpdate() {
const pendingUpdate = __fsState.pendingUpdates.get(this.path);
if (!pendingUpdate) throw new Error(`FS: Pending update not found`);
if (!pendingUpdate.isWriting) throw new Error(`FS: Conflicting update`);
pendingUpdate.isWriting = false;
const throttleTime = pendingUpdate.throttleTime;
if (!throttleTime || throttleTime < Date.now()) {
this.checkNextUpdate();
return;
}
pendingUpdate.throttleTimer = setTimeout(() => this.checkNextUpdate(), throttleTime - Date.now());
}
append(data: string | Buffer, options: AnyObject = {}) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.appendFile(this.path, data, options, err => {
err ? reject(err) : resolve();
});
});
}
appendSync(data: string | Buffer, options: AnyObject = {}) {
if (global.Config?.nofswriting) return;
return fs.appendFileSync(this.path, data, options);
}
symlinkTo(target: string) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.symlink(target, this.path, err => {
err ? reject(err) : resolve();
});
});
}
symlinkToSync(target: string) {
if (global.Config?.nofswriting) return;
return fs.symlinkSync(target, this.path);
}
copyFile(dest: string) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.copyFile(this.path, dest, err => {
err ? reject(err) : resolve();
});
});
}
rename(target: string) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.rename(this.path, target, err => {
err ? reject(err) : resolve();
});
});
}
renameSync(target: string) {
if (global.Config?.nofswriting) return;
return fs.renameSync(this.path, target);
}
readdir(): Promise<string[]> {
return new Promise((resolve, reject) => {
fs.readdir(this.path, (err, data) => {
err ? reject(err) : resolve(data);
});
});
}
readdirSync() {
return fs.readdirSync(this.path);
}
async readdirIfExists(): Promise<string[]> {
if (await this.exists()) return this.readdir();
return Promise.resolve([]);
}
readdirIfExistsSync() {
if (this.existsSync()) return this.readdirSync();
return [];
}
createReadStream() {
return new FileReadStream(this.path);
}
createWriteStream(options = {}): WriteStream {
if (global.Config?.nofswriting) {
return new WriteStream({ write() {} });
}
return new WriteStream(fs.createWriteStream(this.path, options));
}
createAppendStream(options: AnyObject = {}): WriteStream {
if (global.Config?.nofswriting) {
return new WriteStream({ write() {} });
}
options.flags = options.flags || 'a';
return new WriteStream(fs.createWriteStream(this.path, options));
}
unlinkIfExists() {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.unlink(this.path, err => {
if (err && err.code === 'ENOENT') return resolve();
err ? reject(err) : resolve();
});
});
}
unlinkIfExistsSync() {
if (global.Config?.nofswriting) return;
try {
fs.unlinkSync(this.path);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
}
async rmdir(recursive?: boolean) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.rmdir(this.path, { recursive }, err => {
err ? reject(err) : resolve();
});
});
}
rmdirSync(recursive?: boolean) {
if (global.Config?.nofswriting) return;
return fs.rmdirSync(this.path, { recursive });
}
mkdir(mode: string | number = 0o755) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.mkdir(this.path, mode, err => {
err ? reject(err) : resolve();
});
});
}
mkdirSync(mode: string | number = 0o755) {
if (global.Config?.nofswriting) return;
return fs.mkdirSync(this.path, mode);
}
mkdirIfNonexistent(mode: string | number = 0o755) {
if (global.Config?.nofswriting) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
fs.mkdir(this.path, mode, err => {
if (err && err.code === 'EEXIST') return resolve();
err ? reject(err) : resolve();
});
});
}
mkdirIfNonexistentSync(mode: string | number = 0o755) {
if (global.Config?.nofswriting) return;
try {
fs.mkdirSync(this.path, mode);
} catch (err: any) {
if (err.code !== 'EEXIST') throw err;
}
}
/**
* Creates the directory (and any parent directories if necessary).
* Does not throw if the directory already exists.
*/
async mkdirp(mode: string | number = 0o755) {
try {
await this.mkdirIfNonexistent(mode);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
await this.parentDir().mkdirp(mode);
await this.mkdirIfNonexistent(mode);
}
}
/**
* Creates the directory (and any parent directories if necessary).
* Does not throw if the directory already exists. Synchronous.
*/
mkdirpSync(mode: string | number = 0o755) {
try {
this.mkdirIfNonexistentSync(mode);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
this.parentDir().mkdirpSync(mode);
this.mkdirIfNonexistentSync(mode);
}
}
/** Calls the callback if the file is modified. */
onModify(callback: () => void) {
fs.watchFile(this.path, (curr, prev) => {
if (curr.mtime > prev.mtime) return callback();
});
}
/** Clears callbacks added with onModify(). */
unwatch() {
fs.unwatchFile(this.path);
}
async isFile() {
return new Promise<boolean>((resolve, reject) => {
fs.stat(this.path, (err, stats) => {
err ? reject(err) : resolve(stats.isFile());
});
});
}
isFileSync() {
return fs.statSync(this.path).isFile();
}
async isDirectory() {
return new Promise<boolean>((resolve, reject) => {
fs.stat(this.path, (err, stats) => {
err ? reject(err) : resolve(stats.isDirectory());
});
});
}
isDirectorySync() {
return fs.statSync(this.path).isDirectory();
}
async realpath() {
return new Promise<string>((resolve, reject) => {
fs.realpath(this.path, (err, path) => {
err ? reject(err) : resolve(path);
});
});
}
realpathSync() {
return fs.realpathSync(this.path);
}
}
class FileReadStream extends ReadStream {
fd: Promise<number>;
constructor(file: string) {
super();
this.fd = new Promise((resolve, reject) => {
fs.open(file, 'r', (err, fd) => err ? reject(err) : resolve(fd));
});
this.atEOF = false;
}
_read(size = 16384): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (this.atEOF) return void resolve();
this.ensureCapacity(size);
void this.fd.then(fd => {
fs.read(fd, this.buf, this.bufEnd, size, null, (err, bytesRead, buf) => {
if (err) return reject(err);
if (!bytesRead) {
this.atEOF = true;
this.resolvePush();
return resolve();
}
this.bufEnd += bytesRead;
// throw new Error([...this.buf].map(x => x.toString(16)).join(' '));
this.resolvePush();
resolve();
});
});
});
}
_destroy() {
return new Promise<void>(resolve => {
void this.fd.then(fd => {
fs.close(fd, () => resolve());
});
});
}
}
function getFs(path: string) {
return new FSPath(path);
}
export const FS = Object.assign(getFs, {
FileReadStream, FSPath, ROOT_PATH,
});