Skip to content

Commit

Permalink
[v3.0] Always try to load config files via Node if possible (#4621)
Browse files Browse the repository at this point in the history
* Support ES modules in bundles config files

* Always try to load config files directly if possible

* Fix build

* Fix test

* Debug test
  • Loading branch information
lukastaegert committed Sep 6, 2022
1 parent 5534ae8 commit 37af402
Show file tree
Hide file tree
Showing 115 changed files with 400 additions and 222 deletions.
29 changes: 0 additions & 29 deletions LICENSE.md
Expand Up @@ -240,35 +240,6 @@ Repository: jonschlinkert/fill-range
---------------------------------------

## get-package-type
License: MIT
By: Corey Farrell
Repository: git+https://github.com/cfware/get-package-type.git

> MIT License
>
> Copyright (c) 2020 CFWare, LLC
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
---------------------------------------

## glob-parent
License: ISC
By: Gulp Team, Elan Shanker, Blaine Bublitz
Expand Down
4 changes: 2 additions & 2 deletions build-plugins/clean-before-write.ts
@@ -1,4 +1,4 @@
import { remove } from 'fs-extra';
import fs from 'fs-extra';
import type { Plugin } from 'rollup';

export default function cleanBeforeWrite(dir: string): Plugin {
Expand All @@ -7,7 +7,7 @@ export default function cleanBeforeWrite(dir: string): Plugin {
generateBundle(_options, _bundle, isWrite) {
if (isWrite) {
// Only remove before first write, but make all writes wait on the removal
removePromise ||= remove(dir);
removePromise ||= fs.remove(dir);
return removePromise;
}
},
Expand Down
4 changes: 2 additions & 2 deletions build-plugins/copy-types.ts
@@ -1,5 +1,5 @@
import { resolve } from 'node:path';
import { readFile } from 'fs-extra';
import fs from 'fs-extra';
import type { Plugin } from 'rollup';

export default function copyTypes(fileName: string): Plugin {
Expand All @@ -8,7 +8,7 @@ export default function copyTypes(fileName: string): Plugin {
if (isWrite) {
this.emitFile({
fileName,
source: await readFile(resolve('src/rollup/types.d.ts'), 'utf8'),
source: await fs.readFile(resolve('src/rollup/types.d.ts'), 'utf8'),
type: 'asset'
});
}
Expand Down
6 changes: 4 additions & 2 deletions build-plugins/esm-dynamic-import.ts
@@ -1,12 +1,14 @@
import type { Plugin } from 'rollup';

const expectedImports = 3;

export default function esmDynamicImport(): Plugin {
let importsFound = 0;
return {
generateBundle() {
if (importsFound !== 2) {
if (importsFound !== expectedImports) {
throw new Error(
'Could not find 2 dynamic import in "loadConfigFile.ts" and "commandPlugin.ts", were the files renamed or modified?'
`Could not find ${expectedImports} dynamic imports in "loadConfigFile.ts" and "commandPlugin.ts", found ${importsFound}.`
);
}
},
Expand Down
11 changes: 6 additions & 5 deletions build-plugins/get-banner.ts
@@ -1,11 +1,11 @@
import { exec } from 'node:child_process';
import { promises as fs } from 'node:fs';
import { env } from 'node:process';
import { promisify } from 'node:util';
import { version } from '../package.json';

const execPromise = promisify(exec);

function generateBanner(commitHash: string): string {
function generateBanner(commitHash: string, version: string): string {
const date = new Date(
env.SOURCE_DATE_EPOCH ? 1000 * +env.SOURCE_DATE_EPOCH : Date.now()
).toUTCString();
Expand All @@ -24,7 +24,8 @@ function generateBanner(commitHash: string): string {
let getBannerPromise: Promise<string> | null = null;

export default async function getBanner(): Promise<string> {
return (getBannerPromise ||= execPromise('git rev-parse HEAD').then(({ stdout }) =>
generateBanner(stdout.trim())
));
return (getBannerPromise ||= Promise.all([
execPromise('git rev-parse HEAD'),
fs.readFile(new URL('../package.json', import.meta.url), 'utf8')
]).then(([{ stdout }, pkg]) => generateBanner(stdout.trim(), JSON.parse(pkg).version)));
}
120 changes: 69 additions & 51 deletions cli/run/loadConfigFile.ts
@@ -1,26 +1,32 @@
import { extname, isAbsolute } from 'node:path';
import { promises as fs } from 'node:fs';
import { dirname, isAbsolute, join } from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import getPackageType from 'get-package-type';
import * as rollup from '../../src/node-entry';
import type { MergedRollupOptions } from '../../src/rollup/types';
import { bold } from '../../src/utils/colors';
import { errMissingConfig, error, errTranspiledEsmConfig } from '../../src/utils/error';
import {
errCannotBundleConfigAsEsm,
errCannotLoadConfigAsCjs,
errCannotLoadConfigAsEsm,
errMissingConfig,
error
} from '../../src/utils/error';
import { mergeOptions } from '../../src/utils/options/mergeOptions';
import type { GenericConfigObject } from '../../src/utils/options/options';
import relativeId from '../../src/utils/relativeId';
import { stderr } from '../logging';
import batchWarnings, { type BatchWarnings } from './batchWarnings';
import { addCommandPluginsToInputOptions, addPluginsFromCommandOption } from './commandPlugins';

interface NodeModuleWithCompile extends NodeModule {
_compile(code: string, filename: string): any;
}

export async function loadConfigFile(
fileName: string,
commandOptions: any = {}
): Promise<{ options: MergedRollupOptions[]; warnings: BatchWarnings }> {
const configs = await loadConfigsFromFile(fileName, commandOptions);
const configs = await getConfigList(
getDefaultFromCjs(await getConfigFileExport(fileName, commandOptions)),
commandOptions
);
const warnings = batchWarnings();
try {
const normalizedConfigs: MergedRollupOptions[] = [];
Expand All @@ -36,29 +42,51 @@ export async function loadConfigFile(
}
}

async function loadConfigsFromFile(
fileName: string,
commandOptions: Record<string, unknown>
): Promise<GenericConfigObject[]> {
const extension = extname(fileName);

const configFileExport =
commandOptions.configPlugin ||
// We always transpile the .js non-module case because many legacy code bases rely on this
(extension === '.js' && getPackageType.sync(fileName) !== 'module')
? await getDefaultFromTranspiledConfigFile(fileName, commandOptions)
: getDefaultFromCjs((await import(pathToFileURL(fileName).href)).default);

return getConfigList(configFileExport, commandOptions);
async function getConfigFileExport(fileName: string, commandOptions: Record<string, unknown>) {
if (commandOptions.configPlugin || commandOptions.bundleConfigAsCjs) {
try {
return await loadTranspiledConfigFile(fileName, commandOptions);
} catch (err: any) {
if (err.message.includes('not defined in ES module scope')) {
return error(errCannotBundleConfigAsEsm(err));
}
throw err;
}
}
let cannotLoadEsm = false;
const handleWarning = (warning: Error): void => {
if (warning.message.includes('To load an ES module')) {
cannotLoadEsm = true;
}
};
process.on('warning', handleWarning);
try {
const fileUrl = pathToFileURL(fileName);
if (process.env.ROLLUP_WATCH) {
// We are adding the current date to allow reloads in watch mode
fileUrl.search = `?${Date.now()}`;
}
return (await import(fileUrl.href)).default;
} catch (err: any) {
if (cannotLoadEsm) {
return error(errCannotLoadConfigAsCjs(err));
}
if (err.message.includes('not defined in ES module scope')) {
return error(errCannotLoadConfigAsEsm(err));
}
throw err;
} finally {
process.off('warning', handleWarning);
}
}

function getDefaultFromCjs(namespace: GenericConfigObject): unknown {
return namespace.__esModule ? namespace.default : namespace;
return namespace.default || namespace;
}

async function getDefaultFromTranspiledConfigFile(
async function loadTranspiledConfigFile(
fileName: string,
commandOptions: Record<string, unknown>
{ bundleConfigAsCjs, configPlugin, silent }: Record<string, unknown>
): Promise<unknown> {
const warnings = batchWarnings();
const inputOptions = {
Expand All @@ -69,17 +97,17 @@ async function getDefaultFromTranspiledConfigFile(
plugins: [],
treeshake: false
};
await addPluginsFromCommandOption(commandOptions.configPlugin, inputOptions);
await addPluginsFromCommandOption(configPlugin, inputOptions);
const bundle = await rollup.rollup(inputOptions);
if (!commandOptions.silent && warnings.count > 0) {
if (!silent && warnings.count > 0) {
stderr(bold(`loaded ${relativeId(fileName)} with warnings`));
warnings.flush();
}
const {
output: [{ code }]
} = await bundle.generate({
exports: 'named',
format: 'cjs',
format: bundleConfigAsCjs ? 'cjs' : 'es',
plugins: [
{
name: 'transpile-import-meta',
Expand All @@ -94,32 +122,22 @@ async function getDefaultFromTranspiledConfigFile(
}
]
});
return loadConfigFromBundledFile(fileName, code);
return loadConfigFromWrittenFile(
join(dirname(fileName), `rollup.config-${Date.now()}.${bundleConfigAsCjs ? 'cjs' : 'mjs'}`),
code
);
}

function loadConfigFromBundledFile(fileName: string, bundledCode: string): unknown {
const resolvedFileName = require.resolve(fileName);
const extension = extname(resolvedFileName);
const defaultLoader = require.extensions[extension];
require.extensions[extension] = (module: NodeModule, requiredFileName: string) => {
if (requiredFileName === resolvedFileName) {
(module as NodeModuleWithCompile)._compile(bundledCode, requiredFileName);
} else {
if (defaultLoader) {
defaultLoader(module, requiredFileName);
}
}
};
delete require.cache[resolvedFileName];
async function loadConfigFromWrittenFile(
bundledFileName: string,
bundledCode: string
): Promise<unknown> {
await fs.writeFile(bundledFileName, bundledCode);
try {
const config = getDefaultFromCjs(require(fileName));
require.extensions[extension] = defaultLoader;
return config;
} catch (err: any) {
if (err.code === 'ERR_REQUIRE_ESM') {
return error(errTranspiledEsmConfig(fileName));
}
throw err;
return (await import(pathToFileURL(bundledFileName).href)).default;
} finally {
// Not awaiting here saves some ms while potentially hiding a non-critical error
fs.unlink(bundledFileName);
}
}

Expand Down
18 changes: 12 additions & 6 deletions docs/01-command-line-reference.md
Expand Up @@ -18,11 +18,9 @@ export default {
};
```

Typically, it is called `rollup.config.js` or `rollup.config.mjs` and sits in the root directory of your project. If you use the `.mjs` extension or have `type: "module"` in your `package.json` file, Rollup will directly use Node to import it, which is now the recommended way to define Rollup configurations. Note that there are some [caveats when using native Node ES modules](guide/en/#caveats-when-using-native-node-es-modules);
Typically, it is called `rollup.config.js` or `rollup.config.mjs` and sits in the root directory of your project. Unless the [`--configPlugin`](guide/en/#--configplugin-plugin) or [`--bundleConfigAsCjs`](guide/en/#--bundleconfigascjs) options are used, Rollup will directly use Node to import the file. Note that there are some [caveats when using native Node ES modules](guide/en/#caveats-when-using-native-node-es-modules) as Rollup will observe [Node ESM semantics](https://nodejs.org/docs/latest-v14.x/api/packages.html#packages_determining_module_system).

Otherwise, Rollup will transpile and bundle this file and its relative dependencies to CommonJS before requiring it to ensure compatibility with legacy code bases that use ES module syntax without properly respecting [Node ESM semantics](https://nodejs.org/docs/latest-v14.x/api/packages.html#packages_determining_module_system).

If you want to write your configuration as a CommonJS module using `require` and `module.exports`, you should change the file extension to `.cjs`, which will prevent Rollup from trying to transpile the CommonJS file.
If you want to write your configuration as a CommonJS module using `require` and `module.exports`, you should change the file extension to `.cjs`.

You can also use other languages for your configuration files like TypeScript. To do that, install a corresponding Rollup plugin like `@rollup/plugin-typescript` and use the [`--configPlugin`](guide/en/#--configplugin-plugin) option:

Expand Down Expand Up @@ -243,7 +241,7 @@ Besides `RollupOptions` and the `defineConfig` helper that encapsulates this typ
- `Plugin`: A plugin object that provides a `name` and some hooks. All hooks are fully typed to aid in plugin development.
- `PluginImpl`: A function that maps an options object to a plugin object. Most public Rollup plugins follow this pattern.
You can also directly write your config in TypeScript via the [`--configPlugin`](guide/en/#--configplugin-plugin) option. With TypeScript you can import the `RollupOptions` type directly:
You can also directly write your config in TypeScript via the [`--configPlugin`](guide/en/#--configplugin-plugin) option. With TypeScript, you can import the `RollupOptions` type directly:
```typescript
import type { RollupOptions } from 'rollup';
Expand Down Expand Up @@ -478,7 +476,15 @@ Note for Typescript: make sure you have the Rollup config file in your `tsconfig
"include": ["src/**/*", "rollup.config.ts"],
```

This option supports the same syntax as the [`--plugin`](guide/en/#-p-plugin---plugin-plugin) option i.e., you can specify the option multiple times, you can omit the `@rollup/plugin-` prefix and just write `typescript` and you can specify plugin options via `={...}`. Using this option will make Rollup transpile your configuration file to CommonJS first before executing it.
This option supports the same syntax as the [`--plugin`](guide/en/#-p-plugin---plugin-plugin) option i.e., you can specify the option multiple times, you can omit the `@rollup/plugin-` prefix and just write `typescript` and you can specify plugin options via `={...}`.

Using this option will make Rollup transpile your configuration file to an ES module first before executing it. To transpile to CommonJS instead, also pass the [`--bundleConfigAsCjs`](guide/en/#--bundleconfigascjs) option.

#### `--bundleConfigAsCjs`

This option will force your configuration to be transpiled to CommonJS.

This allows you to use CommonJS idioms like `__dirname` or `require.resolve` in your configuration even if the configuration itself is written as an ES module.

#### `-v`/`--version`

Expand Down
7 changes: 3 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Expand Up @@ -85,7 +85,6 @@
"eslint-plugin-prettier": "^4.2.1",
"fixturify": "^2.1.1",
"fs-extra": "^10.1.0",
"get-package-type": "^0.1.0",
"github-api": "^3.4.0",
"hash.js": "^1.1.7",
"husky": "^8.0.1",
Expand Down Expand Up @@ -125,7 +124,7 @@
"dist/es/package.json"
],
"engines": {
"node": ">=14.13.1",
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"exports": {
Expand Down
1 change: 1 addition & 0 deletions src/rollup/types.d.ts
Expand Up @@ -28,6 +28,7 @@ export interface RollupLog {
pluginCode?: string;
pos?: number;
reexporter?: string;
stack?: string;
url?: string;
}

Expand Down

0 comments on commit 37af402

Please sign in to comment.