594 lines
15 KiB
JavaScript
594 lines
15 KiB
JavaScript
/*
|
|
MIT License http://www.opensource.org/licenses/mit-license.php
|
|
Author Tobias Koppers @sokra
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const nextTick = require("process").nextTick;
|
|
|
|
/** @typedef {import("./Resolver").FileSystem} FileSystem */
|
|
/** @typedef {import("./Resolver").SyncFileSystem} SyncFileSystem */
|
|
/** @typedef {any} BaseFileSystem */
|
|
|
|
/**
|
|
* @template T
|
|
* @typedef {import("./Resolver").FileSystemCallback<T>} FileSystemCallback<T>
|
|
*/
|
|
|
|
/**
|
|
* @param {string} path path
|
|
* @returns {string} dirname
|
|
*/
|
|
const dirname = path => {
|
|
let idx = path.length - 1;
|
|
while (idx >= 0) {
|
|
const c = path.charCodeAt(idx);
|
|
// slash or backslash
|
|
if (c === 47 || c === 92) break;
|
|
idx--;
|
|
}
|
|
if (idx < 0) return "";
|
|
return path.slice(0, idx);
|
|
};
|
|
|
|
/**
|
|
* @template T
|
|
* @param {FileSystemCallback<T>[]} callbacks callbacks
|
|
* @param {Error | undefined} err error
|
|
* @param {T} result result
|
|
*/
|
|
const runCallbacks = (callbacks, err, result) => {
|
|
if (callbacks.length === 1) {
|
|
callbacks[0](err, result);
|
|
callbacks.length = 0;
|
|
return;
|
|
}
|
|
let error;
|
|
for (const callback of callbacks) {
|
|
try {
|
|
callback(err, result);
|
|
} catch (e) {
|
|
if (!error) error = e;
|
|
}
|
|
}
|
|
callbacks.length = 0;
|
|
if (error) throw error;
|
|
};
|
|
|
|
class OperationMergerBackend {
|
|
/**
|
|
* @param {function} provider async method in filesystem
|
|
* @param {function} syncProvider sync method in filesystem
|
|
* @param {BaseFileSystem} providerContext call context for the provider methods
|
|
*/
|
|
constructor(provider, syncProvider, providerContext) {
|
|
this._provider = provider;
|
|
this._syncProvider = syncProvider;
|
|
this._providerContext = providerContext;
|
|
this._activeAsyncOperations = new Map();
|
|
|
|
this.provide = this._provider
|
|
? /**
|
|
* @param {string} path path
|
|
* @param {any} options options
|
|
* @param {function} callback callback
|
|
* @returns {any} result
|
|
*/
|
|
(path, options, callback) => {
|
|
if (typeof options === "function") {
|
|
callback = options;
|
|
options = undefined;
|
|
}
|
|
if (options) {
|
|
return this._provider.call(
|
|
this._providerContext,
|
|
path,
|
|
options,
|
|
callback
|
|
);
|
|
}
|
|
if (typeof path !== "string") {
|
|
callback(new TypeError("path must be a string"));
|
|
return;
|
|
}
|
|
let callbacks = this._activeAsyncOperations.get(path);
|
|
if (callbacks) {
|
|
callbacks.push(callback);
|
|
return;
|
|
}
|
|
this._activeAsyncOperations.set(path, (callbacks = [callback]));
|
|
provider(
|
|
path,
|
|
/**
|
|
* @param {Error} err error
|
|
* @param {any} result result
|
|
*/
|
|
(err, result) => {
|
|
this._activeAsyncOperations.delete(path);
|
|
runCallbacks(callbacks, err, result);
|
|
}
|
|
);
|
|
}
|
|
: null;
|
|
this.provideSync = this._syncProvider
|
|
? /**
|
|
* @param {string} path path
|
|
* @param {any} options options
|
|
* @returns {any} result
|
|
*/
|
|
(path, options) => {
|
|
return this._syncProvider.call(this._providerContext, path, options);
|
|
}
|
|
: null;
|
|
}
|
|
|
|
purge() {}
|
|
purgeParent() {}
|
|
}
|
|
|
|
/*
|
|
|
|
IDLE:
|
|
insert data: goto SYNC
|
|
|
|
SYNC:
|
|
before provide: run ticks
|
|
event loop tick: goto ASYNC_ACTIVE
|
|
|
|
ASYNC:
|
|
timeout: run tick, goto ASYNC_PASSIVE
|
|
|
|
ASYNC_PASSIVE:
|
|
before provide: run ticks
|
|
|
|
IDLE --[insert data]--> SYNC --[event loop tick]--> ASYNC_ACTIVE --[interval tick]-> ASYNC_PASSIVE
|
|
^ |
|
|
+---------[insert data]-------+
|
|
*/
|
|
|
|
const STORAGE_MODE_IDLE = 0;
|
|
const STORAGE_MODE_SYNC = 1;
|
|
const STORAGE_MODE_ASYNC = 2;
|
|
|
|
class CacheBackend {
|
|
/**
|
|
* @param {number} duration max cache duration of items
|
|
* @param {function} provider async method
|
|
* @param {function} syncProvider sync method
|
|
* @param {BaseFileSystem} providerContext call context for the provider methods
|
|
*/
|
|
constructor(duration, provider, syncProvider, providerContext) {
|
|
this._duration = duration;
|
|
this._provider = provider;
|
|
this._syncProvider = syncProvider;
|
|
this._providerContext = providerContext;
|
|
/** @type {Map<string, FileSystemCallback<any>[]>} */
|
|
this._activeAsyncOperations = new Map();
|
|
/** @type {Map<string, { err?: Error, result?: any, level: Set<string> }>} */
|
|
this._data = new Map();
|
|
/** @type {Set<string>[]} */
|
|
this._levels = [];
|
|
for (let i = 0; i < 10; i++) this._levels.push(new Set());
|
|
for (let i = 5000; i < duration; i += 500) this._levels.push(new Set());
|
|
this._currentLevel = 0;
|
|
this._tickInterval = Math.floor(duration / this._levels.length);
|
|
/** @type {STORAGE_MODE_IDLE | STORAGE_MODE_SYNC | STORAGE_MODE_ASYNC} */
|
|
this._mode = STORAGE_MODE_IDLE;
|
|
|
|
/** @type {NodeJS.Timeout | undefined} */
|
|
this._timeout = undefined;
|
|
/** @type {number | undefined} */
|
|
this._nextDecay = undefined;
|
|
|
|
// @ts-ignore
|
|
this.provide = provider ? this.provide.bind(this) : null;
|
|
// @ts-ignore
|
|
this.provideSync = syncProvider ? this.provideSync.bind(this) : null;
|
|
}
|
|
|
|
/**
|
|
* @param {string} path path
|
|
* @param {any} options options
|
|
* @param {FileSystemCallback<any>} callback callback
|
|
* @returns {void}
|
|
*/
|
|
provide(path, options, callback) {
|
|
if (typeof options === "function") {
|
|
callback = options;
|
|
options = undefined;
|
|
}
|
|
if (typeof path !== "string") {
|
|
callback(new TypeError("path must be a string"));
|
|
return;
|
|
}
|
|
if (options) {
|
|
return this._provider.call(
|
|
this._providerContext,
|
|
path,
|
|
options,
|
|
callback
|
|
);
|
|
}
|
|
|
|
// When in sync mode we can move to async mode
|
|
if (this._mode === STORAGE_MODE_SYNC) {
|
|
this._enterAsyncMode();
|
|
}
|
|
|
|
// Check in cache
|
|
let cacheEntry = this._data.get(path);
|
|
if (cacheEntry !== undefined) {
|
|
if (cacheEntry.err) return nextTick(callback, cacheEntry.err);
|
|
return nextTick(callback, null, cacheEntry.result);
|
|
}
|
|
|
|
// Check if there is already the same operation running
|
|
let callbacks = this._activeAsyncOperations.get(path);
|
|
if (callbacks !== undefined) {
|
|
callbacks.push(callback);
|
|
return;
|
|
}
|
|
this._activeAsyncOperations.set(path, (callbacks = [callback]));
|
|
|
|
// Run the operation
|
|
this._provider.call(
|
|
this._providerContext,
|
|
path,
|
|
/**
|
|
* @param {Error} [err] error
|
|
* @param {any} [result] result
|
|
*/
|
|
(err, result) => {
|
|
this._activeAsyncOperations.delete(path);
|
|
this._storeResult(path, err, result);
|
|
|
|
// Enter async mode if not yet done
|
|
this._enterAsyncMode();
|
|
|
|
runCallbacks(
|
|
/** @type {FileSystemCallback<any>[]} */ (callbacks),
|
|
err,
|
|
result
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string} path path
|
|
* @param {any} options options
|
|
* @returns {any} result
|
|
*/
|
|
provideSync(path, options) {
|
|
if (typeof path !== "string") {
|
|
throw new TypeError("path must be a string");
|
|
}
|
|
if (options) {
|
|
return this._syncProvider.call(this._providerContext, path, options);
|
|
}
|
|
|
|
// In sync mode we may have to decay some cache items
|
|
if (this._mode === STORAGE_MODE_SYNC) {
|
|
this._runDecays();
|
|
}
|
|
|
|
// Check in cache
|
|
let cacheEntry = this._data.get(path);
|
|
if (cacheEntry !== undefined) {
|
|
if (cacheEntry.err) throw cacheEntry.err;
|
|
return cacheEntry.result;
|
|
}
|
|
|
|
// Get all active async operations
|
|
// This sync operation will also complete them
|
|
const callbacks = this._activeAsyncOperations.get(path);
|
|
this._activeAsyncOperations.delete(path);
|
|
|
|
// Run the operation
|
|
// When in idle mode, we will enter sync mode
|
|
let result;
|
|
try {
|
|
result = this._syncProvider.call(this._providerContext, path);
|
|
} catch (err) {
|
|
this._storeResult(path, /** @type {Error} */ (err), undefined);
|
|
this._enterSyncModeWhenIdle();
|
|
if (callbacks) {
|
|
runCallbacks(callbacks, /** @type {Error} */ (err), undefined);
|
|
}
|
|
throw err;
|
|
}
|
|
this._storeResult(path, undefined, result);
|
|
this._enterSyncModeWhenIdle();
|
|
if (callbacks) {
|
|
runCallbacks(callbacks, undefined, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string|string[]|Set<string>} [what] what to purge
|
|
*/
|
|
purge(what) {
|
|
if (!what) {
|
|
if (this._mode !== STORAGE_MODE_IDLE) {
|
|
this._data.clear();
|
|
for (const level of this._levels) {
|
|
level.clear();
|
|
}
|
|
this._enterIdleMode();
|
|
}
|
|
} else if (typeof what === "string") {
|
|
for (let [key, data] of this._data) {
|
|
if (key.startsWith(what)) {
|
|
this._data.delete(key);
|
|
data.level.delete(key);
|
|
}
|
|
}
|
|
if (this._data.size === 0) {
|
|
this._enterIdleMode();
|
|
}
|
|
} else {
|
|
for (let [key, data] of this._data) {
|
|
for (const item of what) {
|
|
if (key.startsWith(item)) {
|
|
this._data.delete(key);
|
|
data.level.delete(key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (this._data.size === 0) {
|
|
this._enterIdleMode();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string|string[]|Set<string>} [what] what to purge
|
|
*/
|
|
purgeParent(what) {
|
|
if (!what) {
|
|
this.purge();
|
|
} else if (typeof what === "string") {
|
|
this.purge(dirname(what));
|
|
} else {
|
|
const set = new Set();
|
|
for (const item of what) {
|
|
set.add(dirname(item));
|
|
}
|
|
this.purge(set);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} path path
|
|
* @param {undefined | Error} err error
|
|
* @param {any} result result
|
|
*/
|
|
_storeResult(path, err, result) {
|
|
if (this._data.has(path)) return;
|
|
const level = this._levels[this._currentLevel];
|
|
this._data.set(path, { err, result, level });
|
|
level.add(path);
|
|
}
|
|
|
|
_decayLevel() {
|
|
const nextLevel = (this._currentLevel + 1) % this._levels.length;
|
|
const decay = this._levels[nextLevel];
|
|
this._currentLevel = nextLevel;
|
|
for (let item of decay) {
|
|
this._data.delete(item);
|
|
}
|
|
decay.clear();
|
|
if (this._data.size === 0) {
|
|
this._enterIdleMode();
|
|
} else {
|
|
/** @type {number} */
|
|
(this._nextDecay) += this._tickInterval;
|
|
}
|
|
}
|
|
|
|
_runDecays() {
|
|
while (
|
|
/** @type {number} */ (this._nextDecay) <= Date.now() &&
|
|
this._mode !== STORAGE_MODE_IDLE
|
|
) {
|
|
this._decayLevel();
|
|
}
|
|
}
|
|
|
|
_enterAsyncMode() {
|
|
let timeout = 0;
|
|
switch (this._mode) {
|
|
case STORAGE_MODE_ASYNC:
|
|
return;
|
|
case STORAGE_MODE_IDLE:
|
|
this._nextDecay = Date.now() + this._tickInterval;
|
|
timeout = this._tickInterval;
|
|
break;
|
|
case STORAGE_MODE_SYNC:
|
|
this._runDecays();
|
|
// _runDecays may change the mode
|
|
if (
|
|
/** @type {STORAGE_MODE_IDLE | STORAGE_MODE_SYNC | STORAGE_MODE_ASYNC}*/
|
|
(this._mode) === STORAGE_MODE_IDLE
|
|
)
|
|
return;
|
|
timeout = Math.max(
|
|
0,
|
|
/** @type {number} */ (this._nextDecay) - Date.now()
|
|
);
|
|
break;
|
|
}
|
|
this._mode = STORAGE_MODE_ASYNC;
|
|
const ref = setTimeout(() => {
|
|
this._mode = STORAGE_MODE_SYNC;
|
|
this._runDecays();
|
|
}, timeout);
|
|
if (ref.unref) ref.unref();
|
|
this._timeout = ref;
|
|
}
|
|
|
|
_enterSyncModeWhenIdle() {
|
|
if (this._mode === STORAGE_MODE_IDLE) {
|
|
this._mode = STORAGE_MODE_SYNC;
|
|
this._nextDecay = Date.now() + this._tickInterval;
|
|
}
|
|
}
|
|
|
|
_enterIdleMode() {
|
|
this._mode = STORAGE_MODE_IDLE;
|
|
this._nextDecay = undefined;
|
|
if (this._timeout) clearTimeout(this._timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template {function} Provider
|
|
* @template {function} AsyncProvider
|
|
* @template FileSystem
|
|
* @param {number} duration duration in ms files are cached
|
|
* @param {Provider} provider provider
|
|
* @param {AsyncProvider} syncProvider sync provider
|
|
* @param {FileSystem} providerContext provider context
|
|
* @returns {OperationMergerBackend | CacheBackend} backend
|
|
*/
|
|
const createBackend = (duration, provider, syncProvider, providerContext) => {
|
|
if (duration > 0) {
|
|
return new CacheBackend(duration, provider, syncProvider, providerContext);
|
|
}
|
|
return new OperationMergerBackend(provider, syncProvider, providerContext);
|
|
};
|
|
|
|
module.exports = class CachedInputFileSystem {
|
|
/**
|
|
* @param {BaseFileSystem} fileSystem file system
|
|
* @param {number} duration duration in ms files are cached
|
|
*/
|
|
constructor(fileSystem, duration) {
|
|
this.fileSystem = fileSystem;
|
|
|
|
this._lstatBackend = createBackend(
|
|
duration,
|
|
this.fileSystem.lstat,
|
|
this.fileSystem.lstatSync,
|
|
this.fileSystem
|
|
);
|
|
const lstat = this._lstatBackend.provide;
|
|
this.lstat = /** @type {FileSystem["lstat"]} */ (lstat);
|
|
const lstatSync = this._lstatBackend.provideSync;
|
|
this.lstatSync = /** @type {SyncFileSystem["lstatSync"]} */ (lstatSync);
|
|
|
|
this._statBackend = createBackend(
|
|
duration,
|
|
this.fileSystem.stat,
|
|
this.fileSystem.statSync,
|
|
this.fileSystem
|
|
);
|
|
const stat = this._statBackend.provide;
|
|
this.stat = /** @type {FileSystem["stat"]} */ (stat);
|
|
const statSync = this._statBackend.provideSync;
|
|
this.statSync = /** @type {SyncFileSystem["statSync"]} */ (statSync);
|
|
|
|
this._readdirBackend = createBackend(
|
|
duration,
|
|
this.fileSystem.readdir,
|
|
this.fileSystem.readdirSync,
|
|
this.fileSystem
|
|
);
|
|
const readdir = this._readdirBackend.provide;
|
|
this.readdir = /** @type {FileSystem["readdir"]} */ (readdir);
|
|
const readdirSync = this._readdirBackend.provideSync;
|
|
this.readdirSync = /** @type {SyncFileSystem["readdirSync"]} */ (
|
|
readdirSync
|
|
);
|
|
|
|
this._readFileBackend = createBackend(
|
|
duration,
|
|
this.fileSystem.readFile,
|
|
this.fileSystem.readFileSync,
|
|
this.fileSystem
|
|
);
|
|
const readFile = this._readFileBackend.provide;
|
|
this.readFile = /** @type {FileSystem["readFile"]} */ (readFile);
|
|
const readFileSync = this._readFileBackend.provideSync;
|
|
this.readFileSync = /** @type {SyncFileSystem["readFileSync"]} */ (
|
|
readFileSync
|
|
);
|
|
|
|
this._readJsonBackend = createBackend(
|
|
duration,
|
|
// prettier-ignore
|
|
this.fileSystem.readJson ||
|
|
(this.readFile &&
|
|
(
|
|
/**
|
|
* @param {string} path path
|
|
* @param {FileSystemCallback<any>} callback
|
|
*/
|
|
(path, callback) => {
|
|
this.readFile(path, (err, buffer) => {
|
|
if (err) return callback(err);
|
|
if (!buffer || buffer.length === 0)
|
|
return callback(new Error("No file content"));
|
|
let data;
|
|
try {
|
|
data = JSON.parse(buffer.toString("utf-8"));
|
|
} catch (e) {
|
|
return callback(/** @type {Error} */ (e));
|
|
}
|
|
callback(null, data);
|
|
});
|
|
})
|
|
),
|
|
// prettier-ignore
|
|
this.fileSystem.readJsonSync ||
|
|
(this.readFileSync &&
|
|
(
|
|
/**
|
|
* @param {string} path path
|
|
* @returns {any} result
|
|
*/
|
|
(path) => {
|
|
const buffer = this.readFileSync(path);
|
|
const data = JSON.parse(buffer.toString("utf-8"));
|
|
return data;
|
|
}
|
|
)),
|
|
this.fileSystem
|
|
);
|
|
const readJson = this._readJsonBackend.provide;
|
|
this.readJson = /** @type {FileSystem["readJson"]} */ (readJson);
|
|
const readJsonSync = this._readJsonBackend.provideSync;
|
|
this.readJsonSync = /** @type {SyncFileSystem["readJsonSync"]} */ (
|
|
readJsonSync
|
|
);
|
|
|
|
this._readlinkBackend = createBackend(
|
|
duration,
|
|
this.fileSystem.readlink,
|
|
this.fileSystem.readlinkSync,
|
|
this.fileSystem
|
|
);
|
|
const readlink = this._readlinkBackend.provide;
|
|
this.readlink = /** @type {FileSystem["readlink"]} */ (readlink);
|
|
const readlinkSync = this._readlinkBackend.provideSync;
|
|
this.readlinkSync = /** @type {SyncFileSystem["readlinkSync"]} */ (
|
|
readlinkSync
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string|string[]|Set<string>} [what] what to purge
|
|
*/
|
|
purge(what) {
|
|
this._statBackend.purge(what);
|
|
this._lstatBackend.purge(what);
|
|
this._readdirBackend.purgeParent(what);
|
|
this._readFileBackend.purge(what);
|
|
this._readlinkBackend.purge(what);
|
|
this._readJsonBackend.purge(what);
|
|
}
|
|
};
|