Skip to content

Commit

Permalink
Merge #107677
Browse files Browse the repository at this point in the history
107677: ui,build: write typescript declarations to multiple directories in watch mode r=sjbarag a=sjbarag

Previously, running `pnpm tsc:watch` for incremental type-checking of `cluster-ui` would only write files to `./dist/types/*`. This worked fine, but the introduction of a push model for webpack output[^1] meant it was possible to have compiled JS without declarations in some other directory. Push typescript declarations into multiple directories as well, using a small integration with the TypeScript compiler API[^2].

[^1]: 7a6ec95 (ui,build: push cluster-ui assets into external folder during watch mode, 2023-07-24)
[^2]: https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API/167d197d290bec04b626b91b6f453123ef309e58#writing-an-incremental-program-watcher

Release note: None
Epic: none

Co-authored-by: Sean Barag <[email protected]>
  • Loading branch information
craig[bot] and sjbarag committed Jul 27, 2023
2 parents f2e63a4 + 698f035 commit a14d6bb
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 86 deletions.
3 changes: 3 additions & 0 deletions pkg/ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkg/ui/workspaces/cluster-ui/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ npm_link_all_packages(name = "node_modules")
WEBPACK_SRCS = glob(
[
"src/**",
"build/webpack/**",
"build/**",
],
exclude = [
"src/**/*.stories.tsx",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2023 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

const fs = require("fs");
const path = require("path");

const argv = require("minimist")(process.argv.slice(2));
const ts = require("typescript");

const { cleanDestinationPaths, tildeify } = require("../util");

/**
* A minimal wrapper around tsc --watch, implemented using the typescript JS API
* to support writing emitted files to multiple directories.
* This function never returns, as it hosts a filesystem 'watcher'.
* @params {Object} options - a bag of options
* @params {string[]} options.destinations - an array of directories to emit declarations to.
* The default from tsconfig.json will be automatically
* prepended to this list.
* @returns never
* @see https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API/167d197d290bec04b626b91b6f453123ef309e58#writing-an-incremental-program-watcher
*/
function watch(options) {
const destinations = options.destinations;

// Find a tsconfig.json.
const configPath = ts.findConfigFile(
/* searchPath */ "./",
ts.sys.fileExists,
"tsconfig.json",
);
if (!configPath) {
throw new Error("Could not find a valid 'tsconfig.json'");
}

// Create a wrapper around the default ts.sys.writeFile that also writes files
// to each destination.
const tsSysWriteFile = ts.sys.writeFile;
/** Wraps ts.sys.writeFile to write files to multiple destinations. */
function writeFile(fileName, data, writeByteOrderMark) {
// First, write to the intended path. Providing a writeFile function
// means TypeScript won't do this on its own.
tsSysWriteFile(fileName, data, writeByteOrderMark);

// Get a path to fileName relative to the root of this package.
// Luckily, configPath is always the absolute path to a file at the root.
const relPath = path.relative(ts.sys.getCurrentDirectory(), fileName);
for (const dst of destinations) {
const absDstPath = path.join(dst, relPath);
tsSysWriteFile(absDstPath, data, writeByteOrderMark);
}
}

// Create a watching compiler host that we'll pass to ts.createWatchProgram
// later.
const host = ts.createWatchCompilerHost(
configPath,
/* optionsToExtend */ {},
{
...ts.sys,
writeFile,
},
ts.createEmitAndSemanticDiagnosticsBuilderProgram,
);

// Create a wrapper around the default createProgram hook (called whenever a
// compilation pass starts) to log a helpful message. Note that in TS 4.2,
// there's no way to suppress typescript's default terminal-clearing behavior
// when a program is created. To keep anyone from forgetting, print this
// message every time.
const origCreateProgram = host.createProgram;
host.createProgram = (rootNames, options, host, oldProgram, configFileParsingDiagnostics, projectReferences) => {
const compilerOptions = options || {};
const currentDir = host.getCurrentDirectory();
// Compute the declaration directory relative to the project root.
const relDeclarationDir = path.relative(
currentDir,
compilerOptions.declarationDir || compilerOptions.outDir || ""
);

console.log("Declarations will be written to:")
for (const dst of ["./"].concat(destinations)) {
console.log(` ${tildeify(path.join(dst, relDeclarationDir))}`);
}

return origCreateProgram(rootNames, options, host, oldProgram, configFileParsingDiagnostics, projectReferences);
}

// Create an initial program, watch files, and incrementally update that
// program object.
ts.createWatchProgram(host);
}

const isHelp = argv.h || argv.help;
const hasPositionalArgs = argv._.length !== 0;
if (isHelp || hasPositionalArgs) {
const argv1 = path.relative(path.join(__dirname, "../../"), argv[1]);
const help = `
${argv1} - a minimal replacement for 'tsc --watch' that copies generated files to extra directories.
Usage:
${argv1} [--copy-to DIR]...
Flags:
--copy-to DIR path to copy emitted files to, in addition to the default in
tsconfig.json. Can be specified multiple times.
-h, --help prints this message
`;

if (hasPositionalArgs) {
console.error("Unexpected positional arguments:", argv._);
console.error();
}
console.error(help.trim());
process.exit(hasPositionalArgs ? 1 : 0);
}

const copyToArgs = argv["copy-to"];
const destinations = typeof copyToArgs === "string"
? [ copyToArgs ]
: (copyToArgs || []);

watch({
destinations: cleanDestinationPaths(destinations),
});
110 changes: 110 additions & 0 deletions pkg/ui/workspaces/cluster-ui/build/util/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2023 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

const fs = require("fs");
const os = require("os");
const path = require("path");
const semver = require("semver");

/**
* Cleans up destination paths to ensure they point to valid directories,
* automatically adding node_modules/@cockroachlabs/cluster-ui-XX-Y (for the
* current XX-Y in this package's package.json) if a destination isn't specific
* to a single CRDB version.
* @param {string[]} destinations - an array of paths
* @param {Object} [logger=console] - the logger to use when reporting messages (defaults to `console`)
* @returns {string[]} `destinations`, but cleaned up
*/
function cleanDestinationPaths(destinations, logger=console) {
// Extract the major and minor versions of this cluster-ui build.
const pkgVersion = getPkgVersion();

return destinations.map(dstOpt => {
const dst = detildeify(dstOpt);

// The user provided paths to a specific cluster-ui version.
if (dst.includes("@cockroachlabs/cluster-ui-")) {
// Remove a possibly-trailing '/' literal.
const dstClean = dst[dst.length - 1] === "/"
? dst.slice(0, dst.length - 1)
: dst;

return dstClean;
}

// If the user provided a path to a project, look for a top-level
// node_modules/ within that directory
const dirents = fs.readdirSync(dst, { encoding: "utf-8", withFileTypes: true });
for (const dirent of dirents) {
if (dirent.name === "node_modules" && dirent.isDirectory()) {
return path.join(
dst,
`./node_modules/@cockroachlabs/cluster-ui-${pkgVersion.major}-${pkgVersion.minor}`,
);
}
}

const hasPnpmLock = dirents.some((dirent) => dirent.name === "pnpm-lock.yaml");
if (hasPnpmLock) {
logger.error(`Directory ${dst} doesn't have a node_modules directory, but does have a pnpm-lock.yaml.`);
logger.error(`Do you need to run 'pnpm install' there?`);
throw "missing node_modules";
}

logger.error(`Directory ${dst} doesn't have a node_modules directory, and does not appear to be`);
logger.error(`a JS package.`);
throw "unknown destination";
});
}

/**
* Extracts the major and minor version number from the cluster-ui package.
* @returns {object} - an object containing the major (`.major`) and minor
* (`.minor`) versions of the package
*/
function getPkgVersion() {
const pkgJsonStr = fs.readFileSync(
path.join(__dirname, "../../package.json"),
"utf-8",
);
const pkgJson = JSON.parse(pkgJsonStr);
const version = semver.parse(pkgJson.version);
return {
major: version.major,
minor: version.minor,
};
}

/**
* Replaces the user's home directory with '~' in the provided path. The
* opposite of `detildeify`.
* @param {string} path - the path to replace a home directory in
* @returns {string} `path` but with the user's home directory swapped for '~'
*/
function tildeify(path) {
return path.replace(os.homedir(), "~");
}

/**
* Replaces '~' with the user's home directory in the provided path. The
* opposite of `tildeify`.
* @param {string} path - the path to replace a '~' in
* @returns {string} `path` but with '~' swapped for the user's home directory.
*/
function detildeify(path) {
return path.replace("~", os.homedir());
}


module.exports = {
tildeify,
detildeify,
cleanDestinationPaths,
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const path = require("path");
const semver = require("semver");
const { validate } = require("schema-utils");

const { cleanDestinationPaths, tildeify } = require("../util");

const PLUGIN_NAME = `CopyEmittedFilesPlugin`;

const SCHEMA = {
Expand Down Expand Up @@ -65,48 +67,9 @@ class CopyEmittedFilesPlugin {

const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);

// Extract the major and minor versions of this cluster-ui build.
const pkgVersion = getPkgVersion(compiler.context);

// Sanitize provided paths to ensure they point to a reasonable version of
// cluster-ui.
const destinations = this.options.destinations.map((dstOpt) => {
const dst = detildeify(dstOpt);

// The user provided paths to a specific cluster-ui version.
if (dst.includes("@cockroachlabs/cluster-ui-")) {
// Remove a possibly-trailing '/' literal.
const dstClean = dst[dst.length - 1] === "/"
? dst.slice(0, dst.length - 1)
: dst;

return dstClean;
}

// If the user provided a path to a project, look for a top-level
// node_modules/ within that directory
const dirents = fs.readdirSync(dst, { encoding: "utf-8", withFileTypes: true });
for (const dirent of dirents) {
if (dirent.name === "node_modules" && dirent.isDirectory()) {
return path.join(
dst,
`./node_modules/@cockroachlabs/cluster-ui-${pkgVersion.major}-${pkgVersion.minor}`,
);
}
}

const hasPnpmLock = dirents.some((dirent) => dirent.name === "pnpm-lock.yaml");
if (hasPnpmLock) {
logger.error(`Directory ${dst} doesn't have a node_modules directory, but does have a pnpm-lock.yaml.`);
logger.error(`Do you need to run 'pnpm install' there?`);
throw "missing node_modules";
}

logger.error(`Directory ${dst} doesn't have a node_modules directory, and does not appear to be`);
logger.error(`a JS package.`);
throw "unknown destination";
});

const destinations = cleanDestinationPaths(this.options.destinations, logger);
logger.info("Emitted files will be copied to:");
for (const dst of destinations) {
logger.info(" " + tildeify(dst));
Expand All @@ -119,16 +82,22 @@ class CopyEmittedFilesPlugin {
compiler.hooks.afterEnvironment.tap(PLUGIN_NAME, () => {
logger.warn("Deleting destinations in preparation for copied files:");
for (const dst of destinations) {
const prettyDst = tildeify(dst);
const stat = fs.statSync(dst);

if (stat.isDirectory()) {
logger.warn(` rm -r ${prettyDst}`);
// Since the destination is already a directory, it's likely been
// created by webpack or typescript already. Don't remove the entire
// directory --- only remove the js subtree.
const jsDir = path.join(dst, "js");
logger.warn(` rm -r ${tildeify(jsDir)}`);
fs.rmSync(jsDir, { recursive: true });
} else {
logger.warn(` rm ${prettyDst}`);
// Since the destination is a symlink, just remove the single file.
logger.warn(` rm ${tildeify(dst)}`);
fs.rmSync(dst, { recursive: false });
}
fs.rmSync(dst, { recursive: stat.isDirectory() });

// Ensure the destination directory and package.json exist.
logger.debug(`mkdir -p ${path.join(dst, relOutputPath)}`);
fs.mkdirSync(path.join(dst, relOutputPath), { recursive: true });

Expand Down Expand Up @@ -156,44 +125,4 @@ class CopyEmittedFilesPlugin {
}
}

/**
* Extracts the major and minor version number from the package at pkgRoot.
* @param pkgRoot {string} - the absolute path to the directory that holds the
* package's package.json
* @returns {object} - an object containing the major (`.major`) and minor
* (`.minor`) versions of the package
*/
function getPkgVersion(pkgRoot) {
const pkgJsonStr = fs.readFileSync(
path.join(pkgRoot, "package.json"),
"utf-8",
);
const pkgJson = JSON.parse(pkgJsonStr);
const version = semver.parse(pkgJson.version);
return {
major: version.major,
minor: version.minor,
};
}

/**
* Replaces the user's home directory with '~' in the provided path. The
* opposite of `detildeify`.
* @param {string} path - the path to replace a home directory in
* @returns {string} `path` but with the user's home directory swapped for '~'
*/
function tildeify(path) {
return path.replace(os.homedir(), "~");
}

/**
* Replaces '~' with the user's home directory in the provided path. The
* opposite of `tildeify`.
* @param {string} path - the path to replace a '~' in
* @returns {string} `path` but with '~' swapped for the user's home directory.
*/
function detildeify(path) {
return path.replace("~", os.homedir());
}

module.exports.CopyEmittedFilesPlugin = CopyEmittedFilesPlugin;
Loading

0 comments on commit a14d6bb

Please sign in to comment.