1747 lines
54 KiB
Plaintext
1747 lines
54 KiB
Plaintext
/**
|
|
* @flow
|
|
*/
|
|
|
|
import bodyParser from 'body-parser';
|
|
import child_process from 'child_process';
|
|
import delayAsync from 'delay-async';
|
|
import decache from 'decache';
|
|
import express from 'express';
|
|
import freeportAsync from 'freeport-async';
|
|
import fs from 'fs-extra';
|
|
import joi from 'joi';
|
|
import promisify from 'util.promisify';
|
|
import _ from 'lodash';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import minimatch from 'minimatch';
|
|
import ngrok from '@expo/ngrok';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import Request from 'request-promise-native';
|
|
import spawnAsync from '@expo/spawn-async';
|
|
import split from 'split';
|
|
import treekill from 'tree-kill';
|
|
import md5hex from 'md5hex';
|
|
|
|
import * as Analytics from './Analytics';
|
|
import * as Android from './Android';
|
|
import Api from './Api';
|
|
import Config from './Config';
|
|
import * as Doctor from './project/Doctor';
|
|
import ErrorCode from './ErrorCode';
|
|
import logger from './Logger';
|
|
import * as ExponentTools from './detach/ExponentTools';
|
|
import * as Exp from './Exp';
|
|
import * as ExpSchema from './project/ExpSchema';
|
|
import FormData from './tools/FormData';
|
|
import { isNode } from './tools/EnvironmentHelper';
|
|
import * as ProjectSettings from './ProjectSettings';
|
|
import * as ProjectUtils from './project/ProjectUtils';
|
|
import * as UrlUtils from './UrlUtils';
|
|
import UserManager from './User';
|
|
import UserSettings from './UserSettings';
|
|
import * as Versions from './Versions';
|
|
import * as Watchman from './Watchman';
|
|
import XDLError from './XDLError';
|
|
|
|
import type { User as ExpUser } from './User'; //eslint-disable-line
|
|
|
|
const MINIMUM_BUNDLE_SIZE = 500;
|
|
const TUNNEL_TIMEOUT = 10 * 1000;
|
|
|
|
const joiValidateAsync = promisify(joi.validate);
|
|
const treekillAsync = promisify(treekill);
|
|
const ngrokConnectAsync = promisify(ngrok.connect);
|
|
const ngrokKillAsync = promisify(ngrok.kill);
|
|
|
|
const request = Request.defaults({
|
|
resolveWithFullResponse: true,
|
|
});
|
|
|
|
type CachedSignedManifest = {
|
|
manifestString: ?string,
|
|
signedManifest: ?string,
|
|
};
|
|
|
|
let _cachedSignedManifest: CachedSignedManifest = {
|
|
manifestString: null,
|
|
signedManifest: null,
|
|
};
|
|
|
|
export type ProjectStatus = 'running' | 'ill' | 'exited';
|
|
|
|
export async function currentStatus(projectDir: string): Promise<ProjectStatus> {
|
|
const manifestUrl = await UrlUtils.constructManifestUrlAsync(projectDir, {
|
|
urlType: 'http',
|
|
});
|
|
const packagerUrl = await UrlUtils.constructBundleUrlAsync(projectDir, {
|
|
urlType: 'http',
|
|
});
|
|
|
|
let packagerRunning = false;
|
|
try {
|
|
const res = await request(`${packagerUrl}/status`);
|
|
|
|
if (res.statusCode < 400 && res.body && res.body.includes('packager-status:running')) {
|
|
packagerRunning = true;
|
|
}
|
|
} catch (e) {}
|
|
|
|
let manifestServerRunning = false;
|
|
try {
|
|
const res = await request(manifestUrl);
|
|
if (res.statusCode < 400) {
|
|
manifestServerRunning = true;
|
|
}
|
|
} catch (e) {}
|
|
|
|
if (packagerRunning && manifestServerRunning) {
|
|
return 'running';
|
|
} else if (packagerRunning || manifestServerRunning) {
|
|
return 'ill';
|
|
} else {
|
|
return 'exited';
|
|
}
|
|
}
|
|
|
|
async function _areTunnelsHealthy(projectRoot: string) {
|
|
const packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
if (!packagerInfo.packagerNgrokUrl || !packagerInfo.expoServerNgrokUrl) {
|
|
return false;
|
|
}
|
|
const status = await currentStatus(projectRoot);
|
|
return status === 'running';
|
|
}
|
|
|
|
export async function getManifestUrlWithFallbackAsync(projectRoot: string) {
|
|
const projectSettings = await ProjectSettings.readAsync(projectRoot);
|
|
if (
|
|
projectSettings.hostType === 'tunnel' &&
|
|
!Config.offline &&
|
|
!await _areTunnelsHealthy(projectRoot)
|
|
) {
|
|
// Fall back to LAN URL if tunnels are not available.
|
|
return {
|
|
url: await UrlUtils.constructManifestUrlAsync(projectRoot, {
|
|
hostType: 'lan',
|
|
}),
|
|
isUrlFallback: true,
|
|
};
|
|
} else {
|
|
return {
|
|
url: await UrlUtils.constructManifestUrlAsync(projectRoot),
|
|
isUrlFallback: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function _assertValidProjectRoot(projectRoot) {
|
|
if (!projectRoot) {
|
|
throw new XDLError(ErrorCode.NO_PROJECT_ROOT, 'No project root specified');
|
|
}
|
|
}
|
|
|
|
async function _getFreePortAsync(rangeStart) {
|
|
let port = await freeportAsync(rangeStart);
|
|
if (!port) {
|
|
throw new XDLError(ErrorCode.NO_PORT_FOUND, 'No available port found');
|
|
}
|
|
|
|
return port;
|
|
}
|
|
|
|
async function _getForPlatformAsync(projectRoot, url, platform, { errorCode, minLength }) {
|
|
url = UrlUtils.getPlatformSpecificBundleUrl(url, platform);
|
|
|
|
let fullUrl = `${url}&platform=${platform}`;
|
|
let response = await request.get({
|
|
url: fullUrl,
|
|
headers: {
|
|
'Exponent-Platform': platform,
|
|
},
|
|
});
|
|
|
|
if (response.statusCode !== 200) {
|
|
if (response.body) {
|
|
let body;
|
|
try {
|
|
body = JSON.parse(response.body);
|
|
} catch (e) {
|
|
ProjectUtils.logError(projectRoot, 'expo', response.body);
|
|
}
|
|
|
|
if (body !== undefined) {
|
|
if (body.message) {
|
|
ProjectUtils.logError(projectRoot, 'expo', body.message);
|
|
} else {
|
|
ProjectUtils.logError(projectRoot, 'expo', response.body);
|
|
}
|
|
}
|
|
}
|
|
throw new XDLError(
|
|
errorCode,
|
|
`Packager URL ${fullUrl} returned unexpected code ${response.statusCode}. Please open your project in the Expo app and see if there are any errors. Also scroll up and make sure there were no errors or warnings when opening your project.`
|
|
);
|
|
}
|
|
|
|
if (!response.body || (minLength && response.body.length < minLength)) {
|
|
throw new XDLError(errorCode, `Body is: ${response.body}`);
|
|
}
|
|
|
|
return response.body;
|
|
}
|
|
|
|
async function _resolveManifestAssets(projectRoot, manifest, resolver, strict = false) {
|
|
try {
|
|
// Asset fields that the user has set
|
|
const assetSchemas = (await ExpSchema.getAssetSchemasAsync(
|
|
manifest.sdkVersion
|
|
)).filter(({ fieldPath }) => _.get(manifest, fieldPath));
|
|
|
|
// Get the URLs
|
|
const urls = await Promise.all(
|
|
assetSchemas.map(async ({ fieldPath }) => {
|
|
const pathOrURL = _.get(manifest, fieldPath);
|
|
if (pathOrURL.match(/^https?:\/\/(.*)$/)) {
|
|
// It's a remote URL
|
|
return pathOrURL;
|
|
} else if (fs.existsSync(path.resolve(projectRoot, pathOrURL))) {
|
|
return await resolver(pathOrURL);
|
|
} else {
|
|
const err = new Error('Could not resolve local asset.');
|
|
// $FlowFixMe
|
|
err.localAssetPath = pathOrURL;
|
|
// $FlowFixMe
|
|
err.manifestField = fieldPath;
|
|
throw err;
|
|
}
|
|
})
|
|
);
|
|
|
|
// Set the corresponding URL fields
|
|
assetSchemas.forEach(({ fieldPath }, index) => _.set(manifest, fieldPath + 'Url', urls[index]));
|
|
} catch (e) {
|
|
let logMethod = ProjectUtils.logWarning;
|
|
if (strict) {
|
|
logMethod = ProjectUtils.logError;
|
|
}
|
|
if (e.localAssetPath) {
|
|
logMethod(
|
|
projectRoot,
|
|
'expo',
|
|
`Unable to resolve asset "${e.localAssetPath}" from "${e.manifestField}" in your app/exp.json.`
|
|
);
|
|
} else {
|
|
logMethod(
|
|
projectRoot,
|
|
'expo',
|
|
`Warning: Unable to resolve manifest assets. Icons might not work. ${e.message}.`
|
|
);
|
|
}
|
|
|
|
if (strict) {
|
|
throw new Error('Resolving assets failed.');
|
|
}
|
|
}
|
|
}
|
|
|
|
function _requireFromProject(modulePath, projectRoot) {
|
|
try {
|
|
if (modulePath.indexOf('.') === 0) {
|
|
let fullPath = path.resolve(projectRoot, modulePath);
|
|
|
|
// Clear the require cache for this module so get a fresh version of it
|
|
// without requiring the user to restart XDE
|
|
decache(fullPath);
|
|
|
|
// $FlowIssue: doesn't work with dynamic requires
|
|
return require(fullPath);
|
|
} else {
|
|
let fullPath = path.resolve(projectRoot, 'node_modules', modulePath);
|
|
|
|
// Clear the require cache for this module so get a fresh version of it
|
|
// without requiring the user to restart XDE
|
|
decache(fullPath);
|
|
|
|
// $FlowIssue: doesn't work with dynamic requires
|
|
return require(fullPath);
|
|
}
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getSlugAsync(projectRoot: string, options: Object = {}) {
|
|
// Verify that exp/app.json exist
|
|
let { exp, pkg } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
if (!exp || !pkg) {
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
throw new XDLError(
|
|
ErrorCode.NO_PACKAGE_JSON,
|
|
`Couldn't read ${configName} file in project at ${projectRoot}`
|
|
);
|
|
}
|
|
|
|
if (!exp.slug && pkg.name) {
|
|
exp.slug = pkg.name;
|
|
} else if (!exp.slug) {
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
throw new XDLError(
|
|
ErrorCode.INVALID_MANIFEST,
|
|
`${configName} in ${projectRoot} must contain the slug field`
|
|
);
|
|
}
|
|
return exp.slug;
|
|
}
|
|
|
|
export async function getLatestReleaseAsync(
|
|
projectRoot: string,
|
|
options: {
|
|
releaseChannel: string,
|
|
platform: string,
|
|
}
|
|
) {
|
|
// TODO(ville): move request from multipart/form-data to JSON once supported by the endpoint.
|
|
let formData = new FormData();
|
|
formData.append('queryType', 'history');
|
|
formData.append('slug', await getSlugAsync(projectRoot));
|
|
formData.append('version', '2');
|
|
formData.append('count', '1');
|
|
formData.append('releaseChannel', options.releaseChannel);
|
|
formData.append('platform', options.platform);
|
|
const { queryResult } = await Api.callMethodAsync('publishInfo', [], 'post', null, {
|
|
formData,
|
|
});
|
|
if (queryResult && queryResult.length > 0) {
|
|
return queryResult[0];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function publishAsync(
|
|
projectRoot: string,
|
|
options: Object = {}
|
|
): Promise<{ url: string, ids: string[], err: ?string }> {
|
|
const user = await UserManager.ensureLoggedInAsync();
|
|
await _validatePackagerReadyAsync(projectRoot);
|
|
Analytics.logEvent('Publish', { projectRoot });
|
|
|
|
const validationStatus = await Doctor.validateWithNetworkAsync(projectRoot);
|
|
if (validationStatus == Doctor.ERROR || validationStatus === Doctor.FATAL) {
|
|
throw new XDLError(
|
|
ErrorCode.PUBLISH_VALIDATION_ERROR,
|
|
"Couldn't publish because errors were found. (See logs above.) Please fix the errors and try again."
|
|
);
|
|
}
|
|
|
|
// Get project config
|
|
let exp = await _getPublishExpConfigAsync(projectRoot, options);
|
|
|
|
// TODO: refactor this out to a function, throw error if length doesn't match
|
|
let { hooks } = exp;
|
|
delete exp.hooks;
|
|
let validPostPublishHooks = [];
|
|
if (hooks && hooks.postPublish) {
|
|
hooks.postPublish.forEach(hook => {
|
|
let { file, config } = hook;
|
|
let fn = _requireFromProject(file, projectRoot);
|
|
if (fn === null) {
|
|
logger.global.error(`Unable to load postPublishHook: '${file}'`);
|
|
} else {
|
|
hook._fn = fn;
|
|
validPostPublishHooks.push(hook);
|
|
}
|
|
});
|
|
|
|
if (validPostPublishHooks.length !== hooks.postPublish.length) {
|
|
logger.global.error();
|
|
|
|
throw new XDLError(
|
|
ErrorCode.HOOK_INITIALIZATION_ERROR,
|
|
'Please fix your postPublish hook configuration.'
|
|
);
|
|
}
|
|
}
|
|
|
|
let { iosBundle, androidBundle } = await _buildPublishBundlesAsync(projectRoot);
|
|
|
|
await _fetchAndUploadAssetsAsync(projectRoot, exp);
|
|
|
|
let { iosSourceMap, androidSourceMap } = await _maybeBuildSourceMapsAsync(projectRoot, exp, {
|
|
force: validPostPublishHooks.length,
|
|
});
|
|
|
|
let response;
|
|
try {
|
|
response = await _uploadArtifactsAsync({
|
|
exp,
|
|
iosBundle,
|
|
androidBundle,
|
|
options,
|
|
});
|
|
} catch (e) {
|
|
if (e.serverError === 'SCHEMA_VALIDATION_ERROR') {
|
|
throw new Error(
|
|
`There was an error validating your project schema. Check for any warnings about the contents of your app/exp.json.`
|
|
);
|
|
}
|
|
throw new Error(`There was an error publishing your project. Please check for any warnings.`);
|
|
}
|
|
|
|
await _maybeWriteArtifactsToDiskAsync({
|
|
exp,
|
|
projectRoot,
|
|
iosBundle,
|
|
androidBundle,
|
|
iosSourceMap,
|
|
androidSourceMap,
|
|
});
|
|
|
|
if (
|
|
validPostPublishHooks.length ||
|
|
(exp.ios && exp.ios.publishManifestPath) ||
|
|
(exp.android && exp.android.publishManifestPath)
|
|
) {
|
|
let [androidManifest, iosManifest] = await Promise.all([
|
|
ExponentTools.getManifestAsync(response.url, {
|
|
'Exponent-SDK-Version': exp.sdkVersion,
|
|
'Exponent-Platform': 'android',
|
|
'Expo-Release-Channel': options.releaseChannel,
|
|
}),
|
|
ExponentTools.getManifestAsync(response.url, {
|
|
'Exponent-SDK-Version': exp.sdkVersion,
|
|
'Exponent-Platform': 'ios',
|
|
'Expo-Release-Channel': options.releaseChannel,
|
|
}),
|
|
]);
|
|
|
|
const hookOptions = {
|
|
url: response.url,
|
|
exp,
|
|
iosBundle,
|
|
iosSourceMap,
|
|
iosManifest,
|
|
androidBundle,
|
|
androidSourceMap,
|
|
androidManifest,
|
|
projectRoot,
|
|
log: msg => {
|
|
logger.global.info({ quiet: true }, msg);
|
|
},
|
|
};
|
|
|
|
for (let hook of validPostPublishHooks) {
|
|
logger.global.info(`Running postPublish hook: ${hook.file}`);
|
|
try {
|
|
let result = hook._fn({
|
|
config: hook.config,
|
|
...hookOptions,
|
|
});
|
|
|
|
// If it's a promise, wait for it to resolve
|
|
if (result && result.then) {
|
|
result = await result;
|
|
}
|
|
|
|
if (result) {
|
|
logger.global.info({ quiet: true }, result);
|
|
}
|
|
} catch (e) {
|
|
logger.global.warn(`Warning: postPublish hook '${hook.file}' failed: ${e.stack}`);
|
|
}
|
|
}
|
|
|
|
if (exp.ios && exp.ios.publishManifestPath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'ios.publishManifestPath',
|
|
exp.ios.publishManifestPath,
|
|
JSON.stringify(iosManifest)
|
|
);
|
|
}
|
|
|
|
if (exp.android && exp.android.publishManifestPath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'android.publishManifestPath',
|
|
exp.android.publishManifestPath,
|
|
JSON.stringify(androidManifest)
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO: move to postPublish hook
|
|
if (exp.isKernel) {
|
|
await _handleKernelPublishedAsync({
|
|
user,
|
|
exp,
|
|
projectRoot,
|
|
url: response.url,
|
|
});
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async function _uploadArtifactsAsync({ exp, iosBundle, androidBundle, options }) {
|
|
logger.global.info('Uploading JavaScript bundles');
|
|
let formData = new FormData();
|
|
|
|
formData.append('expJson', JSON.stringify(exp));
|
|
formData.append('iosBundle', _createBlob(iosBundle), 'iosBundle');
|
|
formData.append('androidBundle', _createBlob(androidBundle), 'androidBundle');
|
|
formData.append('options', JSON.stringify(options));
|
|
let response = await Api.callMethodAsync('publish', null, 'put', null, {
|
|
formData,
|
|
});
|
|
return response;
|
|
}
|
|
|
|
async function _validatePackagerReadyAsync(projectRoot) {
|
|
_assertValidProjectRoot(projectRoot);
|
|
|
|
// Ensure the packager is started
|
|
let packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
if (!packagerInfo.packagerPort) {
|
|
throw new XDLError(
|
|
ErrorCode.NO_PACKAGER_PORT,
|
|
`No packager found for project at ${projectRoot}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function _getPublishExpConfigAsync(projectRoot, options) {
|
|
let schema = joi.object().keys({
|
|
releaseChannel: joi.string(),
|
|
});
|
|
|
|
// Validate schema
|
|
try {
|
|
await joiValidateAsync(options, schema);
|
|
options.releaseChannel = options.releaseChannel || 'default'; // joi default not enforcing this :/
|
|
} catch (e) {
|
|
throw new XDLError(ErrorCode.INVALID_OPTIONS, e.toString());
|
|
}
|
|
|
|
// Verify that exp/app.json and package.json exist
|
|
let { exp, pkg } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
if (!exp || !pkg) {
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
throw new XDLError(
|
|
ErrorCode.NO_PACKAGE_JSON,
|
|
`Couldn't read ${configName} file in project at ${projectRoot}`
|
|
);
|
|
}
|
|
|
|
// Support version and name being specified in package.json for legacy
|
|
// support pre: exp.json
|
|
if (!exp.version && pkg.version) {
|
|
exp.version = pkg.version;
|
|
}
|
|
|
|
if (!exp.slug && pkg.name) {
|
|
exp.slug = pkg.name;
|
|
}
|
|
|
|
if (exp.android && exp.android.config) {
|
|
delete exp.android.config;
|
|
}
|
|
|
|
if (exp.ios && exp.ios.config) {
|
|
delete exp.ios.config;
|
|
}
|
|
|
|
// Only allow projects to be published with UNVERSIONED if a correct token is set in env
|
|
if (exp.sdkVersion === 'UNVERSIONED' && !process.env['EXPO_SKIP_MANIFEST_VALIDATION_TOKEN']) {
|
|
throw new XDLError(ErrorCode.INVALID_OPTIONS, 'Cannot publish with sdkVersion UNVERSIONED.');
|
|
}
|
|
|
|
return exp;
|
|
}
|
|
|
|
// Fetch iOS and Android bundles for publishing
|
|
async function _buildPublishBundlesAsync(projectRoot) {
|
|
let entryPoint = await Exp.determineEntryPointAsync(projectRoot);
|
|
let publishUrl = await UrlUtils.constructPublishUrlAsync(projectRoot, entryPoint);
|
|
|
|
logger.global.info('Building iOS bundle');
|
|
let iosBundle = await _getForPlatformAsync(projectRoot, publishUrl, 'ios', {
|
|
errorCode: ErrorCode.INVALID_BUNDLE,
|
|
minLength: MINIMUM_BUNDLE_SIZE,
|
|
});
|
|
|
|
logger.global.info('Building Android bundle');
|
|
let androidBundle = await _getForPlatformAsync(projectRoot, publishUrl, 'android', {
|
|
errorCode: ErrorCode.INVALID_BUNDLE,
|
|
minLength: MINIMUM_BUNDLE_SIZE,
|
|
});
|
|
|
|
return { iosBundle, androidBundle };
|
|
}
|
|
|
|
// note(brentvatne): currently we build source map anytime there is a
|
|
// postPublish hook -- we may have an option in the future to manually
|
|
// enable sourcemap building, but for now it's very fast, most apps in
|
|
// production should use sourcemaps for error reporting, and in the worst
|
|
// case, adding a few seconds to a postPublish hook isn't too annoying
|
|
async function _maybeBuildSourceMapsAsync(projectRoot, exp, options = {}) {
|
|
if (!options.force) {
|
|
return { iosSourceMap: null, androidSourceMap: null };
|
|
}
|
|
|
|
let entryPoint = await Exp.determineEntryPointAsync(projectRoot);
|
|
let sourceMapUrl = await UrlUtils.constructSourceMapUrlAsync(projectRoot, entryPoint);
|
|
|
|
logger.global.info('Building sourcemaps');
|
|
let iosSourceMap = await _getForPlatformAsync(projectRoot, sourceMapUrl, 'ios', {
|
|
errorCode: ErrorCode.INVALID_BUNDLE,
|
|
minLength: MINIMUM_BUNDLE_SIZE,
|
|
});
|
|
|
|
let androidSourceMap = await _getForPlatformAsync(projectRoot, sourceMapUrl, 'android', {
|
|
errorCode: ErrorCode.INVALID_BUNDLE,
|
|
minLength: MINIMUM_BUNDLE_SIZE,
|
|
});
|
|
|
|
return { iosSourceMap, androidSourceMap };
|
|
}
|
|
|
|
async function _fetchAndUploadAssetsAsync(projectRoot, exp) {
|
|
logger.global.info('Analyzing assets');
|
|
|
|
let entryPoint = await Exp.determineEntryPointAsync(projectRoot);
|
|
let assetsUrl = await UrlUtils.constructAssetsUrlAsync(projectRoot, entryPoint);
|
|
|
|
let iosAssetsJson = await _getForPlatformAsync(projectRoot, assetsUrl, 'ios', {
|
|
errorCode: ErrorCode.INVALID_ASSETS,
|
|
});
|
|
|
|
let androidAssetsJson = await _getForPlatformAsync(projectRoot, assetsUrl, 'android', {
|
|
errorCode: ErrorCode.INVALID_ASSETS,
|
|
});
|
|
|
|
// Resolve manifest assets to their S3 URL and add them to the list of assets to
|
|
// be uploaded
|
|
const manifestAssets = [];
|
|
await _resolveManifestAssets(
|
|
projectRoot,
|
|
exp,
|
|
async assetPath => {
|
|
const absolutePath = path.resolve(projectRoot, assetPath);
|
|
const contents = await fs.readFile(absolutePath);
|
|
const hash = md5hex(contents);
|
|
manifestAssets.push({ files: [absolutePath], fileHashes: [hash] });
|
|
return 'https://d1wp6m56sqw74a.cloudfront.net/~assets/' + hash;
|
|
},
|
|
true
|
|
);
|
|
|
|
logger.global.info('Uploading assets');
|
|
|
|
// Upload asset files
|
|
const iosAssets = JSON.parse(iosAssetsJson);
|
|
const androidAssets = JSON.parse(androidAssetsJson);
|
|
const assets = iosAssets.concat(androidAssets).concat(manifestAssets);
|
|
if (assets.length > 0 && assets[0].fileHashes) {
|
|
await uploadAssetsAsync(projectRoot, assets);
|
|
} else {
|
|
logger.global.info({ quiet: true }, 'No assets to upload, skipped.');
|
|
}
|
|
|
|
// Convert asset patterns to a list of asset strings that match them.
|
|
// Assets strings are formatted as `asset_<hash>.<type>` and represent
|
|
// the name that the file will have in the app bundle. The `asset_` prefix is
|
|
// needed because android doesn't support assets that start with numbers.
|
|
if (exp.assetBundlePatterns) {
|
|
const fullPatterns = exp.assetBundlePatterns.map(p => path.join(projectRoot, p));
|
|
// The assets returned by the RN packager has duplicates so make sure we
|
|
// only bundle each once.
|
|
const bundledAssets = new Set();
|
|
for (const asset of assets) {
|
|
const file = asset.files && asset.files[0];
|
|
if (asset.__packager_asset && file && fullPatterns.some(p => minimatch(file, p))) {
|
|
bundledAssets.add('asset_' + asset.hash + (asset.type ? '.' + asset.type : ''));
|
|
}
|
|
}
|
|
exp.bundledAssets = [...bundledAssets];
|
|
delete exp.assetBundlePatterns;
|
|
}
|
|
|
|
return exp;
|
|
}
|
|
|
|
async function _writeArtifactSafelyAsync(projectRoot, keyName, artifactPath, artifact) {
|
|
const pathToWrite = path.resolve(projectRoot, artifactPath);
|
|
if (!fs.existsSync(path.dirname(pathToWrite))) {
|
|
logger.global.warn(
|
|
`app.json specifies ${keyName}: ${pathToWrite}, but that directory does not exist.`
|
|
);
|
|
} else {
|
|
await fs.writeFile(pathToWrite, artifact);
|
|
}
|
|
}
|
|
|
|
async function _maybeWriteArtifactsToDiskAsync({
|
|
exp,
|
|
projectRoot,
|
|
iosBundle,
|
|
androidBundle,
|
|
iosSourceMap,
|
|
androidSourceMap,
|
|
}) {
|
|
if (exp.android && exp.android.publishBundlePath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'android.publishBundlePath',
|
|
exp.android.publishBundlePath,
|
|
androidBundle
|
|
);
|
|
}
|
|
|
|
if (exp.ios && exp.ios.publishBundlePath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'ios.publishBundlePath',
|
|
exp.ios.publishBundlePath,
|
|
iosBundle
|
|
);
|
|
}
|
|
|
|
if (exp.android && exp.android.publishSourceMapPath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'android.publishSourceMapPath',
|
|
exp.android.publishSourceMapPath,
|
|
androidSourceMap
|
|
);
|
|
}
|
|
|
|
if (exp.ios && exp.ios.publishSourceMapPath) {
|
|
await _writeArtifactSafelyAsync(
|
|
projectRoot,
|
|
'ios.publishSourceMapPath',
|
|
exp.ios.publishSourceMapPath,
|
|
iosSourceMap
|
|
);
|
|
}
|
|
}
|
|
|
|
async function _handleKernelPublishedAsync({ projectRoot, user, exp, url }) {
|
|
let kernelBundleUrl = `${Config.api.scheme}://${Config.api.host}`;
|
|
if (Config.api.port) {
|
|
kernelBundleUrl = `${kernelBundleUrl}:${Config.api.port}`;
|
|
}
|
|
kernelBundleUrl = `${kernelBundleUrl}/@${user.username}/${exp.slug}/bundle`;
|
|
|
|
if (exp.kernel.androidManifestPath) {
|
|
let manifest = await ExponentTools.getManifestAsync(url, {
|
|
'Exponent-SDK-Version': exp.sdkVersion,
|
|
'Exponent-Platform': 'android',
|
|
});
|
|
manifest.bundleUrl = kernelBundleUrl;
|
|
manifest.sdkVersion = 'UNVERSIONED';
|
|
await fs.writeFile(
|
|
path.resolve(projectRoot, exp.kernel.androidManifestPath),
|
|
JSON.stringify(manifest)
|
|
);
|
|
}
|
|
|
|
if (exp.kernel.iosManifestPath) {
|
|
let manifest = await ExponentTools.getManifestAsync(url, {
|
|
'Exponent-SDK-Version': exp.sdkVersion,
|
|
'Exponent-Platform': 'ios',
|
|
});
|
|
manifest.bundleUrl = kernelBundleUrl;
|
|
manifest.sdkVersion = 'UNVERSIONED';
|
|
await fs.writeFile(
|
|
path.resolve(projectRoot, exp.kernel.iosManifestPath),
|
|
JSON.stringify(manifest)
|
|
);
|
|
}
|
|
}
|
|
|
|
// TODO(jesse): Add analytics for upload
|
|
async function uploadAssetsAsync(projectRoot, assets) {
|
|
// Collect paths by key, also effectively handles duplicates in the array
|
|
const paths = {};
|
|
assets.forEach(asset => {
|
|
asset.files.forEach((path, index) => {
|
|
paths[asset.fileHashes[index]] = path;
|
|
});
|
|
});
|
|
|
|
// Collect list of assets missing on host
|
|
const metas = (await Api.callMethodAsync('assetsMetadata', [], 'post', {
|
|
keys: Object.keys(paths),
|
|
})).metadata;
|
|
const missing = Object.keys(paths).filter(key => !metas[key].exists);
|
|
|
|
if (missing.length === 0) {
|
|
logger.global.info({ quiet: true }, `No assets changed, skipped.`);
|
|
}
|
|
|
|
// Upload them!
|
|
await Promise.all(
|
|
_.chunk(missing, 5).map(async keys => {
|
|
let formData = new FormData();
|
|
for (const key of keys) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `uploading ${paths[key]}`);
|
|
|
|
let relativePath = paths[key].replace(projectRoot, '');
|
|
logger.global.info({ quiet: true }, `Uploading ${relativePath}`);
|
|
|
|
formData.append(key, await _readFileForUpload(paths[key]), paths[key]);
|
|
}
|
|
await Api.callMethodAsync('uploadAssets', [], 'put', null, { formData });
|
|
})
|
|
);
|
|
}
|
|
|
|
function _createBlob(string) {
|
|
if (isNode()) {
|
|
return string;
|
|
} else {
|
|
return new Blob([string]);
|
|
}
|
|
}
|
|
|
|
async function _readFileForUpload(path) {
|
|
if (isNode()) {
|
|
return fs.createReadStream(path);
|
|
} else {
|
|
const data = await fs.readFile(path);
|
|
return new Blob([data]);
|
|
}
|
|
}
|
|
|
|
export async function buildAsync(
|
|
projectRoot: string,
|
|
options: {
|
|
current?: boolean,
|
|
mode?: string,
|
|
platform?: string,
|
|
expIds?: Array<string>,
|
|
type?: string,
|
|
releaseChannel?: string,
|
|
} = {}
|
|
) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
|
|
Analytics.logEvent('Build Shell App', {
|
|
projectRoot,
|
|
});
|
|
|
|
let schema = joi.object().keys({
|
|
current: joi.boolean(),
|
|
mode: joi.string(),
|
|
platform: joi.any().valid('ios', 'android', 'all'),
|
|
expIds: joi.array(),
|
|
type: joi.any().valid('archive', 'simulator'),
|
|
releaseChannel: joi.string().regex(/[a-z\d][a-z\d._-]*/),
|
|
});
|
|
|
|
try {
|
|
await joiValidateAsync(options, schema);
|
|
} catch (e) {
|
|
throw new XDLError(ErrorCode.INVALID_OPTIONS, e.toString());
|
|
}
|
|
|
|
let { exp, pkg } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
const configPrefix = configName === 'app.json' ? 'expo.' : '';
|
|
|
|
if (!exp || !pkg) {
|
|
throw new XDLError(
|
|
ErrorCode.NO_PACKAGE_JSON,
|
|
`Couldn't read ${configName} file in project at ${projectRoot}`
|
|
);
|
|
}
|
|
|
|
// Support version and name being specified in package.json for legacy
|
|
// support pre: exp.json
|
|
if (!exp.version && pkg.version) {
|
|
exp.version = pkg.version;
|
|
}
|
|
if (!exp.slug && pkg.name) {
|
|
exp.slug = pkg.name;
|
|
}
|
|
|
|
if (options.platform === 'ios' || options.platform === 'all') {
|
|
if (!exp.ios || !exp.ios.bundleIdentifier) {
|
|
throw new XDLError(
|
|
ErrorCode.INVALID_MANIFEST,
|
|
`Must specify a bundle identifier in order to build this experience for iOS. Please specify one in ${configName} at "${configPrefix}ios.bundleIdentifier"`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (options.platform === 'android' || options.platform === 'all') {
|
|
if (!exp.android || !exp.android.package) {
|
|
throw new XDLError(
|
|
ErrorCode.INVALID_MANIFEST,
|
|
`Must specify a java package in order to build this experience for Android. Please specify one in ${configName} at "${configPrefix}android.package"`
|
|
);
|
|
}
|
|
}
|
|
|
|
let response = await Api.callMethodAsync('build', [], 'put', {
|
|
manifest: exp,
|
|
options,
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
async function _waitForRunningAsync(url) {
|
|
try {
|
|
let response = await request(url);
|
|
// Looking for "Cached Bundles" string is hacky, but unfortunately
|
|
// ngrok returns a 200 when it succeeds but the port it's proxying
|
|
// isn't bound.
|
|
if (
|
|
response.statusCode >= 200 &&
|
|
response.statusCode < 300 &&
|
|
response.body &&
|
|
response.body.includes('packager-status:running')
|
|
) {
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
// Try again after delay
|
|
}
|
|
|
|
await delayAsync(100);
|
|
return _waitForRunningAsync(url);
|
|
}
|
|
|
|
function _stripPackagerOutputBox(output: string) {
|
|
let re = /Running packager on port (\d+)/;
|
|
let found = output.match(re);
|
|
if (found && found.length >= 2) {
|
|
return `Running packager on port ${found[1]}\n`;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function _processPackagerLine(line: string) {
|
|
// [10:02:59 AM]
|
|
let timestampRe = /\s*\[\d+\:\d+\:\d+\ (AM)?(PM)?\]\s+/;
|
|
// [11/8/2016, 10:02:59 AM]
|
|
let sdk11AndUpTimestampRe = /\s*\[\d+\/\d+\/\d+, \d+\:\d+\:\d+\ (AM)?(PM)?\]\s+/;
|
|
return line.replace(timestampRe, '').replace(sdk11AndUpTimestampRe, '');
|
|
}
|
|
|
|
async function _restartWatchmanAsync(projectRoot: string) {
|
|
try {
|
|
let result = await spawnAsync('watchman', ['watch-del', projectRoot]);
|
|
await spawnAsync('watchman', ['watch-project', projectRoot]);
|
|
if (result.stdout.includes('root')) {
|
|
ProjectUtils.logInfo(projectRoot, 'expo', 'Restarted watchman.');
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
|
|
ProjectUtils.logError(
|
|
projectRoot,
|
|
'expo',
|
|
'Attempted to restart watchman but failed. Please try running `watchman watch-del-all`.'
|
|
);
|
|
}
|
|
|
|
function _parseModuleResolutionError(projectRoot: string, errorMessage: string) {
|
|
let parts = errorMessage.split(' from ');
|
|
let moduleName = parts[0]
|
|
.replace(/.*?Unable to resolve module /, '')
|
|
.replace(/`/g, '')
|
|
.trim();
|
|
let path = parts[1]
|
|
.replace(/`: Module .*/, '')
|
|
.replace(/`/, '')
|
|
.trim();
|
|
let relativePath = path.replace(projectRoot, '').trim();
|
|
|
|
return {
|
|
moduleName,
|
|
relativePath,
|
|
path,
|
|
};
|
|
}
|
|
|
|
const NODE_STDLIB_MODULES = [
|
|
'assert',
|
|
'async_hooks',
|
|
'buffer',
|
|
'child_process',
|
|
'cluster',
|
|
'crypto',
|
|
'dgram',
|
|
'dns',
|
|
'domain',
|
|
'events',
|
|
'fs',
|
|
'http',
|
|
'https',
|
|
'net',
|
|
'os',
|
|
'path',
|
|
'punycode',
|
|
'querystring',
|
|
'readline',
|
|
'repl',
|
|
'stream',
|
|
'string_decoder',
|
|
'tls',
|
|
'tty',
|
|
'url',
|
|
'util',
|
|
'v8',
|
|
'vm',
|
|
'zlib',
|
|
];
|
|
|
|
function _logModuleResolutionError(projectRoot: string, errorMessage: string) {
|
|
let { moduleName, relativePath, path } = _parseModuleResolutionError(projectRoot, errorMessage);
|
|
|
|
const DOCS_PAGE_URL =
|
|
'https://docs.expo.io/versions/latest/introduction/faq.html#can-i-use-nodejs-packages-with-expo';
|
|
|
|
if (NODE_STDLIB_MODULES.includes(moduleName)) {
|
|
if (path.includes('node_modules')) {
|
|
ProjectUtils.logError(
|
|
projectRoot,
|
|
'packager',
|
|
`The package at ".${relativePath}" attempted to import the Node standard library module "${moduleName}". It failed because React Native does not include the Node standard library. Read more at ${DOCS_PAGE_URL}`
|
|
);
|
|
} else {
|
|
ProjectUtils.logError(
|
|
projectRoot,
|
|
'packager',
|
|
`You attempted attempted to import the Node standard library module "${moduleName}" from ".${relativePath}". It failed because React Native does not include the Node standard library. Read more at ${DOCS_PAGE_URL}`
|
|
);
|
|
}
|
|
} else {
|
|
ProjectUtils.logError(
|
|
projectRoot,
|
|
'packager',
|
|
`Unable to resolve ${moduleName}" from "./${relativePath}"`
|
|
);
|
|
}
|
|
}
|
|
|
|
function _logPackagerOutput(projectRoot: string, level: string, data: Object) {
|
|
let output = data.toString();
|
|
if (output.includes('─────')) {
|
|
output = _stripPackagerOutputBox(output);
|
|
if (output) {
|
|
ProjectUtils.logInfo(projectRoot, 'expo', output);
|
|
}
|
|
return;
|
|
}
|
|
if (!output) {
|
|
return;
|
|
} // Fix watchman if it's being dumb
|
|
if (Watchman.isPlatformSupported() && output.includes('watchman watch-del')) {
|
|
// Skip this as it is likely no longer needed. We may want to add a message
|
|
// in this place in the event that there are still issues reported that could
|
|
// be resolved by restarting watchman when the log output includes this message.
|
|
// _restartWatchmanAsync(projectRoot);
|
|
return;
|
|
}
|
|
|
|
if (output.includes('Unable to resolve module')) {
|
|
_logModuleResolutionError(projectRoot, output);
|
|
}
|
|
|
|
// Temporarily hide warnings about duplicate providesModule declarations
|
|
// under react-native
|
|
if (_isIgnorableDuplicateModuleWarning(projectRoot, level, output)) {
|
|
ProjectUtils.logDebug(
|
|
projectRoot,
|
|
'expo',
|
|
`Suppressing @providesModule warning: ${output}`,
|
|
'project-suppress-providesmodule-warning'
|
|
);
|
|
return;
|
|
}
|
|
let lines = output.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
lines[i] = _processPackagerLine(lines[i]);
|
|
}
|
|
output = lines.join('\n');
|
|
if (level === 'info') {
|
|
ProjectUtils.logInfo(projectRoot, 'packager', output);
|
|
} else {
|
|
ProjectUtils.logError(projectRoot, 'packager', output);
|
|
}
|
|
}
|
|
|
|
function _isIgnorableDuplicateModuleWarning(
|
|
projectRoot: string,
|
|
level: string,
|
|
output: string
|
|
): boolean {
|
|
if (
|
|
level !== 'error' ||
|
|
!output.startsWith('jest-haste-map: @providesModule naming collision:')
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let reactNativeNodeModulesPath = path.join(
|
|
projectRoot,
|
|
'node_modules',
|
|
'react-native',
|
|
'node_modules'
|
|
);
|
|
let reactNativeNodeModulesPattern = _.escapeRegExp(reactNativeNodeModulesPath);
|
|
let reactNativeNodeModulesCollisionRegex = new RegExp(
|
|
`Paths: ${reactNativeNodeModulesPattern}.+ collides with ${reactNativeNodeModulesPattern}.+`
|
|
);
|
|
return reactNativeNodeModulesCollisionRegex.test(output);
|
|
}
|
|
|
|
function _handleDeviceLogs(projectRoot: string, deviceId: string, deviceName: string, logs: any) {
|
|
for (let i = 0; i < logs.length; i++) {
|
|
let log = logs[i];
|
|
let body = typeof log.body === 'string' ? [log.body] : log.body;
|
|
let string = body
|
|
.map(obj => {
|
|
if (typeof obj === 'undefined') {
|
|
return 'undefined';
|
|
}
|
|
if (obj === 'null') {
|
|
return 'null';
|
|
}
|
|
if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
|
return obj;
|
|
}
|
|
try {
|
|
return JSON.stringify(obj);
|
|
} catch (e) {
|
|
return obj.toString();
|
|
}
|
|
})
|
|
.join(' ');
|
|
let level = log.level;
|
|
let groupDepth = log.groupDepth;
|
|
let shouldHide = log.shouldHide;
|
|
let includesStack = log.includesStack;
|
|
|
|
ProjectUtils.logWithLevel(
|
|
projectRoot,
|
|
level,
|
|
{
|
|
tag: 'device',
|
|
deviceId,
|
|
deviceName,
|
|
groupDepth,
|
|
shouldHide,
|
|
includesStack,
|
|
},
|
|
string
|
|
);
|
|
}
|
|
}
|
|
export async function startReactNativeServerAsync(
|
|
projectRoot: string,
|
|
options: Object = {},
|
|
verbose: boolean = true
|
|
) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
await stopReactNativeServerAsync(projectRoot);
|
|
await Watchman.addToPathAsync(); // Attempt to fix watchman if it's hanging
|
|
await Watchman.unblockAndGetVersionAsync(projectRoot);
|
|
|
|
let { exp } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
|
|
let packagerPort = await _getFreePortAsync(19001); // Create packager options
|
|
let nodeModulesPath = exp.nodeModulesPath
|
|
? path.join(path.resolve(projectRoot, exp.nodeModulesPath), 'node_modules')
|
|
: path.join(projectRoot, 'node_modules');
|
|
let packagerOpts = {
|
|
port: packagerPort,
|
|
customLogReporterPath: path.join(nodeModulesPath, 'expo', 'tools', 'LogReporter'),
|
|
assetExts: ['ttf'],
|
|
nonPersistent: !!options.nonPersistent,
|
|
};
|
|
|
|
if (!Versions.gteSdkVersion(exp, '16.0.0')) {
|
|
delete packagerOpts.customLogReporterPath;
|
|
}
|
|
const userPackagerOpts = _.get(exp, 'packagerOpts');
|
|
if (userPackagerOpts) {
|
|
// The RN CLI expects rn-cli.config.js's path to be absolute. We use the
|
|
// project root to resolve relative paths since that was the original
|
|
// behavior of the RN CLI.
|
|
if (userPackagerOpts.config) {
|
|
userPackagerOpts.config = path.resolve(projectRoot, userPackagerOpts.config);
|
|
}
|
|
|
|
packagerOpts = {
|
|
...packagerOpts,
|
|
...userPackagerOpts,
|
|
...(userPackagerOpts.assetExts
|
|
? {
|
|
assetExts: _.uniq([...packagerOpts.assetExts, ...userPackagerOpts.assetExts]),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
let cliOpts = _.reduce(
|
|
packagerOpts,
|
|
(opts, val, key) => {
|
|
// If the packager opt value is boolean, don't set
|
|
// --[opt] [value], just set '--opt'
|
|
if (val && typeof val === 'boolean') {
|
|
opts.push(`--${key}`);
|
|
} else if (val) {
|
|
opts.push(`--${key}`, val);
|
|
}
|
|
return opts;
|
|
},
|
|
['start']
|
|
);
|
|
if (options.reset) {
|
|
cliOpts.push('--reset-cache');
|
|
} // Get custom CLI path from project package.json, but fall back to node_module path
|
|
let defaultCliPath = path.join(
|
|
projectRoot,
|
|
'node_modules',
|
|
'react-native',
|
|
'local-cli',
|
|
'cli.js'
|
|
);
|
|
const cliPath = _.get(exp, 'rnCliPath', defaultCliPath);
|
|
let nodePath; // When using a custom path for the RN CLI, we want it to use the project // root to look up config files and Node modules
|
|
if (exp.rnCliPath) {
|
|
nodePath = _nodePathForProjectRoot(projectRoot);
|
|
} else {
|
|
nodePath = null;
|
|
}
|
|
ProjectUtils.logInfo(projectRoot, 'expo', 'Starting React Native packager...'); // Run the copy of Node that's embedded in Electron by setting the // ELECTRON_RUN_AS_NODE environment variable // Note: the CLI script sets up graceful-fs and sets ulimit to 4096 in the // child process
|
|
let packagerProcess = child_process.fork(cliPath, cliOpts, {
|
|
cwd: projectRoot,
|
|
env: {
|
|
...process.env,
|
|
REACT_NATIVE_APP_ROOT: projectRoot,
|
|
NODE_PATH: nodePath,
|
|
ELECTRON_RUN_AS_NODE: 1,
|
|
},
|
|
silent: true,
|
|
});
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
packagerPort,
|
|
packagerPid: packagerProcess.pid,
|
|
}); // TODO: do we need this? don't know if it's ever called
|
|
process.on('exit', () => {
|
|
treekill(packagerProcess.pid);
|
|
});
|
|
packagerProcess.stdout.setEncoding('utf8');
|
|
packagerProcess.stderr.setEncoding('utf8');
|
|
packagerProcess.stdout.pipe(split()).on('data', data => {
|
|
if (verbose) {
|
|
_logPackagerOutput(projectRoot, 'info', data);
|
|
}
|
|
});
|
|
packagerProcess.stderr.on('data', data => {
|
|
if (verbose) {
|
|
_logPackagerOutput(projectRoot, 'error', data);
|
|
}
|
|
});
|
|
packagerProcess.on('exit', async code => {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `packager process exited with code ${code}`);
|
|
});
|
|
let packagerUrl = await UrlUtils.constructBundleUrlAsync(projectRoot, {
|
|
urlType: 'http',
|
|
hostType: 'localhost',
|
|
});
|
|
await _waitForRunningAsync(`${packagerUrl}/status`);
|
|
} // Simulate the node_modules resolution // If you project dir is /Jesse/Expo/Universe/BubbleBounce, returns // "/Jesse/node_modules:/Jesse/Expo/node_modules:/Jesse/Expo/Universe/node_modules:/Jesse/Expo/Universe/BubbleBounce/node_modules"
|
|
function _nodePathForProjectRoot(projectRoot: string): string {
|
|
let paths = [];
|
|
let directory = path.resolve(projectRoot);
|
|
while (true) {
|
|
paths.push(path.join(directory, 'node_modules'));
|
|
let parentDirectory = path.dirname(directory);
|
|
if (directory === parentDirectory) {
|
|
break;
|
|
}
|
|
directory = parentDirectory;
|
|
}
|
|
return paths.join(path.delimiter);
|
|
}
|
|
export async function stopReactNativeServerAsync(projectRoot: string) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
let packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
if (!packagerInfo.packagerPort || !packagerInfo.packagerPid) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `No packager found for project at ${projectRoot}.`);
|
|
return;
|
|
}
|
|
ProjectUtils.logDebug(
|
|
projectRoot,
|
|
'expo',
|
|
`Killing packager process tree: ${packagerInfo.packagerPid}`
|
|
);
|
|
try {
|
|
await treekillAsync(packagerInfo.packagerPid, 'SIGKILL');
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Error stopping packager process: ${e.toString()}`);
|
|
}
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
packagerPort: null,
|
|
packagerPid: null,
|
|
});
|
|
}
|
|
export async function startExpoServerAsync(projectRoot: string) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
await stopExpoServerAsync(projectRoot);
|
|
let app = express();
|
|
app.use(
|
|
bodyParser.json({
|
|
limit: '10mb',
|
|
})
|
|
);
|
|
app.use(
|
|
bodyParser.urlencoded({
|
|
limit: '10mb',
|
|
extended: true,
|
|
})
|
|
);
|
|
if ((await Doctor.validateWithNetworkAsync(projectRoot)) === Doctor.FATAL) {
|
|
throw new Error(`Couldn't start project. Please fix the errors and restart the project.`);
|
|
} // Serve the manifest.
|
|
let manifestHandler = async (req, res) => {
|
|
try {
|
|
// We intentionally don't `await`. We want to continue trying even
|
|
// if there is a potential error in the package.json and don't want to slow
|
|
// down the request
|
|
Doctor.validateWithNetworkAsync(projectRoot);
|
|
let { exp: manifest } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
if (!manifest) {
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
throw new Error(`No ${configName} file found`);
|
|
} // Get packager opts and then copy into bundleUrlPackagerOpts
|
|
let packagerOpts = await ProjectSettings.getPackagerOptsAsync(projectRoot);
|
|
let bundleUrlPackagerOpts = JSON.parse(JSON.stringify(packagerOpts));
|
|
bundleUrlPackagerOpts.urlType = 'http';
|
|
if (bundleUrlPackagerOpts.hostType === 'redirect') {
|
|
bundleUrlPackagerOpts.hostType = 'tunnel';
|
|
}
|
|
manifest.xde = true; // deprecated
|
|
manifest.developer = {
|
|
tool: Config.developerTool,
|
|
projectRoot,
|
|
};
|
|
manifest.packagerOpts = packagerOpts;
|
|
manifest.env = {};
|
|
for (let key of Object.keys(process.env)) {
|
|
if (key.startsWith('REACT_NATIVE_') || key.startsWith('EXPO_')) {
|
|
manifest.env[key] = process.env[key];
|
|
}
|
|
}
|
|
let entryPoint = await Exp.determineEntryPointAsync(projectRoot);
|
|
let platform = req.headers['exponent-platform'] || 'ios';
|
|
entryPoint = UrlUtils.getPlatformSpecificBundleUrl(entryPoint, platform);
|
|
let mainModuleName = UrlUtils.guessMainModulePath(entryPoint);
|
|
let queryParams = await UrlUtils.constructBundleQueryParamsAsync(
|
|
projectRoot,
|
|
packagerOpts,
|
|
req.hostname
|
|
);
|
|
let path = `/${mainModuleName}.bundle?platform=${platform}&${queryParams}`;
|
|
manifest.bundleUrl =
|
|
(await UrlUtils.constructBundleUrlAsync(projectRoot, bundleUrlPackagerOpts, req.hostname)) +
|
|
path;
|
|
manifest.debuggerHost = await UrlUtils.constructDebuggerHostAsync(projectRoot, req.hostname);
|
|
manifest.mainModuleName = mainModuleName;
|
|
manifest.logUrl = await UrlUtils.constructLogUrlAsync(projectRoot, req.hostname);
|
|
await _resolveManifestAssets(
|
|
projectRoot,
|
|
manifest,
|
|
async path => manifest.bundleUrl.match(/^https?:\/\/.*?\//)[0] + 'assets/' + path
|
|
); // the server normally inserts this but if we're offline we'll do it here
|
|
const hostUUID = await UserSettings.anonymousIdentifier();
|
|
if (Config.offline) {
|
|
manifest.id = `@anonymous/${manifest.slug}-${hostUUID}`;
|
|
}
|
|
let manifestString = JSON.stringify(manifest);
|
|
let currentUser;
|
|
if (!Config.offline) {
|
|
currentUser = await UserManager.getCurrentUserAsync();
|
|
}
|
|
if (req.headers['exponent-accept-signature'] && (currentUser || Config.offline)) {
|
|
if (_cachedSignedManifest.manifestString === manifestString) {
|
|
manifestString = _cachedSignedManifest.signedManifest;
|
|
} else {
|
|
if (Config.offline) {
|
|
const unsignedManifest = {
|
|
manifestString,
|
|
signature: 'UNSIGNED',
|
|
};
|
|
_cachedSignedManifest.manifestString = manifestString;
|
|
manifestString = JSON.stringify(unsignedManifest);
|
|
_cachedSignedManifest.signedManifest = manifestString;
|
|
} else {
|
|
let publishInfo = await Exp.getPublishInfoAsync(projectRoot);
|
|
let signedManifest = await Api.callMethodAsync(
|
|
'signManifest',
|
|
[publishInfo.args],
|
|
'post',
|
|
manifest
|
|
);
|
|
_cachedSignedManifest.manifestString = manifestString;
|
|
_cachedSignedManifest.signedManifest = signedManifest.response;
|
|
manifestString = signedManifest.response;
|
|
}
|
|
}
|
|
}
|
|
const hostInfo = {
|
|
host: hostUUID,
|
|
server: 'xdl',
|
|
serverVersion: require('../package.json').version,
|
|
serverDriver: Config.developerTool,
|
|
serverOS: os.platform(),
|
|
serverOSVersion: os.release(),
|
|
};
|
|
res.append('Exponent-Server', JSON.stringify(hostInfo));
|
|
res.send(manifestString);
|
|
Analytics.logEvent('Serve Manifest', {
|
|
projectRoot,
|
|
});
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Error in manifestHandler: ${e} ${e.stack}`); // 5xx = Server Error HTTP code
|
|
res.status(520).send({
|
|
error: e.toString(),
|
|
});
|
|
}
|
|
};
|
|
app.get('/', manifestHandler);
|
|
app.get('/manifest', manifestHandler);
|
|
app.get('/index.exp', manifestHandler);
|
|
app.post('/logs', async (req, res) => {
|
|
try {
|
|
let deviceId = req.get('Device-Id');
|
|
let deviceName = req.get('Device-Name');
|
|
if (deviceId && deviceName && req.body) {
|
|
_handleDeviceLogs(projectRoot, deviceId, deviceName, req.body);
|
|
}
|
|
} catch (e) {
|
|
ProjectUtils.logError(projectRoot, 'expo', `Error getting device logs: ${e} ${e.stack}`);
|
|
}
|
|
res.send('Success');
|
|
});
|
|
app.post('/shutdown', async (req, res) => {
|
|
server.close();
|
|
res.send('Success');
|
|
});
|
|
let expRc = await ProjectUtils.readExpRcAsync(projectRoot);
|
|
let expoServerPort = expRc.manifestPort ? expRc.manifestPort : await _getFreePortAsync(19000);
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
expoServerPort,
|
|
});
|
|
let server = app.listen(expoServerPort, () => {
|
|
let host = server.address().address;
|
|
let port = server.address().port;
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Local server listening at http://${host}:${port}`);
|
|
});
|
|
await Exp.saveRecentExpRootAsync(projectRoot);
|
|
}
|
|
export async function stopExpoServerAsync(projectRoot: string) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
let packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
if (packagerInfo && packagerInfo.expoServerPort) {
|
|
try {
|
|
await request.post(`http://localhost:${packagerInfo.expoServerPort}/shutdown`);
|
|
} catch (e) {}
|
|
}
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
expoServerPort: null,
|
|
});
|
|
}
|
|
async function _connectToNgrokAsync(
|
|
projectRoot: string,
|
|
args: mixed,
|
|
hostnameAsync: Function,
|
|
ngrokPid: ?number,
|
|
attempts: number = 0
|
|
) {
|
|
try {
|
|
let configPath = path.join(UserSettings.dotExpoHomeDirectory(), 'ngrok.yml');
|
|
let hostname = await hostnameAsync();
|
|
let url = await ngrokConnectAsync({
|
|
hostname,
|
|
configPath,
|
|
...args,
|
|
});
|
|
return url;
|
|
} catch (e) {
|
|
// Attempt to connect 3 times
|
|
if (attempts >= 2) {
|
|
if (e.message) {
|
|
throw new XDLError(ErrorCode.NGROK_ERROR, e.toString());
|
|
} else {
|
|
throw new XDLError(ErrorCode.NGROK_ERROR, JSON.stringify(e));
|
|
}
|
|
}
|
|
if (!attempts) {
|
|
attempts = 0;
|
|
} // Attempt to fix the issue
|
|
if (e.error_code && e.error_code === 103) {
|
|
if (attempts === 0) {
|
|
// Failed to start tunnel. Might be because url already bound to another session.
|
|
if (ngrokPid) {
|
|
try {
|
|
process.kill(ngrokPid, 'SIGKILL');
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Couldn't kill ngrok with PID ${ngrokPid}`);
|
|
}
|
|
} else {
|
|
await ngrokKillAsync();
|
|
}
|
|
} else {
|
|
// Change randomness to avoid conflict if killing ngrok didn't help
|
|
await Exp.resetProjectRandomnessAsync(projectRoot);
|
|
}
|
|
} // Wait 100ms and then try again
|
|
await delayAsync(100);
|
|
return _connectToNgrokAsync(projectRoot, args, hostnameAsync, null, attempts + 1);
|
|
}
|
|
}
|
|
|
|
export async function startTunnelsAsync(projectRoot: string) {
|
|
const user = await UserManager.ensureLoggedInAsync();
|
|
if (!user) {
|
|
throw new Error('Internal error -- tunnel started in offline mode.');
|
|
}
|
|
_assertValidProjectRoot(projectRoot);
|
|
let packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
if (!packagerInfo.packagerPort) {
|
|
throw new XDLError(
|
|
ErrorCode.NO_PACKAGER_PORT,
|
|
`No packager found for project at ${projectRoot}.`
|
|
);
|
|
}
|
|
if (!packagerInfo.expoServerPort) {
|
|
throw new XDLError(
|
|
ErrorCode.NO_EXPO_SERVER_PORT,
|
|
`No Expo server found for project at ${projectRoot}.`
|
|
);
|
|
}
|
|
await stopTunnelsAsync(projectRoot);
|
|
if (await Android.startAdbReverseAsync(projectRoot)) {
|
|
ProjectUtils.logInfo(
|
|
projectRoot,
|
|
'expo',
|
|
'Successfully ran `adb reverse`. Localhost urls should work on the connected Android device.',
|
|
'project-adb-reverse'
|
|
);
|
|
} else {
|
|
ProjectUtils.clearNotification(projectRoot, 'project-adb-reverse');
|
|
}
|
|
const { username } = user;
|
|
let packageShortName = path.parse(projectRoot).base;
|
|
let expRc = await ProjectUtils.readExpRcAsync(projectRoot);
|
|
|
|
ngrok.addListener('statuschange', status => {
|
|
if (status === 'reconnecting') {
|
|
ProjectUtils.logError(
|
|
projectRoot,
|
|
'expo',
|
|
'We noticed your tunnel is having issues. This may be due to intermittent problems with our tunnel provider. If you have trouble connecting to your app, try to Restart the project, or switch Host to LAN.'
|
|
);
|
|
} else if (status === 'online') {
|
|
ProjectUtils.logInfo(projectRoot, 'expo', 'Tunnel connected.');
|
|
}
|
|
});
|
|
|
|
try {
|
|
let startedTunnelsSuccessfully = false;
|
|
|
|
// Some issues with ngrok cause it to hang indefinitely. After
|
|
// TUNNEL_TIMEOUTms we just throw an error.
|
|
await Promise.race([
|
|
(async () => {
|
|
await delayAsync(TUNNEL_TIMEOUT);
|
|
if (!startedTunnelsSuccessfully) {
|
|
throw new Error('Starting tunnels timed out');
|
|
}
|
|
})(),
|
|
(async () => {
|
|
let expoServerNgrokUrl = await _connectToNgrokAsync(
|
|
projectRoot,
|
|
{
|
|
authtoken: Config.ngrok.authToken,
|
|
port: packagerInfo.expoServerPort,
|
|
proto: 'http',
|
|
},
|
|
async () => {
|
|
let randomness = expRc.manifestTunnelRandomness
|
|
? expRc.manifestTunnelRandomness
|
|
: await Exp.getProjectRandomnessAsync(projectRoot);
|
|
return [
|
|
randomness,
|
|
UrlUtils.domainify(username),
|
|
UrlUtils.domainify(packageShortName),
|
|
Config.ngrok.domain,
|
|
].join('.');
|
|
},
|
|
packagerInfo.ngrokPid
|
|
);
|
|
let packagerNgrokUrl = await _connectToNgrokAsync(
|
|
projectRoot,
|
|
{
|
|
authtoken: Config.ngrok.authToken,
|
|
port: packagerInfo.packagerPort,
|
|
proto: 'http',
|
|
},
|
|
async () => {
|
|
let randomness = expRc.manifestTunnelRandomness
|
|
? expRc.manifestTunnelRandomness
|
|
: await Exp.getProjectRandomnessAsync(projectRoot);
|
|
return [
|
|
'packager',
|
|
randomness,
|
|
UrlUtils.domainify(username),
|
|
UrlUtils.domainify(packageShortName),
|
|
Config.ngrok.domain,
|
|
].join('.');
|
|
},
|
|
packagerInfo.ngrokPid
|
|
);
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
expoServerNgrokUrl,
|
|
packagerNgrokUrl,
|
|
ngrokPid: ngrok.process().pid,
|
|
});
|
|
|
|
startedTunnelsSuccessfully = true;
|
|
})(),
|
|
]);
|
|
} catch (e) {
|
|
ProjectUtils.logError(projectRoot, 'expo', `Error starting tunnel: ${e.toString()}`);
|
|
throw e;
|
|
}
|
|
}
|
|
export async function stopTunnelsAsync(projectRoot: string) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot); // This will kill all ngrok tunnels in the process. // We'll need to change this if we ever support more than one project // open at a time in XDE.
|
|
let packagerInfo = await ProjectSettings.readPackagerInfoAsync(projectRoot);
|
|
let ngrokProcess = ngrok.process();
|
|
let ngrokProcessPid = ngrokProcess ? ngrokProcess.pid : null;
|
|
ngrok.removeAllListeners('statuschange');
|
|
if (packagerInfo.ngrokPid && packagerInfo.ngrokPid !== ngrokProcessPid) {
|
|
// Ngrok is running in some other process. Kill at the os level.
|
|
try {
|
|
process.kill(packagerInfo.ngrokPid);
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(
|
|
projectRoot,
|
|
'expo',
|
|
`Couldn't kill ngrok with PID ${packagerInfo.ngrokPid}`
|
|
);
|
|
}
|
|
} else {
|
|
// Ngrok is running from the current process. Kill using ngrok api.
|
|
await ngrokKillAsync();
|
|
}
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, {
|
|
expoServerNgrokUrl: null,
|
|
packagerNgrokUrl: null,
|
|
ngrokPid: null,
|
|
});
|
|
await Android.stopAdbReverseAsync(projectRoot);
|
|
}
|
|
|
|
export async function setOptionsAsync(
|
|
projectRoot: string,
|
|
options: {
|
|
packagerPort?: number,
|
|
}
|
|
) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot); // Check to make sure all options are valid
|
|
let schema = joi.object().keys({
|
|
packagerPort: joi.number().integer(),
|
|
});
|
|
try {
|
|
await joiValidateAsync(options, schema);
|
|
} catch (e) {
|
|
throw new XDLError(ErrorCode.INVALID_OPTIONS, e.toString());
|
|
}
|
|
await ProjectSettings.setPackagerInfoAsync(projectRoot, options);
|
|
}
|
|
export async function getUrlAsync(projectRoot: string, options: Object = {}) {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
return await UrlUtils.constructManifestUrlAsync(projectRoot, options);
|
|
}
|
|
|
|
export async function startAsync(
|
|
projectRoot: string,
|
|
options: Object = {},
|
|
verbose: boolean = true
|
|
): Promise<any> {
|
|
await UserManager.ensureLoggedInAsync();
|
|
_assertValidProjectRoot(projectRoot);
|
|
Analytics.logEvent('Start Project', {
|
|
projectRoot,
|
|
});
|
|
await startExpoServerAsync(projectRoot);
|
|
await startReactNativeServerAsync(projectRoot, options, verbose);
|
|
if (!Config.offline) {
|
|
try {
|
|
await startTunnelsAsync(projectRoot);
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Error starting ngrok ${e.message}`);
|
|
}
|
|
}
|
|
let { exp } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
return exp;
|
|
}
|
|
async function _stopInternalAsync(projectRoot: string): Promise<void> {
|
|
await stopExpoServerAsync(projectRoot);
|
|
await stopReactNativeServerAsync(projectRoot);
|
|
if (!Config.offline) {
|
|
try {
|
|
await stopTunnelsAsync(projectRoot);
|
|
} catch (e) {
|
|
ProjectUtils.logDebug(projectRoot, 'expo', `Error stopping ngrok ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
export async function stopAsync(projectDir: string): Promise<void> {
|
|
const result = await Promise.race([
|
|
_stopInternalAsync(projectDir),
|
|
new Promise((resolve, reject) => setTimeout(resolve, 2000, 'stopFailed')),
|
|
]);
|
|
if (result === 'stopFailed') {
|
|
// find RN packager and ngrok pids, attempt to kill them manually
|
|
const { packagerPid, ngrokPid } = await ProjectSettings.readPackagerInfoAsync(projectDir);
|
|
if (packagerPid) {
|
|
try {
|
|
process.kill(packagerPid);
|
|
} catch (e) {}
|
|
}
|
|
if (ngrokPid) {
|
|
try {
|
|
process.kill(ngrokPid);
|
|
} catch (e) {}
|
|
}
|
|
await ProjectSettings.setPackagerInfoAsync(projectDir, {
|
|
expoServerPort: null,
|
|
packagerPort: null,
|
|
packagerPid: null,
|
|
expoServerNgrokUrl: null,
|
|
packagerNgrokUrl: null,
|
|
ngrokPid: null,
|
|
});
|
|
}
|
|
}
|