diff --git a/.changeset/fair-pianos-share.md b/.changeset/fair-pianos-share.md new file mode 100644 index 00000000..d138d7ba --- /dev/null +++ b/.changeset/fair-pianos-share.md @@ -0,0 +1,5 @@ +--- +'@rozenite/web': minor +--- + +Add support for Rozenite for Web in projects that use Metro for mobile and Webpack Dev Server for web, with updated setup guidance for the split bundler workflow. diff --git a/packages/web/README.md b/packages/web/README.md index 678ee98b..870e3ea2 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -8,6 +8,11 @@ The **Rozenite Chrome extension** is required for the integration to work correctly — install it in your browser to connect to your React Native web app. +For bundler integration: + +- use `@rozenite/web/metro` when Metro also bundles the web app +- use `@rozenite/web/webpack` when Metro bundles mobile and Webpack Dev Server bundles web + ## Documentation diff --git a/packages/web/package.json b/packages/web/package.json index 8fb07091..49deb539 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,18 @@ "import": "./dist/esm/react-native/index.js", "require": "./dist/cjs/react-native/index.js" }, + "./webpack": { + "development": "./src/webpack/index.ts", + "types": "./dist/types/webpack/index.d.ts", + "import": "./dist/esm/webpack/index.js", + "require": "./dist/cjs/webpack/index.js" + }, + "./ReactNativeFeatureFlags": { + "development": "./src/metro/ReactNativeFeatureFlags.ts", + "types": "./dist/types/metro/ReactNativeFeatureFlags.d.ts", + "import": "./dist/esm/metro/ReactNativeFeatureFlags.js", + "require": "./dist/cjs/metro/ReactNativeFeatureFlags.js" + }, "./metro": { "development": "./src/metro/index.ts", "types": "./dist/types/metro/index.d.ts", diff --git a/packages/web/src/__tests__/webpack.test.ts b/packages/web/src/__tests__/webpack.test.ts new file mode 100644 index 00000000..85b3ebab --- /dev/null +++ b/packages/web/src/__tests__/webpack.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import { + withRozeniteWeb, + type WebpackConfig, + type WebpackConfigExport, +} from '../webpack/index.js'; + +const createWebpackConfig = ( + overrides: Partial = {}, +): WebpackConfig => ({ + mode: 'development', + entry: './src/index.tsx', + ...overrides, +}); + +describe('withRozeniteWeb (webpack)', () => { + it('adds websocket and http proxies for the full RN dev-middleware surface', () => { + const result = withRozeniteWeb(createWebpackConfig(), { + metroUrl: 'http://localhost:8081', + }) as WebpackConfig; + + expect(result.devServer?.proxy).toEqual([ + { + context: '/inspector/device', + target: 'http://localhost:8081', + changeOrigin: false, + ws: true, + }, + { + context: '/inspector/debug', + target: 'http://localhost:8081', + changeOrigin: false, + ws: true, + }, + { + context: '/open-debugger', + target: 'http://localhost:8081', + changeOrigin: false, + }, + { + context: '/debugger-frontend', + target: 'http://localhost:8081', + changeOrigin: false, + }, + { + context: '/json', + target: 'http://localhost:8081', + changeOrigin: false, + }, + { + context: '/json/list', + target: 'http://localhost:8081', + changeOrigin: false, + }, + { + context: '/json/version', + target: 'http://localhost:8081', + changeOrigin: false, + }, + ]); + }); + + it('preserves existing devServer options and prepends proxy entries to proxy arrays', () => { + const originalHistoryFallback = { index: '/index.html' }; + const originalProxy = [{ context: '/api', target: 'http://localhost:3000' }]; + + const result = withRozeniteWeb( + createWebpackConfig({ + devServer: { + historyApiFallback: originalHistoryFallback, + proxy: originalProxy, + }, + }), + { + metroUrl: 'http://localhost:8081', + }, + ) as WebpackConfig; + + expect(result.devServer?.historyApiFallback).toBe(originalHistoryFallback); + expect(Array.isArray(result.devServer?.proxy)).toBe(true); + expect((result.devServer?.proxy as unknown[]).at(-1)).toBe(originalProxy[0]); + }); + + it('merges proxy object configs without clobbering unrelated entries', () => { + const result = withRozeniteWeb( + createWebpackConfig({ + devServer: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + }), + { + metroUrl: 'http://localhost:8081', + }, + ) as WebpackConfig; + + expect(result.devServer?.proxy).toMatchObject({ + '/inspector/device': { + target: 'http://localhost:8081', + changeOrigin: false, + ws: true, + }, + '/debugger-frontend': { + target: 'http://localhost:8081', + changeOrigin: false, + }, + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }); + }); + + it('injects @rozenite/web into string entries', () => { + const result = withRozeniteWeb( + createWebpackConfig({ + entry: './src/index.tsx', + }), + { + metroUrl: 'http://localhost:8081', + }, + ) as WebpackConfig; + + expect(result.entry).toEqual(['@rozenite/web', './src/index.tsx']); + }); + + it('injects @rozenite/web into object entries with import arrays', () => { + const result = withRozeniteWeb( + createWebpackConfig({ + entry: { + app: { + import: ['./src/index.tsx'], + filename: 'app.js', + }, + }, + }), + { + metroUrl: 'http://localhost:8081', + }, + ) as WebpackConfig; + + expect(result.entry).toEqual({ + app: { + import: ['@rozenite/web', './src/index.tsx'], + filename: 'app.js', + }, + }); + }); + + it('does not duplicate @rozenite/web when already present in the entry', () => { + const result = withRozeniteWeb( + createWebpackConfig({ + entry: ['@rozenite/web', './src/index.tsx'], + }), + { + metroUrl: 'http://localhost:8081', + }, + ) as WebpackConfig; + + expect(result.entry).toEqual(['@rozenite/web', './src/index.tsx']); + }); + + it('can skip entry injection', () => { + const result = withRozeniteWeb( + createWebpackConfig({ + entry: './src/index.tsx', + }), + { + metroUrl: 'http://localhost:8081', + injectEntry: false, + }, + ) as WebpackConfig; + + expect(result.entry).toBe('./src/index.tsx'); + }); + + it('adds a plugin that rewrites ReactNativeFeatureFlags imports', () => { + const result = withRozeniteWeb(createWebpackConfig(), { + metroUrl: 'http://localhost:8081', + }) as WebpackConfig; + + const compilerMock = { + hooks: { + normalModuleFactory: { + tap: ( + _name: string, + callback: (normalModuleFactory: { + hooks: { + beforeResolve: { + tap: ( + _pluginName: string, + beforeResolve: (resolveData: { request: string }) => { + request: string; + }, + ) => void; + }; + }; + }) => void, + ) => { + callback({ + hooks: { + beforeResolve: { + tap: (_pluginName, beforeResolve) => { + const resolved = beforeResolve({ + request: + 'react-native/src/private/featureflags/ReactNativeFeatureFlags', + }); + + expect(resolved.request).toBe( + '@rozenite/web/ReactNativeFeatureFlags', + ); + }, + }, + }, + }); + }, + }, + }, + }; + + expect(result.plugins).toHaveLength(1); + ( + result.plugins?.[0] as { + apply: (compiler: typeof compilerMock) => void; + } + ).apply(compilerMock); + }); + + it('returns production configs unchanged', () => { + const config = createWebpackConfig({ + mode: 'production', + devServer: { + proxy: [{ context: '/api', target: 'http://localhost:3000' }], + }, + plugins: [{ name: 'existing-plugin' }], + }); + + const result = withRozeniteWeb(config, { + metroUrl: 'http://localhost:8081', + }) as WebpackConfig; + + expect(result).toBe(config); + }); + + it('supports async webpack config factories', async () => { + const configFactory: WebpackConfigExport = async (_env, argv) => ({ + mode: argv?.mode, + entry: './src/index.tsx', + }); + + const resultFactory = withRozeniteWeb(configFactory, { + metroUrl: 'http://localhost:8081', + }) as Exclude>; + + const result = await resultFactory({}, { mode: 'development' }); + + expect(result.entry).toEqual(['@rozenite/web', './src/index.tsx']); + expect(result.devServer?.proxy).toBeDefined(); + }); +}); diff --git a/packages/web/src/webpack/index.ts b/packages/web/src/webpack/index.ts new file mode 100644 index 00000000..73776521 --- /dev/null +++ b/packages/web/src/webpack/index.ts @@ -0,0 +1,275 @@ +const ROZENITE_WEB_ENTRY = '@rozenite/web'; +const REACT_NATIVE_FEATURE_FLAGS_REPLACEMENT = + '@rozenite/web/ReactNativeFeatureFlags'; + +const DEV_MIDDLEWARE_WS_ENDPOINTS = ['/inspector/device', '/inspector/debug']; +const DEV_MIDDLEWARE_HTTP_ENDPOINTS = [ + '/open-debugger', + '/debugger-frontend', + '/json', + '/json/list', + '/json/version', +]; + +const REACT_NATIVE_FEATURE_FLAGS_PATTERN = /ReactNativeFeatureFlags(?:\.js)?$/; + +type WebpackMode = 'development' | 'production' | 'none' | string; + +type WebpackEntryDescription = { + import?: string | string[]; + [key: string]: unknown; +}; + +type WebpackEntryValue = string | string[] | WebpackEntryDescription; + +type WebpackEntry = + | string + | string[] + | WebpackEntryDescription + | Record; + +type WebpackResolve = { + alias?: Record; + [key: string]: unknown; +}; + +type DevServerProxyEntry = { + context?: string | string[]; + target?: string; + changeOrigin?: boolean; + ws?: boolean; + [key: string]: unknown; +}; + +type DevServerProxy = + | DevServerProxyEntry[] + | Record; + +type WebpackDevServer = { + proxy?: DevServerProxy; + [key: string]: unknown; +}; + +export type WebpackConfig = { + mode?: WebpackMode; + entry?: WebpackEntry; + resolve?: WebpackResolve; + plugins?: unknown[]; + devServer?: WebpackDevServer; + [key: string]: unknown; +}; + +type WebpackConfigFactoryArgs = { + mode?: WebpackMode; + [key: string]: unknown; +}; + +export type WebpackConfigExport = + | WebpackConfig + | Promise + | (( + env?: Record, + argv?: WebpackConfigFactoryArgs, + ) => WebpackConfig | Promise); + +export type RozeniteWebpackOptions = { + metroUrl: string; + injectEntry?: boolean; +}; + +type WebpackCompiler = { + hooks: { + normalModuleFactory: { + tap: ( + name: string, + callback: (normalModuleFactory: WebpackNormalModuleFactory) => void, + ) => void; + }; + }; +}; + +type WebpackNormalModuleFactory = { + hooks: { + beforeResolve: { + tap: ( + name: string, + callback: ( + resolveData: WebpackBeforeResolveData | undefined, + ) => WebpackBeforeResolveData | false | undefined, + ) => void; + }; + }; +}; + +type WebpackBeforeResolveData = { + request: string; + [key: string]: unknown; +}; + +class RozeniteWebpackPlugin { + apply(compiler: WebpackCompiler) { + compiler.hooks.normalModuleFactory.tap( + 'RozeniteWebpackPlugin', + (normalModuleFactory) => { + normalModuleFactory.hooks.beforeResolve.tap( + 'RozeniteWebpackPlugin', + (resolveData) => { + if ( + resolveData && + REACT_NATIVE_FEATURE_FLAGS_PATTERN.test(resolveData.request) + ) { + resolveData.request = REACT_NATIVE_FEATURE_FLAGS_REPLACEMENT; + } + + return resolveData; + }, + ); + }, + ); + } +} + +const isProductionMode = (mode: WebpackMode | undefined) => mode === 'production'; + +const injectRozeniteImport = (value: string | string[]): string | string[] => { + if (typeof value === 'string') { + return value === ROZENITE_WEB_ENTRY ? value : [ROZENITE_WEB_ENTRY, value]; + } + + return value.includes(ROZENITE_WEB_ENTRY) + ? value + : [ROZENITE_WEB_ENTRY, ...value]; +}; + +const injectRozeniteEntry = (value: WebpackEntryValue): WebpackEntryValue => { + if (typeof value === 'string' || Array.isArray(value)) { + return injectRozeniteImport(value); + } + + if (value.import == null) { + return value; + } + + return { + ...value, + import: injectRozeniteImport(value.import), + }; +}; + +const patchEntry = ( + entry: WebpackConfig['entry'], + injectEntry: boolean, +): WebpackConfig['entry'] => { + if (!injectEntry || entry == null) { + return entry; + } + + if ( + typeof entry === 'string' || + Array.isArray(entry) || + 'import' in entry + ) { + return injectRozeniteEntry(entry); + } + + return Object.fromEntries( + Object.entries(entry).map(([key, value]) => [ + key, + injectRozeniteEntry(value as WebpackEntryValue), + ]), + ); +}; + +const createProxyEntries = (metroUrl: string): DevServerProxyEntry[] => [ + ...DEV_MIDDLEWARE_WS_ENDPOINTS.map((context) => ({ + context, + target: metroUrl, + changeOrigin: false, + ws: true, + })), + ...DEV_MIDDLEWARE_HTTP_ENDPOINTS.map((context) => ({ + context, + target: metroUrl, + changeOrigin: false, + })), +]; + +const mergeProxyConfig = ( + proxy: DevServerProxy | undefined, + metroUrl: string, +): DevServerProxy => { + const rozeniteProxyEntries = createProxyEntries(metroUrl); + + if (proxy == null) { + return rozeniteProxyEntries; + } + + if (Array.isArray(proxy)) { + return [...rozeniteProxyEntries, ...proxy]; + } + + const rozeniteProxyObject = Object.fromEntries( + rozeniteProxyEntries.map((entry) => [ + entry.context as string, + Object.fromEntries( + Object.entries(entry).filter(([key]) => key !== 'context'), + ), + ]), + ); + + return { + ...rozeniteProxyObject, + ...proxy, + }; +}; + +const hasRozeniteWebpackPlugin = (plugins: unknown[] | undefined) => + plugins?.some((plugin) => plugin instanceof RozeniteWebpackPlugin) ?? false; + +const patchConfig = ( + config: WebpackConfig, + argvMode: WebpackMode | undefined, + options: RozeniteWebpackOptions, +): WebpackConfig => { + const resolvedMode = argvMode ?? config.mode; + + if (isProductionMode(resolvedMode)) { + return config; + } + + return { + ...config, + entry: patchEntry(config.entry, options.injectEntry ?? true), + devServer: { + ...config.devServer, + proxy: mergeProxyConfig(config.devServer?.proxy, options.metroUrl), + }, + resolve: { + ...config.resolve, + alias: { + ...config.resolve?.alias, + }, + }, + plugins: hasRozeniteWebpackPlugin(config.plugins) + ? config.plugins + : [...(config.plugins ?? []), new RozeniteWebpackPlugin()], + }; +}; + +export const withRozeniteWeb = ( + config: WebpackConfigExport, + options: RozeniteWebpackOptions, +): WebpackConfigExport => { + if (typeof config === 'function') { + return async (env, argv) => + patchConfig(await config(env, argv), argv?.mode, options); + } + + if (config instanceof Promise) { + return config.then((resolvedConfig) => + patchConfig(resolvedConfig, resolvedConfig.mode, options), + ); + } + + return patchConfig(config, config.mode, options); +}; diff --git a/packages/web/tsconfig.metro.cjs.json b/packages/web/tsconfig.metro.cjs.json index 39268c70..3fa24696 100644 --- a/packages/web/tsconfig.metro.cjs.json +++ b/packages/web/tsconfig.metro.cjs.json @@ -4,6 +4,6 @@ "rootDir": "src", "tsBuildInfoFile": "dist/cjs/tsconfig.metro.tsbuildinfo" }, - "include": ["src/metro/**/*.ts", "globals.d.ts"], + "include": ["src/metro/**/*.ts", "src/webpack/**/*.ts", "globals.d.ts"], "exclude": ["src/react-native"] } diff --git a/packages/web/tsconfig.metro.json b/packages/web/tsconfig.metro.json index bcf71777..b13e9765 100644 --- a/packages/web/tsconfig.metro.json +++ b/packages/web/tsconfig.metro.json @@ -9,6 +9,6 @@ "outDir": "dist/esm", "tsBuildInfoFile": "dist/esm/tsconfig.metro.tsbuildinfo" }, - "include": ["src/metro/**/*.ts", "globals.d.ts"], + "include": ["src/metro/**/*.ts", "src/webpack/**/*.ts", "globals.d.ts"], "exclude": ["src/react-native"] } diff --git a/packages/web/tsconfig.metro.types.json b/packages/web/tsconfig.metro.types.json index a1a448e3..d779bc52 100644 --- a/packages/web/tsconfig.metro.types.json +++ b/packages/web/tsconfig.metro.types.json @@ -9,6 +9,6 @@ "outDir": "dist/types", "tsBuildInfoFile": "dist/types/tsconfig.metro.tsbuildinfo" }, - "include": ["src/metro/**/*.ts", "globals.d.ts"], + "include": ["src/metro/**/*.ts", "src/webpack/**/*.ts", "globals.d.ts"], "exclude": ["src/react-native"] } diff --git a/website/src/docs/rozenite-for-web.mdx b/website/src/docs/rozenite-for-web.mdx index bb55b3ce..8ea0874c 100644 --- a/website/src/docs/rozenite-for-web.mdx +++ b/website/src/docs/rozenite-for-web.mdx @@ -14,8 +14,14 @@ This integration requires two parts: the Rozenite Chrome extension (installed in ## Prerequisites -- React Native project with web support (e.g., Expo) -- Metro bundler +- React Native project with web support +- Metro bundler for mobile and React Native DevTools middleware +- Chromium-based browser with the Rozenite extension installed + +Rozenite for Web supports two development setups: + +- **Metro-only web**: Metro bundles both mobile and web +- **Split Metro + Webpack**: Metro bundles mobile and hosts React Native DevTools middleware, while Webpack bundles and serves the web app ## Chrome extension setup @@ -41,7 +47,7 @@ For more details on loading extensions, see the [Chrome extension loading guide] -### Metro configuration +### Metro-only web configuration Add `withRozeniteWeb` to the `enhanceMetroConfig` chain inside `withRozenite`: @@ -59,6 +65,46 @@ enhanceMetroConfig: composeMetroConfigTransformers( Add `require('@rozenite/web')` in your app entry point (e.g., `main.tsx`) or in the root `_layout` file when using Expo Router. +### Split Metro + Webpack configuration + +When Metro bundles mobile and Webpack bundles web, keep Metro running with Rozenite enabled and proxy the React Native dev-middleware endpoints through Webpack. + +```javascript +const { withRozeniteWeb } = require('@rozenite/web/webpack'); + +module.exports = withRozeniteWeb( + { + // your existing webpack config + }, + { + metroUrl: 'http://localhost:8081', + } +); +``` + +The Webpack helper will: + +- proxy `/inspector/device` and `/inspector/debug` with websocket support +- proxy `/open-debugger`, `/debugger-frontend`, `/json`, `/json/list`, and `/json/version` +- prepend `@rozenite/web` to supported Webpack entry shapes by default +- replace `ReactNativeFeatureFlags` imports with a web-safe shim + +If you prefer manual runtime wiring, disable entry injection and keep `require('@rozenite/web')` in your app entry point: + +```javascript +const { withRozeniteWeb } = require('@rozenite/web/webpack'); + +module.exports = withRozeniteWeb( + { + // your existing webpack config + }, + { + metroUrl: 'http://localhost:8081', + injectEntry: false, + } +); +``` + ## Plugin support **Supported plugins:** @@ -88,4 +134,5 @@ If you maintain a plugin and want it to work on web: ## Troubleshooting - Ensure Metro is running and the extension is installed. -- Ensure the app is served from localhost (e.g., port 8081). +- Ensure the app is served from localhost or 127.0.0.1. +- In split Metro + Webpack setups, ensure Webpack proxies the React Native dev-middleware endpoints to Metro.