From ac7d1053593671994c2e130805f304496ffb38ee Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sat, 14 Nov 2020 19:53:24 -0800 Subject: [PATCH 1/4] Start on watch command. --- package.json | 1 + src/Packemon.ts | 54 +++++++++++----------- src/bin.ts | 5 +- src/commands/Clean.tsx | 4 +- src/commands/Watch.tsx | 102 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + yarn.lock | 2 +- 7 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 src/commands/Watch.tsx diff --git a/package.json b/package.json index 4638c5fcf..4b244c4d8 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "babel-plugin-transform-async-to-promises": "^0.8.15", "babel-plugin-transform-dev": "^2.0.1", "builtin-modules": "^3.1.0", + "chokidar": "^3.4.3", "execa": "^4.1.0", "fast-glob": "^3.2.4", "filesize": "^6.1.0", diff --git a/src/Packemon.ts b/src/Packemon.ts index dfcd0726f..30c8c8046 100644 --- a/src/Packemon.ts +++ b/src/Packemon.ts @@ -9,7 +9,7 @@ import { toArray, WorkspacePackage, } from '@boost/common'; -import { createDebugger } from '@boost/debug'; +import { createDebugger, Debugger } from '@boost/debug'; import { Event } from '@boost/event'; import { PooledPipeline, Context } from '@boost/pipeline'; import Package from './Package'; @@ -31,7 +31,6 @@ import { ValidateOptions, } from './types'; -const debug = createDebugger('packemon:core'); const { array, bool, custom, number, object, string, union } = predicates; const platformPredicate = string('browser').oneOf(['node', 'browser']); @@ -59,6 +58,8 @@ const blueprint: Blueprint> = { }; export default class Packemon { + readonly debug: Debugger; + readonly onPackageBuilt = new Event<[Package]>('package-built'); packages: Package[] = []; @@ -70,14 +71,15 @@ export default class Packemon { constructor(cwd: string = process.cwd()) { this.root = Path.resolve(cwd); this.project = new Project(this.root); + this.debug = createDebugger('packemon:core'); - debug('Initializing packemon in project %s', this.root); + this.debug('Initializing packemon in project %s', this.root); this.project.checkEngineVersionConstraint(); } async build(baseOptions: Partial) { - debug('Starting `build` process'); + this.debug('Starting `build` process'); const options = optimal(baseOptions, { addEngines: bool(), @@ -120,7 +122,7 @@ export default class Packemon { } async clean() { - debug('Starting `clean` process'); + this.debug('Starting `clean` process'); await this.findPackages(); await this.cleanTemporaryFiles(); @@ -148,7 +150,7 @@ export default class Packemon { pathsToRemove.map( (path) => new Promise((resolve, reject) => { - debug(' - %s', path); + this.debug(' - %s', path); rimraf(path, (error) => { if (error) { @@ -163,7 +165,7 @@ export default class Packemon { } async validate(baseOptions: Partial): Promise { - debug('Starting `validate` process'); + this.debug('Starting `validate` process'); const options = optimal(baseOptions, { deps: bool(true), @@ -180,18 +182,12 @@ export default class Packemon { return Promise.all(this.packages.map((pkg) => new PackageValidator(pkg).validate(options))); } - protected async cleanTemporaryFiles() { - debug('Cleaning temporary build files'); - - await Promise.all(this.packages.map((pkg) => pkg.cleanup())); - } - - protected async findPackages(skipPrivate: boolean = false) { + async findPackages(skipPrivate: boolean = false) { if (this.packages.length > 0) { return; } - debug('Finding packages in project'); + this.debug('Finding packages in project'); const pkgPaths: Path[] = []; @@ -199,7 +195,7 @@ export default class Packemon { // Multi package repo if (this.project.workspaces.length > 0) { - debug('Workspaces enabled, finding packages using globs'); + this.debug('Workspaces enabled, finding packages using globs'); this.project.getWorkspacePackagePaths().forEach((filePath) => { pkgPaths.push(Path.create(filePath).append('package.json')); @@ -207,12 +203,12 @@ export default class Packemon { // Single package repo } else { - debug('Not workspaces enabled, using root as package'); + this.debug('Not workspaces enabled, using root as package'); pkgPaths.push(this.root.append('package.json')); } - debug('Found %d package(s)', pkgPaths.length); + this.debug('Found %d package(s)', pkgPaths.length); const privatePackageNames: string[] = []; @@ -220,7 +216,7 @@ export default class Packemon { pkgPaths.map(async (pkgPath) => { const contents = json.parse(await fs.readFile(pkgPath.path(), 'utf8')); - debug( + this.debug( ' - %s: %s', contents.name, pkgPath.path().replace(this.root.path(), '').replace('package.json', ''), @@ -241,14 +237,14 @@ export default class Packemon { if (skipPrivate) { packages = packages.filter((pkg) => !pkg.package.private); - debug('Filtering private packages: %s', privatePackageNames.join(', ')); + this.debug('Filtering private packages: %s', privatePackageNames.join(', ')); } this.packages = this.validateAndPreparePackages(packages); } - protected generateArtifacts(declarationType: DeclarationType) { - debug('Generating build artifacts for packages'); + generateArtifacts(declarationType?: DeclarationType) { + this.debug('Generating build artifacts for packages'); this.packages.forEach((pkg) => { const typesBuilds: TypesBuild[] = []; @@ -277,17 +273,23 @@ export default class Packemon { }); }); - if (declarationType !== 'none') { + if (declarationType && declarationType !== 'none') { const artifact = new TypesArtifact(pkg, typesBuilds); artifact.declarationType = declarationType; pkg.addArtifact(artifact); } - debug(' - %s: %s', pkg.getName(), pkg.artifacts.join(', ')); + this.debug(' - %s: %s', pkg.getName(), pkg.artifacts.join(', ')); }); } + protected async cleanTemporaryFiles() { + this.debug('Cleaning temporary build files'); + + await Promise.all(this.packages.map((pkg) => pkg.cleanup())); + } + protected requiresSharedLib(pkg: Package): boolean { const platformsToBuild = new Set(); let libFormatCount = 0; @@ -308,13 +310,13 @@ export default class Packemon { } protected validateAndPreparePackages(packages: WorkspacePackage[]): Package[] { - debug('Validating found packages'); + this.debug('Validating found packages'); const nextPackages: Package[] = []; packages.forEach(({ metadata, package: contents }) => { if (!contents.packemon) { - debug('No `packemon` configuration found for %s, skipping', contents.name); + this.debug('No `packemon` configuration found for %s, skipping', contents.name); return; } diff --git a/src/bin.ts b/src/bin.ts index 7a582300f..2486de0a3 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,5 +1,5 @@ import { Program, checkPackageOutdated } from '@boost/cli'; -import { BuildCommand, CleanCommand, PackCommand, ValidateCommand } from '.'; +import { BuildCommand, CleanCommand, PackCommand, ValidateCommand, WatchCommand } from '.'; const version = String(require('../package.json').version); @@ -16,7 +16,8 @@ async function run() { .register(new BuildCommand()) .register(new CleanCommand()) .register(new PackCommand()) - .register(new ValidateCommand()); + .register(new ValidateCommand()) + .register(new WatchCommand()); await program.runAndExit(process.argv); } diff --git a/src/commands/Clean.tsx b/src/commands/Clean.tsx index 7b25a4621..7b39d0b1e 100644 --- a/src/commands/Clean.tsx +++ b/src/commands/Clean.tsx @@ -1,10 +1,8 @@ import { Command, Config, GlobalOptions } from '@boost/cli'; import Packemon from '../Packemon'; -export type CleanParams = [string]; - @Config('clean', 'Clean build artifacts from packages') -export class CleanCommand extends Command { +export class CleanCommand extends Command { async run() { await new Packemon().clean(); } diff --git a/src/commands/Watch.tsx b/src/commands/Watch.tsx new file mode 100644 index 000000000..a8032e7b2 --- /dev/null +++ b/src/commands/Watch.tsx @@ -0,0 +1,102 @@ +import { applyStyle, Arg, Command, Config, GlobalOptions } from '@boost/cli'; +import { formatMs } from '@boost/common'; +import chokidar from 'chokidar'; +import Package from '../Package'; +import Packemon from '../Packemon'; + +@Config('watch', 'Watch local files for changes and rebuild') +export class WatchCommand extends Command { + @Arg.Number('Number of milliseconds to wait after a change before triggering a rebuild') + interval: number = 350; + + protected packemon!: Packemon; + + protected packagesToRebuild = new Set(); + + protected rebuilding: boolean = false; + + async run() { + const packemon = new Packemon(); + + this.packemon = packemon; + packemon.debug('Starting `watch` process'); + + // Generate all our build artifacts + await packemon.findPackages(); + await packemon.generateArtifacts(); + + // Instantiate the watcher for each package source + const watchPaths = packemon.packages.map((pkg) => pkg.path.append('src/**/*').path()); + + packemon.debug('Initializing chokidar watcher for paths:'); + packemon.debug(watchPaths.map((path) => ` - ${path}`).join('\n')); + + const watcher = chokidar.watch(watchPaths, { + ignored: /(^|[/\\])\../u, // dotfiles + ignoreInitial: true, + persistent: true, + }); + + // Rebuild when files change + watcher.on('all', this.enqueueRebuild); + + setInterval(() => { + void this.triggerPackageRebuilds(); + }, this.interval); + + this.log('Watching for changes...'); + } + + enqueueRebuild = (event: string, path: string) => { + if (event !== 'add' && event !== 'change' && event !== 'unlink') { + return; + } + + this.log(applyStyle(' - %s', 'muted'), path.replace(`${this.packemon.root.path()}/`, '')); + + const changedPkg = this.packemon.packages.find((pkg) => path.startsWith(pkg.path.path())); + + if (changedPkg) { + this.packagesToRebuild.add(changedPkg); + } + }; + + async triggerPackageRebuilds() { + if (this.rebuilding) { + return; + } + + const pkgs = Array.from(this.packagesToRebuild); + const pkgNames = pkgs.map((pkg) => pkg.getName()); + + if (pkgs.length === 0) { + return; + } + + this.packagesToRebuild.clear(); + this.rebuilding = true; + + const start = Date.now(); + + await Promise.all( + pkgs.map((pkg) => + pkg.build({ + addEngines: false, + addExports: false, + analyzeBundle: 'none', + concurrency: 1, + generateDeclaration: 'none', + skipPrivate: false, + timeout: 0, + }), + ), + ); + + this.rebuilding = false; + this.log( + applyStyle('Built %s in %s', 'success'), + pkgNames.join(', '), + formatMs(Date.now() - start), + ); + } +} diff --git a/src/index.ts b/src/index.ts index e61d65b9f..df3457106 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export * from './commands/Build'; export * from './commands/Clean'; export * from './commands/Pack'; export * from './commands/Validate'; +export * from './commands/Watch'; export * from './constants'; export * from './types'; diff --git a/yarn.lock b/yarn.lock index fdb001bd2..823804537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2635,7 +2635,7 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== -chokidar@^3.4.0: +chokidar@^3.4.0, chokidar@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== From 818010433dac34ae5c248828770a7155516f54b1 Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sun, 15 Nov 2020 14:30:29 -0800 Subject: [PATCH 2/4] Use debounce. --- src/commands/Watch.tsx | 81 +++++++++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/src/commands/Watch.tsx b/src/commands/Watch.tsx index a8032e7b2..d396d3e1f 100644 --- a/src/commands/Watch.tsx +++ b/src/commands/Watch.tsx @@ -1,5 +1,5 @@ import { applyStyle, Arg, Command, Config, GlobalOptions } from '@boost/cli'; -import { formatMs } from '@boost/common'; +import { Bind, formatMs } from '@boost/common'; import chokidar from 'chokidar'; import Package from '../Package'; import Packemon from '../Packemon'; @@ -7,7 +7,10 @@ import Packemon from '../Packemon'; @Config('watch', 'Watch local files for changes and rebuild') export class WatchCommand extends Command { @Arg.Number('Number of milliseconds to wait after a change before triggering a rebuild') - interval: number = 350; + debounce: number = 350; + + @Arg.Flag('Poll for file changes instead of using file system events') + poll: boolean = false; protected packemon!: Packemon; @@ -15,6 +18,8 @@ export class WatchCommand extends Command { protected rebuilding: boolean = false; + protected rebuildTimer?: NodeJS.Timeout; + async run() { const packemon = new Packemon(); @@ -35,15 +40,12 @@ export class WatchCommand extends Command { ignored: /(^|[/\\])\../u, // dotfiles ignoreInitial: true, persistent: true, + usePolling: this.poll, }); // Rebuild when files change watcher.on('all', this.enqueueRebuild); - setInterval(() => { - void this.triggerPackageRebuilds(); - }, this.interval); - this.log('Watching for changes...'); } @@ -58,11 +60,25 @@ export class WatchCommand extends Command { if (changedPkg) { this.packagesToRebuild.add(changedPkg); + this.triggerRebuilds(); } }; - async triggerPackageRebuilds() { + triggerRebuilds() { + if (this.rebuildTimer) { + clearTimeout(this.rebuildTimer); + } + + this.rebuildTimer = setTimeout(() => { + void this.rebuildPackages(); + }, this.debounce); + } + + @Bind() + async rebuildPackages() { if (this.rebuilding) { + this.triggerRebuilds(); + return; } @@ -76,27 +92,34 @@ export class WatchCommand extends Command { this.packagesToRebuild.clear(); this.rebuilding = true; - const start = Date.now(); - - await Promise.all( - pkgs.map((pkg) => - pkg.build({ - addEngines: false, - addExports: false, - analyzeBundle: 'none', - concurrency: 1, - generateDeclaration: 'none', - skipPrivate: false, - timeout: 0, - }), - ), - ); - - this.rebuilding = false; - this.log( - applyStyle('Built %s in %s', 'success'), - pkgNames.join(', '), - formatMs(Date.now() - start), - ); + try { + const start = Date.now(); + + await Promise.all( + pkgs.map((pkg) => + pkg.build({ + addEngines: false, + addExports: false, + analyzeBundle: 'none', + concurrency: 1, + generateDeclaration: 'none', + skipPrivate: false, + timeout: 0, + }), + ), + ); + + this.log( + applyStyle('Built %s in %s', 'success'), + pkgNames.join(', '), + formatMs(Date.now() - start), + ); + } catch (error) { + this.log.error(error.message); + + this.log(applyStyle('Failed to build %s', 'failure'), pkgNames.join(', ')); + } finally { + this.rebuilding = false; + } } } From 52711d03e0744801cbb388f5632efc992646440b Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sun, 15 Nov 2020 14:49:05 -0800 Subject: [PATCH 3/4] Update types. --- src/commands/Build.tsx | 2 +- src/commands/Validate.tsx | 2 +- src/commands/Watch.tsx | 16 ++-------------- src/components/Build.tsx | 2 +- src/components/Validate.tsx | 2 +- src/types.ts | 28 ++++++++++++++-------------- 6 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/commands/Build.tsx b/src/commands/Build.tsx index fb5fb03ac..6434910c8 100644 --- a/src/commands/Build.tsx +++ b/src/commands/Build.tsx @@ -6,7 +6,7 @@ import Packemon from '../Packemon'; import { AnalyzeType, BuildOptions, DeclarationType } from '../types'; @Config('build', 'Build standardized packages for distribution') -export class BuildCommand extends Command { +export class BuildCommand extends Command> { @Arg.Flag('Add `engine` versions to each `package.json`') addEngines: boolean = false; diff --git a/src/commands/Validate.tsx b/src/commands/Validate.tsx index 1bd6c1c91..3e0eea9ba 100644 --- a/src/commands/Validate.tsx +++ b/src/commands/Validate.tsx @@ -5,7 +5,7 @@ import { ValidateOptions } from '../types'; import Validate from '../components/Validate'; @Config('validate', 'Validate package metadata and configuration') -export class ValidateCommand extends Command { +export class ValidateCommand extends Command> { @Arg.Flag('Check that dependencies have valid versions and constraints') deps: boolean = true; diff --git a/src/commands/Watch.tsx b/src/commands/Watch.tsx index d396d3e1f..518c29b1c 100644 --- a/src/commands/Watch.tsx +++ b/src/commands/Watch.tsx @@ -7,7 +7,7 @@ import Packemon from '../Packemon'; @Config('watch', 'Watch local files for changes and rebuild') export class WatchCommand extends Command { @Arg.Number('Number of milliseconds to wait after a change before triggering a rebuild') - debounce: number = 350; + debounce: number = 150; @Arg.Flag('Poll for file changes instead of using file system events') poll: boolean = false; @@ -95,19 +95,7 @@ export class WatchCommand extends Command { try { const start = Date.now(); - await Promise.all( - pkgs.map((pkg) => - pkg.build({ - addEngines: false, - addExports: false, - analyzeBundle: 'none', - concurrency: 1, - generateDeclaration: 'none', - skipPrivate: false, - timeout: 0, - }), - ), - ); + await Promise.all(pkgs.map((pkg) => pkg.build({}))); this.log( applyStyle('Built %s in %s', 'success'), diff --git a/src/components/Build.tsx b/src/components/Build.tsx index e131548e8..ad0fbcfff 100644 --- a/src/components/Build.tsx +++ b/src/components/Build.tsx @@ -9,7 +9,7 @@ import { BuildOptions } from '../types'; import useRenderLoop from './hooks/useRenderLoop'; import useOnMount from './hooks/useOnMount'; -export interface BuildProps extends Partial { +export interface BuildProps extends BuildOptions { packemon: Packemon; onBuilt?: () => void; } diff --git a/src/components/Validate.tsx b/src/components/Validate.tsx index a9b9e062a..bc7050165 100644 --- a/src/components/Validate.tsx +++ b/src/components/Validate.tsx @@ -8,7 +8,7 @@ import { ValidateOptions } from '../types'; import useRenderLoop from './hooks/useRenderLoop'; import useOnMount from './hooks/useOnMount'; -export interface ValidateProps extends Partial { +export interface ValidateProps extends ValidateOptions { packemon: Packemon; onValidated?: () => void; } diff --git a/src/types.ts b/src/types.ts index d300189b7..438d07bce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,13 +64,13 @@ export interface PackageConfig { export type ArtifactState = 'pending' | 'building' | 'passed' | 'failed'; export interface BuildOptions { - addEngines: boolean; - addExports: boolean; - analyzeBundle: AnalyzeType; - concurrency: number; - generateDeclaration: DeclarationType; - skipPrivate: boolean; - timeout: number; + addEngines?: boolean; + addExports?: boolean; + analyzeBundle?: AnalyzeType; + concurrency?: number; + generateDeclaration?: DeclarationType; + skipPrivate?: boolean; + timeout?: number; } export interface BuildResult { @@ -93,13 +93,13 @@ export interface TypesBuild { // VALIDATE export interface ValidateOptions { - deps: boolean; - engines: boolean; - entries: boolean; - license: boolean; - links: boolean; - people: boolean; - repo: boolean; + deps?: boolean; + engines?: boolean; + entries?: boolean; + license?: boolean; + links?: boolean; + people?: boolean; + repo?: boolean; } // CONFIG From d573348db8718cb77d46280308dcf2bc4e3f9b43 Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sun, 15 Nov 2020 14:55:51 -0800 Subject: [PATCH 4/4] Add docs. --- package.json | 1 + website/docs/watch.md | 25 +++++++++++++++++++++++++ website/sidebars.js | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 website/docs/watch.md diff --git a/package.json b/package.json index 4b244c4d8..b69bd96c4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "package" ], "scripts": { + "docs": "cd website && yarn run start", "prepare": "beemo create-config --silent", "setup": "beemo typescript", "build": "yarn run packemon build", diff --git a/website/docs/watch.md b/website/docs/watch.md new file mode 100644 index 000000000..b973c76c5 --- /dev/null +++ b/website/docs/watch.md @@ -0,0 +1,25 @@ +--- +title: Watch +sidebar_label: watch +--- + +The `watch` command will monitor the local file system for any changes and trigger a +[rebuild](./build.md) for affected package(s). The watcher will only monitor files within the `src` +folder of each found package, and will rebuild using default build options. + +```json title="package.json" +{ + "scripts": { + "watch": "packemon watch" + } +} +``` + +## Options + +Watch supports the following command line options. + +- `--debounce` - Number of milliseconds to wait after a change before triggering a rebuild. Defaults + to `150`. +- `--poll` - Poll for file changes instead of using file system events. This is necessary if going + across the network or through containers. diff --git a/website/sidebars.js b/website/sidebars.js index 0f15bea7f..53ff0de15 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -10,7 +10,7 @@ module.exports = { type: 'category', label: 'Commands', collapsed: false, - items: ['build', 'clean', 'validate'], + items: ['build', 'clean', 'validate', 'watch'], }, 'advanced', {