Yusinto Ngadiman
November 12, 2017·4 min read

Relay Modern Persisted Queries

This blog is about relay-compiler-plus, a custom relay compiler which supports persisted queries.

One of the more exciting features of relay modern is the ability to use persisted queries with the graphql server. Instead of sending the full graphql query in the request payload, you send a short query id which gets mapped on the server side to the full query. It's a no-brainer for performance. I upgraded from classic for this!

Alas, the official documentation only briefly mentioned this without anything else. A quick google search reveals a solution from Scott Taylor from the New York Times. However his solution generates query ids at runtime at the network fetch level which is not optimal. I want a solution where the query ids and the query map file are both generated at compile time for maximum performance.

It turns out, the original relay-compiler does have a hidden feature to support persisted queries.

This package is inspired by a conversation with Lee Byron at Graphql Summit 2017.

The key

The key lies in RelayFileWriter. It accepts a config object where you can specify a persistQuery function. You can dig around and peruse that code at your leisure (which I did). The end result is a modification to the relay-compiler which accepts a persistQuery function which gets passed down to RelayFileWriter. I call it relay-compiler-plus.

Step 1: Install

Install relay-compiler-plus and the latest graphql-js package:

yarn add relay-compiler-plus
yarn upgrade graphql --latest

Step 2: Compile

Add this npm command to your package.json:

"scripts": {
    "rcp": "relay-compiler-plus --schema <SCHEMA_FILE_PATH> --src <SRC_DIR_PATH> -f"
},

where:
SCHEMA_FILE_PATH is the path to your schema.graphql or schema.json file
SRC_DIR_PATH is the path to your src directory
-f will delete all **/__generated__/*.graphql.js files under SRC_DIR_PATH before compilation starts.

Run the command to start compiling:

npm run rcp

Step 3: Map query ids on the server

On the server, use matchQueryMiddleware prior to express-graphql to match query ids to actual queries. Note that queryMap.json is auto-generated by relay-compiler-plus in the previous step.

import Express from 'express';
import expressGraphl from 'express-graphql';
import {matchQueryMiddleware} from 'relay-compiler-plus'; // do this
import queryMapJson from '../queryMap.json'; // do this

const app = Express();

app.use('/graphql',
  matchQueryMiddleware(queryMapJson), // do this
  expressGraphl({
    schema: graphqlSchema,
    graphiql: true,
  }));

Step 4: Send query ids on the client

On the client, modify your relay network fetch implementation to pass a queryId parameter in the request body instead of a query parameter. Note that operation.id is generated by relay-compiler-plus in step 2.

function fetchQuery(operation, variables,) {
  return fetch('/graphql', {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify({
      queryId: operation.id, // do this
      variables,
    }),
  }).then(response => {
    return response.json();
  });
}

Bonus

In step 2, running relay-compiler-plus generates relay query files like the original relay-compiler, but with a difference. Inspect a generated ConcreteBatch query file and you'll see that it now has an id assigned to it and that the query text is now null:

const batch /*: ConcreteBatch*/ = {
  "fragment": {
    "argumentDefinitions": [],
    "kind": "Fragment",
    "metadata": null,
    "name": "client_index_Query",
    "selections": [...],
    "type": "Query"
  },
  "id": "6082095e8a45f64d38924775d047cf8c", // look ma, query id!
  "kind": "Batch",
  "metadata": {},
  "name": "client_index_Query",
  "query": {...},
  "text": null // look again ma, null query text!
};

The id is an md5 hash of the query text, generated by the persistQuery function. It looks like this:

 function persistQuery(operationText: string): Promise<string> {
   return new Promise((resolve) => {
     const queryId = md5(operationText);
     
     // queryCache is written to disk at the end as queryMap.json
     queryCache.push({id: queryId, text: operationText});
     resolve(queryId);
   });
 }   

As you can see above, the hash to query text mapping is saved to an array which gets written to disk at the end of the compilation as queryMap.json. This is used on the server side as outlined in step 3.

Conclusion

You can find the package at github with a fully working example.

Let me know if this is useful (or not)!