Skip to content

Commit 9792e80

Browse files
authored
enhance(executor-http): add disposable option (#6325)
* enhance(executor-http): add disposability lazily * Optional * Fix types * TS again
1 parent d5baf88 commit 9792e80

File tree

6 files changed

+154
-52
lines changed

6 files changed

+154
-52
lines changed

.changeset/yellow-weeks-refuse.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@graphql-tools/executor-http": patch
3+
"@graphql-tools/utils": patch
4+
---
5+
6+
Make the executor disposable optional

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function buildGraphQLWSExecutor(
8080
}
8181
return iterableIterator.next().then(({ value }) => value);
8282
};
83-
const disposableExecutor: DisposableExecutor = executor;
83+
const disposableExecutor = executor as DisposableExecutor;
8484
disposableExecutor[Symbol.asyncDispose] = function disposeWS() {
8585
return graphqlWSClient.dispose();
8686
};

packages/executors/http/src/index.ts

Lines changed: 126 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DocumentNode, GraphQLResolveInfo } from 'graphql';
22
import { ValueOrPromise } from 'value-or-promise';
33
import {
4+
AsyncExecutor,
45
createGraphQLError,
56
DisposableAsyncExecutor,
67
DisposableExecutor,
@@ -9,6 +10,7 @@ import {
910
ExecutionResult,
1011
Executor,
1112
getOperationASTFromRequest,
13+
SyncExecutor,
1214
} from '@graphql-tools/utils';
1315
import { fetch as defaultFetch } from '@whatwg-node/fetch';
1416
import { createFormDataFromVariables } from './createFormDataFromVariables.js';
@@ -85,34 +87,89 @@ export interface HTTPExecutorOptions {
8587
* Print function for DocumentNode
8688
*/
8789
print?: (doc: DocumentNode) => string;
90+
/**
91+
* Enable [Explicit Resource Management](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management)
92+
* @default false
93+
*/
94+
disposable?: boolean;
8895
}
8996

9097
export type HeadersConfig = Record<string, string>;
9198

9299
export function buildHTTPExecutor(
93-
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: SyncFetchFn },
100+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
101+
fetch: SyncFetchFn;
102+
disposable: true;
103+
},
94104
): DisposableSyncExecutor<any, HTTPExecutorOptions>;
95105

106+
export function buildHTTPExecutor(
107+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
108+
fetch: SyncFetchFn;
109+
disposable: false;
110+
},
111+
): SyncExecutor<any, HTTPExecutorOptions>;
112+
113+
export function buildHTTPExecutor(
114+
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: SyncFetchFn },
115+
): SyncExecutor<any, HTTPExecutorOptions>;
116+
117+
export function buildHTTPExecutor(
118+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
119+
fetch: AsyncFetchFn;
120+
disposable: true;
121+
},
122+
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
123+
124+
export function buildHTTPExecutor(
125+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
126+
fetch: AsyncFetchFn;
127+
disposable: false;
128+
},
129+
): AsyncExecutor<any, HTTPExecutorOptions>;
130+
96131
export function buildHTTPExecutor(
97132
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: AsyncFetchFn },
133+
): AsyncExecutor<any, HTTPExecutorOptions>;
134+
135+
export function buildHTTPExecutor(
136+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
137+
fetch: RegularFetchFn;
138+
disposable: true;
139+
},
98140
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
99141

142+
export function buildHTTPExecutor(
143+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & {
144+
fetch: RegularFetchFn;
145+
disposable: false;
146+
},
147+
): AsyncExecutor<any, HTTPExecutorOptions>;
148+
100149
export function buildHTTPExecutor(
101150
options?: Omit<HTTPExecutorOptions, 'fetch'> & { fetch: RegularFetchFn },
151+
): AsyncExecutor<any, HTTPExecutorOptions>;
152+
153+
export function buildHTTPExecutor(
154+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & { disposable: true },
102155
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
103156

157+
export function buildHTTPExecutor(
158+
options?: Omit<HTTPExecutorOptions, 'fetch' | 'disposable'> & { disposable: false },
159+
): AsyncExecutor<any, HTTPExecutorOptions>;
160+
104161
export function buildHTTPExecutor(
105162
options?: Omit<HTTPExecutorOptions, 'fetch'>,
106-
): DisposableAsyncExecutor<any, HTTPExecutorOptions>;
163+
): AsyncExecutor<any, HTTPExecutorOptions>;
107164

