mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
967 lines
34 KiB
JavaScript
967 lines
34 KiB
JavaScript
import assert from "assert";
|
|
import {WatchSet, readAndWatchFile, sha1} from '../fs/watch';
|
|
import files, {
|
|
symlinkWithOverwrite, realpath,
|
|
} from '../fs/files';
|
|
import NpmDiscards from './npm-discards';
|
|
import {Profile} from '../tool-env/profile';
|
|
import {
|
|
optimisticReadFile,
|
|
optimisticReaddir,
|
|
optimisticStatOrNull,
|
|
optimisticLStatOrNull,
|
|
optimisticHashOrNull,
|
|
} from "../fs/optimistic";
|
|
|
|
// Builder is in charge of writing "bundles" to disk, which are
|
|
// directory trees such as site archives, programs, and packages. In
|
|
// addition to writing data to files, it can copy or link in existing
|
|
// files and directories (keeping track of them in a WatchSet in order
|
|
// to trigger rebuilds appropriately).
|
|
//
|
|
// By default, Builder constructs the entire output directory from
|
|
// scratch under a temporary name, and then moves it into place.
|
|
// For efficient rebuilds, Builder can be given a `previousBuilder`,
|
|
// in which case it will write files into the existing output directory
|
|
// instead.
|
|
//
|
|
// On Windows (or when METEOR_DISABLE_BUILDER_IN_PLACE is set), Builder
|
|
// always creates a new output directory under a temporary name rather than
|
|
// using the old directory. The reason is that we don't want rebuilding to
|
|
// interfere with the running app, and we rely on the fact that on OS X and
|
|
// Linux, if the process has opened a file for reading, it retains the file
|
|
// by its inode, not path, so it is safe to write a new file to the same path
|
|
// (or delete the file).
|
|
//
|
|
// Separate from that, Builder has a strategy of writing files under a temporary
|
|
// name and then renaming them. This is to achieve an "atomic" write, meaning
|
|
// the server doesn't see a partially-written file that appears truncated.
|
|
//
|
|
// On Windows we copy files instead of symlinking them (see comments inline).
|
|
|
|
|
|
// Whether to support writing files into the same directory as a previous
|
|
// Builder on rebuild (rather than creating a new build directory and
|
|
// moving it into place).
|
|
const ENABLE_IN_PLACE_BUILDER_REPLACEMENT =
|
|
(process.platform !== 'win32') &&
|
|
! process.env.METEOR_DISABLE_BUILDER_IN_PLACE;
|
|
|
|
|
|
// Options:
|
|
// - outputPath: Required. Path to the directory that will hold the
|
|
// bundle when building is complete. It should not exist (unless
|
|
// previousBuilder is passed). Its parents will be created if necessary.
|
|
// - previousBuilder: Optional. An in-memory instance of Builder left
|
|
// from the previous iteration. It is assumed that the previous builder
|
|
// has completed its job successfully and its files are stored on the
|
|
// file system in the exact layout as described in its usedAsFile data
|
|
// structure; and the hashes of the contents correspond to the
|
|
// writtenHashes data strcture.
|
|
export default class Builder {
|
|
constructor({
|
|
outputPath,
|
|
previousBuilder,
|
|
// Even though in-place builds are disabled by default on some
|
|
// platforms (Windows), they can be forcibly reenabled with this
|
|
// option, in cases where it's safe and/or necessary to avoid
|
|
// clobbering existing files.
|
|
forceInPlaceBuild = false,
|
|
}) {
|
|
this.outputPath = outputPath;
|
|
|
|
// Paths already written to. Map from canonicalized relPath (no
|
|
// trailing slash) to true for a file, or false for a directory.
|
|
this.usedAsFile = { '': false, '.': false };
|
|
this.previousUsedAsFile = {};
|
|
|
|
this.writtenHashes = {};
|
|
this.createdSymlinks = {};
|
|
this.previousWrittenHashes = {};
|
|
this.previousCreatedSymlinks = {};
|
|
|
|
// foo/bar => foo/.build1234.bar
|
|
// Should we include a random number? The advantage is that multiple
|
|
// builds can run in parallel. The disadvantage is that stale build
|
|
// files hang around forever. For now, go with the former.
|
|
const nonce = Math.floor(Math.random() * 999999);
|
|
this.buildPath = files.pathJoin(files.pathDirname(this.outputPath),
|
|
'.build' + nonce + "." +
|
|
files.pathBasename(this.outputPath));
|
|
|
|
let resetBuildPath = true;
|
|
|
|
// If we have a previous builder and we are allowed to re-use it,
|
|
// let's keep all the older files on the file-system and replace
|
|
// only outdated ones + write the new files in the same path
|
|
if (previousBuilder &&
|
|
(forceInPlaceBuild || ENABLE_IN_PLACE_BUILDER_REPLACEMENT)) {
|
|
if (previousBuilder.outputPath !== outputPath) {
|
|
throw new Error(
|
|
`previousBuilder option can only be set to a builder with the same output path.
|
|
Previous builder: ${previousBuilder.outputPath}, this builder: ${outputPath}`
|
|
);
|
|
}
|
|
|
|
if (files.exists(previousBuilder.outputPath)) {
|
|
// write files in-place in the output directory of the previous builder
|
|
this.buildPath = previousBuilder.outputPath;
|
|
|
|
this.previousWrittenHashes = previousBuilder.writtenHashes;
|
|
this.previousUsedAsFile = previousBuilder.usedAsFile;
|
|
this.previousCreatedSymlinks = previousBuilder.createdSymlinks;
|
|
|
|
resetBuildPath = false;
|
|
} else {
|
|
resetBuildPath = true;
|
|
}
|
|
}
|
|
|
|
// Build the output from scratch
|
|
if (resetBuildPath) {
|
|
files.rm_recursive(this.buildPath);
|
|
files.mkdir_p(this.buildPath, 0o755);
|
|
}
|
|
|
|
this.watchSet = new WatchSet();
|
|
|
|
// XXX cleaner error handling. don't make the humans read an
|
|
// exception (and, make suitable for use in automated systems)
|
|
}
|
|
|
|
// Like mkdir_p, but records in self.usedAsFile that we have created
|
|
// the directories, and takes a path relative to the bundle
|
|
// root. Throws an exception on failure.
|
|
_ensureDirectory(relPath) {
|
|
const parts = files.pathNormalize(relPath).split(files.pathSep);
|
|
if (parts.length > 1 && parts[parts.length - 1] === '') {
|
|
// remove trailing slash
|
|
parts.pop();
|
|
}
|
|
|
|
const partsSoFar = [];
|
|
parts.forEach(part => {
|
|
partsSoFar.push(part);
|
|
const partial = partsSoFar.join(files.pathSep);
|
|
if (! (partial in this.usedAsFile)) {
|
|
let needToMkdir = true;
|
|
if (partial in this.previousUsedAsFile) {
|
|
if (this.previousUsedAsFile[partial]) {
|
|
// was previously used as file, delete it, create a directory
|
|
try {
|
|
files.unlink(partial);
|
|
} catch (e) {
|
|
// If files.unlink(partial) failed because the file does not
|
|
// exist, then we can just pretend the unlink succeeded.
|
|
if (e.code !== "ENOENT") {
|
|
throw e;
|
|
}
|
|
}
|
|
} else {
|
|
// is already a directory
|
|
needToMkdir = false;
|
|
}
|
|
}
|
|
|
|
if (needToMkdir) {
|
|
// It's new -- create it
|
|
files.mkdir_p(files.pathJoin(this.buildPath, partial), 0o755);
|
|
}
|
|
this.usedAsFile[partial] = false;
|
|
} else if (this.usedAsFile[partial]) {
|
|
// Already exists and is a file. Oops.
|
|
throw new Error(`tried to make ${relPath} a directory but ${partial} is already a file`);
|
|
} else {
|
|
// Already exists and is a directory
|
|
}
|
|
});
|
|
}
|
|
|
|
// isDirectory defaults to false
|
|
_sanitize(relPath, isDirectory) {
|
|
const parts = relPath.split(files.pathSep);
|
|
const partsOut = [];
|
|
for (let i = 0; i < parts.length; i++) {
|
|
let part = parts[i];
|
|
const shouldBeFile = (i === parts.length - 1) && ! isDirectory;
|
|
const mustBeUnique = (i === parts.length - 1);
|
|
|
|
// Basic sanitization
|
|
if (part.match(/^\.+$/)) {
|
|
throw new Error(`Path contains forbidden segment '${part}'`);
|
|
}
|
|
|
|
part = part.replace(/[^a-zA-Z0-9._\:\-@#]/g, '_');
|
|
|
|
// If at last component, pull extension (if any) off of part
|
|
let ext = '';
|
|
if (shouldBeFile) {
|
|
const split = part.split('.');
|
|
if (split.length > 1) {
|
|
ext = "." + split.pop();
|
|
}
|
|
part = split.join('.');
|
|
}
|
|
|
|
// Make sure it's sufficiently unique
|
|
let suffix = '';
|
|
while (true) {
|
|
const candidate = files.pathJoin(partsOut.join(files.pathSep), part + suffix + ext);
|
|
if (candidate.length) {
|
|
// If we've never heard of this, then it's unique enough.
|
|
if (!(candidate in this.usedAsFile)) {
|
|
break;
|
|
}
|
|
// If we want this bit to be a directory, and we don't need it to be
|
|
// unique (ie, it isn't the very last bit), and it's currently a
|
|
// directory, then that's OK.
|
|
if (!(mustBeUnique || this.usedAsFile[candidate])) {
|
|
break;
|
|
}
|
|
// OK, either we want it to be unique and it already exists; or it is
|
|
// currently a file (and we want it to be either a different file or a
|
|
// directory). Try a new suffix.
|
|
}
|
|
|
|
suffix++; // first increment will do '' -> 1
|
|
}
|
|
|
|
partsOut.push(part + suffix + ext);
|
|
}
|
|
|
|
return partsOut.join(files.pathSep);
|
|
}
|
|
|
|
// Checks if a file with the same path and hash was written by
|
|
// the previous builder. If it was, it adds it to the cache and makes
|
|
// sure the parent directories exist and are part of the cache.
|
|
//
|
|
// Returns true if the file was already written
|
|
usePreviousWrite(relPath, hash, sanitize) {
|
|
relPath = this._normalizeFilePath(relPath, sanitize);
|
|
|
|
if (this.previousWrittenHashes[relPath] === hash) {
|
|
this._ensureDirectory(files.pathDirname(relPath));
|
|
this.writtenHashes[relPath] = hash;
|
|
this.usedAsFile[relPath] = true;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
_normalizeFilePath(relPath, sanitize) {
|
|
// Ensure no trailing slash
|
|
if (relPath.slice(-1) === files.pathSep) {
|
|
relPath = relPath.slice(0, -1);
|
|
}
|
|
|
|
// In sanitize mode, ensure path does not contain segments like
|
|
// '..', does not contain forbidden characters, and is unique.
|
|
if (sanitize) {
|
|
relPath = this._sanitize(relPath);
|
|
}
|
|
|
|
return relPath;
|
|
}
|
|
|
|
// Write either a buffer or the contents of a file to `relPath` (a
|
|
// path to a file relative to the bundle root), creating it (and any
|
|
// enclosing directories) if it doesn't exist yet. Exactly one of
|
|
// `data` and or `file` must be passed.
|
|
//
|
|
// Options:
|
|
// - data: a Buffer to write to relPath. Overrides `file`.
|
|
// - file: a filename to write to relPath, as a string.
|
|
// - sanitize: if true, then all components of the path are stripped
|
|
// of any potentially troubling characters, an exception is thrown
|
|
// if any path segments consist entirely of dots (eg, '..'), and
|
|
// if there is a file in the bundle with the same relPath, then
|
|
// the path is changed by adding a numeric suffix.
|
|
// - hash: a sha1 string used to determine if the contents of the
|
|
// new file written is not cached.
|
|
// - executable: if true, mark the file as executable.
|
|
// - symlink: if set to a string, create a symlink to its value
|
|
//
|
|
// Returns the final canonicalize relPath that was written to.
|
|
//
|
|
// If `file` is used then it will be added to the builder's WatchSet.
|
|
write(relPath, {data, file, hash, sanitize, executable, symlink}) {
|
|
relPath = this._normalizeFilePath(relPath, sanitize);
|
|
|
|
let getData = null;
|
|
if (data) {
|
|
if (! (data instanceof Buffer)) {
|
|
throw new Error("data must be a Buffer");
|
|
}
|
|
if (file) {
|
|
throw new Error("May only pass one of data and file, not both");
|
|
}
|
|
getData = () => data;
|
|
} else if (file) {
|
|
// postpone reading the file into memory
|
|
getData = () => readAndWatchFile(this.watchSet, files.pathResolve(file));
|
|
} else if (! symlink) {
|
|
throw new Error('Builder can not write without either data or a file path or a symlink path: ' + relPath);
|
|
}
|
|
|
|
this._ensureDirectory(files.pathDirname(relPath));
|
|
const absPath = files.pathJoin(this.buildPath, relPath);
|
|
|
|
if (symlink) {
|
|
symlinkWithOverwrite(symlink, absPath);
|
|
} else {
|
|
hash = hash || sha1(getData());
|
|
|
|
// Write is called multiple times for assets when they have multiple urls for the same file
|
|
if (this.previousWrittenHashes[relPath] !== hash && this.writtenHashes[relPath] !== hash) {
|
|
|
|
// Builder is used to create build products, which should be read-only;
|
|
// users shouldn't be manually editing automatically generated files and
|
|
// expecting the results to "stick".
|
|
const mode = executable ? 0o555 : 0o444
|
|
|
|
if (this.buildPath === this.outputPath || this.writtenHashes[relPath]) {
|
|
// atomicallyRewriteFile handles overwriting files that have already been created
|
|
atomicallyRewriteFile(absPath, getData(), {
|
|
mode
|
|
});
|
|
} else {
|
|
// Since builder is not updating in place, and
|
|
// this build is only used if every file is successfully written,
|
|
// it is not important to write atomically.
|
|
files.writeFile(absPath, getData(), {
|
|
mode
|
|
})
|
|
}
|
|
}
|
|
|
|
this.writtenHashes[relPath] = hash;
|
|
}
|
|
this.usedAsFile[relPath] = true;
|
|
|
|
return relPath;
|
|
}
|
|
|
|
copyTranspiledModules(relativePaths, {
|
|
sourceRootDir,
|
|
targetRootDir = this.outputPath,
|
|
needToTranspile = files.inCheckout(),
|
|
}) {
|
|
if (!needToTranspile) {
|
|
// If these files have already been transpiled, copy the transpiled files
|
|
// (both .js and .js.map) directly to the builder output directory, without
|
|
// recompiling them.
|
|
relativePaths.forEach(relPath => {
|
|
const jsPath = jsToTs(relPath);
|
|
[jsPath, jsPath + ".map"].forEach(path => {
|
|
this.write(path, {
|
|
file: files.pathJoin(sourceRootDir, path),
|
|
});
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
const babel = require("meteor-babel");
|
|
const commonBabelOptions = babel.getDefaultOptions({
|
|
nodeMajorVersion: parseInt(process.versions.node),
|
|
typescript: true
|
|
});
|
|
commonBabelOptions.sourceMaps = true;
|
|
|
|
const toolsDir = files.getCurrentToolsDir();
|
|
const babelCacheDirectory =
|
|
files.pathJoin(files.pathDirname(toolsDir), ".babel-cache");
|
|
|
|
relativePaths.forEach(relPath => {
|
|
assert.ok(!files.pathIsAbsolute(relPath), relPath);
|
|
const fullPath = files.pathJoin(sourceRootDir, relPath);
|
|
let inputFileContents = files.readFile(fullPath, "utf-8");
|
|
|
|
// If certain behavior should be disabled in the transpiled code, the
|
|
// #RemoveInProd comment can be added to strip out appropriate lines.
|
|
inputFileContents = inputFileContents.replace(/^.*#RemoveInProd.*$/mg, "");
|
|
|
|
var transpiled = babel.compile(inputFileContents, {
|
|
...commonBabelOptions,
|
|
filename: relPath,
|
|
sourceFileName: "/" + relPath,
|
|
}, {
|
|
cacheDirectory: babelCacheDirectory,
|
|
});
|
|
|
|
// The published implementation of the meteor-tool package should
|
|
// contain only .js files, like any compiled TypeScript project.
|
|
// This design has the unfortunate consequence of forbidding
|
|
// explicit .ts file extensions in imported module identifier
|
|
// strings, but that's just how it goes with TypeScript.
|
|
let outputPath = jsToTs(relPath);
|
|
|
|
const sourceMapUrlComment =
|
|
"//# sourceMappingURL=" + files.pathBasename(outputPath + ".map");
|
|
|
|
this.write(outputPath, {
|
|
data: Buffer.from(transpiled.code + "\n" + sourceMapUrlComment, 'utf8')
|
|
});
|
|
|
|
// The babelOptions.sourceMapTarget option was deprecated in Babel
|
|
// 7.0.0-beta.41: https://github.com/babel/babel/pull/7500
|
|
const sourceMapTarget = outputPath + ".map";
|
|
transpiled.map.file = sourceMapTarget;
|
|
|
|
this.write(sourceMapTarget, {
|
|
data: Buffer.from(JSON.stringify(transpiled.map), 'utf8')
|
|
});
|
|
});
|
|
}
|
|
|
|
// Serialize `data` as JSON and write it to `relPath` (a path to a
|
|
// file relative to the bundle root), creating parent directories as
|
|
// necessary. Throw an exception if the file already exists.
|
|
writeJson(relPath, data) {
|
|
// Ensure no trailing slash
|
|
if (relPath.slice(-1) === files.pathSep) {
|
|
relPath = relPath.slice(0, -1);
|
|
}
|
|
|
|
this._ensureDirectory(files.pathDirname(relPath));
|
|
const absPath = files.pathJoin(this.buildPath, relPath);
|
|
|
|
atomicallyRewriteFile(
|
|
absPath,
|
|
Buffer.from(JSON.stringify(data, null, 2), 'utf8'),
|
|
{mode: 0o444});
|
|
|
|
this.usedAsFile[relPath] = true;
|
|
}
|
|
|
|
// Add relPath to the list of "already taken" paths in the
|
|
// bundle. This will cause write, when in sanitize mode, to never
|
|
// pick this filename (and will prevent files that from being
|
|
// written that would conflict with paths that we are expecting to
|
|
// be directories). Calling this twice on the same relPath will
|
|
// given an exception.
|
|
//
|
|
// Returns the *current* (temporary!) path to where the file or directory
|
|
// lives. This is so you could use non-builder code to write into a reserved
|
|
// directory.
|
|
//
|
|
// options:
|
|
// - directory: set to true to reserve this relPath to be a
|
|
// directory rather than a file.
|
|
reserve(relPath, {directory} = {}) {
|
|
// Ensure no trailing slash
|
|
if (relPath.slice(-1) === files.pathSep) {
|
|
relPath = relPath.slice(0, -1);
|
|
}
|
|
|
|
const parts = relPath.split(files.pathSep);
|
|
const partsSoFar = [];
|
|
for (let i = 0; i < parts.length; i ++) {
|
|
const part = parts[i];
|
|
partsSoFar.push(part);
|
|
const soFar = partsSoFar.join(files.pathSep);
|
|
if (this.usedAsFile[soFar]) {
|
|
throw new Error("Path reservation conflict: " + relPath);
|
|
}
|
|
|
|
const shouldBeDirectory = (i < parts.length - 1) || directory;
|
|
if (shouldBeDirectory) {
|
|
if (! (soFar in this.usedAsFile)) {
|
|
let needToMkdir = true;
|
|
if (soFar in this.previousUsedAsFile) {
|
|
if (this.previousUsedAsFile[soFar]) {
|
|
files.unlink(soFar);
|
|
} else {
|
|
needToMkdir = false;
|
|
}
|
|
}
|
|
if (needToMkdir) {
|
|
files.mkdir_p(files.pathJoin(this.buildPath, soFar), 0o755);
|
|
}
|
|
this.usedAsFile[soFar] = false;
|
|
}
|
|
} else {
|
|
this.usedAsFile[soFar] = true;
|
|
}
|
|
}
|
|
|
|
// Return the path we reserved.
|
|
return files.pathJoin(this.buildPath, relPath);
|
|
}
|
|
|
|
// Generate and reserve a unique name for a file based on `relPath`,
|
|
// and return it. If `relPath` is available (there is no file with
|
|
// that name currently existing or reserved, it doesn't contain
|
|
// forbidden characters, a prefix of it is not already in use as a
|
|
// file rather than a directory) then the return value will be
|
|
// `relPath`. Otherwise relPath will be modified to get the return
|
|
// value, say by adding a numeric suffix to some path components
|
|
// (preserving the file extension however) and deleting forbidden
|
|
// characters. Throws an exception if relPath contains any segments
|
|
// that are all dots (eg, '..').
|
|
//
|
|
// options:
|
|
//
|
|
// - directory: generate (and reserve) a name for a directory,
|
|
// rather than a file.
|
|
generateFilename(relPath, {directory} = {}) {
|
|
relPath = this._sanitize(relPath, directory);
|
|
this.reserve(relPath, { directory });
|
|
return relPath;
|
|
}
|
|
|
|
// Convenience wrapper around generateFilename and write.
|
|
//
|
|
// (Note that in the object returned by builder.enter, this method
|
|
// is patched through directly rather than rewriting its inputs and
|
|
// outputs. This is only valid because it does nothing with its inputs
|
|
// and outputs other than send pass them to other methods.)
|
|
writeToGeneratedFilename(relPath, writeOptions) {
|
|
const generated = this.generateFilename(relPath);
|
|
this.write(generated, writeOptions);
|
|
return generated;
|
|
}
|
|
|
|
// A version of copyDirectory that works better for copying node_modules
|
|
// directories when symlinks are involved.
|
|
copyNodeModulesDirectory(options) {
|
|
// Although the options.from directory should probably be a
|
|
// node_modules directory, the only essential precondition here is
|
|
// that the destination directory is a node_modules directory.
|
|
// assert.strictEqual(files.pathBasename(options.from), "node_modules");
|
|
assert.strictEqual(files.pathBasename(options.to), "node_modules");
|
|
|
|
if (options.symlink) {
|
|
// If we're going to use symlinks to speed up this copy, then we
|
|
// need to make sure we've reserved all directories that are not
|
|
// package directories, such as the node_modules directory itself,
|
|
// as well as node_modules/meteor and the parent directories of any
|
|
// scoped npm packages.
|
|
this._ensureAllNonPackageDirectories(
|
|
realpath(options.from),
|
|
options.to
|
|
);
|
|
}
|
|
|
|
// Call this._copyDirectory rather than this.copyDirectory so that the
|
|
// subBuilder hacks from Builder#enter won't apply a second time.
|
|
return this._copyDirectory(options);
|
|
}
|
|
|
|
_ensureAllNonPackageDirectories(absFromDir, relToDir) {
|
|
const dirStat = optimisticStatOrNull(absFromDir);
|
|
if (! (dirStat && dirStat.isDirectory())) {
|
|
return;
|
|
}
|
|
|
|
const absFromPackageJson =
|
|
files.pathJoin(absFromDir, "package.json");
|
|
|
|
const stat = optimisticStatOrNull(absFromPackageJson);
|
|
if (stat && stat.isFile()) {
|
|
// If the directory has a package.json file, it's a package
|
|
// directory, and we should not call this._ensureDirectory, so that
|
|
// the package directory can later be symlinked in copyDirectory.
|
|
return;
|
|
}
|
|
|
|
this._ensureDirectory(relToDir);
|
|
|
|
optimisticReaddir(absFromDir).forEach(item => {
|
|
this._ensureAllNonPackageDirectories(
|
|
files.pathJoin(absFromDir, item),
|
|
files.pathJoin(relToDir, item)
|
|
);
|
|
});
|
|
}
|
|
|
|
// Recursively copy a directory and all of its contents into the
|
|
// bundle. But if the symlink option was passed to the Builder
|
|
// constructor, then make a symlink instead, if possible.
|
|
//
|
|
// Unlike with files.cp_r, if a symlink is found, it is copied as a symlink.
|
|
//
|
|
// This does NOT add anything to the WatchSet.
|
|
//
|
|
// Options:
|
|
// - from: source path on local disk to copy from
|
|
// - to: relative path to a directory in the bundle that will
|
|
// receive the files
|
|
// - ignore: array of regexps of filenames (that is, basenames) to
|
|
// ignore (they may still be visible in the output bundle if
|
|
// symlinks are being used). Like with WatchSets, they match against
|
|
// entries that end with a slash if it's a directory.
|
|
// - specificFiles: just copy these paths (specified as relative to 'to').
|
|
// - symlink: true if the directory should be symlinked instead of copying
|
|
copyDirectory(options) {
|
|
// TODO(benjamn) Remove this wrapper when Builder#enter is no longer
|
|
// implemented using ridiculous hacks.
|
|
return this._copyDirectory(options);
|
|
}
|
|
|
|
_copyDirectory({
|
|
from, to,
|
|
ignore,
|
|
specificFiles,
|
|
symlink,
|
|
npmDiscards,
|
|
// Optional predicate to filter files and directories.
|
|
filter,
|
|
}) {
|
|
if (to.slice(-1) === files.pathSep) {
|
|
to = to.slice(0, -1);
|
|
}
|
|
|
|
if (symlink) {
|
|
if (specificFiles) {
|
|
throw new Error("can't copy only specific paths with a single symlink");
|
|
}
|
|
|
|
if (this.usedAsFile[to]) {
|
|
throw new Error("tried to copy a directory onto " + to +
|
|
" but it is is already a file");
|
|
}
|
|
}
|
|
|
|
ignore = ignore || [];
|
|
let specificPaths = null;
|
|
if (specificFiles) {
|
|
specificPaths = {};
|
|
specificFiles.forEach(f => {
|
|
while (f !== '.') {
|
|
specificPaths[files.pathJoin(to, f)] = true;
|
|
f = files.pathDirname(f);
|
|
}
|
|
});
|
|
}
|
|
|
|
const rootDir = realpath(from);
|
|
|
|
const walk = (absFrom, relTo) => {
|
|
if (symlink && ! (relTo in this.usedAsFile)) {
|
|
this._ensureDirectory(files.pathDirname(relTo));
|
|
const absTo = files.pathResolve(this.buildPath, relTo);
|
|
if (this.previousCreatedSymlinks[absFrom] !== relTo) {
|
|
symlinkWithOverwrite(absFrom, absTo);
|
|
}
|
|
this.usedAsFile[relTo] = false;
|
|
this.createdSymlinks[absFrom] = relTo;
|
|
return;
|
|
}
|
|
|
|
this._ensureDirectory(relTo);
|
|
|
|
optimisticReaddir(absFrom).forEach(item => {
|
|
let thisAbsFrom = files.pathResolve(absFrom, item);
|
|
const thisRelTo = files.pathJoin(relTo, item);
|
|
|
|
if (specificPaths && !(thisRelTo in specificPaths)) {
|
|
return;
|
|
}
|
|
|
|
// Returns files.realpath(thisAbsFrom), if it is external to
|
|
// rootDir, using caching because this function might be called
|
|
// more than once.
|
|
let cachedExternalPath;
|
|
const getExternalPath = () => {
|
|
if (typeof cachedExternalPath !== "undefined") {
|
|
return cachedExternalPath;
|
|
}
|
|
|
|
try {
|
|
var real = realpath(thisAbsFrom);
|
|
} catch (e) {
|
|
if (e.code !== "ENOENT" &&
|
|
e.code !== "ELOOP") {
|
|
throw e;
|
|
}
|
|
return cachedExternalPath = false;
|
|
}
|
|
|
|
const isExternal =
|
|
files.pathRelative(rootDir, real).startsWith("..");
|
|
|
|
// Now cachedExternalPath is either a string or false.
|
|
return cachedExternalPath = isExternal && real;
|
|
};
|
|
|
|
let fileStatus = optimisticLStatOrNull(thisAbsFrom);
|
|
|
|
if (! symlink &&
|
|
fileStatus &&
|
|
fileStatus.isSymbolicLink()) {
|
|
// If copyDirectory is not allowed to create symbolic links to
|
|
// external files, and this file is a symbolic link that points
|
|
// to an external file, update fileStatus so that we copy this
|
|
// file as a normal file rather than as a symbolic link.
|
|
const externalPath = getExternalPath();
|
|
if (externalPath) {
|
|
// Update fileStatus to match the actual file rather than the
|
|
// symbolic link, thus forcing the file to be copied below.
|
|
fileStatus = optimisticLStatOrNull(externalPath);
|
|
}
|
|
}
|
|
|
|
if (! fileStatus) {
|
|
// If the file did not exist, skip it.
|
|
return;
|
|
}
|
|
|
|
let itemForMatch = item;
|
|
const isDirectory = fileStatus.isDirectory();
|
|
if (isDirectory) {
|
|
itemForMatch += '/';
|
|
}
|
|
|
|
// skip excluded files
|
|
if (ignore.some(pattern => itemForMatch.match(pattern))) {
|
|
return;
|
|
}
|
|
|
|
if (typeof filter === "function" &&
|
|
! filter(thisAbsFrom, isDirectory)) {
|
|
return;
|
|
}
|
|
|
|
if (npmDiscards instanceof NpmDiscards &&
|
|
npmDiscards.shouldDiscard(thisAbsFrom, isDirectory)) {
|
|
return;
|
|
}
|
|
|
|
if (isDirectory) {
|
|
walk(thisAbsFrom, thisRelTo);
|
|
return;
|
|
}
|
|
|
|
if (fileStatus.isSymbolicLink()) {
|
|
// Symbolic links pointing to relative external paths are less
|
|
// portable than absolute links, so getExternalPath() is
|
|
// preferred if it returns a path.
|
|
const linkSource = getExternalPath() ||
|
|
files.readlink(thisAbsFrom);
|
|
|
|
const linkTarget =
|
|
files.pathResolve(this.buildPath, thisRelTo);
|
|
|
|
if (symlinkIfPossible(linkSource, linkTarget)) {
|
|
// A symlink counts as a file, as far as "can you put
|
|
// something under it" goes.
|
|
this.usedAsFile[thisRelTo] = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fall back to copying the file, but make sure it's really a file
|
|
// first, just in case it was a symbolic link to a directory that
|
|
// could not be created above.
|
|
fileStatus = optimisticStatOrNull(thisAbsFrom);
|
|
if (fileStatus && fileStatus.isFile()) {
|
|
const hash = optimisticHashOrNull(thisAbsFrom);
|
|
|
|
if (this.previousWrittenHashes[thisRelTo] !== hash) {
|
|
const content = optimisticReadFile(thisAbsFrom);
|
|
|
|
files.writeFile(
|
|
files.pathResolve(this.buildPath, thisRelTo),
|
|
// The reason we call files.writeFile here instead of
|
|
// files.copyFile is so that we can read the file using
|
|
// optimisticReadFile instead of files.createReadStream.
|
|
content,
|
|
// Logic borrowed from files.copyFile: "Create the file as
|
|
// readable and writable by everyone, and executable by everyone
|
|
// if the original file is executably by owner. (This mode will be
|
|
// modified by umask.) We don't copy the mode *directly* because
|
|
// this function is used by 'meteor create' which is copying from
|
|
// the read-only tools tree into a writable app."
|
|
{ mode: (fileStatus.mode & 0o100) ? 0o777 : 0o666 },
|
|
);
|
|
}
|
|
|
|
this.writtenHashes[thisRelTo] = hash;
|
|
this.usedAsFile[thisRelTo] = true;
|
|
}
|
|
});
|
|
};
|
|
|
|
walk(rootDir, to);
|
|
}
|
|
|
|
// Returns a new Builder-compatible object that works just like a
|
|
// Builder, but interprets all paths relative to 'relPath', a path
|
|
// relative to the bundle root which should not start with a '/'.
|
|
//
|
|
// The sub-builder returned does not have all Builder methods (for
|
|
// example, complete() wouldn't make sense) and you should not rely
|
|
// on it being instanceof Builder.
|
|
//
|
|
// TODO(benjamn) This nonsense should be ripped out by any means
|
|
// necessary... whenever someone has the time.
|
|
enter(relPath) {
|
|
const subBuilder = {};
|
|
const relPathWithSep = relPath + files.pathSep;
|
|
const methods = [
|
|
"write",
|
|
"writeJson",
|
|
"reserve",
|
|
"generateFilename",
|
|
"copyDirectory",
|
|
"copyNodeModulesDirectory",
|
|
"enter",
|
|
];
|
|
|
|
methods.forEach(method => {
|
|
subBuilder[method] = (...args) => {
|
|
if (method === "copyDirectory" ||
|
|
method === "copyNodeModulesDirectory") {
|
|
// The copy methods take their relative paths via options.to.
|
|
args[0].to = files.pathJoin(relPath, args[0].to);
|
|
} else {
|
|
// Other methods have relPath as the first argument.
|
|
args[0] = files.pathJoin(relPath, args[0]);
|
|
}
|
|
|
|
let ret = this[method](...args);
|
|
|
|
if (method === "generateFilename") {
|
|
// fix up the returned path to be relative to the
|
|
// sub-bundle, not the parent bundle
|
|
if (ret.substr(0, 1) === '/') {
|
|
ret = ret.substr(1);
|
|
}
|
|
if (ret.substr(0, relPathWithSep.length) !== relPathWithSep) {
|
|
throw new Error("generateFilename returned path outside of " +
|
|
"sub-bundle?");
|
|
}
|
|
ret = ret.substr(relPathWithSep.length);
|
|
}
|
|
|
|
return ret;
|
|
};
|
|
});
|
|
|
|
// Methods that don't have to fix up arguments or return values, because
|
|
// they are implemented purely in terms of other methods which do.
|
|
const passThroughMethods = [
|
|
"writeToGeneratedFilename",
|
|
"copyTranspiledModules",
|
|
];
|
|
passThroughMethods.forEach(method => {
|
|
subBuilder[method] = this[method];
|
|
});
|
|
|
|
return subBuilder;
|
|
}
|
|
|
|
// Move the completed bundle into its final location (outputPath)
|
|
complete() {
|
|
if (this.previousUsedAsFile) {
|
|
// delete files and folders left-over from previous runs and not
|
|
// re-used in this run
|
|
const removed = {};
|
|
const paths = Object.keys(this.previousUsedAsFile);
|
|
paths.forEach((path) => {
|
|
// if the same path was re-used, leave it
|
|
if (this.usedAsFile.hasOwnProperty(path)) { return; }
|
|
|
|
// otherwise, remove it as it is no longer needed
|
|
|
|
// skip if already deleted
|
|
if (removed.hasOwnProperty(path)) { return; }
|
|
|
|
const absPath = files.pathJoin(this.buildPath, path);
|
|
if (this.previousUsedAsFile[path]) {
|
|
// file
|
|
files.unlink(absPath);
|
|
removed[path] = true;
|
|
} else {
|
|
// directory
|
|
files.rm_recursive(absPath);
|
|
|
|
// mark all sub-paths as removed, too
|
|
paths.forEach((anotherPath) => {
|
|
if (anotherPath.startsWith(path + '/')) {
|
|
removed[anotherPath] = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// XXX Alternatively, we could just keep buildPath around, and make
|
|
// outputPath be a symlink pointing to it. This doesn't work for the NPM use
|
|
// case of renameDirAlmostAtomically since that one is constructing files to
|
|
// be checked in to version control, but here we could get away with it.
|
|
if (this.buildPath !== this.outputPath) {
|
|
files.renameDirAlmostAtomically(this.buildPath, this.outputPath);
|
|
}
|
|
}
|
|
|
|
// Delete the partially-completed bundle. Do not disturb outputPath.
|
|
abort() {
|
|
files.rm_recursive(this.buildPath);
|
|
}
|
|
|
|
// Returns a WatchSet representing all files that were read from disk by the
|
|
// builder.
|
|
getWatchSet() {
|
|
return this.watchSet;
|
|
}
|
|
}
|
|
|
|
function jsToTs(path) {
|
|
if (path.endsWith(".ts")) {
|
|
const parts = path.split(".");
|
|
assert.strictEqual(parts.pop(), "ts");
|
|
parts.push("js");
|
|
path = parts.join(".");
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function atomicallyRewriteFile(path, data, options) {
|
|
// create a different file with a random name and then rename over atomically
|
|
const rname = '.builder-tmp-file.' + Math.floor(Math.random() * 999999);
|
|
const rpath = files.pathJoin(files.pathDirname(path), rname);
|
|
files.writeFile(rpath, data, options);
|
|
try {
|
|
files.rename(rpath, path);
|
|
} catch (e) {
|
|
if (e.code === 'EISDIR') {
|
|
// replacing a directory with a file; this is rare (so it can
|
|
// be a slow path) but can legitimately happen if e.g. a developer
|
|
// puts a file where there used to be a directory in their app.
|
|
files.rm_recursive(path);
|
|
files.rename(rpath, path);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
function symlinkIfPossible(source, target) {
|
|
try {
|
|
symlinkWithOverwrite(source, target);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Wrap slow methods into Profiler calls
|
|
const slowBuilderMethods = [
|
|
"_ensureDirectory",
|
|
"write",
|
|
"enter",
|
|
"copyDirectory",
|
|
"copyNodeModulesDirectory",
|
|
"enter",
|
|
"complete",
|
|
];
|
|
|
|
slowBuilderMethods.forEach(method => {
|
|
Builder.prototype[method] =
|
|
Profile(`Builder#${method}`, Builder.prototype[method]);
|
|
});
|