UNPKG

@plugjs/plug

Version:
236 lines 10.1 kB
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