From 01a4eece3c177e51d994e18bebe27a57d9e98636 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Fri, 23 Aug 2024 00:02:17 +0100 Subject: [PATCH] tarsync watch mode --- package.json | 2 +- tasks/framework-tools/tarsync/bin.mts | 84 ++++++++ .../{tarsync.mjs => tarsync/lib.mts} | 181 +++++++---------- tasks/framework-tools/tarsync/output.mts | 189 ++++++++++++++++++ tasks/framework-tools/tarsync/tarsync.mts | 64 ++++++ 5 files changed, 408 insertions(+), 112 deletions(-) create mode 100644 tasks/framework-tools/tarsync/bin.mts rename tasks/framework-tools/{tarsync.mjs => tarsync/lib.mts} (52%) create mode 100644 tasks/framework-tools/tarsync/output.mts create mode 100644 tasks/framework-tools/tarsync/tarsync.mts diff --git a/package.json b/package.json index fb00df71a9d5..cf54b8b2b804 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "project:copy": "node ./tasks/framework-tools/frameworkFilesToProject.mjs", "project:deps": "node ./tasks/framework-tools/frameworkDepsToProject.mjs", "project:sync": "node ./tasks/framework-tools/frameworkSyncToProject.mjs", - "project:tarsync": "node ./tasks/framework-tools/tarsync.mjs", + "project:tarsync": "tsx ./tasks/framework-tools/tarsync/bin.mts", "rebuild-fragments-test-project-fixture": "tsx ./tasks/test-project/rebuild-fragments-test-project-fixture.ts", "rebuild-test-project-fixture": "tsx ./tasks/test-project/rebuild-test-project-fixture.ts", "smoke-tests": "node ./tasks/smoke-tests/smoke-tests.mjs", diff --git a/tasks/framework-tools/tarsync/bin.mts b/tasks/framework-tools/tarsync/bin.mts new file mode 100644 index 000000000000..ca9ed63d3e00 --- /dev/null +++ b/tasks/framework-tools/tarsync/bin.mts @@ -0,0 +1,84 @@ +import path from 'node:path' + +import chokidar from 'chokidar' + +import { FRAMEWORK_PATH, getOptions, IGNORED } from './lib.mjs' +import { tarsync } from './tarsync.mjs' + +async function main() { + const { projectPath, watch, verbose } = await getOptions() + + await tarsync( + { + projectPath, + verbose, + }, + 'CLI invocation', + ) + + if (!watch) { + return + } + + let triggered = '' + let running = false + + const watcher = chokidar.watch(path.join(FRAMEWORK_PATH, 'packages'), { + ignored: IGNORED, + // We don't want chokidar to emit events as it discovers paths, only as they change. + ignoreInitial: true, + // Debounce the events. + awaitWriteFinish: true, + }) + watcher.on('all', async (_event, filePath) => { + if (!running) { + triggered = filePath + return + } + + // If we're already running we don't trigger on certain files which are likely to be + // touched by the build process itself. + + // Package.json files are touched when we switch to between esm and cjs builds. + if (filePath.endsWith('package.json')) { + return + } + + triggered = filePath + }) + + const monitor = setInterval(async () => { + if (triggered && !running) { + const thisTrigger = triggered + triggered = '' + running = true + try { + await tarsync( + { + projectPath, + verbose, + }, + `File change: ${path.relative(FRAMEWORK_PATH, thisTrigger)}`, + ) + } finally { + running = false + } + } + }, 100) + + let cleanedUp = false + async function cleanUp() { + if (cleanedUp) { + return + } + + await watcher.close() + clearInterval(monitor) + cleanedUp = true + } + + process.on('SIGINT', cleanUp) + process.on('exit', cleanUp) +} + +main() diff --git a/tasks/framework-tools/tarsync.mjs b/tasks/framework-tools/tarsync/lib.mts similarity index 52% rename from tasks/framework-tools/tarsync.mjs rename to tasks/framework-tools/tarsync/lib.mts index b8b4852cf4e6..ac48a50a53cd 100644 --- a/tasks/framework-tools/tarsync.mjs +++ b/tasks/framework-tools/tarsync/lib.mts @@ -1,73 +1,79 @@ -#!/usr/bin/env node - -import { performance } from 'node:perf_hooks' import { fileURLToPath } from 'node:url' import { parseArgs as nodeUtilParseArgs } from 'node:util' -import ora from 'ora' -import { cd, chalk, fs, glob, path, within, $ } from 'zx' +import { $, cd, chalk, fs, glob, path, within } from 'zx' -const FRAMEWORK_PATH = fileURLToPath(new URL('../../', import.meta.url)) -const TARBALL_DEST_DIRNAME = 'tarballs' +export const TARBALL_DEST_DIRNAME = 'tarballs' -async function main() { - const { projectPath, verbose } = await getOptions() - $.verbose = verbose +export const FRAMEWORK_PATH = fileURLToPath( + new URL('../../../', import.meta.url), +) - cd(FRAMEWORK_PATH) - performance.mark('startFramework') +export const IGNORE_EXTENSIONS = ['.DS_Store'] - const spinner = getFrameworkSpinner({ text: 'building and packing packages' }) - await buildTarballs() +/** + * Add to this array of strings, RegExps, or functions (whichever makes the most sense) + * to ignore files that we don't want triggering package rebuilds. + */ +export const IGNORED = [ + /node_modules/, - spinner.text = 'moving tarballs' - await moveTarballs(projectPath) + /packages\/codemods/, + /packages\/create-redwood-app/, - spinner.text = 'updating resolutions' - await updateResolutions(projectPath) + /dist/, - performance.mark('endFramework') - performance.measure('framework', 'startFramework', 'endFramework') - const [entry] = performance.getEntriesByName('framework') - spinner.succeed(`finished in ${(entry.duration / 1000).toFixed(2)} seconds`) + /__fixtures__/, + /__mocks__/, + /__tests__/, + /\.test\./, + /jest.config.{js,ts}/, - await yarnInstall(projectPath) + /README.md/, - const entries = performance.getEntriesByType('measure').map((entry) => { - return `• ${entry.name} => ${(entry.duration / 1000).toFixed(2)} seconds` - }) + // esbuild emits meta.json files that we sometimes suffix. + /meta.(\w*\.?)json/, - for (const entry of entries) { - verbose && console.log(entry) - } -} + /tsconfig.tsbuildinfo/, + /tsconfig.build.tsbuildinfo/, + /tsconfig.cjs.tsbuildinfo/, -main() + // The tarballs generated by `yarn build:pack` + /redwoodjs-.*\.tgz$/, -// Helpers -// ------- + (filePath: string) => IGNORE_EXTENSIONS.some((ext) => filePath.endsWith(ext)), +] -async function parseArgs() { +export interface Options { + projectPath: string + watch: boolean + verbose: boolean +} + +export async function getOptions(): Promise { const { positionals, values } = nodeUtilParseArgs({ allowPositionals: true, - options: { verbose: { type: 'boolean', default: false, short: 'v', }, + watch: { + type: 'boolean', + default: false, + short: 'w', + }, }, }) - const [projectPath] = positionals - const options = { - verbose: values.verbose, + const options: Options = { + projectPath: projectPath ?? process.env.RWJS_CWD ?? '', + watch: values.watch ?? false, + verbose: values.verbose ?? false, } - options.projectPath = projectPath ? projectPath : process.env.RWJS_CWD - if (!options.projectPath) { throw new Error( [ @@ -90,47 +96,11 @@ async function parseArgs() { return options } -async function getOptions() { - let options - - try { - options = await parseArgs() - } catch (e) { - console.error(e.message) - process.exitCode = 1 - return - } - - const { projectPath, verbose } = options - - return { - projectPath, - verbose, - } -} - -const mockSpinner = { - text: '', - succeed: () => {}, -} - -function getProjectSpinner({ text }) { - return $.verbose - ? mockSpinner - : ora({ prefixText: `${chalk.green('[ project ]')}`, text }).start() -} - -function getFrameworkSpinner({ text }) { - return $.verbose - ? mockSpinner - : ora({ prefixText: `${chalk.cyan('[framework]')}`, text }).start() -} - -async function buildTarballs() { +export async function buildTarballs() { await $`yarn nx run-many -t build:pack --exclude create-redwood-app` } -async function moveTarballs(projectPath) { +export async function moveTarballs(projectPath: string) { const tarballDest = path.join(projectPath, TARBALL_DEST_DIRNAME) await fs.ensureDir(tarballDest) @@ -145,31 +115,11 @@ async function moveTarballs(projectPath) { ) } -async function getReactResolutions() { - const packageConfig = await fs.readJson( - path.join(FRAMEWORK_PATH, 'packages/web/package.json'), - ) - - const react = packageConfig.peerDependencies.react - const reactDom = packageConfig.peerDependencies['react-dom'] - - if (!react || !reactDom) { - throw new Error( - "Couldn't find react or react-dom in @redwoodjs/web's peerDependencies", - ) - } - - return { - react, - 'react-dom': reactDom, - } -} - -async function updateResolutions(projectPath) { +export async function updateResolutions(projectPath: string) { const resolutions = (await $`yarn workspaces list --json`).stdout .trim() .split('\n') - .map(JSON.parse) + .map((line) => JSON.parse(line)) // Filter out the root workspace. .filter(({ name }) => name) .reduce((resolutions, { name }) => { @@ -201,20 +151,29 @@ async function updateResolutions(projectPath) { ) } -async function yarnInstall(projectPath) { - await within(async () => { - cd(projectPath) - performance.mark('startProject') - - const spinner = getProjectSpinner({ text: 'yarn install' }) +export async function getReactResolutions() { + const packageConfig = await fs.readJson( + path.join(FRAMEWORK_PATH, 'packages/web/package.json'), + ) - await $`yarn install` + const react = packageConfig.peerDependencies.react + const reactDom = packageConfig.peerDependencies['react-dom'] - performance.mark('endProject') - performance.measure('project', 'startProject', 'endProject') + if (!react || !reactDom) { + throw new Error( + "Couldn't find react or react-dom in @redwoodjs/web's peerDependencies", + ) + } - const [entry] = performance.getEntriesByName('project') + return { + react, + 'react-dom': reactDom, + } +} - spinner.succeed(`finished in ${(entry.duration / 1000).toFixed(2)} seconds`) +export async function yarnInstall(projectPath: string) { + await within(async () => { + cd(projectPath) + await $`yarn install` }) } diff --git a/tasks/framework-tools/tarsync/output.mts b/tasks/framework-tools/tarsync/output.mts new file mode 100644 index 000000000000..190581e1b8bd --- /dev/null +++ b/tasks/framework-tools/tarsync/output.mts @@ -0,0 +1,189 @@ +import { chalk, ProcessOutput } from 'zx' + +import { FRAMEWORK_PATH } from './lib.mjs' + +export enum Stage { + NONE = 0, + BUILD_PACK = 1, + MOVE = 2, + RESOLUTIONS = 3, + YARN = 4, + DONE = 5, +} + +export class OutputManager { + timeout?: NodeJS.Timeout + error?: unknown + stage: Stage + triggeredAt: Date + triggeredBy: string + index: number + timings: Map + running: boolean + disabled: boolean + previousLines: string[] + blinker: string + + constructor({ disabled }: { disabled: boolean }) { + this.stage = Stage.NONE + this.triggeredAt = new Date() + this.triggeredBy = 'Unknown' + this.index = 0 + this.timings = new Map() + this.running = false + this.previousLines = [] + this.blinker = '-' + + this.disabled = disabled + } + + start({ triggeredBy }: { triggeredBy: string }) { + this.triggeredBy = triggeredBy + if (this.disabled) { + return + } + + this.running = true + process.stdout.write('\n') + this.timeout = setInterval(() => { + this.render() + this.index++ + if (this.index % 10 === 0) { + this.blinker = this.blinker === '=' ? '-' : '=' + } + }, 50) + + process.on('SIGINT', () => { + this.stop() + }) + } + + stop(error?: unknown) { + if (!this.running) { + return + } + + this.error = error + + clearInterval(this.timeout) + this.running = false + this.render() + } + + switchStage(stage: Stage) { + if (this.stage === Stage.NONE) { + performance.mark('start:' + stage) + } else { + performance.mark('stop:' + this.stage) + performance.mark('start:' + stage) + + performance.measure( + this.stage + ':' + stage, + 'start:' + this.stage, + 'stop:' + this.stage, + ) + const measure = performance.getEntriesByName(this.stage + ':' + stage)[0] + this.timings.set( + this.stage, + Math.round(measure.duration).toLocaleString(), + ) + } + + this.stage = stage + } + + private generateLines() { + const lines = [ + chalk.cyan('[TarSync]'), + chalk.dim(FRAMEWORK_PATH), + chalk.dim( + `${this.triggeredAt.toLocaleTimeString()}: ${this.triggeredBy}`, + ), + '', + ] + + if (this.error) { + lines.push(chalk.red('Error:')) + lines.push(chalk.red('--- start ---')) + if (this.error instanceof ProcessOutput) { + lines.push(this.error.valueOf()) + } else { + lines.push(this.error.toString()) + } + lines.push(chalk.red('--- end ---')) + return lines + } + + lines.push( + this.getPrefix(Stage.BUILD_PACK) + + ' Building and packaging' + + this.getSuffix(Stage.BUILD_PACK), + ) + lines.push( + this.getPrefix(Stage.MOVE) + + ' Moving tarballs' + + this.getSuffix(Stage.MOVE), + ) + lines.push( + this.getPrefix(Stage.RESOLUTIONS) + + ' Updating resolutions' + + this.getSuffix(Stage.RESOLUTIONS), + ) + lines.push( + this.getPrefix(Stage.YARN) + + ' Running yarn install' + + this.getSuffix(Stage.YARN), + ) + + if (this.stage === Stage.DONE) { + const totalTime = Date.now() - this.triggeredAt.getTime() + lines.push( + chalk.green('Done!') + chalk.dim(` (${totalTime.toLocaleString()}ms)`), + ) + lines.push('') + } + + lines.push('') + + return lines + } + + private getPrefix(stage: Stage) { + const color = this.error ? chalk.red : chalk.yellow + let prefix = '[ ]' + if (this.stage === stage) { + prefix = color(`[${this.blinker}]`) + } else if (this.stage < stage) { + prefix = '[ ]' + } else if (this.stage > stage) { + prefix = chalk.green('[x]') + } + return prefix + } + + private getSuffix(stage: Stage) { + if (this.timings.has(stage)) { + return chalk.dim(` (${this.timings.get(stage)}ms)`) + } + return '' + } + + render() { + // Reset cursor to the beginning of the current line + process.stdout.write('\x1b[0G') + // Clear the current line + process.stdout.write('\x1b[2K') + + // Clear previous lines + for (let i = 0; i < this.previousLines.length - 1; i++) { + // Move the cursor up one line + process.stdout.write('\x1b[A') + // Clear the entire line + process.stdout.write('\x1b[2K') + } + + const newLines = this.generateLines() + this.previousLines = newLines + process.stdout.write(newLines.join('\n')) + } +} diff --git a/tasks/framework-tools/tarsync/tarsync.mts b/tasks/framework-tools/tarsync/tarsync.mts new file mode 100644 index 000000000000..55cf9daeb7cb --- /dev/null +++ b/tasks/framework-tools/tarsync/tarsync.mts @@ -0,0 +1,64 @@ +import { $, cd } from 'zx' + +import type { Options } from './lib.mjs' +import { + buildTarballs, + FRAMEWORK_PATH, + moveTarballs, + updateResolutions, + yarnInstall, +} from './lib.mjs' +import { OutputManager, Stage } from './output.mjs' + +export async function tarsync( + { projectPath, verbose }: Omit, + triggedBy: string, +) { + const isTTY = process.stdout.isTTY + const verboseOutput = verbose || !isTTY + $.verbose = verboseOutput + + const outputManager = new OutputManager({ + disabled: verboseOutput, + }) + outputManager.start({ + triggeredBy: triggedBy ?? 'CLI invocation', + }) + + cd(FRAMEWORK_PATH) + + outputManager.switchStage(Stage.BUILD_PACK) + try { + await buildTarballs() + } catch (error) { + outputManager.stop(error) + return + } + + outputManager.switchStage(Stage.MOVE) + try { + await moveTarballs(projectPath) + } catch (error) { + outputManager.stop(error) + return + } + + outputManager.switchStage(Stage.RESOLUTIONS) + try { + await updateResolutions(projectPath) + } catch (error) { + outputManager.stop(error) + return + } + + outputManager.switchStage(Stage.YARN) + try { + await yarnInstall(projectPath) + } catch (error) { + outputManager.stop(error) + return + } + + outputManager.switchStage(Stage.DONE) + outputManager.stop() +}