Skip to content

Commit f91d8e9

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

10 files changed

+234
-153
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

+37-53
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
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+
} from "request";
210
import invariant from "tiny-invariant";
3-
import { getDocumentId, type GqlResponse } from "./helpers";
411
import {
5-
createSha256,
612
errorMessage,
7-
extractOperationName,
13+
getDocumentId,
814
getQueryType,
915
hasPersistedQueryError,
1016
mergeHeaders,
17+
type GqlResponse,
1118
} from "./helpers";
12-
import { print } from "graphql";
13-
import { isNode } from "graphql/language/ast.js";
1419

1520
type Options = {
1621
/**
17-
* Enable use of persisted queries, this will always add a extra roundtrip to the server if queries aren't cacheable
22+
* Enable use of automated persisted queries, this will always add a extra
23+
* roundtrip to the server if queries aren't cacheable
1824
* @default false
1925
*/
26+
apq?: boolean;
27+
28+
/** Deprecated: use `apq: <boolean>` */
2029
persistedQueries?: boolean;
2130

2231
/**
@@ -49,81 +58,52 @@ type RequestOptions = {
4958
export type ClientFetcher = <TResponse, TVariables>(
5059
astNode: DocumentTypeDecoration<TResponse, TVariables>,
5160
variables?: TVariables,
52-
options?: RequestOptions | AbortSignal, // Backwards compatibility
61+
options?: RequestOptions,
5362
) => Promise<GqlResponse<TResponse>>;
5463

5564
export const initClientFetcher =
5665
(
5766
endpoint: string,
5867
{
68+
apq = false,
5969
persistedQueries = false,
6070
defaultTimeout = 30000,
6171
defaultHeaders = {},
62-
createDocumentId = <TResult, TVariables>(
63-
query: DocumentTypeDecoration<TResult, TVariables>,
64-
): string | undefined => getDocumentId(query),
72+
createDocumentId = getDocumentId,
6573
}: Options = {},
6674
): ClientFetcher =>
6775
/**
6876
* Executes a GraphQL query post request on the client.
6977
*
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.
78+
* This is the only fetcher that uses user information in the call since all
79+
* user information is only used after rendering the page for caching reasons.
7280
*/
7381
async <TResponse, TVariables>(
7482
astNode: DocumentTypeDecoration<TResponse, TVariables>,
7583
variables?: TVariables,
76-
optionsOrSignal: RequestOptions | AbortSignal = {
84+
options: RequestOptions = {
7785
signal: AbortSignal.timeout(defaultTimeout),
78-
} satisfies RequestOptions,
86+
},
7987
): 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-
8988
// Make sure that we always have a default signal set
9089
if (!options.signal) {
9190
options.signal = AbortSignal.timeout(defaultTimeout);
9291
}
9392

9493
const query = isNode(astNode) ? print(astNode) : astNode.toString();
95-
96-
const operationName = extractOperationName(query);
9794
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 ?? "");
95+
const request = await createRequest(query, variables, documentId);
11396

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

