From 741e231717ebdf06de5d38e08af68f180d4267a9 Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Tue, 13 Jul 2021 15:06:17 -0600 Subject: [PATCH] Added dev server support added exbuild stuff refactor Update EsbuildConfig.ts Update utils.ts Added logging middleware Added symbolicator remove legacy added license Clean up extras clean up more split out logging plugin Unify platform extensions Refactor refactor --- packages/dev-server/package.json | 5 + packages/dev-server/src/MetroDevServer.ts | 75 +---- .../dev-server/src/esbuild/EsbuildConfig.ts | 161 +++++++++++ .../src/esbuild/EsbuildDevServer.ts | 255 +++++++++++++++++ packages/dev-server/src/esbuild/LICENSE | 23 ++ .../src/esbuild/assets/AssetPaths.ts | 51 ++++ .../dev-server/src/esbuild/assets/Assets.ts | 258 ++++++++++++++++++ .../esbuild/assets/parsePlatformFilePath.ts | 33 +++ .../esbuild/assets/resolveHashAssetFiles.ts | 11 + .../src/esbuild/plugins/aliasPlugin.ts | 18 ++ .../src/esbuild/plugins/assetsPlugin.ts | 63 +++++ .../src/esbuild/plugins/loggingPlugin.ts | 40 +++ .../src/esbuild/plugins/patchPlugin.ts | 30 ++ .../src/esbuild/plugins/removeFlowPlugin.ts | 73 +++++ packages/dev-server/src/esbuild/utils.ts | 56 ++++ .../dev-server/src/middleware/Symbolicator.ts | 235 ++++++++++++++++ .../middleware/createSymbolicateMiddleware.ts | 97 +++++++ .../remoteDevtoolsCorsMiddleware.ts | 32 +++ ...remoteDevtoolsSecurityHeadersMiddleware.ts | 30 ++ .../src/middleware/replaceMiddlewareWith.ts | 12 + packages/expo-cli/package.json | 1 - .../expo-cli/src/commands/esbuild/service.ts | 74 ----- packages/expo-cli/src/commands/index.ts | 1 - packages/expo-cli/src/commands/ios.ts | 29 -- packages/xdl/src/start/startDevServerAsync.ts | 16 +- yarn.lock | 68 ++++- 26 files changed, 1561 insertions(+), 186 deletions(-) create mode 100644 packages/dev-server/src/esbuild/EsbuildConfig.ts create mode 100644 packages/dev-server/src/esbuild/EsbuildDevServer.ts create mode 100644 packages/dev-server/src/esbuild/LICENSE create mode 100644 packages/dev-server/src/esbuild/assets/AssetPaths.ts create mode 100644 packages/dev-server/src/esbuild/assets/Assets.ts create mode 100644 packages/dev-server/src/esbuild/assets/parsePlatformFilePath.ts create mode 100644 packages/dev-server/src/esbuild/assets/resolveHashAssetFiles.ts create mode 100644 packages/dev-server/src/esbuild/plugins/aliasPlugin.ts create mode 100644 packages/dev-server/src/esbuild/plugins/assetsPlugin.ts create mode 100644 packages/dev-server/src/esbuild/plugins/loggingPlugin.ts create mode 100644 packages/dev-server/src/esbuild/plugins/patchPlugin.ts create mode 100644 packages/dev-server/src/esbuild/plugins/removeFlowPlugin.ts create mode 100644 packages/dev-server/src/esbuild/utils.ts create mode 100644 packages/dev-server/src/middleware/Symbolicator.ts create mode 100644 packages/dev-server/src/middleware/createSymbolicateMiddleware.ts create mode 100644 packages/dev-server/src/middleware/remoteDevtoolsCorsMiddleware.ts create mode 100644 packages/dev-server/src/middleware/remoteDevtoolsSecurityHeadersMiddleware.ts create mode 100644 packages/dev-server/src/middleware/replaceMiddlewareWith.ts delete mode 100644 packages/expo-cli/src/commands/esbuild/service.ts delete mode 100644 packages/expo-cli/src/commands/ios.ts 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"