├── .gitignore ├── README.md ├── client ├── api.js ├── nuxt.config.js ├── pages │ ├── index.vue │ └── second.vue └── plugins │ └── api.js ├── index.js ├── package.json └── server ├── api.js ├── gen.js ├── loader.js ├── main.js ├── nuxt.js └── routes ├── hello ├── index.js ├── msg.js └── msgWithInjection.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | client/.nuxt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | See https://hire.jonasgalvez.com.br/2020/feb/22/the-ultimate-nuxt-api-setup/ 2 | -------------------------------------------------------------------------------- /client/api.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated by server/main.js 2 | export default client => ({ 3 | hello: { 4 | msg (options = {}) { 5 | return client.get('/api/hello', options) 6 | }, 7 | msgWithInjection (options = {}) { 8 | return client.get('/api/hello-with-injection', options) 9 | }, 10 | }, 11 | }) -------------------------------------------------------------------------------- /client/nuxt.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | 3 | export default { 4 | srcDir: resolve(__dirname), 5 | buildDir: resolve(__dirname, '.nuxt'), 6 | plugins: ['~/plugins/api'], 7 | modules: ['@nuxtjs/axios'], 8 | axios: { 9 | baseURL: 'http://localhost:3000', 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ message }} 4 | 5 | 6 | Repeat API request from the client 7 | 8 | 9 | 10 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /client/pages/second.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ message }} 4 | 5 | 6 | Repeat API request from the client 7 | 8 | 9 | 10 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /client/plugins/api.js: -------------------------------------------------------------------------------- 1 | import getClientAPI from '../api' 2 | 3 | export default (ctx, inject) => { 4 | if (process.server) { 5 | ctx.$api = process.$api 6 | } else { 7 | ctx.$api = getClientAPI(ctx.$axios) 8 | } 9 | inject('api', ctx.$api) 10 | } 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | //!/bin/env node 2 | // eslint-disable-next-line no-global-assign 3 | require = require('esm')(module) 4 | const { setup, listen } = require('./server/main.js') 5 | 6 | setup().then(listen) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev": "node index.js" 4 | }, 5 | "dependencies": { 6 | "@nuxtjs/axios": "^5.9.5", 7 | "esm": "^3.2.25", 8 | "fastify": "^2.12.0", 9 | "fastify-esm-loader": "0.0.8", 10 | "fastify-sensible": "^2.1.1", 11 | "nuxt": "^2.11.0", 12 | "pino": "^5.16.0", 13 | "pino-pretty": "3.5.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/api.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated by server/main.js 2 | export default ({ handlers, translateRequest, translateRequestWithPayload }) => ({ 3 | hello: { 4 | msg (options = {}) { 5 | return translateRequest(handlers.hello.msg, { }, '/api/hello', options) 6 | }, 7 | msgWithInjection (options = {}) { 8 | return translateRequest(handlers.hello.msgWithInjection, { }, '/api/hello-with-injection', options) 9 | }, 10 | }, 11 | }) -------------------------------------------------------------------------------- /server/gen.js: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | 3 | export function generateServerAPIMethods (methods) { 4 | const body = generateServerAPIMethodsBody(methods) 5 | return ( 6 | '// This file is autogenerated by server/main.js\n' + 7 | `export default ({ handlers, translateRequest, translateRequestWithPayload }) => ({\n${body}})` 8 | ) 9 | } 10 | 11 | export function generateClientAPIMethods (methods) { 12 | const body = generateClientAPIMethodsBody(methods) 13 | return ( 14 | '// This file is autogenerated by server/main.js\n' + 15 | `export default client => ({\n${body}})` 16 | ) 17 | } 18 | 19 | const ssrAPITimeout = 10000 20 | 21 | class SSRAPIRequestTimeoutError extends Error { 22 | constructor (req) { 23 | super(`SSR API request did not complete in time: ${JSON.stringify(req, null, 2)}`) 24 | this.name = 'SSRAPIRequestTimeoutError' 25 | this.req = req 26 | } 27 | } 28 | 29 | function logError (err) { 30 | if (process.dev) { 31 | consola.error(err) 32 | } else if (process.sentry) { 33 | process.sentry.captureException(err) 34 | } 35 | } 36 | 37 | function isObject (val) { 38 | return val !== null && typeof val === 'object' && !Array.isArray(val) 39 | } 40 | 41 | function startTimeout (req, resolve) { 42 | return setTimeout(() => { 43 | logError(new SSRAPIRequestTimeoutError(req)) 44 | resolve({}) 45 | }, ssrAPITimeout) 46 | } 47 | 48 | export function translateRequest (handler, params, url, options = {}) { 49 | consola.info('SSR API request:', url, params) 50 | return new Promise((resolve, reject) => { 51 | try { 52 | const cancelTimeout = startTimeout({ params, url }, resolve) 53 | handler( 54 | { 55 | url, 56 | params, 57 | query: options.params, 58 | headers: options.headers, 59 | }, 60 | { 61 | send: (data) => { 62 | clearTimeout(cancelTimeout) 63 | resolve({ data }) 64 | }, 65 | }, 66 | ) 67 | } catch (err) { 68 | logError(err) 69 | reject(err) 70 | } 71 | }) 72 | } 73 | 74 | export function translateRequestWithPayload (handler, params, url, data, options = {}) { 75 | consola.info('SSR API request w/ payload:', url, params, data) 76 | return new Promise((resolve, reject) => { 77 | try { 78 | const cancelTimeout = startTimeout({ params, url, data }, resolve) 79 | handler( 80 | { 81 | url, 82 | params, 83 | body: data, 84 | query: options.params, 85 | headers: options.headers, 86 | }, 87 | { 88 | send: (data) => { 89 | clearTimeout(cancelTimeout) 90 | resolve({ data }) 91 | }, 92 | }, 93 | ) 94 | } catch (err) { 95 | logError(err) 96 | reject(err) 97 | } 98 | }) 99 | } 100 | 101 | function generateServerAPIMethodsBody (methods, loadedMethods = '', path = null, level = 1) { 102 | const indent = new Array(level * 2).fill(' ').join('') 103 | for (const prop of Object.keys(methods)) { 104 | const val = methods[prop] 105 | if (isObject(val)) { 106 | loadedMethods += `${indent}${prop}: {\n` 107 | loadedMethods = generateServerAPIMethodsBody(val, loadedMethods, path ? `${path}.${prop}` : prop, level + 1) 108 | loadedMethods += `${indent}},\n` 109 | } else if (Array.isArray(val)) { 110 | const args = ['options = {}'] 111 | const params = [] 112 | let url = val[1].replace(/:([\w\d_$]+)/g, (_, param) => { 113 | params.push(param) 114 | return `\${${param}}` 115 | }) 116 | if (url.includes('${')) { 117 | url = `\`${url}\`` 118 | } else { 119 | url = `'${url}'` 120 | } 121 | const method = val[0].toLowerCase() 122 | if (params.length) { 123 | args.splice(0, 0, ...params) 124 | } 125 | const methodPath = path ? `${path}.${prop}` : prop 126 | if (['post', 'put'].includes(method)) { 127 | args.splice(-1, 0, 'data') 128 | loadedMethods += ( 129 | `${indent}${prop} (${args.join(', ')}) {\n` + 130 | `${indent} return translateRequestWithPayload(handlers.${methodPath}, { ${params.join(', ')} }, ${url}, data, options)\n` + 131 | `${indent}},\n` 132 | ) 133 | } else { 134 | loadedMethods += ( 135 | `${indent}${prop} (${args.join(', ')}) {\n` + 136 | `${indent} return translateRequest(handlers.${methodPath}, { ${params.join(', ')} }, ${url}, options)\n` + 137 | `${indent}},\n` 138 | ) 139 | } 140 | } 141 | } 142 | return loadedMethods 143 | } 144 | 145 | function generateClientAPIMethodsBody (methods, loadedMethods = '', path = null, level = 1) { 146 | const indent = new Array(level * 2).fill(' ').join('') 147 | for (const prop of Object.keys(methods)) { 148 | const val = methods[prop] 149 | if (isObject(val)) { 150 | loadedMethods += `${indent}${prop}: {\n` 151 | loadedMethods = generateClientAPIMethodsBody(val, loadedMethods, path ? `${path}.${prop}` : prop, level + 1) 152 | loadedMethods += `${indent}},\n` 153 | } else if (Array.isArray(val)) { 154 | const args = ['options = {}'] 155 | const params = [] 156 | let url = val[1].replace(/:([\w\d_$]+)/g, (_, param) => { 157 | params.push(param) 158 | return `\${${param}}` 159 | }) 160 | if (url.includes('${')) { 161 | url = `\`${url}\`` 162 | } else { 163 | url = `'${url}'` 164 | } 165 | const method = val[0].toLowerCase() 166 | if (params.length) { 167 | args.splice(0, 0, ...params) 168 | } 169 | if (['post', 'put'].includes(method)) { 170 | args.splice(-1, 0, 'data') 171 | loadedMethods += ( 172 | `${indent}${prop} (${args.join(', ')}) {\n` + 173 | `${indent} return client.${method}(${url}, data, options)\n` + 174 | `${indent}},\n` 175 | ) 176 | } else { 177 | loadedMethods += ( 178 | `${indent}${prop} (${args.join(', ')}) {\n` + 179 | `${indent} return client.${method}(${url}, options)\n` + 180 | `${indent}},\n` 181 | ) 182 | } 183 | } 184 | } 185 | return loadedMethods 186 | } 187 | -------------------------------------------------------------------------------- /server/loader.js: -------------------------------------------------------------------------------- 1 | import { resolve, join } from 'path' 2 | import { writeFile } from 'fs-extra' 3 | import setPath from 'lodash/set' 4 | import FastifyESMLoader, { methodPathSymbol } from 'fastify-esm-loader' 5 | import { 6 | translateRequest, 7 | translateRequestWithPayload, 8 | generateServerAPIMethods, 9 | generateClientAPIMethods 10 | } from './gen' 11 | 12 | export default async function FastifyESMLoaderWrapper (fastify, options, done) { 13 | try { 14 | const api = {} 15 | const handlers = {} 16 | fastify.addHook('onRoute', (route) => { 17 | const name = route.handler[methodPathSymbol] 18 | if (name) { 19 | setPath(api, name || route.handler.name, [route.method.toString(), route.url]) 20 | setPath(handlers, name, route.handler) 21 | } 22 | }) 23 | await FastifyESMLoader(fastify, options, done) 24 | await fastify.ready() 25 | const clientMethods = generateClientAPIMethods(api) 26 | const apiClientPath = resolve(__dirname, join('..', 'client', 'api.js')) 27 | await writeFile(apiClientPath, clientMethods) 28 | const serverMethods = generateServerAPIMethods(api) 29 | const apiServerPath = resolve(__dirname, join('api.js')) 30 | await writeFile(apiServerPath, serverMethods) 31 | const getServerAPI = await import(apiServerPath).then(m => m.default || m) 32 | process.$api = getServerAPI({ handlers, translateRequest, translateRequestWithPayload }) 33 | if (process.buildNuxt) { 34 | await process.buildNuxt() 35 | } 36 | } catch (err) { 37 | console.log(err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/main.js: -------------------------------------------------------------------------------- 1 | 2 | process.dev = process.env.NODE_ENV !== 'production' 3 | 4 | import { join } from 'path' 5 | import consola from 'consola' 6 | import Fastify from 'fastify' 7 | import FastifySensible from 'fastify-sensible' 8 | import nuxtPlugin from './nuxt' 9 | import loaderPlugin from './loader' 10 | 11 | const devLogger = { 12 | level: 'error', 13 | prettyPrint: { 14 | levelFirst: true, 15 | }, 16 | } 17 | 18 | export async function setup () { 19 | try { 20 | const fastify = Fastify({ 21 | pluginTimeout: 60000 * 3, 22 | logger: process.dev && devLogger, 23 | }) 24 | const rootInjections = await import('./routes') 25 | 26 | // General purpose plugins 27 | fastify.register(FastifySensible) 28 | fastify.register(nuxtPlugin) 29 | fastify.register(loaderPlugin, { 30 | baseDir: join(__dirname, 'routes'), 31 | injections: rootInjections, 32 | prefix: `/api`, 33 | }) 34 | return fastify 35 | } catch (err) { 36 | consola.error(err) 37 | process.exit(1) 38 | } 39 | } 40 | 41 | export async function listen (fastify) { 42 | try { 43 | if (process.dev) { 44 | process.bindAddress = 'localhost' 45 | } else { 46 | process.bindAddress = process.env.HOST || '0.0.0.0' 47 | } 48 | process.bindPort = process.env.PORT || 3000 49 | await fastify.listen(process.bindPort, process.bindAddress) 50 | if (process.dev) { 51 | consola.info(`Listening at http://${process.bindAddress}:${process.bindPort}`) 52 | } 53 | } catch (err) { 54 | consola.error(err) 55 | process.exit(1) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/nuxt.js: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | import fp from 'fastify-plugin' 3 | import { Nuxt, Builder } from 'nuxt' 4 | import nuxtConfig from '../client/nuxt.config.js' 5 | 6 | async function nuxtPlugin (fastify) { 7 | try { 8 | const nuxt = new Nuxt({ dev: process.dev, ...nuxtConfig }) 9 | await nuxt.ready() 10 | 11 | fastify.get('/*', (req, reply) => { 12 | nuxt.render(req.raw, reply.res) 13 | }) 14 | 15 | if (process.dev) { 16 | process.buildNuxt = () => { 17 | return new Builder(nuxt).build() 18 | .catch((buildError) => { 19 | consola.fatal(buildError) 20 | process.exit(1) 21 | }) 22 | } 23 | } 24 | } catch (err) { 25 | console.log(err) 26 | } 27 | } 28 | 29 | export default fp(nuxtPlugin) 30 | -------------------------------------------------------------------------------- /server/routes/hello/index.js: -------------------------------------------------------------------------------- 1 | export default ({ fastify, self }) => { 2 | fastify.get('/hello', self.msg) 3 | fastify.get('/hello-with-injection', self.msgWithInjection) 4 | } 5 | -------------------------------------------------------------------------------- /server/routes/hello/msg.js: -------------------------------------------------------------------------------- 1 | export default (_, reply) => { 2 | reply.send({ message: 'Hello from API' }) 3 | } 4 | -------------------------------------------------------------------------------- /server/routes/hello/msgWithInjection.js: -------------------------------------------------------------------------------- 1 | export default ({ injection }) => (req, reply) => { 2 | reply.send({ injection }) 3 | } 4 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | export const injection = 'Hello -- this comes from hello/index.js' 2 | 3 | --------------------------------------------------------------------------------
5 | 6 | Repeat API request from the client 7 | 8 |