Skip to content

Instantly share code, notes, and snippets.

@sibelius
Forked from wbyoung/environment.js
Created July 23, 2018 18:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sibelius/e6282c4b8c0dfc95862abde9c6f093b9 to your computer and use it in GitHub Desktop.
Save sibelius/e6282c4b8c0dfc95862abde9c6f093b9 to your computer and use it in GitHub Desktop.
Relay environment while exploring deferred queries
/* @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