Skip to content

Commit

Permalink
Allow arbitrary boundaries (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
robrichard authored Sep 23, 2020
1 parent d962169 commit 438598b
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 167 deletions.
7 changes: 4 additions & 3 deletions src/PatchResolver.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { parseMultipartHttp } from './parseMultipartHttp';

export function PatchResolver({ onResponse }) {
export function PatchResolver({ onResponse, boundary }) {
this.boundary = boundary || '-';
this.onResponse = onResponse;
this.processedChunks = 0;
this.chunkBuffer = '';
}

PatchResolver.prototype.handleChunk = function(data) {
PatchResolver.prototype.handleChunk = function (data) {
this.chunkBuffer += data;
const { newBuffer, parts } = parseMultipartHttp(this.chunkBuffer);
const { newBuffer, parts } = parseMultipartHttp(this.chunkBuffer, this.boundary);
this.chunkBuffer = newBuffer;
if (parts.length) {
this.onResponse(parts);
Expand Down
294 changes: 157 additions & 137 deletions src/__test__/PatchResolver.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { TextEncoder, TextDecoder } from 'util';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

function getMultiPartResponse(data) {
function getMultiPartResponse(data, boundary) {
const json = JSON.stringify(data);
const chunk = Buffer.from(json, 'utf8');

return [
'',
'---',
`--${boundary}`,
'Content-Type: application/json',
`Content-Length: ${String(chunk.length)}`,
'',
Expand All @@ -19,140 +19,160 @@ function getMultiPartResponse(data) {
].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() {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
describe('PathResolver', function () {
for (const boundary of ['-', 'gc0p4Jq0M2Yt08jU534c0p']) {
describe(`boundary ${boundary}`, () => {
const chunk1Data = {
data: {
viewer: {
currencies: null,
user: {
profile: null,
items: {
edges: [
{ node: { isFavorite: null } },
{ node: { isFavorite: null } },
],
},
},
},
},
};
const chunk1 = getMultiPartResponse(chunk1Data, boundary);

const chunk2Data = {
path: ['viewer', 'currencies'],
data: ['USD', 'GBP', 'EUR', 'CAD', 'AUD', 'CHF', '😂'], // test unicode
errors: [{ message: 'Not So Bad Error' }],
};
const chunk2 = getMultiPartResponse(chunk2Data, boundary);

const chunk3Data = {
path: ['viewer', 'user', 'profile'],
data: { displayName: 'Steven Seagal' },
};
const chunk3 = getMultiPartResponse(chunk3Data, boundary);

const chunk4Data = {
data: false,
path: ['viewer', 'user', 'items', 'edges', 1, 'node', 'isFavorite'],
};
const chunk4 = getMultiPartResponse(chunk4Data, boundary);
it('should work on each chunk', function () {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
boundary,
});

resolver.handleChunk(chunk1);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);

onResponse.mockClear();
resolver.handleChunk(chunk2);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);

onResponse.mockClear();
resolver.handleChunk(chunk3);
expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]);

onResponse.mockClear();
resolver.handleChunk(chunk4);
expect(onResponse.mock.calls[0][0]).toEqual([chunk4Data]);
});

it('should work when chunks are split', function () {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
boundary,
});

if (boundary === 'gc0p4Jq0M2Yt08jU534c0p') {
debugger;
}

const chunk1a = chunk1.substr(0, 35);
const chunk1b = chunk1.substr(35, 80);
const chunk1c = chunk1.substr(35 + 80);

resolver.handleChunk(chunk1a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk1b);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk1c);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);
onResponse.mockClear();

const chunk2a = chunk2.substr(0, 35);
const chunk2b = chunk2.substr(35);

resolver.handleChunk(chunk2a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk2b);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);
onResponse.mockClear();

const chunk3a = chunk3.substr(0, 10);
const chunk3b = chunk3.substr(10, 20);
const chunk3c = chunk3.substr(10 + 20);

resolver.handleChunk(chunk3a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk3b);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk3c);
expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]);
});

it('should work when chunks are combined', function () {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
boundary,
});

resolver.handleChunk(chunk1 + chunk2);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data, chunk2Data]);
});

it('should work when chunks are combined and split', function () {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
boundary,
});

const chunk3a = chunk3.substr(0, 11);
const chunk3b = chunk3.substr(11, 20);
const chunk3c = chunk3.substr(11 + 20);

