1
- import { createSchema , createYoga } from '../src/index' ;
1
+ import { useDeferStream } from '@graphql-yoga/plugin-defer-stream' ;
2
+ import { createLogger , createSchema , createYoga , FetchAPI } from '../src/index' ;
2
3
3
- describe ( 'request cancellation' , ( ) => {
4
- it ( 'request cancellation stops invocation of subsequent resolvers' , async ( ) => {
4
+ const variants : Array < [ name : string , fetchAPI : undefined | FetchAPI ] > = [
5
+ [ 'Ponyfilled WhatWG Fetch' , undefined ] ,
6
+ ] ;
7
+
8
+ const [ major ] = globalThis ?. process ?. versions ?. node . split ( '.' ) ?? [ ] ;
9
+
10
+ if ( major === '21' && process . env . LEAKS_TEST !== 'true' ) {
11
+ variants . push ( [
12
+ 'Node.js 21' ,
13
+ {
14
+ fetch : globalThis . fetch ,
15
+ Blob : globalThis . Blob ,
16
+ btoa : globalThis . btoa ,
17
+ FormData : globalThis . FormData ,
18
+ Headers : globalThis . Headers ,
19
+ Request : globalThis . Request ,
20
+ crypto : globalThis . crypto ,
21
+ File : globalThis . File ,
22
+ ReadableStream : globalThis . ReadableStream ,
23
+ // @ts -expect-error json function signature
24
+ Response : globalThis . Response ,
25
+ TextDecoder : globalThis . TextDecoder ,
26
+ TextEncoder : globalThis . TextEncoder ,
27
+ URL : globalThis . URL ,
28
+ TransformStream : globalThis . TransformStream ,
29
+ // URLPattern: globalThis.URLPattern,
30
+ URLSearchParams : globalThis . URLSearchParams ,
31
+ WritableStream : globalThis . WritableStream ,
32
+ } ,
33
+ ] ) ;
34
+ }
35
+
36
+ function waitAFewMillisecondsToMakeSureGraphQLExecutionIsNotResumingInBackground ( ) {
37
+ return new Promise ( res => setTimeout ( res , 5 ) ) ;
38
+ }
39
+
40
+ describe . each ( variants ) ( 'request cancellation (%s)' , ( _ , fetchAPI ) => {
41
+ it ( 'request cancellation stops invocation of subsequent resolvers (GraphQL over HTTP)' , async ( ) => {
5
42
const rootResolverGotInvokedD = createDeferred ( ) ;
6
43
const requestGotCancelledD = createDeferred ( ) ;
7
44
let aResolverGotInvoked = false ;
8
- let rootResolverGotInvoked = false ;
9
45
const schema = createSchema ( {
10
46
typeDefs : /* GraphQL */ `
11
47
type Query {
@@ -18,7 +54,6 @@ describe('request cancellation', () => {
18
54
resolvers : {
19
55
Query : {
20
56
async root ( ) {
21
- rootResolverGotInvoked = true ;
22
57
rootResolverGotInvokedD . resolve ( ) ;
23
58
await requestGotCancelledD . promise ;
24
59
return { a : 'a' } ;
@@ -32,7 +67,10 @@ describe('request cancellation', () => {
32
67
} ,
33
68
} ,
34
69
} ) ;
35
- const yoga = createYoga ( { schema } ) ;
70
+ const logger = createLogger ( 'silent' ) ;
71
+ const debugLogs = jest . fn ( ) ;
72
+ logger . debug = debugLogs ;
73
+ const yoga = createYoga ( { schema, fetchAPI, logging : logger } ) ;
36
74
const abortController = new AbortController ( ) ;
37
75
const promise = Promise . resolve (
38
76
yoga . fetch ( 'http://yoga/graphql' , {
@@ -48,9 +86,188 @@ describe('request cancellation', () => {
48
86
abortController . abort ( ) ;
49
87
requestGotCancelledD . resolve ( ) ;
50
88
await expect ( promise ) . rejects . toThrow ( 'This operation was aborted' ) ;
51
- expect ( rootResolverGotInvoked ) . toBe ( true ) ;
89
+ await waitAFewMillisecondsToMakeSureGraphQLExecutionIsNotResumingInBackground ( ) ;
52
90
expect ( aResolverGotInvoked ) . toBe ( false ) ;
53
- await requestGotCancelledD . promise ;
91
+ expect ( debugLogs . mock . calls ) . toEqual ( [
92
+ [ 'Parsing request to extract GraphQL parameters' ] ,
93
+ [ 'Processing GraphQL Parameters' ] ,
94
+ [ 'Request aborted' ] ,
95
+ ] ) ;
96
+ } ) ;
97
+
98
+ it ( 'request cancellation stops invocation of subsequent resolvers (GraphQL over SSE with Subscription)' , async ( ) => {
99
+ const rootResolverGotInvokedD = createDeferred ( ) ;
100
+ const requestGotCancelledD = createDeferred ( ) ;
101
+ let aResolverGotInvoked = false ;
102
+ const schema = createSchema ( {
103
+ typeDefs : /* GraphQL */ `
104
+ type Query {
105
+ root: A!
106
+ }
107
+ type Subscription {
108
+ root: A!
109
+ }
110
+ type A {
111
+ a: String!
112
+ }
113
+ ` ,
114
+ resolvers : {
115
+ Subscription : {
116
+ root : {
117
+ async * subscribe ( ) {
118
+ yield 1 ;
119
+ } ,
120
+ async resolve ( ) {
121
+ rootResolverGotInvokedD . resolve ( ) ;
122
+ await requestGotCancelledD . promise ;
123
+ return { a : 'a' } ;
124
+ } ,
125
+ } ,
126
+ } ,
127
+ A : {
128
+ a ( ) {
129
+ aResolverGotInvoked = true ;
130
+ return 'a' ;
131
+ } ,
132
+ } ,
133
+ } ,
134
+ } ) ;
135
+ const logger = createLogger ( 'silent' ) ;
136
+ const debugLogs = jest . fn ( ) ;
137
+ logger . debug = debugLogs ;
138
+ const yoga = createYoga ( { schema, fetchAPI, logging : logger } ) ;
139
+ const abortController = new AbortController ( ) ;
140
+ const response = await yoga . fetch ( 'http://yoga/graphql' , {
141
+ method : 'POST' ,
142
+ body : JSON . stringify ( { query : 'subscription { root { a } }' } ) ,
143
+ headers : {
144
+ 'Content-Type' : 'application/json' ,
145
+ Accept : 'text/event-stream' ,
146
+ } ,
147
+ signal : abortController . signal ,
148
+ } ) ;
149
+ expect ( response . status ) . toBe ( 200 ) ;
150
+ const iterator = response . body ! [ Symbol . asyncIterator ] ( ) ;
151
+ // first we will always get a ping/keep alive for flushed headers
152
+ const next = await iterator . next ( ) ;
153
+ expect ( Buffer . from ( next . value ) . toString ( 'utf-8' ) ) . toMatchInlineSnapshot ( `
154
+ ":
155
+
156
+ "
157
+ ` ) ;
158
+
159
+ await rootResolverGotInvokedD . promise ;
160
+ const next$ = iterator . next ( ) . then ( ( { done, value } ) => {
161
+ // in case it resolves, parse the buffer to string for easier debugging.
162
+ return { done, value : Buffer . from ( value ) . toString ( 'utf-8' ) } ;
163
+ } ) ;
164
+
165
+ abortController . abort ( ) ;
166
+ requestGotCancelledD . resolve ( ) ;
167
+
168
+ await expect ( next$ ) . rejects . toThrow ( 'This operation was aborted' ) ;
169
+ await waitAFewMillisecondsToMakeSureGraphQLExecutionIsNotResumingInBackground ( ) ;
170
+ expect ( aResolverGotInvoked ) . toBe ( false ) ;
171
+
172
+ expect ( debugLogs . mock . calls ) . toEqual ( [
173
+ [ 'Parsing request to extract GraphQL parameters' ] ,
174
+ [ 'Processing GraphQL Parameters' ] ,
175
+ [ 'Processing GraphQL Parameters done.' ] ,
176
+ [ 'Request aborted' ] ,
177
+ ] ) ;
178
+ } ) ;
179
+
180
+ it ( 'request cancellation stops invocation of subsequent resolvers (GraphQL over Multipart with defer/stream)' , async ( ) => {
181
+ const aResolverGotInvokedD = createDeferred ( ) ;
182
+ const requestGotCancelledD = createDeferred ( ) ;
183
+ let bResolverGotInvoked = false ;
184
+ const schema = createSchema ( {
185
+ typeDefs : /* GraphQL */ `
186
+ type Query {
187
+ root: A!
188
+ }
189
+ type A {
190
+ a: B!
191
+ }
192
+ type B {
193
+ b: String
194
+ }
195
+ ` ,
196
+ resolvers : {
197
+ Query : {
198
+ async root ( ) {
199
+ return { a : 'a' } ;
200
+ } ,
201
+ } ,
202
+ A : {
203
+ async a ( ) {
204
+ aResolverGotInvokedD . resolve ( ) ;
205
+ await requestGotCancelledD . promise ;
206
+ return { b : 'b' } ;
207
+ } ,
208
+ } ,
209
+ B : {
210
+ b : obj => {
211
+ bResolverGotInvoked = true ;
212
+ return obj . b ;
213
+ } ,
214
+ } ,
215
+ } ,
216
+ } ) ;
217
+ const logger = createLogger ( 'silent' ) ;
218
+ const debugLogs = jest . fn ( ) ;
219
+ logger . debug = debugLogs ;
220
+ const yoga = createYoga ( { schema, plugins : [ useDeferStream ( ) ] , fetchAPI, logging : logger } ) ;
221
+
222
+ const abortController = new AbortController ( ) ;
223
+ const response = await yoga . fetch ( 'http://yoga/graphql' , {
224
+ method : 'POST' ,
225
+ body : JSON . stringify ( {
226
+ query : /* GraphQL */ `
227
+ query {
228
+ root {
229
+ ... @defer {
230
+ a {
231
+ b
232
+ }
233
+ }
234
+ }
235
+ }
236
+ ` ,
237
+ } ) ,
238
+ headers : {
239
+ 'content-type' : 'application/json' ,
240
+ accept : 'multipart/mixed' ,
241
+ } ,
242
+ signal : abortController . signal ,
243
+ } ) ;
244
+ expect ( response . status ) . toEqual ( 200 ) ;
245
+ const iterator = response . body ! [ Symbol . asyncIterator ] ( ) ;
246
+ let payload = '' ;
247
+
248
+ // Shitty wait condition, but it works lol
249
+ while ( payload . split ( '\r\n' ) . length < 6 || ! payload . endsWith ( '---' ) ) {
250
+ const next = await iterator . next ( ) ;
251
+ payload += Buffer . from ( next . value ) . toString ( 'utf-8' ) ;
252
+ }
253
+
254
+ const next$ = iterator . next ( ) . then ( ( { done, value } ) => {
255
+ // in case it resolves, parse the buffer to string for easier debugging.
256
+ return { done, value : Buffer . from ( value ) . toString ( 'utf-8' ) } ;
257
+ } ) ;
258
+
259
+ await aResolverGotInvokedD . promise ;
260
+ abortController . abort ( ) ;
261
+ requestGotCancelledD . resolve ( ) ;
262
+ await expect ( next$ ) . rejects . toThrow ( 'This operation was aborted' ) ;
263
+ await waitAFewMillisecondsToMakeSureGraphQLExecutionIsNotResumingInBackground ( ) ;
264
+ expect ( bResolverGotInvoked ) . toBe ( false ) ;
265
+ expect ( debugLogs . mock . calls ) . toEqual ( [
266
+ [ 'Parsing request to extract GraphQL parameters' ] ,
267
+ [ 'Processing GraphQL Parameters' ] ,
268
+ [ 'Processing GraphQL Parameters done.' ] ,
269
+ [ 'Request aborted' ] ,
270
+ ] ) ;
54
271
} ) ;
55
272
} ) ;
56
273
0 commit comments