diff --git a/.eslintrc.js b/.eslintrc.js index bf4642bb..158cadde 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { rules: { semi: ['error', 'always'], quotes: ['error', 'single'], + camelcase: 'off', 'new-cap': 'off', 'object-curly-spacing': ['error', 'always'], 'array-bracket-spacing': 'off', @@ -43,6 +44,10 @@ module.exports = { '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-throw-literal': 'off', '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', }, }, { @@ -51,6 +56,15 @@ module.exports = { 'no-console': 'off', }, }, + { + files: ['packages/hmr/lib/runtime/*.ts'], + rules: { + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + }, + }, { files: ['packages/jest/lib/**/*.ts'], rules: { diff --git a/.yarn/cache/@types-md5-npm-2.3.5-bd5c825c57-a86baf0521.zip b/.yarn/cache/@types-md5-npm-2.3.5-bd5c825c57-a86baf0521.zip new file mode 100644 index 00000000..bb83c3d1 Binary files /dev/null and b/.yarn/cache/@types-md5-npm-2.3.5-bd5c825c57-a86baf0521.zip differ diff --git a/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip new file mode 100644 index 00000000..6bf799ac Binary files /dev/null and b/.yarn/cache/@types-react-refresh-npm-0.14.3-d201a15407-ff48c18f92.zip differ diff --git a/.yarn/cache/esbuild-dependency-graph-npm-0.3.0-44c84f4894-831634605d.zip b/.yarn/cache/esbuild-dependency-graph-npm-0.3.0-44c84f4894-831634605d.zip new file mode 100644 index 00000000..1378bdaa Binary files /dev/null and b/.yarn/cache/esbuild-dependency-graph-npm-0.3.0-44c84f4894-831634605d.zip differ diff --git a/.yarn/cache/esbuild-plugin-module-id-npm-0.1.3-fd4d7e8747-7ae6493884.zip b/.yarn/cache/esbuild-plugin-module-id-npm-0.1.3-fd4d7e8747-7ae6493884.zip new file mode 100644 index 00000000..6e671624 Binary files /dev/null and b/.yarn/cache/esbuild-plugin-module-id-npm-0.1.3-fd4d7e8747-7ae6493884.zip differ diff --git a/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip b/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip new file mode 100644 index 00000000..49c0da61 Binary files /dev/null and b/.yarn/cache/swc-plugin-global-module-npm-0.1.0-alpha.5-cd339b699a-10523c5464.zip differ diff --git a/README.md b/README.md index 0f40b9ff..4168443b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - 💾 In-memory & Local File System Caching - 🎨 Flexible & Extensible - 🔥 Supports JSC & Hermes Runtime -- 🔄 Supports Live Reload +- 🔄 Supports HMR & Live Reload - 🐛 Supports Debugging(Flipper, Chrome Debugger) - 🌍 Supports All Platforms(Android, iOS, Web) - ✨ New Architecture Ready diff --git a/docs/pages/configuration/basic-configuration.mdx b/docs/pages/configuration/basic-configuration.mdx index 8c5d10b8..31755260 100644 --- a/docs/pages/configuration/basic-configuration.mdx +++ b/docs/pages/configuration/basic-configuration.mdx @@ -16,6 +16,9 @@ exports.default = {}; By default, follow the configuration below. ```js +/** + * @type {import('@react-native-esbuild/core').Config} + */ exports.default = { cache: true, logger: { @@ -28,13 +31,6 @@ exports.default = { assetExtensions: [/* internal/lib/defaults.ts */], }, transformer: { - jsc: { - transform: { - react: { - runtime: 'automatic', - }, - }, - }, stripFlowPackageNames: ['react-native'], }, web: { @@ -68,7 +64,6 @@ Resolver configurations. Transformer configurations. -- `transformer.jsc`: [jsc](https://swc.rs/docs/configuration/compilation) config in swc. - `transformer.stripFlowPackageNames`: Package names to strip flow syntax from (Defaults to `['react-native']`) - `transformer.fullyTransformPackageNames`: Package names to fully transform with [metro-react-native-babel-preset](https://github.com/facebook/react-native/tree/main/packages/react-native-babel-preset) from - `transformer.additionalTransformRules`: Additional transform rules. This rules will be applied before phase of transform to es5 @@ -90,6 +85,15 @@ Additional Esbuild plugins. For more details, go to [Custom Plugins](/configuration/custom-plugins) +### experimental + + + Experimental configurations. + + +- `experimental.hmr`: Enable HMR(Hot Module Replacement) on development mode. (Defaults to `false`) + - For more details and limitations, go to [Hot Module Replacement](/limitations/hot-module-replacement). + ## Types
@@ -210,9 +214,16 @@ interface Config { */ plugins?: EsbuildPlugin[]; /** - * Client event receiver + * Experimental configurations */ - reporter?: (event: ReportableEvent) => void; + experimental?: { + /** + * Enable HMR(Hot Module Replacement) on development mode. + * + * Defaults to `false`. + */ + hmr?: boolean; + }; } ``` @@ -239,28 +250,3 @@ type BabelTransformRule = TransformRuleBase; ```
- -
-ReportableEvent - -```ts -type ReportableEvent = ClientLogEvent; - -interface ClientLogEvent { - type: 'client_log'; - level: - | 'trace' - | 'info' - | 'warn' - | 'error' - | 'log' - | 'group' - | 'groupCollapsed' - | 'groupEnd' - | 'debug'; - data: unknown[]; - mode: 'BRIDGE' | 'NOBRIDGE'; -} -``` - -
diff --git a/docs/pages/getting-started/installation.md b/docs/pages/getting-started/installation.md index 530dc5de..78e783a7 100644 --- a/docs/pages/getting-started/installation.md +++ b/docs/pages/getting-started/installation.md @@ -31,7 +31,7 @@ And create `react-native-esbuild.js` to project root. exports.default = {}; ``` -for more details, go to [Configuration](/configuration/basic). +for more details, go to [Basic Configuration](/configuration/basic-configuration). ## Native Setup diff --git a/docs/pages/limitations/hot-module-replacement.md b/docs/pages/limitations/hot-module-replacement.md deleted file mode 100644 index 9cdb6133..00000000 --- a/docs/pages/limitations/hot-module-replacement.md +++ /dev/null @@ -1,5 +0,0 @@ -# Hot Module Replacement - -Esbuild doesn't currently support Hot Module Replacement(HMR). - -Metro is implementing HMR capabilities based on [react-refresh](https://www.npmjs.com/package/react-refresh). I'll be looking at working with this, but as it is one of the complex implementations, unfortunately not sure when it will be available. diff --git a/docs/pages/limitations/hot-module-replacement.mdx b/docs/pages/limitations/hot-module-replacement.mdx new file mode 100644 index 00000000..a494cd39 --- /dev/null +++ b/docs/pages/limitations/hot-module-replacement.mdx @@ -0,0 +1,30 @@ +import { Callout } from 'nextra/components' + +# Hot Module Replacement + + + HMR(Hot Module Replacement) is experimental. + + +esbuild doesn't currently support Hot Module Replacement(HMR). + +So, I working hard for implement custom HMR and it's partially available as an experimental feature. + +You can enable HMR by `experimental.hmr` set to `true` in your configuration file. + +```js +/** + * @type {import('@react-native-esbuild/core').Config} + */ +exports.default = { + // ... + experimental: { + hmr: true, + }, +}; +``` + +and here are some limitations. + +- Detects changes in the `/*` only. +- Changes detected in `/node_modules/*` will be ignored and fully refreshed after rebuild. diff --git a/example/.gitignore b/example/.gitignore index f4c3ff65..89fdaefa 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -73,6 +73,9 @@ yarn-error.log !.yarn/sdks !.yarn/versions +# @swc +.swc + # @react-native-esbuild .rne .swc diff --git a/example/react-native-esbuild.config.js b/example/react-native-esbuild.config.js index d5655063..9547c723 100644 --- a/example/react-native-esbuild.config.js +++ b/example/react-native-esbuild.config.js @@ -29,4 +29,7 @@ exports.default = { ], }, }, + experimental: { + hmr: true, + }, }; diff --git a/jest.config.ts b/jest.config.ts index 097d9188..5da1e387 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -27,13 +27,6 @@ const config: Config = { testMatch: ['/packages/core/**/*.test.ts'], setupFilesAfterEnv: ['/test/setup.ts'], }, - { - displayName: '@react-native-esbuild/config', - transform, - testEnvironment: 'node', - testMatch: ['/packages/config/**/*.test.ts'], - setupFilesAfterEnv: ['/test/setup.ts'], - }, { displayName: '@react-native-esbuild/dev-server', transform, @@ -41,13 +34,6 @@ const config: Config = { testMatch: ['/packages/dev-server/**/*.test.ts'], setupFilesAfterEnv: ['/test/setup.ts'], }, - { - displayName: '@react-native-esbuild/internal', - transform, - testEnvironment: 'node', - testMatch: ['/packages/internal/**/*.test.ts'], - setupFilesAfterEnv: ['/test/setup.ts'], - }, { displayName: '@react-native-esbuild/plugins', transform, @@ -70,10 +56,10 @@ const config: Config = { setupFilesAfterEnv: ['/test/setup.ts'], }, { - displayName: '@react-native-esbuild/utils', + displayName: '@react-native-esbuild/shared', transform, testEnvironment: 'node', - testMatch: ['/packages/utils/**/*.test.ts'], + testMatch: ['/packages/shared/**/*.test.ts'], setupFilesAfterEnv: ['/test/setup.ts'], }, ], diff --git a/packages/cli/lib/cli.ts b/packages/cli/lib/cli.ts index 86f2a34d..a2e3a783 100644 --- a/packages/cli/lib/cli.ts +++ b/packages/cli/lib/cli.ts @@ -3,8 +3,7 @@ import { hideBin } from 'yargs/helpers'; import { DEFAULT_ENTRY_POINT, DEFAULT_WEB_ENTRY_POINT, -} from '@react-native-esbuild/config'; -import { VERSION } from './constants'; +} from '@react-native-esbuild/shared'; import type { RawArgv } from './types'; const commonOptions = { @@ -27,7 +26,7 @@ const commonOptions = { export const cli = (): RawArgv | Promise => { return yargs(hideBin(process.argv)) .scriptName('rne') - .version(VERSION) + .version(self._version as string) .usage('$0 [args]') .command( 'start', diff --git a/packages/cli/lib/commands/bundle.ts b/packages/cli/lib/commands/bundle.ts index 407a829c..e801dd06 100644 --- a/packages/cli/lib/commands/bundle.ts +++ b/packages/cli/lib/commands/bundle.ts @@ -1,12 +1,11 @@ import path from 'node:path'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; import { - DEFAULT_ENTRY_POINT, + DEFAULT_WEB_ENTRY_POINT, type BundleOptions, -} from '@react-native-esbuild/config'; +} from '@react-native-esbuild/shared'; import { printDebugOptions } from '../helpers'; import { bundleArgvSchema } from '../schema'; -import { presets } from '../presets'; import { logger } from '../shared'; import type { Command } from '../types'; @@ -16,7 +15,7 @@ import type { Command } from '../types'; export const bundle: Command = async (argv) => { const bundleArgv = bundleArgvSchema.parse(argv); const bundleOptions: Partial = { - entry: path.resolve(bundleArgv['entry-file'] ?? DEFAULT_ENTRY_POINT), + entry: path.resolve(bundleArgv['entry-file'] ?? DEFAULT_WEB_ENTRY_POINT), sourcemap: bundleArgv['sourcemap-output'], outfile: bundleArgv['bundle-output'], assetsDir: bundleArgv['assets-dest'], @@ -32,10 +31,6 @@ export const bundle: Command = async (argv) => { printDebugOptions(bundleOptions); const bundler = new ReactNativeEsbuildBundler(root); - (bundleOptions.platform === 'web' ? presets.web : presets.native).forEach( - bundler.addPlugin.bind(bundler), - ); - await bundler.initialize(); await bundler.bundle(bundleOptions); }; diff --git a/packages/cli/lib/commands/serve.ts b/packages/cli/lib/commands/serve.ts index b0771fe5..c4048930 100644 --- a/packages/cli/lib/commands/serve.ts +++ b/packages/cli/lib/commands/serve.ts @@ -1,8 +1,7 @@ import { ReactNativeWebServer } from '@react-native-esbuild/dev-server'; -import type { BundleOptions } from '@react-native-esbuild/config'; +import type { BundleOptions } from '@react-native-esbuild/shared'; import { printDebugOptions } from '../helpers'; import { serveArgvSchema } from '../schema'; -import { presets } from '../presets'; import { logger } from '../shared'; import type { Command } from '../types'; @@ -28,12 +27,7 @@ export const serve: Command = async (argv): Promise => { logger.debug('bundle options'); printDebugOptions(bundleOptions); - const server = await new ReactNativeWebServer( - serveOptions, - bundleOptions, - ).initialize((bundler) => { - presets.web.forEach(bundler.addPlugin.bind(bundler)); - }); - + const server = new ReactNativeWebServer(serveOptions, bundleOptions); + await server.initialize(); await server.listen(); }; diff --git a/packages/cli/lib/commands/start.ts b/packages/cli/lib/commands/start.ts index d94f63b9..30d85a54 100644 --- a/packages/cli/lib/commands/start.ts +++ b/packages/cli/lib/commands/start.ts @@ -1,10 +1,9 @@ /* eslint-disable quotes -- Allow quote in template literal */ import path from 'node:path'; import { ReactNativeAppServer } from '@react-native-esbuild/dev-server'; -import { DEFAULT_ENTRY_POINT } from '@react-native-esbuild/config'; +import { DEFAULT_ENTRY_POINT } from '@react-native-esbuild/shared'; import { enableInteractiveMode, printDebugOptions } from '../helpers'; import { startArgvSchema } from '../schema'; -import { presets } from '../presets'; import { logger } from '../shared'; import type { Command } from '../types'; @@ -21,12 +20,8 @@ export const start: Command = async (argv) => { logger.debug('start options'); printDebugOptions({ entry, ...serveOptions }); - const server = await new ReactNativeAppServer(serveOptions).initialize( - (bundler) => { - presets.native.forEach(bundler.addPlugin.bind(bundler)); - }, - ); - + const server = new ReactNativeAppServer(serveOptions); + await server.initialize(); await server.listen(() => { if ( enableInteractiveMode((keyName) => { diff --git a/packages/cli/lib/constants/index.ts b/packages/cli/lib/constants/index.ts deleted file mode 100644 index ec782205..00000000 --- a/packages/cli/lib/constants/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import pkg from '../../package.json'; - -export const VERSION = pkg.version; diff --git a/packages/cli/lib/helpers/cli.ts b/packages/cli/lib/helpers/cli.ts index d866da86..ff7c1cd5 100644 --- a/packages/cli/lib/helpers/cli.ts +++ b/packages/cli/lib/helpers/cli.ts @@ -1,4 +1,4 @@ -import { colors } from '@react-native-esbuild/utils'; +import { colors } from '@react-native-esbuild/shared'; import { logger } from '../shared'; export const getCommand = ( diff --git a/packages/cli/lib/index.ts b/packages/cli/lib/index.ts index 385b54ae..aa4e633f 100644 --- a/packages/cli/lib/index.ts +++ b/packages/cli/lib/index.ts @@ -1,5 +1,5 @@ import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; -import { LogLevel } from '@react-native-esbuild/utils'; +import { LogLevel } from '@react-native-esbuild/shared'; import { cli } from './cli'; import * as Commands from './commands'; import { getCommand, handleUncaughtException } from './helpers'; diff --git a/packages/cli/lib/schema/index.ts b/packages/cli/lib/schema/index.ts index 1902aad3..c609b31c 100644 --- a/packages/cli/lib/schema/index.ts +++ b/packages/cli/lib/schema/index.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { SUPPORT_PLATFORMS } from '@react-native-esbuild/config'; +import { SUPPORT_PLATFORMS } from '@react-native-esbuild/shared'; import { z } from 'zod'; const resolvePath = (filepath: string): string => diff --git a/packages/cli/lib/shared.ts b/packages/cli/lib/shared.ts index fbc4868f..4c11af6a 100644 --- a/packages/cli/lib/shared.ts +++ b/packages/cli/lib/shared.ts @@ -1,3 +1,3 @@ -import { Logger } from '@react-native-esbuild/utils'; +import { Logger } from '@react-native-esbuild/shared'; export const logger = new Logger('cli'); diff --git a/packages/cli/package.json b/packages/cli/package.json index a0bd9a82..fa60817c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,11 +39,10 @@ "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" }, "dependencies": { - "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", "@react-native-esbuild/dev-server": "workspace:*", "@react-native-esbuild/plugins": "workspace:*", - "@react-native-esbuild/utils": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", "yargs": "^17.7.2", "zod": "^3.22.2" }, diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md deleted file mode 100644 index 2e9b70b6..00000000 --- a/packages/config/CHANGELOG.md +++ /dev/null @@ -1,344 +0,0 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## [0.1.0-beta.12](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.11...v0.1.0-beta.12) (2023-10-25) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.11](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.10...v0.1.0-beta.11) (2023-10-25) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.10](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.9...v0.1.0-beta.10) (2023-10-24) - -### Miscellaneous Chores - -- remove unused code and update comments ([7e03116](https://github.com/leegeunhyeok/react-native-esbuild/commit/7e03116882a9018fdeef1cd137bcc4b169d24d54)) -- update prepack script ([7c155dd](https://github.com/leegeunhyeok/react-native-esbuild/commit/7c155dd1190b3909112895bed8e2fbc916559b6f)) - -### Code Refactoring - -- import orders ([26d4e45](https://github.com/leegeunhyeok/react-native-esbuild/commit/26d4e454abb89b1b7d2e0eadaf15b27b124a34b5)) - -## [0.1.0-beta.9](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.8...v0.1.0-beta.9) (2023-10-22) - -### Code Refactoring - -- **dev-server:** now read assets from origin path ([64e75df](https://github.com/leegeunhyeok/react-native-esbuild/commit/64e75df281e32e549c51a0f544c5c8ae2779fe92)) -- move transformer options to each tranform module ([5d8cc9b](https://github.com/leegeunhyeok/react-native-esbuild/commit/5d8cc9ba0e870e47cbbd4d8591f1bc643df1f25c)) - -### Build System - -- **deps:** bump version up transform packages ([205d3ff](https://github.com/leegeunhyeok/react-native-esbuild/commit/205d3ff2dc0c8df62e3d0ddfce2576e726256c94)) - -## [0.1.0-beta.8](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.7...v0.1.0-beta.8) (2023-10-10) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.7](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.6...v0.1.0-beta.7) (2023-10-10) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.6](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.5...v0.1.0-beta.6) (2023-10-09) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.5](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.4...v0.1.0-beta.5) (2023-10-08) - -### Bug Fixes - -- default entry file path ([1e96aee](https://github.com/leegeunhyeok/react-native-esbuild/commit/1e96aee7542232d3bc829bccbecb5e2ec4e834fa)) - -### Miscellaneous Chores - -- fix keywords ([83c635d](https://github.com/leegeunhyeok/react-native-esbuild/commit/83c635d2e0cf0570513bf6b5a25b816ded976abc)) - -## [0.1.0-beta.4](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.3...v0.1.0-beta.4) (2023-10-08) - -### Features - -- web support ([1723b0c](https://github.com/leegeunhyeok/react-native-esbuild/commit/1723b0c6b30e1c1bb1f5781cc11a093822a60f3d)), closes [#36](https://github.com/leegeunhyeok/react-native-esbuild/issues/36) - -## [0.1.0-beta.3](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.2...v0.1.0-beta.3) (2023-10-04) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.2](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.1...v0.1.0-beta.2) (2023-10-04) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-beta.1](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-beta.0...v0.1.0-beta.1) (2023-10-03) - -### Features - -- add getBuildStatusCachePath ([57dd3b4](https://github.com/leegeunhyeok/react-native-esbuild/commit/57dd3b4b2cb1a9a046241c7b61c2f09f72385851)) -- improve logging ([7f93e19](https://github.com/leegeunhyeok/react-native-esbuild/commit/7f93e19a82dbadd80529356041a126698e99bcac)), closes [#26](https://github.com/leegeunhyeok/react-native-esbuild/issues/26) - -### Code Refactoring - -- move extension constants to internal package ([be450bd](https://github.com/leegeunhyeok/react-native-esbuild/commit/be450bdfda652aa380f8873cc0b8fcc824551ad0)) -- rename config types to options ([7512fae](https://github.com/leegeunhyeok/react-native-esbuild/commit/7512faeb3f7a19365bbf7f9c2ed929e7abe4f538)) -- root based local cache directory ([b30e324](https://github.com/leegeunhyeok/react-native-esbuild/commit/b30e32423cf626dcaed123f4b1d55abdc726e677)) - -## [0.1.0-beta.0](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.39...v0.1.0-beta.0) (2023-09-25) - -### Miscellaneous Chores - -- add comments to setEnvironment ([c9a0f7e](https://github.com/leegeunhyeok/react-native-esbuild/commit/c9a0f7e3f75fbc5548f1566c8c1b636f23fb30cf)) - -### Code Refactoring - -- change function declaration to arrow function ([9aeae13](https://github.com/leegeunhyeok/react-native-esbuild/commit/9aeae1368cdfde8d998b85ebfd609be13b05a50f)) - -### Build System - -- set packages as external ([dd4417f](https://github.com/leegeunhyeok/react-native-esbuild/commit/dd4417fe07c7bd87357246914743067343fdeccb)), closes [#22](https://github.com/leegeunhyeok/react-native-esbuild/issues/22) - -## [0.1.0-alpha.39](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.38...v0.1.0-alpha.39) (2023-09-24) - -### Code Refactoring - -- move bundler config types to core pacakge ([0924b6f](https://github.com/leegeunhyeok/react-native-esbuild/commit/0924b6f04fe59c538d18d5f49abaedc3a61df61d)) - -## [0.1.0-alpha.38](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.37...v0.1.0-alpha.38) (2023-09-24) - -### Features - -- improve configurations ([79c9a68](https://github.com/leegeunhyeok/react-native-esbuild/commit/79c9a687b63ed52244e2dc2f4a7a50f6e5983afd)) - -### Code Refactoring - -- cli option types ([4d7c346](https://github.com/leegeunhyeok/react-native-esbuild/commit/4d7c3468ccb509215164bc29cf5c6d0c265b67f1)) - -## [0.1.0-alpha.37](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.36...v0.1.0-alpha.37) (2023-09-23) - -### Bug Fixes - -- **plugins:** some issue on resolve platform specified assets ([562b3ec](https://github.com/leegeunhyeok/react-native-esbuild/commit/562b3ec651eb6cb1c68f4714cdf9817d42e114fa)) - -## [0.1.0-alpha.33](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.32...v0.1.0-alpha.33) (2023-09-22) - -### Code Refactoring - -- update bundle config type ([274558e](https://github.com/leegeunhyeok/react-native-esbuild/commit/274558e958bf8c4b04abb58df5473d3470b38026)) - -## [0.1.0-alpha.31](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.30...v0.1.0-alpha.31) (2023-09-19) - -### Features - -- add --metafile option for esbuild metafile ([a696fab](https://github.com/leegeunhyeok/react-native-esbuild/commit/a696fabe44e122fb866ca92d7a2518f0fd23cc0c)), closes [#8](https://github.com/leegeunhyeok/react-native-esbuild/issues/8) - -### Bug Fixes - -- unwrap iife for hermes optimization ([220233f](https://github.com/leegeunhyeok/react-native-esbuild/commit/220233f9afc738b6ddb4b2ac0edaac5f4f499632)), closes [#5](https://github.com/leegeunhyeok/react-native-esbuild/issues/5) - -### Miscellaneous Chores - -- bump version up packages ([b0c87fd](https://github.com/leegeunhyeok/react-native-esbuild/commit/b0c87fd8694b6c725267d66494d761809da27111)) -- remove unused module ([11738fe](https://github.com/leegeunhyeok/react-native-esbuild/commit/11738fe371230c35898768a90406bfd69fcc2fb3)) - -### Code Refactoring - -- apply eslint rules ([4792d4a](https://github.com/leegeunhyeok/react-native-esbuild/commit/4792d4a1662ad87fa052b93c709703a8d5f6fe46)) - -## [0.1.0-alpha.30](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.29...v0.1.0-alpha.30) (2023-08-23) - -**Note:** Version bump only for package @react-native-esbuild/config - -## [0.1.0-alpha.29](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.28...v0.1.0-alpha.29) (2023-08-17) - -### Features - -- inject react native polyfills only once ([5b54909](https://github.com/leegeunhyeok/react-native-esbuild/commit/5b5490943ba7a7ea9bdda6d76281b3752224e36d)) -- specify global object by platform ([8abf893](https://github.com/leegeunhyeok/react-native-esbuild/commit/8abf893bde650808bcee201ed6b40081ffa6136f)) - -### Code Refactoring - -- add reactNativeInternal ([f553bdf](https://github.com/leegeunhyeok/react-native-esbuild/commit/f553bdff0d95b9e9240e66ab18bc82c9deaf33e2)) - -### Miscellaneous Chores - -- bump version up pacakges ([e235610](https://github.com/leegeunhyeok/react-native-esbuild/commit/e235610379fbf8f5c6978ecded5dbe6549834975)) - -## [0.1.0-alpha.26](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.25...v0.1.0-alpha.26) (2023-08-10) - -### Code Refactoring - -- rename bitwiseOptions to getIdByOptions ([8055d6a](https://github.com/leegeunhyeok/react-native-esbuild/commit/8055d6a32e1f716615bd91385931ee99b5cf0d83)) - -## [0.1.0-alpha.22](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.21...v0.1.0-alpha.22) (2023-08-09) - -### Features - -- add root ([acde580](https://github.com/leegeunhyeok/react-native-esbuild/commit/acde580db75bffd27e5c12ea11d483bc585ea87a)) -- add root option to transformers ([68c8c52](https://github.com/leegeunhyeok/react-native-esbuild/commit/68c8c524daa458fad5d5f060ffcaba3ca40b2344)) -- add setEnvironment ([2372662](https://github.com/leegeunhyeok/react-native-esbuild/commit/237266258553547e7638d6b499aa44e40f33e37f)) -- cleanup esbuild options ([7ff4cd5](https://github.com/leegeunhyeok/react-native-esbuild/commit/7ff4cd5d08bb66db964945976218b459dc3dae96)) -- follow @react-native-community/cli options ([723a0fd](https://github.com/leegeunhyeok/react-native-esbuild/commit/723a0fda5f4c462c7d1bda0afc084ed48a5b7d3e)) - -## [0.1.0-alpha.21](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.20...v0.1.0-alpha.21) (2023-08-08) - -### Features - -- disable swc loose option ([ab1da4d](https://github.com/leegeunhyeok/react-native-esbuild/commit/ab1da4d8fbfbc9028e1de074e9df1d9dee96ff24)) - -### Bug Fixes - -- wrong **DEV** value ([620c9b4](https://github.com/leegeunhyeok/react-native-esbuild/commit/620c9b4c40d3d97f5676a5114e19c586e06738fb)) - -## [0.1.0-alpha.19](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.18...v0.1.0-alpha.19) (2023-08-06) - -### Features - -- **core:** support platform scoped bundle ([1a7094b](https://github.com/leegeunhyeok/react-native-esbuild/commit/1a7094b51c1327fff6708f32638a78c078a74914)) - -### Performance Improvements - -- improve transform performance ([42670f2](https://github.com/leegeunhyeok/react-native-esbuild/commit/42670f2bfd4d82df623d45713012ccc21bb8678e)) - -## [0.1.0-alpha.18](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.17...v0.1.0-alpha.18) (2023-08-05) - -### Features - -- add bitwiseOptions ([786191d](https://github.com/leegeunhyeok/react-native-esbuild/commit/786191df504bba61c71685196e82d2b2ba4e268d)) -- add scoped cache system ([8d1f0bd](https://github.com/leegeunhyeok/react-native-esbuild/commit/8d1f0bd3235f977a73f1f3725ce393fae244cf97)) - -### Miscellaneous Chores - -- add cleanup script ([0f03232](https://github.com/leegeunhyeok/react-native-esbuild/commit/0f032326ad5a412942b77f40130d38a3efeff472)) - -### Code Refactoring - -- improve config types ([4bacba6](https://github.com/leegeunhyeok/react-native-esbuild/commit/4bacba65c9609191490d89b488a9e00d3127ef38)) -- separate config modules ([ce6d02d](https://github.com/leegeunhyeok/react-native-esbuild/commit/ce6d02d5c5e597469e75c8c6864b553afd53b501)) - -## [0.1.0-alpha.17](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.16...v0.1.0-alpha.17) (2023-08-04) - -### Code Refactoring - -- remove cache option and now following dev option ([0bd385a](https://github.com/leegeunhyeok/react-native-esbuild/commit/0bd385a5931ddc69e258415d7f876bb96b6185de)) - -## [0.1.0-alpha.16](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.15...v0.1.0-alpha.16) (2023-08-04) - -### Features - -- add transform options ([018a731](https://github.com/leegeunhyeok/react-native-esbuild/commit/018a7312679bfed118e6d26ffede696b293f4cb7)) - -## [0.1.0-alpha.15](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.14...v0.1.0-alpha.15) (2023-08-04) - -### Features - -- improve esbuild log ([fa23610](https://github.com/leegeunhyeok/react-native-esbuild/commit/fa23610b9eed876974c8dc07e90baabe405b1df1)) - -### Miscellaneous Chores - -- add rimraf for cleanup build directories ([13356fe](https://github.com/leegeunhyeok/react-native-esbuild/commit/13356fec1868b7634da86bca522e987b5bee2284)) - -## [0.1.0-alpha.14](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.13...v0.1.0-alpha.14) (2023-08-04) - -### Features - -- add svg-transform-plugin ([0526207](https://github.com/leegeunhyeok/react-native-esbuild/commit/05262075d33d8df24a392e731a418435cf74c2bd)) - -## [0.1.0-alpha.12](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.11...v0.1.0-alpha.12) (2023-08-03) - -### Code Refactoring - -- add registerPlugins ([263219f](https://github.com/leegeunhyeok/react-native-esbuild/commit/263219f629b8535a1928e3ef5e87dc0ce797fe9d)) -- **core:** move build-status-plugin to core ([7d23543](https://github.com/leegeunhyeok/react-native-esbuild/commit/7d2354325cdd52b014aecaaa327071300877a1fc)) - -## [0.1.0-alpha.11](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.10...v0.1.0-alpha.11) (2023-08-03) - -### Features - -- copying assets when build complete ([db10be1](https://github.com/leegeunhyeok/react-native-esbuild/commit/db10be14be375910835def9efd07bf7e3efe6398)) - -### Bug Fixes - -- **core:** change react native initialize order ([81b5a30](https://github.com/leegeunhyeok/react-native-esbuild/commit/81b5a3033d0f478dea69a20b2922b0e7bf736858)) - -## [0.1.0-alpha.10](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.9...v0.1.0-alpha.10) (2023-08-01) - -### Features - -- **plugins:** implement asset-register-plugin ([9237cb4](https://github.com/leegeunhyeok/react-native-esbuild/commit/9237cb4802ffe4d9c2696292e6a63d276a1f44e1)) - -## [0.1.0-alpha.9](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.8...v0.1.0-alpha.9) (2023-07-31) - -### Features - -- add core options ([3743760](https://github.com/leegeunhyeok/react-native-esbuild/commit/3743760d285b7e55db1cc634b53800be36c05d1d)) -- change assetsDest to assetsDir ([2ec231b](https://github.com/leegeunhyeok/react-native-esbuild/commit/2ec231b7a63ee68f0acb9c16fba5dea6f355b62a)) -- improve configs and implement start command ([936d33b](https://github.com/leegeunhyeok/react-native-esbuild/commit/936d33b2f916c22410aa7241ae53b634f83116ee)) -- improve module resolution for react native polyfills ([300df3f](https://github.com/leegeunhyeok/react-native-esbuild/commit/300df3f0c6654764ed9539d13243346faa6559a9)) - -### Performance Improvements - -- improve bundle performance ([72844d5](https://github.com/leegeunhyeok/react-native-esbuild/commit/72844d5b5d5529b1245a1642218b5ef9d41e3dd5)) - -### Code Refactoring - -- cleanup import statement ([badc372](https://github.com/leegeunhyeok/react-native-esbuild/commit/badc372d6db1ddb8f3b68270829ea4be842c3c63)) -- split config modules to each target ([f37427d](https://github.com/leegeunhyeok/react-native-esbuild/commit/f37427d3160b7eb995befbeea8116fe53cb9e1d5)) - -## [0.1.0-alpha.8](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.7...v0.1.0-alpha.8) (2023-07-29) - -### Reverts - -- Revert "chore: change type to module" ([96c32ee](https://github.com/leegeunhyeok/react-native-esbuild/commit/96c32ee767cb0553b0bbe0ba3c631da3dbc308bf)) - -## [0.1.0-alpha.7](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.6...v0.1.0-alpha.7) (2023-07-29) - -### Miscellaneous Chores - -- change type to module ([6d63e8a](https://github.com/leegeunhyeok/react-native-esbuild/commit/6d63e8af31f4e485247add463142d81f86c0c0b2)) - -## [0.1.0-alpha.5](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.4...v0.1.0-alpha.5) (2023-07-29) - -### Bug Fixes - -- **config:** add missed esbuild options ([b1fda0d](https://github.com/leegeunhyeok/react-native-esbuild/commit/b1fda0d6e92186a3853b3c71b5687c35b13fd2e8)) - -## [0.1.0-alpha.3](https://github.com/leegeunhyeok/react-native-esbuild/compare/v0.1.0-alpha.2...v0.1.0-alpha.3) (2023-07-29) - -### Features - -- **config:** add outfile to esbuild config ([638e6a2](https://github.com/leegeunhyeok/react-native-esbuild/commit/638e6a27c1f48c5d3ab76bfb63cdc13682d92842)) - -### Code Refactoring - -- **config:** improve config types ([1c2c170](https://github.com/leegeunhyeok/react-native-esbuild/commit/1c2c170d01c2beb2018ac745daaa3973a4368103)) - -## 0.1.0-alpha.1 (2023-07-29) - -### Features - -- add base configs for build ([3acf916](https://github.com/leegeunhyeok/react-native-esbuild/commit/3acf91623d33e9d1f8ee48568d66e57d329683ec)) -- add sourcemap option ([bfb6c9e](https://github.com/leegeunhyeok/react-native-esbuild/commit/bfb6c9edc2338aa612e4f687b05d72e94bc70877)) -- **core:** add request bundle option ([5a76eca](https://github.com/leegeunhyeok/react-native-esbuild/commit/5a76ecac1e07211c95ec356e5829bb0f671009c9)) - -### Bug Fixes - -- circular dependency ([f764fe5](https://github.com/leegeunhyeok/react-native-esbuild/commit/f764fe51c4ec31efd8c89826200bbe275f956e86)) -- process exit when error occurred ([a0ef5ab](https://github.com/leegeunhyeok/react-native-esbuild/commit/a0ef5ab055cab1828fe763473992d995bc65e23d)) -- set react-native as external module ([add4a20](https://github.com/leegeunhyeok/react-native-esbuild/commit/add4a20a3de08c26d42f39afab20c1a890a9939b)) - -### Build System - -- add esbuild scripts ([b38b2c0](https://github.com/leegeunhyeok/react-native-esbuild/commit/b38b2c06bf7f8594fd17675c8d23e38a7f1678fb)) -- change base build config ([752e15a](https://github.com/leegeunhyeok/react-native-esbuild/commit/752e15af5560c6f5648344a2695257e819045d95)) - -### Code Refactoring - -- change custom option variable names ([a0060dc](https://github.com/leegeunhyeok/react-native-esbuild/commit/a0060dcd3a59dc2899cbda90980c5c3aeb38de18)) -- **config:** change swc config builder name ([da39399](https://github.com/leegeunhyeok/react-native-esbuild/commit/da39399595b0a686316146c2d91ec0c5c6ad5bdc)) -- **config:** improve swc option builder ([6dc328a](https://github.com/leegeunhyeok/react-native-esbuild/commit/6dc328a6693edcb58d2a29dd401a4814430fb014)) - -### Miscellaneous Chores - -- add dist directory to publish files ([1abbee2](https://github.com/leegeunhyeok/react-native-esbuild/commit/1abbee2dd1560ac7166903362c220263cd5d895a)) -- add packages ([a2076de](https://github.com/leegeunhyeok/react-native-esbuild/commit/a2076def60774fb9b39cfe90f5af35b44148a46f)) -- add prepack scripts ([3baa83b](https://github.com/leegeunhyeok/react-native-esbuild/commit/3baa83b9ce539c7c797a959a829aaf0e95d0d6d2)) -- update tsconfig for type declaration ([7458d94](https://github.com/leegeunhyeok/react-native-esbuild/commit/7458d945fb3e8c3a5a7b29a00eda197556a5fa5d)) diff --git a/packages/config/README.md b/packages/config/README.md deleted file mode 100644 index fbcb19f3..00000000 --- a/packages/config/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `@react-native-esbuild/config` - -> Shared configs for @react-native-esbuild/config diff --git a/packages/config/lib/common/__tests__/__snapshots__/common.test.ts.snap b/packages/config/lib/common/__tests__/__snapshots__/common.test.ts.snap deleted file mode 100644 index f1edf0b5..00000000 --- a/packages/config/lib/common/__tests__/__snapshots__/common.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`getBuildStatusCachePath should match snapshot 1`] = `"/root/.rne/build-status.json"`; diff --git a/packages/config/lib/common/__tests__/common.test.ts b/packages/config/lib/common/__tests__/common.test.ts deleted file mode 100644 index 53c8c252..00000000 --- a/packages/config/lib/common/__tests__/common.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getIdByOptions, getBuildStatusCachePath } from '../core'; -import { OptionFlag } from '../../types'; - -const BASE_OPTIONS = { - outfile: '', - entry: '', - metafile: false, -} as const; - -const ROOT_DIR = '/root'; - -describe.each([ - [ - { platform: 'android', dev: false, minify: false }, - OptionFlag.PlatformAndroid, - ], - [ - { platform: 'android', dev: true, minify: false }, - OptionFlag.PlatformAndroid | OptionFlag.Dev, - ], - [ - { platform: 'android', dev: false, minify: true }, - OptionFlag.PlatformAndroid | OptionFlag.Minify, - ], - [ - { platform: 'android', dev: true, minify: true }, - OptionFlag.PlatformAndroid | OptionFlag.Dev | OptionFlag.Minify, - ], - [{ platform: 'ios', dev: false, minify: false }, OptionFlag.PlatformIos], - [ - { platform: 'ios', dev: true, minify: false }, - OptionFlag.PlatformIos | OptionFlag.Dev, - ], - [ - { platform: 'ios', dev: false, minify: true }, - OptionFlag.PlatformIos | OptionFlag.Minify, - ], - [ - { platform: 'ios', dev: true, minify: true }, - OptionFlag.PlatformIos | OptionFlag.Dev | OptionFlag.Minify, - ], - [{ platform: 'web', dev: false, minify: false }, OptionFlag.PlatformWeb], - [ - { platform: 'web', dev: true, minify: false }, - OptionFlag.PlatformWeb | OptionFlag.Dev, - ], - [ - { platform: 'web', dev: false, minify: true }, - OptionFlag.PlatformWeb | OptionFlag.Minify, - ], - [ - { platform: 'web', dev: true, minify: true }, - OptionFlag.PlatformWeb | OptionFlag.Dev | OptionFlag.Minify, - ], -] as const)('getIdByOptions', (options, expected) => { - const dev = options.dev ? 'true' : 'false'; - const minify = options.minify ? 'true' : 'false'; - - describe(`platform: ${options.platform}, dev: ${dev}, minify: ${minify}`, () => { - it(`should bitwise value is ${expected}`, () => { - expect(getIdByOptions({ ...BASE_OPTIONS, ...options })).toEqual(expected); - }); - }); -}); - -describe('getBuildStatusCachePath', () => { - it('should match snapshot', () => { - expect(getBuildStatusCachePath(ROOT_DIR)).toMatchSnapshot(); - }); -}); diff --git a/packages/config/lib/common/index.ts b/packages/config/lib/common/index.ts deleted file mode 100644 index 7877787b..00000000 --- a/packages/config/lib/common/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './core'; -export * from '../shares'; diff --git a/packages/config/lib/index.ts b/packages/config/lib/index.ts deleted file mode 100644 index de4fec05..00000000 --- a/packages/config/lib/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './common'; -export * from './shares'; -export { OptionFlag } from './types'; -export type * from './types'; diff --git a/packages/config/lib/shares.ts b/packages/config/lib/shares.ts deleted file mode 100644 index 8cbe1b2f..00000000 --- a/packages/config/lib/shares.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const DEFAULT_ENTRY_POINT = 'index.js'; -export const DEFAULT_WEB_ENTRY_POINT = 'index.web.js'; -export const DEFAULT_OUTFILE = 'main.jsbundle'; - -export const GLOBAL_CACHE_DIR = 'react-native-esbuild'; -export const LOCAL_CACHE_DIR = '.rne'; - -export const SUPPORT_PLATFORMS = ['android', 'ios', 'web'] as const; diff --git a/packages/config/lib/types.ts b/packages/config/lib/types.ts deleted file mode 100644 index 52d4f1c3..00000000 --- a/packages/config/lib/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type BundlerSupportPlatform = 'android' | 'ios' | 'web'; - -export interface BundleOptions { - platform: BundlerSupportPlatform; - entry: string; - outfile: string; - dev: boolean; - minify: boolean; - metafile: boolean; - sourcemap?: string; - assetsDir?: string; -} - -/** - * Flags for `BundleOptions` - */ -export enum OptionFlag { - None = 0b00000000, - PlatformAndroid = 0b00000001, - PlatformIos = 0b00000010, - PlatformWeb = 0b00000100, - Dev = 0b00001000, - Minify = 0b00010000, -} diff --git a/packages/core/lib/bundler/bundler.ts b/packages/core/lib/bundler/ReactNativeEsbuildBundler.ts similarity index 56% rename from packages/core/lib/bundler/bundler.ts rename to packages/core/lib/bundler/ReactNativeEsbuildBundler.ts index 5146daa3..dcfafc0d 100644 --- a/packages/core/lib/bundler/bundler.ts +++ b/packages/core/lib/bundler/ReactNativeEsbuildBundler.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import os from 'node:os'; import esbuild, { type BuildOptions, type BuildResult, @@ -6,52 +7,71 @@ import esbuild, { } from 'esbuild'; import invariant from 'invariant'; import ora from 'ora'; -import { getGlobalVariables } from '@react-native-esbuild/internal'; +import { isHMRBoundary } from '@react-native-esbuild/hmr'; import { + Logger, + LogLevel, setEnvironment, - combineWithDefaultBundleOptions, getIdByOptions, getDevServerPublicPath, + combineWithDefaultBundleOptions, + printLogo, + printVersion, + BuildMode, + type Id, + type BuildStatus, + type BuildContext, + type BuildStatusListener, type BundleOptions, -} from '@react-native-esbuild/config'; -import { Logger, LogLevel } from '@react-native-esbuild/utils'; -import { FileSystemWatcher } from '../watcher'; + type Config, + type AdditionalData, + type PluginFactory, +} from '@react-native-esbuild/shared'; +import { + getGlobalVariables, + type ClientLogEvent, +} from '@react-native-esbuild/internal'; +import { + createBuildStatusPlugin, + createMetafilePlugin, +} from '@react-native-esbuild/plugins'; import { logger } from '../shared'; -import type { - Config, - BundlerInitializeOptions, - BuildTask, - BuildStatus, - BundleMode, - BundlerAdditionalData, - BundleResult, - BundleRequestOptions, - PluginContext, - ReportableEvent, - ReactNativeEsbuildPluginCreator, -} from '../types'; -import { CacheStorage, SharedStorage } from './storages'; -import { createBuildStatusPlugin, createMetafilePlugin } from './plugins'; -import { BundlerEventEmitter } from './events'; import { loadConfig, + createBuildTaskDelegate, getConfigFromGlobal, - createPromiseHandler, getTransformedPreludeScript, getResolveExtensionsOption, getLoaderOption, getEsbuildWebConfig, -} from './helpers'; -import { printLogo, printVersion } from './logo'; + getExternalFromPackageJson, + getExternalModulePattern, +} from '../helpers'; +import type { + BundlerInitializeOptions, + BundleResult, + BundleRequestOptions, + BuildTask, + FileSystemWatchEventListener, +} from '../types'; +import { FileSystemWatcher } from './watcher'; +import { CacheStorage } from './cache'; +import { ModuleManager } from './modules'; +import { BundlerEventEmitter } from './events'; +import { presets } from './plugins'; -export class ReactNativeEsbuildBundler extends BundlerEventEmitter { - public static caches = new CacheStorage(); - public static shared = new SharedStorage(); +export class ReactNativeEsbuildBundler + extends BundlerEventEmitter + implements BuildStatusListener, FileSystemWatchEventListener +{ private appLogger = new Logger('app', LogLevel.Trace); - private buildTasks = new Map(); - private plugins: ReactNativeEsbuildPluginCreator[] = []; - private initialized = false; + private buildTasks = new Map(); + private plugins: PluginFactory[] = []; + private watcher: FileSystemWatcher; private config: Config; + private external: string[]; + private externalPattern: string; + private initialized = false; /** * Must be bootstrapped first at the entry point @@ -91,25 +111,29 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { } public static async resetCache(): Promise { - await ReactNativeEsbuildBundler.caches.clearAll(); + await CacheStorage.clearAll(); logger.info('transform cache was reset'); } constructor(private root: string = process.cwd()) { super(); this.config = getConfigFromGlobal(); + this.external = getExternalFromPackageJson(root); + this.externalPattern = getExternalModulePattern( + this.external, + this.config.resolver?.assetExtensions ?? [], + ); this.on('report', (event) => { this.broadcastToReporter(event); }); } - private broadcastToReporter(event: ReportableEvent): void { - // default reporter (for logging) + private broadcastToReporter(event: ClientLogEvent): void { switch (event.type) { case 'client_log': { if (event.level === 'group' || event.level === 'groupCollapsed') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument -- Allow any type. - this.appLogger.group(...(event.data as any[])); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow + this.appLogger.group(...(event.data as unknown as any[])); return; } else if (event.level === 'groupEnd') { this.appLogger.groupEnd(); @@ -124,43 +148,25 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { break; } } - - // send event to custom reporter - this.config.reporter?.(event); - } - - private startWatcher(): Promise { - return FileSystemWatcher.getInstance() - .setHandler((event, changedFile, stats) => { - const hasTask = this.buildTasks.size > 0; - ReactNativeEsbuildBundler.shared.setValue({ - watcher: { - changed: hasTask && event === 'change' ? changedFile : null, - stats, - }, - }); - - for (const { context, handler } of this.buildTasks.values()) { - context.rebuild().catch((error) => handler?.rejecter?.(error)); - } - }) - .watch(this.root); } - private async getBuildOptionsForBundler( - mode: BundleMode, + private async getBuildOptions( + mode: BuildMode, bundleOptions: BundleOptions, - additionalData?: BundlerAdditionalData, + additionalData?: AdditionalData, ): Promise { const config = this.config; invariant(config.resolver, 'invalid resolver configuration'); invariant(config.resolver.mainFields, 'invalid mainFields'); invariant(config.transformer, 'invalid transformer configuration'); - invariant(config.resolver.assetExtensions, 'invalid assetExtension'); + invariant(config.resolver.assetExtensions, 'invalid assetExtensions'); invariant(config.resolver.sourceExtensions, 'invalid sourceExtensions'); setEnvironment(bundleOptions.dev); + const hmrEnabled = Boolean( + mode === BuildMode.Watch && bundleOptions.dev && config.experimental?.hmr, + ); const webSpecifiedOptions = bundleOptions.platform === 'web' ? getEsbuildWebConfig(mode, this.root, bundleOptions) @@ -171,19 +177,25 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { webSpecifiedOptions.outfile ?? path.basename(bundleOptions.entry); } - const context: PluginContext = { - ...bundleOptions, - id: this.identifyTaskByBundleOptions(bundleOptions), + const context: BuildContext = { + id: getIdByOptions(bundleOptions), root: this.root, config: this.config, + bundleOptions, mode, - additionalData, + moduleManager: new ModuleManager(), + cacheStorage: new CacheStorage(), + hmrEnabled, + additionalData: { + ...additionalData, + externalPattern: this.externalPattern, + }, }; return { entryPoints: [bundleOptions.entry], outfile: bundleOptions.outfile, - sourceRoot: path.dirname(bundleOptions.entry), + sourceRoot: this.root, mainFields: config.resolver.mainFields, resolveExtensions: getResolveExtensionsOption( bundleOptions, @@ -191,23 +203,30 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { config.resolver.assetExtensions, ), loader: getLoaderOption(config.resolver.assetExtensions), - define: getGlobalVariables(bundleOptions), + define: getGlobalVariables(bundleOptions.dev, bundleOptions.platform), banner: { - js: await getTransformedPreludeScript(bundleOptions, this.root), + js: await getTransformedPreludeScript( + bundleOptions, + this.root, + [hmrEnabled ? 'swc-plugin-global-module/runtime' : undefined].filter( + Boolean, + ) as string[], + ), }, plugins: [ - createBuildStatusPlugin(context, { - onStart: this.handleBuildStart.bind(this), - onUpdate: this.handleBuildStateUpdate.bind(this), - onEnd: this.handleBuildEnd.bind(this), - }), + // Common plugins + createBuildStatusPlugin(context, {}), createMetafilePlugin(context), - // Added plugin creators. + // Added plugin creators + ...(bundleOptions.platform === 'web' + ? presets.web + : presets.native + ).map((createPlugin) => createPlugin(context)), ...this.plugins.map((plugin) => plugin(context)), // Additional plugins in configuration. ...(config.plugins ?? []), ], - legalComments: bundleOptions.dev ? 'inline' : 'none', + legalComments: 'none', target: 'es6', format: 'esm', supported: { @@ -225,52 +244,105 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { logLevel: 'silent', bundle: true, sourcemap: true, + metafile: true, minify: bundleOptions.minify, - metafile: bundleOptions.metafile, - write: mode === 'bundle', + write: mode === BuildMode.Bundle, ...webSpecifiedOptions, }; } - private identifyTaskByBundleOptions(bundleOptions: BundleOptions): number { - return getIdByOptions(bundleOptions); - } - private throwIfNotInitialized(): void { if (this.initialized) return; throw new Error('bundler not initialized'); } - private handleBuildStart(context: PluginContext): void { + private async setupTask( + bundleOptions: BundleOptions, + additionalData?: AdditionalData, + ): Promise { + const id = getIdByOptions(bundleOptions); + if (this.buildTasks.has(id)) { + return this.buildTasks.get(id)!; + } + + const buildOptions = await this.getBuildOptions( + BuildMode.Watch, + bundleOptions, + additionalData, + ); + + const buildTask: BuildTask = { + esbuild: await esbuild.context(buildOptions), + delegate: createBuildTaskDelegate(), + buildCount: 0, + status: 'pending', + }; + + this.buildTasks.set(id, buildTask); + logger.debug(`new build context registered (id: ${id})`); + + return buildTask; + } + + private resetTask(context: BuildContext): void { + // Skip when bundle mode because task does not exist in this mode. + if (context.mode === BuildMode.Bundle) return; + + const buildContext = this.buildTasks.get(context.id); + invariant(buildContext, 'no build context'); + + if (buildContext.buildCount === 0) return; + + logger.debug(`reset build context (id: ${context.id})`, { + buildCount: buildContext.buildCount, + }); + + buildContext.delegate = createBuildTaskDelegate(); + buildContext.status = 'pending'; + buildContext.buildCount += 1; + } + + public onBuildStart(context: BuildContext): void { this.resetTask(context); this.emit('build-start', { id: context.id }); } - private handleBuildStateUpdate( - buildState: BuildStatus, - context: PluginContext, - ): void { - this.emit('build-status-change', { id: context.id, ...buildState }); + public onBuild(context: BuildContext, status: BuildStatus): void { + this.emit('build-status-change', { id: context.id, ...status }); } - private handleBuildEnd( + public onBuildEnd( + context: BuildContext, data: { result: BuildResult; success: boolean }, - context: PluginContext, ): void { /** * Exit at the end of a build in bundle mode. * * If the build fails, exit with status 1. */ - if (context.mode === 'bundle') { + if (context.mode === BuildMode.Bundle) { if (data.success) return; process.exit(1); } - const currentTask = this.buildTasks.get(context.id); - invariant(currentTask, 'no task'); + invariant(data.success, 'build failed'); + invariant(data.result.metafile, 'invalid metafile'); + invariant(data.result.outputFiles, 'empty outputFiles'); + + const buildContext = this.buildTasks.get(context.id); + invariant(buildContext, 'no build context'); + + // if (context.hmrEnabled) { + // ReactNativeEsbuildBundler.sharedData.get(context.id).set({ + // dependencyGraph: generateDependencyGraph( + // data.result.metafile, + // context.bundleOptions.entry, + // ), + // }); + // } + const bundleEndedAt = new Date(); - const bundleFilename = context.outfile; + const bundleFilename = context.bundleOptions.outfile; const bundleSourcemapFilename = `${bundleFilename}.map`; const revisionId = bundleEndedAt.getTime().toString(); const { outputFiles } = data.result; @@ -283,9 +355,6 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { }; try { - invariant(data.success, 'build failed'); - invariant(outputFiles, 'empty outputFiles'); - const bundleOutput = outputFiles.find(findFromOutputFile(bundleFilename)); const bundleSourcemapOutput = outputFiles.find( findFromOutputFile(bundleSourcemapFilename), @@ -293,7 +362,7 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { invariant(bundleOutput, 'empty bundle output'); invariant(bundleSourcemapOutput, 'empty sourcemap output'); - currentTask.handler?.resolver?.({ + buildContext.delegate.success({ result: { source: bundleOutput.contents, sourcemap: bundleSourcemapOutput.contents, @@ -303,74 +372,45 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { error: null, }); } catch (error) { - currentTask.handler?.rejecter?.(error); + buildContext.delegate.failure(error); } finally { - currentTask.status = 'resolved'; + buildContext.status = 'resolved'; this.emit('build-end', { revisionId, id: context.id, additionalData: context.additionalData, + update: null, }); } } - private async getOrCreateBundleTask( - bundleOptions: BundleOptions, - additionalData?: BundlerAdditionalData, - ): Promise { - const targetTaskId = this.identifyTaskByBundleOptions(bundleOptions); - - if (!this.buildTasks.has(targetTaskId)) { - logger.debug(`bundle task not registered (id: ${targetTaskId})`); - const buildOptions = await this.getBuildOptionsForBundler( - 'watch', - bundleOptions, - additionalData, - ); - const handler = createPromiseHandler(); - const context = await esbuild.context(buildOptions); - this.buildTasks.set(targetTaskId, { - context, - handler, - status: 'pending', - buildCount: 0, + public onFileSystemChange(event: string, path: string): void { + const hasBuildTask = this.buildTasks.size > 0; + const isChanged = event === 'change'; + + if (!(hasBuildTask && isChanged)) return; + + // Set status as stale (need to rebuild when receive bundle requests) + this.buildTasks.forEach((task) => (task.status = 'pending')); + + if (this.config.experimental?.hmr && isHMRBoundary(path)) { + // for (const [ + // id, + // hmrController, + // ] of ReactNativeEsbuildBundler.hmr.entries()) { + // hmrController.getDelta(changedFile).then((update) => { + // this.emit('build-end', { + // id, + // update, + // revisionId: new Date().getTime().toString(), + // }); + // }); + // } + } else { + this.buildTasks.forEach(({ esbuild }) => { + esbuild.rebuild(); }); - // Trigger first build. - context.rebuild().catch((error) => handler.rejecter?.(error)); - logger.debug(`bundle task is now watching (id: ${targetTaskId})`); } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Already `set()` if not exist. - return this.buildTasks.get(targetTaskId)!; - } - - private resetTask(context: PluginContext): void { - // Skip when bundle mode because task does not exist in this mode. - if (context.mode === 'bundle') return; - - const targetTask = this.buildTasks.get(context.id); - invariant(targetTask, 'no task'); - - logger.debug(`reset task (id: ${context.id})`, { - buildCount: targetTask.buildCount, - }); - - this.buildTasks.set(context.id, { - // Use created esbuild context. - context: targetTask.context, - /** - * Set status to `pending` and create new handler when it is stale. - * - * - `buildCount` is 0, it is first build. - * - `buildCount` is over 0, triggered rebuild (handler is stale). - */ - handler: - targetTask.buildCount === 0 - ? targetTask.handler - : createPromiseHandler(), - status: 'pending', - buildCount: targetTask.buildCount + 1, - }); } public async initialize(options?: BundlerInitializeOptions): Promise { @@ -385,7 +425,8 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { ); if (options?.watcherEnabled) { - await this.startWatcher(); + this.watcher = new FileSystemWatcher(this); + await this.watcher.watch(this.root); } this.initialized = true; @@ -399,18 +440,18 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { return this; } - public addPlugin(creator: ReactNativeEsbuildPluginCreator): this { + public addPlugin(creator: PluginFactory): this { this.plugins.push(creator); return this; } public async bundle( bundleOptions: Partial, - additionalData?: BundlerAdditionalData, + additionalData?: AdditionalData, ): Promise { this.throwIfNotInitialized(); - const buildOptions = await this.getBuildOptionsForBundler( - 'bundle', + const buildOptions = await this.getBuildOptions( + BuildMode.Bundle, combineWithDefaultBundleOptions(bundleOptions), additionalData, ); @@ -419,36 +460,44 @@ export class ReactNativeEsbuildBundler extends BundlerEventEmitter { public async serve( bundleOptions: Partial, - additionalData?: BundlerAdditionalData, + additionalData?: AdditionalData, ): Promise { this.throwIfNotInitialized(); if (bundleOptions.platform !== 'web') { throw new Error('serve mode is only available on web platform'); } - const buildTask = await this.getOrCreateBundleTask( + const buildContext = await this.setupTask( combineWithDefaultBundleOptions(bundleOptions), additionalData, ); - invariant(buildTask.handler, 'no handler'); - return buildTask.context.serve({ + if (buildContext.status === 'pending') { + buildContext.esbuild.rebuild(); + } + + return buildContext.esbuild.serve({ servedir: getDevServerPublicPath(this.root), }); } public async getBundleResult( bundleOptions: BundleRequestOptions, - additionalData?: BundlerAdditionalData, + additionalData?: AdditionalData, ): Promise { this.throwIfNotInitialized(); - const buildTask = await this.getOrCreateBundleTask( + const buildContext = await this.setupTask( combineWithDefaultBundleOptions(bundleOptions), additionalData, ); - invariant(buildTask.handler, 'no handler'); - return buildTask.handler.task; + if (buildContext.status === 'pending') { + buildContext.esbuild.rebuild(); + } + + const result = await buildContext.delegate.promise; + + return result; } public getRoot(): string { diff --git a/packages/core/lib/bundler/cache/CacheController.ts b/packages/core/lib/bundler/cache/CacheController.ts deleted file mode 100644 index 4241eae4..00000000 --- a/packages/core/lib/bundler/cache/CacheController.ts +++ /dev/null @@ -1,41 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import type { Cache } from '../../types'; - -const OPTIONS = { encoding: 'utf-8' } as const; - -export class CacheController { - private cache: Record = {}; - - constructor(private cacheDirectory: string) { - try { - fs.accessSync(cacheDirectory, fs.constants.R_OK | fs.constants.W_OK); - } catch (_error) { - fs.mkdirSync(cacheDirectory, { recursive: true }); - } - } - - public readFromMemory(key: string): Cache | undefined { - return this.cache[key]; - } - - public readFromFileSystem(hash: string): Promise { - return fs.promises - .readFile(path.join(this.cacheDirectory, hash), OPTIONS) - .catch(() => null); - } - - public writeToMemory(key: string, cacheData: Cache): void { - this.cache[key] = cacheData; - } - - public writeToFileSystem(hash: string, data: string): Promise { - return fs.promises - .writeFile(path.join(this.cacheDirectory, hash), data, OPTIONS) - .catch(() => void 0); - } - - public reset(): void { - this.cache = {}; - } -} diff --git a/packages/core/lib/bundler/cache/CacheStorageImpl.ts b/packages/core/lib/bundler/cache/CacheStorageImpl.ts new file mode 100644 index 00000000..f905683b --- /dev/null +++ b/packages/core/lib/bundler/cache/CacheStorageImpl.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + GLOBAL_CACHE_DIR, + type Cache, + type CacheStorage, +} from '@react-native-esbuild/shared'; + +const OPTIONS = { encoding: 'utf-8' } as const; + +export class CacheStorageImpl implements CacheStorage { + private cache: Record = {}; + + public static clearAll(): Promise { + return fs.promises.rm(GLOBAL_CACHE_DIR, { + recursive: true, + }); + } + + constructor() { + try { + fs.accessSync(GLOBAL_CACHE_DIR, fs.constants.R_OK | fs.constants.W_OK); + } catch (_error) { + fs.mkdirSync(GLOBAL_CACHE_DIR, { recursive: true }); + } + } + + public readFromMemory(key: string): Cache | undefined { + return this.cache[key]; + } + + public readFromFileSystem(key: string): Promise { + return fs.promises + .readFile(path.join(GLOBAL_CACHE_DIR, key), OPTIONS) + .catch(() => undefined); + } + + public writeToMemory(key: string, cacheData: Cache): void { + this.cache[key] = cacheData; + } + + public writeToFileSystem(key: string, data: string): Promise { + return fs.promises + .writeFile(path.join(GLOBAL_CACHE_DIR, key), data, OPTIONS) + .catch(() => void 0); + } + + public reset(): void { + this.cache = {}; + } +} diff --git a/packages/core/lib/bundler/cache/__tests__/cache.test.ts b/packages/core/lib/bundler/cache/__tests__/cache.test.ts index 4e6e3fd9..3b3ecf50 100644 --- a/packages/core/lib/bundler/cache/__tests__/cache.test.ts +++ b/packages/core/lib/bundler/cache/__tests__/cache.test.ts @@ -1,11 +1,10 @@ import fs from 'node:fs'; import { faker } from '@faker-js/faker'; -import { CacheController } from '../CacheController'; -import type { Cache } from '../../../types'; +import type { Cache } from '@react-native-esbuild/shared'; +import { CacheStorageImpl } from '../CacheStorageImpl'; -describe('CacheController', () => { - const CACHE_DIR = '/cache'; - let manager: CacheController; +describe('CacheStorageImpl', () => { + let storage: CacheStorageImpl; let mockedFs: Record; beforeAll(() => { @@ -25,7 +24,7 @@ describe('CacheController', () => { }); jest.spyOn(fs.promises, 'rm').mockReturnValue(Promise.resolve()); - manager = new CacheController(CACHE_DIR); + storage = new CacheStorageImpl(); mockedFs = {}; }); @@ -33,24 +32,6 @@ describe('CacheController', () => { jest.restoreAllMocks(); }); - describe('getCacheDirectory', () => { - it('should match snapshot', () => { - expect(CACHE_DIR).toMatchSnapshot(); - }); - - describe('when filename is present', () => { - let filename: string; - - beforeEach(() => { - filename = faker.system.fileName(); - }); - - it('should join path with cache directory', () => { - expect(`${CACHE_DIR}/${filename}`).toMatch(new RegExp(`/${filename}$`)); - }); - }); - }); - describe('memory caching', () => { describe('when write cache to memory', () => { let cacheKey: string; @@ -60,13 +41,13 @@ describe('CacheController', () => { cacheKey = faker.string.uuid(); cache = { data: faker.string.alphanumeric(10), - modifiedAt: faker.date.past().getTime(), + mtimeMs: faker.date.past().getTime(), }; - manager.writeToMemory(cacheKey, cache); + storage.writeToMemory(cacheKey, cache); }); it('should cache data store to memory', () => { - expect(manager.readFromMemory(cacheKey)).toMatchObject(cache); + expect(storage.readFromMemory(cacheKey)).toMatchObject(cache); }); describe('when write cache with exist key to memory', () => { @@ -74,12 +55,12 @@ describe('CacheController', () => { beforeEach(() => { otherData = faker.string.alphanumeric(10); - manager.writeToMemory(cacheKey, { ...cache, data: otherData }); + storage.writeToMemory(cacheKey, { ...cache, data: otherData }); }); it('should overwrite exist cache data', () => { - expect(manager.readFromMemory(cacheKey)).not.toMatchObject(cache); - expect(manager.readFromMemory(cacheKey)).toMatchObject({ + expect(storage.readFromMemory(cacheKey)).not.toMatchObject(cache); + expect(storage.readFromMemory(cacheKey)).toMatchObject({ ...cache, data: otherData, }); @@ -95,12 +76,12 @@ describe('CacheController', () => { beforeEach(async () => { hash = faker.string.alphanumeric(10); data = faker.string.alphanumeric(10); - await manager.writeToFileSystem(hash, data); + await storage.writeToFileSystem(hash, data); }); describe('when write data to file system', () => { it('should store data to file system', async () => { - expect(await manager.readFromFileSystem(hash)).toEqual(data); + expect(await storage.readFromFileSystem(hash)).toEqual(data); }); }); }); diff --git a/packages/core/lib/bundler/cache/index.ts b/packages/core/lib/bundler/cache/index.ts index 33c5958a..05575d3e 100644 --- a/packages/core/lib/bundler/cache/index.ts +++ b/packages/core/lib/bundler/cache/index.ts @@ -1 +1 @@ -export * from './CacheController'; +export { CacheStorageImpl as CacheStorage } from './CacheStorageImpl'; diff --git a/packages/core/lib/bundler/events/index.ts b/packages/core/lib/bundler/events/index.ts index cd3718b7..57b81931 100644 --- a/packages/core/lib/bundler/events/index.ts +++ b/packages/core/lib/bundler/events/index.ts @@ -1,9 +1,7 @@ import EventEmitter from 'node:events'; -import type { - BundlerAdditionalData, - BuildStatus, - ReportableEvent, -} from '../../types'; +import type { HMRTransformResult } from '@react-native-esbuild/hmr'; +import type { AdditionalData, BuildStatus } from '@react-native-esbuild/shared'; +import type { ClientLogEvent } from '@react-native-esbuild/internal'; export class BundlerEventEmitter extends EventEmitter { addListener: ( @@ -41,16 +39,17 @@ export type BundlerEventListener = ( export interface BundlerEventPayload { 'build-start': { id: number; - additionalData?: BundlerAdditionalData; + additionalData?: AdditionalData; }; 'build-end': { id: number; revisionId: string; - additionalData?: BundlerAdditionalData; + update: HMRTransformResult | null; + additionalData?: AdditionalData; }; 'build-status-change': BuildStatus & { id: number; - additionalData?: BundlerAdditionalData; + additionalData?: AdditionalData; }; - report: ReportableEvent; + report: ClientLogEvent; } diff --git a/packages/core/lib/bundler/helpers/async.ts b/packages/core/lib/bundler/helpers/async.ts deleted file mode 100644 index 0f8b7606..00000000 --- a/packages/core/lib/bundler/helpers/async.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { BundleResult, PromiseHandler } from '../../types'; - -export const createPromiseHandler = (): PromiseHandler => { - let resolver: PromiseHandler['resolver'] | undefined; - let rejecter: PromiseHandler['rejecter'] | undefined; - - const task = new Promise((resolve, _reject) => { - resolver = resolve; - rejecter = (reason: Error) => { - resolve({ result: null, error: reason }); - }; - }); - - return { task, resolver, rejecter }; -}; diff --git a/packages/core/lib/bundler/index.ts b/packages/core/lib/bundler/index.ts index a8145c19..a93a7e73 100644 --- a/packages/core/lib/bundler/index.ts +++ b/packages/core/lib/bundler/index.ts @@ -1,2 +1 @@ -export { ReactNativeEsbuildBundler } from './bundler'; -export type * from './events'; +export * from './ReactNativeEsbuildBundler'; diff --git a/packages/core/lib/bundler/modules/ModuleManagerImpl.ts b/packages/core/lib/bundler/modules/ModuleManagerImpl.ts new file mode 100644 index 00000000..cae876fb --- /dev/null +++ b/packages/core/lib/bundler/modules/ModuleManagerImpl.ts @@ -0,0 +1,26 @@ +import type { ModuleManager, ModuleId } from '@react-native-esbuild/shared'; + +type ModuleIds = Record; + +export class ModuleManagerImpl implements ModuleManager { + // Entry point module id is always 0. + private static ENTRY_POINT_MODULE_ID = 0; + private INTERNAL_id = ModuleManagerImpl.ENTRY_POINT_MODULE_ID + 1; + private INTERNAL_moduleIds: ModuleIds = {}; + + public getModuleId(modulePath: string, isEntryPoint?: boolean): ModuleId { + // Already generated. + if (typeof this.INTERNAL_moduleIds[modulePath] === 'number') { + return this.INTERNAL_moduleIds[modulePath]; + } + + // Generate new module id. + return (this.INTERNAL_moduleIds[modulePath] = isEntryPoint + ? ModuleManagerImpl.ENTRY_POINT_MODULE_ID + : this.INTERNAL_id++); + } + + public getModuleIds(): ModuleIds { + return this.INTERNAL_moduleIds; + } +} diff --git a/packages/core/lib/bundler/modules/dependencyGraph.ts b/packages/core/lib/bundler/modules/dependencyGraph.ts new file mode 100644 index 00000000..0ee5b67d --- /dev/null +++ b/packages/core/lib/bundler/modules/dependencyGraph.ts @@ -0,0 +1,9 @@ +import { DependencyGraph } from 'esbuild-dependency-graph'; +import type { Metafile } from 'esbuild'; + +export const generateDependencyGraph = ( + metafile: Metafile, + entryPoint: string, +): DependencyGraph => { + return new DependencyGraph(metafile, entryPoint); +}; diff --git a/packages/core/lib/bundler/modules/index.ts b/packages/core/lib/bundler/modules/index.ts new file mode 100644 index 00000000..f58bdcde --- /dev/null +++ b/packages/core/lib/bundler/modules/index.ts @@ -0,0 +1,2 @@ +export { ModuleManagerImpl as ModuleManager } from './ModuleManagerImpl'; +export * from './dependencyGraph'; diff --git a/packages/core/lib/bundler/plugins/index.ts b/packages/core/lib/bundler/plugins/index.ts index 393e30c9..a9d72701 100644 --- a/packages/core/lib/bundler/plugins/index.ts +++ b/packages/core/lib/bundler/plugins/index.ts @@ -1,2 +1 @@ -export { createMetafilePlugin } from './metafilePlugin'; -export { createBuildStatusPlugin } from './statusPlugin'; +export * as presets from './presets'; diff --git a/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts b/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts deleted file mode 100644 index 70bb8c1b..00000000 --- a/packages/core/lib/bundler/plugins/metafilePlugin/metafilePlugin.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { BuildResult } from 'esbuild'; -import { logger } from '../../../shared'; -import type { ReactNativeEsbuildPluginCreator } from '../../../types'; - -const NAME = 'metafile-plugin'; - -export const createMetafilePlugin: ReactNativeEsbuildPluginCreator = ( - context, -) => ({ - name: NAME, - setup: (build): void => { - build.onEnd(async (result: BuildResult) => { - const { metafile } = result; - const filename = path.join( - context.root, - `metafile-${context.platform}-${new Date().getTime().toString()}.json`, - ); - - if (metafile) { - logger.debug('writing esbuild metafile', { destination: filename }); - await fs.writeFile(filename, JSON.stringify(metafile), { - encoding: 'utf-8', - }); - } - }); - }, -}); diff --git a/packages/cli/lib/presets/index.ts b/packages/core/lib/bundler/plugins/presets.ts similarity index 61% rename from packages/cli/lib/presets/index.ts rename to packages/core/lib/bundler/plugins/presets.ts index 86589491..36b2e230 100644 --- a/packages/cli/lib/presets/index.ts +++ b/packages/core/lib/bundler/plugins/presets.ts @@ -1,21 +1,19 @@ -import type { ReactNativeEsbuildPluginCreator } from '@react-native-esbuild/core'; import { createAssetRegisterPlugin, createReactNativeRuntimeTransformPlugin, createReactNativeWebPlugin, createSvgTransformPlugin, } from '@react-native-esbuild/plugins'; +import type { PluginFactory } from '@react-native-esbuild/shared'; -const native: ReactNativeEsbuildPluginCreator[] = [ +export const native: PluginFactory[] = [ createAssetRegisterPlugin, createSvgTransformPlugin, createReactNativeRuntimeTransformPlugin, ]; -const web: ReactNativeEsbuildPluginCreator[] = [ +export const web: PluginFactory[] = [ createReactNativeWebPlugin, createSvgTransformPlugin, createReactNativeRuntimeTransformPlugin, ]; - -export const presets = { native, web }; diff --git a/packages/core/lib/bundler/plugins/statusPlugin/index.ts b/packages/core/lib/bundler/plugins/statusPlugin/index.ts deleted file mode 100644 index f8c99b19..00000000 --- a/packages/core/lib/bundler/plugins/statusPlugin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createBuildStatusPlugin } from './statusPlugin'; diff --git a/packages/core/lib/bundler/storages/CacheStorage.ts b/packages/core/lib/bundler/storages/CacheStorage.ts deleted file mode 100644 index c62a94c4..00000000 --- a/packages/core/lib/bundler/storages/CacheStorage.ts +++ /dev/null @@ -1,42 +0,0 @@ -import os from 'node:os'; -import fs from 'node:fs'; -import path from 'node:path'; -import { GLOBAL_CACHE_DIR } from '@react-native-esbuild/config'; -import { logger } from '../../shared'; -import { CacheController } from '../cache'; -import { Storage } from './Storage'; - -const CACHE_DIRECTORY = path.join(os.tmpdir(), GLOBAL_CACHE_DIR); - -export class CacheStorage extends Storage { - constructor() { - super(); - try { - fs.accessSync(CACHE_DIRECTORY, fs.constants.R_OK | fs.constants.W_OK); - } catch (_error) { - logger.debug('cache directory is not exist or no access permission'); - fs.mkdirSync(CACHE_DIRECTORY, { recursive: true }); - } - } - - public get(key: number): CacheController { - if (this.data.has(key)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Already `has()` checked. - return this.data.get(key)!; - } - - const controller = new CacheController(CACHE_DIRECTORY); - this.data.set(key, controller); - - return controller; - } - - public clearAll(): Promise { - for (const controller of this.data.values()) { - controller.reset(); - } - return fs.promises.rm(CACHE_DIRECTORY, { - recursive: true, - }); - } -} diff --git a/packages/core/lib/bundler/storages/SharedStorage.ts b/packages/core/lib/bundler/storages/SharedStorage.ts deleted file mode 100644 index 05c2389e..00000000 --- a/packages/core/lib/bundler/storages/SharedStorage.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { BundlerSharedData } from '../../types'; -import { Storage } from './Storage'; - -export class SharedStorage extends Storage { - private getDefaultSharedData(): BundlerSharedData { - return { - watcher: { - changed: null, - stats: undefined, - }, - }; - } - - public get(key: number): BundlerSharedData { - if (this.data.has(key)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Already `has()` checked. - return this.data.get(key)!; - } - - const sharedData = this.getDefaultSharedData(); - this.data.set(key, sharedData); - - return sharedData; - } - - public setValue(value: Partial): void { - for (const sharedData of this.data.values()) { - sharedData.watcher.changed = - value.watcher?.changed ?? sharedData.watcher.changed; - sharedData.watcher.stats = - value.watcher?.stats ?? sharedData.watcher.stats; - } - } - - public clearAll(): Promise { - for (const sharedData of this.data.values()) { - sharedData.watcher.changed = null; - sharedData.watcher.stats = undefined; - } - return Promise.resolve(); - } -} diff --git a/packages/core/lib/bundler/storages/Storage.ts b/packages/core/lib/bundler/storages/Storage.ts deleted file mode 100644 index 83433322..00000000 --- a/packages/core/lib/bundler/storages/Storage.ts +++ /dev/null @@ -1,5 +0,0 @@ -export abstract class Storage { - protected data = new Map(); - public abstract get(key: number): StorageData; - public abstract clearAll(): Promise; -} diff --git a/packages/core/lib/bundler/storages/index.ts b/packages/core/lib/bundler/storages/index.ts deleted file mode 100644 index 5fcca858..00000000 --- a/packages/core/lib/bundler/storages/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './CacheStorage'; -export * from './SharedStorage'; diff --git a/packages/core/lib/watcher/FileSystemWatcher.ts b/packages/core/lib/bundler/watcher/FileSystemWatcher.ts similarity index 77% rename from packages/core/lib/watcher/FileSystemWatcher.ts rename to packages/core/lib/bundler/watcher/FileSystemWatcher.ts index cf167382..189e42a6 100644 --- a/packages/core/lib/watcher/FileSystemWatcher.ts +++ b/packages/core/lib/bundler/watcher/FileSystemWatcher.ts @@ -4,7 +4,8 @@ import { SOURCE_EXTENSIONS, ASSET_EXTENSIONS, } from '@react-native-esbuild/internal'; -import { logger } from '../shared'; +import { logger } from '../../shared'; +import type { FileSystemWatchEventListener } from '../../types'; const WATCH_EXTENSIONS_REGEXP = new RegExp( `(?:${[...SOURCE_EXTENSIONS, ...ASSET_EXTENSIONS].join('|')})$`, @@ -12,21 +13,10 @@ const WATCH_EXTENSIONS_REGEXP = new RegExp( export class FileSystemWatcher { public static DEBOUNCE_DELAY = 300; - private static instance: FileSystemWatcher | null = null; private watcher: chokidar.FSWatcher | null = null; private debounceTimer?: NodeJS.Timeout; - private onWatch?: (event: string, path: string, stats?: Stats) => void; - private constructor() { - /* Empty constructor */ - } - - public static getInstance(): FileSystemWatcher { - if (FileSystemWatcher.instance === null) { - FileSystemWatcher.instance = new FileSystemWatcher(); - } - return FileSystemWatcher.instance; - } + constructor(private listener: FileSystemWatchEventListener) {} private handleWatch(event: string, path: string, stats?: Stats): void { if ( @@ -38,18 +28,11 @@ export class FileSystemWatcher { this.debounceTimer = setTimeout(() => { this.debounceTimer = undefined; - this.onWatch?.(event, path, stats); + this.listener.onFileSystemChange(event, path, stats); logger.debug('changes detected by watcher', { event, path }); }, FileSystemWatcher.DEBOUNCE_DELAY); } - setHandler( - handler: (event: string, path: string, stats?: Stats) => void, - ): this { - this.onWatch = handler; - return this; - } - watch(targetPath: string): Promise { if (this.watcher) { logger.debug('already watching'); diff --git a/packages/core/lib/watcher/index.ts b/packages/core/lib/bundler/watcher/index.ts similarity index 100% rename from packages/core/lib/watcher/index.ts rename to packages/core/lib/bundler/watcher/index.ts diff --git a/packages/core/lib/bundler/helpers/__tests__/config.test.ts b/packages/core/lib/helpers/__tests__/config.test.ts similarity index 95% rename from packages/core/lib/bundler/helpers/__tests__/config.test.ts rename to packages/core/lib/helpers/__tests__/config.test.ts index 22d29158..fc793073 100644 --- a/packages/core/lib/bundler/helpers/__tests__/config.test.ts +++ b/packages/core/lib/helpers/__tests__/config.test.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; +import type { Config } from '@react-native-esbuild/shared'; import { loadConfig, getConfigFromGlobal } from '../config'; -import type { Config } from '../../../types'; describe('config helpers', () => { describe('loadConfig', () => { diff --git a/packages/core/lib/helpers/buildTask.ts b/packages/core/lib/helpers/buildTask.ts new file mode 100644 index 00000000..2482211c --- /dev/null +++ b/packages/core/lib/helpers/buildTask.ts @@ -0,0 +1,19 @@ +import invariant from 'invariant'; +import type { BundleResult, BuildTaskDelegate } from '../types'; + +export const createBuildTaskDelegate = (): BuildTaskDelegate => { + let resolver: BuildTaskDelegate['success'] | undefined; + let rejecter: BuildTaskDelegate['failure'] | undefined; + + const task = new Promise((resolve, _reject) => { + resolver = resolve; + rejecter = (reason: Error) => { + resolve({ result: null, error: reason }); + }; + }); + + invariant(resolver, 'resolver is undefined'); + invariant(rejecter, 'rejecter is undefined'); + + return { promise: task, success: resolver, failure: rejecter }; +}; diff --git a/packages/core/lib/bundler/helpers/config.ts b/packages/core/lib/helpers/config.ts similarity index 76% rename from packages/core/lib/bundler/helpers/config.ts rename to packages/core/lib/helpers/config.ts index 8c83fd43..cfc129ee 100644 --- a/packages/core/lib/bundler/helpers/config.ts +++ b/packages/core/lib/helpers/config.ts @@ -1,17 +1,18 @@ import path from 'node:path'; import type { BuildOptions } from 'esbuild'; import deepmerge from 'deepmerge'; +import { + getDevServerPublicPath, + BuildMode, + type BundleOptions, + type Config, +} from '@react-native-esbuild/shared'; import { RESOLVER_MAIN_FIELDS, SOURCE_EXTENSIONS, ASSET_EXTENSIONS, } from '@react-native-esbuild/internal'; -import { - getDevServerPublicPath, - type BundleOptions, -} from '@react-native-esbuild/config'; -import { logger } from '../../shared'; -import type { BundleMode, Config } from '../../types'; +import { logger } from '../shared'; export const loadConfig = (configFilePath?: string): Config => { let config: Config | undefined; @@ -29,13 +30,6 @@ export const loadConfig = (configFilePath?: string): Config => { assetExtensions: ASSET_EXTENSIONS, }, transformer: { - jsc: { - transform: { - react: { - runtime: 'automatic', - }, - }, - }, stripFlowPackageNames: ['react-native'], }, web: { @@ -44,12 +38,11 @@ export const loadConfig = (configFilePath?: string): Config => { }; try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires -- Config file may not exist. + // eslint-disable-next-line @typescript-eslint/no-var-requires -- Config file may not exist. config = require( configFilePath ? path.resolve(configFilePath) : path.resolve(process.cwd(), 'react-native-esbuild.config.js'), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Config will be default exported. ).default; } catch (error) { /** @@ -85,7 +78,7 @@ export const getConfigFromGlobal = (): Config => { }; export const getEsbuildWebConfig = ( - mode: BundleMode, + mode: BuildMode, root: string, bundleOptions: BundleOptions, ): BuildOptions => { @@ -97,7 +90,8 @@ export const getEsbuildWebConfig = ( * - bundle mode: `outfile` * - watch mode:`outdir` */ - outdir: mode === 'bundle' ? undefined : getDevServerPublicPath(root), - outfile: mode === 'bundle' ? bundleOptions.outfile : undefined, + outdir: + mode === BuildMode.Bundle ? undefined : getDevServerPublicPath(root), + outfile: mode === BuildMode.Bundle ? bundleOptions.outfile : undefined, }; }; diff --git a/packages/core/lib/helpers/fs.ts b/packages/core/lib/helpers/fs.ts new file mode 100644 index 00000000..2223cce5 --- /dev/null +++ b/packages/core/lib/helpers/fs.ts @@ -0,0 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const getExternalFromPackageJson = (root: string): string[] => { + const { dependencies = {} } = JSON.parse( + fs.readFileSync(path.join(root, 'package.json'), 'utf-8'), + ); + return [ + 'react/jsx-runtime', + '@react-navigation/devtools', + ...Object.keys(dependencies as Record), + ]; +}; diff --git a/packages/core/lib/bundler/helpers/index.ts b/packages/core/lib/helpers/index.ts similarity index 51% rename from packages/core/lib/bundler/helpers/index.ts rename to packages/core/lib/helpers/index.ts index dd49165c..763ef7dc 100644 --- a/packages/core/lib/bundler/helpers/index.ts +++ b/packages/core/lib/helpers/index.ts @@ -1,3 +1,4 @@ -export * from './async'; +export * from './buildTask'; export * from './config'; +export * from './fs'; export * from './internal'; diff --git a/packages/core/lib/bundler/helpers/internal.ts b/packages/core/lib/helpers/internal.ts similarity index 53% rename from packages/core/lib/bundler/helpers/internal.ts rename to packages/core/lib/helpers/internal.ts index 5955e7f3..8c67502b 100644 --- a/packages/core/lib/bundler/helpers/internal.ts +++ b/packages/core/lib/helpers/internal.ts @@ -1,38 +1,53 @@ +import fs from 'node:fs/promises'; import type { BuildOptions } from 'esbuild'; -import { getPreludeScript } from '@react-native-esbuild/internal'; -import type { TransformerContext } from '@react-native-esbuild/transformer'; +import type { TransformContext } from '@react-native-esbuild/transformer'; import { stripFlowWithSucrase, - minifyWithSwc, + transformWithSwc, swcPresets, } from '@react-native-esbuild/transformer'; -import type { BundleOptions } from '@react-native-esbuild/config'; +import type { BundleOptions } from '@react-native-esbuild/shared'; +import { getPreludeScript } from '@react-native-esbuild/internal'; + +const loadScript = (path: string): Promise => + fs.readFile(require.resolve(path), 'utf-8'); export const getTransformedPreludeScript = async ( bundleOptions: BundleOptions, root: string, + additionalScriptPaths?: string[], ): Promise => { // Dummy context - const context: TransformerContext = { + const context: TransformContext = { root, - path: '', + path: 'react-native:prelude', id: 0, dev: bundleOptions.dev, entry: bundleOptions.entry, + pluginData: {}, }; - const preludeScript = await getPreludeScript(bundleOptions, root); + + const additionalPreludeScripts = await Promise.all( + (additionalScriptPaths ?? []).map(loadScript), + ); + + const preludeScript = [ + await getPreludeScript(bundleOptions.dev, root), + ...additionalPreludeScripts, + ].join('\n'); /** * Remove `"use strict";` added by sucrase. * @see {@link https://github.com/alangpierce/sucrase/issues/787#issuecomment-1483934492} */ - const strippedScript = stripFlowWithSucrase(preludeScript, context) + const strippedScript = stripFlowWithSucrase(preludeScript, { context }) .replace(/"use strict";/, '') .trim(); - return bundleOptions.minify - ? minifyWithSwc(strippedScript, context, swcPresets.getMinifyPreset()) - : strippedScript; + return transformWithSwc(strippedScript, { + context, + preset: swcPresets.getMinifyPreset({ minify: bundleOptions.minify }), + }); }; export const getResolveExtensionsOption = ( @@ -70,3 +85,24 @@ export const getLoaderOption = ( assetExtensions.map((ext) => [ext, 'file'] as const), ); }; + +export const getExternalModulePattern = ( + externalPackages: string[], + assetExtensions: string[], +): string => { + const externalPackagePatterns = externalPackages + .map((packageName) => `^${packageName}/?$`) + .join('|'); + + const assetPatterns = [ + ...assetExtensions, + // `.svg` assets will be handled by `svg-transform-plugin`. + '.svg', + // `.json` contents will be handled by `react-native-runtime-transform-plugin`. + '.json', + ] + .map((extension) => `${extension}$`) + .join('|'); + + return `(${externalPackagePatterns}|${assetPatterns})`; +}; diff --git a/packages/core/lib/index.ts b/packages/core/lib/index.ts index e932ee9b..1699602c 100644 --- a/packages/core/lib/index.ts +++ b/packages/core/lib/index.ts @@ -4,6 +4,6 @@ import pkg from '../package.json'; self._version = pkg.version; export { ReactNativeEsbuildBundler } from './bundler'; +export type { Config } from '@react-native-esbuild/shared'; +export type * from './bundler/events'; export type * from './types'; -export type * from './bundler'; -export type { CacheController } from './bundler/cache'; diff --git a/packages/core/lib/shared.ts b/packages/core/lib/shared.ts index 5690d4fd..d29f0798 100644 --- a/packages/core/lib/shared.ts +++ b/packages/core/lib/shared.ts @@ -1,3 +1,3 @@ -import { Logger } from '@react-native-esbuild/utils'; +import { Logger } from '@react-native-esbuild/shared'; export const logger = new Logger('core'); diff --git a/packages/core/lib/types.ts b/packages/core/lib/types.ts index 139eb200..72f2ce62 100644 --- a/packages/core/lib/types.ts +++ b/packages/core/lib/types.ts @@ -1,147 +1,22 @@ import type { Stats } from 'node:fs'; -import type { BuildContext, Plugin } from 'esbuild'; -import type { - BabelTransformRule, - SwcTransformRule, -} from '@react-native-esbuild/transformer'; -import type { BundleOptions } from '@react-native-esbuild/config'; -import type { JscConfig } from '@swc/core'; - -export interface Config { - /** - * Enable cache. - * - * Defaults to `true` - */ - cache?: boolean; - /** - * Logger configurations - */ - logger?: { - /** - * Disable log. - * - * Defaults to `false` - */ - disabled?: boolean; - /** - * Print timestamp with log when format is specified. - * - * Defaults to `null` - */ - timestamp?: string | null; - }; - /** - * Resolver configurations - */ - resolver?: { - /** - * Field names for resolve package's modules. - * - * Defaults to `['react-native', 'browser', 'main', 'module']` - */ - mainFields?: string[]; - /** - * File extensions for transform. - * - * Defaults: https://github.com/leegeunhyeok/react-native-esbuild/blob/master/packages/internal/lib/defaults.ts - */ - sourceExtensions?: string[]; - /** - * File extensions for assets registration. - * - * Defaults: https://github.com/leegeunhyeok/react-native-esbuild/blob/master/packages/internal/lib/defaults.ts - */ - assetExtensions?: string[]; - }; - /** - * Transformer configurations - */ - transformer?: { - /** - * Swc's `jsc` config. - */ - jsc: Pick; - /** - * Strip flow syntax. - * - * Defaults to `['react-native']` - */ - stripFlowPackageNames?: string[]; - /** - * Transform with babel using `metro-react-native-babel-preset` (slow) - */ - fullyTransformPackageNames?: string[]; - /** - * Additional transform rules. This rules will be applied before phase of transform to es5. - */ - additionalTransformRules?: { - /** - * Additional babel transform rules - */ - babel?: BabelTransformRule[]; - /** - * Additional swc transform rules - */ - swc?: SwcTransformRule[]; - }; - }; - /** - * Web configurations - */ - web?: { - /** - * Index page template file path - */ - template?: string; - /** - * Placeholders for replacement - * - * ```js - * // web.placeholders - * { placeholder_name: 'Hello, world!' }; - * ``` - * - * will be replaced to - * - * ```html - * - * {{placeholder_name}} - * - * - * Hello, world! - * ``` - * - * --- - * - * Reserved placeholder name - * - * - `root`: root tag id - * - `script`: bundled script path - */ - placeholders?: Record; - }; - /** - * Additional Esbuild plugins. - */ - plugins?: Plugin[]; - /** - * Client event receiver (only work on native) - */ - reporter?: (event: ReportableEvent) => void; -} +import type { BuildContext as EsbuildBuildContext } from 'esbuild'; +import type { BundleOptions } from '@react-native-esbuild/shared'; export interface BundlerInitializeOptions { watcherEnabled?: boolean; } -export type BundleMode = 'bundle' | 'watch'; - export interface BuildTask { - context: BuildContext; - handler: PromiseHandler | null; - status: 'pending' | 'resolved'; + esbuild: EsbuildBuildContext; + delegate: BuildTaskDelegate; buildCount: number; + status: 'pending' | 'resolved'; +} + +export interface BuildTaskDelegate { + promise: Promise; + success: (val: BundleResult) => void; + failure: (reason?: unknown) => void; } export type BundleResult = BundleSuccessResult | BundleFailureResult; @@ -161,28 +36,6 @@ export interface BundleFailureResult { error: Error; } -export type ReactNativeEsbuildPluginCreator = ( - context: PluginContext, - config?: PluginConfig, -) => Plugin; - -export interface BundlerSharedData { - watcher: { - changed: string | null; - stats?: Stats; - }; -} - -export type BundlerAdditionalData = Record; - -export interface PluginContext extends BundleOptions { - id: number; - root: string; - config: Config; - mode: BundleMode; - additionalData?: BundlerAdditionalData; -} - export interface BundleRequestOptions { entry?: BundleOptions['entry']; dev?: BundleOptions['dev']; @@ -191,49 +44,11 @@ export interface BundleRequestOptions { runModule: boolean; } -export interface PromiseHandler { - task: Promise; - resolver?: (val: Result) => void; - rejecter?: (reason?: unknown) => void; -} - -export interface Cache { - data: string; - modifiedAt: number; -} - -export type ReportableEvent = ClientLogEvent; - -/** - * Event reportable event types - * - * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/lib/reporting.js#L36} - */ -export interface ClientLogEvent { - type: 'client_log'; - level: - | 'trace' - | 'info' - | 'warn' - /** - * In react-native, ReportableEvent['level'] does not defined `error` type. - * But, flipper supports the `error` type. - * - * @see {@link https://github.com/facebook/flipper/blob/v0.211.0/desktop/flipper-common/src/server-types.tsx#L76} - */ - | 'error' - | 'log' - | 'group' - | 'groupCollapsed' - | 'groupEnd' - | 'debug'; - data: unknown[]; - mode: 'BRIDGE' | 'NOBRIDGE'; -} - -// StatusLogger -export interface BuildStatus { - total: number; - resolved: number; - loaded: number; +// Watcher +export interface FileSystemWatchEventListener { + onFileSystemChange: ( + event: string, + path: string, + stats: Stats | undefined, + ) => void; } diff --git a/packages/core/package.json b/packages/core/package.json index 7005cc38..3768e292 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,13 +40,15 @@ "node": ">=16" }, "dependencies": { - "@react-native-esbuild/config": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", + "@react-native-esbuild/plugins": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", - "@react-native-esbuild/utils": "workspace:*", "chokidar": "^3.5.3", "deepmerge": "^4.3.1", "esbuild": "^0.19.5", + "esbuild-dependency-graph": "^0.3.0", "invariant": "^2.2.4", "ora": "^5.4.1" }, diff --git a/packages/dev-server/lib/helpers/request.ts b/packages/dev-server/lib/helpers/request.ts index fe749302..44cd90c5 100644 --- a/packages/dev-server/lib/helpers/request.ts +++ b/packages/dev-server/lib/helpers/request.ts @@ -1,6 +1,6 @@ import { parse } from 'node:url'; import { z } from 'zod'; -import type { BundleOptions } from '@react-native-esbuild/config'; +import type { BundleOptions } from '@react-native-esbuild/shared'; import { BundleRequestType } from '../types'; export type ParsedBundleOptions = z.infer; diff --git a/packages/dev-server/lib/middlewares/__tests__/serveAsset.test.ts b/packages/dev-server/lib/middlewares/__tests__/serveAsset.test.ts index 0f43de5d..f0a2fc77 100644 --- a/packages/dev-server/lib/middlewares/__tests__/serveAsset.test.ts +++ b/packages/dev-server/lib/middlewares/__tests__/serveAsset.test.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import path from 'node:path'; import fs from 'node:fs/promises'; import type { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; -import { ASSET_PATH } from '@react-native-esbuild/config'; +import { ASSET_PATH } from '@react-native-esbuild/shared'; import { faker } from '@faker-js/faker'; import { createServeAssetMiddleware } from '../serveAsset'; import type { DevServerMiddleware } from '../../types'; diff --git a/packages/dev-server/lib/middlewares/hmr.ts b/packages/dev-server/lib/middlewares/hmr.ts new file mode 100644 index 00000000..8dd2a41c --- /dev/null +++ b/packages/dev-server/lib/middlewares/hmr.ts @@ -0,0 +1,111 @@ +import { + HmrAppServer, + HmrWebServer, + type HMRClientMessage, +} from '@react-native-esbuild/hmr'; +import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; +import type { HMRMiddleware } from '../types'; +import { logger } from '../shared'; + +export const createHMRMiddlewareForApp = ({ + onMessage, +}: { + onMessage?: (event: HMRClientMessage) => void; +}): HMRMiddleware => { + const server = new HmrAppServer(); + + server.setMessageHandler((event) => onMessage?.(event)); + + const updateStart = (): void => { + logger.debug('send update-start message'); + server.send('update-start', { isInitialUpdate: false }); + }; + + const updateDone = (): void => { + logger.debug('send update-done message'); + server.send('update-done', undefined); + }; + + const hotReload = (revisionId: string, code: string): void => { + logger.debug('send update message (hmr)'); + server.send('update', { + added: [ + { + module: [-1, code], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + const liveReload = (revisionId: string): void => { + logger.debug('send update message (live reload)'); + server.send('update', { + added: [ + { + module: [-1, getReloadByDevSettingsProxy()], + sourceMappingURL: null, + sourceURL: null, + }, + ], + deleted: [], + modified: [], + isInitialUpdate: false, + revisionId, + }); + }; + + return { server, updateStart, updateDone, hotReload, liveReload }; +}; + +export const createHMRMiddlewareForWeb = (): HMRMiddleware => { + const server = new HmrWebServer(); + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- noop + const noop = (): void => {}; + + return { + server, + updateStart: noop, + updateDone: noop, + hotReload: (_revisionId: string, _code: string): void => { + logger.debug('send update message (hmr)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, code], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + liveReload: (_revisionId: string): void => { + logger.debug('send update message (live reload)'); + // TODO + // server.send('update', { + // added: [ + // { + // module: [-1, 'window.location.reload();'], + // sourceMappingURL: null, + // sourceURL: null, + // }, + // ], + // deleted: [], + // modified: [], + // isInitialUpdate: false, + // revisionId, + // }); + }, + }; +}; diff --git a/packages/dev-server/lib/middlewares/hotReload.ts b/packages/dev-server/lib/middlewares/hotReload.ts deleted file mode 100644 index 3ce6a213..00000000 --- a/packages/dev-server/lib/middlewares/hotReload.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Server, type WebSocket, type MessageEvent, type Data } from 'ws'; -import type { ClientLogEvent } from '@react-native-esbuild/core'; -import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; -import { logger } from '../shared'; -import type { - HotReloadMiddleware, - HmrClientMessage, - HmrUpdateDoneMessage, - HmrUpdateMessage, - HmrUpdateStartMessage, -} from '../types'; - -const getMessage = (data: Data): HmrClientMessage | null => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Web socket data. - const parsedData = JSON.parse(String(data)); - return 'type' in parsedData ? (parsedData as HmrClientMessage) : null; - } catch (error) { - return null; - } -}; - -export const createHotReloadMiddleware = ({ - onLog, -}: { - onLog?: (event: ClientLogEvent) => void; -}): HotReloadMiddleware => { - const server = new Server({ noServer: true }); - let connectedSocket: WebSocket | null = null; - - const handleClose = (): void => { - connectedSocket = null; - logger.debug('HMR web socket was closed'); - }; - - const handleMessage = (event: MessageEvent): void => { - const message = getMessage(event.data); - if (!message) return; - - /** - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HmrServer.js#L200-L239} - */ - switch (message.type) { - case 'log': { - onLog?.({ - type: 'client_log', - level: message.level, - data: message.data, - mode: 'BRIDGE', - }); - break; - } - - // Not supported - case 'register-entrypoints': - case 'log-opt-in': - break; - } - }; - - const handleError = (error?: Error): void => { - if (error) { - logger.error('unable to send HMR update message', error); - } - }; - - /** - * Send reload code to client. - * - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/HMRClient.js#L91-L99} - */ - const hotReload = (revisionId: string): void => { - const hmrUpdateMessage: HmrUpdateMessage = { - type: 'update', - body: { - added: [ - { - module: [-1, getReloadByDevSettingsProxy()], - sourceMappingURL: null, - sourceURL: null, - }, - ], - deleted: [], - modified: [], - isInitialUpdate: false, - revisionId, - }, - }; - - logger.debug('sending update message with reload code'); - connectedSocket?.send(JSON.stringify(hmrUpdateMessage), handleError); - }; - - const updateStart = (): void => { - logger.debug('sending update-start'); - const hmrUpdateStartMessage: HmrUpdateStartMessage = { - type: 'update-start', - body: { - isInitialUpdate: false, - }, - }; - connectedSocket?.send(JSON.stringify(hmrUpdateStartMessage), handleError); - }; - - const updateDone = (): void => { - logger.debug('sending update-done'); - const hmrUpdateDoneMessage: HmrUpdateDoneMessage = { type: 'update-done' }; - connectedSocket?.send(JSON.stringify(hmrUpdateDoneMessage), handleError); - }; - - server.on('connection', (socket) => { - connectedSocket = socket; - connectedSocket.onerror = handleClose; - connectedSocket.onclose = handleClose; - connectedSocket.onmessage = handleMessage; - logger.debug('HMR web socket was connected'); - }); - - server.on('error', (error) => { - logger.error('HMR web socket server error', error); - }); - - return { server, hotReload, updateStart, updateDone }; -}; diff --git a/packages/dev-server/lib/middlewares/index.ts b/packages/dev-server/lib/middlewares/index.ts index 6e88227f..caf48ffb 100644 --- a/packages/dev-server/lib/middlewares/index.ts +++ b/packages/dev-server/lib/middlewares/index.ts @@ -1,4 +1,4 @@ -export * from './hotReload'; +export * from './hmr'; export * from './indexPage'; export * from './serveAsset'; export * from './serveBundle'; diff --git a/packages/dev-server/lib/middlewares/serveAsset.ts b/packages/dev-server/lib/middlewares/serveAsset.ts index cbb32143..0d1271f1 100644 --- a/packages/dev-server/lib/middlewares/serveAsset.ts +++ b/packages/dev-server/lib/middlewares/serveAsset.ts @@ -2,7 +2,7 @@ import fs, { type FileHandle } from 'node:fs/promises'; import path from 'node:path'; import url from 'node:url'; import mime from 'mime'; -import { ASSET_PATH } from '@react-native-esbuild/config'; +import { ASSET_PATH } from '@react-native-esbuild/shared'; import { logger } from '../shared'; import type { DevServerMiddlewareCreator } from '../types'; diff --git a/packages/dev-server/lib/middlewares/serveBundle.ts b/packages/dev-server/lib/middlewares/serveBundle.ts index 84c85939..99aa8ce4 100644 --- a/packages/dev-server/lib/middlewares/serveBundle.ts +++ b/packages/dev-server/lib/middlewares/serveBundle.ts @@ -3,7 +3,7 @@ import type { BundlerEventListener, ReactNativeEsbuildBundler, } from '@react-native-esbuild/core'; -import { getIdByOptions } from '@react-native-esbuild/config'; +import { getIdByOptions } from '@react-native-esbuild/shared'; import { BundleResponse, parseBundleOptionsFromRequestUrl, diff --git a/packages/dev-server/lib/middlewares/symbolicate.ts b/packages/dev-server/lib/middlewares/symbolicate.ts index be726501..f9601862 100644 --- a/packages/dev-server/lib/middlewares/symbolicate.ts +++ b/packages/dev-server/lib/middlewares/symbolicate.ts @@ -3,7 +3,7 @@ import { parseStackFromRawBody, symbolicateStackTrace, } from '@react-native-esbuild/symbolicate'; -import type { BundleOptions } from '@react-native-esbuild/config'; +import type { BundleOptions } from '@react-native-esbuild/shared'; import { parseBundleOptionsForWeb, parseBundleOptionsFromRequestUrl, diff --git a/packages/dev-server/lib/server/ReactNativeAppServer.ts b/packages/dev-server/lib/server/ReactNativeAppServer.ts index 1a18b19c..849ee6ae 100644 --- a/packages/dev-server/lib/server/ReactNativeAppServer.ts +++ b/packages/dev-server/lib/server/ReactNativeAppServer.ts @@ -7,7 +7,7 @@ import { InspectorProxy } from 'metro-inspector-proxy'; import { createDevServerMiddleware } from '@react-native-community/cli-server-api'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; import { - createHotReloadMiddleware, + createHMRMiddlewareForApp, createServeAssetMiddleware, createServeBundleMiddleware, createSymbolicateMiddleware, @@ -105,10 +105,26 @@ export class ReactNativeAppServer extends DevServer { throw new Error('server is not initialized'); } - const { server: hotReloadWss, ...hr } = createHotReloadMiddleware({ - onLog: (event) => { - this.eventsSocketEndpoint.reportEvent(event); - this.bundler?.emit('report', event); + const { server: hmrServer, ...hmr } = createHMRMiddlewareForApp({ + onMessage: (message) => { + switch (message.type) { + case 'log': { + const clientLogEvent = { + type: 'client_log', + level: message.level, + data: message.data, + mode: 'BRIDGE', + } as const; + this.bundler?.emit('report', clientLogEvent); + this.eventsSocketEndpoint.reportEvent(clientLogEvent); + break; + } + + // not supported + case 'register-entrypoints': + case 'log-opt-in': + break; + } }, }); @@ -123,20 +139,22 @@ export class ReactNativeAppServer extends DevServer { ); const webSocketServer: Record = { - '/hot': hotReloadWss, + '/hot': hmrServer.getWebSocketServer(), '/debugger-proxy': this.debuggerProxyEndpoint.server, '/message': this.messageSocketEndpoint.server, '/events': this.eventsSocketEndpoint.server, ...inspectorProxyWss, }; - this.bundler.on('build-start', hr.updateStart); - this.bundler.on('build-end', ({ revisionId, additionalData }) => { + this.bundler.on('build-start', hmr.updateStart); + this.bundler.on('build-end', ({ revisionId, update, additionalData }) => { // `additionalData` can be `{ disableRefresh: true }` by `serve-asset-middleware`. if (!additionalData?.disableRefresh) { - hr.hotReload(revisionId); + update === null || update.fullyReload + ? hmr.liveReload(revisionId) + : hmr.hotReload(revisionId, update.code); } - hr.updateDone(); + hmr.updateDone(); }); this.server.on('upgrade', (request, socket, head) => { diff --git a/packages/dev-server/lib/server/ReactNativeWebServer.ts b/packages/dev-server/lib/server/ReactNativeWebServer.ts index c9fd5f1e..b7201de3 100644 --- a/packages/dev-server/lib/server/ReactNativeWebServer.ts +++ b/packages/dev-server/lib/server/ReactNativeWebServer.ts @@ -3,14 +3,18 @@ import http, { type ServerResponse, type IncomingMessage, } from 'node:http'; +import { parse } from 'node:url'; import type { ServeResult } from 'esbuild'; import invariant from 'invariant'; import { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; import { combineWithDefaultBundleOptions, type BundleOptions, -} from '@react-native-esbuild/config'; -import { createSymbolicateMiddleware } from '../middlewares'; +} from '@react-native-esbuild/shared'; +import { + createHMRMiddlewareForWeb, + createSymbolicateMiddleware, +} from '../middlewares'; import { logger } from '../shared'; import type { DevServerOptions } from '../types'; import { DevServer } from './DevServer'; @@ -82,9 +86,7 @@ export class ReactNativeWebServer extends DevServer { }); } - async initialize( - onPostSetup?: (bundler: ReactNativeEsbuildBundler) => void | Promise, - ): Promise { + async initialize(): Promise { if (this.initialized) { logger.warn('dev server already initialized'); return this; @@ -96,6 +98,16 @@ export class ReactNativeWebServer extends DevServer { this.devServerOptions.root, ).initialize({ watcherEnabled: true })); + const { server: hmrServer, ...hmr } = createHMRMiddlewareForWeb(); + + this.bundler.on('build-end', ({ revisionId, update, additionalData }) => { + if (!additionalData?.disableRefresh) { + update?.fullyReload + ? hmr.liveReload(revisionId) + : hmr.hotReload(revisionId, update?.code ?? ''); + } + }); + const symbolicateMiddleware = createSymbolicateMiddleware( { bundler, devServerOptions: this.devServerOptions }, { webBundleOptions: this.bundleOptions }, @@ -114,7 +126,19 @@ export class ReactNativeWebServer extends DevServer { }); }); - await onPostSetup?.(bundler); + this.server.on('upgrade', (request, socket, head) => { + if (!request.url) return; + const { pathname } = parse(request.url); + + if (pathname === '/hot') { + const wss = hmrServer.getWebSocketServer(); + wss.handleUpgrade(request, socket, head, (client) => { + wss.emit('connection', client, request); + }); + } else { + socket.destroy(); + } + }); return this; } diff --git a/packages/dev-server/lib/shared.ts b/packages/dev-server/lib/shared.ts index a8830816..6d5573c6 100644 --- a/packages/dev-server/lib/shared.ts +++ b/packages/dev-server/lib/shared.ts @@ -1,3 +1,3 @@ -import { Logger } from '@react-native-esbuild/utils'; +import { Logger } from '@react-native-esbuild/shared'; export const logger = new Logger('dev-server'); diff --git a/packages/dev-server/lib/types.ts b/packages/dev-server/lib/types.ts index 36ac5c11..20c425d3 100644 --- a/packages/dev-server/lib/types.ts +++ b/packages/dev-server/lib/types.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Server as WebSocketServer } from 'ws'; import type { ReactNativeEsbuildBundler } from '@react-native-esbuild/core'; +import type { HMRServer } from '@react-native-esbuild/hmr'; export enum BundleRequestType { Unknown, @@ -30,22 +30,14 @@ export type DevServerMiddleware = ( next: (error?: unknown) => void, ) => void; -export interface HotReloadMiddleware { - server: WebSocketServer; - hotReload: (revisionId: string) => void; +export interface HMRMiddleware { + server: HMRServer; updateStart: () => void; updateDone: () => void; + hotReload: (revisionId: string, code: string) => void; + liveReload: (revisionId: string) => void; } -/** - * HMR web socket messages - * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L68} - */ -export type HmrClientMessage = - | RegisterEntryPointsMessage - | LogMessage - | LogOptInMessage; - export interface RegisterEntryPointsMessage { type: 'register-entrypoints'; entryPoints: string[]; diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index ed84c8a9..60788afc 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -47,11 +47,11 @@ }, "dependencies": { "@react-native-community/cli-server-api": "^11.3.6", - "@react-native-esbuild/config": "workspace:*", "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", "@react-native-esbuild/symbolicate": "workspace:*", - "@react-native-esbuild/utils": "workspace:*", "invariant": "^2.2.4", "metro-inspector-proxy": "^0.78.0", "mime": "^3.0.0", diff --git a/packages/hmr/README.md b/packages/hmr/README.md new file mode 100644 index 00000000..3c587cde --- /dev/null +++ b/packages/hmr/README.md @@ -0,0 +1,52 @@ +# `@react-native-esbuild/hmr` + +> `react-refresh` based HMR implementation for @react-native-esbuild + +## Usage + +1. Add import statement to top of entry file(`import 'hmr:runtime';`) +2. Wrap React component module with `wrapWithHmrBoundary` + +```js +import { + getHmrRuntimeInitializeScript, + wrapWithHmrBoundary, + HMR_RUNTIME_IMPORT_NAME +} from '@react-native-esbuild/hmr'; + +// In esbuild plugin +build.onResolve({ filter: new RegExp(HMR_RUNTIME_IMPORT_NAME) }, (args) => { + return { + path: args.path, + namespace: 'hmr-runtime', + }; +}); + +build.onLoad({ filter: /(?:.*)/, namespace: 'hmr-runtime' }, (args) => { + return { + js: await getHmrRuntimeInitializeScript(), + loader: 'js', + }; +}); + +build.onLoad({ filter: /* some filter */ }, (args) => { + if (isEntryFile) { + code = await fs.readFile(args.path, 'utf-8'); + code = `import ${HMR_RUNTIME_IMPORT_NAME};\n\n` + code; + // ... + + return { + contents: code, + loader: 'js', + }; + } else { + // ... + + return { + // code, component name, module id + contents: wrapWithHmrBoundary(code, '', args.path), + loader: 'js', + }; + } +}); +``` diff --git a/packages/hmr/build/index.js b/packages/hmr/build/index.js new file mode 100644 index 00000000..9ea82862 --- /dev/null +++ b/packages/hmr/build/index.js @@ -0,0 +1,25 @@ +const path = require('node:path'); +const esbuild = require('esbuild'); +const { getEsbuildBaseOptions, getPackageRoot } = require('../../../shared'); +const { name, version } = require('../package.json'); + +const buildOptions = getEsbuildBaseOptions(__dirname); +const root = getPackageRoot(__dirname); + +(async () => { + // package + await esbuild.build(buildOptions); + + // runtime + await esbuild.build({ + entryPoints: [path.join(root, './lib/runtime/setup.ts')], + outfile: 'dist/runtime.js', + bundle: true, + banner: { + js: `// ${name}@${version} runtime`, + }, + }); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/hmr/lib/constants.ts b/packages/hmr/lib/constants.ts new file mode 100644 index 00000000..f2806bec --- /dev/null +++ b/packages/hmr/lib/constants.ts @@ -0,0 +1,11 @@ +/** + * WARNING: Property and function identifiers must match the names defined in `types.ts`. + */ +export const HMR_REGISTER_FUNCTION = 'global.__hmr.register'; +export const HMR_UPDATE_FUNCTION = 'global.__hmr.update'; +export const REACT_REFRESH_REGISTER_FUNCTION = + 'global.__hmr.reactRefresh.register'; +export const REACT_REFRESH_GET_SIGNATURE_FUNCTION = + 'global.__hmr.reactRefresh.getSignatureFunction'; +export const PERFORM_REACT_REFRESH_SCRIPT = + 'global.__hmr.reactRefresh.performReactRefresh()'; diff --git a/packages/hmr/lib/helpers.ts b/packages/hmr/lib/helpers.ts new file mode 100644 index 00000000..ff14193d --- /dev/null +++ b/packages/hmr/lib/helpers.ts @@ -0,0 +1,129 @@ +import path from 'node:path'; +import { + colors, + type BuildContext, + type ModuleId, +} from '@react-native-esbuild/shared'; +import type { Metafile } from 'esbuild'; +import { getReloadByDevSettingsProxy } from '@react-native-esbuild/internal'; +import { HMR_REGISTER_FUNCTION, HMR_UPDATE_FUNCTION } from './constants'; +import { logger } from './shared'; + +let INTERNAL_boundaryIndex = 0; + +export const asHMRBoundary = (id: ModuleId, body: string): string => { + const ident = `__hmr${INTERNAL_boundaryIndex++}`; + return `var ${ident} = ${HMR_REGISTER_FUNCTION}(${JSON.stringify(id)}); + ${body} + ${ident}.dispose(function () { }); + ${ident}.accept(function (payload) { + global.__hmr.reactRefresh.performReactRefresh(); + });`; +}; + +/** + * Make code that wrap body with HMR API's update callback. + * + * **example** + * ```js + * global.__hmr.update(id, function () { + * {body} + * }); + * ``` + */ +export const asHMRUpdateCall = (id: ModuleId, body: string): string => { + return `${HMR_UPDATE_FUNCTION}(${JSON.stringify(id)}, function () { + ${body} + });`; +}; + +/** + * Make code that wrap with try-catch block with fallback handler + * + * **example** + * ```js + * try { + * {body} + * } catch (error) { + * console.error('[HMR] unable to accept', error); + * reload(); + * } + * ``` + */ +export const asFallbackBoundary = (body: string): string => { + return `try { + ${body} + } catch (error) { + console.error('[HMR] unable to accept', error); + ${getReloadByDevSettingsProxy()} + }`; +}; + +/** + * Make code that register as external module. + * + * **example** + * ```js + * {body} + * global.__modules.external(id, identName); + * ``` + */ +export const registerAsExternalModule = ( + id: ModuleId, + body: string, + identifierName: string, +): string => { + return `${body}\nglobal.__modules.external(${JSON.stringify( + id, + )}, ${identifierName});`; +}; + +export const isHMRBoundary = (path: string): boolean => { + return !path.includes('/node_modules/') && !path.endsWith('runtime.js'); +}; + +/** + * Get actual imported module path. + */ +export const getActualImportPaths = ( + buildContext: BuildContext, + imports: Metafile['inputs'][string]['imports'], +): Record => { + const importPaths = imports.reduce( + (prev, curr) => { + // To avoid wrong assets path. + // eg. `react-native-esbuild-assets:/path/to/assets` + const splitted = path.resolve(buildContext.root, curr.path).split(':'); + const actualPath = splitted[splitted.length - 1]; + if (curr.original && !prev[curr.original]) { + logger.debug( + `${colors.gray( + `├─ ${stripRoot(buildContext.root, curr.original)} ▸`, + )} ${ + isExternal( + buildContext.additionalData.externalPattern as string, + curr.original, + ) + ? colors.gray('') + : stripRoot(buildContext.root, actualPath) + }`, + ); + } + return { + ...prev, + ...(curr.original ? { [curr.original]: actualPath } : null), + }; + }, + {} as Record, + ); + + logger.debug(colors.gray(`╰─ ${Object.keys(importPaths).length} import(s)`)); + + return importPaths; +}; + +const stripRoot = (rootPath: string, path: string): string => + path.replace(new RegExp(`^${rootPath}/?`), ''); + +const isExternal = (pattern: string, path: string): boolean => + new RegExp(pattern).test(path); diff --git a/packages/hmr/lib/index.ts b/packages/hmr/lib/index.ts new file mode 100644 index 00000000..93da20b8 --- /dev/null +++ b/packages/hmr/lib/index.ts @@ -0,0 +1,5 @@ +export * from './server'; +export * from './constants'; +export * from './helpers'; +export type * from './types'; +export type { HMRServer } from './server/HMRServer'; diff --git a/packages/hmr/lib/runtime/setup.ts b/packages/hmr/lib/runtime/setup.ts new file mode 100644 index 00000000..995abc94 --- /dev/null +++ b/packages/hmr/lib/runtime/setup.ts @@ -0,0 +1,102 @@ +import type { ModuleId } from '@react-native-esbuild/shared'; +import * as RefreshRuntime from 'react-refresh/runtime'; + +type HotModuleReplacementAcceptCallback = (payload: { id: ModuleId }) => void; +type HotModuleReplacementDisposeCallback = () => void; + +if (__DEV__ && typeof global.__hmr === 'undefined') { + const HMR_DEBOUNCE_DELAY = 50; + let performReactRefreshTimeout: NodeJS.Timeout | null = null; + + class HotModuleReplacementContext { + public static registry: Record< + ModuleId, + HotModuleReplacementContext | undefined + > = {}; + public locked = false; + public acceptCallbacks: HotModuleReplacementAcceptCallback[] = []; + public disposeCallbacks: HotModuleReplacementDisposeCallback[] = []; + + constructor(public id: ModuleId) {} + + accept(acceptCallback: HotModuleReplacementAcceptCallback): void { + if (this.locked) return; + this.acceptCallbacks.push(acceptCallback); + } + + dispose(disposeCallback: HotModuleReplacementDisposeCallback): void { + if (this.locked) return; + this.disposeCallbacks.push(disposeCallback); + } + + lock(): void { + this.locked = true; + } + } + + const HotModuleReplacementRuntimeModule: HotModuleReplacementRuntimeModule = { + register: (id: ModuleId) => { + const context = HotModuleReplacementContext.registry[id]; + if (context) { + context.lock(); + return context; + } + return (HotModuleReplacementContext.registry[id] = + new HotModuleReplacementContext(id)); + }, + update: (id: ModuleId, evalUpdates: () => void) => { + const context = HotModuleReplacementContext.registry[id]; + if (context) { + context.disposeCallbacks.forEach((callback) => { + callback(); + }); + evalUpdates(); + context.acceptCallbacks.forEach((callback) => { + callback({ id }); + }); + } + }, + reactRefresh: { + register: RefreshRuntime.register, + getSignatureFunction: () => + RefreshRuntime.createSignatureFunctionForTransform, + performReactRefresh: () => { + if (performReactRefreshTimeout !== null) { + return; + } + + performReactRefreshTimeout = setTimeout(() => { + performReactRefreshTimeout = null; + }, HMR_DEBOUNCE_DELAY); + + if (RefreshRuntime.hasUnrecoverableErrors()) { + console.error('[HMR::react-refresh] has unrecoverable errors'); + return; + } + RefreshRuntime.performReactRefresh(); + }, + }, + }; + + RefreshRuntime.injectIntoGlobalHook(global); + + Object.defineProperty(global, '__hmr', { + enumerable: false, + value: HotModuleReplacementRuntimeModule, + }); + + // for web + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- allow + const isWeb = global?.navigator?.appName === 'Netscape'; + if (isWeb) { + const socketURL = new URL('hot', `ws://${window.location.host}`); + const socket = new window.WebSocket(socketURL.href); + socket.addEventListener('message', (event) => { + const payload = window.JSON.parse(event.data); + if (payload.type === 'update') { + const code = payload.body?.added[0]?.module?.[1]; + code && window.eval(code); + } + }); + } +} diff --git a/packages/hmr/lib/server/HmrAppServer.ts b/packages/hmr/lib/server/HmrAppServer.ts new file mode 100644 index 00000000..c5c7c19d --- /dev/null +++ b/packages/hmr/lib/server/HmrAppServer.ts @@ -0,0 +1,53 @@ +import type { Server, MessageEvent, Data } from 'ws'; +import type { HMRClientMessage, HMRMessage, HMRMessageType } from '../types'; +import { HMRServer } from './HMRServer'; + +export class HmrAppServer extends HMRServer { + private messageHandler?: (message: HMRClientMessage) => void; + + constructor() { + super(); + this.setup((socket) => { + socket.onmessage = this.handleMessage.bind(this); + }); + } + + private parseClientMessage(data: Data): HMRClientMessage | null { + try { + const parsedData = JSON.parse(String(data)); + return 'type' in parsedData ? (parsedData as HMRClientMessage) : null; + } catch (error) { + return null; + } + } + + private handleMessage(event: MessageEvent): void { + const message = this.parseClientMessage(event.data); + if (!message) return; + + /** + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro/src/HMRServer.js#L200-L239} + */ + this.messageHandler?.(message); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public setMessageHandler(handler: (message: HMRClientMessage) => void): void { + this.messageHandler = handler; + } + + public send( + type: MessageType, + body: HMRMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/HmrServer.ts b/packages/hmr/lib/server/HmrServer.ts new file mode 100644 index 00000000..7de92657 --- /dev/null +++ b/packages/hmr/lib/server/HmrServer.ts @@ -0,0 +1,40 @@ +import { Server, type WebSocket } from 'ws'; +import { logger } from '../shared'; +import type { HMRMessage, HMRMessageType } from '../types'; + +export abstract class HMRServer { + protected server: Server; + protected connectedSocket?: WebSocket; + + constructor() { + this.server = new Server({ noServer: true }); + } + + private handleClose(): void { + this.connectedSocket = undefined; + logger.debug('HMR web socket was closed'); + } + + public setup(onConnect?: (socket: WebSocket) => void): void { + this.server.on('connection', (socket) => { + this.connectedSocket = socket; + this.connectedSocket.onclose = this.handleClose.bind(this); + this.connectedSocket.onerror = this.handleClose.bind(this); + logger.debug('HMR web socket was connected'); + onConnect?.(socket); + }); + + this.server.on('error', (error) => { + logger.error('HMR web socket server error', error); + }); + } + + public getWebSocketServer(): Server { + return this.server; + } + + public abstract send( + type: MessageType, + body: HMRMessage[MessageType], + ): void; +} diff --git a/packages/hmr/lib/server/HmrWebServer.ts b/packages/hmr/lib/server/HmrWebServer.ts new file mode 100644 index 00000000..f9ceb82e --- /dev/null +++ b/packages/hmr/lib/server/HmrWebServer.ts @@ -0,0 +1,21 @@ +import type { HMRMessage, HMRMessageType } from '../types'; +import { HMRServer } from './HMRServer'; + +export class HmrWebServer extends HMRServer { + constructor() { + super(); + this.setup(); + } + + public send( + type: MessageType, + body: HMRMessage[MessageType], + ): void { + this.connectedSocket?.send( + JSON.stringify({ + type, + body, + }), + ); + } +} diff --git a/packages/hmr/lib/server/index.ts b/packages/hmr/lib/server/index.ts new file mode 100644 index 00000000..b1bc45f4 --- /dev/null +++ b/packages/hmr/lib/server/index.ts @@ -0,0 +1,2 @@ +export * from './HMRAppServer'; +export * from './HMRWebServer'; diff --git a/packages/hmr/lib/shared.ts b/packages/hmr/lib/shared.ts new file mode 100644 index 00000000..93cfbf63 --- /dev/null +++ b/packages/hmr/lib/shared.ts @@ -0,0 +1,3 @@ +import { Logger } from '@react-native-esbuild/shared'; + +export const logger = new Logger('hmr'); diff --git a/packages/hmr/lib/types.ts b/packages/hmr/lib/types.ts new file mode 100644 index 00000000..cf927f3a --- /dev/null +++ b/packages/hmr/lib/types.ts @@ -0,0 +1,96 @@ +import type { + register, + createSignatureFunctionForTransform, +} from 'react-refresh'; +import type { ModuleId } from '@react-native-esbuild/shared'; + +/* eslint-disable no-var -- allow */ +declare global { + interface HotModuleReplacementRuntimeModule { + register: (id: ModuleId) => void; + update: (id: ModuleId, evalUpdates: () => void) => void; + // react-refresh/runtime + reactRefresh: { + register: typeof register; + getSignatureFunction: () => typeof createSignatureFunctionForTransform; + performReactRefresh: () => void; + }; + } + + // react-native + var __DEV__: boolean; + + // react-refresh/runtime + var __hmr: HotModuleReplacementRuntimeModule | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow + var window: any; +} + +/** + * HMR web socket messages + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L68} + */ +export type HMRClientMessage = + | RegisterEntryPointsMessage + | LogMessage + | LogOptInMessage; + +export interface RegisterEntryPointsMessage { + type: 'register-entrypoints'; + entryPoints: string[]; +} + +export interface LogMessage { + type: 'log'; + level: + | 'trace' + | 'info' + | 'warn' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- follow metro types + data: any[]; + mode: 'BRIDGE' | 'NOBRIDGE'; +} + +export interface LogOptInMessage { + type: 'log-opt-in'; +} + +/** + * HMR update message + * @see {@link https://github.com/facebook/metro/blob/v0.77.0/packages/metro-runtime/src/modules/types.flow.js#L44-L56} + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- allow type +export type HMRMessage = { + update: HMRUpdate; + 'update-start': { + isInitialUpdate: boolean; + }; + 'update-done': undefined; +}; + +export type HMRMessageType = keyof HMRMessage; + +export interface HMRUpdate { + readonly added: HMRModule[]; + readonly deleted: ModuleId[]; + readonly modified: HMRModule[]; + isInitialUpdate: boolean; + revisionId: string; +} +export interface HMRModule { + module: [ModuleId, string]; + sourceMappingURL: string | null; + sourceURL: string | null; +} + +export interface HMRTransformResult { + id: ModuleId; + code: string; + fullyReload: boolean; +} diff --git a/packages/config/package.json b/packages/hmr/package.json similarity index 58% rename from packages/config/package.json rename to packages/hmr/package.json index b6912dfe..b6fb5518 100644 --- a/packages/config/package.json +++ b/packages/hmr/package.json @@ -1,10 +1,12 @@ { - "name": "@react-native-esbuild/config", - "version": "0.1.0-beta.12", - "description": "shared configs for @react-native-esbuild", + "name": "@react-native-esbuild/hmr", + "version": "0.1.0-beta.8", + "description": "HMR implementation for @react-native-esbuild", "keywords": [ "react-native", - "esbuild" + "esbuild", + "hmr", + "react-refresh" ], "author": "leegeunhyeok ", "homepage": "https://github.com/leegeunhyeok/react-native-esbuild#readme", @@ -13,6 +15,10 @@ "module": "lib/index.ts", "main": "dist/index.js", "types": "dist/lib/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./runtime": "./dist/runtime.js" + }, "directories": { "lib": "lib", "test": "__tests__" @@ -29,7 +35,7 @@ "url": "git+https://github.com/leegeunhyeok/react-native-esbuild.git" }, "scripts": { - "prepack": "yarn cleanup && yarn build", + "prepack": "yarn build", "cleanup": "rimraf ./dist", "build": "node build/index.js && tsc" }, @@ -37,6 +43,14 @@ "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" }, "devDependencies": { - "esbuild": "^0.19.5" + "@swc/helpers": "^0.5.2", + "@types/react-refresh": "^0.14.3", + "esbuild": "^0.19.3" + }, + "dependencies": { + "@react-native-esbuild/internal": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", + "react-refresh": "^0.14.0", + "ws": "^8.14.2" } } diff --git a/packages/utils/tsconfig.json b/packages/hmr/tsconfig.json similarity index 100% rename from packages/utils/tsconfig.json rename to packages/hmr/tsconfig.json diff --git a/packages/internal/README.md b/packages/internal/README.md index f56a534f..48e835b0 100644 --- a/packages/internal/README.md +++ b/packages/internal/README.md @@ -1,3 +1,3 @@ # `@react-native-esbuild/internal` -> Internal configs and helpers for react-native +> Internal(react-native) configs and helpers for @react-native-esbuild diff --git a/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap b/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap index ddf3b952..618b9261 100644 --- a/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap +++ b/packages/internal/lib/__tests__/__snapshots__/presets.test.ts.snap @@ -1,7 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`getAssetRegistrationScript should match snapshot 1`] = ` -" - module.exports = require('react-native/Libraries/Image/AssetRegistry').registerAsset({"__packager_asset":true,"name":"image","type":"png","scales":[1,2,3],"hash":"hash","httpServerLocation":"/image.png","width":0,"height":0}); - " -`; +exports[`getAssetRegistrationScript should match snapshot 1`] = `"module.exports =require('react-native/Libraries/Image/AssetRegistry').registerAsset({"__packager_asset":true,"name":"image","type":"png","scales":[1,2,3],"hash":"hash","httpServerLocation":"/image.png","width":0,"height":0});"`; diff --git a/packages/internal/lib/__tests__/presets.test.ts b/packages/internal/lib/__tests__/presets.test.ts index 1bd6162e..5dfbf3b1 100644 --- a/packages/internal/lib/__tests__/presets.test.ts +++ b/packages/internal/lib/__tests__/presets.test.ts @@ -1,4 +1,4 @@ -import { getAssetRegistrationScript } from '../presets'; +import { getAssetRegistrationScript } from '../helpers'; describe('getAssetRegistrationScript', () => { let assetRegistrationScript: string; diff --git a/packages/internal/lib/defaults.ts b/packages/internal/lib/constants.ts similarity index 100% rename from packages/internal/lib/defaults.ts rename to packages/internal/lib/constants.ts diff --git a/packages/internal/lib/helpers.ts b/packages/internal/lib/helpers.ts index 364dfba7..0776f61f 100644 --- a/packages/internal/lib/helpers.ts +++ b/packages/internal/lib/helpers.ts @@ -1,5 +1,123 @@ +/* eslint-disable quotes -- Allow using backtick */ +import fs from 'node:fs/promises'; import path from 'node:path'; import indent from 'indent-string'; +import type { Asset } from './types'; + +const REACT_NATIVE_GET_POLYFILLS_PATH = 'react-native/rn-get-polyfills'; +const REACT_NATIVE_INITIALIZE_CORE_PATH = + 'react-native/Libraries/Core/InitializeCore'; + +const getNodeEnv = (dev: boolean): string => + dev ? 'development' : 'production'; + +/** + * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/lib/getPreludeCode.js} + */ +export const getInjectVariables = (dev: boolean): string[] => [ + '__BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()', + `__METRO_GLOBAL_PREFIX__=''`, + `__DEV__=${JSON.stringify(dev)}`, + 'process=this.process||{}', + `global = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this`, +]; + +const getReactNativePolyfills = (root: string): string[] => { + // eslint-disable-next-line @typescript-eslint/no-var-requires -- Allow dynamic require. + const getPolyfills = require( + resolveFromRoot(REACT_NATIVE_GET_POLYFILLS_PATH, root), + ) as () => string[]; + + return getPolyfills(); +}; + +export const getReactNativeInitializeCore = (root: string): string => { + return require.resolve( + resolveFromRoot(REACT_NATIVE_INITIALIZE_CORE_PATH, root), + ); +}; + +export const getGlobalVariables = ( + dev: boolean, + platform: string, +): Record => ({ + __DEV__: JSON.stringify(dev), + global: platform === 'web' ? 'window' : 'global', + 'process.env.NODE_ENV': JSON.stringify(getNodeEnv(dev)), +}); + +export const getPreludeScript = async ( + dev: boolean, + root: string, +): Promise => { + const scripts = await Promise.all( + getReactNativePolyfills(root).map((scriptPath) => + fs + .readFile(scriptPath, { encoding: 'utf-8' }) + .then((code) => wrapWithIIFE(code, scriptPath)), + ), + ); + + const initialScripts = [ + `var ${getInjectVariables(dev).join(',')};`, + `process.env=process.env||{};`, + `process.env.NODE_ENV=${JSON.stringify(getNodeEnv(dev))};`, + ...scripts, + ].join('\n'); + + return initialScripts; +}; + +/** + * Get asset registration script. + * + * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/Bundler/util.js#L29-L57} + * @see {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/Image/RelativeImageStub.js} + */ +export const getAssetRegistrationScript = ({ + name, + type, + scales, + hash, + httpServerLocation, + dimensions, +}: Pick< + Asset, + 'name' | 'type' | 'scales' | 'hash' | 'httpServerLocation' | 'dimensions' +>): string => { + return `module.exports =require('react-native/Libraries/Image/AssetRegistry').registerAsset(${JSON.stringify( + { + __packager_asset: true, + name, + type, + scales, + hash, + httpServerLocation, + width: dimensions.width, + height: dimensions.height, + }, + )});`; +}; + +/** + * Get reload script. + * + * @see turboModuleProxy {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/TurboModule/TurboModuleRegistry.js#L17} + * @see nativeModuleProxy {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/BatchedBridge/NativeModules.js#L179} + * + * ```ts + * // It works the same as the code below. + * import { DevSettings } from 'react-native'; + * + * DevSettings.reload(); + * ``` + */ +export const getReloadByDevSettingsProxy = (): string => `(function () { + var moduleName = "DevSettings"; + (global.__turboModuleProxy + ? global.__turboModuleProxy(moduleName) + : global.nativeModuleProxy[moduleName]).reload(); +})();`; export const resolveFromRoot = ( targetPath: string, @@ -16,7 +134,7 @@ export const resolveFromRoot = ( }); }; -export const wrapWithIIFE = (body: string, filepath: string): string => ` +const wrapWithIIFE = (body: string, filepath: string): string => ` // ${filepath} (function (global) { ${indent(body, 2)} diff --git a/packages/internal/lib/index.ts b/packages/internal/lib/index.ts index 0d551471..79b36a72 100644 --- a/packages/internal/lib/index.ts +++ b/packages/internal/lib/index.ts @@ -1,3 +1,3 @@ -export * from './presets'; -export * from './defaults'; +export * from './constants'; +export * from './helpers'; export type * from './types'; diff --git a/packages/internal/lib/presets.ts b/packages/internal/lib/presets.ts deleted file mode 100644 index 5cdd1b05..00000000 --- a/packages/internal/lib/presets.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* eslint-disable quotes -- Allow using backtick */ -import fs from 'node:fs/promises'; -import type { BundleOptions } from '@react-native-esbuild/config'; -import { resolveFromRoot, wrapWithIIFE } from './helpers'; -import type { Asset } from './types'; - -const REACT_NATIVE_GET_POLYFILLS_PATH = 'react-native/rn-get-polyfills'; -const REACT_NATIVE_INITIALIZE_CORE_PATH = - 'react-native/Libraries/Core/InitializeCore'; - -const getNodeEnv = (dev: boolean): string => - dev ? 'development' : 'production'; - -/** - * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/lib/getPreludeCode.js} - */ -export const getInjectVariables = (dev: boolean): string[] => [ - '__BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now()', - `__METRO_GLOBAL_PREFIX__=''`, - `__DEV__=${JSON.stringify(dev)}`, - 'process=this.process||{}', - `global = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this`, -]; - -const getReactNativePolyfills = (root: string): Promise => { - // eslint-disable-next-line @typescript-eslint/no-var-requires -- Allow dynamic require. - const getPolyfills = require( - resolveFromRoot(REACT_NATIVE_GET_POLYFILLS_PATH, root), - ) as () => string[]; - - return Promise.all( - getPolyfills().map((scriptPath) => - fs - .readFile(scriptPath, { encoding: 'utf-8' }) - .then((code) => wrapWithIIFE(code, scriptPath)), - ), - ); -}; - -export const getReactNativeInitializeCore = (root: string): string => { - return require.resolve( - resolveFromRoot(REACT_NATIVE_INITIALIZE_CORE_PATH, root), - ); -}; - -export const getGlobalVariables = ({ - dev = true, - platform, -}: BundleOptions): Record => ({ - __DEV__: JSON.stringify(dev), - global: platform === 'web' ? 'window' : 'global', - 'process.env.NODE_ENV': JSON.stringify(getNodeEnv(dev)), -}); - -export const getPreludeScript = async ( - { dev = true }: BundleOptions, - root: string, -): Promise => { - const polyfills = await getReactNativePolyfills(root); - const initialScripts = [ - `var ${getInjectVariables(dev).join(',')};`, - `process.env=process.env||{};`, - `process.env.NODE_ENV=${JSON.stringify(getNodeEnv(dev))};`, - ...polyfills, - ].join('\n'); - - return initialScripts; -}; -/** - * Get asset registration script. - * - * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/Bundler/util.js#L29-L57} - * @see {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/Image/RelativeImageStub.js} - */ -export const getAssetRegistrationScript = ({ - name, - type, - scales, - hash, - httpServerLocation, - dimensions, -}: Pick< - Asset, - 'name' | 'type' | 'scales' | 'hash' | 'httpServerLocation' | 'dimensions' ->): string => { - return ` - module.exports = require('react-native/Libraries/Image/AssetRegistry').registerAsset(${JSON.stringify( - { - __packager_asset: true, - name, - type, - scales, - hash, - httpServerLocation, - width: dimensions.width, - height: dimensions.height, - }, - )}); - `; -}; - -/** - * Get reload script. - * - * @see turboModuleProxy {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/TurboModule/TurboModuleRegistry.js#L17} - * @see nativeModuleProxy {@link https://github.com/facebook/react-native/blob/v0.72.0/packages/react-native/Libraries/BatchedBridge/NativeModules.js#L179} - * - * ```ts - * // It works the same as the code below. - * import { DevSettings } from 'react-native'; - * - * DevSettings.reload(); - * ``` - */ -export const getReloadByDevSettingsProxy = (): string => `(function () { - var moduleName = "DevSettings"; - (global.__turboModuleProxy - ? global.__turboModuleProxy(moduleName) - : global.nativeModuleProxy[moduleName]).reload(); -})();`; diff --git a/packages/internal/lib/types.ts b/packages/internal/lib/types.ts index d4bf6e48..f2ca2b2b 100644 --- a/packages/internal/lib/types.ts +++ b/packages/internal/lib/types.ts @@ -1,5 +1,3 @@ -import type { BundlerSupportPlatform } from '@react-native-esbuild/config'; - export interface Asset { // `/path/to/asset/image.png` path: string; @@ -16,7 +14,34 @@ export interface Asset { httpServerLocation: string; hash: string; dimensions: { width: number; height: number }; - platform: BundlerSupportPlatform | null; + platform: string | null; } export type AssetScale = 0.75 | 1 | 1.5 | 2 | 3; + +/** + * Event reportable event types + * + * @see {@link https://github.com/facebook/metro/blob/v0.78.0/packages/metro/src/lib/reporting.js#L36} + */ +export interface ClientLogEvent { + type: 'client_log'; + level: + | 'trace' + | 'info' + | 'warn' + /** + * In react-native, ClientLogEvent['level'] does not defined `error` type. + * But, flipper supports the `error` type. + * + * @see {@link https://github.com/facebook/flipper/blob/v0.211.0/desktop/flipper-common/src/server-types.tsx#L76} + */ + | 'error' + | 'log' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'debug'; + data: unknown[]; + mode: 'BRIDGE' | 'NOBRIDGE'; +} diff --git a/packages/internal/package.json b/packages/internal/package.json index 82c5e887..665a03c1 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,7 +1,7 @@ { "name": "@react-native-esbuild/internal", "version": "0.1.0-beta.12", - "description": "shared configs and helpers for internal of react-native", + "description": "internal(react-native) configs and helpers for @react-native-esbuild", "keywords": [ "react-native", "esbuild" @@ -37,7 +37,6 @@ "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" }, "devDependencies": { - "@react-native-esbuild/config": "workspace:*", "esbuild": "^0.19.5" }, "dependencies": { diff --git a/packages/jest/lib/transformer/createTransformer.ts b/packages/jest/lib/transformer/createTransformer.ts index a02d910e..c6f68245 100644 --- a/packages/jest/lib/transformer/createTransformer.ts +++ b/packages/jest/lib/transformer/createTransformer.ts @@ -10,20 +10,17 @@ import { SyncTransformPipeline, AsyncTransformPipeline, swcPresets, - type TransformerContext, + type BaseTransformContext, } from '@react-native-esbuild/transformer'; import { getReactNativeInitializeCore } from '@react-native-esbuild/internal'; import type { TransformerConfig } from '../types'; import pkg from '../../package.json'; -const DUMMY_ESBUILD_VALUE = ''; const ROOT = process.cwd(); -const TRANSFORMER_CONTEXT: TransformerContext = { +const BASE_TRANSFORM_CONTEXT: BaseTransformContext = { root: ROOT, - id: 0, dev: true, entry: '', - path: '', }; ReactNativeEsbuildBundler.bootstrap(); @@ -39,7 +36,7 @@ export const createTransformer = (config: TransformerConfig): Transformer => { : undefined; const syncTransformPipeline = new SyncTransformPipeline.builder( - TRANSFORMER_CONTEXT, + BASE_TRANSFORM_CONTEXT, ) .setSwcPreset( swcPresets.getJestPreset({ @@ -59,7 +56,7 @@ export const createTransformer = (config: TransformerConfig): Transformer => { .build(); const asyncTransformPipeline = new AsyncTransformPipeline.builder( - TRANSFORMER_CONTEXT, + BASE_TRANSFORM_CONTEXT, ) .setSwcPreset( // Async transform is always ESM. @@ -87,10 +84,9 @@ export const createTransformer = (config: TransformerConfig): Transformer => { _options: TransformOptions, ): TransformedSource => { const transformResult = syncTransformPipeline.transform(code, { + id: 0, path, - pluginData: DUMMY_ESBUILD_VALUE, - namespace: DUMMY_ESBUILD_VALUE, - suffix: DUMMY_ESBUILD_VALUE, + pluginData: {}, }); return { code: transformResult.code }; }, @@ -100,10 +96,9 @@ export const createTransformer = (config: TransformerConfig): Transformer => { _options: TransformOptions, ): Promise => { const transformResult = await asyncTransformPipeline.transform(code, { + id: 0, path, - pluginData: DUMMY_ESBUILD_VALUE, - namespace: DUMMY_ESBUILD_VALUE, - suffix: DUMMY_ESBUILD_VALUE, + pluginData: {}, }); return { code: transformResult.code }; }, diff --git a/packages/jest/package.json b/packages/jest/package.json index c2cb7f67..128799d5 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -45,6 +45,7 @@ "@jest/create-cache-key-function": "^29.7.0", "@react-native-esbuild/core": "workspace:*", "@react-native-esbuild/internal": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", "md5": "^2.3.0" }, diff --git a/packages/plugins/lib/assetRegisterPlugin/__tests__/assetRegisterPlugin.test.ts b/packages/plugins/lib/assetRegisterPlugin/__tests__/assetRegisterPlugin.test.ts index 3a2dab34..3261e88f 100644 --- a/packages/plugins/lib/assetRegisterPlugin/__tests__/assetRegisterPlugin.test.ts +++ b/packages/plugins/lib/assetRegisterPlugin/__tests__/assetRegisterPlugin.test.ts @@ -2,11 +2,11 @@ import fs from 'node:fs/promises'; import deepmerge from 'deepmerge'; import { faker } from '@faker-js/faker'; import type { OnLoadArgs } from 'esbuild'; -import type { PluginContext } from '@react-native-esbuild/core'; import { SUPPORT_PLATFORMS, - type BundlerSupportPlatform, -} from '@react-native-esbuild/config'; + type BuildContext, + type SupportedPlatform, +} from '@react-native-esbuild/shared'; import type { AssetScale } from '@react-native-esbuild/internal'; import { getAssetPriority, @@ -38,7 +38,7 @@ describe('assetRegisterPlugin', () => { let filename: string; let extension: string; let scale: AssetScale; - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; beforeEach(() => { filename = faker.string.alphanumeric(10); @@ -72,7 +72,7 @@ describe('assetRegisterPlugin', () => { let filename: string; let extension: string; let scale: AssetScale; - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; beforeEach(() => { filename = faker.string.alphanumeric(10); @@ -146,7 +146,7 @@ describe('assetRegisterPlugin', () => { let filename: string; let extension: string; let scale: AssetScale; - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; beforeEach(() => { filename = faker.string.alphanumeric(10); @@ -241,7 +241,7 @@ describe('assetRegisterPlugin', () => { }); describe('when `platform` is present', () => { - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; let suffixPathResult: SuffixPathResult; beforeEach(() => { @@ -281,7 +281,7 @@ describe('assetRegisterPlugin', () => { }); describe('when both `platform` and `scale` are present', () => { - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; let scale: AssetScale; let suffixPathResult: SuffixPathResult; @@ -308,14 +308,16 @@ describe('assetRegisterPlugin', () => { let filename: string; let extension: string; let pullPath: string; - let mockedContext: PluginContext; + let mockedContext: BuildContext; let mockedArgs: OnLoadArgs; - const mockContext = (platform: BundlerSupportPlatform): void => { - mockedContext = deepmerge(mockedContext, { platform } as PluginContext); + const mockContext = (platform: SupportedPlatform): void => { + mockedContext = deepmerge(mockedContext, { + bundleOptions: { platform }, + } as BuildContext); }; - const mockArgs = (platform: BundlerSupportPlatform | null): void => { + const mockArgs = (platform: SupportedPlatform | null): void => { mockedArgs = { path: pullPath, pluginData: { @@ -343,7 +345,7 @@ describe('assetRegisterPlugin', () => { filename = faker.string.alphanumeric(10); extension = faker.helpers.arrayElement(['.png', '.jpg', '.jpeg', '.gif']); pullPath = `${dirname}/${filename}${extension}`; - mockedContext = { root: faker.system.directoryPath() } as PluginContext; + mockedContext = { root: faker.system.directoryPath() } as BuildContext; mockedArgs = { path: pullPath, pluginData: { @@ -355,7 +357,7 @@ describe('assetRegisterPlugin', () => { }); describe('when platform suffixed asset is exist', () => { - let platform: BundlerSupportPlatform; + let platform: SupportedPlatform; beforeEach(() => { platform = faker.helpers.arrayElement(SUPPORT_PLATFORMS); diff --git a/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts b/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts index b115bfda..084da130 100644 --- a/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts +++ b/packages/plugins/lib/assetRegisterPlugin/assetRegisterPlugin.ts @@ -1,11 +1,12 @@ import path from 'node:path'; import type { OnResolveArgs, ResolveResult } from 'esbuild'; -import type { ReactNativeEsbuildPluginCreator } from '@react-native-esbuild/core'; +import { registerAsExternalModule } from '@react-native-esbuild/hmr'; +import type { PluginFactory } from '@react-native-esbuild/shared'; import { + ASSET_EXTENSIONS, getAssetRegistrationScript, type Asset, } from '@react-native-esbuild/internal'; -import { ASSET_EXTENSIONS } from '@react-native-esbuild/internal'; import type { AssetRegisterPluginConfig, SuffixPathResult } from '../types'; import { copyAssetsToDestination, @@ -19,9 +20,9 @@ const DEFAULT_PLUGIN_CONFIG: AssetRegisterPluginConfig = { assetExtensions: ASSET_EXTENSIONS, }; -export const createAssetRegisterPlugin: ReactNativeEsbuildPluginCreator< +export const createAssetRegisterPlugin: PluginFactory< AssetRegisterPluginConfig -> = (context, config = DEFAULT_PLUGIN_CONFIG) => ({ +> = (buildContext, config = DEFAULT_PLUGIN_CONFIG) => ({ name: NAME, setup: (build) => { const { assetExtensions = ASSET_EXTENSIONS } = config; @@ -59,14 +60,14 @@ export const createAssetRegisterPlugin: ReactNativeEsbuildPluginCreator< // 1 let suffixedPathResult = getSuffixedPath(args.path, { scale: 1, - platform: context.platform, + platform: buildContext.bundleOptions.platform, }); let resolveResult = await resolveAsset(suffixedPathResult, args); // 2 if (resolveResult.errors.length) { suffixedPathResult = getSuffixedPath(args.path, { - platform: context.platform, + platform: buildContext.bundleOptions.platform, }); resolveResult = await resolveAsset(suffixedPathResult, args); } @@ -95,13 +96,21 @@ export const createAssetRegisterPlugin: ReactNativeEsbuildPluginCreator< }); build.onLoad({ filter: /./, namespace: ASSET_NAMESPACE }, async (args) => { - const asset = await resolveScaledAssets(context, args); + const asset = await resolveScaledAssets(buildContext, args); + const assetRegistrationScript = getAssetRegistrationScript(asset); + const moduleId = buildContext.moduleManager.getModuleId(args.path); assets.push(asset); return { resolveDir: path.dirname(args.path), - contents: getAssetRegistrationScript(asset), + contents: buildContext.hmrEnabled + ? registerAsExternalModule( + moduleId, + assetRegistrationScript, + 'module.exports', + ) + : assetRegistrationScript, loader: 'js', }; }); @@ -109,7 +118,7 @@ export const createAssetRegisterPlugin: ReactNativeEsbuildPluginCreator< build.onEnd(async (result) => { // Skip copying assets when build failure. if (result.errors.length) return; - await copyAssetsToDestination(context, assets); + await copyAssetsToDestination(buildContext, assets); }); }, }); diff --git a/packages/plugins/lib/assetRegisterPlugin/helpers/fs.ts b/packages/plugins/lib/assetRegisterPlugin/helpers/fs.ts index 3bcb4c68..37019853 100644 --- a/packages/plugins/lib/assetRegisterPlugin/helpers/fs.ts +++ b/packages/plugins/lib/assetRegisterPlugin/helpers/fs.ts @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { PluginContext } from '@react-native-esbuild/core'; +import { BuildMode, type BuildContext } from '@react-native-esbuild/shared'; import type { Asset } from '@react-native-esbuild/internal'; import { logger } from '../../shared'; import { @@ -15,11 +15,13 @@ import { * @see {@link https://github.com/react-native-community/cli/blob/v11.3.6/packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts} */ export const copyAssetsToDestination = async ( - context: PluginContext, + context: BuildContext, assets: Asset[], ): Promise => { - const { assetsDir, mode } = context; - if (mode === 'watch') return; + const { mode } = context; + const { platform, assetsDir } = context.bundleOptions; + + if (mode === BuildMode.Watch) return; if (!assetsDir) { logger.warn('asset destination is not set'); @@ -40,7 +42,7 @@ export const copyAssetsToDestination = async ( assets.map((asset): Promise => { return Promise.all( asset.scales.map(async (scale): Promise => { - if (context.platform !== 'android') { + if (platform !== 'android') { const from = await resolveAssetPath(asset, scale); const to = path.join( assetsDir, diff --git a/packages/plugins/lib/assetRegisterPlugin/helpers/path.ts b/packages/plugins/lib/assetRegisterPlugin/helpers/path.ts index cf129080..9d21f932 100644 --- a/packages/plugins/lib/assetRegisterPlugin/helpers/path.ts +++ b/packages/plugins/lib/assetRegisterPlugin/helpers/path.ts @@ -5,13 +5,13 @@ import type { OnLoadArgs } from 'esbuild'; import { imageSize } from 'image-size'; import md5 from 'md5'; import invariant from 'invariant'; -import type { PluginContext } from '@react-native-esbuild/core'; -import type { Asset, AssetScale } from '@react-native-esbuild/internal'; import { ASSET_PATH, SUPPORT_PLATFORMS, - type BundlerSupportPlatform, -} from '@react-native-esbuild/config'; + type BuildContext, + type SupportedPlatform, +} from '@react-native-esbuild/shared'; +import type { Asset, AssetScale } from '@react-native-esbuild/internal'; import type { SuffixPathResult } from '../../types'; const PLATFORM_SUFFIX_PATTERN = SUPPORT_PLATFORMS.map( @@ -19,7 +19,7 @@ const PLATFORM_SUFFIX_PATTERN = SUPPORT_PLATFORMS.map( ).join('|'); const SCALE_PATTERN = '@(\\d+\\.?\\d*)x'; -const ALLOW_SCALES: Partial> = { +const ALLOW_SCALES: Partial> = { ios: [1, 2, 3], }; @@ -54,7 +54,7 @@ export const addSuffix = ( basename: string, extension: string, options?: { - platform?: BundlerSupportPlatform | null; + platform?: string | null; scale?: string | number; }, ): string => { @@ -93,7 +93,7 @@ export const getSuffixedPath = ( assetPath: string, options?: { scale?: AssetScale; - platform?: BundlerSupportPlatform | null; + platform?: string | null; }, ): SuffixPathResult => { // if `scale` present, append scale suffix to path @@ -127,14 +127,13 @@ export const getDevServerBasePath = (asset: Asset): string => { function assertSuffixPathResult( data: OnLoadArgs['pluginData'], ): asserts data is SuffixPathResult { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- `pluginData` is any type. invariant(data.basename, 'basename is empty'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- `pluginData` is any type. + invariant(data.extension, 'extension is empty'); } export const resolveScaledAssets = async ( - context: PluginContext, + context: BuildContext, args: OnLoadArgs, ): Promise => { assertSuffixPathResult(args.pluginData); @@ -178,7 +177,9 @@ export const resolveScaledAssets = async ( .map(parseFloat) .filter((scale: number) => { // https://github.com/react-native-community/cli/blob/v11.3.6/packages/cli-plugin-metro/src/commands/bundle/filterPlatformAssetScales.ts - return ALLOW_SCALES[context.platform]?.includes(scale) ?? true; + return ( + ALLOW_SCALES[context.bundleOptions.platform]?.includes(scale) ?? true + ); }) .sort(), httpServerLocation: path.join(ASSET_PATH, path.dirname(relativePath)), diff --git a/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts b/packages/plugins/lib/buildStatusPlugin/StatusLogger.ts similarity index 89% rename from packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts rename to packages/plugins/lib/buildStatusPlugin/StatusLogger.ts index 0a8c4384..5e1092f6 100644 --- a/packages/core/lib/bundler/plugins/statusPlugin/StatusLogger.ts +++ b/packages/plugins/lib/buildStatusPlugin/StatusLogger.ts @@ -3,11 +3,16 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import esbuild, { type BuildResult, type Message } from 'esbuild'; import ora, { type Ora } from 'ora'; -import { getBuildStatusCachePath } from '@react-native-esbuild/config'; -import { colors, isTTY } from '@react-native-esbuild/utils'; -import { logger } from '../../../shared'; -import { ESBUILD_LABEL } from '../../logo'; -import type { BuildStatus, PluginContext } from '../../../types'; +import { + colors, + isTTY, + getBuildStatusCachePath, + BuildMode, + ESBUILD_LABEL, + type BuildStatus, + type BuildContext, +} from '@react-native-esbuild/shared'; +import { logger } from '../shared'; import { fromTemplate, getSummaryTemplate } from './templates'; export class StatusLogger { @@ -18,20 +23,23 @@ export class StatusLogger { private loadedModules = 0; private previousPercent = 0; - constructor(private context: PluginContext) { + constructor(private context: BuildContext) { this.platformText = colors.gray( - `[${[context.platform, context.dev ? 'dev' : null] + `[${[ + context.bundleOptions.platform, + context.bundleOptions.dev ? 'dev' : null, + ] .filter(Boolean) .join(', ')}]`, ); this.spinner = ora({ color: 'yellow', - discardStdin: context.mode === 'bundle', + discardStdin: context.mode === BuildMode.Bundle, prefixText: colors.bgYellow(colors.black(ESBUILD_LABEL)), }); } - private statusUpdate(): void { + private updateStatus(): void { const { resolved } = this.getStatus(); const loaded = this.loadedModules; @@ -92,7 +100,7 @@ export class StatusLogger { onLoad(): void { ++this.loadedModules; - this.statusUpdate(); + this.updateStatus(); } setup(): void { @@ -101,8 +109,9 @@ export class StatusLogger { this.resolvedModules.clear(); this.loadedModules = 0; this.previousPercent = 0; - this.statusUpdate(); + this.updateStatus(); + process.stdout.write('\n'); isTTY() ? this.spinner.start() : this.print(`${this.platformText} build in progress...`); diff --git a/packages/core/lib/bundler/plugins/statusPlugin/statusPlugin.ts b/packages/plugins/lib/buildStatusPlugin/buildStatusPlugin.ts similarity index 55% rename from packages/core/lib/bundler/plugins/statusPlugin/statusPlugin.ts rename to packages/plugins/lib/buildStatusPlugin/buildStatusPlugin.ts index 61332838..899ea7df 100644 --- a/packages/core/lib/bundler/plugins/statusPlugin/statusPlugin.ts +++ b/packages/plugins/lib/buildStatusPlugin/buildStatusPlugin.ts @@ -1,29 +1,17 @@ import path from 'node:path'; import type { BuildResult } from 'esbuild'; -import type { - ReactNativeEsbuildPluginCreator, - PluginContext, - BuildStatus, -} from '../../../types'; +import type { PluginFactory } from '@react-native-esbuild/shared'; +import type { BuildStatusPluginConfig } from '../types'; import { StatusLogger } from './StatusLogger'; -const NAME = 'build-status-plugin'; - -export const createBuildStatusPlugin: ReactNativeEsbuildPluginCreator<{ - onStart: (context: PluginContext) => void; - onUpdate: (buildState: BuildStatus, context: PluginContext) => void; - onEnd: ( - data: { - result: BuildResult; - success: boolean; - }, - context: PluginContext, - ) => void; -}> = (context, config) => ({ - name: NAME, +export const createBuildStatusPlugin: PluginFactory = ( + buildContext, + config, +) => ({ + name: 'react-native-esbuild-bundler-status-plugin', setup: (build): void => { - const statusLogger = new StatusLogger(context); const filter = /.*/; + const statusLogger = new StatusLogger(buildContext); let statusLoaded = false; build.onStart(async () => { @@ -32,7 +20,7 @@ export const createBuildStatusPlugin: ReactNativeEsbuildPluginCreator<{ statusLoaded = true; } statusLogger.setup(); - config?.onStart(context); + config?.handler?.onBuildStart(buildContext); }); build.onResolve({ filter }, (args) => { @@ -40,7 +28,7 @@ export const createBuildStatusPlugin: ReactNativeEsbuildPluginCreator<{ statusLogger.onResolve( isRelative ? path.resolve(args.resolveDir, args.path) : args.path, ); - config?.onUpdate(statusLogger.getStatus(), context); + config?.handler?.onBuild(buildContext, statusLogger.getStatus()); return null; }); @@ -52,7 +40,11 @@ export const createBuildStatusPlugin: ReactNativeEsbuildPluginCreator<{ build.onEnd(async (result: BuildResult) => { const success = await statusLogger.summary(result); await statusLogger.persistStatus(); - config?.onEnd({ result, success }, context); + config?.handler?.onBuildEnd( + buildContext, + { result, success }, + statusLogger.getStatus(), + ); }); }, }); diff --git a/packages/plugins/lib/buildStatusPlugin/index.ts b/packages/plugins/lib/buildStatusPlugin/index.ts new file mode 100644 index 00000000..135f4064 --- /dev/null +++ b/packages/plugins/lib/buildStatusPlugin/index.ts @@ -0,0 +1 @@ +export { createBuildStatusPlugin } from './buildStatusPlugin'; diff --git a/packages/core/lib/bundler/plugins/statusPlugin/templates.ts b/packages/plugins/lib/buildStatusPlugin/templates.ts similarity index 91% rename from packages/core/lib/bundler/plugins/statusPlugin/templates.ts rename to packages/plugins/lib/buildStatusPlugin/templates.ts index 50407223..504c24c3 100644 --- a/packages/core/lib/bundler/plugins/statusPlugin/templates.ts +++ b/packages/plugins/lib/buildStatusPlugin/templates.ts @@ -1,4 +1,4 @@ -import { isTTY, colors } from '@react-native-esbuild/utils'; +import { colors, isTTY } from '@react-native-esbuild/shared'; const summaryTemplateForTTY = ` ╭───────────╯ diff --git a/packages/plugins/lib/index.ts b/packages/plugins/lib/index.ts index cee87ec3..3a93c85a 100644 --- a/packages/plugins/lib/index.ts +++ b/packages/plugins/lib/index.ts @@ -1,4 +1,6 @@ export * from './assetRegisterPlugin'; +export * from './buildStatusPlugin'; +export * from './metafilePlugin'; export * from './reactNativeRuntimeTransformPlugin'; export * from './reactNativeWebPlugin'; export * from './svgTransformPlugin'; diff --git a/packages/core/lib/bundler/plugins/metafilePlugin/index.ts b/packages/plugins/lib/metafilePlugin/index.ts similarity index 100% rename from packages/core/lib/bundler/plugins/metafilePlugin/index.ts rename to packages/plugins/lib/metafilePlugin/index.ts diff --git a/packages/plugins/lib/metafilePlugin/metafilePlugin.ts b/packages/plugins/lib/metafilePlugin/metafilePlugin.ts new file mode 100644 index 00000000..e7e7cb77 --- /dev/null +++ b/packages/plugins/lib/metafilePlugin/metafilePlugin.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { BuildResult } from 'esbuild'; +import type { PluginFactory } from '@react-native-esbuild/shared'; +import { logger } from '../shared'; + +export const createMetafilePlugin: PluginFactory = (buildContext) => ({ + name: 'react-native-esbuild-metafile-plugin', + setup: (build): void => { + build.onEnd(async (result: BuildResult) => { + if (!(buildContext.bundleOptions.metafile && result.metafile)) return; + + const filename = path.join( + buildContext.root, + `metafile-${buildContext.bundleOptions.platform}-${new Date() + .getTime() + .toString()}.json`, + ); + logger.debug('writing esbuild metafile', { destination: filename }); + + await fs.writeFile(filename, JSON.stringify(result.metafile), { + encoding: 'utf-8', + }); + }); + }, +}); diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/caches.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/caches.ts index 0a9aedf3..2e9be2df 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/caches.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/caches.ts @@ -1,40 +1,113 @@ -import type { CacheController } from '@react-native-esbuild/core'; -import type { CacheConfig } from '../../types'; +import type { AsyncTransformStep } from '@react-native-esbuild/transformer'; +import { + getCacheKey, + type BuildContext, + type CacheStorage, +} from '@react-native-esbuild/shared'; + +export const getCachingSteps = ( + buildContext: BuildContext, +): { + beforeTransform: AsyncTransformStep; + afterTransform: AsyncTransformStep; +} => { + const findCacheBeforeTransform: AsyncTransformStep = async ( + code, + context, + ) => { + /** + * 1. Use previous transformed result and skip transform + * when file is not changed and transform result exist in memory. + */ + const inMemoryCache = getTransformedCodeFromInMemoryCache( + buildContext.cacheStorage, + context.path, + context.pluginData.mtimeMs, + ); + if (inMemoryCache) { + return { code: inMemoryCache, done: true }; + } + + // 2. Transform code on each build task when cache is disabled. + if (!buildContext.config.cache) { + return { code, done: false }; + } + + // 3. Trying to get cache from file system. + // = cache exist ? use cache : transform code + const cachedCode = await getTransformedCodeFromFileSystemCache( + buildContext.cacheStorage, + (context.pluginData.hash = getCacheKey( + buildContext.id, + context.path, + context.pluginData.mtimeMs, + )), + ); + + return { code: cachedCode ?? code, done: Boolean(cachedCode) }; + }; + + const writeCacheAfterTransform: AsyncTransformStep = async ( + code, + context, + ) => { + writeTransformedCodeToInMemoryCache( + buildContext.cacheStorage, + code, + context.path, + context.pluginData.mtimeMs, + ); + + if (buildContext.config.cache) { + await writeTransformedCodeToFileSystemCache( + buildContext.cacheStorage, + code, + context.pluginData.hash, + ); + } + + return { code, done: true }; + }; + + return { + beforeTransform: findCacheBeforeTransform, + afterTransform: writeCacheAfterTransform, + }; +}; export const getTransformedCodeFromInMemoryCache = ( - controller: CacheController, - cacheConfig: CacheConfig, + transformCacheStorage: CacheStorage, + filePath: string, + mtimeMs: number, ): string | null => { - const inMemoryCache = controller.readFromMemory(cacheConfig.hash); + const inMemoryCache = transformCacheStorage.readFromMemory(filePath); // If file is not modified, use cache data instead. - return inMemoryCache && inMemoryCache.modifiedAt === cacheConfig.mtimeMs + return inMemoryCache && inMemoryCache.mtimeMs === mtimeMs ? inMemoryCache.data : null; }; export const getTransformedCodeFromFileSystemCache = async ( - controller: CacheController, - cacheConfig: CacheConfig, + transformCacheStorage: CacheStorage, + key: string, ): Promise => { - const fsCache = await controller.readFromFileSystem(cacheConfig.hash); - return fsCache ?? null; + const fileCache = await transformCacheStorage.readFromFileSystem(key); + return fileCache ?? null; }; export const writeTransformedCodeToInMemoryCache = ( - controller: CacheController, - code: string, - cacheConfig: CacheConfig, + transformCacheStorage: CacheStorage, + data: string, + key: string, + mtimeMs: number, ): void => { - controller.writeToMemory(cacheConfig.hash, { - data: code, - modifiedAt: cacheConfig.mtimeMs, - }); + transformCacheStorage.writeToMemory(key, { data, mtimeMs }); }; export const writeTransformedCodeToFileSystemCache = ( - controller: CacheController, - code: string, - cacheConfig: CacheConfig, + transformCacheStorage: CacheStorage, + data: string, + key: string, ): Promise => { - return controller.writeToFileSystem(cacheConfig.hash, code); + return transformCacheStorage.writeToFileSystem(key, data); }; diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts index c9314409..d4fdc5e5 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/index.ts @@ -1 +1,2 @@ export * from './caches'; +export * from './json'; diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts new file mode 100644 index 00000000..378c202a --- /dev/null +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/helpers/json.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs/promises'; +import type { PluginBuild } from 'esbuild'; +import { registerAsExternalModule } from '@react-native-esbuild/hmr'; +import type { BuildContext } from '@react-native-esbuild/shared'; + +/** + * When development mode + HMR enabled, the '.json' contents + * must be registered in the global module registry for HMR. + */ +export const transformJsonAsJsModule = ( + buildContext: BuildContext, + build: PluginBuild, +): void => { + if (!buildContext.hmrEnabled) return; + + build.onLoad({ filter: /\.json$/ }, async (args) => { + const moduleId = buildContext.moduleManager.getModuleId(args.path); + const rawJson = await fs.readFile(args.path, { encoding: 'utf-8' }); + const parsedJson = JSON.parse(rawJson) as Record; + const identifier = 'json'; + + return { + contents: registerAsExternalModule( + moduleId, + `const ${identifier} = ${rawJson}; + ${Object.keys(parsedJson) + .map((member) => { + const memberName = JSON.stringify(member); + return `exports[${memberName}] = ${identifier}[${memberName}]`; + }) + .join('\n')} + module.exports = ${identifier};`, + identifier, + ), + loader: 'js', + }; + }); +}; diff --git a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts index 6b9d3026..7de75d84 100644 --- a/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts +++ b/packages/plugins/lib/reactNativeRuntimeTransformPlugin/reactNativeRuntimeTransformPlugin.ts @@ -1,144 +1,71 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { OnLoadResult } from 'esbuild'; -import { - ReactNativeEsbuildBundler as Bundler, - type ReactNativeEsbuildPluginCreator, -} from '@react-native-esbuild/core'; -import { getReactNativeInitializeCore } from '@react-native-esbuild/internal'; -import { - AsyncTransformPipeline, - swcPresets, - type AsyncTransformStep, -} from '@react-native-esbuild/transformer'; +import { getCommonReactNativeRuntimePipelineBuilder } from '@react-native-esbuild/transformer'; +import type { PluginFactory } from '@react-native-esbuild/shared'; import { logger } from '../shared'; -import type { ReactNativeRuntimeTransformPluginConfig } from '../types'; -import { - getTransformedCodeFromInMemoryCache, - getTransformedCodeFromFileSystemCache, - writeTransformedCodeToInMemoryCache, - writeTransformedCodeToFileSystemCache, -} from './helpers'; +import { getCachingSteps, transformJsonAsJsModule } from './helpers'; -const NAME = 'react-native-runtime-transform-plugin'; - -export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCreator< - ReactNativeRuntimeTransformPluginConfig -> = (context, config) => ({ - name: NAME, +export const createReactNativeRuntimeTransformPlugin: PluginFactory = ( + buildContext, +) => ({ + name: 'react-native-runtime-transform-plugin', setup: (build): void => { - const cacheController = Bundler.caches.get(context.id); - const bundlerSharedData = Bundler.shared.get(context.id); - const cacheEnabled = context.config.cache ?? true; - const { - stripFlowPackageNames = [], - fullyTransformPackageNames = [], - additionalTransformRules, - } = context.config.transformer ?? {}; - const additionalBabelRules = additionalTransformRules?.babel ?? []; - const additionalSwcRules = additionalTransformRules?.swc ?? []; - const injectScriptPaths = [ - getReactNativeInitializeCore(context.root), - ...(config?.injectScriptPaths ?? []), - ]; - - const reactNativeRuntimePreset = swcPresets.getReactNativeRuntimePreset( - context.config.transformer?.jsc, + const filter = /\.(?:[mc]js|[tj]sx?)$/; + const cachingSteps = getCachingSteps(buildContext); + const transformPipeline = getCommonReactNativeRuntimePipelineBuilder( + buildContext, + ) + .beforeTransform(cachingSteps.beforeTransform) + .afterTransform(cachingSteps.afterTransform) + .build(); + + transformJsonAsJsModule(buildContext, build); + + build.onResolve({ filter }, (args) => + args.kind === 'entry-point' + ? { + path: args.path, + pluginData: { isEntryPoint: true }, + } + : null, ); - const onBeforeTransform: AsyncTransformStep = async ( - code, - args, - moduleMeta, - ) => { - const isChangedFile = bundlerSharedData.watcher.changed === args.path; - const cacheConfig = { - hash: moduleMeta.hash, - mtimeMs: moduleMeta.stats.mtimeMs, - }; - - // 1. Force re-transform when file is changed. - if (isChangedFile) { - logger.debug('changed file detected', { path: args.path }); - return { code, done: false }; - } - - /** - * 2. Use previous transformed result and skip transform - * when file is not changed and transform result exist in memory. - */ - const inMemoryCache = getTransformedCodeFromInMemoryCache( - cacheController, - cacheConfig, - ); - if (inMemoryCache) { - return { code: inMemoryCache, done: true }; + build.onLoad({ filter }, async (args) => { + const moduleId = buildContext.moduleManager.getModuleId(args.path); + let handle: fs.FileHandle | undefined; + + try { + handle = await fs.open(args.path); + const stat = await handle.stat(); + const rawCode = await handle.readFile({ encoding: 'utf-8' }); + + const { code } = await transformPipeline.transform(rawCode, { + id: moduleId, + path: args.path, + pluginData: { + ...args.pluginData, + mtimeMs: stat.mtimeMs, + externalPattern: buildContext.additionalData.externalPattern, + }, + }); + + return { contents: code, loader: 'js' }; + } finally { + try { + await handle?.close(); + } catch (error) { + logger.error('unexpected error', error); + process.exit(1); + } } - - // 3. Transform code on each build task when cache is disabled. - if (!cacheEnabled) { - return { code, done: false }; - } - - // 4. Trying to get cache from file system. - // = cache exist ? use cache : transform code - const cachedCode = await getTransformedCodeFromFileSystemCache( - cacheController, - cacheConfig, - ); - - return { code: cachedCode ?? code, done: Boolean(cachedCode) }; - }; - - const onAfterTransform: AsyncTransformStep = async ( - code, - _args, - moduleMeta, - ) => { - const cacheConfig = { - hash: moduleMeta.hash, - mtimeMs: moduleMeta.stats.mtimeMs, - }; - writeTransformedCodeToInMemoryCache(cacheController, code, cacheConfig); - - if (cacheEnabled) { - await writeTransformedCodeToFileSystemCache( - cacheController, - code, - cacheConfig, - ); - } - - return { code, done: true }; - }; - - let transformPipeline: AsyncTransformPipeline; - const transformPipelineBuilder = new AsyncTransformPipeline.builder(context) - .setSwcPreset(reactNativeRuntimePreset) - .setInjectScripts(injectScriptPaths) - .setFullyTransformPackages(fullyTransformPackageNames) - .setStripFlowPackages(stripFlowPackageNames) - .setAdditionalBabelTransformRules(additionalBabelRules) - .setAdditionalSwcTransformRules(additionalSwcRules) - .onStart(onBeforeTransform) - .onEnd(onAfterTransform); - - build.onStart(() => { - transformPipeline = transformPipelineBuilder.build(); - }); - - build.onLoad({ filter: /\.(?:[mc]js|[tj]sx?)$/ }, async (args) => { - const rawCode = await fs.readFile(args.path, { encoding: 'utf-8' }); - return { - contents: (await transformPipeline.transform(rawCode, args)).code, - loader: 'js', - } as OnLoadResult; }); build.onEnd(async (args) => { if (args.errors.length) return; - if (!(build.initialOptions.outfile && context.sourcemap)) { + if ( + !(build.initialOptions.outfile && buildContext.bundleOptions.sourcemap) + ) { logger.debug('outfile or sourcemap path is not specified'); return; } @@ -149,10 +76,10 @@ export const createReactNativeRuntimeTransformPlugin: ReactNativeEsbuildPluginCr logger.debug('move sourcemap to specified path', { from: sourceMapPath, - to: context.sourcemap, + to: buildContext.bundleOptions.sourcemap, }); - await fs.rename(sourceMapPath, context.sourcemap); + await fs.rename(sourceMapPath, buildContext.bundleOptions.sourcemap); }); }, }); diff --git a/packages/plugins/lib/reactNativeWebPlugin/reactNativeWebPlugin.ts b/packages/plugins/lib/reactNativeWebPlugin/reactNativeWebPlugin.ts index 71fb6d72..d50e483b 100644 --- a/packages/plugins/lib/reactNativeWebPlugin/reactNativeWebPlugin.ts +++ b/packages/plugins/lib/reactNativeWebPlugin/reactNativeWebPlugin.ts @@ -1,11 +1,13 @@ import path from 'node:path'; import type { OnResolveArgs, ResolveResult } from 'esbuild'; -import type { ReactNativeEsbuildPluginCreator } from '@react-native-esbuild/core'; -import { getDevServerPublicPath } from '@react-native-esbuild/config'; +import { + BuildMode, + getDevServerPublicPath, + type PluginFactory, +} from '@react-native-esbuild/shared'; import { logger } from '../shared'; import { generateIndexPage } from './helpers'; -const NAME = 'react-native-web-plugin'; const RESOLVE_PATTERNS = [ // For relative path import of initializeCore. /node_modules\/react-native\//, @@ -13,16 +15,17 @@ const RESOLVE_PATTERNS = [ /^react-native$/, ]; -export const createReactNativeWebPlugin: ReactNativeEsbuildPluginCreator = ( - context, -) => ({ - name: NAME, +export const createReactNativeWebPlugin: PluginFactory = (buildContext) => ({ + name: 'react-native-web-plugin', setup: (build): void => { - const { root, platform, outfile, mode } = context; - const { template, placeholders } = context.config.web ?? {}; - const destination = - mode === 'watch' ? getDevServerPublicPath(root) : path.dirname(outfile); + const { root, mode } = buildContext; + const { platform, outfile } = buildContext.bundleOptions; + const { template, placeholders } = buildContext.config.web ?? {}; const bundleFilename = path.basename(outfile); + const destination = + mode === BuildMode.Watch + ? getDevServerPublicPath(root) + : path.dirname(outfile); if (platform !== 'web') return; diff --git a/packages/plugins/lib/shared.ts b/packages/plugins/lib/shared.ts index 2c5a342e..72e2144f 100644 --- a/packages/plugins/lib/shared.ts +++ b/packages/plugins/lib/shared.ts @@ -1,3 +1,3 @@ -import { Logger } from '@react-native-esbuild/utils'; +import { Logger } from '@react-native-esbuild/shared'; export const logger = new Logger('plugins'); diff --git a/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts b/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts index 41005e78..fde974c6 100644 --- a/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts +++ b/packages/plugins/lib/svgTransformPlugin/svgTransformPlugin.ts @@ -1,27 +1,37 @@ import fs from 'node:fs/promises'; import { transform } from '@svgr/core'; -import type { ReactNativeEsbuildPluginCreator } from '@react-native-esbuild/core'; +import { registerAsExternalModule } from '@react-native-esbuild/hmr'; +import type { PluginFactory } from '@react-native-esbuild/shared'; +import { defaultTemplate, SVG_COMPONENT_NAME } from './templates'; -const NAME = 'svg-transform-plugin'; - -export const createSvgTransformPlugin: ReactNativeEsbuildPluginCreator = ( - context, -) => ({ - name: NAME, +export const createSvgTransformPlugin: PluginFactory = (buildContext) => ({ + name: 'react-native-esbuild-svg-transform-plugin', setup: (build): void => { - const isNative = ['android', 'ios'].includes(context.platform); + const isNative = ['android', 'ios'].includes( + buildContext.bundleOptions.platform, + ); build.onLoad({ filter: /\.svg$/ }, async (args) => { + const moduleId = buildContext.moduleManager.getModuleId(args.path); const rawSvg = await fs.readFile(args.path, { encoding: 'utf8' }); + const svgTransformedCode = await transform( + rawSvg, + { + template: defaultTemplate, + plugins: ['@svgr/plugin-jsx'], + native: isNative, + }, + { filePath: args.path }, + ); + return { - contents: await transform( - rawSvg, - { - plugins: ['@svgr/plugin-jsx'], - native: isNative, - }, - { filePath: args.path }, - ), + contents: buildContext.hmrEnabled + ? registerAsExternalModule( + moduleId, + svgTransformedCode, + SVG_COMPONENT_NAME, + ) + : svgTransformedCode, loader: 'jsx', }; }); diff --git a/packages/plugins/lib/svgTransformPlugin/templates/index.ts b/packages/plugins/lib/svgTransformPlugin/templates/index.ts new file mode 100644 index 00000000..c4ce035f --- /dev/null +++ b/packages/plugins/lib/svgTransformPlugin/templates/index.ts @@ -0,0 +1,15 @@ +import type { Config } from '@svgr/core'; + +export const SVG_COMPONENT_NAME = 'SvgLogo'; + +export const defaultTemplate: Config['template'] = (variables, { tpl }) => { + return tpl`${variables.imports}; + +${variables.interfaces}; + +const ${SVG_COMPONENT_NAME} = (${variables.props}) => ( + ${variables.jsx} +); + +export default ${SVG_COMPONENT_NAME};`; +}; diff --git a/packages/plugins/lib/types.ts b/packages/plugins/lib/types.ts index 5b837962..02edfe84 100644 --- a/packages/plugins/lib/types.ts +++ b/packages/plugins/lib/types.ts @@ -1,6 +1,6 @@ -import type { BundlerSupportPlatform } from '@react-native-esbuild/config'; +import type { BuildStatusListener } from '@react-native-esbuild/shared'; -// asset-register-plugin +// assetRegisterPlugin export interface AssetRegisterPluginConfig { assetExtensions?: string[]; } @@ -10,15 +10,10 @@ export interface SuffixPathResult { dirname: string; basename: string; extension: string; - platform: BundlerSupportPlatform | null; + platform: string | null; } -// react-native-runtime-transform-plugin -export interface ReactNativeRuntimeTransformPluginConfig { - injectScriptPaths?: string[]; -} - -export interface CacheConfig { - hash: string; - mtimeMs: number; +// buildStatusPlugin +export interface BuildStatusPluginConfig { + handler?: BuildStatusListener; } diff --git a/packages/plugins/package.json b/packages/plugins/package.json index aac9060c..317bce59 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -44,15 +44,16 @@ "esbuild": "^0.19.5" }, "dependencies": { - "@react-native-esbuild/config": "workspace:*", - "@react-native-esbuild/core": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", "@react-native-esbuild/internal": "workspace:*", + "@react-native-esbuild/shared": "workspace:*", "@react-native-esbuild/transformer": "workspace:*", - "@react-native-esbuild/utils": "workspace:*", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", + "esbuild-plugin-module-id": "^0.1.3", "image-size": "^1.0.2", "invariant": "^2.2.4", - "md5": "^2.3.0" + "md5": "^2.3.0", + "ora": "^5.4.1" } } diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json index 3d3bb195..bd42e358 100644 --- a/packages/plugins/tsconfig.json +++ b/packages/plugins/tsconfig.json @@ -6,6 +6,6 @@ "declaration": true, "emitDeclarationOnly": true }, - "include": ["./lib", "../core/lib/transformers/babel.ts"], + "include": ["./lib"], "exclude": ["./dist"] } diff --git a/packages/utils/CHANGELOG.md b/packages/shared/CHANGELOG.md similarity index 100% rename from packages/utils/CHANGELOG.md rename to packages/shared/CHANGELOG.md diff --git a/packages/utils/README.md b/packages/shared/README.md similarity index 100% rename from packages/utils/README.md rename to packages/shared/README.md diff --git a/packages/config/build/index.js b/packages/shared/build/index.js similarity index 100% rename from packages/config/build/index.js rename to packages/shared/build/index.js diff --git a/packages/utils/lib/__tests__/logger.test.ts b/packages/shared/lib/__tests__/logger.test.ts similarity index 100% rename from packages/utils/lib/__tests__/logger.test.ts rename to packages/shared/lib/__tests__/logger.test.ts diff --git a/packages/shared/lib/constants.ts b/packages/shared/lib/constants.ts new file mode 100644 index 00000000..13b4d9fb --- /dev/null +++ b/packages/shared/lib/constants.ts @@ -0,0 +1,15 @@ +import path from 'node:path'; +import os from 'node:os'; + +export const DEFAULT_ENTRY_POINT = 'index.js'; +export const DEFAULT_WEB_ENTRY_POINT = 'index.web.js'; +export const DEFAULT_OUTFILE = 'main.jsbundle'; + +export const LOCAL_CACHE_DIR = '.rne'; +export const GLOBAL_CACHE_DIR = path.join(os.tmpdir(), 'react-native-esbuild'); + +export const SUPPORT_PLATFORMS = ['android', 'ios', 'web'] as const; + +export const ASSET_PATH = 'assets'; +export const PUBLIC_PATH = 'public'; +export const STATUS_CACHE_FILE = 'build-status.json'; diff --git a/packages/shared/lib/enums.ts b/packages/shared/lib/enums.ts new file mode 100644 index 00000000..86f4e94d --- /dev/null +++ b/packages/shared/lib/enums.ts @@ -0,0 +1,16 @@ +/** + * Flags for `BundleOptions` + */ +export enum OptionFlag { + None = 0b00000000, + PlatformAndroid = 0b00000001, + PlatformIos = 0b00000010, + PlatformWeb = 0b00000100, + Dev = 0b00001000, + Minify = 0b00010000, +} + +export enum BuildMode { + Bundle, + Watch, +} diff --git a/packages/config/lib/common/core.ts b/packages/shared/lib/helpers.ts similarity index 72% rename from packages/config/lib/common/core.ts rename to packages/shared/lib/helpers.ts index bc24da2c..24ddb5aa 100644 --- a/packages/config/lib/common/core.ts +++ b/packages/shared/lib/helpers.ts @@ -1,10 +1,14 @@ import path from 'node:path'; +import md5 from 'md5'; +import { OptionFlag } from './enums'; +import type { BundleOptions, Id } from './types/core'; import { DEFAULT_ENTRY_POINT, DEFAULT_OUTFILE, LOCAL_CACHE_DIR, -} from '../shares'; -import { OptionFlag, type BundleOptions } from '../types'; + PUBLIC_PATH, + STATUS_CACHE_FILE, +} from './constants'; export const combineWithDefaultBundleOptions = ( options: Partial, @@ -43,6 +47,27 @@ export const getIdByOptions = ({ return value; }; +/** + * Generate hash that contains the file path, modification time, and bundle options. + * + * `id` is combined(platform, dev, minify) bundle options value in `@react-native-esbuild`. + * + * hash = md5(id + modified time + file path + core version) + * number + number + string + string + * + * @param id - id + * @param filepath - file path + * @param modifiedAt - file modified at timestamp + * @returns + */ +export const getCacheKey = ( + id: Id, + filepath: string, + modifiedAt: number, +): string => { + return md5(id + modifiedAt + filepath + (self._version as string)); +}; + /** * For resolve environment mismatch issue. * @@ -54,6 +79,7 @@ export const getIdByOptions = ({ * * Override `NODE_ENV`, `BABEL_ENV` to bundler's environment. * + * @param isDev - env is 'development' * @see {@link https://github.com/babel/babel/blob/v7.23.0/packages/babel-core/src/config/helpers/environment.ts#L2} */ export const setEnvironment = (isDev: boolean): void => { @@ -62,10 +88,6 @@ export const setEnvironment = (isDev: boolean): void => { process.env.BABEL_ENV = env; }; -export const ASSET_PATH = 'assets'; -export const PUBLIC_PATH = 'public'; -export const STATUS_CACHE_FILE = 'build-status.json'; - export const getDevServerPublicPath = (root: string): string => { return path.resolve(root, LOCAL_CACHE_DIR, PUBLIC_PATH); }; diff --git a/packages/shared/lib/index.ts b/packages/shared/lib/index.ts new file mode 100644 index 00000000..ea0144ca --- /dev/null +++ b/packages/shared/lib/index.ts @@ -0,0 +1,18 @@ +import colors from 'colors'; +import { isCI, isTTY } from './utils'; + +(isCI() || !isTTY()) && colors.disable(); + +// Data, utilities, helpers +export * as colors from 'colors'; +export * from './constants'; +export * from './helpers'; +export * from './misc'; +export * from './utils'; +export * from './enums'; +export { Logger, LogLevel } from './logger'; + +// Shared types +export type * from './types/config'; +export type * from './types/core'; +export type * from './types/transformers'; diff --git a/packages/utils/lib/logger.ts b/packages/shared/lib/logger.ts similarity index 100% rename from packages/utils/lib/logger.ts rename to packages/shared/lib/logger.ts diff --git a/packages/core/lib/bundler/logo.ts b/packages/shared/lib/misc.ts similarity index 93% rename from packages/core/lib/bundler/logo.ts rename to packages/shared/lib/misc.ts index 64e2cc26..73232238 100644 --- a/packages/core/lib/bundler/logo.ts +++ b/packages/shared/lib/misc.ts @@ -1,4 +1,5 @@ -import { colors, isTTY } from '@react-native-esbuild/utils'; +import * as colors from 'colors'; +import { isTTY } from './utils'; const LOGO = ` "88e "88e @@ -12,9 +13,10 @@ const LOGO = ` // Center column index of `LOGO` const LOGO_CENTER_X = 18; -export const ESBUILD_LABEL = ' » esbuild '; const DESCRIPTION = 'An extremely fast bundler'; +export const ESBUILD_LABEL = ' » esbuild '; + export const printLogo = (): void => { if (isTTY()) { process.stdout.write(`${colors.yellow(LOGO)}\n`); diff --git a/packages/shared/lib/types/config.ts b/packages/shared/lib/types/config.ts new file mode 100644 index 00000000..381109b8 --- /dev/null +++ b/packages/shared/lib/types/config.ts @@ -0,0 +1,129 @@ +import type { Plugin } from 'esbuild'; +import type { BabelTransformRule, SwcTransformRule } from './transformers'; + +export interface Config { + /** + * Enable cache. + * + * Defaults to `true` + */ + cache?: boolean; + /** + * Logger configurations + */ + logger?: { + /** + * Disable log. + * + * Defaults to `false` + */ + disabled?: boolean; + /** + * Print timestamp with log when format is specified. + * + * Defaults to `null` + */ + timestamp?: string | null; + }; + /** + * Resolver configurations + */ + resolver?: { + /** + * Field names for resolve package's modules. + * + * Defaults to `['react-native', 'browser', 'main', 'module']` + */ + mainFields?: string[]; + /** + * File extensions for transform. + * + * Defaults: https://github.com/leegeunhyeok/react-native-esbuild/blob/master/packages/internal/lib/defaults.ts + */ + sourceExtensions?: string[]; + /** + * File extensions for assets registration. + * + * Defaults: https://github.com/leegeunhyeok/react-native-esbuild/blob/master/packages/internal/lib/defaults.ts + */ + assetExtensions?: string[]; + }; + /** + * Transformer configurations + */ + transformer?: { + /** + * Strip flow syntax. + * + * Defaults to `['react-native']` + */ + stripFlowPackageNames?: string[]; + /** + * Transform with babel using `metro-react-native-babel-preset` (slow) + */ + fullyTransformPackageNames?: string[]; + /** + * Additional transform rules. This rules will be applied before phase of transform to es5. + */ + additionalTransformRules?: { + /** + * Additional babel transform rules + */ + babel?: BabelTransformRule[]; + /** + * Additional swc transform rules + */ + swc?: SwcTransformRule[]; + }; + }; + /** + * Web configurations + */ + web?: { + /** + * Index page template file path + */ + template?: string; + /** + * Placeholders for replacement + * + * ```js + * // web.placeholders + * { placeholder_name: 'Hello, world!' }; + * ``` + * + * will be replaced to + * + * ```html + * + * {{placeholder_name}} + * + * + * Hello, world! + * ``` + * + * --- + * + * Reserved placeholder name + * + * - `root`: root tag id + * - `script`: bundled script path + */ + placeholders?: Record; + }; + /** + * Additional Esbuild plugins. + */ + plugins?: Plugin[]; + /** + * Experimental configurations + */ + experimental?: { + /** + * Enable HMR(Hot Module Replacement) on development mode. + * + * Defaults to `false`. + */ + hmr?: boolean; + }; +} diff --git a/packages/shared/lib/types/core.ts b/packages/shared/lib/types/core.ts new file mode 100644 index 00000000..0d6c5dd7 --- /dev/null +++ b/packages/shared/lib/types/core.ts @@ -0,0 +1,95 @@ +import type { BuildResult, Plugin } from 'esbuild'; +import type { BuildMode } from '../enums'; +import type { Config } from './config'; + +export type SupportedPlatform = 'android' | 'ios' | 'web'; + +// Internal task id. +export type Id = number; + +// Internal bundler options. +export interface BundleOptions { + platform: SupportedPlatform; + entry: string; + outfile: string; + dev: boolean; + minify: boolean; + metafile: boolean; + sourcemap?: string; + assetsDir?: string; +} + +export interface BuildContext { + id: Id; + root: string; + mode: BuildMode; + config: Config; + bundleOptions: BundleOptions; + moduleManager: ModuleManager; + cacheStorage: CacheStorage; + hmrEnabled: boolean; + /** + * ```ts + * interface AdditionalData { + * // Set string value when HMR is enabled. + * externalPattern?: string; + * } + * ``` + */ + additionalData: AdditionalData; +} + +export type AdditionalData = Record; + +export interface BuildStatusListener { + onBuildStart: (context: BuildContext) => void; + onBuild: (context: BuildContext, status: BuildStatus) => void; + onBuildEnd: ( + context: BuildContext, + data: { + result: BuildResult; + success: boolean; + }, + status: BuildStatus, + ) => void; +} + +/** + * - `total`: total module count + * - `resolved`: resolved module count + * - `loaded`: loaded module count + */ +export interface BuildStatus { + total: number; + resolved: number; + loaded: number; +} + +// Internal plugin factory. +export type PluginFactory = ( + context: BuildContext, + config?: PluginConfig, +) => Plugin; + +// Caches +export interface Cache { + data: string; + mtimeMs: number; +} + +export interface CacheStorage { + readFromMemory: (key: string) => Cache | undefined; + readFromFileSystem: (key: string) => Promise; + writeToMemory: (key: string, cacheData: Cache) => void; + writeToFileSystem: (key: string, data: string) => Promise; +} + +// Modules +export type ModuleId = number; + +export interface ModuleManager { + /** + * @param modulePath - actual module path(`path` value in esbuild metafile). + */ + getModuleId: (modulePath: string, isEntryPoint?: boolean) => number; +} diff --git a/packages/shared/lib/types/transformers.ts b/packages/shared/lib/types/transformers.ts new file mode 100644 index 00000000..82f4ea37 --- /dev/null +++ b/packages/shared/lib/types/transformers.ts @@ -0,0 +1,16 @@ +import type { TransformOptions as BabelTransformOptions } from '@babel/core'; +import type { Options as SwcTransformOptions } from '@swc/core'; + +export interface TransformRuleBase { + /** + * Predicator for transform + */ + test: (path: string, code: string) => boolean; + /** + * Transformer options + */ + options: T | ((path: string, code: string) => T); +} + +export type BabelTransformRule = TransformRuleBase; +export type SwcTransformRule = TransformRuleBase; diff --git a/packages/utils/lib/env.ts b/packages/shared/lib/utils.ts similarity index 100% rename from packages/utils/lib/env.ts rename to packages/shared/lib/utils.ts diff --git a/packages/utils/package.json b/packages/shared/package.json similarity index 82% rename from packages/utils/package.json rename to packages/shared/package.json index a3092323..49e2f9a5 100644 --- a/packages/utils/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { - "name": "@react-native-esbuild/utils", + "name": "@react-native-esbuild/shared", "version": "0.1.0-beta.12", - "description": "utilities for @react-native-esbuild", + "description": "shared data and utilities for @react-native-esbuild", "keywords": [ "react-native", "esbuild" @@ -37,12 +37,16 @@ "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" }, "devDependencies": { + "@babel/core": "^7.23.2", "@faker-js/faker": "^8.1.0", + "@swc/core": "^1.3.95", + "@types/md5": "^2.3.5", "esbuild": "^0.19.5" }, "dependencies": { "colors": "^1.4.0", "dayjs": "^1.11.10", + "md5": "^2.3.0", "node-self": "^1.0.2" } } diff --git a/packages/config/tsconfig.json b/packages/shared/tsconfig.json similarity index 88% rename from packages/config/tsconfig.json rename to packages/shared/tsconfig.json index 83cc53a6..bd42e358 100644 --- a/packages/config/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": ".", "outDir": "./dist", + "declaration": true, "emitDeclarationOnly": true }, "include": ["./lib"], diff --git a/packages/symbolicate/lib/symbolicate.ts b/packages/symbolicate/lib/symbolicate.ts index de99916c..45d6f9d1 100644 --- a/packages/symbolicate/lib/symbolicate.ts +++ b/packages/symbolicate/lib/symbolicate.ts @@ -65,7 +65,6 @@ const originalPositionFor = ( ); return { ...targetFrame, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Allow for `Object.entries()`. ...(value ? { [targetKey]: value } : null), }; }, @@ -101,7 +100,7 @@ const getCodeFrame = ( const source = sourcemapConsumer.sourceContentFor(frame.file); const { lineNumber, column, file } = frame; return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment -- `codeFrameColumns` type isn't defined. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- `codeFrameColumns` type isn't defined. content: codeFrameColumns( source, { diff --git a/packages/transformer/README.md b/packages/transformer/README.md index 2982f484..29ac1ee5 100644 --- a/packages/transformer/README.md +++ b/packages/transformer/README.md @@ -7,11 +7,9 @@ import { stripFlowWithSucrase, transformWithBabel, transformWithSwc, - minifyWithSwc, } from '@react-native-esbuild/transformer'; await stripFlowWithSucrase(code, context); await transformWithBabel(code, context, options); await transformWithSwc(code, context, options); -await minifyWithSwc(code, context, options); ``` diff --git a/packages/transformer/lib/common/builder.ts b/packages/transformer/lib/common/builder.ts new file mode 100644 index 00000000..7fa6ddb0 --- /dev/null +++ b/packages/transformer/lib/common/builder.ts @@ -0,0 +1,35 @@ +import type { BuildContext } from '@react-native-esbuild/shared'; +import { getReactNativeInitializeCore } from '@react-native-esbuild/internal'; +import { AsyncTransformPipeline } from '../pipelines'; +import { swcPresets } from '../transformer'; + +export const getCommonReactNativeRuntimePipelineBuilder = ( + context: BuildContext, +): InstanceType => { + const { + stripFlowPackageNames = [], + fullyTransformPackageNames = [], + additionalTransformRules, + } = context.config.transformer ?? {}; + const additionalBabelRules = additionalTransformRules?.babel ?? []; + const additionalSwcRules = additionalTransformRules?.swc ?? []; + const injectScriptPaths = [ + getReactNativeInitializeCore(context.root), + // `hmr/runtime` should import after `initializeCore` initialized. + context.hmrEnabled ? '@react-native-esbuild/hmr/runtime' : undefined, + ].filter(Boolean) as string[]; + + const builder = new AsyncTransformPipeline.builder({ + dev: context.bundleOptions.dev, + entry: context.bundleOptions.entry, + root: context.root, + }) + .setSwcPreset(swcPresets.getReactNativeRuntimePreset()) + .setInjectScripts(injectScriptPaths) + .setFullyTransformPackages(fullyTransformPackageNames) + .setStripFlowPackages(stripFlowPackageNames) + .setAdditionalBabelTransformRules(additionalBabelRules) + .setAdditionalSwcTransformRules(additionalSwcRules); + + return builder; +}; diff --git a/packages/transformer/lib/common/index.ts b/packages/transformer/lib/common/index.ts new file mode 100644 index 00000000..ecea700b --- /dev/null +++ b/packages/transformer/lib/common/index.ts @@ -0,0 +1 @@ +export * from './builder'; diff --git a/packages/transformer/lib/helpers/transformer.ts b/packages/transformer/lib/helpers/transformer.ts index 4e4877b3..04bda9e8 100644 --- a/packages/transformer/lib/helpers/transformer.ts +++ b/packages/transformer/lib/helpers/transformer.ts @@ -1,16 +1,15 @@ +import type { + BabelTransformRule, + SwcTransformRule, + TransformRuleBase, +} from '@react-native-esbuild/shared'; import { transformWithBabel, transformSyncWithBabel, transformWithSwc, transformSyncWithSwc, } from '../transformer'; -import type { - TransformRuleBase, - TransformerContext, - SwcTransformRule, - BabelTransformRule, - TransformerOptionsPreset, -} from '../types'; +import type { TransformContext, TransformerOptionsPreset } from '../types'; const ruleOptionsToPreset = ( options: TransformRuleBase['options'], @@ -24,47 +23,51 @@ const ruleOptionsToPreset = ( export const transformByBabelRule = ( rule: BabelTransformRule, code: string, - context: TransformerContext, + context: TransformContext, ): Promise => { return rule.test(context.path, code) - ? transformWithBabel(code, context, ruleOptionsToPreset(rule.options, code)) + ? transformWithBabel(code, { + context, + preset: ruleOptionsToPreset(rule.options, code), + }) : Promise.resolve(null); }; export const transformSyncByBabelRule = ( rule: BabelTransformRule, code: string, - context: TransformerContext, + context: TransformContext, ): string | null => { return rule.test(context.path, code) - ? transformSyncWithBabel( - code, + ? transformSyncWithBabel(code, { context, - ruleOptionsToPreset(rule.options, code), - ) + preset: ruleOptionsToPreset(rule.options, code), + }) : null; }; export const transformBySwcRule = ( rule: SwcTransformRule, code: string, - context: TransformerContext, + context: TransformContext, ): Promise => { return rule.test(context.path, code) - ? transformWithSwc(code, context, ruleOptionsToPreset(rule.options, code)) + ? transformWithSwc(code, { + context, + preset: ruleOptionsToPreset(rule.options, code), + }) : Promise.resolve(null); }; export const transformSyncBySwcRule = ( rule: SwcTransformRule, code: string, - context: TransformerContext, + context: TransformContext, ): string | null => { return rule.test(context.path, code) - ? transformSyncWithSwc( - code, + ? transformSyncWithSwc(code, { context, - ruleOptionsToPreset(rule.options, code), - ) + preset: ruleOptionsToPreset(rule.options, code), + }) : null; }; diff --git a/packages/transformer/lib/index.ts b/packages/transformer/lib/index.ts index eef47918..eca6e441 100644 --- a/packages/transformer/lib/index.ts +++ b/packages/transformer/lib/index.ts @@ -1,4 +1,5 @@ export * from './transformer'; export * from './pipelines'; export * from './helpers'; +export * from './common'; export type * from './types'; diff --git a/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts b/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts deleted file mode 100644 index e3330a0c..00000000 --- a/packages/transformer/lib/pipelines/AsyncTransformPipeline.ts +++ /dev/null @@ -1,152 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs/promises'; -import type { OnLoadArgs } from 'esbuild'; -import { - transformWithBabel, - transformWithSwc, - stripFlowWithSucrase, -} from '../transformer'; -import { transformByBabelRule, transformBySwcRule } from '../helpers'; -import type { AsyncTransformStep, ModuleMeta, TransformResult } from '../types'; -import { TransformPipeline } from './pipeline'; -import { TransformPipelineBuilder } from './builder'; - -export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< - AsyncTransformStep, - AsyncTransformPipeline -> { - build(): AsyncTransformPipeline { - const pipeline = new AsyncTransformPipeline(this.context); - - this.onBefore && pipeline.beforeTransform(this.onBefore); - this.onAfter && pipeline.afterTransform(this.onAfter); - - // 1. Inject initializeCore and specified scripts to entry file. - if (this.injectScriptPaths.length) { - const entryFile = path.resolve(this.context.root, this.context.entry); - pipeline.addStep((code, args) => { - return Promise.resolve({ - code: - args.path === entryFile - ? this.combineInjectScripts(code, this.injectScriptPaths) - : code, - done: false, - }); - }); - } - - // 2. Fully transform and skip other steps. - const fullyTransformPackagesRegExp = this.getNodePackageRegExp( - this.fullyTransformPackageNames, - ); - if (fullyTransformPackagesRegExp) { - pipeline.addStep(async (code, args) => { - if (fullyTransformPackagesRegExp.test(args.path)) { - return { - code: await transformWithBabel( - code, - this.getContext(args), - this.presets.babelFullyTransform, - ), - // skip other transformations when fully transformed - done: true, - }; - } - return { code, done: false }; - }); - } - - // 3. Strip flow syntax. - const stripFlowPackageNamesRegExp = this.getNodePackageRegExp( - this.stripFlowPackageNames, - ); - if (stripFlowPackageNamesRegExp) { - pipeline.addStep((code, args) => { - if ( - stripFlowPackageNamesRegExp.test(args.path) || - this.isFlow(code, args.path) - ) { - code = stripFlowWithSucrase(code, this.getContext(args)); - } - - return Promise.resolve({ code, done: false }); - }); - } - - // 4. Apply additional babel rules. - if (this.additionalBabelRules.length) { - pipeline.addStep(async (code, args) => { - const context = this.getContext(args); - for await (const rule of this.additionalBabelRules) { - code = (await transformByBabelRule(rule, code, context)) ?? code; - } - return { code, done: false }; - }); - } - - // 5. Apply additional swc rules. - if (this.additionalSwcRules.length) { - pipeline.addStep(async (code, args) => { - const context = this.getContext(args); - for await (const rule of this.additionalSwcRules) { - code = (await transformBySwcRule(rule, code, context)) ?? code; - } - return { code, done: false }; - }); - } - - // 6. Transform code to es5. - pipeline.addStep(async (code, args) => { - return { - code: await transformWithSwc( - code, - this.getContext(args), - this.swcPreset, - ), - done: true, - }; - }); - - return pipeline; - } -} - -export class AsyncTransformPipeline extends TransformPipeline { - public static builder = AsyncTransformPipelineBuilder; - protected steps: AsyncTransformStep[] = []; - protected onBeforeTransform?: AsyncTransformStep; - protected onAfterTransform?: AsyncTransformStep; - - async transform(code: string, args: OnLoadArgs): Promise { - const fileStat = await fs.stat(args.path); - const moduleMeta: ModuleMeta = { - stats: fileStat, - hash: this.getHash(this.context.id, args.path, fileStat.mtimeMs), - }; - - const before: AsyncTransformStep = (code, args) => { - return this.onBeforeTransform - ? this.onBeforeTransform(code, args, moduleMeta) - : Promise.resolve({ code, done: false }); - }; - - const after: AsyncTransformStep = (code, args) => { - return this.onAfterTransform - ? this.onAfterTransform(code, args, moduleMeta) - : Promise.resolve({ code, done: true }); - }; - - const result = await this.steps.reduce( - (prev, curr) => { - return Promise.resolve(prev).then((prevResult) => - prevResult.done - ? Promise.resolve({ code: prevResult.code, done: true }) - : curr(prevResult.code, args, moduleMeta), - ); - }, - before(code, args, moduleMeta), - ); - - return after(result.code, args, moduleMeta); - } -} diff --git a/packages/transformer/lib/pipelines/AsyncTransformPipeline/AsyncTransformPipeline.ts b/packages/transformer/lib/pipelines/AsyncTransformPipeline/AsyncTransformPipeline.ts new file mode 100644 index 00000000..b0ab82a0 --- /dev/null +++ b/packages/transformer/lib/pipelines/AsyncTransformPipeline/AsyncTransformPipeline.ts @@ -0,0 +1,131 @@ +import { + transformWithBabel, + transformWithSwc, + stripFlowWithSucrase, +} from '../../transformer'; +import { transformByBabelRule, transformBySwcRule } from '../../helpers'; +import type { + AsyncTransformStep, + BaseTransformContext, + TransformContext, + TransformResult, +} from '../../types'; +import { TransformPipeline } from '../TransformPipeline'; +import { TransformPipelineBuilder } from '../TransformPipelineBuilder'; + +export class AsyncTransformPipelineBuilder extends TransformPipelineBuilder< + AsyncTransformStep, + AsyncTransformPipeline +> { + build(): AsyncTransformPipeline { + const pipeline = new AsyncTransformPipeline(this.baseContext); + + this.beforeTransformStep && pipeline.addStep(this.beforeTransformStep); + + // 1. Inject initializeCore and specified scripts to entry file. + if (this.injectScriptPaths.length) { + pipeline.addStep((code, context) => { + return Promise.resolve({ + code: context.pluginData?.isEntryPoint + ? this.combineInjectScripts(code, this.injectScriptPaths) + : code, + done: false, + }); + }); + } + + // 2. Fully transform and skip other steps. + const fullyTransformPackagesRegExp = this.getNodePackageRegExp( + this.fullyTransformPackageNames, + ); + if (fullyTransformPackagesRegExp) { + pipeline.addStep(async (code, context) => { + if (fullyTransformPackagesRegExp.test(context.path)) { + return { + code: await transformWithBabel(code, { + context, + preset: this.presets.babelFullyTransform, + }), + // skip other transformations when fully transformed + done: true, + }; + } + return { code, done: false }; + }); + } + + // 3. Strip flow syntax. + const stripFlowPackageNamesRegExp = this.getNodePackageRegExp( + this.stripFlowPackageNames, + ); + if (stripFlowPackageNamesRegExp) { + pipeline.addStep((code, context) => { + if ( + stripFlowPackageNamesRegExp.test(context.path) || + this.isFlow(code, context.path) + ) { + code = stripFlowWithSucrase(code, { context }); + } + + return Promise.resolve({ code, done: false }); + }); + } + + // 4. Apply additional babel rules. + if (this.additionalBabelRules.length) { + pipeline.addStep(async (code, context) => { + for await (const rule of this.additionalBabelRules) { + code = (await transformByBabelRule(rule, code, context)) ?? code; + } + return { code, done: false }; + }); + } + + // 5. Apply additional swc rules. + if (this.additionalSwcRules.length) { + pipeline.addStep(async (code, context) => { + for await (const rule of this.additionalSwcRules) { + code = (await transformBySwcRule(rule, code, context)) ?? code; + } + return { code, done: false }; + }); + } + + // 6. Transform code to es5. + pipeline.addStep(async (code, context) => { + return { + code: await transformWithSwc(code, { + context, + preset: this.swcPreset, + }), + done: true, + }; + }); + + this.afterTransformStep && pipeline.addStep(this.afterTransformStep); + + return pipeline; + } +} + +export class AsyncTransformPipeline extends TransformPipeline { + public static builder = AsyncTransformPipelineBuilder; + protected steps: AsyncTransformStep[] = []; + + async transform( + code: string, + context: Omit, + ): Promise { + const transformContext = { ...this.baseContext, ...context }; + const transformResult = await this.steps.reduce( + (prev, curr) => { + return prev.then((result) => + result.done ? result : curr(result.code, transformContext), + ); + }, + Promise.resolve({ code, done: false } as TransformResult), + ); + + return transformResult; + } +} diff --git a/packages/transformer/lib/pipelines/AsyncTransformPipeline/index.ts b/packages/transformer/lib/pipelines/AsyncTransformPipeline/index.ts new file mode 100644 index 00000000..2719ba0f --- /dev/null +++ b/packages/transformer/lib/pipelines/AsyncTransformPipeline/index.ts @@ -0,0 +1 @@ +export * from './AsyncTransformPipeline'; diff --git a/packages/transformer/lib/pipelines/HMRTransformPipeline/HMRTransformPipeline.ts b/packages/transformer/lib/pipelines/HMRTransformPipeline/HMRTransformPipeline.ts new file mode 100644 index 00000000..29675053 --- /dev/null +++ b/packages/transformer/lib/pipelines/HMRTransformPipeline/HMRTransformPipeline.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises'; +import { isExternal, type DependencyGraph } from 'esbuild-dependency-graph'; +import { + colors, + type ModuleId, + type BuildContext, +} from '@react-native-esbuild/shared'; +import { + asHMRUpdateCall, + asFallbackBoundary, + type HMRTransformResult, +} from '@react-native-esbuild/hmr'; +import type { AsyncTransformPipeline } from '../AsyncTransformPipeline'; +import { getCommonReactNativeRuntimePipelineBuilder } from '../../common'; +import { logger } from '../../shared'; + +export class HMRTransformPipeline { + private pipeline: AsyncTransformPipeline; + private dependencyGraph: DependencyGraph | null = null; + + constructor(private buildContext: BuildContext) { + this.pipeline = + getCommonReactNativeRuntimePipelineBuilder(buildContext).build(); + } + + public setDependencyGraph(dependencyGraph: DependencyGraph): void { + this.dependencyGraph = dependencyGraph; + } + + public async transformDelta(id: ModuleId): Promise { + try { + performance.mark(`hmr:build:${id}`); + const transformResult = await this.transform(id); + const { code, dependencies } = transformResult; + const { duration } = performance.measure( + 'hmr:build-duration', + `hmr:build:${id}`, + ); + logger.info( + [ + colors.gray(`+ ${dependencies} module(s)`), + 'transformed in', + colors.cyan(`${Math.floor(duration)}ms`), + ].join(' '), + ); + return { id, code, fullyReload: false }; + } catch (error) { + logger.error('unable to transform runtime modules', error as Error); + return { id, code: '', fullyReload: true }; + } + } + + private async transform( + id: ModuleId, + ): Promise<{ code: string; dependencies: number }> { + const dependencyGraph = this.dependencyGraph; + if (!dependencyGraph) { + throw new Error('dependency graph is not initialized'); + } + + const inverseDependencies = dependencyGraph.inverseDependenciesOf(id); + const transformedCodes = await Promise.all( + [id, ...inverseDependencies].map(async (moduleId) => { + const module = dependencyGraph.getModule(moduleId); + + if (isExternal(module)) { + logger.debug('external module found', { path: module.path }); + return ''; + } + + const rawCode = await fs.readFile(module.path, 'utf-8'); + logger.debug(`${colors.cyan(module.path)} imports`); + + const { code } = await this.pipeline.transform(rawCode, { + id: moduleId, + path: module.path, + pluginData: { + isEntryPoint: false, + externalPattern: this.buildContext.additionalData.externalPattern, + }, + }); + + return asHMRUpdateCall(id, code); + }), + ); + + return { + code: asFallbackBoundary(transformedCodes.join('\n')), + dependencies: inverseDependencies.length - 1, + }; + } +} diff --git a/packages/transformer/lib/pipelines/HMRTransformPipeline/index.ts b/packages/transformer/lib/pipelines/HMRTransformPipeline/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/transformer/lib/pipelines/SyncTransformPipeline.ts b/packages/transformer/lib/pipelines/SyncTransformPipeline.ts deleted file mode 100644 index 8eef2f51..00000000 --- a/packages/transformer/lib/pipelines/SyncTransformPipeline.ts +++ /dev/null @@ -1,161 +0,0 @@ -import path from 'node:path'; -import fs from 'node:fs'; -import type { OnLoadArgs } from 'esbuild'; -import { - transformSyncWithBabel, - transformSyncWithSwc, - stripFlowWithSucrase, -} from '../transformer'; -import { transformSyncByBabelRule, transformSyncBySwcRule } from '../helpers'; -import type { SyncTransformStep, TransformResult, ModuleMeta } from '../types'; -import { TransformPipeline } from './pipeline'; -import { TransformPipelineBuilder } from './builder'; - -export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< - SyncTransformStep, - SyncTransformPipeline -> { - build(): SyncTransformPipeline { - const pipeline = new SyncTransformPipeline(this.context); - - this.onBefore && pipeline.beforeTransform(this.onBefore); - this.onAfter && pipeline.afterTransform(this.onAfter); - - // 1. Inject initializeCore and specified scripts to entry file. - if (this.injectScriptPaths.length) { - const entryFile = path.resolve(this.context.root, this.context.entry); - pipeline.addStep((code, args) => { - return { - code: - args.path === entryFile - ? this.combineInjectScripts(code, this.injectScriptPaths) - : code, - done: false, - }; - }); - } - - // 2. Fully transform and skip other steps. - const fullyTransformPackagesRegExp = this.getNodePackageRegExp( - this.fullyTransformPackageNames, - ); - if (fullyTransformPackagesRegExp) { - pipeline.addStep((code, args) => { - if (fullyTransformPackagesRegExp.test(args.path)) { - return { - code: transformSyncWithBabel( - code, - this.getContext(args), - this.presets.babelFullyTransform, - ), - // skip other transformations when fully transformed - done: true, - }; - } - return { code, done: false }; - }); - } - - // 3. Strip flow syntax. - const stripFlowPackageNamesRegExp = this.getNodePackageRegExp( - this.stripFlowPackageNames, - ); - if (stripFlowPackageNamesRegExp) { - pipeline.addStep((code, args) => { - if ( - stripFlowPackageNamesRegExp.test(args.path) || - this.isFlow(code, args.path) - ) { - code = stripFlowWithSucrase(code, this.getContext(args)); - } - - return { code, done: false }; - }); - } - - // 4. Apply additional babel rules. - if (this.additionalBabelRules.length) { - pipeline.addStep((code, args) => { - const context = this.getContext(args); - for (const rule of this.additionalBabelRules) { - code = transformSyncByBabelRule(rule, code, context) ?? code; - } - return { code, done: false }; - }); - } - - // 5. Apply additional swc rules. - if (this.additionalSwcRules.length) { - pipeline.addStep((code, args) => { - const context = this.getContext(args); - for (const rule of this.additionalSwcRules) { - code = transformSyncBySwcRule(rule, code, context) ?? code; - } - return { code, done: false }; - }); - } - - // 6. Transform code to es5. - pipeline.addStep((code, args) => { - return { - code: transformSyncWithSwc(code, this.getContext(args), this.swcPreset), - done: true, - }; - }); - - return pipeline; - } -} - -export class SyncTransformPipeline extends TransformPipeline { - public static builder = SyncTransformPipelineBuilder; - protected steps: SyncTransformStep[] = []; - protected onBeforeTransform?: SyncTransformStep; - protected onAfterTransform?: SyncTransformStep; - - beforeTransform(onBeforeTransform: SyncTransformStep): this { - this.onBeforeTransform = onBeforeTransform; - return this; - } - - afterTransform(onAfterTransform: SyncTransformStep): this { - this.onAfterTransform = onAfterTransform; - return this; - } - - addStep(runner: SyncTransformStep): this { - this.steps.push(runner); - return this; - } - - transform(code: string, args: OnLoadArgs): TransformResult { - const fileStat = fs.statSync(args.path); - const moduleMeta: ModuleMeta = { - stats: fileStat, - hash: this.getHash(this.context.id, args.path, fileStat.mtimeMs), - }; - - const before: SyncTransformStep = (code, args) => { - return this.onBeforeTransform - ? this.onBeforeTransform(code, args, moduleMeta) - : { code, done: false }; - }; - - const after: SyncTransformStep = (code, args) => { - return this.onAfterTransform - ? this.onAfterTransform(code, args, moduleMeta) - : { code, done: true }; - }; - - const result = this.steps.reduce( - (prev, curr) => { - return prev.done - ? { code: prev.code, done: true } - : curr(prev.code, args, moduleMeta); - }, - before(code, args, moduleMeta), - ); - - return after(result.code, args, moduleMeta); - } -} diff --git a/packages/transformer/lib/pipelines/SyncTransformPipeline/SyncTransformPipeline.ts b/packages/transformer/lib/pipelines/SyncTransformPipeline/SyncTransformPipeline.ts new file mode 100644 index 00000000..1b9a76be --- /dev/null +++ b/packages/transformer/lib/pipelines/SyncTransformPipeline/SyncTransformPipeline.ts @@ -0,0 +1,135 @@ +import { + transformSyncWithBabel, + transformSyncWithSwc, + stripFlowWithSucrase, +} from '../../transformer'; +import { + transformSyncByBabelRule, + transformSyncBySwcRule, +} from '../../helpers'; +import type { + SyncTransformStep, + TransformResult, + TransformContext, + BaseTransformContext, +} from '../../types'; +import { TransformPipeline } from '../TransformPipeline'; +import { TransformPipelineBuilder } from '../TransformPipelineBuilder'; + +export class SyncTransformPipelineBuilder extends TransformPipelineBuilder< + SyncTransformStep, + SyncTransformPipeline +> { + build(): SyncTransformPipeline { + const pipeline = new SyncTransformPipeline(this.baseContext); + + this.beforeTransformStep && pipeline.addStep(this.beforeTransformStep); + + // 1. Inject initializeCore and specified scripts to entry file. + if (this.injectScriptPaths.length) { + pipeline.addStep((code, context) => { + return { + code: context.pluginData?.isEntryPoint + ? this.combineInjectScripts(code, this.injectScriptPaths) + : code, + done: false, + }; + }); + } + + // 2. Fully transform and skip other steps. + const fullyTransformPackagesRegExp = this.getNodePackageRegExp( + this.fullyTransformPackageNames, + ); + if (fullyTransformPackagesRegExp) { + pipeline.addStep((code, context) => { + if (fullyTransformPackagesRegExp.test(context.path)) { + return { + code: transformSyncWithBabel(code, { + context, + preset: this.presets.babelFullyTransform, + }), + // skip other transformations when fully transformed + done: true, + }; + } + return { code, done: false }; + }); + } + + // 3. Strip flow syntax. + const stripFlowPackageNamesRegExp = this.getNodePackageRegExp( + this.stripFlowPackageNames, + ); + if (stripFlowPackageNamesRegExp) { + pipeline.addStep((code, context) => { + if ( + stripFlowPackageNamesRegExp.test(context.path) || + this.isFlow(code, context.path) + ) { + code = stripFlowWithSucrase(code, { context }); + } + + return { code, done: false }; + }); + } + + // 4. Apply additional babel rules. + if (this.additionalBabelRules.length) { + pipeline.addStep((code, context) => { + for (const rule of this.additionalBabelRules) { + code = transformSyncByBabelRule(rule, code, context) ?? code; + } + return { code, done: false }; + }); + } + + // 5. Apply additional swc rules. + if (this.additionalSwcRules.length) { + pipeline.addStep((code, context) => { + for (const rule of this.additionalSwcRules) { + code = transformSyncBySwcRule(rule, code, context) ?? code; + } + return { code, done: false }; + }); + } + + // 6. Transform code to es5. + pipeline.addStep((code, context) => { + return { + code: transformSyncWithSwc(code, { + context, + preset: this.swcPreset, + }), + done: true, + }; + }); + + this.afterTransformStep && pipeline.addStep(this.afterTransformStep); + + return pipeline; + } +} + +export class SyncTransformPipeline extends TransformPipeline { + public static builder = SyncTransformPipelineBuilder; + protected steps: SyncTransformStep[] = []; + + addStep(runner: SyncTransformStep): this { + this.steps.push(runner); + return this; + } + + transform( + code: string, + context: Omit, + ): TransformResult { + const transformContext = { ...this.baseContext, ...context }; + const transformResult = this.steps.reduce( + (prev, curr) => (prev.done ? prev : curr(prev.code, transformContext)), + { code, done: false } as TransformResult, + ); + + return transformResult; + } +} diff --git a/packages/transformer/lib/pipelines/SyncTransformPipeline/index.ts b/packages/transformer/lib/pipelines/SyncTransformPipeline/index.ts new file mode 100644 index 00000000..bf66b0eb --- /dev/null +++ b/packages/transformer/lib/pipelines/SyncTransformPipeline/index.ts @@ -0,0 +1 @@ +export * from './SyncTransformPipeline'; diff --git a/packages/transformer/lib/pipelines/TransformPipeline.ts b/packages/transformer/lib/pipelines/TransformPipeline.ts new file mode 100644 index 00000000..cc8c47ba --- /dev/null +++ b/packages/transformer/lib/pipelines/TransformPipeline.ts @@ -0,0 +1,21 @@ +import type { + BaseTransformContext, + TransformStep, + TransformContext, +} from '../types'; + +export abstract class TransformPipeline> { + protected steps: Step[] = []; + + constructor(protected baseContext: BaseTransformContext) {} + + public addStep(step: Step): this { + this.steps.push(step); + return this; + } + + abstract transform( + code: string, + context: Omit, + ): ReturnType; +} diff --git a/packages/transformer/lib/pipelines/builder.ts b/packages/transformer/lib/pipelines/TransformPipelineBuilder.ts similarity index 82% rename from packages/transformer/lib/pipelines/builder.ts rename to packages/transformer/lib/pipelines/TransformPipelineBuilder.ts index 366f9e28..d4243e8a 100644 --- a/packages/transformer/lib/pipelines/builder.ts +++ b/packages/transformer/lib/pipelines/TransformPipelineBuilder.ts @@ -1,14 +1,15 @@ -import type { OnLoadArgs } from 'esbuild'; import type { Options as SwcTransformOptions } from '@swc/core'; import type { BabelTransformRule, SwcTransformRule, +} from '@react-native-esbuild/shared'; +import type { + BaseTransformContext, TransformStep, - TransformerContext, TransformerOptionsPreset, } from '../types'; import { babelPresets } from '../transformer'; -import type { TransformPipeline } from './pipeline'; +import type { TransformPipeline } from './TransformPipeline'; const FLOW_SYMBOL = ['@flow', '@noflow'] as const; @@ -19,8 +20,8 @@ export abstract class TransformPipelineBuilder< protected presets = { babelFullyTransform: babelPresets.getFullyTransformPreset(), }; - protected onBefore?: Step; - protected onAfter?: Step; + protected beforeTransformStep?: Step; + protected afterTransformStep?: Step; protected swcPreset?: TransformerOptionsPreset; protected injectScriptPaths: string[] = []; protected fullyTransformPackageNames: string[] = []; @@ -28,7 +29,7 @@ export abstract class TransformPipelineBuilder< protected additionalBabelRules: BabelTransformRule[] = []; protected additionalSwcRules: SwcTransformRule[] = []; - constructor(protected context: Omit) {} + constructor(protected baseContext: BaseTransformContext) {} protected getNodePackageRegExp(packageNames: string[]): RegExp | null { return packageNames.length @@ -36,10 +37,6 @@ export abstract class TransformPipelineBuilder< : null; } - protected getContext(args: OnLoadArgs): TransformerContext { - return { ...this.context, path: args.path }; - } - protected combineInjectScripts( code: string, injectScriptPaths: string[], @@ -57,13 +54,13 @@ export abstract class TransformPipelineBuilder< ); } - public onStart(transformer: Step): this { - this.onBefore = transformer; + public beforeTransform(transformStep: Step): this { + this.beforeTransformStep = transformStep; return this; } - public onEnd(transformer: Step): this { - this.onAfter = transformer; + public afterTransform(transformStep: Step): this { + this.afterTransformStep = transformStep; return this; } diff --git a/packages/transformer/lib/pipelines/pipeline.ts b/packages/transformer/lib/pipelines/pipeline.ts deleted file mode 100644 index 84c84c3e..00000000 --- a/packages/transformer/lib/pipelines/pipeline.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { OnLoadArgs } from 'esbuild'; -import md5 from 'md5'; -import type { TransformStep, TransformerContext } from '../types'; - -export abstract class TransformPipeline> { - protected steps: Step[] = []; - protected onBeforeTransform?: Step; - protected onAfterTransform?: Step; - - constructor(protected context: Omit) {} - - /** - * Generate hash that contains the file path, modification time, and bundle options. - * - * `id` is combined(platform, dev, minify) bundle options value in `@react-native-esbuild`. - * - * hash = md5(id + modified time + file path + core version) - * number + number + string + string - */ - protected getHash(id: number, filepath: string, modifiedAt: number): string { - return md5(id + modifiedAt + filepath + self._version); - } - - public beforeTransform(onBeforeTransform: Step): this { - this.onBeforeTransform = onBeforeTransform; - return this; - } - - public afterTransform(onAfterTransform: Step): this { - this.onAfterTransform = onAfterTransform; - return this; - } - - public addStep(step: Step): this { - this.steps.push(step); - return this; - } - - abstract transform(code: string, args: OnLoadArgs): ReturnType; -} diff --git a/packages/transformer/lib/shared.ts b/packages/transformer/lib/shared.ts new file mode 100644 index 00000000..d3a96fbb --- /dev/null +++ b/packages/transformer/lib/shared.ts @@ -0,0 +1,3 @@ +import { Logger } from '@react-native-esbuild/shared'; + +export const logger = new Logger('transformer'); diff --git a/packages/transformer/lib/transformer/babel/babel.ts b/packages/transformer/lib/transformer/babel/babel.ts index 95261d9c..0a5fba78 100644 --- a/packages/transformer/lib/transformer/babel/babel.ts +++ b/packages/transformer/lib/transformer/babel/babel.ts @@ -3,11 +3,11 @@ import { loadOptions, transformAsync, transformSync } from '@babel/core'; import type { AsyncTransformer, SyncTransformer, - TransformerContext, + TransformContext, } from '../../types'; const loadBabelOptions = ( - context: TransformerContext, + context: TransformContext, options?: TransformOptions, ): ReturnType => { return loadOptions({ @@ -19,8 +19,7 @@ const loadBabelOptions = ( export const transformWithBabel: AsyncTransformer = async ( code: string, - context, - preset, + { context, preset }, ) => { const babelOptions = loadBabelOptions(context, preset?.(context)); if (!babelOptions) { @@ -37,8 +36,7 @@ export const transformWithBabel: AsyncTransformer = async ( export const transformSyncWithBabel: SyncTransformer = ( code: string, - context, - preset, + { context, preset }, ) => { const babelOptions = loadBabelOptions(context, preset?.(context)); if (!babelOptions) { diff --git a/packages/transformer/lib/transformer/sucrase/sucrase.ts b/packages/transformer/lib/transformer/sucrase/sucrase.ts index a0d5baf2..5fc752ee 100644 --- a/packages/transformer/lib/transformer/sucrase/sucrase.ts +++ b/packages/transformer/lib/transformer/sucrase/sucrase.ts @@ -10,7 +10,10 @@ const stripFlowTypeofImportStatements = (code: string): string => { .join('\n'); }; -export const stripFlowWithSucrase: SyncTransformer = (code, context) => { +export const stripFlowWithSucrase: SyncTransformer = ( + code, + { context }, +) => { return stripFlowTypeofImportStatements( transform(code, { transforms: TRANSFORM_FOR_STRIP_FLOW, diff --git a/packages/transformer/lib/transformer/swc/presets.ts b/packages/transformer/lib/transformer/swc/presets.ts index 62eb43f2..e5a432bb 100644 --- a/packages/transformer/lib/transformer/swc/presets.ts +++ b/packages/transformer/lib/transformer/swc/presets.ts @@ -1,12 +1,13 @@ -import type { - Options, - JscConfig, - TsParserConfig, - EsParserConfig, -} from '@swc/core'; +import type { Options, TsParserConfig, EsParserConfig } from '@swc/core'; +import { + REACT_REFRESH_GET_SIGNATURE_FUNCTION, + REACT_REFRESH_REGISTER_FUNCTION, + isHMRBoundary, +} from '@react-native-esbuild/hmr'; import type { TransformerOptionsPreset, SwcJestPresetOptions, + SwcMinifyPresetOptions, } from '../../types'; const getParserOptions = (path: string): TsParserConfig | EsParserConfig => { @@ -26,27 +27,51 @@ const getParserOptions = (path: string): TsParserConfig | EsParserConfig => { /** * swc transform options preset for react-native runtime. */ -const getReactNativeRuntimePreset = ( - jscConfig?: Pick, -): TransformerOptionsPreset => { - return (context) => ({ - minify: false, - sourceMaps: false, - isModule: true, - inputSourceMap: false, - inlineSourcesContent: false, - jsc: { - parser: getParserOptions(context.path), - target: 'es5', - loose: false, - externalHelpers: true, - keepClassNames: true, - transform: jscConfig?.transform, - experimental: jscConfig?.experimental, - }, - filename: context.path, - root: context.root, - }); +const getReactNativeRuntimePreset = (): TransformerOptionsPreset => { + return (context) => { + const hmrEnabled = isHMRBoundary(context.path); + return { + minify: false, + sourceMaps: false, + isModule: true, + inputSourceMap: false, + inlineSourcesContent: false, + jsc: { + parser: getParserOptions(context.path), + target: 'es5', + loose: false, + externalHelpers: !context.dev, + keepClassNames: true, + transform: { + react: { + development: context.dev, + refresh: hmrEnabled + ? { + refreshReg: REACT_REFRESH_REGISTER_FUNCTION, + refreshSig: REACT_REFRESH_GET_SIGNATURE_FUNCTION, + } + : undefined, + }, + }, + experimental: { + plugins: hmrEnabled + ? [ + [ + 'swc-plugin-global-module', + { + runtimeModule: context.pluginData?.isRuntimeModule, + externalPattern: context.pluginData?.externalPattern, + importPaths: context.pluginData?.importPaths, + }, + ], + ] + : undefined, + }, + }, + filename: context.path, + root: context.root, + } as Options; + }; }; const getJestPreset = ( @@ -95,11 +120,21 @@ const getJestPreset = ( }); }; -const getMinifyPreset = () => { - return () => ({ - compress: true, - mangle: true, - sourceMap: false, +const getMinifyPreset = ({ + minify, +}: SwcMinifyPresetOptions): TransformerOptionsPreset => { + return (context) => ({ + minify, + inputSourceMap: false, + inlineSourcesContent: false, + jsc: { + parser: getParserOptions(context.path), + target: 'es5', + loose: false, + keepClassNames: true, + }, + filename: context.path, + root: context.root, }); }; diff --git a/packages/transformer/lib/transformer/swc/swc.ts b/packages/transformer/lib/transformer/swc/swc.ts index 9fe79f90..59b12b85 100644 --- a/packages/transformer/lib/transformer/swc/swc.ts +++ b/packages/transformer/lib/transformer/swc/swc.ts @@ -1,16 +1,9 @@ -import { - transform, - transformSync, - minify, - type Options, - type JsMinifyOptions, -} from '@swc/core'; +import { transform, transformSync, type Options } from '@swc/core'; import type { AsyncTransformer, SyncTransformer } from '../../types'; export const transformWithSwc: AsyncTransformer = async ( code, - context, - preset, + { context, preset }, ) => { const { code: transformedCode } = await transform(code, preset?.(context)); @@ -23,8 +16,7 @@ export const transformWithSwc: AsyncTransformer = async ( export const transformSyncWithSwc: SyncTransformer = ( code, - context, - preset, + { context, preset }, ) => { const { code: transformedCode } = transformSync(code, preset?.(context)); @@ -34,17 +26,3 @@ export const transformSyncWithSwc: SyncTransformer = ( return transformedCode; }; - -export const minifyWithSwc: AsyncTransformer = async ( - code, - context, - preset, -) => { - const { code: minifiedCode } = await minify(code, preset?.(context)); - - if (typeof minifiedCode !== 'string') { - throw new Error('swc minified source is empty'); - } - - return minifiedCode; -}; diff --git a/packages/transformer/lib/types.ts b/packages/transformer/lib/types.ts index 52591bbe..67b1642f 100644 --- a/packages/transformer/lib/types.ts +++ b/packages/transformer/lib/types.ts @@ -1,37 +1,58 @@ -import type { Stats } from 'node:fs'; import type { OnLoadArgs } from 'esbuild'; -import type { TransformOptions as BabelTransformOptions } from '@babel/core'; -import type { Options as SwcTransformOptions } from '@swc/core'; +import type { ModuleId } from '@react-native-esbuild/shared'; export type AsyncTransformer = ( code: string, - context: TransformerContext, - preset?: TransformerOptionsPreset, + config: TransformerConfig, ) => Promise; export type SyncTransformer = ( code: string, - context: TransformerContext, - preset?: TransformerOptionsPreset, + config: TransformerConfig, ) => string; -export interface TransformerContext { - id: number; +interface TransformerConfig { + context: TransformContext; + preset?: TransformerOptionsPreset; +} + +export interface BaseTransformContext { root: string; entry: string; dev: boolean; +} + +export interface TransformContext extends BaseTransformContext { + id: ModuleId; path: string; + /** + * @internal + * + * - `react-native-runtime-transform-plugin` + * - `mtimeMs`, `hash`, `externalPattern` + * - `HMRTransformPipeline` + * - `externalPattern` + * + * ```ts + * interface PluginData { + * // Set modified at timestamp. + * mtimeMs?: number; + * // Set hash value when cache enabled. + * hash?: string; + * // Set map value when HMR enabled. + * importPaths?: Record; + * // Set `true` if it's entry-point module. + * isEntryPoint?: boolean; + * } + * ``` + */ + pluginData: OnLoadArgs['pluginData']; } export type TransformerOptionsPreset = ( - context: TransformerContext, + context: TransformContext, ) => TransformerOptions; -// swc preset options -export interface SwcReactNativeRuntimePresetOptions { - reactRefresh?: { moduleId: string }; -} - export interface SwcJestPresetOptions { module?: 'cjs' | 'esm'; experimental?: { @@ -63,25 +84,14 @@ export interface SwcJestPresetOptions { }; } -export interface TransformRuleBase { - /** - * Predicator for transform - */ - test: (path: string, code: string) => boolean; - /** - * Transformer options - */ - options: T | ((path: string, code: string) => T); +export interface SwcMinifyPresetOptions { + minify: boolean; } -export type BabelTransformRule = TransformRuleBase; -export type SwcTransformRule = TransformRuleBase; - // TransformPipelineBuilder export type TransformStep = ( code: string, - args: OnLoadArgs, - moduleMeta: ModuleMeta, + context: TransformContext, ) => Result; export type AsyncTransformStep = TransformStep>; @@ -91,8 +101,3 @@ export interface TransformResult { code: string; done: boolean; } - -export interface ModuleMeta { - hash: string; - stats: Stats; -} diff --git a/packages/transformer/package.json b/packages/transformer/package.json index 4c10fcb3..6f3e920b 100644 --- a/packages/transformer/package.json +++ b/packages/transformer/package.json @@ -38,17 +38,19 @@ "url": "https://github.com/leegeunhyeok/react-native-esbuild/issues" }, "devDependencies": { - "@types/md5": "^2.3.4", "esbuild": "^0.19.5" }, "dependencies": { "@babel/core": "^7.23.2", - "@react-native-esbuild/config": "workspace:*", + "@react-native-esbuild/hmr": "workspace:*", + "@react-native-esbuild/internal": "workspace:^", + "@react-native-esbuild/shared": "workspace:*", "@swc/core": "^1.3.95", "@swc/helpers": "^0.5.3", - "md5": "^2.3.0", + "esbuild-dependency-graph": "^0.3.0", "sucrase": "^3.34.0", "swc-plugin-coverage-instrument": "^0.0.20", + "swc-plugin-global-module": "^0.1.0-alpha.5", "swc_mut_cjs_exports": "^0.85.0" } } diff --git a/packages/utils/build/index.js b/packages/utils/build/index.js deleted file mode 100644 index 11d3d095..00000000 --- a/packages/utils/build/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const esbuild = require('esbuild'); -const { getEsbuildBaseOptions } = require('../../../shared'); - -const buildOptions = getEsbuildBaseOptions(__dirname); - -esbuild.build(buildOptions).catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/packages/utils/lib/index.ts b/packages/utils/lib/index.ts deleted file mode 100644 index 64ae3a2a..00000000 --- a/packages/utils/lib/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import colors from 'colors'; -import { isCI, isTTY } from './env'; - -(isCI() || !isTTY()) && colors.disable(); - -export * as colors from 'colors'; -export * from './env'; -export { Logger, LogLevel } from './logger'; diff --git a/shared/index.js b/shared/index.js index 931f6fc5..33c3386d 100644 --- a/shared/index.js +++ b/shared/index.js @@ -1,15 +1,24 @@ const path = require('node:path'); /** - * @param {string} entryFile filename + * @param {string} packageDir build script directory * @param {import('esbuild').BuildOptions} options additional options * @returns {import('esbuild').BuildOptions} */ -exports.getEsbuildBaseOptions = (packageDir, options = {}) => ({ - entryPoints: [path.resolve(packageDir, '../lib/index.ts')], +const getEsbuildBaseOptions = (packageDir, options = {}) => ({ + entryPoints: [path.join(getPackageRoot(packageDir), 'lib/index.ts')], outfile: 'dist/index.js', bundle: true, platform: 'node', packages: 'external', ...options, }); + +/** + * @param {string} packageDir build script directory + * @returns package root path + */ +const getPackageRoot = (packageDir) => path.resolve(packageDir, '../'); + +exports.getEsbuildBaseOptions = getEsbuildBaseOptions; +exports.getPackageRoot = getPackageRoot; diff --git a/yarn.lock b/yarn.lock index 42602e43..66a45d0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3621,11 +3621,10 @@ __metadata: version: 0.0.0-use.local resolution: "@react-native-esbuild/cli@workspace:packages/cli" dependencies: - "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" "@react-native-esbuild/dev-server": "workspace:*" "@react-native-esbuild/plugins": "workspace:*" - "@react-native-esbuild/utils": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" "@types/yargs": ^17.0.24 esbuild: ^0.19.5 yargs: ^17.7.2 @@ -3635,28 +3634,22 @@ __metadata: languageName: unknown linkType: soft -"@react-native-esbuild/config@workspace:*, @react-native-esbuild/config@workspace:packages/config": - version: 0.0.0-use.local - resolution: "@react-native-esbuild/config@workspace:packages/config" - dependencies: - esbuild: ^0.19.5 - languageName: unknown - linkType: soft - "@react-native-esbuild/core@workspace:*, @react-native-esbuild/core@workspace:packages/core": version: 0.0.0-use.local resolution: "@react-native-esbuild/core@workspace:packages/core" dependencies: "@babel/core": ^7.23.2 "@faker-js/faker": ^8.1.0 - "@react-native-esbuild/config": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" + "@react-native-esbuild/plugins": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" - "@react-native-esbuild/utils": "workspace:*" "@swc/core": ^1.3.95 chokidar: ^3.5.3 deepmerge: ^4.3.1 esbuild: ^0.19.5 + esbuild-dependency-graph: ^0.3.0 invariant: ^2.2.4 ora: ^5.4.1 peerDependencies: @@ -3670,11 +3663,11 @@ __metadata: dependencies: "@faker-js/faker": ^8.1.0 "@react-native-community/cli-server-api": ^11.3.6 - "@react-native-esbuild/config": "workspace:*" "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" "@react-native-esbuild/symbolicate": "workspace:*" - "@react-native-esbuild/utils": "workspace:*" "@types/connect": ^3.4.35 "@types/invariant": ^2.2.36 "@types/mime": ^3.0.1 @@ -3702,11 +3695,24 @@ __metadata: languageName: unknown linkType: soft -"@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:packages/internal": +"@react-native-esbuild/hmr@workspace:*, @react-native-esbuild/hmr@workspace:packages/hmr": + version: 0.0.0-use.local + resolution: "@react-native-esbuild/hmr@workspace:packages/hmr" + dependencies: + "@react-native-esbuild/internal": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" + "@swc/helpers": ^0.5.2 + "@types/react-refresh": ^0.14.3 + esbuild: ^0.19.3 + react-refresh: ^0.14.0 + ws: ^8.14.2 + languageName: unknown + linkType: soft + +"@react-native-esbuild/internal@workspace:*, @react-native-esbuild/internal@workspace:^, @react-native-esbuild/internal@workspace:packages/internal": version: 0.0.0-use.local resolution: "@react-native-esbuild/internal@workspace:packages/internal" dependencies: - "@react-native-esbuild/config": "workspace:*" esbuild: ^0.19.5 indent-string: ^4.0.0 languageName: unknown @@ -3720,6 +3726,7 @@ __metadata: "@jest/transform": ^29.7.0 "@react-native-esbuild/core": "workspace:*" "@react-native-esbuild/internal": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" "@types/md5": ^2.3.2 esbuild: ^0.19.5 @@ -3734,20 +3741,37 @@ __metadata: resolution: "@react-native-esbuild/plugins@workspace:packages/plugins" dependencies: "@faker-js/faker": ^8.1.0 - "@react-native-esbuild/config": "workspace:*" - "@react-native-esbuild/core": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" "@react-native-esbuild/internal": "workspace:*" + "@react-native-esbuild/shared": "workspace:*" "@react-native-esbuild/transformer": "workspace:*" - "@react-native-esbuild/utils": "workspace:*" "@svgr/core": ^8.1.0 "@svgr/plugin-jsx": ^8.1.0 "@types/invariant": ^2.2.36 "@types/md5": ^2.3.2 deepmerge: ^4.3.1 esbuild: ^0.19.5 + esbuild-plugin-module-id: ^0.1.3 image-size: ^1.0.2 invariant: ^2.2.4 md5: ^2.3.0 + ora: ^5.4.1 + languageName: unknown + linkType: soft + +"@react-native-esbuild/shared@workspace:*, @react-native-esbuild/shared@workspace:packages/shared": + version: 0.0.0-use.local + resolution: "@react-native-esbuild/shared@workspace:packages/shared" + dependencies: + "@babel/core": ^7.23.2 + "@faker-js/faker": ^8.1.0 + "@swc/core": ^1.3.95 + "@types/md5": ^2.3.5 + colors: ^1.4.0 + dayjs: ^1.11.10 + esbuild: ^0.19.5 + md5: ^2.3.0 + node-self: ^1.0.2 languageName: unknown linkType: soft @@ -3766,30 +3790,20 @@ __metadata: resolution: "@react-native-esbuild/transformer@workspace:packages/transformer" dependencies: "@babel/core": ^7.23.2 - "@react-native-esbuild/config": "workspace:*" + "@react-native-esbuild/hmr": "workspace:*" + "@react-native-esbuild/internal": "workspace:^" + "@react-native-esbuild/shared": "workspace:*" "@swc/core": ^1.3.95 "@swc/helpers": ^0.5.3 - "@types/md5": ^2.3.4 esbuild: ^0.19.5 - md5: ^2.3.0 + esbuild-dependency-graph: ^0.3.0 sucrase: ^3.34.0 swc-plugin-coverage-instrument: ^0.0.20 + swc-plugin-global-module: ^0.1.0-alpha.5 swc_mut_cjs_exports: ^0.85.0 languageName: unknown linkType: soft -"@react-native-esbuild/utils@workspace:*, @react-native-esbuild/utils@workspace:packages/utils": - version: 0.0.0-use.local - resolution: "@react-native-esbuild/utils@workspace:packages/utils" - dependencies: - "@faker-js/faker": ^8.1.0 - colors: ^1.4.0 - dayjs: ^1.11.10 - esbuild: ^0.19.5 - node-self: ^1.0.2 - languageName: unknown - linkType: soft - "@react-native/assets-registry@npm:^0.72.0": version: 0.72.0 resolution: "@react-native/assets-registry@npm:0.72.0" @@ -4414,7 +4428,7 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:^0.5.3": +"@swc/helpers@npm:^0.5.2, @swc/helpers@npm:^0.5.3": version: 0.5.3 resolution: "@swc/helpers@npm:0.5.3" dependencies: @@ -4541,7 +4555,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:*, @types/babel__core@npm:^7.1.14": version: 7.20.3 resolution: "@types/babel__core@npm:7.20.3" dependencies: @@ -4743,13 +4757,20 @@ __metadata: languageName: node linkType: hard -"@types/md5@npm:^2.3.2, @types/md5@npm:^2.3.4": +"@types/md5@npm:^2.3.2": version: 2.3.4 resolution: "@types/md5@npm:2.3.4" checksum: 9a724277b2e4b5c6fce4d34d2e2baab821ef47904eccdcbd389fb9973f2e5198f71a4aa98ddcf092fe32457be307d2f3d3f1e099a53d1642c6e569ade5450548 languageName: node linkType: hard +"@types/md5@npm:^2.3.5": + version: 2.3.5 + resolution: "@types/md5@npm:2.3.5" + checksum: a86baf0521006e3072488bd79089b84831780866102e5e4b4f7afabfab17e0270a3791f3331776b73efb2cc9317efd56a334fd3d2698c7929e9b18593ca3fd39 + languageName: node + linkType: hard + "@types/mdast@npm:^3.0.0": version: 3.0.15 resolution: "@types/mdast@npm:3.0.15" @@ -4840,6 +4861,16 @@ __metadata: languageName: node linkType: hard +"@types/react-refresh@npm:^0.14.3": + version: 0.14.3 + resolution: "@types/react-refresh@npm:0.14.3" + dependencies: + "@types/babel__core": "*" + csstype: ^3.0.2 + checksum: ff48c18f928f213cfd1fa379f6b275e401adad87f909a83501a7026075a1d596ef2697269a9ce4fc5063841622ea2c2ffe9c332a2cc9e5ca67199f05c7994f89 + languageName: node + linkType: hard + "@types/react-test-renderer@npm:^18.0.5": version: 18.0.5 resolution: "@types/react-test-renderer@npm:18.0.5" @@ -8201,7 +8232,21 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.19.5": +"esbuild-dependency-graph@npm:^0.3.0": + version: 0.3.0 + resolution: "esbuild-dependency-graph@npm:0.3.0" + checksum: 831634605db2efaf5ee321b2db13fd5a832a47bf7b0f5e769471ae0f83082841448f55027e4f87a0e1a92e25615fff27ca96f187912e1e6422128d7d74fd7678 + languageName: node + linkType: hard + +"esbuild-plugin-module-id@npm:^0.1.3": + version: 0.1.3 + resolution: "esbuild-plugin-module-id@npm:0.1.3" + checksum: 7ae649388483063b426ea7c4be985e12cf0f992d3504a41d204303be403c61e0876e10b655e73a3243c4ed40eb6059a9143d9135a374de44a32bb65d0c167d31 + languageName: node + linkType: hard + +"esbuild@npm:^0.19.3, esbuild@npm:^0.19.5": version: 0.19.5 resolution: "esbuild@npm:0.19.5" dependencies: @@ -17694,6 +17739,13 @@ __metadata: languageName: node linkType: hard +"swc-plugin-global-module@npm:^0.1.0-alpha.5": + version: 0.1.0-alpha.5 + resolution: "swc-plugin-global-module@npm:0.1.0-alpha.5" + checksum: 10523c54644544a989bdd77cf45a273f529d191816d8c5a4cb992267845048d66125e720a71bca49ae2ab47fcd051077f024d00cf4cf31030eeac1c67082ac18 + languageName: node + linkType: hard + "swc_mut_cjs_exports@npm:^0.85.0": version: 0.85.0 resolution: "swc_mut_cjs_exports@npm:0.85.0" @@ -19122,7 +19174,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0": +"ws@npm:^8.13.0, ws@npm:^8.14.2": version: 8.14.2 resolution: "ws@npm:8.14.2" peerDependencies: