diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 6fcce11443..863eb84180 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -39,7 +39,12 @@ "@expo/metro-config": "0.1.78", "@react-native-community/cli-server-api": "^5.0.1", "body-parser": "1.19.0", + "deepmerge": "^4.2.2", + "deno-importmap": "^0.1.6", + "esbuild": "^0.12.15", + "flow-remove-types": "^2.155.0", "fs-extra": "9.0.0", + "image-size": "^1.0.0", "open": "^8.2.0", "resolve-from": "^5.0.0", "semver": "7.3.2", diff --git a/packages/dev-server/src/MetroDevServer.ts b/packages/dev-server/src/MetroDevServer.ts index 094c1e255d..dce225999d 100644 --- a/packages/dev-server/src/MetroDevServer.ts +++ b/packages/dev-server/src/MetroDevServer.ts @@ -8,12 +8,10 @@ import { import bodyParser from 'body-parser'; import type { Server as ConnectServer, HandleFunction } from 'connect'; import http from 'http'; -import type { IncomingMessage, ServerResponse } from 'http'; import type Metro from 'metro'; import path from 'path'; import resolveFrom from 'resolve-from'; import semver from 'semver'; -import { parse as parseUrl } from 'url'; import { buildHermesBundleAsync, @@ -23,6 +21,9 @@ import { import LogReporter from './LogReporter'; import clientLogsMiddleware from './middleware/clientLogsMiddleware'; import createJsInspectorMiddleware from './middleware/createJsInspectorMiddleware'; +import { remoteDevtoolsCorsMiddleware } from './middleware/remoteDevtoolsCorsMiddleware'; +import { remoteDevtoolsSecurityHeadersMiddleware } from './middleware/remoteDevtoolsSecurityHeadersMiddleware'; +import { replaceMiddlewareWith } from './middleware/replaceMiddlewareWith'; export type MetroDevServerOptions = ExpoMetroConfig.LoadOptions & { logger: Log; @@ -257,76 +258,6 @@ function importMetroServerFromProject(projectRoot: string): typeof Metro.Server return require(resolvedPath); } -function replaceMiddlewareWith( - app: ConnectServer, - sourceMiddleware: HandleFunction, - targetMiddleware: HandleFunction -) { - const item = app.stack.find(middleware => middleware.handle === sourceMiddleware); - if (item) { - item.handle = targetMiddleware; - } -} - -// Like securityHeadersMiddleware but further allow cross-origin requests -// from https://chrome-devtools-frontend.appspot.com/ -function remoteDevtoolsSecurityHeadersMiddleware( - req: IncomingMessage, - res: ServerResponse, - next: (err?: Error) => void -) { - // Block any cross origin request. - if ( - typeof req.headers.origin === 'string' && - !req.headers.origin.match(/^https?:\/\/localhost:/) && - !req.headers.origin.match(/^https:\/\/chrome-devtools-frontend\.appspot\.com/) - ) { - next( - new Error( - `Unauthorized request from ${req.headers.origin}. ` + - 'This may happen because of a conflicting browser extension to intercept HTTP requests. ' + - 'Please try again without browser extensions or using incognito mode.' - ) - ); - return; - } - - // Block MIME-type sniffing. - res.setHeader('X-Content-Type-Options', 'nosniff'); - - next(); -} - -// Middleware that accepts multiple Access-Control-Allow-Origin for processing *.map. -// This is a hook middleware before metro processing *.map, -// which originally allow only devtools://devtools -function remoteDevtoolsCorsMiddleware( - req: IncomingMessage, - res: ServerResponse, - next: (err?: Error) => void -) { - if (req.url) { - const url = parseUrl(req.url); - const origin = req.headers.origin; - const isValidOrigin = - origin && - ['devtools://devtools', 'https://chrome-devtools-frontend.appspot.com'].includes(origin); - if (url.pathname?.endsWith('.map') && origin && isValidOrigin) { - res.setHeader('Access-Control-Allow-Origin', origin); - - // Prevent metro overwrite Access-Control-Allow-Origin header - const setHeader = res.setHeader.bind(res); - res.setHeader = (key, ...args) => { - if (key === 'Access-Control-Allow-Origin') { - return; - } - setHeader(key, ...args); - }; - } - } - next(); -} - // Cloned from xdl/src/Versions.ts, we cannot use that because of circular dependency function gteSdkVersion(expJson: Pick, sdkVersion: string): boolean { if (!expJson.sdkVersion) { diff --git a/packages/dev-server/src/esbuild/EsbuildConfig.ts b/packages/dev-server/src/esbuild/EsbuildConfig.ts new file mode 100644 index 0000000000..24a8b0118e --- /dev/null +++ b/packages/dev-server/src/esbuild/EsbuildConfig.ts @@ -0,0 +1,161 @@ +import Log from '@expo/bunyan'; +import { getBareExtensions } from '@expo/config/paths'; +import merge from 'deepmerge'; +import { BuildOptions } from 'esbuild'; +import fs from 'fs-extra'; +import path from 'path'; +import resolveFrom from 'resolve-from'; +import { resolveEntryPoint } from 'xdl/build/tools/resolveEntryPoint'; + +import { mergePlugins, setAssetLoaders, setPlugins } from './utils'; + +const assetExts = ['bmp', 'gif', 'jpg', 'jpeg', 'png', 'psd', 'svg', 'webp', 'm4v', 'mov', 'mp4', 'mpeg', 'mpg', 'webm', 'aac', 'aiff', 'caf', 'm4a', 'mp3', 'wav', 'html', 'pdf', 'yaml', 'yml', 'otf', 'ttf', 'zip', 'db'] //prettier-ignore + +const native = { + target: 'esnext', + format: 'iife', + plugins: [ + { name: 'expoLogging' }, + { + name: 'stripFlowTypes', + params: [ + 'react-native', + '@react-native-community/masked-view', + 'expo-asset-utils', + '@react-native-picker/picker', + '@react-native-segmented-control/segmented-control', + '@react-native-community/datetimepicker', + '@react-native-async-storage/async-storage', + 'react-native-view-shot', + 'react-native-gesture-handler', + '@react-native-community/toolbar-android', + '@react-native/normalize-color', + '@react-native/assets', + '@react-native/polyfills', + ], + }, + { + name: 'reactNativeAssets', + params: assetExts, + }, + { name: 'patches' }, + ], +}; + +const config: { ios: any; android: any; web: any; native: any } = { + web: { + target: 'es2020', + format: 'esm', + plugins: [ + { name: 'expoLogging' }, + { + name: 'alias', + params: { + // TODO: Dynamic + 'react-native': './node_modules/react-native-web/dist/index.js', + }, + }, + ], + }, + native, + ios: native, + android: native, +}; + +async function getBuildOptions( + projectRoot: string, + logger: Log, + { + platform, + minify, + cleanCache, + }: { platform: keyof typeof config; minify?: boolean; cleanCache?: boolean }, + customConfig?: any +) { + const filename = resolveEntryPoint(projectRoot, platform); + + const outputPath = path.resolve(`dist/index.${platform}.js`); + await fs.ensureDir(path.dirname(outputPath)); + + const base: BuildOptions = { + entryPoints: [filename || 'App.js'], + outfile: outputPath, + assetNames: 'assets/[name]', + publicPath: '/', + minify, + write: true, + bundle: true, + legalComments: 'none', + sourcemap: true, + incremental: true, + logLevel: 'debug', + mainFields: ['react-native', 'browser', 'module', 'main'], + define: { + 'process.env.JEST_WORKER_ID': 'false', + 'process.env.NODE_DEV': minify ? '"production"' : '"development"', + __DEV__: minify ? 'false' : 'true', + global: 'window', + }, + loader: { '.js': 'jsx', ...setAssetLoaders(assetExts) }, + resolveExtensions: getBareExtensions([platform, 'native'], { + isModern: false, + isTS: true, + isReact: true, + }).map(value => '.' + value), + }; + + if (platform !== 'web') { + if (!base.plugins) { + base.plugins = []; + } + + base.plugins.push({ + name: 'alias', + params: { + 'react-native-vector-icons/': resolveFrom.silent(projectRoot, '@expo/vector-icons'), + }, + }); + + base.inject = [ + resolveRelative(projectRoot, 'react-native/Libraries/polyfills/console.js'), + resolveRelative(projectRoot, 'react-native/Libraries/polyfills/error-guard.js'), + resolveRelative(projectRoot, 'react-native/Libraries/polyfills/Object.es7.js'), + // Sets up React DevTools + resolveRelative(projectRoot, 'react-native/Libraries/Core/InitializeCore.js'), + ]; + } else { + base.inject = [resolveFrom(projectRoot, 'setimmediate/setImmediate.js')]; + } + + const buildOptions: BuildOptions = merge.all([base, config[platform], customConfig]); + const mergedPlugins = mergePlugins(config[platform].plugins, customConfig?.plugins); + buildOptions.plugins = setPlugins(projectRoot, logger, mergedPlugins, platform, cleanCache); + + // Append to the top of the bundle + if (!buildOptions.banner) { + buildOptions.banner = { js: '' }; + } + + if (platform === 'web' && !minify) { + buildOptions.banner.js = + `(() => new EventSource("/esbuild").onmessage = () => location.reload())();\n` + + buildOptions.banner.js; + } + if (platform !== 'web') { + if (!buildOptions.banner.js) { + buildOptions.banner.js = ``; + } + buildOptions.banner.js += `\nvar __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now(); +var window = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this;`; + } + + return { buildOptions, mergedPlugins }; +} + +function resolveRelative(projectRoot: string, moduleId: string): string { + const _path = path.relative(projectRoot, resolveFrom(projectRoot, moduleId)); + if (_path.startsWith('.')) return _path; + return './' + _path; +} + +export default getBuildOptions; diff --git a/packages/dev-server/src/esbuild/EsbuildDevServer.ts b/packages/dev-server/src/esbuild/EsbuildDevServer.ts new file mode 100644 index 0000000000..fbc4fd7a2f --- /dev/null +++ b/packages/dev-server/src/esbuild/EsbuildDevServer.ts @@ -0,0 +1,255 @@ +import { ExpoConfig } from '@expo/config'; +import { + createDevServerMiddleware, + securityHeadersMiddleware, +} from '@react-native-community/cli-server-api'; +import bodyParser from 'body-parser'; +import type { Server as ConnectServer } from 'connect'; +import * as esbuild from 'esbuild'; +import { ensureDirSync } from 'fs-extra'; +import http from 'http'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { BundleOptions } from 'metro'; +import type Metro from 'metro'; +import path from 'path'; +import resolveFrom from 'resolve-from'; +import { parse as parseUrl } from 'url'; + +import { BundleOutput, MessageSocket, MetroDevServerOptions } from '../MetroDevServer'; +import { StackFrame } from '../middleware/Symbolicator'; +import clientLogsMiddleware from '../middleware/clientLogsMiddleware'; +import createJsInspectorMiddleware from '../middleware/createJsInspectorMiddleware'; +import { createSymbolicateMiddleware } from '../middleware/createSymbolicateMiddleware'; +import { remoteDevtoolsCorsMiddleware } from '../middleware/remoteDevtoolsCorsMiddleware'; +import { remoteDevtoolsSecurityHeadersMiddleware } from '../middleware/remoteDevtoolsSecurityHeadersMiddleware'; +import { replaceMiddlewareWith } from '../middleware/replaceMiddlewareWith'; +import getBuildOptions from './EsbuildConfig'; + +// Import only the types here, the values will be imported from the project, at runtime. +export const INTERNAL_CALLSITES_REGEX = new RegExp( + [ + '/Libraries/Renderer/implementations/.+\\.js$', + '/Libraries/BatchedBridge/MessageQueue\\.js$', + '/Libraries/YellowBox/.+\\.js$', + '/Libraries/LogBox/.+\\.js$', + '/Libraries/Core/Timers/.+\\.js$', + 'node_modules/react-devtools-core/.+\\.js$', + 'node_modules/react-refresh/.+\\.js$', + 'node_modules/scheduler/.+\\.js$', + // we want to omit this method from the stack trace. + // This is akin to most React tooling. + // Hide frames related to a fast refresh. + 'node_modules/eventemitter3/index.js', + 'node_modules/event-target-shim/dist/.+\\.js$', + // Ignore the log forwarder used in the Expo Go app + '/expo/build/environment/react-native-logs.fx.js$', + '/expo/build/logs/RemoteConsole.js$', + // Improve errors thrown by invariant (ex: `Invariant Violation: "main" has not been registered`). + 'node_modules/invariant/.+\\.js$', + // Remove babel runtime additions + 'node_modules/regenerator-runtime/.+\\.js$', + // Remove react native setImmediate ponyfill + 'node_modules/promise/setimmediate/.+\\.js$', + // Babel helpers that implement language features + 'node_modules/@babel/runtime/.+\\.js$', + ].join('|') +); + +const nativeMiddleware = (port: number, _platform: string) => ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: Error) => void +) => { + if (!req.url) return next(); + + const urlObj = parseUrl(req.url, true); + + const pathname = urlObj.pathname || ''; + + if (pathname === '/logs' || pathname === '/') { + return next(); + } + + // TODO: Replace + const url = req?.url?.split('?')[0]; + if (!url) return next(); + + let proxyPath: string = ''; + + if (pathname.endsWith('.bundle')) { + // TODO: parseOptionsFromUrl + const { platform = _platform } = urlObj.query || {}; + proxyPath = url.replace('.bundle', `.${platform}.js`); + } else if (pathname.endsWith('.map')) { + // Chrome dev tools may need to access the source maps. + res.setHeader('Access-Control-Allow-Origin', 'devtools://devtools'); + proxyPath = url; + } else if (pathname.endsWith('.assets')) { + // await this._processAssetsRequest( + // req, + // res, + // this._parseOptions(formattedUrl), + // ); + proxyPath = url; + } else if (pathname.startsWith('/assets/')) { + // await this._processSingleAssetRequest(req, res); + proxyPath = url; + } else if (pathname === '/symbolicate') { + return next(); + } else { + return next(); + } + + const proxyUrl = `http://0.0.0.0:${port}${proxyPath}`; + + const proxyReq = http.request( + proxyUrl, + { method: req.method, headers: req.headers }, + proxyRes => { + if (proxyRes.statusCode === 404) return next(); + if (url?.endsWith('.js')) proxyRes.headers['content-type'] = 'application/javascript'; + res.writeHead(proxyRes.statusCode!, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + } + ); + req.pipe(proxyReq, { end: true }); +}; + +export async function startDevServerAsync( + projectRoot: string, + options: MetroDevServerOptions +): Promise<{ + server: http.Server; + middleware: any; + messageSocket: MessageSocket; +}> { + const { port = 19000 } = options; + const platform = process.env.EXPO_PLATFORM ?? ('ios' as any); + + const customizeFrame = (frame: StackFrame) => { + let collapse = Boolean(frame.file && INTERNAL_CALLSITES_REGEX.test(frame.file)); + + if (!collapse) { + // This represents the first frame of the stacktrace. + // Often this looks like: `__r(0);`. + // The URL will also be unactionable in the app and therefore not very useful to the developer. + if ( + frame.column === 3 && + frame.methodName === 'global code' && + frame.file?.match(/^https?:\/\//g) + ) { + collapse = true; + } + } + + return { ...(frame || {}), collapse }; + }; + + ensureDirSync(path.join(projectRoot, '.expo/esbuild/cache')); + + const { buildOptions } = await getBuildOptions( + projectRoot, + options.logger, + { + platform, + // TODO: dev + minify: false, + cleanCache: options.resetCache, + }, + {} + ); + + let reload: Function; + const liveReload = true; + await esbuild.build({ + ...buildOptions, + watch: { + onRebuild(error, result) { + if (error) { + options.logger.error({ tag: 'esbuild' }, `watch build failed: ${error}`); + + throw error; + } + + options.logger.info( + { tag: 'esbuild' }, + `rebuild succeeded. errors: ${result?.errors.length}, warnings: ${result?.warnings.length}` + ); + + if (liveReload && reload) { + reload(); + } + }, + }, + }); + + const dist = './dist'; + return new Promise((res, rej) => { + esbuild + .serve({ servedir: dist }, {}) + .then(esbuildServerHooks => { + const { middleware, attachToServer } = createDevServerMiddleware({ + host: '127.0.0.1', + port, + watchFolders: [], + }); + + middleware.use(nativeMiddleware(8000, platform)); + middleware.stack.unshift(middleware.stack.pop()); + + // securityHeadersMiddleware does not support cross-origin requests for remote devtools to get the sourcemap. + // We replace with the enhanced version. + + replaceMiddlewareWith( + middleware as ConnectServer, + securityHeadersMiddleware, + remoteDevtoolsSecurityHeadersMiddleware + ); + middleware.use(remoteDevtoolsCorsMiddleware); + + middleware.use(bodyParser.json()); + middleware.use( + '/symbolicate', + createSymbolicateMiddleware( + projectRoot, + options.logger, + customizeFrame, + path.join(projectRoot, dist) + ) + ); + middleware.use('/logs', clientLogsMiddleware(options.logger)); + middleware.use('/inspector', createJsInspectorMiddleware()); + + const server = http.createServer(middleware).listen(port); + const { messageSocket } = attachToServer(server); + + reload = () => messageSocket.broadcast('reload'); + + res({ + server, + middleware, + messageSocket, + }); + }) + .catch(rej); + }); +} + +export async function bundleAsync( + projectRoot: string, + expoConfig: ExpoConfig, + options: MetroDevServerOptions, + bundles: BundleOptions[] +): Promise { + // TODO + throw new Error('no imp'); +} + +// TODO: Import from project +function importBundlerFromProject(projectRoot: string): typeof Metro { + const resolvedPath = resolveFrom.silent(projectRoot, 'esbuild'); + if (!resolvedPath) { + throw new Error('Missing package "esbuild" in the project at ' + projectRoot + '.'); + } + return require(resolvedPath); +} diff --git a/packages/dev-server/src/esbuild/LICENSE b/packages/dev-server/src/esbuild/LICENSE new file mode 100644 index 0000000000..76c8b33161 --- /dev/null +++ b/packages/dev-server/src/esbuild/LICENSE @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2021 Dalci de Jesus Bagolin +Copyright (c) Facebook, Inc. and its affiliates. +Copyright (c) 2021 Callstack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dev-server/src/esbuild/assets/AssetPaths.ts b/packages/dev-server/src/esbuild/assets/AssetPaths.ts new file mode 100644 index 0000000000..bb240d3256 --- /dev/null +++ b/packages/dev-server/src/esbuild/assets/AssetPaths.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import path from 'path'; + +import parsePlatformFilePath from './parsePlatformFilePath'; + +const ASSET_BASE_NAME_RE = /(.+?)(@([\d.]+)x)?$/; + +function parseBaseName(baseName: string) { + const match = baseName.match(ASSET_BASE_NAME_RE); + if (!match) { + throw new Error(`invalid asset name: \`${baseName}'`); + } + const rootName = match[1]; + if (match[3] != null) { + const resolution = parseFloat(match[3]); + if (!Number.isNaN(resolution)) { + return { rootName, resolution }; + } + } + return { rootName, resolution: 1 }; +} + +export function tryParse(filePath: string, platforms: Set) { + const result = parsePlatformFilePath(filePath, platforms); + const { dirPath, baseName, platform, extension } = result; + if (extension == null) { + return null; + } + const { rootName, resolution } = parseBaseName(baseName); + return { + assetName: path.join(dirPath, `${rootName}.${extension}`), + name: rootName, + platform, + resolution, + type: extension, + }; +} + +export function parse(filePath: string, platforms: Set) { + const result = tryParse(filePath, platforms); + if (result == null) { + throw new Error(`invalid asset file path: ${filePath}`); + } + return result; +} diff --git a/packages/dev-server/src/esbuild/assets/Assets.ts b/packages/dev-server/src/esbuild/assets/Assets.ts new file mode 100644 index 0000000000..4832c2a993 --- /dev/null +++ b/packages/dev-server/src/esbuild/assets/Assets.ts @@ -0,0 +1,258 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import crypto from 'crypto'; +import fs from 'fs'; +import imageSize from 'image-size'; +import path from 'path'; + +import { parse, tryParse } from './AssetPaths'; + +function isAssetTypeAnImage(type: string): boolean { + return /^(png|jpg|jpeg|bmp|gif|svg|psd|tiff|webp)$/.test(type); +} + +export type AssetInfo = { + files: string[]; + hash: string; + name: string; + scales: number[]; + type: string; +}; + +export type AssetDataWithoutFiles = { + __packager_asset: boolean; + fileSystemLocation: string; + hash: string; + height?: number; + httpServerLocation: string; + name: string; + scales: number[]; + type: string; + width?: number; + [key: string]: any; +}; + +export type AssetDataFiltered = { + __packager_asset: boolean; + hash: string; + height?: number; + httpServerLocation: string; + name: string; + scales: number[]; + type: string; + width?: number; + [key: string]: any; +}; + +export type AssetData = AssetDataWithoutFiles & { files: string[]; [key: string]: any }; + +export type AssetDataPlugin = (assetData: AssetData) => AssetData | Promise; + +async function hashFiles(files: string[], hash: crypto.Hash) { + if (!files.length) { + return; + } + const file = files.shift(); + if (file) { + await fs.promises + .readFile(file) + .then(async data => { + hash.update(data); + await hashFiles(files, hash); + }) + .catch(err => { + console.log(err); + }); + } +} + +function buildAssetMap(dir: string, files: string[], platform: string | null) { + const platforms = new Set(platform != null ? [platform] : []); + const assets = files.map(file => tryParse(file, platforms)); + const map = new Map(); + assets.forEach(function (asset, i) { + if (asset == null) { + return; + } + const file = files[i]; + const assetKey = getAssetKey(asset.assetName, asset.platform); + let record = map.get(assetKey); + if (!record) { + record = { + scales: [], + files: [], + }; + map.set(assetKey, record); + } + + let insertIndex; + const length = record.scales.length; + + for (insertIndex = 0; insertIndex < length; insertIndex++) { + if (asset.resolution < record.scales[insertIndex]) { + break; + } + } + record.scales.splice(insertIndex, 0, asset.resolution); + record.files.splice(insertIndex, 0, path.join(dir, file)); + }); + + return map; +} + +function getAssetKey(assetName: string, platform: string | null) { + if (platform != null) { + return `${assetName} : ${platform}`; + } else { + return assetName; + } +} + +async function getAbsoluteAssetRecord(assetPath: string, platform: string | null = null) { + const filename = path.basename(assetPath); + const dir = path.dirname(assetPath); + const files = await fs.promises.readdir(dir); + + const assetData = parse(filename, new Set(platform != null ? [platform] : [])); + + const map = buildAssetMap(dir, files, platform); + + let record; + if (platform != null) { + record = map.get(getAssetKey(assetData.assetName, platform)) || map.get(assetData.assetName); + } else { + record = map.get(assetData.assetName); + } + + if (!record) { + throw new Error(`Asset not found: ${assetPath} for platform: ${platform}`); + } + + return record; +} + +async function getAbsoluteAssetInfo(assetPath: string, platform: string | null = null) { + const nameData = parse(assetPath, new Set(platform != null ? [platform] : [])); + const { name, type } = nameData; + + const { scales, files } = await getAbsoluteAssetRecord(assetPath, platform); + const hasher = crypto.createHash('md5'); + + if (files.length > 0) { + await hashFiles(Array.from(files), hasher); + } + + return { files, hash: hasher.digest('hex'), name, scales, type }; +} + +export async function getAssetData( + assetPath: string, + localPath: string, + assetDataPlugins: string[], + platform: string | null = null, + publicPath: string +) { + let assetUrlPath = localPath.startsWith('..') + ? publicPath.replace(/\/$/, '') + '/' + path.dirname(localPath) + : path.join(publicPath, path.dirname(localPath)); + + if (path.sep === '\\') { + assetUrlPath = assetUrlPath.replace(/\\/g, '/'); + } + + const isImage = isAssetTypeAnImage(path.extname(assetPath).slice(1)); + const assetInfo = await getAbsoluteAssetInfo(assetPath, platform); + + const isImageInput = assetInfo.files[0].includes('.zip/') + ? fs.readFileSync(assetInfo.files[0]) + : assetInfo.files[0]; + + const dimensions = isImage ? imageSize(isImageInput) : null; + + const scale = assetInfo.scales[0]; + + const assetData = { + __packager_asset: true, + fileSystemLocation: path.dirname(assetPath), + httpServerLocation: assetUrlPath, + width: dimensions?.width ? dimensions.width / scale : undefined, + height: dimensions?.height ? dimensions.height / scale : undefined, + scales: assetInfo.scales, + files: assetInfo.files, + hash: assetInfo.hash, + name: assetInfo.name, + type: assetInfo.type, + }; + return await applyAssetDataPlugins(assetDataPlugins, assetData); +} + +async function applyAssetDataPlugins( + assetDataPlugins: string[], + assetData: AssetData +): Promise { + if (!assetDataPlugins.length) { + return assetData; + } + + const [currentAssetPlugin, ...remainingAssetPlugins] = assetDataPlugins; + let assetPluginFunction = require(currentAssetPlugin); + if (typeof assetPluginFunction !== 'function') { + assetPluginFunction = assetPluginFunction.default; + } + const resultAssetData = await assetPluginFunction(assetData); + return await applyAssetDataPlugins(remainingAssetPlugins, resultAssetData); +} + +export async function getAssetFiles(assetPath: string, platform: string | null = null) { + const assetData = await getAbsoluteAssetRecord(assetPath, platform); + return assetData.files; +} + +export async function getAsset( + relativePath: string, + projectRoot: string, + watchFolders: string[], + platform: string | null = null, + assetExts?: string[] +) { + const assetData = parse(relativePath, new Set(platform != null ? [platform] : [])); + + const absolutePath = path.resolve(projectRoot, relativePath); + + if (!assetExts?.includes(assetData.type)) { + throw new Error( + `'${relativePath}' cannot be loaded as its extension is not registered in assetExts` + ); + } + + if (!pathBelongsToRoots(absolutePath, [projectRoot, ...watchFolders])) { + throw new Error( + `'${relativePath}' could not be found, because it cannot be found in the project root or any watch folder` + ); + } + + const record = await getAbsoluteAssetRecord(absolutePath, platform); + + for (let i = 0; i < record.scales.length; i++) { + if (record.scales[i] >= assetData.resolution) { + return fs.promises.readFile(record.files[i]); + } + } + + return fs.promises.readFile(record.files[record.files.length - 1]); +} + +function pathBelongsToRoots(pathToCheck: string, roots: string[]) { + for (const rootFolder of roots) { + if (pathToCheck.startsWith(path.resolve(rootFolder))) { + return true; + } + } + + return false; +} diff --git a/packages/dev-server/src/esbuild/assets/parsePlatformFilePath.ts b/packages/dev-server/src/esbuild/assets/parsePlatformFilePath.ts new file mode 100644 index 0000000000..d69fad9383 --- /dev/null +++ b/packages/dev-server/src/esbuild/assets/parsePlatformFilePath.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import path from 'path'; + +const PATH_RE = /^(.+?)(\.([^.]+))?\.([^.]+)$/; + +/** + * Extract the components of a file path that can have a platform specifier: Ex. + * `index.ios.js` is specific to the `ios` platform and has the extension `js`. + */ +function parsePlatformFilePath(filePath: string, platforms: Set) { + const dirPath = path.dirname(filePath); + const fileName = path.basename(filePath); + const match = fileName.match(PATH_RE); + if (!match) { + return { dirPath, baseName: fileName, platform: null, extension: null }; + } + const extension = match[4] || null; + const platform = match[3] || null; + if (platform == null || platforms.has(platform)) { + return { dirPath, baseName: match[1], platform, extension }; + } + const baseName = `${match[1]}.${platform}`; + return { dirPath, baseName, platform: null, extension }; +} + +export default parsePlatformFilePath; diff --git a/packages/dev-server/src/esbuild/assets/resolveHashAssetFiles.ts b/packages/dev-server/src/esbuild/assets/resolveHashAssetFiles.ts new file mode 100644 index 0000000000..7e3f486370 --- /dev/null +++ b/packages/dev-server/src/esbuild/assets/resolveHashAssetFiles.ts @@ -0,0 +1,11 @@ +import resolveFrom from 'resolve-from'; + +export function resolveHashAssetFiles(projectRoot: string) { + try { + return resolveFrom(projectRoot, 'expo-asset/tools/hashAssetFiles'); + } catch { + throw new Error( + 'cannot resolve `expo-asset/tools/hashAssetFiles`, please install `expo-asset` and try again.' + ); + } +} diff --git a/packages/dev-server/src/esbuild/plugins/aliasPlugin.ts b/packages/dev-server/src/esbuild/plugins/aliasPlugin.ts new file mode 100644 index 0000000000..73a9427d77 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/aliasPlugin.ts @@ -0,0 +1,18 @@ +import { ImportMap, resolve } from 'deno-importmap'; +import { Plugin } from 'esbuild'; +import path from 'path'; + +export default function aliasPlugin(alias: ImportMap['imports']) { + const importMap: ImportMap = { imports: alias }; + const plugin: Plugin = { + name: 'alias', + setup(build) { + build.onResolve({ filter: /.*/ }, (args: any) => { + const resolvedPath = resolve(args.path, importMap); + if (args.path === resolvedPath) return; + return { path: require.resolve(path.resolve(resolvedPath)) }; + }); + }, + }; + return plugin; +} diff --git a/packages/dev-server/src/esbuild/plugins/assetsPlugin.ts b/packages/dev-server/src/esbuild/plugins/assetsPlugin.ts new file mode 100644 index 0000000000..b029d40b43 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/assetsPlugin.ts @@ -0,0 +1,63 @@ +import { Plugin } from 'esbuild'; +import path from 'path'; + +import { getAssetData } from '../assets/Assets'; +import { resolveHashAssetFiles } from '../assets/resolveHashAssetFiles'; + +function camelize(text: string) { + text = text.replace(/[-_\s.@]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')); + return text.substr(0, 1).toLowerCase() + text.substr(1); +} + +function assetsPlugin(projectRoot: string, platform: string, assetExts: string[]) { + const filter = new RegExp(assetExts.map(ext => `[.]${ext}$`).join('|')); + + const plugin: Plugin = { + name: 'assets', + setup(build) { + build.onResolve({ filter }, args => { + let assetFile; + try { + assetFile = require.resolve(args.path, { + paths: [process.cwd()], + }); + } catch (e) { + assetFile = path.resolve(args.resolveDir, args.path); + } + assetFile = assetFile.replace(/\\/g, '/'); + if (path.basename(args.importer) === path.basename(args.path + '.ast.js')) { + return { path: assetFile, namespace: 'file' }; + } + return { path: assetFile + '.ast.js', namespace: 'assets' }; + }); + build.onLoad({ filter: /.*/, namespace: 'assets' }, async args => { + const assetPath = args.path.slice(0, -7).replace(/\\/g, '/'); + + const hashAssetFiles = resolveHashAssetFiles(projectRoot); + + const asset = await getAssetData( + assetPath, + path.basename(assetPath), + [hashAssetFiles], + platform, + '/assets' + ); + + const contents = ` + const { registerAsset } = require('react-native/Libraries/Image/AssetRegistry.js') + ${asset.files + .map( + file => `const ${camelize(path.parse(file).name)} = require('${file.replace(/\\/g, '/')}')` + ) + .join('\n')}\n + const asset = registerAsset(${JSON.stringify(asset, null, 6)}) + module.exports = asset + `; + return { contents, loader: 'js', resolveDir: path.resolve(path.parse(args.path).dir) }; + }); + }, + }; + return plugin; +} + +export default assetsPlugin; diff --git a/packages/dev-server/src/esbuild/plugins/loggingPlugin.ts b/packages/dev-server/src/esbuild/plugins/loggingPlugin.ts new file mode 100644 index 0000000000..15b307c3a0 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/loggingPlugin.ts @@ -0,0 +1,40 @@ +import Log from '@expo/bunyan'; +import { Plugin } from 'esbuild'; + +export default function loggingPlugin(logger: Log) { + const plugin: Plugin = { + name: 'expoLogging', + setup(build) { + build.onStart(() => { + logger.info( + { tag: 'metro' }, + JSON.stringify({ + type: 'bundle_build_started', + id: Date.now(), + }) + ); + }); + + build.onEnd((result: any) => { + if (result.errors.length) { + logger.info( + { tag: 'metro' }, + JSON.stringify({ + type: 'bundle_build_failed', + id: Date.now(), + }) + ); + } else { + logger.info( + { tag: 'metro' }, + JSON.stringify({ + type: 'bundle_build_done', + id: Date.now(), + }) + ); + } + }); + }, + }; + return plugin; +} diff --git a/packages/dev-server/src/esbuild/plugins/patchPlugin.ts b/packages/dev-server/src/esbuild/plugins/patchPlugin.ts new file mode 100644 index 0000000000..8733d31528 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/patchPlugin.ts @@ -0,0 +1,30 @@ +import { Plugin } from 'esbuild'; +import fs from 'fs'; +import path from 'path'; + +const patchPlugin: Plugin = { + name: 'patches', + setup(build) { + build.onLoad({ filter: /views[\\|/]GestureHandlerNative\.js$/ }, args => { + return replace(args, ', PanGestureHandlerGestureEvent', ''); + }); + build.onLoad( + { filter: /react-native-maps[\\|/]lib[\\|/]components[\\|/]AnimatedRegion.js$/ }, + args => { + return replace( + args, + `AnimatedWithChildren.name !== 'AnimatedWithChildren'`, + `!AnimatedWithChildren.name.startsWith('AnimatedWithChildren')` + ); + } + ); + }, +}; + +function replace(args: { path: string }, remove: string, include: string) { + const relpath = path.relative(process.cwd(), args.path); + const source = fs.readFileSync(relpath, 'utf8'); + return { contents: source.replace(remove, include) }; +} + +export default patchPlugin; diff --git a/packages/dev-server/src/esbuild/plugins/removeFlowPlugin.ts b/packages/dev-server/src/esbuild/plugins/removeFlowPlugin.ts new file mode 100644 index 0000000000..21fe6f92e8 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/removeFlowPlugin.ts @@ -0,0 +1,73 @@ +import { Plugin } from 'esbuild'; +import findUp from 'find-up'; +// @ts-ignore +import flowRemoveTypes from 'flow-remove-types'; +import fs from 'fs'; +import path from 'path'; +import resolveFrom from 'resolve-from'; + +const projectRoot = process.cwd().replace(/\\/g, '/'); + +let cache = new Map(); +let updateCache = false; +const cacheFile = projectRoot + '/.expo/esbuild/cache/removed-flow.json'; + +function findUpPackageJson(root: string) { + const packageJson = findUp.sync('package.json', { cwd: root }); + return packageJson || null; +} + +function moduleRoot(projectRoot: string, moduleId: string) { + const moduleEntry = resolveFrom.silent(projectRoot, moduleId); + if (moduleEntry) { + const pkgJson = findUpPackageJson(moduleEntry); + return pkgJson ? path.dirname(pkgJson) : null; + } + return null; +} + +function removeFlowPlugin(projectRoot: string, modules: string[], cleanCache?: boolean) { + if (fs.existsSync(cacheFile) && !cleanCache) { + cache = new Map(JSON.parse(fs.readFileSync(cacheFile).toString())); + } + + const resolvedModules = modules + .map(module => { + return moduleRoot(projectRoot, module); + }) + .filter(Boolean) as string[]; + + const packagesRemoveFlow = new RegExp( + resolvedModules.map(module => `${module}.*\\.jsx?$`).join('|'), + 'g' + ); + + const plugin: Plugin = { + name: 'removeFlow', + setup(build) { + build.onLoad({ filter: packagesRemoveFlow, namespace: 'file' }, async args => { + const relpath = path.relative(process.cwd(), args.path); + const cacheResult = cache.get(relpath); + if (cacheResult) { + return { contents: cacheResult, loader: 'jsx' }; + } + const source = fs.readFileSync(relpath, 'utf8'); + const output = flowRemoveTypes(/* '// @flow\n' + */ source, { + pretty: false, + all: true, + ignoreUninitializedFields: true, + }); + const contents = output.toString().replace(/static\s+\+/g, 'static '); + cache.set(relpath, contents); + updateCache = true; + return { contents, loader: 'jsx' }; + }); + build.onEnd(() => { + if (updateCache) fs.writeFileSync(cacheFile, JSON.stringify([...cache.entries()])); + }); + }, + }; + return plugin; +} + +export default removeFlowPlugin; diff --git a/packages/dev-server/src/esbuild/utils.ts b/packages/dev-server/src/esbuild/utils.ts new file mode 100644 index 0000000000..2767ce54fa --- /dev/null +++ b/packages/dev-server/src/esbuild/utils.ts @@ -0,0 +1,56 @@ +import Log from '@expo/bunyan'; +import merge from 'deepmerge'; +import { Plugin } from 'esbuild'; + +import aliasPlugin from './plugins/aliasPlugin'; +import assetsPlugin from './plugins/assetsPlugin'; +import loggingPlugin from './plugins/loggingPlugin'; +import patchPlugin from './plugins/patchPlugin'; +import removeFlowPlugin from './plugins/removeFlowPlugin'; + +export function mergePlugins( + configPlatformPlugins: Plugin[], + customConfigPlugins: { name: string }[] = [] +) { + const mergedInternalPlugins = [ + 'alias', + 'expoLogging', + 'stripFlowTypes', + 'reactNativeAssets', + 'patches', + ] + .map(name => { + const platformConfigPlugin = configPlatformPlugins.find(plugin => plugin.name === name); + const customConfigPlugin = customConfigPlugins.find(plugin => plugin.name === name); + return merge(platformConfigPlugin || {}, customConfigPlugin || {}); + }) + .filter(plugin => plugin.name); + const externalPlugins = customConfigPlugins.filter(plugin => !plugin.name.startsWith('esbuild')); + return [...mergedInternalPlugins, ...externalPlugins]; +} + +export function setPlugins( + projectRoot: string, + logger: Log, + plugins: { name: string; params: any }[], + platform: string, + cleanCache: boolean +) { + return plugins.map(plugin => { + if (plugin.name === 'stripFlowTypes') + return removeFlowPlugin(projectRoot, plugin.params, cleanCache); + if (plugin.name === 'alias') return aliasPlugin(plugin.params); + if (plugin.name === 'expoLogging') return loggingPlugin(logger); + if (plugin.name === 'reactNativeAssets') + return assetsPlugin(projectRoot, platform, plugin.params); + if (plugin.name === 'patches') return patchPlugin; + return plugin; + }); +} + +export function setAssetLoaders(assetExts: string[]) { + return assetExts.reduce>((loaders, ext) => { + loaders['.' + ext] = 'file'; + return loaders; + }, {}); +} diff --git a/packages/dev-server/src/middleware/Symbolicator.ts b/packages/dev-server/src/middleware/Symbolicator.ts new file mode 100644 index 0000000000..4f147f919f --- /dev/null +++ b/packages/dev-server/src/middleware/Symbolicator.ts @@ -0,0 +1,235 @@ +import { codeFrameColumns } from '@babel/code-frame'; +import fs from 'fs'; +import path from 'path'; +import { SourceMapConsumer } from 'source-map'; +import { URL } from 'url'; +import { promisify } from 'util'; + +const readFileAsync = promisify(fs.readFile); + +/** + * Raw React Native stack frame. + */ +export interface ReactNativeStackFrame { + lineNumber: number | null; + column: number | null; + file: string | null; + methodName: string; +} + +/** + * React Native stack frame used as input when processing by {@link Symbolicator}. + */ +export interface InputStackFrame extends ReactNativeStackFrame { + file: string; +} + +/** + * Final symbolicated stack frame. + */ +export interface StackFrame extends InputStackFrame { + collapse: boolean; +} + +/** + * Represents [@babel/core-frame](https://babeljs.io/docs/en/babel-code-frame). + */ +export interface CodeFrame { + content: string; + location: { + row: number; + column: number; + }; + fileName: string; +} + +/** + * Represents results of running {@link process} method on {@link Symbolicator} instance. + */ +export interface SymbolicatorResults { + codeFrame: CodeFrame | null; + stack: StackFrame[]; +} + +/** + * Class for transforming stack traces from React Native application with using Source Map. + * Raw stack frames produced by React Native, points to some location from the bundle + * eg `index.bundle?platform=ios:567:1234`. By using Source Map for that bundle `Symbolicator` + * produces frames that point to source code inside your project eg `Hello.tsx:10:9`. + */ +export class Symbolicator { + /** + * Infer platform from stack frames. + * Usually at least one frame has `file` field with the bundle URL eg: + * `http://localhost:8081/index.bundle?platform=ios&...`, which can be used to infer platform. + * + * @param stack Array of stack frames. + * @returns Inferred platform or `undefined` if cannot infer. + */ + static inferPlatformFromStack(stack: ReactNativeStackFrame[]) { + for (const frame of stack) { + if (!frame.file) { + return null; + } + + const { searchParams, pathname } = new URL(frame.file, 'file://'); + const platform = searchParams.get('platform'); + if (platform) { + return platform; + } else { + const [bundleFilename] = pathname.split('/').reverse(); + const [, platformOrExtension, extension] = bundleFilename.split('.'); + if (extension) { + return platformOrExtension; + } + } + } + return null; + } + + /** + * Cache with initialized `SourceMapConsumer` to improve symbolication performance. + */ + sourceMapConsumerCache: Record = {}; + + /** + * Constructs new `Symbolicator` instance. + * + * @param projectRoot Absolute path to root directory of the project. + * @param logger Fastify logger instance. + * @param readFileFromBundler Function to read arbitrary file from the bundler. + * @param readSourceMapFromBundler Function to read Source Map file from bundler. + */ + constructor( + private projectRoot: string, + private distFolder: string, + private logger: { error: (message: string) => void }, + private customizeFrame: (frame: StackFrame) => StackFrame, + private readFileFromBundler: (fileUrl: string) => Promise, + private readSourceMapFromBundler: (fileUrl: string) => Promise + ) {} + + /** + * Process raw React Native stack frames and transform them using Source Maps. + * Method will try to symbolicate as much data as possible, but if the Source Maps + * are not available, invalid or the original positions/data is not found in Source Maps, + * the method will return raw values - the same as supplied with `stack` parameter. + * For example out of 10 frames, it's possible that only first 7 will be symbolicated and the + * remaining 3 will be unchanged. + * + * @param stack Raw stack frames. + * @returns Symbolicated stack frames. + */ + async process(stack: ReactNativeStackFrame[]): Promise { + // TODO: add debug logging + const frames: InputStackFrame[] = []; + for (const frame of stack) { + const { file } = frame; + if (file?.startsWith('http') && !file.includes('debuggerWorker')) { + frames.push(frame as InputStackFrame); + } + } + + try { + const processedFrames: StackFrame[] = []; + for (const frame of frames) { + if (!this.sourceMapConsumerCache[frame.file]) { + const rawSourceMap = await this.readSourceMapFromBundler(frame.file); + const sourceMapConsumer = await new SourceMapConsumer(rawSourceMap as any); + this.sourceMapConsumerCache[frame.file] = sourceMapConsumer; + } + const processedFrame = this.customizeFrame(this.processFrame(frame)); + processedFrames.push(processedFrame); + } + + return { + stack: processedFrames, + codeFrame: (await this.getCodeFrame(processedFrames)) ?? null, + }; + } finally { + for (const key in this.sourceMapConsumerCache) { + // this.sourceMapConsumerCache[key].destroy(); + delete this.sourceMapConsumerCache[key]; + } + } + } + + private processFrame(frame: InputStackFrame): StackFrame { + if (!frame.lineNumber || !frame.column) { + return { + ...frame, + collapse: false, + }; + } + + const consumer = this.sourceMapConsumerCache[frame.file]; + if (!consumer) { + return { + ...frame, + collapse: false, + }; + } + + const lookup = consumer.originalPositionFor({ + line: frame.lineNumber, + column: frame.column, + }); + + // If lookup fails, we get the same shape object, but with + // all values set to null + if (!lookup.source) { + // It is better to gracefully return the original frame + // than to throw an exception + return { + ...frame, + collapse: false, + }; + } + + return { + lineNumber: lookup.line || frame.lineNumber, + column: lookup.column || frame.column, + file: path.join(this.distFolder, lookup.source), + methodName: lookup.name || frame.methodName, + collapse: false, + }; + } + + private async getCodeFrame(processedFrames: StackFrame[]): Promise { + for (const frame of processedFrames) { + if (frame.collapse || !frame.lineNumber || !frame.column) { + continue; + } + + try { + let filename; + let source; + if (frame.file.startsWith('http') && frame.file.includes('index.bundle')) { + filename = frame.file; + source = await this.readFileFromBundler('/index.bundle'); + } else { + filename = frame.file; + source = await readFileAsync(filename, 'utf8'); + } + + return { + content: codeFrameColumns( + source, + { + start: { column: frame.column, line: frame.lineNumber }, + }, + { forceColor: true } + ), + location: { + row: frame.lineNumber, + column: frame.column, + }, + fileName: filename, + }; + } catch (error) { + this.logger.error('Failed to create code frame: ' + error.message); + } + } + return undefined; + } +} diff --git a/packages/dev-server/src/middleware/createSymbolicateMiddleware.ts b/packages/dev-server/src/middleware/createSymbolicateMiddleware.ts new file mode 100644 index 0000000000..99ca61d893 --- /dev/null +++ b/packages/dev-server/src/middleware/createSymbolicateMiddleware.ts @@ -0,0 +1,97 @@ +import Log from '@expo/bunyan'; +import fs from 'fs'; +import type { IncomingMessage, ServerResponse } from 'http'; +import path from 'path'; +import { parse as parseUrl } from 'url'; + +import { ReactNativeStackFrame, StackFrame, Symbolicator } from './Symbolicator'; + +function getFilenameFromUrl(url: string): string { + const parsedUrl = parseUrl(url, true); + const { platform } = parsedUrl.query || {}; + const filePath = parsedUrl.pathname!.split('/').pop()!; + return filePath.replace('.bundle', [platform && `.${platform}`, `.js`].filter(Boolean).join('')); +} + +// TODO: For some reason the LogBox shows the error as unsymbolicated until you interact with the page. + +export function createSymbolicateMiddleware( + projectRoot: string, + logger: Log, + customizeFrame: (frame: StackFrame) => StackFrame, + hostedDirectory: string +) { + const symbolicator = new Symbolicator( + projectRoot, + hostedDirectory, + logger, + customizeFrame, + async fileUrl => { + throw new Error('unimplemented -- TODO'); + }, + async (fileUrl: string) => { + // http://127.0.0.1:19000/index.bundle?platform=ios&dev=true&hot=false&minify=false + const fileName = getFilenameFromUrl(fileUrl); + if (fileName) { + const filePath = path.join(hostedDirectory, fileName); + const fallbackSourceMapFilename = `${filePath}.map`; + // TODO: Read from some kinda cache + const bundle = await fs.promises.readFile(fallbackSourceMapFilename, 'utf8'); + const [, sourceMappingUrl] = /sourceMappingURL=(.+)$/.exec(bundle) || [ + undefined, + undefined, + ]; + const [sourceMapBasename] = sourceMappingUrl?.split('?') ?? [undefined]; + + let sourceMapFilename = fallbackSourceMapFilename; + if (sourceMapBasename) { + sourceMapFilename = path.join(path.dirname(filePath), sourceMapBasename); + } + try { + // TODO: Read from some kinda cache + return fs.promises.readFile(sourceMapFilename, 'utf8'); + } catch { + logger.warn( + { + tag: 'expo', + sourceMappingUrl, + sourceMapFilename, + }, + 'Failed to read source map from sourceMappingURL, trying fallback' + ); + return fs.promises.readFile(fallbackSourceMapFilename, 'utf8'); + } + } else { + throw new Error(`Cannot infer filename from url: ${fileUrl}`); + } + } + ); + return async function ( + req: IncomingMessage & { body?: any; rawBody?: any }, + res: ServerResponse + ) { + try { + // TODO: Fix body not being set + if (!req.rawBody) { + res.writeHead(400).end('Missing request body.'); + return; + } + + const { stack } = JSON.parse(req.rawBody as string) as { + stack: ReactNativeStackFrame[]; + }; + + if (!Symbolicator.inferPlatformFromStack(stack)) { + res.writeHead(400).end('Missing platform in stack'); + return; + } + + const results = await symbolicator.process(stack); + res.end(JSON.stringify(results)); + } catch (error) { + logger.error({ tag: 'expo' }, `Failed to symbolicate: ${error} ${error.stack}`); + res.statusCode = 500; + res.end(JSON.stringify({ error: error.message })); + } + }; +} diff --git a/packages/dev-server/src/middleware/remoteDevtoolsCorsMiddleware.ts b/packages/dev-server/src/middleware/remoteDevtoolsCorsMiddleware.ts new file mode 100644 index 0000000000..3426595ad0 --- /dev/null +++ b/packages/dev-server/src/middleware/remoteDevtoolsCorsMiddleware.ts @@ -0,0 +1,32 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { parse as parseUrl } from 'url'; + +// Middleware that accepts multiple Access-Control-Allow-Origin for processing *.map. +// This is a hook middleware before metro processing *.map, +// which originally allow only devtools://devtools +export function remoteDevtoolsCorsMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: Error) => void +) { + if (req.url) { + const url = parseUrl(req.url); + const origin = req.headers.origin; + const isValidOrigin = + origin && + ['devtools://devtools', 'https://chrome-devtools-frontend.appspot.com'].includes(origin); + if (url.pathname?.endsWith('.map') && origin && isValidOrigin) { + res.setHeader('Access-Control-Allow-Origin', origin); + + // Prevent metro overwrite Access-Control-Allow-Origin header + const setHeader = res.setHeader.bind(res); + res.setHeader = (key, ...args) => { + if (key === 'Access-Control-Allow-Origin') { + return; + } + setHeader(key, ...args); + }; + } + } + next(); +} diff --git a/packages/dev-server/src/middleware/remoteDevtoolsSecurityHeadersMiddleware.ts b/packages/dev-server/src/middleware/remoteDevtoolsSecurityHeadersMiddleware.ts new file mode 100644 index 0000000000..39778d2bf7 --- /dev/null +++ b/packages/dev-server/src/middleware/remoteDevtoolsSecurityHeadersMiddleware.ts @@ -0,0 +1,30 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +// Like securityHeadersMiddleware but further allow cross-origin requests +// from https://chrome-devtools-frontend.appspot.com/ +export function remoteDevtoolsSecurityHeadersMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: Error) => void +) { + // Block any cross origin request. + if ( + typeof req.headers.origin === 'string' && + !req.headers.origin.match(/^https?:\/\/localhost:/) && + !req.headers.origin.match(/^https:\/\/chrome-devtools-frontend\.appspot\.com/) + ) { + next( + new Error( + `Unauthorized request from ${req.headers.origin}. ` + + 'This may happen because of a conflicting browser extension to intercept HTTP requests. ' + + 'Please try again without browser extensions or using incognito mode.' + ) + ); + return; + } + + // Block MIME-type sniffing. + res.setHeader('X-Content-Type-Options', 'nosniff'); + + next(); +} diff --git a/packages/dev-server/src/middleware/replaceMiddlewareWith.ts b/packages/dev-server/src/middleware/replaceMiddlewareWith.ts new file mode 100644 index 0000000000..235e821105 --- /dev/null +++ b/packages/dev-server/src/middleware/replaceMiddlewareWith.ts @@ -0,0 +1,12 @@ +import type { Server as ConnectServer, HandleFunction } from 'connect'; + +export function replaceMiddlewareWith( + app: ConnectServer, + sourceMiddleware: HandleFunction, + targetMiddleware: HandleFunction +) { + const item = app.stack.find(middleware => middleware.handle === sourceMiddleware); + if (item) { + item.handle = targetMiddleware; + } +} diff --git a/packages/expo-cli/package.json b/packages/expo-cli/package.json index 802d2c7c0b..b11394377b 100644 --- a/packages/expo-cli/package.json +++ b/packages/expo-cli/package.json @@ -94,7 +94,6 @@ "dateformat": "3.0.3", "env-editor": "^0.4.1", "envinfo": "7.5.0", - "esbuild": "~0.5.0", "find-up": "^5.0.0", "find-yarn-workspace-root": "~2.0.0", "form-data": "^2.3.2", diff --git a/packages/expo-cli/src/commands/esbuild/service.ts b/packages/expo-cli/src/commands/esbuild/service.ts deleted file mode 100644 index 0e3f3d03f5..0000000000 --- a/packages/expo-cli/src/commands/esbuild/service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { build } from 'esbuild'; -import fs from 'fs-extra'; -import path from 'path'; - -// import { resolveModule } from '@expo/config'; - -import { getBareExtensions } from '@expo/config/paths'; - -/** - * Get the platform specific platform extensions in the format that Webpack expects (with a dot prefix). - * - * @param platforms supported platforms in order of priority. ex: ios, android, web, native, electron, etc... - * @category env - */ -function getModuleFileExtensions(...platforms: string[]): string[] { - // Webpack requires a `.` before each value - return getBareExtensions(platforms).map(value => `.${value}`); -} - -function getNativeModuleFileExtensions(...platforms: string[]): string[] { - // Webpack requires a `.` before each value - // Disable modern when using `react-native` - return getBareExtensions(platforms, { isReact: true, isTS: true, isModern: false }).map( - value => `.${value}` - ); -} - -function isPlatformNative(platform: string): boolean { - return ['ios', 'android'].includes(platform); -} - -function getPlatformsExtensions(platform: string): string[] { - if (isPlatformNative(platform)) { - return getNativeModuleFileExtensions(platform, 'native'); - } - return getModuleFileExtensions(platform); -} - -export async function buildAsync( - projectRoot: string, - mainFile: string, - platform: string, - outputPath: string -): Promise { - await fs.ensureDir(path.dirname(outputPath)); - console.log('Building: ', mainFile); - console.log('output: ', outputPath); - try { - const { warnings } = await build({ - entryPoints: [ - // resolveModule('react-native/build/polyfills/console.js', projectRoot, {}), - // resolveModule('react-native/build/polyfills/error-guard.js', projectRoot, {}), - // resolveModule('react-native/build/polyfills/Object.es7.js', projectRoot, {}), - mainFile, - ], - resolveExtensions: getPlatformsExtensions(platform), - minify: true, - bundle: true, - sourcemap: true, - target: 'esnext', - outfile: outputPath, - loader: { '.js': 'jsx' }, - define: { - 'process.env.NODE_ENV': '"production"', - __DEV__: 'false', - }, - }); - const output = fs.readFileSync(outputPath, 'utf8'); - await fs.writeFile(outputPath, 'var global=global||this;' + output); - console.log('success', { warnings }); - } catch ({ stderr, errors, warnings }) { - console.error('failure', { stderr, errors, warnings }); - } -} diff --git a/packages/expo-cli/src/commands/index.ts b/packages/expo-cli/src/commands/index.ts index b3b314a167..780c157d30 100644 --- a/packages/expo-cli/src/commands/index.ts +++ b/packages/expo-cli/src/commands/index.ts @@ -2,7 +2,6 @@ import type { Command } from 'commander'; const COMMANDS = [ require('./build'), - require('./ios'), require('./bundle-assets'), require('./client'), require('./config/config'), diff --git a/packages/expo-cli/src/commands/ios.ts b/packages/expo-cli/src/commands/ios.ts deleted file mode 100644 index dc298da664..0000000000 --- a/packages/expo-cli/src/commands/ios.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { resolveEntryPoint } from '@expo/xdl/build/tools/resolveEntryPoint'; -import chalk from 'chalk'; -import { Command } from 'commander'; -import path from 'path'; - -import { buildAsync } from './esbuild/service'; - -export default function (program: Command) { - program - .command('ios [project-dir]') - .description( - chalk.yellow`Deprecated: Opens your app in Expo in an iOS simulator on your computer` - ) - .allowOffline() - .asyncActionProjectDir(async (projectRoot: string) => { - const platform = process.env.EXPO_PLATFORM ?? 'ios'; - - const filename = resolveEntryPoint(projectRoot, platform); - // const contents = await readFile(filename, 'utf8'); - // console.log(filename, contents); - await buildAsync( - projectRoot, - path.resolve(filename), - platform, - path.join(projectRoot, `public/index.${platform}.js`) - ); - // console.log(data); - }); -} diff --git a/packages/xdl/src/start/startDevServerAsync.ts b/packages/xdl/src/start/startDevServerAsync.ts index 0f8d910557..974f4db92f 100644 --- a/packages/xdl/src/start/startDevServerAsync.ts +++ b/packages/xdl/src/start/startDevServerAsync.ts @@ -1,5 +1,6 @@ import { ProjectTarget } from '@expo/config'; import { MessageSocket, MetroDevServerOptions, runMetroDevServerAsync } from '@expo/dev-server'; +import * as EsbuildDevServer from '@expo/dev-server/build/esbuild/EsbuildDevServer'; import http from 'http'; import { @@ -59,7 +60,20 @@ export async function startDevServerAsync( options.maxWorkers = startOptions.maxWorkers; } - const { server, middleware, messageSocket } = await runMetroDevServerAsync(projectRoot, options); + let serverInfo: { + server: http.Server; + middleware: any; + messageSocket: MessageSocket; + }; + + if (process.env.EXPO_BUNDLER === 'esbuild') { + serverInfo = await EsbuildDevServer.startDevServerAsync(projectRoot, options); + } else { + serverInfo = await runMetroDevServerAsync(projectRoot, options); + } + + const { server, middleware, messageSocket } = serverInfo; + middleware.use(ManifestHandler.getManifestHandler(projectRoot)); middleware.use(ExpoUpdatesManifestHandler.getManifestHandler(projectRoot)); diff --git a/yarn.lock b/yarn.lock index c706bc59ac..8aedff2ec2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3898,6 +3898,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.15.tgz#0de7e978fb43db62da369db18ea088a63673c182" integrity sha512-lowukE3GUI+VSYSu6VcBXl14d61Rp5hA1D+61r16qnwC0lYNSqdxcvRh0pswejorHfS+HgwBasM8jLXz0/aOsw== +"@types/node@^14.14.11": + version "14.17.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.5.tgz#b59daf6a7ffa461b5648456ca59050ba8e40ed54" + integrity sha512-bjqH2cX/O33jXT/UmReo2pM7DIJREPMnarixbQ57DOOzzFaI6D2+IcwaJQaJpv0M1E9TIhPCYVxrkcityLjlqA== + "@types/node@^9.4.6": version "9.6.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.55.tgz#7cc1358c9c18e71f6c020e410962971863232cf5" @@ -7749,6 +7754,13 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +deno-importmap@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/deno-importmap/-/deno-importmap-0.1.6.tgz#04656ff12793003eafbe20c3df59ea423740a4a6" + integrity sha512-nZ5ZA8qW5F0Yzq1VhRp1wARpWSfD0FQvI1IUHXbE3oROO6tcYomTIWSAZGzO4LGQl1hTG6UmhPNTP3d4uMXzMg== + dependencies: + "@types/node" "^14.14.11" + denodeify@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/denodeify/-/denodeify-1.2.1.tgz#3a36287f5034e699e7577901052c2e6c94251631" @@ -8328,10 +8340,10 @@ es6-weak-map@^2.0.2: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -esbuild@~0.5.0: - version "0.5.26" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.5.26.tgz#0868e8f3e0938c6cb9a8c93e74f85900eff1a071" - integrity sha512-OopLmIbQNOWBWMBoOtHjDEaIiTI2okEpRkbiKccfatnXJtsGazwWhR0dmvY8ynjLx/BOPS9mQ6QFl7J1BQCcaQ== +esbuild@^0.12.15: + version "0.12.15" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.12.15.tgz#9d99cf39aeb2188265c5983e983e236829f08af0" + integrity sha512-72V4JNd2+48eOVCXx49xoSWHgC3/cCy96e7mbXKY+WOWghN00cCmlGnwVLRhRHorvv0dgCyuMYBZlM2xDM5OQw== escalade@^3.0.1, escalade@^3.0.2, escalade@^3.1.1: version "3.1.1" @@ -9403,6 +9415,20 @@ flow-parser@0.*: resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.113.0.tgz#5b5913c54833918d0c3136ba69f6cf0cdd85fc20" integrity sha512-+hRyEB1sVLNMTMniDdM1JIS8BJ3HUL7IFIJaxX+t/JUy0GNYdI0Tg1QLx8DJmOF8HeoCrUDcREpnDAc/pPta3w== +flow-parser@^0.155.0: + version "0.155.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.155.0.tgz#bfff4287fa4e8b046937e881851736fbef473b3a" + integrity sha512-DegBwxIjw8ZmgLO9Qae/uSDWlioenV7mbfMoPem97y1OZVxlTAXNVHt5JthwrGLwk4kpmHQ3VRcp1Jxj84NcWw== + +flow-remove-types@^2.155.0: + version "2.155.0" + resolved "https://registry.yarnpkg.com/flow-remove-types/-/flow-remove-types-2.155.0.tgz#a419b5da5473a1794de3d0e9a30ba5d308c902d7" + integrity sha512-7tyM6PsuGe9/EUMay8wPK/pUiYoQvCm2cJc4Ng2l1cOjiE/sRrJz/KZgED47r4tynCe2Fe7OCIygYJPXGbtDBg== + dependencies: + flow-parser "^0.155.0" + pirates "^3.0.2" + vlq "^0.2.1" + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -10190,10 +10216,10 @@ he@1.2.0, he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -hermes-engine@0.0.0, hermes-engine@~0.5.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/hermes-engine/-/hermes-engine-0.0.0.tgz#6a65954646b5e32c87aa998dee16152c0c904cd6" - integrity sha512-q5DP4aUe6LnfMaLsxFP1cCY5qA0Ca5Qm2JQ/OgKi3sTfPpXth79AQ7vViXh/RRML53EpokDewMLJmI31RioBAA== +hermes-engine@~0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/hermes-engine/-/hermes-engine-0.5.1.tgz#601115e4b1e0a17d9aa91243b96277de4e926e09" + integrity sha512-hLwqh8dejHayjlpvZY40e1aDCDvyP98cWx/L5DhAjSJLH8g4z9Tp08D7y4+3vErDsncPOdf1bxm+zUWpx0/Fxg== hermes-profile-transformer@^0.0.6: version "0.0.6" @@ -10586,6 +10612,13 @@ image-size@^0.6.0: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.6.3.tgz#e7e5c65bb534bd7cdcedd6cb5166272a85f75fb2" integrity sha512-47xSUiQioGaB96nqtp5/q55m0aBQSQdyIloMOc/x+QVTDZLNmXE892IIDrJ0hM1A5vcNUDD5tDffkSP5lCaIIA== +image-size@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.0.tgz#58b31fe4743b1cec0a0ac26f5c914d3c5b2f0750" + integrity sha512-JLJ6OwBfO1KcA+TvJT+v8gbE6iWbj24LyDNFgFEN0lzegn6cC6a/p3NIDaepMsJjQjlUWqIC7wJv8lBFxPNjcw== + dependencies: + queue "6.0.2" + immediate@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.2.3.tgz#d140fa8f614659bd6541233097ddaac25cdd991c" @@ -15110,6 +15143,13 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= +pirates@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9" + integrity sha512-c5CgUJq6H2k6MJz72Ak1F5sN9n9wlSlJyEnwvpm9/y3WB4E3pHBDT2c6PEiS1vyJvq2bUxUAIu0EGf8Cx4Ic7Q== + dependencies: + node-modules-regexp "^1.0.0" + pirates@^4.0.0, pirates@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" @@ -15898,6 +15938,13 @@ querystringify@^2.1.1: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== +queue@6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" + integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== + dependencies: + inherits "~2.0.3" + quick-lru@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" @@ -19220,6 +19267,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vlq@^0.2.1: + version "0.2.3" + resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" + integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== + vlq@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468"