import chokidar from 'chokidar'; import path, { dirname, resolve, join } from 'path'; import { Observable, of, from, defer, concat, timer, never, empty } from 'rxjs'; import { mergeMap, map, expand, filter, find, concatMap, toArray, mapTo, catchError, switchMapTo, } from 'rxjs/operators'; import { IServiceConfig, isTruthy, isUnitTest, isIntegrationTest, defaultBasicLogger, } from '../shared'; import { pathExists } from 'fs-extra'; import { TeardownHandler } from '../shared/teardown'; import { clearModule } from '../shared/clearModule'; if (process.env.NODE_ENV === 'production') { throw new Error('This file should not be imported in production'); } const watchMultiple = (patterns: string[]) => { if (isUnitTest() || isIntegrationTest()) { return never(); } const logger = defaultBasicLogger(); return new Observable((subscriber) => { const watcher = chokidar.watch(patterns, { ignorePermissionErrors: true, }); const onChange = (file: string) => { logger.log('Change detected for', file); subscriber.next(file); }; const onError = (err: Error) => { subscriber.error(err); }; const onClose = () => { subscriber.complete(); }; watcher.on('change', onChange).on('error', onError).on('close', onClose); return () => { watcher.close().catch((err) => { logger.log('Couldnt close file watcher', err); }); }; }); }; let teardownOldServer: TeardownHandler = async () => { const logger = defaultBasicLogger(); logger.log('Dummy teardown was called ... odd'); return; }; const moduleInfo = (mod: NodeModule) => ({ filePath: path.relative('./', mod.filename), mod, }); function mainModule() { if (!require.main) { throw new Error('No require.main defined'); } return require.main; } function allChildModules(startFrom: NodeModule = mainModule()) { return of(moduleInfo(startFrom)).pipe((stream) => { const set = new Set(); // sometimes modules circularly reference each other :( const uniqueModules = (arr: NodeModule[]) => { const items = arr.filter((item) => !set.has(item)); items.forEach(set.add.bind(set)); return items; }; return stream.pipe( expand((data) => from(uniqueModules(data.mod.children)).pipe( map(moduleInfo), filter((pair) => !pair.filePath.includes('node_modules')) ) ) ); }); } function findModule( fullPathToJs: string, startFrom: NodeModule = mainModule() ) { const compareTo = resolve(path.normalize(fullPathToJs)); return concat( allChildModules(startFrom), from( Object.entries(require.cache as { [key: string]: NodeModule | undefined }) .filter((entry) => !entry[0].includes('node_modules')) .map((entry) => entry[1]) .filter(isTruthy) .map((module) => moduleInfo(module)) ) ).pipe( // find((result) => { const resolvedPath = resolve(path.normalize(result.filePath)); return resolvedPath === compareTo; }) ); } function allParentModules(module: NodeModule) { return defer(() => { return of(module.parent).pipe( filter(isTruthy), expand((next) => (next.parent ? of(next.parent) : empty())), map((mod) => moduleInfo(mod)) ); }); } type ServiceSetupFunc = (config: IServiceConfig) => Promise; function requireSetupModule(moduleId: string): IServiceConfig { // eslint-disable-next-line const result = require(moduleId) as | IServiceConfig | { default: IServiceConfig; }; if (typeof result !== 'object') { throw new Error('Resolved to a non-object'); } if ('default' in result) { return result.default; } return result; } export async function serviceSetupInWatchMode( setupFilePath: string, setup: ServiceSetupFunc ): Promise { const initialConfig = requireSetupModule(setupFilePath); const logger = defaultBasicLogger(); teardownOldServer = await setup(initialConfig); // please note that changes to this pattern will probably need changes to `watchServerCode` function // to detect .ts file locations correctly const WATCH_PATTERNS = initialConfig.watchPatterns || [ 'lib/**/*.js', '.env', '.env.local', ]; const subscription = defer(() => { logger.log(`🔍 Watching for file changes in ${WATCH_PATTERNS.join(', ')}`); return watchMultiple(WATCH_PATTERNS); }) .pipe( mergeMap((filePath) => from( pathExists(join(process.cwd(), filePath)) .catch(() => false) .then((exists) => ({ exists, filePath, resolved: join(process.cwd(), filePath), })) ) ), filter((pair) => { if (!pair.exists) { logger.log( `Cannot resolve changes to ${pair.filePath} (tried ${pair.resolved}), ignoring` ); } return pair.exists; }), mergeMap((fileInfo) => findModule(fileInfo.resolved).pipe( filter(isTruthy), concatMap((mod) => concat(of(mod), allParentModules(mod.mod))), toArray() ) ), filter((mods) => mods.length > 0), concatMap((mods) => from(teardownOldServer('watch-mode')).pipe(mapTo(mods)) ), concatMap((mods) => { for (const mod of mods) { if (mod.mod.id === '.') { // we do not reload the main module continue; } if (dirname(mod.mod.id) === __dirname) { continue; } clearModule(mod.mod.id); } if (!mods.find((item) => item.filePath === setupFilePath)) { clearModule(setupFilePath); } return defer(() => from( setup(requireSetupModule(setupFilePath)).then((teardown) => { teardownOldServer = teardown; return Promise.resolve(); }) ) ); }), catchError((err, self) => { logger.log( '💥 Watching error, will wait for 2sec before restart ... ', err ); return timer(2000).pipe(switchMapTo(self)); }) ) .subscribe( () => { return; }, (err) => logger.log('💥 Watching error', err), () => logger.log('Watching stopped') ); return async (mode) => { subscription.unsubscribe(); await teardownOldServer(mode); }; }