@plugjs/plug
Version:
PlugJS Build System ===================
236 lines • 10.1 kB
JavaScript
import { BuildFailure, assert } from "./asserts.js";
import { runAsync } from "./async.js";
import { $grn, $gry, $ms, $p, $plur, $t, $ylw, NOTICE, getLogger, log, logOptions } from "./logging.js";
import { Context, ContextPromises, PipeImpl } from "./pipe.js";
import { findCaller } from "./utils/caller.js";
import { getSingleton } from "./utils/singleton.js";
/* ========================================================================== *
* INTERNAL UTILITIES *
* ========================================================================== */
/**
* Symbol indicating that an object is a {@link Build}.
*
* In a compiled {@link Build} this symbol will be associated with a function
* taking an array of strings (task names) and record of props to override
*/
const buildMarker = Symbol.for('plugjs:plug:types:Build');
/** Symbol indicating that an object is a {@link TaskCall} */
const taskCallMarker = Symbol.for('plugjs:plug:types:TaskCall');
/** Type guard for {@link TaskCall}s */
function isTaskCall(something) {
return something[taskCallMarker] === taskCallMarker;
}
/** Shallow merge two records */
function merge(a, b) {
return Object.assign(Object.create(null), a, b);
}
/** Create a {@link State} from its components */
function makeState(state) {
const { cache = new Map(), fails = new Set(), stack = [], tasks = {}, props = {}, } = state;
return { cache, fails, stack, tasks, props };
}
/* ========================================================================== *
* TASK IMPLEMENTATION *
* ========================================================================== */
const lastIdKey = Symbol.for('plugjs:plug:singleton:taskId');
const taskId = getSingleton(lastIdKey, () => ({ id: 0 }));
class TaskImpl {
name;
buildFile;
_def;
before = [];
after = [];
id = ++taskId.id;
props;
tasks;
constructor(name, buildFile, _def, _tasks, _props) {
this.name = name;
this.buildFile = buildFile;
this._def = _def;
this.tasks = _tasks;
this.props = _props;
}
async invoke(state, taskName) {
assert(!state.stack.includes(this), `Recursion detected calling ${$t(taskName)}`);
/* Check cache */
const cached = state.cache.get(this);
if (cached)
return cached;
/* Create new substate merging sibling tasks/props and adding this to the stack */
state = makeState({
props: merge(this.props, state.props),
tasks: merge(this.tasks, state.tasks),
stack: [...state.stack, this],
cache: state.cache,
fails: state.fails,
});
/* Create run context and build */
const context = new Context(this.buildFile, taskName);
/* The build (the `this` value calling the definition) is a proxy */
const build = new Proxy({}, {
get: (_, name) => {
// Tasks first, props might come also from environment
if (name in state.tasks) {
return () => {
const promise = state.tasks[name].invoke(state, name);
return new PipeImpl(context, promise);
};
}
else if (name in state.props) {
return state.props[name];
}
},
});
/* Run all tasks hooked _before_ this one */
for (const before of this.before)
await before.invoke(state, before.name);
/* Some logging */
context.log.info('Running...');
const now = Date.now();
/* Run asynchronously in an asynchronous context */
const promise = runAsync(context, async () => {
return await this._def.call(build) || undefined;
}).then(async (result) => {
const level = taskName.startsWith('_') ? 'info' : 'notice';
context.log[level](`Success ${$ms(Date.now() - now)}`);
return result;
}).catch((error) => {
state.fails.add(this);
context.log.error(`Failure ${$ms(Date.now() - now)}`, error);
throw BuildFailure.fail();
}).finally(async () => {
await ContextPromises.wait(context);
}).then(async (result) => {
for (const after of this.after)
await after.invoke(state, after.name);
return result;
});
/* Cache the resulting promise and return it */
state.cache.set(this, promise);
return promise;
}
}
/* ========================================================================== *
* BUILD COMPILER *
* ========================================================================== */
/** Compile a {@link BuildDef | build definition} into a {@link Build} */
export function plugjs(def) {
const buildFile = findCaller(plugjs);
const tasks = {};
const props = {};
/* Iterate through all definition extracting properties and tasks */
for (const [key, val] of Object.entries(def)) {
let len = 0;
if (isTaskCall(val)) { // this goes first, tasks calls _are_ functions!
tasks[key] = val.task;
len = key.length;
}
else if (typeof val === 'string') {
props[key] = val;
}
else if (typeof val === 'function') {
tasks[key] = new TaskImpl(key, buildFile, val, tasks, props);
len = key.length;
}
/* Update the logger's own "taskLength" for nice printing */
if ((logOptions.level >= NOTICE) && (key.startsWith('_')))
continue;
/* coverage ignore if */
if (len > logOptions.taskLength)
logOptions.taskLength = len;
}
/* A function _starting_ a build */
const start = async function start(callback, overrideProps = {}) {
/* Let's go down to business */
const state = makeState({ tasks, props: merge(props, overrideProps) });
const logger = getLogger();
logger.notice('Starting...');
const now = Date.now();
try {
const result = await callback(state);
logger.notice(`Build successful ${$ms(Date.now() - now)}`);
return result;
}
catch (error) {
if (state.fails.size) {
logger.error('');
logger.error($plur(state.fails.size, 'task', 'tasks'), 'failed:');
state.fails.forEach((task) => logger.error($gry('*'), $t(task.name)));
logger.error('');
}
throw logger.fail(`Build failed ${$ms(Date.now() - now)}`, error);
}
};
/* Create the "invoke" function for this build */
const invoke = async function invoke(taskNames, overrideProps = {}) {
await start(async (state) => {
for (const name of taskNames) {
const task = tasks[name];
assert(task, `Task ${$t(name)} not found in build ${$p(buildFile)}`);
await task.invoke(state, name);
}
}, overrideProps);
};
/* Convert our Tasks into TaskCalls */
const callables = {};
for (const [name, task] of Object.entries(tasks)) {
/** The callable function, using "start" */
const callable = async (overrideProps) => start(async (state) => task.invoke(state, name), overrideProps);
/* Extra properties for our callable: marker, task and name */
callables[name] = Object.defineProperties(callable, {
[taskCallMarker]: { value: taskCallMarker },
'task': { value: task },
'name': { value: name },
});
}
/* Create and return our build */
const compiled = merge(props, callables);
Object.defineProperty(compiled, buildMarker, { value: invoke });
return compiled;
}
/** @deprecated Please use the new {@link plugjs} export */
export const build = function (def) {
log.warn(`Use of deprecated ${$ylw('build')} entry point, please use ${$grn('plugjs')}`);
return plugjs(def);
};
/** Check if the specified build is actually a {@link Build} */
export function isBuild(build) {
return build && typeof build[buildMarker] === 'function';
}
/** Invoke a number of tasks in a {@link Build} */
export function invokeTasks(build, tasks, props) {
if (isBuild(build)) {
return build[buildMarker](tasks, props);
}
else {
throw new TypeError('Invalid build instance');
}
}
/* ========================================================================== *
* HOOKS *
* ========================================================================== */
/** Make sure that the specified hooks run _before_ the given tasks */
export function hookBefore(build, taskName, hooks) {
const taskCall = build[taskName];
assert(isTaskCall(taskCall), `Task "${$t(taskName)}" not found in build`);
for (const hook of hooks) {
const beforeHook = build[hook];
assert(isTaskCall(beforeHook), `Task "${$t(hook)}" to hook before "${$t(taskName)}" not found in build`);
if (taskCall.task.before.includes(beforeHook.task))
continue;
taskCall.task.before.push(beforeHook.task);
}
}
/** Make sure that the specified hooks run _after_ the given tasks */
export function hookAfter(build, taskName, hooks) {
const taskCall = build[taskName];
assert(isTaskCall(taskCall), `Task "${$t(taskName)}" not found in build`);
for (const hook of hooks) {
const afterHook = build[hook];
assert(isTaskCall(afterHook), `Task "${$t(hook)}" to hook after "${$t(taskName)}" not found in build`);
if (taskCall.task.after.includes(afterHook.task))
continue;
taskCall.task.after.push(afterHook.task);
}
}
//# sourceMappingURL=build.js.map