Repository: urql-graphql/urql (ts)

Issue:
RFC: Support persisted mutations with multipart fetch 
<!--
  🚨 RFCs are for proposed changes (not bugs or questions)
  Specifically they are whenever you'd like to see new features
  being added to urql, or enable new use-cases.
  They're also relevant whenever @urql/core is changed, since
  changes to e.g. the `Client` or `Exchange` types are kept rare
  and lean.
-->

## Summary

With the persisted fetch exchange now supporting mutation, it can be used with the multipart fetch exchange to allow uploading files in a persisted fetch operation. Currently, this isn't supported because the persisted fetch exchange will capture the operation and send `null` fields to the server in the persisted operation. The multipart fetch exchange will be skipped for all mutations

## Proposed Solution

I can think of two solutions:
- Integrate the multipart fetch logic inside `persistedFetchExchange` and enable it behind an option flag: This is the simplest to integrate (for me at least) but break the separation of concern between these packages
- Refactor `persistedFetchExchange` to instead of terminating the fetch operation, pass the operation to the forward exchange with the operation hash provided in the operation context: more complex to implement and could potentially create backward incompatible change. I'm also not sure how we can integrate the retry logic of the persisted exchange with an external exchange down the pipeline. 


### Note:

For my usage, this is a blocking problem and I'll need to fix it asap. I will most likely implement the first solution in my fork and can submit a PR with these changes if you think it's a good enough solution. I'm not sure I'll have time to work on the second solution as it needs a lot more in-depth understanding of the codebase and discussion around the implementation.  

Pull request:
feat(core): Add support for GraphQL multipart requests to core
Resolves #2961
Closes #2994

## Summary

