Merge branch 'next' into next-passport

This commit is contained in:
Wyatt Johnson
2018-07-04 09:57:07 -06:00
16 changed files with 624 additions and 54 deletions
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env ts-node
import program from "commander";
import fs from "fs";
import path from "path";
const config = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "../.graphqlconfig"), "utf8")
);
program
.version("0.1.0")
.usage("<project>")
.arguments("<project>")
.description(
"Returns the schema graph in `.graphqlconfig` based on <project>"
)
.action(project => {
if (!config.projects) {
// tslint:disable-next-line:no-console
console.error("Missing projects key in .graphqconfig");
process.exit(1);
}
if (!config.projects[project]) {
// tslint:disable-next-line:no-console
console.error(`Project ${project} not found in .graphqconfig`);
process.exit(1);
}
if (!config.projects[project].schemaPath) {
// tslint:disable-next-line:no-console
console.error(
`SchemaPath for project ${project} not found in .graphqconfig`
);
process.exit(1);
}
// tslint:disable-next-line:no-console
console.log(config.projects[project].schemaPath);
})
.parse(process.argv);
+81
View File
@@ -0,0 +1,81 @@
import chokidar from "chokidar";
import { Watcher, WatchOptions } from "./types";
export default class ChokidarWatcher implements Watcher {
public watch(
paths: ReadonlyArray<string>,
options: WatchOptions = {}
): AsyncIterable<string> {
const client = chokidar.watch(paths as string[], {
ignored: options.ignore,
});
// An array to hold all changes, that has not yet been yield.
const queue: string[] = [];
let firstError: Error | null = null;
// If this is set, a pending promise is waiting for the next result.
let pending:
| ({ resolve: (result: string) => void; reject: (error: Error) => void })
| null = null;
// Listen for errors
client.on("error", (error: Error) => {
// Resolve pending request.
if (pending) {
pending.reject(error);
pending = null;
return;
}
if (!firstError) {
firstError = error;
}
});
// Listen for changes
client.on("change", (pathFile: string) => {
// Resolve pending request.
if (pending) {
pending.resolve(pathFile);
pending = null;
return;
}
// There is no pending request, save it into the queue.
queue.unshift(pathFile);
});
return {
[Symbol.asyncIterator]() {
return {
next: () =>
new Promise<IteratorResult<string>>((resolve, reject) => {
const wrapped = {
resolve: (pathFile: string) =>
resolve({
done: false,
value: pathFile,
}),
reject: (error: Error) =>
reject({
done: true,
value: error,
}),
};
// We already have a change to return
if (firstError) {
wrapped.reject(firstError);
return;
}
if (queue.length) {
wrapped.resolve(queue.pop()!);
return;
}
// We need to wait for the next change event.
pending = wrapped;
}),
};
},
};
}
}
+81
View File
@@ -0,0 +1,81 @@
import spawn from "cross-spawn";
import { Cancelable, debounce } from "lodash";
import { Executor } from "./types";
interface CommandExecutorOptions {
args?: ReadonlyArray<string>;
// If true, allow spawning multiple processes.
spawnMutiple?: boolean;
// Specify the period in which the process is started at max once.
debounce?: number | false;
// If true, will run command upon initialization.
runOnInit?: boolean;
}
export default class CommandExecutor implements Executor {
private cmd: string;
private args?: ReadonlyArray<string>;
private spawnMultiple: boolean;
private runOnInit: boolean;
private isRunning: boolean = false;
private shouldRespawn: boolean = false;
private spawnProcessDebounced?: (() => void) & Cancelable;
constructor(cmd: string, opts: CommandExecutorOptions = {}) {
this.cmd = cmd;
this.args = opts.args;
this.spawnMultiple = opts.spawnMutiple || false;
this.runOnInit = opts.runOnInit || false;
const wait = opts.debounce === undefined ? 500 : opts.debounce;
if (wait) {
this.spawnProcessDebounced = debounce(() => this.spawnProcess(), wait);
}
}
// This is called before watching starts.
public onInit(): void {
if (this.runOnInit) {
this.spawnProcessPotentiallyDebounced();
}
}
private spawnProcessPotentiallyDebounced() {
if (this.spawnProcessDebounced) {
this.spawnProcessDebounced();
return;
}
this.spawnProcess();
}
private spawnProcess() {
if (this.isRunning && !this.spawnMultiple) {
this.shouldRespawn = true;
return;
}
this.isRunning = true;
this.shouldRespawn = false;
const child = spawn(this.cmd, this.args as string[], {
stdio: "inherit",
shell: !this.args,
});
child.on("close", (code: number) => {
this.isRunning = false;
if (code !== 0) {
// tslint:disable-next-line: no-console
console.log(`We had an error building ${code}`);
}
if (this.shouldRespawn) {
this.spawnProcessPotentiallyDebounced();
}
});
}
public execute(filePath: string) {
this.spawnProcessPotentiallyDebounced();
}
}
+88
View File
@@ -0,0 +1,88 @@
import { ChildProcess } from "child_process";
import spawn from "cross-spawn";
import { Cancelable, debounce } from "lodash";
import { Executor } from "./types";
interface LongRunningExecutorOptions {
args?: ReadonlyArray<string>;
// Specify the period in which the process is restarted at max once.
debounce?: number;
}
export default class LongRunningExecutor implements Executor {
private cmd: string;
private args?: ReadonlyArray<string>;
private process: ChildProcess | null = null;
private isRunning: boolean = false;
private shouldRestart: boolean = false;
private restartDebounced: (() => void) & Cancelable;
constructor(cmd: string, opts: LongRunningExecutorOptions = {}) {
this.cmd = cmd;
this.args = opts.args;
this.restartDebounced = debounce(
() => this.restart(),
opts.debounce || 500
);
}
private spawnProcess() {
this.isRunning = true;
this.process = spawn(this.cmd, this.args as string[], {
stdio: "inherit",
// Have all child processes in their own group.
// See `process.kill` below.
detached: true,
shell: !this.args,
});
this.process!.on("exit", (code: number) => {
this.isRunning = false;
if (code !== 0 && code !== null) {
// tslint:disable-next-line: no-console
console.error(`Exit code returned ${code}`);
return;
}
if (this.shouldRestart) {
this.spawnProcess();
}
});
}
private restart(): void {
this.shouldRestart = true;
// Using the `-` will kill all child procceses in the group.
// See: https://azimi.me/2014/12/31/kill-child_process-node-js.html
process.kill(-this.process!.pid, "SIGTERM");
}
private kill(): void {
this.shouldRestart = false;
// Using the `-` will kill all child procceses in the group.
// See: https://azimi.me/2014/12/31/kill-child_process-node-js.html
process.kill(-this.process!.pid, "SIGTERM");
}
// This is called before watching starts.
public onInit(): void {
this.spawnProcess();
}
// This is called before exiting.
public onCleanup() {
this.restartDebounced.cancel();
if (this.isRunning) {
this.kill();
}
}
public execute(filePath: string) {
if (this.isRunning) {
this.restartDebounced();
return;
}
this.spawnProcess();
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env ts-node
import program from "commander";
import path from "path";
import watch from "../";
program
.version("0.1.0")
.usage("<configFile>")
.arguments("<configFile>")
.description("Run watchers defined in <configFile>")
.action(configFile => {
let config: any = require(path.resolve(configFile));
if (config.__esModule) {
config = config.default;
}
watch(config);
})
.parse(process.argv);
+5
View File
@@ -0,0 +1,5 @@
export { default as ChokidarWatcher } from "./ChokidarWatcher";
export { default as CommandExecutor } from "./CommandExecutor";
export { default as LongRunningExecutor } from "./LongRunningExecutor";
export { default } from "./watch";
export * from "./types";
+50
View File
@@ -0,0 +1,50 @@
import Joi from "joi";
export interface WatchOptions {
ignore?: ReadonlyArray<string>;
}
export interface Watcher {
watch(
paths: ReadonlyArray<string>,
options?: WatchOptions
): AsyncIterable<string>;
}
export interface Executor {
onInit?(): void;
onCleanup?(): void;
execute(filePath: string): void;
}
export interface Config {
rootDir?: string;
backend?: Watcher;
watchers: {
[key: string]: WatchConfig;
};
}
export interface WatchConfig {
paths: ReadonlyArray<string>;
ignore?: ReadonlyArray<string>;
executor: Executor;
}
export const configSchema = Joi.object({
rootDir: Joi.string().optional(),
backend: Joi.object().optional(),
watchers: Joi.object().pattern(
/.*/,
Joi.object({
paths: Joi.array()
.items(Joi.string())
.unique(),
ignore: Joi.array()
.items(Joi.string())
.unique()
.optional(),
executor: Joi.object(),
})
),
});
+58
View File
@@ -0,0 +1,58 @@
import Joi from "joi";
import path from "path";
import ChokidarWatcher from "./ChokidarWatcher";
import { Config, configSchema, WatchConfig, Watcher } from "./types";
// Polyfill the asyncIterator symbol.
if (Symbol.asyncIterator === undefined) {
(Symbol as any).asyncIterator = Symbol.for("asyncIterator");
}
async function beginWatch(watcher: Watcher, key: string, config: WatchConfig) {
const { paths, ignore, executor } = config;
if (executor.onInit) {
executor.onInit();
}
for await (const filePath of watcher.watch(paths, { ignore })) {
// tslint:disable-next-line:no-console
console.log(`Execute "${key}"`);
executor.execute(filePath);
}
}
function prependRootDir(prepend: string, cfg: WatchConfig): WatchConfig {
const prependFunc = (p: string) => path.resolve(prepend, p);
return {
...cfg,
paths: cfg.paths.map(prependFunc),
ignore: cfg.ignore ? cfg.ignore.map(prependFunc) : undefined,
};
}
function setupCleanup(config: Config) {
["SIGINT", "SIGTERM"].forEach(signal =>
process.once(signal as any, () => {
for (const key of Object.keys(config.watchers)) {
if (config.watchers[key].executor.onCleanup) {
config.watchers[key].executor.onCleanup!();
}
}
process.exit(0);
})
);
}
export default async function watch(config: Config) {
Joi.assert(config, configSchema);
const watcher = config.backend || new ChokidarWatcher();
setupCleanup(config);
for (const key of Object.keys(config.watchers)) {
// tslint:disable-next-line:no-console
console.log(`Start watcher "${key}"`);
const watcherConfig = config.rootDir
? prependRootDir(config.rootDir, config.watchers[key])
: config.watchers[key];
beginWatch(watcher, key, watcherConfig);
}
}