diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f485395..5c310da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## unreleased + +### pack, serve, eject + +* Added experimental support for the Vite bundler, enabled with the `--vite` option or the `ENACT_BUNDLER=vite` environment variable. + ## 7.3.3 (July 7, 2026) ### transpile diff --git a/commands/eject.js b/commands/eject.js index 10734c20..17e58865 100755 --- a/commands/eject.js +++ b/commands/eject.js @@ -50,6 +50,32 @@ const bareTasks = { test: 'jest --config config/jest/jest.config.js', 'test-watch': 'jest --config config/jest/jest.config.js --watch' }; +// Vite variants of the barebones setup (used with `--vite`). Vite copies `public/` +// automatically (no `cpy` step) and loads the generated root `vite.config.mjs`. +// Reuse the same `rimraf` pin as the webpack bare setup so the version lives in one place. +const bareDepsVite = {rimraf: bareDeps.rimraf}; +const bareTasksVite = { + serve: 'vite', + pack: 'vite build --mode development', + 'pack-p': 'vite build', + watch: 'vite build --watch --mode development', + clean: 'rimraf build dist', + lint: bareTasks.lint, + license: bareTasks.license, + test: bareTasks.test, + 'test-watch': bareTasks['test-watch'] +}; +// Bundler-driven scripts that understand `--vite`; used to steer a non-bare Vite eject. +const VITE_CAPABLE_SCRIPTS = ['serve', 'pack']; +// The Enact Vite config (config/vite.config.js) is a factory `(mode) => InlineConfig`, +// not the object/`{command, mode}` shape Vite's CLI expects. A bare Vite eject writes +// this thin root config to adapt it so `vite` / `vite build` work directly. +const VITE_ROOT_CONFIG = + "import {createRequire} from 'module';\n" + + "const require = createRequire(import.meta.url);\n" + + '// config/vite.config.js exports `(mode) => InlineConfig`; Vite calls with {command, mode}.\n' + + "const enactViteConfig = require('./config/vite.config.js');\n" + + "export default ({mode}) => enactViteConfig(mode || 'production');\n"; function displayHelp () { let e = 'node ' + path.relative(process.cwd(), __filename); @@ -62,6 +88,9 @@ function displayHelp () { console.log(' -b, --bare Abandon Enact CLI command enhancements'); console.log(' and eject into a a barebones setup (using'); console.log(' webpack, eslint, karma, etc. directly)'); + console.log(' --vite [Experimental] Use Vite instead of webpack:'); + console.log(' alone, points the serve/pack scripts at the'); + console.log(' Vite path; with --bare, emits a bare Vite setup'); console.log(' -v, --version Display version information'); console.log(' -h, --help Display help information'); console.log(); @@ -154,12 +183,15 @@ function copySanitizedFile ({src, dest}) { fs.writeFileSync(dest, data, {encoding: 'utf8'}); } -function configurePackage (bare) { +function configurePackage (bare, vite) { const own = require('../package.json'); const app = require(path.resolve('package.json')); const backup = JSON.stringify(app, null, 2) + os.EOL; const availScripts = fs.existsSync('./scripts') ? fs.readdirSync('./scripts').map(f => f.replace(/\.js$/, '')) : []; const enactCLI = new RegExp('enact (' + availScripts.join('|') + ')', 'g'); + // Select the webpack or Vite flavor of the barebones tasks/deps. + const tasks = vite ? bareTasksVite : bareTasks; + const deps = vite ? bareDepsVite : bareDeps; const eslintConfig = {extends: 'enact'}; const eslintIgnore = ['build/*', 'config/*', 'dist/*', 'node_modules/*', 'scripts/*']; const conflicts = []; @@ -182,9 +214,9 @@ function configurePackage (bare) { // Add any additional dependencies if (bare) { - Object.keys(bareDeps).forEach(key => { + Object.keys(deps).forEach(key => { console.log(` Adding ${chalk.cyan(key)} to devDependencies`); - app.devDependencies[key] = bareDeps[key]; + app.devDependencies[key] = deps[key]; }); } @@ -193,16 +225,20 @@ function configurePackage (bare) { // Update NPM task scripts const type = chalk.cyan('npm script'); Object.keys(app.scripts).forEach(key => { - if (bare && bareTasks[key]) { + if (bare && tasks[key]) { if (!conflicts.includes(type)) conflicts.push(type); - const bin = bareTasks[key].match(/^(?:node\s+)*(\S*)/); - const updated = (bin && bin[1]) || bareTasks[key]; + const bin = tasks[key].match(/^(?:node\s+)*(\S*)/); + const updated = (bin && bin[1]) || tasks[key]; console.log(` Updating npm task ${chalk.cyan(key)} to use ${chalk.cyan(updated)}`); - app.scripts[key] = bareTasks[key]; + app.scripts[key] = tasks[key]; } else if (!bare) { app.scripts[key] = app.scripts[key].replace(enactCLI, (match, name) => { - console.log(` Updating npm task ${chalk.cyan(key)} to use ` + chalk.cyan(`scripts/${name}.js`)); - return `node ./scripts/${name}.js`; + // In a non-bare Vite eject, steer the bundler-driven scripts down the + // Vite path so `npm run serve`/`pack` use Vite, not webpack. Only the + // commands that understand the flag (serve, pack) get it. + const viteFlag = vite && VITE_CAPABLE_SCRIPTS.includes(name) ? ' --vite' : ''; + console.log(` Updating npm task ${chalk.cyan(key)} to use ` + chalk.cyan(`scripts/${name}.js${viteFlag}`)); + return `node ./scripts/${name}.js${viteFlag}`; }); } }); @@ -259,7 +295,7 @@ function npmInstall () { }); } -function api ({bare = false} = {}) { +function api ({bare = false, vite = false} = {}) { if (bare) { assets.pop(); } @@ -270,9 +306,15 @@ function api ({bare = false} = {}) { console.log(chalk.cyan(`Copying files into ${process.cwd()}`)); assets.forEach(dir => !fs.existsSync(dir.dest) && fs.mkdirSync(dir.dest, {recursive: true})); files.forEach(copySanitizedFile); + // A bare Vite eject drives the Vite CLI directly, which loads a root + // config; write the adapter that wires it to config/vite.config.js. + if (bare && vite) { + console.log(` Adding ${chalk.cyan('vite.config.mjs')} to the project`); + fs.writeFileSync('vite.config.mjs', VITE_ROOT_CONFIG, {encoding: 'utf8'}); + } console.log(); console.log(chalk.cyan('Configuring package.json')); - const con = configurePackage(bare); + const con = configurePackage(bare, vite); console.log(); console.log(chalk.cyan('Running npm install...')); return npmInstall().then(() => { @@ -298,7 +340,7 @@ function api ({bare = false} = {}) { function cli (args) { const opts = minimist(args, { - boolean: ['bare', 'help'], + boolean: ['bare', 'vite', 'help'], alias: {b: 'bare', h: 'help'} }); if (opts.help) displayHelp(); @@ -307,7 +349,7 @@ function cli (args) { import('chalk').then(({default: _chalk}) => { chalk = _chalk; - api({bare: opts.bare}).catch(err => { + api({bare: opts.bare, vite: opts.vite}).catch(err => { console.error(chalk.red('ERROR: ') + err.message); process.exit(1); }); diff --git a/commands/pack.js b/commands/pack.js index cb937de1..e22b9db8 100755 --- a/commands/pack.js +++ b/commands/pack.js @@ -20,6 +20,12 @@ const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages'); const printBuildError = require('react-dev-utils/printBuildError'); const webpack = require('webpack'); const {optionParser: app, mixins, configHelper: helper} = require('@enact/dev-utils'); +const viteFw = require('@enact/dev-utils/mixins/vite-framework'); +const viteIso = require('@enact/dev-utils/mixins/vite-isomorphic'); +const viteSnap = require('@enact/dev-utils/mixins/vite-snapshot'); +const {parseLocales} = require('@enact/dev-utils/plugins/PrerenderPlugin/parse-locales'); + +const {isViteBundler} = require('./vite-utils'); let chalk; let stripAnsi; @@ -52,6 +58,7 @@ function displayHelp () { console.log(' -c, --custom-skin Build with a custom skin'); console.log(' --no-linting Build without code linting'); console.log(' --no-animation Build without effects such as animation and shadow'); + console.log(' --vite [Experimental] Build with Vite instead of webpack'); console.log(' --stats Output bundle analysis file'); console.log(' --verbose Verbose log build details'); console.log(' -v, --version Display version information'); @@ -113,7 +120,7 @@ function details (err, stats, output) { console.log( chalk.yellow( '\nTreating warnings as errors because process.env.CI = true. \n' + - 'Most CI servers set it automatically.\n' + 'Most CI servers set it automatically.\n' ) ); return new Error(filteredWarnings.join('\n\n')); @@ -130,7 +137,7 @@ function details (err, stats, output) { console.log( chalk.yellow( 'NOTICE: This build contains debugging functionality and may run' + - ' slower than in production mode.' + ' slower than in production mode.' ) ); } @@ -186,7 +193,7 @@ function printErrorDetails (err, handler) { console.log( chalk.yellow( 'Compiled with the following type errors (you may want to check ' + - 'these before deploying your app):\n' + 'these before deploying your app):\n' ) ); printBuildError(err); @@ -197,6 +204,234 @@ function printErrorDetails (err, handler) { } } + +// Build the shared Enact framework bundle (react + ilib + all @enact) as reusable ESM +// addressed by an import map, plus a manifest. Vite counterpart to webpack `pack --framework`. +async function viteFramework (opts) { + const {createRequire} = require('module'); + const {build: viteBuildApi} = require('vite'); + const appRequire = createRequire(path.join(app.context, 'package.json')); + + const specs = viteFw.enumerateSpecifiers(app.context, {polyfill: opts['externals-polyfill']}); + // Building --framework inside a theme repo: also include the theme's own components + // (webpack's `libraries.push('.')`), which aren't in the repo's node_modules/@enact. + const self = viteFw.enumerateSelfSpecs(app.context); + const allSpecs = self ? specs.concat(self.specs.filter(s => !specs.includes(s))) : specs; + const selfSet = self ? new Set(self.specs) : null; + const srcDir = path.join(app.context, '.enact-framework-src'); + const {input, names} = viteFw.writeWrappers(allSpecs, srcDir, appRequire, selfSet); + + const configFactory = require('../config/vite.config'); + const config = configFactory(opts.production ? 'production' : 'development', !opts.linting); + const outDir = opts.output ? path.resolve(opts.output) : path.resolve('./dist'); + viteFw.applyFramework(config, {input, outDir, selfAlias: self && {find: self.name, replacement: self.root}}); + // --no-minify/--verbose/--stats still apply to the framework build. + mixins.applyVite(config, opts); + + console.log(`Creating the Enact framework bundle (${Object.keys(input).length} modules)...`); + await viteBuildApi(config); + const manifest = viteFw.writeManifest(outDir, names); + fs.removeSync(srcDir); + console.log( + chalk.green(`Framework compiled successfully. (${Object.keys(manifest.imports).length} specifiers)`) + ); +} + +// Isomorphic (prerendered) Vite build. Client build (hydrateRoot) + a real `vite build --ssr` +// of the app entry, then per-locale server render (FileXHR for iLib data) and assembly into +// the webpack-compatible output (fallback index.html + deduped index..html + +// locale-map.json + per-locale webOS appinfo). Vite counterpart to webpack `pack --isomorphic`. +async function viteIsomorphic (opts) { + const {createRequire} = require('module'); + const {build: viteBuildApi} = require('vite'); + const appRequire = createRequire(path.join(app.context, 'package.json')); + const configFactory = require('../config/vite.config'); + + const locales = opts.locales ? parseLocales(app.context, opts.locales) || ['en-US'] : ['en-US']; + const outDir = opts.output ? path.resolve(opts.output) : path.resolve('./dist'); + const serverEntry = path.resolve(opts.entry || app.entry || path.join(app.context, 'src/index.js')); + const ssrOut = path.join(app.context, '.enact-ssr'); + + console.log( + opts.snapshot ? + `Creating a V8 snapshot production build (${locales.length} locale(s))...` : + `Creating an isomorphic production build (${locales.length} locale(s))...` + ); + + // 1) Client build (isomorphic ON → the app entry uses hydrateRoot). + const clientConfig = configFactory( + opts.production ? 'production' : 'development', + !opts.linting, + opts['content-hash'], + true /* isomorphic */, + !opts.animation, + !opts['split-css'], + opts['ilib-additional-path'], + opts.locales + ); + if (opts.output) clientConfig.build.outDir = outDir; + // --externals: externalize the shared framework from the CLIENT build (browser loads it via + // import map). The SSR build below always bundles @enact so it can render. CSS-module hashes + // stay consistent because both reuse the factory (same rootContext) as the framework build. + const collected = new Set(); + let manifest = null; + if (opts.externals) { + manifest = viteFw.readManifest(path.resolve(opts.externals)); + viteFw.applyExternals(clientConfig, collected, manifest, {polyfill: opts['externals-polyfill']}); + } + // --snapshot: build the client as a self-contained UMD bundle (App global) that mksnapshot + // can snapshot. Implies isomorphic; --snapshot + --externals is unsupported (the snapshot + // must embed @enact, not externalize it), matching webpack (`opts.snapshot && !opts.externals`). + if (opts.snapshot && !opts.externals) { + viteSnap.applySnapshotBuild(clientConfig, {context: app.context, appEntry: serverEntry}); + } + mixins.applyVite(clientConfig, opts); + await viteBuildApi(clientConfig); + + // Inject the framework import map (+ shared stylesheet) into the client index.html BEFORE + // the isomorphic assembly transforms it into the fallback/variant files. + if (opts.externals) { + let base = opts['externals-public']; + if (!base) { + fs.copySync(path.resolve(opts.externals), path.join(outDir, 'framework'), {dereference: true}); + base = './framework'; + } + viteFw.injectHtml(path.join(outDir, 'index.html'), manifest, collected, base); + } + + // 2) SSR build (Node-loadable CJS whose default export is the app element). + const ssrConfig = configFactory(opts.production ? 'production' : 'development', true, false, true); + viteIso.applySsrBuild(ssrConfig, {serverEntry, outDir: ssrOut}); + await viteBuildApi(ssrConfig); + + // 3) Per-locale prerender. Load the SSR bundle fresh for each locale so iLib re-initializes. + const bundlePath = path.join(ssrOut, 'app.server.cjs'); + const ssrRequire = createRequire(path.join(ssrOut, 'noop.js')); + const {renderToString} = appRequire('react-dom/server'); + const load = () => { + Object.keys(ssrRequire.cache || {}).forEach(k => { + if (k.startsWith(ssrOut)) delete ssrRequire.cache[k]; + }); + const mod = ssrRequire(bundlePath); + // eslint-disable-next-line no-undefined + return mod && mod.default !== undefined ? mod.default : mod; + }; + const {prerenders, attr, aliasOf} = viteIso.prerender({ + locales, + load, + renderToString, + fontGenerator: app.fontGenerator + }); + + // 4) Assemble HTML + locale-map, then (5) webOS per-locale appinfo. + const {localeMap} = viteIso.assemble({ + outDir, locales, prerenders, attr, aliasOf, + screenTypes: app.screenTypes || [], + snapshot: !!opts.snapshot + }); + viteIso.writeAppinfo({outDir, locales, localeMap}); + + fs.removeSync(ssrOut); + console.log( + chalk.cyan(`Prerendered ${locales.length} locale(s) into ${prerenders.length} variant(s).`) + ); + + // 6) V8 snapshot: run mksnapshot against the UMD main.js and record the blob in appinfo. + // Requires the webOS `V8_MKSNAPSHOT` toolchain; without it the build still succeeds and + // the startup script falls back to loading main.js (classic