366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
import { readFile } from './fs.js';
|
|
import { resolve } from 'path';
|
|
|
|
import File from './File.js';
|
|
import Watcher from './Watcher.js';
|
|
import * as context from './context.js';
|
|
|
|
const _origData = Symbol();
|
|
const _status = Symbol();
|
|
const _before = Symbol();
|
|
const _during = Symbol();
|
|
const _after = Symbol();
|
|
const _watchers = Symbol();
|
|
const _transform = Symbol();
|
|
const _generators = Symbol();
|
|
const _resolver = Symbol();
|
|
const _onerror = Symbol();
|
|
const _active = Symbol();
|
|
const _waitingFor = Symbol();
|
|
const _whenFound = Symbol();
|
|
const _deps = Symbol();
|
|
const _queue = Symbol();
|
|
const _isProcessing = Symbol();
|
|
const _startWave = Symbol();
|
|
const _endWave = Symbol();
|
|
const _enqueue = Symbol();
|
|
const _processPhysicalFile = Symbol();
|
|
const _processFile = Symbol();
|
|
const _callTransform = Symbol();
|
|
const _processGenerator = Symbol();
|
|
const _checkWave = Symbol();
|
|
const _processDependents = Symbol();
|
|
const _markFound = Symbol();
|
|
|
|
export default class Defiler {
|
|
constructor(...args) {
|
|
const { transform, generators = [], resolver, onerror } = args.pop();
|
|
if (typeof transform !== 'function') {
|
|
throw new TypeError('defiler: transform must be a function');
|
|
}
|
|
if (
|
|
!Array.isArray(generators) ||
|
|
generators.some(generator => typeof generator !== 'function')
|
|
) {
|
|
throw new TypeError('defiler: generators must be an array of functions');
|
|
}
|
|
if (resolver && typeof resolver !== 'function') {
|
|
throw new TypeError('defiler: resolver must be a function');
|
|
}
|
|
if (onerror && typeof onerror !== 'function') {
|
|
throw new TypeError('defiler: onerror must be a function');
|
|
}
|
|
// set of original paths for all physical files
|
|
this.paths = new Set();
|
|
// original paths -> original file data for all physical files ({ path, stats, bytes, enc })
|
|
this[_origData] = new Map();
|
|
// original paths -> transformed files for all physical and virtual files
|
|
this.files = new Map();
|
|
// _before, _during, or _after exec has been called
|
|
this[_status] = _before;
|
|
// Watcher instances
|
|
this[_watchers] = args.map(
|
|
({
|
|
dir,
|
|
filter,
|
|
read = true,
|
|
enc = 'utf8',
|
|
pre,
|
|
watch = true,
|
|
debounce = 10,
|
|
}) => {
|
|
if (typeof dir !== 'string') {
|
|
throw new TypeError('defiler: dir must be a string');
|
|
}
|
|
if (filter && typeof filter !== 'function') {
|
|
throw new TypeError('defiler: filter must be a function');
|
|
}
|
|
if (typeof read !== 'boolean' && typeof read !== 'function') {
|
|
throw new TypeError('defiler: read must be a boolean or a function');
|
|
}
|
|
if (!Buffer.isEncoding(enc) && typeof enc !== 'function') {
|
|
throw new TypeError(
|
|
'defiler: enc must be a supported encoding or a function',
|
|
);
|
|
}
|
|
if (pre && typeof pre !== 'function') {
|
|
throw new TypeError('defiler: pre must be a function');
|
|
}
|
|
if (typeof watch !== 'boolean') {
|
|
throw new TypeError('defiler: watch must be a boolean');
|
|
}
|
|
if (typeof debounce !== 'number') {
|
|
throw new TypeError('defiler: debounce must be a number');
|
|
}
|
|
dir = resolve(dir);
|
|
return new Watcher({ dir, filter, read, enc, pre, watch, debounce });
|
|
},
|
|
);
|
|
// the transform to run on all files
|
|
this[_transform] = transform;
|
|
// unique symbols -> registered generators
|
|
this[_generators] = new Map(
|
|
generators.map(generator => [Symbol(), generator]),
|
|
);
|
|
// (base, path) => path resolver function, used in defiler.get and defiler.add from transform
|
|
this[_resolver] = resolver;
|
|
// handler to call when errors occur
|
|
this[_onerror] = onerror;
|
|
// original paths of all files currently undergoing transformation and symbols of all generators currently running
|
|
this[_active] = new Set();
|
|
// original paths -> number of other files they're currently waiting on to exist
|
|
this[_waitingFor] = new Map();
|
|
// original paths -> { promise, resolve, paths } objects for when awaited files become available
|
|
this[_whenFound] = new Map();
|
|
// array of [dependent, dependency] pairs, specifying changes to which files should trigger re-processing which other files
|
|
this[_deps] = [];
|
|
// queue of pending Watcher events to handle
|
|
this[_queue] = [];
|
|
// whether some Watcher event is currently already in the process of being handled
|
|
this[_isProcessing] = false;
|
|
}
|
|
|
|
// execute everything, and return a promise that resolves when the first wave of processing is complete
|
|
async exec() {
|
|
if (this[_status] !== _before) {
|
|
throw new Error('defiler.exec: cannot call more than once');
|
|
}
|
|
this[_status] = _during;
|
|
this[_isProcessing] = true;
|
|
const done = this[_startWave]();
|
|
// init the Watcher instances
|
|
const files = [];
|
|
await Promise.all(
|
|
this[_watchers].map(async watcher => {
|
|
watcher.on('', event => this[_enqueue](watcher, event));
|
|
// note that all files are pending transformation
|
|
await Promise.all(
|
|
(await watcher.init()).map(async file => {
|
|
const { path } = file;
|
|
if (watcher.pre) {
|
|
await watcher.pre(file);
|
|
}
|
|
this.paths.add(file.path);
|
|
this[_active].add(file.path);
|
|
files.push([watcher, path, file]);
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
for (const symbol of this[_generators].keys()) {
|
|
this[_active].add(symbol);
|
|
}
|
|
// process each physical file
|
|
for (const [watcher, path, file] of files) {
|
|
this[_processPhysicalFile](watcher, path, file);
|
|
}
|
|
// process each generator
|
|
for (const symbol of this[_generators].keys()) {
|
|
this[_processGenerator](symbol);
|
|
}
|
|
// wait and finish up
|
|
await done;
|
|
this[_status] = _after;
|
|
this[_isProcessing] = false;
|
|
this[_enqueue]();
|
|
}
|
|
|
|
// wait for a file to be available and retrieve it, marking dependencies as appropriate
|
|
async get(path) {
|
|
if (Array.isArray(path)) {
|
|
return Promise.all(path.map(path => this.get(path)));
|
|
}
|
|
const current = context.current();
|
|
path = this.resolve(path);
|
|
if (typeof path !== 'string') {
|
|
throw new TypeError('defiler.get: path must be a string');
|
|
}
|
|
if (current) {
|
|
this[_deps].push([current, path]);
|
|
}
|
|
if (this[_status] === _during && !this.files.has(path) && current) {
|
|
this[_waitingFor].set(current, (this[_waitingFor].get(current) || 0) + 1);
|
|
if (this[_whenFound].has(path)) {
|
|
const { promise, paths } = this[_whenFound].get(path);
|
|
paths.push(current);
|
|
await promise;
|
|
} else {
|
|
let resolve;
|
|
const promise = new Promise(res => (resolve = res));
|
|
this[_whenFound].set(path, { promise, resolve, paths: [current] });
|
|
await promise;
|
|
}
|
|
}
|
|
return this.files.get(path);
|
|
}
|
|
|
|
// add a new virtual file
|
|
add(file) {
|
|
if (this[_status] === _before) {
|
|
throw new Error('defiler.add: cannot call before calling exec');
|
|
}
|
|
if (typeof file !== 'object') {
|
|
throw new TypeError('defiler.add: file must be an object');
|
|
}
|
|
file.path = this.resolve(file.path);
|
|
this[_origData].set(file.path, file);
|
|
this[_processFile](file, 'add');
|
|
}
|
|
|
|
// resolve a given path from the file currently being transformed
|
|
resolve(path) {
|
|
return this[_resolver] && typeof context.current() === 'string'
|
|
? this[_resolver](context.current(), path)
|
|
: path;
|
|
}
|
|
|
|
// private methods
|
|
|
|
// return a Promise that we will resolve at the end of this wave, and save its resolver
|
|
[_startWave]() {
|
|
return new Promise(res => (this[_endWave] = res));
|
|
}
|
|
|
|
// add a Watcher event to the queue, and handle queued events
|
|
async [_enqueue](watcher, event) {
|
|
if (event) {
|
|
this[_queue].push([watcher, event]);
|
|
}
|
|
if (this[_isProcessing]) {
|
|
return;
|
|
}
|
|
this[_isProcessing] = true;
|
|
while (this[_queue].length) {
|
|
const done = this[_startWave]();
|
|
const [watcher, { event, path, stats }] = this[_queue].shift();
|
|
const file = { path, stats };
|
|
if (watcher.pre) {
|
|
await watcher.pre(file);
|
|
}
|
|
if (event === '+') {
|
|
this[_processPhysicalFile](watcher, path, file);
|
|
} else if (event === '-') {
|
|
const { path } = file;
|
|
const oldFile = this.files.get(path);
|
|
this.paths.delete(path);
|
|
this[_origData].delete(path);
|
|
this.files.delete(path);
|
|
await this[_callTransform](oldFile, 'delete');
|
|
this[_processDependents](path);
|
|
}
|
|
await done;
|
|
}
|
|
this[_isProcessing] = false;
|
|
}
|
|
|
|
// create a file object for a physical file and process it
|
|
async [_processPhysicalFile]({ dir, read, enc }, path, file) {
|
|
if (typeof read === 'function') {
|
|
read = await read({ path, stats: file.stats });
|
|
}
|
|
if (read) {
|
|
file.bytes = await readFile(dir + '/' + path);
|
|
}
|
|
if (typeof enc === 'function') {
|
|
enc = await enc({ path, stats: file.stats, bytes: file.bytes });
|
|
}
|
|
file.enc = enc;
|
|
this.paths.add(file.path);
|
|
this[_origData].set(file.path, file);
|
|
await this[_processFile](file, 'read');
|
|
}
|
|
|
|
// transform a file, store it, and process dependents
|
|
async [_processFile](data, event) {
|
|
const file = Object.assign(new File(), data);
|
|
const { path } = file;
|
|
this[_active].add(path);
|
|
await this[_callTransform](file, event);
|
|
this.files.set(path, file);
|
|
this[this[_status] === _during ? _markFound : _processDependents](path);
|
|
this[_active].delete(path);
|
|
this[_checkWave]();
|
|
}
|
|
|
|
// call the transform on a file with the given event string, and handle errors
|
|
async [_callTransform](file, event) {
|
|
await null;
|
|
context.create(file.path);
|
|
try {
|
|
await this[_transform]({ file, event });
|
|
} catch (error) {
|
|
if (this[_onerror]) {
|
|
this[_onerror]({ file, event, error });
|
|
}
|
|
}
|
|
}
|
|
|
|
// run the generator given by the symbol
|
|
async [_processGenerator](symbol) {
|
|
this[_active].add(symbol);
|
|
const generator = this[_generators].get(symbol);
|
|
await null;
|
|
context.create(symbol);
|
|
try {
|
|
await generator();
|
|
} catch (error) {
|
|
if (this[_onerror]) {
|
|
this[_onerror]({ generator, error });
|
|
}
|
|
}
|
|
this[_active].delete(symbol);
|
|
this[_checkWave]();
|
|
}
|
|
|
|
// re-process all files that depend on a particular path
|
|
[_processDependents](path) {
|
|
const dependents = new Set();
|
|
for (const [dependent, dependency] of this[_deps]) {
|
|
if (dependency === path) {
|
|
dependents.add(dependent);
|
|
}
|
|
}
|
|
this[_deps] = this[_deps].filter(
|
|
([dependent]) => !dependents.has(dependent),
|
|
);
|
|
for (const dependent of dependents) {
|
|
if (this[_origData].has(dependent)) {
|
|
this[_processFile](this[_origData].get(dependent), 'retransform');
|
|
} else if (this[_generators].has(dependent)) {
|
|
this[_processGenerator](dependent);
|
|
}
|
|
}
|
|
this[_checkWave]();
|
|
}
|
|
|
|
// check whether this wave is complete, and, if not, whether we need to break a deadlock
|
|
[_checkWave]() {
|
|
if (!this[_active].size) {
|
|
this[_endWave]();
|
|
} else if (
|
|
this[_status] === _during &&
|
|
[...this[_active]].every(path => this[_waitingFor].get(path))
|
|
) {
|
|
// all pending files are currently waiting for one or more other files to exist
|
|
// break deadlock: assume all files that have not appeared yet will never do so
|
|
for (const path of this[_whenFound].keys()) {
|
|
if (!this[_active].has(path)) {
|
|
this[_markFound](path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// mark a given awaited file as being found
|
|
[_markFound](path) {
|
|
if (this[_whenFound].has(path)) {
|
|
const { resolve, paths } = this[_whenFound].get(path);
|
|
for (const path of paths) {
|
|
this[_waitingFor].set(path, this[_waitingFor].get(path) - 1);
|
|
}
|
|
resolve();
|
|
this[_whenFound].delete(path);
|
|
}
|
|
}
|
|
}
|