/* @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