Working: Apollo Client/Server Setup, with Context, for Both Queries and Subscriptions

It seems like I finally got Apollo setup working, with subscriptions, and context for both queries and subscriptions. That was so much work. People will be so much better off when they can just drop in an updated version of swydo.

Thanks to @storyteller and @minhna for your help on this.

Here’s my current setup.

If you see stuff that can be improved, please let me know. I’m sure there are things that can be improved.

E.g. objects from the Sequelize ORM have their data in a dataValues field of an object. That works fine for queries and mutations, but currently if I want them to arrive on the client in a subscription, I have to do something like myObject = myObject.dataValues. Then the data makes it to the client in data from a useSubscription call.

ApolloClient Setup

import {ApolloClient, split, HttpLink, InMemoryCache, ApolloLink, ApolloProvider, createHttpLink} from "@apollo/client";
import {GraphQLWsLink} from "@apollo/client/link/subscriptions";
import {setContext} from "@apollo/client/link/context";
import {getMainDefinition} from "@apollo/client/utilities";
import {createClient} from "graphql-ws";

const theWsLinkUrl = 'ws://localhost:4000/graphql'
const appApiUrl = 'http://localhost:4000/graphql'



const wsLink = theWsLinkUrl
    ? new GraphQLWsLink(
        createClient({
            url: theWsLinkUrl,
            connectionParams: () => {
                return {
                    authorization: localStorage.getItem("Meteor.loginToken") || "",
                };
            },
            on: {
                error: props => {
                    console.log("wsLink error", props);
                },
            },
        }),
    )
    : null;

const httpLink = createHttpLink({
    uri: appApiUrl,
    fetchOptions: {
        rejectUnauthorized: false,
    },
});


// Middleware to add custom headers
const customHeadersMiddleware = new ApolloLink((operation, forward) => {
    // Define your custom headers
    const customHeaders = {
        "token": localStorage.getItem("Meteor.loginToken")
    };

    // Use operation.setContext to add the custom headers to the request
    operation.setContext(({ headers }) => ({
        headers: {
            ...headers,
            ...customHeaders,
        },
    }));

    return forward(operation);
});

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

const apolloClient = new ApolloClient({
    link: splitLink,
    cache: new InMemoryCache({}),
});


export {apolloClient};

ApolloServer

import {ApolloServer} from "@apollo/server";
import {expressMiddleware} from "@apollo/server/express4";
import {ApolloServerPluginDrainHttpServer} from "@apollo/server/plugin/drainHttpServer";
import express from "express";
import http from "http";
import cors from "cors";
import {PubSub, withFilter} from "graphql-subscriptions-continued";
import {WebSocketServer} from "ws";
import {useServer} from "graphql-ws/lib/use/ws";
import {makeExecutableSchema} from "@graphql-tools/schema";
import {MongodbPubSub} from 'graphql-mongodb-subscriptions';
import {getUserIdByLoginToken} from "./utils_server/meteor-apollo-utils";
import typeDefs from "./api/schema";
import {resolvers} from "./api/resolvers";
import mongoose from "mongoose";
import bodyParser from "body-parser";
import {getMainDefinition} from "@apollo/client/utilities";

const MONGODB_URI = `mongodb://localhost:3001/meteor`;

const connectToDb = async () => {
    await mongoose.connect(MONGODB_URI);
};

await connectToDb();
console.log('🎉 Connected to database successfully');

const mongodbpubsub = new MongodbPubSub({
    connectionDb: mongoose.connections[0].db
});
// Create the schema, which will be used separately by ApolloServer and
// the WebSocket server.
const schema = makeExecutableSchema({typeDefs, resolvers});

// https://www.youtube.com/watch?v=AcZ5dcYMwA4
const app = express();
const httpServer = http.createServer(app);
const wsServer = new WebSocketServer({
// port: 4000,
    path: "/graphql",
    server: httpServer,
});

async function getDynamicContext(ctx, msg, args, isSubscription){
    let token = null;
    if (isSubscription){
        token = ctx.connectionParams.authorization
    }
    else{
        token = ctx.req.headers['token']
    }

    let user = null;
    let userId = null;
    try {
        if ((!!token) && (token !== "null")) {
            [user, userId] = await getUserIdByLoginToken(token);
        }
    } catch (error) {
        console.log('context: ', error)
    }

    let clientIp = '';
    try {
        clientIp = ctx.req.headers['x-forwarded-for'] || ctx.req.connection.remoteAddress;
    } catch {
        console.log("Couldn't get clientIp in ctx.req")
    }

    return {
        user: user,
        userId: userId,
        clientIp: clientIp,
        pubsub: mongodbpubsub
    };
}

