cli: unify production packing logic and add support for exports field

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-14 16:37:33 +01:00
parent a9b88cda17
commit 3099c54f22
5 changed files with 235 additions and 162 deletions
+6 -67
View File
@@ -14,77 +14,16 @@
* limitations under the License.
*/
import fs from 'fs-extra';
import {
productionPack,
revertProductionPack,
} from '../lib/packager/productionPack';
import { paths } from '../lib/paths';
import { join as joinPath } from 'path';
const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes'];
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
function resolveEntrypoint(pkg: any, name: string) {
const targetEntry = pkg.publishConfig[name] || pkg[name];
return targetEntry && joinPath('..', targetEntry);
}
// Writes e.g. alpha/package.json
async function writeReleaseStageEntrypoint(pkg: any, stage: 'alpha' | 'beta') {
await fs.ensureDir(paths.resolveTarget(stage));
await fs.writeJson(
paths.resolveTarget(stage, PKG_PATH),
{
name: pkg.name,
version: pkg.version,
main: resolveEntrypoint(pkg, 'main'),
module: resolveEntrypoint(pkg, 'module'),
browser: resolveEntrypoint(pkg, 'browser'),
types: joinPath('..', pkg.publishConfig[`${stage}Types`]),
},
{ encoding: 'utf8', spaces: 2 },
);
}
export const pre = async () => {
const pkgPath = paths.resolveTarget(PKG_PATH);
const pkgContent = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(pkgContent);
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
const publishConfig = pkg.publishConfig ?? {};
for (const key of Object.keys(publishConfig)) {
if (!SKIPPED_KEYS.includes(key)) {
pkg[key] = publishConfig[key];
}
}
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
if (publishConfig.alphaTypes) {
await writeReleaseStageEntrypoint(pkg, 'alpha');
}
if (publishConfig.betaTypes) {
await writeReleaseStageEntrypoint(pkg, 'beta');
}
await productionPack({ packageDir: paths.targetDir });
};
export const post = async () => {
// postpack isn't called by yarn right now, so it needs to be called manually
try {
await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true });
// Check if we're shipping types for other release stages, clean up in that case
const pkg = await fs.readJson(PKG_PATH);
if (pkg.publishConfig?.alphaTypes) {
await fs.remove(paths.resolveTarget('alpha'));
}
if (pkg.publishConfig?.betaTypes) {
await fs.remove(paths.resolveTarget('beta'));
}
} catch (error) {
console.warn(
`Failed to restore package.json during postpack, ${error}. ` +
'Your package will be fine but you may have ended up with some garbage in the repo.',
);
}
await revertProductionPack(paths.targetDir);
};
@@ -27,6 +27,7 @@ type PackageJSON = Package['packageJson'];
export interface ExtendedPackageJSON extends PackageJSON {
main?: string;
module?: string;
types?: string;
scripts?: {
[key: string]: string;
@@ -1,93 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import npmPackList from 'npm-packlist';
import { join as joinPath, resolve as resolvePath } from 'path';
const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes'];
function resolveEntrypoint(pkg: any, name: string) {
const targetEntry = pkg.publishConfig[name] || pkg[name];
return targetEntry && joinPath('..', targetEntry);
}
// Writes e.g. alpha/package.json
async function writeReleaseStageEntrypoint(
pkg: any,
stage: 'alpha' | 'beta',
targetDir: string,
) {
await fs.ensureDir(resolvePath(targetDir, stage));
await fs.writeJson(
resolvePath(targetDir, stage, 'package.json'),
{
name: pkg.name,
version: pkg.version,
main: resolveEntrypoint(pkg, 'main'),
module: resolveEntrypoint(pkg, 'module'),
browser: resolveEntrypoint(pkg, 'browser'),
types: joinPath('..', pkg.publishConfig[`${stage}Types`]),
},
{ encoding: 'utf8', spaces: 2 },
);
}
export async function copyPackageDist(packageDir: string, targetDir: string) {
const pkgPath = resolvePath(packageDir, 'package.json');
const pkgContent = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(pkgContent);
const publishConfig = pkg.publishConfig ?? {};
for (const key of Object.keys(publishConfig)) {
if (!SKIPPED_KEYS.includes(key)) {
pkg[key] = publishConfig[key];
}
}
// We remove the dependencies from package.json of packages that are marked
// as bundled, so that yarn doesn't try to install them.
if (pkg.bundled) {
delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.peerDependencies;
delete pkg.optionalDependencies;
}
// Lists all dist files, respecting .npmignore, files field in package.json, etc.
const filePaths = await npmPackList({
path: packageDir,
// This makes sure we use the update package.json when listing files
packageJsonCache: new Map([[resolvePath(packageDir, 'package.json'), pkg]]),
});
await fs.ensureDir(targetDir);
for (const filePath of filePaths.sort()) {
const target = resolvePath(targetDir, filePath);
if (filePath === 'package.json') {
await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 });
} else {
await fs.copy(resolvePath(packageDir, filePath), target);
}
}
if (publishConfig.alphaTypes) {
await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir);
}
if (publishConfig.betaTypes) {
await writeReleaseStageEntrypoint(pkg, 'beta', targetDir);
}
}
@@ -37,7 +37,7 @@ import {
getOutputsForRole,
Output,
} from '../builder';
import { copyPackageDist } from './copyPackageDist';
import { productionPack } from './productionPack';
import { getRoleInfo } from '../role';
import { runParallelWorkers } from '../parallel';
@@ -258,7 +258,10 @@ async function moveToDistWorkspace(
const outputDir = relativePath(paths.targetRoot, target.dir);
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
await copyPackageDist(target.dir, absoluteOutputPath);
await productionPack({
packageDir: target.dir,
targetDir: absoluteOutputPath,
});
}),
);
@@ -0,0 +1,223 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import npmPackList from 'npm-packlist';
import { join as joinPath, resolve as resolvePath } from 'path';
import { ExtendedPackageJSON } from '../monorepo';
import { readEntryPoints } from '../monorepo/entryPoints';
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes'];
interface ProductionPackOptions {
packageDir: string;
targetDir?: string;
}
export async function productionPack(options: ProductionPackOptions) {
const { packageDir, targetDir } = options;
const pkgPath = resolvePath(packageDir, PKG_PATH);
const pkgContent = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(pkgContent) as ExtendedPackageJSON;
// If we're making the update in-line, back up the package.json
if (!targetDir) {
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
}
const hasStageEntry =
!!pkg.publishConfig?.alphaTypes || !!pkg.publishConfig?.betaTypes;
if (pkg.exports && hasStageEntry) {
throw new Error(
'Combining both exports and alpha/beta types is not supported',
);
}
// This mutates pkg to fill in index exports, so call it before applying publishConfig
if (pkg.exports) {
await writeExportsEntryPoints(pkg, targetDir ?? packageDir);
}
// TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior
const publishConfig = pkg.publishConfig ?? {};
for (const key of Object.keys(publishConfig)) {
if (!SKIPPED_KEYS.includes(key)) {
(pkg as any)[key] = publishConfig[key as keyof typeof publishConfig];
}
}
// We remove the dependencies from package.json of packages that are marked
// as bundled, so that yarn doesn't try to install them.
if (pkg.bundled) {
delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.peerDependencies;
delete pkg.optionalDependencies;
}
if (targetDir) {
// Lists all dist files, respecting .npmignore, files field in package.json, etc.
const filePaths = await npmPackList({
path: packageDir,
// This makes sure we use the updated package.json when listing files
packageJsonCache: new Map([
[resolvePath(packageDir, PKG_PATH), pkg],
]) as any, // Seems like this parameter type is wrong,
});
await fs.ensureDir(targetDir);
for (const filePath of filePaths.sort()) {
const target = resolvePath(targetDir, filePath);
if (filePath === PKG_PATH) {
await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 });
} else {
await fs.copy(resolvePath(packageDir, filePath), target);
}
}
} else {
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
}
if (publishConfig.alphaTypes) {
await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir ?? packageDir);
}
if (publishConfig.betaTypes) {
await writeReleaseStageEntrypoint(pkg, 'beta', targetDir ?? packageDir);
}
}
// Reverts the changes made by productionPack when called without a targetDir.
export async function revertProductionPack(packageDir: string) {
// postpack isn't called by yarn right now, so it needs to be called manually
try {
await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true });
// Check if we're shipping types for other release stages, clean up in that case
const pkg = await fs.readJson(PKG_PATH);
if (pkg.publishConfig?.alphaTypes) {
await fs.remove(resolvePath(packageDir, 'alpha'));
}
if (pkg.publishConfig?.betaTypes) {
await fs.remove(resolvePath(packageDir, 'beta'));
}
// Remove any extra entrypoint backwards compatibility directories
const entryPoints = readEntryPoints(pkg);
for (const entryPoint of entryPoints) {
if (entryPoint.mount !== '.') {
await fs.remove(resolvePath(packageDir, entryPoint.name));
}
}
} catch (error) {
console.warn(
`Failed to restore package.json, ${error}. ` +
'Your package will be fine but you may have ended up with some garbage in the repo.',
);
}
}
function resolveEntrypoint(pkg: any, name: string) {
const targetEntry = pkg.publishConfig[name] || pkg[name];
return targetEntry && joinPath('..', targetEntry);
}
// Writes e.g. alpha/package.json
async function writeReleaseStageEntrypoint(
pkg: ExtendedPackageJSON,
stage: 'alpha' | 'beta',
targetDir: string,
) {
await fs.ensureDir(resolvePath(targetDir, stage));
await fs.writeJson(
resolvePath(targetDir, stage, PKG_PATH),
{
name: pkg.name,
version: pkg.version,
main: resolveEntrypoint(pkg, 'main'),
module: resolveEntrypoint(pkg, 'module'),
browser: resolveEntrypoint(pkg, 'browser'),
types: joinPath('..', pkg.publishConfig![`${stage}Types`]!),
},
{ encoding: 'utf8', spaces: 2 },
);
}
const EXPORT_MAP = {
import: '.esm.js',
require: '.cjs.js',
types: '.d.ts',
};
/**
* Rewrites the exports field in package.json to point to dist files, as
* well as creating backwards compatibility entry points for importers
* that don't support exports.
*/
async function writeExportsEntryPoints(
pkg: ExtendedPackageJSON,
targetDir: string,
) {
const distFiles = await fs.readdir(resolvePath(targetDir, 'dist'));
const outputExports = {} as Record<string, Record<string, string>>;
const entryPoints = readEntryPoints(pkg);
for (const entryPoint of entryPoints) {
const exp = {} as Record<string, string>;
for (const [key, ext] of Object.entries(EXPORT_MAP)) {
const name = `${entryPoint.name}${ext}`;
if (distFiles.includes(name)) {
exp[key] = `./${joinPath(`dist`, name)}`;
}
}
exp.default = exp.require ?? exp.import;
// This creates a directory with a lone package.json for backwards compatibility
if (entryPoint.mount === '.') {
if (exp.default) {
pkg.main = exp.default;
}
if (exp.import) {
pkg.module = exp.import;
}
if (exp.types) {
pkg.types = exp.types;
}
} else {
const entryPointDir = resolvePath(targetDir, entryPoint.name);
await fs.ensureDir(entryPointDir);
await fs.writeJson(
resolvePath(entryPointDir, PKG_PATH),
{
name: pkg.name,
version: pkg.version,
...(exp.default ? { main: joinPath('..', exp.default) } : {}),
...(exp.import ? { module: joinPath('..', exp.import) } : {}),
...(exp.types ? { types: joinPath('..', exp.types) } : {}),
},
{ encoding: 'utf8', spaces: 2 },
);
}
if (Object.keys(exp).length > 0) {
outputExports[entryPoint.mount] = exp;
}
}
pkg.exports = outputExports;
}