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
5 changes: 5 additions & 0 deletions .changeset/rspack-2-node-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

Raise a clear error when running the Rspack-based commands with Rspack 2 on Node.js older than 20.19 (Rspack 2 requires Node `^20.19.0 || >=22.12.0`), instead of failing with `ERR_REQUIRE_ESM`. Rspack commands are now loaded lazily so the version check runs before `@rspack/core` is touched.
4 changes: 4 additions & 0 deletions packages/repack/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ module.exports = {
// Transform everything in `test` environment, so unit test can pass.
test: {
presets: [['@babel/preset-env', { targets: { node: 18 } }]],
// The build keeps `import(...)` untransformed (see the override above),
// but Jest runs modules as CJS, so tests need `import(...)` lowered
// to `require(...)` as well.
plugins: ['@babel/plugin-transform-dynamic-import'],
},
},
};
1 change: 1 addition & 0 deletions packages/repack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"devDependencies": {
"@babel/cli": "^7.25.2",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-dynamic-import": "^7.24.7",
"@babel/plugin-transform-export-namespace-from": "^7.24.6",
"@babel/plugin-transform-modules-commonjs": "^7.23.2",
"@module-federation/enhanced": "0.8.9",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Loading '@rspack/core' at require time would crash with ERR_REQUIRE_ESM
// on Node versions unsupported by Rspack 2, before the Node compatibility
// guard has a chance to run. This mock turns any eager load into a test
// failure. Note that bundle.js/start.js are deliberately NOT mocked here,
// so this fails if the commands module ever imports them eagerly again.
jest.mock('@rspack/core', () => {
throw new Error(
'@rspack/core must not be loaded when the rspack commands module is imported'
);
});

describe('rspack commands module', () => {
it('registers commands without loading @rspack/core', () => {
expect(() => require('../index.js')).not.toThrow();
});

it('exposes the expected commands', () => {
const commands: typeof import('../index.js').default =
require('../index.js').default;

expect(commands.map((command) => command.name)).toEqual([
'bundle',
'webpack-bundle',
'start',
'webpack-start',
]);

for (const command of commands) {
expect(typeof command.func).toBe('function');
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { CLIError, getRspackVersion } from '../../../helpers/index.js';
import { ensureNodeSupportsRspack } from '../ensureNodeCompat.js';

jest.mock('../../../helpers/index.js', () => ({
...jest.requireActual<typeof import('../../../helpers/index.js')>(
'../../../helpers/index.js'
),
getRspackVersion: jest.fn(),
}));

const getRspackVersionMock = jest.mocked(getRspackVersion);

// `process.versions.node` is a read-only property, so it cannot be
// mocked with `jest.replaceProperty` - redefine it manually instead.
const originalNodeVersion = Object.getOwnPropertyDescriptor(
process.versions,
'node'
);

const setup = (rspackVersion: string | null, nodeVersion: string) => {
getRspackVersionMock.mockReturnValue(rspackVersion);
Object.defineProperty(process.versions, 'node', {
value: nodeVersion,
configurable: true,
});
};

afterAll(() => {
if (originalNodeVersion) {
Object.defineProperty(process.versions, 'node', originalNodeVersion);
}
});

describe('ensureNodeSupportsRspack', () => {
it('does nothing when @rspack/core is not resolvable', () => {
setup(null, '18.20.0');
expect(() => ensureNodeSupportsRspack()).not.toThrow();
});

it('does nothing for Rspack 1.x regardless of Node version', () => {
setup('1.5.8', '18.20.0');
expect(() => ensureNodeSupportsRspack()).not.toThrow();
});

it.each(['18.20.0', '20.18.3', '21.7.3', '22.11.0'])(
'throws CLIError for Rspack 2 on unsupported Node %s',
(nodeVersion) => {
setup('2.0.0', nodeVersion);
expect(() => ensureNodeSupportsRspack()).toThrow(CLIError);
}
);

it.each(['20.19.0', '20.19.4', '22.12.0', '24.0.0'])(
'does nothing for Rspack 2 on supported Node %s',
(nodeVersion) => {
setup('2.0.0', nodeVersion);
expect(() => ensureNodeSupportsRspack()).not.toThrow();
}
);

it('treats prerelease Rspack 2 versions as Rspack 2', () => {
setup('2.0.0-beta.1', '18.20.0');
expect(() => ensureNodeSupportsRspack()).toThrow(CLIError);
});

it('reports both the Rspack and Node versions in the error', () => {
setup('2.0.0', '18.20.0');
expect(() => ensureNodeSupportsRspack()).toThrow(
/Rspack 2\.0\.0 requires Node\.js .+ found 18\.20\.0/
);
});
});
94 changes: 94 additions & 0 deletions packages/repack/src/commands/rspack/__tests__/lazyCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type {
BundleArguments,
CliConfig,
StartArguments,
} from '../../types.js';
import { bundle } from '../bundle.js';
import { ensureNodeSupportsRspack } from '../ensureNodeCompat.js';
import commands from '../index.js';
import { start } from '../start.js';

// safety net: any code path that loads '@rspack/core' fails loudly
jest.mock('@rspack/core', () => {
throw new Error(
'@rspack/core must not be loaded before the Node compatibility guard runs'
);
});

jest.mock('../ensureNodeCompat.js', () => ({
ensureNodeSupportsRspack: jest.fn(),
}));
jest.mock('../bundle.js', () => ({ bundle: jest.fn() }));
jest.mock('../start.js', () => ({ start: jest.fn() }));

const guardMock = jest.mocked(ensureNodeSupportsRspack);
const bundleMock = jest.mocked(bundle);
const startMock = jest.mocked(start);

const [bundleCommand, webpackBundleCommand, startCommand, webpackStartCommand] =
commands;

const cliConfig: CliConfig = {
root: '/project',
platforms: ['ios', 'android'],
reactNativePath: '/project/node_modules/react-native',
};

const bundleArgs: BundleArguments = { platform: 'ios', dev: true };
const startArgs: StartArguments = { host: '' };

describe('lazy rspack commands', () => {
it.each([
['bundle', bundleCommand],
['webpack-bundle', webpackBundleCommand],
] as const)(
'%s runs the Node compatibility guard before delegating to bundle',
async (_name, command) => {
await command.func([], cliConfig, bundleArgs);

expect(guardMock).toHaveBeenCalledTimes(1);
expect(bundleMock).toHaveBeenCalledWith([], cliConfig, bundleArgs);
expect(guardMock.mock.invocationCallOrder[0]).toBeLessThan(
bundleMock.mock.invocationCallOrder[0]
);
}
);

it.each([
['start', startCommand],
['webpack-start', webpackStartCommand],
] as const)(
'%s runs the Node compatibility guard before delegating to start',
async (_name, command) => {
await command.func([], cliConfig, startArgs);

expect(guardMock).toHaveBeenCalledTimes(1);
expect(startMock).toHaveBeenCalledWith([], cliConfig, startArgs);
expect(guardMock.mock.invocationCallOrder[0]).toBeLessThan(
startMock.mock.invocationCallOrder[0]
);
}
);

it('does not run bundle when the guard throws', async () => {
guardMock.mockImplementationOnce(() => {
throw new Error('Rspack 2.0.0 requires Node.js ^20.19.0 || >=22.12.0');
});

await expect(bundleCommand.func([], cliConfig, bundleArgs)).rejects.toThrow(
'Rspack 2.0.0 requires Node.js'
);
expect(bundleMock).not.toHaveBeenCalled();
});

it('does not run start when the guard throws', async () => {
guardMock.mockImplementationOnce(() => {
throw new Error('Rspack 2.0.0 requires Node.js ^20.19.0 || >=22.12.0');
});

await expect(startCommand.func([], cliConfig, startArgs)).rejects.toThrow(
'Rspack 2.0.0 requires Node.js'
);
expect(startMock).not.toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions packages/repack/src/commands/rspack/ensureNodeCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import semver from 'semver';
import { CLIError, getRspackVersion } from '../../helpers/index.js';

// Rspack 2 is published as a pure ESM package and declares
// engines.node: "^20.19.0 || >=22.12.0" - the versions where
// loading ESM through require() is supported.
const RSPACK_2_NODE_RANGE = '^20.19.0 || >=22.12.0';

/**
* Fails fast with an actionable error when the installed Rspack major
* requires a newer Node.js version than the current one.
*
* Without this guard, loading `@rspack/core` v2 on an unsupported Node
* version crashes with an obscure `ERR_REQUIRE_ESM` error.
*/
export function ensureNodeSupportsRspack() {
Comment thread
dannyhw marked this conversation as resolved.
const rspackVersion = getRspackVersion();

// not resolvable - let the import of '@rspack/core' surface its own error
if (!rspackVersion) return;

if (semver.major(semver.coerce(rspackVersion) ?? '0.0.0') < 2) return;

if (semver.satisfies(process.versions.node, RSPACK_2_NODE_RANGE)) return;

throw new CLIError(
`Rspack ${rspackVersion} requires Node.js ^20.19.0 || >=22.12.0 - ` +
`found ${process.versions.node}. ` +
'Upgrade Node.js or use @rspack/core@1 instead.'
);
}
31 changes: 25 additions & 6 deletions packages/repack/src/commands/rspack/index.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,50 @@
import { bundleCommandOptions, startCommandOptions } from '../options.js';
import { bundle } from './bundle.js';
import { start } from './start.js';

import type { bundle } from './bundle.js';
import type { start } from './start.js';

// The command modules load '@rspack/core' which is ESM-only in Rspack 2,
// so they are imported lazily: first the Node version guard runs and turns
// an unsupported setup into an actionable error instead of ERR_REQUIRE_ESM,
// and loading the project config stays free of bundler imports.
const lazyBundle = async (...args: Parameters<typeof bundle>) => {
const { ensureNodeSupportsRspack } = await import('./ensureNodeCompat.js');
ensureNodeSupportsRspack();
const { bundle } = await import('./bundle.js');
return bundle(...args);
};

const lazyStart = async (...args: Parameters<typeof start>) => {
const { ensureNodeSupportsRspack } = await import('./ensureNodeCompat.js');
ensureNodeSupportsRspack();
const { start } = await import('./start.js');
return start(...args);
};

const commands = [
{
name: 'bundle',
description: 'Build the bundle for the provided JavaScript entry file.',
options: bundleCommandOptions,
func: bundle,
func: lazyBundle,
},
{
name: 'webpack-bundle',
description: 'Build the bundle for the provided JavaScript entry file.',
options: bundleCommandOptions,
func: bundle,
func: lazyBundle,
},
{
name: 'start',
description: 'Start the React Native development server.',
options: startCommandOptions,
func: start,
func: lazyStart,
},
{
name: 'webpack-start',
description: 'Start the React Native development server.',
options: startCommandOptions,
func: start,
func: lazyStart,
},
] as const;

Expand Down
8 changes: 4 additions & 4 deletions packages/repack/src/commands/rspack/profile/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { rspackVersion } from '@rspack/core';
import { getRspackVersion } from '../../../helpers/index.js';

function getRspackVersion() {
function getInstalledRspackVersion() {
// get rid of `-beta`, `-rc` etc.
const version = rspackVersion.split('-')[0];
const version = (getRspackVersion() ?? '0.0.0').split('-')[0];
const [major, minor, patch] = version.split('.').map(Number);
return { major, minor, patch };
}

async function getProfilingHandler() {
const { major, minor } = getRspackVersion();
const { major, minor } = getInstalledRspackVersion();
if (major > 1 || (major === 1 && minor >= 4)) {
return await import('./profile-1.4.js');
}
Expand Down
Loading
Loading