Skip to content

Commit

Permalink
feat(core): Implement GraphQL SSE Response Support (#3050)
Browse files Browse the repository at this point in the history
  • Loading branch information
kitten authored Mar 15, 2023
1 parent 35be3e5 commit afc68a1
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 196 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-buses-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@urql/core': minor
---

Implement `text/event-stream` response support. This generally adheres to the GraphQL SSE protocol and GraphQL Yoga push responses, and is an alternative to `multipart/mixed`.
3 changes: 2 additions & 1 deletion packages/core/src/internal/fetchOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ export const makeFetchOptions = (

const headers: HeadersInit = {
accept:
'application/graphql-response+json, application/graphql+json, application/json, multipart/mixed',
'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed',
};

if (!useGETMethod) headers['content-type'] = 'application/json';
const extraOptions =
(typeof operation.context.fetchOptions === 'function'
Expand Down
179 changes: 19 additions & 160 deletions packages/core/src/internal/fetchSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe('on multipart/mixed', () => {
JSON.stringify(json) +
'\r\n---';

it('listens for more responses (stream)', async () => {
it('listens for more streamed responses', async () => {
fetch.mockResolvedValue({
status: 200,
headers: {
Expand All @@ -226,9 +226,7 @@ describe('on multipart/mixed', () => {
data: {
author: {
id: '1',
name: 'Steve',
__typename: 'Author',
todos: [{ id: '1', text: 'stream', __typename: 'Todo' }],
},
},
})
Expand All @@ -240,8 +238,8 @@ describe('on multipart/mixed', () => {
wrap({
incremental: [
{
path: ['author', 'todos', 1],
data: { id: '2', text: 'defer', __typename: 'Todo' },
path: ['author'],
data: { name: 'Steve' },
},
],
hasNext: true,
Expand Down Expand Up @@ -269,20 +267,24 @@ describe('on multipart/mixed', () => {
},
});

const AuthorFragment = gql`
fragment authorFields on Author {
name
}
`;

const streamedQueryOperation: Operation = makeOperation(
'query',
{
query: gql`
query {
author {
id
name
todos @stream {
id
text
}
...authorFields @defer
}
}
${AuthorFragment}
`,
variables: {},
key: 1,
Expand All @@ -301,9 +303,7 @@ describe('on multipart/mixed', () => {
expect(chunks[0].data).toEqual({
author: {
id: '1',
name: 'Steve',
__typename: 'Author',
todos: [{ id: '1', text: 'stream', __typename: 'Todo' }],
},
});

Expand All @@ -312,10 +312,6 @@ describe('on multipart/mixed', () => {
id: '1',
name: 'Steve',
__typename: 'Author',
todos: [
{ id: '1', text: 'stream', __typename: 'Todo' },
{ id: '2', text: 'defer', __typename: 'Todo' },
],
},
});

Expand All @@ -324,30 +320,26 @@ describe('on multipart/mixed', () => {
id: '1',
name: 'Steve',
__typename: 'Author',
todos: [
{ id: '1', text: 'stream', __typename: 'Todo' },
{ id: '2', text: 'defer', __typename: 'Todo' },
],
},
});
});
});

it('listens for more responses (defer)', async () => {
describe('on text/event-stream', () => {
const wrap = (json: object) => 'data: ' + JSON.stringify(json) + '\n\n';

it('listens for streamed responses', async () => {
fetch.mockResolvedValue({
status: 200,
headers: {
get() {
return 'multipart/mixed';
return 'text/event-stream';
},
},
body: {
getReader: function () {
let cancelled = false;
const results = [
{
done: false,
value: Buffer.from('\r\n---'),
},
{
done: false,
value: Buffer.from(
Expand Down Expand Up @@ -378,7 +370,7 @@ describe('on multipart/mixed', () => {
},
{
done: false,
value: Buffer.from(wrap({ hasNext: false }) + '--'),
value: Buffer.from(wrap({ hasNext: false })),
},
{ done: true },
];
Expand Down Expand Up @@ -453,137 +445,4 @@ describe('on multipart/mixed', () => {
},
});
});

it('listens for more responses (defer-neted)', async () => {
fetch.mockResolvedValue({
status: 200,
headers: {
get() {
return 'multipart/mixed';
},
},
body: {
getReader: function () {
let cancelled = false;
const results = [
{
done: false,
value: Buffer.from('\r\n---'),
},
{
done: false,
value: Buffer.from(
wrap({
hasNext: true,
data: {
author: {
id: '1',
name: 'Steve',
address: {
country: 'UK',
__typename: 'Address',
},
__typename: 'Author',
},
},
})
),
},
{
done: false,
value: Buffer.from(
wrap({
incremental: [
{
path: ['author', 'address'],
data: { street: 'home' },
},
],
hasNext: true,
})
),
},
{
done: false,
value: Buffer.from(wrap({ hasNext: false }) + '--'),
},
{ done: true },
];
let count = 0;
return {
cancel: function () {
cancelled = true;
},
read: function () {
if (cancelled) throw new Error('No');

return Promise.resolve(results[count++]);
},
};
},
},
});

const AddressFragment = gql`
fragment addressFields on Address {
street
}
`;

const streamedQueryOperation: Operation = makeOperation(
'query',
{
query: gql`
query {
author {
id
address {
id
country
...addressFields @defer
}
}
}
${AddressFragment}
`,
variables: {},
key: 1,
},
context
);

const chunks: OperationResult[] = await pipe(
makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}),
scan((prev: OperationResult[], item) => [...prev, item], []),
toPromise
);

expect(chunks.length).toEqual(3);

expect(chunks[0].data).toEqual({
author: {
id: '1',
name: 'Steve',
address: {
country: 'UK',
__typename: 'Address',
},
__typename: 'Author',
},
});

expect(chunks[1].data).toEqual({
author: {
id: '1',
name: 'Steve',
address: {
country: 'UK',
street: 'home',
__typename: 'Address',
},
__typename: 'Author',
},
});
});
});
Loading

0 comments on commit afc68a1

Please sign in to comment.