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}