diff --git a/incubator/esbuild-service/README.md b/incubator/esbuild-service/README.md new file mode 100644 index 0000000000..de44ea5980 --- /dev/null +++ b/incubator/esbuild-service/README.md @@ -0,0 +1,205 @@ +# @rnx-kit/esbuild-service + +[![Build](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml/badge.svg)](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml) + +🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 + +### This tool is EXPERIMENTAL - USE WITH CAUTION + +🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧 + +A Metro-independent, esbuild-based bundler for React Native. + +## Motivation: Metro vs. esbuild + +[Metro](https://facebook.github.io/metro/) is the standard bundler for React +Native. It is reliable, battle-tested, and deeply integrated into the React +Native toolchain. However, Metro was designed around CommonJS semantics and +Babel transformations. This makes it slower at scale and harder to integrate +with modern tooling. + +[esbuild](https://esbuild.github.io/) is an extremely fast JavaScript bundler +written in Go. It handles TypeScript and JSX natively, provides excellent tree- +shaking, and produces source maps with minimal overhead. + +This package explores using esbuild as a **complete replacement for Metro** +rather than just its serialization step (which is what +[`@rnx-kit/metro-serializer-esbuild`](../packages/metro-serializer-esbuild) +does). + +--- + +## Metro component analysis + +The table below maps each Metro component to its esbuild equivalent and +explains how much code must be reimplemented. + +| Metro component | Can esbuild replace it? | Notes | +|---|---|---| +| **Transformer** (Babel / Flow) | βœ… Yes β€” natively | esbuild supports TypeScript and JSX out of the box. Flow types can be stripped with a simple plugin. Babel is no longer needed for the common case. | +| **Dependency graph** | βœ… Yes β€” natively | esbuild builds its own dependency graph as part of bundling. | +| **Tree-shaking** | βœ… Yes β€” natively | esbuild performs dead code elimination (DCE) automatically for ESM code. | +| **Minifier** | βœ… Yes β€” natively | esbuild has a built-in, high-performance minifier. | +| **Source maps** | βœ… Yes β€” natively | esbuild generates linked or inline source maps. | +| **Serializer** | βœ… Yes β€” natively | esbuild produces the final bundle; this is the role of `metro-serializer-esbuild`. | +| **Resolver** (platform extensions, `react-native` field) | ⚠️ Plugin required | The `reactNativeResolver` plugin in this package reimplements Metro's platform-extension resolution (`.ios.js`, `.android.js`, `.native.js`) and the `react-native` β†’ `module` β†’ `browser` β†’ `main` field priority from `package.json`. ~250 lines of code. | +| **Pre-modules / polyfills** | ⚠️ Plugin required | The `reactNativePolyfills` plugin reimplements Metro's `preModules` mechanism by injecting a virtual entry-point that sets up `global`, `__DEV__`, and any user-provided polyfills. ~110 lines of code. | +| **Asset handling** | βœ… Plugin included | The `reactNativeAssets` plugin transforms image/font imports into `registerAsset()` calls, reusing Metro's own `getAssetData()` implementation to discover scale variants, compute hashes, and read image dimensions. Asset files are copied to the output directory using `@rnx-kit/metro-service`. ~250 lines of code. | +| **Dev server + HMR** | ❌ Cannot replace | Metro's development server implements React Native's fast-refresh / HMR protocol. esbuild has a basic HTTP server mode but no HMR support. | +| **RAM bundles** | ❌ Cannot replace | Metro's indexed RAM bundle format has no esbuild equivalent. | +| **Lazy module loading** | ❌ Cannot replace | Metro's async require / lazy-loading mechanism requires a custom module loader runtime that esbuild does not provide. | + +### Code reuse from `@rnx-kit/metro-serializer-esbuild` + +| Component | Reuse? | Notes | +|---|---|---| +| `targets.ts` β€” Hermes target inference | βœ… Copied | Identical logic; infers the right `hermesX.Y` esbuild target from the installed `react-native` version. | +| `getSideEffects` from `module.ts` | βœ… Concept reused | The `sideEffects` package.json field logic applies equally to a standalone esbuild bundler; esbuild respects it natively through its own side-effects handling. | +| `esbuildTransformerConfig` | ❌ Not applicable | That export configures Metro's Babel transformer to be esbuild-friendly. It is not relevant when Metro is removed entirely. | +| `index.ts` β€” the custom serializer | ❌ Not applicable | The serializer depends on Metro's dependency graph API and cannot be reused. | +| `sourceMap.ts` β€” Metro source map helpers | ❌ Not applicable | These helpers wrap Metro's source-map utilities; not needed without Metro. | + +--- + +## Installation + +```sh +yarn add --dev @rnx-kit/esbuild-service +``` + +## Usage + +```typescript +import { bundle } from "@rnx-kit/esbuild-service"; + +await bundle({ + entryFile: "index.js", + platform: "ios", + dev: false, + bundleOutput: "dist/main.ios.jsbundle", + sourcemapOutput: "dist/main.ios.jsbundle.map", + // Optional: copy assets to a destination directory + assetsDest: "dist/assets", +}); +``` + +## API + +### `bundle(options)` + +Bundles a React Native application using esbuild, without Metro. + +#### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `entryFile` | `string` | required | Path to the entry file. | +| `platform` | `AllPlatforms` | required | Target platform (`android`, `ios`, `macos`, `windows`, …). | +| `dev` | `boolean` | `false` | Bundle in development mode. | +| `minify` | `boolean` | `!dev` | Minify the output. | +| `bundleOutput` | `string` | required | Path to write the bundle to. | +| `sourcemapOutput` | `string` | β€” | Path to write the source map to. | +| `assetsDest` | `string` | β€” | Directory to copy asset files to after bundling. | +| `assetCatalogDest` | `string` | β€” | iOS asset catalog directory (`RNAssets.xcassets`). | +| `assetDataPlugins` | `string[]` | `[]` | Metro asset data plugins to apply. | +| `target` | `string \| string[]` | Auto-detected | esbuild target (e.g. `"hermes0.12"`). | +| `plugins` | `Plugin[]` | `[]` | Extra esbuild plugins. | +| `projectRoot` | `string` | `process.cwd()` | Project root directory. | +| `logLevel` | esbuild log level | `"warning"` | esbuild log level. | +| `drop` | esbuild drop | β€” | Drop `debugger` or `console` calls. | +| `pure` | `string[]` | β€” | Mark calls as side-effect free. | + +### `reactNativeResolver(platform, mainFields?)` + +An esbuild plugin that adds React Native–specific module resolution: + +- Platform-specific file extensions (`.ios.ts`, `.android.ts`, `.native.ts`, …) +- `react-native` β†’ `module` β†’ `browser` β†’ `main` field priority in `package.json` + +```typescript +import { reactNativeResolver } from "@rnx-kit/esbuild-service"; +import * as esbuild from "esbuild"; + +await esbuild.build({ + entryPoints: ["index.ts"], + bundle: true, + plugins: [reactNativeResolver("ios")], + outfile: "dist/bundle.js", +}); +``` + +### `reactNativePolyfills(options)` + +An esbuild plugin that injects React Native globals (`global`, `__DEV__`) and +optional polyfills as a virtual entry-point before your application code. + +```typescript +import { reactNativePolyfills } from "@rnx-kit/esbuild-service"; +import * as esbuild from "esbuild"; + +await esbuild.build({ + entryPoints: ["index.ts"], + bundle: true, + plugins: [ + reactNativePolyfills({ + entryFile: "index.ts", + dev: false, + polyfills: ["./polyfills/myPolyfill.js"], + }), + ], + outfile: "dist/bundle.js", +}); +``` + +### `reactNativeAssets(options)` + +An esbuild plugin that handles React Native asset imports (images, fonts, media +files). It calls Metro's own `getAssetData()` to discover scale variants and +collect metadata, then generates the same `registerAsset()` call that Metro's +asset transformer produces. + +The plugin attaches a `getCollectedAssets()` method to retrieve all asset data +gathered during the build, which can be passed to `@rnx-kit/metro-service`'s +`saveAssets()` to copy files to disk. + +```typescript +import { reactNativeAssets } from "@rnx-kit/esbuild-service"; +import * as esbuild from "esbuild"; + +const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: process.cwd(), + // Optional: override the asset registry module path + // assetRegistryPath: "@react-native/assets-registry/registry", +}); + +await esbuild.build({ + entryPoints: ["index.ts"], + bundle: true, + plugins: [assetsPlugin], + outfile: "dist/bundle.js", +}); + +// Retrieve collected AssetData[] to copy files to disk +const assets = assetsPlugin.getCollectedAssets(); +``` + +### `inferBuildTarget(projectRoot?)` + +Infers the appropriate esbuild target string for the installed version of +`react-native` / Hermes. + +```typescript +import { inferBuildTarget } from "@rnx-kit/esbuild-service"; + +const target = inferBuildTarget(); // e.g. "hermes0.12" +``` + +## Known Limitations + +- **Dev server / HMR** β€” use Metro for development; this package targets + production bundling only. +- **RAM bundles** β€” not supported. Use Metro if you need indexed RAM bundles. +- **Flow types** β€” esbuild cannot strip Flow types natively. You'll need a Flow- + stripping Babel transform or a third-party esbuild plugin if your code uses + Flow. diff --git a/incubator/esbuild-service/package.json b/incubator/esbuild-service/package.json new file mode 100644 index 0000000000..cb7803e9d8 --- /dev/null +++ b/incubator/esbuild-service/package.json @@ -0,0 +1,63 @@ +{ + "name": "@rnx-kit/esbuild-service", + "version": "0.0.1", + "description": "EXPERIMENTAL - USE WITH CAUTION - esbuild-based bundler for React Native (Metro-independent)", + "homepage": "https://github.com/microsoft/rnx-kit/tree/main/incubator/esbuild-service#readme", + "license": "MIT", + "author": { + "name": "Microsoft Open Source", + "email": "microsoftopensource@users.noreply.github.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rnx-kit", + "directory": "incubator/esbuild-service" + }, + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "typescript": "./src/index.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "rnx-kit-scripts build", + "format": "rnx-kit-scripts format", + "lint": "rnx-kit-scripts lint", + "test": "rnx-kit-scripts test" + }, + "dependencies": { + "@rnx-kit/metro-service": "*", + "@rnx-kit/tools-node": "^3.0.4", + "@rnx-kit/tools-react-native": "^2.3.3", + "@rnx-kit/types-bundle-config": "^1.0.0", + "esbuild": "^0.27.1" + }, + "devDependencies": { + "@rnx-kit/scripts": "*", + "@rnx-kit/tsconfig": "*", + "@types/node": "^24.0.0", + "metro": "^0.83.3", + "react-native": "^0.83.0" + }, + "engines": { + "node": ">=18.12" + }, + "experimental": true, + "peerDependencies": { + "metro": ">=0.72.0" + }, + "peerDependenciesMeta": { + "metro": { + "optional": true + } + } +} diff --git a/incubator/esbuild-service/src/bundle.ts b/incubator/esbuild-service/src/bundle.ts new file mode 100644 index 0000000000..1e405715b9 --- /dev/null +++ b/incubator/esbuild-service/src/bundle.ts @@ -0,0 +1,156 @@ +import * as esbuild from "esbuild"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { reactNativeAssets } from "./plugins/assets.ts"; +import { reactNativePolyfills } from "./plugins/polyfills.ts"; +import { reactNativeResolver } from "./plugins/resolver.ts"; +import { inferBuildTarget } from "./targets.ts"; +import type { BundleServiceOptions } from "./types.ts"; + +/** + * Bundles a React Native application using esbuild, without requiring Metro. + * + * ## Metro to esbuild mapping + * + * | Metro component | esbuild replacement | + * |--------------------------|--------------------------------------------------| + * | Transformer (Babel/Flow) | esbuild native TypeScript + JSX loader | + * | Dependency graph | esbuild native bundler | + * | Tree-shaking | esbuild native tree-shaking | + * | Minifier | esbuild native minifier | + * | Source maps | esbuild native source-map generation | + * | Resolver | `reactNativeResolver` plugin (reimplemented) | + * | Pre-modules / polyfills | `reactNativePolyfills` plugin (reimplemented) | + * | Asset handling | `reactNativeAssets` plugin (uses Metro's code) | + * + * ## What cannot be replaced by esbuild + * + * - **Dev server with HMR** - esbuild has a basic `serve` mode but does not + * implement React Native's fast-refresh / HMR protocol. + * - **RAM bundles** - Metro's indexed RAM bundle format has no esbuild + * equivalent. + * - **Lazy module loading** - Metro's built-in lazy-loading mechanism requires + * a reimplementation of the module loader runtime. + * + * @param options Bundle options. + * @returns A promise that resolves when the bundle has been written to disk. + */ +export async function bundle(options: BundleServiceOptions): Promise { + const { + entryFile, + platform, + dev = false, + minify = !dev, + bundleOutput, + sourcemapOutput, + assetsDest, + assetCatalogDest, + assetDataPlugins, + target, + plugins: extraPlugins = [], + projectRoot = process.cwd(), + logLevel = "warning", + drop, + pure, + } = options; + + const resolvedEntry = path.resolve(projectRoot, entryFile); + const resolvedOutput = path.resolve(projectRoot, bundleOutput); + const resolvedSourcemap = sourcemapOutput + ? path.resolve(projectRoot, sourcemapOutput) + : undefined; + + // Ensure the output directory exists. + fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true }); + + const buildTarget = target ?? inferBuildTarget(projectRoot); + + // Create the assets plugin so we can collect assets during the build. + const assetsPlugin = reactNativeAssets({ + platform, + projectRoot, + assetDataPlugins, + }); + + await esbuild.build({ + bundle: true, + define: { + __DEV__: JSON.stringify(dev), + __METRO_GLOBAL_PREFIX__: "''", + global: "global", + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + drop, + entryPoints: [resolvedEntry], + legalComments: "none", + logLevel, + metafile: false, + minify, + outfile: resolvedOutput, + platform: "node", + plugins: [ + reactNativePolyfills({ + entryFile: resolvedEntry, + dev, + }), + reactNativeResolver(platform), + assetsPlugin, + ...extraPlugins, + ], + pure, + sourcemap: resolvedSourcemap ? "external" : false, + target: buildTarget, + supported: (() => { + if ( + typeof buildTarget !== "string" || + !buildTarget.startsWith("hermes") + ) { + return undefined; + } + + // Hermes supports these ES6+ features even though the compatibility + // table may not list them. See the metro-serializer-esbuild package for + // the original rationale. + // + // Note: unlike metro-serializer-esbuild (which receives Babel-pre- + // processed code), this bundler passes raw TypeScript/ES6 source to + // esbuild. We therefore also mark const-and-let as supported because + // Hermes has supported block scoping since its earliest versions. + return { + arrow: true, + "const-and-let": true, + "default-argument": true, + destructuring: true, + generator: true, + "rest-argument": true, + "template-literal": true, + }; + })(), + write: true, + }); + + // Move the source map to the requested location when it differs from the + // default `.js.map` path that esbuild writes. + if (resolvedSourcemap) { + const defaultMapPath = resolvedOutput + ".map"; + if ( + defaultMapPath !== resolvedSourcemap && + fs.existsSync(defaultMapPath) + ) { + fs.renameSync(defaultMapPath, resolvedSourcemap); + } + } + + // Copy assets to the destination directory, using the same logic as Metro. + if (assetsDest) { + const metroService = await import("@rnx-kit/metro-service/assets"); + const collectedAssets = assetsPlugin.getCollectedAssets(); + await metroService.saveAssets( + collectedAssets, + platform, + assetsDest, + assetCatalogDest, + metroService.getSaveAssetsPlugin(platform, projectRoot) + ); + } +} diff --git a/incubator/esbuild-service/src/index.ts b/incubator/esbuild-service/src/index.ts new file mode 100644 index 0000000000..e49b124b73 --- /dev/null +++ b/incubator/esbuild-service/src/index.ts @@ -0,0 +1,9 @@ +export { bundle } from "./bundle.ts"; +export { reactNativeAssets } from "./plugins/assets.ts"; +export type { AssetsPluginOptions } from "./plugins/assets.ts"; +export { DEFAULT_ASSET_EXTS } from "./plugins/assets.ts"; +export { reactNativePolyfills } from "./plugins/polyfills.ts"; +export type { PolyfillsPluginOptions } from "./plugins/polyfills.ts"; +export { reactNativeResolver } from "./plugins/resolver.ts"; +export { inferBuildTarget } from "./targets.ts"; +export type { BundleServiceOptions } from "./types.ts"; diff --git a/incubator/esbuild-service/src/plugins/assets.ts b/incubator/esbuild-service/src/plugins/assets.ts new file mode 100644 index 0000000000..6919e44383 --- /dev/null +++ b/incubator/esbuild-service/src/plugins/assets.ts @@ -0,0 +1,360 @@ +import { findMetroPath } from "@rnx-kit/tools-react-native/metro"; +import type { AssetData } from "metro"; +import type { Plugin } from "esbuild"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Default asset file extensions that Metro handles as assets (non-JS files + * that get registered in the React Native asset registry). + * + * Source: `metro-config` defaults. + */ +export const DEFAULT_ASSET_EXTS = [ + // Image formats + "bmp", + "gif", + "jpg", + "jpeg", + "png", + "psd", + "svg", + "webp", + "xml", + // Video formats + "m4v", + "mov", + "mp4", + "mpeg", + "mpg", + "webm", + // Audio formats + "aac", + "aiff", + "caf", + "m4a", + "mp3", + "wav", + // Document formats + "html", + "pdf", + "yaml", + "yml", + // Font formats + "otf", + "ttf", + // Archive formats + "zip", +] as const; + +/** + * Properties that Metro's `generateAssetCodeFileAst()` strips from + * `AssetData` before registering. We apply the same filter. + */ +const ASSET_PROPERTY_BLOCKLIST = new Set(["files", "fileSystemLocation"]); + +/** + * Signature of Metro's `getAssetData` function (`metro/src/Assets.js`). + * Defined locally to avoid importing from the un-typed `metro/private/Assets` + * subpath. + */ +type MetroGetAssetData = ( + assetPath: string, + localPath: string, + assetDataPlugins: string[], + platform: string | null | undefined, + publicPath: string +) => Promise; + +/** + * Options for the React Native assets esbuild plugin. + */ +export type AssetsPluginOptions = { + /** + * Target platform (e.g. `"ios"`, `"android"`). + */ + platform: string; + + /** + * Project root directory. Used to compute relative asset paths and to + * locate Metro. + */ + projectRoot: string; + + /** + * The public URL path prefix for assets, used to compute + * `httpServerLocation`. Defaults to `"/assets"`. + */ + publicPath?: string; + + /** + * Path to the React Native asset registry module. esbuild will generate + * `require(assetRegistryPath).registerAsset(...)` calls for every asset. + * + * Defaults to `"react-native/Libraries/Image/AssetRegistry"`. + */ + assetRegistryPath?: string; + + /** + * Additional Metro asset data plugins to apply. Each entry is a module path + * whose default export is a function that receives and transforms `AssetData`. + */ + assetDataPlugins?: string[]; + + /** + * Asset file extensions to handle. Defaults to {@link DEFAULT_ASSET_EXTS}. + */ + assetExts?: readonly string[]; +}; + +/** + * Retrieves Metro's `getAssetData` function from the project's Metro + * installation. Returns `undefined` if Metro is not installed. + * + * This is the "use Metro's code if possible" part: we call into Metro's own + * asset-data API rather than reimplementing it. + */ +function tryGetMetroAssetData( + projectRoot: string +): MetroGetAssetData | undefined { + const metroPath = findMetroPath(projectRoot); + if (!metroPath) { + return undefined; + } + try { + // metro/private/* maps to metro/src/* via the package.json exports field: + // "./private/*": "./src/*.js" + const assetsModule = require( + require.resolve("metro/src/Assets", { paths: [metroPath] }) + ); + return assetsModule.getAssetData as MetroGetAssetData; + } catch (_) { + return undefined; + } +} + +/** + * Returns the asset data properties that should be included in the + * `registerAsset()` call, following the same filter Metro applies + * in `generateAssetCodeFileAst()`. + */ +function getRegisterableAssetData( + assetData: AssetData +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(assetData)) { + if (!ASSET_PROPERTY_BLOCKLIST.has(key)) { + result[key] = value; + } + } + return result; +} + +/** + * Generates the JavaScript source that Metro's asset transformer emits for a + * given asset. It is equivalent to the output of Metro's + * `generateAssetCodeFileAst()`: + * + * ```js + * module.exports = require("react-native/Libraries/Image/AssetRegistry").registerAsset({ + * __packager_asset: true, + * httpServerLocation: "/assets/path/to", + * ... + * }); + * ``` + */ +function generateAssetCode( + assetRegistryPath: string, + assetData: AssetData +): string { + const registerable = getRegisterableAssetData(assetData); + return `module.exports = require(${JSON.stringify(assetRegistryPath)}).registerAsset(${JSON.stringify(registerable)});`; +} + +/** + * An esbuild plugin that handles React Native asset imports (images, fonts, + * media files, etc.) by: + * + * 1. Intercepting imports of files with asset extensions. + * 2. Calling Metro's own `getAssetData()` function to collect the asset + * metadata (file paths, scales, dimensions, hash, etc.). + * 3. Generating the same `registerAsset(...)` call that Metro's asset + * transformer generates, so the asset registry is populated identically + * to a Metro bundle. + * 4. Exposing the collected `AssetData[]` via the `getCollectedAssets()` + * method so the caller can copy the files to the output directory. + * + * **What this replaces from Metro:** + * - Metro's asset transformer (`metro-transform-worker`'s `assetTransformer`). + * - The `assetPlugin` step that transforms asset imports into registry calls. + * + * **What this reuses from Metro:** + * - `getAssetData()` from `metro/src/Assets` β€” the canonical implementation + * that discovers all scale variants (`@2x`, `@3x`), computes the MD5 + * hash, and reads image dimensions. If Metro is not installed in the + * project, a minimal fallback implementation is used instead. + * + * @param options Plugin configuration. + * @returns An esbuild `Plugin` with an extra `getCollectedAssets()` method. + */ +export function reactNativeAssets( + options: AssetsPluginOptions +): Plugin & { getCollectedAssets(): readonly AssetData[] } { + const { + platform, + projectRoot, + publicPath = "/assets", + assetRegistryPath = "react-native/Libraries/Image/AssetRegistry", + assetDataPlugins = [], + assetExts = DEFAULT_ASSET_EXTS, + } = options; + + const collectedAssets: AssetData[] = []; + const getAssetData = tryGetMetroAssetData(projectRoot); + + // Build a regex that matches any of the asset extensions. + const extPattern = assetExts + .map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const assetFilter = new RegExp(`\\.(${extPattern})$`, "i"); + + // Build the esbuild Plugin object with only the two recognised properties + // (name + setup), then attach getCollectedAssets as a non-enumerable method + // so that esbuild's option-validation loop does not reject it. + const plugin: Plugin = { + name: "@rnx-kit/esbuild-service:react-native-assets", + + setup(build) { + build.onLoad({ filter: assetFilter }, async (args) => { + const absolutePath = args.path; + const relativePath = path.relative(projectRoot, absolutePath); + + let assetData: AssetData; + if (getAssetData) { + // Use Metro's own getAssetData() implementation. + assetData = await getAssetData( + absolutePath, + relativePath, + assetDataPlugins, + platform, + publicPath + ); + } else { + // Minimal fallback when Metro is not available in the project. + assetData = await getAssetDataFallback( + absolutePath, + relativePath, + platform, + publicPath + ); + } + + collectedAssets.push(assetData); + + return { + contents: generateAssetCode(assetRegistryPath, assetData), + loader: "js", + }; + }); + }, + }; + + // Attach getCollectedAssets as a non-enumerable property so that esbuild's + // plugin-option validation (which iterates enumerable keys) does not reject + // it as an unknown option. + Object.defineProperty(plugin, "getCollectedAssets", { + enumerable: false, + configurable: true, + writable: true, + value(): readonly AssetData[] { + return collectedAssets; + }, + }); + + return plugin as Plugin & { getCollectedAssets(): readonly AssetData[] }; +} + +// --------------------------------------------------------------------------- +// Fallback implementation (used when Metro is not installed) +// --------------------------------------------------------------------------- + +/** + * A minimal implementation of Metro's `getAssetData()` for cases where Metro + * is not installed in the project. It only supports single-scale assets and + * does not compute image dimensions. + */ +async function getAssetDataFallback( + absolutePath: string, + relativePath: string, + platform: string, + publicPath: string +): Promise { + const crypto = await import("node:crypto"); + + const dir = path.dirname(absolutePath); + const ext = path.extname(absolutePath).slice(1); + const base = path.basename(absolutePath, `.${ext}`); + + // Strip scale suffix from base name if present (e.g. "icon@2x" -> "icon") + const nameMatch = base.match(/^(.+?)(?:@(\d+(?:\.\d+)?)x)?$/); + const name = nameMatch ? nameMatch[1] : base; + const scaleStr = nameMatch?.[2]; + const scale = scaleStr ? parseFloat(scaleStr) : 1; + + // Discover all scale variants in the same directory + const dirFiles = fs.readdirSync(dir); + const scales: number[] = []; + const scaleFiles: string[] = []; + + const platformSuffix = `.${platform}`; + for (const file of dirFiles.sort()) { + const fileExt = path.extname(file).slice(1); + if (fileExt !== ext) { + continue; + } + const fileBase = path.basename(file, `.${fileExt}`); + // Match e.g. "icon", "icon.ios", "icon@2x", "icon.ios@2x" + const match = fileBase.match( + new RegExp( + `^${escapeRegExp(name)}(?:${escapeRegExp(platformSuffix)})?(?:@(\\d+(?:\\.\\d+)?)x)?$` + ) + ); + if (!match) { + continue; + } + const s = match[1] ? parseFloat(match[1]) : 1; + scales.push(s); + scaleFiles.push(path.join(dir, file)); + } + + if (scales.length === 0) { + scales.push(scale); + scaleFiles.push(absolutePath); + } + + // Compute an MD5 hash of the primary file + const content = fs.readFileSync(absolutePath); + const hash = crypto.createHash("md5").update(content).digest("hex"); + + // Compute httpServerLocation from the relative directory of the asset + const relDir = path.dirname(relativePath); + const httpServerLocation = `${publicPath}/${relDir}` + .replace(/\\/g, "/") + .replace(/\/+/g, "/") + .replace(/\/$/, ""); + + return { + __packager_asset: true, + fileSystemLocation: dir, + httpServerLocation, + hash, + name, + type: ext, + scales, + files: scaleFiles, + }; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/incubator/esbuild-service/src/plugins/index.ts b/incubator/esbuild-service/src/plugins/index.ts new file mode 100644 index 0000000000..56e94fe713 --- /dev/null +++ b/incubator/esbuild-service/src/plugins/index.ts @@ -0,0 +1,6 @@ +export { reactNativeAssets } from "./assets.ts"; +export type { AssetsPluginOptions } from "./assets.ts"; +export { DEFAULT_ASSET_EXTS } from "./assets.ts"; +export { reactNativePolyfills } from "./polyfills.ts"; +export type { PolyfillsPluginOptions } from "./polyfills.ts"; +export { reactNativeResolver } from "./resolver.ts"; diff --git a/incubator/esbuild-service/src/plugins/polyfills.ts b/incubator/esbuild-service/src/plugins/polyfills.ts new file mode 100644 index 0000000000..471c0b1cdd --- /dev/null +++ b/incubator/esbuild-service/src/plugins/polyfills.ts @@ -0,0 +1,109 @@ +import type { Plugin } from "esbuild"; + +/** + * The virtual module path used as the synthetic entry-point that injects React + * Native globals and polyfills before your application code runs. + * + * @internal + */ +export const POLYFILLS_VIRTUAL_ENTRY = "rnx-esbuild-polyfills:entry"; + +/** Namespace used to scope the virtual polyfills module within esbuild. */ +const POLYFILLS_NAMESPACE = "rnx-esbuild-polyfills"; + +/** Filter regex that matches only the virtual entry path. */ +const VIRTUAL_ENTRY_FILTER = /^rnx-esbuild-polyfills:entry$/; + +/** + * Options for the React Native polyfills plugin. + */ +export type PolyfillsPluginOptions = { + /** + * The real entry file path (e.g. `index.js`). + */ + entryFile: string; + + /** + * Whether bundling in development mode. When `true`, `__DEV__` is set to + * `true` in the output; otherwise `false`. + */ + dev: boolean; + + /** + * Additional polyfill module paths to `require()` before the entry file. + * These are executed in the order provided. + */ + polyfills?: string[]; +}; + +/** + * An esbuild plugin that injects the React Native global scope setup and any + * extra polyfills as a virtual entry-point module. + * + * **What this replaces from Metro:** + * - Metro's `preModules` (pre-bundle initialization modules such as + * `InitializeCore.js`, error guard polyfills, etc.). + * - Metro's `__prelude__` virtual module that sets up `global` and + * `__METRO_GLOBAL_PREFIX__`. + * - The `runBeforeMainModule` option in Metro's serializer that ensures + * `InitializeCore.js` runs before user code. + * + * The plugin works by redirecting esbuild to use a synthetic virtual module as + * the entry-point. This virtual module: + * 1. Sets `global` to `globalThis` (React Native modules assume `global` + * is the global object, which is not guaranteed in strict ES environments). + * 2. Requires any additional `polyfills` supplied by the caller. + * 3. Finally, requires the real entry file. + * + * @param options Plugin configuration. + */ +export function reactNativePolyfills( + options: PolyfillsPluginOptions +): Plugin { + const { entryFile, dev, polyfills = [] } = options; + + function escapePath(p: string): string { + return p.replace(/\\/g, "\\\\"); + } + + const virtualContents = [ + // Ensure `global` is available as React Native modules rely on it. + 'var global = typeof globalThis !== "undefined" ? globalThis : typeof global !== "undefined" ? global : typeof window !== "undefined" ? window : new Function("return this;")();', + + // Expose __DEV__ as a global so it is available without bundler define + // transforms (though we also pass it via esbuild `define`). + `var __DEV__ = ${JSON.stringify(dev)};`, + + // Additional polyfills + ...polyfills.map((p) => `require("${escapePath(p)}");`), + + // Entry file - must always be last. + `require("${escapePath(entryFile)}");`, + ].join("\n"); + + return { + name: "@rnx-kit/esbuild-service:react-native-polyfills", + setup(build) { + const { initialOptions } = build; + + // Redirect the original entry point to the virtual polyfills module. + const originalEntry = initialOptions.entryPoints; + if (Array.isArray(originalEntry)) { + initialOptions.entryPoints = [POLYFILLS_VIRTUAL_ENTRY]; + } + + build.onResolve({ filter: VIRTUAL_ENTRY_FILTER }, () => ({ + path: POLYFILLS_VIRTUAL_ENTRY, + namespace: POLYFILLS_NAMESPACE, + })); + + build.onLoad( + { filter: VIRTUAL_ENTRY_FILTER, namespace: POLYFILLS_NAMESPACE }, + () => ({ + contents: virtualContents, + resolveDir: process.cwd(), + }) + ); + }, + }; +} diff --git a/incubator/esbuild-service/src/plugins/resolver.ts b/incubator/esbuild-service/src/plugins/resolver.ts new file mode 100644 index 0000000000..3a99dc8512 --- /dev/null +++ b/incubator/esbuild-service/src/plugins/resolver.ts @@ -0,0 +1,254 @@ +import { expandPlatformExtensions } from "@rnx-kit/tools-react-native"; +import type { Plugin } from "esbuild"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Base JS file extensions that esbuild will try, in prioritized order. + */ +const BASE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"]; + +/** + * Checks whether the given path refers to a directory that contains a + * `package.json` file. + */ +function isPackageDir(p: string): boolean { + return fs.existsSync(path.join(p, "package.json")); +} + +/** + * Reads the `react-native` or `main` field from a `package.json` file. + * + * Metro (and the React Native ecosystem) prioritises the `react-native` field + * in `package.json` over the standard `main` field so that packages can ship + * source files or non-CommonJS builds specifically for React Native. + */ +function getPackageMainField( + pkgDir: string, + mainFields: readonly string[] +): string | undefined { + const pkgJsonPath = path.join(pkgDir, "package.json"); + try { + const manifest = JSON.parse( + fs.readFileSync(pkgJsonPath, { encoding: "utf-8" }) + ); + for (const field of mainFields) { + const value = manifest[field]; + if (typeof value === "string" && value) { + return value; + } + } + } catch (_) { + // ignore + } + return undefined; +} + +/** + * Attempts to resolve `filePath` by trying platform-specific extensions first, + * then falling back to base extensions. + * + * @example + * For `platform = "ios"` the order tried is: + * `.ios.tsx`, `.ios.ts`, `.ios.jsx`, `.ios.js`, + * `.native.tsx`, `.native.ts`, `.native.jsx`, `.native.js`, + * `.tsx`, `.ts`, `.jsx`, `.js` + */ +function tryResolveFile( + filePath: string, + platform: string +): string | undefined { + const extensions = expandPlatformExtensions(platform, BASE_EXTENSIONS); + for (const ext of extensions) { + const candidate = filePath + ext; + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +/** + * Resolves a bare module specifier (e.g. `react-native`) to an absolute path, + * applying React Native field priority and platform extension logic. + */ +function resolvePackage( + moduleName: string, + resolveDir: string, + platform: string, + mainFields: readonly string[] +): string | undefined { + // Use Node's require.resolve to find the package directory + let pkgJsonPath: string | undefined; + try { + pkgJsonPath = require.resolve(`${moduleName}/package.json`, { + paths: [resolveDir], + }); + } catch (_) { + try { + // Some packages don't have an explicit package.json export β€” try to + // locate it by resolving the package's index file and walking up. + const main = require.resolve(moduleName, { paths: [resolveDir] }); + let dir = path.dirname(main); + while (dir !== path.dirname(dir)) { + if (isPackageDir(dir)) { + pkgJsonPath = path.join(dir, "package.json"); + break; + } + dir = path.dirname(dir); + } + } catch (_) { + // ignore + } + } + + if (!pkgJsonPath) { + return undefined; + } + + const pkgDir = path.dirname(pkgJsonPath); + const mainField = getPackageMainField(pkgDir, mainFields); + if (!mainField) { + return undefined; + } + + const mainPath = path.resolve(pkgDir, mainField); + + // First try the exact path from the main field. + if (fs.existsSync(mainPath)) { + return mainPath; + } + + // Then try adding platform/base extensions. + return tryResolveFile(mainPath, platform); +} + +/** + * An esbuild plugin that replicates React Native's module resolution strategy: + * + * 1. **Platform-specific file extensions** β€” when resolving a file `foo`, it + * tries (for `platform = "ios"`) `foo.ios.ts`, `foo.ios.tsx`, `foo.ios.js`, + * `foo.native.ts`, `foo.native.tsx`, `foo.native.js`, and then the plain + * equivalents, before deferring to esbuild's built-in resolver. + * + * 2. **`react-native` field in `package.json`** β€” when resolving a package + * `react-native` relies on (e.g. `react-native` itself, or community + * packages that ship a React Native–specific entry), the resolver checks the + * `react-native` field in `package.json` before the standard `main` field. + * The precedence order mirrors Metro's default `resolverMainFields`: + * `react-native` β†’ `module` β†’ `browser` β†’ `main`. + * + * Parts of Metro that this plugin **replaces**: + * - `metro-resolver` – the core module resolution logic. + * - Platform-extension expansion from `metro-config`'s default resolver + * configuration. + * + * Parts of Metro that esbuild handles **natively** (no plugin required): + * - TypeScript / JSX transformation. + * - Module bundling and dependency graph construction. + * - Tree-shaking (dead code elimination). + * - Minification. + * - Source-map generation. + * + * Parts of Metro that **cannot** be replaced by esbuild (out of scope): + * - Dev server with Hot Module Replacement (HMR). + * - RAM bundle (indexed bundle) format. + * - Lazy module loading. + * + * @param platform The target React Native platform. + * @param mainFields The `package.json` fields to check in priority order. + * Defaults to `["react-native", "module", "browser", "main"]`, which + * matches Metro's default resolver configuration. + */ +export function reactNativeResolver( + platform: string, + mainFields: readonly string[] = ["react-native", "module", "browser", "main"] +): Plugin { + return { + name: "@rnx-kit/esbuild-service:react-native-resolver", + setup(build) { + // Intercept ALL resolution attempts so we can check for platform-specific + // file variants before esbuild's default resolver runs. + build.onResolve({ filter: /.*/ }, (args) => { + const { path: importPath, resolveDir, kind } = args; + + // Skip esbuild internals and data URIs. + if ( + !resolveDir || + importPath.startsWith("data:") || + importPath.startsWith("<") + ) { + return undefined; + } + + // Skip absolute paths – esbuild handles them fine and we'd just need to + // replicate the same platform extension logic which is covered below for + // relative imports. + if (path.isAbsolute(importPath)) { + // Still apply platform extension expansion for absolute paths. + const resolved = tryResolveFile(importPath, platform); + if (resolved) { + return { path: resolved }; + } + if (fs.existsSync(importPath)) { + return { path: importPath }; + } + return undefined; + } + + // Relative import (./foo, ../bar) – apply platform extension expansion. + if (importPath.startsWith(".")) { + const base = path.resolve(resolveDir, importPath); + + // If it already resolves to a real file, let esbuild handle it. + if (fs.existsSync(base) && !fs.statSync(base).isDirectory()) { + return undefined; + } + + // Try platform-specific and base extensions. + const resolved = tryResolveFile(base, platform); + if (resolved) { + return { path: resolved }; + } + + // Try index file inside a directory. + if (fs.existsSync(base) && fs.statSync(base).isDirectory()) { + const indexResolved = tryResolveFile( + path.join(base, "index"), + platform + ); + if (indexResolved) { + return { path: indexResolved }; + } + } + + // Fall through to esbuild's default resolver. + return undefined; + } + + // Bare module specifier – apply react-native field priority. + // Only intercept if the entry kind is an import/require (not a + // dynamic-import-related resolution that esbuild handles internally). + if ( + kind === "import-statement" || + kind === "require-call" || + kind === "dynamic-import" || + kind === "import-rule" + ) { + const resolved = resolvePackage( + importPath, + resolveDir, + platform, + mainFields + ); + if (resolved) { + return { path: resolved }; + } + } + + // Fall through to esbuild's default resolver. + return undefined; + }); + }, + }; +} diff --git a/incubator/esbuild-service/src/targets.ts b/incubator/esbuild-service/src/targets.ts new file mode 100644 index 0000000000..3a1cde90cf --- /dev/null +++ b/incubator/esbuild-service/src/targets.ts @@ -0,0 +1,47 @@ +import * as fs from "node:fs"; + +function v(version: string): number { + const [major, minor = 0, patch = 0] = version.split("-")[0].split("."); + return Number(major) * 1000000 + Number(minor) * 1000 + Number(patch); +} + +/** + * Infers the appropriate esbuild target string for the installed version of + * React Native / Hermes. + * + * The Hermes target was introduced in esbuild 0.14.49 and is updated as new + * Hermes versions ship. It ensures that esbuild emits code that is compatible + * with the Hermes JS engine used by the given React Native version. + * + * @param projectRoot Directory from which to resolve `react-native`. + * Defaults to `process.cwd()`. + * @returns An esbuild target string such as `"hermes0.12"`. + */ +export function inferBuildTarget(projectRoot = process.cwd()): string { + try { + const options = { paths: [projectRoot] }; + const react = require.resolve("react-native/package.json", options); + const manifest = fs.readFileSync(react, { encoding: "utf-8" }); + const { version } = JSON.parse(manifest); + const versionNum = v(version); + + if (versionNum >= v("0.83.0")) { + return "hermes0.14"; + } else if (versionNum >= v("0.75.0")) { + return "hermes0.13"; + } else if (versionNum >= v("0.71.0")) { + return "hermes0.12"; + } else if (versionNum >= v("0.68.0")) { + return "hermes0.11"; + } else if (versionNum >= v("0.67.0")) { + return "hermes0.10"; + } else if (versionNum >= v("0.66.0")) { + return "hermes0.9"; + } else if (versionNum >= v("0.65.0")) { + return "hermes0.8"; + } + } catch (_) { + // ignore + } + return "hermes0.7"; +} diff --git a/incubator/esbuild-service/src/types.ts b/incubator/esbuild-service/src/types.ts new file mode 100644 index 0000000000..143479e571 --- /dev/null +++ b/incubator/esbuild-service/src/types.ts @@ -0,0 +1,96 @@ +import type { AllPlatforms } from "@rnx-kit/types-bundle-config"; +import type { BuildOptions, Plugin } from "esbuild"; + +/** + * Options for bundling a React Native application with esbuild. + */ +export type BundleServiceOptions = { + /** + * Path to the entry file (e.g. `index.js`). + */ + entryFile: string; + + /** + * Target platform. + */ + platform: AllPlatforms; + + /** + * Whether to bundle in development mode. + * Defaults to `false`. + */ + dev?: boolean; + + /** + * Whether to minify the output. + * Defaults to `true` when `dev` is `false`. + */ + minify?: boolean; + + /** + * Path to write the bundle to. + */ + bundleOutput: string; + + /** + * Path to write the source map to. + */ + sourcemapOutput?: string; + + /** + * Destination directory for asset files. When set, all assets referenced + * by the bundle are copied to this directory after bundling. + */ + assetsDest?: string; + + /** + * Destination directory for iOS asset catalogs (`RNAssets.xcassets`). + * Only relevant for iOS builds. + */ + assetCatalogDest?: string; + + /** + * Additional Metro asset data plugins to apply to every asset. + * Each entry is a module path whose default export transforms `AssetData`. + */ + assetDataPlugins?: string[]; + + /** + * The esbuild target environment. Defaults to the appropriate Hermes target + * inferred from the installed version of `react-native`. + */ + target?: string | string[]; + + /** + * Additional esbuild plugins to include in the build pipeline. + */ + plugins?: Plugin[]; + + /** + * Additional paths to use when resolving modules, in addition to the + * standard Node.js module resolution. + */ + moduleDirectories?: string[]; + + /** + * The project root directory. Defaults to `process.cwd()`. + */ + projectRoot?: string; + + /** + * The log level to pass to esbuild. + */ + logLevel?: BuildOptions["logLevel"]; + + /** + * When enabled, esbuild will drop `debugger` statements, `console` calls, + * or both from the bundle. See https://esbuild.github.io/api/#drop. + */ + drop?: BuildOptions["drop"]; + + /** + * Sets `/* @__PURE__ *\/` annotation for the specified new or call + * expressions. See https://esbuild.github.io/api/#pure. + */ + pure?: BuildOptions["pure"]; +}; diff --git a/incubator/esbuild-service/test/__fixtures__/assetEntry.ts b/incubator/esbuild-service/test/__fixtures__/assetEntry.ts new file mode 100644 index 0000000000..25762f084d --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/assetEntry.ts @@ -0,0 +1,4 @@ +// This fixture imports an asset (PNG), which triggers the assets plugin. +// The import will be resolved to a registerAsset() call in the bundle. +const icon = require("./icon.png"); +export default icon; diff --git a/incubator/esbuild-service/test/__fixtures__/entry.ts b/incubator/esbuild-service/test/__fixtures__/entry.ts new file mode 100644 index 0000000000..549f5546ab --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/entry.ts @@ -0,0 +1,4 @@ +import { add } from "./math"; + +const result = add(1, 2); +export { result }; diff --git a/incubator/esbuild-service/test/__fixtures__/icon.png b/incubator/esbuild-service/test/__fixtures__/icon.png new file mode 100644 index 0000000000..62a5f8f47f Binary files /dev/null and b/incubator/esbuild-service/test/__fixtures__/icon.png differ diff --git a/incubator/esbuild-service/test/__fixtures__/icon@2x.png b/incubator/esbuild-service/test/__fixtures__/icon@2x.png new file mode 100644 index 0000000000..fa36c746d3 Binary files /dev/null and b/incubator/esbuild-service/test/__fixtures__/icon@2x.png differ diff --git a/incubator/esbuild-service/test/__fixtures__/math.ts b/incubator/esbuild-service/test/__fixtures__/math.ts new file mode 100644 index 0000000000..1e5d373c88 --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/math.ts @@ -0,0 +1,7 @@ +export function add(a: number, b: number): number { + return a + b; +} + +export function unused(): string { + return "this should be removed by tree-shaking"; +} diff --git a/incubator/esbuild-service/test/__fixtures__/platform.android.ts b/incubator/esbuild-service/test/__fixtures__/platform.android.ts new file mode 100644 index 0000000000..ea5e8c0a8a --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/platform.android.ts @@ -0,0 +1,3 @@ +export function greeting(): string { + return "Hello from Android!"; +} diff --git a/incubator/esbuild-service/test/__fixtures__/platform.ios.ts b/incubator/esbuild-service/test/__fixtures__/platform.ios.ts new file mode 100644 index 0000000000..1de9bed223 --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/platform.ios.ts @@ -0,0 +1,3 @@ +export function greeting(): string { + return "Hello from iOS!"; +} diff --git a/incubator/esbuild-service/test/__fixtures__/platform.native.ts b/incubator/esbuild-service/test/__fixtures__/platform.native.ts new file mode 100644 index 0000000000..7b06020645 --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/platform.native.ts @@ -0,0 +1,3 @@ +export function greeting(): string { + return "Hello from native!"; +} diff --git a/incubator/esbuild-service/test/__fixtures__/platformEntry.ts b/incubator/esbuild-service/test/__fixtures__/platformEntry.ts new file mode 100644 index 0000000000..3eb3f5bbb1 --- /dev/null +++ b/incubator/esbuild-service/test/__fixtures__/platformEntry.ts @@ -0,0 +1,3 @@ +import { greeting } from "./platform"; + +export { greeting }; diff --git a/incubator/esbuild-service/test/assets.test.ts b/incubator/esbuild-service/test/assets.test.ts new file mode 100644 index 0000000000..0965e4d069 --- /dev/null +++ b/incubator/esbuild-service/test/assets.test.ts @@ -0,0 +1,206 @@ +import { ok, match, strictEqual } from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { reactNativeAssets } from "../src/plugins/assets.ts"; +import { DEFAULT_ASSET_EXTS } from "../src/plugins/assets.ts"; + +const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); + +let tmpDir: string; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rnx-assets-test-")); +}); + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("DEFAULT_ASSET_EXTS", () => { + it("includes common image formats", () => { + ok(DEFAULT_ASSET_EXTS.includes("png"), "should include png"); + ok(DEFAULT_ASSET_EXTS.includes("jpg"), "should include jpg"); + ok(DEFAULT_ASSET_EXTS.includes("gif"), "should include gif"); + ok(DEFAULT_ASSET_EXTS.includes("svg"), "should include svg"); + }); + + it("includes font formats", () => { + ok(DEFAULT_ASSET_EXTS.includes("ttf"), "should include ttf"); + ok(DEFAULT_ASSET_EXTS.includes("otf"), "should include otf"); + }); + + it("includes audio/video formats", () => { + ok(DEFAULT_ASSET_EXTS.includes("mp3"), "should include mp3"); + ok(DEFAULT_ASSET_EXTS.includes("mp4"), "should include mp4"); + }); +}); + +describe("reactNativeAssets plugin", () => { + it("intercepts asset imports and generates registerAsset code", async () => { + const output = path.join(tmpDir, "asset-bundle.js"); + const esbuild = await import("esbuild"); + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + }); + + const buildResult = await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "assetEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + ok(buildResult.errors.length === 0, "build should have no errors"); + ok(fs.existsSync(output), "bundle output file should exist"); + + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes("registerAsset"), + "bundle should contain a registerAsset call" + ); + ok( + code.includes("__packager_asset"), + "bundle should contain __packager_asset field" + ); + ok( + code.includes("httpServerLocation"), + "bundle should contain httpServerLocation field" + ); + }); + + it("collects asset data after the build", async () => { + const output = path.join(tmpDir, "asset-collect.js"); + const esbuild = await import("esbuild"); + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + }); + + await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "assetEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + const assets = assetsPlugin.getCollectedAssets(); + strictEqual(assets.length, 1, "should have collected exactly one asset"); + + const asset = assets[0]; + strictEqual(asset.name, "icon", "asset name should be 'icon'"); + strictEqual(asset.type, "png", "asset type should be 'png'"); + ok( + asset.httpServerLocation.includes("assets"), + "httpServerLocation should contain 'assets'" + ); + }); + + it("discovers scale variants (@2x etc.)", async () => { + const output = path.join(tmpDir, "asset-scales.js"); + const esbuild = await import("esbuild"); + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + }); + + await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "assetEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + const assets = assetsPlugin.getCollectedAssets(); + const asset = assets[0]; + // The fixtures directory has icon.png and icon@2x.png + ok(asset.scales.length >= 1, "asset should have at least one scale"); + ok(asset.files.length >= 1, "asset should have at least one file path"); + }); + + it("uses the specified assetRegistryPath", async () => { + const output = path.join(tmpDir, "asset-registry.js"); + const esbuild = await import("esbuild"); + const customRegistryPath = "@react-native/assets-registry/registry"; + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + assetRegistryPath: customRegistryPath, + }); + + await esbuild.build({ + bundle: false, + entryPoints: [path.join(fixturesDir, "icon.png")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes(customRegistryPath), + "bundle should use the custom registry path" + ); + }); + + it("does not include file paths in the bundle output", async () => { + const output = path.join(tmpDir, "asset-no-files.js"); + const esbuild = await import("esbuild"); + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + }); + + await esbuild.build({ + bundle: false, + entryPoints: [path.join(fixturesDir, "icon.png")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + const code = fs.readFileSync(output, "utf-8"); + // The 'files' and 'fileSystemLocation' properties should be stripped + ok( + !code.includes('"files"'), + 'bundle should not contain the "files" property' + ); + ok( + !code.includes('"fileSystemLocation"'), + 'bundle should not contain the "fileSystemLocation" property' + ); + }); + + it("uses custom publicPath", async () => { + const output = path.join(tmpDir, "asset-publicpath.js"); + const esbuild = await import("esbuild"); + const assetsPlugin = reactNativeAssets({ + platform: "ios", + projectRoot: fixturesDir, + publicPath: "/static", + }); + + await esbuild.build({ + bundle: false, + entryPoints: [path.join(fixturesDir, "icon.png")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [assetsPlugin], + }); + + const code = fs.readFileSync(output, "utf-8"); + match(code, /\/static/, "httpServerLocation should use custom publicPath"); + }); +}); diff --git a/incubator/esbuild-service/test/index.test.ts b/incubator/esbuild-service/test/index.test.ts new file mode 100644 index 0000000000..c6c3251b17 --- /dev/null +++ b/incubator/esbuild-service/test/index.test.ts @@ -0,0 +1,172 @@ +import { ok } from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { bundle } from "../src/bundle.ts"; +import { inferBuildTarget } from "../src/targets.ts"; +import { reactNativeResolver } from "../src/plugins/resolver.ts"; + +const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); + +// Use a modern Hermes target so tests don't fail due to unsupported lowering +// (e.g. const/let are not supported in very early Hermes targets). +const TEST_TARGET = "hermes0.12"; + +let tmpDir: string; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rnx-esbuild-service-test-")); +}); + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("inferBuildTarget", () => { + it("returns a hermes target string", () => { + const target = inferBuildTarget(); + ok( + typeof target === "string" && target.startsWith("hermes"), + `Expected hermes target, got: ${target}` + ); + }); +}); + +describe("bundle", () => { + it("bundles a TypeScript entry file", async () => { + const output = path.join(tmpDir, "bundle-ts.js"); + await bundle({ + entryFile: path.join(fixturesDir, "entry.ts"), + platform: "ios", + dev: false, + minify: false, + bundleOutput: output, + target: TEST_TARGET, + }); + ok(fs.existsSync(output), "bundle output file should exist"); + const code = fs.readFileSync(output, "utf-8"); + ok(code.length > 0, "bundle should not be empty"); + ok(code.includes("add"), "bundle should include the `add` function"); + }); + + it("injects the global variable setup", async () => { + const output = path.join(tmpDir, "bundle-global.js"); + await bundle({ + entryFile: path.join(fixturesDir, "entry.ts"), + platform: "ios", + dev: true, + minify: false, + bundleOutput: output, + target: TEST_TARGET, + }); + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes("globalThis") || code.includes("global"), + "bundle should reference globalThis or global" + ); + }); + + it("writes a source map when sourcemapOutput is provided", async () => { + const output = path.join(tmpDir, "bundle-map.js"); + const mapOutput = path.join(tmpDir, "bundle-map.js.map"); + await bundle({ + entryFile: path.join(fixturesDir, "entry.ts"), + platform: "ios", + dev: false, + minify: false, + bundleOutput: output, + sourcemapOutput: mapOutput, + target: TEST_TARGET, + }); + ok(fs.existsSync(output), "bundle output file should exist"); + ok(fs.existsSync(mapOutput), "source map file should exist"); + const map = JSON.parse(fs.readFileSync(mapOutput, "utf-8")); + ok(Array.isArray(map.sources), "source map should have sources array"); + }); + + it("tree-shakes unused exports", async () => { + const output = path.join(tmpDir, "bundle-treeshake.js"); + await bundle({ + entryFile: path.join(fixturesDir, "entry.ts"), + platform: "ios", + dev: false, + minify: false, + bundleOutput: output, + target: TEST_TARGET, + }); + const code = fs.readFileSync(output, "utf-8"); + ok( + !code.includes("this should be removed by tree-shaking"), + "bundle should not contain unused function body" + ); + }); +}); + +describe("reactNativeResolver plugin", () => { + it("resolves platform-specific files for ios", async () => { + const output = path.join(tmpDir, "bundle-resolver-ios.js"); + const esbuild = await import("esbuild"); + const buildResult = await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "platformEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [reactNativeResolver("ios")], + }); + ok(buildResult.errors.length === 0, "build should have no errors"); + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes("Hello from iOS!"), + "bundle should use the iOS platform file" + ); + ok( + !code.includes("Hello from Android!"), + "bundle should NOT use the Android platform file" + ); + }); + + it("resolves platform-specific files for android", async () => { + const output = path.join(tmpDir, "bundle-resolver-android.js"); + const esbuild = await import("esbuild"); + const buildResult = await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "platformEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [reactNativeResolver("android")], + }); + ok(buildResult.errors.length === 0, "build should have no errors"); + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes("Hello from Android!"), + "bundle should use the Android platform file" + ); + ok( + !code.includes("Hello from iOS!"), + "bundle should NOT use the iOS platform file" + ); + }); + + it("falls back to .native extension when platform file is absent", async () => { + const output = path.join(tmpDir, "bundle-resolver-macos.js"); + const esbuild = await import("esbuild"); + const buildResult = await esbuild.build({ + bundle: true, + entryPoints: [path.join(fixturesDir, "platformEntry.ts")], + outfile: output, + write: true, + logLevel: "silent", + plugins: [reactNativeResolver("macos")], + }); + ok(buildResult.errors.length === 0, "build should have no errors"); + const code = fs.readFileSync(output, "utf-8"); + ok( + code.includes("Hello from native!"), + "bundle should fall back to the .native extension" + ); + }); +}); diff --git a/incubator/esbuild-service/tsconfig.json b/incubator/esbuild-service/tsconfig.json new file mode 100644 index 0000000000..b2ba99487b --- /dev/null +++ b/incubator/esbuild-service/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@rnx-kit/tsconfig/tsconfig.node.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/metro-service/package.json b/packages/metro-service/package.json index 69336a752d..982b67b03c 100644 --- a/packages/metro-service/package.json +++ b/packages/metro-service/package.json @@ -25,6 +25,11 @@ "typescript": "./src/index.ts", "default": "./lib/index.js" }, + "./assets": { + "types": "./lib/assets.d.ts", + "typescript": "./src/assets.ts", + "default": "./lib/assets.js" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/metro-service/src/assets.ts b/packages/metro-service/src/assets.ts new file mode 100644 index 0000000000..ca29e10828 --- /dev/null +++ b/packages/metro-service/src/assets.ts @@ -0,0 +1,3 @@ +export { getSaveAssetsPlugin } from "./asset/saveAssets.ts"; +export { saveAssets } from "./asset/write.ts"; +export type { AssetData, SaveAssetsPlugin } from "./asset/types.ts"; diff --git a/yarn.lock b/yarn.lock index aec475980d..a71d9f936d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5299,6 +5299,28 @@ __metadata: languageName: unknown linkType: soft +"@rnx-kit/esbuild-service@workspace:incubator/esbuild-service": + version: 0.0.0-use.local + resolution: "@rnx-kit/esbuild-service@workspace:incubator/esbuild-service" + dependencies: + "@rnx-kit/metro-service": "npm:*" + "@rnx-kit/scripts": "npm:*" + "@rnx-kit/tools-node": "npm:^3.0.4" + "@rnx-kit/tools-react-native": "npm:^2.3.3" + "@rnx-kit/tsconfig": "npm:*" + "@rnx-kit/types-bundle-config": "npm:^1.0.0" + "@types/node": "npm:^24.0.0" + esbuild: "npm:^0.27.1" + metro: "npm:^0.83.3" + react-native: "npm:^0.83.0" + peerDependencies: + metro: ">=0.72.0" + peerDependenciesMeta: + metro: + optional: true + languageName: unknown + linkType: soft + "@rnx-kit/eslint-config@npm:*, @rnx-kit/eslint-config@workspace:*, @rnx-kit/eslint-config@workspace:packages/eslint-config": version: 0.0.0-use.local resolution: "@rnx-kit/eslint-config@workspace:packages/eslint-config"