-
-
Save satya164/2ac9ef03ec7bd24cff89ec1a66e8c7e3 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* @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