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)