diff --git a/.eslintrc.js b/.eslintrc.js index 4be90d2..d5da526 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -88,6 +88,7 @@ module.exports = { }, ignorePatterns: [ '.eslintrc.js', + 'rollup.config.js', 'jest.config.js', 'scripts/**/*', 'dist/**/*', diff --git a/package.json b/package.json index d0acca9..6b0606a 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,11 @@ "scripts": { "test": "jest --verbose --runInBand", "clean": "node scripts/clean.js", - "prepareSources": "node scripts/prepareSources.js", + "prepare-sources": "node scripts/prepare-sources.js", "compile": "yarn clean && tsc", - "build": "yarn prepareSources && yarn compile && node scripts/dist.js", - "dev": "yarn prepareSources && concurrently -k -n \"Typescript,Node,Webpack,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon -i public -i build\" \"maildev\"", + "build": "yarn prepare-sources && yarn compile && node . pre-compile-views && node scripts/dist.js", + "build-production": "NODE_ENV=production yarn build", + "dev": "yarn prepare-sources && concurrently -k -n \"Typescript,Node,ViewPreCompile,Maildev\" -p \"[{name}]\" -c \"blue,green,red,yellow\" \"tsc --watch\" \"nodemon -i public -i build\" \"nodemon -i public -i build -- pre-compile-views --watch\" \"maildev\"", "lint": "eslint .", "release": "yarn build && yarn lint && yarn test && cd dist && yarn publish" }, diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..3985c2b --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,70 @@ +import path from "path"; +import svelte from "rollup-plugin-svelte"; +import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess"; +import cssOnlyRollupPlugin from "rollup-plugin-css-only"; +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import {terser} from "rollup-plugin-terser"; +import livereloadRollupPlugin from "rollup-plugin-livereload"; + +const production = process.env.ENV === 'production'; +const buildDir = process.env.BUILD_DIR; +const publicDir = process.env.PUBLIC_DIR; +const input = process.env.INPUT.split(':'); + +export default commandLineArgs => { + const type = commandLineArgs.input; + delete commandLineArgs.input; + + return { + input: input, + output: { + sourcemap: true, + format: 'es', + name: 'bundle', + dir: path.join(publicDir, 'js'), + entryFileNames: (chunkInfo) => { + const name = chunkInfo.facadeModuleId ? + path.relative(buildDir, chunkInfo.facadeModuleId) : + chunkInfo.name; + return name + '.js'; + }, + }, + plugins: [ + svelte({ + preprocess: sveltePreprocess({ + typescript: { + tsconfigFile: 'tsconfig.views.json', + }, + }), + compilerOptions: { + dev: !production, + hydratable: true, + }, + }), + + // Extract css into separate files + cssOnlyRollupPlugin({output: 'bundle.css'}), + + // If you have external dependencies installed from + // npm, you'll most likely need these plugins. In + // some cases you'll need additional configuration - + // consult the documentation for details: + // https://github.com/rollup/plugins/tree/master/packages/commonjs + resolve({ + browser: true, + dedupe: ['svelte'], + }), + commonjs(), + + // Live reload in dev + !production && !!commandLineArgs.watch && livereloadRollupPlugin('public'), + + // Minify in production + production && terser(), + ], + watch: { + clearScreen: false, + }, + }; +}; diff --git a/scripts/prepareSources.js b/scripts/prepare-sources.js similarity index 100% rename from scripts/prepareSources.js rename to scripts/prepare-sources.js diff --git a/src/Application.ts b/src/Application.ts index b37b3be..35fe860 100644 --- a/src/Application.ts +++ b/src/Application.ts @@ -17,6 +17,7 @@ import CacheProvider from "./CacheProvider"; import RedisComponent from "./components/RedisComponent"; import Extendable from "./Extendable"; import {logger, loggingContextMiddleware} from "./Logger"; +import FrontendToolsComponent from "./components/FrontendToolsComponent"; import TemplateError = lib.TemplateError; export default abstract class Application implements Extendable> { @@ -225,6 +226,7 @@ export default abstract class Application implements Extendable('view.dev')) { - await this.viewEngine.preCompileAll(); - await this.viewEngine.watch(); - } - // Setup express view engine app.engine(this.viewEngine.getExtension(), (path, options, callback) => { this.viewEngine.render(path, options as Record, callback) @@ -45,7 +39,7 @@ export default class FrontendToolsComponent extends ApplicationComponent { } public async stop(): Promise { - await this.viewEngine.end?.(); + await this.viewEngine.stop(); } public async handle(router: Router): Promise { @@ -57,4 +51,8 @@ export default class FrontendToolsComponent extends ApplicationComponent { next(); }); } + + public async preCompileViews(watch: boolean): Promise { + await this.viewEngine.preCompileAll(watch); + } } diff --git a/src/frontend/SvelteViewEngine.ts b/src/frontend/SvelteViewEngine.ts index 9607828..0ce432f 100644 --- a/src/frontend/SvelteViewEngine.ts +++ b/src/frontend/SvelteViewEngine.ts @@ -1,6 +1,5 @@ import crypto from "crypto"; import path from "path"; -import {PreRenderedChunk, rollup, RollupBuild, RollupOptions, RollupWatcher, RollupWatchOptions, watch} from "rollup"; import config from "config"; import ViewEngine from "./ViewEngine"; import {logger} from "../Logger"; @@ -10,12 +9,7 @@ import {compile, preprocess} from "svelte/compiler"; import {sveltePreprocess} from "svelte-preprocess/dist/autoProcess"; import requireFromString from "require-from-string"; import {CssResult} from "svelte/types/compiler/interfaces"; -import svelte from "rollup-plugin-svelte"; -import cssOnlyRollupPlugin from "rollup-plugin-css-only"; -import resolve from "@rollup/plugin-node-resolve"; -import commonjs from "@rollup/plugin-commonjs"; -import {terser} from "rollup-plugin-terser"; -import livereloadRollupPlugin from "rollup-plugin-livereload"; +import * as child_process from "child_process"; const BACKEND_CODE_PREFIX = 'swaf.'; const COMPILED_SVELTE_EXTENSION = '.swafview'; @@ -37,7 +31,7 @@ export default class SvelteViewEngine extends ViewEngine { backendLines: string[], }> = {}; - private rollup?: RollupBuild | RollupWatcher; + private rollup?: child_process.ChildProcess; public constructor( buildDir: string, @@ -112,7 +106,8 @@ export default class SvelteViewEngine extends ViewEngine { callback(null, output); } - public async end(): Promise { + public async stop(): Promise { + await super.stop(); await this.stopRollup(); } @@ -130,7 +125,7 @@ export default class SvelteViewEngine extends ViewEngine { const file = await this.resolveFileFromCanonicalName(canonicalName); const intermediateFile = path.join(this.getBuildDir(), canonicalName); - logger.debug(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile); + logger.info(canonicalName + ' > ', 'Pre-compiling', file, '->', intermediateFile); const source = await this.fileCache.get(file, config.get('view.dev')); const allBackendLines: string[] = []; @@ -157,7 +152,7 @@ export default class SvelteViewEngine extends ViewEngine { await afs.writeFile(swafViewFile, finalCode); if (alsoCompileDependents && Object.keys(this.dependencyCache).indexOf(canonicalName) >= 0) { - logger.debug(canonicalName + ' > ', 'Compiling dependents...'); + logger.info(canonicalName + ' > ', 'Compiling dependents...'); for (const dependent of [...this.dependencyCache[canonicalName]]) { await this.preCompile(dependent, true); } @@ -271,8 +266,8 @@ export default class SvelteViewEngine extends ViewEngine { return generated; } - public async afterPreCompile(): Promise { - await this.bundle(...Object.keys(this.backendCodeCache)); + public async afterPreCompile(watch: boolean): Promise { + await this.bundle(watch, ...Object.keys(this.backendCodeCache)); } @@ -284,13 +279,22 @@ export default class SvelteViewEngine extends ViewEngine { delete this.backendCodeCache[this.toCanonicalName(file)]; } + public async onFileRemove(file: string): Promise { + const canonicalName = this.toCanonicalName(file); + delete this.backendCodeCache[canonicalName]; + delete this.dependencyCache[canonicalName]; + Object.values(this.dependencyCache).forEach(set => set.delete(canonicalName)); + await this.stopRollup(); + await this.afterPreCompile(true); + } + private async compileSsr(canonicalName: string, file: string, code: string): Promise<{ head: string, css: CssResult, html: string, }> { // Svelte preprocess - logger.debug(canonicalName + ' > ', 'Preprocessing svelte', file); + logger.info(canonicalName + ' > ', 'Preprocessing svelte', file); const preprocessed = await preprocess( code, sveltePreprocess({ @@ -304,7 +308,7 @@ export default class SvelteViewEngine extends ViewEngine { ); // Svelte compile - logger.debug(canonicalName + ' > ', 'Compiling svelte ssr', file); + logger.info(canonicalName + ' > ', 'Compiling svelte ssr', file); const svelteSsr = compile(preprocessed.code, { dev: config.get('view.dev'), generate: 'ssr', @@ -324,103 +328,43 @@ export default class SvelteViewEngine extends ViewEngine { }); } - /** - * TODO: load rollup config from external source - */ - private async bundle(...canonicalViewNames: string[]): Promise { - logger.debug('Bundling'); - - const production = !config.get('view.dev'); - const options: RollupOptions | RollupWatchOptions = { - input: canonicalViewNames.map(name => path.join(this.getBuildDir(), name)), - output: { - sourcemap: true, - format: 'es', - name: 'bundle', - dir: path.join(this.getPublicDir(), 'js'), - entryFileNames: (chunkInfo: PreRenderedChunk): string => { - const name = chunkInfo.facadeModuleId ? - path.relative(this.getBuildDir(), chunkInfo.facadeModuleId) : - chunkInfo.name; - return name + '.js'; - }, - }, - plugins: [ - svelte({ - preprocess: sveltePreprocess({ - typescript: { - tsconfigFile: 'tsconfig.views.json', - }, - }), - compilerOptions: { - // enable run-time checks when not in production - dev: !production, - hydratable: true, - }, - }), - // we'll extract any component CSS out into - // a separate file - better for performance - cssOnlyRollupPlugin({output: 'bundle.css'}), - - // If you have external dependencies installed from - // npm, you'll most likely need these plugins. In - // some cases you'll need additional configuration - - // consult the documentation for details: - // https://github.com/rollup/plugins/tree/master/packages/commonjs - resolve({ - browser: true, - dedupe: ['svelte'], - }), - commonjs(), - ], - watch: { - buildDelay: 1000, - clearScreen: false, - }, - }; - - if (production) { - // If we're building for production (npm run build - // instead of npm run dev), minify - options.plugins?.push(terser()); - } else { - // Watch the `public` directory and refresh the - // browser on changes when not in production - const plugin = livereloadRollupPlugin('public'); - options.plugins?.push(plugin); - } + private async bundle(watch: boolean, ...canonicalViewNames: string[]): Promise { + logger.info('Bundling...'); + // Prepare output dir for (const name of canonicalViewNames) { await afs.mkdir(path.dirname(path.join(this.getPublicDir(), 'js', name)), {recursive: true}); } - if (production) { - if (!this.rollup) { - this.rollup = await rollup(options); + const production = !config.get('view.dev'); + const input = canonicalViewNames.map(name => path.join(this.getBuildDir(), name)); - await this.rollup.write({ - format: 'es', - dir: path.join(this.getPublicDir(), 'js'), - }); - } - } else { - if (!this.rollup) { - this.rollup = watch(options); - this.rollup.on('event', (event) => { - if (event.code === 'ERROR' || event.code === 'BUNDLE_END') { - event.result?.close().catch(err => logger.error(err)); - logger.debug('Bundled from watch'); - } - }); - } + if (!this.rollup) { + const args = [ + 'rollup', + '-c', 'rollup.config.js', + '--environment', `ENV:${production ? 'production' : 'dev'},BUILD_DIR:${this.getBuildDir()},PUBLIC_DIR:${this.getPublicDir()},INPUT:${input.join(':')}`, + ]; + if (watch) args.push('--watch'); + this.rollup = child_process.spawn('yarn', args, {stdio: [process.stdin, process.stdout, process.stderr]}); + logger.info('Rollup started'); + this.rollup.once('exit', () => { + logger.info('Rollup stopped'); + this.rollup = undefined; + }); } } private async stopRollup(): Promise { if (this.rollup) { - logger.debug('Stopping rollup...'); - await this.rollup.close(); - this.rollup = undefined; + logger.info(`Stopping rollup (${this.rollup.pid})...`); + await new Promise((resolve, reject) => { + if (!this.rollup) return resolve(); + this.rollup.once('exit', () => { + resolve(); + }); + if (!this.rollup.kill("SIGTERM")) reject('Could not stop rollup.'); + }); } } } diff --git a/src/frontend/ViewEngine.ts b/src/frontend/ViewEngine.ts index e16db1d..bae684c 100644 --- a/src/frontend/ViewEngine.ts +++ b/src/frontend/ViewEngine.ts @@ -1,11 +1,12 @@ import path from "path"; import fs from "fs"; -import chokidar from "chokidar"; +import chokidar, {FSWatcher} from "chokidar"; import {logger} from "../Logger"; import {afs, readdirRecursively} from "../Utils"; export default abstract class ViewEngine { protected readonly viewPaths: string[]; + private watcher?: FSWatcher; /** * @param buildDir A temporary directory that will contain any non-final or final non-public asset. @@ -41,8 +42,6 @@ export default abstract class ViewEngine { callback: (err: Error | null, output?: string) => void, ): Promise; - public end?(): Promise; - public getViewPaths(): string[] { return this.viewPaths; } @@ -61,9 +60,9 @@ export default abstract class ViewEngine { public preCompile?(canonicalName: string, alsoCompileDependents: boolean): Promise; - public afterPreCompile?(): Promise; + public afterPreCompile?(watch: boolean): Promise; - public async preCompileAll(): Promise { + public async preCompileAll(watch: boolean): Promise { if (this.preCompile) { logger.info(`Pre-compiling ${this.getExtension()} views...`); @@ -83,38 +82,60 @@ export default abstract class ViewEngine { } } - await this.afterPreCompile?.(); + await this.afterPreCompile?.(watch); + + if (watch) { + await this.watch(); + } } public onNewFile?(file: string): Promise; public onFileChange?(file: string): Promise; - public async watch(): Promise { - const watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true}); - watcher.on('ready', () => { - logger.debug(`Watching ${this.getExtension()} assets for changes`); + public onFileRemove?(file: string): Promise; - watcher.on('add', (file) => { + public async watch(): Promise { + this.watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true}); + this.watcher.on('ready', () => { + if (!this.watcher) return; + + logger.info(`Watching ${this.getExtension()} assets for changes`); + + this.watcher.on('add', (file) => { if (file.endsWith('.' + this.getExtension())) { (this.onNewFile ? this.onNewFile(file) : Promise.resolve()) .then(() => this.preCompile?.(this.toCanonicalName(file), true)) - .then(() => this.afterPreCompile?.()) + .then(() => this.afterPreCompile?.(true)) .catch(err => logger.error(err)); } }); - watcher.on('change', (file) => { + this.watcher.on('change', (file) => { if (file.endsWith('.' + this.getExtension())) { (this.onFileChange ? this.onFileChange(file) : Promise.resolve()) .then(() => this.preCompile?.(this.toCanonicalName(file), true)) - .then(() => this.afterPreCompile?.()) + .then(() => this.afterPreCompile?.(true)) + .catch(err => logger.error(err)); + } + }); + + this.watcher.on('unlink', (file) => { + if (file.endsWith('.' + this.getExtension())) { + (this.onFileRemove ? this.onFileRemove(file) : Promise.resolve()) .catch(err => logger.error(err)); } }); }); } + public async stop(): Promise { + if (this.watcher) { + await this.watcher.close(); + this.watcher = undefined; + } + } + protected toCanonicalName(file: string): string { const resolvedFilePath = path.resolve(file);