diff --git a/.changeset/chilly-ravens-smile.md b/.changeset/chilly-ravens-smile.md new file mode 100644 index 0000000000..9f201e2b62 --- /dev/null +++ b/.changeset/chilly-ravens-smile.md @@ -0,0 +1,28 @@ +--- +"@lynx-js/externals-loading-webpack-plugin": patch +--- + +Introduce `@lynx-js/externals-loading-webpack-plugin`. + +```js +// webpack.config.js +import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin'; + +export default { + plugins: [ + new ExternalsLoadingPlugin({ + mainThreadChunks: ['index__main-thread'], + backgroundChunks: ['index'], + mainThreadLayer: 'main-thread', + backgroundLayer: 'background', + externals: { + lodash: { + url: 'http://lodash.lynx.bundle', + background: { sectionPath: 'background' }, + mainThread: { sectionPath: 'main-thread' }, + }, + }, + }), + ], +}; +``` diff --git a/examples/react-externals/README.md b/examples/react-externals/README.md new file mode 100644 index 0000000000..6082662561 --- /dev/null +++ b/examples/react-externals/README.md @@ -0,0 +1,16 @@ +# @lynx-js/example-react-externals + +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/externals-loading-webpack-plugin` to load ReactLynx runtime (sync) and component bundle (async). + +## Usage + +```bash +pnpm build:reactlynx +pnpm build:comp-lib +pnpx http-server -p 8080 dist +EXTERNAL_BUNDLE_PREFIX=http://${YOUR_IP_HERE}:8080 pnpm dev +``` diff --git a/examples/react-externals/external-bundle/CompLib.tsx b/examples/react-externals/external-bundle/CompLib.tsx new file mode 100644 index 0000000000..3c7068933a --- /dev/null +++ b/examples/react-externals/external-bundle/CompLib.tsx @@ -0,0 +1 @@ +export { App } from '../src/App.js'; diff --git a/examples/react-externals/external-bundle/ReactLynx.ts b/examples/react-externals/external-bundle/ReactLynx.ts new file mode 100644 index 0000000000..401d2ca086 --- /dev/null +++ b/examples/react-externals/external-bundle/ReactLynx.ts @@ -0,0 +1,10 @@ +export * as React from '@lynx-js/react'; +export * as ReactInternal from '@lynx-js/react/internal'; +export * as ReactLazyImport from '@lynx-js/react/experimental/lazy/import'; +export * as ReactLegacyRuntime from '@lynx-js/react/legacy-react-runtime'; +export * as ReactComponents from '@lynx-js/react/runtime-components'; +export * as ReactWorkletRuntime from '@lynx-js/react/worklet-runtime/bindings'; +export * as ReactDebug from '@lynx-js/react/debug'; +// @ts-expect-error preact is aliased by rspeedy +// eslint-disable-next-line import/no-unresolved +export * as Preact from 'preact'; diff --git a/examples/react-externals/lynx.config.js b/examples/react-externals/lynx.config.js new file mode 100644 index 0000000000..98efc148f1 --- /dev/null +++ b/examples/react-externals/lynx.config.js @@ -0,0 +1,108 @@ +import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin'; +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; +import { LAYERS, pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; +import { defineConfig } from '@lynx-js/rspeedy'; + +const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; +const EXTERNAL_BUNDLE_PREFIX = process.env['EXTERNAL_BUNDLE_PREFIX'] || ''; + +export default defineConfig({ + tools: { + rspack: { + plugins: [ + new ExternalsLoadingPlugin({ + mainThreadChunks: ['main__main-thread'], + backgroundChunks: ['main'], + mainThreadLayer: LAYERS.MAIN_THREAD, + backgroundLayer: LAYERS.BACKGROUND, + 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/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: false, + }, + }, + }), + ], + }, + }, + plugins: [ + pluginReactLynx(), + pluginQRCode({ + schema(url) { + // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode + return `${url}?fullscreen=true`; + }, + }), + ], + environments: { + web: {}, + lynx: { + performance: { + profile: enableBundleAnalysis, + }, + }, + }, + output: { + filenameHash: 'contenthash:8', + cleanDistPath: false, + }, +}); diff --git a/examples/react-externals/package.json b/examples/react-externals/package.json new file mode 100644 index 0000000000..f04b9d960f --- /dev/null +++ b/examples/react-externals/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lynx-js/example-react-externals", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rspeedy build", + "build:comp-lib": "rslib build --config rslib-comp-lib.config.ts", + "build:reactlynx": "rslib build --config rslib-reactlynx.config.ts", + "dev": "rspeedy dev" + }, + "dependencies": { + "@lynx-js/react": "workspace:*" + }, + "devDependencies": { + "@lynx-js/externals-loading-webpack-plugin": "workspace:*", + "@lynx-js/lynx-bundle-rslib-config": "workspace:*", + "@lynx-js/preact-devtools": "^5.0.1-cf9aef5", + "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", + "@lynx-js/react-alias-rsbuild-plugin": "workspace:*", + "@lynx-js/react-rsbuild-plugin": "workspace:*", + "@lynx-js/react-webpack-plugin": "workspace:*", + "@lynx-js/rspeedy": "workspace:*", + "@lynx-js/types": "3.4.11", + "@types/react": "^18.3.25" + } +} diff --git a/examples/react-externals/rslib-comp-lib.config.ts b/examples/react-externals/rslib-comp-lib.config.ts new file mode 100644 index 0000000000..e77e81cfaa --- /dev/null +++ b/examples/react-externals/rslib-comp-lib.config.ts @@ -0,0 +1,79 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { + LAYERS, + defineExternalBundleRslibConfig, +} from '@lynx-js/lynx-bundle-rslib-config'; +import { pluginReactAlias } from '@lynx-js/react-alias-rsbuild-plugin'; +import { ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin'; + +const require = createRequire(import.meta.url); +const reactLynxDir = path.dirname( + require.resolve('@lynx-js/react/package.json'), +); + +export default defineExternalBundleRslibConfig({ + id: 'comp-lib', + tools: { + rspack: { + module: { + rules: [ + { + test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/, + issuerLayer: LAYERS.BACKGROUND, + loader: ReactWebpackPlugin.loaders.BACKGROUND, + }, + { + test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/, + issuerLayer: LAYERS.MAIN_THREAD, + loader: ReactWebpackPlugin.loaders.MAIN_THREAD, + }, + ], + }, + }, + }, + source: { + entry: { + 'CompLib': './external-bundle/CompLib.tsx', + }, + define: { + '__DEV__': 'false', + 'process.env.NODE_ENV': '"production"', + '__FIRST_SCREEN_SYNC_TIMING__': '"immediately"', + '__ENABLE_SSR__': 'false', + '__PROFILE__': 'false', + '__EXTRACT_STR__': 'false', + }, + }, + plugins: [ + pluginReactAlias({ + LAYERS, + rootPath: reactLynxDir, + }), + ], + output: { + cleanDistPath: false, + dataUriLimit: Number.POSITIVE_INFINITY, + externals: { + '@lynx-js/react': ['ReactLynx', 'React'], + '@lynx-js/react/internal': ['ReactLynx', 'ReactInternal'], + '@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'], + }, + minify: false, + }, +}); diff --git a/examples/react-externals/rslib-reactlynx.config.ts b/examples/react-externals/rslib-reactlynx.config.ts new file mode 100644 index 0000000000..097615dfd1 --- /dev/null +++ b/examples/react-externals/rslib-reactlynx.config.ts @@ -0,0 +1,58 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { + LAYERS, + defineExternalBundleRslibConfig, +} from '@lynx-js/lynx-bundle-rslib-config'; +import { pluginReactAlias } from '@lynx-js/react-alias-rsbuild-plugin'; +import { ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin'; + +const require = createRequire(import.meta.url); +const reactLynxDir = path.dirname( + require.resolve('@lynx-js/react/package.json'), +); +export default defineExternalBundleRslibConfig({ + id: 'react', + tools: { + rspack: { + module: { + rules: [ + { + test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/, + issuerLayer: LAYERS.BACKGROUND, + loader: ReactWebpackPlugin.loaders.BACKGROUND, + }, + { + issuerLayer: LAYERS.MAIN_THREAD, + test: /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/, + loader: ReactWebpackPlugin.loaders.MAIN_THREAD, + }, + ], + }, + }, + }, + source: { + entry: { + 'ReactLynx': './external-bundle/ReactLynx.ts', + }, + define: { + '__DEV__': 'false', + 'process.env.NODE_ENV': '"production"', + '__FIRST_SCREEN_SYNC_TIMING__': '"immediately"', + '__ENABLE_SSR__': 'false', + '__PROFILE__': 'false', + '__EXTRACT_STR__': 'false', + }, + }, + plugins: [ + pluginReactAlias({ + LAYERS, + rootPath: reactLynxDir, + }), + ], + output: { + cleanDistPath: false, + minify: false, + }, +}); diff --git a/examples/react-externals/src/App.css b/examples/react-externals/src/App.css new file mode 100644 index 0000000000..650e423282 --- /dev/null +++ b/examples/react-externals/src/App.css @@ -0,0 +1,119 @@ +:root { + background-color: #000; + --color-text: #fff; +} + +.Background { + position: fixed; + background: radial-gradient( + 71.43% 62.3% at 46.43% 36.43%, + rgba(18, 229, 229, 0) 15%, + rgba(239, 155, 255, 0.3) 56.35%, + #ff6448 100% + ); + box-shadow: 0px 12.93px 28.74px 0px #ffd28db2 inset; + border-radius: 50%; + width: 200vw; + height: 200vw; + top: -60vw; + left: -14.27vw; + transform: rotate(15.25deg); +} + +.App { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +text { + color: var(--color-text); +} + +.Banner { + flex: 5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 100; +} + +.Logo { + flex-direction: column; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +.Logo--react { + width: 100px; + height: 100px; + animation: Logo--spin infinite 20s linear; +} + +.Logo--lynx { + width: 100px; + height: 100px; + animation: Logo--shake infinite 0.5s ease; +} + +@keyframes Logo--spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes Logo--shake { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + } +} + +.Content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.Arrow { + width: 24px; + height: 24px; +} + +.Title { + font-size: 36px; + font-weight: 700; +} + +.Subtitle { + font-style: italic; + font-size: 22px; + font-weight: 600; + margin-bottom: 8px; +} + +.Description { + font-size: 20px; + color: rgba(255, 255, 255, 0.85); + margin: 15rpx; +} + +.Hint { + font-size: 12px; + margin: 5px; + color: rgba(255, 255, 255, 0.65); +} diff --git a/examples/react-externals/src/App.tsx b/examples/react-externals/src/App.tsx new file mode 100644 index 0000000000..5da30edede --- /dev/null +++ b/examples/react-externals/src/App.tsx @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from '@lynx-js/react'; + +import './App.css'; +import arrow from './assets/arrow.png'; +import lynxLogo from './assets/lynx-logo.png'; +import reactLynxLogo from './assets/react-logo.png'; + +export function App() { + const [alterLogo, setAlterLogo] = useState(false); + + useEffect(() => { + console.info('Hello, ReactLynx'); + }, []); + + const onTap = useCallback(() => { + 'background-only'; + setAlterLogo(prevAlterLogo => !prevAlterLogo); + }, []); + + return ( + + + + + + {alterLogo + ? + : } + + React + on Lynx + + + + Tap the logo and have fun! + + Edit + {' src/App.tsx '} + + to see updates! + + + + + + ); +} diff --git a/examples/react-externals/src/assets/arrow.png b/examples/react-externals/src/assets/arrow.png new file mode 100644 index 0000000000..435c8ad4d1 Binary files /dev/null and b/examples/react-externals/src/assets/arrow.png differ diff --git a/examples/react-externals/src/assets/lynx-logo.png b/examples/react-externals/src/assets/lynx-logo.png new file mode 100644 index 0000000000..fe44bf0e3c Binary files /dev/null and b/examples/react-externals/src/assets/lynx-logo.png differ diff --git a/examples/react-externals/src/assets/react-logo.png b/examples/react-externals/src/assets/react-logo.png new file mode 100644 index 0000000000..4ad12a6b55 Binary files /dev/null and b/examples/react-externals/src/assets/react-logo.png differ diff --git a/examples/react-externals/src/index.tsx b/examples/react-externals/src/index.tsx new file mode 100644 index 0000000000..d0d893b024 --- /dev/null +++ b/examples/react-externals/src/index.tsx @@ -0,0 +1,17 @@ +import '@lynx-js/react/debug'; +import { root } from '@lynx-js/react'; + +import { App } from './App.js'; + +// We have to manually import the css now +// TODO: load css from external bundle +// when it is supported in Lynx engine +import './App.css'; + +root.render( + , +); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} diff --git a/examples/react-externals/src/rspeedy-env.d.ts b/examples/react-externals/src/rspeedy-env.d.ts new file mode 100644 index 0000000000..1c813a68b0 --- /dev/null +++ b/examples/react-externals/src/rspeedy-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react-externals/tsconfig.json b/examples/react-externals/tsconfig.json new file mode 100644 index 0000000000..8f6d4de332 --- /dev/null +++ b/examples/react-externals/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "noEmit": true, + + "allowJs": true, + "checkJs": true, + "isolatedDeclarations": false, + }, + "include": ["src", "external-bundle", "lynx.config.js", "rslib-comp-lib.config.ts", "rslib-reactlynx.config.ts"], + "references": [ + { "path": "../../packages/react/tsconfig.json" }, + { "path": "../../packages/rspeedy/core/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-qrcode/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-react/tsconfig.build.json" }, + ], +} 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 44dc5394cb..cc2e267015 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 @@ -14,8 +14,10 @@ import type { RslibConfig } from '@rslib/core'; // @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: LibConfig, encodeOptions?: EncodeOptions): RslibConfig; +export function defineExternalBundleRslibConfig(userLibConfig: ExternalBundleLibConfig, encodeOptions?: EncodeOptions): RslibConfig; // @public export interface EncodeOptions { diff --git a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts index 81dd438f1e..e22fa1ebde 100644 --- a/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts +++ b/packages/rspeedy/lynx-bundle-rslib-config/src/externalBundleRslibConfig.ts @@ -75,6 +75,45 @@ export const defaultExternalBundleLibConfig: LibConfig = { }, } +export type Externals = Record + +export type LibOutputConfig = Required['output'] + +export interface OutputConfig extends LibOutputConfig { + externals?: Externals +} + +export interface ExternalBundleLibConfig extends LibConfig { + output?: OutputConfig +} + +function transformExternals( + externals?: Externals, +): Required['externals'] { + if (!externals) return {} + + return function({ request, contextInfo }, callback) { + if (!request) return callback() + const libraryName = externals[request] + if (!libraryName) return callback() + + if (contextInfo?.issuerLayer === LAYERS.MAIN_THREAD) { + callback(undefined, [ + 'globalThis', + 'lynx_ex', + ...(Array.isArray(libraryName) ? libraryName : [libraryName]), + ], 'var') + } else { + callback(undefined, [ + 'lynxCoreInject', + 'tt', + 'lynx_ex', + ...(Array.isArray(libraryName) ? libraryName : [libraryName]), + ], 'var') + } + } +} + /** * Get the rslib config for building Lynx external bundles. * @@ -146,7 +185,7 @@ export const defaultExternalBundleLibConfig: LibConfig = { * Then you can use `lynx.loadScript('utils', { bundleName: 'utils-lib-bundle-url' })` in background thread and `lynx.loadScript('utils__main-thread', { bundleName: 'utils-lib-bundle-url' })` in main-thread. */ export function defineExternalBundleRslibConfig( - userLibConfig: LibConfig, + userLibConfig: ExternalBundleLibConfig, encodeOptions: EncodeOptions = {}, ): RslibConfig { return { @@ -154,7 +193,13 @@ export function defineExternalBundleRslibConfig( // eslint-disable-next-line import/namespace rsbuild.mergeRsbuildConfig( defaultExternalBundleLibConfig, - userLibConfig, + { + ...userLibConfig, + output: { + ...userLibConfig.output, + externals: transformExternals(userLibConfig.output?.externals), + }, + }, ), ], plugins: [ diff --git a/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md b/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md new file mode 100644 index 0000000000..1d683b7972 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/CHANGELOG.md @@ -0,0 +1 @@ +# @lynx-js/externals-loading-webpack-plugin diff --git a/packages/webpack/externals-loading-webpack-plugin/README.md b/packages/webpack/externals-loading-webpack-plugin/README.md new file mode 100644 index 0000000000..264ad94f04 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/README.md @@ -0,0 +1,3 @@ +

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

