Skip to content

Commit 698f035

Browse files
committed
ui,build: write typescript declarations to multiple directories in watch mode
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
1 parent f2e63a4 commit 698f035

File tree

6 files changed

+261
-86
lines changed

6 files changed

+261
-86
lines changed

pkg/ui/pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/ui/workspaces/cluster-ui/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ npm_link_all_packages(name = "node_modules")
1313
WEBPACK_SRCS = glob(
1414
[
1515
"src/**",
16-
"build/webpack/**",
16+
"build/**",
1717
],
1818
exclude = [
1919
"src/**/*.stories.tsx",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2023 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
const fs = require("fs");
12+
const path = require("path");
13+
14+
const argv = require("minimist")(process.argv.slice(2));
15+
const ts = require("typescript");
16+
17+
const { cleanDestinationPaths, tildeify } = require("../util");
18+
19+
/**
20+
* A minimal wrapper around tsc --watch, implemented using the typescript JS API
21+
* to support writing emitted files to multiple directories.
22+
* This function never returns, as it hosts a filesystem 'watcher'.
23+
* @params {Object} options - a bag of options
24+
* @params {string[]} options.destinations - an array of directories to emit declarations to.
25+
* The default from tsconfig.json will be automatically
26+
* prepended to this list.
27+
* @returns never
28+
* @see https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API/167d197d290bec04b626b91b6f453123ef309e58#writing-an-incremental-program-watcher
29+
*/
30+
function watch(options) {
31+
const destinations = options.destinations;
32+
33+
// Find a tsconfig.json.
34+
const configPath = ts.findConfigFile(
35+
/* searchPath */ "./",
36+
ts.sys.fileExists,
37+
"tsconfig.json",
38+
);
39+
if (!configPath) {
40+
throw new Error("Could not find a valid 'tsconfig.json'");
41+
}
42+
43+
// Create a wrapper around the default ts.sys.writeFile that also writes files
44+
// to each destination.
45+
const tsSysWriteFile = ts.sys.writeFile;
46+
/** Wraps ts.sys.writeFile to write files to multiple destinations. */
47+
function writeFile(fileName, data, writeByteOrderMark) {
48+
// First, write to the intended path. Providing a writeFile function
49+
// means TypeScript won't do this on its own.
50+
tsSysWriteFile(fileName, data, writeByteOrderMark);
51+
52+
// Get a path to fileName relative to the root of this package.
53+
// Luckily, configPath is always the absolute path to a file at the root.
54+
const relPath = path.relative(ts.sys.getCurrentDirectory(), fileName);
55+
for (const dst of destinations) {
56+
const absDstPath = path.join(dst, relPath);
57+
tsSysWriteFile(absDstPath, data, writeByteOrderMark);
58+
}
59+
}
60+
61+
// Create a watching compiler host that we'll pass to ts.createWatchProgram
62+
// later.
63+
const host = ts.createWatchCompilerHost(
64+
configPath,
65+
/* optionsToExtend */ {},
66+
{
67+
...ts.sys,
68+
writeFile,
69+
},
70+
ts.createEmitAndSemanticDiagnosticsBuilderProgram,
71+
);
72+
73+
// Create a wrapper around the default createProgram hook (called whenever a
74+
// compilation pass starts) to log a helpful message. Note that in TS 4.2,
75+
// there's no way to suppress typescript's default terminal-clearing behavior
76+
// when a program is created. To keep anyone from forgetting, print this
77+
// message every time.
78+
const origCreateProgram = host.createProgram;
79+
host.createProgram = (rootNames, options, host, oldProgram, configFileParsingDiagnostics, projectReferences) => {
80+
const compilerOptions = options || {};
81+
const currentDir = host.getCurrentDirectory();
82+
// Compute the declaration directory relative to the project root.
83+
const relDeclarationDir = path.relative(
84+
currentDir,
85+
compilerOptions.declarationDir || compilerOptions.outDir || ""
86+
);
87+
88+
console.log("Declarations will be written to:")
89+
for (const dst of ["./"].concat(destinations)) {
90+
console.log(` ${tildeify(path.join(dst, relDeclarationDir))}`);
91+
}
92+
93+
return origCreateProgram(rootNames, options, host, oldProgram, configFileParsingDiagnostics, projectReferences);
94+
}
95+
96+
// Create an initial program, watch files, and incrementally update that
97+
// program object.
98+
ts.createWatchProgram(host);
99+
}
100+
101+
const isHelp = argv.h || argv.help;
102+
const hasPositionalArgs = argv._.length !== 0;
103+
if (isHelp || hasPositionalArgs) {
104+
const argv1 = path.relative(path.join(__dirname, "../../"), argv[1]);
105+
const help = `
106+
${argv1} - a minimal replacement for 'tsc --watch' that copies generated files to extra directories.
107+
108+
Usage:
109+
${argv1} [--copy-to DIR]...
110+
111+
Flags:
112+
--copy-to DIR path to copy emitted files to, in addition to the default in
113+
tsconfig.json. Can be specified multiple times.
114+
-h, --help prints this message
115+
`;
116+
117+
if (hasPositionalArgs) {
118+
console.error("Unexpected positional arguments:", argv._);
119+
console.error();
120+
}
121+
console.error(help.trim());
122+
process.exit(hasPositionalArgs ? 1 : 0);
123+
}
124+
125+
const copyToArgs = argv["copy-to"];
126+
const destinations = typeof copyToArgs === "string"
127+
? [ copyToArgs ]
128+
: (copyToArgs || []);
129+
130+
watch({
131+
destinations: cleanDestinationPaths(destinations),
132+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright 2023 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
const fs = require("fs");
12+
const os = require("os");
13+
const path = require("path");
14+
const semver = require("semver");
15+
16+
/**
17+
* Cleans up destination paths to ensure they point to valid directories,
18+
* automatically adding node_modules/@cockroachlabs/cluster-ui-XX-Y (for the
19+
* current XX-Y in this package's package.json) if a destination isn't specific
20+
* to a single CRDB version.
21+
* @param {string[]} destinations - an array of paths
22+
* @param {Object} [logger=console] - the logger to use when reporting messages (defaults to `console`)
23+
* @returns {string[]} `destinations`, but cleaned up
24+
*/
25+
function cleanDestinationPaths(destinations, logger=console) {
26+
// Extract the major and minor versions of this cluster-ui build.
27+
const pkgVersion = getPkgVersion();
28+
29+
return destinations.map(dstOpt => {
30+
const dst = detildeify(dstOpt);
31+
32+
// The user provided paths to a specific cluster-ui version.
33+
if (dst.includes("@cockroachlabs/cluster-ui-")) {
34+
// Remove a possibly-trailing '/' literal.
35+
const dstClean = dst[dst.length - 1] === "/"
36+
? dst.slice(0, dst.length - 1)
37+
: dst;
38+
39+
return dstClean;
40+
}
41+
42+
// If the user provided a path to a project, look for a top-level
43+
// node_modules/ within that directory
44+
const dirents = fs.readdirSync(dst, { encoding: "utf-8", withFileTypes: true });
45+
for (const dirent of dirents) {
46+
if (dirent.name === "node_modules" && dirent.isDirectory()) {
47+
return path.join(
48+
dst,
49+
`./node_modules/@cockroachlabs/cluster-ui-${pkgVersion.major}-${pkgVersion.minor}`,
50+
);
51+
}
52+
}
53+
54+
const hasPnpmLock = dirents.some((dirent) => dirent.name === "pnpm-lock.yaml");
55+
if (hasPnpmLock) {
56+
logger.error(`Directory ${dst} doesn't have a node_modules directory, but does have a pnpm-lock.yaml.`);
57+
logger.error(`Do you need to run 'pnpm install' there?`);
58+
throw "missing node_modules";
59+
}
60+
61+
logger.error(`Directory ${dst} doesn't have a node_modules directory, and does not appear to be`);
62+
logger.error(`a JS package.`);
63+
throw "unknown destination";
64+
});
65+
}
66+
67+
/**
68+
* Extracts the major and minor version number from the cluster-ui package.
69+
* @returns {object} - an object containing the major (`.major`) and minor
70+
* (`.minor`) versions of the package
71+
*/
72+
function getPkgVersion() {
73+
const pkgJsonStr = fs.readFileSync(
74+
path.join(__dirname, "../../package.json"),
75+
"utf-8",
76+
);
77+
const pkgJson = JSON.parse(pkgJsonStr);
78+
const version = semver.parse(pkgJson.version);
79+
return {
80+
major: version.major,
81+
minor: version.minor,
82+
};
83+
}
84+
85+
/**
86+
* Replaces the user's home directory with '~' in the provided path. The
87+
* opposite of `detildeify`.
88+
* @param {string} path - the path to replace a home directory in
89+
* @returns {string} `path` but with the user's home directory swapped for '~'
90+
*/
91+
function tildeify(path) {
92+
return path.replace(os.homedir(), "~");
93+
}
94+
95+
/**
96+
* Replaces '~' with the user's home directory in the provided path. The
97+
* opposite of `tildeify`.
98+
* @param {string} path - the path to replace a '~' in
99+
* @returns {string} `path` but with '~' swapped for the user's home directory.
100+
*/
101+
function detildeify(path) {
102+
return path.replace("~", os.homedir());
103+
}
104+
105+
106+
module.exports = {
107+
tildeify,
108+
detildeify,
109+
cleanDestinationPaths,
110+
}

pkg/ui/workspaces/cluster-ui/build/webpack/copyEmittedFilesPlugin.js

Lines changed: 13 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const path = require("path");
1515
const semver = require("semver");
1616
const { validate } = require("schema-utils");
1717

18+
const { cleanDestinationPaths, tildeify } = require("../util");
19+
1820
const PLUGIN_NAME = `CopyEmittedFilesPlugin`;
1921

2022
const SCHEMA = {
@@ -65,48 +67,9 @@ class CopyEmittedFilesPlugin {
6567

6668
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME);
6769

68-
// Extract the major and minor versions of this cluster-ui build.
69-
const pkgVersion = getPkgVersion(compiler.context);
70-
7170
// Sanitize provided paths to ensure they point to a reasonable version of
7271
// cluster-ui.
73-
const destinations = this.options.destinations.map((dstOpt) => {
74-
const dst = detildeify(dstOpt);
75-
76-
// The user provided paths to a specific cluster-ui version.
77-
if (dst.includes("@cockroachlabs/cluster-ui-")) {
78-
// Remove a possibly-trailing '/' literal.
79-
const dstClean = dst[dst.length - 1] === "/"
80-
? dst.slice(0, dst.length - 1)
81-
: dst;
82-
83-
return dstClean;
84-
}
85-
86-
// If the user provided a path to a project, look for a top-level
87-
// node_modules/ within that directory
88-
const dirents = fs.readdirSync(dst, { encoding: "utf-8", withFileTypes: true });
89-
for (const dirent of dirents) {
90-
if (dirent.name === "node_modules" && dirent.isDirectory()) {
91-
return path.join(
92-
dst,
93-
`./node_modules/@cockroachlabs/cluster-ui-${pkgVersion.major}-${pkgVersion.minor}`,
94-
);
95-
}
96-
}
97-
98-
const hasPnpmLock = dirents.some((dirent) => dirent.name === "pnpm-lock.yaml");
99-
if (hasPnpmLock) {
100-
logger.error(`Directory ${dst} doesn't have a node_modules directory, but does have a pnpm-lock.yaml.`);
101-
logger.error(`Do you need to run 'pnpm install' there?`);
102-
throw "missing node_modules";
103-
}
104-
105-
logger.error(`Directory ${dst} doesn't have a node_modules directory, and does not appear to be`);
106-
logger.error(`a JS package.`);
107-
throw "unknown destination";
108-
});
109-
72+
const destinations = cleanDestinationPaths(this.options.destinations, logger);
11073
logger.info("Emitted files will be copied to:");
11174
for (const dst of destinations) {
11275
logger.info(" " + tildeify(dst));
@@ -119,16 +82,22 @@ class CopyEmittedFilesPlugin {
11982
compiler.hooks.afterEnvironment.tap(PLUGIN_NAME, () => {
12083
logger.warn("Deleting destinations in preparation for copied files:");
12184
for (const dst of destinations) {
122-
const prettyDst = tildeify(dst);
12385
const stat = fs.statSync(dst);
12486

12587
if (stat.isDirectory()) {
126-
logger.warn(` rm -r ${prettyDst}`);
88+
// Since the destination is already a directory, it's likely been
89+
// created by webpack or typescript already. Don't remove the entire
90+
// directory --- only remove the js subtree.
91+
const jsDir = path.join(dst, "js");
92+
logger.warn(` rm -r ${tildeify(jsDir)}`);
93+
fs.rmSync(jsDir, { recursive: true });
12794
} else {
128-
logger.warn(` rm ${prettyDst}`);
95+
// Since the destination is a symlink, just remove the single file.
96+
logger.warn(` rm ${tildeify(dst)}`);
97+
fs.rmSync(dst, { recursive: false });
12998
}
130-
fs.rmSync(dst, { recursive: stat.isDirectory() });
13199

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

@@ -156,44 +125,4 @@ class CopyEmittedFilesPlugin {
156125
}
157126
}
158127

159-
/**
160-
* Extracts the major and minor version number from the package at pkgRoot.
161-
* @param pkgRoot {string} - the absolute path to the directory that holds the
162-
* package's package.json
163-
* @returns {object} - an object containing the major (`.major`) and minor
164-
* (`.minor`) versions of the package
165-
*/
166-
function getPkgVersion(pkgRoot) {
167-
const pkgJsonStr = fs.readFileSync(
168-
path.join(pkgRoot, "package.json"),
169-
"utf-8",
170-
);
171-
const pkgJson = JSON.parse(pkgJsonStr);
172-
const version = semver.parse(pkgJson.version);
173-
return {
174-
major: version.major,
175-
minor: version.minor,
176-
};
177-
}
178-
179-
/**
180-
* Replaces the user's home directory with '~' in the provided path. The
181-
* opposite of `detildeify`.
182-
* @param {string} path - the path to replace a home directory in
183-
* @returns {string} `path` but with the user's home directory swapped for '~'
184-
*/
185-
function tildeify(path) {
186-
return path.replace(os.homedir(), "~");
187-
}
188-
189-
/**
190-
* Replaces '~' with the user's home directory in the provided path. The
191-
* opposite of `tildeify`.
192-
* @param {string} path - the path to replace a '~' in
193-
* @returns {string} `path` but with '~' swapped for the user's home directory.
194-
*/
195-
function detildeify(path) {
196-
return path.replace("~", os.homedir());
197-
}
198-
199128
module.exports.CopyEmittedFilesPlugin = CopyEmittedFilesPlugin;

0 commit comments

Comments
 (0)