diff --git a/_headers.ejs b/_headers.ejs new file mode 100644 index 00000000..83682103 --- /dev/null +++ b/_headers.ejs @@ -0,0 +1,15 @@ +# Long-term cache by default. +/* + Cache-Control: max-age=31536000 + +# And here are the exceptions: +/ + Cache-Control: no-cache + +/serviceworker.js + Cache-Control: no-cache + +# URLs in /assets do not include a hash and are mutable. +# But it isn't a big deal if the user gets an old version. +/assets/* + Cache-Control: must-revalidate, max-age=3600 diff --git a/config/asset-template-plugin.js b/config/asset-template-plugin.js new file mode 100644 index 00000000..fb882d3b --- /dev/null +++ b/config/asset-template-plugin.js @@ -0,0 +1,47 @@ +const path = require('path'); +const fs = require('fs'); +const ejs = require('ejs'); +const AssetsPlugin = require('assets-webpack-plugin'); + +module.exports = class AssetTemplatePlugin extends AssetsPlugin { + constructor(options) { + options = options || {}; + if (!options.template) throw Error('AssetTemplatePlugin: template option is required.'); + super({ + useCompilerPath: true, + filename: options.filename, + processOutput: files => this._processOutput(files) + }); + this._template = path.resolve(process.cwd(), options.template); + const ignore = options.ignore || /(manifest\.json|\.DS_Store)$/; + this._ignore = typeof ignore === 'function' ? ({ test: ignore }) : ignore; + } + + _processOutput(files) { + const mapping = { + all: [], + byType: {}, + entries: {} + }; + for (const entryName in files) { + // non-entry-point-derived assets are collected under an empty string key + // since that's a bit awkward, we'll call them "assets" + const name = entryName === '' ? 'assets' : entryName; + const listing = files[entryName]; + const entry = mapping.entries[name] = { + all: [], + byType: {} + }; + for (let type in listing) { + const list = [].concat(listing[type]).filter(file => !this._ignore.test(file)); + if (!list.length) continue; + mapping.all = mapping.all.concat(list); + mapping.byType[type] = (mapping.byType[type] || []).concat(list); + entry.all = entry.all.concat(list); + entry.byType[type] = (entry.byType[type] || []).concat(list); + } + } + mapping.files = mapping.all; + return ejs.render(fs.readFileSync(this._template, 'utf8'), mapping); + } +}; diff --git a/package-lock.json b/package-lock.json index 6802e256..a11b944b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -578,6 +578,27 @@ "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true }, + "assets-webpack-plugin": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/assets-webpack-plugin/-/assets-webpack-plugin-3.9.7.tgz", + "integrity": "sha512-yxo4MlSb++B88qQFE27Wf56ykGaDHZeKcSbrstSFOOwOxv33gWXtM49+yfYPSErlXPAMT5lVy3YPIhWlIFjYQw==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "escape-string-regexp": "^1.0.3", + "lodash.assign": "^4.2.0", + "lodash.merge": "^4.6.1", + "mkdirp": "^0.5.1" + }, + "dependencies": { + "camelcase": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true + } + } + }, "assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -7923,6 +7944,12 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "lodash.merge": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", + "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==", + "dev": true + }, "lodash.mergewith": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", diff --git a/package.json b/package.json index 670121e3..e88c256a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/pretty-bytes": "^5.1.0", "@types/webassembly-js-api": "0.0.1", "@webcomponents/custom-elements": "^1.2.1", + "assets-webpack-plugin": "^3.9.7", "babel-loader": "^7.1.5", "babel-plugin-jsx-pragmatic": "^1.0.2", "babel-plugin-syntax-dynamic-import": "^6.18.0", @@ -36,6 +37,7 @@ "copy-webpack-plugin": "^4.5.3", "critters-webpack-plugin": "^2.0.1", "css-loader": "^0.28.11", + "ejs": "^2.6.1", "exports-loader": "^0.7.0", "file-drop-element": "^0.0.9", "file-loader": "^1.1.11", diff --git a/src/lib/offliner.ts b/src/lib/offliner.ts index 040a9891..928161c0 100644 --- a/src/lib/offliner.ts +++ b/src/lib/offliner.ts @@ -42,6 +42,9 @@ async function updateReady(reg: ServiceWorkerRegistration): Promise { /** Set up the service worker and monitor changes */ export async function offliner(showSnack: SnackBarElement['showSnackbar']) { + // This needs to be a typeof because Webpack. + if (typeof PRERENDER === 'boolean') return; + if (process.env.NODE_ENV === 'production') { navigator.serviceWorker.register('../sw'); } diff --git a/webpack.config.js b/webpack.config.js index f4e7a717..54c51907 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,6 +14,7 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl const WorkerPlugin = require('worker-plugin'); const AutoSWPlugin = require('./config/auto-sw-plugin'); const CrittersPlugin = require('critters-webpack-plugin'); +const AssetTemplatePlugin = require('./config/asset-template-plugin'); function readJson (filename) { return JSON.parse(fs.readFileSync(filename)); @@ -238,8 +239,11 @@ module.exports = function (_, env) { compile: true }), - new AutoSWPlugin({ - version: VERSION + new AutoSWPlugin({ version: VERSION }), + + isProd && new AssetTemplatePlugin({ + template: path.join(__dirname, '_headers.ejs'), + filename: '_headers', }), new ScriptExtHtmlPlugin({