100+
const queryType = getQueryType(query);
101+
102+
apq = apq || persistedQueries;
103+
118104
// 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-
}
105+
if (queryType === "query" && (apq || isPersistedQuery(request))) {
106+
const url = asRequestURL(endpoint, request);
127107
response = await parseResponse<GqlResponse<TResponse>>(() =>
128108
fetch(url.toString(), {
129109
headers: Object.fromEntries(headers.entries()),
@@ -134,13 +114,17 @@ export const initClientFetcher =
134114
);
135115
}
136116

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
117+
if (
118+
!response ||
119+
(isPersistedQuery(request) && hasPersistedQueryError(response))
120+
) {
121+
// Persisted query not used or found, fall back to POST request and
122+
// include extension to cache the query on the server
139123
response = await parseResponse<GqlResponse<TResponse>>(() =>
140-
fetch(url.toString(), {
124+
fetch(endpoint, {
141125
headers: Object.fromEntries(headers.entries()),
142126
method: "POST",
143-
body: JSON.stringify({ documentId, query, variables, extensions }),
127+
body: asRequestBody(request),
144128
credentials: "include",
145129
signal: options.signal,
146130
}),

src/helpers.ts

+1-1
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>(

src/request.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { DocumentTypeDecoration } from "@graphql-typed-document-node/core";
2+
import { createSha256, extractOperationName, pruneObject } from "helpers";
3+
4+
type DocumentIdFn = <TResult, TVariables>(
5+
query: DocumentTypeDecoration<TResult, TVariables>,
6+
) => string | undefined;
7+
8+
export type DefaultGraphQLRequest<TVariables> = {
9+
operationName: string;
10+
query: string;
11+
variables: TVariables | undefined;
12+
extensions: Record<string, unknown>;
13+
};
14+
15+
export type PersistedGraphQLRequest<TVariables> = {
16+
documentId: string;
17+
operationName: string;
18+
variables: TVariables | undefined;
19+
extensions: Record<string, unknown>;
20+
};
21+
22+
export type GraphQLRequest<TVariables> =
23+
| DefaultGraphQLRequest<TVariables>
24+
| PersistedGraphQLRequest<TVariables>;
25+
26+
export const isPersistedQuery = <T>(
27+
request: GraphQLRequest<T>,
28+
): request is PersistedGraphQLRequest<T> => "documentId" in request;
29+
30+
export const asQueryString = <TVariables>(
31+
request: GraphQLRequest<TVariables>,
32+
) =>
33+
new URLSearchParams(
34+
pruneObject({
35+
...(isPersistedQuery(request)
36+
? {
37+
documentId: request.documentId,
38+
}
39+
: {
40+
query: request.query,
41+
}),
42+
operationName: request.operationName,
43+
variables: JSON.stringify(request.variables),
44+
extensions: JSON.stringify(request.extensions),
45+
}),
46+
);
47+
48+
export const asRequestURL = <TVariables>(
49+
url: string,
50+
request: GraphQLRequest<TVariables>,
51+
): URL => {
52+
const result = new URL(url);
53+
const qs = asQueryString(request);
54+
for (const [key, value] of qs) {
55+
result.searchParams.append(key, value);
56+
}
57+
return result;
58+
};
59+
60+
export const asRequestBody = <TVariables>(
61+
request: GraphQLRequest<TVariables>,
62+
) => JSON.stringify(request);
63+
64+
export const createRequest = async <TVariables>(
65+
query: string,
66+
variables: TVariables,
67+
documentId?: string,
68+
): Promise<GraphQLRequest<TVariables>> => {
69+
const operationName = extractOperationName(query) || "(GraphQL)";
70+
71+
if (documentId) {
72+
return {
73+
documentId,
74+
operationName,
75+
variables,
76+
extensions: {},
77+
};
78+
}
79+
/**
80+
* Replace full queries with generated ID's to reduce bandwidth.
81+
* @see https://www.apollographql.com/docs/react/api/link/persisted-queries/#protocol
82+
*
83+
* Note that these are not the same hashes as the documentId, which is
84+
* used for allowlisting of query documents
85+
*/
86+
const extensions = {
87+
persistedQuery: {
88+
version: 1,
89+
sha256Hash: await createSha256(query),
90+
},
91+
};
92+
93+
return {
94+
operationName,
95+
query,
96+
variables,
97+
extensions,
98+
};
99+
};

src/server.test.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,12 @@ describe("gqlServerFetch", () => {
291291
fetchMock.mockResponse(successResponse);
292292

293293
const controller = new AbortController();
294-
await gqlServerFetch(query, { myVar: "baz" }, {}, controller.signal);
294+
await gqlServerFetch(
295+
query,
296+
{ myVar: "baz" },
297+
{},
298+
{ signal: controller.signal },
299+
);
295300

296301
expect(fetchMock).toHaveBeenCalledWith(
297302
expect.any(String),

0 commit comments

Comments
 (0)