@whook/whook
Version:
Build strong and efficient REST web services.
251 lines (227 loc) • 6.99 kB
text/typescript
import chokidar from 'chokidar';
import { dirname, join } from 'node:path';
import crypto from 'node:crypto';
import { PassThrough } from 'node:stream';
import { createWriteStream } from 'node:fs';
import initGenerateOpenAPITypes from './commands/generateOpenAPITypes.js';
import initGetOpenAPI from './routes/getOpenAPI.js';
import initWatchResolve from './services/watchResolve.js';
import { readFile } from 'node:fs/promises';
import ignore from 'ignore';
import { type AppEnvVars } from 'application-services';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { type Dependencies, Knifecycle, constant } from 'knifecycle';
import { type DelayService, type LogService } from 'common-services';
import { type OpenAPITypesConfig } from './commands/generateOpenAPITypes.js';
import { printStackTrace } from 'yerror';
let $instance: Knifecycle;
let log: LogService;
let delay: DelayService;
let delayPromise: Promise<void> | undefined;
let hash: string;
export type WatchProcessDependencies = OpenAPITypesConfig & {
ENV: AppEnvVars;
MAIN_FILE_URL: string;
$instance: Knifecycle;
delay: DelayService;
getOpenAPI: Awaited<ReturnType<typeof initGetOpenAPI>>;
log: LogService;
};
export type WatchProcessArgs<T extends Dependencies> = {
injectedNames: string[];
ignored?: string[];
watched?: string[];
afterRestartEnd?: (
services: T & WatchProcessDependencies,
context: { apiChanged: boolean; openAPIData: string },
) => Promise<void>;
};
export const DEFAULT_IGNORED = ['node_modules', '*.d.ts', '.git'];
export const DEFAULT_WATCHED = ['src', 'package.json', 'package-lock.json'];
export async function watchDevProcess<T extends Dependencies>(
{
injectedNames = [],
ignored,
watched,
afterRestartEnd,
}: WatchProcessArgs<T> = {
injectedNames: [],
},
): Promise<void> {
let restartsCounter = 0;
const ignoreFilter: (pathname: string) => boolean = await (async () => {
let ignoreFileContent: string;
let ignoreBuilder = ignore();
ignoreBuilder = ignoreBuilder.add(
ignored?.length ? ignored : DEFAULT_IGNORED,
);
try {
ignoreFileContent = (await readFile('.gitignore')).toString();
ignoreBuilder = ignoreBuilder.add(ignoreFileContent || []);
} catch (err) {
// TODO: requires a deeper integration of Knifecycle to
// start with a silo containing only the environment
// and then another one for the server. It would allow
// to wrap the chokidar watch into a service too.
log?.('debug', '🤷 - Cannot find/parse .gitignore');
log?.('debug-stack', printStackTrace(err as Error));
}
return ignoreBuilder.createFilter();
})();
await restartDevProcess({ injectedNames, afterRestartEnd, restartsCounter });
await new Promise<void>((resolve, reject) => {
chokidar
.watch(watched?.length ? watched : DEFAULT_WATCHED, {
ignored: (str, stats) => {
if (!stats?.isFile()) {
return false;
}
return !ignoreFilter(str);
},
ignoreInitial: true,
})
.once('ready', () => {
resolve();
})
.once('error', (err) => {
reject(err as Error);
})
.on('all', (_event, filePath) => {
// TODO: determine all the files needing a complete restart
if (
filePath.match(/package.*\.json/) ||
filePath.match(/src\/[^/]*\.ts/)
) {
log(
'warning',
`☢️ - A file changed that may need a full restart (${filePath}).`,
);
}
if (delay) {
if (!delayPromise) {
delayPromise = delay.create(2000);
restartDevProcess({
injectedNames,
afterRestartEnd,
restartsCounter: restartsCounter++,
});
}
}
});
});
}
export async function restartDevProcess<T extends Dependencies>({
injectedNames = [],
afterRestartEnd,
restartsCounter,
}: WatchProcessArgs<T> & {
restartsCounter: number;
}): Promise<void> {
if ($instance) {
log(
'warning',
`➡️ - Changes detected : Will restart the server soon (${restartsCounter})...`,
);
await delayPromise;
await $instance.destroy();
}
const { runProcess, prepareEnvironment, prepareProcess } = await import(
pathToFileURL(join(process.cwd(), 'src', 'index.ts')).toString() +
(restartsCounter ? '?restartsCounter=' + restartsCounter : '')
);
async function prepareWatchEnvironment<T extends Knifecycle>(
$: T = new Knifecycle() as T,
): Promise<T> {
$ = await prepareEnvironment($);
$.register(initWatchResolve);
$.register(constant('RESTARTS_COUNTER', restartsCounter));
return $;
}
const {
ENV,
OPEN_API_TYPES_CONFIG,
MAIN_FILE_URL,
$instance: _instance,
delay: _delay,
getOpenAPI,
log: _log,
...additionalServices
} = (await runProcess(prepareWatchEnvironment, prepareProcess, [
...new Set([
...injectedNames,
'ENV',
'OPEN_API_TYPES_CONFIG',
'MAIN_FILE_URL',
'$instance',
'delay',
'getOpenAPI',
'log',
]),
])) as WatchProcessDependencies & T;
$instance = _instance;
delay = _delay;
log = _log;
delayPromise = undefined;
const response = await getOpenAPI({
options: {
authenticated: true,
},
query: {
mutedMethods: ['options'],
mutedParameters: [],
},
});
const openAPIData = JSON.stringify(response.body);
const newHash = crypto.createHash('md5').update(openAPIData).digest('hex');
const apiChanged = hash !== newHash;
if (apiChanged) {
hash = newHash;
log('warning', '🦄 - API Changed : Generating API types...');
const instream = new PassThrough();
const bridge = new PassThrough();
const openAPITypesGenerationPromise = (
await initGenerateOpenAPITypes({
OPEN_API_TYPES_CONFIG,
instream,
outstream: bridge,
log,
})
)({
command: 'whook',
namedArguments: {},
rest: [],
});
const writeStream = createWriteStream(
join(dirname(fileURLToPath(MAIN_FILE_URL)), 'openAPISchema.d.ts'),
);
const writeStreamCompletionPromise = new Promise<void>(
(resolve, reject) => {
writeStream.once('finish', resolve);
writeStream.once('error', reject);
},
);
bridge.pipe(writeStream);
instream.write(openAPIData);
instream.end();
await Promise.all([
openAPITypesGenerationPromise,
writeStreamCompletionPromise,
]);
log('warning', '🦄 - API types generated!');
}
if (afterRestartEnd) {
await afterRestartEnd(
{
ENV,
OPEN_API_TYPES_CONFIG,
MAIN_FILE_URL,
$instance,
delay,
getOpenAPI,
log,
...additionalServices,
} as WatchProcessDependencies & T,
{ apiChanged, openAPIData },
);
}
}