lfades / next-with-apollo

Apollo HOC for Next.js
MIT License
765 stars 78 forks source link

does the ssr support apollo subscription ? #123

Open Kingwzb opened 4 years ago

Kingwzb commented 4 years ago

Firstly, thanks for you good work to share such useful module and saved me lots of time!

I managed to use isomorphic-ws to create the ws link for apollo client on both side and passed getDataFromTree to withApollo HOC on my page.

For react component using useQuery hook, the ssr works correctly but for component using useSubscription hook, I see 6 subscriptions other than 1 got fired and the page source code is still showing 'loading' instead of sever rendered context with initial data from subscription

is there any working example to do ssr rendering with initial subscription data ?

positonic commented 4 years ago

I have the same question. It calls my Apollo server and renders the page, but subscriptions don't work. Which kinda makes sense to me since it was server side rendered, and the page is rendered - done job...

I'm new to next.js so not sure if/how subscription will work after render in this case...

hoangvvo commented 4 years ago

I do not think subscription is meant to be used server-side. Subscription is used if you want to notify the user while they use the app, not when they load it the first time.

You should not use isomorphic-ws. Instead, simply excluding WebSocketLink for server-side:

const httpLink = createUploadLink({
  uri: process.env.API_URI,
  fetch,
  credentials: process.browser ? 'same-origin' : 'include',
});

const link = process.browser
  ? split(
      ({ query }) => {
        const definition = getMainDefinition(query);
        return (
          definition.kind === 'OperationDefinition' &&
          definition.operation === 'subscription'
        );
      },
      new WebSocketLink({
        uri: process.env.WEBSOCKET_URI,
        options: {
          reconnect: true,
        },
      }),
      httpLink
    )
  : httpLink;
Kingwzb commented 4 years ago

I've figured it out using graphql query to get initial data then use subscribeToMore to trigger subscription to get realtime data, the server side rendering works fine with the initial query, hope it helps.

(isormophic-ws used below is not needed, I will remove it from my code) with-apollo.tsx

import React from 'react';
import withApollo from 'next-with-apollo';
import {ApolloProvider} from '@apollo/react-hooks';
import {ApolloLink, split} from 'apollo-link';
import {HttpLink} from 'apollo-link-http';
import {WebSocketLink} from 'apollo-link-ws';
import {getMainDefinition} from 'apollo-utilities';
import {ApolloClient} from 'apollo-client';
import {InMemoryCache} from "apollo-cache-inmemory";
import fetch from 'isomorphic-unfetch';
import WebSocket from 'isomorphic-ws';

