From 95fff270c8095d1baf556237d9108f030345303f Mon Sep 17 00:00:00 2001 From: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:50:38 +0800 Subject: [PATCH 1/4] fix(devtool-connector): align global switch keys and filter responses (#2392) ## Summary - align `GlobalKeys` in devtool connector with the full Android `DevToolSettings` key list - add explicit response filters for `GetGlobalSwitch` and `SetGlobalSwitch` to avoid consuming unrelated customized events - add a changeset for `@lynx-js/devtool-connector` patch release ## Verification - `pnpm --filter @lynx-js/devtool-connector build` ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Fixed alignment of device settings keys with Android specifications * Enhanced response filtering for global switch operations * **New Features** * Expanded supported device settings to include logbox, debug mode, launch recording, quickjs debugging, performance metrics, and visual debugging tools --- .changeset/forty-trains-carry.md | 5 ++++ .../devtool-connector/src/index.ts | 19 +++++++++--- .../devtool-connector/src/types.ts | 30 ++++++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 .changeset/forty-trains-carry.md diff --git a/.changeset/forty-trains-carry.md b/.changeset/forty-trains-carry.md new file mode 100644 index 0000000000..b11d41383a --- /dev/null +++ b/.changeset/forty-trains-carry.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/devtool-connector": patch +--- + +fix: align GlobalKeys with Android DevToolSettings keys and filter global switch responses diff --git a/packages/mcp-servers/devtool-connector/src/index.ts b/packages/mcp-servers/devtool-connector/src/index.ts index 18efd11156..7b84d6da4e 100644 --- a/packages/mcp-servers/devtool-connector/src/index.ts +++ b/packages/mcp-servers/devtool-connector/src/index.ts @@ -31,7 +31,12 @@ import type { Transport, TransportConnectOptions, } from './transport/transport.ts'; -import { isInitializeResponse, isListSessionResponse } from './types.ts'; +import { + isGetGlobalSwitchResponse, + isInitializeResponse, + isListSessionResponse, + isSetGlobalSwitchResponse, +} from './types.ts'; import type { AppInfo, CDPRequestMessage, @@ -267,7 +272,9 @@ export class Connector { options.port, ), ], - output: [], + output: [ + new FilterTransformStream(isGetGlobalSwitchResponse), + ], }, ); @@ -288,7 +295,9 @@ export class Connector { input: [ new GlobalSwitchRequestTransformStream('SetGlobalSwitch', options.port), ], - output: [], + output: [ + new FilterTransformStream(isSetGlobalSwitchResponse), + ], }); } @@ -469,7 +478,9 @@ export class Connector { input: [ new GlobalSwitchRequestTransformStream('SetGlobalSwitch', port), ], - output: [], + output: [ + new FilterTransformStream(isSetGlobalSwitchResponse), + ], }, ); } catch (err) { diff --git a/packages/mcp-servers/devtool-connector/src/types.ts b/packages/mcp-servers/devtool-connector/src/types.ts index fb67180680..443c329412 100644 --- a/packages/mcp-servers/devtool-connector/src/types.ts +++ b/packages/mcp-servers/devtool-connector/src/types.ts @@ -71,7 +71,21 @@ export type ListSessionRequest = CustomizedEvent< >; export type ListSessionResponse = CustomizedEvent<'SessionList', Session[]>; -export type GlobalKeys = 'enable_devtool'; +export type GlobalKeys = + | 'enable_devtool' + | 'enable_logbox' + | 'enable_debug_mode' + | 'enable_launch_record' + | 'enable_quickjs_debug' + | 'enable_quickjs_cache' + | 'enable_v8' + | 'enable_dom_tree' + | 'enable_long_press_menu' + | 'enable_highlight_touch' + | 'enable_preview_screen_shot' + | 'enable_pixel_copy' + | 'enable_fsp_screenshot' + | 'enable_perf_metrics'; export type GetGlobalSwitchRequest = CustomizedEvent<'GetGlobalSwitch', { client_id: number; session_id: number; @@ -124,6 +138,20 @@ export function isListSessionResponse( && response.data.type === 'SessionList'; } +export function isGetGlobalSwitchResponse( + response: Response, +): response is GetGlobalSwitchResponse { + return response.event === 'Customized' + && response.data.type === 'GetGlobalSwitch'; +} + +export function isSetGlobalSwitchResponse( + response: Response, +): response is SetGlobalSwitchResponse { + return response.event === 'Customized' + && response.data.type === 'SetGlobalSwitch'; +} + export function isCustomizedResponseWithType< T extends keyof CustomizedResponseMap, >( From 919371167f4136f2ee975075d8e73d2986b20a8f Mon Sep 17 00:00:00 2001 From: Hengchang Lu Date: Sat, 28 Mar 2026 11:51:53 +0800 Subject: [PATCH 2/4] fix(template-webpack-plugin): use path.posix.format for consistent path separators (#2359) The `tasm.json` path on Windows platform has `\` backslash. We should always use `/` slash and webpack/rspack use slash too. image ## Summary by CodeRabbit * **Bug Fixes** * Development and debug asset paths now use consistent POSIX-style separators across Windows, macOS, and Linux, preventing mixed backslash/forward-slash issues and reducing cross-platform asset loading problems. * **Chores** * Prepared and declared a patch release for the template webpack plugin to deliver this cross-platform path consistency fix. ## Checklist - [ ] Tests updated (or not required). - [ ] Documentation updated (or not required). - [ ] Changeset added, and when a BREAKING CHANGE occurs, it needs to be clearly marked (or not required). --------- Signed-off-by: Hengchang Lu Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .changeset/fix-path-posix-format.md | 5 +++++ .../template-webpack-plugin/src/LynxTemplatePlugin.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-path-posix-format.md diff --git a/.changeset/fix-path-posix-format.md b/.changeset/fix-path-posix-format.md new file mode 100644 index 0000000000..16da511491 --- /dev/null +++ b/.changeset/fix-path-posix-format.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/template-webpack-plugin': patch +--- + +use path.posix.format instead of path.format to ensure consistent path separators across platforms diff --git a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts index 38c8287030..dc3dc28d86 100644 --- a/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts +++ b/packages/webpack/template-webpack-plugin/src/LynxTemplatePlugin.ts @@ -759,8 +759,9 @@ class LynxTemplatePluginImpl { ); let templateDebugUrl = ''; + const intermediatePosix = intermediate.replace(/\\/g, '/'); const debugInfoPath = path.posix.format({ - dir: intermediate, + dir: intermediatePosix, base: 'debug-info.json', }); // TODO: Support publicPath function @@ -866,8 +867,8 @@ class LynxTemplatePluginImpl { if (isDebug() || isDev) { compilation.emitAsset( - path.format({ - dir: intermediate, + path.posix.format({ + dir: intermediatePosix, base: 'tasm.json', }), new RawSource( @@ -877,8 +878,8 @@ class LynxTemplatePluginImpl { Object.entries(resolvedEncodeOptions.lepusCode.lepusChunk).forEach( ([name, content]) => { compilation.emitAsset( - path.format({ - dir: intermediate, + path.posix.format({ + dir: intermediatePosix, name, ext: '.js', }), From 7b7a0c6ee35e32f9575436cb36b25f2931f43c05 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Sat, 28 Mar 2026 12:02:07 +0800 Subject: [PATCH 3/4] feat: simplify reactlynx external bundle configuration (#2370) ## Summary - add `reactlynx` externals presets to both `defineExternalBundleRslibConfig` and `pluginExternalBundle` - add `bundlePath`-based external loading so runtime code resolves externals from `publicPath + bundlePath` instead of hard-coded absolute URLs - add `@lynx-js/react-umd` dev/prod export entries so external-bundle tooling can resolve packaged ReactLynx runtimes explicitly - simplify the `react-externals` example to use preset-based configuration, dedicated external bundle output roots, and emitted bundle assets - add coverage for preset expansion, explicit override precedence, runtime loading behavior, and artifact emission ## Testing - `pnpm --filter @lynx-js/lynx-bundle-rslib-config test -- test/external-bundle.test.ts` - `pnpm --filter @lynx-js/external-bundle-rsbuild-plugin test` - `pnpm --filter @lynx-js/externals-loading-webpack-plugin test` - `pnpm turbo api-extractor --filter=@lynx-js/lynx-bundle-rslib-config --filter=@lynx-js/external-bundle-rsbuild-plugin --filter=@lynx-js/externals-loading-webpack-plugin -- --local` ## Summary by CodeRabbit * **New Features** * Extensible externals presets with a built-in reactlynx preset (supports preset inheritance), string shorthand for bundlePath-based externals, automatic dev serving and build-time emission of managed bundles, and an added React UMD entry for reuse by wrapper libraries. * **Documentation** * Updated READMEs, examples, and guidance on presets, bundlePath vs url, and recommended configuration flows. * **Chores** * Added changesets to bump affected packages and record changelog notes. --------- Signed-off-by: Yiming Li Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .changeset/blue-emus-brake.md | 9 + .changeset/four-forks-watch.md | 5 + ...als-loading-webpack-plugin.instructions.md | 5 + .../lynx-bundle-rslib-config.instructions.md | 5 + .../plugin-external-bundle.instructions.md | 9 + examples/react-externals/.gitignore | 1 + examples/react-externals/README.md | 6 +- examples/react-externals/lynx.config.ts | 178 +--- examples/react-externals/package.json | 2 +- .../react-externals/rslib-comp-lib.config.ts | 31 +- examples/react-externals/src/App.tsx | 2 +- examples/react-externals/src/index.tsx | 7 +- examples/react-externals/turbo.json | 13 + packages/react-umd/README.md | 167 ++-- packages/react-umd/package.json | 2 + packages/react-umd/rslib.config.ts | 1 + packages/react-umd/turbo.json | 13 + .../lynx-bundle-rslib-config/README.md | 79 ++ .../etc/lynx-bundle-rslib-config.api.md | 39 +- .../src/externalBundleRslibConfig.ts | 264 +++++- .../lynx-bundle-rslib-config/src/index.ts | 12 +- .../test/external-bundle.test.ts | 217 ++++- .../rspeedy/plugin-external-bundle/README.md | 174 +++- .../etc/external-bundle-rsbuild-plugin.api.md | 55 +- .../plugin-external-bundle/package.json | 1 + .../plugin-external-bundle/src/index.ts | 806 +++++++++++++++++- .../plugin-external-bundle/test/index.test.ts | 617 +++++++++++++- .../test/resolve-root-path.test.ts | 86 ++ .../tsconfig.build.json | 2 +- .../plugin-external-bundle/tsconfig.json | 2 +- .../rspeedy/plugin-external-bundle/turbo.json | 13 + .../README.md | 47 + .../externals-loading-webpack-plugin.api.md | 4 +- .../src/index.ts | 136 ++- pnpm-lock.yaml | 3 + 35 files changed, 2676 insertions(+), 337 deletions(-) create mode 100644 .changeset/blue-emus-brake.md create mode 100644 .changeset/four-forks-watch.md create mode 100644 .github/externals-loading-webpack-plugin.instructions.md create mode 100644 .github/lynx-bundle-rslib-config.instructions.md create mode 100644 .github/plugin-external-bundle.instructions.md create mode 100644 examples/react-externals/.gitignore create mode 100644 examples/react-externals/turbo.json create mode 100644 packages/react-umd/turbo.json create mode 100644 packages/rspeedy/plugin-external-bundle/test/resolve-root-path.test.ts create mode 100644 packages/rspeedy/plugin-external-bundle/turbo.json diff --git a/.changeset/blue-emus-brake.md b/.changeset/blue-emus-brake.md new file mode 100644 index 0000000000..16c6ac62ea --- /dev/null +++ b/.changeset/blue-emus-brake.md @@ -0,0 +1,9 @@ +--- +"@lynx-js/external-bundle-rsbuild-plugin": minor +"@lynx-js/externals-loading-webpack-plugin": minor +"@lynx-js/lynx-bundle-rslib-config": minor +--- + +**BREAKING CHANGE**: + +Simplify the API for external bundle builds by `externalsPresets` and `externalsPresetDefinitions`. diff --git a/.changeset/four-forks-watch.md b/.changeset/four-forks-watch.md new file mode 100644 index 0000000000..31455d3da8 --- /dev/null +++ b/.changeset/four-forks-watch.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react-umd": patch +--- + +Add a new `entry` export to `@lynx-js/react-umd` for reuse by wrapper libraries of `@lynx-js/react`. diff --git a/.github/externals-loading-webpack-plugin.instructions.md b/.github/externals-loading-webpack-plugin.instructions.md new file mode 100644 index 0000000000..80354c2f47 --- /dev/null +++ b/.github/externals-loading-webpack-plugin.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/webpack/externals-loading-webpack-plugin/**" +--- + +Keep `ExternalsLoadingPlugin` focused on consuming finalized `externals` maps and generating runtime loading code. Do not bake project-specific preset expansion or filesystem-backed dev serving into this low-level plugin; those concerns belong in higher-level Rsbuild integrations such as `pluginExternalBundle`. It is acceptable for this low-level plugin to resolve a relative `bundlePath` against the runtime `publicPath`, because that stays within generic bundler/runtime behavior instead of Rspeedy-specific URL inference. diff --git a/.github/lynx-bundle-rslib-config.instructions.md b/.github/lynx-bundle-rslib-config.instructions.md new file mode 100644 index 0000000000..cfd1d42595 --- /dev/null +++ b/.github/lynx-bundle-rslib-config.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/rspeedy/lynx-bundle-rslib-config/**" +--- + +Keep `@lynx-js/lynx-bundle-rslib-config` preset resolution extensible. `output.externalsPresetDefinitions` should live alongside `output.externalsPresets`, so business configs can register new preset names such as `lynxUi` directly in their rslib config while wrappers still layer in defaults. New preset behavior should be expressed through exported preset-definition types and `extends` relationships rather than by hard-coding one-off merge logic outside the shared resolver. diff --git a/.github/plugin-external-bundle.instructions.md b/.github/plugin-external-bundle.instructions.md new file mode 100644 index 0000000000..facf0032b1 --- /dev/null +++ b/.github/plugin-external-bundle.instructions.md @@ -0,0 +1,9 @@ +--- +applyTo: "packages/rspeedy/plugin-external-bundle/**" +--- + +Keep `pluginExternalBundle` responsible for expanding built-in externals presets and for Rspeedy-specific dev-server behavior such as serving local external bundles during development. Use `externalBundleRoot` as the source directory for project-owned external bundles referenced by `bundlePath`, but treat `dist-external-bundle` as the normal default and only show `externalBundleRoot` in examples when the bundle source directory is non-default. Resolve the built-in React preset bundle from the `@lynx-js/react-umd/dev` or `@lynx-js/react-umd/prod` peer dependency instead of reaching into the monorepo with a relative filesystem path, and when the preset uses `bundlePath` rather than an explicit `url`, emit that runtime bundle into the user's build output automatically. +Document `bundlePath` as the preferred option over `url` in both public README examples and API docs. README examples for external-bundle workflows should show `externalsPresets.reactlynx` and `externalsPresetDefinitions` instead of hand-written React externals maps or custom middleware, and should prefer the shorthand `externals: { './App.js': 'comp.lynx.bundle' }` form when request keys and section names naturally align. +Treat `externalsPresetDefinitions` as the main extension point for internal wrappers or downstream variants. When a wrapper needs to add aliases like `@byted-lynx/*`, extend the OSS plugin through preset definitions instead of forking the whole plugin implementation. +Because the built-in `reactlynx` preset resolves `@lynx-js/react-umd/dev` or `@lynx-js/react-umd/prod` to generated `dist/*` artifacts, add precise package-level Turbo dependencies for the affected `test` or `build` tasks instead of widening the global workspace `test` pipeline; otherwise clean CI runs can fail even when local machines pass due to stale built files already existing. +When `@lynx-js/react-umd` builds both development and production bundles into the same `dist` directory, keep `output.cleanDistPath: false` in its rslib config so `react-dev.lynx.bundle` and `react-prod.lynx.bundle` coexist; otherwise the second build silently removes the first export target and breaks `@lynx-js/react-umd/dev`. diff --git a/examples/react-externals/.gitignore b/examples/react-externals/.gitignore new file mode 100644 index 0000000000..4a5d0912ea --- /dev/null +++ b/examples/react-externals/.gitignore @@ -0,0 +1 @@ +dist-external-bundle diff --git a/examples/react-externals/README.md b/examples/react-externals/README.md index 7d50ba1eb8..db45fc478d 100644 --- a/examples/react-externals/README.md +++ b/examples/react-externals/README.md @@ -2,16 +2,14 @@ In this example, we show: -- Use `@lynx-js/lynx-bundle-rslib-config` to bundle ReactLynx runtime to a separate Lynx bundle. - Use `@lynx-js/lynx-bundle-rslib-config` to bundle a simple ReactLynx component library to a separate Lynx bundle. -- Use `@lynx-js/external-bundle-rsbuild-plugin` to load ReactLynx runtime (sync) and component bundle (async). +- Use `@lynx-js/external-bundle-rsbuild-plugin` to load the built-in ReactLynx runtime bundle (sync) and component bundle (async). ## Usage ```bash -pnpm build:reactlynx pnpm build:comp-lib pnpm dev ``` -The dev server will automatically serve the ReactLynx runtime and the component library bundles. +The dev server will automatically serve the built-in ReactLynx runtime bundle and the component library bundle. diff --git a/examples/react-externals/lynx.config.ts b/examples/react-externals/lynx.config.ts index de67981bfc..9f2815ca04 100644 --- a/examples/react-externals/lynx.config.ts +++ b/examples/react-externals/lynx.config.ts @@ -1,91 +1,12 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { pluginExternalBundle } from '@lynx-js/external-bundle-rsbuild-plugin'; import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; import { defineConfig } from '@lynx-js/rspeedy'; -import type { RsbuildPlugin } from '@lynx-js/rspeedy'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -function getIPAddress() { - const interfaces = os.networkInterfaces(); - for (const devName in interfaces) { - const iface = interfaces[devName]; - if (iface) { - for (const alias of iface) { - if ( - alias.family === 'IPv4' && alias.address !== '127.0.0.1' - && !alias.internal - ) { - return alias.address; - } - } - } - } - return 'localhost'; -} const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; -const PORT = 8291; -const EXTERNAL_BUNDLE_PREFIX = process.env['EXTERNAL_BUNDLE_PREFIX'] - ?? `http://${getIPAddress()}:${PORT}`; - -const pluginServeExternals = (): RsbuildPlugin => ({ - name: 'serve-externals', - setup(api) { - api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => { - return mergeRsbuildConfig(config, { - dev: { - setupMiddlewares: [ - (middlewares) => { - middlewares.unshift(( - req: import('node:http').IncomingMessage, - res: import('node:http').ServerResponse, - next: () => void, - ) => { - if (req.url === '/react.lynx.bundle') { - const bundlePath = path.resolve( - __dirname, - '../../packages/react-umd/dist/react-dev.lynx.bundle', - ); - if (fs.existsSync(bundlePath)) { - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Access-Control-Allow-Origin', '*'); - fs.createReadStream(bundlePath).pipe(res); - return; - } - } - if (req.url === '/comp-lib.lynx.bundle') { - const bundlePath = path.resolve( - __dirname, - './dist/comp-lib.lynx.bundle', - ); - if (fs.existsSync(bundlePath)) { - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Access-Control-Allow-Origin', '*'); - fs.createReadStream(bundlePath).pipe(res); - return; - } - } - next(); - }); - return middlewares; - }, - ], - }, - }); - }); - }, -}); export default defineConfig({ plugins: [ - pluginServeExternals(), pluginReactLynx(), pluginQRCode({ schema(url) { @@ -94,98 +15,11 @@ export default defineConfig({ }, }), pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, externals: { - '@lynx-js/react': { - libraryName: ['ReactLynx', 'React'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/internal': { - libraryName: ['ReactLynx', 'ReactInternal'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/jsx-dev-runtime': { - libraryName: ['ReactLynx', 'ReactJSXDevRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/jsx-runtime': { - libraryName: ['ReactLynx', 'ReactJSXRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/lepus/jsx-dev-runtime': { - libraryName: ['ReactLynx', 'ReactJSXLepusDevRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/lepus/jsx-runtime': { - libraryName: ['ReactLynx', 'ReactJSXLepusRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/experimental/lazy/import': { - libraryName: ['ReactLynx', 'ReactLazyImport'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/legacy-react-runtime': { - libraryName: ['ReactLynx', 'ReactLegacyRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/runtime-components': { - libraryName: ['ReactLynx', 'ReactComponents'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/worklet-runtime/bindings': { - libraryName: ['ReactLynx', 'ReactWorkletRuntime'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - '@lynx-js/react/debug': { - libraryName: ['ReactLynx', 'ReactDebug'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - preact: { - libraryName: ['ReactLynx', 'Preact'], - url: `${EXTERNAL_BUNDLE_PREFIX}/react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, - }, - './App.js': { - libraryName: 'CompLib', - url: `${EXTERNAL_BUNDLE_PREFIX}/comp-lib.lynx.bundle`, - background: { sectionPath: 'CompLib' }, - mainThread: { sectionPath: 'CompLib__main-thread' }, - async: true, - }, + './App.js': 'comp-lib.lynx.bundle', }, globalObject: 'globalThis', }), @@ -198,11 +32,7 @@ export default defineConfig({ }, }, }, - server: { - port: PORT, - }, output: { filenameHash: 'contenthash:8', - cleanDistPath: false, }, }); diff --git a/examples/react-externals/package.json b/examples/react-externals/package.json index d2d9ad5418..c5e9d0f26b 100644 --- a/examples/react-externals/package.json +++ b/examples/react-externals/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:comp-lib && npm run build:reactlynx && rspeedy build", + "build": "pnpm run build:comp-lib && pnpm run build:reactlynx && rspeedy build", "build:comp-lib": "rslib build --config rslib-comp-lib.config.ts", "build:comp-lib:dev": "cross-env NODE_ENV=development rslib build --config rslib-comp-lib.config.ts", "build:reactlynx": "pnpm --filter @lynx-js/react-umd build", diff --git a/examples/react-externals/rslib-comp-lib.config.ts b/examples/react-externals/rslib-comp-lib.config.ts index de4203e400..cdb8ce9088 100644 --- a/examples/react-externals/rslib-comp-lib.config.ts +++ b/examples/react-externals/rslib-comp-lib.config.ts @@ -5,40 +5,15 @@ export default defineExternalBundleRslibConfig({ id: 'comp-lib', source: { entry: { - 'CompLib': './external-bundle/CompLib.tsx', + './App.js': './external-bundle/CompLib.tsx', }, }, plugins: [ pluginReactLynx(), ], output: { - cleanDistPath: false, - dataUriLimit: Number.POSITIVE_INFINITY, - externals: { - '@lynx-js/react': ['ReactLynx', 'React'], - '@lynx-js/react/internal': ['ReactLynx', 'ReactInternal'], - '@lynx-js/react/jsx-dev-runtime': ['ReactLynx', 'ReactJSXDevRuntime'], - '@lynx-js/react/jsx-runtime': ['ReactLynx', 'ReactJSXRuntime'], - '@lynx-js/react/lepus/jsx-dev-runtime': [ - 'ReactLynx', - 'ReactJSXLepusDevRuntime', - ], - '@lynx-js/react/lepus/jsx-runtime': ['ReactLynx', 'ReactJSXLepusRuntime'], - '@lynx-js/react/experimental/lazy/import': [ - 'ReactLynx', - 'ReactLazyImport', - ], - '@lynx-js/react/legacy-react-runtime': [ - 'ReactLynx', - 'ReactLegacyRuntime', - ], - '@lynx-js/react/runtime-components': ['ReactLynx', 'ReactComponents'], - '@lynx-js/react/worklet-runtime/bindings': [ - 'ReactLynx', - 'ReactWorkletRuntime', - ], - '@lynx-js/react/debug': ['ReactLynx', 'ReactDebug'], - 'preact': ['ReactLynx', 'Preact'], + externalsPresets: { + reactlynx: true, }, globalObject: 'globalThis', }, diff --git a/examples/react-externals/src/App.tsx b/examples/react-externals/src/App.tsx index 5da30edede..7076442cc6 100644 --- a/examples/react-externals/src/App.tsx +++ b/examples/react-externals/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from '@lynx-js/react'; +import { useCallback, useEffect, useState } from 'react'; import './App.css'; import arrow from './assets/arrow.png'; diff --git a/examples/react-externals/src/index.tsx b/examples/react-externals/src/index.tsx index 5bbf167939..ffd79f1415 100644 --- a/examples/react-externals/src/index.tsx +++ b/examples/react-externals/src/index.tsx @@ -1,4 +1,6 @@ import '@lynx-js/react/debug'; +import { Fragment } from 'react'; + import { root } from '@lynx-js/react'; import { App } from './App.js'; @@ -6,7 +8,10 @@ import { App } from './App.js'; import './index.css'; root.render( - , + // biome-ignore lint/style/useFragmentSyntax: Just to demonstrate import react is external + + , + , ); if (import.meta.webpackHot) { diff --git a/examples/react-externals/turbo.json b/examples/react-externals/turbo.json new file mode 100644 index 0000000000..bba445e8a6 --- /dev/null +++ b/examples/react-externals/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": [ + "//" + ], + "tasks": { + "build": { + "dependsOn": [ + "@lynx-js/react-umd#build" + ] + } + } +} diff --git a/packages/react-umd/README.md b/packages/react-umd/README.md index c30eb9c9b5..045ab0c10e 100644 --- a/packages/react-umd/README.md +++ b/packages/react-umd/README.md @@ -1,97 +1,158 @@ # @lynx-js/react-umd -This package provides a standalone, Universal Module Definition (UMD) build of the `ReactLynx` runtime. It is designed to be used as an **external bundle**, allowing multiple Lynx components or applications to load a single React runtime instance over the network, which improves load time, caches efficiency, and reduces memory usage. +`@lynx-js/react-umd` ships prebuilt ReactLynx runtime bundles for external-bundle workflows. -## Purpose +It exposes two entry points: -When building Lynx applications, the ReactLynx runtime is typically bundled directly into the application instance. However, for advanced use cases like micro-frontends or dynamically loading remote components, it's highly beneficial to expose ReactLynx as an external dependency. `react-umd` pre-bundles ReactLynx so that it can be loaded on-demand and shared across different parts of your Lynx app. +- `@lynx-js/react-umd/dev` +- `@lynx-js/react-umd/prod` -## Building +`@lynx-js/external-bundle-rsbuild-plugin` resolves one of these entry points automatically for the built-in `reactlynx` preset, based on `NODE_ENV`. -To build the development and production bundles locally: +## Build ```bash pnpm build ``` -The script will automatically execute: +This generates: -- `pnpm build:development` (sets `NODE_ENV=development`) -- `pnpm build:production` (sets `NODE_ENV=production`) +- `dist/react-dev.lynx.bundle` +- `dist/react-prod.lynx.bundle` -This generates the following artifacts in the `dist/` directory: +## Recommended Usage -- `react-dev.lynx.bundle` -- `react-prod.lynx.bundle` +For most projects, use this package through the `reactlynx` preset instead of wiring ReactLynx externals by hand. -## Usage as an External Bundle +### Producing a component external bundle -For a full working example of how to serve and consume this external bundle, see the [`react-externals` example](../../examples/react-externals/README.md) in this repository. +If you are building a Lynx external bundle that depends on ReactLynx, use `externalsPresets.reactlynx` in `defineExternalBundleRslibConfig`: -Typically, you will use `@lynx-js/external-bundle-rsbuild-plugin` to map `@lynx-js/react` and its internal modules directly to the URL where this UMD bundle is served in your Lynx config file (eg. `lynx.config.ts`). +```ts +import { defineExternalBundleRslibConfig } from '@lynx-js/lynx-bundle-rslib-config'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; + +export default defineExternalBundleRslibConfig({ + id: 'comp', + source: { + entry: { + './components': './src/components/index.js', + }, + }, + plugins: [pluginReactLynx()], + output: { + externalsPresets: { + reactlynx: true, + }, + }, +}); +``` + +That preset maps the standard ReactLynx module requests to the globals exposed by the React UMD bundle. Using the same `./components` request key on both sides keeps the produced external section names aligned with the host app shorthand configuration. -### 1. Consuming in a Custom Component Bundle (`rslib.config.ts`) +If you need extra business-specific presets, you can define them alongside +`externalsPresets`: + +```ts +export default defineExternalBundleRslibConfig({ + output: { + externalsPresets: { + reactlynx: true, + lynxUi: true, + }, + externalsPresetDefinitions: { + lynxUi: { + externals: { + '@lynx-js/lynx-ui': ['LynxUI', 'UI'], + }, + }, + }, + }, +}); +``` -If you are building your own external UI component library that relies on React, you should map the React imports to the global variable exposed by this UMD bundle. +You can also extend the built-in preset directly: ```ts -// rslib-comp-lib.config.ts export default defineExternalBundleRslibConfig({ output: { - externals: { - '@lynx-js/react': ['ReactLynx', 'React'], - '@lynx-js/react/internal': ['ReactLynx', 'ReactInternal'], - '@lynx-js/react/jsx-dev-runtime': ['ReactLynx', 'ReactJSXDevRuntime'], - '@lynx-js/react/jsx-runtime': ['ReactLynx', 'ReactJSXRuntime'], - '@lynx-js/react/lepus/jsx-dev-runtime': [ - 'ReactLynx', - 'ReactJSXLepusDevRuntime', - ], - '@lynx-js/react/lepus/jsx-runtime': ['ReactLynx', 'ReactJSXLepusRuntime'], - '@lynx-js/react/experimental/lazy/import': [ - 'ReactLynx', - 'ReactLazyImport', - ], - '@lynx-js/react/legacy-react-runtime': [ - 'ReactLynx', - 'ReactLegacyRuntime', - ], - '@lynx-js/react/runtime-components': ['ReactLynx', 'ReactComponents'], - '@lynx-js/react/worklet-runtime/bindings': [ - 'ReactLynx', - 'ReactWorkletRuntime', - ], - '@lynx-js/react/debug': ['ReactLynx', 'ReactDebug'], - 'preact': ['ReactLynx', 'Preact'], + externalsPresets: { + reactlynxPlus: true, + }, + externalsPresetDefinitions: { + reactlynxPlus: { + extends: 'reactlynx', + externals: { + '@lynx-js/lynx-ui': ['LynxUI', 'UI'], + }, + }, }, }, }); ``` -### 2. Resolving the Bundle in a Lynx App (`lynx.config.ts`) +### Consuming external bundles in a Lynx app -In the host application, configure the Rsbuild plugin to load the React UMD bundle into the correct engine layers (Background Thread and Main Thread) when the app starts. +In the host app, use `pluginExternalBundle` with the same `reactlynx` preset: ```ts -// lynx.config.ts +import { defineConfig } from '@lynx-js/rspeedy'; import { pluginExternalBundle } from '@lynx-js/external-bundle-rsbuild-plugin'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; + +export default defineConfig({ + plugins: [ + pluginReactLynx(), + pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + externals: { + './components': 'comp.lynx.bundle', + }, + }), + ], +}); +``` +If you need custom section names instead of deriving them from the request key, +use the full object form: + +```ts export default defineConfig({ plugins: [ + pluginReactLynx(), pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, externals: { - '@lynx-js/react': { - libraryName: ['ReactLynx', 'React'], - url: `http:///react.lynx.bundle`, - background: { sectionPath: 'ReactLynx' }, - mainThread: { sectionPath: 'ReactLynx__main-thread' }, - async: false, + './components': { + libraryName: 'CompLib', + bundlePath: 'comp.lynx.bundle', + background: { sectionPath: 'CompLib' }, + mainThread: { sectionPath: 'CompLib__main-thread' }, + async: true, }, - // ... include similar configurations for other @lynx-js/react/* subpaths }, }), ], }); ``` -> Note: Ensure you map both the `background` and `mainThread` configurations properly so that React successfully attaches across the threaded architecture. +When the preset uses `bundlePath` instead of an explicit `url`, the plugin will: + +- resolve `@lynx-js/react-umd/dev` or `@lynx-js/react-umd/prod` +- emit `react.lynx.bundle` into the app output +- load it through the runtime public path + +Consumer-side preset extension is also supported through +`pluginExternalBundle({ externalsPresetDefinitions })`, but those definitions +use `resolveExternals` / `resolveManagedAssets` callbacks rather than inline +`externals`. + +## Manual Hosting + +If you host the React runtime bundle on a CDN or another server, you can still override the preset with `url`, but `bundlePath` is recommended because it keeps the runtime aligned with the current build output and enables automatic asset emission. + +For a full working example, see [examples/react-externals](../../examples/react-externals/README.md). diff --git a/packages/react-umd/package.json b/packages/react-umd/package.json index b5a5564ff5..d8bfb9914a 100644 --- a/packages/react-umd/package.json +++ b/packages/react-umd/package.json @@ -21,10 +21,12 @@ }, "type": "module", "exports": { + "./entry": "./src/index.js", "./dev": "./dist/react-dev.lynx.bundle", "./prod": "./dist/react-prod.lynx.bundle" }, "files": [ + "src", "dist", "CHANGELOG.md", "README.md" diff --git a/packages/react-umd/rslib.config.ts b/packages/react-umd/rslib.config.ts index 0723848c2c..82f936f186 100644 --- a/packages/react-umd/rslib.config.ts +++ b/packages/react-umd/rslib.config.ts @@ -13,5 +13,6 @@ export default defineExternalBundleRslibConfig({ ], output: { cleanDistPath: false, + distPath: './dist', }, }); diff --git a/packages/react-umd/turbo.json b/packages/react-umd/turbo.json new file mode 100644 index 0000000000..f5e766029e --- /dev/null +++ b/packages/react-umd/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "src/**", + "rslib.config.ts" + ], + "outputs": ["dist/**"] + } + } +} diff --git a/packages/rspeedy/lynx-bundle-rslib-config/README.md b/packages/rspeedy/lynx-bundle-rslib-config/README.md index 737d4345ce..4c11b85f19 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/README.md +++ b/packages/rspeedy/lynx-bundle-rslib-config/README.md @@ -1,3 +1,82 @@

@lynx-js/lynx-bundle-rslib-config

The package `@lynx-js/lynx-bundle-rslib-config` provides the configurations for bundling Lynx bundle with [Rslib](https://rslib.rs/). + +## Usage + +Use `defineExternalBundleRslibConfig` when you want to build a Lynx external +bundle that will later be loaded by `pluginExternalBundle`. + +### Minimal example + +```ts +import { defineExternalBundleRslibConfig } from '@lynx-js/lynx-bundle-rslib-config' +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' + +export default defineExternalBundleRslibConfig({ + id: 'comp-lib', + source: { + entry: { + './App.js': './external-bundle/CompLib.tsx', + }, + }, + plugins: [ + pluginReactLynx(), + ], + output: { + externalsPresets: { + reactlynx: true, + }, + globalObject: 'globalThis', + }, +}) +``` + +This produces an external bundle whose React-related requests are mapped to the +built-in `reactlynx` preset instead of a hand-written externals table. + +### Custom presets + +If your business bundle needs extra preset mappings, define them next to +`externalsPresets`: + +```ts +export default defineExternalBundleRslibConfig({ + output: { + externalsPresets: { + reactlynx: true, + lynxUi: true, + }, + externalsPresetDefinitions: { + lynxUi: { + externals: { + '@lynx-js/lynx-ui': ['LynxUI', 'UI'], + }, + }, + }, + }, +}) +``` + +If you need to extend a built-in preset instead of defining a brand new one, +use `extends`: + +```ts +export default defineExternalBundleRslibConfig({ + output: { + externalsPresets: { + reactlynxPlus: true, + }, + externalsPresetDefinitions: { + reactlynxPlus: { + extends: 'reactlynx', + externals: { + '@lynx-js/lynx-ui': ['LynxUI', 'UI'], + }, + }, + }, + }, +}) +``` + +Explicit `output.externals` still override preset-provided mappings. diff --git a/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md b/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md index 7b8788fe9a..2162aca6d8 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md +++ b/packages/rspeedy/lynx-bundle-rslib-config/etc/lynx-bundle-rslib-config.api.md @@ -9,13 +9,14 @@ import type { Compiler } from 'webpack'; import type { LibConfig } from '@rslib/core'; import type { RslibConfig } from '@rslib/core'; +// @public +export const builtInExternalsPresetDefinitions: ExternalsPresetDefinitions; + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@lynx-js/lynx-bundle-rslib-config" does not have an export "LibConfig" // // @public export const defaultExternalBundleLibConfig: LibConfig; -// Warning: (ae-forgotten-export) The symbol "ExternalBundleLibConfig" needs to be exported by the entry point index.d.ts -// // @public export function defineExternalBundleRslibConfig(userLibConfig: ExternalBundleLibConfig, encodeOptions?: EncodeOptions): RslibConfig; @@ -24,6 +25,12 @@ export interface EncodeOptions { engineVersion?: string; } +// @public +export interface ExternalBundleLibConfig extends LibConfig { + // (undocumented) + output?: OutputConfig; +} + // @public export class ExternalBundleWebpackPlugin { constructor(options: ExternalBundleWebpackPluginOptions); @@ -40,6 +47,23 @@ export interface ExternalBundleWebpackPluginOptions { engineVersion?: string | undefined; } +// @public +export type Externals = Record; + +// @public +export interface ExternalsPresetDefinition { + extends?: string | string[]; + externals?: Externals; +} + +// @public +export type ExternalsPresetDefinitions = Record; + +// @public +export type ExternalsPresets = { + reactlynx?: boolean; +} & Record; + // @public export class MainThreadRuntimeWrapperWebpackPlugin { constructor(options?: Partial); @@ -52,4 +76,15 @@ export interface MainThreadRuntimeWrapperWebpackPluginOptions { test: BannerPlugin['options']['test']; } +// @public +export type OutputConfig = Required['output'] & { + externalsPresets?: ExternalsPresets; + externalsPresetDefinitions?: ExternalsPresetDefinitions; + externals?: Externals; + globalObject?: 'lynx' | 'globalThis'; +}; + +// @public +export const reactLynxExternalsPreset: Externals; + ``` diff --git a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts index 6da6895ffc..fcfd47b724 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts +++ b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts @@ -64,17 +64,127 @@ export const DEFAULT_EXTERNAL_BUNDLE_LIB_CONFIG: LibConfig = { ? false : DEFAULT_EXTERNAL_BUNDLE_MINIFY_CONFIG, target: 'web', + dataUriLimit: Number.POSITIVE_INFINITY, + distPath: { + root: 'dist-external-bundle', + }, }, source: { include: [/node_modules/], }, } +/** + * External module to global-name mappings used when building Lynx external + * bundles. + * + * @public + */ export type Externals = Record -export type LibOutputConfig = Required['output'] +/** + * Standard ReactLynx external mappings used by the built-in `reactlynx` + * preset. + * + * @public + */ +export const reactLynxExternalsPreset: Externals = { + 'react': ['ReactLynx', 'React'], + '@lynx-js/react': ['ReactLynx', 'React'], + '@lynx-js/react/internal': ['ReactLynx', 'ReactInternal'], + '@lynx-js/react/jsx-dev-runtime': ['ReactLynx', 'ReactJSXDevRuntime'], + '@lynx-js/react/jsx-runtime': ['ReactLynx', 'ReactJSXRuntime'], + '@lynx-js/react/lepus/jsx-dev-runtime': [ + 'ReactLynx', + 'ReactJSXLepusDevRuntime', + ], + '@lynx-js/react/lepus/jsx-runtime': ['ReactLynx', 'ReactJSXLepusRuntime'], + '@lynx-js/react/experimental/lazy/import': [ + 'ReactLynx', + 'ReactLazyImport', + ], + '@lynx-js/react/legacy-react-runtime': [ + 'ReactLynx', + 'ReactLegacyRuntime', + ], + '@lynx-js/react/runtime-components': ['ReactLynx', 'ReactComponents'], + '@lynx-js/react/worklet-runtime/bindings': [ + 'ReactLynx', + 'ReactWorkletRuntime', + ], + '@lynx-js/react/debug': ['ReactLynx', 'ReactDebug'], + preact: ['ReactLynx', 'Preact'], +} -export interface OutputConfig extends LibOutputConfig { +/** + * Enabled externals presets. + * + * Preset names are resolved from the built-in preset definitions plus any + * custom definitions passed to {@link defineExternalBundleRslibConfig}. + * + * @public + */ +export type ExternalsPresets = { + reactlynx?: boolean +} & Record + +/** + * Definition for a named externals preset. + * + * @public + */ +export interface ExternalsPresetDefinition { + /** + * Other preset names to include before applying the current preset. + */ + extends?: string | string[] + + /** + * Externals contributed by this preset. + */ + externals?: Externals +} + +/** + * Available externals preset definitions. + * + * @public + */ +export type ExternalsPresetDefinitions = Record< + string, + ExternalsPresetDefinition +> + +/** + * Built-in externals preset definitions. + * + * @public + */ +export const builtInExternalsPresetDefinitions: ExternalsPresetDefinitions = { + reactlynx: { + externals: reactLynxExternalsPreset, + }, +} + +/** + * Output config accepted by Lynx external bundle builds. + * + * @public + */ +export type OutputConfig = Required['output'] & { + /** + * Presets for external libraries. + * + * Same as https://rspack.rs/config/externals#externalspresets but for Lynx. + */ + externalsPresets?: ExternalsPresets + /** + * Definitions for custom externals presets enabled by `externalsPresets`. + * + * Use this to add business-specific presets such as `@lynx-js/lynx-ui`, or to extend a + * built-in preset through `extends`. + */ + externalsPresetDefinitions?: ExternalsPresetDefinitions externals?: Externals /** * This option indicates what global object will be used to mount the library. @@ -83,19 +193,153 @@ export interface OutputConfig extends LibOutputConfig { * * If you have enabled share js context and want to reuse the library by mounting to the global object, you can set this option to `'globalThis'`. * - * @default 'lynx' + * @defaultValue `'lynx'` */ globalObject?: 'lynx' | 'globalThis' } +/** + * Rslib config shape accepted by `defineExternalBundleRslibConfig`. + * + * @public + */ export interface ExternalBundleLibConfig extends LibConfig { output?: OutputConfig } +/** + * Resolve a single preset into the final external map that Rslib should use. + * + * This walks `extends` recursively so a business preset can layer on top of a + * built-in preset such as `reactlynx`, while still detecting unknown preset + * names and circular references early. + */ +function resolvePresetExternals( + presetName: string, + presetDefinitions: ExternalsPresetDefinitions, + resolving: string[], +): Externals { + const presetDefinition = presetDefinitions[presetName] + if (!presetDefinition) { + throw new Error( + `Unknown externals preset "${presetName}". Define it in \`output.externalsPresetDefinitions\` before enabling it in \`output.externalsPresets\`.`, + ) + } + + if (resolving.includes(presetName)) { + throw new Error( + `Circular externals preset dependency detected: ${ + [...resolving, presetName].join(' -> ') + }`, + ) + } + + const mergedExternals: Externals = {} + const nextResolving = [...resolving, presetName] + const inheritedPresetNames = presetDefinition.extends + ? (Array.isArray(presetDefinition.extends) + ? presetDefinition.extends + : [presetDefinition.extends]) + : [] + // recursively resolve extended presets + for (const inheritedPresetName of inheritedPresetNames) { + Object.assign( + mergedExternals, + resolvePresetExternals( + inheritedPresetName, + presetDefinitions, + nextResolving, + ), + ) + } + + Object.assign(mergedExternals, presetDefinition.externals) + + return mergedExternals +} + +/** + * Merge user-provided preset definitions with the built-in preset table. + * + * A custom definition with the same name as a built-in preset is treated as an + * extension of that preset instead of a full replacement, so callers can + * augment `reactlynx` without re-declaring all of its default externals. + */ +function resolvePresetDefinitions( + presetDefinitions?: ExternalsPresetDefinitions, +): ExternalsPresetDefinitions { + const resolvedDefinitions = { + ...builtInExternalsPresetDefinitions, + } + + for ( + const [presetName, presetDefinition] of Object.entries( + presetDefinitions ?? {}, + ) + ) { + const builtInPresetDefinition = + builtInExternalsPresetDefinitions[presetName] + if (!builtInPresetDefinition) { + resolvedDefinitions[presetName] = presetDefinition + continue + } + + const builtInExtends = builtInPresetDefinition.extends + ? (Array.isArray(builtInPresetDefinition.extends) + ? builtInPresetDefinition.extends + : [builtInPresetDefinition.extends]) + : [] + const customExtends = presetDefinition.extends + ? (Array.isArray(presetDefinition.extends) + ? presetDefinition.extends + : [presetDefinition.extends]) + : [] + + resolvedDefinitions[presetName] = { + extends: [...builtInExtends, ...customExtends], + externals: { + ...builtInPresetDefinition.externals, + ...presetDefinition.externals, + }, + } + } + + return resolvedDefinitions +} + +/** + * Convert Lynx-friendly preset/object config into the low-level Rslib + * `output.externals` callback. + * + * This is the bridge between: + * - preset-based config exposed by `defineExternalBundleRslibConfig`, and + * - the final `var` externals shape consumed by Rspack/Rslib. + */ function transformExternals( + externalsPresets?: ExternalsPresets, externals?: Externals, globalObject?: string, -): Required['externals'] { + presetDefinitions?: ExternalsPresetDefinitions, +): Required['output']>['externals'] { + const resolvedPresetDefinitions = resolvePresetDefinitions(presetDefinitions) + + if (externalsPresets) { + const presetExternals: Externals = {} + for (const [presetName, isEnabled] of Object.entries(externalsPresets)) { + if (!isEnabled) { + continue + } + Object.assign( + presetExternals, + resolvePresetExternals(presetName, resolvedPresetDefinitions, []), + ) + } + externals = { + ...presetExternals, + ...externals, + } + } + if (!externals) return {} return function({ request }, callback) { @@ -199,8 +443,10 @@ export function defineExternalBundleRslibConfig( ...userLibConfig.output, minify: normalizedOutputMinify, externals: transformExternals( + userLibConfig.output?.externalsPresets, userLibConfig.output?.externals, userLibConfig.output?.globalObject, + userLibConfig.output?.externalsPresetDefinitions, ), }, }, @@ -218,6 +464,14 @@ interface ExposedLayers { readonly MAIN_THREAD: string } +/** + * Rewrite user entries into explicit background/main-thread entries. + * + * External bundles are emitted per thread, so a single logical entry without + * an explicit layer is expanded into two concrete entries: + * - `` for background + * - `__main-thread` for main thread + */ const externalBundleEntryRsbuildPlugin = (): rsbuild.RsbuildPlugin => ({ name: 'lynx:external-bundle-entry', // ensure dsl plugin has exposed LAYERS @@ -230,7 +484,7 @@ const externalBundleEntryRsbuildPlugin = (): rsbuild.RsbuildPlugin => ({ if (!LAYERS) { throw new Error( - 'external-bundle-rsbuild-plugin requires exposed `LAYERS`.', + 'lynx-bundle-rslib-config requires exposed `LAYERS`. Please install a DSL plugin, for example `pluginReactLynx` for ReactLynx.', ) } diff --git a/packages/rspeedy/lynx-bundle-rslib-config/src/index.ts b/packages/rspeedy/lynx-bundle-rslib-config/src/index.ts index a16b00da26..d11f654f92 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/src/index.ts +++ b/packages/rspeedy/lynx-bundle-rslib-config/src/index.ts @@ -9,9 +9,19 @@ */ export { defineExternalBundleRslibConfig, + builtInExternalsPresetDefinitions, + reactLynxExternalsPreset, DEFAULT_EXTERNAL_BUNDLE_LIB_CONFIG as defaultExternalBundleLibConfig, } from './externalBundleRslibConfig.js' -export type { EncodeOptions } from './externalBundleRslibConfig.js' +export type { + EncodeOptions, + ExternalBundleLibConfig, + Externals, + ExternalsPresetDefinition, + ExternalsPresetDefinitions, + ExternalsPresets, + OutputConfig, +} from './externalBundleRslibConfig.js' export { ExternalBundleWebpackPlugin } from './webpack/ExternalBundleWebpackPlugin.js' export type { ExternalBundleWebpackPluginOptions } from './webpack/ExternalBundleWebpackPlugin.js' export { MainThreadRuntimeWrapperWebpackPlugin } from './webpack/MainThreadRuntimeWrapperWebpackPlugin.js' diff --git a/packages/rspeedy/lynx-bundle-rslib-config/test/external-bundle.test.ts b/packages/rspeedy/lynx-bundle-rslib-config/test/external-bundle.test.ts index d85669d91c..6fb02ea02c 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/test/external-bundle.test.ts +++ b/packages/rspeedy/lynx-bundle-rslib-config/test/external-bundle.test.ts @@ -26,6 +26,33 @@ async function build(rslibConfig: RslibConfig) { return await rslib.build() } +function resolveExternal( + rslibConfig: RslibConfig, + request: string, +) { + const externalsResolver = rslibConfig.lib[0]?.output?.externals as + | (( + data: { request?: string }, + callback: (error?: Error, result?: unknown) => void, + ) => void) + | undefined + + return new Promise((resolve, reject) => { + if (!externalsResolver) { + reject(new Error('Expected output.externals to be configured')) + return + } + + externalsResolver({ request }, (error, result) => { + if (error) { + reject(error) + return + } + resolve(result) + }) + }) +} + describe('define config', () => { it('should return entry config', () => { const rslibConfig = defineExternalBundleRslibConfig({ @@ -347,8 +374,9 @@ describe('debug mode artifacts', () => { }) describe('mount externals library', () => { + const fixtureDir = path.join(__dirname, './fixtures/utils-lib') + it('should mount externals library to lynx by default', async () => { - const fixtureDir = path.join(__dirname, './fixtures/utils-lib') const rslibConfig = defineExternalBundleRslibConfig({ source: { entry: { @@ -384,8 +412,193 @@ describe('mount externals library', () => { 'lynx[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].ReactLynx.React', ) }) + + it('should apply reactlynx externals preset to the final bundle', async () => { + const rslibConfig = defineExternalBundleRslibConfig({ + source: { + entry: { + utils: path.join(__dirname, './fixtures/utils-lib/index.ts'), + }, + }, + id: 'utils-reactlynx-preset', + output: { + distPath: { + root: path.join(fixtureDir, 'dist'), + }, + externalsPresets: { + reactlynx: true, + }, + minify: false, + globalObject: 'globalThis', + }, + plugins: [pluginReactLynx()], + }) + + await expect(resolveExternal(rslibConfig, 'react')).resolves + .toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'ReactLynx', + 'React', + ]) + await expect(resolveExternal(rslibConfig, '@lynx-js/react')).resolves + .toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'ReactLynx', + 'React', + ]) + + await build(rslibConfig) + + const decodedResult = await decodeTemplate( + path.join( + fixtureDir, + 'dist/utils-reactlynx-preset.lynx.bundle', + ), + ) + expect(Object.keys(decodedResult['custom-sections']).sort()).toEqual([ + 'utils', + 'utils__main-thread', + ]) + expect(decodedResult['custom-sections']['utils']).toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].ReactLynx.React', + ) + expect(decodedResult['custom-sections']['utils__main-thread']).toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].ReactLynx.React', + ) + }) + + it('should let explicit externals override the reactlynx preset', async () => { + const rslibConfig = defineExternalBundleRslibConfig({ + source: { + entry: { + utils: path.join(__dirname, './fixtures/utils-lib/index.ts'), + }, + }, + id: 'utils-reactlynx-preset-override', + output: { + distPath: { + root: path.join(fixtureDir, 'dist'), + }, + externalsPresets: { + reactlynx: true, + }, + externals: { + '@lynx-js/react': ['CustomRuntime', 'React'], + }, + minify: false, + globalObject: 'globalThis', + }, + plugins: [pluginReactLynx()], + }) + + await build(rslibConfig) + + const decodedResult = await decodeTemplate( + path.join( + fixtureDir, + 'dist/utils-reactlynx-preset-override.lynx.bundle', + ), + ) + expect(decodedResult['custom-sections']['utils']).toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].CustomRuntime.React', + ) + expect(decodedResult['custom-sections']['utils__main-thread']).toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].CustomRuntime.React', + ) + expect(decodedResult['custom-sections']['utils']).not.toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].ReactLynx.React', + ) + expect(decodedResult['custom-sections']['utils__main-thread']).not + .toContain( + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")].ReactLynx.React', + ) + }) + + it('should allow extending the built-in reactlynx preset', async () => { + const rslibConfig = defineExternalBundleRslibConfig({ + source: { + entry: { + utils: path.join(__dirname, './fixtures/utils-lib/index.ts'), + }, + }, + id: 'utils-reactlynx-custom-extend', + output: { + distPath: { + root: path.join(fixtureDir, 'dist'), + }, + externalsPresets: { + reactlynxPlus: true, + }, + externalsPresetDefinitions: { + reactlynxPlus: { + extends: 'reactlynx', + externals: { + '@lynx-js/react': ['CustomRuntime', 'React'], + }, + }, + }, + minify: false, + globalObject: 'globalThis', + }, + plugins: [pluginReactLynx()], + }) + await expect(resolveExternal(rslibConfig, '@lynx-js/react')).resolves + .toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'CustomRuntime', + 'React', + ]) + await expect(resolveExternal(rslibConfig, '@lynx-js/react/jsx-runtime')) + .resolves.toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'ReactLynx', + 'ReactJSXRuntime', + ]) + }) + + it('should allow custom externals presets that are not built in', async () => { + const rslibConfig = defineExternalBundleRslibConfig({ + source: { + entry: { + utils: path.join(__dirname, './fixtures/utils-lib/index.ts'), + }, + }, + id: 'utils-custom-preset', + output: { + distPath: { + root: path.join(fixtureDir, 'dist'), + }, + externalsPresets: { + lynxUi: true, + }, + externalsPresetDefinitions: { + lynxUi: { + externals: { + '@lynx-js/react': ['LynxUI', 'React'], + '@lynx-js/react/jsx-runtime': ['LynxUI', 'ReactJSXRuntime'], + }, + }, + }, + minify: false, + globalObject: 'globalThis', + }, + plugins: [pluginReactLynx()], + }) + await expect(resolveExternal(rslibConfig, '@lynx-js/react')).resolves + .toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'LynxUI', + 'React', + ]) + await expect(resolveExternal(rslibConfig, '@lynx-js/react/jsx-runtime')) + .resolves.toEqual([ + 'globalThis[Symbol.for("__LYNX_EXTERNAL_GLOBAL__")]', + 'LynxUI', + 'ReactJSXRuntime', + ]) + }) + it('should mount externals library to globalThis', async () => { - const fixtureDir = path.join(__dirname, './fixtures/utils-lib') const rslibConfig = defineExternalBundleRslibConfig({ source: { entry: { diff --git a/packages/rspeedy/plugin-external-bundle/README.md b/packages/rspeedy/plugin-external-bundle/README.md index b7e00dd7f0..8a51d9f4ca 100644 --- a/packages/rspeedy/plugin-external-bundle/README.md +++ b/packages/rspeedy/plugin-external-bundle/README.md @@ -17,6 +17,12 @@ npm install -D @lynx-js/external-bundle-rsbuild-plugin ``` +If you want to use the built-in `reactlynx` preset, also install: + +```bash +npm install -D @lynx-js/react-umd +``` + ## Usage ```ts @@ -28,11 +34,20 @@ export default { plugins: [ pluginReactLynx(), pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, externals: { - lodash: { - url: 'http://lodash.lynx.bundle', - background: { sectionPath: 'background' }, - mainThread: { sectionPath: 'mainThread' }, + 'lodash-es': { + bundlePath: 'lodash-es.lynx.bundle', + background: { sectionPath: 'lodash-es' }, + mainThread: { sectionPath: 'lodash-es__main-thread' }, + async: false, + }, + './components': { + bundlePath: 'comp.lynx.bundle', + background: { sectionPath: 'component' }, + mainThread: { sectionPath: 'component__main-thread' }, }, }, }), @@ -40,6 +55,157 @@ export default { } ``` +If your external bundle request key already matches the produced section names, +you can use the shorthand string form: + +```ts +pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + externals: { + './App.js': 'comp.lynx.bundle', + }, +}) +``` + +The shorthand expands to the same `bundlePath` plus default: + +- `libraryName = './App.js'` +- `background.sectionPath = './App.js'` +- `mainThread.sectionPath = './App.js__main-thread'` +- `async = true` + +If you need custom section names or a custom exported library name, use the +full object form instead: + +```ts +pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + externals: { + './App.js': { + libraryName: 'CompLib', + bundlePath: 'comp.lynx.bundle', + background: { sectionPath: 'CompLib' }, + mainThread: { sectionPath: 'CompLib__main-thread' }, + async: true, + }, + }, +}) +``` + +## `bundlePath` vs `url` + +Prefer `bundlePath` for bundles that belong to the current project. + +- `bundlePath` + - resolves through the runtime public path + - lets the plugin serve local bundles in development + - lets the plugin emit managed bundle assets during build +- `url` + - uses a fully resolved address directly + - is better only when the bundle is hosted outside the current build output, such as on a CDN + +For the built-in `reactlynx` preset, `bundlePath` is especially recommended because the plugin will automatically emit `react.lynx.bundle` by resolving `@lynx-js/react-umd/dev` or `@lynx-js/react-umd/prod`. + +## Building project-owned external bundles + +By default, `pluginExternalBundle` reads project-owned external bundles from `dist-external-bundle` before emitting them into the final app output. + +If you use that default directory, no extra config is needed: + +```ts +pluginExternalBundle({ + externals: { + './components': { + bundlePath: 'comp.lynx.bundle', + background: { sectionPath: 'component' }, + mainThread: { sectionPath: 'component__main-thread' }, + }, + }, +}) +``` + +Then: + +- development serves `dist-external-bundle/comp.lynx.bundle` +- production emits that bundle into the app output + +If your local external bundles live somewhere else, set `externalBundleRoot` explicitly. + +## ReactLynx preset + +`externalsPresets.reactlynx` expands the standard ReactLynx module requests automatically, so you do not need to write the full externals map by hand. + +If your app needs business-specific presets, define them next to +`externalsPresets`: + +```ts +pluginExternalBundle({ + externalsPresets: { + lynxUi: true, + }, + externalsPresetDefinitions: { + lynxUi: { + resolveExternals() { + return { + '@lynx-js/lynx-ui': { + libraryName: ['LynxUI', 'UI'], + bundlePath: 'lynx-ui.lynx.bundle', + background: { sectionPath: 'LynxUI' }, + mainThread: { sectionPath: 'LynxUI__main-thread' }, + async: false, + }, + } + }, + }, + }, +}) +``` + +If you want to extend the built-in `reactlynx` preset instead of defining a +completely separate one, use `extends`: + +```ts +pluginExternalBundle({ + externalsPresets: { + reactlynxPlus: true, + }, + externalsPresetDefinitions: { + reactlynxPlus: { + extends: 'reactlynx', + resolveExternals() { + return { + '@lynx-js/lynx-ui': { + libraryName: ['LynxUI', 'UI'], + bundlePath: 'lynx-ui.lynx.bundle', + background: { sectionPath: 'LynxUI' }, + mainThread: { sectionPath: 'LynxUI__main-thread' }, + async: false, + }, + } + }, + }, + }, +}) +``` + +Use it together with `@lynx-js/lynx-bundle-rslib-config` when producing external bundles: + +```ts +import { defineExternalBundleRslibConfig } from '@lynx-js/lynx-bundle-rslib-config' + +export default defineExternalBundleRslibConfig({ + output: { + externalsPresets: { + reactlynx: true, + }, + }, +}) +``` + ## Documentation Visit [Lynx Website](https://lynxjs.org/api/rspeedy/external-bundle-rsbuild-plugin) to view the full documentation. diff --git a/packages/rspeedy/plugin-external-bundle/etc/external-bundle-rsbuild-plugin.api.md b/packages/rspeedy/plugin-external-bundle/etc/external-bundle-rsbuild-plugin.api.md index 4fcc0d0144..538e109bd9 100644 --- a/packages/rspeedy/plugin-external-bundle/etc/external-bundle-rsbuild-plugin.api.md +++ b/packages/rspeedy/plugin-external-bundle/etc/external-bundle-rsbuild-plugin.api.md @@ -5,12 +5,65 @@ ```ts import type { ExternalsLoadingPluginOptions } from '@lynx-js/externals-loading-webpack-plugin'; +import type { ExternalValue } from '@lynx-js/externals-loading-webpack-plugin'; import type { RsbuildPlugin } from '@rsbuild/core'; +// @public +export const builtInExternalsPresetDefinitions: ExternalsPresetDefinitions; + +// @public +export interface ExternalsPresetContext { + rootPath: string; +} + +// @public +export interface ExternalsPresetDefinition { + extends?: string | string[]; + resolveExternals?: (value: boolean | object, context: ExternalsPresetContext) => ExternalsLoadingPluginOptions['externals']; + resolveManagedAssets?: (value: boolean | object, context: ExternalsPresetContext) => Map | Record; +} + +// @public +export type ExternalsPresetDefinitions = Record; + +// @public +export interface ExternalsPresets { + [presetName: string]: boolean | object | undefined; + reactlynx?: boolean | ReactLynxExternalsPresetOptions; +} + +// @public +export function normalizeBundlePath(bundlePath: string): string; + // @public export function pluginExternalBundle(options: PluginExternalBundleOptions): RsbuildPlugin; // @public -export type PluginExternalBundleOptions = Pick; +export interface PluginExternalBundleOptions extends Pick { + externalBundleRoot?: string; + externals?: Record; + externalsPresetDefinitions?: ExternalsPresetDefinitions; + externalsPresets?: ExternalsPresets; +} + +// @public +export type PluginExternalConfig = PluginExternalValue | string; + +// @public +export interface PluginExternalValue extends Omit { + bundlePath?: string; + // @deprecated + url?: string; +} + +// @public +export interface ReactLynxExternalsPresetOptions { + bundlePath?: string; + reactUmdPackageName?: string; + // @deprecated + url?: string; +} + +// (No @packageDocumentation comment for this package) ``` diff --git a/packages/rspeedy/plugin-external-bundle/package.json b/packages/rspeedy/plugin-external-bundle/package.json index dddfc1fd7d..e6a8190317 100644 --- a/packages/rspeedy/plugin-external-bundle/package.json +++ b/packages/rspeedy/plugin-external-bundle/package.json @@ -41,6 +41,7 @@ "@lynx-js/externals-loading-webpack-plugin": "workspace:*" }, "devDependencies": { + "@lynx-js/react-umd": "workspace:*", "@microsoft/api-extractor": "catalog:", "@rsbuild/core": "catalog:rsbuild" }, diff --git a/packages/rspeedy/plugin-external-bundle/src/index.ts b/packages/rspeedy/plugin-external-bundle/src/index.ts index 21d37ef9e2..ff940f3922 100644 --- a/packages/rspeedy/plugin-external-bundle/src/index.ts +++ b/packages/rspeedy/plugin-external-bundle/src/index.ts @@ -8,9 +8,17 @@ * A rsbuild plugin for loading external bundles using externals-loading-webpack-plugin. */ -import type { RsbuildPlugin } from '@rsbuild/core' +import { createReadStream, existsSync, readFileSync } from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { createRequire } from 'node:module' +import path from 'node:path' -import type { ExternalsLoadingPluginOptions } from '@lynx-js/externals-loading-webpack-plugin' +import type { RsbuildPlugin, RsbuildPluginAPI, Rspack } from '@rsbuild/core' + +import type { + ExternalValue, + ExternalsLoadingPluginOptions, +} from '@lynx-js/externals-loading-webpack-plugin' import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin' interface ExposedLayers { @@ -18,16 +26,719 @@ interface ExposedLayers { readonly MAIN_THREAD: string } +const require = createRequire(import.meta.url) + +const DEFAULT_REACT_UMD_PACKAGE_NAME = '@lynx-js/react-umd' +const REACT_LYNX_BUNDLE_FILE_NAME = 'react.lynx.bundle' + +const reactLynxExternalTemplate = { + 'react': { + libraryName: ['ReactLynx', 'React'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react': { + libraryName: ['ReactLynx', 'React'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/internal': { + libraryName: ['ReactLynx', 'ReactInternal'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/jsx-dev-runtime': { + libraryName: ['ReactLynx', 'ReactJSXDevRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/jsx-runtime': { + libraryName: ['ReactLynx', 'ReactJSXRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/lepus/jsx-dev-runtime': { + libraryName: ['ReactLynx', 'ReactJSXLepusDevRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/lepus/jsx-runtime': { + libraryName: ['ReactLynx', 'ReactJSXLepusRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/experimental/lazy/import': { + libraryName: ['ReactLynx', 'ReactLazyImport'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/legacy-react-runtime': { + libraryName: ['ReactLynx', 'ReactLegacyRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/runtime-components': { + libraryName: ['ReactLynx', 'ReactComponents'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/worklet-runtime/bindings': { + libraryName: ['ReactLynx', 'ReactWorkletRuntime'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + '@lynx-js/react/debug': { + libraryName: ['ReactLynx', 'ReactDebug'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + preact: { + libraryName: ['ReactLynx', 'Preact'], + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, +} satisfies Record> + +type ExternalsPresetValue = boolean | object + +type ManagedBundleAssets = Map + +function toManagedBundleAssets( + assets?: ManagedBundleAssets | Record, +): ManagedBundleAssets { + if (!assets) { + return new Map() + } + if (assets instanceof Map) { + return new Map(assets) + } + return new Map(Object.entries(assets)) +} + /** - * Options for the external-bundle-rsbuild-plugin. + * Options for the built-in `reactlynx` externals preset. * * @public */ -export type PluginExternalBundleOptions = Pick< - ExternalsLoadingPluginOptions, - 'externals' | 'globalObject' +export interface ReactLynxExternalsPresetOptions { + /** + * Emit the ReactLynx runtime bundle into the current build output and load it + * through the generated runtime public path. + * + * Prefer this over `url` for normal Rspeedy projects. In addition to letting + * the runtime resolve the final URL from `publicPath`, the plugin will also + * copy the corresponding `@lynx-js/react-umd` bundle into the emitted assets, + * so application bundles can reference it without requiring an extra manual + * copy step when publishing. + * + * @defaultValue `'react.lynx.bundle'` + */ + bundlePath?: string + + /** + * Package name that provides the ReactLynx runtime bundle. + * + * Override this when wrapping the plugin for another distribution, such as + * `@byted-lynx/react-umd`. + * + * @defaultValue `'@lynx-js/react-umd'` + */ + reactUmdPackageName?: string + + /** + * Override the runtime bundle URL directly. + * + * Use this only when the ReactLynx runtime is hosted outside the current + * build output, for example on a CDN. Unlike `bundlePath`, this does not emit + * any extra asset into the current compilation. + * + * @deprecated Prefer `bundlePath`, which resolves through the runtime public + * path and enables automatic asset emission. + */ + url?: string +} + +/** + * Presets for external bundle dependencies. + * + * @public + */ +export interface ExternalsPresets { + /** + * Load the ReactLynx runtime bundle and wire its standard module globals. + */ + reactlynx?: boolean | ReactLynxExternalsPresetOptions + + /** + * Additional custom preset flags. + */ + [presetName: string]: boolean | object | undefined +} + +/** + * Context passed to externals preset resolvers. + * + * @public + */ +export interface ExternalsPresetContext { + /** + * The current Rsbuild project root path. + */ + rootPath: string +} + +/** + * Definition for a named externals preset. + * + * @public + */ +export interface ExternalsPresetDefinition { + /** + * Other preset names to apply before the current preset. + */ + extends?: string | string[] + + /** + * Resolve external request mappings contributed by this preset. + */ + resolveExternals?: ( + value: boolean | object, + context: ExternalsPresetContext, + ) => ExternalsLoadingPluginOptions['externals'] + + /** + * Resolve managed bundle assets contributed by this preset. + */ + resolveManagedAssets?: ( + value: boolean | object, + context: ExternalsPresetContext, + ) => Map | Record +} + +/** + * Available externals preset definitions. + * + * @public + */ +export type ExternalsPresetDefinitions = Record< + string, + ExternalsPresetDefinition > +/** + * Built-in externals preset definitions. + * + * @public + */ +export const builtInExternalsPresetDefinitions: ExternalsPresetDefinitions = + createBuiltInExternalsPresetDefinitions() + +/** + * Options for the external-bundle-rsbuild-plugin. + * + * @public + */ +export interface PluginExternalBundleOptions extends + Pick< + ExternalsLoadingPluginOptions, + 'globalObject' | 'timeout' + > +{ + /** + * Root directory that stores project-owned external bundles referenced by + * `bundlePath`. + * + * `pluginExternalBundle` uses this directory for both development serving + * and build-time asset emission. Prefer setting this explicitly when + * external bundles are built into a separate output folder, such as + * `dist-external-bundle`. + */ + externalBundleRoot?: string + + /** + * Additional explicit externals to load. + */ + externals?: Record + + /** + * Presets for external libraries. + * + * Same as https://rspack.rs/config/externals#externalspresets but for Lynx. + */ + externalsPresets?: ExternalsPresets + + /** + * Definitions for custom externals presets enabled by `externalsPresets`. + * + * Use this to add business-specific presets such as `lynxUi`, or to extend a + * built-in preset through `extends`. + */ + externalsPresetDefinitions?: ExternalsPresetDefinitions +} + +/** + * External bundle reference accepted by `pluginExternalBundle`. + * + * @public + */ +export interface PluginExternalValue extends Omit { + /** + * Bundle path resolved against the runtime public path. + * + * Prefer this over `url` when the external bundle should be emitted or served + * as part of the current project. `pluginExternalBundle` can use this + * information to manage local bundle files, while the runtime keeps the final + * URL aligned with the active `publicPath`. + */ + bundlePath?: string + + /** + * Bundle URL. + * + * Use this only when the external bundle lives outside the current build + * output and should not be emitted or served by `pluginExternalBundle`. + * + * @deprecated Prefer `bundlePath`, which resolves through the runtime public + * path and lets higher-level tooling manage asset emission. + */ + url?: string +} + +/** + * External bundle shorthand accepted by `pluginExternalBundle`. + * + * When a string is provided, it is treated as `bundlePath` and the plugin will + * infer: + * - `libraryName`: the external request key + * - `background.sectionPath`: the external request key + * - `mainThread.sectionPath`: `${request}__main-thread` + * + * @public + */ +export type PluginExternalConfig = PluginExternalValue | string + +function normalizeReactLynxPreset( + preset: ExternalsPresets['reactlynx'], +): ReactLynxExternalsPresetOptions | undefined { + if (!preset) { + return undefined + } + return preset === true ? {} : preset +} + +/** + * Normalize a public bundle path by removing leading slashes. + * + * @public + */ +export function normalizeBundlePath(bundlePath: string): string { + return bundlePath.replace(/^\/+/, '') +} + +function createBuiltInExternalsPresetDefinitions(): ExternalsPresetDefinitions { + return { + reactlynx: { + resolveExternals(value) { + return createReactLynxExternals( + normalizeReactLynxPreset(value as ExternalsPresets['reactlynx']), + ) + }, + resolveManagedAssets(value, context) { + const preset = normalizeReactLynxPreset( + value as ExternalsPresets['reactlynx'], + ) + if (!preset || preset.url) { + return new Map() + } + return new Map([ + [ + getDefaultReactLynxBundlePath(preset), + getReactLynxBundlePath( + context.rootPath, + preset.reactUmdPackageName ?? DEFAULT_REACT_UMD_PACKAGE_NAME, + ), + ], + ]) + }, + }, + } +} + +function getReactLynxBundlePath( + rootPath: string, + reactUmdPackageName: string, +): string { + const reactUmdExport = process.env['NODE_ENV'] === 'production' + ? `${reactUmdPackageName}/prod` + : `${reactUmdPackageName}/dev` + try { + return require.resolve(reactUmdExport, { paths: [rootPath] }) + } catch { + throw new Error( + `external-bundle-rsbuild-plugin requires \`${reactUmdExport}\` when \`externalsPresets.reactlynx\` is enabled. Install a compatible \`${reactUmdPackageName}\` into your devDependencies.`, + ) + } +} + +function mergePresetDefinitions( + baseDefinition: ExternalsPresetDefinition, + extraDefinition: ExternalsPresetDefinition, +): ExternalsPresetDefinition { + const baseExtends = baseDefinition.extends + ? (Array.isArray(baseDefinition.extends) + ? baseDefinition.extends + : [baseDefinition.extends]) + : [] + const extraExtends = extraDefinition.extends + ? (Array.isArray(extraDefinition.extends) + ? extraDefinition.extends + : [extraDefinition.extends]) + : [] + + return { + extends: [...baseExtends, ...extraExtends], + resolveExternals(value, context) { + return { + ...(baseDefinition.resolveExternals?.(value, context) ?? {}), + ...(extraDefinition.resolveExternals?.(value, context) ?? {}), + } + }, + resolveManagedAssets(value, context) { + const assets = toManagedBundleAssets( + baseDefinition.resolveManagedAssets?.(value, context), + ) + for ( + const [bundlePath, sourcePath] of toManagedBundleAssets( + extraDefinition.resolveManagedAssets?.(value, context), + ) + ) { + assets.set(bundlePath, sourcePath) + } + return assets + }, + } +} + +function resolvePresetDefinitions( + presetDefinitions?: ExternalsPresetDefinitions, +): ExternalsPresetDefinitions { + const resolvedDefinitions: ExternalsPresetDefinitions = { + ...createBuiltInExternalsPresetDefinitions(), + } + + for ( + const [presetName, presetDefinition] of Object.entries( + presetDefinitions ?? {}, + ) + ) { + const builtInDefinition = builtInExternalsPresetDefinitions[presetName] + resolvedDefinitions[presetName] = builtInDefinition + ? mergePresetDefinitions(builtInDefinition, presetDefinition) + : presetDefinition + } + + return resolvedDefinitions +} + +function resolvePresetResult( + presetName: string, + presetValue: ExternalsPresetValue, + presetDefinitions: ExternalsPresetDefinitions, + resolving: string[], + context: ExternalsPresetContext, +): { + externals: ExternalsLoadingPluginOptions['externals'] + managedAssets: ManagedBundleAssets +} { + const presetDefinition = presetDefinitions[presetName] + if (!presetDefinition) { + throw new Error( + `Unknown externals preset "${presetName}". Define it in \`externalsPresetDefinitions\` before enabling it in \`externalsPresets\`.`, + ) + } + + if (resolving.includes(presetName)) { + throw new Error( + `Circular externals preset dependency detected: ${ + [...resolving, presetName].join(' -> ') + }`, + ) + } + + const externals = {} + const managedAssets = new Map() + const nextResolving = [...resolving, presetName] + const inheritedPresetNames = presetDefinition.extends + ? (Array.isArray(presetDefinition.extends) + ? presetDefinition.extends + : [presetDefinition.extends]) + : [] + + for (const inheritedPresetName of inheritedPresetNames) { + const inheritedResult = resolvePresetResult( + inheritedPresetName, + true, + presetDefinitions, + nextResolving, + context, + ) + Object.assign(externals, inheritedResult.externals) + for (const [bundlePath, sourcePath] of inheritedResult.managedAssets) { + managedAssets.set(bundlePath, sourcePath) + } + } + + Object.assign( + externals, + presetDefinition.resolveExternals?.(presetValue, context) ?? {}, + ) + for ( + const [bundlePath, sourcePath] of toManagedBundleAssets( + presetDefinition.resolveManagedAssets?.(presetValue, context), + ) + ) { + managedAssets.set(bundlePath, sourcePath) + } + + return { externals, managedAssets } +} + +function resolvePresetExternals( + externalsPresets: ExternalsPresets | undefined, + presetDefinitions: ExternalsPresetDefinitions, + context: ExternalsPresetContext, +): { + externals: ExternalsLoadingPluginOptions['externals'] + managedAssets: ManagedBundleAssets +} { + const externals = {} + const managedAssets = new Map() + + for ( + const [presetName, presetValue] of Object.entries(externalsPresets ?? {}) + ) { + if (!presetValue) { + continue + } + const resolvedPreset = resolvePresetResult( + presetName, + presetValue, + presetDefinitions, + [], + context, + ) + Object.assign(externals, resolvedPreset.externals) + for (const [bundlePath, sourcePath] of resolvedPreset.managedAssets) { + managedAssets.set(bundlePath, sourcePath) + } + } + + return { externals, managedAssets } +} + +function createReactLynxExternals( + preset: ReactLynxExternalsPresetOptions | undefined, +): ExternalsLoadingPluginOptions['externals'] { + const bundleReference = preset?.url + ? { url: preset.url } + : { bundlePath: getDefaultReactLynxBundlePath(preset) } + + return Object.fromEntries( + Object.entries(reactLynxExternalTemplate).map(([request, external]) => [ + request, + { + ...external, + ...bundleReference, + }, + ]), + ) +} + +class EmitManagedBundleAssetsPlugin { + constructor( + private assets: ReadonlyMap, + ) {} + + apply(compiler: Rspack.Compiler): void { + compiler.hooks.thisCompilation.tap( + EmitManagedBundleAssetsPlugin.name, + (compilation) => { + compilation.hooks.processAssets.tap( + { + name: EmitManagedBundleAssetsPlugin.name, + // Emit managed bundle files after optimization so binary template + // assets such as `*.template.js` are not treated as JS chunks by + // later minification stages. + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + () => { + const { RawSource } = compiler.webpack.sources + for (const [bundlePath, sourcePath] of this.assets) { + if (compilation.getAsset(bundlePath)) { + continue + } + + if (!existsSync(sourcePath)) { + throw new Error( + `external-bundle-rsbuild-plugin could not find local bundle \`${sourcePath}\` for emitted asset \`${bundlePath}\`.`, + ) + } + + compilation.emitAsset( + bundlePath, + new RawSource(readFileSync(sourcePath), false), + ) + } + }, + ) + }, + ) + } +} + +function getDefaultReactLynxBundlePath( + preset: ReactLynxExternalsPresetOptions | undefined, +) { + return normalizeBundlePath(preset?.bundlePath ?? REACT_LYNX_BUNDLE_FILE_NAME) +} + +function joinUrlPath(base: string | undefined, bundlePath: string) { + const normalizedBase = base ?? '/' + const normalizedBundlePath = normalizeBundlePath(bundlePath) + if (normalizedBase === '/') { + return `/${normalizedBundlePath}` + } + if (/^[a-z][a-z\d+\-.]*:/i.test(normalizedBase)) { + try { + return new URL(normalizedBundlePath, normalizedBase).toString() + } catch { + return `${normalizedBase.replace(/\/$/, '')}/${normalizedBundlePath}` + } + } + return `${normalizedBase.replace(/\/$/, '')}/${normalizedBundlePath}` +} + +function getExternalBundleRoot( + options: PluginExternalBundleOptions, + api: RsbuildPluginAPI, +): string { + return path.resolve( + api.context.rootPath, + options.externalBundleRoot ?? 'dist-external-bundle', + ) +} + +function getManagedBundleAssets( + options: PluginExternalBundleOptions, + presetManagedAssets: ManagedBundleAssets, + api: RsbuildPluginAPI, +): Map { + const assets = new Map(presetManagedAssets) + + const externalBundleRoot = getExternalBundleRoot(options, api) + for ( + const [request, rawExternal] of Object.entries(options.externals ?? {}) + ) { + const external = normalizePluginExternal(request, rawExternal) + if (external.url || !external.bundlePath) { + continue + } + + assets.set( + normalizeBundlePath(external.bundlePath), + path.resolve( + externalBundleRoot, + normalizeBundlePath(external.bundlePath), + ), + ) + } + + return assets +} + +function getLocalBundleAssets( + options: PluginExternalBundleOptions, + presetManagedAssets: ManagedBundleAssets, + api: RsbuildPluginAPI, + serverBase: string | undefined, +): Map { + const assets = new Map() + for ( + const [bundlePath, sourcePath] of getManagedBundleAssets( + options, + presetManagedAssets, + api, + ) + ) { + assets.set( + joinUrlPath(serverBase, bundlePath), + sourcePath, + ) + } + + return assets +} + +function resolvePluginExternals( + externals: PluginExternalBundleOptions['externals'] | undefined, +): ExternalsLoadingPluginOptions['externals'] { + if (!externals) { + return {} + } + + for (const [request, external] of Object.entries(externals)) { + const normalizedExternal = normalizePluginExternal(request, external) + if (normalizedExternal.url || normalizedExternal.bundlePath) { + continue + } + + throw new Error( + `external-bundle-rsbuild-plugin requires \`url\` or \`bundlePath\` for external "${request}".`, + ) + } + + return Object.fromEntries( + Object.entries(externals).map(([request, external]) => [ + request, + normalizePluginExternal(request, external), + ]), + ) +} + +function normalizePluginExternal( + request: string, + external: PluginExternalConfig, +): PluginExternalValue { + if (typeof external !== 'string') { + return external + } + + return { + bundlePath: external, + libraryName: request, + background: { + sectionPath: request, + }, + mainThread: { + sectionPath: `${request}__main-thread`, + }, + async: true, + } +} + /** * Create a rsbuild plugin for loading external bundles. * @@ -46,7 +757,7 @@ export type PluginExternalBundleOptions = Pick< * pluginExternalBundle({ * externals: { * lodash: { - * url: 'http://lodash.lynx.bundle', + * bundlePath: 'lodash.lynx.bundle', * background: { sectionPath: 'background' }, * mainThread: { sectionPath: 'mainThread' }, * }, @@ -64,24 +775,99 @@ export function pluginExternalBundle( return { name: 'lynx:external-bundle', setup(api) { + const presetDefinitions = resolvePresetDefinitions( + options.externalsPresetDefinitions, + ) + const presetResolution = resolvePresetExternals( + options.externalsPresets, + presetDefinitions, + { + rootPath: api.context.rootPath, + }, + ) + + api.modifyRsbuildConfig((config, { mergeRsbuildConfig }) => { + const localBundleAssets = getLocalBundleAssets( + options, + presetResolution.managedAssets, + api, + config.server?.base, + ) + + if (localBundleAssets.size === 0) { + return config + } + + return mergeRsbuildConfig(config, { + dev: { + setupMiddlewares: [ + (middlewares) => { + middlewares.unshift(( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ) => { + const bundlePath = req.url + ? localBundleAssets.get(req.url) + : undefined + + if (bundlePath && existsSync(bundlePath)) { + res.setHeader( + 'Content-Type', + 'application/octet-stream', + ) + res.setHeader('Access-Control-Allow-Origin', '*') + createReadStream(bundlePath).pipe(res) + return + } + next() + }) + return middlewares + }, + ], + }, + }) + }) + api.modifyRspackConfig((config) => { - // Get layer names from react-rsbuild-plugin const LAYERS = api.useExposed( Symbol.for('LAYERS'), ) if (!LAYERS) { throw new Error( - 'external-bundle-rsbuild-plugin requires exposed `LAYERS`.', + 'external-bundle-rsbuild-plugin requires exposed `LAYERS`. Please install a DSL plugin, for example `pluginReactLynx` for ReactLynx.', ) } + + const explicitExternals = resolvePluginExternals( + options.externals, + ) + const externals = { + ...presetResolution.externals, + ...explicitExternals, + } + const managedBundleAssets = getManagedBundleAssets( + options, + presetResolution.managedAssets, + api, + ) + config.plugins = config.plugins || [] + if (managedBundleAssets.size > 0) { + config.plugins.push( + new EmitManagedBundleAssetsPlugin( + managedBundleAssets, + ), + ) + } config.plugins.push( new ExternalsLoadingPlugin({ backgroundLayer: LAYERS.BACKGROUND, mainThreadLayer: LAYERS.MAIN_THREAD, - externals: options.externals, + externals, globalObject: options.globalObject, + timeout: options.timeout, }), ) return config diff --git a/packages/rspeedy/plugin-external-bundle/test/index.test.ts b/packages/rspeedy/plugin-external-bundle/test/index.test.ts index 95038e00e8..221d480af7 100644 --- a/packages/rspeedy/plugin-external-bundle/test/index.test.ts +++ b/packages/rspeedy/plugin-external-bundle/test/index.test.ts @@ -2,25 +2,83 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { Writable } from 'node:stream' + import { createRsbuild } from '@rsbuild/core' import { describe, expect, test } from 'vitest' +import type { ExternalsLoadingPluginOptions } from '@lynx-js/externals-loading-webpack-plugin' import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin' import { pluginStubLayers } from './stub-layers.plugin.js' +class MockResponse extends Writable { + headers = new Map() + + override _write( + _chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + callback() + } + + setHeader(name: string, value: string): void { + this.headers.set(name, value) + } +} + +function getExternalsLoadingPlugin( + plugins: unknown[], +): ExternalsLoadingPlugin { + const plugin = plugins.find( + (value): value is ExternalsLoadingPlugin => + value instanceof ExternalsLoadingPlugin, + ) + + expect(plugin).toBeDefined() + return plugin! +} + +function getExternalsLoadingPluginOptions( + plugin: ExternalsLoadingPlugin, +): ExternalsLoadingPluginOptions { + return (plugin as unknown as { + options: ExternalsLoadingPluginOptions + }).options +} + +type Middleware = ( + req: IncomingMessage & { url?: string }, + res: ServerResponse, + next: () => void, +) => void + +type SetupMiddlewares = (middlewares: Middleware[]) => Middleware[] + describe('pluginExternalBundle', () => { test('should register ExternalsLoadingPlugin with correct options', async () => { const { pluginExternalBundle } = await import('../src/index.js') const externalsConfig = { lodash: { - url: 'http://lodash.lynx.bundle', + bundlePath: 'lodash.lynx.bundle', background: { sectionPath: 'background' }, mainThread: { sectionPath: 'mainThread' }, }, react: { - url: 'http://react.lynx.bundle', + bundlePath: 'react.lynx.bundle', background: { sectionPath: 'react-background' }, mainThread: { sectionPath: 'react-main' }, }, @@ -29,7 +87,11 @@ describe('pluginExternalBundle', () => { let capturedPlugins: unknown[] = [] const rsbuild = await createRsbuild({ + cwd: __dirname, rsbuildConfig: { + dev: { + assetPrefix: 'http://example.com/assets/', + }, source: { entry: { main: './fixtures/basic.tsx', @@ -55,18 +117,73 @@ describe('pluginExternalBundle', () => { await rsbuild.inspectConfig() // Verify that ExternalsLoadingPlugin is registered - const externalBundlePlugin = capturedPlugins.find( - (plugin) => plugin instanceof ExternalsLoadingPlugin, - ) - - expect(externalBundlePlugin).toBeDefined() + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) // Verify plugin options expect(externalBundlePlugin).toMatchObject({ options: { backgroundLayer: 'BACKGROUND_LAYER', mainThreadLayer: 'MAIN_THREAD_LAYER', - externals: externalsConfig, + externals: { + lodash: { + background: { sectionPath: 'background' }, + mainThread: { sectionPath: 'mainThread' }, + bundlePath: 'lodash.lynx.bundle', + }, + react: { + background: { sectionPath: 'react-background' }, + mainThread: { sectionPath: 'react-main' }, + bundlePath: 'react.lynx.bundle', + }, + }, + }, + }) + }) + + test('should expand string shorthand externals config', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externals: { + './App.js': 'comp-lib.template.js', + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + expect(externalBundlePlugin).toMatchObject({ + options: { + externals: { + './App.js': { + libraryName: './App.js', + bundlePath: 'comp-lib.template.js', + background: { sectionPath: './App.js' }, + mainThread: { sectionPath: './App.js__main-thread' }, + async: true, + }, + }, }, }) }) @@ -75,6 +192,7 @@ describe('pluginExternalBundle', () => { const { pluginExternalBundle } = await import('../src/index.js') const rsbuild = await createRsbuild({ + cwd: __dirname, rsbuildConfig: { source: { entry: { @@ -86,7 +204,7 @@ describe('pluginExternalBundle', () => { pluginExternalBundle({ externals: { lodash: { - url: 'http://lodash.lynx.bundle', + bundlePath: 'lodash.lynx.bundle', background: { sectionPath: 'background' }, mainThread: { sectionPath: 'mainThread' }, }, @@ -98,7 +216,7 @@ describe('pluginExternalBundle', () => { // The error should be thrown during config inspection/build await expect(rsbuild.inspectConfig()).rejects.toThrow( - 'external-bundle-rsbuild-plugin requires exposed `LAYERS`.', + 'external-bundle-rsbuild-plugin requires exposed `LAYERS`. Please install a DSL plugin, for example `pluginReactLynx` for ReactLynx.', ) }) @@ -108,6 +226,7 @@ describe('pluginExternalBundle', () => { let capturedPlugins: unknown[] = [] const rsbuild = await createRsbuild({ + cwd: __dirname, rsbuildConfig: { source: { entry: { @@ -138,6 +257,478 @@ describe('pluginExternalBundle', () => { }) }) + test('should throw when an external is missing both url and bundlePath', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externals: { + lodash: { + background: { sectionPath: 'background' }, + mainThread: { sectionPath: 'mainThread' }, + }, + }, + }), + ], + }, + }) + + await expect(rsbuild.inspectConfig()).rejects.toThrow( + 'external-bundle-rsbuild-plugin requires `url` or `bundlePath` for external "lodash".', + ) + }) + + test('should expand the reactlynx preset with the normalized asset prefix', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + dev: { + assetPrefix: 'http://example.com/assets/', + }, + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + const externals = getExternalsLoadingPluginOptions(externalBundlePlugin) + .externals + + expect(externals?.['react']).toMatchObject({ + libraryName: ['ReactLynx', 'React'], + bundlePath: 'react.lynx.bundle', + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }) + expect(externals?.['@lynx-js/react']).toMatchObject({ + libraryName: ['ReactLynx', 'React'], + bundlePath: 'react.lynx.bundle', + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }) + expect(rsbuild.getNormalizedConfig().dev?.setupMiddlewares).toHaveLength(1) + }) + + test('should keep reactlynx preset as bundlePath when assetPrefix contains placeholders', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + dev: { + assetPrefix: 'http://100.82.226.164:/', + }, + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + const reactExternal = getExternalsLoadingPluginOptions(externalBundlePlugin) + .externals?.['@lynx-js/react'] + expect(reactExternal?.bundlePath).toBe('react.lynx.bundle') + }) + + test('should emit the reactlynx bundle into build output by default', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + const distRoot = mkdtempSync(path.join(tmpdir(), 'rspeedy-externals-')) + const projectRoot = mkdtempSync( + path.join(tmpdir(), 'rspeedy-externals-src-'), + ) + const entryFile = path.join(projectRoot, 'index.js') + writeFileSync(entryFile, 'console.log("external bundle test");') + + try { + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + output: { + distPath: { + root: distRoot, + }, + }, + source: { + entry: { + main: entryFile, + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + }), + ], + }, + }) + + await rsbuild.build() + + const bundleFile = path.join(distRoot, 'react.lynx.bundle') + expect(existsSync(bundleFile)).toBe(true) + expect(readFileSync(bundleFile).length).toBeGreaterThan(0) + } finally { + rmSync(distRoot, { recursive: true, force: true }) + rmSync(projectRoot, { recursive: true, force: true }) + } + }) + + test('should let an explicit preset url override the automatic reactlynx url', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + dev: { + assetPrefix: 'http://example.com/assets/', + }, + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalsPresets: { + reactlynx: { + url: 'https://cdn.example.com/react.lynx.bundle', + }, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + const externals = getExternalsLoadingPluginOptions(externalBundlePlugin) + .externals + + expect(externals?.['@lynx-js/react']).toMatchObject({ + url: 'https://cdn.example.com/react.lynx.bundle', + }) + expect(rsbuild.getNormalizedConfig().dev?.setupMiddlewares).toBeUndefined() + }) + + test('should allow custom externals presets from plugin options', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalsPresets: { + lynxUi: true, + }, + externalsPresetDefinitions: { + lynxUi: { + resolveExternals() { + return { + '@lynx-js/lynx-ui': { + libraryName: ['LynxUI', 'UI'], + bundlePath: 'lynx-ui.lynx.bundle', + background: { sectionPath: 'LynxUI' }, + mainThread: { sectionPath: 'LynxUI__main-thread' }, + async: false, + }, + } + }, + }, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + const externals = getExternalsLoadingPluginOptions(externalBundlePlugin) + .externals + + expect(externals?.['@lynx-js/lynx-ui']).toMatchObject({ + libraryName: ['LynxUI', 'UI'], + bundlePath: 'lynx-ui.lynx.bundle', + background: { sectionPath: 'LynxUI' }, + mainThread: { sectionPath: 'LynxUI__main-thread' }, + async: false, + }) + }) + + test('should resolve explicit external bundle paths against assetPrefix', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + let capturedPlugins: unknown[] = [] + + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + dev: { + assetPrefix: 'http://example.com/assets/', + }, + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + tools: { + rspack(config) { + capturedPlugins = config.plugins || [] + return config + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externals: { + './App.js': { + bundlePath: '/comp-lib.lynx.bundle', + libraryName: 'CompLib', + background: { sectionPath: 'CompLib' }, + mainThread: { sectionPath: 'CompLib__main-thread' }, + async: true, + }, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const externalBundlePlugin = getExternalsLoadingPlugin(capturedPlugins) + const externals = getExternalsLoadingPluginOptions(externalBundlePlugin) + .externals + + expect(externals?.['./App.js']).toMatchObject({ + bundlePath: '/comp-lib.lynx.bundle', + }) + expect(rsbuild.getNormalizedConfig().dev?.setupMiddlewares).toHaveLength(1) + }) + + test('should serve explicit bundlePath files from externalBundleRoot', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + const tempRoot = mkdtempSync( + path.join(process.cwd(), '.tmp-plugin-external-bundle-'), + ) + const bundlePath = 'nested/comp-lib.lynx.bundle' + const bundleFile = path.join(tempRoot, bundlePath) + + mkdirSync(path.dirname(bundleFile), { recursive: true }) + writeFileSync(bundleFile, 'bundle') + + try { + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + source: { + entry: { + main: './fixtures/basic.tsx', + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalBundleRoot: tempRoot, + externals: { + './App.js': { + bundlePath, + libraryName: 'CompLib', + background: { sectionPath: 'CompLib' }, + mainThread: { sectionPath: 'CompLib__main-thread' }, + async: true, + }, + }, + }), + ], + }, + }) + + await rsbuild.inspectConfig() + + const setupMiddlewares = rsbuild.getNormalizedConfig().dev + ?.setupMiddlewares as SetupMiddlewares[] | undefined + expect(setupMiddlewares).toHaveLength(1) + const firstSetupMiddleware = setupMiddlewares?.[0] + expect(firstSetupMiddleware).toBeDefined() + + const middlewares = firstSetupMiddleware ? firstSetupMiddleware([]) : [] + expect(middlewares).toHaveLength(1) + const firstMiddleware = middlewares[0] + expect(firstMiddleware).toBeDefined() + + let nextCalled = false + const res = new MockResponse() + const finished = new Promise((resolve, reject) => { + res.on('finish', resolve) + res.on('error', reject) + }) + + firstMiddleware!( + { + url: '/nested/comp-lib.lynx.bundle', + } as IncomingMessage & { url?: string }, + res as unknown as ServerResponse, + () => { + nextCalled = true + }, + ) + + await finished + + expect(nextCalled).toBe(false) + expect(res.headers.get('Content-Type')).toBe('application/octet-stream') + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + } finally { + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + test('should emit explicit bundlePath assets from externalBundleRoot', async () => { + const { pluginExternalBundle } = await import('../src/index.js') + + const distRoot = mkdtempSync(path.join(tmpdir(), 'rspeedy-externals-')) + const externalBundleRoot = mkdtempSync( + path.join(tmpdir(), 'rspeedy-external-bundles-'), + ) + const projectRoot = mkdtempSync( + path.join(tmpdir(), 'rspeedy-externals-src-'), + ) + const entryFile = path.join(projectRoot, 'index.js') + const bundlePath = 'nested/comp-lib.lynx.bundle' + const externalBundleFile = path.join(externalBundleRoot, bundlePath) + + writeFileSync(entryFile, 'console.log("external bundle test");') + mkdirSync(path.dirname(externalBundleFile), { recursive: true }) + writeFileSync(externalBundleFile, 'external bundle') + + try { + const rsbuild = await createRsbuild({ + cwd: __dirname, + rsbuildConfig: { + output: { + distPath: { + root: distRoot, + }, + }, + source: { + entry: { + main: entryFile, + }, + }, + plugins: [ + pluginStubLayers(), + pluginExternalBundle({ + externalBundleRoot, + externals: { + './App.js': { + bundlePath, + libraryName: 'CompLib', + background: { sectionPath: 'CompLib' }, + mainThread: { sectionPath: 'CompLib__main-thread' }, + async: true, + }, + }, + }), + ], + }, + }) + + await rsbuild.build() + + const emittedBundleFile = path.join(distRoot, bundlePath) + expect(existsSync(emittedBundleFile)).toBe(true) + expect(readFileSync(emittedBundleFile, 'utf8')).toBe('external bundle') + } finally { + rmSync(distRoot, { recursive: true, force: true }) + rmSync(externalBundleRoot, { recursive: true, force: true }) + rmSync(projectRoot, { recursive: true, force: true }) + } + }) + test('should correctly pass layer names from LAYERS', async () => { const { pluginExternalBundle } = await import('../src/index.js') @@ -149,6 +740,7 @@ describe('pluginExternalBundle', () => { let capturedPlugins: unknown[] = [] const rsbuild = await createRsbuild({ + cwd: __dirname, rsbuildConfig: { source: { entry: { @@ -166,7 +758,7 @@ describe('pluginExternalBundle', () => { pluginExternalBundle({ externals: { lodash: { - url: 'http://lodash.lynx.bundle', + bundlePath: 'lodash.lynx.bundle', background: { sectionPath: 'background' }, mainThread: { sectionPath: 'mainThread' }, }, @@ -197,6 +789,7 @@ describe('pluginExternalBundle', () => { let capturedPlugins: unknown[] = [] const rsbuild = await createRsbuild({ + cwd: __dirname, rsbuildConfig: { source: { entry: { @@ -214,7 +807,7 @@ describe('pluginExternalBundle', () => { pluginExternalBundle({ externals: { lodash: { - url: 'http://lodash.lynx.bundle', + bundlePath: 'lodash.lynx.bundle', background: { sectionPath: 'background' }, mainThread: { sectionPath: 'mainThread' }, }, diff --git a/packages/rspeedy/plugin-external-bundle/test/resolve-root-path.test.ts b/packages/rspeedy/plugin-external-bundle/test/resolve-root-path.test.ts new file mode 100644 index 0000000000..81d53af3c9 --- /dev/null +++ b/packages/rspeedy/plugin-external-bundle/test/resolve-root-path.test.ts @@ -0,0 +1,86 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { afterEach, describe, expect, it, vi } from 'vitest' + +const resolveMock = vi.fn((id: string, options?: { paths?: string[] }) => + (options?.paths?.length ?? 0) > 0 + ? `${options!.paths![0]}/${id}.js` + : `/default/${id}.js` +) + +async function loadModule() { + vi.resetModules() + vi.doMock('node:module', () => ({ + createRequire: () => ({ + resolve: resolveMock, + }), + })) + + return import('../src/index.js') +} + +describe('pluginExternalBundle reactlynx peer resolution', () => { + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('resolves @lynx-js/react-umd from the rsbuild rootPath', async () => { + vi.stubEnv('NODE_ENV', 'development') + const { pluginExternalBundle } = await loadModule() + + const plugin = pluginExternalBundle({ + externalsPresets: { + reactlynx: true, + }, + }) + + await plugin.setup?.({ + context: { + rootPath: '/virtual/app', + }, + modifyRsbuildConfig( + callback: ( + config: Record, + utils: { + mergeRsbuildConfig: ( + base: Record, + extra: Record, + ) => Record + }, + ) => Record, + ) { + callback( + {}, + { + mergeRsbuildConfig(base, extra) { + return { + ...base, + ...extra, + } + }, + }, + ) + }, + modifyRspackConfig( + callback: (config: { plugins?: unknown[] }) => { plugins?: unknown[] }, + ) { + callback({ plugins: [] }) + }, + getRsbuildConfig() { + return {} + }, + useExposed() { + return { + BACKGROUND: 'background', + MAIN_THREAD: 'main-thread', + } + }, + } as never) + + expect(resolveMock).toHaveBeenCalledWith('@lynx-js/react-umd/dev', { + paths: ['/virtual/app'], + }) + }) +}) diff --git a/packages/rspeedy/plugin-external-bundle/tsconfig.build.json b/packages/rspeedy/plugin-external-bundle/tsconfig.build.json index b75bf5dd1c..f4fa6a5a61 100644 --- a/packages/rspeedy/plugin-external-bundle/tsconfig.build.json +++ b/packages/rspeedy/plugin-external-bundle/tsconfig.build.json @@ -6,7 +6,7 @@ "rootDir": "./src", }, "references": [ - { "path": "../core/tsconfig.build.json" }, + { "path": "../../webpack/externals-loading-webpack-plugin/tsconfig.build.json" }, ], "include": ["src"], } diff --git a/packages/rspeedy/plugin-external-bundle/tsconfig.json b/packages/rspeedy/plugin-external-bundle/tsconfig.json index 110e5a0ccb..6ccf330c5c 100644 --- a/packages/rspeedy/plugin-external-bundle/tsconfig.json +++ b/packages/rspeedy/plugin-external-bundle/tsconfig.json @@ -6,6 +6,6 @@ }, "include": ["src", "test", "vitest.config.ts"], "references": [ - { "path": "../core/tsconfig.build.json" }, + { "path": "../../webpack/externals-loading-webpack-plugin/tsconfig.build.json" }, ], } diff --git a/packages/rspeedy/plugin-external-bundle/turbo.json b/packages/rspeedy/plugin-external-bundle/turbo.json new file mode 100644 index 0000000000..1c3707975b --- /dev/null +++ b/packages/rspeedy/plugin-external-bundle/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": [ + "//" + ], + "tasks": { + "test": { + "dependsOn": [ + "@lynx-js/react-umd#build" + ] + } + } +} diff --git a/packages/webpack/externals-loading-webpack-plugin/README.md b/packages/webpack/externals-loading-webpack-plugin/README.md index 264ad94f04..a54935295a 100644 --- a/packages/webpack/externals-loading-webpack-plugin/README.md +++ b/packages/webpack/externals-loading-webpack-plugin/README.md @@ -1,3 +1,50 @@

@lynx-js/externals-loading-webpack-plugin

A webpack plugin to support loading externals in Lynx. + +## When to use this package + +Most applications should use +`@lynx-js/external-bundle-rsbuild-plugin`, which expands presets and handles +development serving and build-time asset emission. + +Use `@lynx-js/externals-loading-webpack-plugin` directly only when you need the +low-level webpack/Rspack runtime integration. + +## Usage + +```ts +import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin'; + +export default { + plugins: [ + new ExternalsLoadingPlugin({ + backgroundLayer: 'BACKGROUND_LAYER', + mainThreadLayer: 'MAIN_THREAD_LAYER', + externals: { + './App.js': { + libraryName: './App.js', + bundlePath: 'comp-lib.lynx.bundle', + background: { sectionPath: './App.js' }, + mainThread: { sectionPath: './App.js__main-thread' }, + async: true, + }, + '@lynx-js/react': { + libraryName: ['ReactLynx', 'React'], + bundlePath: 'react.lynx.bundle', + background: { sectionPath: 'ReactLynx' }, + mainThread: { sectionPath: 'ReactLynx__main-thread' }, + async: false, + }, + }, + }), + ], +}; +``` + +### `bundlePath` vs `url` + +- `bundlePath` is preferred for bundles that should resolve from the runtime + public path. +- `url` is only needed when the external bundle is hosted outside the current + build output. diff --git a/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md b/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md index 952b9e52f6..e0deb0628e 100644 --- a/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md +++ b/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md @@ -20,16 +20,18 @@ export interface ExternalsLoadingPluginOptions { // Warning: (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration globalObject?: 'lynx' | 'globalThis' | undefined; mainThreadLayer: string; + timeout?: number | undefined; } // @public export interface ExternalValue { async?: boolean; background?: LayerOptions; + bundlePath?: string; libraryName?: string | string[]; mainThread?: LayerOptions; timeout?: number; - url: string; + url?: string; } // @public diff --git a/packages/webpack/externals-loading-webpack-plugin/src/index.ts b/packages/webpack/externals-loading-webpack-plugin/src/index.ts index 8569478f07..13a18a6541 100644 --- a/packages/webpack/externals-loading-webpack-plugin/src/index.ts +++ b/packages/webpack/externals-loading-webpack-plugin/src/index.ts @@ -90,6 +90,13 @@ export interface ExternalsLoadingPluginOptions { * @default 'lynx' */ globalObject?: 'lynx' | 'globalThis' | undefined; + + /** + * The timeout in milliseconds for loading the externals. + * + * @defaultValue 2000 + */ + timeout?: number | undefined; } /** @@ -99,9 +106,23 @@ export interface ExternalsLoadingPluginOptions { */ export interface ExternalValue { /** - * The bundle url of the library. The library source should be placed in `customSections`. + * The final bundle URL to fetch directly at runtime. + * + * Use this when the external bundle is hosted outside the current build + * output, such as on a CDN. If both `url` and `bundlePath` are provided, + * `url` takes precedence because it is already the fully resolved address. + */ + url?: string; + + /** + * The bundle path resolved against the runtime public path. + * + * Prefer this over `url` when the bundle should follow the active + * `publicPath`. The runtime will load it with `publicPath + bundlePath`, + * which keeps external bundle resolution aligned with the current build + * output without hard-coding an absolute URL into the generated runtime code. */ - url: string; + bundlePath?: string; /** * The name of the library. Same as https://webpack.js.org/configuration/externals/#string. @@ -225,6 +246,18 @@ function getLynxExternalGlobal(globalObject?: string) { return `${globalObject ?? 'lynx'}[Symbol.for('__LYNX_EXTERNAL_GLOBAL__')]`; } +function validateExternals(externals: Record): void { + for (const [request, external] of Object.entries(externals)) { + if (external.url || external.bundlePath) { + continue; + } + + throw new Error( + `ExternalsLoadingPlugin requires \`url\` or \`bundlePath\` for external "${request}".`, + ); + } +} + /** * The webpack plugin to load lynx external bundles. * @@ -260,20 +293,30 @@ function getLynxExternalGlobal(globalObject?: string) { * @public */ export class ExternalsLoadingPlugin { - constructor(private options: ExternalsLoadingPluginOptions) {} + constructor(private options: ExternalsLoadingPluginOptions) { + validateExternals(this.options.externals); + } apply(compiler: Compiler): void { const { RuntimeModule } = compiler.webpack; const externalsLoadingPluginOptions = this.options; + const externals = externalsLoadingPluginOptions.externals ?? {}; class ExternalsLoadingRuntimeModule extends RuntimeModule { - constructor(private options: { layer: string }) { - super('externals-loading-runtime'); + constructor( + public runtimeRequirements: Set, + private options: { layer: string }, + ) { + super( + 'externals-loading-runtime', + RuntimeModule.STAGE_TRIGGER, + ); + this.runtimeRequirements = runtimeRequirements; } override generate() { - if (!this.chunk?.name || !externalsLoadingPluginOptions.externals) { + if (!this.chunk?.name || !externals) { return ''; } return this.#genExternalsLoadingCode(this.options.layer); @@ -302,14 +345,14 @@ export class ExternalsLoadingPlugin { const isMainThreadLayer = layer === 'mainThread'; for ( const [pkgName, external] of Object.entries( - externalsLoadingPluginOptions.externals, + externals, ) ) { externalsMap.set(external.libraryName ?? pkgName, external); } - const externals = Array.from(externalsMap.entries()); + const finalExternals = Array.from(externalsMap.entries()); - if (externals.length === 0) { + if (finalExternals.length === 0) { return ''; } const lynxExternalGlobal = getLynxExternalGlobal( @@ -351,6 +394,7 @@ function createLoadExternalAsync(handler, sectionPath) { }) }) } + function createLoadExternalSync(handler, sectionPath, timeout) { const response = handler.wait(timeout) if (response.code === 0) { @@ -385,12 +429,14 @@ function createLoadExternalSync(handler, sectionPath, timeout) { const hasUrlLibraryNamePairInjected = new Set(); - for (const [pkgName, external] of externals) { + for (const [pkgName, external] of finalExternals) { const { libraryName, url, + bundlePath, async = true, - timeout: timeoutInMs = 2000, + timeout: timeoutInMs = externalsLoadingPluginOptions.timeout + ?? 2000, } = external; const layerOptions = external[layer]; // Lynx fetchBundle timeout is in seconds @@ -405,15 +451,27 @@ function createLoadExternalSync(handler, sectionPath, timeout) { ? libraryNameWithDefault[0] : libraryNameWithDefault; - const hash = `${url}-${libraryNameStr}`; + const normalizedExternal = bundlePath + ? { + ...external, + bundlePath: bundlePath.replace(/^\/+/, ''), + } + : external; + const urlKey = url ?? `bundlePath:${normalizedExternal.bundlePath}`; + const hash = `${urlKey}-${libraryNameStr}`; if (hasUrlLibraryNamePairInjected.has(hash)) { continue; } hasUrlLibraryNamePairInjected.add(hash); + const fetchExpr = normalizedExternal.url + ? JSON.stringify(normalizedExternal.url) + : `${compiler.webpack.RuntimeGlobals.publicPath} + ${ + JSON.stringify(normalizedExternal.bundlePath) + }`; url2fetchCode.set( - url, - `lynx.fetchBundle(${JSON.stringify(url)}, {});`, + urlKey, + `lynx.fetchBundle(${fetchExpr}, {});`, ); const mountVar = `${ @@ -424,7 +482,7 @@ function createLoadExternalSync(handler, sectionPath, timeout) { if (async) { loadCode.add( `${mountVar} = ${mountVar} === undefined ? createLoadExternalAsync(handler${ - [...url2fetchCode.keys()].indexOf(url) + [...url2fetchCode.keys()].indexOf(urlKey) }, ${JSON.stringify(layerOptions.sectionPath)}) : ${mountVar};`, ); continue; @@ -432,7 +490,7 @@ function createLoadExternalSync(handler, sectionPath, timeout) { loadCode.add( `${mountVar} = ${mountVar} === undefined ? createLoadExternalSync(handler${ - [...url2fetchCode.keys()].indexOf(url) + [...url2fetchCode.keys()].indexOf(urlKey) }, ${ JSON.stringify(layerOptions.sectionPath) }, ${timeout}) : ${mountVar};`, @@ -465,26 +523,34 @@ function createLoadExternalSync(handler, sectionPath, timeout) { ExternalsLoadingRuntimeModule.name, compilation => { compilation.hooks.additionalTreeRuntimeRequirements - .tap(ExternalsLoadingRuntimeModule.name, (chunk) => { - const modules = compilation.chunkGraph.getChunkModulesIterable( - chunk, - ); - let layer: string | undefined; - for (const module of modules) { - if (module.layer) { - layer = module.layer; - break; + .tap( + ExternalsLoadingRuntimeModule.name, + (chunk, runtimeRequirements) => { + const modules = compilation.chunkGraph.getChunkModulesIterable( + chunk, + ); + let layer: string | undefined; + for (const module of modules) { + if (module.layer) { + layer = module.layer; + break; + } } - } - if (!layer) { - // Skip chunks without a layer - return; - } - compilation.addRuntimeModule( - chunk, - new ExternalsLoadingRuntimeModule({ layer }), - ); - }); + if (!layer) { + // Skip chunks without a layer + return; + } + runtimeRequirements.add( + compiler.webpack.RuntimeGlobals.publicPath, + ); + compilation.addRuntimeModule( + chunk, + new ExternalsLoadingRuntimeModule(runtimeRequirements, { + layer, + }), + ); + }, + ); }, ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8e56022d4..d62918efb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -938,6 +938,9 @@ importers: specifier: workspace:* version: link:../../webpack/externals-loading-webpack-plugin devDependencies: + '@lynx-js/react-umd': + specifier: workspace:* + version: link:../../react-umd '@microsoft/api-extractor': specifier: 'catalog:' version: 7.57.6(@types/node@24.10.13) From 434721d9cb277d7b67b95430f8111e29148d955f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:59:53 +0800 Subject: [PATCH 4/4] chore(deps): update dependency lynx-community/benchx_cli to benchx_cli-202603021542 (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | [lynx-community/benchx_cli](https://redirect.github.com/lynx-community/benchx_cli) | minor | `benchx_cli-202602132156` → `benchx_cli-202603021542` | --- ### Release Notes
lynx-community/benchx_cli (lynx-community/benchx_cli) ### [`vbenchx_cli-202603021542`](https://redirect.github.com/lynx-community/benchx_cli/compare/benchx_cli-202602132156...benchx_cli-202603021542) [Compare Source](https://redirect.github.com/lynx-community/benchx_cli/compare/benchx_cli-202602132156...benchx_cli-202603021542)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/lynx-family/lynx-stack). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/lynx/benchx_cli/scripts/build_unix.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lynx/benchx_cli/scripts/build_unix.sh b/packages/lynx/benchx_cli/scripts/build_unix.sh index 4064db3dbb..0f5aac301d 100755 --- a/packages/lynx/benchx_cli/scripts/build_unix.sh +++ b/packages/lynx/benchx_cli/scripts/build_unix.sh @@ -13,7 +13,7 @@ if [ ! -f "package.json" ] || ! grep -q '"name": "benchx_cli"' package.json; the exit 1 fi -LOCKED_VERSION="benchx_cli-202602132156" +LOCKED_VERSION="benchx_cli-202603021542" # Check if binary is up to date if [ -f "./dist/bin/benchx_cli" ] && [ -f "./dist/bin/benchx_cli.version" ]; then