ui: Adds XHR connection management to HTTP/1.1 installs (#5083)
Adds xhr connection managment to http/1.1 installs This includes various things: 1. An object pool to 'acquire', 'release' and 'dispose' of objects, also a 'purge' to completely empty it 2. A `Request` data object, mainly for reasoning about the object better 3. A pseudo http 'client' which doens't actually control the request itself but does help to manage the connections An initializer is used to detect the script element of the consul-ui sourcecode which we use later to sniff the protocol that we are most likely using for API access
This commit is contained in:
parent
4761277fd0
commit
746201ed49
|
@ -1,10 +1,12 @@
|
|||
import Adapter from 'ember-data/adapters/rest';
|
||||
import { AbortError } from 'ember-data/adapters/errors';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { get } from '@ember/object';
|
||||
|
||||
import URL from 'url';
|
||||
import createURL from 'consul-ui/utils/createURL';
|
||||
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
|
||||
import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL } from 'consul-ui/utils/http/consul';
|
||||
|
||||
export const REQUEST_CREATE = 'createRecord';
|
||||
export const REQUEST_READ = 'queryRecord';
|
||||
|
@ -14,10 +16,31 @@ export const REQUEST_DELETE = 'deleteRecord';
|
|||
|
||||
export const DATACENTER_QUERY_PARAM = 'dc';
|
||||
|
||||
import { HEADERS_SYMBOL as HTTP_HEADERS_SYMBOL } from 'consul-ui/utils/http/consul';
|
||||
export default Adapter.extend({
|
||||
namespace: 'v1',
|
||||
repo: service('settings'),
|
||||
client: service('client/http'),
|
||||
manageConnection: function(options) {
|
||||
const client = get(this, 'client');
|
||||
const complete = options.complete;
|
||||
const beforeSend = options.beforeSend;
|
||||
options.beforeSend = function(xhr) {
|
||||
if (typeof beforeSend === 'function') {
|
||||
beforeSend(...arguments);
|
||||
}
|
||||
options.id = client.request(options, xhr);
|
||||
};
|
||||
options.complete = function(xhr, textStatus) {
|
||||
client.complete(options.id);
|
||||
if (typeof complete === 'function') {
|
||||
complete(...arguments);
|
||||
}
|
||||
};
|
||||
return options;
|
||||
},
|
||||
_ajaxRequest: function(options) {
|
||||
return this._super(this.manageConnection(options));
|
||||
},
|
||||
queryRecord: function() {
|
||||
return this._super(...arguments).catch(function(e) {
|
||||
if (e instanceof AbortError) {
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
const scripts = document.getElementsByTagName('script');
|
||||
const current = scripts[scripts.length - 1];
|
||||
|
||||
export function initialize(application) {
|
||||
const Client = application.resolveRegistration('service:client/http');
|
||||
Client.reopen({
|
||||
isCurrent: function(src) {
|
||||
return current.src === src;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
initialize,
|
||||
};
|
|
@ -0,0 +1,87 @@
|
|||
import Service, { inject as service } from '@ember/service';
|
||||
import { get, set } from '@ember/object';
|
||||
import { Promise } from 'rsvp';
|
||||
|
||||
import getObjectPool from 'consul-ui/utils/get-object-pool';
|
||||
import Request from 'consul-ui/utils/http/request';
|
||||
|
||||
const dispose = function(request) {
|
||||
if (request.headers()['content-type'] === 'text/event-stream') {
|
||||
const xhr = request.connection();
|
||||
// unsent and opened get aborted
|
||||
// headers and loading means wait for it
|
||||
// to finish for the moment
|
||||
if (xhr.readyState) {
|
||||
switch (xhr.readyState) {
|
||||
case 0:
|
||||
case 1:
|
||||
xhr.abort();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return request;
|
||||
};
|
||||
export default Service.extend({
|
||||
dom: service('dom'),
|
||||
init: function() {
|
||||
this._super(...arguments);
|
||||
let protocol = 'http/1.1';
|
||||
try {
|
||||
protocol = performance.getEntriesByType('resource').find(item => {
|
||||
// isCurrent is added in initializers/client and is used
|
||||
// to ensure we use the consul-ui.js src to sniff what the protocol
|
||||
// is. Based on the assumption that whereever this script is it's
|
||||
// likely to be the same as the xmlhttprequests
|
||||
return item.initiatorType === 'script' && this.isCurrent(item.name);
|
||||
}).nextHopProtocol;
|
||||
} catch (e) {
|
||||
// pass through
|
||||
}
|
||||
let maxConnections;
|
||||
// http/2, http2+QUIC/39 and SPDY don't have connection limits
|
||||
switch (true) {
|
||||
case protocol.indexOf('h2') === 0:
|
||||
case protocol.indexOf('hq') === 0:
|
||||
case protocol.indexOf('spdy') === 0:
|
||||
break;
|
||||
default:
|
||||
// generally 6 are available
|
||||
// reserve 1 for traffic that we can't manage
|
||||
maxConnections = 5;
|
||||
break;
|
||||
}
|
||||
set(this, 'connections', getObjectPool(dispose, maxConnections));
|
||||
if (typeof maxConnections !== 'undefined') {
|
||||
set(this, 'maxConnections', maxConnections);
|
||||
const doc = get(this, 'dom').document();
|
||||
// when the user hides the tab, abort all connections
|
||||
doc.addEventListener('visibilitychange', e => {
|
||||
if (e.target.hidden) {
|
||||
get(this, 'connections').purge();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
whenAvailable: function(e) {
|
||||
const doc = get(this, 'dom').document();
|
||||
// if we are using a connection limited protocol and the user has hidden the tab (hidden browser/tab switch)
|
||||
// any aborted errors should restart
|
||||
if (typeof get(this, 'maxConnections') !== 'undefined' && doc.hidden) {
|
||||
return new Promise(function(resolve) {
|
||||
doc.addEventListener('visibilitychange', function listen(event) {
|
||||
doc.removeEventListener('visibilitychange', listen);
|
||||
resolve(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve(e);
|
||||
},
|
||||
request: function(options, xhr) {
|
||||
const request = new Request(options.type, options.url, { body: options.data || {} }, xhr);
|
||||
return get(this, 'connections').acquire(request, request.getId());
|
||||
},
|
||||
complete: function() {
|
||||
return get(this, 'connections').release(...arguments);
|
||||
},
|
||||
});
|
|
@ -50,7 +50,7 @@ export default Service.extend({
|
|||
},
|
||||
elementsByTagName: function(name, context) {
|
||||
context = typeof context === 'undefined' ? get(this, 'doc') : context;
|
||||
return context.getElementByTagName(name);
|
||||
return context.getElementsByTagName(name);
|
||||
},
|
||||
elements: function(selector, context) {
|
||||
// don't ever be tempted to [...$$()] here
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
export default function(dispose = function() {}, max, objects = []) {
|
||||
return {
|
||||
acquire: function(obj, id) {
|
||||
// TODO: what should happen if an ID already exists
|
||||
// should we ignore and release both? Or prevent from acquiring? Or generate a unique ID?
|
||||
// what happens if we can't get an id via getId or .id?
|
||||
// could potentially use Set
|
||||
objects.push(obj);
|
||||
if (typeof max !== 'undefined') {
|
||||
if (objects.length > max) {
|
||||
return dispose(objects.shift());
|
||||
}
|
||||
}
|
||||
return id;
|
||||
},
|
||||
// release releases the obj from the pool but **doesn't** dispose it
|
||||
release: function(obj) {
|
||||
let index = -1;
|
||||
let id;
|
||||
if (typeof obj === 'string') {
|
||||
id = obj;
|
||||
} else {
|
||||
id = obj.id;
|
||||
}
|
||||
objects.forEach(function(item, i) {
|
||||
let itemId;
|
||||
if (typeof item.getId === 'function') {
|
||||
itemId = item.getId();
|
||||
} else {
|
||||
itemId = item.id;
|
||||
}
|
||||
if (itemId === id) {
|
||||
index = i;
|
||||
}
|
||||
});
|
||||
if (index !== -1) {
|
||||
return objects.splice(index, 1)[0];
|
||||
}
|
||||
},
|
||||
purge: function() {
|
||||
let obj;
|
||||
const objs = [];
|
||||
while ((obj = objects.shift())) {
|
||||
objs.push(dispose(obj));
|
||||
}
|
||||
return objs;
|
||||
},
|
||||
dispose: function(id) {
|
||||
return dispose(this.release(id));
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
export default class {
|
||||
constructor(method, url, headers, xhr) {
|
||||
this._xhr = xhr;
|
||||
this._url = url;
|
||||
this._method = method;
|
||||
this._headers = headers;
|
||||
this._headers = {
|
||||
...headers,
|
||||
'content-type': 'application/json',
|
||||
'x-request-id': `${this._method} ${this._url}?${JSON.stringify(headers.body)}`,
|
||||
};
|
||||
if (typeof this._headers.body.index !== 'undefined') {
|
||||
// this should probably be in a response
|
||||
this._headers['content-type'] = 'text/event-stream';
|
||||
}
|
||||
}
|
||||
headers() {
|
||||
return this._headers;
|
||||
}
|
||||
getId() {
|
||||
return this._headers['x-request-id'];
|
||||
}
|
||||
abort() {
|
||||
this._xhr.abort();
|
||||
}
|
||||
connection() {
|
||||
return this._xhr;
|
||||
}
|
||||
}
|
|
@ -2,14 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
|
|||
import repo from 'consul-ui/tests/helpers/repo';
|
||||
const NAME = 'intention';
|
||||
moduleFor(`service:repository/${NAME}`, `Integration | Service | ${NAME}`, {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: [
|
||||
'service:settings',
|
||||
'service:store',
|
||||
`adapter:${NAME}`,
|
||||
`serializer:${NAME}`,
|
||||
`model:${NAME}`,
|
||||
],
|
||||
integration: true,
|
||||
});
|
||||
|
||||
const dc = 'dc-1';
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { moduleFor, test } from 'ember-qunit';
|
||||
|
||||
moduleFor('service:client/http', 'Unit | Service | client/http', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: ['service:dom'],
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it exists', function(assert) {
|
||||
let service = this.subject();
|
||||
assert.ok(service);
|
||||
});
|
|
@ -0,0 +1,98 @@
|
|||
import getObjectPool from 'consul-ui/utils/get-object-pool';
|
||||
import { module, skip } from 'qunit';
|
||||
import test from 'ember-sinon-qunit/test-support/test';
|
||||
|
||||
module('Unit | Utility | get object pool');
|
||||
|
||||
skip('Decide what to do if you add 2 objects with the same id');
|
||||
test('acquire adds objects', function(assert) {
|
||||
const actual = [];
|
||||
const expected = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-123',
|
||||
};
|
||||
const expected2 = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-456',
|
||||
};
|
||||
const pool = getObjectPool(function() {}, 10, actual);
|
||||
pool.acquire(expected, expected.id);
|
||||
assert.deepEqual(actual[0], expected);
|
||||
pool.acquire(expected2, expected2.id);
|
||||
assert.deepEqual(actual[1], expected2);
|
||||
});
|
||||
test('acquire adds objects and returns the id', function(assert) {
|
||||
const arr = [];
|
||||
const expected = 'hi-there-123';
|
||||
const obj = {
|
||||
hi: 'there',
|
||||
id: expected,
|
||||
};
|
||||
const pool = getObjectPool(function() {}, 10, arr);
|
||||
const actual = pool.acquire(obj, expected);
|
||||
assert.equal(actual, expected);
|
||||
});
|
||||
test('acquire adds objects, and disposes when there is no room', function(assert) {
|
||||
const actual = [];
|
||||
const expected = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-123',
|
||||
};
|
||||
const expected2 = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-456',
|
||||
};
|
||||
const dispose = this.stub()
|
||||
.withArgs(expected)
|
||||
.returnsArg(0);
|
||||
const pool = getObjectPool(dispose, 1, actual);
|
||||
pool.acquire(expected, expected.id);
|
||||
assert.deepEqual(actual[0], expected);
|
||||
pool.acquire(expected2, expected2.id);
|
||||
assert.deepEqual(actual[0], expected2);
|
||||
assert.ok(dispose.calledOnce);
|
||||
});
|
||||
test('it disposes', function(assert) {
|
||||
const arr = [];
|
||||
const expected = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-123',
|
||||
};
|
||||
const expected2 = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-456',
|
||||
};
|
||||
const dispose = this.stub().returnsArg(0);
|
||||
const pool = getObjectPool(dispose, 2, arr);
|
||||
const id = pool.acquire(expected, expected.id);
|
||||
assert.deepEqual(arr[0], expected);
|
||||
pool.acquire(expected2, expected2.id);
|
||||
assert.deepEqual(arr[1], expected2);
|
||||
const actual = pool.dispose(id);
|
||||
assert.ok(dispose.calledOnce);
|
||||
assert.equal(arr.length, 1, 'object was removed from array');
|
||||
assert.deepEqual(actual, expected, 'returned object is expected object');
|
||||
assert.deepEqual(arr[0], expected2, 'object in the pool is expected object');
|
||||
});
|
||||
test('it purges', function(assert) {
|
||||
const arr = [];
|
||||
const expected = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-123',
|
||||
};
|
||||
const expected2 = {
|
||||
hi: 'there',
|
||||
id: 'hi-there-456',
|
||||
};
|
||||
const dispose = this.stub().returnsArg(0);
|
||||
const pool = getObjectPool(dispose, 2, arr);
|
||||
pool.acquire(expected, expected.id);
|
||||
assert.deepEqual(arr[0], expected);
|
||||
pool.acquire(expected2, expected2.id);
|
||||
assert.deepEqual(arr[1], expected2);
|
||||
const actual = pool.purge();
|
||||
assert.ok(dispose.calledTwice, 'dispose was called on everything');
|
||||
assert.equal(arr.length, 0, 'the pool is empty');
|
||||
assert.deepEqual(actual[0], expected, 'the first purged object is correct');
|
||||
assert.deepEqual(actual[1], expected2, 'the second purged object is correct');
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
import httpRequest from 'consul-ui/utils/http/request';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
module('Unit | Utility | http/request');
|
||||
|
||||
// Replace this with your real tests.
|
||||
test('it works', function(assert) {
|
||||
const actual = httpRequest;
|
||||
assert.ok(typeof actual === 'function');
|
||||
});
|
Loading…
Reference in New Issue