Spaces:
Running
Running
/** | |
* 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, | |
}); | |