mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-03-21 17:25:10 -05:00
520 lines
14 KiB
TypeScript
520 lines
14 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
override _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();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
override _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,
|
|
});
|