Skip to content

Commit cacf20f

Browse files
authored
feat(executors): implement disposable (#6323)
* feat(executors): implement disposable * Ah typo * Go
1 parent 8f6a514 commit cacf20f

File tree

11 files changed

+232
-122
lines changed

11 files changed

+232
-122
lines changed

.changeset/wise-singers-cry.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@graphql-tools/executor-graphql-ws": minor
3+
"@graphql-tools/executor-legacy-ws": minor
4+
"@graphql-tools/executor-http": minor
5+
"@graphql-tools/utils": minor
6+
---
7+
8+
Implement Symbol.dispose or Symbol.asyncDispose to make \`Executor\`s \`Disposable\`

packages/executors/graphql-ws/src/index.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import { print } from 'graphql';
22
import { Client, ClientOptions, createClient } from 'graphql-ws';
33
import WebSocket from 'isomorphic-ws';
44
import {
5+
DisposableExecutor,
56
ExecutionRequest,
6-
ExecutionResult,
7-
Executor,
87
getOperationASTFromRequest,
8+
memoize1,
99
} from '@graphql-tools/utils';
1010

11+
const defaultPrintFn = memoize1(print);
12+
1113
interface GraphQLWSExecutorOptions extends ClientOptions {
1214
onClient?: (client: Client) => void;
15+
print?: typeof print;
1316
}
1417

1518
function isClient(client: Client | GraphQLWSExecutorOptions): client is Client {
@@ -18,12 +21,16 @@ function isClient(client: Client | GraphQLWSExecutorOptions): client is Client {
1821

1922
export function buildGraphQLWSExecutor(
2023
clientOptionsOrClient: GraphQLWSExecutorOptions | Client,
21-
): Executor {
24+
): DisposableExecutor {
2225
let graphqlWSClient: Client;
2326
let executorConnectionParams = {};
27+
let printFn = defaultPrintFn;
2428
if (isClient(clientOptionsOrClient)) {
2529
graphqlWSClient = clientOptionsOrClient;
2630
} else {
31+
if (clientOptionsOrClient.print) {
32+
printFn = clientOptionsOrClient.print;
33+
}
2734
graphqlWSClient = createClient({
2835
...clientOptionsOrClient,
2936
webSocketImpl: WebSocket,
@@ -40,14 +47,12 @@ export function buildGraphQLWSExecutor(
4047
clientOptionsOrClient.onClient(graphqlWSClient);
4148
}
4249
}
43-
return function GraphQLWSExecutor<
50+
const executor = function GraphQLWSExecutor<
4451
TData,
4552
TArgs extends Record<string, any>,
4653
TRoot,
4754
TExtensions extends Record<string, any>,
48-
>(
49-
executionRequest: ExecutionRequest<TArgs, any, TRoot, TExtensions>,
50-
): AsyncIterableIterator<ExecutionResult<TData>> | Promise<ExecutionResult<TData>> {
55+
>(executionRequest: ExecutionRequest<TArgs, any, TRoot, TExtensions>) {
5156
const {
5257
document,
5358
variables,
@@ -63,7 +68,7 @@ export function buildGraphQLWSExecutor(
6368
extensions['connectionParams'],
6469
);
6570
}
66-
const query = print(document);
71+
const query = printFn(document);
6772
const iterableIterator = graphqlWSClient.iterate<TData, TExtensions>({
6873
query,
6974
variables,
@@ -75,4 +80,9 @@ export function buildGraphQLWSExecutor(
7580
}
7681
return iterableIterator.next().then(({ value }) => value);
7782
};
83+
const disposableExecutor: DisposableExecutor = executor;
84+
disposableExecutor[Symbol.asyncDispose] = function disposeWS() {
85+
return graphqlWSClient.dispose();
86+
};
87+
return disposableExecutor;
7888
}

packages/executors/graphql-ws/tests/graphql-ws.test.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { createServer, Server } from 'http';
22
import { AddressInfo } from 'net';
33
import { parse } from 'graphql';
44
import { useServer } from 'graphql-ws/lib/use/ws';
5+
import { Repeater } from 'graphql-yoga';
56
import { WebSocketServer } from 'ws'; // yarn add ws
67

78
import { buildGraphQLWSExecutor } from '@graphql-tools/executor-graphql-ws';
89
import { makeExecutableSchema } from '@graphql-tools/schema';
910
import { Executor, isAsyncIterable } from '@graphql-tools/utils';
11+
import { assertAsyncIterable } from '../../../loaders/url/tests/test-utils';
1012

1113
describe('GraphQL WS Executor', () => {
1214
let server: Server;
@@ -35,10 +37,28 @@ describe('GraphQL WS Executor', () => {
3537
},
3638
Subscription: {
3739
count: {
38-
subscribe: async function* (_root, { to }) {
39-
for (let i = 0; i < to; i++) {
40-
yield { count: i };
41-
}
40+
subscribe(_, { to }: { to: number }) {
41+
return new Repeater((push, stop) => {
42+
let i = 0;
43+
let closed = false;
44+
let timeout: NodeJS.Timeout;
45+
const pump = async () => {
46+
if (closed) {
47+
return;
48+
}
49+
await push({ count: i });
50+
if (i++ < to) {
51+
timeout = setTimeout(pump, 150);
52+
} else {
53+
stop();
54+
}
55+
};
56+
stop.then(() => {
57+
closed = true;
58+
clearTimeout(timeout);
59+
});
60+
pump();
61+
});
4262
},
4363
},
4464
},
@@ -53,8 +73,9 @@ describe('GraphQL WS Executor', () => {
5373
url: `ws://localhost:${(server.address() as AddressInfo).port}/graphql`,
5474
});
5575
});
56-
afterAll(async () => {
57-
await new Promise(resolve => server.close(resolve));
76+
afterAll(done => {
77+
server.closeAllConnections();
78+
server.close(done);
5879
});
5980
it('should return a promise of an execution result for regular queries', async () => {
6081
const result = await executor({
@@ -92,6 +113,25 @@ describe('GraphQL WS Executor', () => {
92113
{ data: { count: 0 } },
93114
{ data: { count: 1 } },
94115
{ data: { count: 2 } },
116+
{ data: { count: 3 } },
95117
]);
96118
});
119+
it('should close connections when disposed', async () => {
120+
const result = await executor({
121+
document: parse(/* GraphQL */ `
122+
subscription {
123+
count(to: 4)
124+
}
125+
`),
126+
});
127+
assertAsyncIterable(result);
128+
for await (const item of result) {
129+
if (item.data?.count === 2) {
130+
await executor[Symbol.asyncDispose]();
131+
}
132+
if (item.data?.count === 3) {
133+
throw new Error('Expected connection to be closed before receiving the third item');
134+
}
135+
}
136+
});
97137
});

packages/executors/http/src/addCancelToResponseStream.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/executors/http/src/handleEventStreamResponse.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
11
import { ExecutionResult, inspect, isAsyncIterable } from '@graphql-tools/utils';
2-
import { addCancelToResponseStream } from './addCancelToResponseStream.js';
32
import { handleAsyncIterable } from './handleAsyncIterable.js';
43
import { handleReadableStream } from './handleReadableStream.js';
54

65
export function isReadableStream(value: any): value is ReadableStream {
76
return value && typeof value.getReader === 'function';
87
}
98

10-
export function handleEventStreamResponse(
11-
response: Response,
12-
controller?: AbortController,
13-
): AsyncIterable<ExecutionResult> {
9+
export function handleEventStreamResponse(response: Response): AsyncIterable<ExecutionResult> {
1410
// node-fetch returns body as a promise so we need to resolve it
1511
const body = response.body;
1612
if (body) {
1713
if (isAsyncIterable<Uint8Array | string>(body)) {
18-
const resultStream = handleAsyncIterable(body);
19-
if (controller) {
20-
return addCancelToResponseStream(resultStream, controller);
21-
} else {
22-
return resultStream;
23-
}
14+
return handleAsyncIterable(body);
2415
}
2516
if (isReadableStream(body)) {
2617
return handleReadableStream(body);

packages/executors/http/src/handleMultipartMixedResponse.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { IncomingMessage } from 'http';
22
import { meros as merosReadableStream } from 'meros/browser';
33
import { meros as merosIncomingMessage } from 'meros/node';
44
import { ExecutionResult, mapAsyncIterator, mergeIncrementalResult } from '@graphql-tools/utils';
5-
import { addCancelToResponseStream } from './addCancelToResponseStream.js';
65

76
type Part =
87
| {
@@ -18,10 +17,7 @@ function isIncomingMessage(body: any): body is IncomingMessage {
1817
return body != null && typeof body === 'object' && 'pipe' in body;
1918
}
2019

21-
export async function handleMultipartMixedResponse(
22-
response: Response,
23-
controller?: AbortController,
24-
) {
20+
export async function handleMultipartMixedResponse(response: Response) {
2521
const body = response.body;
2622
const contentType = response.headers.get('content-type') || '';
2723
let asyncIterator: AsyncIterator<Part> | undefined;
@@ -60,9 +56,5 @@ export async function handleMultipartMixedResponse(
6056
}
6157
});
6258

63-
if (controller) {
64-
return addCancelToResponseStream(resultStream, controller);
65-
}
66-
6759
return resultStream;
6860
}

packages/executors/http/src/index.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { DocumentNode, GraphQLResolveInfo } from 'graphql';
22
import { ValueOrPromise } from 'value-or-promise';
33
import {
4-
AsyncExecutor,
54
createGraphQLError,
5+
DisposableAsyncExecutor,
6+
DisposableExecutor,
7+
DisposableSyncExecutor,
68
ExecutionRequest,
79
ExecutionResult,
810
Executor,
911
getOperationASTFromRequest,
10-
SyncExecutor,
1112
} from '@graphql-tools/utils';
1213
import { fetch as defaultFetch } from '@whatwg-node/fetch';
1314
import { createFormDataFromVariables } from './createFormDataFromVariables.js';
@@ -90,27 +91,31 @@ export type HeadersConfig = Record<string, string>;
9091

9192
export function buildHTTPExecutor(
9293
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: SyncFetchFn },
93-
): SyncExecutor<any, HTTPExecutorOptions>;
94+
): DisposableSyncExecutor<any, HTTPExecutorOptions>;
9495

9596
export function buildHTTPExecutor(
9697
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: AsyncFetchFn },
97-
): AsyncExecutor<any, HTTPExecutorOptions>;
98+
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
9899

99100
export function buildHTTPExecutor(
100101
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: RegularFetchFn },
101-
): AsyncExecutor<any, HTTPExecutorOptions>;
102+
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
102103

103104
export function buildHTTPExecutor(
104105
options?: Omit<HTTPExecutorOptions, 'fetch'>,
105-
): AsyncExecutor<any, HTTPExecutorOptions>;
106+
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
106107

107108
export function buildHTTPExecutor(
108109
options?: HTTPExecutorOptions,
109110
): Executor<any, HTTPExecutorOptions> {
110111
const printFn = options?.print ?? defaultPrintFn;
112+
const controller = new AbortController();
111113
const executor = (request: ExecutionRequest<any, any, any, HTTPExecutorOptions>) => {
114+
if (controller.signal.aborted) {
115+
throw new Error('Executor was disposed. Aborting execution');
116+
}
112117
const fetchFn = request.extensions?.fetch ?? options?.fetch ?? defaultFetch;
113-
let controller: AbortController | undefined;
118+
let signal = controller.signal;
114119
let method = request.extensions?.method || options?.method;
115120

116121
const operationAst = getOperationASTFromRequest(request);
@@ -149,14 +154,10 @@ export function buildHTTPExecutor(
149154

150155
const query = printFn(request.document);
151156

152-
let timeoutId: any;
153157
if (options?.timeout) {
154-
controller = new AbortController();
155-
timeoutId = setTimeout(() => {
156-
if (!controller?.signal.aborted) {
157-
controller?.abort('timeout');
158-
}
159-
}, options.timeout);
158+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
159+
// @ts-ignore AbortSignal.any is not yet in the DOM types
160+
signal = AbortSignal.any([signal, AbortSignal.timeout(options.timeout)]);
160161
}
161162

162163
const responseDetailsForError: {
@@ -177,7 +178,7 @@ export function buildHTTPExecutor(
177178
const fetchOptions: RequestInit = {
178179
method: 'GET',
179180
headers,
180-
signal: controller?.signal,
181+
signal,
181182
};
182183
if (options?.credentials != null) {
183184
fetchOptions.credentials = options.credentials;
@@ -207,7 +208,7 @@ export function buildHTTPExecutor(
207208
method: 'POST',
208209
body,
209210
headers,
210-
signal: controller?.signal,
211+
signal,
211212
};
212213
if (options?.credentials != null) {
213214
fetchOptions.credentials = options.credentials;
@@ -220,9 +221,6 @@ export function buildHTTPExecutor(
220221
.then((fetchResult: Response): any => {
221222
responseDetailsForError.status = fetchResult.status;
222223
responseDetailsForError.statusText = fetchResult.statusText;
223-
if (timeoutId != null) {
224-
clearTimeout(timeoutId);
225-
}
226224

227225
// Retry should respect HTTP Errors
228226
if (options?.retry != null && !fetchResult.status.toString().startsWith('2')) {
@@ -231,9 +229,9 @@ export function buildHTTPExecutor(
231229

232230
const contentType = fetchResult.headers.get('content-type');
233231
if (contentType?.includes('text/event-stream')) {
234-
return handleEventStreamResponse(fetchResult, controller);
232+
return handleEventStreamResponse(fetchResult);
235233
} else if (contentType?.includes('multipart/mixed')) {
236-
return handleMultipartMixedResponse(fetchResult, controller);
234+
return handleMultipartMixedResponse(fetchResult);
237235
}
238236

239237
return fetchResult.text();
@@ -317,10 +315,10 @@ export function buildHTTPExecutor(
317315
}),
318316
],
319317
};
320-
} else if (e.name === 'AbortError' && controller?.signal?.reason) {
318+
} else if (e.name === 'AbortError' && signal?.reason) {
321319
return {
322320
errors: [
323-
createGraphQLError('The operation was aborted. reason: ' + controller.signal.reason, {
321+
createGraphQLError('The operation was aborted. reason: ' + signal.reason, {
324322
extensions: {
325323
requestBody: {
326324
query,
@@ -395,7 +393,17 @@ export function buildHTTPExecutor(
395393
};
396394
}
397395

398-
return executor;
396+
const disposableExecutor: DisposableExecutor = executor;
397+
398+
disposableExecutor[Symbol.dispose] = () => {
399+
return controller.abort(new Error('Executor was disposed. Aborting execution'));
400+
};
401+
402+
disposableExecutor[Symbol.asyncDispose] = () => {
403+
return controller.abort(new Error('Executor was disposed. Aborting execution'));
404+
};
405+
406+
return disposableExecutor;
399407
}
400408

401409
export { isLiveQueryOperationDefinitionNode };

0 commit comments

Comments
 (0)