Getting Started

Getting Started

This tutorial will take us through the process of building a GraphQL server with @benzene/http and @benzene/ws. We will build a simple book voting app that can:

  • Returns a list of books and authors
  • Upvote a book
  • Synchronize votes with real-time changes

Everything will be built with the bare http module and the ws library. However, we can adapt it to any other libraries.

In this tutorial, we will use the SDL-first library graphql-tools for our schema. (We can also choose code-first libraries likes type-graphql and @nexus/schema)

Check out the source code or follow along.

Setup Project

Create a project and install the necessary dependencies.

mkdir book-votes
cd book-votes
npm init -y
npm i ws graphql @benzene/http @benzene/ws @graphql-tools/schema

Also, make sure to set type to "module" in package.json since we are working with ESModule. (Otherwise, we can write the upcoming code in CommonJS)

Define our schema

Create a file called schema.js and add the following:

import { makeExecutableSchema } from "@graphql-tools/schema";
import { on, EventEmitter } from "events";
 
const authors = [
  { id: 1, name: "Tom Coleman" },
  { id: 2, name: "Sashko Stubailo" },
  { id: 3, name: "Mikhail Novikov" },
];
 
const books = [
  { id: 1, authorId: 1, title: "Introduction to GraphQL", votes: 2 },
  { id: 2, authorId: 2, title: "Welcome to Meteor", votes: 3 },
  { id: 3, authorId: 2, title: "Advanced GraphQL", votes: 1 },
  { id: 4, authorId: 3, title: "Launchpad is Cool", votes: 7 },
];
 
const typeDefs = `
  type Author {
    id: Int!
    name: String
    books: [Book]
  }
 
  type Book {
    id: Int!
    title: String
    author: Author
    votes: Int
  }
 
  type Query {
    books: [Book]
  }
 
  type Mutation {
    bookUpvote (
      bookId: Int!
    ): Book
  }
 
  type Subscription {
    bookSubscribe: Book
  }
`;
 
const ee = new EventEmitter();
 
const resolvers = {
  Query: {
    books: () => books,
  },
 
  Mutation: {
    bookUpvote: (_, { bookId }) => {
      const book = books.find((book) => book.id === bookId);
      if (!book) {
        throw new Error(`Couldn't find book with id ${bookId}`);
      }
      book.votes += 1;
      ee.emit("BOOK_SUBSCRIBE", { bookSubscribe: book });
      return book;
    },
  },
 
  Subscription: {
    bookSubscribe: {
      subscribe: async function* bookSubscribe() {
        for await (const event of on(ee, "BOOK_SUBSCRIBE")) {
          yield event[0];
        }
      },
    },
  },
 
  Author: {
    books: (author) => books.filter((book) => book.authorId === author.id),
  },
 
  Book: {
    author: (book) => authors.find((author) => author.id === book.authorId),
  },
};
 
const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});
 
export default schema;

We created a GraphQL schema with three functionalities:

  • A query to retrieve all books, as well resolvers for Book and Author to resolve nested fields.
  • A mutation to upvote a book, which also announces the change to the "BOOK_SUBSCRIBE" event.
  • A subscription to book updates that listens to the "BOOK_SUBSCRIBE" event.
💡

We use events.on to create the async iterator, but you may be more familiar with graphql-subscriptions)

Create the server

Create a file called server.js and add the following:

import { createServer } from "http";
import WebSocket from "ws";
import { Benzene, parseGraphQLBody, makeHandler } from "@benzene/http";
import { makeHandler as makeHandlerWs } from "@benzene/ws";
import schema from "./schema.js";
 
function readBody(request) {
  return new Promise((resolve) => {
    let body = "";
    request.on("data", (chunk) => (body += chunk));
    request.on("end", () => resolve(body));
  });
}
 
const GQL = new Benzene({ schema });
 
const graphqlHTTP = makeHandler(GQL);
const graphqlWS = makeHandlerWs(GQL);
 
