isolate-package
Version:
Isolate monorepo packages to form a self-contained deployable unit
215 lines (180 loc) • 6.07 kB
text/typescript
import { execFileSync } from "node:child_process";
import fs from "fs-extra";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { isEmpty } from "remeda";
import { type LogLevel, setLogLevel, useLogger } from "./logger";
import { inspectValue, readTypedJsonSync } from "./utils";
export type IsolateConfigResolved = {
buildDirName?: string;
includeDevDependencies: boolean;
isolateDirName: string;
logLevel: LogLevel;
targetPackagePath?: string;
tsconfigPath: string;
workspacePackages?: string[];
workspaceRoot: string;
forceNpm: boolean;
pickFromScripts?: string[];
omitFromScripts?: string[];
omitPackageManager?: boolean;
};
export type IsolateConfig = Partial<IsolateConfigResolved>;
const configDefaults: IsolateConfigResolved = {
buildDirName: undefined,
includeDevDependencies: false,
isolateDirName: "isolate",
logLevel: "info",
targetPackagePath: undefined,
tsconfigPath: "./tsconfig.json",
workspacePackages: undefined,
workspaceRoot: "../..",
forceNpm: false,
pickFromScripts: undefined,
omitFromScripts: undefined,
omitPackageManager: false,
};
const validConfigKeys = Object.keys(configDefaults);
const CONFIG_FILE_NAME_TS = "isolate.config.ts";
const CONFIG_FILE_NAME_JS = "isolate.config.js";
const CONFIG_FILE_NAME_JSON = "isolate.config.json";
/**
* Load a JS or TS config file by spawning a Node subprocess. For TS files,
* --experimental-strip-types is added so Node can handle TypeScript natively.
* This keeps the function synchronous while allowing us to import the module.
*/
const CONFIG_JSON_DELIMITER = "__ISOLATE_CONFIG_JSON__";
function loadModuleConfig(filePath: string): IsolateConfig {
const fileUrl = pathToFileURL(filePath).href;
const isTypeScript = filePath.endsWith(".ts");
const script = `import(process.argv[1])
.then(m => {
if (m.default === undefined) {
process.stderr.write("Config file has no default export");
process.exit(1);
}
process.stdout.write("${CONFIG_JSON_DELIMITER}" + JSON.stringify(m.default) + "${CONFIG_JSON_DELIMITER}");
})
.catch(err => {
process.stderr.write(String(err));
process.exit(1);
})`;
try {
const result = execFileSync(
process.execPath,
[
...(isTypeScript ? ["--experimental-strip-types"] : []),
"--no-warnings",
"--input-type=module",
"-e",
script,
fileUrl,
],
{ encoding: "utf8" },
);
const jsonMatch = result.split(CONFIG_JSON_DELIMITER)[1];
if (jsonMatch === undefined) {
throw new Error("Failed to extract config JSON from subprocess output");
}
const parsed = JSON.parse(jsonMatch);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
throw new Error(
`Expected default export to be an object, got ${typeof parsed}`,
);
}
return parsed;
} catch (error) {
const stderr =
error instanceof Error && "stderr" in error
? String(error.stderr).trim()
: "";
const detail = stderr || (error instanceof Error ? error.message : "");
throw new Error(
`Failed to load config from ${filePath}${detail ? `: ${detail}` : ""}`,
{ cause: error },
);
}
}
export function loadConfigFromFile(): IsolateConfig {
const log = useLogger();
const cwd = process.cwd();
const tsConfigPath = path.join(cwd, CONFIG_FILE_NAME_TS);
const jsConfigPath = path.join(cwd, CONFIG_FILE_NAME_JS);
const jsonConfigPath = path.join(cwd, CONFIG_FILE_NAME_JSON);
const tsExists = fs.existsSync(tsConfigPath);
const jsExists = fs.existsSync(jsConfigPath);
const jsonExists = fs.existsSync(jsonConfigPath);
const existingFiles = [
tsExists && CONFIG_FILE_NAME_TS,
jsExists && CONFIG_FILE_NAME_JS,
jsonExists && CONFIG_FILE_NAME_JSON,
].filter(Boolean);
if (existingFiles.length > 1) {
log.warn(
`Found multiple config files: ${existingFiles.join(", ")}. Using ${existingFiles[0]}.`,
);
}
if (tsExists) {
return loadModuleConfig(tsConfigPath);
}
if (jsExists) {
return loadModuleConfig(jsConfigPath);
}
if (jsonExists) {
return readTypedJsonSync<IsolateConfig>(jsonConfigPath);
}
return {};
}
/** Helper for type-safe configuration in isolate.config.ts files. */
export function defineConfig(config: IsolateConfig): IsolateConfig {
return config;
}
function validateConfig(config: IsolateConfig) {
const log = useLogger();
const foreignKeys = Object.keys(config).filter(
(key) => !validConfigKeys.includes(key),
);
if (!isEmpty(foreignKeys)) {
log.warn(`Found invalid config settings:`, foreignKeys.join(", "));
}
}
/**
* Resolve the target package directory and workspace root directory from the
* configuration. When targetPackagePath is set, the config is assumed to live
* at the workspace root. Otherwise it lives in the target package directory.
*/
export function resolveWorkspacePaths(config: IsolateConfigResolved) {
const targetPackageDir = config.targetPackagePath
? path.join(process.cwd(), config.targetPackagePath)
: process.cwd();
const workspaceRootDir = config.targetPackagePath
? process.cwd()
: path.join(targetPackageDir, config.workspaceRoot);
return { targetPackageDir, workspaceRootDir };
}
export function resolveConfig(
initialConfig?: IsolateConfig,
): IsolateConfigResolved {
setLogLevel(process.env.DEBUG_ISOLATE_CONFIG ? "debug" : "info");
const log = useLogger();
const userConfig = initialConfig ?? loadConfigFromFile();
if (initialConfig) {
log.debug(`Using user defined config:`, inspectValue(initialConfig));
} else {
log.debug(`Loaded config from file`);
}
validateConfig(userConfig);
if (userConfig.logLevel) {
setLogLevel(userConfig.logLevel);
}
const config = {
...configDefaults,
...userConfig,
} satisfies IsolateConfigResolved;
log.debug("Using configuration:", inspectValue(config));
return config;
}