diff --git a/packages/config/src/lib/config.ts b/packages/config/src/lib/config.ts index 27762ba4c..46e6c7da2 100644 --- a/packages/config/src/lib/config.ts +++ b/packages/config/src/lib/config.ts @@ -13,6 +13,34 @@ export type PluginOutput = { description: string; }; +export type DevServerArgs = { + interactive: boolean; + clientLogs: boolean; + port?: string; + host?: string; + https?: boolean; + resetCache?: boolean; + devServer?: boolean; + platforms?: string[]; + [key: string]: unknown; +}; + +export type StartDevServerArgs = { + root: string; + args: DevServerArgs; + reactNativeVersion: string; + reactNativePath: string; + platforms: Record; +}; + +type StartDevServerFunction = (options: StartDevServerArgs) => Promise; + +export type BundlerPluginOutput = { + name: string; + description: string; + start: StartDevServerFunction; +}; + export type PlatformOutput = PluginOutput & { autolinkingConfig: { project: Record | undefined }; }; @@ -27,10 +55,11 @@ export type PluginApi = { null | undefined | (() => RemoteBuildCache) >; getFingerprintOptions: () => FingerprintSources; + getBundlerStart: () => ({ args }: { args: DevServerArgs }) => void; }; type PluginType = (args: PluginApi) => PluginOutput; - +type BundlerPluginType = (args: PluginApi) => BundlerPluginOutput; type PlatformType = (args: PluginApi) => PlatformOutput; type ArgValue = string | string[] | boolean; @@ -63,7 +92,7 @@ export type ConfigType = { root?: string; reactNativeVersion?: string; reactNativePath?: string; - bundler?: PluginType; + bundler?: BundlerPluginType; plugins?: PluginType[]; platforms?: Record; commands?: Array; @@ -79,6 +108,7 @@ export type ConfigOutput = { root: string; commands?: Array; platforms?: Record; + bundler?: BundlerPluginOutput; } & PluginApi; const extensions = ['.js', '.ts', '.mjs']; @@ -160,6 +190,8 @@ export async function getConfig( process.exit(1); } + let bundler: BundlerPluginOutput | undefined; + const api = { registerCommand: (command: CommandType) => { validatedConfig.commands = [...(validatedConfig.commands || []), command]; @@ -184,8 +216,18 @@ Read more: ${colorLink('https://rockjs.dev/docs/configuration#github-actions-pro } return validatedConfig.remoteCacheProvider; }, - getFingerprintOptions: () => - validatedConfig.fingerprint as FingerprintSources, + getFingerprintOptions: () => validatedConfig.fingerprint as FingerprintSources, + getBundlerStart: + () => + ({ args }: { args: DevServerArgs }) => { + return bundler?.start({ + root: api.getProjectRoot(), + args, + reactNativeVersion: api.getReactNativeVersion(), + reactNativePath: api.getReactNativePath(), + platforms: api.getPlatforms(), + }); + }, }; const platforms: Record = {}; @@ -205,7 +247,11 @@ Read more: ${colorLink('https://rockjs.dev/docs/configuration#github-actions-pro } if (validatedConfig.bundler) { - assignOriginToCommand(validatedConfig.bundler, api, validatedConfig); + bundler = assignOriginToCommand( + validatedConfig.bundler, + api, + validatedConfig + ) as BundlerPluginOutput; } for (const internalPlugin of internalPlugins) { @@ -220,6 +266,7 @@ Read more: ${colorLink('https://rockjs.dev/docs/configuration#github-actions-pro root: projectRoot, commands: validatedConfig.commands ?? [], platforms: platforms ?? {}, + bundler, ...api, }; @@ -236,16 +283,17 @@ function resolveReactNativePath(root: string) { * Assigns __origin property to each command in the config for later use in error handling. */ function assignOriginToCommand( - plugin: PluginType, + plugin: PluginType | BundlerPluginType, api: PluginApi, config: ConfigType, ) { const len = config.commands?.length ?? 0; - const { name } = plugin(api); + const { name, ...rest } = plugin(api); const newlen = config.commands?.length ?? 0; for (let i = len; i < newlen; i++) { if (config.commands?.[i]) { config.commands[i].__origin = name; } } + return { name, ...rest }; } diff --git a/packages/platform-android/src/lib/commands/runAndroid/__tests__/runAndroid.test.ts b/packages/platform-android/src/lib/commands/runAndroid/__tests__/runAndroid.test.ts index d846306b0..9fa4e5d5d 100644 --- a/packages/platform-android/src/lib/commands/runAndroid/__tests__/runAndroid.test.ts +++ b/packages/platform-android/src/lib/commands/runAndroid/__tests__/runAndroid.test.ts @@ -32,6 +32,8 @@ const androidProject: AndroidProjectConfig = { const OLD_ENV = process.env; let adbDevicesCallsCount = 0; +const mockPlatforms = { ios: {}, android: {} }; + beforeEach(() => { adbDevicesCallsCount = 0; vi.clearAllMocks(); @@ -312,6 +314,10 @@ test.each([['release'], ['debug'], ['staging']])( '/', undefined, { extraSources: [], ignorePaths: [], env: [] }, + vi.fn(), // startDevServer mock + '/path/to/react-native', // reactNativePath + '0.79.0', // reactNativeVersion + mockPlatforms, ); expect(tools.outro).toBeCalledWith('Success 🎉.'); @@ -361,6 +367,10 @@ test('runAndroid runs gradle build with custom --appId, --appIdSuffix and --main '/', undefined, { extraSources: [], ignorePaths: [], env: [] }, + vi.fn(), // startDevServer mock + '/path/to/react-native', // reactNativePath + '0.79.0', // reactNativeVersion + mockPlatforms, ); expect(tools.outro).toBeCalledWith('Success 🎉.'); @@ -388,6 +398,10 @@ test('runAndroid fails to launch an app on not-connected device when specified w '/', undefined, { extraSources: [], ignorePaths: [], env: [] }, + vi.fn(), // startDevServer mock + '/path/to/react-native', // reactNativePath + '0.79.0', // reactNativeVersion + mockPlatforms, ); expect(logWarnSpy).toBeCalledWith( 'No devices or emulators found matching "emulator-5554". Using available one instead.', @@ -457,6 +471,10 @@ test.each([['release'], ['debug']])( '/', undefined, { extraSources: [], ignorePaths: [], env: [] }, + vi.fn(), // startDevServer mock + '/path/to/react-native', // reactNativePath + '0.79.0', // reactNativeVersion + mockPlatforms, ); // we don't want to run installDebug when a device is selected, because gradle will install the app on all connected devices @@ -517,7 +535,7 @@ test('runAndroid launches an app on all connected devices', async () => { extraSources: [], ignorePaths: [], env: [], - }); + }, vi.fn(), '/path/to/react-native', '0.79.0', mockPlatforms); // Runs assemble debug task with active architectures arm64-v8a, armeabi-v7a expect(spawn).toBeCalledWith( @@ -584,6 +602,10 @@ test('runAndroid skips building when --binary-path is passed', async () => { '/root', undefined, { extraSources: [], ignorePaths: [], env: [] }, + vi.fn(), // startDevServer mock + '/path/to/react-native', // reactNativePath + '0.79.0', // reactNativeVersion + mockPlatforms, ); // Skips gradle diff --git a/packages/platform-android/src/lib/commands/runAndroid/command.ts b/packages/platform-android/src/lib/commands/runAndroid/command.ts index 69e7646df..f9e998840 100644 --- a/packages/platform-android/src/lib/commands/runAndroid/command.ts +++ b/packages/platform-android/src/lib/commands/runAndroid/command.ts @@ -21,6 +21,10 @@ export function registerRunCommand( projectRoot, await api.getRemoteCacheProvider(), api.getFingerprintOptions(), + api.getBundlerStart(), + api.getReactNativeVersion(), + api.getReactNativePath(), + api.getPlatforms() ); }, options: runOptions, diff --git a/packages/platform-android/src/lib/commands/runAndroid/runAndroid.ts b/packages/platform-android/src/lib/commands/runAndroid/runAndroid.ts index 88241f5ea..bdf1d18a9 100644 --- a/packages/platform-android/src/lib/commands/runAndroid/runAndroid.ts +++ b/packages/platform-android/src/lib/commands/runAndroid/runAndroid.ts @@ -4,6 +4,7 @@ import type { AndroidProjectConfig, Config, } from '@react-native-community/cli-types'; +import type { StartDevServerArgs } from '@rock-js/config'; import type { FingerprintSources, RemoteBuildCache } from '@rock-js/tools'; import { color, @@ -37,6 +38,8 @@ export interface Flags extends BuildFlags { binaryPath?: string; user?: string; local?: boolean; + devServer?: boolean; + clientLogs?: boolean; } export type AndroidProject = NonNullable; @@ -50,9 +53,29 @@ export async function runAndroid( projectRoot: string, remoteCacheProvider: null | (() => RemoteBuildCache) | undefined, fingerprintOptions: FingerprintSources, + startDevServer: (options: StartDevServerArgs) => void, + reactNativeVersion: string, + reactNativePath: string, + platforms: { [platform: string]: object } ) { intro('Running Android app'); + const startDevServerHelper = () => { + if(args.devServer) { + logger.info('🔍 Starting dev server...'); + startDevServer({ + root: projectRoot, + reactNativePath, + reactNativeVersion, + platforms, + args: { + interactive: isInteractive(), + clientLogs: args.clientLogs ?? true, + }, + }); + } + }; + normalizeArgs(args, projectRoot); const devices = await listAndroidDevices(); @@ -88,6 +111,8 @@ export async function runAndroid( } await runOnDevice({ device, androidProject, args, tasks, binaryPath }); } + + startDevServerHelper(); } else { if ((await getDevices()).length === 0) { if (isInteractive()) { @@ -109,6 +134,7 @@ export async function runAndroid( await runOnDevice({ device, androidProject, args, tasks, binaryPath }); } } + startDevServerHelper(); } outro('Success 🎉.'); @@ -279,4 +305,12 @@ export const runOptions = [ name: '--user ', description: 'Id of the User Profile you want to install the app on.', }, + { + name: '--client-logs', + description: 'Enable client logs in dev server.', + }, + { + name: '--dev-server', + description: 'Enable automatic bundler detection and switching for Android apps.', + }, ]; diff --git a/packages/platform-apple-helpers/src/lib/commands/run/createRun.ts b/packages/platform-apple-helpers/src/lib/commands/run/createRun.ts index dc8abc2e4..fd5a35adc 100644 --- a/packages/platform-apple-helpers/src/lib/commands/run/createRun.ts +++ b/packages/platform-apple-helpers/src/lib/commands/run/createRun.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { StartDevServerArgs } from '@rock-js/config'; import type { FingerprintSources, RemoteBuildCache } from '@rock-js/tools'; import { color, @@ -34,6 +35,9 @@ export const createRun = async ({ remoteCacheProvider, fingerprintOptions, reactNativePath, + reactNativeVersion, + platforms, + startDevServer, }: { platformName: ApplePlatform; projectConfig: ProjectConfig; @@ -42,7 +46,26 @@ export const createRun = async ({ remoteCacheProvider: null | (() => RemoteBuildCache) | undefined; fingerprintOptions: FingerprintSources; reactNativePath: string; + reactNativeVersion: string; + platforms: { [platform: string]: object }; + startDevServer: (options: StartDevServerArgs) => void; }) => { + + const startDevServerHelper = () => { + if (args.devServer) { + logger.info('Starting dev server...'); + startDevServer({ + root: projectRoot, + reactNativePath, + reactNativeVersion, + platforms, + args: { + interactive: isInteractive(), + clientLogs: args.clientLogs ?? true, + }, + }); + } + }; validateArgs(args, projectRoot); const deviceOrSimulator = args.destination @@ -92,7 +115,9 @@ export const createRun = async ({ deviceOrSimulator, fingerprintOptions, }); + await runOnMac(appPath); + startDevServerHelper(); return; } else if (args.catalyst) { const { appPath, scheme } = await buildApp({ @@ -108,8 +133,11 @@ export const createRun = async ({ deviceOrSimulator, fingerprintOptions, }); + + if (scheme) { await runOnMacCatalyst(appPath, scheme); + startDevServerHelper(); return; } else { throw new RockError('Failed to get project scheme'); @@ -128,7 +156,7 @@ export const createRun = async ({ if (device) { if (device.type !== deviceOrSimulator) { throw new RockError( - `Selected device "${device.name}" is not a ${deviceOrSimulator}. + `Selected device "${device.name}" is not a ${deviceOrSimulator}. Please either use "--destination ${ deviceOrSimulator === 'simulator' ? 'device' : 'simulator' }" flag or select available ${deviceOrSimulator}: @@ -155,7 +183,9 @@ ${devices fingerprintOptions, }), ]); + await runOnSimulator(device, appPath, infoPlistPath); + startDevServerHelper(); } else if (device.type === 'device') { const { appPath, bundleIdentifier } = await buildApp({ args, @@ -169,12 +199,14 @@ ${devices deviceOrSimulator, fingerprintOptions, }); + await runOnDevice( device, appPath, projectConfig.sourceDir, bundleIdentifier, ); + startDevServerHelper(); } return; } else { @@ -223,6 +255,7 @@ ${devices fingerprintOptions, }), ]); + if (bootedDevice.type === 'simulator') { await runOnSimulator(bootedDevice, appPath, infoPlistPath); } else { @@ -234,6 +267,7 @@ ${devices ); } } + startDevServerHelper(); } }; diff --git a/packages/platform-apple-helpers/src/lib/commands/run/runOptions.ts b/packages/platform-apple-helpers/src/lib/commands/run/runOptions.ts index 1bacf0383..8dd9daa23 100644 --- a/packages/platform-apple-helpers/src/lib/commands/run/runOptions.ts +++ b/packages/platform-apple-helpers/src/lib/commands/run/runOptions.ts @@ -8,6 +8,8 @@ export interface RunFlags extends BuildFlags { device?: string; catalyst?: boolean; local?: boolean; + devServer?: boolean; + clientLogs?: boolean; } export const getRunOptions = ({ platformName }: BuilderCommand) => { @@ -30,6 +32,14 @@ export const getRunOptions = ({ platformName }: BuilderCommand) => { name: '--catalyst', description: 'Run on Mac Catalyst.', }, + { + name: '--client-logs', + description: 'Enable client logs in dev server.', + }, + { + name: '--dev-server', + description: 'Disable dev server startup for iOS apps (use local bundle only).', + }, ...getBuildOptions({ platformName }), ]; }; diff --git a/packages/platform-ios/src/lib/platformIOS.ts b/packages/platform-ios/src/lib/platformIOS.ts index 9703ba160..bae946659 100644 --- a/packages/platform-ios/src/lib/platformIOS.ts +++ b/packages/platform-ios/src/lib/platformIOS.ts @@ -61,6 +61,9 @@ export const platformIOS = remoteCacheProvider: await api.getRemoteCacheProvider(), fingerprintOptions: api.getFingerprintOptions(), reactNativePath: api.getReactNativePath(), + reactNativeVersion: api.getReactNativeVersion(), + platforms: api.getPlatforms(), + startDevServer: api.getBundlerStart(), }); outro('Success 🎉.'); }, diff --git a/packages/plugin-metro/src/index.ts b/packages/plugin-metro/src/index.ts index de3870480..aa80ef452 100644 --- a/packages/plugin-metro/src/index.ts +++ b/packages/plugin-metro/src/index.ts @@ -1 +1,2 @@ export * from './lib/pluginMetro.js'; +export { startDevServer } from './lib/start/command.js'; diff --git a/packages/plugin-metro/src/lib/pluginMetro.ts b/packages/plugin-metro/src/lib/pluginMetro.ts index 83aade990..2529d9339 100644 --- a/packages/plugin-metro/src/lib/pluginMetro.ts +++ b/packages/plugin-metro/src/lib/pluginMetro.ts @@ -1,16 +1,17 @@ -import type { PluginApi, PluginOutput } from '@rock-js/config'; +import type { BundlerPluginOutput, PluginApi } from '@rock-js/config'; import { registerBundleCommand } from './bundle/command.js'; -import { registerStartCommand } from './start/command.js'; +import { registerStartCommand, startDevServer } from './start/command.js'; export const pluginMetro = () => - (api: PluginApi): PluginOutput => { + (api: PluginApi): BundlerPluginOutput => { registerStartCommand(api); registerBundleCommand(api); return { name: '@rock-js/plugin-metro', description: 'Rock plugin for Metro bundler.', + start: startDevServer, }; }; diff --git a/packages/plugin-metro/src/lib/start/command.ts b/packages/plugin-metro/src/lib/start/command.ts index 8f3cb33b2..304dcbcd9 100644 --- a/packages/plugin-metro/src/lib/start/command.ts +++ b/packages/plugin-metro/src/lib/start/command.ts @@ -7,10 +7,33 @@ import path from 'node:path'; import type { PluginApi } from '@rock-js/config'; +import type { StartDevServerArgs } from '@rock-js/config'; import { findDevServerPort, intro } from '@rock-js/tools'; import type { StartCommandArgs } from './runServer.js'; import runServer from './runServer.js'; +export async function startDevServer({ + root, + args, + reactNativeVersion, + reactNativePath, + platforms, +}: StartDevServerArgs) { + const { port, startDevServer } = await findDevServerPort( + args.port ? Number(args.port) : 8081, + root + ); + + if (!startDevServer) { + return; + } + + return runServer( + { root, reactNativeVersion, reactNativePath, platforms }, + { ...args, port, platforms: Object.keys(platforms) } + ); +} + export const registerStartCommand = (api: PluginApi) => { api.registerCommand({ name: 'start', diff --git a/packages/plugin-repack/src/index.ts b/packages/plugin-repack/src/index.ts index 8530c0676..10556cbda 100644 --- a/packages/plugin-repack/src/index.ts +++ b/packages/plugin-repack/src/index.ts @@ -1 +1,2 @@ export * from './lib/pluginRepack.js'; +export { startDevServer } from './lib/pluginRepack.js'; diff --git a/packages/plugin-repack/src/lib/pluginRepack.ts b/packages/plugin-repack/src/lib/pluginRepack.ts index 659414c01..621c192c9 100644 --- a/packages/plugin-repack/src/lib/pluginRepack.ts +++ b/packages/plugin-repack/src/lib/pluginRepack.ts @@ -1,5 +1,5 @@ import commands from '@callstack/repack/commands/rspack'; -import type { PluginApi, PluginOutput } from '@rock-js/config'; +import type { BundlerPluginOutput, PluginApi, StartDevServerArgs } from '@rock-js/config'; import { colorLink, findDevServerPort, @@ -25,9 +25,39 @@ type BundleArgs = Parameters['func']>[2] & { const startCommand = commands.find((command) => command.name === 'start'); const bundleCommand = commands.find((command) => command.name === 'bundle'); +export async function startDevServer({ + root, + args, + reactNativeVersion: _reactNativeVersion, + reactNativePath, + platforms, +}: StartDevServerArgs, pluginConfig: PluginConfig = {}) { + const { port, startDevServer } = await findDevServerPort( + args.port ? Number(args.port) : 8081, + root + ); + + if (!startDevServer) { + return; + } + + if (!startCommand) { + throw new RockError('Re.Pack "start" command not found.'); + } + + logger.info('Starting Re.Pack dev server...'); + + startCommand.func( + [], + // @ts-expect-error TODO fix getPlatforms type + { reactNativePath, root, platforms, ...pluginConfig }, + { ...args, port }, + ); +} + export const pluginRepack = (pluginConfig: PluginConfig = {}) => - (api: PluginApi): PluginOutput => { + (api: PluginApi): BundlerPluginOutput => { if (!startCommand) { throw new RockError('Re.Pack "start" command not found.'); } @@ -117,6 +147,7 @@ export const pluginRepack = return { name: '@rock-js/plugin-repack', description: 'Rock plugin for Re.Pack toolkit with Rspack.', + start: startDevServer, }; };