├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── examples └── embedded.js ├── index.js ├── lib ├── proxy.js ├── push.js └── utils.js └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style = space 3 | indent_size = 4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | certs -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http2-serverpush-proxy 2 | 3 | This is a reverse proxy that helps you to automatically make use of HTTP/2.0's [server push](http://blog.xebia.com/http2-server-push/) mechanism for your static websites. 4 | 5 | [![NPM](https://nodei.co/npm/http2-serverpush-proxy.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/http2-serverpush-proxy/) 6 | 7 | ## How it works 8 | Usually, websites consist of multiple assets, like CSS and JS files as well as images like PNGs, JPGs and SVGs. Traditionally, a user's browser fetches the HTML first, parses it and then downloads all linked assets. However, this is slow, since the assets can't be loaded before the HTML is completely fetched and parsed. With server push, your webserver can actively sends those assets to the client browser even before it requested them. To prevent you from having to implement this functionality, _http2-serverpush-proxy_ sits as a proxy between your actual webserver and the user. In contrast to some other approaches like [http2-push-manifest](https://github.com/GoogleChrome/http2-push-manifest), where the assets to be pushed are declared statically, this library __dynamically parses the HTML__ files and extracts contained asset that should be pushed. 9 | 10 | ![](https://anchr.io/i/XEitW.png) 11 | Without server push 12 | ![](https://anchr.io/i/AOisH.png) 13 | With server push 14 | 15 | ## Usage 16 | ### Standalone 17 | One way to use this is as a standalone proxy by installing it globally with `npm install -g http2-serverpush-proxy` 18 | ```bash 19 | $ serverpush-proxy --extensions=css,js,svg --target=http://localhost:8080 --key=./certs/dev-key.pem --cert=./certs/dev-cert.pem --port 3000 20 | ``` 21 | 22 | #### Options 23 | * `--target` __[required]__: The target URL to be proxied. E.g. if your website runs at _http://localhost:8080_, this would be your target URL. 24 | * `--extensions`: File extensions to be push candidates Defaults to: see [this section](#what-is-pushed) 25 | * `--key` __[required]__: Path to your SSL key (HTTP/2 requires TLS (HTTPS) encryption) . 26 | * `--cert` __[required]__: Path to your SSL certificate. 27 | * `--port`: Port to make the proxy listen on. Defaults to `8080`. 28 | 29 | ### Embedded (connect middleware) 30 | You can also use this library as [connect](https://www.npmjs.com/package/connect) middleware in your application. You need a webserver running with [node-spdy](https://www.npmjs.com/package/spdy) (you need HTTP/2!). Please not that currently this middleware must be __the last one in your stack__, since it calls `res.end()`. 31 | 32 | #### Example 33 | ```javascript 34 | const pushMiddleware = require('http2-serverpush-proxy')({ baseUrl: 'http://localhost:8080' }) 35 | , app = require('express')() 36 | , http = require('spdy') 37 | , fs = require('fs'); 38 | 39 | app.use('/static', pushMiddleware.proxy); 40 | app.use('/static', pushMiddleware.push); 41 | app.get('/', (req, res) => { 42 | res.send('It works!'); 43 | }); 44 | 45 | const spdyOpts = { 46 | key: fs.readFileSync(__dirname + '/certs/dev-key.pem'), 47 | cert: fs.readFileSync(__dirname + '/certs/dev-cert.pem'), 48 | spdy: { 49 | protocols: ['h2', 'spdy/3.1', 'http/1.1'], 50 | plain: false, 51 | 'x-forwarded-for': true, 52 | } 53 | }; 54 | 55 | http.createServer(spdyOpts, app).listen(8081); 56 | ``` 57 | 58 | This would spawn an Express webserver, where all requests to `/static` are proxied to `http://localhost:8080` and all HTML (`Content-Type: text/html`) responses are parsed for assets to get server-pushed. 59 | 60 | #### Options 61 | Instantiating the middleware happens through calling a function (see line 1) that receives a config object with following parameters. 62 | * `baseUrl` __[required]__: The target URL to be proxied. E.g. if your website runs at _http://localhost:8080_, this would be your target URL. 63 | * `extensions` __[optional]__: File extensions to be push candidates Defaults to: see [this section](#what-is-pushed) 64 | 65 | ## What is pushed? 66 | Currently, ` { 9 | res.send('It works!'); 10 | }); 11 | 12 | const spdyOpts = { 13 | key: fs.readFileSync(__dirname + '/certs/dev-key.pem'), 14 | cert: fs.readFileSync(__dirname + '/certs/dev-cert.pem'), 15 | spdy: { 16 | protocols: ['h2', 'spdy/3.1', 'http/1.1'], 17 | plain: false, 18 | 'x-forwarded-for': true, 19 | } 20 | }; 21 | 22 | http.createServer(spdyOpts, app).listen(8081); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const http = require('spdy') 6 | , fs = require('fs') 7 | , request = require('request') 8 | , app = require('connect')() 9 | , proxy = require('./lib/proxy') 10 | , push = require('./lib/push') 11 | , argv = require('yargs').argv 12 | , path = require('path') 13 | , sprintf = require("sprintf-js").sprintf; 14 | 15 | if (require.main === module) { 16 | if (!argv.target || !argv.key || !argv.cert) return printHelp(); 17 | const baseUrl = argv.target; 18 | const sslKey = argv.key; 19 | const sslCert = argv.cert; 20 | const port = argv.port || 8080; 21 | const extensions = argv.extensions ? argv.extensions.split(',') : null; 22 | 23 | const spdyOpts = { 24 | key: fs.readFileSync(path.normalize(sslKey)), 25 | cert: fs.readFileSync(path.normalize(sslCert)), 26 | spdy: { 27 | protocols: ['h2', 'spdy/3.1', 'http/1.1'], 28 | plain: false, 29 | 'x-forwarded-for': true, 30 | } 31 | }; 32 | 33 | app.use(proxy(baseUrl)); 34 | app.use(push({baseUrl: baseUrl, extensions: extensions})); 35 | http.createServer(spdyOpts, app).listen(port); 36 | } 37 | 38 | function printHelp() { 39 | const help = ` 40 | Welcome to serverpush-proxy! 41 | 42 | These parameters are required: 43 | --target= The target URL to be proxied. E.g. http://localhost:8080. 44 | --key= Path to your SSL key (HTTP/2 requires TLS (HTTPS) encryption). E.g. ./certs/key.pem. 45 | --cert= Path to your SSL certificate. E.g. ./certs/cert.pem. 46 | 47 | Additionally, these parameters are optional: 48 | --extensions= File extensions to be push candidates. E.g. css,js,svg 49 | --port= Port to make the proxy listen on. Defaults to 8080. 50 | `; 51 | console.log(sprintf(help)); 52 | } 53 | 54 | module.exports = (config) => { 55 | return { 56 | proxy: proxy(config.baseUrl), 57 | push: push(config) 58 | } 59 | } -------------------------------------------------------------------------------- /lib/proxy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const request = require('request') 4 | , http = require('spdy') 5 | , Negotiator = require('negotiator') 6 | , utils = require('./utils'); 7 | 8 | module.exports = (baseUrl) => { 9 | if (/^https/.test(baseUrl)) return console.log('[Proxy Middleware] Error: Proxied endpoints must not be encrypted (no https)!'); 10 | return (req, res, next) => { 11 | const negotiator = new Negotiator(req); 12 | let htmlAccepted = !!negotiator.mediaType(['text/html']); 13 | 14 | let chunks; 15 | let proxyRequest = req.pipe(request({ 16 | method: req.method, 17 | url: baseUrl + req.url, 18 | headers: utils.omit(req.headers, ['accept-encoding']), 19 | encoding: null 20 | })).on('response', (response) => { 21 | utils.copyHeaders(response, res); 22 | res.statusCode = response.statusCode; 23 | }).on('error', function (err) { 24 | res.statusCode = 500; 25 | next(); 26 | }).on('data', function (chunk) { 27 | if (!chunks) chunks = chunk; 28 | else chunks = utils.appendBuffer(chunks, chunk); 29 | }).on('end', () => { 30 | if (htmlAccepted && res._headers && res._headers['content-type'].indexOf('text/html') !== -1 || !chunks.length) res.htmlBody = chunks.toString('utf-8'); 31 | else if (htmlAccepted) res.write(new Buffer(chunks)); 32 | next(); 33 | }); 34 | 35 | if (!htmlAccepted || req.method !== 'GET') proxyRequest.pipe(res); 36 | }; 37 | }; -------------------------------------------------------------------------------- /lib/push.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const xpath = require('xpath') 4 | , dom = require('xmldom').DOMParser 5 | , request = require('request') 6 | , utils = require('./utils'); 7 | 8 | const FILE_EXTENSIONS = { 9 | 'css': { mime: 'text/css', as: 'style' }, 10 | 'js': { mime: 'application/javascript', as: 'script' }, 11 | 'png': { mime: 'image/png', as: 'image' }, 12 | 'jpg': { mime: 'image/jpeg', as: 'image' }, 13 | 'gif': { mime: 'image/gif', as: 'image' }, 14 | 'svg': { mime: 'image/svg+xml', as: 'image' }, 15 | 'mp4': { mime: 'video/mp4', as: 'video' }, 16 | 'ogg': { mime: 'video/ogg', as: 'video' } 17 | }; 18 | 19 | const cfg = { baseUrl: '', extensions: {} } 20 | , xpathQueries = ["//link/@href", "//img/@src", "//script/@src"]; 21 | 22 | function parseAssetsFromHtml(html, errorCallback) { 23 | let assets = []; 24 | let doc = new dom({ 25 | errorHandler: { error: errorCallback } 26 | }).parseFromString(html); 27 | 28 | xpathQueries.forEach((q) => { 29 | let nodes = xpath.select(q, doc) 30 | .map(n => n.nodeValue) 31 | .filter(n => cfg.extensions.hasOwnProperty(getFileExtension(n.toLowerCase()))); 32 | assets = assets.concat(nodes); 33 | }); 34 | return assets; 35 | } 36 | 37 | function fetchAsset(assetUrl, destPushStream, headers) { 38 | return new Promise((resolve, reject) => { 39 | request(cfg.baseUrl + assetUrl, { headers: headers }) 40 | .on('response', (response) => { 41 | destPushStream.sendHeaders(response.headers); 42 | }) 43 | .on('err', reject) 44 | .on('end', resolve) 45 | .pipe(destPushStream); 46 | }); 47 | } 48 | 49 | function omitContentRelatedHeaders(oldHeaders, additionalHeaderFields) { 50 | let newHeaders = utils.omit(oldHeaders, ['accept', 'content-type']); 51 | Object.assign(newHeaders, additionalHeaderFields); 52 | return newHeaders; 53 | } 54 | 55 | function getFileExtension(str) { 56 | return str.substr(str.lastIndexOf('.') + 1).split('?')[0]; 57 | } 58 | 59 | module.exports = (config) => { 60 | if (/^https/.test(config.baseUrl)) return console.log('[Push Middleware] Error: Proxied endpoints must not be encrypted (no https)!'); 61 | cfg.baseUrl = config.baseUrl.lastIndexOf('/') === config.baseUrl.length - 1 ? config.baseUrl.split('/').slice(0, -1).join('/') : config.baseUrl; 62 | if (config.extensions && config.extensions.length) config.extensions.forEach((e) => { cfg.extensions[e] = FILE_EXTENSIONS[e]} ) 63 | else cfg.extensions = FILE_EXTENSIONS; 64 | 65 | return (req, res, next) => { 66 | if (!res.htmlBody || !res.push) return res.end(); 67 | let body = res.htmlBody; 68 | 69 | let assets = parseAssetsFromHtml(body, () => { 70 | res.statusCode = 500; 71 | res.end(); 72 | }); 73 | 74 | let promises = []; 75 | let linkHeader = ''; 76 | 77 | assets.forEach((asset, i) => { 78 | if (/^(http(s)?:)?\/\//.test(asset)) return; 79 | if (asset.indexOf('/') !== 0) asset = '/' + asset; 80 | 81 | promises.push(new Promise((resolve, reject) => { 82 | let pushStream = res.push(asset, { request: { 'accept': '*/*' } }); 83 | pushStream.on('error', () => { return; }); 84 | 85 | fetchAsset(asset, pushStream, omitContentRelatedHeaders(req.headers, { 'accept': cfg.extensions[getFileExtension(asset)].mime })).then(resolve).catch(resolve); 86 | })); 87 | 88 | linkHeader += `<${asset}>; rel=preload; as=${cfg.extensions[getFileExtension(asset)].as}${i < assets.length - 1 ? ',' : ''}`; 89 | }); 90 | 91 | Promise.all(promises).then(() => { 92 | //if (assets.length) res.setHeader('link', linkHeader); 93 | res.write(body); 94 | res.end(); 95 | }); 96 | }; 97 | }; -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | copyHeaders: (from, to) => { 5 | for (let hKey in from.headers) { 6 | to.setHeader(hKey, from.headers[hKey]); 7 | } 8 | }, 9 | omit: (obj, propertyKeys) => { 10 | if (!Array.isArray(propertyKeys)) propertyKeys = [propertyKeys]; 11 | let newObj = {}; 12 | Object.assign(newObj, obj); 13 | for (let key in propertyKeys) { 14 | delete newObj[key]; 15 | } 16 | return newObj; 17 | }, 18 | appendBuffer: (buffer1, buffer2) => { 19 | let tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); 20 | tmp.set(new Uint8Array(buffer1), 0); 21 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength); 22 | return tmp.buffer; 23 | } 24 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http2-serverpush-proxy", 3 | "version": "0.0.4", 4 | "description": "A simple standalone reverse proxy that automatically enables server-push for assets related to a HTTP response.", 5 | "homepage": "https://github.com/n1try/http2-serverpush-proxy", 6 | "author": { 7 | "name": "Ferdinand Mütsch", 8 | "url": "https://ferdinand-muetsch.de" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/n1try/http2-serverpush-proxy" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/n1try/http2-serverpush-proxy/issues" 16 | }, 17 | "main": "index.js", 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1", 20 | "start": "node ." 21 | }, 22 | "bin": { 23 | "serverpush-proxy": "./index.js" 24 | }, 25 | "engines": { 26 | "node": ">=6.4.0" 27 | }, 28 | "keywords": [ 29 | "http2", 30 | "server-push", 31 | "connect", 32 | "express", 33 | "middleware", 34 | "reverse", 35 | "proxy" 36 | ], 37 | "license": "MIT", 38 | "dependencies": { 39 | "connect": "^3.5.0", 40 | "http-proxy": "^1.15.2", 41 | "negotiator": "^0.6.1", 42 | "request": "^2.78.0", 43 | "spdy": "^3.4.4", 44 | "sprintf-js": "^1.0.3", 45 | "xmldom": "^0.1.22", 46 | "xpath": "0.0.23", 47 | "yargs": "^6.3.0" 48 | } 49 | } 50 | --------------------------------------------------------------------------------