resolver.handleChunk(chunk1 + chunk2 + chunk3a);
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]).toEqual([chunk3Data]);
});

it('should work when chunks are combined across boundaries', function () {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
boundary,
});

const chunk2a = chunk2.substring(0, 35);
const chunk2b = chunk2.substring(35);

resolver.handleChunk(chunk1 + chunk2a);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);
onResponse.mockClear();
resolver.handleChunk(chunk2b);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);
});
});

resolver.handleChunk(chunk1);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);

onResponse.mockClear();
resolver.handleChunk(chunk2);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);

onResponse.mockClear();
resolver.handleChunk(chunk3);
expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]);

onResponse.mockClear();
resolver.handleChunk(chunk4);
expect(onResponse.mock.calls[0][0]).toEqual([chunk4Data]);
});

it('should work when chunks are split', function() {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
});

const chunk1a = chunk1.substr(0, 35);
const chunk1b = chunk1.substr(35, 80);
const chunk1c = chunk1.substr(35 + 80);

resolver.handleChunk(chunk1a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk1b);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk1c);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);
onResponse.mockClear();

const chunk2a = chunk2.substr(0, 35);
const chunk2b = chunk2.substr(35);

resolver.handleChunk(chunk2a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk2b);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);
onResponse.mockClear();

const chunk3a = chunk3.substr(0, 10);
const chunk3b = chunk3.substr(11, 20);
const chunk3c = chunk3.substr(11 + 20);

resolver.handleChunk(chunk3a);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk3b);
expect(onResponse).not.toHaveBeenCalled();
resolver.handleChunk(chunk3c);
expect(onResponse.mock.calls[0][0]).toEqual([chunk3Data]);
});

it('should work when chunks are combined', function() {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
});

resolver.handleChunk(chunk1 + chunk2);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data, chunk2Data]);
});

it('should work when chunks are combined and split', function() {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
});

const chunk3a = chunk3.substr(0, 10);
const chunk3b = chunk3.substr(11, 20);
const chunk3c = chunk3.substr(11 + 20);

resolver.handleChunk(chunk1 + chunk2 + chunk3a);
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]).toEqual([chunk3Data]);
});

it('should work when chunks are combined across boundaries', function() {
const onResponse = jest.fn();
const resolver = new PatchResolver({
onResponse,
});

const chunk2a = chunk2.substring(0, 35);
const chunk2b = chunk2.substring(35);

resolver.handleChunk(chunk1 + chunk2a);
expect(onResponse.mock.calls[0][0]).toEqual([chunk1Data]);
onResponse.mockClear();
resolver.handleChunk(chunk2b);
expect(onResponse.mock.calls[0][0]).toEqual([chunk2Data]);
});
}
});
21 changes: 10 additions & 11 deletions src/fetch.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { PatchResolver } from './PatchResolver';
import { getBoundary } from './getBoundary';

export function fetchImpl(
url,
{ method, headers, credentials, body, onNext, onError, onComplete, applyToPrevious }
{ method, headers, credentials, body, onNext, onError, onComplete }
) {
return fetch(url, { method, headers, body, credentials })
.then(response => {
.then((response) => {
const contentType = (!!response.headers && response.headers.get('Content-Type')) || '';
// @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
) {
if (response.status < 300 && contentType.indexOf('multipart/mixed') >= 0) {
const boundary = getBoundary(contentType);

// 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,
onResponse: (r) => onNext(r),
boundary,
});
return reader.read().then(function sendNext({ value, done }) {
if (!done) {
Expand All @@ -40,7 +39,7 @@ export function fetchImpl(
}
});
} else {
return response.json().then(json => {
return response.json().then((json) => {
onNext([json]);
onComplete();
});
Expand Down
16 changes: 16 additions & 0 deletions src/getBoundary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { xhrImpl } from './xhr';
import { fetchImpl } from './fetch';

export function getBoundary(contentType = '') {
const contentTypeParts = contentType.split(';');
for (const contentTypePart of contentTypeParts) {
const [key, value] = (contentTypePart || '').trim().split('=');
if (key === 'boundary' && !!value) {
if (value[0] === '"' && value[value.length - 1] === '"') {
return value.substr(1, value.length - 2);
}
return value;
}
}
return '-';
}
Loading

0 comments on commit 438598b

Please sign in to comment.