Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/wrap-terser-minify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@callstack/repack": patch
---

Minify `.bundle` assets with every version of `terser-webpack-plugin`. Since 5.6.0
the plugin only hands `.js`, `.cjs` and `.mjs` assets to its built-in terser
implementation, so it silently left Re.Pack's output unminified on both Rspack and
webpack. Re.Pack now configures its own `minify` wrapper, which carries no such
restriction and still runs the terser installed in the project.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { getMinimizerConfig } from '../getMinimizerConfig.js';

// `importDefaultESM` uses a native dynamic import, which is unavailable inside
// the Jest VM, so load the resolved plugin with `require` instead
jest.mock('../../../../helpers/index.js', () => ({
...jest.requireActual('../../../../helpers/index.js'),
importDefaultESM: (absolutePath: string) =>
Promise.resolve(require(absolutePath)),
}));

const ASSET_NAME = 'index.bundle';
const ASSET_SOURCE = 'const answer = 40 + 2;\n';

// the filter `terser-webpack-plugin` attaches to its built-in minifiers since 5.6.0
const JS_ONLY_FILTER = '(name) => /\\.[cm]?js(\\?.*)?$/i.test(name)';

const rootDirs: string[] = [];

function createRootDir() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-minimizer-'));
rootDirs.push(rootDir);
return rootDir;
}

// installs a stand-in for `terser-webpack-plugin` in a project, so `getMinimizerConfig`
// resolves it the way it would in a real one. It normalizes options like the real plugin.
function createProjectWithPlugin(filter?: string) {
const rootDir = createRootDir();
const pluginDir = path.join(rootDir, 'node_modules', 'terser-webpack-plugin');

fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'package.json'),
JSON.stringify({ name: 'terser-webpack-plugin', main: './index.js' })
);
fs.writeFileSync(
path.join(pluginDir, 'index.js'),
'class ProjectTerserPlugin {\n' +
' constructor(options) {\n' +
' this.options = {\n' +
' minimizer: {\n' +
' implementation: options.minify || ProjectTerserPlugin.terserMinify,\n' +
' options: options.terserOptions,\n' +
' },\n' +
' };\n' +
' }\n' +
'}\n' +
'ProjectTerserPlugin.terserMinify = function terserMinify(input) {\n' +
" return { code: 'project(' + Object.values(input)[0] + ')' };\n" +
'};\n' +
"ProjectTerserPlugin.terserMinify.getMinimizerVersion = () => '5.99.0';\n" +
(filter ? `ProjectTerserPlugin.terserMinify.filter = ${filter};\n` : '') +
'module.exports = ProjectTerserPlugin;\n'
);

return rootDir;
}

// the options `terser-webpack-plugin` normalizes a `minify` implementation into
function getMinimizer(minimizer: unknown) {
return (
minimizer as {
options: { minimizer: { implementation: any; options: unknown } };
}
).options.minimizer;
}

// mirrors how the plugin handles one asset: `filter` decides whether it is minified
// at all, and the implementation is re-evaluated from its source inside a worker
async function minifyAsset(minimizer: unknown, assetName = ASSET_NAME) {
const { implementation, options } = getMinimizer(minimizer);
if (implementation.filter?.(assetName, {}) === false) {
return ASSET_SOURCE;
}
const inWorker = new Function('require', `return ${implementation}`)(require);
const { code } = await inWorker(
{ [assetName]: ASSET_SOURCE },
undefined,
options,
false
);
return code;
}

describe('getMinimizerConfig', () => {
afterAll(() => {
for (const rootDir of rootDirs) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});

it('should minify .bundle assets with a project plugin that only accepts .js', async () => {
const rootDir = createProjectWithPlugin(JS_ONLY_FILTER);
const [minimizer] = await getMinimizerConfig('webpack', rootDir);

await expect(minifyAsset(minimizer)).resolves.toBe(
`project(${ASSET_SOURCE})`
);
});

it('should minify .bundle assets with a project plugin that has no filter', async () => {
const rootDir = createProjectWithPlugin();
const [minimizer] = await getMinimizerConfig('webpack', rootDir);

await expect(minifyAsset(minimizer)).resolves.toBe(
`project(${ASSET_SOURCE})`
);
});

it('should minify .bundle assets with the plugin shipped with Re.Pack', async () => {
const [minimizer] = await getMinimizerConfig('webpack', createRootDir());

await expect(minifyAsset(minimizer)).resolves.toBe('const answer=42;');
});

it('should keep reporting the version of the wrapped implementation', async () => {
const rootDir = createProjectWithPlugin(JS_ONLY_FILTER);
const [minimizer] = await getMinimizerConfig('webpack', rootDir);

expect(getMinimizer(minimizer).implementation.getMinimizerVersion()).toBe(
'5.99.0'
);
});
});
20 changes: 18 additions & 2 deletions packages/repack/src/commands/common/config/getMinimizerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,30 @@ async function getTerserPlugin(rootDir: string) {
terserPluginPath = require.resolve('terser-webpack-plugin');
}
const plugin = await importDefaultESM<typeof TerserPlugin>(terserPluginPath);
return plugin;
return { plugin, path: terserPluginPath };
}

// since 5.6.0 `terserMinify.filter` rejects anything but `.js`, `.cjs` and `.mjs`,
// and the plugin only consults `filter` when the configured `minify` has one.
// The wrapper is serialized into a worker, so it must not close over anything.
function createTerserMinify(plugin: typeof TerserPlugin, pluginPath: string) {
const minify = new Function(
`return function repackTerserMinify(input, sourceMap, minimizerOptions, extractComments) {
return require(${JSON.stringify(pluginPath)}).terserMinify(input, sourceMap, minimizerOptions, extractComments);
}`
)();
// keeps the terser version in the chunk hash, like the unwrapped implementation
minify.getMinimizerVersion = plugin.terserMinify.getMinimizerVersion;
return minify as (typeof TerserPlugin)['terserMinify'];
}

async function getTerserConfig(rootDir: string) {
const TerserPlugin = await getTerserPlugin(rootDir);
const { plugin: TerserPlugin, path: pluginPath } =
await getTerserPlugin(rootDir);
return new TerserPlugin({
test: /\.(js)?bundle(\?.*)?$/i,
extractComments: false,
minify: createTerserMinify(TerserPlugin, pluginPath),
terserOptions: {
format: { comments: false },
},
Expand Down