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/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/.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/.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/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
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,
>(
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/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',
}),
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)