diff --git a/.gitignore b/.gitignore index 022498ba1f..173cfba5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ lib/extensions/_extensions.scss .DS_Store .start.pid .port.tmp +app/version.txt public node_modules/* .tmuxp.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 9694a7060b..3f332bf15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Features: # 8.5.0 Features: +- [#627 Add static asset caching (with cache busting)](https://github.com/alphagov/govuk-prototype-kit/pull/627) - [#672 Replace ‘check answers’ pattern with updated code](https://github.com/alphagov/govuk-prototype-kit/pull/672) - [#671 Update to GOV.UK Frontend version 2.5.0](https://github.com/alphagov/govuk-prototype-kit/pull/671) Allows use of new components Accordion and Summary List diff --git a/app/views/includes/head.html b/app/views/includes/head.html index 05fcc71f56..c16b9725e6 100644 --- a/app/views/includes/head.html +++ b/app/views/includes/head.html @@ -1,5 +1,5 @@ - - + + {% for stylesheetUrl in extensionConfig.stylesheets %} diff --git a/app/views/includes/scripts.html b/app/views/includes/scripts.html index 46aff854ff..51ef0daf8f 100644 --- a/app/views/includes/scripts.html +++ b/app/views/includes/scripts.html @@ -1,12 +1,12 @@ - + {% for scriptUrl in extensionConfig.scripts %} {% endfor %} - + {% if useAutoStoreData %} - + {% endif %} diff --git a/docs/views/includes/head.html b/docs/views/includes/head.html index 33dbf59c86..89895edc36 100644 --- a/docs/views/includes/head.html +++ b/docs/views/includes/head.html @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/docs/views/includes/scripts.html b/docs/views/includes/scripts.html index 65f5c79369..fdedcb4222 100644 --- a/docs/views/includes/scripts.html +++ b/docs/views/includes/scripts.html @@ -1,8 +1,8 @@ - - - + + + {% if useAutoStoreData %} - + {% endif %} diff --git a/docs/views/layout_unbranded.html b/docs/views/layout_unbranded.html index 77387dc65f..97595ddd98 100644 --- a/docs/views/layout_unbranded.html +++ b/docs/views/layout_unbranded.html @@ -10,7 +10,7 @@ {% endblock %} {% block head %} - + {% endblock %} {% block header %}{% endblock %} diff --git a/gulp/sass.js b/gulp/sass.js index 1b33eed74c..1f5edddd74 100644 --- a/gulp/sass.js +++ b/gulp/sass.js @@ -5,15 +5,31 @@ also includes sourcemaps */ +const fs = require('fs') const gulp = require('gulp') +const path = require('path') const sass = require('gulp-sass') +const sassVariables = require('gulp-sass-variables') const sourcemaps = require('gulp-sourcemaps') -const path = require('path') -const fs = require('fs') const extensions = require('../lib/extensions/extensions') const config = require('./config.json') +// Default cache prefix +let cacheId = '' + +// Inject Sass variables +const variables = () => { + cacheId = cacheId || fs.readFileSync(path.resolve('./app/version.txt'), 'utf-8').trim() + + return { + '$govuk-assets-path': cacheId + ? `/assets/${cacheId}/` + : '/assets/' + .join('\n') + } +} + gulp.task('sass-extensions', function (done) { const fileContents = '$govuk-extensions-url-context: "/extension-assets"; ' + extensions.getFileSystemPaths('sass') .map(filePath => `@import "${filePath.split(path.sep).join('/')}";`) @@ -24,6 +40,7 @@ gulp.task('sass-extensions', function (done) { gulp.task('sass', function () { return gulp.src(config.paths.assets + '/sass/*.scss') .pipe(sourcemaps.init()) + .pipe(sassVariables(variables())) .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError)) .pipe(sourcemaps.write()) .pipe(gulp.dest(config.paths.public + '/stylesheets/')) @@ -32,6 +49,7 @@ gulp.task('sass', function () { gulp.task('sass-documentation', function () { return gulp.src(config.paths.docsAssets + '/sass/*.scss') .pipe(sourcemaps.init()) + .pipe(sassVariables(variables())) .pipe(sass({ outputStyle: 'expanded' }).on('error', sass.logError)) .pipe(sourcemaps.write()) .pipe(gulp.dest(config.paths.public + '/stylesheets/')) @@ -42,6 +60,7 @@ gulp.task('sass-documentation', function () { gulp.task('sass-v6', function () { return gulp.src(config.paths.v6Assets + '/sass/*.scss') .pipe(sourcemaps.init()) + .pipe(sassVariables(variables())) .pipe(sass({ outputStyle: 'expanded', includePaths: [ diff --git a/gulp/version.js b/gulp/version.js new file mode 100644 index 0000000000..c44554b607 --- /dev/null +++ b/gulp/version.js @@ -0,0 +1,14 @@ +/* + version.js + =========== + generates an incremental hash for cache-busting +*/ + +const fs = require('fs') +const gulp = require('gulp') +const path = require('path') + +gulp.task('version', function (done) { + const version = (+new Date()).toString(36) + fs.writeFile(path.resolve('./app/version.txt'), version, done) +}) diff --git a/gulpfile.js b/gulpfile.js index 3b4702b22a..6252d006c6 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,6 +18,7 @@ requireDir('./gulp', { recurse: true }) // We'll keep our top-level tasks in this file so that they are defined at the end of the chain, after their dependencies. gulp.task('generate-assets', gulp.series( 'clean', + 'version', 'sass-extensions', gulp.parallel( 'sass', diff --git a/lib/utils.js b/lib/utils.js index b3adf58100..06b4f91fa5 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -310,3 +310,20 @@ exports.handleCookies = function (app) { next() } } + +// Remove cache ID from URL +exports.removeCacheId = function (req, res, next) { + const cacheId = req.app.locals.cacheId + const cachePath = `/${cacheId}/` + + // Reset cache header if ID not found + if (!cacheId || !req.url.includes(cachePath)) { + res.setHeader('Cache-Control', 'public, max-age=0') + } + + if (cacheId) { + req.url = req.url.replace(cachePath, '/') + } + + next() +} diff --git a/package.json b/package.json index 9090f2265e..2e9935724b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "gulp": "^4.0.0", "gulp-nodemon": "^2.1.0", "gulp-sass": "^4.0.1", + "gulp-sass-variables": "^1.2.0", "gulp-sourcemaps": "^2.6.0", "keypather": "^3.0.0", "marked": "^0.4.0", diff --git a/server.js b/server.js index 3d58729cbe..eacfe21424 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ // Core dependencies +const fs = require('fs') const path = require('path') // NPM dependencies @@ -72,6 +73,12 @@ promoMode = promoMode.toLowerCase() // Disable promo mode if docs aren't enabled if (!useDocumentation) promoMode = 'false' +// Optional cache directory +var cacheId = '' +try { + cacheId = fs.readFileSync(`${__dirname}/app/version.txt`, 'utf-8').trim() +} catch (e) {} + // Force HTTPS on production. Do this before using basicAuth to avoid // asking for username/password twice (for `http`, then `https`). var isSecure = (env === 'production' && useHttps === 'true') @@ -108,11 +115,16 @@ utils.addNunjucksFilters(nunjucksAppEnv) // Set views engine app.set('view engine', 'html') +// Cache assets for one year per deployment +const maxAge = 60 * 1000 * 60 * 24 * 365 +app.use(utils.removeCacheId) + // Middleware to serve static assets -app.use('/public', express.static(path.join(__dirname, '/public'))) +app.use('/public', express.static(path.join(__dirname, '/public'), { maxAge })) +app.use('/assets', express.static(path.join(__dirname, 'node_modules', 'govuk-frontend', 'assets'), { maxAge })) // Serve govuk-frontend in from node_modules (so not to break pre-extenstions prototype kits) -app.use('/node_modules/govuk-frontend', express.static(path.join(__dirname, '/node_modules/govuk-frontend'))) +app.use('/node_modules/govuk-frontend', express.static(path.join(__dirname, '/node_modules/govuk-frontend'), { maxAge })) // Set up documentation app if (useDocumentation) { @@ -155,9 +167,9 @@ if (useV6) { v6App.set('view engine', 'html') // Backward compatibility with GOV.UK Elements - app.use('/public/v6/', express.static(path.join(__dirname, '/node_modules/govuk_template_jinja/assets'))) - app.use('/public/v6/', express.static(path.join(__dirname, '/node_modules/govuk_frontend_toolkit'))) - app.use('/public/v6/javascripts/govuk/', express.static(path.join(__dirname, '/node_modules/govuk_frontend_toolkit/javascripts/govuk/'))) + app.use('/public/v6/', express.static(path.join(__dirname, '/node_modules/govuk_template_jinja/assets'), { maxAge })) + app.use('/public/v6/', express.static(path.join(__dirname, '/node_modules/govuk_frontend_toolkit'), { maxAge })) + app.use('/public/v6/javascripts/govuk/', express.static(path.join(__dirname, '/node_modules/govuk_frontend_toolkit/javascripts/govuk/'), { maxAge })) } // Add global variable to determine if DoNotTrack is enabled. @@ -171,7 +183,9 @@ app.use(function (req, res, next) { // Add variables that are available in all views app.locals.gtmId = gtmId -app.locals.asset_path = '/public/' +app.locals.cacheId = cacheId +app.locals.publicUrl = app.locals.publicPath = '/public' +app.locals.assetUrl = app.locals.assetPath = '/assets' app.locals.useAutoStoreData = (useAutoStoreData === 'true') app.locals.useCookieSessionStore = (useCookieSessionStore === 'true') app.locals.cookieText = config.cookieText @@ -181,6 +195,15 @@ app.locals.serviceName = config.serviceName // extensionConfig sets up variables used to add the scripts and stylesheets to each page. app.locals.extensionConfig = extensions.getAppConfig() +// Add cache directory +if (cacheId) { + app.locals.publicUrl = app.locals.publicPath = `/public/${cacheId}` + app.locals.assetUrl = app.locals.assetPath = `/assets/${cacheId}` +} + +// Legacy asset_path for compatibility +app.locals.asset_path = `${app.locals.publicPath}/` + // Session uses service name to avoid clashes with other prototypes const sessionName = 'govuk-prototype-kit-' + (Buffer.from(config.serviceName, 'utf8')).toString('hex') let sessionOptions = { @@ -279,7 +302,7 @@ if (useDocumentation) { if (useV6) { // Clone app locals to v6 app locals v6App.locals = Object.assign({}, app.locals) - v6App.locals.asset_path = '/public/v6/' + v6App.locals.asset_path = `${app.locals.asset_path}v6/` // Create separate router for v6 app.use('/', v6App)