9

I'm looking into implementing a "subscription" type using server-sent events as the backing api.

What I'm struggling with is the interface, to be more precise, the http layer of such operation.

The problem:

Using the native EventSource does not support:

  1. Specifying an HTTP method, "GET" is used by default.
  2. Including a payload (The GraphQL query)

While #1 is irrefutable, #2 can be circumvented using query parameters.

Query parameters have a limit of ~2000 chars (can be debated) which makes relying solely on them feels too fragile.

The solution I'm thinking of is to create a dedicated end-point for each possible event.

For example: A URI for an event representing a completed transaction between parties:

/graphql/transaction-status/$ID

Will translate to this query in the server:

subscription TransactionStatusSubscription {
    status(id: $ID) {
        ready
    }
}

The issues with this approach is:

  1. Creating a handler for each URI-to-GraphQL translation is to be added.
  2. Deploy a new version of the server
  3. Loss of the flexibility offered by GraphQL -> The client should control the query
  4. Keep track of all the end-points in the code base (back-end, front-end, mobile)

There are probably more issues I'm missing.

Is there perhaps a better approach that you can think of? One the would allow a better approach at providing the request payload using EventSource?

elad.chen
  • 2,375
  • 5
  • 25
  • 37

4 Answers4

11

Subscriptions in GraphQL are normally implemented using WebSockets, not SSE. Both Apollo and Relay support using subscriptions-transport-ws client-side to listen for events. Apollo Server includes built-in support for subscriptions using WebSockets. If you're just trying to implement subscriptions, it would be better to utilize one of these existing solutions.

That said, there's a library for utilizing SSE for subscriptions here. It doesn't look like it's maintained anymore, but you can poke around the source code to get some ideas if you're bent on trying to get SSE to work. Looking at the source, it looks like the author got around the limitations you mention above by initializing each subscription with a POST request that returns a subscription id.

Daniel Rearden
  • 80,636
  • 11
  • 185
  • 183
  • I'm currently building an app that uses notifications. I have already set up one RESTful endpoint with SSE and it's working well. Does it make sense to continue using SSE for notifications inside the app? I also am using GraphQL for my all my other resources but I'm not seeing a reason to use Websockets – Amon Aug 21 '22 at 05:46
4

As of now you have multiple Packages for GraphQL subscription over SSE.

graphql-sse

Provides both client and server for using GraphQL subscription over SSE. This package has a dedicated handler for subscription.

Here is an example usage with express.

import express from 'express'; // yarn add express
import { createHandler } from 'graphql-sse';

// Create the GraphQL over SSE handler
const handler = createHandler({ schema });

// Create an express app serving all methods on `/graphql/stream`
const app = express();
app.use('/graphql/stream', handler);

app.listen(4000);
console.log('Listening to port 4000');

@graphql-sse/server

Provides a server handler for GraphQL subscription. However, the HTTP handling is up to u depending of the framework you use.

Disclaimer: I am the author of the @graphql-sse packages

Here is an example with express.

import express, { RequestHandler } from "express";
import {
  getGraphQLParameters,
  processSubscription,
} from "@graphql-sse/server";
import { schema } from "./schema";

const app = express();

app.use(express.json());

app.post(path, async (req, res, next) => {
    const request = {
        body: req.body,
        headers: req.headers,
        method: req.method,
        query: req.query,
    };

    const { operationName, query, variables } = getGraphQLParameters(request);
    if (!query) {
        return next();
    }
    const result = await processSubscription({
        operationName,
        query,
        variables,
        request: req,
        schema,
    });

    if (result.type === RESULT_TYPE.NOT_SUBSCRIPTION) {
        return next();
    } else if (result.type === RESULT_TYPE.ERROR) {
        result.headers.forEach(({ name, value }) => res.setHeader(name, value));
        res.status(result.status);
        res.json(result.payload);
    } else if (result.type === RESULT_TYPE.EVENT_STREAM) {
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            Connection: 'keep-alive',
            'Cache-Control': 'no-cache',
        });

        result.subscribe((data) => {
            res.write(`data: ${JSON.stringify(data)}\n\n`);
        });

        req.on('close', () => {
            result.unsubscribe();
        });
    }
});

