Skip to content

Commit e303a2c

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

File tree

2 files changed

+181
-14
lines changed

2 files changed

+181
-14
lines changed

src/__tests__/http-test.ts

Lines changed: 145 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,143 @@ 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+
res.on('end', (err) => {
2490+
cb(err, null);
2491+
});
2492+
});
2493+
2494+
try {
2495+
await request;
2496+
} catch (e: unknown) {
2497+
// ignore aborted error
2498+
}
2499+
// sleep to allow time for return function to be called
2500+
await sleep(2);
2501+
expect(text).to.equal(
2502+
[
2503+
'',
2504+
'---',
2505+
'Content-Type: application/json; charset=utf-8',
2506+
'Content-Length: 47',
2507+
'',
2508+
'{"data":{"test":"Hello, World"},"hasNext":true}',
2509+
'',
2510+
].join('\r\n'),
2511+
);
2512+
expect(fakeReturn.callCount).to.equal(1);
2513+
});
2514+
2515+
it('handles return function on async iterable that throws', async () => {
2516+
const app = server();
2517+
2518+
app.get(
2519+
urlString(),
2520+
graphqlHTTP(() => ({
2521+
schema: TestSchema,
2522+
// custom iterable keeps yielding until return is called
2523+
customExecuteFn() {
2524+
let returned = false;
2525+
return {
2526+
[Symbol.asyncIterator]: () => ({
2527+
next: async () => {
2528+
await sleep();
2529+
if (returned) {
2530+
return { value: undefined, done: true };
2531+
}
2532+
return {
2533+
value: { data: { test: 'Hello, World' }, hasNext: true },
2534+
done: false,
2535+
};
2536+
},
2537+
return: () => {
2538+
returned = true;
2539+
return Promise.reject(new Error('Throws!'));
2540+
},
2541+
}),
2542+
};
2543+
},
2544+
})),
2545+
);
2546+
2547+
let text = '';
2548+
const request = app
2549+
.request()
2550+
.get(urlString({ query: '{test}' }))
2551+
.parse((res, cb) => {
2552+
res.on('data', (data) => {
2553+
text = `${text}${data.toString('utf8') as string}`;
2554+
((res as unknown) as http.IncomingMessage).destroy();
2555+
cb(new Error('Aborted connection'), null);
2556+
});
2557+
res.on('end', (err) => {
2558+
cb(err, null);
2559+
});
2560+
});
2561+
2562+
try {
2563+
await request;
2564+
} catch (e: unknown) {
2565+
// ignore aborted error
2566+
}
2567+
// sleep to allow return function to be called
2568+
await sleep(2);
2569+
expect(text).to.equal(
2570+
[
2571+
'',
2572+
'---',
2573+
'Content-Type: application/json; charset=utf-8',
2574+
'Content-Length: 47',
2575+
'',
2576+
'{"data":{"test":"Hello, World"},"hasNext":true}',
2577+
'',
2578+
].join('\r\n'),
2579+
);
2580+
});
24392581
});
24402582

24412583
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)