diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 783ce384e7..a3545048c0 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -39,7 +39,11 @@ "@expo/metro-config": "0.1.80", "@react-native-community/cli-server-api": "^5.0.1", "body-parser": "1.19.0", + "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", @@ -49,6 +53,7 @@ "devDependencies": { "@expo/babel-preset-cli": "0.2.21", "@types/connect": "^3.4.33", + "@types/image-size": "^0.8.0", "connect": "^3.7.0", "node-fetch": "^2.6.0", "rimraf": "^3.0.2" diff --git a/packages/dev-server/src/esbuild/EsbuildConfig.ts b/packages/dev-server/src/esbuild/EsbuildConfig.ts new file mode 100644 index 0000000000..0035f35122 --- /dev/null +++ b/packages/dev-server/src/esbuild/EsbuildConfig.ts @@ -0,0 +1,166 @@ +import Log from '@expo/bunyan'; +import { getBareExtensions } from '@expo/config/paths'; +import { BuildOptions } from 'esbuild'; +import fs from 'fs'; +import path from 'path'; +import resolveFrom from 'resolve-from'; +import { resolveEntryPoint } from 'xdl/build/tools/resolveEntryPoint'; + +import { + getAssetExtensions, + getBundleEnvironment, + getMainFields, + isDebug, +} from './bundlerSettings'; +import aliasPlugin from './plugins/aliasPlugin'; +import loggingPlugin from './plugins/loggingPlugin'; +import patchPlugin from './plugins/patchPlugin'; +import reactNativeAssetsPlugin from './plugins/reactNativeAssetsPlugin'; +import stripFlowTypesPlugin from './plugins/stripFlowTypesPlugin'; + +function setAssetLoaders(assetExts: string[]) { + return assetExts.reduce>( + (loaders, ext) => ({ ...loaders, ['.' + ext]: 'file' }), + {} + ); +} + +export function loadConfig( + projectRoot: string, + { + logger, + platform, + isDev, + cleanCache, + config, + }: { + logger: Log; + platform: 'ios' | 'android' | 'web'; + isDev: boolean; + cleanCache?: boolean; + config?: Partial; + } +) { + const distFolder = path.resolve(projectRoot, `dist`); + const outputPath = path.resolve(`dist/index.${platform}.js`); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + const assetExtensions = getAssetExtensions(); + const buildOptions: BuildOptions = { + ...(config || {}), + entryPoints: [resolveEntryPoint(projectRoot, platform)], + outfile: outputPath, + assetNames: 'assets/[name]', + publicPath: '/', + minify: !isDev, + write: true, + bundle: true, + // For now, remove all comments so the client can download the bundle faster. + legalComments: isDev ? 'none' : 'eof', + // This helps the source maps be located in the correct place. + // Without it, tapping the stack traces will open to the wrong place. + sourceRoot: distFolder, + sourcemap: true, + incremental: true, + logLevel: isDebug ? 'verbose' : 'debug', + mainFields: getMainFields(platform), + define: getBundleEnvironment({ isDev }), + loader: { '.js': 'jsx', ...setAssetLoaders(assetExtensions) }, + }; + if (!buildOptions.plugins) { + buildOptions.plugins = []; + } + + if (platform !== 'web') { + buildOptions.target = 'esnext'; + buildOptions.format = 'iife'; + + buildOptions.resolveExtensions = getBareExtensions([platform, 'native'], { + isModern: false, + isTS: true, + isReact: true, + }).map(value => '.' + value); + + buildOptions.plugins.push( + stripFlowTypesPlugin( + projectRoot, + [ + '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', + ], + cleanCache + ), + loggingPlugin(logger), + reactNativeAssetsPlugin(projectRoot, platform, assetExtensions), + patchPlugin(), + aliasPlugin({ + // TODO: Make this interface more like { 'react-native-vector-icons': '@expo/vector-icons' } + // TODO: Make optional + 'react-native-vector-icons/': resolveFrom(projectRoot, '@expo/vector-icons'), + }) + ); + + buildOptions.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 { + buildOptions.target = 'es2020'; + buildOptions.format = 'esm'; + + buildOptions.resolveExtensions = getBareExtensions([platform], { + isModern: false, + isTS: true, + isReact: true, + }).map(value => '.' + value); + + buildOptions.plugins.push( + loggingPlugin(logger), + aliasPlugin({ + 'react-native-vector-icons/': resolveFrom(projectRoot, '@expo/vector-icons'), + }) + ); + + buildOptions.inject = [resolveFrom(projectRoot, 'setimmediate/setImmediate.js')]; + } + + // Append to the top of the bundle + if (!buildOptions.banner) { + buildOptions.banner = { js: '' }; + } + + if (platform === 'web' && isDev) { + 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; +} + +function resolveRelative(projectRoot: string, moduleId: string): string { + const _path = path.relative(projectRoot, resolveFrom(projectRoot, moduleId)); + if (_path.startsWith('.')) return _path; + return './' + _path; +} diff --git a/packages/dev-server/src/esbuild/EsbuildDevServer.ts b/packages/dev-server/src/esbuild/EsbuildDevServer.ts new file mode 100644 index 0000000000..9e0d53f3f1 --- /dev/null +++ b/packages/dev-server/src/esbuild/EsbuildDevServer.ts @@ -0,0 +1,238 @@ +import { ExpoConfig } from '@expo/config'; +import { INTERNAL_CALLSITES_REGEX } from '@expo/metro-config'; +import { + createDevServerMiddleware, + securityHeadersMiddleware, +} from '@react-native-community/cli-server-api'; +import bodyParser from 'body-parser'; +import type { Server as ConnectServer } from 'connect'; +import Debug from 'debug'; +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 { loadConfig } from './EsbuildConfig'; + +const debug = Debug('dev-server:esbuild'); + +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`); + + // TODO: esbuild's dev server doesn't support arbitrary bundle paths. + proxyPath = `/index.${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')) { + proxyPath = url; + } else if (pathname.startsWith('/assets/')) { + proxyPath = url; + } else if (pathname === '/symbolicate') { + return next(); + } else { + return next(); + } + + const proxyUrl = `http://0.0.0.0:${port}${proxyPath}`; + + debug('proxy url: ', proxyUrl); + + 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 & { isDev?: boolean } +): 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 = loadConfig(projectRoot, { + logger: options.logger, + platform, + isDev: !!options.isDev, + cleanCache: options.resetCache, + // config: {} + }); + + let reload: Function; + const liveReload = !!options.isDev; + await esbuild.build({ + ...buildOptions, + watch: { + onRebuild(error, result) { + if (error) { + options.logger.error({ tag: 'dev-server' }, `Failed to watch build changes: ${error}`); + throw error; + } + + options.logger.info( + { tag: 'dev-server' }, + `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(() => { + 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({ + logger: options.logger, + customizeFrame, + dist: 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('unimp'); + // const { server } = await startDevServerAsync(projectRoot, { ...options, isDev: false }); + + // async function buildAsync(bundle: BundleOptions) {} + + // try { + // return await Promise.all( + // bundles.map(async (bundle: BundleOptions) => { + // const bundleOutput = await buildAsync(bundle); + // return maybeAddHermesBundleAsync(bundle, bundleOutput); + // }) + // ); + // } finally { + // server.close(); + // } +} + +// 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/bundlerSettings.ts b/packages/dev-server/src/esbuild/bundlerSettings.ts new file mode 100644 index 0000000000..651058121e --- /dev/null +++ b/packages/dev-server/src/esbuild/bundlerSettings.ts @@ -0,0 +1,53 @@ +import getenv from 'getenv'; + +export const isDebug = getenv.boolish('EXPO_DEBUG', false); + +export function getBundleEnvironment({ isDev }: { isDev: boolean }) { + return { + 'process.env.JEST_WORKER_ID': String(false), + 'process.env.NODE_DEV': isDev ? '"development"' : '"production"', + __DEV__: String(isDev), + global: 'window', + }; +} + +export function getMainFields(platform: string) { + const fields = ['browser', 'module', 'main']; + if (platform !== 'web') { + fields.unshift('react-native'); + } + return fields; +} + +export function getAssetExtensions() { + return [ + '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', + ]; +} 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/loggingPlugin.ts b/packages/dev-server/src/esbuild/plugins/loggingPlugin.ts new file mode 100644 index 0000000000..8f461438c0 --- /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: 'dev-server' }, + JSON.stringify({ + type: 'bundle_build_started', + id: Date.now(), + }) + ); + }); + + build.onEnd((result: any) => { + if (result.errors.length) { + logger.info( + { tag: 'dev-server' }, + JSON.stringify({ + type: 'bundle_build_failed', + id: Date.now(), + }) + ); + } else { + logger.info( + { tag: 'dev-server' }, + 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..a62515817d --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/patchPlugin.ts @@ -0,0 +1,31 @@ +import { Plugin } from 'esbuild'; +import fs from 'fs'; +import path from 'path'; + +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 function patchPlugin() { + 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')` + ); + } + ); + }, + }; + return patchPlugin; +} diff --git a/packages/dev-server/src/esbuild/plugins/reactNativeAssetsPlugin.ts b/packages/dev-server/src/esbuild/plugins/reactNativeAssetsPlugin.ts new file mode 100644 index 0000000000..5b19ebaa71 --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/reactNativeAssetsPlugin.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 reactNativeAssetsPlugin(projectRoot: string, platform: string, assetExts: string[]) { + const filter = new RegExp(assetExts.map(ext => `[.]${ext}$`).join('|')); + + const plugin: Plugin = { + name: 'reactNativeAssets', + 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 reactNativeAssetsPlugin; diff --git a/packages/dev-server/src/esbuild/plugins/stripFlowTypesPlugin.ts b/packages/dev-server/src/esbuild/plugins/stripFlowTypesPlugin.ts new file mode 100644 index 0000000000..dd6f23d21f --- /dev/null +++ b/packages/dev-server/src/esbuild/plugins/stripFlowTypesPlugin.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 stripFlowTypesPlugin(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: 'stripFlowTypes', + 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 stripFlowTypesPlugin; diff --git a/packages/dev-server/src/middleware/Symbolicator.ts b/packages/dev-server/src/middleware/Symbolicator.ts new file mode 100644 index 0000000000..388bfc9f00 --- /dev/null +++ b/packages/dev-server/src/middleware/Symbolicator.ts @@ -0,0 +1,226 @@ +import { codeFrameColumns } from '@babel/code-frame'; +import type Log from '@expo/bunyan'; +import fs from 'fs'; +import { SourceMapConsumer } from 'source-map'; +import { URL } from 'url'; + +/** + * 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}. + */ +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 = {}; + + constructor( + private props: { + logger: Log; + customizeFrame: (frame: StackFrame) => StackFrame; + readFileFromBundler: (fileUrl: string) => Promise; + 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')) { + frames.push(frame as InputStackFrame); + } + } + + try { + const processedFrames: StackFrame[] = []; + for (const frame of frames) { + if (!this.sourceMapConsumerCache[frame.file]) { + const rawSourceMap = await this.props.readSourceMapFromBundler(frame.file); + const sourceMapConsumer = await new SourceMapConsumer(rawSourceMap as any); + this.sourceMapConsumerCache[frame.file] = sourceMapConsumer; + } + const processedFrame = this.props.customizeFrame(this.processFrame(frame)); + processedFrames.push(processedFrame); + } + + return { + stack: processedFrames, + codeFrame: (await this.getCodeFrame(processedFrames)) ?? null, + }; + } finally { + for (const key in this.sourceMapConsumerCache) { + 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: 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.props.readFileFromBundler('/index.bundle'); + } else { + filename = frame.file; + source = await fs.promises.readFile(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.props.logger.error( + { tag: 'dev-server' }, + '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..fd564e14a3 --- /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({ + logger, + customizeFrame, + dist, +}: { + logger: Log; + customizeFrame: (frame: StackFrame) => StackFrame; + dist: string; +}) { + const symbolicator = new Symbolicator({ + logger, + customizeFrame, + async readFileFromBundler(fileUrl: string) { + throw new Error('unimplemented -- TODO'); + }, + async readSourceMapFromBundler(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(dist, 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: 'dev-server', + 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; + } + + res.end(JSON.stringify(await symbolicator.process(stack))); + } catch (error) { + logger.error({ tag: 'dev-server' }, `Failed to symbolicate: ${error} ${error.stack}`); + res.statusCode = 500; + res.end(JSON.stringify({ error: error.message })); + } + }; +} diff --git a/packages/xdl/src/project/createBundlesAsync.ts b/packages/xdl/src/project/createBundlesAsync.ts index 804ba9a9ef..f28cdd1061 100644 --- a/packages/xdl/src/project/createBundlesAsync.ts +++ b/packages/xdl/src/project/createBundlesAsync.ts @@ -87,6 +87,30 @@ export async function createBundlesAsync( const config = getConfig(projectRoot, { skipSDKVersionRequirement: true }); const isLegacy = isLegacyImportsEnabled(config.exp); const platforms: Platform[] = ['android', 'ios']; + if (process.env.EXPO_BUNDLER === 'esbuild') { + const { bundleAsync } = await import('@expo/dev-server/build/esbuild/EsbuildDevServer'); + const [android, ios] = await bundleAsync( + projectRoot, + config.exp, + { + // If not legacy, ignore the target option to prevent warnings from being thrown. + target: !isLegacy ? undefined : publishOptions.target, + resetCache: publishOptions.resetCache, + logger: ProjectUtils.getLogger(projectRoot), + quiet: publishOptions.quiet, + }, + platforms.map((platform: Platform) => ({ + platform, + entryPoint: resolveEntryPoint(projectRoot, platform), + dev: bundleOptions.dev, + })) + ); + + return { + android, + ios, + }; + } const [android, ios] = await bundleAsync( projectRoot, config.exp, diff --git a/packages/xdl/src/start/startDevServerAsync.ts b/packages/xdl/src/start/startDevServerAsync.ts index 0f8d910557..01a9cbdfd2 100644 --- a/packages/xdl/src/start/startDevServerAsync.ts +++ b/packages/xdl/src/start/startDevServerAsync.ts @@ -1,6 +1,6 @@ -import { ProjectTarget } from '@expo/config'; -import { MessageSocket, MetroDevServerOptions, runMetroDevServerAsync } from '@expo/dev-server'; -import http from 'http'; +import type { ProjectTarget } from '@expo/config'; +import type { MessageSocket, MetroDevServerOptions } from '@expo/dev-server'; +import type http from 'http'; import { assertValidProjectRoot, @@ -59,7 +59,24 @@ 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') { + // lazy load esbuild + const { startDevServerAsync } = await import('@expo/dev-server/build/esbuild/EsbuildDevServer'); + serverInfo = await startDevServerAsync(projectRoot, { ...options, isDev: true }); + } else { + // lazy load metro + const { runMetroDevServerAsync } = await import('@expo/dev-server'); + 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 e1c0c30262..62083ea25e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3733,6 +3733,13 @@ dependencies: "@types/node" "*" +"@types/image-size@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@types/image-size/-/image-size-0.8.0.tgz#736342ea51e076fa5fffd6ff219e9b412d4093f1" + integrity sha512-hMlhu25ji75dXQk2uZkN3pTJ+lWrgKr8M1fTpyyFvuu+SJZBdGa5gDm4BVNobWXHZbOU11mBj0vciYp7qOfAFg== + dependencies: + image-size "*" + "@types/invariant@^2.2.29": version "2.2.31" resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.31.tgz#4444c03004f215289dbca3856538434317dd28b2" @@ -3871,7 +3878,12 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>= 8", "@types/node@>=6", "@types/node@^12": +"@types/node@*", "@types/node@>= 8", "@types/node@>=6", "@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@^12": version "12.19.15" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.15.tgz#0de7e978fb43db62da369db18ea088a63673c182" integrity sha512-lowukE3GUI+VSYSu6VcBXl14d61Rp5hA1D+61r16qnwC0lYNSqdxcvRh0pswejorHfS+HgwBasM8jLXz0/aOsw== @@ -7743,6 +7755,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" @@ -8322,6 +8341,11 @@ es6-weak-map@^2.0.2: es6-iterator "^2.0.3" es6-symbol "^3.1.1" +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" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -9387,10 +9411,19 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== -flow-parser@0.*: - version "0.113.0" - resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.113.0.tgz#5b5913c54833918d0c3136ba69f6cf0cdd85fc20" - integrity sha512-+hRyEB1sVLNMTMniDdM1JIS8BJ3HUL7IFIJaxX+t/JUy0GNYdI0Tg1QLx8DJmOF8HeoCrUDcREpnDAc/pPta3w== +flow-parser@0.*, 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" @@ -10179,10 +10212,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" @@ -10570,6 +10603,13 @@ ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== +image-size@*, 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" + image-size@^0.6.0: version "0.6.3" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.6.3.tgz#e7e5c65bb534bd7cdcedd6cb5166272a85f75fb2" @@ -15092,6 +15132,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" @@ -15880,6 +15927,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" @@ -19180,6 +19234,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"