diff --git a/bun-plugins/framework.js b/bun-plugins/framework.js new file mode 100644 index 0000000..9ce248f --- /dev/null +++ b/bun-plugins/framework.js @@ -0,0 +1,25 @@ +const path = require('path'); +const fs = require('fs'); + +function resolveCliFramework() { + const roots = [ + path.join(__dirname, '..', '..', 'cli', 'config', 'bun', 'framework.js'), + path.join(__dirname, '..', '..', '..', 'cli', 'config', 'bun', 'framework.js') + ]; + for (const frameworkPath of roots) { + if (fs.existsSync(frameworkPath)) { + return require(frameworkPath); + } + } + return null; +} + +async function applyFramework(options = {}) { + const cliFramework = resolveCliFramework(); + if (cliFramework) { + return cliFramework.applyFramework(options); + } + throw new Error('Enact CLI Bun framework module not found.'); +} + +module.exports = {applyFramework}; diff --git a/bun-plugins/index.js b/bun-plugins/index.js new file mode 100644 index 0000000..1034dfb --- /dev/null +++ b/bun-plugins/index.js @@ -0,0 +1,6 @@ +module.exports = { + applyPostBuild: require('./post-build').applyPostBuild, + applyPrerender: require('./prerender').applyPrerender, + applyFramework: require('./framework').applyFramework, + applySnapshot: require('./snapshot').applySnapshot +}; diff --git a/bun-plugins/post-build.js b/bun-plugins/post-build.js new file mode 100644 index 0000000..492c6b1 --- /dev/null +++ b/bun-plugins/post-build.js @@ -0,0 +1,18 @@ +const path = require('path'); + +function resolveCliModule(subpath) { + const roots = [ + path.join(__dirname, '..', '..', 'cli', subpath), + path.join(__dirname, '..', '..', '..', 'cli', subpath) + ]; + for (const candidate of roots) { + try { + return require(candidate); + } catch (e) { + // continue + } + } + throw new Error('Unable to resolve CLI Bun module: ' + subpath); +} + +module.exports = resolveCliModule('config/bun/post-build.js'); diff --git a/bun-plugins/prerender.js b/bun-plugins/prerender.js new file mode 100644 index 0000000..4f073b6 --- /dev/null +++ b/bun-plugins/prerender.js @@ -0,0 +1,7 @@ +const PrerenderPlugin = require('../plugins/PrerenderPlugin'); + +function applyPrerender(options = {}) { + return PrerenderPlugin.applyBunPostBuild(options); +} + +module.exports = {applyPrerender, parseLocales: PrerenderPlugin.parseLocales}; diff --git a/bun-plugins/snapshot.js b/bun-plugins/snapshot.js new file mode 100644 index 0000000..951983b --- /dev/null +++ b/bun-plugins/snapshot.js @@ -0,0 +1,18 @@ +const path = require('path'); + +function resolveCliModule(subpath) { + const roots = [ + path.join(__dirname, '..', '..', 'cli', subpath), + path.join(__dirname, '..', '..', '..', 'cli', subpath) + ]; + for (const candidate of roots) { + try { + return require(candidate); + } catch (e) { + // continue + } + } + throw new Error('Unable to resolve CLI Bun module: ' + subpath); +} + +module.exports = resolveCliModule('config/bun/snapshot.js'); diff --git a/index.js b/index.js index acb88a2..ff061dd 100644 --- a/index.js +++ b/index.js @@ -28,3 +28,8 @@ exportOnDemand({ VerboseLogPlugin: () => require('./plugins/VerboseLogPlugin'), WebOSMetaPlugin: () => require('./plugins/WebOSMetaPlugin') }); + +// Export Bun build helpers. +exportOnDemand({ + bunPlugins: () => require('./bun-plugins') +}); diff --git a/plugins/PrerenderPlugin/FileXHR.js b/plugins/PrerenderPlugin/FileXHR.js index b9f0cc4..8fb5c1a 100644 --- a/plugins/PrerenderPlugin/FileXHR.js +++ b/plugins/PrerenderPlugin/FileXHR.js @@ -1,4 +1,135 @@ const fs = require('fs'); +const path = require('path'); + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function untransformPath(uri) { + return uri + .replace(/\\/g, '/') + .replace(/(^|\/)(_)($|\/)/g, (match, before, segment, after) => before + '..' + (after || '')); +} + +const ILIB_URL_PREFIXES = [ + '/node_modules/ilib', + '/node_modules/@enact/i18n/ilib', + '/node_modules/_enact/i18n/ilib', + 'node_modules/ilib', + 'node_modules/@enact/i18n/ilib', + 'node_modules/_enact/i18n/ilib' +]; + +function normalizePathSlashes(filePath) { + return filePath.replace(/\\/g, '/'); +} + +function resolveIlibReplacement() { + if (process.env.ILIB_FS_PATH) { + return normalizePathSlashes(process.env.ILIB_FS_PATH); + } + + const basePath = normalizePathSlashes(process.env.ILIB_BASE_PATH || ''); + if (/i18n[/\\]ilib[/\\]*$/.test(basePath)) { + return 'node_modules/@enact/i18n/ilib'; + } + + return 'node_modules/ilib'; +} + +function getIlibUrlPrefixes() { + const prefixes = [process.env.ILIB_BASE_PATH, ...ILIB_URL_PREFIXES].filter(Boolean).map(normalizePathSlashes); + + return [...new Set(prefixes)]; +} + +function tryResolvePath(candidate) { + return fs.existsSync(candidate) ? candidate : null; +} + +function resolveFromIlibPrefix(filePath, prefix, cwd) { + if (!prefix || !filePath.startsWith(prefix)) { + return null; + } + + const suffix = filePath.slice(prefix.length).replace(/^\//, ''); + const fsPath = process.env.ILIB_FS_PATH; + + if (fsPath) { + const fromFs = tryResolvePath(path.join(fsPath, suffix)); + if (fromFs) { + return fromFs; + } + } + + const replacement = resolveIlibReplacement(); + const relative = normalizePathSlashes(path.join(replacement, suffix)); + + let resolved = tryResolvePath(path.resolve(cwd, relative)); + if (resolved) { + return resolved; + } + + if (relative.includes('@enact/i18n/ilib')) { + resolved = tryResolvePath(path.resolve(cwd, relative.replace('@enact/i18n/ilib', 'ilib'))); + if (resolved) { + return resolved; + } + } + + return null; +} + +function isWebRootNodeModulesPath(filePath) { + return /^\/node_modules\//.test(filePath); +} + +function resolveFilePath(uri) { + const cwd = process.env.ILIB_CONTEXT || process.cwd(); + let filePath = normalizePathSlashes(uri); + + for (const prefix of getIlibUrlPrefixes()) { + const fromPrefix = resolveFromIlibPrefix(filePath, prefix, cwd); + if (fromPrefix) { + return fromPrefix; + } + } + + if (process.env.ILIB_BASE_PATH) { + const replacement = resolveIlibReplacement(); + filePath = filePath.replace( + new RegExp('^' + escapeRegExp(normalizePathSlashes(process.env.ILIB_BASE_PATH))), + replacement + ); + } + + filePath = untransformPath(filePath); + + if (isWebRootNodeModulesPath(filePath)) { + const fromWebRoot = tryResolvePath(path.resolve(cwd, filePath.replace(/^\//, ''))); + if (fromWebRoot) { + return fromWebRoot; + } + } + + if (path.isAbsolute(filePath) && !isWebRootNodeModulesPath(filePath)) { + return filePath; + } + + const resolved = path.resolve(cwd, filePath); + if (fs.existsSync(resolved)) { + return resolved; + } + + if (filePath.includes('@enact/i18n/ilib')) { + const fallback = path.resolve(cwd, filePath.replace('@enact/i18n/ilib', 'ilib')); + if (fs.existsSync(fallback)) { + return fallback; + } + } + + return resolved; +} function FileXHR() {} @@ -14,20 +145,9 @@ FileXHR.prototype.addEventListener = function (evt, fn) { FileXHR.prototype.send = function () { if (this.method.toUpperCase() === 'GET' && this.uri && this.sync) { - if (process.env.ILIB_BASE_PATH) { - // Backward compatability for old iLib location - if (/i18n[/\\]ilib[/\\]*$/.test(process.env.ILIB_BASE_PATH)) { - this.uri = this.uri.replace( - new RegExp('^' + process.env.ILIB_BASE_PATH), - 'node_modules/@enact/i18n/ilib' - ); - } else { - this.uri = this.uri.replace(new RegExp('^' + process.env.ILIB_BASE_PATH), 'node_modules/ilib'); - } - } - const parsedURI = this.uri.replace(/\\/g, '/').replace(/^(_\/)+/g, match => match.replace(/_/g, '..')); + const parsedURI = resolveFilePath(this.uri); try { - if (!fs.existsSync(parsedURI)) throw new Error('File not found: ' + this.uri); + if (!fs.existsSync(parsedURI)) throw new Error('File not found: ' + parsedURI); this.response = this.responseText = fs.readFileSync(parsedURI, {encoding: 'utf8'}); this.status = 200; diff --git a/plugins/PrerenderPlugin/index.js b/plugins/PrerenderPlugin/index.js index 85c6376..34ea72e 100644 --- a/plugins/PrerenderPlugin/index.js +++ b/plugins/PrerenderPlugin/index.js @@ -622,4 +622,136 @@ function emitAsset(compilation, name, data) { // Usage: PrerenderPlugin.getHooks(compilation).HOOK_NAME.tapAsync('YourPluginName', () => { ... }); PrerenderPlugin.getHooks = getPrerenderPluginHooks; +function stageBunChunk(code, opts) { + if (/__webpack_require__\.e\s*=/.test(code)) { + code = code.replace('__webpack_require__.e =', '__webpack_require__.e = function() {}; var origE ='); + } + if (/function webpackAsyncContext\(req\)/.test(code)) { + code = code.replace( + 'function webpackAsyncContext(req) {', + 'function webpackAsyncContext(req) {\n\treturn new Promise(function() {});' + ); + } + + // Do not regex-replace bare window/document — that corrupts string literals + // (e.g. "window" → "undefined"). vdom-server-render loads the chunk in a vm + // context that omits those globals instead. + vdomServer.stage(code, opts); +} + +function applyBunPostBuild(options = {}) { + const opts = Object.assign( + { + chunk: 'main.js', + locales: 'en-US', + mapfile: 'locale-map.json', + publicPath: '/', + screenTypes: [], + deep: null, + externals: null, + fontGenerator: null, + externalStartup: false + }, + options + ); + const chunkPath = path.join(opts.output, opts.chunk); + if (!fs.existsSync(chunkPath)) { + throw new Error('PrerenderPlugin: Unable to find build chunk at ' + chunkPath); + } + const code = fs.readFileSync(chunkPath, {encoding: 'utf8'}); + const locales = parseLocales(opts.context, opts.locales); + const server = opts.server || path.join(opts.context, 'node_modules', 'react-dom', 'server.js'); + const status = {prerender: [], attr: [], alias: []}; + + stageBunChunk(code, {chunk: opts.chunk, externals: opts.externals}); + for (let i = 0; i < locales.length; i++) { + try { + const renderOpts = { + server, + locale: locales[i], + externals: opts.externals, + fontGenerator: opts.fontGenerator, + context: opts.context + }; + let appHtml = vdomServer.render(renderOpts); + status.attr[i] = {classes: ''}; + appHtml = appHtml.replace( + /(]*class="((?!enact-locale-)[^"])*)(\senact-locale-[^"]*)"/i, + (match, before, s, classAttr) => { + status.attr[i].classes = classAttr; + return before + '"'; + } + ); + const index = status.prerender.indexOf(appHtml); + if (index === -1) { + status.prerender[i] = appHtml; + } else { + status.alias[i] = locales[index]; + } + } catch (e) { + status.err = {locale: locales[i], result: e}; + break; + } + } + vdomServer.unstage(); + if (status.err) { + throw status.err.result; + } + if (status.alias.some(Boolean)) { + simplifyAliases(locales, status); + } + + const indexPath = path.join(opts.output, 'index.html'); + let html = fs.readFileSync(indexPath, {encoding: 'utf8'}); + const jsAssets = opts.startupAssets || [ + path.posix.join(opts.publicPath || '/', opts.chunk).replace(/\/{2,}/g, '/') + ]; + const startupScript = templates.startup(opts.screenTypes, jsAssets); + html = html.replace(/type="module"/g, 'type="text/javascript"'); + html = html.replace( + /<\/head>/i, + match => `\t\n\t${match}` + ); + + const applyToRoot = rootInjection(html); + for (let i = 0; i < locales.length; i++) { + if (!status.prerender[i] || status.alias[i]) continue; + const linked = Object.keys(status.alias).filter(key => status.alias[key] === locales[i]); + let mapping; + if (linked.length === 0) { + status.prerender[i] = status.prerender[i].replace( + /(]*class="[^"]*)"/i, + '$1' + status.attr[i].classes + '"' + ); + } else { + mapping = linked.reduce((m, c) => Object.assign(m, {[locales[c].toLowerCase()]: status.attr[c]}), {}); + } + const appHtml = parsePrerender(status.prerender[i]); + const updater = templates.update(mapping, opts.deep, appHtml.prerender); + let localeHtml = applyToRoot(appHtml.prerender); + if (updater) { + localeHtml = localeHtml.replace( + /<\/body>/i, + match => `\t\n\t${match}` + ); + } + if (locales.length === 1) { + html = localeHtml; + } else { + fs.writeFileSync(path.join(opts.output, 'index.' + locales[i] + '.html'), localeHtml, {encoding: 'utf8'}); + } + } + fs.writeFileSync(indexPath, html, {encoding: 'utf8'}); + + if (opts.mapfile && locales.length > 1) { + const mapper = (m, c, i) => + status.alias.includes(c) ? m : Object.assign(m, {[c]: `index.${status.alias[i] || c}.html`}); + const mapping = {fallback: 'index.html', locales: locales.reduce(mapper, {})}; + fs.writeFileSync(path.join(opts.output, opts.mapfile), JSON.stringify(mapping, null, '\t'), {encoding: 'utf8'}); + } +} + +PrerenderPlugin.applyBunPostBuild = applyBunPostBuild; +PrerenderPlugin.parseLocales = parseLocales; + module.exports = PrerenderPlugin; diff --git a/plugins/PrerenderPlugin/vdom-server-render.js b/plugins/PrerenderPlugin/vdom-server-render.js index d79d0f0..c43aefa 100644 --- a/plugins/PrerenderPlugin/vdom-server-render.js +++ b/plugins/PrerenderPlugin/vdom-server-render.js @@ -7,6 +7,8 @@ const fs = require('fs'); const path = require('path'); +const vm = require('vm'); +const Module = require('module'); const requireUncached = require('import-fresh'); const reroute = require('mock-require'); const FileXHR = require('./FileXHR'); @@ -16,17 +18,189 @@ require('console.mute'); let chunkTarget; let prerenderCache; -import('find-cache-directory').then(({default: findCacheDirectory}) => { - prerenderCache = path.join( - findCacheDirectory({ - name: 'enact-dev', - create: true - }), - 'prerender' - ); +const ILIB_LOCALEMATCH_GLOBAL = '__ENACT_PRERENDER_LOCALEMATCH__'; +const ILIB_DATA_INIT_PATTERN = + /cache: \{\} \}, typeof module2 != "undefined" && \(module2\.exports = ilib, module2\.exports\.ilib = ilib\)/g; +const ILIB_DATA_INIT_SEED = + 'cache: {} }, global.' + + ILIB_LOCALEMATCH_GLOBAL + + '&&(ilib.data.localematch=global.' + + ILIB_LOCALEMATCH_GLOBAL + + '), typeof module2 != "undefined" && (module2.exports = ilib, module2.exports.ilib = ilib)'; - if (!fs.existsSync(prerenderCache)) fs.mkdirSync(prerenderCache); -}); +/** + * Load a staged chunk in a vm context that shares Node require / prerender + * globals but intentionally omits window/document/self so browser-only paths + * see typeof window === 'undefined' without regex-rewriting the bundle text. + */ +function loadStagedChunk(chunkPath) { + const code = fs.readFileSync(chunkPath, {encoding: 'utf8'}); + const moduleObject = {exports: {}}; + const localRequire = Module.createRequire(chunkPath); + const context = { + module: moduleObject, + exports: moduleObject.exports, + require: localRequire, + __filename: chunkPath, + __dirname: path.dirname(chunkPath), + console, + process, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + setImmediate, + clearImmediate, + queueMicrotask, + URL, + URLSearchParams, + Promise, + Object, + Array, + String, + Number, + Boolean, + Symbol, + Math, + Date, + RegExp, + Error, + TypeError, + RangeError, + SyntaxError, + JSON, + Map, + Set, + WeakMap, + WeakSet, + Proxy, + Reflect, + parseInt, + parseFloat, + isNaN, + isFinite, + encodeURIComponent, + decodeURIComponent, + encodeURI, + decodeURI, + // Prerender-shared state (also mirrored onto context.global below) + React: global.React, + enact_framework: global.enact_framework, + enactHooks: global.enactHooks, + ilib: global.ilib, + XMLHttpRequest: global.XMLHttpRequest, + skipPolyfills: global.skipPolyfills + }; + if (global[ILIB_LOCALEMATCH_GLOBAL] !== undefined) { + context[ILIB_LOCALEMATCH_GLOBAL] = global[ILIB_LOCALEMATCH_GLOBAL]; + } + // No window / document / self — keeps DOM startup paths inert. + context.global = context; + context.globalThis = context; + + vm.createContext(context); + vm.runInContext(code, context, { + filename: chunkPath, + displayErrors: true + }); + + return moduleObject.exports && moduleObject.exports.default !== undefined + ? moduleObject.exports + : context.module.exports; +} + +function readLocaleMatchData() { + const fsPath = process.env.ILIB_FS_PATH || process.env.ILIB_BASE_PATH; + if (!fsPath) return null; + + const localeMatchPath = path.join(fsPath, 'locale', 'localematch.json'); + if (!fs.existsSync(localeMatchPath)) return null; + + try { + const localematch = JSON.parse(fs.readFileSync(localeMatchPath, {encoding: 'utf8'})); + return localematch && localematch.likelyLocales ? localematch : null; + } catch (_e) { + return null; + } +} + +function resetIlibGlobalState() { + // ilib.js uses `var ilib = ilib || {}`, so repeated prerender passes share one global + // singleton. After many locale renders, cached localematch data can become incomplete. + if (global.ilib) { + if (global.ilib.data && typeof global.ilib.clearCache === 'function') { + global.ilib.clearCache(); + } + delete global.ilib; + } +} + +function seedIlibLocaleMatch() { + const localematch = readLocaleMatchData(); + if (!localematch) return; + + global.ilib = global.ilib || {}; + global.ilib.data = global.ilib.data || {}; + global.ilib.data.localematch = localematch; + if (global.ilib._load) { + global.ilib._load._cacheValidated = false; + } +} + +function injectBundledIlibLocaleMatchSeed(code) { + return code.replace(ILIB_DATA_INIT_PATTERN, ILIB_DATA_INIT_SEED); +} + +function resolveFromContext(moduleName, context) { + if (!context) { + return require.resolve(moduleName); + } + return require.resolve(moduleName, {paths: [path.join(context, 'node_modules')]}); +} + +function clearPrerenderModules(serverPath) { + const chunkPath = chunkTarget ? path.resolve(chunkTarget) : null; + const serverDir = serverPath ? path.dirname(path.resolve(serverPath)) : null; + + Object.keys(require.cache) + .filter(c => { + if (chunkPath && path.resolve(c) === chunkPath) return true; + if (serverDir && c.startsWith(serverDir)) return true; + return /[\\/]node_modules[\\/](react(-dom)?|ilib|@enact)([\\/]|$)/.test(c); + }) + .forEach(c => delete require.cache[c]); + + try { + reroute.stop('react'); + } catch (_e) { + // ignore if react was not rerouted yet + } + + delete global.React; + delete global.enact_framework; + delete global.enactHooks; +} + +function getPrerenderCache() { + if (prerenderCache) return prerenderCache; + + try { + const findCacheDirectory = require('find-cache-directory'); + prerenderCache = path.join( + findCacheDirectory({ + name: 'enact-dev', + create: true + }), + 'prerender' + ); + } catch (e) { + prerenderCache = path.join(process.cwd(), 'node_modules', '.cache', 'enact-dev', 'prerender'); + } + + fs.mkdirSync(prerenderCache, {recursive: true}); + return prerenderCache; +} // Skip using the polyfills embedded within the bundle and instead use a local core-js, // since the bundle's target may differ in compatibility from the active Node process @@ -57,7 +231,8 @@ module.exports = { 'require("' + path.resolve(path.join(opts.externals, 'enact.js')) + '")' ); } - chunkTarget = path.join(prerenderCache, opts.chunk); + code = injectBundledIlibLocaleMatchSeed(code); + chunkTarget = path.join(getPrerenderCache(), opts.chunk); fs.writeFileSync(chunkTarget, code, {encoding: 'utf8'}); }, @@ -86,7 +261,7 @@ module.exports = { console.mute(); try { - const generator = require(path.resolve(opts.fontGenerator)); + const generator = requireUncached(path.resolve(opts.fontGenerator)); const locale = opts.locale || 'en-US'; style = generator(locale); if (generator.fontOverrideGenerator) style += generator.fontOverrideGenerator(locale); @@ -102,26 +277,63 @@ module.exports = { global.process.env.LANG = opts.locale; + clearPrerenderModules(opts.server); + + if (opts.locale) { + resetIlibGlobalState(); + seedIlibLocaleMatch(); + } + if (opts.externals) { - // Ensure locale switching support is loaded globally with external framework usage. - const framework = requireUncached(path.resolve(path.join(opts.externals, 'enact.js'))); + const frameworkPath = path.resolve(path.join(opts.externals, 'enact.js')); + let framework = requireUncached(frameworkPath); + if (framework && typeof framework.default === 'function') { + framework = framework.default; + } + if (typeof framework !== 'function') { + const previousFramework = global.enact_framework; + delete global.enact_framework; + requireUncached(frameworkPath); + framework = global.enact_framework; + if (previousFramework !== undefined) { + global.enact_framework = previousFramework; + } else { + delete global.enact_framework; + } + } + if (typeof framework !== 'function') { + throw new Error('External Enact framework must export enact_framework(id).'); + } + global.enact_framework = framework; global.React = framework('react'); } else { - delete global.React; + global.React = requireUncached(resolveFromContext('react', opts.context)); } - const chunk = requireUncached(path.resolve(chunkTarget)); + reroute('react', global.React); - // Clear any server-related children modules from cache - Object.keys(require.cache) - .filter(c => c.startsWith(path.dirname(opts.server))) - .forEach(c => delete require.cache[c]); + let localeMatchSeed; + if (opts.locale) { + localeMatchSeed = readLocaleMatchData(); + if (localeMatchSeed) { + global[ILIB_LOCALEMATCH_GLOBAL] = localeMatchSeed; + } + } - // Use the specified server, optionally with exposed React, and generate HTML string - if (global.React) reroute('react', global.React); - const server = requireUncached(opts.server); + const chunk = loadStagedChunk(path.resolve(chunkTarget)); + + if (localeMatchSeed) { + delete global[ILIB_LOCALEMATCH_GLOBAL]; + } + let server; + if (opts.externals && global.enact_framework) { + const domServer = global.enact_framework('react-dom/server'); + server = domServer.default || domServer; + } else { + server = requireUncached(opts.server); + } rendered = server.renderToString(chunk['default'] || chunk); - if (global.React) reroute.stop('react'); + reroute.stop('react'); if (style) { rendered = '\n' + style + '\n' + rendered;