diff --git a/package.json b/package.json index a5ae6016..091c6b50 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "private": true, "dependencies": { "@koa/cors": "^3.3.0", + "chokidar": "^3.5.3", "bcryptjs": "^2.4.3", "bytes": "^3.1.2", "class-validator": "^0.13.2", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 83ac1631..15c3b032 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -56,6 +56,7 @@ program './vulcan.yaml' ) .option('-p --port ', 'server port', '3000') + .option('-w --watch', 'watch file changes', false) .action(async (options) => { await handleStart(options); }); diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index f0756f2d..ba846364 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -12,6 +12,15 @@ const defaultOptions: BuildCommandOptions = { config: './vulcan.yaml', }; +export const mergeBuildDefaultOption = ( + options: Partial +) => { + return { + ...defaultOptions, + ...options, + } as BuildCommandOptions; +}; + export const buildVulcan = async (options: BuildCommandOptions) => { const configPath = path.resolve(process.cwd(), options.config); const config: any = jsYAML.load(await fs.readFile(configPath, 'utf-8')); @@ -36,9 +45,5 @@ export const buildVulcan = async (options: BuildCommandOptions) => { export const handleBuild = async ( options: Partial ): Promise => { - options = { - ...defaultOptions, - ...options, - }; - await buildVulcan(options as BuildCommandOptions); + await buildVulcan(mergeBuildDefaultOption(options)); }; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3aad2481..e7a60b1b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1,7 +1,12 @@ import * as jsYAML from 'js-yaml'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { addShutdownJob, localModulePath, logger } from '../utils'; +import { + addShutdownJob, + localModulePath, + logger, + removeShutdownJob, +} from '../utils'; export interface ServeCommandOptions { config: string; @@ -13,6 +18,15 @@ const defaultOptions: ServeCommandOptions = { port: 3000, }; +export const mergeServeDefaultOption = ( + options: Partial +) => { + return { + ...defaultOptions, + ...options, + } as ServeCommandOptions; +}; + export const serveVulcan = async (options: ServeCommandOptions) => { const configPath = path.resolve(process.cwd(), options.config); const config: any = jsYAML.load(await fs.readFile(configPath, 'utf-8')); @@ -25,19 +39,24 @@ export const serveVulcan = async (options: ServeCommandOptions) => { const server = new VulcanServer(config); await server.start(); logger.info(`Server is listening at port ${config.port || 3000}.`); - addShutdownJob(async () => { + + const closeServerJob = async () => { logger.info(`Stopping server...`); await server.close(); logger.info(`Server stopped`); - }); + }; + addShutdownJob(closeServerJob); + + return { + stopServer: async () => { + removeShutdownJob(closeServerJob); + await closeServerJob(); + }, + }; }; export const handleServe = async ( options: Partial ): Promise => { - options = { - ...defaultOptions, - ...options, - }; - await serveVulcan(options as ServeCommandOptions); + await serveVulcan(mergeServeDefaultOption(options)); }; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 84f16ac3..5881a6c8 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,9 +1,127 @@ -import { BuildCommandOptions, handleBuild } from './build'; -import { handleServe, ServeCommandOptions } from './serve'; +import { + BuildCommandOptions, + buildVulcan, + mergeBuildDefaultOption, +} from './build'; +import { + mergeServeDefaultOption, + ServeCommandOptions, + serveVulcan, +} from './serve'; +import * as chokidar from 'chokidar'; +import * as jsYAML from 'js-yaml'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { addShutdownJob, logger } from '../utils'; + +const callAfterFulfilled = (func: () => Promise) => { + let busy = false; + let waitQueue: (() => void)[] = []; + const runJob = () => { + const currentPromises = waitQueue; + waitQueue = []; + busy = true; + func().finally(() => { + currentPromises.forEach((resolve) => resolve()); + busy = false; + if (waitQueue.length > 0) runJob(); + }); + }; + const callback = () => + new Promise((resolve) => { + waitQueue.push(resolve); + if (!busy) runJob(); + }); + return callback; +}; + +export interface StartCommandOptions { + watch: boolean; + config: string; +} + +const defaultOptions: StartCommandOptions = { + config: './vulcan.yaml', + watch: false, +}; + +export const mergeStartDefaultOption = ( + options: Partial +) => { + return { + ...defaultOptions, + ...options, + } as StartCommandOptions; +}; export const handleStart = async ( - options: Partial + options: Partial< + StartCommandOptions & BuildCommandOptions & ServeCommandOptions + > ): Promise => { - await handleBuild(options); - await handleServe(options); + const buildOptions = mergeBuildDefaultOption(options); + const serveOptions = mergeServeDefaultOption(options); + const startOptions = mergeStartDefaultOption(options); + + const configPath = path.resolve(process.cwd(), startOptions.config); + const config: any = jsYAML.load(await fs.readFile(configPath, 'utf-8')); + + let stopServer: (() => Promise) | undefined; + + const restartServer = async () => { + if (stopServer) await stopServer(); + try { + await buildVulcan(buildOptions); + stopServer = (await serveVulcan(serveOptions)).stopServer; + } catch (e) { + // Ignore the error to keep watch process works + if (!startOptions.watch) throw e; + } + }; + + await restartServer(); + + if (startOptions.watch) { + const pathsToWatch: string[] = []; + + // YAML files + const schemaReader = config['schema-parser']?.['reader']; + if (schemaReader === 'LocalFile') { + pathsToWatch.push( + `${path + .resolve(config['schema-parser']?.['folderPath']) + .split(path.sep) + .join('/')}/**/*.yaml` + ); + } else { + logger.warn( + `We can't watch with schema parser reader: ${schemaReader}, ignore it.` + ); + } + + // SQL files + const templateProvider = config['template']?.['provider']; + if (templateProvider === 'LocalFile') { + pathsToWatch.push( + `${path + .resolve(config['template']?.['folderPath']) + .split(path.sep) + .join('/')}/**/*.sql` + ); + } else { + logger.warn( + `We can't watch with template provider: ${templateProvider}, ignore it.` + ); + } + + const restartWhenFulfilled = callAfterFulfilled(restartServer); + const watcher = chokidar + .watch(pathsToWatch, { ignoreInitial: true }) + .on('all', () => restartWhenFulfilled()); + addShutdownJob(async () => { + logger.info(`Stop watching changes...`); + await watcher.close(); + }); + logger.info(`Start watching changes...`); + } }; diff --git a/packages/cli/src/utils/shutdown.ts b/packages/cli/src/utils/shutdown.ts index f1f6a251..691fce8d 100644 --- a/packages/cli/src/utils/shutdown.ts +++ b/packages/cli/src/utils/shutdown.ts @@ -8,6 +8,11 @@ export const addShutdownJob = (job: JobBeforeShutdown) => { shutdownJobs.push(job); }; +export const removeShutdownJob = (job: JobBeforeShutdown) => { + const index = shutdownJobs.indexOf(job); + if (index >= 0) shutdownJobs.splice(index, 1); +}; + export const runShutdownJobs = async () => { logger.info('Ctrl-C signal caught, stopping services...'); await Promise.all(shutdownJobs.map((job) => job())); diff --git a/packages/cli/test/cli.spec.ts b/packages/cli/test/cli.spec.ts index d6ab905d..b1f9d586 100644 --- a/packages/cli/test/cli.spec.ts +++ b/packages/cli/test/cli.spec.ts @@ -37,7 +37,7 @@ beforeAll(async () => { }, 30000); afterAll(async () => { - await fs.rm(projectRoot, { recursive: true, force: true }); + await fs.rm(projectRoot, { recursive: true, force: true, maxRetries: 5 }); }); afterEach(async () => { @@ -83,6 +83,28 @@ it('Start command should build the project and start Vulcan server', async () => await runShutdownJobs(); }); +it('Start command with watch option should watch the file changes', (done) => { + // Arrange + const agent = supertest(`http://localhost:${testingServerPort}`); + const testYamlPath = path.resolve(projectRoot, 'sqls', 'test.yaml'); + + // Act + program + .parseAsync(['node', 'vulcan', 'start', '-w']) + // Wait for server start + .then(() => new Promise((resolve) => setTimeout(resolve, 1000))) + // Write some invalid configs, let server fail to start + .then(() => fs.writeFile(testYamlPath, 'url: /user/:id\ns', 'utf-8')) + // Wait for serer restart + .then(() => new Promise((resolve) => setTimeout(resolve, 1000))) + .then(() => expect(agent.get('/doc')).rejects.toThrow()) + .finally(() => { + runShutdownJobs() + .then(() => fs.rm(testYamlPath)) + .then(() => done()); + }); +}, 5000); + it('Version command should execute without error', async () => { // Action, Assert await expect( diff --git a/yarn.lock b/yarn.lock index 93808cb1..69e1284f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2157,7 +2157,7 @@ charenc@0.0.2: resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA== -chokidar@^3.5.1: +chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==