Skip to content

Commit 42ca78b

Browse files
committed
Builders + documentation
1 parent a66675c commit 42ca78b

File tree

8 files changed

+1760
-284
lines changed

8 files changed

+1760
-284
lines changed

.eslintrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "@unitario"
3+
}

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Unitario Angular Devkit
2+
3+
This package is a utility library for setting up, configuring and deploying Angular applications.
4+
5+
Its goal is to provide a rich set of tools to enchance the development experience when working with the framework.
6+
7+
## Installation
8+
9+
```
10+
npm install @unitario/angular-devkit --save-dev
11+
```
12+
13+
## Tools
14+
15+
### Builders
16+
17+
Builders allows you to develop [Angular Builders](https://angular.io/guide/cli-builder) with a streamlined interface. It aims to make the process of developing builders easier and
18+
19+
#### What it does
20+
21+
* Simplyfies the process for developing Angular builders
22+
* Allows you to pipe multiple builders in a single sequence
23+
* Minimizes your consoel logs (only logs what's important)
24+
25+
#### Usage
26+
27+
```typescript
28+
import {
29+
BuilderOutput,
30+
createBuilder
31+
} from "@angular-devkit/architect";
32+
33+
import {
34+
builderHandler,
35+
scheduleBuilder,
36+
when,
37+
Options,
38+
Context
39+
} from "@unitario/angular-devkit";
40+
41+
interface UserOptions extends Options {
42+
errorOnDepreciated: boolean;
43+
}
44+
45+
const isLibrary = ({ metadata }: Context) => metadata.projectType === "library";
46+
47+
export default const createHandler<Options>(
48+
"Building",
49+
[
50+
// Builder referenced by package
51+
scheduleBuilder("@angular-eslint/builder:lint", "Linting"),
52+
// Builder with options
53+
scheduleBuilder("@angular-builders/jest:run", "Testing", { errorOnDepreciated }),
54+
// Builder with callback
55+
scheduleBuilder(({ options, context, metadata }) => {
56+
// Return promise, observable or value
57+
return new Promise((resolve, reject) => {
58+
// Errors will be automatically resolved
59+
return reject("Show this error in console");
60+
})
61+
})
62+
// Builder based on predicate
63+
when(isLibrary,
64+
scheduleBuilder("@angular-devkit/build-ng-packagr:build", "Building")
65+
),
66+
]
67+
);
68+
69+
export default const createBuilder<Options>(builderHandler);
70+
```
71+
72+
#### Console Output

package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
"main": "dist/index.js",
66
"license": "MIT",
77
"private": false,
8+
"scripts": {
9+
"build": "tsc",
10+
"test": "jest",
11+
"lint": "eslint"
12+
},
813
"dependencies": {
914
"chalk": "^3.0.0",
1015
"ora": "^4.0.3",
@@ -14,7 +19,15 @@
1419
"@angular-devkit/architect": "^0.900.7",
1520
"@angular-devkit/core": "^9.0.7",
1621
"@types/node": "^13.9.2",
22+
"@types/ramda": "^0.26.44",
1723
"@unitario/eslint-config": "^1.0.0",
24+
"eslint": "^6.8.0",
25+
"jest": "^25.1.0",
26+
"prettier": "^2.0.1",
1827
"typescript": "^3.8.3"
28+
},
29+
"enginesStrict": true,
30+
"engines": {
31+
"node": ">=10"
1932
}
2033
}

src/builders/index.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { BuilderContext, BuilderOutput } from "@angular-devkit/architect";
2+
import { JsonObject } from "@angular-devkit/core";
3+
import { OperatorFunction, from, Observable, of, ReplaySubject, Subscription } from "rxjs";
4+
import { map, first, pluck, switchMap, finalize, catchError } from "rxjs/operators";
5+
import { cyan, dim } from "chalk";
6+
import { is } from "ramda";
7+
import * as cluster from "cluster";
8+
import * as ora from "ora";
9+
10+
import { IS_SINGLE_CPU } from "../index";
11+
12+
/**
13+
* The global base configuration interface as shared by all builders in the application
14+
*/
15+
export interface Options extends JsonObject {
16+
/** Name of the project this build is targeting */
17+
project: string;
18+
/** Run build and output a detailed record of the child tasks console logs. Default is `false`. */
19+
verbose: boolean;
20+
}
21+
22+
/**
23+
* The builder context interface as passed through the chain of builder tasks provided to the `builderHandler`
24+
*/
25+
export interface Context {
26+
/** The assigned values of the global options, target options and user provided options. */
27+
options: Options & JsonObject;
28+
/** The builder context */
29+
context: BuilderContext;
30+
/** The target project metadata (as specified in the workspace `angular.json` configuration file) */
31+
metadata: JsonObject;
32+
/** The builder output from the last completed builder task/-s */
33+
output?: BuilderOutput;
34+
}
35+
36+
/**
37+
* A builder callback function, may return an object, promise or observable. Unhandled exzceptions will be resolved into a `BuilderOutput` object.
38+
*/
39+
export type BuilderCallback = (context: Context) => BuilderOutput | Promise<BuilderOutput> | Observable<BuilderOutput>;
40+
41+
/**
42+
* Builders provided to `builderHandler`
43+
*/
44+
export type Builders = OperatorFunction<Context, Context & BuilderOutput>[];
45+
46+
/**
47+
* Takes a list of builder tasks, executes them in sequence and returns a `BuilderOutput` observable. The builder output will only return `success: true` if all tasks has resolved without error.
48+
* @param builderMessage Message to print when the builder is initialized
49+
* @param builders List of build tasks to be executed in this builder context
50+
*/
51+
export const builderHandler = (builderMessage: string, ...builders: Builders) => {
52+
return <T>(options: Options & T, context: BuilderContext) => {
53+
if(IS_SINGLE_CPU) {
54+
context.logger.info(`Builder is running on a single-core processing unit. Switching to single-threaded mode.`);
55+
options.verbose = true;
56+
};
57+
const project = context.target && context.target.project;
58+
if(!project) {
59+
context.logger.fatal(`The builder '${context.builder.builderName}' could not execute. No project was found.`);
60+
}
61+
const projectMetadata = context.getProjectMetadata(project);
62+
const assignContext = map((metadata: JsonObject) => ({
63+
project,
64+
options,
65+
context,
66+
metadata
67+
} as Context));
68+
// Clear console from previous build
69+
console.clear();
70+
// Logs initializaton message
71+
context.logger.info(`\n${builderMessage} ${cyan(project)} \n`);
72+
73+
const initializer = from(projectMetadata)
74+
// Initialize the builder
75+
.pipe(
76+
first(),
77+
assignContext
78+
)
79+
80+
const proccesser = initializer
81+
// Apply builder tasks
82+
.pipe.apply(initializer, builders)
83+
.pipe(
84+
pluck<Context, BuilderOutput>("output"),
85+
map<BuilderOutput, BuilderOutput>(({ success, error }) => {
86+
if(success)
87+
context.logger.info(dim(`\nCompleted successfully.\n`))
88+
else {
89+
context.logger.info(dim(`\nCompleted with error.\n`));
90+
}
91+
return { success, error };
92+
})
93+
) as Observable<BuilderOutput>;
94+
95+
return proccesser;
96+
97+
}
98+
}
99+
100+
/**
101+
* Shedules a build run for a specific builder target, logs the process of that build and returns an observable function which wraps a `Context` object
102+
* @param builder The name of a builder, i.e. its `package:builderName` tuple, or a builder callback function
103+
* @param builderOptions Additional options passed to the builder
104+
* @param builderMessage Message to print when the builder is either initalized or completed
105+
*/
106+
export const scheduleBuilder = (builder: string | BuilderCallback, builderOptions?: JsonObject, builderMessage: string = "") => {
107+
108+
return switchMap((context: Context) => {
109+
110+
const loader = ora({ indent: 2 });
111+
112+
/**
113+
* Transforms `BuilderOutput` to `Context` object
114+
* @param builderOutput `BuilderOutput` object
115+
*/
116+
const toContext = map(({ success, error }: BuilderOutput) => ({
117+
...context, output: {
118+
// Only failed outcomes should persist
119+
success: success === false ? false : context.output.success,
120+
error
121+
} as BuilderOutput
122+
} as Context));
123+
124+
/**
125+
* Initialize a new loading state for the worker
126+
*/
127+
const onOnline = () => {
128+
if(cluster.isMaster) {
129+
// Close all running processes on hot reloads
130+
for(const index in cluster.workers) {
131+
if(cluster.workers[index].isConnected()) {
132+
loader.info(`Builder ${builderMessage} terminated`);
133+
cluster.workers[index].disconnect();
134+
}
135+
}
136+
loader.start(`Building ${builderMessage}`);
137+
}
138+
}
139+
140+
/**
141+
* Sets the `BuilderOutput` state. Runs every time the cluster master receives a message from its worker.
142+
* @param builderOutput `BuilderOutput` object
143+
*/
144+
const onWorkerMessage = ({ success, error }: BuilderOutput) => {
145+
if(success) {
146+
loader.succeed()
147+
}
148+
builderOutput$.next({ success, error });
149+
}
150+
151+
/**
152+
* Handles errors gracefully when a worker process has failed
153+
* @param error `Error` object
154+
*/
155+
const onWorkerError = ({ message }: Error) => {
156+
builderOutput$.next({
157+
success: false,
158+
error:
159+
// prettier-ignore
160+
`Worker process for ${context.context.builder.builderName} failed with an exception.\n\n` +
161+
`The reason for this is due to one of the following reasons:\n\n` +
162+
` 1. The worker could not be spawned, or\n` +
163+
` 2. The worker could not be killed, or\n` +
164+
` 3. The worker were unable to send a message to the master.\n\n` +
165+
`Error message: ${message}`
166+
});
167+
builderOutput$.complete();
168+
}
169+
170+
/**
171+
* Exists process when a worker was killed or exited
172+
* @param worker `Worker` object
173+
* @param code Exit code
174+
* @param signal Exit signal
175+
*/
176+
const onWorkerExit = (_worker: cluster.Worker, code: number, signal: string) => {
177+
if(code) {
178+
builderOutput$.next({
179+
success: false,
180+
error:
181+
// prettier-ignore
182+
`Worker process for ${context.context.builder.builderName} failed with an exception.\n\n` +
183+
`Process failed with exit code '${code}' and signal '${signal}'`
184+
});
185+
}
186+
builderOutput$.complete();
187+
}
188+
189+
/**
190+
* Executes when the builder is an observable and that observable has emitted a new value. Will only be called when the builder is in watch mode.
191+
* @param builderOutput Builder output object
192+
*/
193+
const onBuilderCallbackNext = ({ success, error }: BuilderOutput) => {
194+
if(cluster.isWorker) {
195+
if(process.send) {
196+
process.send({ success, error });
197+
}
198+
}
199+
}
200+
201+
/**
202+
* Executes when the builder's callback either has:
203+
* 1. Returned a observable which has completed with an error, or
204+
* 2. Returned a promise which has rejected, or
205+
* 3. Thrown an error
206+
* @param error Error message or object
207+
*/
208+
const onBuilderCallbackError = (error: any) => {
209+
if(cluster.isWorker) {
210+
if(process.send) {
211+
process.send({ success: false, error });
212+
}
213+
}
214+
return of({ success: false, error }) as Observable<BuilderOutput>;
215+
}
216+
217+
/**
218+
* Executes when the builder's callback either has:
219+
* 1. Returned a observable which has completed without error, or
220+
* 2. Returned a promise which has resolved, or
221+
* 3. Returned a value
222+
*/
223+
const onBuilderCallbackComplete = () => {
224+
if(cluster.isWorker) {
225+
if(process.send) {
226+
process.send({ success: true });
227+
}
228+
}
229+
}
230+
231+
/**
232+
* Returns a builder callback as observable
233+
*/
234+
const getBuilderCallback = () => {
235+
if(is(String, builder)) {
236+
return from(context.context.scheduleBuilder(builder as string, { ...context.options, ...{ builderOptions } }))
237+
.pipe(switchMap((builderRun) => builderRun.output));
238+
}
239+
if(is(Function, builder)) {
240+
let builderCallback = (builder as BuilderCallback)(context);
241+
if(is(Promise, builderCallback))
242+
builderCallback = from(builderCallback as Promise<BuilderOutput>);
243+
if(is(Object, builderCallback))
244+
builderCallback = of(builderCallback as BuilderOutput);
245+
return (builderCallback as Observable<BuilderOutput>)
246+
.pipe(catchError(onBuilderCallbackError));
247+
}
248+
}
249+
250+
let builderOutput$: ReplaySubject<BuilderOutput>;
251+
252+
if(context.options.verbose) {
253+
// Verbose output will execute on a single thread
254+
return getBuilderCallback().pipe(toContext);
255+
} else {
256+
if(cluster.isMaster) {
257+
// Do not pipe the worker's stdout or stderr
258+
cluster.setupMaster({ silent: true });
259+
cluster.fork()
260+
// When the worker has been connected to master
261+
.on("online", onOnline)
262+
// When the worker emits a message
263+
.on("message", onWorkerMessage)
264+
// When the worker has thrown a critical error
265+
.on("error", onWorkerError)
266+
// When the worker has been either exited or killed
267+
.on("exit", onWorkerExit);
268+
} else {
269+
270+
const subscription: Subscription = getBuilderCallback()
271+
.pipe(finalize(() => subscription.unsubscribe()))
272+
.subscribe(onBuilderCallbackNext, onBuilderCallbackError, onBuilderCallbackComplete);
273+
274+
}
275+
}
276+
277+
})
278+
}

0 commit comments

Comments
 (0)