Skip to content

Commit 3dc1d79

Browse files
committed
refactor: improve support for persistent queries
1 parent 29c19e4 commit 3dc1d79

12 files changed

+476
-202
lines changed

.changeset/warm-tomatoes-wonder.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@labdigital/graphql-fetcher": major
3+
---
4+
5+
Internal refactor to better support persisted operations / trusted documents

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

+27-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import createFetchMock from "vitest-fetch-mock";
1111
import { initClientFetcher } from "./client";
1212
import { TypedDocumentString } from "./testing";
13+
import { createSha256 } from "helpers";
1314

1415
const query = new TypedDocumentString(/* GraphQL */ `
1516
query myQuery {
@@ -52,14 +53,21 @@ describe("gqlClientFetch", () => {
5253
"https://localhost/graphql?op=myQuery",
5354
{
5455
// This exact body should be sent:
55-
body: '{"query":"\\n\\tquery myQuery {\\n\\t\\tfoo\\n\\t\\tbar\\n\\t}\\n","variables":{"myVar":"baz"},"extensions":{}}',
56+
body: JSON.stringify({
57+
query: query,
58+
variables: { myVar: "baz" },
59+
extensions: { persistedQuery: {
60+
version: 1,
61+
sha256Hash: await createSha256(query.toString()),
62+
}},
63+
}),
5664
// Method was post:
5765
method: "POST",
5866
// These exact headers should be set:
5967
credentials: "include",
60-
headers: {
68+
headers: new Headers({
6169
"content-type": "application/json",
62-
},
70+
}),
6371
signal: expect.any(AbortSignal),
6472
},
6573
);
@@ -75,15 +83,15 @@ describe("gqlClientFetch", () => {
7583
expect(gqlResponse).toEqual(response);
7684
expect(mockedFetch).toHaveBeenCalledWith(
7785
// When persisted queries are enabled, we suffix all the variables and extensions as search parameters
78-
"https://localhost/graphql?op=myQuery&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22e5276e0694f661ef818210402d06d249625ef169a1c2b60383acb2c42d45f7ae%22%7D%7D&variables=%7B%22myVar%22%3A%22baz%22%7D",
86+
"https://localhost/graphql?op=myQuery&variables=%7B%22myVar%22%3A%22baz%22%7D&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22e5276e0694f661ef818210402d06d249625ef169a1c2b60383acb2c42d45f7ae%22%7D%7D",
7987
{
8088
// Query is persisted, uses GET to be cached by CDN
8189
method: "GET",
8290
// These exact headers should be set:
8391
credentials: "include",
84-
headers: {
92+
headers: new Headers({
8593
"content-type": "application/json",
86-
},
94+
}),
8795
signal: expect.any(AbortSignal),
8896
},
8997
);
@@ -196,7 +204,7 @@ describe("gqlClientFetch", () => {
196204
{
197205
myVar: "baz",
198206
},
199-
controller.signal,
207+
{ signal: controller.signal },
200208
);
201209

202210
expect(fetchMock).toHaveBeenCalledWith(
@@ -231,15 +239,22 @@ describe("gqlClientFetch", () => {
231239
"https://localhost/graphql?op=myQuery",
232240
{
233241
// This exact body should be sent:
234-
body: '{"query":"\\n\\tquery myQuery {\\n\\t\\tfoo\\n\\t\\tbar\\n\\t}\\n","variables":{"myVar":"baz"},"extensions":{}}',
242+
body: JSON.stringify({
243+
query: query,
244+
variables: { myVar: "baz" },
245+
extensions: { persistedQuery: {
246+
version: 1,
247+
sha256Hash: await createSha256(query.toString()),
248+
}},
249+
}),
235250
// Method was post:
236251
method: "POST",
237252
// These exact headers should be set:
238253
credentials: "include",
239-
headers: {
240-
"content-type": "application/json",
241-
"x-extra-header": "foo",
242-
},
254+
headers: new Headers({
255+
"Content-Type": "application/json",
256+
"X-extra-header": "foo",
257+
}),
243258
signal: expect.any(AbortSignal),
244259
},
245260
);

src/client.ts

+46-54
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+
createRequest,
6+
createRequestBody,
7+
createRequestURL,
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,98 +61,78 @@ 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 = createRequestURL(endpoint, request);
127111
response = await parseResponse<GqlResponse<TResponse>>(() =>
128112
fetch(url.toString(), {
129-
headers: Object.fromEntries(headers.entries()),
113+
headers: headers,
130114
method: "GET",
131115
credentials: "include",
132116
signal: options.signal,
133117
}),
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+
const url = new URL(endpoint);
127+
url.searchParams.append("op", request.operationName);
128+
129+
// Persisted query not used or found, fall back to POST request and
130+
// include extension to cache the query on the server
139131
response = await parseResponse<GqlResponse<TResponse>>(() =>
140132
fetch(url.toString(), {
141-
headers: Object.fromEntries(headers.entries()),
133+
headers: headers,
142134
method: "POST",
143-
body: JSON.stringify({ documentId, query, variables, extensions }),
135+
body: createRequestBody(request),
144136
credentials: "include",
145137
signal: options.signal,
146138
}),

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)