Skip to content

Commit ce8429e

Browse files
committed
call return on underlying async iterator when connection closes
1 parent 928b129 commit ce8429e

File tree

2 files changed

+175
-14
lines changed

2 files changed

+175
-14
lines changed

src/__tests__/http-test.ts

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import zlib from 'zlib';
2+
import type http from 'http';
23

34
import type { Server as Restify } from 'restify';
45
import connect from 'connect';
@@ -81,6 +82,12 @@ function urlString(urlParams?: { [param: string]: string }): string {
8182
return string;
8283
}
8384

85+
function sleep(ms = 1) {
86+
return new Promise((r) => {
87+
setTimeout(r, ms);
88+
});
89+
}
90+
8491
describe('GraphQL-HTTP tests for connect', () => {
8592
runTests(() => {
8693
const app = connect();
@@ -2389,9 +2396,7 @@ function runTests(server: Server) {
23892396
graphqlHTTP(() => ({
23902397
schema: TestSchema,
23912398
async *customExecuteFn() {
2392-
await new Promise((r) => {
2393-
setTimeout(r, 1);
2394-
});
2399+
await sleep();
23952400
yield {
23962401
data: {
23972402
test2: 'Modification',
@@ -2436,6 +2441,137 @@ function runTests(server: Server) {
24362441
].join('\r\n'),
24372442
);
24382443
});
2444+
2445+
it('calls return on underlying async iterable when connection is closed', async () => {
2446+
const app = server();
2447+
const fakeReturn = sinon.fake();
2448+
2449+
app.get(
2450+
urlString(),
2451+
graphqlHTTP(() => ({
2452+
schema: TestSchema,
2453+
// custom iterable keeps yielding until return is called
2454+
customExecuteFn() {
2455+
let returned = false;
2456+
return {
2457+
[Symbol.asyncIterator]: () => ({
2458+
next: async () => {
2459+
await sleep();
2460+
if (returned) {
2461+
return { value: undefined, done: true };
2462+
}
2463+
return {
2464+
value: { data: { test: 'Hello, World' }, hasNext: true },
2465+
done: false,
2466+
};
2467+
},
2468+
return: () => {
2469+
returned = true;
2470+
fakeReturn();
2471+
return Promise.resolve({ value: undefined, done: true });
2472+
},
2473+
}),
2474+
};
2475+
},
2476+
})),
2477+
);
2478+
2479+
let text = '';
2480+
const request = app
2481+
.request()
2482+
.get(urlString({ query: '{test}' }))
2483+
.parse((res, cb) => {
2484+
res.on('data', (data) => {
2485+
text = `${text}${data.toString('utf8') as string}`;
2486+
((res as unknown) as http.IncomingMessage).destroy();
2487+
cb(new Error('Aborted connection'), null);
2488+
});
2489+
});
2490+
2491+
try {
2492+
await request;
2493+
} catch (e: unknown) {
2494+
// ignore aborted error
2495+
}
2496+
// sleep to allow time for return function to be called
2497+
await sleep(2);
2498+
expect(text).to.equal(
2499+
[
2500+
'',
2501+
'---',
2502+
'Content-Type: application/json; charset=utf-8',
2503+
'Content-Length: 47',
2504+
'',
2505+
'{"data":{"test":"Hello, World"},"hasNext":true}',
2506+
'',
2507+
].join('\r\n'),
2508+
);
2509+
expect(fakeReturn.callCount).to.equal(1);
2510+
});
2511+
2512+
it('handles return function on async iterable that throws', async () => {
2513+
const app = server();
2514+
2515+
app.get(
2516+
urlString(),
2517+
graphqlHTTP(() => ({
2518+
schema: TestSchema,
2519+
// custom iterable keeps yielding until return is called
2520+
customExecuteFn() {
2521+
let returned = false;
2522+
return {
2523+
[Symbol.asyncIterator]: () => ({
2524+
next: async () => {
2525+
await sleep();
2526+
if (returned) {
2527+
return { value: undefined, done: true };
2528+
}
2529+
return {
2530+
value: { data: { test: 'Hello, World' }, hasNext: true },
2531+
done: false,
2532+
};
2533+
},
2534+
return: () => {
2535+
returned = true;
2536+
return Promise.reject(new Error('Throws!'));
2537+
},
2538+
}),
2539+
};
2540+
},
2541+
})),
2542+
);
2543+
2544+
let text = '';
2545+
const request = app
2546+
.request()
2547+
.get(urlString({ query: '{test}' }))
2548+
.parse((res, cb) => {
2549+
res.on('data', (data) => {
2550+
text = `${text}${data.toString('utf8') as string}`;
2551+
((res as unknown) as http.IncomingMessage).destroy();
2552+
cb(new Error('Aborted connection'), null);
2553+
});
2554+
});
2555+
2556+
try {
2557+
await request;
2558+
} catch (e: unknown) {
2559+
// ignore aborted error
2560+
}
2561+
// sleep to allow return function to be called
2562+
await sleep(2);
2563+
expect(text).to.equal(
2564+
[
2565+
'',
2566+
'---',
2567+
'Content-Type: application/json; charset=utf-8',
2568+
'Content-Length: 47',
2569+
'',
2570+
'{"data":{"test":"Hello, World"},"hasNext":true}',
2571+
'',
2572+
].join('\r\n'),
2573+
);
2574+
});
24392575
});
24402576

24412577
describe('Custom parse function', () => {

src/index.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export function graphqlHTTP(options: Options): Middleware {
213213
let documentAST: DocumentNode;
214214
let executeResult;
215215
let result: ExecutionResult;
216+
let finishedIterable = false;
216217

217218
try {
218219
// Parse the Request to get GraphQL request parameters.
@@ -371,6 +372,23 @@ export function graphqlHTTP(options: Options): Middleware {
371372
const asyncIterator = getAsyncIterator<ExecutionResult>(
372373
executeResult,
373374
);
375+
376+
response.on('close', () => {
377+
if (
378+
!finishedIterable &&
379+
typeof asyncIterator.return === 'function'
380+
) {
381+
asyncIterator.return().then(null, (rawError: unknown) => {
382+
const graphqlError = getGraphQlError(rawError);
383+
sendPartialResponse(pretty, response, {
384+
data: undefined,
385+
errors: [formatErrorFn(graphqlError)],
386+
hasNext: false,
387+
});
388+
});
389+
}
390+
});
391+
374392
const { value } = await asyncIterator.next();
375393
result = value;
376394
} else {
@@ -398,6 +416,7 @@ export function graphqlHTTP(options: Options): Middleware {
398416
rawError instanceof Error ? rawError : String(rawError),
399417
);
400418

419+
// eslint-disable-next-line require-atomic-updates
401420
response.statusCode = error.status;
402421

403422
const { headers } = error;
@@ -431,6 +450,7 @@ export function graphqlHTTP(options: Options): Middleware {
431450
// the resulting JSON payload.
432451
// https://graphql.github.io/graphql-spec/#sec-Data
433452
if (response.statusCode === 200 && result.data == null) {
453+
// eslint-disable-next-line require-atomic-updates
434454
response.statusCode = 500;
435455
}
436456

@@ -462,17 +482,7 @@ export function graphqlHTTP(options: Options): Middleware {
462482
sendPartialResponse(pretty, response, formattedPayload);
463483
}
464484
} catch (rawError: unknown) {
465-
/* istanbul ignore next: Thrown by underlying library. */
466-
const error =
467-
rawError instanceof Error ? rawError : new Error(String(rawError));
468-
const graphqlError = new GraphQLError(
469-
error.message,
470-
undefined,
471-
undefined,
472-
undefined,
473-
undefined,
474-
error,
475-
);
485+
const graphqlError = getGraphQlError(rawError);
476486
sendPartialResponse(pretty, response, {
477487
data: undefined,
478488
errors: [formatErrorFn(graphqlError)],
@@ -481,6 +491,7 @@ export function graphqlHTTP(options: Options): Middleware {
481491
}
482492
response.write('\r\n-----\r\n');
483493
response.end();
494+
finishedIterable = true;
484495
return;
485496
}
486497

@@ -657,3 +668,17 @@ function getAsyncIterator<T>(
657668
const method = asyncIterable[Symbol.asyncIterator];
658669
return method.call(asyncIterable);
659670
}
671+
672+
function getGraphQlError(rawError: unknown) {
673+
/* istanbul ignore next: Thrown by underlying library. */
674+
const error =
675+
rawError instanceof Error ? rawError : new Error(String(rawError));
676+
return new GraphQLError(
677+
error.message,
678+
undefined,
679+
undefined,
680+
undefined,
681+
undefined,
682+
error,
683+
);
684+
}

0 commit comments

Comments
 (0)