-
-
Save sibelius/e6282c4b8c0dfc95862abde9c6f093b9 to your computer and use it in GitHub Desktop.
Relay environment while exploring deferred queries
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 { | |
forOwn, | |
size, | |
get, | |
transform, | |
noop, | |
} from 'lodash'; | |
import EventEmitter from './event-emitter'; | |
import { | |
Environment as RelayEnvironment, | |
Network, | |
RecordSource, | |
Store, | |
Observable, | |
} from 'relay-runtime'; | |
import config from './config'; | |
import invariant from 'invariant'; | |
import { logger } from './util/logging'; | |
export type UploadableDataURI = { | |
uri: string, | |
type: string, | |
name: string, | |
}; | |
export type BlobData = any; | |
export type UploadableBlob = { | |
name?: string, | |
blob: BlobData, | |
}; | |
export type Uploadable = | |
UploadableDataURI | | |
UploadableBlob; | |
/** | |
* Environment for application. | |
* | |
* Emits the following network activity related events that all contain a unique | |
* `requestId` in the event object: | |
* | |
* - request-progress | |
* - request-end | |
* - upload-progress | |
*/ | |
class Environment extends EventEmitter { | |
_relayEnvironment: RelayEnvironment; | |
_token: ?string; | |
_requestId: number; | |
_metadata: *; | |
constructor() { | |
super(); | |
this._reset(); | |
this._requestId = 0; | |
this._metadata = null; | |
} | |
get relayEnvironment(): RelayEnvironment { return this._relayEnvironment; } | |
get token(): ?string { return this._token; } | |
setToken(token: ?string) { | |
// ensure consistency of using null for falsy values to ensure change check | |
// will produce a stable result (and we don't flap between null/undefined | |
// for instance). | |
const currentToken = this._token || null; | |
const nextToken = token || null; | |
const change = currentToken !== nextToken; | |
this._token = nextToken; | |
if (nextToken && change) { this._authenticate(); } | |
else if (!nextToken && change) { this._unauthenticate(); } | |
} | |
get metadata(): any { return this._metadata; } | |
set metadata(metadata: any) { this._metadata = metadata; } | |
_authenticate() { | |
this.emit('authenticated'); | |
} | |
_unauthenticate() { | |
this._reset(); | |
this.emit('unauthenticated'); | |
} | |
_reset() { | |
this._token = null; | |
this._createRelayEnvironment(); | |
} | |
_createRelayEnvironment() { | |
this._relayEnvironment = new RelayEnvironment({ | |
network: Network.create(this._fetchQuery.bind(this)), | |
store: new Store(new RecordSource()), | |
}); | |
} | |
_fetchOperation( | |
operation: *, | |
variables: *, | |
uploadables: ?{[key: string]: Uploadable}, | |
): Promise<any> { | |
let body; | |
const headers: { [string]: string } = {}; | |
const requestId = String(this._requestId++); | |
if (this.token) { | |
Object.assign(headers, { | |
'Authorization': `Bearer ${this.token}`, | |
'Authorization-Refresh': 'accept', | |
}); | |
} | |
if (uploadables) { | |
const form = new FormData(); | |
form.append('query', operation.text); | |
form.append('variables', JSON.stringify(variables)); | |
forOwn(uploadables, (uploadable: any, key: string) => { | |
form.append(key, uploadable); | |
}); | |
body = form; | |
headers['Content-Type'] = 'multipart/form-data'; | |
} | |
else { | |
body = JSON.stringify({ | |
query: operation.text, | |
variables, | |
}); | |
headers['Content-Type'] = 'application/json'; | |
} | |
return new Promise(( | |
resolve: (any) => void, | |
reject: (Error) => void, | |
) => { | |
const request = new XMLHttpRequest(); | |
const upload = request.upload; | |
const errorDetails = (event: *) => ( | |
get(event, 'currentTarget.responseText', 'Unknown error') | |
); | |
request.addEventListener('load', () => { | |
const status = request.status; | |
const statusClass = `${Math.floor(status / 100)}xx`; | |
const unauthroized = status === 401; | |
const tokenRefresh = request.getResponseHeader('Authorization-Refresh'); | |
if (unauthroized) { | |
this.setToken(null); | |
} | |
if (tokenRefresh) { | |
this.setToken(tokenRefresh); | |
} | |
// we'll expect that the server returns response data that is associated | |
// with whatever status code is being returned. all responses are | |
// allowed to pass back through to whatever requested it & it's expected | |
// that something upstream will better handle the error. since non 400 | |
// status codes aren't really expected, though, we can at least leave a | |
// trail to follow. for the time being, we'll consider them errors, but | |
// they're not really client errors, so this could be `logger.warn` | |
// rather than `logger.error`. | |
if (!unauthroized && statusClass !== '2xx') { | |
logger.error( | |
`Unexpected status code in response (${status}): ` + | |
`${request.response}` | |
); | |
} | |
try { resolve(JSON.parse(request.response)); } | |
catch (err) { reject(err); } | |
}); | |
request.addEventListener('progress', (details: *) => { | |
this.emit('request-progress', { | |
requestId, | |
loaded: details.loaded, | |
total: details.total, | |
percent: details.loaded / details.total, | |
}); | |
}); | |
request.addEventListener('error', (event: *) => { | |
reject(new Error(`Request failed: ${errorDetails(event)}`)); | |
}); | |
request.addEventListener('timeout', (event: *) => { | |
reject(new Error(`Request timed out: ${errorDetails(event)}`)); | |
}); | |
request.addEventListener('abort', (event: *) => { | |
reject(new Error(`Request aborted: ${errorDetails(event)}`)); | |
}); | |
request.addEventListener('loadend', () => { | |
try { | |
invariant(false, ( | |
'Expected promise to have been resolved or rejected prior to ' + | |
'call of request.loadend' | |
)); | |
} | |
catch (err) { reject(err); } | |
this.emit('request-end', { requestId }); | |
}); | |
if (uploadables) { | |
upload.addEventListener('progress', (details: *) => { | |
this.emit('upload-progress', { | |
requestId, | |
loaded: details.loaded, | |
total: details.total, | |
percent: details.loaded / details.total, | |
}); | |
}); | |
} | |
request.open('POST', `https://api.${config.app.domain}/`); | |
forOwn(headers, (value: any, header: string) => { | |
request.setRequestHeader(header, value); | |
}); | |
request.send(body); | |
this.emit('request-sent', { | |
requestId, | |
upload: !!size(uploadables), | |
}); | |
}); | |
} | |
_fetchQuery( | |
operation: *, | |
variables: *, | |
cacheConfig: *, | |
uploadables: {[key: string]: Uploadable}, | |
): Promise<any> | Observable { | |
if (operation.kind === 'BatchRequest') { | |
return Observable.create((sink: *) => { | |
(async () => { | |
const responses = {}; | |
await operation.requests.reduce((promise: *, request: *) => { | |
return promise.then(async (): Promise<void> => { | |
const requestVariables = transform(request.argumentDependencies, (vars, { | |
name, | |
fromRequestName, | |
fromRequestPath, | |
}: *) => { | |
vars[name] = get(responses[fromRequestName].data, fromRequestPath); | |
}, {}); | |
const response = await this._fetchOperation(request, requestVariables, null); | |
responses[request.name] = response; | |
sink.next({ | |
operation: request.operation, | |
variables: requestVariables, | |
response, | |
}); | |
}); | |
}, Promise.resolve()); | |
sink.complete(); | |
})().catch(sink.error.bind(sink)); | |
}); | |
} | |
else { | |
return this._fetchOperation(operation, variables, uploadables); | |
} | |
} | |
} | |
export default Environment; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment