Meteor/Apollo 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!

1 Like

I don’t have the answer but I think this meme belongs to this thread :smile:


// sorry, just for fun

3 Likes

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 follows:

(Sidenote: I basically used the Apollo-Express examples laid out in the graphql-ws Recipes section, and integrated it with Meteor’s Accounts’ authentication)

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
)
// You can just use a normal InMemoryCache with no options, I just left
// my implementation in
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.

I’ll attempt to answer some of my own questions.

  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.

Definitely wasn’t the right way to do this. req is undefined for subscriptions and thus I had to handle authentication through the connectionParams option for the WebSocket server. I do this in a separate wsContext which can be seen in my server code.

  1. 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.
  2. Are there any ramifications for this?

What swydo:ddp-apollo is trying to solve is to allow for Meteor’s DDP connection and the GraphQL subscription to share the same WebSocket. I don’t quite see why that is necessary in production as I’m going to be disabling the DDP connection anyway with Meteor.disconnect(). This way I won’t have a persistent WebSocket open for every user, and only on pages which require subscriptions. I can leave the DDP WebSocket open in development because it’s just my local machine that’s handling 2 WebSockets, and I haven’t had any problems or performance issues with that so far.

I would still appreciate any further insights anyone can give on this. All I’m really using Meteor for right now is as a build tool and for the Accounts package. To address Question #2, in my current state I am not using Meteor at all since there’s an Express server running on port 4000, so there’s really no need for WebApp anywhere.

2 Likes

Thank you for your elaborate comment and faithful transfer of knowledge. I’m glad you were able to figure it out.

All I’m really using Meteor for right now is as a build tool and for the Accounts package

accounts-js shall remedy all your authentication needs, it’s inspired by the Meteor Accounts system so it has a somewhat similar API but it’s more powerful, modular and db agnostic.

As for the build tool, I guess this isn’t a big deal matter of fact the nodejs alternatives are much more powerful, and the community would like to even integrate some of the underlying tools to better improve the build tool performance.

2 Likes

Yep thanks but I think I also forgot to mention that this is a mobile app as well so I’m using the built-in Cordova integration to help with wrapping it into a native app. That’s a key thing I’m relying on right now and I’m not sure how tough it’s going to be to migrate to something else like Capacitor.

EDIT: I just saw the edit and yes - I’m looking forward to the Avalanche build tool, that looks like a very clean way of integrating everything.

If Meteor somehow is able to migrate to using Snowpack or esbuild, I think that’ll propel it even further. The build speeds are just incredible.

1 Like

If Meteor somehow is able to migrate to using Snowpack or esbuild, I think that’ll propel it even further. The build speeds are just incredible.

Indeed, I believe once Meteor gets rid of fibers everything else will fall into place.

1 Like

A little off-topic but I’ve seen in many places that Meteor is “dead/outdated” and I hope that modernizing it will give it a fresh breath of life. It’s been so quick to get up and running, it’d be a shame if Meteor is left in the dust. Definitely still has its place in 2021, but is in dire need of modernization in my opinion.

1 Like