GraphQL subscriptions working but I don't quite understand why/how

Hi, I’ve been trying to get GraphQL subscriptions working in my Meteor/Apollo stack for some time and I’ve finally managed to get it working, but I don’t really understand why it’s working and if I’ve done anything wrong. Here is the full server & client code.

Server code:

import { ApolloServer } from "apollo-server"
import { makeExecutableSchema } from "graphql-tools"
import { getUser } from "meteor/apollo"
import merge from "lodash/merge"

const typeDefs = [/* Bunch of Schemas*/]
const resolvers = merge(/* Bunch of Resolvers */)

const schema = makeExecutableSchema({ typeDefs, resolvers })

const context = async ({ req }) => ({ user: await getUser(req?.headers.authorization) })

const server = new ApolloServer({
    schema,
    context,
    subscriptions : {
        path: '/subscriptions',
        onConnect: (connectionParams, webSocket, context) => {
            console.log('Client connected')
        },
        onDisconnect: (webSocket, context) => {
            console.log('Client disconnected')
        }
    }
})

server.listen({ port: 4000 }).then(({ url, subscriptionsUrl }) => {
    console.log(`🚀 Server ready at ${url}`)
    console.log(`🚀 Subscriptions ready at ${subscriptionsUrl}`)
})

Client code:

import React from "react"
import { Meteor } from "meteor/meteor"
import { render } from "react-dom"
import { BrowserRouter } from "react-router-dom"
import { ApolloClient, ApolloProvider, HttpLink, split, InMemoryCache } from "@apollo/client"
import { ApolloLink, from } from "apollo-link"
import { getMainDefinition } from '@apollo/client/utilities';
import { WebSocketLink } from "@apollo/client/link/ws"
import { offsetLimitPagination } from "@apollo/client/utilities"
import App from "./some_path/App"

const httpLink = new HttpLink({
    uri: "http://XXX.XXX.XX.XX:4000/graphql"
})

const authLink = new ApolloLink((operation, forward) => {
    const token = Accounts._storedLoginToken()
    operation.setContext(() => ({
        headers: {
            authorization: token
        }
    }))
    return forward(operation)
})

const wsLink = new WebSocketLink({
    uri: "ws://XXX.XXX.XX.XX:4000/subscriptions",
    options: {
        reconnect: true
    }
})

const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query)
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      )
    },
    wsLink,
    httpLink
)

const cache = new InMemoryCache({
    typePolicies: {
        Query: {
            fields: {
                getFeed: offsetLimitPagination()
            }
        }
    }
})

const client = new ApolloClient({
    link: from([authLink, splitLink]),
    cache
})

const ApolloApp = () => (
    <BrowserRouter>
        <ApolloProvider client={client}>
            <App />
        </ApolloProvider>
    </BrowserRouter>
)

Meteor.startup(() => {
    render(<ApolloApp />, document.getElementById("app"))
})

I’ll now step through the code about the bits I don’t understand/have concerns:

const context = async ({ req }) => ({ user: await getUser(req?.headers.authorization) })
  1. I had to do req? instead of req because I was getting an error in GraphiQL that 'headers' was not defined whenever I tried to run a subscription. Doesn’t feel like the right way to do this though.

For the next question I need to show my previous server code. Note that ApolloServer here is imported from apollo-server-express instead of apollo-server as per my new code:

const server = new ApolloServer({
    schema, // Same as per above
    context // Same as per above except without the ?
})

server.applyMiddleware({
    app: WebApp.connectHandlers,
    path: "/graphql",
    bodyParserConfig: {
        limit: "50mb"
    }
})

WebApp.connectHandlers.use("/graphql", (req, res) => {
    if (req.method === "GET") {
        res.end()
    }
})
  1. Here I had to apply middleware and pass in Meteor’s WebApp.connectHandlers. The key difference is that my graphql endpoint here was on port 3000 but now it’s on port 4000 (I still access my app on port 3000). Since I removed this bit, I’m not sure what exactly changed apart from the port. If I understand this code correctly, this is telling Meteor that any requests which hit the /graphql endpoint should not allow access anywhere within the app since this is the endpoint for GraphiQL (and queries, etc).

  2. If this indeed works without any problem, does this mean I don’t need to use swydo:ddp-apollo to solve this? One limitation of this package is that you lose access to GraphiQL. I personally can’t access GraphiQL in the Apollo Dev Tools either because the screen is completely blank (it’s a known issue with the browser extension - has been raised in the repository)

EDIT: I observed that I have two WebSocket connections open, 1 on port 3000 for Meteor through SockJS and 1 on 4000/subscriptions for my GraphQL subscriptions. On my client, after Meteor.startup, I disconnected the DDP with Meteor.disconnect so now I only have my GraphQL subscriptions WebSocket open. Of course this means I can’t have Hot Code Push working. I don’t use the DDP for anything other than HCP.

  1. Are there any ramifications for this?

So far all my queries/mutations/subscriptions and routing, etc all work perfectly fine as per before, but I would like to understand more precisely what I’ve actually changed, as I’ve had to do quite a bit of trial and error without completely understanding what I’m doing. Appreciate any help in explaining this, thank you!

I can really answer only the first one: It’s because your server now accepts graphql subscription connections, and those are routed through the context function as well. In that case, there will be a connection rather than a req parameter, which is why req can now be undefined.

I don’t know how Meteor works so I can’t help with the rest, but it looks to me like you’ve done all you need to do to get subscriptions running (web socket link in the frontend, Apollo Server’s subscriptions option in the backend). It’s really rather straightforward, and I don’t know why you’d need ddp-apollo or other Meteor-related stuff.

1 Like

Thanks for the clarification on the first bit.

And with regards to DDP, I was also wondering the same. Meteor opens up a WebSocket connection for every user so what I think that’s trying to solve is to avoid having 2 WebSocket connections open. Right now I see 2 WebSocket connections in my Network tab, 1 for Meteor and 1 for my subscriptions. I really only use Meteor’s DDP connection for Hot Code Push, otherwise I don’t need it for anything else (I think). I’ve gone ahead and disconnected it and now only my subscriptions WebSocket is open. I don’t think there’s any issue with this. I’ve updated the OP with this as well.

I’d definitely be disabling HCP for production because it’s not necessary.

So as kindly pointed out in this graphql-ws discussion thread, ApolloServer and WebSocketLink make use of the legacy unmaintained subscription-transport-ws library. I thus ported this over to graphql-ws and now my code is as such:

Client:

import React from "react"
import { render } from "react-dom"
import { BrowserRouter } from "react-router-dom"
import { ApolloClient, ApolloProvider, HttpLink, split, InMemoryCache } from "@apollo/client"
import { ApolloLink, Observable} from '@apollo/client/core'
import { offsetLimitPagination, getMainDefinition } from "@apollo/client/utilities"
import { ApolloLink as ApolloLink2, from } from "apollo-link"
import { print, GraphQLError } from "graphql"
import { createClient } from "graphql-ws"
import App from "../../ui/App"
  
class WebSocketLink extends ApolloLink {
    constructor(options) {
        super()
        this.client = createClient(options)
    }

    request(operation) {
        return new Observable((sink) => {
            return this.client.subscribe(
            { ...operation, query: print(operation.query) },
            {
            next: sink.next.bind(sink),
            complete: sink.complete.bind(sink),
            error: (err) => {
                if (err instanceof Error) return sink.error(err)

                if (err instanceof CloseEvent) {
                    return sink.error(
                        // reason will be available on clean closes
                        new Error(
                        `Socket closed with event ${err.code} ${err.reason || ''}`
                        )
                    )
                }

                return sink.error(new GraphQLError({ message: message }))
            }
            }
            )
      })
    }
}

const httpLink = new HttpLink({ uri: "http://XXX.XXX.XX.XX:4000/graphql" })

const authLink = new ApolloLink2((operation, forward) => {
    const token = Accounts._storedLoginToken()
    operation.setContext(() => ({
        headers: {
            authorization: token
        }
    }))
    return forward(operation)
})

const wsLink = new WebSocketLink({
    url: "ws://XXX.XXX.XX.XX:4000/subscriptions",
    connectionParams: () => ({ authorization: Accounts._storedLoginToken() })
})

const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query)
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      )
    },
    wsLink,
    httpLink
)

const cache = new InMemoryCache({
    typePolicies: {
        Query: {
            fields: {
                getFeed: offsetLimitPagination()
            }
        }
    }
})

const client = new ApolloClient({
    link: from([authLink, splitLink]),
    cache
})

const ApolloApp = () => (
    <BrowserRouter>
        <ApolloProvider client={client}>
            <App client={client} />
        </ApolloProvider>
    </BrowserRouter>
)

Meteor.startup(() => {
    render(<ApolloApp />, document.getElementById("app"))
})
/*
The following line is to disconnect Meteor's DDP/WebSocket connection
as it is not needed in production. Can be commented out in development
to allow for Hot Code Push convenience.
*/
//Meteor.disconnect()

Server:

import express from "express"
import { ApolloServer } from "apollo-server-express"
import ws from "ws"
import { useServer } from "graphql-ws/lib/use/ws"

const typeDefs = [/* Schemas */]
const resolvers = merge(/* Resolvers */)

const schema = makeExecutableSchema({ typeDefs, resolvers })

const app = express()

const context = async ({req}) => ({
     user: await getUser(req.headers.authorization) 
})

const wsContext = async ({ connectionParams }) => ({
    user: await getUser(connectionParams.authorization)
})

const apolloServer = new ApolloServer({ schema, context })

apolloServer.applyMiddleware({ app })

const server = app.listen(4000, () => {
    const wsServer = new ws.Server({
        server,
        path: '/subscriptions'
    })

    useServer({ schema, context: wsContext }, wsServer)
})

This also ensures the Meteor account userID is available in the subscription context and not only for HTTP requests. Again, it is worth noting that I’m not really using Meteor for anything more than a build tool and for its Accounts system.