This PR proposes to move support for [GraphQL multipart requests](https://github.com/jaydenseric/graphql-multipart-request-spec) into `@urql/core`.

The reasoning here is that we can implement identical functionality today by slightly modifying the implementation of `makeFetchOptions` without any changes to our behaviour.

This will basically replace `@urql/exchange-multipart-fetch` and deprecate it.
Its functionaliy is fully absorbed into `@urql/core`.

`5.9kB -> 6.16kB` min+gzip

## Set of changes

- Implement `extractFiles` which doesn't create a `variables` copy
- Use `stringifyVariables` in construction of requests to omit `File`/`Blob` from variables
- Add multipart mechanism in `makeFetchOptions` for mutations


Patch:
diff --git a/.changeset/brave-sloths-bathe.md b/.changeset/brave-sloths-bathe.md
new file mode 100644
index 00000000..3cdadf17
--- /dev/null
+++ b/.changeset/brave-sloths-bathe.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-persisted-fetch': patch
+---
+
+Refactor `persistedFetchExchange` and SHA256 logic to save on bundlesize.
diff --git a/.changeset/quiet-ants-collect.md b/.changeset/quiet-ants-collect.md
new file mode 100644
index 00000000..e97416fb
--- /dev/null
+++ b/.changeset/quiet-ants-collect.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': minor
+---
+
+Implement GraphQL Multipart Request support in `@urql/core`. This adds the File/Blob upload support to `@urql/core`, which effectively deprecates `@urql/exchange-multipart-fetch`
diff --git a/.changeset/violet-kings-fold.md b/.changeset/violet-kings-fold.md
new file mode 100644
index 00000000..22d97fbb
--- /dev/null
+++ b/.changeset/violet-kings-fold.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-multipart-fetch': patch
+---
+
+Deprecate `@urql/exchange-multipart-fetch` as behaviour has been absorbed into `@urql/core`. If you're using the `multipartFetchExchange`, you should now be able to simply remove it.
diff --git a/docs/api/multipart-fetch-exchange.md b/docs/api/multipart-fetch-exchange.md
index 366206f5..5bf848b5 100644
--- a/docs/api/multipart-fetch-exchange.md
+++ b/docs/api/multipart-fetch-exchange.md
@@ -5,6 +5,11 @@ order: 7
 
 # Multipart Fetch Exchange
 
+> **Deprecation**: The `multipartFetchExchange` has been deprecated, and
+> `@urql/core` now supports GraphQL Multipart Requests natively. This won't
+> break the behaviour of your existing apps, however, it's recommended to remove
+> the `multipartFetchExchange` from your apps.
+
 The `@urql/exchange-multipart-fetch` package contains an addon `multipartFetchExchange` for `urql`
 that enables file uploads via `multipart/form-data` POST requests.
 
diff --git a/docs/comparison.md b/docs/comparison.md
index 1bdd5485..194ea408 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -53,7 +53,7 @@ All features are marked to indicate the following:
 | Live Queries                               | 🛑                                  | 🛑                                                                         | ✅                             |
 | Defer & Stream Directives                  | ✅                                  | ✅ / 🛑 (`@defer` is supported in >=3.7.0, `@stream` is not yet supported) | 🟡 (unreleased)                |
 | Switching to `GET` method                  | ✅                                  | ✅                                                                         | 🟡 `react-relay-network-layer` |
-| File Uploads                               | ✅ `@urql/exchange-multipart-fetch` | 🟡 `apollo-upload-client`                                                  | 🛑                             |
+| File Uploads                               | ✅                                  | 🟡 `apollo-upload-client`                                                  | 🛑                             |
 | Retrying Failed Queries                    | ✅ `@urql/exchange-retry`           | ✅ `apollo-link-retry`                                                     | ✅ `DefaultNetworkLayer`       |
 | Easy Authentication Flows                  | ✅ `@urql/exchange-auth`            | 🛑 (no docs for refresh-based authentication)                              | 🟡 `react-relay-network-layer` |
 | Automatic Refetch after Mutation           | ✅ (with document cache)            | 🛑                                                                         | ✅                             |
diff --git a/exchanges/multipart-fetch/README.md b/exchanges/multipart-fetch/README.md
index d8f2074c..b3ae75cb 100644
--- a/exchanges/multipart-fetch/README.md
+++ b/exchanges/multipart-fetch/README.md
@@ -1,5 +1,10 @@
 # @urql/exchange-multipart-fetch
 
+> **DEPRECATION NOTICE**: The `multipartFetchExchange` has been deprecated, and
+> `@urql/core` now supports GraphQL Multipart Requests natively. This won't
+> break the behaviour of your existing apps, however, it's recommended to remove
+> the `multipartFetchExchange` from your apps.
+
 The `multipartFetchExchange` is an exchange that builds on the regular `fetchExchange`
 but adds the multipart file upload capability.
 
diff --git a/exchanges/multipart-fetch/src/multipartFetchExchange.ts b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
index 2b8a1e68..c5acdc5a 100644
--- a/exchanges/multipart-fetch/src/multipartFetchExchange.ts
+++ b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
@@ -9,6 +9,10 @@ import {
   makeFetchSource,
 } from '@urql/core/internal';
 
+/** An Exchange which creates a multipart request when File/Blobs are found in variables.
+ * @deprecated
+ * `@urql/core` now supports the GraphQL Multipart Request spec out-of-the-box.
+ */
 export const multipartFetchExchange: Exchange = ({
   forward,
   dispatchDebug,
@@ -36,26 +40,28 @@ export const multipartFetchExchange: Exchange = ({
       if (files.size) {
         url = makeFetchURL(operation);
         fetchOptions = makeFetchOptions(operation);
-        if (fetchOptions.headers!['content-type'] === 'application/json') {
-          delete fetchOptions.headers!['content-type'];
-        }
+        if (!(fetchOptions.body instanceof FormData)) {
+          if (fetchOptions.headers!['content-type'] === 'application/json') {
+            delete fetchOptions.headers!['content-type'];
+          }
 
-        fetchOptions.method = 'POST';
-        fetchOptions.body = new FormData();
-        fetchOptions.body.append('operations', JSON.stringify(body));
+          fetchOptions.method = 'POST';
+          fetchOptions.body = new FormData();
+          fetchOptions.body.append('operations', JSON.stringify(body));
 
-        const map = {};
-        let i = 0;
-        files.forEach(paths => {
-          map[++i] = paths.map(path => `variables.${path}`);
-        });
+          const map = {};
+          let i = 0;
+          files.forEach(paths => {
+            map[++i] = paths.map(path => `variables.${path}`);
+          });
 
-        fetchOptions.body.append('map', JSON.stringify(map));
+          fetchOptions.body.append('map', JSON.stringify(map));
 
-        i = 0;
-        files.forEach((_, file) => {
-          (fetchOptions.body as FormData).append(`${++i}`, file, file.name);
-        });
+          i = 0;
+          files.forEach((_, file) => {
+            (fetchOptions.body as FormData).append(`${++i}`, file, file.name);
+          });
+        }
       } else {
         url = makeFetchURL(operation, body);
         fetchOptions = makeFetchOptions(operation, body);
diff --git a/exchanges/persisted-fetch/src/persistedFetchExchange.ts b/exchanges/persisted-fetch/src/persistedFetchExchange.ts
index a6b2eafa..e38f49bb 100644
--- a/exchanges/persisted-fetch/src/persistedFetchExchange.ts
+++ b/exchanges/persisted-fetch/src/persistedFetchExchange.ts
@@ -86,6 +86,7 @@ export const persistedFetchExchange = (
               // Attach SHA256 hash and remove query from body
               body.query = undefined;
               body.extensions = {
+                ...body.extensions,
                 persistedQuery: {
                   version: 1,
                   sha256Hash,
@@ -108,7 +109,8 @@ export const persistedFetchExchange = (
               if (result.error && isPersistedUnsupported(result.error)) {
                 // Reset the body back to its non-persisted state
                 body.query = query;
-                body.extensions = undefined;
+                if (body.extensions && body.extensions.persistedQuery)
+                  body.extensions.persistedQuery = undefined;
                 // Disable future persisted queries if they're not enforced
                 supportsPersistedQueries = false;
                 return makePersistedFetchSource(
@@ -154,14 +156,10 @@ const makePersistedFetchSource = (
 ): Source<OperationResult> => {
   const newOperation = makeOperation(operation.kind, operation, {
     ...operation.context,
-    preferGetMethod: useGet || operation.context.preferGetMethod,
+    preferGetMethod: useGet ? 'force' : operation.context.preferGetMethod,
   });
 
-  const url = makeFetchURL(
-    newOperation,
-    body.query ? body : { ...body, query: '' }
-  );
-
+  const url = makeFetchURL(newOperation, body);
   const fetchOptions = makeFetchOptions(newOperation, body);
 
   dispatchDebug({
@@ -176,31 +174,38 @@ const makePersistedFetchSource = (
     },
   });
 
-  return pipe(
-    makeFetchSource(newOperation, url, fetchOptions),
-    onPush(result => {
-      const persistFail =
-        result.error &&
-        (isPersistedMiss(result.error) || isPersistedUnsupported(result.error));
-      const error = !result.data ? result.error : undefined;
-
-      dispatchDebug({
-        // TODO: Assign a new name to this once @urql/devtools supports it
-        type: persistFail || error ? 'fetchError' : 'fetchSuccess',
-        message: persistFail
-          ? 'A Persisted Query request has failed. A non-persisted GraphQL request will follow.'
-          : `A ${
-              error ? 'failed' : 'successful'
-            } fetch response has been returned.`,
-        operation,
-        data: {
-          url,
-          fetchOptions,
-          value: persistFail ? result.error! : error || result,
-        },
-      });
-    })
-  );
+  let fetch$ = makeFetchSource(newOperation, url, fetchOptions);
+
+  if (process.env.NODE_ENV !== 'production') {
+    fetch$ = pipe(
+      fetch$,
+      onPush(result => {
+        const persistFail =
+          result.error &&
+          (isPersistedMiss(result.error) ||
+            isPersistedUnsupported(result.error));
+        const error = !result.data ? result.error : undefined;
+
+        dispatchDebug({
+          // TODO: Assign a new name to this once @urql/devtools supports it
+          type: persistFail || error ? 'fetchError' : 'fetchSuccess',
+          message: persistFail
+            ? 'A Persisted Query request has failed. A non-persisted GraphQL request will follow.'
+            : `A ${
+                error ? 'failed' : 'successful'
+              } fetch response has been returned.`,
+          operation,
+          data: {
+            url,
+            fetchOptions,
+            value: persistFail ? result.error! : error || result,
+          },
+        });
+      })
+    );
+  }
+
+  return fetch$;
 };
 
 const isPersistedMiss = (error: CombinedError): boolean =>
diff --git a/exchanges/persisted-fetch/src/sha256.ts b/exchanges/persisted-fetch/src/sha256.ts
index 147bbd74..050ce569 100644
--- a/exchanges/persisted-fetch/src/sha256.ts
+++ b/exchanges/persisted-fetch/src/sha256.ts
@@ -1,47 +1,39 @@
-const globals: { crypto?: Crypto } = (typeof window !== 'undefined'
-  ? window
+const webCrypto = (typeof window !== 'undefined'
+  ? window.crypto
   : typeof self !== 'undefined'
-  ? self
-  : null) as any;
-const webCrypto = globals && globals.crypto;
+  ? self.crypto
+  : null) as typeof globalThis.crypto | null;
 
-const sha256Browser = (bytes: Uint8Array): Promise<Uint8Array> =>
-  webCrypto!.subtle
-    .digest({ name: 'SHA-256' }, bytes)
-    .then(result => new Uint8Array(result));
+let nodeCrypto: Promise<typeof import('crypto') | void> | void;
 
-let nodeCrypto: Promise<typeof import('crypto')> | void;
-if (typeof window === 'undefined' && !webCrypto) {
-  // Indirect eval'd require/import to guarantee no side-effects in module scope
-  // (optimization for minifiers)
-  try {
-    nodeCrypto = Promise.resolve(
-      new Function('require', 'return require("crypto")')(require)
-    );
-  } catch (_error) {
+const getNodeCrypto = async (): Promise<typeof import('crypto') | void> => {
+  if (!nodeCrypto) {
+    // Indirect eval'd require/import to guarantee no side-effects in module scope
+    // (optimization for minifiers)
     try {
-      nodeCrypto = new Function('return import("crypto")')();
-    } catch (_error) {}
+      nodeCrypto = new Function('require', 'return require("crypto")')(require);
+    } catch (_error) {
+      try {
+        nodeCrypto = new Function('return import("crypto")')();
+      } catch (_error) {}
+    }
   }
-}
+  return nodeCrypto;
+};
 
 export const hash = async (query: string): Promise<string> => {
-  // Node.js support
-  if (nodeCrypto) {
-    return nodeCrypto.then(crypto =>
-      crypto.createHash('sha256').update(query).digest('hex')
+  if (webCrypto) {
+    const digest = await webCrypto.subtle.digest(
+      { name: 'SHA-256' },
+      new TextEncoder().encode(query)
     );
-  } else if (webCrypto) {
-    const buf = new TextEncoder().encode(query);
-    const out = await sha256Browser(buf);
-
-    let hash = '';
-    for (let i = 0, l = out.length; i < l; i++) {
-      const hex = out[i].toString(16);
-      hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
-    }
-
-    return hash;
+    return new Uint8Array(digest).reduce(
+      (prev, byte) => prev + byte.toString(16).padStart(2, '0'),
+      ''
+    );
+  } else if (await getNodeCrypto()) {
+    // Node.js support
+    return (await nodeCrypto)!.createHash('sha256').update(query).digest('hex');
   }
 
   if (process.env.NODE_ENV !== 'production') {
@@ -51,5 +43,5 @@ export const hash = async (query: string): Promise<string> => {
     );
   }
 
-  return Promise.resolve('');
+  return '';
 };
diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts
index 7aea1603..3ea94e17 100644
--- a/packages/core/src/internal/fetchOptions.ts
+++ b/packages/core/src/internal/fetchOptions.ts
@@ -2,7 +2,9 @@ import {
   stringifyDocument,
   getOperationName,
   stringifyVariables,
+  extractFiles,
 } from '../utils';
+
 import { AnyVariables, GraphQLRequest, Operation } from '../types';
 
 /** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */
@@ -69,6 +71,33 @@ export const makeFetchURL = (
   return finalUrl;
 };
 
+/** Serializes a {@link FetchBody} into a {@link RequestInit.body} format. */
+const serializeBody = (
+  operation: Operation,
+  body?: FetchBody
+): FormData | string | undefined => {
+  const omitBody =
+    operation.kind === 'query' && !!operation.context.preferGetMethod;
+  if (body && !omitBody) {
+    const json = stringifyVariables(body);
+    const files = extractFiles(body.variables);
+    if (files.size) {
+      const form = new FormData();
+
+      form.append('operations', json);
+      form.append('map', stringifyVariables({ ...[...files.keys()] }));
+
+      let index = 0;
+      for (const file of files.values()) {
+        form.append(`${index++}`, file);
+      }
+
+      return form;
+    }
+    return json;
+  }
+};
+
 /** Creates a `RequestInit` object for a given `Operation`.
  *
  * @param operation - An {@link Operation} for which to make the request.
@@ -86,15 +115,10 @@ export const makeFetchOptions = (
   operation: Operation,
   body?: FetchBody
 ): RequestInit => {
-  const useGETMethod =
-    operation.kind === 'query' && !!operation.context.preferGetMethod;
-
   const headers: HeadersInit = {
     accept:
       'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed',
   };
-
-  if (!useGETMethod) headers['content-type'] = 'application/json';
   const extraOptions =
     (typeof operation.context.fetchOptions === 'function'
       ? operation.context.fetchOptions()
@@ -102,10 +126,13 @@ export const makeFetchOptions = (
   if (extraOptions.headers)
     for (const key in extraOptions.headers)
       headers[key.toLowerCase()] = extraOptions.headers[key];
+  const serializedBody = serializeBody(operation, body);
+  if (typeof serializedBody === 'string' && !headers['content-type'])
+    headers['content-type'] = 'application/json';
   return {
     ...extraOptions,
-    body: !useGETMethod && body ? JSON.stringify(body) : undefined,
-    method: useGETMethod ? 'GET' : 'POST',
+    method: serializedBody ? 'POST' : 'GET',
+    body: serializedBody,
     headers,
   };
 };
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index 6b8469f1..4956d90b 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -2,7 +2,7 @@ export * from './error';
 export * from './request';
 export * from './result';
 export * from './typenames';
-export * from './stringifyVariables';
+export * from './variables';
 export * from './maskTypename';
 export * from './streamUtils';
 export * from './operation';
diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts
index 28640cbb..8da3e8ca 100644
--- a/packages/core/src/utils/request.ts
+++ b/packages/core/src/utils/request.ts
@@ -8,7 +8,7 @@ import {
 } from 'graphql';
 
 import { HashValue, phash } from './hash';
-import { stringifyVariables } from './stringifyVariables';
+import { stringifyVariables } from './variables';
 import { TypedDocumentNode, AnyVariables, GraphQLRequest } from '../types';
 
 interface WritableLocation {
diff --git a/packages/core/src/utils/stringifyVariables.ts b/packages/core/src/utils/variables.ts
similarity index 61%
rename from packages/core/src/utils/stringifyVariables.ts
rename to packages/core/src/utils/variables.ts
index d84bdb85..ad586879 100644
--- a/packages/core/src/utils/stringifyVariables.ts
+++ b/packages/core/src/utils/variables.ts
@@ -1,3 +1,5 @@
+export type FileMap = Map<string, File | Blob>;
+
 const seen = new Set();
 const cache = new WeakMap();
 
@@ -40,6 +42,20 @@ const stringify = (x: any): string => {
   return out;
 };
 
+const extract = (map: FileMap, path: string, x: any) => {
+  if (x == null || typeof x !== 'object' || x.toJSON || seen.has(x)) {
+    /*noop*/
+  } else if (Array.isArray(x)) {
+    for (let i = 0, l = x.length; i < l; i++)
+      extract(map, `${path}.${i}`, x[i]);
+  } else if (x instanceof FileConstructor || x instanceof BlobConstructor) {
+    map.set(path, x as File | Blob);
+  } else {
+    seen.add(x);
+    for (const key of Object.keys(x)) extract(map, `${path}.${key}`, x[key]);
+  }
+};
+
 /** A stable stringifier for GraphQL variables objects.
  *
  * @param x - any JSON-like data.
@@ -58,3 +74,19 @@ export const stringifyVariables = (x: any): string => {
   seen.clear();
   return stringify(x);
 };
+
+class NoopConstructor {}
+const FileConstructor = typeof File !== 'undefined' ? File : NoopConstructor;
+const BlobConstructor = typeof Blob !== 'undefined' ? Blob : NoopConstructor;
+
+export const extractFiles = (x: any): FileMap => {
+  const map: FileMap = new Map();
+  if (
+    FileConstructor !== NoopConstructor ||
+    BlobConstructor !== NoopConstructor
+  ) {
+    seen.clear();
+    extract(map, 'variables', x);
+  }
+  return map;
+};


Test patch:
diff --git a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
index c0141abd..7ea81507 100644
--- a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
+++ b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
@@ -455,7 +455,7 @@ exports[`on success > returns response data 1`] = `
 }
 `;
 
-exports[`on success > returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
+exports[`on success > returns response data 2`] = `"{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
 
 exports[`on success > uses a file when given 1`] = `
 {
diff --git a/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap b/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
index f0001ae3..7582cf82 100644
--- a/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
+++ b/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
@@ -1,9 +1,9 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`accepts successful persisted query responses 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`;
+exports[`accepts successful persisted query responses 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
 
-exports[`supports cache-miss persisted query errors 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`;
+exports[`supports cache-miss persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
 
-exports[`supports cache-miss persisted query errors 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`;
+exports[`supports cache-miss persisted query errors 2`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
 
-exports[`supports unsupported persisted query errors 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`;
+exports[`supports unsupported persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
diff --git a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
index 1f07fb7a..59cc89f2 100644
--- a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -455,4 +455,4 @@ exports[`on success > returns response data 1`] = `
 }
 `;
 
-exports[`on success > returns response data 2`] = `"{\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
+exports[`on success > returns response data 2`] = `"{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
diff --git a/packages/core/src/internal/fetchOptions.test.ts b/packages/core/src/internal/fetchOptions.test.ts
new file mode 100644
index 00000000..ecce2836
--- /dev/null
+++ b/packages/core/src/internal/fetchOptions.test.ts
@@ -0,0 +1,122 @@
+import { expect, describe, it } from 'vitest';
+import { makeOperation } from '../utils/operation';
+import { queryOperation, mutationOperation } from '../test-utils';
+import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions';
+
+describe('makeFetchURL', () => {
+  it('returns the URL by default', () => {
+    const body = makeFetchBody(queryOperation);
+    expect(makeFetchURL(queryOperation, body)).toBe(
+      'http://localhost:3000/graphql'
+    );
+  });
+
+  it('returns a query parameter URL when GET is preferred', () => {
+    const operation = makeOperation(queryOperation.kind, queryOperation, {
+      ...queryOperation.context,
+      preferGetMethod: true,
+    });
+
+    const body = makeFetchBody(operation);
+    expect(makeFetchURL(operation, body)).toMatchInlineSnapshot(
+      '"http://localhost:3000/graphql?query=query+getUser%28%24name%3A+String%29+%7B%0A++user%28name%3A+%24name%29+%7B%0A++++id%0A++++firstName%0A++++lastName%0A++%7D%0A%7D&operationName=getUser&variables=%7B%22name%22%3A%22Clara%22%7D"'
+    );
+  });
+
+  it('returns the URL without query parameters when it exceeds given length', () => {
+    const operation = makeOperation(queryOperation.kind, queryOperation, {
+      ...queryOperation.context,
+      preferGetMethod: true,
+    });
+
+    operation.variables = {
+      ...operation.variables,
+      test: 'x'.repeat(2048),
+    };
+
+    const body = makeFetchBody(operation);
+    expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql');
+    // Resets the `preferGetMethod` field
+    expect(operation.context.preferGetMethod).toBe(false);
+  });
+
+  it('returns the URL without query parameters for mutations', () => {
+    const operation = makeOperation(mutationOperation.kind, mutationOperation, {
+      ...mutationOperation.context,
+      preferGetMethod: true,
+    });
+
+    const body = makeFetchBody(operation);
+    expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql');
+  });
+});
+
+describe('makeFetchOptions', () => {
+  it('creates a JSON request by default', () => {
+    const body = makeFetchBody(queryOperation);
+    expect(makeFetchOptions(queryOperation, body)).toMatchInlineSnapshot(`
+      {
+        "body": "{\\"operationName\\":\\"getUser\\",\\"query\\":\\"query getUser($name: String) {\\\\n  user(name: $name) {\\\\n    id\\\\n    firstName\\\\n    lastName\\\\n  }\\\\n}\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}",
+        "headers": {
+          "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
+          "content-type": "application/json",
+        },
+        "method": "POST",
+      }
+    `);
+  });
+
+  it('creates a GET request when preferred for query operations', () => {
+    const operation = makeOperation(queryOperation.kind, queryOperation, {
+      ...queryOperation.context,
+      preferGetMethod: 'force',
+    });
+
+    const body = makeFetchBody(operation);
+    expect(makeFetchOptions(operation, body)).toMatchInlineSnapshot(`
+      {
+        "body": undefined,
+        "headers": {
+          "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
+        },
+        "method": "GET",
+      }
+    `);
+  });
+
+  it('creates a POST multipart request when a file is detected', () => {
+    const operation = makeOperation(mutationOperation.kind, mutationOperation);
+    operation.variables = {
+      ...operation.variables,
+      file: new Blob(),
+    };
+
+    const body = makeFetchBody(operation);
+    const options = makeFetchOptions(operation, body);
+    expect(options).toMatchInlineSnapshot(`
+      {
+        "body": FormData {},
+        "headers": {
+          "accept": "application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed",
+        },
+        "method": "POST",
+      }
+    `);
+
+    expect(options.body).toBeInstanceOf(FormData);
+    const form = options.body as FormData;
+
+    expect(JSON.parse(form.get('operations') as string)).toEqual({
+      ...body,
+      variables: {
+        ...body.variables,
+        file: { __key: expect.any(String) },
+      },
+    });
+
+    expect(form.get('map')).toMatchInlineSnapshot(
+      '"{\\"0\\":\\"variables.file\\"}"'
+    );
+    expect(form.get('0')).toBeInstanceOf(Blob);
+  });
+});
diff --git a/packages/core/src/utils/stringifyVariables.test.ts b/packages/core/src/utils/stringifyVariables.test.ts
deleted file mode 100644
index 3ef8fccc..00000000
--- a/packages/core/src/utils/stringifyVariables.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { stringifyVariables } from './stringifyVariables';
-import { it, expect } from 'vitest';
-
-it('stringifies objects stabily', () => {
-  expect(stringifyVariables({ b: 'b', a: 'a' })).toBe('{"a":"a","b":"b"}');
-  expect(stringifyVariables({ x: { b: 'b', a: 'a' } })).toBe(
-    '{"x":{"a":"a","b":"b"}}'
-  );
-});
-
-it('stringifies arrays', () => {
-  expect(stringifyVariables([1, 2])).toBe('[1,2]');
-  expect(stringifyVariables({ x: [1, 2] })).toBe('{"x":[1,2]}');
-});
-
-it('stringifies scalars', () => {
-  expect(stringifyVariables(1)).toBe('1');
-  expect(stringifyVariables('test')).toBe('"test"');
-  expect(stringifyVariables(null)).toBe('null');
-  expect(stringifyVariables(undefined)).toBe('');
-  expect(stringifyVariables(Infinity)).toBe('null');
-  expect(stringifyVariables(1 / 0)).toBe('null');
-});
-
-it('returns null for circular structures', () => {
-  const x = { x: null } as any;
-  x.x = x;
-  expect(stringifyVariables(x)).toBe('{"x":null}');
-});
-
-it('stringifies dates correctly', () => {
-  const date = new Date('2019-12-11T04:20:00');
-  expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`);
-});
-
-it('stringifies dictionaries (Object.create(null)) correctly', () => {
-  expect(stringifyVariables(Object.create(null))).toBe('{}');
-});
-
-it('stringifies files correctly', () => {
-  const file = new File([0] as any, 'test.js');
-  Object.defineProperty(file, 'lastModified', { value: 123 });
-  const str = stringifyVariables(file);
-  expect(str).toBe(stringifyVariables(file));
-
-  const otherFile = new File([0] as any, 'otherFile.js');
-  Object.defineProperty(otherFile, 'lastModified', { value: 234 });
-  expect(str).not.toBe(stringifyVariables(otherFile));
-});
diff --git a/packages/core/src/utils/variables.test.ts b/packages/core/src/utils/variables.test.ts
new file mode 100644
index 00000000..dc414a93
--- /dev/null
+++ b/packages/core/src/utils/variables.test.ts
@@ -0,0 +1,67 @@
+import { stringifyVariables, extractFiles } from './variables';
+import { describe, it, expect } from 'vitest';
+
+describe('stringifyVariables', () => {
+  it('stringifies objects stabily', () => {
+    expect(stringifyVariables({ b: 'b', a: 'a' })).toBe('{"a":"a","b":"b"}');
+    expect(stringifyVariables({ x: { b: 'b', a: 'a' } })).toBe(
+      '{"x":{"a":"a","b":"b"}}'
+    );
+  });
+
+  it('stringifies arrays', () => {
+    expect(stringifyVariables([1, 2])).toBe('[1,2]');
+    expect(stringifyVariables({ x: [1, 2] })).toBe('{"x":[1,2]}');
+  });
+
+  it('stringifies scalars', () => {
+    expect(stringifyVariables(1)).toBe('1');
+    expect(stringifyVariables('test')).toBe('"test"');
+    expect(stringifyVariables(null)).toBe('null');
+    expect(stringifyVariables(undefined)).toBe('');
+    expect(stringifyVariables(Infinity)).toBe('null');
+    expect(stringifyVariables(1 / 0)).toBe('null');
+  });
+
+  it('returns null for circular structures', () => {
+    const x = { x: null } as any;
+    x.x = x;
+    expect(stringifyVariables(x)).toBe('{"x":null}');
+  });
+
+  it('stringifies dates correctly', () => {
+    const date = new Date('2019-12-11T04:20:00');
+    expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`);
+  });
+
+  it('stringifies dictionaries (Object.create(null)) correctly', () => {
+    expect(stringifyVariables(Object.create(null))).toBe('{}');
+  });
+
+  it('stringifies files correctly', () => {
+    const file = new File([0] as any, 'test.js');
+    Object.defineProperty(file, 'lastModified', { value: 123 });
+    const str = stringifyVariables(file);
+    expect(str).toBe(stringifyVariables(file));
+
+    const otherFile = new File([0] as any, 'otherFile.js');
+    Object.defineProperty(otherFile, 'lastModified', { value: 234 });
+    expect(str).not.toBe(stringifyVariables(otherFile));
+  });
+});
+
+describe('extractFiles', () => {
+  it('extracts files from nested objects', () => {
+    const file = new Blob();
+    expect(extractFiles({ files: { a: file } })).toEqual(
+      new Map([['variables.files.a', file]])
+    );
+  });
+
+  it('extracts files from nested arrays', () => {
+    const file = new Blob();
+    expect(extractFiles({ files: [file] })).toEqual(
+      new Map([['variables.files.0', file]])
+    );
+  });
+});