Skip to content

Commit d4a5a10

Browse files
committed
refactor: improve support for persistent queries
1 parent efb73c0 commit d4a5a10

10 files changed

+290
-159
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@vitest/coverage-v8": "3.0.2",
6060
"tsup": "8.3.5",
6161
"typescript": "5.7.3",
62+
"vite-tsconfig-paths": "5.1.4",
6263
"vitest": "3.0.2",
6364
"vitest-fetch-mock": "^0.2.2"
6465
},

pnpm-lock.yaml

+41
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ describe("gqlClientFetch", () => {
196196
{
197197
myVar: "baz",
198198
},
199-
controller.signal,
199+
{ signal: controller.signal },
200200
);
201201

202202
expect(fetchMock).toHaveBeenCalledWith(

src/client.ts

+42-53
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
2+
import { print } from "graphql";
3+
import { isNode } from "graphql/language/ast.js";
4+
import {
5+
asRequestBody,
6+
asRequestURL,
7+
createRequest,
8+
isPersistedQuery,
9+
type ModeFlags,
10+
} from "request";
211
import invariant from "tiny-invariant";
3-
import { getDocumentId, type GqlResponse } from "./helpers";
412
import {
5-
createSha256,
613
errorMessage,
7-
extractOperationName,
14+
getDocumentId,
815
getQueryType,
916
hasPersistedQueryError,
1017
mergeHeaders,
18+
type GqlResponse,
1119
} from "./helpers";
12-
import { print } from "graphql";
13-
import { isNode } from "graphql/language/ast.js";
1420

1521
type Options = {
1622
/**
17-
* Enable use of persisted queries, this will always add a extra roundtrip to the server if queries aren't cacheable
23+
* Enable use of automated persisted queries, this will always add a extra
24+
* roundtrip to the server if queries aren't cacheable
1825
* @default false
1926
*/
27+
apq?: boolean;
28+
29+
/** Deprecated: use `apq: <boolean>` */
2030
persistedQueries?: boolean;
2131

2232
/**
@@ -31,6 +41,8 @@ type Options = {
3141
*/
3242
defaultHeaders?: Headers | Record<string, string>;
3343

44+
mode?: ModeFlags
45+
3446
/**
3547
* Function to customize creating the documentId from a query
3648
*
@@ -49,81 +61,53 @@ type RequestOptions = {
4961
export type ClientFetcher = <TResponse, TVariables>(
5062
astNode: DocumentTypeDecoration<TResponse, TVariables>,
5163
variables?: TVariables,
52-
options?: RequestOptions | AbortSignal, // Backwards compatibility
64+
options?: RequestOptions,
5365
) => Promise<GqlResponse<TResponse>>;
5466

5567
export const initClientFetcher =
5668
(
5769
endpoint: string,
5870
{
71+
apq = false,
5972
persistedQueries = false,
6073
defaultTimeout = 30000,
6174
defaultHeaders = {},
62-
createDocumentId = <TResult, TVariables>(
63-
query: DocumentTypeDecoration<TResult, TVariables>,
64-
): string | undefined => getDocumentId(query),
75+
mode = "document",
76+
createDocumentId = getDocumentId,
6577
}: Options = {},
6678
): ClientFetcher =>
6779
/**
6880
* Executes a GraphQL query post request on the client.
6981
*
70-
* This is the only fetcher that uses user information in the call since all user information is only
71-
* used after rendering the page for caching reasons.
82+
* This is the only fetcher that uses user information in the call since all
83+
* user information is only used after rendering the page for caching reasons.
7284
*/
7385
async <TResponse, TVariables>(
7486
astNode: DocumentTypeDecoration<TResponse, TVariables>,
7587
variables?: TVariables,
76-
optionsOrSignal: RequestOptions | AbortSignal = {
88+
options: RequestOptions = {
7789
signal: AbortSignal.timeout(defaultTimeout),
78-
} satisfies RequestOptions,
90+
},
7991
): Promise<GqlResponse<TResponse>> => {
80-
// For backwards compatibility, when options is an AbortSignal we transform
81-
// it into a RequestOptions object
82-
const options: RequestOptions = {};
83-
if (optionsOrSignal instanceof AbortSignal) {
84-
options.signal = optionsOrSignal;
85-
} else {
86-
Object.assign(options, optionsOrSignal);
87-
}
88-
8992
// Make sure that we always have a default signal set
9093
if (!options.signal) {
9194
options.signal = AbortSignal.timeout(defaultTimeout);
9295
}
9396

9497
const query = isNode(astNode) ? print(astNode) : astNode.toString();
95-
96-
const operationName = extractOperationName(query);
9798
const documentId = createDocumentId(astNode);
98-
99-
let extensions = {};
100-
if (persistedQueries) {
101-
const hash = await createSha256(query);
102-
103-
extensions = {
104-
persistedQuery: {
105-
version: 1,
106-
sha256Hash: hash,
107-
},
108-
};
109-
}
110-
111-
const url = new URL(endpoint);
112-
url.searchParams.set("op", operationName ?? "");
99+
const request = await createRequest(mode, query, variables, documentId);
113100

114101
let response: GqlResponse<TResponse> | undefined = undefined;
115-
116102
const headers = mergeHeaders({ ...defaultHeaders, ...options.headers });
117103

104+
const queryType = getQueryType(query);
105+
106+
apq = apq || persistedQueries;
107+
118108
// For queries we can use GET requests if persisted queries are enabled
119-
if (persistedQueries && getQueryType(query) === "query") {
120-
url.searchParams.set("extensions", JSON.stringify(extensions));
121-
if (variables) {
122-
url.searchParams.set("variables", JSON.stringify(variables));
123-
}
124-
if (documentId) {
125-
url.searchParams.set("documentId", documentId);
126-
}
109+
if (queryType === "query" && (apq || isPersistedQuery(request))) {
110+
const url = asRequestURL(endpoint, request);
127111
response = await parseResponse<GqlResponse<TResponse>>(() =>
128112
fetch(url.toString(), {
129113
headers: Object.fromEntries(headers.entries()),
@@ -134,13 +118,18 @@ export const initClientFetcher =
134118
);
135119
}
136120

137-
if (!response || hasPersistedQueryError(response)) {
138-
// Persisted query not used or found, fall back to POST request and include extension to cache the query on the server
121+
// For failed APQ calls or mutations we need to fall back to POST requests
122+
if (
123+
!response ||
124+
(isPersistedQuery(request) && hasPersistedQueryError(response))
125+
) {
126+
// Persisted query not used or found, fall back to POST request and
127+
// include extension to cache the query on the server
139128
response = await parseResponse<GqlResponse<TResponse>>(() =>
140-
fetch(url.toString(), {
129+
fetch(endpoint, {
141130
headers: Object.fromEntries(headers.entries()),
142131
method: "POST",
143-
body: JSON.stringify({ documentId, query, variables, extensions }),
132+
body: asRequestBody(request),
144133
credentials: "include",
145134
signal: options.signal,
146135
}),

src/helpers.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const mergeHeaders = (
2828
return result;
2929
};
3030

31-
export const getQueryType = (query: string) =>
31+
export const getQueryType = (query: string): "query" | "mutation" =>
3232
query.trim().startsWith("query") ? "query" : "mutation";
3333

3434
export const getDocumentId = <TResult, TVariables>(
@@ -55,8 +55,17 @@ export interface NextFetchRequestConfig {
5555
tags?: string[];
5656
}
5757

58-
export const pruneObject = <T>(object: T): Partial<T> =>
59-
JSON.parse(JSON.stringify(object ?? null));
58+
export const pruneObject = <T>(object: T): Partial<T> => {
59+
const data: Record<string, unknown> = {}
60+
for (const key in object) {
61+
if (isNotEmpty(object[key])) {
62+
data[key] = object[key];
63+
}
64+
}
65+
return JSON.parse(JSON.stringify(data ?? null));
66+
}
67+
68+
const isNotEmpty = (value: unknown) => value && Object.keys(value).length > 0;
6069

6170
// createSha256 creates a sha256 hash from a message with the same algorithm as
6271
// Apollo Server, so we know for certain the same hash is used for automatic

0 commit comments

Comments
 (0)