const serverCleanUp =useServer({
    schema,
    context: async (ctx, msg, args) => {
        let context = await getDynamicContext(ctx, msg, args, true)
        return context;
    },
}, wsServer);

const apolloServer = new ApolloServer({
    schema,
    plugins: [
        ApolloServerPluginDrainHttpServer({httpServer}),
        {
            async serverWillStart() {
                return {
                    async drainServer() {
                        await serverCleanUp.dispose();
                    }
                }
            }
        }
    ],
});

await apolloServer.start();

app.use(
    "/graphql",
    cors({
        origin: "http://localhost:3000",
        credentials: true,
    }),
    express.json(),
    expressMiddleware(apolloServer, {
        context: async (ctx, msg, args) => {
            const context = await getDynamicContext(ctx, msg, args, false);
            return context;
        }
    }),
);

await new Promise((resolve) => httpServer.listen({port: 4000}, resolve));

const PORT = 4000;
console.log(`Server is now running on http://localhost:${PORT}/graphql`);

// https://github.com/mjwheatley/graphql-mongodb-subscriptions/issues/43
// https://github.com/mjwheatley/apollo-graphql-mongodb/blob/main/src/index.ts
// https://www.apollographql.com/docs/apollo-server/migration/#migrate-from-apollo-server-express
// …in the section following the text:
//     The context function's syntax is similar for the expressMiddleware function:
// https://www.apollographql.com/docs/apollo-server/data/subscriptions
// https://www.apollographql.com/docs/apollo-server/data/subscriptions/#production-pubsub-libraries
3 Likes

There is a lot of stuff going on. Probably would be a good idea to go through it to make sure that everything is needed. Especially related to GraphQL subscriptions.
Little nitpick so far with express. I would import that from WebApp:

const express = WebAppInternals.NpmModules.express.module

so that there isn’t an issue with version mismatch in the future.

1 Like

I’ve updated the Express initializer per your advice. Thanks!

Probably would be a good idea to go through it to make sure that everything is needed. Especially related to GraphQL subscriptions.

Agreed. For me, the hardest part to get working, was subscriptions with context.

@storyteller, I’m testing this in production, and it looks like instead of:

 const httpServer = http.createServer(app);

…I need a version that handles https. It looks like I need to set up a LetsEncrypt certificate for it, since GraphQL runs on a different port than Meteor. Am I correct about this so far?

UPDATE

It turns out I can run ApolloGraphQL on the same port as Meteor, and listen for calls to the /graphql endpoint this way:

WebApp.handlers.get("/graphql", async (req, res) => {
  [.....]
});

Yes, please see the Apollo skeleton for full setup.

1 Like

UPDATED CODE

I had ApolloGraphQL working on localdev using port 4000 for subscriptions, but on Galaxy I only have access to the Meteor port, i.e. usually port 3000. So I had to figure out how to get a websocket listener on port 3000. That took some research (described in this thread). Here is the updated code.

SERVER SETUP:

import {ApolloServer} from "@apollo/server";
import {expressMiddleware} from "@apollo/server/express4";
import {ApolloServerPluginDrainHttpServer} from "@apollo/server/plugin/drainHttpServer";
import http from "http";
import cors from "cors";
import {PubSub, withFilter} from "graphql-subscriptions-continued";
import {WebSocketServer} from "ws";
import {useServer} from "graphql-ws/lib/use/ws";
import {makeExecutableSchema} from "@graphql-tools/schema";
import {MongodbPubSub} from 'graphql-mongodb-subscriptions';
import {getUserIdByLoginToken} from "./utils_server/meteor-apollo-utils";
import typeDefs from "./api/schema";
import {resolvers} from "./api/resolvers";
import {array_of_cors_origins} from "../both";

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

// https://forums.meteor.com/t/working-apollo-client-server-setup-with-context-for-both-queries-and-subscriptions/61770/2?u=vikr00001
const meteorInternalVersionOfExpress = WebAppInternals.NpmModules.express.module;
const app = meteorInternalVersionOfExpress()
const httpServer = http.createServer(app);
const wsServer = new WebSocketServer({
    path: "/graphql",
    noServer: true,
});

//https://forums.meteor.com/t/apollo-server-express-server-setup-for-subscriptions/57871/2?u=vikr00001
WebApp.httpServer.on("upgrade", function upgrade(request, socket, head) {
    if (!request.url) {
        return;
    }
    switch (request.url) {
        case "/graphql":
            return wsServer.handleUpgrade(request, socket, head, function done(ws) {
                wsServer.emit("connection", ws, request);
            });
        default:
            break;
    }
});


