7

i try to setup GraphQL Subscriptions inside a next.js 9.x app. The app is totally fake, it is just for trying Apollo Server subscriptions. The "database" is just an array, where I push new users to it.

This is the code I got so far.

import { ApolloServer, gql, makeExecutableSchema } from "apollo-server-micro"
import { PubSub } from "apollo-server"

const typeDefs = gql`
  type User {
    id: ID!
    name: String
    status: String
  }


  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    addUser(id: String, name: String, status: String): User
  }

  type Subscription {
    newUser: User!
  }
`

const fakedb = [
  {
    id: "1",
    name: "myname",
    status: "active",
  },
]

const NEW_USER = "NEW_USER"

const resolvers = {
  Subscription: {
    newUser: {
      subscribe: (_, __, { pubsub }) => pubsub.asyncIterator(NEW_USER),
    },
  },

  Query: {
    users: (parent, args, context) => {
      console.log(context)

      return fakedb
    },
    user: (_, { id }) => {
      console.log(id)
      console.log(fakedb)

      return fakedb.find((user) => user.id == id)
    },
  },
  Mutation: {
    addUser(_, { id, name, status }, { pubsub }) {
      console.log(pubsub)

      const newUser = {
        id,
        name,
        status,
      }

      pubsub.publish(NEW_USER, { newUser: newUser })

      fakedb.push(newUser)
      return newUser
    },
  },
}

export const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
})

const pubsub = new PubSub()
const apolloServer = new ApolloServer({
  // typeDefs,
  // resolvers,
  schema,
  context: ({ req, res }) => {
    return { req, res, pubsub }
  },
  introspection: true,
  subscriptions: {
    path: "/api/graphql",
    // keepAlive: 15000,
    onConnect: () => console.log("connected"),
    onDisconnect: () => console.log("disconnected"),
  },
})

export const config = {
  api: {
    bodyParser: false,
  },
}

export default apolloServer.createHandler({ path: "/api/graphql" })

I run this subscription in localhost:3000/api/graphql:

subscription { newUser { id name } }

I get this error. I am not sure, where and how to fix this, as I can not find any documentation about this.

{ "error": "Could not connect to websocket endpoint ws://localhost:3000/api/graphql. Please check if the endpoint url is correct." }

I found out how to add the subscriptions path, as it complained about that before (was /graphql before). But still not working.

3 Answers3

6

This is how I made it work.

import { ApolloServer } from 'apollo-server-micro';
import schema from './src/schema';

const apolloServer = new ApolloServer({
  schema,
  context: async ({ req, connection }) => {
    if (connection) {
      // check connection for metadata
      return connection.context;
    }
    // get the user from the request
    return {
      user: req.user,
      useragent: req.useragent,
    };
  },

  subscriptions: {
    path: '/api/graphqlSubscriptions',
    keepAlive: 9000,
    onConnect: console.log('connected'),
    onDisconnect: () => console.log('disconnected'),
  },
  playground: {
    subscriptionEndpoint: '/api/graphqlSubscriptions',

    settings: {
      'request.credentials': 'same-origin',
    },
  },
});

export const config = {
  api: {
    bodyParser: false,
  },
};

const graphqlWithSubscriptionHandler = (req, res, next) => {
  if (!res.socket.server.apolloServer) {
    console.log(`* apolloServer first use *`);

    apolloServer.installSubscriptionHandlers(res.socket.server);
    const handler = apolloServer.createHandler({ path: '/api/graphql' });
    res.socket.server.apolloServer = handler;
  }

  return res.socket.server.apolloServer(req, res, next);
};

export default graphqlWithSubscriptionHandler;

Just make sure that the websocket path works. https://www.websocket.org/echo.html

ordepim
  • 806
  • 7
  • 9
  • One unfortunate downside with this solution is that hot-reloading apollo server code no longer works after `res.socket.server.apolloServer` is set. – Daniel Jul 25 '20 at 05:37
  • 1
    Another quirk of this is that you subscriptions will not start working until you fire at least one "normal" request to your server. This is because the API route handler is not executed before a normal request is sent. – karl Jan 13 '21 at 07:34
6

I have inspired by @ordepim's answer and I fixed Hot-reload problem this way (I also added typings):

import { ApolloServer } from 'apollo-server-micro'
import { NextApiRequest, NextApiResponse } from 'next'
import { schema } from '../../lib/schema'

//note: this log occurs on every hot-reload
console.log('CREATING APOLLOSERVER ')

const apolloServer = new ApolloServer({
  schema,
  context: async ({ req, connection }) => {
    if (connection) {
      // check connection for metadata
      return connection.context
    }
    // get the user from the request
    return {
      user: req.user,
      useragent: req.useragent,
    }
  },

  subscriptions: {
    path: '/api/graphqlSubscriptions',
    keepAlive: 9000,
    onConnect: () => console.log('connected'),
    onDisconnect: () => console.log('disconnected'),
  },
  playground: {
    subscriptionEndpoint: '/api/graphqlSubscriptions',

    settings: {
      'request.credentials': 'same-origin',
    },
  },
})
export const config = {
  api: {
    bodyParser: false,
  },
}

