Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 9.6 kB view raw
1/* Summary: This file handles the HTTP transport via GraphQL over HTTP 2 * See: https://graphql.github.io/graphql-over-http/draft/ 3 * 4 * `@urql/core`, by default, implements several RFC'd protocol extensions 5 * on top of this. As such, this implementation supports: 6 * - [Incremental Delivery](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) 7 * - [GraphQL over SSE](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md) 8 * 9 * This also supports the "Defer Stream" payload format. 10 * See: https://github.com/graphql/graphql-wg/blob/main/rfcs/DeferStream.md 11 * Implementation for this is located in `../utils/result.ts` in `mergeResultPatch` 12 * 13 * And; this also supports the GraphQL Multipart spec for file uploads. 14 * See: https://github.com/jaydenseric/graphql-multipart-request-spec 15 * Implementation for this is located in `../utils/variables.ts` in `extractFiles`, 16 * and `./fetchOptions.ts` in `serializeBody`. 17 * 18 * And; this also supports GET requests (and hence; automatic persisted queries) 19 * via the `@urql/exchange-persisted` package. 20 * 21 * This implementation DOES NOT support Batching. 22 * See: https://github.com/graphql/graphql-over-http/blob/main/rfcs/Batching.md 23 * Which is deemed out-of-scope, as it's sufficiently unnecessary given 24 * modern handling of HTTP requests being in parallel. 25 * 26 * The implementation in this file needs to make certain accommodations for: 27 * - The Web Fetch API 28 * - Non-browser or polyfill Fetch APIs 29 * - Node.js-like Fetch implementations 30 * 31 * GraphQL over SSE has a reference implementation, which supports non-HTTP/2 32 * modes and is a faithful implementation of the spec. 33 * See: https://github.com/enisdenjo/graphql-sse 34 * 35 * GraphQL Inremental Delivery (aka “GraphQL Multipart Responses”) has a 36 * reference implementation, which a prior implementation of this file heavily 37 * leaned on (See prior attribution comments) 38 * See: https://github.com/maraisr/meros 39 * 40 * This file merges support for all three GraphQL over HTTP response formats 41 * via async generators and Wonka’s `fromAsyncIterable`. As part of this, `streamBody` 42 * and `split` are the common, cross-compatible base implementations. 43 */ 44 45import type { Source } from 'wonka'; 46import { fromAsyncIterable, onEnd, filter, pipe } from 'wonka'; 47import type { Operation, OperationResult, ExecutionResult } from '../types'; 48import { makeResult, makeErrorResult, mergeResultPatch } from '../utils'; 49 50const boundaryHeaderRe = /boundary="?([^=";]+)"?/i; 51const eventStreamRe = /data: ?([^\n]+)/; 52 53type ChunkData = Buffer | Uint8Array; 54 55async function* streamBody( 56 response: Response 57): AsyncIterableIterator<ChunkData> { 58 if (response.body![Symbol.asyncIterator]) { 59 for await (const chunk of response.body! as any) yield chunk as ChunkData; 60 } else { 61 const reader = response.body!.getReader(); 62 let result: ReadableStreamReadResult<ChunkData>; 63 try { 64 while (!(result = await reader.read()).done) yield result.value; 65 } finally { 66 reader.cancel(); 67 } 68 } 69} 70 71async function* streamToBoundedChunks( 72 chunks: AsyncIterableIterator<ChunkData>, 73 boundary: string 74): AsyncIterableIterator<string> { 75 const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; 76 let buffer = ''; 77 let boundaryIndex: number; 78 for await (const chunk of chunks) { 79 // NOTE: We're avoiding referencing the `Buffer` global here to prevent 80 // auto-polyfilling in Webpack 81 buffer += 82 chunk.constructor.name === 'Buffer' 83 ? (chunk as Buffer).toString() 84 : decoder!.decode(chunk as ArrayBuffer, { stream: true }); 85 while ((boundaryIndex = buffer.indexOf(boundary)) > -1) { 86 yield buffer.slice(0, boundaryIndex); 87 buffer = buffer.slice(boundaryIndex + boundary.length); 88 } 89 } 90} 91 92async function* parseJSON( 93 response: Response 94): AsyncIterableIterator<ExecutionResult> { 95 yield JSON.parse(await response.text()); 96} 97 98async function* parseEventStream( 99 response: Response 100): AsyncIterableIterator<ExecutionResult> { 101 let payload: any; 102 for await (const chunk of streamToBoundedChunks( 103 streamBody(response), 104 '\n\n' 105 )) { 106 const match = chunk.match(eventStreamRe); 107 if (match) { 108 const chunk = match[1]; 109 try { 110 yield (payload = JSON.parse(chunk)); 111 } catch (error) { 112 if (!payload) throw error; 113 } 114 if (payload && payload.hasNext === false) break; 115 } 116 } 117 if (payload && payload.hasNext !== false) { 118 yield { hasNext: false }; 119 } 120} 121 122async function* parseMultipartMixed( 123 contentType: string, 124 response: Response 125): AsyncIterableIterator<ExecutionResult> { 126 const boundaryHeader = contentType.match(boundaryHeaderRe); 127 const boundary = '--' + (boundaryHeader ? boundaryHeader[1] : '-'); 128 let isPreamble = true; 129 let payload: any; 130 for await (let chunk of streamToBoundedChunks( 131 streamBody(response), 132 '\r\n' + boundary 133 )) { 134 if (isPreamble) { 135 isPreamble = false; 136 const preambleIndex = chunk.indexOf(boundary); 137 if (preambleIndex > -1) { 138 chunk = chunk.slice(preambleIndex + boundary.length); 139 } else { 140 continue; 141 } 142 } 143 try { 144 yield (payload = JSON.parse(chunk.slice(chunk.indexOf('\r\n\r\n') + 4))); 145 } catch (error) { 146 if (!payload) throw error; 147 } 148 if (payload && payload.hasNext === false) break; 149 } 150 if (payload && payload.hasNext !== false) { 151 yield { hasNext: false }; 152 } 153} 154 155async function* parseMaybeJSON( 156 response: Response 157): AsyncIterableIterator<ExecutionResult> { 158 const text = await response.text(); 159 try { 160 const result = JSON.parse(text); 161 if (process.env.NODE_ENV !== 'production') { 162 console.warn( 163 `Found response with content-type "text/plain" but it had a valid "application/json" response.` 164 ); 165 } 166 yield result; 167 } catch (e) { 168 throw new Error(text); 169 } 170} 171 172async function* fetchOperation( 173 operation: Operation, 174 url: string, 175 fetchOptions: RequestInit 176) { 177 let networkMode = true; 178 let result: OperationResult | null = null; 179 let response: Response | undefined; 180 181 try { 182 // Delay for a tick to give the Client a chance to cancel the request 183 // if a teardown comes in immediately 184 yield await Promise.resolve(); 185 186 response = await (operation.context.fetch || fetch)(url, fetchOptions); 187 const contentType = response.headers.get('Content-Type') || ''; 188 189 let results: AsyncIterable<ExecutionResult>; 190 if (/multipart\/mixed/i.test(contentType)) { 191 results = parseMultipartMixed(contentType, response); 192 } else if (/text\/event-stream/i.test(contentType)) { 193 results = parseEventStream(response); 194 } else if (!/text\//i.test(contentType)) { 195 results = parseJSON(response); 196 } else { 197 results = parseMaybeJSON(response); 198 } 199 200 let pending: ExecutionResult['pending']; 201 for await (const payload of results) { 202 if (payload.pending && !result) { 203 pending = payload.pending; 204 } else if (payload.pending) { 205 pending = [...pending!, ...payload.pending]; 206 } 207 result = result 208 ? mergeResultPatch(result, payload, response, pending) 209 : makeResult(operation, payload, response); 210 networkMode = false; 211 yield result; 212 networkMode = true; 213 } 214 215 if (!result) { 216 yield (result = makeResult(operation, {}, response)); 217 } 218 } catch (error: any) { 219 if (!networkMode) { 220 throw error; 221 } 222 223 yield makeErrorResult( 224 operation, 225 response && 226 (response.status < 200 || response.status >= 300) && 227 response.statusText 228 ? new Error(response.statusText) 229 : error, 230 response 231 ); 232 } 233} 234 235/** Makes a GraphQL HTTP request to a given API by wrapping around the Fetch API. 236 * 237 * @param operation - The {@link Operation} that should be sent via GraphQL over HTTP. 238 * @param url - The endpoint URL for the GraphQL HTTP API. 239 * @param fetchOptions - The {@link RequestInit} fetch options for the request. 240 * @returns A Wonka {@link Source} of {@link OperationResult | OperationResults}. 241 * 242 * @remarks 243 * This utility defines how all built-in fetch exchanges make GraphQL HTTP requests, 244 * supporting multipart incremental responses, cancellation and other smaller 245 * implementation details. 246 * 247 * If you’re implementing a modified fetch exchange for a GraphQL over HTTP API 248 * it’s recommended you use this utility. 249 * 250 * Hint: This function does not use the passed `operation` to create or modify the 251 * `fetchOptions` and instead expects that the options have already been created 252 * using {@link makeFetchOptions} and modified as needed. 253 * 254 * @throws 255 * If the `fetch` polyfill or globally available `fetch` function doesn’t support 256 * streamed multipart responses while trying to handle a `multipart/mixed` GraphQL response, 257 * the source will throw “Streaming requests unsupported”. 258 * This shouldn’t happen in modern browsers and Node.js. 259 * 260 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} for the Fetch API spec. 261 */ 262export function makeFetchSource( 263 operation: Operation, 264 url: string, 265 fetchOptions: RequestInit 266): Source<OperationResult> { 267 let abortController: AbortController | void; 268 if (typeof AbortController !== 'undefined') { 269 fetchOptions.signal = (abortController = new AbortController()).signal; 270 } 271 return pipe( 272 fromAsyncIterable(fetchOperation(operation, url, fetchOptions)), 273 filter((result): result is OperationResult => !!result), 274 onEnd(() => { 275 if (abortController) abortController.abort(); 276 }) 277 ); 278}