const serverCleanUp =useServer({
    schema,
    context: async (ctx, msg, args) => {
        let context = await getDynamicContext(ctx, msg, args, true)
        return context;
    },
}, wsServer);

async function getDynamicContext(ctx, msg, args, isSubscription){
    let token = null;
    if (isSubscription){
        token = ctx.connectionParams.authorization
    }
    else{
        token = ctx.req.headers['token']
    }

    let user = null;
    let userId = null;
    try {
        if ((!!token) && (token !== "null")) {
            [user, userId] = await getUserIdByLoginToken(token);
        }
    } catch (error) {
        console.log('context: ', error)
    }

    let clientIp = '';
    try {
        clientIp = ctx.req.headers['x-forwarded-for'] || ctx.req.connection.remoteAddress;
    } catch {
        console.log("Couldn't get clientIp in ctx.req")
    }

    return {
        user: user,
        userId: userId,
        clientIp: clientIp
    };
}

const apolloServer = new ApolloServer({
    schema,
    plugins: [
        ApolloServerPluginDrainHttpServer({httpServer}),
        {
            async serverWillStart() {
                return {
                    async drainServer() {
                        await serverCleanUp.dispose();
                    }
                }
            }
        }
    ],
});

await apolloServer.start();

WebApp.handlers.use(
    "/graphql",
    cors({
        origin: function (origin, callback) {
            const allowedOrigins = array_of_cors_origins;
            if (!origin || allowedOrigins.indexOf(origin) !== -1) {
                callback(null, true);
            } else {

                console.log('origin: ', origin)
                console.log('array_of_cors_origins: ', array_of_cors_origins)
                callback(new Error('Not allowed by CORS'));
            }
        },
        credentials: true,
    }),
    meteorInternalVersionOfExpress.json(),
    expressMiddleware(apolloServer, {
        context: async (ctx, msg, args) => {
            const context = await getDynamicContext(ctx, msg, args, false);
            return context;
        }
    }),
);

CLIENT SETUP

import {ApolloClient, split, HttpLink, InMemoryCache, ApolloLink, ApolloProvider, createHttpLink} from "@apollo/client";
import {GraphQLWsLink} from "@apollo/client/link/subscriptions";
import {setContext} from "@apollo/client/link/context";
import {getMainDefinition} from "@apollo/client/utilities";
import {createClient} from "graphql-ws";
import {graphQLQueryEndpoint, graphQLSubscriptionEndpoint} from "../both";

\\examples:
\\
\\graphQLQueryEndpoint == 'http://localhost:3000/graphql'
\\graphQLSubscriptionEndpoint == 'ws://localhost:3000/graphql'
\\
\\graphQLQueryEndpoint == 'https://myDomainHostedOnGalaxy.com/graphql'
\\graphQLSubscriptionEndpoint == 'wss://myDomainHostedOnGalaxy.com/graphql'

const wsLink = graphQLSubscriptionEndpoint
    ? new GraphQLWsLink(
        createClient({
            url: graphQLSubscriptionEndpoint,
            connectionParams: () => {
                return {
                    authorization: localStorage.getItem("Meteor.loginToken") || "",
                };
            },
            on: {
                error: props => {
                    console.log("wsLink error", props);
                },
            },
        }),
    )
    : null;

const httpLink = createHttpLink({
    uri: graphQLQueryEndpoint,
    fetchOptions: {
        rejectUnauthorized: false,
    },
});


// Middleware to add custom headers
const customHeadersMiddleware = new ApolloLink((operation, forward) => {
    // Define your custom headers
    const customHeaders = {
        "token": localStorage.getItem("Meteor.loginToken")
    };

    // Use operation.setContext to add the custom headers to the request
    operation.setContext(({ headers }) => ({
        headers: {
            ...headers,
            ...customHeaders,
        },
    }));

    return forward(operation);
});

const splitLink = split(
    ({ query }) => {
        const definition = getMainDefinition(query);
        // console.log('definition.kind: ', definition.kind)
        // console.log('definition.operation: ', definition?.operation)
        return (
            definition.kind === 'OperationDefinition' &&
            definition.operation === 'subscription'
        );
    },
    customHeadersMiddleware.concat(wsLink),
    customHeadersMiddleware.concat(httpLink)
)

const apolloClient = new ApolloClient({
    link: splitLink,
    cache: new InMemoryCache({}),
});


export {apolloClient};
1 Like