Clients

The two packages mentioned above have companion clients. Because of the limitation of the EventSource API, both packages implement a custom client that provides options for sending HTTP Headers, payload with post, what the EvenSource API does not support. The graphql-sse comes together with it client while the @graphql-sse/server has companion clients in a separate packages.

graphql-sse client example

import { createClient } from 'graphql-sse';

const client = createClient({
  // singleConnection: true, use "single connection mode" instead of the default "distinct connection mode"
  url: 'http://localhost:4000/graphql/stream',
});

// query

  const result = await new Promise((resolve, reject) => {
    let result;
    client.subscribe(
      {
        query: '{ hello }',
      },
      {
        next: (data) => (result = data),
        error: reject,
        complete: () => resolve(result),
      },
    );
  });


 // subscription

  const onNext = () => {
    /* handle incoming values */
  };

  let unsubscribe = () => {
    /* complete the subscription */
  };

  await new Promise((resolve, reject) => {
    unsubscribe = client.subscribe(
      {
        query: 'subscription { greetings }',
      },
      {
        next: onNext,
        error: reject,
        complete: resolve,
      },
    );
  });

;

@graphql-sse/client

A companion of the @graphql-sse/server.

Example

import {
  SubscriptionClient,
  SubscriptionClientOptions,
} from '@graphql-sse/client';

const subscriptionClient = SubscriptionClient.create({
    graphQlSubscriptionUrl: 'http://some.host/graphl/subscriptions'
});

const subscription = subscriptionClient.subscribe(
    {
        query: 'subscription { greetings }',
    }
)

const onNext = () => {
    /* handle incoming values */
  };

const onError = () => {
    /* handle incoming errors */
  };

subscription.susbscribe(onNext, onError)

@gaphql-sse/apollo-client

A companion package of the @graph-sse/server package for Apollo Client.

import { split, HttpLink, ApolloClient, InMemoryCache } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { ServerSentEventsLink } from '@graphql-sse/apollo-client';

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
});

const sseLink = new ServerSentEventsLink({
  graphQlSubscriptionUrl: 'http://localhost:4000/graphql',
});

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

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});
faboulaws
  • 1,909
  • 16
  • 16
3

If you're using Apollo, they support automatic persisted queries (abbreviated APQ in the docs). If you're not using Apollo, the implementation shouldn't be too bad in any language. I'd recommend following their conventions just so your clients can use Apollo if they want.

The first time any client makes an EventSource request with a hash of the query, it'll fail, then retry the request with the full payload to a regular GraphQL endpoint. If APQ is enabled on the server, subsequent GET requests from all clients with query parameters will execute as planned.

Once you've solved that problem, you just have to make a server-sent events transport for GraphQL (should be easy considering the subscribe function just returns an AsyncIterator)

I'm looking into doing this at my company because some frontend developers like how easy EventSource is to deal with.

Seth
  • 490
  • 2
  • 7
1

There are two things at play here: the SSE connection and the GraphQL endpoint. The endpoint has a spec to follow, so just returning SSE from a subscription request is not done and needs a GET request anyway. So the two have to be separate.

How about letting the client open an SSE channel via /graphql-sse, which creates a channel token. Using this token the client can then request subscriptions and the events will arrive via the chosen channel.

The token could be sent as the first event on the SSE channel, and to pass the token to the query, it can be provided by the client in a cookie, a request header or even an unused query variable.

Alternatively, the server can store the last opened channel in session storage (limiting the client to a single channel).

If no channel is found, the query fails. If the channel closes, the client can open it again, and either pass the token in the query string/cookie/header or let the session storage handle it.

w00t
  • 17,944
  • 8
  • 54
  • 62