const server = createServer(async (req, res) => {
  const rawBody = await readBody(req);
  const result = await graphqlHTTP({
    method: req.method,
    headers: req.headers,
    body: parseGraphQLBody(rawBody, req.headers["content-type"]),
  });
  res.writeHead(result.status, result.headers);
  res.end(JSON.stringify(result.payload));
});
 
const wss = new WebSocket.Server({ server });
 
wss.on("connection", (ws) => {
  graphqlWS(ws);
});
 
server.listen(3000, () => {
  console.log(`🚀  Server ready at http://localhost:3000`);
});

Parse incoming request

Our defined readBody function read the data from the incoming request and output it as a string. Meanwhile, the graphql-over-http spec allows different incoming Content-Type, each must be parsed differently. We provide parseGraphQLBody function, which accepts the body string and the content type, to do just that.

💡

We do not have to do this if we use a framework or library with body parsing, like express

Create a Benzene instance and transport handlers

After creating a Benzene instance, we create the HTTP and WebSocket handlers by supplying it to the makeHandler functions from @benzene/http and @benzene/ws.

@benzene/http is then called with a generic request object and returns a generic response object with status, headers, and payload so we can respond as we wish. This allows us to work with any frameworks or runtimes. Check out Examples for more integrations.

Similarly, @benzene/ws is called with any WebSocket-like instance, so we can use it with libraries other than ws.

📦

The same Benzene class is exported from both @benzene/ws and @benzene/http

Optional: Use a different runtime

Benzene supports different implementations of GraphQL. If you are looking for a more performant GraphQL server, take a look at the runtime documentation. Below is an example using graphql-jit (from the @benzene/jit package) to achieve more than 2x performance gain.

import { makeCompileQuery } from "@benzene/jit";
 
const GQL = new Benzene({
  compileQuery: makeCompileQuery(),
});

Also, check out the benchmarks section to learn more about the difference in performance.

Start the application

Using the Svelte + urql app

Although setting up the client is not in the scope of this tutorial, examples/book-votes features one built with svelte and urql.

book-votes-example

curl https://codeload.github.com/hoangvvo/benzene/tar.gz/main | tar -xz --strip=2 benzene-main/examples/book-votes
cd book-votes
npm i
npm run start

Trying out with DevTools

Without building a front-end, we can still test out the Getting Started example using DevTools.

Start the Node application using

node ./server.js

Go to the http://localhost:3000 and open the DevTools.

First, try to query all books:

await fetch("/graphql", {
  method: "POST",
  // application/json is also supported as a legacy content-type
  headers: { "content-type": "application/graphql+json" },
  body: JSON.stringify({
    query: "query { books { id, title, author { name }, votes } }",
  }),
}).then((res) => res.json());

Before we make a mutation, let's try to subscribe to our books' changes using GraphQL Subscription over WebSockets.

const websocket = new WebSocket(
  "ws://localhost:3000/graphql",
  "graphql-transport-ws"
);
// Wait a bit for WebSocket to connect and then run:
websocket.send(JSON.stringify({ type: "connection_init" }));

Take a look at the WS tab in the Network panel. We can see an opening WebSocket communicating with our GraphQL server. Let's try to subscribe to bookSubscribe.

websocket.send(
  JSON.stringify({
    type: "subscribe",
    id: "1",
    payload: { query: "subscription { bookSubscribe { title, votes } }" },
  })
);

Now make a request to increase the vote:

await fetch("/graphql", {
  method: "POST",
  headers: { "content-type": "application/graphql+json" },
  body: JSON.stringify({ query: "mutation { bookUpvote(bookId: 1) { id } }" }),
}).then((res) => res.json());

We will notice a message coming from the WebSocket channel:

{
  "id": "1",
  "payload": {
    "data": {
      "bookSubscribe": { "title": "Introduction to GraphQL", "votes": 5 }
    }
  },
  "type": "next"
}