How to set up a CDN for SPA (got problems with vue-router)
-
@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
-
@beets WOW - that’s incredible. I think I need some time to understand it. Thank you very much!!!
-
@daniel No problem, I’m not sure if you’re using SSR, it’s definitely a lot to wrap your head around at first, but I like how it’s set up for this project I’m working on. This project is still in development, perhaps once it’s done and I’ve organized my notes a bit more, I can make a sample project or post about it.
-
@beets That would be nice. Personally I created only SPAs so far. But SPA has a few drawbacks and I’m dealing with SSR for some time.
-
@daniel Yeah, I had never used SSR before this, I always figured it was too complex. But this project is an e-commerce site, so SSR is mandatory to get meta to work, and better SEO.
-
@beets nice work in deployment
If you’re using nginx, then if it is not an enterprise one, I strongly recommend using openresty fork - you can process browser feedback like csp etc. directly in lua part of nginx (and many, many more).
The second hint for nginx and even lover latencies is using custom configured caches - caching dynamic pages even for 1-2 minutes gives a BIG win in some setups. Well, nginx/openresty is obviously awesome. Oh, and proper host configuration - files, handles, buffers etc. That is not an art - it is a black magic haha Careful host configuration can give you even lower latency and higher reliability.
Anyway, I like your style
-
@beets Definitely. And what about the page speed and size? Our SPA has a huge JS file)s), about 700K which is necessary for the index page (vendor*.js). I tried to reduce its size but without luck. Is the size of the JS files still the same with SSR?
-
@daniel Bundle size won’t change [with SSR], but is there anything in vendor chunk that isn’t needed on every page? If so you can add vendor -> remove in quasar.conf.js, like this:
vendor: { remove: [ 'braintree-web', 'vue-smooth-dnd', 'pdfjs-dist', 'howler' ] },
Here you can see I remove braintree sdk, the smooth drag and drop module, pdfjs, and howler (an audio library.) Those aren’t needed on most pages, so I remove them from the vendor chunk and they get their own js file through webpack.
Also, with SSR, you have other problems like component hydration taking a long time. To solve that, I use https://github.com/maoberlehner/vue-lazy-hydration wherever possible. Then I delay hydrating some components until idle, or other conditions which helps a ton.
Another problem, is that I try to use
@click
events as little as possible. For example I won’t use a click event that pushes a new route, since if the page isn’t fully hydrated, the button would do nothing. Instead I use just a router-link. So if a user clicks a link too fast after loading the initial page, they just make a normal http request, and hopefully the next page they wait a second for the SPA to kick in. -
@qyloxe Yeah, nginx and host config is definitely black magic. I had considered openresty for other things, but haven’t used it yet. I just have a custom CSP endpoint for that. As far as caching, it’s tricky because every user can see different prices, “you’ve bought this item” icon, etc. I know I could figure out some hole punching system, or just use ajax after the client loads the page, but I haven’t gone down that rabbit hole yet. I was more focused on the API speed (coming from magento, now have a custom API with lots of redis cache.)
-
@beets I have to admit that I am not a full-time frontend developer. So, currently I am speechless, never heard about this before. This is definitely a very good advise - removing libs from the bundle. Thanks again.
-
@daniel If you uncomment the analyze part in:
build: { scopeHoisting: true, vueRouterMode: 'history', showProgress: true, gzip: false, // analyze: { // analyzerPort: 9000, // openAnalyzer: true // }, }
And build your application, you’ll see webpack’s bundle analyzer. It’s very helpful in optimizing your JS chunks.