Skip to content

Instantly share code, notes, and snippets.

@satya164

satya164/App.js Secret

Created February 16, 2018 09:44
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save satya164/2ac9ef03ec7bd24cff89ec1a66e8c7e3 to your computer and use it in GitHub Desktop.
Save satya164/2ac9ef03ec7bd24cff89ec1a66e8c7e3 to your computer and use it in GitHub Desktop.
/* @flow */
import Expo, { Amplitude, KeepAwake, Constants } from 'expo';
import React from 'react';
import { Linking, Platform } from 'react-native';
import PubNub from 'pubnub';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import pickBy from 'lodash/pickBy';
import mapValues from 'lodash/mapValues';
import * as jsDiff from 'diff';
import { findAndWriteDependencyVersions as moduleUtils } from 'snack-sdk';
import * as ModuleManager from './modules/ModuleManager';
import BarcodeScannerView from './components/BarcodeScannerView';
import LoadingView from './components/LoadingView';
import WrappedComponent from './components/WrappedComponent';
import * as path from './utils/path';
import * as config from '../config';
import type { ExpoSnackFiles, FileDependencies } from './types.js';
// $FlowIgnore
console._errorOriginal = console.warn;
// $FlowIgnore
console.reportErrorsAsExceptions = false;
const RETRY_TIMEOUT_MS = 4 * 1000;
const SUBSCRIBE_TIMEOUT_MS = 8 * 1000;
const TIMEOUT_WARNING_MESSAGE = `This is taking a while… You can cancel loading to scan another QR code.`;
const RESOLVING_DEPENDENCIES_MESSAGE = `Resolving dependencies. This might take a while…`;
const WAITING_FOR_CODE_MESSAGE = `Waiting for code…`;
const AMPLITUDE_KEY = '3b373bbc96d76a58d5efe2b73118a96e';
const pubnub = new PubNub({
publishKey: 'pub-c-2a7fd67b-333d-40db-ad2d-3255f8835f70',
subscribeKey: 'sub-c-0b655000-d784-11e6-b950-02ee2ddab7fe',
uuid: JSON.stringify({
id: Constants.deviceId,
name: Constants.deviceName,
platform: Platform.OS,
}),
ssl: true,
presenceTimeout: 5,
});
const defaultFiles = (code = '') => ({
'App.js': { contents: code, type: 'CODE' },
});
type Props = {
exp: {
initialUri: ?string,
manifest: {
extra?: {
code?: ExpoSnackFiles,
},
},
},
};
type State = {
files: ExpoSnackFiles,
newFiles: ExpoSnackFiles,
s3code: { [key: string]: string },
s3url: { [key: string]: string },
diffCache: { [key: string]: string },
dependencies: { [key: string]: string },
resolving: boolean,
initialCode: ?ExpoSnackFiles,
channel: ?string,
shouldShowCamera: boolean,
shouldShowLoadingErrorMessage: boolean,
loadingMessage: ?string,
isLoadingFirstDependencies: boolean,
error: ?Error,
};
class App extends React.Component<void, Props, State> {
_hasSentAnalytics: boolean;
constructor(props, context) {
super(props, context);
let initialCode =
props.exp.manifest.extra && props.exp.manifest.extra.code
? props.exp.manifest.extra.code
: null;
if (props.exp.initialUri) {
this._setStagingFromUrl(props.exp.initialUri);
}
this.state = {
files: defaultFiles(),
newFiles: defaultFiles(),
dependencies: props.exp.manifest.dependencies
? mapValues(props.exp.manifest.dependencies, dep => dep.version)
: {},
diffCache: {},
s3code: {},
s3url: {},
resolving: false,
initialCode,
channel: null,
shouldShowCamera: false,
shouldShowLoadingErrorMessage: false,
loadingMessage: null,
isLoadingFirstDependencies: !!initialCode,
error: null,
};
this._hasSentAnalytics = false;
}
state: State;
componentWillMount() {
const { initialCode } = this.state;
if (initialCode) {
console.log(initialCode);
this._fetchDependenciesAndTriggerEval(initialCode, true);
}
}
componentDidCatch(error) {
this.setState({ error });
}
componentDidMount() {
const { exp } = this.props;
const { initialCode } = this.state;
if ((!exp.initialUri || !this._handleOpenUrl(exp.initialUri)) && !initialCode) {
this.setState({
shouldShowCamera: true,
});
}
Linking.addEventListener('url', event => {
const { url } = event;
this._handleOpenUrl(url);
});
Amplitude.initialize(AMPLITUDE_KEY);
pubnub.addListener({
message: ({ message }) => {
switch (message.type) {
case 'CODE':
try {
this._updateCode(message);
} catch (e) {
// TODO: probably need to reload all js here
this._sendError(`Error in setState: ${e.toString()}`);
}
try {
this._sendAnalytics(message.metadata);
} catch (e) {
// Couldn't send analytics, not a big deal
}
break;
case 'LOADING_MESSAGE':
// Show a loading message until the next code is sent
this.setState({
files: defaultFiles(),
loadingMessage: message.message,
});
break;
default:
console.log('Received message ', message);
}
},
});
KeepAwake.activate();
}
_updateCodeNotDebounced = async codeMessage => {
const { diffCache } = this.state;
let updatedFiles = defaultFiles();
let { code, diff, s3url, dependencies } = codeMessage;
if (!code && !diff) {
console.warn('message does not contain code');
return;
}
// Create a complete codebase from pubnub message
if (code) {
// this message is coming from the old SDK
updatedFiles = defaultFiles(code);
} else {
// assuming the message is well-formed and diff and s3url are populated
await Promise.all(
Object.keys(diff).map(async key => {
try {
if (s3url[key]) {
if (s3url[key].includes('~asset') || s3url[key].includes('%7Easset')) {
updatedFiles[key] = {
...updatedFiles[key],
contents: s3url[key],
};
} else {
const downloadedCode = await this._maybeDownloadCode(key, s3url[key]);
if (diff[key] !== diffCache[key] || s3url[key] !== this.state.s3url[key]) {
updatedFiles[key] = {
...updatedFiles[key],
contents: this._patchCode(downloadedCode, diff[key]),
};
} else {
updatedFiles[key] = {
...updatedFiles[key],
contents: this.state.files[key]
? this.state.files[key].contents
: this.state.newFiles[key].contents,
};
}
}
} else {
updatedFiles[key] = {
...updatedFiles[key],
contents: this._patchCode('', diff[key]),
};
}
} catch (e) {
this._sendError(`Failed to prepare the contents of ${key}`);
}
})
);
}
// install any new dependencies
this._installRemoteDependencies(mapValues(dependencies, dep => dep.version), null);
// Don't update this.state.files yet since that will trigger eval on app.js
this.setState({ newFiles: updatedFiles, diffCache: diff, error: null, resolving: true });
this._fetchDependenciesAndTriggerEval(updatedFiles);
};
_fetchDependenciesAndTriggerEval = async (
updatedFiles: ExpoSnackFiles,
initializeNewFiles: ?boolean
): Promise<void> => {
let error = null;
if (initializeNewFiles) {
await this.setState({ newFiles: updatedFiles, error: null, resolving: true });
}
try {
await this._handleDependencies(updatedFiles['App.js'].contents, 'App.js');
} catch (e) {
error = e;
console.log(e);
}
this.setState({
files: updatedFiles,
loadingMessage: null,
resolving: false,
isLoadingFirstDependencies: false,
});
};
_patchCode = (code, patch) => {
if (patch !== null) {
try {
const patched = jsDiff.applyPatch(code, patch);
return patched;
} catch (e) {
throw new Error('Error patching code with diff ' + e);
}
}
throw new Error('No patch / invalid patch. Cannot update code.');
};
// Only download code if not cached or s3url has changed
_maybeDownloadCode = async (key, url) => {
const { s3url, s3code } = this.state;
if (url !== s3url[key]) {
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'text/plain',
},
});
const code = await response.text();
s3code[key] = code;
s3url[key] = url;
this.setState({ s3code, s3url });
return code;
} catch (e) {
throw new Error('Error downloading code from s3: ' + url);
}
} else {
return this.state.s3code[key];
}
};
_handleDependencies = async (code: string, filePath: string): Promise<FileDependencies> => {
const fileDir = path.parse(filePath).dir;
let dependencies: Object, localDependencies, remoteDependencies, dependencyResult;
try {
const dep = moduleUtils.findModuleDependencies(code);
localDependencies = pickBy(dep, (version, module) => module.startsWith('.'));
remoteDependencies = mapValues(
pickBy(
dep,
(version, module) =>
!module.startsWith('.') && !ModuleManager.preloadedModules.includes(module)
),
(version, module) => version || this.state.dependencies[module]
);
} catch (e) {
e.message = e.message + '\n in ' + filePath;
console.log(e);
this.setState(state => ({
error: state.error || e,
}));
dependencies = localDependencies = remoteDependencies = {};
} finally {
dependencyResult = {
local: localDependencies,
remote: remoteDependencies,
};
}
if (
(!remoteDependencies && !localDependencies) ||
(!Object.keys(remoteDependencies).length && !Object.keys(localDependencies).length)
) {
return dependencyResult;
}
try {
await this._installRemoteDependencies(remoteDependencies, filePath);
await this._installLocalDependencies(localDependencies, filePath, fileDir);
} finally {
this.forceUpdate();
}
return dependencyResult;
};
_installRemoteDependencies = async (remoteDependencies, filePath) => {
if (isEqual(remoteDependencies, this.state.dependencies)) {
return;
}
this.setState({
dependencies: remoteDependencies,
});
return Promise.all(
Object.keys(remoteDependencies).map(async name => {
const depName = name;
const depVersion = remoteDependencies[name] || 'latest';
try {
//TODO: make sure this cache does not get stale
if (!ModuleManager.installed(depName, depVersion)) {
await ModuleManager.installAsync(depName, depVersion);
}
} catch (e) {
this._sendError(`Error loading dependency: ${e.toString()}`);
e.message = e.message + '\n imported from ' + filePath;
this.setState(state => ({
error: state.error || e,
}));
}
})
);
};
_installLocalDependencies = async (localDependencies, filePath, fileDir) => {
return Promise.all(
Object.keys(localDependencies).map(async name => {
try {
const relativePath = name;
const absPath = this._getAbsPath(relativePath, fileDir);
const file = this._getFileFromPath(absPath, this.state.newFiles);
if (this._isAsset(relativePath)) {
console.log(`Installing asset: ${relativePath}`);
await ModuleManager.installAssetAsync(file, absPath);
} else {
console.log(`Installing: ${relativePath}`);
let awaitingDeps = { local: {}, remote: {} };
if (absPath.endsWith('.js')) {
awaitingDeps = await this._handleDependencies(file, absPath);
}
await ModuleManager.installLocal(file, absPath, awaitingDeps);
}
} catch (e) {
// TODO: these errors should persist even if the code runs fine
this._sendError(`Error loading dependency: ${e.toString()}`);
console.log('Error loading local file: ', name, e.toString(), e);
e.message = e.message + '\n imported from ' + filePath;
this.setState(state => ({
error: state.error || e,
}));
}
})
);
};
// Change releative file path to absolute file path
_getAbsPath = (relativePath, dir) => {
const files = this.state.newFiles;
let absPath = path.resolve(dir, relativePath);
// Check if there is an .js extension, if not --> append
if (!/\.[^/.]+$/.test(absPath)) {
absPath += files[absPath + '.js'] ? '.js' : '/index.js';
}
return absPath;
};
//Check if file has changed against old version currently running
_fileHasChanged = (file, absPath) => {
try {
const oldFile = this._getFileFromPath(absPath, this.state.files);
return file !== oldFile;
} catch (e) {
return true;
}
};
// Check if file is code
_isAsset = filePath => {
const codeExtensions = ['.js', '.json'];
const extPattern = /\.([0-9a-z]+)(?=[?#])|(\.)(?:[\w]+)$/gim;
const ext = filePath.match(extPattern);
if (ext) {
return !codeExtensions.includes(ext[0]);
}
return false;
};
//Return file (string) from specified absolute path
_getFileFromPath = (path: string, files: ExpoSnackFiles): string => {
const file = files[path];
if (file) {
return file.contents;
}
throw new Error('Cannot find file: ' + path);
};
_publish = message => {
const { channel } = this.state;
const device = {
id: Constants.deviceId,
name: Constants.deviceName,
platform: Platform.OS,
};
if (channel) {
pubnub.publish({ channel, message: { ...message, device } }, (status, response) => {
if (status.error) {
console.log('Sending message failed', message);
} else {
console.log('Sent message: ', message);
}
});
}
};
_updateCode = debounce(this._updateCodeNotDebounced, 300);
_sendError = error =>
this._publish({
type: 'ERROR',
error: error ? this._stringifyError(error) : null,
});
_sendConsole = (method, payload) =>
this._publish({
type: 'CONSOLE',
method,
payload: payload.map(item => {
if (typeof item === 'object') {
if (item instanceof Error && item.stack) {
return item.stack;
}
try {
return JSON.stringify(item);
} catch (e) {
// Ignore
}
}
return String(item);
}),
});
_stringifyError = error => {
return JSON.stringify(
typeof error !== 'object' ? { message: String(error) } : error,
typeof error === 'object' ? Object.getOwnPropertyNames(error) : undefined
);
};
_setStagingFromUrl = (url: string) => {
try {
config.setStaging(
url.includes('staging.expo.io') ||
url.includes('staging.snack.expo.io') ||
url.includes('snack.jesseruder.ngrok.io') ||
url.includes('snack.tc.ngrok.io')
);
} catch (e) {}
};
_handleOpenUrl = (url: string) => {
if (this.state.shouldShowLoadingErrorMessage) {
this.setState({ shouldShowLoadingErrorMessage: false });
}
if (!url) {
return false;
}
let channel = null;
if (url.indexOf('+') > 0) {
// URL looks like https://exp.host/@snack/SAVE_UUID+CHANNEL_UUID
channel = url.substring(url.indexOf('+') + 1);
} else if (url.indexOf('/sdk.') > 0) {
// URL looks like https://exp.host/@snack/sdk.14.0.0-UUID
// Use UUID for both channel name and experience id
let afterSDK = url.substring(url.indexOf('/sdk.') + 1);
if (afterSDK.indexOf('-') > 0) {
channel = afterSDK.substring(afterSDK.indexOf('-') + 1);
}
}
if (!channel) {
return false;
}
this._setStagingFromUrl(url);
try {
console.log(`Channel: ${channel}`);
this._subscribe(channel);
return true;
} catch (e) {
console.error(e);
}
};
_unsubscribe = () => {
const oldChannel = this.state.channel;
if (oldChannel) {
pubnub.unsubscribe({
channel: oldChannel,
});
}
};
_subscribe = channel => {
this._unsubscribe();
pubnub.hereNow(
{
channels: [channel],
includeUUIDs: false,
},
(status, response) => {
let isWebpageOpen = response && response.totalOccupancy > 0;
if (isWebpageOpen) {
// We think the webpage is open and ready to send us code.
// Connect and show the loading indicator
pubnub.subscribe({
channels: [channel],
});
this.setState({
channel,
shouldShowCamera: false,
});
setTimeout(this._reAskForCodeIfNotConnected, RETRY_TIMEOUT_MS);
setTimeout(this._errorIfNotConnected, SUBSCRIBE_TIMEOUT_MS);
} else {
// No one else is in the channel. Something went wrong so show the camera.
// This can happen if an old link to an unsaved snack is opened.
this.setState({
shouldShowCamera: true,
});
}
}
);
};
_reAskForCodeIfNotConnected = () => {
let { files, loadingMessage } = this.state;
const entry = files['App.js'];
if (!entry && !loadingMessage) {
this._publish({
type: 'RESEND_CODE',
});
}
};
_errorIfNotConnected = () => {
let { files, loadingMessage } = this.state;
const entry = files['App.js'];
if (!entry && !loadingMessage) {
this.setState({
shouldShowLoadingErrorMessage: true,
});
}
};
_handleBarCodeRead = ({ data: url }) => {
this._handleOpenUrl(url);
};
_onClickWarningMessage = () => {
this._unsubscribe();
this.setState({
files: defaultFiles(),
newFiles: defaultFiles(),
diffCache: {},
s3code: {},
s3url: {},
loadingMessage: null,
channel: null,
shouldShowCamera: true,
shouldShowLoadingErrorMessage: false,
});
};
render() {
let {
files,
resolving,
shouldShowCamera,
shouldShowLoadingErrorMessage,
loadingMessage,
isLoadingFirstDependencies,
} = this.state;
const entry = files['App.js'].contents;
if (loadingMessage) {
return <LoadingView message={loadingMessage} />;
} else if (shouldShowLoadingErrorMessage) {
return (
<LoadingView
message={TIMEOUT_WARNING_MESSAGE}
action="Cancel loading"
onButtonPress={this._onClickWarningMessage}
/>
);
} else if (isLoadingFirstDependencies) {
// Don't show a message when loading a saved snack with dependencies
return <LoadingView />;
} else if (shouldShowCamera) {
return <BarcodeScannerView onBarCodeRead={this._handleBarCodeRead} />;
} else if (resolving) {
return <LoadingView message={RESOLVING_DEPENDENCIES_MESSAGE} />;
} else if (!entry && !this.state.error) {
return <LoadingView message={WAITING_FOR_CODE_MESSAGE} />;
}
const result = (
<WrappedComponent
code={entry}
console={{
log: (...args) => this._sendConsole('log', args),
error: (...args) => this._sendConsole('error', args),
warn: (...args) => this._sendConsole('warn', args),
}}
onError={this._sendError}
error={this.state.error}
/>
);
this._sendError(null);
return result;
}
_sendAnalytics = (metadata: any = {}) => {
if (this._hasSentAnalytics) {
return;
}
this._hasSentAnalytics = true;
Amplitude.logEventWithProperties('RECEIVED_FIRST_CODE', {
phoneId: Constants.deviceId,
phoneName: Constants.deviceName,
...metadata,
});
};
}
Expo.registerRootComponent(App);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment