Apollo Server with subscriptions used inside next.js api routes: websockets trouble
Asked Answered
W

3

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.

Weinman answered 14/6, 2020 at 10:31 Comment(0)
R
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

Randazzo answered 16/7, 2020 at 17:32 Comment(2)
One unfortunate downside with this solution is that hot-reloading apollo server code no longer works after res.socket.server.apolloServer is set.Durr
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.Joyajoyan
P
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

Pervious answered 18/2, 2021 at 21:49 Comment(1)
Could you please help me #67562203.Naiad
I
5

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.

Invaginate answered 14/5, 2022 at 13:22 Comment(5)
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<false | undefined>' 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 `Harlen
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 ;)Harlen
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.Invaginate
Hi Igor, I was checking the code and everything is working as expected except for the upgrade event listener, 'cause is never executed. When this event is emitted from Apollo Server, and how can be called?Behah
Things probably changed drastically since 2022. I don't have the recent expertise to help.Invaginate

© 2022 - 2024 — McMap. All rights reserved.