diff --git a/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js b/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js new file mode 100644 index 00000000000000..94200ed41cb642 --- /dev/null +++ b/flow-typed/npm/@react-native-community/cli-server-api_v12.x.x.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +declare module '@react-native-community/cli-server-api' { + import type {NextHandleFunction, Server} from 'connect'; + + declare type MiddlewareOptions = { + host?: string, + watchFolders: $ReadOnlyArray, + port: number, + }; + + declare export function createDevServerMiddleware( + options: MiddlewareOptions, + ): { + middleware: Server, + websocketEndpoints: { + '/debugger-proxy': ws$WebSocketServer, + '/message': ws$WebSocketServer, + '/events': ws$WebSocketServer, + }, + debuggerProxyEndpoint: { + server: ws$WebSocketServer, + isDebuggerConnected: () => boolean, + }, + messageSocketEndpoint: { + server: ws$WebSocketServer, + broadcast: ( + method: string, + params?: Record | null, + ) => void, + }, + eventsSocketEndpoint: { + server: ws$WebSocketServer, + reportEvent: (event: any) => void, + }, + ... + }; + + declare export const indexPageMiddleware: NextHandleFunction; +} diff --git a/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js b/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js new file mode 100644 index 00000000000000..9c78411325744e --- /dev/null +++ b/flow-typed/npm/@react-native-community/cli-tools_v12.x.x.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +declare module '@react-native-community/cli-tools' { + declare export class CLIError extends Error { + constructor(msg: string, originalError?: Error | mixed | string): this; + } + + declare export function hookStdout(callback: Function): () => void; + + declare export const logger: $ReadOnly<{ + success: (...message: Array) => void, + info: (...message: Array) => void, + warn: (...message: Array) => void, + error: (...message: Array) => void, + debug: (...message: Array) => void, + log: (...message: Array) => void, + setVerbose: (level: boolean) => void, + isVerbose: () => boolean, + disable: () => void, + enable: () => void, + }>; + + declare export function resolveNodeModuleDir( + root: string, + packageName: string, + ): string; + + declare export const version: $ReadOnly<{ + logIfUpdateAvailable: (projectRoot: string) => Promise, + }>; +} diff --git a/flow-typed/npm/@react-native-community/cli-types_v12.x.x.js b/flow-typed/npm/@react-native-community/cli-types_v12.x.x.js new file mode 100644 index 00000000000000..2a1cd2da6eba9a --- /dev/null +++ b/flow-typed/npm/@react-native-community/cli-types_v12.x.x.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +declare module '@react-native-community/cli-types' { + declare type PlatformConfig = { + npmPackageName: string, + ... + }; + + declare export type Config = { + root: string, + reactNativePath: string, + reactNativeVersion: string, + project: Object, + platforms: { + android: PlatformConfig, + ios: PlatformConfig, + [name: string]: PlatformConfig, + }, + ... + }; +} diff --git a/flow-typed/npm/execa_v5.x.x.js b/flow-typed/npm/execa_v5.x.x.js new file mode 100644 index 00000000000000..e3916b531ff375 --- /dev/null +++ b/flow-typed/npm/execa_v5.x.x.js @@ -0,0 +1,118 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +// Modified from flow-typed repo: +// https://github.com/flow-typed/flow-typed/blob/master/definitions/npm/execa_v2.x.x/flow_v0.104.x-/execa_v2.x.x.js#L2 + +declare module 'execa' { + declare type StdIoOption = + | 'pipe' + | 'ipc' + | 'ignore' + | 'inherit' + | stream$Stream + | number; + + declare type CommonOptions = { + argv0?: string, + cleanup?: boolean, + cwd?: string, + detached?: boolean, + encoding?: string, + env?: {[string]: string | void, ...}, + extendEnv?: boolean, + gid?: number, + killSignal?: string | number, + localDir?: string, + maxBuffer?: number, + preferLocal?: boolean, + reject?: boolean, + shell?: boolean | string, + stderr?: ?StdIoOption, + stdin?: ?StdIoOption, + stdio?: 'pipe' | 'ignore' | 'inherit' | $ReadOnlyArray, + stdout?: ?StdIoOption, + stripEof?: boolean, + timeout?: number, + uid?: number, + windowsVerbatimArguments?: boolean, + buffer?: boolean, + all?: boolean, + stripFinalNewline?: boolean, + }; + + declare type SyncOptions = { + ...CommonOptions, + input?: string | Buffer, + }; + + declare type Options = { + ...CommonOptions, + input?: string | Buffer | stream$Readable, + }; + + declare type SyncResult = { + stdout: string, + stderr: string, + exitCode: number, + failed: boolean, + signal: ?string, + command: string, + timedOut: boolean, + }; + + declare type Result = { + ...SyncResult, + killed: boolean, + }; + + declare interface ExecaPromise + extends Promise, + child_process$ChildProcess { + } + + declare interface ExecaError extends ErrnoError { + stdout: string; + stderr: string; + failed: boolean; + signal: ?string; + command: string; + timedOut: boolean; + exitCode: number; + } + + declare interface Execa { + ( + file: string, + args?: $ReadOnlyArray, + options?: $ReadOnly, + ): ExecaPromise; + (file: string, options?: $ReadOnly): ExecaPromise; + + command(command: string, options?: $ReadOnly): ExecaPromise; + commandSync(command: string, options?: $ReadOnly): ExecaPromise; + + node( + path: string, + args?: $ReadOnlyArray, + options?: $ReadOnly, + ): void; + + sync( + file: string, + args?: $ReadOnlyArray, + options?: $ReadOnly, + ): SyncResult; + sync(file: string, options?: $ReadOnly): SyncResult; + } + + declare module.exports: Execa; +} diff --git a/flow-typed/npm/ws_v7.x.x.js b/flow-typed/npm/ws_v7.x.x.js new file mode 100644 index 00000000000000..c2759dbb18c44a --- /dev/null +++ b/flow-typed/npm/ws_v7.x.x.js @@ -0,0 +1,343 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + * @oncall react_native + */ + +declare type ws$PerMessageDeflateOptions = { + serverNoContextTakeover?: boolean, + clientNoContextTakeover?: boolean, + serverMaxWindowBits?: boolean | number, + clientMaxWindowBits?: boolean | number, + zlibDeflateOptions?: zlib$options, + zlibInflateOptions?: zlib$options, + threshold?: number, + concurrencyLimit?: number, + isServer?: boolean, + maxPayload?: number, +}; + +/* $FlowFixMe[incompatible-extend] - Found with Flow v0.143.1 upgrade + * "on" definition failing with string is incompatible with string literal */ +declare class ws$WebSocketServer extends events$EventEmitter { + /** + * Create a `WebSocketServer` instance. + */ + constructor( + options: { + backlog?: number, + clientTracking?: boolean, + handleProtocols?: () => mixed, + host?: string, + maxPayload?: number, + noServer?: boolean, + path?: string, + perMessageDeflate?: boolean | ws$PerMessageDeflateOptions, + port?: number, + server?: http$Server | https$Server, + verifyClient?: () => mixed, + }, + callback?: () => mixed, + ): this; + + /** + * Emitted when the server closes. + */ + on(event: 'close', () => mixed): this; + + /** + * Emitted when the handshake is complete. + */ + on( + event: 'connection', + (socket: ws$WebSocket, request: http$IncomingMessage<>) => mixed, + ): this; + + /** + * Emitted when an error occurs on the underlying server. + */ + on(event: 'error', (error: Error) => mixed): this; + + /** + * Emitted before the response headers are written to the socket as part of + * the handshake. + */ + on( + event: 'headers', + (headers: Array, request: http$IncomingMessage<>) => mixed, + ): this; + + /** + * Emitted when the underlying server has been bound. + */ + on(event: 'listening', () => mixed): this; + + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + */ + address(): null | string | {port?: number, family?: string, address?: string}; + + /** + * A set that stores all connected clients. Please note that this property is + * only added when the `clientTracking` is truthy. + */ + clients: Set; + + /** + * Close the server. + */ + close(callback?: () => mixed): void; + + /** + * Handle a HTTP Upgrade request. + */ + handleUpgrade( + request: http$IncomingMessage<>, + socket: net$Socket, + head: Buffer, + callback: (?ws$WebSocket) => mixed, + ): void; + + /** + * See if a given request should be handled by this server instance. + */ + shouldHandle(request: http$IncomingMessage<>): boolean; +} + +declare type ws$WebSocketOptions = { + followRedirects?: boolean, + handshakeTimeout?: number, + maxRedirects?: number, + perMessageDeflate?: boolean | ws$PerMessageDeflateOptions, + protocolVersion?: number, + origin?: string, + maxPayload?: number, + ...requestOptions, + agent?: boolean | http$Agent<> | http$Agent, + createConnection?: + | ((options: net$connectOptions, callback?: () => mixed) => net$Socket) + | ((options: tls$connectOptions, callback?: () => mixed) => tls$TLSSocket), +}; + +declare type ws$CloseListener = (code: number, reason: string) => mixed; +declare type ws$ErrorListener = (error: Error) => mixed; +declare type ws$MessageListener = ( + data: string | Buffer | ArrayBuffer | Array, +) => mixed; +declare type ws$OpenListener = () => mixed; +declare type ws$PingListener = (Buffer) => mixed; +declare type ws$PongListener = (Buffer) => mixed; +declare type ws$UnexpectedResponseListener = ( + request: http$ClientRequest<>, + response: http$IncomingMessage<>, +) => mixed; +declare type ws$UpgradeListener = (response: http$IncomingMessage<>) => mixed; + +/* $FlowFixMe[incompatible-extend] - Found with Flow v0.143.1 upgrade + * "on" definition failing with string is incompatible with string literal */ +declare class ws$WebSocket extends events$EventEmitter { + static Server: typeof ws$WebSocketServer; + + static createWebSocketStream: ( + WebSocket: ws$WebSocket, + options?: duplexStreamOptions, + ) => stream$Duplex; + + static CONNECTING: number; + static OPEN: number; + static CLOSING: number; + static CLOSED: number; + + /** + * Create a `WebSocket` instance. + */ + constructor( + address: string | URL, + protocols?: string | Array, + options?: ws$WebSocketOptions, + ): this; + constructor(address: string | URL, options: ws$WebSocketOptions): this; + + /* + * Emitted when the connection is closed. + */ + on('close', ws$CloseListener): this; + + /* + * Emitted when an error occurs. + */ + on('error', ws$ErrorListener): this; + + /* + * Emitted when a message is received from the server. + */ + on('message', ws$MessageListener): this; + + /* + * Emitted when the connection is established. + */ + on('open', ws$OpenListener): this; + + /* + * Emitted when a ping is received from the server. + */ + on('ping', ws$PingListener): this; + + /* + * Emitted when a pong is received from the server. + */ + on('pong', ws$PongListener): this; + + /* + * Emitted when the server response is not the expected one, + * for example a 401 response. + */ + on('unexpected-response', ws$UnexpectedResponseListener): this; + + /* + * Emitted when response headers are received from the server as part of the + * handshake. + */ + on('upgrade', ws$UpgradeListener): this; + + /** + * Register an event listener emulating the `EventTarget` interface. + */ + addEventListener( + type: 'close', + listener: ws$CloseListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'error', + listener: ws$ErrorListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'message', + listener: ws$MessageListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'open', + listener: ws$OpenListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'ping', + listener: ws$PingListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'pong', + listener: ws$PongListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'unexpected-response', + ws$UnexpectedResponseListener, + options?: {once?: boolean}, + ): this; + addEventListener( + type: 'upgrade', + listener: ws$UpgradeListener, + options?: {once?: boolean}, + ): this; + + /** + * A string indicating the type of binary data being transmitted by the + * connection. + */ + binaryType: string; + + /** + * The number of bytes of data that have been queued using calls to send() + * but not yet transmitted to the network. + */ + bufferedAmount: number; + + /** + * Initiate a closing handshake. + */ + close(code?: number, reason?: string): void; + + /** + * The negotiated extensions. + */ + extensions: string; + + /** + * Send a ping. + */ + ping(data?: any, mask?: boolean, callback?: () => mixed): void; + ping(data: any, callback: () => mixed): void; + ping(callback: () => mixed): void; + + /** + * Send a pong. + */ + pong(data?: any, mask?: boolean, callback?: () => mixed): void; + pong(data: any, callback: () => mixed): void; + pong(callback: () => mixed): void; + + /** + * The subprotocol selected by the server. + */ + protocol: string; + + /** + * The current state of the connection. + */ + readyState: number; + + /** + * Removes an event listener emulating the `EventTarget` interface. + */ + removeEventListener(type: 'close', listener: ws$CloseListener): this; + removeEventListener(type: 'error', listener: ws$ErrorListener): this; + removeEventListener(type: 'message', listener: ws$MessageListener): this; + removeEventListener(type: 'open', listener: ws$OpenListener): this; + removeEventListener(type: 'ping', listener: ws$PingListener): this; + removeEventListener(type: 'pong', listener: ws$PongListener): this; + removeEventListener( + type: 'unexpected-response', + ws$UnexpectedResponseListener, + ): this; + removeEventListener(type: 'upgrade', listener: ws$UpgradeListener): this; + + /** + * Send a data message. + */ + send( + data?: any, + options?: { + compress?: boolean, + binary?: boolean, + mask?: boolean, + fin?: boolean, + }, + callback?: () => mixed, + ): void; + send(data: any, callback: () => mixed): void; + + /** + * Forcibly close the connection. + */ + terminate(): void; +} + +declare module 'ws' { + declare module.exports: typeof ws$WebSocket; +} + +declare module 'ws/lib/websocket-server' { + declare module.exports: typeof ws$WebSocketServer; +} diff --git a/packages/cli-commands/.gitignore b/packages/cli-commands/.gitignore new file mode 100644 index 00000000000000..40d93a0332c96d --- /dev/null +++ b/packages/cli-commands/.gitignore @@ -0,0 +1,5 @@ +# Dependencies +/node_modules + +# Build output +/dist diff --git a/packages/cli-commands/README.md b/packages/cli-commands/README.md new file mode 100644 index 00000000000000..252e69f5dce31f --- /dev/null +++ b/packages/cli-commands/README.md @@ -0,0 +1,87 @@ +# @react-native/cli-commands + +![https://img.shields.io/npm/v/@react-native/cli-commands?color=brightgreen&label=npm%20package](https://www.npmjs.com/package/@react-native/cli-commands) + +CLI entry points supporting core React Native development features. This package is preconfigured in all React Native projects. + +Formerly [@react-native-community/cli-plugin-metro](https://www.npmjs.com/package/@react-native-community/cli-plugin-metro). + +## Commands + +### `start` + +Starts the React Native development server. + +#### Usage + +```sh +react-native start [options] +``` + +#### Options + +| Option | Description | +| - | - | +| `--port ` | Set the server port. | +| `--host ` | Set the server host. | +| `--projectRoot ` | Set the path to the project root. | +| `--watchFolders ` | Specify additional folders to be added to the watch list. | +| `--assetPlugins ` | Specify additional asset plugins. | +| `--sourceExts ` | Specify additional source extensions to bundle. | +| `--max-workers ` | Set the maximum number of workers the worker-pool will spawn for transforming files. Defaults to the number of the cores available on your machine. | +| `--transformer ` | Specify a custom transformer. | +| `--reset-cache` | Remove cached files. | +| `--custom-log-reporter-path ` | Specify a module path exporting a replacement for `TerminalReporter`. | +| `--https` | Enable HTTPS connections. | +| `--key `| Specify path to a custom SSL key. | +| `--cert ` | Specify path to a custom SSL cert. | +| `--config ` | Path to the CLI configuration file. | +| `--no-interactive` | Disables interactive mode. | + +### `bundle` + +Builds the JavaScript bundle for offline use. + +#### Usage + +```sh +react-native bundle --entry-file [options] +``` + +#### Options + +| Option | Description | +| - | - | +| `--entry-file ` | Set the path to the root JavaScript entry file. | +| `--platform ` | Set the target platform (either `"android"` or `"ios"`). Defaults to `"ios"`. | +| `--transformer ` | Specify a custom transformer. | +| `--dev [boolean]` | If `false`, warnings are disabled and the bundle is minified. Defaults to `true`. | +| `--minify [boolean]` | Allows overriding whether bundle is minified. Defaults to `false` if `--dev` is set. Disabling minification can be useful for speeding up production builds for testing purposes. | +| `--bundle-output ` | Specify the path to store the resulting bundle. | +| `--bundle-encoding ` | Specify the encoding for writing the bundle (). | +| `--sourcemap-output ` | Specify the path to store the source map file for the resulting bundle. | +| `--sourcemap-sources-root ` | Set the root path for source map entries. | +| `--sourcemap-use-absolute-path` | Report `SourceMapURL` using its full path. | +| `--max-workers ` | Set the maximum number of workers the worker-pool will spawn for transforming files. Defaults to the number of the cores available on your machine. | +| `--assets-dest ` | Specify the directory path for storing assets referenced in the bundle. | +| `--reset-cache` | Remove cached files. | +| `--read-global-cache` | Attempt to fetch transformed JS code from the global cache, if configured. Defaults to `false`. | +| `--config ` | Path to the CLI configuration file. | + +### `ram-bundle` + +Builds JavaScript as a "Random Access Module" bundle for offline use. + +#### Usage + +```sh +react-native ram-bundle --entry-file [options] +``` + +#### Options + +Accepts all options supported by [`bundle`](#bundle) and the following: + +| Option | Description | +| - | - | +| `--indexed-ram-bundle` | Force the "Indexed RAM" bundle file format, even when building for Android. | diff --git a/packages/cli-commands/index.js.flow b/packages/cli-commands/index.js.flow new file mode 100644 index 00000000000000..17ae7ce0503d8c --- /dev/null +++ b/packages/cli-commands/index.js.flow @@ -0,0 +1,12 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +export * from './src'; diff --git a/packages/cli-commands/launchPackager.bat b/packages/cli-commands/launchPackager.bat new file mode 100644 index 00000000000000..a76f4f06fe353f --- /dev/null +++ b/packages/cli-commands/launchPackager.bat @@ -0,0 +1,7 @@ +@echo off +title Metro +call .packager.bat +cd "%PROJECT_ROOT%" +node "%REACT_NATIVE_PATH%/cli.js" start +pause +exit diff --git a/packages/cli-commands/launchPackager.command b/packages/cli-commands/launchPackager.command new file mode 100755 index 00000000000000..5fb23c56e451ea --- /dev/null +++ b/packages/cli-commands/launchPackager.command @@ -0,0 +1,10 @@ +THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd) + +source "$THIS_DIR/.packager.env" +cd $PROJECT_ROOT +$REACT_NATIVE_PATH/cli.js start + +if [[ -z "$CI" ]]; then + echo "Process terminated. Press to close the window" + read -r +fi diff --git a/packages/cli-commands/package.json b/packages/cli-commands/package.json new file mode 100644 index 00000000000000..6c81b7a5793292 --- /dev/null +++ b/packages/cli-commands/package.json @@ -0,0 +1,41 @@ +{ + "name": "@react-native/cli-commands", + "version": "0.73.0", + "description": "Build scripts for React Native", + "keywords": [ + "react-native", + "tools" + ], + "homepage": "https://github.com/facebook/react-native/tree/HEAD/packages/buid-scripts#readme", + "bugs": "https://github.com/facebook/react-native/issues", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react-native.git", + "directory": "packages/cli-commands" + }, + "license": "MIT", + "exports": { + ".": "./src/index.js", + "./package.json": "./package.json" + }, + "files": [ + "dist", + "launchPackager.bat", + "launchPackager.command" + ], + "dependencies": { + "@react-native-community/cli-server-api": "12.0.0-alpha.7", + "@react-native-community/cli-tools": "12.0.0-alpha.7", + "@react-native/metro-babel-transformer": "^0.73.11", + "chalk": "^4.0.0", + "execa": "^5.1.1", + "metro": "0.77.0", + "metro-config": "0.77.0", + "metro-core": "0.77.0", + "metro-resolver": "0.77.0", + "readline": "^1.3.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/cli-commands/src/commands/bundle/__mocks__/sign.js b/packages/cli-commands/src/commands/bundle/__mocks__/sign.js new file mode 100644 index 00000000000000..7f967ff3b53d2b --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/__mocks__/sign.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall react_native + */ + +function sign(source) { + return source; +} + +module.exports = sign; diff --git a/packages/cli-commands/src/commands/bundle/__tests__/filterPlatformAssetScales-test.js b/packages/cli-commands/src/commands/bundle/__tests__/filterPlatformAssetScales-test.js new file mode 100644 index 00000000000000..aa7c7a22ef004a --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/__tests__/filterPlatformAssetScales-test.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import filterPlatformAssetScales from '../filterPlatformAssetScales'; + +jest.dontMock('../filterPlatformAssetScales').dontMock('../assetPathUtils'); + +describe('filterPlatformAssetScales', () => { + test('removes everything but 2x and 3x for iOS', () => { + expect(filterPlatformAssetScales('ios', [1, 1.5, 2, 3, 4])).toEqual([ + 1, 2, 3, + ]); + expect(filterPlatformAssetScales('ios', [3, 4])).toEqual([3]); + }); + + test('keeps closest largest one if nothing matches', () => { + expect(filterPlatformAssetScales('ios', [0.5, 4, 100])).toEqual([4]); + expect(filterPlatformAssetScales('ios', [0.5, 100])).toEqual([100]); + expect(filterPlatformAssetScales('ios', [0.5])).toEqual([0.5]); + expect(filterPlatformAssetScales('ios', [])).toEqual([]); + }); + + test('keeps all scales for unknown platform', () => { + expect(filterPlatformAssetScales('freebsd', [1, 1.5, 2, 3.7])).toEqual([ + 1, 1.5, 2, 3.7, + ]); + }); +}); diff --git a/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathAndroid-test.js b/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathAndroid-test.js new file mode 100644 index 00000000000000..a1d8cae1876326 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathAndroid-test.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import getAssetDestPathAndroid from '../getAssetDestPathAndroid'; + +jest.dontMock('../getAssetDestPathAndroid').dontMock('../assetPathUtils'); + +const path = require('path'); + +describe('getAssetDestPathAndroid', () => { + test('should use the right destination folder', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/test', + }; + + const expectDestPathForScaleToStartWith = ( + scale: number, + location: string, + ) => { + if (!getAssetDestPathAndroid(asset, scale).startsWith(location)) { + throw new Error( + `asset for scale ${scale} should start with path '${location}'`, + ); + } + }; + + expectDestPathForScaleToStartWith(1, 'drawable-mdpi'); + expectDestPathForScaleToStartWith(1.5, 'drawable-hdpi'); + expectDestPathForScaleToStartWith(2, 'drawable-xhdpi'); + expectDestPathForScaleToStartWith(3, 'drawable-xxhdpi'); + expectDestPathForScaleToStartWith(4, 'drawable-xxxhdpi'); + }); + + test('should lowercase path', () => { + const asset = { + name: 'Icon', + type: 'png', + httpServerLocation: '/assets/App/Test', + }; + + expect(getAssetDestPathAndroid(asset, 1)).toBe( + path.normalize('drawable-mdpi/app_test_icon.png'), + ); + }); + + test('should remove `assets/` prefix', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/RKJSModules/Apps/AndroidSample/Assets', + }; + + expect(getAssetDestPathAndroid(asset, 1).startsWith('assets_')).toBeFalsy(); + }); + + test('should put non-drawable resources to `raw/`', () => { + const asset = { + name: 'video', + type: 'mp4', + httpServerLocation: '/assets/app/test', + }; + + expect(getAssetDestPathAndroid(asset, 1)).toBe( + path.normalize('raw/app_test_video.mp4'), + ); + }); + + test('should handle assets with a relative path outside of root', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/../../test', + }; + + expect(getAssetDestPathAndroid(asset, 1)).toBe( + path.normalize('drawable-mdpi/__test_icon.png'), + ); + }); +}); diff --git a/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathIOS-test.js b/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathIOS-test.js new file mode 100644 index 00000000000000..8bf573d0972e3c --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/__tests__/getAssetDestPathIOS-test.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import getAssetDestPathIOS from '../getAssetDestPathIOS'; + +jest.dontMock('../getAssetDestPathIOS'); + +const path = require('path'); + +describe('getAssetDestPathIOS', () => { + test('should build correct path', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/test', + }; + + expect(getAssetDestPathIOS(asset, 1)).toBe( + path.normalize('assets/test/icon.png'), + ); + }); + + test('should consider scale', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/test', + }; + + expect(getAssetDestPathIOS(asset, 2)).toBe( + path.normalize('assets/test/icon@2x.png'), + ); + expect(getAssetDestPathIOS(asset, 3)).toBe( + path.normalize('assets/test/icon@3x.png'), + ); + }); + + test('should handle assets with a relative path outside of root', () => { + const asset = { + name: 'icon', + type: 'png', + httpServerLocation: '/assets/../../test', + }; + + expect(getAssetDestPathIOS(asset, 1)).toBe( + path.normalize('assets/__test/icon.png'), + ); + }); +}); diff --git a/packages/cli-commands/src/commands/bundle/assetCatalogIOS.js b/packages/cli-commands/src/commands/bundle/assetCatalogIOS.js new file mode 100644 index 00000000000000..ed6aaa1c4c63e0 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/assetCatalogIOS.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {AssetData} from 'metro/src/Assets'; + +import path from 'path'; +import fs from 'fs'; +import assetPathUtils from './assetPathUtils'; + +export function cleanAssetCatalog(catalogDir: string): void { + const files = fs + .readdirSync(catalogDir) + .filter(file => file.endsWith('.imageset')); + for (const file of files) { + fs.rmSync(path.join(catalogDir, file)); + } +} + +type ImageSet = { + basePath: string, + files: {name: string, src: string, scale: number}[], +}; + +export function getImageSet( + catalogDir: string, + asset: AssetData, + scales: $ReadOnlyArray, +): ImageSet { + const fileName = assetPathUtils.getResourceIdentifier(asset); + return { + basePath: path.join(catalogDir, `${fileName}.imageset`), + files: scales.map((scale, idx) => { + const suffix = scale === 1 ? '' : `@${scale}x`; + return { + name: `${fileName + suffix}.${asset.type}`, + scale, + src: asset.files[idx], + }; + }), + }; +} + +export function isCatalogAsset(asset: AssetData): boolean { + return asset.type === 'png' || asset.type === 'jpg' || asset.type === 'jpeg'; +} + +export function writeImageSet(imageSet: ImageSet): void { + fs.mkdirSync(imageSet.basePath, {recursive: true}); + + for (const file of imageSet.files) { + const dest = path.join(imageSet.basePath, file.name); + fs.copyFileSync(file.src, dest); + } + + fs.writeFileSync( + path.join(imageSet.basePath, 'Contents.json'), + JSON.stringify({ + images: imageSet.files.map(file => ({ + filename: file.name, + idiom: 'universal', + scale: `${file.scale}x`, + })), + info: { + author: 'xcode', + version: 1, + }, + }), + ); +} diff --git a/packages/cli-commands/src/commands/bundle/assetPathUtils.js b/packages/cli-commands/src/commands/bundle/assetPathUtils.js new file mode 100644 index 00000000000000..7dc19ca0fcb936 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/assetPathUtils.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +export type PackagerAsset = $ReadOnly<{ + httpServerLocation: string, + name: string, + type: string, + ... +}>; + +/** + * FIXME: using number to represent discrete scale numbers is fragile in essence because of + * floating point numbers imprecision. + */ +function getAndroidAssetSuffix(scale: number): string { + switch (scale) { + case 0.75: + return 'ldpi'; + case 1: + return 'mdpi'; + case 1.5: + return 'hdpi'; + case 2: + return 'xhdpi'; + case 3: + return 'xxhdpi'; + case 4: + return 'xxxhdpi'; + default: + return ''; + } +} + +// See https://developer.android.com/guide/topics/resources/drawable-resource.html +const drawableFileTypes = new Set([ + 'gif', + 'jpeg', + 'jpg', + 'png', + 'webp', + 'xml', +]); + +function getAndroidResourceFolderName( + asset: PackagerAsset, + scale: number, +): string { + if (!drawableFileTypes.has(asset.type)) { + return 'raw'; + } + const suffix = getAndroidAssetSuffix(scale); + if (!suffix) { + throw new Error( + `Don't know which android drawable suffix to use for asset: ${JSON.stringify( + asset, + )}`, + ); + } + const androidFolder = `drawable-${suffix}`; + return androidFolder; +} + +function getResourceIdentifier(asset: PackagerAsset): string { + const folderPath = getBasePath(asset); + return `${folderPath}/${asset.name}` + .toLowerCase() + .replace(/\//g, '_') // Encode folder structure in file name + .replace(/([^a-z0-9_])/g, '') // Remove illegal chars + .replace(/^assets_/, ''); // Remove "assets_" prefix +} + +function getBasePath(asset: PackagerAsset): string { + let basePath = asset.httpServerLocation; + if (basePath[0] === '/') { + basePath = basePath.substr(1); + } + return basePath; +} + +export default { + getAndroidAssetSuffix, + getAndroidResourceFolderName, + getResourceIdentifier, + getBasePath, +}; diff --git a/packages/cli-commands/src/commands/bundle/buildBundle.js b/packages/cli-commands/src/commands/bundle/buildBundle.js new file mode 100644 index 00000000000000..aa20889587379c --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/buildBundle.js @@ -0,0 +1,113 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; +import type {RequestOptions} from 'metro/src/shared/types.flow'; +import type {ConfigT} from 'metro-config'; +import type {CommandLineArgs} from './bundleCommandLineArgs'; + +import Server from 'metro/src/Server'; +const outputBundle = require('metro/src/shared/output/bundle'); +import path from 'path'; +import chalk from 'chalk'; +import saveAssets from './saveAssets'; +import {default as loadMetroConfig} from '../../utils/loadMetroConfig'; +import {logger} from '@react-native-community/cli-tools'; + +async function buildBundle( + args: CommandLineArgs, + ctx: Config, + output: typeof outputBundle = outputBundle, +): Promise { + const config = await loadMetroConfig(ctx, { + maxWorkers: args.maxWorkers, + resetCache: args.resetCache, + config: args.config, + }); + + return buildBundleWithConfig(args, config, output); +} + +/** + * Create a bundle using a pre-loaded Metro config. The config can be + * re-used for several bundling calls if multiple platforms are being + * bundled. + */ +export async function buildBundleWithConfig( + args: CommandLineArgs, + config: ConfigT, + output: typeof outputBundle = outputBundle, +): Promise { + if (config.resolver.platforms.indexOf(args.platform) === -1) { + logger.error( + `Invalid platform ${ + args.platform ? `"${chalk.bold(args.platform)}" ` : '' + }selected.`, + ); + + logger.info( + `Available platforms are: ${config.resolver.platforms + .map(x => `"${chalk.bold(x)}"`) + .join( + ', ', + )}. If you are trying to bundle for an out-of-tree platform, it may not be installed.`, + ); + + throw new Error('Bundling failed'); + } + + // This is used by a bazillion of npm modules we don't control so we don't + // have other choice than defining it as an env variable here. + process.env.NODE_ENV = args.dev ? 'development' : 'production'; + + let sourceMapUrl = args.sourcemapOutput; + if (sourceMapUrl && !args.sourcemapUseAbsolutePath) { + sourceMapUrl = path.basename(sourceMapUrl); + } + + // $FlowIgnore[prop-missing] + const requestOpts: RequestOptions & {...} = { + entryFile: args.entryFile, + sourceMapUrl, + dev: args.dev, + minify: args.minify !== undefined ? args.minify : !args.dev, + platform: args.platform, + unstable_transformProfile: args.unstableTransformProfile, + }; + const server = new Server(config); + + try { + const bundle = await output.build(server, requestOpts); + + // $FlowIgnore[class-object-subtyping] + // $FlowIgnore[incompatible-call] + await output.save(bundle, args, logger.info); + + // Save the assets of the bundle + const outputAssets = await server.getAssets({ + ...Server.DEFAULT_BUNDLE_OPTIONS, + ...requestOpts, + bundleType: 'todo', + }); + + // When we're done saving bundle output and the assets, we're done. + return await saveAssets( + outputAssets, + args.platform, + args.assetsDest, + args.assetCatalogDest, + ); + } finally { + server.end(); + } +} + +export default buildBundle; diff --git a/packages/cli-commands/src/commands/bundle/bundle.js b/packages/cli-commands/src/commands/bundle/bundle.js new file mode 100644 index 00000000000000..c001188fc288bf --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/bundle.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; +import type {CommandLineArgs} from './bundleCommandLineArgs'; + +import buildBundle from './buildBundle'; +import bundleCommandLineArgs from './bundleCommandLineArgs'; + +/** + * Builds the bundle starting to look for dependencies at the given entry path. + */ +export function bundleWithOutput( + _: Array, + config: Config, + args: CommandLineArgs, + output: any, // untyped metro/src/shared/output/bundle or metro/src/shared/output/RamBundle +): Promise { + return buildBundle(args, config, output); +} + +export const bundleCommand = { + name: 'bundle', + description: 'builds the javascript bundle for offline use', + func: bundleWithOutput, + options: bundleCommandLineArgs, +}; diff --git a/packages/cli-commands/src/commands/bundle/bundleCommandLineArgs.js b/packages/cli-commands/src/commands/bundle/bundleCommandLineArgs.js new file mode 100644 index 00000000000000..84491e1115c11e --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/bundleCommandLineArgs.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import path from 'path'; + +export interface CommandLineArgs { + assetsDest?: string; + assetCatalogDest?: string; + entryFile: string; + resetCache: boolean; + resetGlobalCache: boolean; + transformer?: string; + minify?: boolean; + config?: string; + platform: string; + dev: boolean; + bundleOutput: string; + bundleEncoding?: 'utf8' | 'utf16le' | 'ascii'; + maxWorkers?: number; + sourcemapOutput?: string; + sourcemapSourcesRoot?: string; + sourcemapUseAbsolutePath: boolean; + verbose: boolean; + unstableTransformProfile: string; +} + +export default [ + { + name: '--entry-file ', + description: + 'Path to the root JS file, either absolute or relative to JS root', + }, + { + name: '--platform ', + description: 'Either "ios" or "android"', + default: 'ios', + }, + { + name: '--transformer ', + description: 'Specify a custom transformer to be used', + }, + { + name: '--dev [boolean]', + description: 'If false, warnings are disabled and the bundle is minified', + parse: (val: string): boolean => val !== 'false', + default: true, + }, + { + name: '--minify [boolean]', + description: + 'Allows overriding whether bundle is minified. This defaults to ' + + 'false if dev is true, and true if dev is false. Disabling minification ' + + 'can be useful for speeding up production builds for testing purposes.', + parse: (val: string): boolean => val !== 'false', + }, + { + name: '--bundle-output ', + description: + 'File name where to store the resulting bundle, ex. /tmp/groups.bundle', + }, + { + name: '--bundle-encoding ', + description: + 'Encoding the bundle should be written in (https://nodejs.org/api/buffer.html#buffer_buffer).', + default: 'utf8', + }, + { + name: '--max-workers ', + description: + 'Specifies the maximum number of workers the worker-pool ' + + 'will spawn for transforming files. This defaults to the number of the ' + + 'cores available on your machine.', + parse: (workers: string): number => Number(workers), + }, + { + name: '--sourcemap-output ', + description: + 'File name where to store the sourcemap file for resulting bundle, ex. /tmp/groups.map', + }, + { + name: '--sourcemap-sources-root ', + description: + "Path to make sourcemap's sources entries relative to, ex. /root/dir", + }, + { + name: '--sourcemap-use-absolute-path', + description: 'Report SourceMapURL using its full path', + default: false, + }, + { + name: '--assets-dest ', + description: + 'Directory name where to store assets referenced in the bundle', + }, + { + name: '--unstable-transform-profile ', + description: + 'Experimental, transform JS for a specific JS engine. Currently supported: hermes, hermes-canary, default', + default: 'default', + }, + { + name: '--asset-catalog-dest [string]', + description: 'Path where to create an iOS Asset Catalog for images', + }, + { + name: '--reset-cache', + description: 'Removes cached files', + default: false, + }, + { + name: '--read-global-cache', + description: + 'Try to fetch transformed JS code from the global cache, if configured.', + default: false, + }, + { + name: '--config ', + description: 'Path to the CLI configuration file', + parse: (val: string): string => path.resolve(val), + }, +]; diff --git a/packages/cli-commands/src/commands/bundle/filterPlatformAssetScales.js b/packages/cli-commands/src/commands/bundle/filterPlatformAssetScales.js new file mode 100644 index 00000000000000..bf2f1c72dd7c16 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/filterPlatformAssetScales.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +const ALLOWED_SCALES: {[key: string]: number[]} = { + ios: [1, 2, 3], +}; + +function filterPlatformAssetScales( + platform: string, + scales: $ReadOnlyArray, +): $ReadOnlyArray { + const whitelist: number[] = ALLOWED_SCALES[platform]; + if (!whitelist) { + return scales; + } + const result = scales.filter(scale => whitelist.indexOf(scale) > -1); + if (result.length === 0 && scales.length > 0) { + // No matching scale found, but there are some available. Ideally we don't + // want to be in this situation and should throw, but for now as a fallback + // let's just use the closest larger image + const maxScale = whitelist[whitelist.length - 1]; + for (const scale of scales) { + if (scale > maxScale) { + result.push(scale); + break; + } + } + + // There is no larger scales available, use the largest we have + if (result.length === 0) { + result.push(scales[scales.length - 1]); + } + } + return result; +} + +export default filterPlatformAssetScales; diff --git a/packages/cli-commands/src/commands/bundle/getAssetDestPathAndroid.js b/packages/cli-commands/src/commands/bundle/getAssetDestPathAndroid.js new file mode 100644 index 00000000000000..5213a0b3181452 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/getAssetDestPathAndroid.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {PackagerAsset} from './assetPathUtils'; + +import path from 'path'; +import assetPathUtils from './assetPathUtils'; + +function getAssetDestPathAndroid(asset: PackagerAsset, scale: number): string { + const androidFolder = assetPathUtils.getAndroidResourceFolderName( + asset, + scale, + ); + const fileName = assetPathUtils.getResourceIdentifier(asset); + return path.join(androidFolder, `${fileName}.${asset.type}`); +} + +export default getAssetDestPathAndroid; diff --git a/packages/cli-commands/src/commands/bundle/getAssetDestPathIOS.js b/packages/cli-commands/src/commands/bundle/getAssetDestPathIOS.js new file mode 100644 index 00000000000000..6099375d2ba47a --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/getAssetDestPathIOS.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {PackagerAsset} from './assetPathUtils'; + +import path from 'path'; + +function getAssetDestPathIOS(asset: PackagerAsset, scale: number): string { + const suffix = scale === 1 ? '' : `@${scale}x`; + const fileName = `${asset.name + suffix}.${asset.type}`; + return path.join( + // Assets can have relative paths outside of the project root. + // Replace `../` with `_` to make sure they don't end up outside of + // the expected assets directory. + asset.httpServerLocation.substr(1).replace(/\.\.\//g, '_'), + fileName, + ); +} + +export default getAssetDestPathIOS; diff --git a/packages/cli-commands/src/commands/bundle/ramBundle.js b/packages/cli-commands/src/commands/bundle/ramBundle.js new file mode 100644 index 00000000000000..77c7254b0282d0 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/ramBundle.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; +import type {CommandLineArgs} from './bundleCommandLineArgs'; + +import outputUnbundle from 'metro/src/shared/output/RamBundle'; +import {bundleWithOutput} from './bundle'; +import bundleCommandLineArgs from './bundleCommandLineArgs'; + +/** + * Builds the bundle starting to look for dependencies at the given entry path. + */ +function ramBundle( + argv: Array, + config: Config, + args: CommandLineArgs, +): Promise { + return bundleWithOutput(argv, config, args, outputUnbundle); +} + +export default { + name: 'ram-bundle', + description: + 'builds javascript as a "Random Access Module" bundle for offline use', + func: ramBundle, + options: (bundleCommandLineArgs.concat({ + name: '--indexed-ram-bundle', + description: + 'Force the "Indexed RAM" bundle file format, even when building for android', + default: false, + }): Array), +}; + +export {ramBundle}; diff --git a/packages/cli-commands/src/commands/bundle/saveAssets.js b/packages/cli-commands/src/commands/bundle/saveAssets.js new file mode 100644 index 00000000000000..f2da7f0296e9d1 --- /dev/null +++ b/packages/cli-commands/src/commands/bundle/saveAssets.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {AssetData} from 'metro/src/Assets'; + +import {logger} from '@react-native-community/cli-tools'; +import fs from 'fs'; +import path from 'path'; +import { + cleanAssetCatalog, + getImageSet, + isCatalogAsset, + writeImageSet, +} from './assetCatalogIOS'; +import filterPlatformAssetScales from './filterPlatformAssetScales'; +import getAssetDestPathAndroid from './getAssetDestPathAndroid'; +import getAssetDestPathIOS from './getAssetDestPathIOS'; + +type CopiedFiles = { + [src: string]: string, +}; + +async function saveAssets( + assets: $ReadOnlyArray, + platform: string, + assetsDest?: string, + assetCatalogDest?: string, +): Promise { + if (!assetsDest) { + logger.warn('Assets destination folder is not set, skipping...'); + return; + } + + const filesToCopy: CopiedFiles = {}; + + const getAssetDestPath = + platform === 'android' ? getAssetDestPathAndroid : getAssetDestPathIOS; + + const addAssetToCopy = (asset: AssetData) => { + const validScales = new Set( + filterPlatformAssetScales(platform, asset.scales), + ); + + asset.scales.forEach((scale, idx) => { + if (!validScales.has(scale)) { + return; + } + const src = asset.files[idx]; + const dest = path.join(assetsDest, getAssetDestPath(asset, scale)); + filesToCopy[src] = dest; + }); + }; + + if (platform === 'ios' && assetCatalogDest != null) { + // Use iOS Asset Catalog for images. This will allow Apple app thinning to + // remove unused scales from the optimized bundle. + const catalogDir = path.join(assetCatalogDest, 'RNAssets.xcassets'); + if (!fs.existsSync(catalogDir)) { + logger.error( + `Could not find asset catalog 'RNAssets.xcassets' in ${assetCatalogDest}. Make sure to create it if it does not exist.`, + ); + return; + } + + logger.info('Adding images to asset catalog', catalogDir); + cleanAssetCatalog(catalogDir); + for (const asset of assets) { + if (isCatalogAsset(asset)) { + const imageSet = getImageSet( + catalogDir, + asset, + filterPlatformAssetScales(platform, asset.scales), + ); + writeImageSet(imageSet); + } else { + addAssetToCopy(asset); + } + } + logger.info('Done adding images to asset catalog'); + } else { + assets.forEach(addAssetToCopy); + } + + return copyAll(filesToCopy); +} + +function copyAll(filesToCopy: CopiedFiles) { + const queue = Object.keys(filesToCopy); + if (queue.length === 0) { + return Promise.resolve(); + } + + logger.info(`Copying ${queue.length} asset files`); + return new Promise((resolve, reject) => { + const copyNext = (error?: Error) => { + if (error) { + reject(error); + return; + } + if (queue.length === 0) { + logger.info('Done copying assets'); + resolve(); + } else { + // queue.length === 0 is checked in previous branch, so this is string + const src = queue.shift(); + const dest = filesToCopy[src]; + copy(src, dest, copyNext); + } + }; + copyNext(); + }); +} + +function copy( + src: string, + dest: string, + callback: (error: Error) => void, +): void { + const destDir = path.dirname(dest); + fs.mkdir(destDir, {recursive: true}, (err?) => { + if (err) { + callback(err); + return; + } + fs.createReadStream(src) + .pipe(fs.createWriteStream(dest)) + .on('finish', callback); + }); +} + +export default saveAssets; diff --git a/packages/cli-commands/src/commands/start/index.js b/packages/cli-commands/src/commands/start/index.js new file mode 100644 index 00000000000000..412587e7e41d19 --- /dev/null +++ b/packages/cli-commands/src/commands/start/index.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import path from 'path'; +import runServer from './runServer'; + +export default { + name: 'start', + func: runServer, + description: 'starts the webserver', + options: [ + { + name: '--port ', + parse: Number, + }, + { + name: '--host ', + default: '', + }, + { + name: '--projectRoot ', + description: 'Path to a custom project root', + parse: (val: string): string => path.resolve(val), + }, + { + name: '--watchFolders ', + description: + 'Specify any additional folders to be added to the watch list', + parse: (val: string): Array => + val.split(',').map((folder: string) => path.resolve(folder)), + }, + { + name: '--assetPlugins ', + description: + 'Specify any additional asset plugins to be used by the packager by full filepath', + parse: (val: string): Array => val.split(','), + }, + { + name: '--sourceExts ', + description: + 'Specify any additional source extensions to be used by the packager', + parse: (val: string): Array => val.split(','), + }, + { + name: '--max-workers ', + description: + 'Specifies the maximum number of workers the worker-pool ' + + 'will spawn for transforming files. This defaults to the number of the ' + + 'cores available on your machine.', + parse: (workers: string): number => Number(workers), + }, + { + name: '--transformer ', + description: 'Specify a custom transformer to be used', + }, + { + name: '--reset-cache, --resetCache', + description: 'Removes cached files', + }, + { + name: '--custom-log-reporter-path, --customLogReporterPath ', + description: + 'Path to a JavaScript file that exports a log reporter as a replacement for TerminalReporter', + }, + { + name: '--https', + description: 'Enables https connections to the server', + }, + { + name: '--key ', + description: 'Path to custom SSL key', + }, + { + name: '--cert ', + description: 'Path to custom SSL cert', + }, + { + name: '--config ', + description: 'Path to the CLI configuration file', + parse: (val: string): string => path.resolve(val), + }, + { + name: '--no-interactive', + description: 'Disables interactive mode', + }, + ], +}; + +export {startServerInNewWindow} from './startServerInNewWindow'; diff --git a/packages/cli-commands/src/commands/start/runServer.js b/packages/cli-commands/src/commands/start/runServer.js new file mode 100644 index 00000000000000..92e47ee5eb825d --- /dev/null +++ b/packages/cli-commands/src/commands/start/runServer.js @@ -0,0 +1,156 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; +import type {Reporter} from 'metro/src/lib/reporting'; +import type {TerminalReportableEvent} from 'metro/src/lib/TerminalReporter'; +import typeof TerminalReporter from 'metro/src/lib/TerminalReporter'; +import type Server from 'metro/src/Server'; +import type {Middleware} from 'metro-config'; + +import Metro from 'metro'; +import {Terminal} from 'metro-core'; +import path from 'path'; +import { + createDevServerMiddleware, + indexPageMiddleware, +} from '@react-native-community/cli-server-api'; + +import loadMetroConfig from '../../utils/loadMetroConfig'; +import {version} from '@react-native-community/cli-tools'; +import enableWatchMode from './watchMode'; + +export type Args = { + assetPlugins?: string[], + cert?: string, + customLogReporterPath?: string, + host?: string, + https?: boolean, + maxWorkers?: number, + key?: string, + platforms?: string[], + port?: number, + resetCache?: boolean, + sourceExts?: string[], + transformer?: string, + watchFolders?: string[], + config?: string, + projectRoot?: string, + interactive: boolean, +}; + +async function runServer(_argv: Array, ctx: Config, args: Args) { + let reportEvent: (event: any) => void; + const terminal = new Terminal(process.stdout); + const ReporterImpl = getReporterImpl(args.customLogReporterPath); + const terminalReporter = new ReporterImpl(terminal); + const reporter: Reporter = { + update(event: TerminalReportableEvent) { + terminalReporter.update(event); + if (reportEvent) { + reportEvent(event); + } + }, + }; + + const metroConfig = await loadMetroConfig(ctx, { + config: args.config, + maxWorkers: args.maxWorkers, + port: args.port, + resetCache: args.resetCache, + watchFolders: args.watchFolders, + projectRoot: args.projectRoot, + sourceExts: args.sourceExts, + reporter, + }); + + if (args.assetPlugins) { + // $FlowIgnore[cannot-write] Assigning to readonly property + metroConfig.transformer.assetPlugins = args.assetPlugins.map(plugin => + require.resolve(plugin), + ); + } + + const { + middleware, + websocketEndpoints, + messageSocketEndpoint, + eventsSocketEndpoint, + } = createDevServerMiddleware({ + host: args.host, + port: metroConfig.server.port, + watchFolders: metroConfig.watchFolders, + }); + middleware.use(indexPageMiddleware); + + const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; + // $FlowIgnore[cannot-write] Assigning to readonly property + metroConfig.server.enhanceMiddleware = ( + metroMiddleware: Middleware, + server: Server, + ) => { + if (customEnhanceMiddleware) { + metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); + } + return middleware.use(metroMiddleware); + }; + + const serverInstance = await Metro.runServer(metroConfig, { + host: args.host, + secure: args.https, + secureCert: args.cert, + secureKey: args.key, + // $FlowFixMe[incompatible-call] Incompatibly defined WebSocketServer type + websocketEndpoints, + }); + + reportEvent = eventsSocketEndpoint.reportEvent; + + if (args.interactive) { + enableWatchMode(messageSocketEndpoint, ctx); + } + + // In Node 8, the default keep-alive for an HTTP connection is 5 seconds. In + // early versions of Node 8, this was implemented in a buggy way which caused + // some HTTP responses (like those containing large JS bundles) to be + // terminated early. + // + // As a workaround, arbitrarily increase the keep-alive from 5 to 30 seconds, + // which should be enough to send even the largest of JS bundles. + // + // For more info: https://github.com/nodejs/node/issues/13391 + // + serverInstance.keepAliveTimeout = 30000; + + await version.logIfUpdateAvailable(ctx.root); +} + +function getReporterImpl(customLogReporterPath?: string): TerminalReporter { + if (customLogReporterPath == null) { + return require('metro/src/lib/TerminalReporter'); + } + try { + // First we let require resolve it, so we can require packages in node_modules + // as expected. eg: require('my-package/reporter'); + // $FlowIgnore[unsupported-syntax] + return require(customLogReporterPath); + } catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + // If that doesn't work, then we next try relative to the cwd, eg: + // require('./reporter'); + // $FlowIgnore[unsupported-syntax] + return require(path.resolve(customLogReporterPath)); + } +} + +export default runServer; diff --git a/packages/cli-commands/src/commands/start/startServerInNewWindow.js b/packages/cli-commands/src/commands/start/startServerInNewWindow.js new file mode 100644 index 00000000000000..6a02dcf5c10a16 --- /dev/null +++ b/packages/cli-commands/src/commands/start/startServerInNewWindow.js @@ -0,0 +1,123 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {ExecaPromise, SyncResult} from 'execa'; + +import path from 'path'; +import fs from 'fs'; +import execa from 'execa'; +import { + CLIError, + logger, + resolveNodeModuleDir, +} from '@react-native-community/cli-tools'; + +export function startServerInNewWindow( + port: number, + terminal: string, + projectRoot: string, + reactNativePath: string, +): SyncResult | ExecaPromise | CLIError | void { + /** + * Set up OS-specific filenames and commands + */ + const isWindows = /^win/.test(process.platform); + const scriptFile = isWindows + ? 'launchPackager.bat' + : 'launchPackager.command'; + const packagerEnvFilename = isWindows ? '.packager.bat' : '.packager.env'; + const packagerEnvFileExportContent = isWindows + ? `set RCT_METRO_PORT=${port}\nset PROJECT_ROOT=${projectRoot}\nset REACT_NATIVE_PATH=${reactNativePath}` + : `export RCT_METRO_PORT=${port}\nexport PROJECT_ROOT=${projectRoot}\nexport REACT_NATIVE_PATH=${reactNativePath}`; + const nodeModulesPath = resolveNodeModuleDir(projectRoot, '.bin'); + const cliPluginMetroPath = path.join( + path.dirname(require.resolve('@react-native/cli-commands/package.json')), + 'build', + ); + + /** + * Set up the `.packager.(env|bat)` file to ensure the packager starts on the right port and in right directory. + */ + const packagerEnvFile = path.join(nodeModulesPath, `${packagerEnvFilename}`); + + /** + * Set up the `launchPackager.(command|bat)` file. + * It lives next to `.packager.(bat|env)` + */ + const launchPackagerScript = path.join(nodeModulesPath, scriptFile); + const procConfig = {cwd: path.dirname(packagerEnvFile)}; + + /** + * Ensure we overwrite file by passing the `w` flag + */ + fs.writeFileSync(packagerEnvFile, packagerEnvFileExportContent, { + encoding: 'utf8', + flag: 'w', + }); + + /** + * Copy files into `node_modules/.bin`. + */ + + try { + if (isWindows) { + fs.copyFileSync( + path.join(cliPluginMetroPath, 'launchPackager.bat'), + path.join(nodeModulesPath, 'launchPackager.bat'), + ); + } else { + fs.copyFileSync( + path.join(cliPluginMetroPath, 'launchPackager.command'), + path.join(nodeModulesPath, 'launchPackager.command'), + ); + } + } catch (error) { + return new CLIError( + `Couldn't copy the script for running bundler. Please check if the "${scriptFile}" file exists in the "node_modules/@react-native/cli-commands" folder and try again.`, + error, + ); + } + + if (process.platform === 'darwin') { + try { + return execa.sync( + 'open', + ['-a', terminal, launchPackagerScript], + procConfig, + ); + } catch (error) { + return execa.sync('open', [launchPackagerScript], procConfig); + } + } + if (process.platform === 'linux') { + try { + return execa.sync(terminal, ['-e', `sh ${launchPackagerScript}`], { + ...procConfig, + detached: true, + }); + } catch (error) { + // By default, the child shell process will be attached to the parent + return execa.sync('sh', [launchPackagerScript], procConfig); + } + } + if (isWindows) { + // Awaiting this causes the CLI to hang indefinitely, so this must execute without await. + return execa('cmd.exe', ['/C', launchPackagerScript], { + ...procConfig, + detached: true, + stdio: 'ignore', + }); + } + logger.error( + `Cannot start the packager. Unknown platform ${process.platform}`, + ); + return; +} diff --git a/packages/cli-commands/src/commands/start/watchMode.js b/packages/cli-commands/src/commands/start/watchMode.js new file mode 100644 index 00000000000000..b4370cc03aca7d --- /dev/null +++ b/packages/cli-commands/src/commands/start/watchMode.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; + +import readline from 'readline'; +import {logger, hookStdout} from '@react-native-community/cli-tools'; +import execa from 'execa'; +import chalk from 'chalk'; + +function printWatchModeInstructions() { + logger.log( + `${chalk.bold('r')} - reload the app\n${chalk.bold( + 'd', + )} - open developer menu\n${chalk.bold('i')} - run on iOS\n${chalk.bold( + 'a', + )} - run on Android`, + ); +} + +function enableWatchMode( + messageSocket: $ReadOnly<{ + broadcast: (type: string, params?: Record | null) => void, + ... + }>, + ctx: Config, +) { + // We need to set this to true to catch key presses individually. + // As a result we have to implement our own method for exiting + // and other commands (e.g. ctrl+c & ctrl+z) + // $FlowIgnore[method-unbinding] + if (!process.stdin.setRawMode) { + logger.debug('Watch mode is not supported in this environment'); + return; + } + + readline.emitKeypressEvents(process.stdin); + + // $FlowIgnore[prop-missing] + process.stdin.setRawMode(true); + + // We have no way of knowing when the dependency graph is done loading + // except by hooking into stdout itself. We want to print instructions + // right after its done loading. + const restore: () => void = hookStdout((output: string) => { + if (output.includes('Fast - Scalable - Integrated')) { + printWatchModeInstructions(); + restore(); + } + }); + + process.stdin.on( + 'keypress', + (_key: string, data: {ctrl: boolean, name: string}) => { + const {ctrl, name} = data; + if (ctrl === true) { + switch (name) { + case 'c': + process.exit(); + break; + case 'z': + process.emit('SIGTSTP', 'SIGTSTP'); + break; + } + } else if (name === 'r') { + messageSocket.broadcast('reload', null); + logger.info('Reloading app...'); + } else if (name === 'd') { + messageSocket.broadcast('devMenu', null); + logger.info('Opening developer menu...'); + } else if (name === 'i' || name === 'a') { + logger.info( + `Opening the app on ${name === 'i' ? 'iOS' : 'Android'}...`, + ); + const params = + name === 'i' + ? ctx.project.ios?.watchModeCommandParams + : ctx.project.android?.watchModeCommandParams; + execa('npx', [ + 'react-native', + name === 'i' ? 'run-ios' : 'run-android', + ...(params ?? []), + ]).stdout?.pipe(process.stdout); + } else { + console.log(_key); + } + }, + ); +} + +export default enableWatchMode; diff --git a/packages/cli-commands/src/index.flow.js b/packages/cli-commands/src/index.flow.js new file mode 100644 index 00000000000000..457feba07beb01 --- /dev/null +++ b/packages/cli-commands/src/index.flow.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +export {bundleCommand} from './commands/bundle/bundle'; +export {default as ramBundleCommand} from './commands/bundle/ramBundle'; +export {default as startCommand} from './commands/start'; +export {startServerInNewWindow} from './commands/start/startServerInNewWindow'; diff --git a/packages/cli-commands/src/index.js b/packages/cli-commands/src/index.js new file mode 100644 index 00000000000000..c95fbd8400e1d4 --- /dev/null +++ b/packages/cli-commands/src/index.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +/*:: +export type * from './index.flow'; +*/ + +if (!process.env.BUILD_EXCLUDE_BABEL_REGISTER) { + require('../../../scripts/build/babel-register').registerForMonorepo(); +} + +module.exports = require('./index.flow'); diff --git a/packages/cli-commands/src/utils/loadMetroConfig.js b/packages/cli-commands/src/utils/loadMetroConfig.js new file mode 100644 index 00000000000000..fb278955657175 --- /dev/null +++ b/packages/cli-commands/src/utils/loadMetroConfig.js @@ -0,0 +1,125 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {Config} from '@react-native-community/cli-types'; +import type {ConfigT, InputConfigT} from 'metro-config'; + +import path from 'path'; +import {loadConfig, mergeConfig, resolveConfig} from 'metro-config'; +import {CLIError, logger} from '@react-native-community/cli-tools'; +import {reactNativePlatformResolver} from './metroPlatformResolver'; + +export type {Config}; + +export type ConfigLoadingContext = $ReadOnly<{ + root: Config['root'], + reactNativePath: Config['reactNativePath'], + platforms: Config['platforms'], + ... +}>; + +/** + * Get the config options to override based on RN CLI inputs. + */ +function getOverrideConfig(ctx: ConfigLoadingContext): InputConfigT { + const outOfTreePlatforms = Object.keys(ctx.platforms).filter( + platform => ctx.platforms[platform].npmPackageName, + ); + const resolver: Partial<{...ConfigT['resolver']}> = { + platforms: [...Object.keys(ctx.platforms), 'native'], + }; + + if (outOfTreePlatforms.length) { + resolver.resolveRequest = reactNativePlatformResolver( + outOfTreePlatforms.reduce<{[platform: string]: string}>( + (result, platform) => { + result[platform] = ctx.platforms[platform].npmPackageName; + return result; + }, + {}, + ), + ); + } + + return { + resolver, + serializer: { + // We can include multiple copies of InitializeCore here because metro will + // only add ones that are already part of the bundle + getModulesRunBeforeMainModule: () => [ + require.resolve( + path.join(ctx.reactNativePath, 'Libraries/Core/InitializeCore'), + {paths: [ctx.root]}, + ), + ...outOfTreePlatforms.map(platform => + require.resolve( + `${ctx.platforms[platform].npmPackageName}/Libraries/Core/InitializeCore`, + ), + ), + ], + }, + }; +} + +export type ConfigOptionsT = $ReadOnly<{ + maxWorkers?: number, + port?: number, + projectRoot?: string, + resetCache?: boolean, + watchFolders?: string[], + sourceExts?: string[], + reporter?: any, + config?: string, +}>; + +/** + * Load Metro config. + * + * Allows the CLI to override select values in `metro.config.js` based on + * dynamic user options in `ctx`. + */ +export default async function loadMetroConfig( + ctx: ConfigLoadingContext, + options: ConfigOptionsT = {}, +): Promise { + const overrideConfig = getOverrideConfig(ctx); + if (options.reporter) { + overrideConfig.reporter = options.reporter; + } + + const projectConfig = await resolveConfig(undefined, ctx.root); + + if (projectConfig.isEmpty) { + throw new CLIError(`No metro config found in ${ctx.root}`); + } + + logger.debug(`Reading Metro config from ${projectConfig.filepath}`); + + if (!global.__REACT_NATIVE_METRO_CONFIG_LOADED) { + const warning = ` +================================================================================================= +From React Native 0.73, your project's Metro config should extend '@react-native/metro-config' +or it will fail to build. Please copy the template at: +https://github.com/facebook/react-native/blob/main/packages/react-native/template/metro.config.js +This warning will be removed in future (https://github.com/facebook/metro/issues/1018). +================================================================================================= + `; + + for (const line of warning.trim().split('\n')) { + logger.warn(line); + } + } + + return mergeConfig( + await loadConfig({cwd: ctx.root, ...options}), + overrideConfig, + ); +} diff --git a/packages/cli-commands/src/utils/metroPlatformResolver.js b/packages/cli-commands/src/utils/metroPlatformResolver.js new file mode 100644 index 00000000000000..236ae6e4e31a65 --- /dev/null +++ b/packages/cli-commands/src/utils/metroPlatformResolver.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import type {CustomResolver} from 'metro-resolver'; + +/** + * This is an implementation of a metro resolveRequest option which will remap react-native imports + * to different npm packages based on the platform requested. This allows a single metro instance/config + * to produce bundles for multiple out of tree platforms at a time. + * + * @param platformImplementations + * A map of platform to npm package that implements that platform + * + * Ex: + * { + * windows: 'react-native-windows' + * macos: 'react-native-macos' + * } + */ +export function reactNativePlatformResolver(platformImplementations: { + [platform: string]: string, +}): CustomResolver { + return (context, moduleName, platform) => { + let modifiedModuleName = moduleName; + if (platform != null && platformImplementations[platform]) { + if (moduleName === 'react-native') { + modifiedModuleName = platformImplementations[platform]; + } else if (moduleName.startsWith('react-native/')) { + modifiedModuleName = `${ + platformImplementations[platform] + }/${modifiedModuleName.slice('react-native/'.length)}`; + } + } + return context.resolveRequest(context, modifiedModuleName, platform); + }; +} diff --git a/scripts/build/config.js b/scripts/build/config.js index 994f785db8ee4b..1c8378a8663d6f 100644 --- a/scripts/build/config.js +++ b/scripts/build/config.js @@ -26,6 +26,7 @@ const TARGET_NODE_VERSION = '18'; const buildConfig /*: BuildConfig */ = { // The packages to include for build and their build options packages: { + 'cli-commands': {target: 'node'}, 'dev-middleware': {target: 'node'}, }, }; diff --git a/yarn.lock b/yarn.lock index ac5d663236de7d..5e6240cafeafa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5733,7 +5733,7 @@ events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: +execa@^5.0.0, execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==