diff --git a/README.md b/README.md index de89fad..d766fad 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ function fetchQuery(operation, variables) { variables, }), credentials: 'same-origin', - onNext: json => sink.next(json), + onNext: parts => sink.next(parts), onError: err => sink.error(err), onComplete: () => sink.complete(), }); diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..7ce28e5 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,10 @@ +module.exports = { + bracketSpacing: true, + jsxBracketSameLine: false, + printWidth: 100, + requirePragma: false, + singleQuote: true, + tabWidth: 4, + trailingComma: 'es5', + useTabs: false, +}; diff --git a/src/PatchResolver.js b/src/PatchResolver.js index 206f432..f94c3e7 100644 --- a/src/PatchResolver.js +++ b/src/PatchResolver.js @@ -1,45 +1,7 @@ import { parseMultipartHttp } from './parseMultipartHttp'; -function insertPatch(obj, path, data) { - if (Array.isArray(obj) && typeof path === 'number') { - return [].concat(obj.slice(0, path), [data], obj.slice(path + 1)); - } else { - return { - ...obj, - [path]: data, - }; - } -} - -// recursive function to apply the patch to the previous response -function applyPatch(previousResponse, patchPath, patchData) { - const [nextPath, ...rest] = patchPath; - if (rest.length === 0) { - return insertPatch(previousResponse, nextPath, patchData); - } - return insertPatch( - previousResponse, - nextPath, - applyPatch(previousResponse[nextPath], rest, patchData) - ); -} - -function mergeErrors(previousErrors, patchErrors) { - if (previousErrors && patchErrors) { - return [].concat(previousErrors, patchErrors); - } else if (previousErrors) { - return previousErrors; - } else if (patchErrors) { - return patchErrors; - } - return undefined; -} - -export function PatchResolver({ onResponse, applyToPrevious, mergeExtensions = () => {} }) { - this.applyToPrevious = typeof applyToPrevious === 'boolean' ? applyToPrevious : true; +export function PatchResolver({ onResponse }) { this.onResponse = onResponse; - this.mergeExtensions = mergeExtensions; - this.previousResponse = null; this.processedChunks = 0; this.chunkBuffer = ''; } @@ -49,27 +11,6 @@ PatchResolver.prototype.handleChunk = function(data) { const { newBuffer, parts } = parseMultipartHttp(this.chunkBuffer); this.chunkBuffer = newBuffer; if (parts.length) { - if (this.applyToPrevious) { - parts.forEach(part => { - if (this.processedChunks === 0) { - this.previousResponse = part; - } else { - if (!(Array.isArray(part.path) && typeof part.data !== 'undefined')) { - throw new Error('invalid patch format ' + JSON.stringify(part, null, 2)); - } - this.previousResponse = { - ...this.previousResponse, - data: applyPatch(this.previousResponse.data, part.path, part.data), - errors: mergeErrors(this.previousResponse.errors, part.errors), - extensions: this.mergeExtensions(this.previousResponse.extensions, part.extensions), - }; - } - this.processedChunks += 1; - }); - // don't need to re-trigger every intermediate state - this.onResponse(this.previousResponse); - } else { - parts.forEach(part => this.onResponse(part)); - } + this.onResponse(parts); } }; diff --git a/src/__test__/PatchResolver.spec.js b/src/__test__/PatchResolver.spec.js index 9c40a34..b294bac 100644 --- a/src/__test__/PatchResolver.spec.js +++ b/src/__test__/PatchResolver.spec.js @@ -4,61 +4,49 @@ import { TextEncoder, TextDecoder } from 'util'; global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; -const chunk1 = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 142', - '', - '{"data":{"viewer":{"currencies":null,"user":{"profile":null,"items":{"edges":[{"node":{"isFavorite":null}},{"node":{"isFavorite":null}}]}}}}}\n', -].join('\r\n'); - -const chunk1error = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 104', - '', - '{"data":{"viewer":{"currencies":null,"user":{"profile":null}}},"errors":[{"message":"Very Bad Error"}]}\n', -].join('\r\n'); - -const chunk2 = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 85', - '', - '{"path":["viewer","currencies"],"data":["USD","GBP","EUR","CAD","AUD","CHF","😂"]}\n', // test unicode -].join('\r\n'); - -const chunk2error = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 127', - '', - '{"path":["viewer","currencies"],"data":["USD","GBP","EUR","CAD","AUD","CHF","😂"],"errors":[{"message":"Not So Bad Error"}]}\n', -].join('\r\n'); - -const chunk3 = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 76', - '', - '{"path":["viewer","user","profile"],"data":{"displayName":"Steven Seagal"}}\n', -].join('\r\n'); - -const chunk4 = [ - '', - '---', - 'Content-Type: application/json', - 'Content-Length: 78', - '', - '{"data":false,"path":["viewer","user","items","edges",1,"node","isFavorite"]}\n', - '', - '-----\r\n', -].join('\r\n'); +function getMultiPartResponse(data) { + const json = JSON.stringify(data); + const chunk = Buffer.from(json, 'utf8'); + + return [ + '', + '---', + 'Content-Type: application/json', + `Content-Length: ${String(chunk.length)}`, + '', + json, + '', + ].join('\r\n'); +} + +const chunk1Data = { + data: { + viewer: { + currencies: null, + user: { + profile: null, + items: { edges: [{ node: { isFavorite: null } }, { node: { isFavorite: null } }] }, + }, + }, + }, +}; +const chunk1 = getMultiPartResponse(chunk1Data); + +const chunk2Data = { + path: ['viewer', 'currencies'], + data: ['USD', 'GBP', 'EUR', 'CAD', 'AUD', 'CHF', '😂'], // test unicode + errors: [{ message: 'Not So Bad Error' }], +}; +const chunk2 = getMultiPartResponse(chunk2Data); + +const chunk3Data = { path: ['viewer', 'user', 'profile'], data: { displayName: 'Steven Seagal' } }; +const chunk3 = getMultiPartResponse(chunk3Data); + +const chunk4Data = { + data: false, + path: ['viewer', 'user', 'items', 'edges', 1, 'node', 'isFavorite'], +}; +const chunk4 = getMultiPartResponse(chunk4Data); describe('PathResolver', function() { it('should work on each chunk', function() { @@ -68,19 +56,19 @@ describe('PathResolver', function() { }); resolver.handleChunk(chunk1); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]); onResponse.mockClear(); resolver.handleChunk(chunk2); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]); onResponse.mockClear(); resolver.handleChunk(chunk3); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]); onResponse.mockClear(); resolver.handleChunk(chunk4); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk4Data]); }); it('should work when chunks are split', function() { @@ -98,7 +86,7 @@ describe('PathResolver', function() { resolver.handleChunk(chunk1b); expect(onResponse).not.toHaveBeenCalled(); resolver.handleChunk(chunk1c); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]); onResponse.mockClear(); const chunk2a = chunk2.substr(0, 35); @@ -107,7 +95,7 @@ describe('PathResolver', function() { resolver.handleChunk(chunk2a); expect(onResponse).not.toHaveBeenCalled(); resolver.handleChunk(chunk2b); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]); onResponse.mockClear(); const chunk3a = chunk3.substr(0, 10); @@ -119,7 +107,7 @@ describe('PathResolver', function() { resolver.handleChunk(chunk3b); expect(onResponse).not.toHaveBeenCalled(); resolver.handleChunk(chunk3c); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]); }); it('should work when chunks are combined', function() { @@ -129,7 +117,7 @@ describe('PathResolver', function() { }); resolver.handleChunk(chunk1 + chunk2); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data, chunk2Data]); }); it('should work when chunks are combined and split', function() { @@ -143,13 +131,13 @@ describe('PathResolver', function() { const chunk3c = chunk3.substr(11 + 20); resolver.handleChunk(chunk1 + chunk2 + chunk3a); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data, chunk2Data]); onResponse.mockClear(); resolver.handleChunk(chunk3b); expect(onResponse).not.toHaveBeenCalled(); resolver.handleChunk(chunk3c); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]); }); it('should work when chunks are combined across boundaries', function() { @@ -162,42 +150,9 @@ describe('PathResolver', function() { const chunk2b = chunk2.substring(35); resolver.handleChunk(chunk1 + chunk2a); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]); onResponse.mockClear(); resolver.handleChunk(chunk2b); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); - }); - - it('should merge errors', function() { - const onResponse = jest.fn(); - const resolver = new PatchResolver({ - onResponse, - }); - - resolver.handleChunk(chunk1error); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); - onResponse.mockClear(); - resolver.handleChunk(chunk2error); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); - onResponse.mockClear(); - resolver.handleChunk(chunk3); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); - }); - - it('should work when not applying to previous', function() { - const onResponse = jest.fn(); - const resolver = new PatchResolver({ - onResponse, - applyToPrevious: false, - }); - - const chunk2a = chunk2.substring(0, 35); - const chunk2b = chunk2.substring(35); - - resolver.handleChunk(chunk1 + chunk2a); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); - onResponse.mockClear(); - resolver.handleChunk(chunk2b); - expect(onResponse.mock.calls[0][0]).toMatchSnapshot(); + expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]); }); }); diff --git a/src/__test__/__snapshots__/PatchResolver.spec.js.snap b/src/__test__/__snapshots__/PatchResolver.spec.js.snap deleted file mode 100644 index 1e534f5..0000000 --- a/src/__test__/__snapshots__/PatchResolver.spec.js.snap +++ /dev/null @@ -1,548 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PathResolver should merge errors 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": null, - "user": Object { - "profile": null, - }, - }, - }, - "errors": Array [ - Object { - "message": "Very Bad Error", - }, - ], -} -`; - -exports[`PathResolver should merge errors 2`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "profile": null, - }, - }, - }, - "errors": Array [ - Object { - "message": "Very Bad Error", - }, - Object { - "message": "Not So Bad Error", - }, - ], - "extensions": undefined, -} -`; - -exports[`PathResolver should merge errors 3`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "profile": Object { - "displayName": "Steven Seagal", - }, - }, - }, - }, - "errors": Array [ - Object { - "message": "Very Bad Error", - }, - Object { - "message": "Not So Bad Error", - }, - ], - "extensions": undefined, -} -`; - -exports[`PathResolver should work on each chunk 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": null, - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, -} -`; - -exports[`PathResolver should work on each chunk 2`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work on each chunk 3`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": Object { - "displayName": "Steven Seagal", - }, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work on each chunk 4`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": false, - }, - }, - ], - }, - "profile": Object { - "displayName": "Steven Seagal", - }, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are combined 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are combined across boundaries 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": null, - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, -} -`; - -exports[`PathResolver should work when chunks are combined across boundaries 2`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are combined and split 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are combined and split 2`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": Object { - "displayName": "Steven Seagal", - }, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are split 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": null, - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, -} -`; - -exports[`PathResolver should work when chunks are split 2`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when chunks are split 3`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": Object { - "displayName": "Steven Seagal", - }, - }, - }, - }, - "errors": undefined, - "extensions": undefined, -} -`; - -exports[`PathResolver should work when not applying to previous 1`] = ` -Object { - "data": Object { - "viewer": Object { - "currencies": null, - "user": Object { - "items": Object { - "edges": Array [ - Object { - "node": Object { - "isFavorite": null, - }, - }, - Object { - "node": Object { - "isFavorite": null, - }, - }, - ], - }, - "profile": null, - }, - }, - }, -} -`; - -exports[`PathResolver should work when not applying to previous 2`] = ` -Object { - "data": Array [ - "USD", - "GBP", - "EUR", - "CAD", - "AUD", - "CHF", - "😂", - ], - "path": Array [ - "viewer", - "currencies", - ], -} -`; diff --git a/src/fetch.js b/src/fetch.js index 356ef6d..9afa00d 100644 --- a/src/fetch.js +++ b/src/fetch.js @@ -1,42 +1,50 @@ import { PatchResolver } from './PatchResolver'; -export function fetchImpl(url, { method, headers, credentials, body, onNext, onError, onComplete, applyToPrevious }) { - return fetch(url, { method, headers, body, credentials }).then(response => { - // @defer uses multipart responses to stream patches over HTTP - if ( - response.status < 300 && - response.headers && - response.headers.get('Content-Type') && - response.headers.get('Content-Type').indexOf('multipart/mixed') >= 0 - ) { - // For the majority of browsers with support for ReadableStream and TextDecoder - const reader = response.body.getReader(); - const textDecoder = new TextDecoder(); - const patchResolver = new PatchResolver({ onResponse: r => onNext(r), applyToPrevious }); - return reader.read().then(function sendNext({ value, done }) { - if (!done) { - let plaintext; - try { - plaintext = textDecoder.decode(value); - // Read the header to get the Content-Length - patchResolver.handleChunk(plaintext); - } catch (err) { - const parseError = err; - parseError.response = response; - parseError.statusCode = response.status; - parseError.bodyText = plaintext; - onError(parseError); +export function fetchImpl( + url, + { method, headers, credentials, body, onNext, onError, onComplete, applyToPrevious } +) { + return fetch(url, { method, headers, body, credentials }) + .then(response => { + // @defer uses multipart responses to stream patches over HTTP + if ( + response.status < 300 && + response.headers && + response.headers.get('Content-Type') && + response.headers.get('Content-Type').indexOf('multipart/mixed') >= 0 + ) { + // For the majority of browsers with support for ReadableStream and TextDecoder + const reader = response.body.getReader(); + const textDecoder = new TextDecoder(); + const patchResolver = new PatchResolver({ + onResponse: r => onNext(r), + applyToPrevious, + }); + return reader.read().then(function sendNext({ value, done }) { + if (!done) { + let plaintext; + try { + plaintext = textDecoder.decode(value); + // Read the header to get the Content-Length + patchResolver.handleChunk(plaintext); + } catch (err) { + const parseError = err; + parseError.response = response; + parseError.statusCode = response.status; + parseError.bodyText = plaintext; + onError(parseError); + } + reader.read().then(sendNext); + } else { + onComplete(); } - reader.read().then(sendNext); - } else { + }); + } else { + return response.json().then(json => { + onNext(json); onComplete(); - } - }); - } else { - return response.json().then(json => { - onNext(json); - onComplete(); - }); - } - }).catch(onError); + }); + } + }) + .catch(onError); } diff --git a/src/index.js b/src/index.js index 460e645..0caba3c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ -import { getTransport } from "./getTransport"; -import { PatchResolver } from "./PatchResolver"; +import { getTransport } from './getTransport'; +import { PatchResolver } from './PatchResolver'; export { PatchResolver }; export default getTransport(); diff --git a/src/xhr.js b/src/xhr.js index 3621596..4517178 100644 --- a/src/xhr.js +++ b/src/xhr.js @@ -11,7 +11,10 @@ function supportsXhrResponseType(type) { return false; } -export function xhrImpl(url, { method, headers, credentials, body, onNext, onError, onComplete, applyToPrevious }) { +export function xhrImpl( + url, + { method, headers, credentials, body, onNext, onError, onComplete, applyToPrevious } +) { const xhr = new XMLHttpRequest(); xhr.withCredentials = credentials === 'include'; // follow behavior of fetch credentials param https://github.com/github/fetch#sending-cookies let index = 0;