@plugjs/plug
Version:
PlugJS Build System ===================
316 lines (275 loc) • 10.9 kB
text/typescript
import { BuildFailure, assert } from './asserts'
import { runAsync } from './async'
import { $grn, $gry, $ms, $p, $plur, $t, $ylw, NOTICE, getLogger, log, logOptions } from './logging'
import { Context, ContextPromises, PipeImpl } from './pipe'
import { findCaller } from './utils/caller'
import { getSingleton } from './utils/singleton'
import type { Pipe } from './index'
import type { AbsolutePath } from './paths'
import type {
Build,
BuildDef,
BuildProps,
BuildTasks,
Props,
Result,
State,
Task,
TaskCall,
TaskDef,
Tasks,
ThisBuild,
} from './types'
/* ========================================================================== *
* 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: any): something is TaskCall {
return something[taskCallMarker] === taskCallMarker
}
/** Shallow merge two records */
function merge<A, B>(a: A, b: B): A & B {
return Object.assign(Object.create(null), a, b)
}
/** Create a {@link State} from its components */
function makeState(state: {
cache?: Map<Task, Promise<Result>>
stack?: Task[],
tasks?: Record<string, Task>
props?: Record<string, string>
fails?: Set<Task>
}): State {
const {
cache = new Map(),
fails = new Set(),
stack = [],
tasks = {},
props = {},
} = state
return { cache, fails, stack, tasks, props } as State
}
/* ========================================================================== *
* TASK IMPLEMENTATION *
* ========================================================================== */
const lastIdKey = Symbol.for('plugjs:plug:singleton:taskId')
const taskId = getSingleton(lastIdKey, () => ({ id: 0 }))
class TaskImpl<R extends Result> implements Task<R> {
public readonly before: Task<Result>[] = []
public readonly after: Task<Result>[] = []
public readonly id: number = ++ taskId.id
props: Props<BuildDef>
tasks: Tasks<BuildDef>
constructor(
public readonly name: string,
public readonly buildFile: AbsolutePath,
private readonly _def: TaskDef,
_tasks: Record<string, Task>,
_props: Record<string, string>,
) {
this.tasks = _tasks as Tasks
this.props = _props as Props
}
async invoke(state: State, taskName: string): Promise<R> {
assert(! state.stack.includes(this), `Recursion detected calling ${$t(taskName)}`)
/* Check cache */
const cached = state.cache.get(this)
if (cached) return cached as Promise<R>
/* 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: (_: any, name: string): void | string | (() => Pipe) => {
// Tasks first, props might come also from environment
if (name in state.tasks) {
return (): Pipe => {
const promise = (state as any).tasks[name]!.invoke(state, name)
return new PipeImpl(context, promise)
}
} else if (name in state.props) {
return (state as any).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 as Promise<R>
}
}
/* ========================================================================== *
* BUILD COMPILER *
* ========================================================================== */
/** Compile a {@link BuildDef | build definition} into a {@link Build} */
export function plugjs<
D extends BuildDef, B extends ThisBuild<D>,
>(def: D & ThisType<B>): Build<D> {
const buildFile = findCaller(plugjs)
const tasks: Record<string, Task> = {}
const props: Record<string, string> = {}
/* 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<R>(
callback: (state: State) => Promise<R>,
overrideProps: Record<string, string | undefined> = {},
): Promise<R> {
/* 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: readonly string[],
overrideProps: Record<string, string | undefined> = {},
): Promise<void> {
await start(async (state: State): Promise<void> => {
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: Record<string, TaskCall> = {}
for (const [ name, task ] of Object.entries(tasks)) {
/** The callable function, using "start" */
const callable = async (overrideProps?: Record<string, string>): Promise<Result> =>
start(async (state: State): Promise<Result> =>
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 },
}) as TaskCall
}
/* Create and return our build */
const compiled = merge(props, callables)
Object.defineProperty(compiled, buildMarker, { value: invoke })
return compiled as Build<D>
}
/** @deprecated Please use the new {@link plugjs} export */
export const build: typeof plugjs = function<
D extends BuildDef, B extends ThisBuild<D>,
>(def: D & ThisType<B>): Build<D> {
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: any): build is Build<Record<string, any>> {
return build && typeof build[buildMarker] === 'function'
}
/** Invoke a number of tasks in a {@link Build} */
export function invokeTasks<B extends Build>(
build: B,
tasks: readonly BuildTasks<B>[],
props?: BuildProps<B>,
): Promise<void> {
if (isBuild(build)) {
return (build as any)[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<B extends Build, T extends keyof B>(
build: B,
taskName: string & T & BuildTasks<B>,
hooks: readonly (string & Exclude<BuildTasks<B>, T>)[],
): void {
const taskCall = build[taskName]
assert(isTaskCall(taskCall), `Task "${$t(taskName)}" not found in build`)
for (const hook of hooks) {
const beforeHook = build[hook] as any
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<B extends Build, T extends keyof B>(
build: B,
taskName: string & T & BuildTasks<B>,
hooks: readonly (string & Exclude<BuildTasks<B>, T>)[],
): void {
const taskCall = build[taskName]
assert(isTaskCall(taskCall), `Task "${$t(taskName)}" not found in build`)
for (const hook of hooks) {
const afterHook = build[hook] as any
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)
}
}