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
19 changes: 12 additions & 7 deletions src/builder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,16 @@ export function getContentHash(content: string, length = 8) {
return createHash('md5').update(content).digest('hex').slice(0, length);
}

function toUmdExternal(name: string) {
// Preserve explicitly typed and whitespace-containing values.
if (!name || /\s/.test(name)) return name;

// Webpack UMD uses the same request for CommonJS and browser globals.
return { root: name, commonjs: name };
}

/**
* Convert webpack externals configuration to utoopack externals format
* Convert webpack UMD externals configuration to utoopack externals format
* @param externals webpack externals configuration
* @returns utoopack externals configuration
*/
Expand All @@ -184,15 +192,13 @@ export function convertExternalsToUtooPackExternals(

// Handle different webpack externals formats
if (typeof externals === 'string') {
// Single string external: "lodash" -> { "lodash": "lodash" }
return { [externals]: externals };
return { [externals]: toUmdExternal(externals) };
}

if (Array.isArray(externals)) {
// Array of externals: ["lodash", "react"] -> { "lodash": "lodash", "react": "react" }
return externals.reduce((acc, external) => {
if (typeof external === 'string') {
acc[external] = external;
acc[external] = toUmdExternal(external);
} else if (typeof external === 'object' && external !== null) {
Object.assign(acc, convertExternalsToUtooPackExternals(external));
}
Expand All @@ -219,8 +225,7 @@ export function convertExternalsToUtooPackExternals(
result[key] = value;
}
} else {
// Simple string mapping: { "react": "React" }
result[key] = value;
result[key] = toUmdExternal(value);
}
} else if (Array.isArray(value)) {
// Array format handling
Expand Down
75 changes: 75 additions & 0 deletions tests/utoo-pack.config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Joi from '@umijs/utils/compiled/@hapi/joi';
import { mergeUtooPackConfig } from '../src/builder/bundle';
import type { IBundleConfig } from '../src/builder/config';
import { convertExternalsToUtooPackExternals } from '../src/builder/utils';
import { getSchemas } from '../src/features/configPlugins/schema';

test('utoopack: merges user config into father defaults', () => {
Expand Down Expand Up @@ -57,3 +58,77 @@ test('utoopack: accepts config passthrough in father schema', () => {

expect(result.error).toBeUndefined();
});

describe('utoopack UMD externals', () => {
test('preserves the external request name for CommonJS and browser consumers', () => {
expect(
convertExternalsToUtooPackExternals({
react: 'React',
'react-dom': 'ReactDOM',
}),
).toEqual({
react: { root: 'React', commonjs: 'React' },
'react-dom': { root: 'ReactDOM', commonjs: 'ReactDOM' },
});
});

test('supports a single module name', () => {
expect(convertExternalsToUtooPackExternals('react')).toEqual({
react: { root: 'react', commonjs: 'react' },
});
});

test('supports mixed arrays without dropping UMD semantics', () => {
expect(
convertExternalsToUtooPackExternals([
'react',
{ 'react-dom': 'ReactDOM' },
]),
).toEqual({
react: { root: 'react', commonjs: 'react' },
'react-dom': { root: 'ReactDOM', commonjs: 'ReactDOM' },
});
});

test.each([
'commonjs React',
'commonjs2 React',
'module react',
'esm react',
'global React',
'var React',
'window React',
'promise Promise.resolve(React)',
'script React@https://example.com/react.js',
])('preserves an explicitly typed external: %s', (external) => {
expect(convertExternalsToUtooPackExternals({ react: external })).toEqual({
react: external,
});
});

test('preserves explicit objects and script shorthand', () => {
const externals = {
react: { root: 'React', commonjs: 'react', commonjs2: 'react' },
jquery: { root: 'jQuery', type: 'global' },
};
expect(convertExternalsToUtooPackExternals(externals)).toEqual(externals);
expect(
convertExternalsToUtooPackExternals({
react: 'React@https://example.com/react.js',
}),
).toEqual({ react: 'script React@https://example.com/react.js' });
});

test.each(['', 'React .Component', 'React\t.Component'])(
'leaves empty and whitespace-containing values unchanged: %j',
(external) => {
expect(convertExternalsToUtooPackExternals({ react: external })).toEqual({
react: external,
});
},
);

test('keeps absent externals absent', () => {
expect(convertExternalsToUtooPackExternals()).toBeUndefined();
});
});
188 changes: 188 additions & 0 deletions tests/utoo-pack.externals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { URL } from 'url';
import vm from 'vm';

const rootPath = path.resolve(__dirname, '..');
const fixtures: string[] = [];
const libraryName = 'ExternalsRuntimeFixture';
const externalName = 'HostDependency';

type LoadMode = 'commonjs' | 'umd-loader' | 'browser';
interface HostDependency {
state: { value: number };
}
interface Library {
dependency: HostDependency;
namedState: HostDependency['state'];
increment(): number;
}

function build(bundler: 'webpack' | 'utoopack') {
// Keep the fixture inside rootPath so Pack can read its source and loaders.
const cwd = fs.mkdtempSync(path.join(__dirname, '.tmp-umd-externals-'));
fixtures.push(cwd);
fs.mkdirSync(path.join(cwd, 'src'));
fs.writeFileSync(
path.join(cwd, 'package.json'),
JSON.stringify({ name: 'externals-runtime-fixture', version: '1.0.0' }),
);
fs.writeFileSync(
path.join(cwd, 'src/index.js'),
`import dependency, { state } from 'host-dep';
export { dependency };
export const namedState = state;
export function increment() {
dependency.state.value += 1;
return dependency.state.value;
}
`,
);
fs.writeFileSync(
path.join(cwd, '.fatherrc.js'),
`export default ${JSON.stringify({
umd: {
entry: 'src/index.js',
name: libraryName,
bundler,
rootPath,
output: { path: 'dist', filename: 'externals.min.js' },
externals: { 'host-dep': externalName },
},
})};`,
);

// Run the real CLI/service/build pipeline in a child process, loading source
// with tsx so this regression never accidentally tests a stale dist build.
// No bundler mocks or parent-process environment changes are involved.
let output: string;
try {
output = execFileSync(
process.execPath,
[
'--require',
require.resolve('tsx/cjs'),
'-e',
`require(${JSON.stringify(
path.join(rootPath, 'src/cli/cli.ts'),
)}).run({ args: { _: ['build'], $0: 'node' } }).catch(error => {
console.error(error);
process.exitCode = 1;
});`,
],
{
cwd,
env: {
...process.env,
APP_ROOT: cwd,
FATHER_CACHE: 'none',
NODE_ENV: 'production',
TURBO_TASKS_AVAILABLE_PARALLELISM: '2',
},
encoding: 'utf-8',
stdio: 'pipe',
timeout: 120000,
maxBuffer: 10 * 1024 * 1024,
},
);
} catch (error: any) {
throw new Error(
`${bundler} CLI build failed:\n${error.stdout || ''}\n${
error.stderr || error.message
}`,
);
}

const outputFile = path.join(cwd, 'dist/externals.min.js');
if (!fs.existsSync(outputFile)) {
throw new Error(`${bundler} did not emit its UMD bundle:\n${output}`);
}
return fs.readFileSync(outputFile, 'utf-8');
}

function load(code: string, mode: LoadMode, host: HostDependency) {
const required: string[] = [];
const inject = (name: string) => {
required.push(name);
if (name !== externalName) {
throw new Error(`Unexpected external request: ${name}`);
}
return host;
};
const sandbox: Record<string, any> = { URL };
sandbox.self = sandbox;
const context = vm.createContext(sandbox);
let library: Library;

if (mode === 'browser') {
// No module/exports/require: the UMD must use the host's browser global.
sandbox[externalName] = host;
vm.runInContext(code, context, { timeout: 5000 });
library = sandbox[libraryName];
} else {
// A CommonJS host injects require without defining HostDependency globally.
const module = { exports: {} };
expect(externalName in sandbox).toBe(false);
if (mode === 'commonjs') {
Object.assign(sandbox, {
module,
exports: module.exports,
require: inject,
});
vm.runInContext(code, context, { timeout: 5000 });
} else {
// @alipay/umd-loader evaluates UMD with module/exports/require function
// arguments. Exercise that branch without adding a dependency global.
const evaluate = vm.runInContext(
`(function(module, exports, require) {\n${code}\n})`,
context,
{ timeout: 5000 },
);
evaluate(module, module.exports, inject);
}
expect(externalName in sandbox).toBe(false);
library = module.exports as Library;
}

return { library, required };
}

afterAll(() => {
fixtures.forEach((cwd) => fs.rmSync(cwd, { recursive: true, force: true }));
});

// Webpack is a real-build oracle for the public father externals contract; both
// bundlers execute identical source/configuration and the same runtime checks.
describe.each(['webpack', 'utoopack'] as const)(
'%s UMD externals runtime',
(bundler) => {
let code: string;
beforeAll(() => {
code = build(bundler);
});

test.each<LoadMode>(['commonjs', 'umd-loader', 'browser'])(
'uses the supplied dependency singleton in %s',
(mode) => {
const host = { state: { value: 41 } };
const { library, required } = load(code, mode, host);

expect(Object.keys(library).sort()).toEqual([
'dependency',
'increment',
'namedState',
]);
expect(library.dependency).toBe(host);
expect(library.namedState).toBe(host.state);
expect(library.increment()).toBe(42);
expect(host.state.value).toBe(42);
host.state.value = 50;
expect(library.increment()).toBe(51);

if (mode === 'browser') expect(required).toEqual([]);
else expect(new Set(required)).toEqual(new Set([externalName]));
},
);
},
);
Loading