Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Implement GraphQL SSE Response Support #3050

Merged
merged 8 commits into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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