Skip to content

Commit

Permalink
feat(static): Rewrite CSS URLs to Static ESI URLs so that better cach…
Browse files Browse the repository at this point in the history
…ing 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
  • Loading branch information
trieloff committed Apr 20, 2019
1 parent 8415e9d commit 396c55b
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 6 deletions.
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
"openwhisk": "3.19.0",
"opn": "6.0.0",
"parcel-bundler": "1.12.3",
"postcss": "^7.0.14",
"postcss-url": "^8.0.0",
"postcss-value-parser": "^3.3.1",
"progress": "2.0.1",
"request": "2.87.0",
"request-promise-native": "1.0.7",
Expand Down
92 changes: 86 additions & 6 deletions src/openwhisk/static.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}<esi:include src="${importuri.path}.esi"/><esi:remove>${importuri.path}</esi:remove>${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}<esi:include src="${importuri.path}.esi"/><esi:remove>${importuri.path}</esi:remove>${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 `<esi:include src="${asset.relativePath}.esi"/><esi:remove>${asset.relativePath}</esi:remove>`;
}
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();
}

Expand All @@ -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}`;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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,
};
42 changes: 42 additions & 0 deletions test/testStatic.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,48 @@ describe('Static Delivery Action #integrationtest', () => {
}).timeout(5000);
});

describe('CSS and JS Rewriting', () => {
it('Rewrite CSS', async () => {
assert.equal(await index.getBody('text/css', '', true), '');
assert.equal(await index.getBody('text/css', `.element {
background: url('images/../sprite.png?foo=bar');
}`, true), `.element {
background: url('images/../sprite.png?foo=bar');
}`);
assert.equal(await index.getBody('text/css', `.element {
background: url('https://example.com/sprite.png?foo=bar');
}`, true), `.element {
background: url('https://example.com/sprite.png?foo=bar');
}`);
assert.equal(await index.getBody('text/css', `.element {
background: url('images/../sprite.png');
}`, true), `.element {
background: url('<esi:include src="sprite.png.esi"/><esi:remove>sprite.png</esi:remove>');
}`);
assert.equal(await index.getBody('text/css', `.element {
background: url("images/../sprite.png");
}`, true), `.element {
background: url("<esi:include src="sprite.png.esi"/><esi:remove>sprite.png</esi:remove>");
}`);
assert.equal(await index.getBody('text/css',
'@import "fineprint.css" print;', true),
'@import "<esi:include src="fineprint.css.esi"/><esi:remove>fineprint.css</esi:remove>" print;');
assert.equal(await index.getBody('text/css',
'@import \'fineprint.css\' print;', true),
'@import \'<esi:include src="fineprint.css.esi"/><esi:remove>fineprint.css</esi:remove>\' print;');
assert.equal(await index.getBody('text/css',
'@import url(\'fineprint.css\') print;', true),
'@import url(\'<esi:include src="fineprint.css.esi"/><esi:remove>fineprint.css</esi:remove>\') print;');
assert.equal(await index.getBody('text/css',
'@import url("fineprint.css") print;', true),
'@import url("<esi:include src="fineprint.css.esi"/><esi:remove>fineprint.css</esi:remove>") print;');
});

it('Rewrite JS', async () => {
assert.equal(await index.getBody('text/javascript', '', true), '');
});
});

describe('Static Delivery Action #unittest', () => {
setupPolly({
recordFailedRequests: true,
Expand Down

0 comments on commit 396c55b

Please sign in to comment.