108165
export function buildHTTPExecutor(
109166
options?: HTTPExecutorOptions,
110-
): Executor<any, HTTPExecutorOptions> {
167+
): DisposableExecutor<any, HTTPExecutorOptions> | Executor<any, HTTPExecutorOptions> {
111168
const printFn = options?.print ?? defaultPrintFn;
112-
const disposeCtrl = new AbortController();
113-
const executor = (request: ExecutionRequest<any, any, any, HTTPExecutorOptions>) => {
114-
if (disposeCtrl.signal.aborted) {
115-
throw new Error('Executor was disposed. Aborting execution');
169+
let disposeCtrl: AbortController | undefined;
170+
const baseExecutor = (request: ExecutionRequest<any, any, any, HTTPExecutorOptions>) => {
171+
if (disposeCtrl?.signal.aborted) {
172+
return createResultForAbort(disposeCtrl.signal);
116173
}
117174
const fetchFn = request.extensions?.fetch ?? options?.fetch ?? defaultFetch;
118175
let method = request.extensions?.method || options?.method;
@@ -153,17 +210,17 @@ export function buildHTTPExecutor(
153210

154211
const query = printFn(request.document);
155212

156-
let signal = disposeCtrl.signal;
213+
let signal = disposeCtrl?.signal;
157214
let clearTimeoutFn: VoidFunction = () => {};
158215
if (options?.timeout) {
159216
const timeoutCtrl = new AbortController();
160217
signal = timeoutCtrl.signal;
161-
disposeCtrl.signal.addEventListener('abort', clearTimeoutFn);
218+
disposeCtrl?.signal.addEventListener('abort', clearTimeoutFn);
162219
const timeoutId = setTimeout(() => {
163220
if (!timeoutCtrl.signal.aborted) {
164221
timeoutCtrl.abort('timeout');
165222
}
166-
disposeCtrl.signal.removeEventListener('abort', clearTimeoutFn);
223+
disposeCtrl?.signal.removeEventListener('abort', clearTimeoutFn);
167224
}, options.timeout);
168225
clearTimeoutFn = () => {
169226
clearTimeout(timeoutId);
@@ -349,20 +406,17 @@ export function buildHTTPExecutor(
349406
],
350407
};
351408
} else if (e.name === 'AbortError' && signal?.reason) {
352-
return {
353-
errors: [
354-
createGraphQLError('The operation was aborted. reason: ' + signal.reason, {
355-
extensions: {
356-
requestBody: {
357-
query,
358-
operationName: request.operationName,
359-
},
360-
responseDetails: responseDetailsForError,
361-
},
362-
originalError: e,
363-
}),
364-
],
365-
};
409+
return createResultForAbort(
410+
signal,
411+
{
412+
requestBody: {
413+
query,
414+
operationName: request.operationName,
415+
},
416+
responseDetails: responseDetailsForError,
417+
},
418+
e,
419+
);
366420
} else if (e.message) {
367421
return {
368422
errors: [
@@ -398,11 +452,16 @@ export function buildHTTPExecutor(
398452
.resolve();
399453
};
400454

455+
let executor: Executor = baseExecutor;
456+
401457
if (options?.retry != null) {
402-
return function retryExecutor(request: ExecutionRequest) {
458+
executor = function retryExecutor(request: ExecutionRequest) {
403459
let result: ExecutionResult<any> | undefined;
404460
let attempt = 0;
405461
function retryAttempt(): Promise<ExecutionResult<any>> | ExecutionResult<any> {
462+
if (disposeCtrl?.signal.aborted) {
463+
return createResultForAbort(disposeCtrl.signal);
464+
}
406465
attempt++;
407466
if (attempt > options!.retry!) {
408467
if (result != null) {
@@ -412,7 +471,7 @@ export function buildHTTPExecutor(
412471
errors: [createGraphQLError('No response returned from fetch')],
413472
};
414473
}
415-
return new ValueOrPromise(() => executor(request))
474+
return new ValueOrPromise(() => baseExecutor(request))
416475
.then(res => {
417476
result = res;
418477
if (result?.errors?.length) {
@@ -426,17 +485,50 @@ export function buildHTTPExecutor(
426485
};
427486
}
428487

429-
const disposableExecutor: DisposableExecutor = executor;
488+
if (!options?.disposable) {
489+
disposeCtrl = undefined;
490+
return executor;
491+
}
430492

431-
disposableExecutor[Symbol.dispose] = () => {
432-
return disposeCtrl.abort(new Error('Executor was disposed. Aborting execution'));
433-
};
493+
disposeCtrl = new AbortController();
494+
495+
Object.defineProperties(executor, {
496+
[Symbol.dispose]: {
497+
get() {
498+
return function dispose() {
499+
return disposeCtrl!.abort(createAbortErrorReason());
500+
};
501+
},
502+
},
503+
[Symbol.asyncDispose]: {
504+
get() {
505+
return function asyncDispose() {
506+
return disposeCtrl!.abort(createAbortErrorReason());
507+
};
508+
},
509+
},
510+
});
511+
512+
return executor;
513+
}
434514

435-
disposableExecutor[Symbol.asyncDispose] = () => {
436-
return disposeCtrl.abort(new Error('Executor was disposed. Aborting execution'));
437-
};
515+
function createAbortErrorReason() {
516+
return new Error('Executor was disposed.');
517+
}
438518

439-
return disposableExecutor;
519+
function createResultForAbort(
520+
signal: AbortSignal,
521+
extensions?: Record<string, any>,
522+
originalError?: Error,
523+
) {
524+
return {
525+
errors: [
526+
createGraphQLError('The operation was aborted. reason: ' + signal.reason, {
527+
extensions,
528+
originalError,
529+
}),
530+
],
531+
};
440532
}
441533

442534
export { isLiveQueryOperationDefinitionNode };

packages/executors/http/tests/buildHTTPExecutor.test.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ describe('buildHTTPExecutor', () => {
223223
await new Promise<void>(resolve => server.listen(0, resolve));
224224
const executor = buildHTTPExecutor({
225225
endpoint: `http://localhost:${(server.address() as any).port}`,
226+
disposable: true,
226227
});
227228
const result = executor({
228229
document: parse(/* GraphQL */ `
@@ -231,29 +232,31 @@ describe('buildHTTPExecutor', () => {
231232
}
232233
`),
233234
});
234-
executor[Symbol.dispose]?.();
235+
await executor[Symbol.asyncDispose]();
235236
await expect(result).resolves.toEqual({
236237
errors: [
237-
createGraphQLError(
238-
'The operation was aborted. reason: Error: Executor was disposed. Aborting execution',
239-
),
238+
createGraphQLError('The operation was aborted. reason: Error: Executor was disposed.'),
240239
],
241240
});
242241
});
243242
it('does not allow new requests when the executor is disposed', async () => {
244243
const executor = buildHTTPExecutor({
245244
fetch: () => Response.json({ data: { hello: 'world' } }),
245+
disposable: true,
246246
});
247247
executor[Symbol.dispose]?.();
248-
expect(() =>
249-
executor({
250-
document: parse(/* GraphQL */ `
251-
query {
252-
hello
253-
}
254-
`),
255-
}),
256-
).toThrow('Executor was disposed. Aborting execution');
248+
const result = await executor({
249+
document: parse(/* GraphQL */ `
250+
query {
251+
hello
252+
}
253+
`),
254+
});
255+
expect(result).toMatchObject({
256+
errors: [
257+
createGraphQLError('The operation was aborted. reason: Error: Executor was disposed.'),
258+
],
259+
});
257260
});
258261
it('should return return GraphqlError instances', async () => {
259262
const executor = buildHTTPExecutor({

packages/federation/src/gateway.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { createDefaultExecutor, SubschemaConfig } from '@graphql-tools/delegate'
1414
import { buildHTTPExecutor, HTTPExecutorOptions } from '@graphql-tools/executor-http';
1515
import { stitchSchemas, SubschemaConfigTransform } from '@graphql-tools/stitch';
1616
import {
17-
AsyncExecutor,
1817
createGraphQLError,
1918
ExecutionResult,
2019
Executor,
@@ -40,7 +39,7 @@ export const SubgraphSDLQuery = /* GraphQL */ `
4039
export async function getSubschemaForFederationWithURL(
4140
config: HTTPExecutorOptions,
4241
): Promise<SubschemaConfig> {
43-
const executor = buildHTTPExecutor(config as any) as AsyncExecutor;
42+
const executor = buildHTTPExecutor(config);
4443
const subschemaConfig = await getSubschemaForFederationWithExecutor(executor);
4544
return {
4645
batch: true,

packages/utils/src/executor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ export type Executor<TBaseContext = Record<string, any>, TBaseExtensions = Recor
4242
export type DisposableSyncExecutor<
4343
TBaseContext = Record<string, any>,
4444
TBaseExtensions = Record<string, any>,
45-
> = SyncExecutor<TBaseContext, TBaseExtensions> & { [Symbol.dispose]?: () => void };
45+
> = SyncExecutor<TBaseContext, TBaseExtensions> & { [Symbol.dispose]: () => void };
4646
export type DisposableAsyncExecutor<
4747
TBaseContext = Record<string, any>,
4848
TBaseExtensions = Record<string, any>,
49-
> = AsyncExecutor<TBaseContext, TBaseExtensions> & { [Symbol.dispose]?: () => void };
49+
> = AsyncExecutor<TBaseContext, TBaseExtensions> & {
50+
[Symbol.asyncDispose]: () => PromiseLike<void>;
51+
};
5052
export type DisposableExecutor<
5153
TBaseContext = Record<string, any>,
5254
TBaseExtensions = Record<string, any>,

0 commit comments

Comments
 (0)