Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

feat(persisted): Replace persistedFetchExchange with generic persistedExchange (#3057)

authored by kitten.sh and committed by

GitHub 7241181d 20ce6542

+487 -750
+5
.changeset/pretty-cows-dance.md
··· 1 + --- 2 + '@urql/core': patch 3 + --- 4 + 5 + Add logic for `request.extensions.persistedQuery` to `@urql/core` to omit sending `query` as needed.
+5
.changeset/strange-apples-trade.md
··· 1 + --- 2 + '@urql/exchange-persisted-fetch': major 3 + --- 4 + 5 + Remove `persistedFetchExchange` and instead implement `persistedExchange`. This exchange must be placed in front of a terminating exchange (such as the default `fetchExchange` or a `subscriptionExchange` that supports persisted queries), and only modifies incoming operations to contain `extensions.persistedQuery`, which is sent on via the API. If the API expects Automatic Persisted Queries, requests are retried by this exchange internally.
+5
.changeset/three-poets-think.md
··· 1 + --- 2 + '@urql/exchange-persisted-fetch': major 3 + --- 4 + 5 + Rename `@urql/exchange-persisted-fetch` to `@urql/exchange-persisted`
+37 -69
docs/advanced/persistence-and-uploads.md
··· 6 6 # Persisted Queries and Uploads 7 7 8 8 `urql` supports both [Automatic Persisted 9 - Queries](https://www.apollographql.com/docs/apollo-server/performance/apq/) and [File 10 - Uploads](https://www.apollographql.com/docs/apollo-server/data/file-uploads/). 11 - Both of these features are implemented by enhancing or swapping out the default 12 - [`fetchExchange`](../api/core.md#fetchexchange). 9 + Queries](https://www.apollographql.com/docs/apollo-server/performance/apq/), Persisted Queries, and 10 + [File Uploads](https://www.apollographql.com/docs/apollo-server/data/file-uploads/). 11 + 12 + While File Uploads should work without any modifications, an additional exchange must be installed 13 + and added for Persisted Queries to work. 13 14 14 15 ## Automatic Persisted Queries 15 16 ··· 29 30 requests. If we only send the persisted queries with hashes as GET requests then they become a lot 30 31 easier for a CDN to cache, as by default most caches would not cache POST requests automatically. 31 32 32 - In `urql`, we may use the `@urql/exchange-persisted-fetch` package's `persistedFetchExchange` to 33 - implement Automatic Persisted Queries. This exchange works alongside other fetch exchanges and only 34 - handles `query` operations. 33 + In `urql`, we may use the `@urql/exchange-persisted` package's `persistedExchange` to 34 + implement Automatic Persisted Queries. This exchange works alongside the default `fetchExchange` 35 + and other exchanges by adding the `extensions.persistedQuery` parameters to a GraphQL request. 35 36 36 37 ### Installation & Setup 37 38 38 - First install `@urql/exchange-persisted-fetch` alongside `urql`: 39 + First install `@urql/exchange-persisted` alongside `urql`: 39 40 40 41 ```sh 41 - yarn add @urql/exchange-persisted-fetch 42 + yarn add @urql/exchange-persisted 42 43 # or 43 - npm install --save @urql/exchange-persisted-fetch 44 + npm install --save @urql/exchange-persisted 44 45 ``` 45 46 46 - You'll then need to add the `persistedFetchExchange` method, that this package exposes, 47 + You'll then need to add the `persistedExchange` function, that this package exposes, 47 48 to your `exchanges`. 48 49 49 50 ```js 50 51 import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 51 - import { persistedFetchExchange } from '@urql/exchange-persisted-fetch'; 52 + import { persistedExchange } from '@urql/exchange-persisted-fetch'; 52 53 53 54 const client = createClient({ 54 55 url: 'http://localhost:1234/graphql', 55 56 exchanges: [ 56 57 dedupExchange, 57 58 cacheExchange, 58 - persistedFetchExchange({ 59 + persistedExchange({ 59 60 preferGetForPersistedQueries: true, 60 61 }), 61 62 fetchExchange, ··· 65 66 66 67 As we can see, typically it's recommended to set `preferGetForPersistedQueries` to `true` to force 67 68 all persisted queries to use GET requests instead of POST so that CDNs can do their job. 68 - We also added the `persistedFetchExchange` in front of the usual `fetchExchange`, since it only 69 - handles queries but not mutations. 69 + We also added the `persistedExchange` in front of the usual `fetchExchange`, since it has to 70 + update operations before they reach an exchange that talks to an API. 70 71 71 72 The `preferGetForPersistedQueries` is similar to the [`Client`'s 72 73 `preferGetMethod`](../api/core.md#client) but only switches persisted queries to use GET requests 73 74 instead. This is preferable since sometimes the GraphQL query can grow too large for a simple GET 74 - query to handle, while the `persistedFetchExchange`'s SHA256 hashes will remain predictably small. 75 + query to handle, while the `persistedExchange`'s SHA256 hashes will remain predictably small. 75 76 76 77 ### Customizing Hashing 77 78 78 - The `persistedFetchExchange` also accepts a `generateHash` option. This may be used to swap out the 79 + The `persistedExchange` also accepts a `generateHash` option. This may be used to swap out the 79 80 exchange's default method of generating SHA256 hashes. By default, the exchange will use the 80 - built-in [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) on the 81 - browser, which has been implemented to support IE11 as well. In Node.js it'll use the [Node 82 - Crypto Module](https://nodejs.org/api/crypto.html) instead. 81 + built-in [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) when it's 82 + available, and in Node.js it'll use the [Node Crypto Module](https://nodejs.org/api/crypto.html) 83 + instead. 83 84 84 85 If you're using [the `graphql-persisted-document-loader` for 85 - Webpack](https://github.com/leoasis/graphql-persisted-document-loader) for instance, then you will 86 + Webpack](https://github.com/leoasis/graphql-persisted-document-loader), for instance, then you will 86 87 already have a loader generating SHA256 hashes for you at compile time. In that case we could swap 87 88 out the `generateHash` function with a much simpler one that uses the `generateHash` function's 88 89 second argument, a GraphQL `DocumentNode` object. ··· 94 95 ``` 95 96 96 97 If you're using **React Native** then you may not have access to the Web Crypto API, which means 97 - that you have to provide your own SHA256 function to the `persistedFetchExchange`. Luckily we can do 98 + that you have to provide your own SHA256 function to the `persistedExchange`. Luckily, we can do 98 99 so easily by using the first argument `generateHash` receives, a GraphQL query as a string. 99 100 100 101 ```js ··· 108 109 ``` 109 110 110 111 Additionally, if the API only expects persisted queries and not arbitrary ones and all queries are 111 - pre-registered against the API then the `persistedFetchExchange` may be put into a **non-automatic** 112 + pre-registered against the API then the `persistedExchange` may be put into a **non-automatic** 112 113 persisted queries mode by giving it the `enforcePersistedQueries: true` option. This disables any 113 114 retry logic and assumes that persisted queries will be handled like regular GraphQL requests. 114 115 115 - [Read more about `@urql/persisted-fetch-exchange` in our API 116 - docs.](../api/persisted-fetch-exchange.md) 117 - 118 116 ## File Uploads 119 117 120 - GraphQL server frameworks like [Apollo Server support an unofficial spec for file 121 - uploads.](https://www.apollographql.com/docs/apollo-server/data/file-uploads/) This allows us to 122 - define mutations on our API that accept an `Upload` input, which on the client would be a variable 123 - that we can set to a [File](https://developer.mozilla.org/en-US/docs/Web/API/File), which we'd 124 - typically retrieve via a [file input for 125 - instance](https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications). 118 + Many GraphQL server frameworks and APIs support the ["GraphQL Multipart Request 119 + Spec](https://github.com/jaydenseric/graphql-multipart-request-spec) to allow files to be uploaded. 120 + Often, this is defined in schemas using a `File` or `Upload` input. 121 + This allows us to pass a `File` or `Blob` directly to our GraphQL requests as variables, and the 122 + spec requires us to perform this request as a multipart upload. 126 123 127 - In `urql`, we may use the `@urql/exchange-multipart-fetch` package's `multipartFetchExchange` to 128 - support file uploads, which is a drop-in replacement for the default 129 - [`fetchExchange`](../api/core.md#fetchexchange). It may also be used [alongside the 130 - `persistedFetchExchange`](#automatic-persisted-queries). 124 + Files are often handled in the browser via the [File API](https://developer.mozilla.org/en-US/docs/Web/API/File), 125 + which we may typically get to via a [file input](https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications) 126 + for example. 131 127 132 - It works by using the [`extract-files` package](https://www.npmjs.com/package/extract-files). When 133 - the `multipartFetchExchange` sees at least one `File` in the variables it receives for a mutation, 134 - then it will send a `multipart/form-data` POST request instead of a standard `application/json` 135 - one. This is basically the same kind of request that we'd expect to send for regular HTML forms. 136 - 137 - ### Installation & Setup 128 + In `urql`, these are supported natively, so as long as your JS environment supports either `File` or 129 + `Blob`s, you can pass these directly to any `urql` API via your `variables`, and the default 130 + `fetchExchange` will swich to using a multipart request instead. 138 131 139 - First install `@urql/exchange-multipart-fetch` alongside `urql`: 140 - 141 - ```sh 142 - yarn add @urql/exchange-multipart-fetch 143 - # or 144 - npm install --save @urql/exchange-multipart-fetch 145 - ``` 146 - 147 - The `multipartFetchExchange` is a drop-in replacement for the `fetchExchange`, which should be 148 - replaced in the list of `exchanges`: 149 - 150 - ```js 151 - import { createClient, dedupExchange, cacheExchange } from 'urql'; 152 - import { multipartFetchExchange } from '@urql/exchange-multipart-fetch'; 153 - 154 - const client = createClient({ 155 - url: 'http://localhost:3000/graphql', 156 - exchanges: [dedupExchange, cacheExchange, multipartFetchExchange], 157 - }); 158 - ``` 159 - 160 - If you're using the `persistedFetchExchange` then put the `persistedFetchExchange` in front of the 161 - `multipartFetchExchange`, since only the latter is a full replacement for the `fetchExchange`, and 162 - the former only handled query operations. 163 - 164 - [Read more about `@urql/multipart-fetch-exchange` in our API 165 - docs.](../api/multipart-fetch-exchange.md) 132 + Previously, this worked by installing the [`@urql/multipart-fetch-exchange` package](../api/multipart-fetch-exchange.md), 133 + however, this package has been deprecated and file uploads are now built into `@urql/core`.
-1
docs/api/README.md
··· 19 19 - [`@urql/exchange-retry` API docs](./retry-exchange.md) 20 20 - [`@urql/exchange-execute` API docs](./execute-exchange.md) 21 21 - [`@urql/exchange-multipart-fetch` API docs](./multipart-fetch-exchange.md) 22 - - [`@urql/exchange-persisted-fetch` API docs](./persisted-fetch-exchange.md) 23 22 - [`@urql/exchange-request-policy` API docs](./request-policy-exchange.md) 24 23 - [`@urql/exchange-auth` API docs](./auth-exchange.md) 25 24 - [`@urql/exchange-refocus` API docs](./refocus-exchange.md)
+1 -2
docs/api/multipart-fetch-exchange.md
··· 17 17 Spec](https://github.com/jaydenseric/graphql-multipart-request-spec) which is supported by the 18 18 [Apollo Sever package](https://www.apollographql.com/docs/apollo-server/data/file-uploads/). 19 19 20 - This exchange uses the same fetch logic as the [`fetchExchange`](./core.md#fetchexchange) and the 21 - [`persistedFetchExchange`](./persisted-fetch-exchange.md) by reusing logic from `@urql/core/internal`. 20 + This exchange uses the same fetch logic as the [`fetchExchange`](./core.md#fetchexchange) and by reusing logic from `@urql/core/internal`. 22 21 The `multipartFetchExchange` is a drop-in replacement for the default 23 22 [`fetchExchange`](./core.md#fetchexchange) and will act exactly like the `fetchExchange` unless the 24 23 `variables` that it receives for mutations contain any `File`s as detected by the `extract-files` package.
-68
docs/api/persisted-fetch-exchange.md
··· 1 - --- 2 - title: '@urql/exchange-persisted-fetch' 3 - order: 8 4 - --- 5 - 6 - # Persisted Fetch Exchange 7 - 8 - The `@urql/exchange-persisted-fetch` package contains an addon `persistedFetchExchange` for `urql` 9 - that enables the use of _Automatic Persisted Queries_ with `urql`. 10 - 11 - It follows the unofficial [GraphQL Persisted Queries 12 - Spec](https://github.com/apollographql/apollo-link-persisted-queries#apollo-engine) which is 13 - supported by the 14 - [Apollo Sever package](https://www.apollographql.com/docs/apollo-server/performance/apq/). 15 - 16 - This exchange uses the same fetch logic as the [`fetchExchange`](./core.md#fetchexchange) and the 17 - [`multipartFetchExchange`](./multipart-fetch-exchange.md) by reusing logic from `@urql/core/internal`. 18 - The `persistedFetchExchange` will attempt to send queries with an additional SHA256 hash to the 19 - GraphQL API and will otherwise, when Automatic Persisted Queries are unsupported or when a mutation 20 - or subscription is sent, forward the operation to the next exchange. Hence it should always be added 21 - in front of another [`fetchExchange`](./core.md#fetchexchange). 22 - 23 - The `persistedFetchExchange` will use the built-in [Web Crypto 24 - API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) for SHA256 hashing in the 25 - browser, which has been implemented to support IE11 as well. In Node.js it'll use the [Node 26 - Crypto Module](https://nodejs.org/api/crypto.html) instead. It's also possible to use the 27 - `generateHash` option to alter how the SHA256 hash is generated or retrieved. 28 - 29 - ## Installation and Setup 30 - 31 - First install `@urql/exchange-persisted-fetch` alongside `urql`: 32 - 33 - ```sh 34 - yarn add @urql/exchange-persisted-fetch 35 - # or 36 - npm install --save @urql/exchange-persisted-fetch 37 - ``` 38 - 39 - You'll then need to add the `persistedFetchExchange`, that this package exposes, to your 40 - `exchanges`, in front of the `fetchExchange`. If you're using the 41 - [`multipartFetchExchange`](./multipart-fetch-exchange.md) then it must be added in front of that 42 - instead: 43 - 44 - ```js 45 - import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 46 - import { persistedFetchExchange } from '@urql/exchange-persisted-fetch'; 47 - 48 - const client = createClient({ 49 - url: 'http://localhost:1234/graphql', 50 - exchanges: [ 51 - dedupExchange, 52 - cacheExchange, 53 - persistedFetchExchange({ 54 - /* optional config */ 55 - }), 56 - fetchExchange, 57 - ], 58 - }); 59 - ``` 60 - 61 - ## Options 62 - 63 - | Option | Description | 64 - | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 65 - | `preferGetForPersistedQueries` | This is similar to [the `Client`'s `preferGetMethod` option](./core.md#client) and will cause all persisted queries to be sent using a GET request. | 66 - | `enforcePersistedQueries` | This option enforced persisted queries. Instead of allowing automatic persisted queries or triggering any retry logic when the API responds, it instead assumes that persisted queries will succeed and run like normal GraphQL API requests. | 67 - | `generateHash` | This option accepts a function that receives the `query` as a string and the raw `DocumentNode` as a second argument and must return a `Promise<string>` resolving to a SHA256 hash. This can be used to swap out the SHA256 API, e.g. for React Native, or to use pre-generated SHA256 strings from the `DocumentNode`. | 68 - | `enableForMutation` | This option allows mutations to be persisted in addition to queries. It's false by default. When a persisted mutation is requested, `preferGetForPersistedQueries` will be ignored and a POST method will always be used. |
+22 -22
docs/comparison.md
··· 35 35 36 36 ### Core Features 37 37 38 - | | urql | Apollo | Relay | 39 - | ------------------------------------------ | ----------------------------------- | -------------------------------------------------------------------------- | ------------------------------ | 40 - | Extensible on a network level | ✅ Exchanges | ✅ Links | ✅ Network Layers | 41 - | Extensible on a cache / control flow level | ✅ Exchanges | 🛑 | 🛑 | 42 - | Base Bundle Size | **5.9kB** (7.1kB with bindings) | 32.9kB | 27.7kB (34.1kB with bindings) | 43 - | Devtools | ✅ | ✅ | ✅ | 44 - | Subscriptions | ✅ | ✅ | ✅ | 45 - | Client-side Rehydration | ✅ | ✅ | ✅ | 46 - | Polled Queries | 🔶 | ✅ | ✅ | 47 - | Lazy Queries | ✅ | ✅ | ✅ | 48 - | Stale while Revalidate / Cache and Network | ✅ | ✅ | ✅ | 49 - | Focus Refetching | ✅ `@urql/exchange-refocus` | 🛑 | 🛑 | 50 - | Stale Time Configuration | ✅ `@urql/exchange-request-policy` | ✅ | 🛑 | 51 - | Persisted Queries | ✅ `@urql/exchange-persisted-fetch` | ✅ `apollo-link-persisted-queries` | ✅ | 52 - | Batched Queries | 🛑 | ✅ `apollo-link-batch-http` | 🟡 `react-relay-network-layer` | 53 - | Live Queries | 🛑 | 🛑 | ✅ | 54 - | Defer & Stream Directives | ✅ | ✅ / 🛑 (`@defer` is supported in >=3.7.0, `@stream` is not yet supported) | 🟡 (unreleased) | 55 - | Switching to `GET` method | ✅ | ✅ | 🟡 `react-relay-network-layer` | 56 - | File Uploads | ✅ | 🟡 `apollo-upload-client` | 🛑 | 57 - | Retrying Failed Queries | ✅ `@urql/exchange-retry` | ✅ `apollo-link-retry` | ✅ `DefaultNetworkLayer` | 58 - | Easy Authentication Flows | ✅ `@urql/exchange-auth` | 🛑 (no docs for refresh-based authentication) | 🟡 `react-relay-network-layer` | 59 - | Automatic Refetch after Mutation | ✅ (with document cache) | 🛑 | ✅ | 38 + | | urql | Apollo | Relay | 39 + | ------------------------------------------ | ---------------------------------- | -------------------------------------------------------------------------- | ------------------------------ | 40 + | Extensible on a network level | ✅ Exchanges | ✅ Links | ✅ Network Layers | 41 + | Extensible on a cache / control flow level | ✅ Exchanges | 🛑 | 🛑 | 42 + | Base Bundle Size | **5.9kB** (7.1kB with bindings) | 32.9kB | 27.7kB (34.1kB with bindings) | 43 + | Devtools | ✅ | ✅ | ✅ | 44 + | Subscriptions | ✅ | ✅ | ✅ | 45 + | Client-side Rehydration | ✅ | ✅ | ✅ | 46 + | Polled Queries | 🔶 | ✅ | ✅ | 47 + | Lazy Queries | ✅ | ✅ | ✅ | 48 + | Stale while Revalidate / Cache and Network | ✅ | ✅ | ✅ | 49 + | Focus Refetching | ✅ `@urql/exchange-refocus` | 🛑 | 🛑 | 50 + | Stale Time Configuration | ✅ `@urql/exchange-request-policy` | ✅ | 🛑 | 51 + | Persisted Queries | ✅ `@urql/exchange-persisted` | ✅ `apollo-link-persisted-queries` | ✅ | 52 + | Batched Queries | 🛑 | ✅ `apollo-link-batch-http` | 🟡 `react-relay-network-layer` | 53 + | Live Queries | 🛑 | 🛑 | ✅ | 54 + | Defer & Stream Directives | ✅ | ✅ / 🛑 (`@defer` is supported in >=3.7.0, `@stream` is not yet supported) | 🟡 (unreleased) | 55 + | Switching to `GET` method | ✅ | ✅ | 🟡 `react-relay-network-layer` | 56 + | File Uploads | ✅ | 🟡 `apollo-upload-client` | 🛑 | 57 + | Retrying Failed Queries | ✅ `@urql/exchange-retry` | ✅ `apollo-link-retry` | ✅ `DefaultNetworkLayer` | 58 + | Easy Authentication Flows | ✅ `@urql/exchange-auth` | 🛑 (no docs for refresh-based authentication) | 🟡 `react-relay-network-layer` | 59 + | Automatic Refetch after Mutation | ✅ (with document cache) | 🛑 | ✅ | 60 60 61 61 Typically these are all additional addon features that you may expect from a GraphQL client, no 62 62 matter which framework you use it with. It's worth mentioning that all three clients support some
exchanges/persisted-fetch/CHANGELOG.md exchanges/persisted/CHANGELOG.md
-67
exchanges/persisted-fetch/README.md
··· 1 - # @urql/exchange-persisted-fetch 2 - 3 - The `persistedFetchExchange` is an exchange that builds on the regular `fetchExchange` 4 - but adds support for Persisted Queries. 5 - 6 - ## Quick Start Guide 7 - 8 - First install `@urql/exchange-persisted-fetch` alongside `urql`: 9 - 10 - ```sh 11 - yarn add @urql/exchange-persisted-fetch 12 - # or 13 - npm install --save @urql/exchange-persisted-fetch 14 - ``` 15 - 16 - You'll then need to add the `persistedFetchExchange` method, that this package exposes, 17 - to your `exchanges`. 18 - 19 - ```js 20 - import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 21 - import { persistedFetchExchange } from '@urql/exchange-persisted-fetch'; 22 - 23 - const client = createClient({ 24 - url: 'http://localhost:1234/graphql', 25 - exchanges: [ 26 - dedupExchange, 27 - cacheExchange, 28 - persistedFetchExchange({ 29 - /* optional config */ 30 - }), 31 - fetchExchange, 32 - ], 33 - }); 34 - ``` 35 - 36 - The `persistedQueryExchange` supports three configuration options: 37 - 38 - - `preferGetForPersistedQueries`: Use `GET` for fetches with persisted queries 39 - - `enforcePersistedQueries`: This disables _automatic persisted queries_ and disables any retry 40 - logic for how the API responds to persisted queries. Instead it's assumed that they'll always 41 - succeed. 42 - - `generateHash`: A function that takes a GraphQL query and returns the hashed result. This defaults to the `window.crypto` API in the browser and the `crypto` module in node. 43 - 44 - The `persistedFetchExchange` only handles queries, so for mutations we keep the 45 - `fetchExchange` around alongside of it. 46 - 47 - ## Avoid hashing during runtime 48 - 49 - If you want to generate hashes at build-time you can use a [webpack-loader](https://github.com/leoasis/graphql-persisted-document-loader) to achieve this, 50 - when using this all you need to do in this exchange is the following: 51 - 52 - ```js 53 - import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 54 - import { persistedFetchExchange } from '@urql/exchange-persisted-fetch'; 55 - 56 - const client = createClient({ 57 - url: 'http://localhost:1234/graphql', 58 - exchanges: [ 59 - dedupExchange, 60 - cacheExchange, 61 - persistedFetchExchange({ 62 - generateHash: (_, document) => document.documentId, 63 - }), 64 - fetchExchange, 65 - ], 66 - }); 67 - ```
+8 -8
exchanges/persisted-fetch/package.json exchanges/persisted/package.json
··· 1 1 { 2 - "name": "@urql/exchange-persisted-fetch", 2 + "name": "@urql/exchange-persisted", 3 3 "version": "2.1.0", 4 4 "description": "An exchange that allows for persisted queries support when fetching queries", 5 5 "sideEffects": false, ··· 9 9 "repository": { 10 10 "type": "git", 11 11 "url": "https://github.com/urql-graphql/urql.git", 12 - "directory": "exchanges/persisted-fetch" 12 + "directory": "exchanges/persisted" 13 13 }, 14 14 "keywords": [ 15 15 "urql", ··· 17 17 "persisted queries", 18 18 "exchanges" 19 19 ], 20 - "main": "dist/urql-exchange-persisted-fetch", 21 - "module": "dist/urql-exchange-persisted-fetch.mjs", 22 - "types": "dist/urql-exchange-persisted-fetch.d.ts", 20 + "main": "dist/urql-exchange-persisted", 21 + "module": "dist/urql-exchange-persisted.mjs", 22 + "types": "dist/urql-exchange-persisted.d.ts", 23 23 "source": "src/index.ts", 24 24 "exports": { 25 25 ".": { 26 - "import": "./dist/urql-exchange-persisted-fetch.mjs", 27 - "require": "./dist/urql-exchange-persisted-fetch.js", 28 - "types": "./dist/urql-exchange-persisted-fetch.d.ts", 26 + "import": "./dist/urql-exchange-persisted.mjs", 27 + "require": "./dist/urql-exchange-persisted.js", 28 + "types": "./dist/urql-exchange-persisted.d.ts", 29 29 "source": "./src/index.ts" 30 30 }, 31 31 "./package.json": "./package.json"
-9
exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
··· 1 - // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 - 3 - exports[`accepts successful persisted query responses 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 4 - 5 - exports[`supports cache-miss persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`; 6 - 7 - 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\\"}}"`; 8 - 9 - exports[`supports unsupported persisted query errors 1`] = `"{\\"extensions\\":{\\"persistedQuery\\":{\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\",\\"version\\":1}},\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"}}"`;
-1
exchanges/persisted-fetch/src/index.ts
··· 1 - export * from './persistedFetchExchange';
-282
exchanges/persisted-fetch/src/persistedFetchExchange.test.ts
··· 1 - /** 2 - * @vitest-environment node 3 - */ 4 - 5 - import { empty, fromValue, fromArray, pipe, Source, toPromise } from 'wonka'; 6 - import { vi, expect, it, afterEach, Mock } from 'vitest'; 7 - 8 - import { DocumentNode, print } from 'graphql'; 9 - import { Client, OperationResult } from '@urql/core'; 10 - 11 - import { queryOperation } from './test-utils'; 12 - import { hash } from './sha256'; 13 - import { persistedFetchExchange } from './persistedFetchExchange'; 14 - 15 - const fetch = (global as any).fetch as Mock; 16 - 17 - const exchangeArgs = { 18 - dispatchDebug: vi.fn(), 19 - forward: () => empty as Source<OperationResult>, 20 - client: ({ 21 - debugTarget: { 22 - dispatchEvent: vi.fn(), 23 - }, 24 - } as any) as Client, 25 - }; 26 - 27 - afterEach(() => { 28 - fetch.mockClear(); 29 - }); 30 - 31 - it('accepts successful persisted query responses', async () => { 32 - const expected = JSON.stringify({ 33 - data: { 34 - test: true, 35 - }, 36 - }); 37 - 38 - fetch.mockResolvedValueOnce({ 39 - status: 200, 40 - headers: { get: () => 'application/json' }, 41 - text: () => Promise.resolve(expected), 42 - }); 43 - 44 - const actual = await pipe( 45 - fromValue(queryOperation), 46 - persistedFetchExchange()(exchangeArgs), 47 - toPromise 48 - ); 49 - 50 - expect(fetch).toHaveBeenCalledTimes(1); 51 - expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 52 - expect(actual.data).not.toBeUndefined(); 53 - }); 54 - 55 - it('supports cache-miss persisted query errors', async () => { 56 - const expectedMiss = JSON.stringify({ 57 - errors: [{ message: 'PersistedQueryNotFound' }], 58 - }); 59 - 60 - const expectedRetry = JSON.stringify({ 61 - data: { 62 - test: true, 63 - }, 64 - }); 65 - 66 - fetch 67 - .mockResolvedValueOnce({ 68 - status: 200, 69 - headers: { get: () => 'application/json' }, 70 - text: () => Promise.resolve(expectedMiss), 71 - }) 72 - .mockResolvedValueOnce({ 73 - status: 200, 74 - headers: { get: () => 'application/json' }, 75 - text: () => Promise.resolve(expectedRetry), 76 - }); 77 - 78 - const actual = await pipe( 79 - fromValue(queryOperation), 80 - persistedFetchExchange()(exchangeArgs), 81 - toPromise 82 - ); 83 - 84 - expect(fetch).toHaveBeenCalledTimes(2); 85 - expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 86 - expect(fetch.mock.calls[1][1].body).toMatchSnapshot(); 87 - expect(actual.data).not.toBeUndefined(); 88 - }); 89 - 90 - it('supports GET exclusively for persisted queries', async () => { 91 - const expectedMiss = JSON.stringify({ 92 - errors: [{ message: 'PersistedQueryNotFound' }], 93 - }); 94 - 95 - const expectedRetry = JSON.stringify({ 96 - data: { 97 - test: true, 98 - }, 99 - }); 100 - 101 - fetch 102 - .mockResolvedValueOnce({ 103 - status: 200, 104 - headers: { get: () => 'application/json' }, 105 - text: () => Promise.resolve(expectedMiss), 106 - }) 107 - .mockResolvedValueOnce({ 108 - status: 200, 109 - headers: { get: () => 'application/json' }, 110 - text: () => Promise.resolve(expectedRetry), 111 - }); 112 - 113 - const actual = await pipe( 114 - fromValue(queryOperation), 115 - persistedFetchExchange({ preferGetForPersistedQueries: true })( 116 - exchangeArgs 117 - ), 118 - toPromise 119 - ); 120 - 121 - expect(fetch).toHaveBeenCalledTimes(2); 122 - expect(fetch.mock.calls[0][1].method).toEqual('GET'); 123 - expect(fetch.mock.calls[1][1].method).toEqual('POST'); 124 - expect(actual.data).not.toBeUndefined(); 125 - }); 126 - 127 - it('supports unsupported persisted query errors', async () => { 128 - const expectedMiss = JSON.stringify({ 129 - errors: [{ message: 'PersistedQueryNotSupported' }], 130 - }); 131 - 132 - const expectedRetry = JSON.stringify({ 133 - data: { 134 - test: true, 135 - }, 136 - }); 137 - 138 - fetch 139 - .mockResolvedValueOnce({ 140 - status: 200, 141 - headers: { get: () => 'application/json' }, 142 - text: () => Promise.resolve(expectedMiss), 143 - }) 144 - .mockResolvedValueOnce({ 145 - status: 200, 146 - headers: { get: () => 'application/json' }, 147 - text: () => Promise.resolve(expectedRetry), 148 - }) 149 - .mockResolvedValueOnce({ 150 - status: 200, 151 - headers: { get: () => 'application/json' }, 152 - text: () => Promise.resolve(expectedRetry), 153 - }); 154 - 155 - const actual = await pipe( 156 - fromArray([queryOperation, queryOperation]), 157 - persistedFetchExchange()(exchangeArgs), 158 - toPromise 159 - ); 160 - 161 - expect(fetch).toHaveBeenCalledTimes(3); 162 - expect(fetch.mock.calls[0][1].body).toMatchSnapshot(); 163 - expect(fetch.mock.calls[1][1].body).toEqual(fetch.mock.calls[1][1].body); 164 - expect(actual.data).not.toBeUndefined(); 165 - }); 166 - 167 - it('correctly generates an SHA256 hash', async () => { 168 - const expected = JSON.stringify({ 169 - data: { 170 - test: true, 171 - }, 172 - }); 173 - 174 - fetch.mockResolvedValue({ 175 - text: () => Promise.resolve(expected), 176 - }); 177 - 178 - const queryHash = await hash(print(queryOperation.query)); 179 - 180 - await pipe( 181 - fromValue(queryOperation), 182 - persistedFetchExchange()(exchangeArgs), 183 - toPromise 184 - ); 185 - 186 - expect(fetch).toHaveBeenCalledTimes(1); 187 - 188 - const body = JSON.parse(fetch.mock.calls[0][1].body); 189 - 190 - expect(queryHash).toBe( 191 - 'b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8' 192 - ); 193 - 194 - expect(body).toMatchObject({ 195 - extensions: { 196 - persistedQuery: { 197 - version: 1, 198 - sha256Hash: queryHash, 199 - }, 200 - }, 201 - }); 202 - }); 203 - 204 - it('supports a custom hash function', async () => { 205 - const expected = JSON.stringify({ 206 - data: { 207 - test: true, 208 - }, 209 - }); 210 - 211 - fetch.mockResolvedValueOnce({ 212 - text: () => Promise.resolve(expected), 213 - }); 214 - 215 - const hashFn = vi.fn((_input: string, _doc: DocumentNode) => { 216 - return Promise.resolve('hello'); 217 - }); 218 - 219 - await pipe( 220 - fromValue(queryOperation), 221 - persistedFetchExchange({ generateHash: hashFn })(exchangeArgs), 222 - toPromise 223 - ); 224 - 225 - expect(fetch).toHaveBeenCalledTimes(1); 226 - 227 - const body = JSON.parse(fetch.mock.calls[0][1].body); 228 - 229 - expect(body).toMatchObject({ 230 - extensions: { 231 - persistedQuery: { 232 - version: 1, 233 - sha256Hash: 'hello', 234 - }, 235 - }, 236 - }); 237 - const queryString = `query getUser($name: String) { 238 - user(name: $name) { 239 - id 240 - firstName 241 - lastName 242 - } 243 - }`; 244 - expect(hashFn).toBeCalledWith(queryString, queryOperation.query); 245 - }); 246 - 247 - it('falls back to a non-persisted query if the hash is falsy', async () => { 248 - const expected = JSON.stringify({ 249 - data: { 250 - test: true, 251 - }, 252 - }); 253 - 254 - fetch.mockResolvedValueOnce({ 255 - text: () => Promise.resolve(expected), 256 - }); 257 - 258 - const hashFn = vi.fn(() => Promise.resolve('')); 259 - 260 - await pipe( 261 - fromValue(queryOperation), 262 - persistedFetchExchange({ generateHash: hashFn })(exchangeArgs), 263 - toPromise 264 - ); 265 - 266 - expect(fetch).toHaveBeenCalledTimes(1); 267 - 268 - const body = JSON.parse(fetch.mock.calls[0][1].body); 269 - 270 - expect(body).toMatchObject({ 271 - query: 272 - 'query getUser($name: String) {\n' + 273 - ' user(name: $name) {\n' + 274 - ' id\n' + 275 - ' firstName\n' + 276 - ' lastName\n' + 277 - ' }\n' + 278 - '}', 279 - operationName: 'getUser', 280 - variables: { name: 'Clara' }, 281 - }); 282 - });
-215
exchanges/persisted-fetch/src/persistedFetchExchange.ts
··· 1 - /* eslint-disable @typescript-eslint/no-use-before-define */ 2 - import { 3 - Source, 4 - fromValue, 5 - fromPromise, 6 - filter, 7 - merge, 8 - mergeMap, 9 - pipe, 10 - share, 11 - onPush, 12 - takeUntil, 13 - } from 'wonka'; 14 - 15 - import { 16 - makeOperation, 17 - CombinedError, 18 - ExchangeInput, 19 - Exchange, 20 - Operation, 21 - OperationResult, 22 - } from '@urql/core'; 23 - 24 - import { 25 - FetchBody, 26 - makeFetchBody, 27 - makeFetchURL, 28 - makeFetchOptions, 29 - makeFetchSource, 30 - } from '@urql/core/internal'; 31 - import { DocumentNode } from 'graphql'; 32 - 33 - import { hash } from './sha256'; 34 - 35 - interface PersistedFetchExchangeOptions { 36 - preferGetForPersistedQueries?: boolean; 37 - enforcePersistedQueries?: boolean; 38 - generateHash?: (query: string, document: DocumentNode) => Promise<string>; 39 - enableForMutation?: boolean; 40 - } 41 - 42 - export const persistedFetchExchange = ( 43 - options?: PersistedFetchExchangeOptions 44 - ): Exchange => ({ forward, dispatchDebug }) => { 45 - if (!options) options = {}; 46 - 47 - const preferGetForPersistedQueries = !!options.preferGetForPersistedQueries; 48 - const enforcePersistedQueries = !!options.enforcePersistedQueries; 49 - const hashFn = options.generateHash || hash; 50 - const enableForMutation = !!options.enableForMutation; 51 - let supportsPersistedQueries = true; 52 - 53 - const operationFilter = (operation: Operation) => 54 - (enableForMutation && operation.kind === 'mutation') || 55 - operation.kind === 'query'; 56 - 57 - return ops$ => { 58 - const sharedOps$ = share(ops$); 59 - const fetchResults$ = pipe( 60 - sharedOps$, 61 - filter(operationFilter), 62 - mergeMap(operation => { 63 - const { key } = operation; 64 - const teardown$ = pipe( 65 - sharedOps$, 66 - filter(op => op.kind === 'teardown' && op.key === key) 67 - ); 68 - 69 - const body = makeFetchBody(operation); 70 - if (!supportsPersistedQueries) { 71 - // Runs the usual non-persisted fetchExchange query logic 72 - return pipe( 73 - makePersistedFetchSource(operation, body, dispatchDebug, false), 74 - takeUntil(teardown$) 75 - ); 76 - } 77 - 78 - const query: string = body.query!; 79 - 80 - return pipe( 81 - // Hash the given GraphQL query 82 - fromPromise(hashFn(query, operation.query)), 83 - mergeMap(sha256Hash => { 84 - // if the hashing operation was successful, add the persisted query extension 85 - if (sha256Hash) { 86 - // Attach SHA256 hash and remove query from body 87 - body.query = undefined; 88 - body.extensions = { 89 - ...body.extensions, 90 - persistedQuery: { 91 - version: 1, 92 - sha256Hash, 93 - }, 94 - }; 95 - } 96 - const useGet = 97 - operation.kind === 'query' && 98 - preferGetForPersistedQueries && 99 - !!sha256Hash; 100 - return makePersistedFetchSource( 101 - operation, 102 - body, 103 - dispatchDebug, 104 - useGet 105 - ); 106 - }), 107 - mergeMap(result => { 108 - if (!enforcePersistedQueries) { 109 - if (result.error && isPersistedUnsupported(result.error)) { 110 - // Reset the body back to its non-persisted state 111 - body.query = query; 112 - if (body.extensions && body.extensions.persistedQuery) 113 - body.extensions.persistedQuery = undefined; 114 - // Disable future persisted queries if they're not enforced 115 - supportsPersistedQueries = false; 116 - return makePersistedFetchSource( 117 - operation, 118 - body, 119 - dispatchDebug, 120 - false 121 - ); 122 - } else if (result.error && isPersistedMiss(result.error)) { 123 - // Add query to the body but leave SHA256 hash intact 124 - body.query = query; 125 - return makePersistedFetchSource( 126 - operation, 127 - body, 128 - dispatchDebug, 129 - false 130 - ); 131 - } 132 - } 133 - 134 - return fromValue(result); 135 - }), 136 - takeUntil(teardown$) 137 - ); 138 - }) 139 - ); 140 - 141 - const forward$ = pipe( 142 - sharedOps$, 143 - filter(operation => !operationFilter(operation)), 144 - forward 145 - ); 146 - 147 - return merge([fetchResults$, forward$]); 148 - }; 149 - }; 150 - 151 - const makePersistedFetchSource = ( 152 - operation: Operation, 153 - body: FetchBody, 154 - dispatchDebug: ExchangeInput['dispatchDebug'], 155 - useGet: boolean 156 - ): Source<OperationResult> => { 157 - const newOperation = makeOperation(operation.kind, operation, { 158 - ...operation.context, 159 - preferGetMethod: useGet ? 'force' : operation.context.preferGetMethod, 160 - }); 161 - 162 - const url = makeFetchURL(newOperation, body); 163 - const fetchOptions = makeFetchOptions(newOperation, body); 164 - 165 - dispatchDebug({ 166 - type: 'fetchRequest', 167 - message: !body.query 168 - ? 'A fetch request for a persisted query is being executed.' 169 - : 'A fetch request is being executed.', 170 - operation: newOperation, 171 - data: { 172 - url, 173 - fetchOptions, 174 - }, 175 - }); 176 - 177 - let fetch$ = makeFetchSource(newOperation, url, fetchOptions); 178 - 179 - if (process.env.NODE_ENV !== 'production') { 180 - fetch$ = pipe( 181 - fetch$, 182 - onPush(result => { 183 - const persistFail = 184 - result.error && 185 - (isPersistedMiss(result.error) || 186 - isPersistedUnsupported(result.error)); 187 - const error = !result.data ? result.error : undefined; 188 - 189 - dispatchDebug({ 190 - // TODO: Assign a new name to this once @urql/devtools supports it 191 - type: persistFail || error ? 'fetchError' : 'fetchSuccess', 192 - message: persistFail 193 - ? 'A Persisted Query request has failed. A non-persisted GraphQL request will follow.' 194 - : `A ${ 195 - error ? 'failed' : 'successful' 196 - } fetch response has been returned.`, 197 - operation, 198 - data: { 199 - url, 200 - fetchOptions, 201 - value: persistFail ? result.error! : error || result, 202 - }, 203 - }); 204 - }) 205 - ); 206 - } 207 - 208 - return fetch$; 209 - }; 210 - 211 - const isPersistedMiss = (error: CombinedError): boolean => 212 - error.graphQLErrors.some(x => x.message === 'PersistedQueryNotFound'); 213 - 214 - const isPersistedUnsupported = (error: CombinedError): boolean => 215 - error.graphQLErrors.some(x => x.message === 'PersistedQueryNotSupported');
+1 -1
exchanges/persisted-fetch/src/sha256.ts exchanges/persisted/src/sha256.ts
··· 22 22 }; 23 23 24 24 export const hash = async (query: string): Promise<string> => { 25 - if (webCrypto) { 25 + if (webCrypto && webCrypto.subtle) { 26 26 const digest = await webCrypto.subtle.digest( 27 27 { name: 'SHA-256' }, 28 28 new TextEncoder().encode(query)
exchanges/persisted-fetch/src/test-utils.ts exchanges/persisted/src/test-utils.ts
exchanges/persisted-fetch/tsconfig.json exchanges/persisted/tsconfig.json
+63
exchanges/persisted/README.md
··· 1 + # @urql/exchange-persisted 2 + 3 + The `persistedExchange` is an exchange that allows other terminating exchanges to support Persisted Queries, and is as such placed in front of either the default `fetchExchange` or 4 + other terminating exchanges. 5 + 6 + ## Quick Start Guide 7 + 8 + First install `@urql/exchange-persisted` alongside `urql`: 9 + 10 + ```sh 11 + yarn add @urql/exchange-persisted 12 + # or 13 + npm install --save @urql/exchange-persisted 14 + ``` 15 + 16 + You'll then need to add the `persistedExchange` function, that this package exposes, 17 + to your `exchanges`. 18 + 19 + ```js 20 + import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 21 + import { persistedExchange } from '@urql/exchange-persisted'; 22 + 23 + const client = createClient({ 24 + url: 'http://localhost:1234/graphql', 25 + exchanges: [ 26 + dedupExchange, 27 + cacheExchange, 28 + persistedExchange({ 29 + /* optional config */ 30 + }), 31 + fetchExchange, 32 + ], 33 + }); 34 + ``` 35 + 36 + The `persistedExchange` supports three configuration options: 37 + 38 + - `preferGetForPersistedQueries`: Enforce `GET` method to be used by the default `fetchExchange` for persisted queries 39 + - `enforcePersistedQueries`: This disables _automatic persisted queries_ and disables any retry logic for how the API responds to persisted queries. Instead it's assumed that they'll always succeed. 40 + - `generateHash`: A function that takes a GraphQL query and returns the hashed result. This defaults to the `window.crypto` API in the browser and the `crypto` module in Node. 41 + - `enableForMutation`: By default, the exchange only handles `query` operations, but enabling this allows it to handle mutations as well. 42 + 43 + ## Avoid hashing during runtime 44 + 45 + If you want to generate hashes at build-time you can use a [webpack-loader](https://github.com/leoasis/graphql-persisted-document-loader) to achieve this, 46 + when using this all you need to do in this exchange is the following: 47 + 48 + ```js 49 + import { createClient, dedupExchange, fetchExchange, cacheExchange } from 'urql'; 50 + import { persistedExchange } from '@urql/exchange-persisted'; 51 + 52 + const client = createClient({ 53 + url: 'http://localhost:1234/graphql', 54 + exchanges: [ 55 + dedupExchange, 56 + cacheExchange, 57 + persistedExchange({ 58 + generateHash: (_, document) => document.documentId, 59 + }), 60 + fetchExchange, 61 + ], 62 + }); 63 + ```
+1
exchanges/persisted/src/index.ts
··· 1 + export * from './persistedExchange';
+120
exchanges/persisted/src/persistedExchange.test.ts
··· 1 + import { 2 + Source, 3 + pipe, 4 + fromValue, 5 + fromArray, 6 + toPromise, 7 + delay, 8 + take, 9 + tap, 10 + map, 11 + } from 'wonka'; 12 + 13 + import { Client, Operation, OperationResult, CombinedError } from '@urql/core'; 14 + 15 + import { vi, expect, it } from 'vitest'; 16 + import { 17 + queryResponse, 18 + queryOperation, 19 + } from '../../../packages/core/src/test-utils'; 20 + import { persistedExchange } from './persistedExchange'; 21 + 22 + const makeExchangeArgs = () => { 23 + const operations: Operation[] = []; 24 + 25 + const result = vi.fn( 26 + (operation: Operation): OperationResult => ({ ...queryResponse, operation }) 27 + ); 28 + 29 + return { 30 + operations, 31 + result, 32 + exchangeArgs: { 33 + forward: (op$: Source<Operation>) => 34 + pipe( 35 + op$, 36 + tap(op => operations.push(op)), 37 + map(result) 38 + ), 39 + client: new Client({ url: '/api', exchanges: [] }), 40 + } as any, 41 + }; 42 + }; 43 + 44 + it('adds the APQ extensions correctly', async () => { 45 + const { exchangeArgs } = makeExchangeArgs(); 46 + 47 + const res = await pipe( 48 + fromValue(queryOperation), 49 + persistedExchange()(exchangeArgs), 50 + take(1), 51 + toPromise 52 + ); 53 + 54 + expect(res.operation.context.persistAttempt).toBe(true); 55 + expect(res.operation.extensions).toEqual({ 56 + persistedQuery: { 57 + version: 1, 58 + sha256Hash: expect.any(String), 59 + miss: undefined, 60 + }, 61 + }); 62 + }); 63 + 64 + it('retries query when persisted query resulted in miss', async () => { 65 + const { result, operations, exchangeArgs } = makeExchangeArgs(); 66 + 67 + result.mockImplementationOnce(operation => ({ 68 + ...queryResponse, 69 + operation, 70 + error: new CombinedError({ 71 + graphQLErrors: [{ message: 'PersistedQueryNotFound' }], 72 + }), 73 + })); 74 + 75 + const res = await pipe( 76 + fromValue(queryOperation), 77 + persistedExchange()(exchangeArgs), 78 + take(1), 79 + toPromise 80 + ); 81 + 82 + expect(res.operation.context.persistAttempt).toBe(true); 83 + expect(operations.length).toBe(2); 84 + 85 + expect(operations[1].extensions).toEqual({ 86 + persistedQuery: { 87 + version: 1, 88 + sha256Hash: expect.any(String), 89 + miss: true, 90 + }, 91 + }); 92 + }); 93 + 94 + it('retries query persisted query resulted in unsupported', async () => { 95 + const { result, operations, exchangeArgs } = makeExchangeArgs(); 96 + 97 + result.mockImplementationOnce(operation => ({ 98 + ...queryResponse, 99 + operation, 100 + error: new CombinedError({ 101 + graphQLErrors: [{ message: 'PersistedQueryNotSupported' }], 102 + }), 103 + })); 104 + 105 + await pipe( 106 + fromArray([queryOperation, queryOperation]), 107 + delay(0), 108 + persistedExchange()(exchangeArgs), 109 + take(2), 110 + toPromise 111 + ); 112 + 113 + expect(operations.length).toBe(3); 114 + 115 + expect(operations[1].extensions).toEqual({ 116 + persistedQuery: undefined, 117 + }); 118 + 119 + expect(operations[2].extensions).toEqual(undefined); 120 + });
+143
exchanges/persisted/src/persistedExchange.ts
··· 1 + import { 2 + map, 3 + makeSubject, 4 + fromPromise, 5 + filter, 6 + merge, 7 + mergeMap, 8 + pipe, 9 + share, 10 + } from 'wonka'; 11 + 12 + import { 13 + makeOperation, 14 + stringifyDocument, 15 + PersistedRequestExtensions, 16 + OperationResult, 17 + CombinedError, 18 + Exchange, 19 + Operation, 20 + } from '@urql/core'; 21 + 22 + import type { DocumentNode } from 'graphql'; 23 + 24 + import { hash } from './sha256'; 25 + 26 + const isPersistedMiss = (error: CombinedError): boolean => 27 + error.graphQLErrors.some(x => x.message === 'PersistedQueryNotFound'); 28 + 29 + const isPersistedUnsupported = (error: CombinedError): boolean => 30 + error.graphQLErrors.some(x => x.message === 'PersistedQueryNotSupported'); 31 + 32 + export interface PersistedExchangeOptions { 33 + preferGetForPersistedQueries?: boolean; 34 + enforcePersistedQueries?: boolean; 35 + generateHash?: (query: string, document: DocumentNode) => Promise<string>; 36 + enableForMutation?: boolean; 37 + } 38 + 39 + export const persistedExchange = ( 40 + options?: PersistedExchangeOptions 41 + ): Exchange => ({ forward }) => { 42 + if (!options) options = {}; 43 + 44 + const preferGetForPersistedQueries = !!options.preferGetForPersistedQueries; 45 + const enforcePersistedQueries = !!options.enforcePersistedQueries; 46 + const hashFn = options.generateHash || hash; 47 + const enableForMutation = !!options.enableForMutation; 48 + let supportsPersistedQueries = true; 49 + 50 + const operationFilter = (operation: Operation) => 51 + supportsPersistedQueries && 52 + !operation.context.persistAttempt && 53 + ((enableForMutation && operation.kind === 'mutation') || 54 + operation.kind === 'query'); 55 + 56 + return operations$ => { 57 + const retries = makeSubject<Operation>(); 58 + const sharedOps$ = share(operations$); 59 + 60 + const forwardedOps$ = pipe( 61 + sharedOps$, 62 + filter(operation => !operationFilter(operation)) 63 + ); 64 + 65 + const persistedOps$ = pipe( 66 + sharedOps$, 67 + filter(operationFilter), 68 + map(async operation => { 69 + const persistedOperation = makeOperation(operation.kind, operation, { 70 + ...operation.context, 71 + persistAttempt: true, 72 + }); 73 + 74 + const sha256Hash = await hashFn( 75 + stringifyDocument(operation.query), 76 + operation.query 77 + ); 78 + if (sha256Hash) { 79 + persistedOperation.extensions = { 80 + ...persistedOperation.extensions, 81 + persistedQuery: { 82 + version: 1, 83 + sha256Hash, 84 + }, 85 + }; 86 + if ( 87 + persistedOperation.kind === 'query' && 88 + preferGetForPersistedQueries 89 + ) { 90 + persistedOperation.context.preferGetMethod = 'force'; 91 + } 92 + } 93 + 94 + return persistedOperation; 95 + }), 96 + mergeMap(fromPromise) 97 + ); 98 + 99 + return pipe( 100 + merge([persistedOps$, forwardedOps$, retries.source]), 101 + forward, 102 + map(result => { 103 + if ( 104 + !enforcePersistedQueries && 105 + result.operation.extensions && 106 + result.operation.extensions.persistedQuery 107 + ) { 108 + if (result.error && isPersistedUnsupported(result.error)) { 109 + // Disable future persisted queries if they're not enforced 110 + supportsPersistedQueries = false; 111 + // Update operation with unsupported attempt 112 + const followupOperation = makeOperation( 113 + result.operation.kind, 114 + result.operation 115 + ); 116 + if (followupOperation.extensions) 117 + delete followupOperation.extensions.persistedQuery; 118 + retries.next(followupOperation); 119 + return null; 120 + } else if (result.error && isPersistedMiss(result.error)) { 121 + // Update operation with unsupported attempt 122 + const followupOperation = makeOperation( 123 + result.operation.kind, 124 + result.operation 125 + ); 126 + // Mark as missed persisted query 127 + followupOperation.extensions = { 128 + ...followupOperation.extensions, 129 + persistedQuery: { 130 + ...(followupOperation.extensions || {}).persistedQuery, 131 + miss: true, 132 + } as PersistedRequestExtensions, 133 + }; 134 + retries.next(followupOperation); 135 + return null; 136 + } 137 + } 138 + return result; 139 + }), 140 + filter((result): result is OperationResult => !!result) 141 + ); 142 + }; 143 + };
+1
packages/core/src/index.ts
··· 7 7 export { 8 8 CombinedError, 9 9 stringifyVariables, 10 + stringifyDocument, 10 11 createRequest, 11 12 makeResult, 12 13 makeErrorResult,
+39
packages/core/src/internal/fetchOptions.test.ts
··· 3 3 import { queryOperation, mutationOperation } from '../test-utils'; 4 4 import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions'; 5 5 6 + describe('makeFetchBody', () => { 7 + it('creates a fetch body', () => { 8 + const body = makeFetchBody(queryOperation); 9 + expect(body).toMatchInlineSnapshot(` 10 + { 11 + "extensions": undefined, 12 + "operationName": "getUser", 13 + "query": "query getUser($name: String) { 14 + user(name: $name) { 15 + id 16 + firstName 17 + lastName 18 + } 19 + }", 20 + "variables": { 21 + "name": "Clara", 22 + }, 23 + } 24 + `); 25 + }); 26 + 27 + it('omits the query property when APQ is set', () => { 28 + const apqOperation = makeOperation(queryOperation.kind, queryOperation); 29 + 30 + apqOperation.extensions = { 31 + ...apqOperation.extensions, 32 + persistedQuery: { 33 + version: 1, 34 + sha256Hash: '[test]', 35 + }, 36 + }; 37 + 38 + expect(makeFetchBody(apqOperation).query).toBe(undefined); 39 + 40 + apqOperation.extensions.persistedQuery!.miss = true; 41 + expect(makeFetchBody(apqOperation).query).not.toBe(undefined); 42 + }); 43 + }); 44 + 6 45 describe('makeFetchURL', () => { 7 46 it('returns the URL by default', () => { 8 47 const body = makeFetchBody(queryOperation);
+5 -1
packages/core/src/internal/fetchOptions.ts
··· 24 24 Data = any, 25 25 Variables extends AnyVariables = AnyVariables 26 26 >(request: Omit<GraphQLRequest<Data, Variables>, 'key'>): FetchBody { 27 + const isAPQ = 28 + request.extensions && 29 + request.extensions.persistedQuery && 30 + !request.extensions.persistedQuery.miss; 27 31 return { 28 - query: stringifyDocument(request.query), 32 + query: isAPQ ? undefined : stringifyDocument(request.query), 29 33 operationName: getOperationName(request.query), 30 34 variables: request.variables || undefined, 31 35 extensions: request.extensions,
+22 -1
packages/core/src/types.ts
··· 41 41 */ 42 42 type Extensions = Record<string, any>; 43 43 44 + /** Extensions sub-property on `persistedQuery` for Automatic Persisted Queries. 45 + * 46 + * @remarks 47 + * This is part of the Automatic Persisted Query defacto standard and allows an API 48 + * request to omit the `query`, instead sending this `sha256Hash`. 49 + */ 50 + export interface PersistedRequestExtensions { 51 + version?: 1; 52 + sha256Hash: string; 53 + /** Set when a `sha256Hash` previously experienced a miss which will force `query` to be sent. */ 54 + miss?: boolean; 55 + } 56 + 57 + /** Extensions which may be palced on {@link GraphQLRequest | GraphQLRequests}. 58 + * @see {@link https://github.com/graphql/graphql-over-http/blob/1928447/spec/GraphQLOverHTTP.md#request-parameters} for the GraphQL over HTTP spec 59 + */ 60 + export interface RequestExtensions { 61 + persistedQuery?: PersistedRequestExtensions; 62 + [extension: string]: any; 63 + } 64 + 44 65 /** Incremental Payloads sent as part of "Incremental Delivery" patching prior result data. 45 66 * 46 67 * @remarks ··· 253 274 /** Additional metadata that a GraphQL API may accept for spec extensions. 254 275 * @see {@link https://github.com/graphql/graphql-over-http/blob/1928447/spec/GraphQLOverHTTP.md#request-parameters} for the GraphQL over HTTP spec 255 276 */ 256 - extensions?: Record<string, any> | undefined; 277 + extensions?: RequestExtensions | undefined; 257 278 } 258 279 259 280 /** Parameters from which {@link GraphQLRequest | GraphQLRequests} are created from.
+8 -2
packages/core/src/utils/request.ts
··· 9 9 10 10 import { HashValue, phash } from './hash'; 11 11 import { stringifyVariables } from './variables'; 12 - import { TypedDocumentNode, AnyVariables, GraphQLRequest } from '../types'; 12 + 13 + import type { 14 + TypedDocumentNode, 15 + AnyVariables, 16 + GraphQLRequest, 17 + RequestExtensions, 18 + } from '../types'; 13 19 14 20 interface WritableLocation { 15 21 loc: Location | undefined; ··· 153 159 >( 154 160 _query: string | DocumentNode | TypedDocumentNode<Data, Variables>, 155 161 _variables: Variables, 156 - extensions?: Record<string, any> | undefined 162 + extensions?: RequestExtensions | undefined 157 163 ): GraphQLRequest<Data, Variables> => { 158 164 const variables = _variables || ({} as Variables); 159 165 const query = keyDocument(_query);
+1 -1
pnpm-lock.yaml
··· 190 190 devDependencies: 191 191 graphql: 16.0.1 192 192 193 - exchanges/persisted-fetch: 193 + exchanges/persisted: 194 194 specifiers: 195 195 '@urql/core': '>=3.2.2' 196 196 graphql: ^16.0.0