From 75b47e5b0286b1603c8511223bf1cae2f7a56192 Mon Sep 17 00:00:00 2001 From: Lars Trieloff Date: Sat, 20 Apr 2019 16:11:39 +0000 Subject: [PATCH] feat(static): Rewrite CSS URLs to Static ESI URLs so that better caching can be achieved When static is called with the `?esi=true` parameter, then all URL references in CSS files will be replaced with ESI tags that include the `.esi` version of the same resource. This `.esi` version will then resolve, with the help of adobe/helix-publish#61 to a long-cachable URL for the same resource. Partial implementation of adobe/helix-pipeline#267 --- static.js | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/static.js b/static.js index f1c0e4a0..e3a04b5c 100644 --- a/static.js +++ b/static.js @@ -13,6 +13,12 @@ const request = require('request-promise-native'); const crypto = require('crypto'); const mime = require('mime-types'); +const postcss = require('postcss'); +const postcssurl = require('postcss-url'); +const parser = require('postcss-value-parser'); + +const { space } = postcss.list; +const uri = require('uri-js'); /* eslint-disable no-console */ // one megabyte openwhisk limit + 20% Base64 inflation + safety padding @@ -74,17 +80,89 @@ function isBinary(type) { return true; } +function isCSS(type) { + return type === 'text/css'; +} + +function isJavaScript(type) { + return type.match(/(text|application)\/(x-)?(javascript|ecmascript)/); +} + +function rewriteImports(tree) { + tree.walkAtRules('import', (rule) => { + if (rule.name === 'import') { + const [url, queries] = space(rule.params); + const parsedurl = parser(url); + if (parsedurl.nodes + && parsedurl.nodes.length === 1 + && parsedurl.nodes[0].value === 'url' + && parsedurl.nodes[0].nodes + && parsedurl.nodes[0].nodes.length === 1 + && parsedurl.nodes[0].nodes[0].type === 'string' + && typeof parsedurl.nodes[0].nodes[0].value === 'string' + && typeof parsedurl.nodes[0].nodes[0].quote === 'string') { + const importuri = uri.parse(parsedurl.nodes[0].nodes[0].value); + const { quote } = parsedurl.nodes[0].nodes[0]; + if (importuri.reference === 'relative' && !importuri.query) { + rule.replaceWith(postcss.atRule({ + name: 'import', + params: `url(${quote}${quote}) ${queries}`, + })); + } + } else if (parsedurl.nodes + && parsedurl.nodes[0].type === 'string' + && typeof parsedurl.nodes[0].value === 'string' + && typeof parsedurl.nodes[0].quote === 'string') { + const importuri = uri.parse(parsedurl.nodes[0].value); + const { quote } = parsedurl.nodes[0]; + if (importuri.reference === 'relative' && !importuri.query) { + rule.replaceWith(postcss.atRule({ + name: 'import', + params: `${quote}${quote} ${queries}`, + })); + } + } + } + }); + return tree; +} + +function rewriteCSS(css) { + const processor = postcss() + .use(rewriteImports) + .use(postcssurl({ + url: (asset) => { + // TODO pass in request URL and make it absolute. + if (asset.search === '' && asset.absolutePath !== '.' && asset.relativePath !== '.') { + return ``; + } + return asset.url; + }, + })); + return processor.process(css, { from: undefined }).then(result => result.css); +} + +function rewriteJavaScript(javascript) { + return javascript; +} + function isJSON(type) { return !!type.match(/json/); } -function getBody(type, responsebody) { +function getBody(type, responsebody, esi = false) { if (isBinary(type)) { return Buffer.from(responsebody).toString('base64'); } if (isJSON(type)) { return JSON.parse(responsebody); } + if (esi && isCSS(type)) { + return rewriteCSS(responsebody.toString()); + } + if (esi && isJavaScript(type)) { + return rewriteJavaScript(responsebody.toString()); + } return responsebody.toString(); } @@ -103,7 +181,7 @@ function staticBase(owner, repo, entry, ref, strain = 'default') { return `__HLX/${owner}/${repo}/${strain}/${ref}/${entry}/DIST__`; } -function deliverPlain(owner, repo, ref, entry, root) { +function deliverPlain(owner, repo, ref, entry, root, esi = false) { const cleanentry = (`${root}/${entry}`).replace(/^\//, '').replace(/[/]+/g, '/'); console.log('deliverPlain()', owner, repo, ref, cleanentry); const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${cleanentry}`; @@ -116,12 +194,12 @@ function deliverPlain(owner, repo, ref, entry, root) { encoding: null, }; - return request.get(rawopts).then((response) => { + return request.get(rawopts).then(async (response) => { const type = mime.lookup(cleanentry) || 'application/octet-stream'; const size = parseInt(response.headers['content-length'], 10); console.log('size', size); if (size < REDIRECT_LIMIT) { - const body = getBody(type, response.body); + const body = await getBody(type, response.body, esi); console.log(`delivering file ${cleanentry} type ${type} binary: ${isBinary(type)}`); return { statusCode: 200, @@ -184,6 +262,7 @@ function blacklisted(path, allow, deny) { * @param {string} params.allow regular expression pattern that all delivered files must follow * @param {string} params.deny regular expression pattern that all delivered files may not follow * @param {string} params.root document root for all static files in the repository + * @param {boolean} params.esi replace relative URL references in JS and CSS with ESI references */ async function main({ owner, @@ -196,6 +275,7 @@ async function main({ allow, deny, root = '', + esi = false, }) { console.log('main()', owner, repo, ref, path, entry, strain, plain, allow, deny, root); @@ -204,12 +284,12 @@ async function main({ } if (plain) { - return deliverPlain(owner, repo, ref, entry, root); + return deliverPlain(owner, repo, ref, entry, root, esi); } return forbidden(); } module.exports = { - main, error, addHeaders, isBinary, staticBase, blacklisted, + main, error, addHeaders, isBinary, staticBase, blacklisted, getBody, };