type CustomSocket = Exclude<NextApiResponse<any>['socket'], null> & {
  server: Parameters<ApolloServer['installSubscriptionHandlers']>[0] & {
    apolloServer?: ApolloServer
    apolloServerHandler?: any
  }
}

type CustomNextApiResponse<T = any> = NextApiResponse<T> & {
  socket: CustomSocket
}

const graphqlWithSubscriptionHandler = (
  req: NextApiRequest,
  res: CustomNextApiResponse
) => {
  const oldOne = res.socket.server.apolloServer
  if (
    //we need compare old apolloServer with newOne, becasue after hot-reload are not equals
    oldOne &&
    oldOne !== apolloServer
  ) {
    console.warn('FIXING HOT RELOAD !!!!!!!!!!!!!!! ')
    delete res.socket.server.apolloServer
  }

  if (!res.socket.server.apolloServer) {
    console.log(`* apolloServer (re)initialization *`)

    apolloServer.installSubscriptionHandlers(res.socket.server)
    res.socket.server.apolloServer = apolloServer
    const handler = apolloServer.createHandler({ path: '/api/graphql' })
    res.socket.server.apolloServerHandler = handler
    //clients losts old connections, but clients are able to reconnect
    oldOne?.stop()
  }

  return res.socket.server.apolloServerHandler(req, res)
}

export default graphqlWithSubscriptionHandler

user2958194
  • 116
  • 2
  • 3
  • Could you please help me https://stackoverflow.com/questions/67561703/cannot-return-null-for-non-nullable-field-subscription-counter. – softshipper May 16 '21 at 21:32
4

They have removed the subscription support from appollo-server v3.

An updated solution for v3 looks like this. This is typescript but you could adapt it to JS removing types.

import { ApolloServer } from 'apollo-server-micro'
import { makeExecutableSchema } from '@graphql-tools/schema';
import { useServer } from 'graphql-ws/lib/use/ws';
import { Disposable } from 'graphql-ws';
import Cors from 'micro-cors'
import type { NextApiRequest } from 'next'
import { WebSocketServer } from 'ws';
import { typeDefs } from '../../graphql/schema'
import { resolvers } from '../../graphql/resolvers'
import { NextApiResponseServerIO } from '../../types/next';

const schema = makeExecutableSchema({ typeDefs, resolvers });

const cors = Cors()

let serverCleanup: Disposable | null = null;

const apolloServer = new ApolloServer({
  schema,
  plugins: [
    // Proper shutdown for the WebSocket server.
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup?.dispose();
          },
        };
      },
    },
  ]
});

const startServer = apolloServer.start()

const getHandler = async () => {
  await startServer;
  return apolloServer.createHandler({
    path: '/api/graphql',
  });
}

const wsServer = new WebSocketServer({
  noServer: true
});

export default cors(async function handler(req: any, res: any) {
  if (req.method === 'OPTIONS') {
    res.end()
    return false
  }
  res.socket.server.ws ||= (() => {
    res.socket.server.on('upgrade', function (request, socket, head) {
      wsServer.handleUpgrade(request, socket, head, function (ws) {
        wsServer.emit('connection', ws);
      })
    })
    serverCleanup = useServer({ schema }, wsServer);
    return wsServer;
  })();

  const h = await getHandler();

  await h(req, res)
})

export const config = {
  api: {
    bodyParser: false,
  },
}

The solution starts the server once and lives together with Queries / Mutations. Note that in graphql explorer, the newer version of the protocol (graphql-ws) should be chosen. Probably this solution won't work for the older protocol, which shouldn't be an issue.

Igor Loskutov
  • 2,157
  • 2
  • 20
  • 33
  • Hi Igor, thanks a lot first of all. May I ask why typescript raises a type error on `handler` in my setup? I see: ` Argument of type '(req: NextApiRequest, res: NextApiResponseServerIO) => Promise' is not assignable to parameter of type 'RequestHandler'. Types of parameters 'req' and 'req' are incompatible. Type 'IncomingMessage' is missing the following properties from type 'NextApiRequest': query, cookies, body, env ` – Morriz May 27 '22 at 20:35
  • 1
    It was due to micro-cors' RequestHandler not matching with req from next. I didn't need it because same domain, but would be nice to see the snippet type correct ;) – Morriz May 27 '22 at 20:58
  • Thank you for checking this. I'll change the answer to :any :any for req/res, that'll do for now until I can look into it. At least people won't get faulty code. – Igor Loskutov May 28 '22 at 06:00