mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-04-24 15:00:11 -05:00
PS's FS(...).writeUpdate(...) has a `throttle` option. This changes it so it's possible to call it with the throttle on sometimes and off sometimes, and "throttle off" will pre-empt "throttle on" calls.
457 lines
12 KiB
JavaScript
457 lines
12 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const pathModule = require('path');
|
|
const fs = require('fs');
|
|
const Streams = require('./streams');
|
|
const WriteStream = Streams.WriteStream;
|
|
|
|
const ROOT_PATH = pathModule.resolve(__dirname, '..');
|
|
|
|
/*eslint no-unused-expressions: ["error", { "allowTernary": true }]*/
|
|
|
|
class FSPath {
|
|
/**
|
|
* @param {string} path
|
|
*/
|
|
constructor(path) {
|
|
this.path = pathModule.resolve(ROOT_PATH, path);
|
|
}
|
|
parentDir() {
|
|
return new FSPath(pathModule.dirname(this.path));
|
|
}
|
|
/**
|
|
* @param {AnyObject | string} [options]
|
|
* @return {Promise<string>}
|
|
*/
|
|
read(options = 'utf8') {
|
|
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(/** @type {string} */ (data));
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {AnyObject | string} [options]
|
|
* @return {string}
|
|
*/
|
|
readSync(/** @type {AnyObject | string} */ options = 'utf8') {
|
|
if (typeof options !== 'string' && options.encoding === undefined) {
|
|
options.encoding = 'utf8';
|
|
}
|
|
return /** @type {string} */ (fs.readFileSync(this.path, options));
|
|
}
|
|
/**
|
|
* @param {AnyObject | string} [options]
|
|
* @return {Promise<Buffer>}
|
|
*/
|
|
readBuffer(/** @type {AnyObject | string} */ options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
fs.readFile(this.path, options, (err, data) => {
|
|
err ? reject(err) : resolve(/** @type {Buffer} */ (data));
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {AnyObject | string} [options]
|
|
* @return {Buffer}
|
|
*/
|
|
readBufferSync(/** @type {AnyObject | string} */ options = {}) {
|
|
return /** @type {Buffer} */ (fs.readFileSync(this.path, options));
|
|
}
|
|
/**
|
|
* @return {Promise<string>}
|
|
*/
|
|
readIfExists() {
|
|
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) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
return '';
|
|
}
|
|
/**
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
write(data, options = {}) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.writeFile(this.path, data, options, err => {
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
writeSync(data, options = {}) {
|
|
if (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.
|
|
*
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
async safeWrite(data, options = {}) {
|
|
await FS(this.path + '.NEW').write(data, options);
|
|
await FS(this.path + '.NEW').rename(this.path);
|
|
}
|
|
/**
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
safeWriteSync(data, options = {}) {
|
|
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`.
|
|
*
|
|
* @param {() => string | Buffer} dataFetcher
|
|
* @param {Object} options
|
|
*/
|
|
writeUpdate(dataFetcher, options = {}) {
|
|
if (Config.nofswriting) return;
|
|
const pendingUpdate = FS.pendingUpdates.get(this.path);
|
|
if (pendingUpdate) {
|
|
const [, oldOptions, throttlePromise] = pendingUpdate;
|
|
if (!throttlePromise || options.throttle) {
|
|
pendingUpdate[0] = dataFetcher;
|
|
if (!options.throttle) oldOptions.throttle = 0;
|
|
return;
|
|
}
|
|
FS.pendingUpdates.delete(this.path);
|
|
}
|
|
(async (dataFetcher, options) => {
|
|
let pendingFetcher = /** @type {(() => string | Buffer)?} */ (dataFetcher);
|
|
while (pendingFetcher) {
|
|
/** @type {PendingUpdate | undefined} */
|
|
let pendingUpdate = [null, options, null];
|
|
FS.pendingUpdates.set(this.path, pendingUpdate);
|
|
await this.safeWrite(pendingFetcher(), options);
|
|
pendingUpdate = FS.pendingUpdates.get(this.path);
|
|
if (!pendingUpdate) return;
|
|
[, options] = pendingUpdate;
|
|
if (options.throttle) {
|
|
let throttlePromise = new Promise(resolve => setTimeout(resolve, options.throttle));
|
|
pendingUpdate[2] = throttlePromise;
|
|
await throttlePromise;
|
|
let newUpdate = FS.pendingUpdates.get(this.path);
|
|
if (newUpdate && newUpdate[2] === throttlePromise) return;
|
|
}
|
|
pendingUpdate = FS.pendingUpdates.get(this.path);
|
|
if (!pendingUpdate) return;
|
|
[pendingFetcher, options] = pendingUpdate;
|
|
}
|
|
FS.pendingUpdates.delete(this.path);
|
|
})(dataFetcher, options);
|
|
}
|
|
/**
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
append(data, options = {}) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.appendFile(this.path, data, options, err => {
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string | Buffer} data
|
|
* @param {Object} options
|
|
*/
|
|
appendSync(data, options = {}) {
|
|
if (Config.nofswriting) return;
|
|
return fs.appendFileSync(this.path, data, options);
|
|
}
|
|
/**
|
|
* @param {string} target
|
|
*/
|
|
symlinkTo(target) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.symlink(target, this.path, err => {
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string} target
|
|
*/
|
|
symlinkToSync(target) {
|
|
if (Config.nofswriting) return;
|
|
return fs.symlinkSync(target, this.path);
|
|
}
|
|
/**
|
|
* @param {string} target
|
|
*/
|
|
rename(target) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.rename(this.path, target, err => {
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string} target
|
|
*/
|
|
renameSync(target) {
|
|
if (Config.nofswriting) return;
|
|
return fs.renameSync(this.path, target);
|
|
}
|
|
readdir() {
|
|
return new Promise((resolve, reject) => {
|
|
fs.readdir(this.path, (err, data) => {
|
|
err ? reject(err) : resolve(data);
|
|
});
|
|
});
|
|
}
|
|
readdirSync() {
|
|
return fs.readdirSync(this.path);
|
|
}
|
|
createReadStream() {
|
|
return new FileReadStream(this.path);
|
|
}
|
|
/**
|
|
* @return {WriteStream}
|
|
*/
|
|
createWriteStream(options = {}) {
|
|
if (Config.nofswriting) {
|
|
// @ts-ignore
|
|
return new WriteStream({write() {}});
|
|
}
|
|
// @ts-ignore
|
|
return new WriteStream(fs.createWriteStream(this.path, options));
|
|
}
|
|
/**
|
|
* @return {WriteStream}
|
|
*/
|
|
createAppendStream(options = {}) {
|
|
if (Config.nofswriting) {
|
|
// @ts-ignore
|
|
return new WriteStream({write() {}});
|
|
}
|
|
// @ts-ignore
|
|
options.flags = options.flags || 'a';
|
|
// @ts-ignore
|
|
return new WriteStream(fs.createWriteStream(this.path, options));
|
|
}
|
|
unlinkIfExists() {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.unlink(this.path, err => {
|
|
if (err && err.code === 'ENOENT') return resolve();
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
unlinkIfExistsSync() {
|
|
if (Config.nofswriting) return;
|
|
try {
|
|
fs.unlinkSync(this.path);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
}
|
|
}
|
|
/**
|
|
* @param {string | number} mode
|
|
*/
|
|
mkdir(mode = 0o755) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.mkdir(this.path, mode, err => {
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string | number} mode
|
|
*/
|
|
mkdirSync(mode = 0o755) {
|
|
if (Config.nofswriting) return;
|
|
return fs.mkdirSync(this.path, mode);
|
|
}
|
|
/**
|
|
* @param {string | number} mode
|
|
*/
|
|
mkdirIfNonexistent(mode = 0o755) {
|
|
if (Config.nofswriting) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
fs.mkdir(this.path, mode, err => {
|
|
if (err && err.code === 'EEXIST') return resolve();
|
|
err ? reject(err) : resolve();
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* @param {string | number} mode
|
|
*/
|
|
mkdirIfNonexistentSync(mode = 0o755) {
|
|
if (Config.nofswriting) return;
|
|
try {
|
|
fs.mkdirSync(this.path, mode);
|
|
} catch (err) {
|
|
if (err.code !== 'EEXIST') throw err;
|
|
}
|
|
}
|
|
/**
|
|
* Creates the directory (and any parent directories if necessary).
|
|
* Does not throw if the directory already exists.
|
|
* @param {string | number} mode
|
|
*/
|
|
async mkdirp(mode = 0o755) {
|
|
try {
|
|
await this.mkdirIfNonexistent(mode);
|
|
} catch (err) {
|
|
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.
|
|
* @param {string | number} mode
|
|
*/
|
|
mkdirpSync(mode = 0o755) {
|
|
try {
|
|
this.mkdirIfNonexistentSync(mode);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') throw err;
|
|
this.parentDir().mkdirpSync(mode);
|
|
this.mkdirIfNonexistentSync(mode);
|
|
}
|
|
}
|
|
/**
|
|
* Calls the callback if the file is modified.
|
|
* @param {function (): void} callback
|
|
*/
|
|
onModify(callback) {
|
|
fs.watchFile(this.path, (curr, prev) => {
|
|
if (curr.mtime > prev.mtime) return callback();
|
|
});
|
|
}
|
|
/**
|
|
* Clears callbacks added with onModify()
|
|
*/
|
|
unwatch() {
|
|
fs.unwatchFile(this.path);
|
|
}
|
|
}
|
|
|
|
class FileReadStream extends Streams.ReadStream {
|
|
/**
|
|
* @param {string} file
|
|
*/
|
|
constructor(file) {
|
|
super();
|
|
/** @type {Promise<number>} */
|
|
this.fd = new Promise((resolve, reject) => {
|
|
fs.open(file, 'r', (err, fd) => err ? reject(err) : resolve(fd));
|
|
});
|
|
this.atEOF = false;
|
|
}
|
|
_read(size = 16384) {
|
|
return new Promise((resolve, reject) => {
|
|
if (this.atEOF) return resolve(false);
|
|
this.ensureCapacity(size);
|
|
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(false);
|
|
}
|
|
this.bufEnd += bytesRead;
|
|
// throw new Error([...this.buf].map(x => x.toString(16)).join(' '));
|
|
this.resolvePush();
|
|
resolve(true);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
_destroy() {
|
|
return /** @type {Promise<void>} */ (new Promise(resolve => {
|
|
this.fd.then(fd => {
|
|
fs.close(fd, () => resolve());
|
|
});
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} path
|
|
*/
|
|
function getFs(path) {
|
|
return new FSPath(path);
|
|
}
|
|
|
|
/**
|
|
* [updater, options, throttlePromise]
|
|
* @typedef {[(() => string | Buffer)?, Object, Promise<void>?]} PendingUpdate
|
|
*/
|
|
|
|
const FS = Object.assign(getFs, {
|
|
FileReadStream,
|
|
|
|
/**
|
|
* @type {Map<string, PendingUpdate>}
|
|
*/
|
|
pendingUpdates: new Map(),
|
|
});
|
|
|
|
module.exports = FS;
|