diff --git a/src/Channel.ts b/src/Channel.ts index ad409d2..7493d53 100644 --- a/src/Channel.ts +++ b/src/Channel.ts @@ -3,61 +3,10 @@ import { TransportMessage } from './Message' export type OnMessageCallback = (message: {}) => void export interface Channel { - timeout?: number + timeout: number send: (message: TransportMessage) => void onData: (cb: OnMessageCallback) => void onConnect: (cb: () => void) => void onDisconnect: (cb: () => void) => void onError: (cb: (e: Error) => void) => void } - -export abstract class GenericChannel implements Channel { - - public timeout?: number - - private _onMessageCallbacks: OnMessageCallback[] = [] - private _onConnectCallbacks: Function[] = [] - private _onDisconnectCallbacks: Function[] = [] - private _onErrorCallbacks: Function[] = [] - private _ready = false - public abstract send(message: TransportMessage): void - - public onData(cb: OnMessageCallback): void { - if (this._onMessageCallbacks.indexOf(cb) === -1) { - this._onMessageCallbacks.push(cb) - } - } - - public onConnect(cb: Function): void { - if (this._ready) { - cb() - } - this._onConnectCallbacks.push(cb) - } - - public onDisconnect(cb: Function): void { - this._onDisconnectCallbacks.push(cb) - } - - public onError(cb: Function): void { - this._onErrorCallbacks.push(cb) - } - - protected _messageReceived(message: TransportMessage) { - this._onMessageCallbacks.forEach(cb => cb(message)) - } - - protected _error(error: any) { - this._onErrorCallbacks.forEach(cb => cb(error)) - } - - protected _connected() { - this._ready = true - this._onConnectCallbacks.forEach(cb => cb()) - } - - protected _disconnected() { - this._ready = false - this._onDisconnectCallbacks.forEach(cb => cb()) - } -} diff --git a/src/Channels/ChunkedChannel.ts b/src/Channels/ChunkedChannel.ts new file mode 100644 index 0000000..b91f054 --- /dev/null +++ b/src/Channels/ChunkedChannel.ts @@ -0,0 +1,229 @@ +import { GenericChannel } from './GenericChannel' +import { TransportMessage, isTransportMessage } from '../Message' + +export type ChunkedMessageStart = { type: 'chunk_start', chunkId: string, size: number } +export type ChunkedMessage = { type: 'chunk_data', chunkId: string, data: any } +export type ChunkedMessageEnd = { type: 'chunk_end', chunkId: string } +export type ChunkedTransportMessage = ChunkedMessageEnd | ChunkedMessageStart | ChunkedMessage + +/** + * A chunk is a array of bytes. + * It is stored as a array of numbers and is manipulated using a Uint16Array. + */ +type Chunk = number[] + +interface ChunkBuffer { + [chunkId: string]: { + id: string + chunks: Chunk[] + size: number + } +} + +const utils = { + + getRandomId: () => [...Array(30)].map(() => Math.random().toString(36)[3]).join(''), + + str2byteArray: (str: string) => { + const bufView = new Uint16Array(str.length) + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i) + } + + return bufView + }, + + convertUintArrayToString: (a: Uint16Array, maxStringAlloc: number) => { + if (maxStringAlloc === -1) { + return String.fromCharCode.apply(null, a) + } else { + let result = '' + for (let i = 0; i < a.length; i += maxStringAlloc) { + if (i + maxStringAlloc > a.length) { + result += String.fromCharCode.apply(null, a.subarray(i)) + } else { + result += String.fromCharCode.apply(null, a.subarray(i, i + maxStringAlloc)) + } + } + return result + + } + }, + + checkForChunkId: (message: ChunkedTransportMessage) => { + if (!message.chunkId) { + throw new Error(`ChunkedMessage did not have a chunkId: ${JSON.stringify(message)}`) + } + } + +} + +export interface ChunkedChannelConstructorOptions { + chunkSize: number + sender: (m: TransportMessage) => void + timeout?: number + maxStringAlloc?: number +} + +/** + * Overrides the `send` and `_messageReceived` methods of the GenericChannel class + * to offer transparent message chunking over a fragile underlying channel. + */ +export class ChunkedChannel extends GenericChannel { + constructor(opts: ChunkedChannelConstructorOptions) { + super(opts.timeout) + this._chunkSize = opts.chunkSize + this._sender = opts.sender + this._maxStringAlloc = opts.maxStringAlloc || -1 + } + + /** + * The size of the data array in each chunk. + * Note that the total "size" of the message will be larger + * because of the chunking metadata. + */ + private _chunkSize: number + + /** + * Defines the maximum string length that will be allocated at once when + * merging the buffered chunks into the original string. + * This is only needed if the environment where this instance is running applies restriction + * on memory for string allocation. + * Omitting to set this will just create the string from the chunks in one go. + */ + private _maxStringAlloc: number + + /** The actual sending via the underlying channel (eg. websocket) */ + protected _sender: (m: TransportMessage | ChunkedTransportMessage) => void + + /** Stores chunks pending flush */ + private _buffer: ChunkBuffer = {} + + /** + * This method override will chunk messages so that an array of no more than + * `chunkSize` bytes (excluding internal metadata) will be sent for each call + * to a given slot. + */ + public send(message: TransportMessage) { + const stringified = JSON.stringify(message) + if (stringified.length <= this._chunkSize) { + this._sender(message) + return + } + + const messageAsByteArray = utils.str2byteArray(stringified) + const chunkId = utils.getRandomId() + + this._sender({ + type: 'chunk_start', + chunkId, + size: stringified.length + }) + + const sendChunks = (start = 0) => { + let chunk = messageAsByteArray.slice(start, start + this._chunkSize) + if (chunk.length) { + this._sender({ + type: 'chunk_data', + chunkId, + + // To avoid having the underlying channel implemetation interpret/cast + // the UintArray into something else, we explicitely send an array + data: Array.from(chunk) + }) + sendChunks(start + this._chunkSize) + } + } + sendChunks() + + this._sender({ + type: 'chunk_end', + chunkId + }) + + } + + /** + * When a message is received on this channel, either it has been chunked because its original size + * was greater than the chunkSize in which case it will be a `ChunkedTransportMessage`, + * or it was small enough so that it could be sent un chunked in which + * case it will be a plain `TransportMessage`. + */ + protected _messageReceived(message: TransportMessage | ChunkedTransportMessage) { + + switch (message.type) { + case 'chunk_start': + this._receiveNewChunk(message) + break + + case 'chunk_data': + this._receiveChunkData(message) + break + + case 'chunk_end': + const decodedMessage: TransportMessage = this._mergeChunks(message) + super._messageReceived(decodedMessage) + break + + default: + // If the message is small enough, it won't be chunked before sending + // so it won't need merging/buffering here + super._messageReceived(message as TransportMessage) + } + + } + + private _receiveNewChunk(message: ChunkedMessageStart) { + utils.checkForChunkId(message) + if (this._buffer[message.chunkId]) { + throw new Error(`There was already an entry in the buffer for chunkId ${message.chunkId}`) + } + + this._buffer[message.chunkId] = { + id: message.chunkId, + chunks: [], + size: message.size + } + } + + private _receiveChunkData(message: ChunkedMessage) { + utils.checkForChunkId(message) + if (!this._buffer[message.chunkId]) { + throw new Error(`ChunkId ${message.chunkId} was not found in the buffer`) + } + + this._buffer[message.chunkId].chunks.push(message.data) + } + + private _mergeChunks(message: ChunkedMessageEnd): TransportMessage { + utils.checkForChunkId(message) + if (!this._buffer[message.chunkId]) { + throw new Error(`ChunkId ${message.chunkId} was not found in the buffer`) + } + + // Store all the chunks into one Uint16Array + const mergedChunks = this._buffer[message.chunkId].chunks.reduce((d, chunk, ix) => { + chunk.forEach((byte, i) => d.uintArray[d.currentIx + i] = byte) + d.currentIx += chunk.length + return d + }, { uintArray: new Uint16Array(this._buffer[message.chunkId].size), currentIx: 0 }) + + let transportMessage: TransportMessage + + // Then rebuild the object from the merged chunk, now stored as one string + const dataAsString = utils.convertUintArrayToString(mergedChunks.uintArray, this._maxStringAlloc) + try { + transportMessage = JSON.parse(dataAsString) as TransportMessage + } catch (e) { + throw new Error(`Not a valid JSON string: ${dataAsString}`) + } + + if (!isTransportMessage(transportMessage)) { + throw new Error(`Not a transport message: ${JSON.stringify(transportMessage)}`) + } + + return transportMessage + } + +} + diff --git a/src/Channels/GenericChannel.ts b/src/Channels/GenericChannel.ts new file mode 100644 index 0000000..ebf0f0b --- /dev/null +++ b/src/Channels/GenericChannel.ts @@ -0,0 +1,60 @@ +import { Channel, OnMessageCallback } from '../Channel' +import { TransportMessage } from '../Message' + +const DEFAULT_TIMEOUT = 5000 +export abstract class GenericChannel implements Channel { + + public constructor(private _timeout = DEFAULT_TIMEOUT) { } + + get timeout() { + return this._timeout + } + + protected _onMessageCallbacks: OnMessageCallback[] = [] + private _onConnectCallbacks: Function[] = [] + private _onDisconnectCallbacks: Function[] = [] + private _onErrorCallbacks: Function[] = [] + private _ready = false + + public abstract send(message: TransportMessage): void + + public onData(cb: OnMessageCallback): void { + if (this._onMessageCallbacks.indexOf(cb) === -1) { + this._onMessageCallbacks.push(cb) + } + } + + public onConnect(cb: Function): void { + if (this._ready) { + cb() + } + this._onConnectCallbacks.push(cb) + } + + public onDisconnect(cb: Function): void { + this._onDisconnectCallbacks.push(cb) + } + + public onError(cb: Function): void { + this._onErrorCallbacks.push(cb) + } + + protected _messageReceived(message: TransportMessage) { + this._onMessageCallbacks.forEach(cb => cb(message)) + } + + protected _error(error: any) { + this._onErrorCallbacks.forEach(cb => cb(error)) + } + + protected _connected() { + this._ready = true + this._onConnectCallbacks.forEach(cb => cb()) + } + + protected _disconnected() { + this._ready = false + this._onDisconnectCallbacks.forEach(cb => cb()) + } +} + diff --git a/src/Message.ts b/src/Message.ts index fbb5e7f..2a0bf0b 100644 --- a/src/Message.ts +++ b/src/Message.ts @@ -1,4 +1,3 @@ - export type TransportRequest = { type: 'request', slotName: string, id: string, data: any } export type TransportResponse = { type: 'response', slotName: string, id: string, data: any } export type TransportError = { type: 'error', slotName: string, id: string, message: string, stack?: string } @@ -10,3 +9,16 @@ export type TransportMessage = | TransportRequest | TransportResponse | TransportError + +export function isTransportMessage(m: { type: string }): m is TransportMessage { + switch (m.type) { + case 'request': + case 'response': + case 'error': + case 'handler_unregistered': + case 'handler_registered': + return true + default: + return false + } +} diff --git a/src/Transport.ts b/src/Transport.ts index 5a5793e..2c6ce53 100644 --- a/src/Transport.ts +++ b/src/Transport.ts @@ -17,8 +17,6 @@ const assertNever = (a: never) => { throw new Error(`Should not happen: ${a}`) } -const DEFAULT_TIMEOUT = 5000 - const ERRORS = { TIMED_OUT: 'TIMED_OUT', REMOTE_CONNECTION_CLOSED: 'REMOTE_CONNECTION_CLOSED', @@ -209,7 +207,7 @@ export class Transport { this._pendingRequests[slotName][id].reject(new Error(`${ERRORS.TIMED_OUT} on ${slotName}`)) delete this._pendingRequests[slotName][id] } - }, this._channel.timeout || DEFAULT_TIMEOUT) + }, this._channel.timeout) }) this._remoteHandlers[slotName] = remoteHandler addHandler(remoteHandler) diff --git a/src/index.ts b/src/index.ts index 65bdfe7..8e1b7a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export { slot, Slot } from './Slot' export { EventDeclaration, combineEvents, createEventBus } from './Events' -export { Channel, GenericChannel } from './Channel' +export { Channel } from './Channel' +export { GenericChannel } from './Channels/GenericChannel' +export { ChunkedChannel } from './Channels/ChunkedChannel' export { TransportMessage } from './Message' diff --git a/test/Channel.test.ts b/test/Channel.test.ts index 078a34a..21d3774 100644 --- a/test/Channel.test.ts +++ b/test/Channel.test.ts @@ -1,7 +1,8 @@ import 'should' -import { TestChannel } from './TestChannel' +import { TestChannel, TestChunkedChannel } from './TestChannel' import * as sinon from 'sinon' +import { largeData } from './data' describe('GenericChannel', () => { @@ -38,3 +39,94 @@ describe('GenericChannel', () => { }) }) + + +describe('ChunkedChannel', () => { + const generateTest = (testObject: any, chunkSize: number) => { + const objSize = JSON.stringify(testObject).length + + // +2 for chunk_start & chunk_end messages + const numberOfMessages = Math.ceil(objSize / chunkSize) + 2 + + const testInstance = new TestChunkedChannel(chunkSize) + testInstance.onData(d => { + testInstance.sendSpy.getCall(0).args[0].type.should.equal('chunk_start') + testInstance.sendSpy.getCall(1).args[0].type.should.equal('chunk_data') + testInstance.sendSpy.getCall(1).args[0].chunkId.should.be.a.String + testInstance.sendSpy.getCall(1).args[0].data.should.be.lengthOf(chunkSize) + d.should.match(testObject) + testInstance.sendSpy.callCount.should.equal(numberOfMessages) + testInstance.dataSpy.callCount.should.equal(1) + }) + testInstance.send(testObject) + } + + + const cases = [ + { + name: 'a basic request', + testObject: { + type: 'request', + slotName: 'test', + id: '1', + data: { aKey: 'someData' } + }, + chunkSize: 10 + }, + + { + name: 'a request with special characters', + testObject: { + type: 'request', + slotName: 'testジ', + id: '1', + data: { aKey: 'ジンボはリンゴを食べる。' } + }, + chunkSize: 10 + }, + + { + name: 'a large request', + testObject: { + type: 'request', + slotName: 'test', + id: '42', + data: largeData + }, + chunkSize: 101 + } + ] + + cases.forEach(c => { + it(`should work for ${c.name}`, () => { + generateTest(c.testObject, c.chunkSize) + }) + }) + + it('should not chunk small messages', () => { + const smallObject = { bli: 'bla' } + const testInstance = new TestChunkedChannel(100) + testInstance.onData(d => { + // The first message sent should be the message itself, instead of 'chunk_start' + testInstance.sendSpy.getCall(0).args[0].should.match(smallObject) + testInstance.sendSpy.callCount.should.equal(1) + testInstance.dataSpy.callCount.should.equal(1) + }) + testInstance.send(smallObject as any) + }) + + it('should work when using small intermediate strings to merge the chunks into a big string', () => { + const obj = { + type: 'request', + slotName: 'test', + id: '42', + data: largeData + } + const testInstance = new TestChunkedChannel(100, 500) + testInstance.onData(d => { + testInstance.dataSpy.getCall(0).args[0].should.match(obj) + }) + testInstance.send(obj as any) + }) + +}) diff --git a/test/Event.test.ts b/test/Event.test.ts index ead44a8..30230b5 100644 --- a/test/Event.test.ts +++ b/test/Event.test.ts @@ -2,7 +2,6 @@ import 'should' import {slot} from './../src/Slot' import {combineEvents, createEventBus} from './../src/Events' -import {GenericChannel} from './../src/Channel' import {TransportMessage} from './../src/Message' import {TestChannel} from './TestChannel' import * as sinon from 'sinon' diff --git a/test/TestChannel.ts b/test/TestChannel.ts index 80cfcba..aa5b44f 100644 --- a/test/TestChannel.ts +++ b/test/TestChannel.ts @@ -1,11 +1,13 @@ -import {TransportMessage, GenericChannel} from './../src/' import * as sinon from 'sinon' +import { ChunkedChannel } from './../src/Channels/ChunkedChannel' +import { GenericChannel } from './../src/Channels/GenericChannel' +import { TransportMessage } from './../src/Message' export class TestChannel extends GenericChannel { public sendSpy = sinon.spy() - constructor(public timeout?: number) { + constructor() { super() } @@ -46,3 +48,23 @@ export class TestChannel extends GenericChannel { } } + +export class TestChunkedChannel extends ChunkedChannel { + public sendSpy = sinon.spy() + public dataSpy = sinon.spy() + + constructor(chunkSize: number, stringAlloc = -1) { + super({ + chunkSize, + sender: null as any, + maxStringAlloc: stringAlloc + }) + this._sender = m => { + this.sendSpy(m) + this._messageReceived(m) + } + + this.onData(this.dataSpy) + this._connected() + } +} diff --git a/test/data.ts b/test/data.ts new file mode 100644 index 0000000..ab2873a --- /dev/null +++ b/test/data.ts @@ -0,0 +1,515 @@ +export const largeData = [ + { + "_id": "5beaf406d6e5b94161bdc980", + "index": 0, + "guid": "c9c5dfea-a5e2-4a26-b146-3e393623b58f", + "isActive": true, + "balance": "$3,985.99", + "picture": "http://placehold.it/32x32", + "age": 40, + "eyeColor": "blue", + "name": { + "first": "Maritza", + "last": "Padilla" + }, + "company": "OVOLO", + "email": "maritza.padilla@ovolo.ca", + "phone": "+1 (815) 593-2782", + "address": "795 Bedford Place, Canoochee, Massachusetts, 2840", + "about": "Eiusmod sunt dolor cillum quis duis. Et culpa sunt sunt nulla labore adipisicing exercitation in exercitation sunt laboris pariatur officia. Ex in enim cillum eiusmod. Id officia est est amet esse consectetur commodo qui pariatur occaecat consectetur et anim enim.", + "registered": "Wednesday, July 2, 2014 4:45 AM", + "latitude": "33.890222", + "longitude": "-177.380301", + "tags": [ + "duis", + "et", + "tempor", + "laborum", + "pariatur" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Carr Everett" + }, + { + "id": 1, + "name": "Sherry Macias" + }, + { + "id": 2, + "name": "Mcintyre Cruz" + } + ], + "greeting": "Hello, Maritza! You have 9 unread messages.", + "favoriteFruit": "apple" + }, + { + "_id": "5beaf4068208125a5f7717cf", + "index": 1, + "guid": "8d98939d-4731-49b1-9690-24be4766f741", + "isActive": true, + "balance": "$3,315.04", + "picture": "http://placehold.it/32x32", + "age": 39, + "eyeColor": "blue", + "name": { + "first": "Amelia", + "last": "Stuart" + }, + "company": "ANACHO", + "email": "amelia.stuart@anacho.tv", + "phone": "+1 (916) 483-2759", + "address": "999 Sedgwick Street, Florence, Arizona, 2993", + "about": "Cupidatat elit adipisicing mollit reprehenderit officia cillum pariatur qui. Anim Lorem quis eiusmod exercitation labore Lorem adipisicing mollit in fugiat veniam quis ipsum. Fugiat labore magna esse nisi esse enim laborum ex.", + "registered": "Tuesday, January 28, 2014 10:07 PM", + "latitude": "86.589919", + "longitude": "-80.754492", + "tags": [ + "elit", + "eu", + "anim", + "mollit", + "Lorem" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Melba Sandoval" + }, + { + "id": 1, + "name": "Rachael Hickman" + }, + { + "id": 2, + "name": "Irwin Pena" + } + ], + "greeting": "Hello, Amelia! You have 9 unread messages.", + "favoriteFruit": "banana" + }, + { + "_id": "5beaf406c5f6979235b83eb5", + "index": 2, + "guid": "5218d7c3-3fa6-445b-b5bb-ace19f21d60a", + "isActive": false, + "balance": "$1,186.48", + "picture": "http://placehold.it/32x32", + "age": 38, + "eyeColor": "blue", + "name": { + "first": "Graves", + "last": "Castaneda" + }, + "company": "ZAJ", + "email": "graves.castaneda@zaj.biz", + "phone": "+1 (819) 590-3286", + "address": "312 Chase Court, Celeryville, South Dakota, 110", + "about": "Lorem eiusmod velit eiusmod sit labore anim deserunt aliqua pariatur ut incididunt exercitation. Fugiat cupidatat deserunt adipisicing aliqua amet exercitation. Deserunt sunt velit do ea sint. Veniam laboris in pariatur laborum reprehenderit amet. Cupidatat ut fugiat veniam consectetur officia cupidatat.", + "registered": "Thursday, September 13, 2018 3:01 PM", + "latitude": "-75.307296", + "longitude": "144.902635", + "tags": [ + "duis", + "sit", + "minim", + "quis", + "reprehenderit" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Crosby Copeland" + }, + { + "id": 1, + "name": "Pearl Mathis" + }, + { + "id": 2, + "name": "Montgomery Simmons" + } + ], + "greeting": "Hello, Graves! You have 5 unread messages.", + "favoriteFruit": "strawberry" + }, + { + "_id": "5beaf406f0c96c7e8d5e7932", + "index": 3, + "guid": "80f3c312-b7b6-4004-9915-90735f302e89", + "isActive": true, + "balance": "$3,022.64", + "picture": "http://placehold.it/32x32", + "age": 27, + "eyeColor": "blue", + "name": { + "first": "Harrington", + "last": "Robles" + }, + "company": "GEEKUS", + "email": "harrington.robles@geekus.name", + "phone": "+1 (990) 516-3519", + "address": "576 Sunnyside Avenue, Ronco, California, 1246", + "about": "Lorem laboris incididunt nostrud excepteur sit exercitation laboris id commodo amet do. Ex fugiat adipisicing cillum reprehenderit nulla irure in dolor pariatur mollit voluptate. Ut sunt do minim pariatur qui anim esse consectetur laboris id. Officia officia laborum duis culpa velit adipisicing esse mollit irure dolor minim labore occaecat. Et duis duis nulla reprehenderit nisi eu. Consectetur eiusmod ipsum reprehenderit consequat est enim sint.", + "registered": "Friday, October 27, 2017 5:28 AM", + "latitude": "-54.765939", + "longitude": "26.43996", + "tags": [ + "aliquip", + "enim", + "ad", + "proident", + "labore" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Gardner Velazquez" + }, + { + "id": 1, + "name": "Nadine Griffin" + }, + { + "id": 2, + "name": "Singleton Sykes" + } + ], + "greeting": "Hello, Harrington! You have 6 unread messages.", + "favoriteFruit": "strawberry" + }, + { + "_id": "5beaf406050045b9215b6d78", + "index": 4, + "guid": "b83ab206-e041-4cfb-af3c-2bc42b28c4af", + "isActive": true, + "balance": "$1,833.35", + "picture": "http://placehold.it/32x32", + "age": 24, + "eyeColor": "brown", + "name": { + "first": "Dolores", + "last": "Patrick" + }, + "company": "APPLIDECK", + "email": "dolores.patrick@applideck.us", + "phone": "+1 (813) 411-3853", + "address": "858 Woodrow Court, Newkirk, Federated States Of Micronesia, 5788", + "about": "Lorem non et exercitation ad ullamco aliqua adipisicing veniam ut ex consectetur quis. Qui velit aliquip cupidatat adipisicing irure cillum enim Lorem. Exercitation proident deserunt ea Lorem culpa ullamco exercitation sunt. Ullamco do adipisicing magna nostrud quis id cupidatat proident nostrud laboris. Officia officia et reprehenderit commodo cupidatat laboris ea minim ullamco duis voluptate eiusmod dolor. Velit quis duis dolore cupidatat esse eiusmod amet proident eiusmod dolore dolor magna. Consequat elit dolore nisi est Lorem laboris laborum enim aute magna qui.", + "registered": "Sunday, November 16, 2014 12:52 AM", + "latitude": "-11.241668", + "longitude": "66.091751", + "tags": [ + "aliquip", + "est", + "ea", + "exercitation", + "dolore" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Kathie Eaton" + }, + { + "id": 1, + "name": "Herring Boone" + }, + { + "id": 2, + "name": "Dina Hunter" + } + ], + "greeting": "Hello, Dolores! You have 6 unread messages.", + "favoriteFruit": "strawberry" + }, + { + "_id": "5beaf406ae293b0951845c4f", + "index": 5, + "guid": "d806ca54-f5f3-4fdf-9f11-c2bf8cacffef", + "isActive": false, + "balance": "$2,497.53", + "picture": "http://placehold.it/32x32", + "age": 28, + "eyeColor": "blue", + "name": { + "first": "Leon", + "last": "Harrington" + }, + "company": "FIBEROX", + "email": "leon.harrington@fiberox.net", + "phone": "+1 (834) 503-2228", + "address": "415 Bergen Place, Thatcher, Guam, 3628", + "about": "Sunt duis sit ut nostrud laborum duis aliquip ullamco aliqua excepteur ad fugiat eiusmod dolor. Cupidatat culpa in id dolor. In enim tempor ullamco reprehenderit laboris ad.", + "registered": "Tuesday, March 31, 2015 1:54 PM", + "latitude": "55.083753", + "longitude": "117.907668", + "tags": [ + "do", + "velit", + "ea", + "nulla", + "proident" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Peck Howe" + }, + { + "id": 1, + "name": "Rosario Howard" + }, + { + "id": 2, + "name": "Shelly Gonzales" + } + ], + "greeting": "Hello, Leon! You have 5 unread messages.", + "favoriteFruit": "strawberry" + }, + { + "_id": "5beaf4064a8bdb2a78673bc4", + "index": 6, + "guid": "2e17738a-3763-4015-b603-319f28f8be7e", + "isActive": true, + "balance": "$2,767.85", + "picture": "http://placehold.it/32x32", + "age": 22, + "eyeColor": "brown", + "name": { + "first": "Browning", + "last": "French" + }, + "company": "CUBIX", + "email": "browning.french@cubix.io", + "phone": "+1 (919) 530-3750", + "address": "956 Poplar Avenue, Katonah, Oklahoma, 771", + "about": "Non laborum irure magna mollit pariatur labore. Ut tempor exercitation nostrud sint. Culpa labore reprehenderit duis velit tempor id laborum velit sit id labore eu. Incididunt laborum deserunt mollit reprehenderit magna quis exercitation cupidatat ea pariatur. Sit deserunt commodo ex excepteur ipsum sit voluptate ullamco mollit ad mollit aute. Cillum elit mollit adipisicing aute consectetur qui aliqua cupidatat anim adipisicing excepteur deserunt. Aliqua anim dolore consequat est in.", + "registered": "Wednesday, February 5, 2014 8:50 AM", + "latitude": "66.346856", + "longitude": "85.548427", + "tags": [ + "sunt", + "quis", + "ex", + "velit", + "nulla" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Bruce Sanchez" + }, + { + "id": 1, + "name": "Robyn Mckinney" + }, + { + "id": 2, + "name": "Sandoval Nieves" + } + ], + "greeting": "Hello, Browning! You have 8 unread messages.", + "favoriteFruit": "apple" + }, + { + "_id": "5beaf4065ebaee6bc499914e", + "index": 7, + "guid": "a0f00741-c753-48a4-8817-eaf0d243d2a7", + "isActive": true, + "balance": "$2,094.47", + "picture": "http://placehold.it/32x32", + "age": 28, + "eyeColor": "blue", + "name": { + "first": "Cantu", + "last": "Reid" + }, + "company": "LIQUICOM", + "email": "cantu.reid@liquicom.co.uk", + "phone": "+1 (838) 496-2709", + "address": "176 Etna Street, Joppa, Missouri, 6330", + "about": "Voluptate est aute voluptate aute. Et duis laborum mollit quis eiusmod. Nulla aliquip aliqua amet irure dolore anim ullamco minim aute dolore eiusmod voluptate sit. Deserunt tempor fugiat nostrud elit ipsum Lorem do cupidatat. Incididunt esse sit commodo tempor officia ullamco cillum commodo voluptate aliqua culpa. Deserunt excepteur anim id sit tempor laborum qui veniam nisi consectetur duis.", + "registered": "Saturday, July 22, 2017 8:21 PM", + "latitude": "46.563625", + "longitude": "86.130268", + "tags": [ + "fugiat", + "aute", + "laborum", + "reprehenderit", + "enim" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Odessa Boyle" + }, + { + "id": 1, + "name": "Maria Brock" + }, + { + "id": 2, + "name": "Potts Stanton" + } + ], + "greeting": "Hello, Cantu! You have 7 unread messages.", + "favoriteFruit": "apple" + }, + { + "_id": "5beaf40669b92b470203d84b", + "index": 8, + "guid": "3849bac2-5756-4b23-8a02-01c46e98da9a", + "isActive": false, + "balance": "$3,466.40", + "picture": "http://placehold.it/32x32", + "age": 29, + "eyeColor": "brown", + "name": { + "first": "Lesa", + "last": "Kirby" + }, + "company": "ACIUM", + "email": "lesa.kirby@acium.info", + "phone": "+1 (951) 444-3930", + "address": "485 Krier Place, Movico, Michigan, 4137", + "about": "Officia irure eu ex est cillum tempor. Officia commodo do dolore ea aliqua quis amet irure tempor eiusmod aliquip ea non. Veniam laborum dolor id officia cillum ea excepteur ut velit.", + "registered": "Sunday, February 15, 2015 11:42 AM", + "latitude": "56.197719", + "longitude": "-135.847904", + "tags": [ + "incididunt", + "excepteur", + "proident", + "Lorem", + "esse" + ], + "range": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "friends": [ + { + "id": 0, + "name": "Alvarez Shaw" + }, + { + "id": 1, + "name": "Sharp Melendez" + }, + { + "id": 2, + "name": "Jerri Dalton" + } + ], + "greeting": "Hello, Lesa! You have 5 unread messages.", + "favoriteFruit": "apple" + } +]