@backstage/cli
Version:
CLI for developing Backstage plugins and apps
283 lines (250 loc) • 9.42 kB
JavaScript
/*
* Copyright 2024 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 { dirname, extname, resolve as resolvePath } from 'path';
import { fileURLToPath } from 'url';
import { transformFile } from '@swc/core';
import { isBuiltin } from 'node:module';
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
// @ts-check
// No explicit file extension, no type in package.json
const DEFAULT_MODULE_FORMAT = 'commonjs';
// Source file extensions to look for when using bundle resolution strategy
const SRC_EXTS = ['.ts', '.js'];
const TS_EXTS = ['.ts', '.mts', '.cts'];
const moduleTypeTable = {
'.mjs': 'module',
'.mts': 'module',
'.cjs': 'commonjs',
'.cts': 'commonjs',
'.ts': undefined,
'.js': undefined,
};
/** @type {import('module').ResolveHook} */
export async function resolve(specifier, context, nextResolve) {
// Built-in modules are handled by the default resolver
if (isBuiltin(specifier)) {
return nextResolve(specifier, context);
}
const ext = extname(specifier);
// Unless there's an explicit import attribute, JSON files are loaded with our custom loader that's defined below.
if (ext === '.json' && !context.importAttributes?.type) {
const jsonResult = await nextResolve(specifier, context);
return {
...jsonResult,
format: 'commonjs',
importAttributes: { type: 'json' },
};
}
// Anything else with an explicit extension is handled by the default
// resolver, except that we help determine the module type where needed.
if (ext !== '') {
return withDetectedModuleType(await nextResolve(specifier, context));
}
// Other external modules are handled by the default resolver, but again we
// help determine the module type where needed.
if (!specifier.startsWith('.')) {
return withDetectedModuleType(await nextResolve(specifier, context));
}
// The rest of this function handles the case of resolving imports that do not
// specify any extension and might point to a directory with an `index.*`
// file. We resolve those using the same logic as most JS bundlers would, with
// the addition of checking if there's an explicit module format listed in the
// closest `package.json` file.
//
// We use a bundle resolution strategy in order to keep code consistent across
// Backstage codebases that contains code both for Web and Node.js, and to
// support packages with common code that can be used in both environments.
try {
// This is expected to throw, but in the event that this module specifier is
// supported we prefer to use the default resolver.
return await nextResolve(specifier, context);
} catch (error) {
if (error.code === 'ERR_UNSUPPORTED_DIR_IMPORT') {
const spec = `${specifier}${specifier.endsWith('/') ? '' : '/'}index`;
const resolved = await resolveWithoutExt(spec, context, nextResolve);
if (resolved) {
return withDetectedModuleType(resolved);
}
} else if (error.code === 'ERR_MODULE_NOT_FOUND') {
const resolved = await resolveWithoutExt(specifier, context, nextResolve);
if (resolved) {
return withDetectedModuleType(resolved);
}
}
// Unexpected error or no resolution found
throw error;
}
}
/**
* Populates the `format` field in the resolved object based on the closest `package.json` file.
*
* @param {import('module').ResolveFnOutput} resolved
* @returns {Promise<import('module').ResolveFnOutput>}
*/
async function withDetectedModuleType(resolved) {
// Already has an explicit format
if (resolved.format) {
return resolved;
}
// Happens in Node.js v22 when there's a package.json without an explicit "type" field. Use the default.
if (resolved.format === null) {
return { ...resolved, format: DEFAULT_MODULE_FORMAT };
}
const ext = extname(resolved.url);
const explicitFormat = moduleTypeTable[ext];
if (explicitFormat) {
return {
...resolved,
format: explicitFormat,
};
}
// TODO(Rugvip): Afaik this should never happen and we can remove this check, but want it here for a little while to verify.
if (ext === '.js') {
throw new Error('Unexpected .js file without explicit format');
}
// TODO(Rugvip): Does this need caching? kept it simple for now but worth exploring
const packageJsonPath = await findPackageJSON(fileURLToPath(resolved.url));
if (!packageJsonPath) {
return resolved;
}
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
return {
...resolved,
format: packageJson.type ?? DEFAULT_MODULE_FORMAT,
};
}
/**
* Find the closest package.json file from the given path.
*
* TODO(Rugvip): This can be replaced with the Node.js built-in with the same name once it is stable.
* @param {string} startPath
* @returns {Promise<string | undefined>}
*/
async function findPackageJSON(startPath) {
let path = startPath;
// Some confidence check to avoid infinite loop
for (let i = 0; i < 1000; i++) {
const packagePath = resolvePath(path, 'package.json');
if (existsSync(packagePath)) {
return packagePath;
}
const newPath = dirname(path);
if (newPath === path) {
return undefined;
}
path = newPath;
}
throw new Error(
`Iteration limit reached when searching for package.json at ${startPath}`,
);
}
/** @type {import('module').ResolveHook} */
async function resolveWithoutExt(specifier, context, nextResolve) {
for (const tryExt of SRC_EXTS) {
try {
const resolved = await nextResolve(specifier + tryExt, {
...context,
format: 'commonjs',
});
return {
...resolved,
format: moduleTypeTable[tryExt] ?? resolved.format,
};
} catch {
/* ignore */
}
}
return undefined;
}
/** @type {import('module').LoadHook} */
export async function load(url, context, nextLoad) {
// Non-file URLs are handled by the default loader
if (!url.startsWith('file://')) {
return nextLoad(url, context);
}
// JSON files loaded as CommonJS are handled by this custom loader, because
// the default one doesn't work. For JSON loading to work we'd need the
// synchronous hooks that aren't supported yet, or avoid using the CommonJS
// compatibility.
if (
context.format === 'commonjs' &&
context.importAttributes?.type === 'json'
) {
try {
// TODO(Rugvip): Make sure this is valid JSON
const content = await readFile(fileURLToPath(url), 'utf8');
return {
source: `module.exports = (${content})`,
format: 'commonjs',
shortCircuit: true,
};
} catch {
// Let the default loader generate the error
return nextLoad(url, context);
}
}
const ext = extname(url);
// Non-TS files are handled by the default loader
if (!TS_EXTS.includes(ext)) {
return nextLoad(url, context);
}
const format = context.format ?? DEFAULT_MODULE_FORMAT;
// We have two choices at this point, we can either transform CommonJS files
// and return the transformed source code, or let the default loader handle
// them. If we transform them ourselves we will enter CommonJS compatibility
// mode in the new module system in Node.js, this effectively means all
// CommonJS loaded via `require` calls from this point will all be treated as
// if it was loaded via `import` calls from modules.
//
// The CommonJS compatibility layer will try to identify named exports and
// make them available directly, which is convenient as it avoids things like
// `import(...).then(m => m.default.foo)`, allowing you to instead write
// `import(...).then(m => m.foo)`. The compatibility layer doesn't always work
// all that well though, and can lead to module loading issues in many cases,
// especially for older code.
// This `if` block opts-out of using CommonJS compatibility mode by default,
// and instead leaves it to our existing loader to transform CommonJS. We do
// however use compatibility mode for the more explicit .cts file extension,
// allows for a way to opt-in to the new behavior.
//
// TODO(Rugvip): Once the synchronous hooks API is available for us to use, we might be able to adopt that instead
if (format === 'commonjs' && ext !== '.cts') {
return nextLoad(url, { ...context, format });
}
const transformed = await transformFile(fileURLToPath(url), {
sourceMaps: 'inline',
module: {
type: format === 'module' ? 'es6' : 'commonjs',
ignoreDynamic: true,
// This helps the Node.js CommonJS compat layer identify named exports.
exportInteropAnnotation: true,
},
jsc: {
target: 'es2022',
parser: {
syntax: 'typescript',
},
},
});
return {
...context,
shortCircuit: true,
source: transformed.code,
format,
responseURL: url,
};
}