+ +A webpack plugin to support loading externals in Lynx. diff --git a/packages/webpack/externals-loading-webpack-plugin/api-extractor.json b/packages/webpack/externals-loading-webpack-plugin/api-extractor.json new file mode 100644 index 0000000000..65b0c69ca6 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/api-extractor.json @@ -0,0 +1,6 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "extends": "../../../api-extractor.json", +} 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 new file mode 100644 index 0000000000..05bf339102 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/etc/externals-loading-webpack-plugin.api.md @@ -0,0 +1,37 @@ +## API Report File for "@lynx-js/externals-loading-webpack-plugin" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Compiler } from '@rspack/core'; + +// @public +export class ExternalsLoadingPlugin { + constructor(options: ExternalsLoadingPluginOptions); + // (undocumented) + apply(compiler: Compiler): void; +} + +// @public +export interface ExternalsLoadingPluginOptions { + backgroundChunks: string[]; + backgroundLayer: string; + externals: Record; + mainThreadChunks: string[]; + mainThreadLayer: string; +} + +// @public +export interface LayerOptions { + sectionPath: string; +} + +``` diff --git a/packages/webpack/externals-loading-webpack-plugin/package.json b/packages/webpack/externals-loading-webpack-plugin/package.json new file mode 100644 index 0000000000..1ba1bc0096 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/package.json @@ -0,0 +1,46 @@ +{ + "name": "@lynx-js/externals-loading-webpack-plugin", + "version": "0.0.0", + "description": "A webpack plugin to load lynx external bundles.", + "keywords": [ + "webpack", + "Lynx" + ], + "repository": { + "type": "git", + "url": "https://github.com/lynx-family/lynx-stack.git", + "directory": "packages/webpack/externals-loading-webpack-plugin" + }, + "license": "Apache-2.0", + "author": { + "name": "Hengchang Lu", + "email": "luhengchang228@gmail.com" + }, + "type": "module", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "types": "./lib/index.d.ts", + "files": [ + "lib", + "!lib/**/*.js.map", + "CHANGELOG.md", + "README.md" + ], + "scripts": { + "api-extractor": "api-extractor run --verbose", + "test": "vitest" + }, + "devDependencies": { + "@lynx-js/test-tools": "workspace:*", + "@rspack/core": "catalog:rspack", + "foo": "./test/foo" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/webpack/externals-loading-webpack-plugin/src/index.ts b/packages/webpack/externals-loading-webpack-plugin/src/index.ts new file mode 100644 index 0000000000..9f4d635bf7 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/src/index.ts @@ -0,0 +1,483 @@ +// Copyright 2025 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. + +/** + * @packageDocumentation + * + * A webpack plugin to load externals in Lynx. Use `lynx.fetchBundle()` and `lynx.loadScript()` API to load and parse the externals. + * + * @remarks + * Requires Lynx version 3.5 or later. + */ + +import type { + Compiler, + ExternalItemFunctionData, + ExternalItemValue, + ExternalsType, +} from '@rspack/core'; + +/** + * The options of the `ExternalsLoadingPlugin`. + * + * @public + */ +export interface ExternalsLoadingPluginOptions { + /** + * The chunk names to be considered as main thread chunks. + */ + mainThreadChunks: string[]; + + /** + * The chunk names to be considered as background chunks. + */ + backgroundChunks: string[]; + + /** + * The name of the main thread layer. + */ + mainThreadLayer: string; + + /** + * The name of the background layer. + */ + backgroundLayer: string; + + /** + * Specify the externals to be loaded. The externals should be Lynx Bundles. + * + * @example + * + * Load `lodash` library in background layer and `main-thread` layer. + * + * ```js + * module.exports = { + * plugins: [ + * new ExternalsLoadingPlugin({ + * externals: { + * lodash: { + * url: 'http://lodash.lynx.bundle', + * background: { sectionPath: 'background' }, + * mainThread: { sectionPath: 'mainThread' }, + * }, + * }, + * }), + * ], + * }; + * ``` + * + * @example + * + * Load `lodash` library only in background layer. + * + * ```js + * module.exports = { + * plugins: [ + * new ExternalsLoadingPlugin({ + * externals: { + * lodash: { + * url: 'http://lodash.lynx.bundle', + * background: { sectionPath: 'background' } + * }, + * }, + * }), + * ], + * }; + * ``` + */ + externals: Record< + string, + { + /** + * The bundle(lynx.bundle) url of the library. The library source should be placed in `customSections`. + */ + url: string; + + /** + * The name of the library. Same as https://webpack.js.org/configuration/externals/#string. + * + * By default, the library name is the same as the externals key. For example: + * + * The config + * + * ```js + * ExternalsLoadingPlugin({ + * externals: { + * lodash: { + * url: '……', + * } + * } + * }) + * ``` + * + * Will generate the following webpack externals config: + * + * ```js + * externals: { + * lodash: '__webpack_require__.lynx_ex.lodash', + * } + * ``` + * + * If one external bundle contains multiple modules, should set the same library name to ensure it's loaded only once. For example: + * + * ```js + * ExternalsLoadingPlugin({ + * externals: { + * lodash: { + * libraryName: 'Lodash', + * url: '……', + * }, + * 'lodash-es': { + * libraryName: 'Lodash', + * url: '……', + * } + * } + * }) + * ``` + * Will generate the following webpack externals config: + * + * ```js + * externals: { + * lodash: '__webpack_require__.lynx_ex.Lodash', + * 'lodash-es': '__webpack_require__.lynx_ex.Lodash', + * } + * ``` + * + * You can pass an array to specify subpath of the external. Same as https://webpack.js.org/configuration/externals/#string-1. For example: + * + * ```js + * ExternalsLoadingPlugin({ + * externals: { + * preact: { + * libraryName: ['ReactLynx', 'Preact'], + * url: '……', + * }, + * } + * }) + * ``` + * + * Will generate the following webpack externals config: + * + * ```js + * externals: { + * preact: '__webpack_require__.lynx_ex.ReactLynx.Preact', + * } + * ``` + * + * @defaultValue `undefined` + * + * @example `Lodash` + */ + libraryName?: string | string[]; + + /** + * Whether the source should be loaded asynchronously or not. + * + * @defaultValue `true` + */ + async?: boolean; + + /** + * The options of the background layer. + * + * @defaultValue `undefined` + */ + background?: LayerOptions; + + /** + * The options of the main-thread layer. + * + * @defaultValue `undefined` + */ + mainThread?: LayerOptions; + + /** + * The wait time in milliseconds. + * + * @defaultValue `2000` + */ + timeout?: number; + } + >; +} + +/** + * The options of the background or main-thread layer. + * + * @public + */ +export interface LayerOptions { + /** + * The path in `customSections`. + */ + sectionPath: string; +} + +function getLynxExternalGlobal(layer: 'background' | 'mainThread') { + // We do not use `globalThis` in BTS to avoid issues when sharing js context is enabled + return `${ + layer === 'background' ? 'lynxCoreInject.tt.lynx_ex' : 'globalThis.lynx_ex' + }`; +} + +/** + * The webpack plugin to load lynx external bundles. + * + * @example + * ```js + * // webpack.config.js + * import { ExternalsLoadingPlugin } from '@lynx-js/externals-loading-webpack-plugin'; + * + * export default { + * plugins: [ + * new ExternalsLoadingPlugin({ + * mainThreadLayer: 'main-thread', + * backgroundLayer: 'background', + * mainThreadChunks: ['index__main-thread'], + * backgroundChunks: ['index'], + * externals: { + * 'lodash': { + * url: 'http://lodash.lynx.bundle', + * async: true, + * background: { + * sectionPath: 'background', + * }, + * mainThread: { + * sectionPath: 'mainThread', + * }, + * }, + * } + * }) + * ] + * } + * ``` + * + * @public + */ +export class ExternalsLoadingPlugin { + constructor(private options: ExternalsLoadingPluginOptions) {} + + apply(compiler: Compiler): void { + const { RuntimeModule } = compiler.webpack; + + const externalsLoadingPluginOptions = this.options; + + class ExternalsLoadingRuntimeModule extends RuntimeModule { + constructor() { + super('externals-loading-runtime'); + } + + override generate() { + if (!this.chunk?.name) { + return ''; + } + if (!externalsLoadingPluginOptions.externals) { + return ''; + } + + if ( + externalsLoadingPluginOptions.backgroundChunks.some(name => + name === this.chunk!.name + ) + ) { + return this.#genFetchAndLoadCode('background'); + } + + if ( + externalsLoadingPluginOptions.mainThreadChunks.some(name => + name === this.chunk!.name + ) + ) { + return this.#genFetchAndLoadCode('mainThread'); + } + + return ''; + } + + #genFetchAndLoadCode( + layer: 'background' | 'mainThread', + ): string { + const fetchCode: string[] = []; + const asyncLoadCode: string[] = []; + const syncLoadCode: string[] = []; + // filter duplicate externals by libraryName or package name to avoid loading the same external multiple times. We keep the last one. + const externalsMap = new Map< + string | string[], + ExternalsLoadingPluginOptions['externals'][string] + >(); + for ( + const [pkgName, external] of Object.entries( + externalsLoadingPluginOptions.externals, + ) + ) { + externalsMap.set(external.libraryName ?? pkgName, external); + } + const externals = Array.from(externalsMap.entries()); + + if (externals.length === 0) { + return ''; + } + const runtimeGlobalsInit = `${getLynxExternalGlobal(layer)} = {};`; + const loadExternalFunc = ` +function createLoadExternalAsync(handler, sectionPath) { + return new Promise((resolve, reject) => { + handler.then((response) => { + if (response.code === 0) { + try { + const result = lynx.loadScript(sectionPath, { bundleName: response.url }); + resolve(result) + } catch (error) { + reject(new Error('Failed to load script ' + sectionPath + ' in ' + response.url, { cause: error })) + } + } else { + reject(new Error('Failed to fetch external source ' + response.url + ' . The response is ' + JSON.stringify(response), { cause: response })); + } + }) + }) +} +function createLoadExternalSync(handler, sectionPath, timeout) { + const response = handler.wait(timeout) + if (response.code === 0) { + try { + const result = lynx.loadScript(sectionPath, { bundleName: response.url }); + return result + } catch (error) { + throw new Error('Failed to load script ' + sectionPath + ' in ' + response.url, { cause: error }) + } + } else { + throw new Error('Failed to fetch external source ' + response.url + ' . The response is ' + JSON.stringify(response), { cause: response }) + } +} +`; + + const hasUrlLibraryNamePairInjected = new Set(); + + for (let i = 0; i < externals.length; i++) { + const [pkgName, external] = externals[i]!; + const { + libraryName, + url, + async = true, + timeout: timeoutInMs = 2000, + } = external; + const layerOptions = external[layer]; + // Lynx fetchBundle timeout is in seconds + const timeout = timeoutInMs / 1000; + + if (!layerOptions?.sectionPath) { + continue; + } + + const libraryNameWithDefault = libraryName ?? pkgName; + const libraryNameStr = Array.isArray(libraryNameWithDefault) + ? libraryNameWithDefault[0] + : libraryNameWithDefault; + + const hash = `${url}-${libraryNameStr}`; + if (hasUrlLibraryNamePairInjected.has(hash)) { + continue; + } + hasUrlLibraryNamePairInjected.add(hash); + + fetchCode.push( + `const handler${i} = lynx.fetchBundle(${JSON.stringify(url)}, {});`, + ); + + if (async) { + asyncLoadCode.push( + `${getLynxExternalGlobal(layer)}[${ + JSON.stringify(libraryNameStr) + }] = createLoadExternalAsync(handler${i}, ${ + JSON.stringify(layerOptions.sectionPath) + });`, + ); + continue; + } + + syncLoadCode.push( + `${getLynxExternalGlobal(layer)}[${ + JSON.stringify(libraryNameStr) + }] = createLoadExternalSync(handler${i}, ${ + JSON.stringify(layerOptions.sectionPath) + }, ${timeout});`, + ); + } + + return [ + runtimeGlobalsInit, + loadExternalFunc, + fetchCode, + asyncLoadCode, + syncLoadCode, + ].flat().join('\n'); + } + } + + compiler.hooks.environment.tap(ExternalsLoadingPlugin.name, () => { + compiler.options.externals = [ + ...(Array.isArray(compiler.options.externals) + ? compiler.options.externals + : (typeof compiler.options.externals === 'undefined' + ? [] + : [compiler.options.externals])), + this.#genExternalsConfig(), + ]; + }); + + compiler.hooks.compilation.tap( + ExternalsLoadingRuntimeModule.name, + compilation => { + compilation.hooks.additionalTreeRuntimeRequirements + .tap(ExternalsLoadingRuntimeModule.name, (chunk) => { + compilation.addRuntimeModule( + chunk, + new ExternalsLoadingRuntimeModule(), + ); + }); + }, + ); + } + + /** + * If the external is async, use `promise` external type; otherwise, use `var` external type. + */ + #genExternalsConfig(): ( + data: ExternalItemFunctionData, + callback: ( + err?: Error, + result?: ExternalItemValue, + type?: ExternalsType, + ) => void, + ) => void { + const { externals, backgroundLayer, mainThreadLayer } = this.options; + const externalDeps = new Set(Object.keys(externals)); + + return ({ request, contextInfo }, callback) => { + const currentLayer = contextInfo?.issuerLayer === mainThreadLayer + ? 'mainThread' + : (contextInfo?.issuerLayer === backgroundLayer + ? 'background' + : undefined); + if ( + request + && externalDeps.has(request) + && currentLayer + && externals[request]?.[currentLayer] + ) { + const isAsync = externals[request]?.async ?? true; + const libraryName = externals[request]?.libraryName ?? request; + return callback( + undefined, + [ + getLynxExternalGlobal(currentLayer), + ...(Array.isArray(libraryName) ? libraryName : [libraryName]), + ], + isAsync ? 'promise' : undefined, + ); + } + // Continue without externalizing the import + callback(); + }; + } +} diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts b/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts new file mode 100644 index 0000000000..b0d75d72cc --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases.test.ts @@ -0,0 +1,11 @@ +// Copyright 2025 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 path from 'node:path'; + +import { describeCases } from '@lynx-js/test-tools'; + +describeCases({ + name: 'externals-loading', + casePath: path.join(__dirname, 'cases'), +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js new file mode 100644 index 0000000000..d3fbd5307f --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/index.js @@ -0,0 +1,12 @@ +import { add } from 'foo'; + +const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => + undefined +); + +console.info(add(1, 2)); + +it('should log 3', () => { + expect(consoleInfoMock).toBeCalledTimes(1); + expect(consoleInfoMock).toBeCalledWith(3); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js new file mode 100644 index 0000000000..631b857106 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/rspack.config.js @@ -0,0 +1,27 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + 'foo': { + libraryName: 'Foo', + url: 'foo', + async: true, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/async/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js new file mode 100644 index 0000000000..81aa1a4e60 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/index.js @@ -0,0 +1,31 @@ +import x from 'foo'; +import x2 from 'foo2'; + +console.info(x); +console.info(x2); + +it('should filter duplicate externals', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + + const background = fs.readFileSync( + path.resolve(__dirname, 'main:background.js'), + 'utf-8', + ); + const mainThread = fs.readFileSync( + path.resolve(__dirname, 'main:main-thread.js'), + 'utf-8', + ); + expect( + background.split( + 'lynxCoreInject.tt.lynx_ex["Foo"] ' + + '= createLoadExternalSync(', + ).length - 1, + ).toBe(1); + expect( + mainThread.split( + 'globalThis.lynx_ex["Foo"] ' + + '= createLoadExternalSync(', + ).length - 1, + ).toBe(1); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js new file mode 100644 index 0000000000..b2fbd4c765 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/rspack.config.js @@ -0,0 +1,49 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + 'foo': { + libraryName: 'Foo', + url: 'foo', + async: false, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + 'foo': { + libraryName: 'Foo', + url: 'foo', + async: false, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + 'foo2': { + libraryName: 'Foo', + url: 'foo', + async: false, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/filter-duplicate-externals/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js new file mode 100644 index 0000000000..90a1ffb7b8 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/index.js @@ -0,0 +1,28 @@ +import lodash from 'lodash'; +import x from 'foo'; + +console.info(lodash); +console.info(x); + +it('should external lodash and foo', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + + const background = fs.readFileSync( + path.resolve(__dirname, 'main:background.js'), + 'utf-8', + ); + const mainThread = fs.readFileSync( + path.resolve(__dirname, 'main:main-thread.js'), + 'utf-8', + ); + + expect(background).toContain('module.exports ' + '= Lodash;'); + expect(mainThread).toContain('module.exports ' + '= Lodash;'); + expect(background).toContain( + 'module.exports ' + '= lynxCoreInject.tt.lynx_ex.Foo;', + ); + expect(mainThread).toContain( + 'module.exports ' + '= globalThis.lynx_ex.Foo;', + ); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js new file mode 100644 index 0000000000..6cee3becfd --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/rspack.config.js @@ -0,0 +1,30 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + 'foo': { + libraryName: 'Foo', + url: 'foo', + async: false, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + }, + }, + { + lodash: 'Lodash', + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/not-overrides-existed-externals/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js new file mode 100644 index 0000000000..7e134569f9 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/index.js @@ -0,0 +1,27 @@ +import { add } from 'foo'; + +const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => + undefined +); + +console.info(add(1, 2)); + +it('should log 3', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + + const background = fs.readFileSync( + path.resolve(__dirname, 'main:background.js'), + 'utf-8', + ); + const mainThread = fs.readFileSync( + path.resolve(__dirname, 'main:main-thread.js'), + 'utf-8', + ); + + expect(background).not.toContain('add' + '(a, b)'); + expect(mainThread).toContain('add' + '(a, b)'); + + expect(consoleInfoMock).toBeCalledTimes(1); + expect(consoleInfoMock).toBeCalledWith(3); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js new file mode 100644 index 0000000000..dee1adf70c --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/rspack.config.js @@ -0,0 +1,24 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + 'foo': { + libraryName: 'Foo', + url: 'foo', + async: true, + background: { + sectionPath: 'background', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-background-externals/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js new file mode 100644 index 0000000000..abced9aafa --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/index.js @@ -0,0 +1,26 @@ +import { add } from 'foo'; + +const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => + undefined +); + +console.info(add(1, 2)); + +it('should log 3', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const background = fs.readFileSync( + path.resolve(__dirname, 'main:background.js'), + 'utf-8', + ); + const mainThread = fs.readFileSync( + path.resolve(__dirname, 'main:main-thread.js'), + 'utf-8', + ); + + expect(background).toContain('add' + '(a, b)'); + expect(mainThread).not.toContain('add' + '(a, b)'); + + expect(consoleInfoMock).toBeCalledTimes(1); + expect(consoleInfoMock).toBeCalledWith(3); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js new file mode 100644 index 0000000000..ab4d27db75 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/rspack.config.js @@ -0,0 +1,23 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + 'foo': { + libraryName: 'Foo', + url: 'foo', + mainThread: { + sectionPath: 'mainThread', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/only-main-thread-externals/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js new file mode 100644 index 0000000000..007a3e05cb --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/index.js @@ -0,0 +1,12 @@ +import { add } from '@lynx-js/foo'; + +const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => + undefined +); + +console.info(add(1, 2)); + +it('should log 3', () => { + expect(consoleInfoMock).toBeCalledTimes(1); + expect(consoleInfoMock).toBeCalledWith(3); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js new file mode 100644 index 0000000000..3ec8fd9f82 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/rspack.config.js @@ -0,0 +1,26 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundChunks: ['main:background'], + mainThreadChunks: ['main:main-thread'], + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + '@lynx-js/foo': { + url: 'foo', + async: false, + background: { + sectionPath: 'background', + }, + mainThread: { + sectionPath: 'mainThread', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/sync/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js b/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js new file mode 100644 index 0000000000..9fa3420657 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/foo/index.js @@ -0,0 +1,6 @@ +// Copyright 2025 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. +export function add(a, b) { + return a + b; +} diff --git a/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json b/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json new file mode 100644 index 0000000000..ff5350bd63 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/foo/package.json @@ -0,0 +1,4 @@ +{ + "name": "foo", + "private": true +} diff --git a/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js b/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js new file mode 100644 index 0000000000..542ccefd9a --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/helpers/create-config.js @@ -0,0 +1,46 @@ +// Copyright 2025 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 { ExternalsLoadingPlugin } from '../../src'; + +/** + * @param {string=} name - The name + * @param {string=} source - The source path + * @returns {Record} - The react entries. + */ +export function createEntries(name = 'main', source = './index.js') { + return { + [`${name}:main-thread`]: { + layer: 'main-thread', + import: source, + }, + [`${name}:background`]: { + layer: 'background', + import: source, + }, + }; +} + +/** + * @param {import('../../src').ExternalsLoadingPluginOptions} externalsLoadingPluginOptions ExternalsLoadingPlugin options + * @param {import("@rspack/core").Configuration['externals']} externals rspack externals config + * + * @returns {import("@rspack/core").Configuration} The default Rspack configuration. + */ +export function createConfig(externalsLoadingPluginOptions, externals = {}) { + return { + entry: createEntries(), + experiments: { + layers: true, + }, + externals, + plugins: [ + new ExternalsLoadingPlugin( + externalsLoadingPluginOptions, + ), + ], + output: { + filename: '[name].js', + }, + }; +} diff --git a/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js b/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js new file mode 100644 index 0000000000..db86d756ef --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/helpers/setup-env.js @@ -0,0 +1,38 @@ +// Copyright 2025 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 { createRequire } from 'node:module'; + +__injectGlobals(globalThis); + +const require = createRequire(import.meta.url); + +// eslint-disable-next-line import/no-commonjs +const foo = require('../foo/index'); + +const CustomSections = { + 'background': foo, + 'mainThread': foo, +}; + +function __injectGlobals(target) { + target.printLogger = process.argv.includes('--verbose'); + + target.lynx = { + fetchBundle: (url) => { + return { + wait: () => ({ url, code: 0, err: null }), + then: (callback) => callback({ url, code: 0, err: null }), + }; + }, + loadScript: (sectionPath) => { + const module = CustomSections[sectionPath] ?? {}; + return module; + }, + }; + + target.Lodash = {}; + + target.lynxCoreInject = {}; + target.lynxCoreInject.tt = {}; +} diff --git a/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json b/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json new file mode 100644 index 0000000000..9d130b6e32 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./lib", + "baseUrl": "./", + "rootDir": "./src", + }, + "include": ["src"], +} diff --git a/packages/webpack/externals-loading-webpack-plugin/tsconfig.json b/packages/webpack/externals-loading-webpack-plugin/tsconfig.json new file mode 100644 index 0000000000..86cc1aca31 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": ["./tsconfig.build.json"], + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + }, + "include": ["src", "test", "vitest.config.ts"], +} diff --git a/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts b/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts new file mode 100644 index 0000000000..f44e98fe04 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineProject } from 'vitest/config'; +import type { UserWorkspaceConfig } from 'vitest/config'; + +const config: UserWorkspaceConfig = defineProject({ + test: { + name: 'webpack/externals-loading', + setupFiles: ['test/helpers/setup-env.js'], + }, +}); + +export default config; diff --git a/packages/webpack/tsconfig.json b/packages/webpack/tsconfig.json index 9aa9d8a006..66728b8887 100644 --- a/packages/webpack/tsconfig.json +++ b/packages/webpack/tsconfig.json @@ -14,6 +14,7 @@ { "path": "./react-refresh-webpack-plugin/tsconfig.build.json" }, { "path": "./webpack-runtime-globals/tsconfig.build.json" }, { "path": "./cache-events-webpack-plugin/tsconfig.build.json" }, + { "path": "./externals-loading-webpack-plugin/tsconfig.build.json" }, /** packages-end */ ], "include": [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e89f0c0aa6..1034fc7f1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,43 @@ importers: specifier: 0.0.0-experimental-0566679-20250709 version: 0.0.0-experimental-0566679-20250709 + examples/react-externals: + dependencies: + '@lynx-js/react': + specifier: workspace:* + version: link:../../packages/react + devDependencies: + '@lynx-js/externals-loading-webpack-plugin': + specifier: workspace:* + version: link:../../packages/webpack/externals-loading-webpack-plugin + '@lynx-js/lynx-bundle-rslib-config': + specifier: workspace:* + version: link:../../packages/rspeedy/lynx-bundle-rslib-config + '@lynx-js/preact-devtools': + specifier: ^5.0.1-cf9aef5 + version: 5.0.1 + '@lynx-js/qrcode-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-qrcode + '@lynx-js/react-alias-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-react-alias + '@lynx-js/react-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-react + '@lynx-js/react-webpack-plugin': + specifier: workspace:* + version: link:../../packages/webpack/react-webpack-plugin + '@lynx-js/rspeedy': + specifier: workspace:* + version: link:../../packages/rspeedy/core + '@lynx-js/types': + specifier: 3.4.11 + version: 3.4.11 + '@types/react': + specifier: ^18.3.25 + version: 18.3.25 + examples/react-lazy-bundle: dependencies: '@lynx-js/react': @@ -1259,6 +1296,18 @@ importers: specifier: ^5.102.0 version: 5.102.0 + packages/webpack/externals-loading-webpack-plugin: + devDependencies: + '@lynx-js/test-tools': + specifier: workspace:* + version: link:../test-tools + '@rspack/core': + specifier: 1.6.6 + version: 1.6.6(@swc/helpers@0.5.17) + foo: + specifier: ./test/foo + version: link:test/foo + packages/webpack/react-refresh-webpack-plugin: devDependencies: '@lynx-js/react':