vuejs / vue-hackernews-2.0

HackerNews clone built with Vue 2.0, vue-router & vuex, with server-side rendering
MIT License
10.96k stars 2.15k forks source link

SSR + Vuex + JWT #301

Closed BorjaRafols closed 6 years ago

BorjaRafols commented 6 years ago

I'm having some hard time getting this to work, and I'd love to see how you guys would do this.

Currently I'm using Vuejs + Vuex + Axios for async HTTP calls. Everything I've done is based on this template, but I don't see any info on how to to the following.

My Vuex actions look like this:

src/store/actions/dashboard/properties.js

import { fetchUserProperties, fetchUserProperty } from '@/src/api/dashboard/properties'

let FETCH_USER_PROPERTIES = ({commit, state}, data) => {

    return fetchUserProperties(data)
    .then((result) => {
        console.log("FETCH_USER_PROPERTIES",result)
        commit('SET_USER_PROPERTIES', result)
    })
    .catch((error) => {
        console.log("error", error)
    })
}

src/api/dashboard/properties

import axios from '@/src/api/axios'

let fetchUserProperties = () => {

    return new Promise((resolve, reject) => {

        axios.get(`/profile/properties`)
        .then((result) => {
            resolve(result.data)
        })
        .catch((error) => {
            reject(error)
        })

    });
}

src/api/axios

import axios from 'axios';
import config from '@/config/config'
import https from 'https'

var instance = axios.create(args);

export let initializeApi = (store, router) => {

    // Set base URL
    instance.defaults.baseURL = config.API_URL

  instance.interceptors.response.use(responseSuccess, (error) => {
    if (error.response.status === 401) {

      store.commit('REMOVE_USER_AND_TOKEN')

      router.push('/login')
    }

    return Promise.reject(error)
  })

  // Auth
    instance.interceptors.request.use((requestConfig) => {  

    let token = store.state.auth.token
    if (token) {
        requestConfig.headers.Authorization = `Bearer ${token}`
    }

    return requestConfig
  });  

}

export let updateToken = (token) => {
  instance.interceptors.request.use((requestConfig) => {  

    if (token) {
        requestConfig.headers.Authorization = `Bearer ${token}`
    }

    return requestConfig
  });
}

export default instance;

How does this work? Basically, both in entry-server.js and entry-client.js I make a call to this initializeApi function with value of the store.

entry-client.js

import Vue from 'vue'
import { app, router, store } from './client-create-app'
import { initializeApi } from '@/src/api/axios'

// Initialize api
initializeApi(store, router)

// wait until router has resolved all async before hooks
// and async components...
router.onReady(() => {

  // Add router hook for handling asyncData.
  // Doing it after initial route is resolved so that we don't double-fetch
  // the data that we already have. Using router.beforeResolve() so that all
  // async components are resolved.
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })
    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    bar.start()

    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to, router })))
      .then(() => {
        bar.finish()
        next()
      })
      .catch(next)
  })

  // actually mount to DOM
  app.$mount('#app')
})

// service worker
if ('https:' === location.protocol && navigator.serviceWorker) {
  navigator.serviceWorker.register('/service-worker.js')
}

entry-server.js

import { createApp } from './app'
import { initializeApi } from '@/src/api/axios'

const isDev = process.env.NODE_ENV !== 'production'

// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
  return new Promise((resolve, reject) => {
    const s = isDev && Date.now()
    const { app, router, store } = createApp()

    const { url } = context
    const route = router.resolve(url).route
    const { fullPath } = route

    if (fullPath !== url) {
      return reject({ url: fullPath })
    }

    if(context.cookies.token) {
      store.state.auth.token = context.cookies.token
    }

    if(context.cookies.user) {
      store.state.user = JSON.parse(decodeURIComponent(context.cookies.user))
    }

    // Initialize API
    initializeApi(store, router)
    // initializeApiV2(store, router)

    // set router's location
    if (route.matched[0].meta.requiresAuth && !context.cookies.token) {
      router.replace('/login')
    }else{
      // set router's location
      router.push(url)  
    }

    // wait until router has resolved possible async hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents() 

      // no matched routes
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // Call fetchData hooks on components matched by the route.
      // A preFetch hook dispatches a store action and returns a Promise,
      // which is resolved when the action is complete and store state has been
      // updated.
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute,
        router: router
      }))).then(() => {
        isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
        // After all preFetch hooks are resolved, our store is now
        // filled with the state needed to render the app.
        // Expose the state on the render context, and let the request handler
        // inline the state in the HTML response. This allows the client-side
        // store to pick-up the server-side state without having to duplicate
        // the initial data fetching on the client.
        context.state = store.state
        context.meta = app.$meta()
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

So I thought this looks and works beautifully. Now all by Axios request get the Authorization header appended and everything is good.

However after hours of debugging I found this:

https://medium.com/@iaincollins/how-not-to-create-a-singleton-in-node-js-bd7fde5361f5

Which means that on the server (NodeJS), my /src/api/axios.js file is getting cached by node. So that when I require the axios instance in my API files, sometimes it returns an axios instance created by another user, along with the old user interceptors and token. Very bad.

How are you guys handling this case where all HTTP request from the app need to have an Authorization header attached?

I'd love to see how @yyx990803 would do this. Would be nice to have it added to this example too.

Please let me know if you need any more information.

JounQin commented 6 years ago

FYI var instance = axios.create(args); in src/api/axios means there will be only one axios instance all through the app.

But actually you need to create a single axios instance for every request on Server.

BorjaRafols commented 6 years ago

Not true.

Watch this, I added a few debugging lines.

src/api/axios.js

var instance = axios.create(args);
instance.longandobviuslynotoverwrittenarg = new Date().toUTCString();

src/api/dashboard/properties

import axios from '@/src/api/axios'

let fetchUserProperties = () => {

    console.log("axios:instance", axios.longandobviuslynotoverwrittenarg)
    console.log("axios:instance.request", new Date().toUTCString())

    return new Promise((resolve, reject) => {

        axios.get(`/profile/properties`)
        .then((result) => {
            resolve(result.data)
        })
        .catch((error) => {
            reject(error)
        })

    });
}

And these are the logs I get by refreshing the page.

First request: image

Second request:

image

This also makes some sense after reading:

https://medium.com/@iaincollins/how-not-to-create-a-singleton-in-node-js-bd7fde5361f5

That means that when import a module in node and exporting an object NodeJS reserves itself the rigth to cache that object. So that you can have any guarantee that a new axios instance is created on each new request.

JounQin commented 6 years ago

I've said that you're sharing only one axios instance on server, that's why axios.longandobviuslynotoverwrittenarg is always same. You need to wrap axios.create(args) into a function to create another instance for all requests, for example:

// entry-server.js
import axios from 'axios'

export default () => new Promise((resolve, reject) => {
  const instance = axios.create();

  // add interceptors on instance...
})
BorjaRafols commented 6 years ago

Men, starting to feel stupid.

I have no idea how to go about this.

Nor how I can add the token to this axios instance from entry-server.js...

Do you know any resources online? Could you share your own snippet?

PD: I also don't understand why you would add a Promise in there.

JounQin commented 6 years ago

PD: I also don't understand why you would add a Promise in there.

It is entry-server.js, so it should return a promise.

You can check https://github.com/JounQin/vue-ssr/blob/master/src/entry-server.js#L13, add axios instance into context to attach it on server via this.$http:

import Vue from 'vue'
import axios from 'axios'

Object.defineProperty(
  Vue.prototype,
  '$http',
  __SERVER__
    ? {
        get() {
          return this.$ssrContext.axios // this.$http will be new for every request
        },
        configurable: __DEV__,
      }
    : {
        value: axios,
        writable: __DEV__,
      },
)
BorjaRafols commented 6 years ago

Thanks a lot Sir, I eneded up with this based on your repo.

/src/api/axios

import axios from 'axios';
import config from '@/config/config'
import https from 'https'

const createInstance = (store) => {

    let token = store.state.auth.token

    let instance = axios.create({
        'baseURL': config.API_URL,
        'headers': {
            'Authorization': `Bearer ${token}`
        }
    })

    instance.interceptors.response.use(
        (success) => {
            return success
        }, 
        (error) => {
            if (error.response.status === 401) {
                store.commit('REMOVE_USER_AND_TOKEN')
                router.push('/login')
            }
            return Promise.reject(error)
        })

    return instance
}

export { createInstance }

src/entry-server.js

import { createApp } from './app'
import { createInstance } from '@/src/api/axios'

const isDev = process.env.NODE_ENV !== 'production'

// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
  return new Promise((resolve, reject) => {
    const s = isDev && Date.now()
    const { app, router, store } = createApp()

    const { url } = context
    const route = router.resolve(url).route
    const { fullPath } = route

    if (fullPath !== url) {
      return reject({ url: fullPath })
    }

    const token = context.cookies.token
    if(token) {
      store.state.auth.token = token
    }

    if(context.cookies.user) {
      store.state.user = JSON.parse(decodeURIComponent(context.cookies.user))
    }

    store.commit("SET_AXIOS", createInstance(store))

    /* Initialize API
    let instance = initializeApi(store, router)
    store.state.$axios = instance
    context.$axios = instance
    */
    // initializeApiV2(store, router)

    // set router's location
    if (route.matched[0].meta.requiresAuth && !context.cookies.token) {
      router.replace('/login')
    }else{
      // set router's location
      router.push(url)  
    }

    // wait until router has resolved possible async hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents() 

      // no matched routes
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // Call fetchData hooks on components matched by the route.
      // A preFetch hook dispatches a store action and returns a Promise,
      // which is resolved when the action is complete and store state has been
      // updated.
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute,
        router: router
      }))).then(() => {
        isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
        // After all preFetch hooks are resolved, our store is now
        // filled with the state needed to render the app.
        // Expose the state on the render context, and let the request handler
        // inline the state in the HTML response. This allows the client-side
        // store to pick-up the server-side state without having to duplicate
        // the initial data fetching on the client.
        context.state = store.state
        context.meta = app.$meta()
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

src/entry-client.js

import Vue from 'vue'
import 'es6-promise/auto'
import { app, router, store } from './client-create-app'
import { createInstance } from '@/src/api/axios'
import ProgressBar from './components/ProgressBar.vue'

// global progress bar
const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
document.body.appendChild(bar.$el)

// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    console.log("beforeRouteUpdate", router)
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to,
        router: router
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

// Initialize api
store.commit("SET_AXIOS", createInstance(store))

// wait until router has resolved all async before hooks
// and async components...
router.onReady(() => {

  // Add router hook for handling asyncData.
  // Doing it after initial route is resolved so that we don't double-fetch
  // the data that we already have. Using router.beforeResolve() so that all
  // async components are resolved.
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })
    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    bar.start()

    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to, router })))
      .then(() => {
        bar.finish()
        next()
      })
      .catch(next)
  })

  // actually mount to DOM
  app.$mount('#app')
})

// service worker
if ('https:' === location.protocol && navigator.serviceWorker) {
  navigator.serviceWorker.register('/service-worker.js')
}

Works fine. Thanks again

JounQin commented 6 years ago

So please close issue. 🙂