TrueCar / gluestick

GlueStick is a command line interface for quickly developing universal web applications using React and Redux.
MIT License
361 stars 43 forks source link

GraphQL/Apollo Support #1223

Open chrischen opened 5 years ago

chrischen commented 5 years ago

I'm trying to implement Apollo-client support while maintaining server-side-rendering compatibility. It seems that I can add ApolloProvider in the routes.js file but the only way to access application state (that can be passed to client) is through the redux store (not ideal as redux is superseded by apollo-client and its ability to manage client-only data). Is there a way to inject data to the "window" object from any of the application-side code to hydrate the apollo store on the client from data from the server render?

toddw commented 5 years ago

Hi @chrischen, Yeah, it is possible for sure. You would want to create a simple server plugin. Here is an example of a what the server plugin would look like if you wanted to support Aphrodite and Apollo https://gist.github.com/toddw/971f9a1acebd406ba0ffac5f3e422276

You can easily refactor out the Aphrodite code if you are not using it.

toddw commented 5 years ago

If you wanted to do this without a plugin, you could try to do something similar. You inject data by using dangerouslySetInnerHTML. Be careful though, this can open you up to XSS attacks if you don't escape. JSON.stringify does not sanitize </script><script>alert('XSS')

chrischen commented 5 years ago

Will take a look at the plugin method, but for the injecting script tag method, where would I do that from the application code? I'm assuming the script tag would be parsed after mounting which might be after when I would normally initialize ApolloProvider?

toddw commented 5 years ago

A couple of things you do to determine when you are on the server or on the client:

  1. if(typeof window === "undefined")
  2. import something from "filename.server.js

In the browser something will be null and on the server it will return whatever filename.server.js exports.

chrischen commented 5 years ago

Ok I decided to go with the non-plugin method so that I could keep client and server Apollo code relatively synchronized. For anyone else wondering how to implement this here are the steps I took:

src/apps/main/Index.js This file is run on the server so I create ApolloProvider here with ssrMode: true. Also injected the apollo state like so

<script
  key="apollo-state"
  dangerouslySetInnerHTML={{
  __html: `window.__APOLLO_STATE__=${JSON.stringify(
  client.extract(),
  ).replace(/</g, '\\u003c')}`,
  }}
/>

src/apps/main/routes.js This file is run on both client and server, so I do a check if we're on the client and wrap with if we're in the browser. Client hydrates from window.__APOLLO_STATE__ which should have been inserted on server render.

chrischen commented 5 years ago

Ok I found an issue with this implementation.

The ApolloClient sometimes may need to send HTTP headers (for authorization, cookies, etc), which in my case are stored in cookies and sent to the server as part of the request headers. On browser renders I can access cookies, but on initial server renders I can't seem to access cookies until inside a container component.

From what I can tell, I can only access serverProps or renderProps to access headers/cookies from the gsBeforeRoute function in container components.

Is there any way to access the gluestick http headers from src/main/Index.js or from routes.js?

toddw commented 5 years ago

The function used to return routes in routes.js gets passed the store and httpClient. The httpClient is returned from getHttpClient and does some work to mirror the cookies of the client: getHttpClient#L98-L130

This is pretty buried but it's all done around here https://github.com/TrueCar/gluestick/blob/55d1da8c6fe9f81fe70629942c4126838f16d448/packages/gluestick/src/generator/templates/EntryWrapper.js

toddw commented 5 years ago

So, if you use the httpClient which is just a configured axios instance. A request that comes in through the browser includes cookies. Those cookies are copied along with any http request that the server makes using the configured httpClient so long as the host matches.

chrischen commented 5 years ago

I'm using Apollo's HttpLink which would do its own HTTP request. Is there any way to extract the cookies that are cached from the axios instance from the client request?

toddw commented 5 years ago

Maybe you can use an axios instance with HttpLink with https://github.com/lifeomic/axios-fetch ?

chrischen commented 5 years ago

Ok I'll try that and report back.

On that topic though, is there a way to use axios from redux-middlewares? I've been using isomorphic-fetch in a middleware and using cookies set in redux store from gsBeforeRoute.

toddw commented 5 years ago

Yes, if you use the built in promise middleware it exposes httpClient which is the configured axios instance mentioned above with cookie handling. https://github.com/TrueCar/gluestick/blob/develop/packages/gluestick/shared/lib/promiseMiddleware.js does this so your action creator would look something like this:

export function getData() {
  return {
    promise: (httpClient) => {
      return httpClient.get("/……")
    }
  }
}
chrischen commented 5 years ago

As it turns out when I wrapped <Route> in routes.js and <html> in main/Index.js with <ApolloProvider> it did not actually work. The Apollo context was not provided to the rest of the app.

So that brings me back to square 1.

I got it to partially work by wrapping the <Root> component in gluestick/EntryWrapper.js, but of course the top of the file is prefixed with /** DO NOT MODIFY **/. I'm assuming this file gets generated. In any case I still cannot find a good place to inject the __APOLLO_STATE__. It was being injected in

My requirements are: 1) must be created with the Axios instance to forward cookies/headers. Problem: Only place I can get the Axios client is in routes.js (but then it does not provide Apollo context to rest of app if I wrap it here) or in gluestick/EntryWrapper.js (says do not edit) 2) Need SSR so __APOLLO_STATE__ must be injected into window somewhere. Problem: No good place to inject it. main/Index.js can inject it but wrapping ApolloProvider here doesn't provide context for the rest of the app.

Potential workarounds: 1) Wrap each component in each route with <ApolloProvider> Doesn't work. 2) Inject__APOLLO_STATE__ in MasterLayout and every top-level layout. Wrap MasterLayout. Problem is how can I access the axios instance here? 3) Do server plugin and wrap the Root element there. Problem is cannot seem to get axios client this way?

chrischen commented 5 years ago

Ok here's the final solution:

gluestick/EntryWrapper.js I wrap <Root> with <ApolloProvider> and create Apollo's client here with the httpClient (axios client). Client/Server differentiation is done here to selectively create Apollo client in ssrMode.

MasterLayout.js In every MasterLayout.js I inject __APOLLO_STATE__

<Helmet {...config.head}>
    <script type="text/javascript"
        key="apollo-state"
    >
        {`window.__APOLLO_STATE__=${JSON.stringify(
            apolloClient.extract(),
        ).replace(/</g, '\\u003c')}`}
    </script>
</Helmet>