let ssrMode = !process.browser;
let httpURI: string = '/graphql';
let wsURI = '';
if (ssrMode) {
    const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3000;
    httpURI = `http://localhost:${port}/graphql`;
    wsURI = `ws://localhost:${port}/graphql`;
} else {
    wsURI = 'ws://' + window.location.hostname + ':' + window.location.port + '/graphql';
}
let httpLink: ApolloLink = new HttpLink({
    uri: httpURI,
    credentials: 'same-origin',
    fetch: fetch
});
let link = httpLink;
if (!ssrMode) {
    let wsLink = new WebSocketLink({
        uri: wsURI,
        options: {
            reconnect: true,
        },
        webSocketImpl: WebSocket
    });

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

export default withApollo(
    ({initialState}) =>
        new ApolloClient({
            link: link,
            ssrMode: ssrMode,
            connectToDevTools: !ssrMode,
            cache: new InMemoryCache().restore(initialState || {})
        }),
    {
        render: ({Page, props}) => {
            return (
                <ApolloProvider client={props.apollo}>
                    <Page {...props} />
                </ApolloProvider>
            );
        }
    }
);

resolvers.ts

import grpc from 'grpc';
import {MarketClient} from '../grpc/market/market_grpc_pb';
import pbMarket from '../grpc/market/market_pb';
import {Index, IndexResponse, Resolvers} from './types';
import {PubSub} from 'apollo-server-express';
import * as pb from 'google-protobuf/google/protobuf/timestamp_pb';
import dayjs from 'dayjs';

const pub = new PubSub();
let subID = 1;

const grpcPort = (process.env.GRPCPORT && parseInt(process.env.GRPCPORT, 10)) || 3001;
const grpcURI = 'localhost:' + grpcPort;
const marketClient = new MarketClient(grpcURI, grpc.credentials.createInsecure());

function formatTimestamp(timestamp: pb.Timestamp | undefined): string {
    const fmt = "YYYY-MM-DDTHH:mm:ss.SSS"
    if (timestamp) {
        let obj = timestamp.toObject();
        return dayjs(new Date(obj.seconds * 1000 + obj.nanos / 1000000)).format(fmt);
    }
    return dayjs().format(fmt);
}

function withCancel<T>(
    asyncIterator: AsyncIterator<T | undefined>,
    onCancel: () => void
): AsyncIterator<T | undefined> {
    if (!asyncIterator.return) {
        asyncIterator.return = () => Promise.resolve({value: undefined, done: true});
    }

    const savedReturn = asyncIterator.return.bind(asyncIterator);
    asyncIterator.return = () => {
        onCancel();
        return savedReturn();
    };

    return asyncIterator;
}
export const resolvers: Resolvers = {
    Query: {
        indexLevel: async (_parent, args) => {
            return new Promise<IndexResponse>((resolve, reject) => {
                let req = new pbMarket.IndexRequest();
                req.setUnderlying(args.underlying);
                marketClient.getIndexLevel(req, (error, data) => {
                    if (error) {
                        reject(error);
                    } else {
                        let index: Index = {
                            level: data.getLevel()
                        }
                        let response: IndexResponse = {
                            index: index,
                            timestamp: formatTimestamp(data.getTimestamp())
                        };
                        resolve(response);
                    }
                });
            });
        }
    },
    Subscription: {
        indexLevel: {
            subscribe: (_parent, args) => {
                let req = new pbMarket.IndexRequest();
                req.setUnderlying(args.underlying);
                let sub = new pbMarket.IndexSubscription();
                sub.setRequest(req);
                sub.setMinIntervalMs(args.minIntervalMs);
                let responses = marketClient.subscribeIndexLevel(sub);
                let topic = 'IndexLevel_' + (subID++);
                console.info('started graphql subscription', topic);
                responses.on('data', (data: pbMarket.IndexResponse) => {
                    let index: Index = {
                        level: data.getLevel()
                    };
                    let response: IndexResponse = {
                        index: index,
                        timestamp: formatTimestamp(data.getTimestamp())
                    }
                    pub.publish(topic, {indexLevel: response});
                });
                responses.on('error', (err) => {
                    console.warn(topic, err.message);
                });
                return withCancel(pub.asyncIterator(topic), () => {
                    console.info('cancelling graphql subscription', topic);
                    responses.cancel();
                });
            }
        }
    }
};

pages¥index.tsx

import React, {useEffect} from 'react';
import {useQuery} from '@apollo/react-hooks';
import {getDataFromTree} from '@apollo/react-ssr';
import gql from 'graphql-tag';
import withApollo from '../api/graphql/with-apollo';

const INDEX_QUERY = gql`
    query {
        indexLevel(underlying:"BTC-USD") {
            index {
                level
            }
            timestamp
        }
    }
`;

const INDEX_SUBSCRIPTION = gql`
    subscription {
        indexLevel(underlying:"BTC-USD" minIntervalMs:1000) {
            index {
                level
            }
            timestamp
        }
    }
`;
const IndexLevel = () => {
    const {loading, data, subscribeToMore} = useQuery(INDEX_QUERY);
    useEffect(() => {
        const unsub = subscribeToMore({
            document: INDEX_SUBSCRIPTION,
            updateQuery: (prev, {subscriptionData}) => {
                if (!subscriptionData.data) {
                    return prev;
                }
                return subscriptionData.data;
            }
        });
        return () => {
            unsub();
        }
    }, [subscribeToMore]);
    if (loading || !data) {
        return <p>loading</p>;
    }
    return <p>{data.indexLevel.index.level} {data.indexLevel.timestamp}</p>;
};

const Home = () => (
    <div>
        <IndexLevel/>
    </div>
)
export default withApollo(Home,
    {getDataFromTree}
);