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};