Earlier this week I released GraphQL Helix, a new JavaScript library that lets you take charge of your GraphQL server implementation.
There's a couple of factors that pushed me to roll my own GraphQL server library:
- I wanted to use bleeding-edge GraphQL features like
@defer
,@stream
and@live
directives. - I wanted to make sure I wasn't tied down to a specific framework or runtime environment.
- I wanted control over how server features like persisted queries were implemented.
- I wanted to use something other than WebSocket (i.e. SSE) for subscriptions.
Unfortunately, popular solutions like Apollo Server, express-graphql and Mercurius fell short in one or more of these regards, so here we are.
Existing libraries like Apollo Server provide you with either a complete HTTP server or else a middleware function that you can plug into your framework of choice. GraphQL Helix takes a different approach -- it just provides a handful of functions that you can use to turn an HTTP request into a GraphQL execution result. In other words, GraphQL Helix leaves it up to you to decide how to send back the response.
Let's see how this works in practice.
A Basic Example
We'll start by building an express application and adding a /graphql
endpoint.
import express from "express";
import { schema } from "./my-awesome-schema";
const app = express();
app.use(express.json());
app.use("/graphql", async (res, req) => {
// TODO
});
app.listen(8000);
Note that we're assuming here we already have a GraphQL schema we've created. However you build your schema (GraphQL Tools, TypeGraphQL,
graphql-compose, GraphQL Nexus, etc.) is irrelevant -- as long as you have a GraphQLSchema object, you're good to go.
Next, let's extract the relevant bits from our request into a standard GraphQL Helix object:
app.use("/graphql", async (res, req) => {
const request = {
body: req.body,
headers: req.headers,
method: req.method,
query: req.query,
};
});
More astute readers might note that we could have just used the req
object as-is — and that's true! However, this step will look a little different depending on the framework or runtime we use, so I'm being more explicit about how we define this object.
Now let's extract the relevant parameters from the request and process them.
import {
getGraphQLParameters,
processRequest
} from "graphql-helix";
...
app.use("/graphql", async (res, req) => {
const request = {
body: req.body,
headers: req.headers,
method: req.method,
query: req.query,
};
const {
query,
variables,
operationName
} = getGraphQLParameters(request);
const result = await processRequest({
schema,
query,
variables,
operationName,
request,
})
});
processRequest
still takes our Request
object as a parameter, so why doesn't it just call getGraphQLParameters
for us? As we'll see later on, this is an intentional design choice that gives us the flexibility to decide how the parameters are actually derived from the request.
So, we've processed our request and now have a result. Groovy. Let's do something with that result.
app.use("/graphql", async (res, req) => {
const request = {
body: req.body,
headers: req.headers,
method: req.method,
query: req.query,
};
const {
query,
variables,
operationName
} = getGraphQLParameters(request);
const result = await processRequest({
schema,
query,
variables,
operationName,
request,
})
if (result.type === "RESPONSE") {
result.headers.forEach(({ name, value }) => {
res.setHeader(name, value)
});
res.status(result.status);
res.json(result.payload);
} else {
// TODO
}
});
Our result includes the headers we should send back, an HTTP status code and the response payload (i.e. an object containing the data
and errors
we get by actually validating and executing the request).
And that's it! We now have a working /graphql
endpoint that can process our requests. Neat.
So why are we writing all this extra boilerplate when I could do the same thing in a few lines of code in Apollo Server? In a word: flexibility. If we swap out Express for another framework like Fastify, we only have to change how we construct our request object and how we handle the result. In fact, we could use the meat of our implementation in virtually any other runtime -- serverless, Deno or even in the browser.
Moreover, we can process the result however our business needs dictate. We have a GraphQL over HTTP specification, but if for some reason you need to deviate from it, you can. It's your application -- send back the status, headers or response that are right for your use case.
So... what's up with that else
block? As it turns out, processRequest
will return one of three types of results:
-
RESPONSE
for standard queries and mutations, -
MULTIPART_RESPONSE
for requests that include the new@defer
and@stream
directives, and -
PUSH
for subscriptions
Again, it's up to us to implement how to send back these responses, so let's do that now!
Subscriptions
We'll implement our subscriptions using Server Sent Events (SSE). There's a lot of advantages of using SSE over something like WebSockets for subscriptions, like being able to use the same middleware for all your requests, but a deeper comparison of the two approaches will be the topic of a future article.
There's a few libraries out there that can make integrating SSE with Express easier, but we'll do it from scratch for this example:
if (result.type === "RESPONSE") {
...
} else if (result.type === "PUSH") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
Connection: "keep-alive",
"Cache-Control": "no-cache",
});
req.on("close", () => {
result.unsubscribe();
});
await result.subscribe((result) => {
res.write(`data: ${JSON.stringify(result)}\n\n`);
});
}
Here, our result includes two methods -- subscribe
and unsubscribe
. We call subscribe
with a callback that's passed the result each time a new subscription event is pushed -- within this callback, we just write
to the response with a SSE-compatible payload. And we call unsubscribe
when the request is closed (i.e. when the client closes the connection) to prevent memory leaks.
Easy, peasy. Now let's take a look at MULTIPART_RESPONSE
.
Multipart Responses
If our request includes @stream
or @defer
directives, our request needs to be sent down to the client in chunks. For example, with @defer
, we send down everything except the deferred fragment and eventually send down the deferred fragment data when its finally resolved. As such, our MULTIPART_RESPONSE
result looks a lot like the PUSH
result with one key difference -- we do want to eventually end our response once all parts have been sent.
if (result.type === "RESPONSE") {
...
} else if (result.type === "PUSH") {
...
} else {
res.writeHead(200, {
Connection: "keep-alive",
"Content-Type": 'multipart/mixed; boundary="-"',
"Transfer-Encoding": "chunked",
});
req.on("close", () => {
result.unsubscribe();
});
await result.subscribe((result) => {
const chunk = Buffer.from(
JSON.stringify(result),
"utf8"
);
const data = [
"",
"---",
"Content-Type: application/json; charset=utf-8",
"Content-Length: " + String(chunk.length),
"",
chunk,
"",
].join("\r\n");
res.write(data);
});
res.end("\r\n-----\r\n");
}
Note that the Promise returned by subscribe
won't resolve until the request has been fully resolved and the callback has been called with all the chunks, at which point we can safely end our response.
Congrats! Our API now has support for @defer
and @stream
(provide you're using the correct version of graphql-js
).
Adding GraphiQL
GraphQL Helix comes with two additional functions that can be used to expose a GraphiQL interface on your server.
shouldRenderGraphiQL
takes a Request object and returns a boolean that indicates, as you may have already guessed, whether you should render the interface. This is helpful when you have a single endpoint for both your API and the interface and only want to return the GraphiQL interface when processing a GET request from inside a browser.
renderGraphiQL
just returns a string with the HTML necessary to the render the interface. If you want to create a separate endpoint for your documentation, you can use this function without using shouldRenderGraphiQL
at all.
app.use("/graphql", async (req, res) => {
const request = {
body: req.body,
headers: req.headers,
method: req.method,
query: req.query,
};
if (shouldRenderGraphiQL(request)) {
res.send(renderGraphiQL());
} else {
// Process the request
}
});
The returned GraphiQL has a fetcher implementation that will work with multipart requests and SSE as shown in the examples above. If you need to do something else for your server, you can roll your own using renderGraphiQL
as a template only.
Evolving your server implementation
GraphQL Helix is, by design, light-weight and unopinionated. Libraries like Apollo Server are bloated with a lot of features that you may never need.
However, that doesn't mean you can't add those features back if you need them. For example, we can add uploads to our server by adding the Upload scalar and using the appropriate middleware from graphql-upload
import { graphqlUploadExpress } from "graphql-upload";
app.use(
"/graphql",
graphqlUploadExpress({
maxFileSize: 10000000,
maxFiles: 10,
}),
(req, res) => {
// Our implementation from before
}
)
Similarly, we can add support for live queries with the @live
directive by adding @n1ru4l/graphql-live-query and @n1ru4l/in-memory-live-query-store. We just need to add the directive to our schema and provide the appropriate execute
implementation:
import {
InMemoryLiveQueryStore
} from "@n1ru4l/in-memory-live-query-store";
const liveQueryStore = new InMemoryLiveQueryStore();
...
const result = const result = await processRequest({
schema,
query,
variables,
operationName,
request,
execute: liveQueryStore.execute,
});
Tracing, logging, persisted queries, request batching, response deduplication and any number of other features can be added just as easily without the bloat and without having to wrestle with some plugin API or unfriendly abstraction.
You can check the repository for more examples and recipes (I'll be adding more as time allows and also accepting PRs!).
Conclusion
So when should you use Apollo Server instead of GraphQL Helix? If you need to throw together a quick POC or tutorial, Apollo Server is great. If you want to use federation, you might want to stick with Apollo (and even then there are better alternatives to doing GraphQL with microservices).
GraphQL Helix offers a flexible, extensible approach to building a GraphQL server, without the bloat. If you're building something other than another to-do tutorial, I highly recommend checking it out :)
Top comments (6)
If this existed before the Apollo I'd certainly use it everywhere.
What is the difference between this and
graphql-yoga
?the-guild.dev/graphql/yoga-server/...
do you have this in a repo I can pull down ?
You said it's not tied down to any specific framework, so it's possible to use fastify in place of express right?
can we run this in a gateway mode?