Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 11 kB view raw
1import type { GraphQLError } from '../utils/graphql'; 2import { pipe, filter, merge, map, tap } from 'wonka'; 3import type { Exchange, OperationResult, Operation } from '../types'; 4import { addMetadata, CombinedError } from '../utils'; 5import { reexecuteOperation, mapTypeNames } from './cache'; 6 7/** A serialized version of an {@link OperationResult}. 8 * 9 * @remarks 10 * All properties are serialized separately as JSON strings, except for the 11 * {@link CombinedError} to speed up JS parsing speed, even if a result doesn’t 12 * end up being used. 13 * 14 * @internal 15 */ 16export interface SerializedResult { 17 hasNext?: boolean; 18 /** JSON-serialized version of {@link OperationResult.data}. */ 19 data?: string | undefined; // JSON string of data 20 /** JSON-serialized version of {@link OperationResult.extensions}. */ 21 extensions?: string | undefined; 22 /** JSON version of {@link CombinedError}. */ 23 error?: { 24 graphQLErrors: Array<Partial<GraphQLError> | string>; 25 networkError?: string; 26 }; 27} 28 29/** A dictionary of {@link Operation.key} keys to serializable {@link SerializedResult} objects. 30 * 31 * @remarks 32 * It’s not recommended to modify the serialized data manually, however, multiple payloads of 33 * this dictionary may safely be merged and combined. 34 */ 35export interface SSRData { 36 [key: string]: SerializedResult; 37} 38 39/** Options for the `ssrExchange` allowing it to either operate on the server- or client-side. */ 40export interface SSRExchangeParams { 41 /** Indicates to the {@link SSRExchange} whether it's currently in server-side or client-side mode. 42 * 43 * @remarks 44 * Depending on this option, the {@link SSRExchange} will either capture or replay results. 45 * When `true`, it’s in client-side mode and results will be serialized. When `false`, it’ll 46 * use its deserialized data and replay results from it. 47 */ 48 isClient?: boolean; 49 /** May be used on the client-side to pass the {@link SSRExchange} serialized data from the server-side. 50 * 51 * @remarks 52 * Alternatively, {@link SSRExchange.restoreData} may be called to imperatively add serialized data to 53 * the exchange. 54 * 55 * Hint: This method also works on the server-side to add to the initial serialized data, which enables 56 * you to combine multiple {@link SSRExchange} results, as needed. 57 */ 58 initialState?: SSRData; 59 /** Forces a new API request to be sent in the background after replaying the deserialized result. 60 * 61 * @remarks 62 * Similarly to the `cache-and-network` {@link RequestPolicy}, this option tells the {@link SSRExchange} 63 * to send a new API request for the {@link Operation} after replaying a serialized result. 64 * 65 * Hint: This is useful when you're caching SSR results and need the client-side to update itself after 66 * rendering the initial serialized SSR results. 67 */ 68 staleWhileRevalidate?: boolean; 69 /** Forces {@link OperationResult.extensions} to be serialized alongside the rest of a result. 70 * 71 * @remarks 72 * Entries in the `extension` object of a GraphQL result are often non-standard metdata, and many 73 * APIs use it for data that changes between every request. As such, the {@link SSRExchange} will 74 * not serialize this data by default, unless this flag is set. 75 */ 76 includeExtensions?: boolean; 77} 78 79/** An `SSRExchange` either in server-side mode, serializing results, or client-side mode, deserializing and replaying results.. 80 * 81 * @remarks 82 * This same {@link Exchange} is used in your code both for the client-side and server-side as it’s “universal” 83 * and can be put into either client-side or server-side mode using the {@link SSRExchangeParams.isClient} flag. 84 * 85 * In server-side mode, the `ssrExchange` will “record” results it sees from your API and provide them for you 86 * to send to the client-side using the {@link SSRExchange.extractData} method. 87 * 88 * In client-side mode, the `ssrExchange` will use these serialized results, rehydrated either using 89 * {@link SSRExchange.restoreData} or {@link SSRexchangeParams.initialState}, to replay results the 90 * server-side has seen and sent before. 91 * 92 * Each serialized result will only be replayed once, as it’s assumed that your cache exchange will have the 93 * results cached afterwards. 94 */ 95export interface SSRExchange extends Exchange { 96 /** Client-side method to add serialized results to the {@link SSRExchange}. 97 * @param data - {@link SSRData}, 98 */ 99 restoreData(data: SSRData): void; 100 /** Server-side method to get all serialized results the {@link SSRExchange} has captured. 101 * @returns an {@link SSRData} dictionary. 102 */ 103 extractData(): SSRData; 104} 105 106/** Serialize an OperationResult to plain JSON */ 107const serializeResult = ( 108 result: OperationResult, 109 includeExtensions: boolean 110): SerializedResult => { 111 const serialized: SerializedResult = { 112 hasNext: result.hasNext, 113 }; 114 115 if (result.data !== undefined) { 116 serialized.data = JSON.stringify(result.data); 117 } 118 119 if (includeExtensions && result.extensions !== undefined) { 120 serialized.extensions = JSON.stringify(result.extensions); 121 } 122 123 if (result.error) { 124 serialized.error = { 125 graphQLErrors: result.error.graphQLErrors.map(error => { 126 if (!error.path && !error.extensions) return error.message; 127 128 return { 129 message: error.message, 130 path: error.path, 131 extensions: error.extensions, 132 }; 133 }), 134 }; 135 136 if (result.error.networkError) { 137 serialized.error.networkError = '' + result.error.networkError; 138 } 139 } 140 141 return serialized; 142}; 143 144/** Deserialize plain JSON to an OperationResult 145 * @internal 146 */ 147const deserializeResult = ( 148 operation: Operation, 149 result: SerializedResult, 150 includeExtensions: boolean 151): OperationResult => ({ 152 operation, 153 data: result.data ? JSON.parse(result.data) : undefined, 154 extensions: 155 includeExtensions && result.extensions 156 ? JSON.parse(result.extensions) 157 : undefined, 158 error: result.error 159 ? new CombinedError({ 160 networkError: result.error.networkError 161 ? new Error(result.error.networkError) 162 : undefined, 163 graphQLErrors: result.error.graphQLErrors, 164 }) 165 : undefined, 166 stale: false, 167 hasNext: !!result.hasNext, 168}); 169 170const revalidated = new Set<number>(); 171 172/** Creates a server-side rendering `Exchange` that either captures responses on the server-side or replays them on the client-side. 173 * 174 * @param params - An {@link SSRExchangeParams} configuration object. 175 * @returns the created {@link SSRExchange} 176 * 177 * @remarks 178 * When dealing with server-side rendering, we essentially have two {@link Client | Clients} making requests, 179 * the server-side client, and the client-side one. The `ssrExchange` helps implementing a tiny cache on both 180 * sides that: 181 * 182 * - captures results on the server-side which it can serialize, 183 * - replays results on the client-side that it deserialized from the server-side. 184 * 185 * Hint: The `ssrExchange` is basically an exchange that acts like a replacement for any fetch exchange 186 * temporarily. As such, you should place it after your cache exchange but in front of any fetch exchange. 187 */ 188export const ssrExchange = (params: SSRExchangeParams = {}): SSRExchange => { 189 const staleWhileRevalidate = !!params.staleWhileRevalidate; 190 const includeExtensions = !!params.includeExtensions; 191 const data: Record<string, SerializedResult | null> = {}; 192 193 // On the client-side, we delete results from the cache as they're resolved 194 // this is delayed so that concurrent queries don't delete each other's data 195 const invalidateQueue: number[] = []; 196 const invalidate = (result: OperationResult) => { 197 invalidateQueue.push(result.operation.key); 198 if (invalidateQueue.length === 1) { 199 Promise.resolve().then(() => { 200 let key: number | void; 201 while ((key = invalidateQueue.shift())) { 202 data[key] = null; 203 } 204 }); 205 } 206 }; 207 208 // The SSR Exchange is a temporary cache that can populate results into data for suspense 209 // On the client it can be used to retrieve these temporary results from a rehydrated cache 210 const ssr: SSRExchange = 211 ({ client, forward }) => 212 ops$ => { 213 // params.isClient tells us whether we're on the client-side 214 // By default we assume that we're on the client if suspense-mode is disabled 215 const isClient = 216 params && typeof params.isClient === 'boolean' 217 ? !!params.isClient 218 : !client.suspense; 219 220 let forwardedOps$ = pipe( 221 ops$, 222 filter( 223 operation => 224 operation.kind === 'teardown' || 225 !data[operation.key] || 226 !!data[operation.key]!.hasNext || 227 operation.context.requestPolicy === 'network-only' 228 ), 229 map(mapTypeNames), 230 forward 231 ); 232 233 // NOTE: Since below we might delete the cached entry after accessing 234 // it once, cachedOps$ needs to be merged after forwardedOps$ 235 let cachedOps$ = pipe( 236 ops$, 237 filter( 238 operation => 239 operation.kind !== 'teardown' && 240 !!data[operation.key] && 241 operation.context.requestPolicy !== 'network-only' 242 ), 243 map(op => { 244 const serialized = data[op.key]!; 245 const cachedResult = deserializeResult( 246 op, 247 serialized, 248 includeExtensions 249 ); 250 251 if (staleWhileRevalidate && !revalidated.has(op.key)) { 252 cachedResult.stale = true; 253 revalidated.add(op.key); 254 reexecuteOperation(client, op); 255 } 256 257 const result: OperationResult = { 258 ...cachedResult, 259 operation: addMetadata(op, { 260 cacheOutcome: 'hit', 261 }), 262 }; 263 return result; 264 }) 265 ); 266 267 if (!isClient) { 268 // On the server we cache results in the cache as they're resolved 269 forwardedOps$ = pipe( 270 forwardedOps$, 271 tap((result: OperationResult) => { 272 const { operation } = result; 273 if (operation.kind !== 'mutation') { 274 const serialized = serializeResult(result, includeExtensions); 275 data[operation.key] = serialized; 276 } 277 }) 278 ); 279 } else { 280 // On the client we delete results from the cache as they're resolved 281 cachedOps$ = pipe(cachedOps$, tap(invalidate)); 282 } 283 284 return merge([forwardedOps$, cachedOps$]); 285 }; 286 287 ssr.restoreData = (restore: SSRData) => { 288 for (const key in restore) { 289 // We only restore data that hasn't been previously invalidated 290 if (data[key] !== null) { 291 data[key] = restore[key]; 292 } 293 } 294 }; 295 296 ssr.extractData = () => { 297 const result: SSRData = {}; 298 for (const key in data) if (data[key] != null) result[key] = data[key]!; 299 return result; 300 }; 301 302 if (params && params.initialState) { 303 ssr.restoreData(params.initialState); 304 } 305 306 return ssr; 307};