@daniel Here’s some snippets on how I have it set up. This is not the normal SSR setup, but was customized pretty heavily for my needs.
config.json
{
"api_url": "https://api.example.com:4443",
"api_url_local": "http://127.0.0.1:8001",
"media_url": "https://media.example.com:4443",
"static_url": "https://static.example.com:4443",
"frontend_url": "https://example.com:4443",
}
Above is the basic config file, set for the dev environment. The api_url_local
is just because on SSR, we can access the loopback address instead calling the full domain, which makes it a bit faster.
quasar.conf.js
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const CompressionPlugin = require('compression-webpack-plugin')
const config = require('./config.json')
module.exports = function (ctx) {
return {
boot: [
'init',
'filters',
'plugins',
{ path: 'a11y', server: false },
{ path: 'polyfills', server: false },
{ path: 'hydrate', server: false },
],
css: [
'index.sass'
],
extras: [
],
framework: {
iconSet: 'svg-mdi-v5',
lang: 'en-us',
importStrategy: 'auto',
plugins: [
'Cookies',
'AddressbarColor',
'AppVisibility',
'LoadingBar',
'Dialog',
'Screen',
'Notify',
],
config: {
loadingBar: {
color: 'secondary',
size: '4px',
skipHijack: true,
}
}
},
preFetch: true,
build: {
scopeHoisting: true,
vueRouterMode: 'history',
showProgress: true,
gzip: false,
// analyze: {
// analyzerPort: 9000,
// openAnalyzer: false
// },
appBase: false,
beforeDev({ quasarConf }) {
// Hook our own meta system into Quasar
quasarConf.__meta = true
},
extendWebpack (cfg) {
cfg.resolve.alias['@'] = path.resolve(__dirname, 'src/components')
cfg.resolve.alias['mixins'] = path.resolve(__dirname, 'src/mixins')
cfg.resolve.alias['modules'] = path.resolve(__dirname, 'src/modules')
cfg.resolve.alias['utils'] = path.resolve(__dirname, 'src/utils')
if(cfg.mode === 'production') {
cfg.output.publicPath = config.static_url + '/'
cfg.plugins.push(
new CompressionPlugin({
filename: '[path].gz[query]',
algorithm: 'gzip',
test: /\.(js|css|svg)$/,
compressionOptions: {
level: 9,
},
minRatio: 1,
}),
new CompressionPlugin({
filename: '[path].br[query]',
algorithm: 'brotliCompress',
test: /\.(js|css|svg)$/,
compressionOptions: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
},
minRatio: 1,
})
)
}
}
},
devServer: {
// https: {
// key: fs.readFileSync('certs/key.pem'),
// cert: fs.readFileSync('certs/cert.pem'),
// ca: fs.readFileSync('certs/minica.pem'),
// },
https: false,
port: 8000,
sockPort: 4443,
open: false,
},
animations: [
'slideInLeft',
'slideInRight',
'slideOutLeft',
'slideOutRight',
'zoomIn',
'zoomOut',
'pulse',
],
ssr: {
pwa: false,
manualHydration: true,
extendPackageJson(pkg) {
// Default quasar SSR packages we don't use
delete pkg.dependencies['compression']
delete pkg.dependencies['express']
delete pkg.dependencies['lru-cache']
}
}
}
}
Main interesting things above are:
- I use the webpack compress plugin (only on production) to gzip and brotli compress the assets. You can just do .gz without the plugin, but I wanted .br too.
- I also set the publicPath there too, as shown in my other post
- For SSR, I’m removing express and some other packages I don’t use
- I also manually hydrate, which is pretty store specific to my case with vuex. It’s because I freeze a lot of objects stored there.
- I rolled my own meta plugin, for various reasons, so I had to mimic how the official one works with the beforeDev hook.
Then finally, src-ssr/index.js
const { createServer } = require('http')
const { promisify } = require('util')
const randomBytes = promisify(require('crypto').randomBytes)
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require('./quasar.server-manifest.json')
const clientManifest = require('./quasar.client-manifest.json')
const config = require('../config.json')
const renderer = createBundleRenderer(bundle, {
clientManifest,
runInNewContext: false,
})
const port = 8000
const server = createServer(async (req, res) => {
// Stats
const start_time = Date.now()
let ttfb = null, total = null
// Generate CSP nonce
const nonce = (await randomBytes(16)).toString('base64')
const ctx = {
url: req.url,
req,
res,
nonce,
}
const stream = renderer.renderToStream(ctx)
stream.once('data', () => {
console.log('First chunk: ', Date.now())
// Interesting note: if needed, we can access vuex with ctx.state
// Custom Asset Prefetch
// Instead of using ${ ctx.renderResourceHints() } in the template,
// We are going to do the same here but remove preload links handled above
// Todo: would be nice if vue exposed the render function for just these
let prefetchLinks = ctx.renderResourceHints()
prefetchLinks = prefetchLinks.substring(prefetchLinks.indexOf('<link rel="prefetch"'))
// Custom Asset Preload
// Instead of using ${ ctx.renderResourceHints() } in the template,
// We are going to push the preload files in the HTTP header
let preloadLinks = ctx.getPreloadFiles().map(f => {
return {
file: config.static_url + '/' + f.file,
asType: f.asType,
//extra: '; crossorigin',
extra: '',
}
})
preloadLinks.push(
{ file: config.media_url + '/fonts/roboto-v20-latin-300.woff2', asType: 'font', extra: '; crossorigin; type="font/woff2"' },
{ file: config.media_url + '/fonts/roboto-v20-latin-regular.woff2', asType: 'font', extra: '; crossorigin; type="font/woff2"' },
{ file: config.media_url + '/fonts/roboto-v20-latin-500.woff2', asType: 'font', extra: '; crossorigin; type="font/woff2"' },
)
preloadLinks = preloadLinks.map(f => `<${f.file}>; rel=preload; as=${f.asType}${f.extra}`).join(', ')
const csp_template = {
'default-src': [config.static_url],
'prefetch-src': [config.static_url],
'base-uri': ["'self'"],
'script-src': [config.static_url, `'nonce-${nonce}'`, "'unsafe-inline'", /*"'strict-dynamic'",*/],
// abbreviated
}
const csp = Object.entries(csp_template).reduce((acc, [key, values]) => {
return acc += key + ' ' + values.join(' ') + '; '
}, '')
res.writeHead(ctx.httpCode || 200, {
'Content-Type': 'text/html; charset=UTF-8',
'Content-Security-Policy': csp,
'Link': preloadLinks,
})
res.write(`<!DOCTYPE html>
<html ${ctx.Q_HTML_ATTRS}>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" sizes="128x128" href="${config.media_url}/favicon/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="${config.media_url}/favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="${config.media_url}/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="${config.media_url}/favicon/favicon-16x16.png">
<link rel="icon" type="image/ico" href="${config.media_url}/favicon/favicon.ico">
${ ctx.Q_HEAD_TAGS }
${ ctx.renderStyles() }
${ prefetchLinks }
</head>
<body class="${ctx.Q_BODY_CLASSES}">
<noscript>
<div>
Javascript disabled
</div>
</noscript>
`)
ttfb = Date.now() - start_time
})
stream.on('data', chunk => {
res.write(chunk)
})
stream.on('end', () => {
res.end(`
${ ctx.renderState() }
${ ctx.renderScripts() }
</body>
</html>
`)
total = Date.now() - start_time
const ttfb_pct = Math.floor(100 * ttfb / total)
console.log(`${req.url} TTFB: ${ttfb} Total: ${total} - ${ttfb_pct}%`)
})
stream.on('error', error => {
console.log("ONERROR")
console.log(error.message)
if(ctx.statusCode === 307 || ctx.statusCode === 308) {
res.writeHead(ctx.statusCode, {
'Location': ctx.location
})
res.end()
} else {
// Nginx will render this error page
// Note, statusMessage doesn't seem to be
// available to nginx
res.statusCode = 502
res.statusMessage = error.message
res.end()
}
})
})
server.listen(port, '127.0.0.1', error => {
console.log(`Server listening at port ${port}`)
})
The above file it a bit of a doozy, I really didn’t need express for SSR mode, and I really wanted to get renderToStream to work (which it does, and saves about 50% TTFB.) Luckily, Quasar just gives you a template for the SSR server, but you don’t have to stick to it. The file generates the CSP, and also sends the preload links as HTTP headers, instead of meta tags. It also completely ignores what’s in index.template.html
and writes it directly.
All of the domains are set up through nginx, I’ll see if I can clean that up and post it here, but basic idea is that example.com goes to the SSR process (which you can see doesn’t serve any static assets) and static.example.com serves the dist/ssr/www folder, with gzip_static and brotli_static enabled (you have to manually compile the brotli plugin for that to work, but it’s worth it for me.)
Finally, I also serve quasar’s dev mode through nginx as well, just so I can use SSL, test things like my CSP, etc. Basically if i run quasar dev, or build it and run the node process, I just access it through example.com:4443.
Edit: an example of the TTFB savings:
/ TTFB: 188 Total: 458 - 41%
That is logged when I request my homepage. With renderToStream, I get a TTFB of 188ms (minus network latency) while rederToString would take 458ms