├── .gitignore ├── .prettierrc.yaml ├── .travis.yml ├── .eslintrc.yml ├── .github └── workflows │ └── semgrep.yml ├── LICENSE.md ├── posthtml-preload.js ├── package.json ├── README.md ├── index.js └── test └── preload.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 80 2 | tabWidth: 2 3 | useTabs: false 4 | semi: true 5 | singleQuote: true 6 | trailingComma: es5 7 | bracketSpacing: false 8 | parser: babylon -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 0.10 5 | - 0.12 6 | - 4 7 | - 6 8 | - stable 9 | - lts/* 10 | before_install: 11 | - npm install -g npm@latest-2 12 | after_success: 13 | - npm run coverage 14 | branches: 15 | except: 16 | - "/^v\\d+\\.\\d+\\.\\d+$/" 17 | 18 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - plugin:prettier/recommended 3 | - plugin:eslint-comments/recommended 4 | - plugin:node/recommended 5 | - plugin:security/recommended 6 | - plugin:promise/recommended 7 | plugins: 8 | - eslint-comments 9 | - node 10 | - security 11 | - promise 12 | rules: 13 | prefer-reflect: off 14 | no-underscore-dangle: 15 | - error 16 | - allowAfterThis: true 17 | prefer-rest-params: off 18 | node/exports-style: 19 | - error 20 | - module.exports 21 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 CloudFlare, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /posthtml-preload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(options, foundEntries) { 4 | return function(tree) { 5 | var matchers = []; 6 | 7 | matchers.push({ 8 | tag: 'base', 9 | attrs: { 10 | href: true, 11 | }, 12 | }); 13 | 14 | if (options.images) { 15 | matchers.push({ 16 | tag: 'img', 17 | attrs: { 18 | src: true, 19 | }, 20 | }); 21 | } 22 | 23 | if (options.scripts) { 24 | matchers.push({ 25 | tag: 'script', 26 | attrs: { 27 | src: true, 28 | nomodule: false, 29 | }, 30 | }); 31 | } 32 | 33 | if (options.styles) { 34 | matchers.push({ 35 | tag: 'link', 36 | attrs: { 37 | rel: 'stylesheet', 38 | }, 39 | }); 40 | } 41 | 42 | if (matchers.length) { 43 | tree.match(matchers, function(node) { 44 | switch (node.tag) { 45 | case 'base': 46 | foundEntries.push([node.attrs.href, 'base']); 47 | break; 48 | case 'img': 49 | // Ensure we're not preloading an inline image 50 | if (node.attrs.src.indexOf('data:') !== 0) { 51 | foundEntries.push([node.attrs.src, node.attrs.crossorigin, 'image']); 52 | } 53 | break; 54 | case 'script': 55 | foundEntries.push([node.attrs.src, node.attrs.crossorigin, 'script']); 56 | break; 57 | case 'link': 58 | foundEntries.push([node.attrs.href, node.attrs.crossorigin, 'style']); 59 | break; 60 | // no default 61 | } 62 | return node; 63 | }); 64 | } 65 | 66 | return foundEntries; 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netjet", 3 | "version": "1.4.0", 4 | "description": "Express middleware to generate preload headers", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint '{index,posthtml-preload,test/**/*}.js'", 8 | "test": "istanbul cover -- _mocha --check-leaks --reporter spec", 9 | "coverage": "cat ./coverage/lcov.info | coveralls" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/cloudflare/netjet.git" 14 | }, 15 | "keywords": [ 16 | "express", 17 | "middleware", 18 | "link", 19 | "preload" 20 | ], 21 | "author": "Terin Stock (https://terinstock.com/)", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/cloudflare/netjet/issues" 25 | }, 26 | "homepage": "https://github.com/cloudflare/netjet#readme", 27 | "files": [ 28 | "index.js", 29 | "posthtml-preload.js" 30 | ], 31 | "dependencies": { 32 | "bl": "^1.0.1", 33 | "hijackresponse": "^2.0.0", 34 | "lodash.defaults": "^4.0.0", 35 | "lodash.unescape": "^4.0.0", 36 | "lru-cache": "^4.0.0", 37 | "posthtml": "^0.9.0" 38 | }, 39 | "devDependencies": { 40 | "assume": "^1.3.1", 41 | "coveralls": "^2.11.6", 42 | "detour": "^1.3.0", 43 | "eslint": "^4.16.0", 44 | "eslint-config-prettier": "^2.9.0", 45 | "eslint-plugin-eslint-comments": "^2.0.2", 46 | "eslint-plugin-mocha": "^4.11.0", 47 | "eslint-plugin-node": "^5.2.1", 48 | "eslint-plugin-prettier": "^2.5.0", 49 | "eslint-plugin-promise": "^3.6.0", 50 | "eslint-plugin-security": "^1.4.0", 51 | "istanbul": "^0.4.2", 52 | "mocha": "^3.0.0", 53 | "prettier": "^1.10.2", 54 | "supertest": "^2.0.0", 55 | "supertest-as-promised": "^4.0.0", 56 | "testdouble": "^1.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netjet [![Travis-CI Status](https://img.shields.io/travis/cloudflare/netjet/master.svg?label=Travis%20CI&style=flat-square)](https://travis-ci.org/cloudflare/netjet)[![](https://img.shields.io/npm/dm/netjet.svg?style=flat-square)](http://browsenpm.org/package/netjet)[![](https://img.shields.io/npm/v/netjet.svg?style=flat-square)](http://browsenpm.org/package/netjet)[![](https://img.shields.io/coveralls/cloudflare/netjet/master.svg?style=flat-square)](https://coveralls.io/github/cloudflare/netjet)[![](https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square)](https://nodejs.org/api/documentation.html#documentation_stability_index) 2 | 3 | netjet is a Node.js HTTP middleware to automatically insert [Preload][preload] link headers in HTML responses. 4 | These Preload link headers allow for web browsers to initiate early resource fetching before being needed for execution. 5 | 6 | ## Example usage 7 | 8 | ```javascript 9 | var express = require('express'); 10 | var netjet = require('netjet'); 11 | var root = '/path/to/static/folder'; 12 | 13 | express() 14 | .use(netjet()) 15 | .use(express.static(root)) 16 | .listen(1337); 17 | ``` 18 | 19 | ## Options 20 | 21 | * **images**, **scripts**, and **styles**: `Boolean`: 22 | 23 | If `true` the corresponding subresources are parsed and added as a Preload Link headers. 24 | 25 | * **cache**: `Object`: 26 | 27 | Object passed straight to [`lru-cache`][lru-cache]. It is highly recommended to set `cache.max` to an integer. 28 | 29 | * **attributes**: `Array` 30 | 31 | List of custom attributes that should be added to the Preload Link headers. 32 | 33 | ## License 34 | 35 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://www.tldrlegal.com/l/mit) see `LICENSE.md`. 36 | 37 | [preload]: https://www.w3.org/TR/preload/ 38 | [posthtml]: https://github.com/posthtml/posthtml#readme 39 | [lru-cache]: https://github.com/isaacs/node-lru-cache#readme 40 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var posthtml = require('posthtml'); 3 | var unescape = require('lodash.unescape'); 4 | var defaults = require('lodash.defaults'); 5 | var hijackresponse = require('hijackresponse'); 6 | var bl = require('bl'); 7 | var LRU = require('lru-cache'); 8 | var posthtmlPreload = require('./posthtml-preload'); 9 | 10 | function encodeRFC5987(str) { 11 | return encodeURI(str) 12 | .replace(/['()]/g, escape) 13 | .replace(/\*/g, '%2A') 14 | .replace(/%(?:7C|60|5E)/g, unescape); 15 | } 16 | 17 | module.exports = function netjet(options) { 18 | options = defaults(options, { 19 | images: true, 20 | scripts: true, 21 | styles: true, 22 | cache: {}, 23 | attributes: [], 24 | }); 25 | 26 | var cache = new LRU(options.cache); 27 | var attributes = [''].concat(options.attributes).join('; '); 28 | 29 | return function netjetMiddleware(req, res, next) { 30 | function appendHeader(field, value) { 31 | var prev = res.getHeader(field); 32 | 33 | if (prev) { 34 | value = [].concat(prev, value); 35 | } 36 | 37 | res.setHeader(field, value); 38 | } 39 | 40 | function insertLinkArray(entries) { 41 | var baseTag; 42 | entries.forEach(function(entry) { 43 | if (entry[1] === 'base') { 44 | baseTag = entry; 45 | } 46 | }); 47 | 48 | entries 49 | .filter(function(entry) { 50 | return entry[1] !== 'base'; 51 | }) 52 | .forEach(function(entry) { 53 | var url = entry[0]; 54 | var crossOrigin = entry[1]; 55 | var asType = entry[2]; 56 | var addBaseHref = 57 | baseTag !== undefined && 58 | !new RegExp('^([a-z]+://|/)', 'i').test(url); 59 | 60 | var crossOriginString = ''; 61 | if (crossOrigin) { 62 | crossOriginString = '; crossorigin=' + crossOrigin; 63 | } 64 | 65 | appendHeader( 66 | 'Link', 67 | '<' + 68 | (addBaseHref ? baseTag[0] : '') + 69 | encodeRFC5987(unescape(url)) + 70 | '>; rel=preload; as=' + 71 | asType + 72 | crossOriginString + 73 | attributes 74 | ); 75 | }); 76 | } 77 | 78 | function processBody(body) { 79 | var foundEntries = []; 80 | 81 | posthtml() 82 | .use(posthtmlPreload(options, foundEntries)) 83 | .process(body, {sync: true}); 84 | 85 | return foundEntries; 86 | } 87 | 88 | hijackresponse(res, function(err, res) { 89 | /* istanbul ignore next */ 90 | // `err` from hijackresponse is currently hardcoded to "null" 91 | if (err) { 92 | res.unhijack(); 93 | next(err); 94 | return; 95 | } 96 | 97 | // Only hijack HTML responses 98 | if (!/^text\/html(?:;|\s|$)/.test(res.getHeader('Content-Type'))) { 99 | res.unhijack(); 100 | return; 101 | } 102 | 103 | var etag = res.getHeader('etag'); 104 | var entries; 105 | 106 | // reuse previous parse if the etag already exists in cache 107 | if (etag) { 108 | entries = cache.get(etag); 109 | 110 | if (entries) { 111 | insertLinkArray(entries); 112 | res.pipe(res); 113 | return; 114 | } 115 | } 116 | 117 | res.pipe( 118 | bl(function(err, data) { 119 | if (err) { 120 | res.unhijack(); 121 | next(err); 122 | return; 123 | } 124 | 125 | entries = processBody(data.toString()); 126 | 127 | insertLinkArray(entries); 128 | cache.set(etag, entries); 129 | 130 | res.end(data); 131 | }) 132 | ); 133 | }); 134 | 135 | next(); 136 | }; 137 | }; 138 | -------------------------------------------------------------------------------- /test/preload.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var http = require('http'); 3 | var describe = require('mocha').describe; 4 | var before = require('mocha').before; 5 | var beforeEach = require('mocha').beforeEach; 6 | var it = require('mocha').it; 7 | var expect = require('assume'); 8 | var request = require('supertest-as-promised'); 9 | var td = require('testdouble'); 10 | var detour = require('detour'); 11 | var preload = require('../'); 12 | 13 | describe('preload', function() { 14 | describe('requests', function() { 15 | describe('skips non-HTML', function() { 16 | before(function() { 17 | this.server = createServer(); 18 | }); 19 | 20 | it('should not parse binary responses', function(done) { 21 | request(this.server) 22 | .get('/static/image.png') 23 | .expect('Content-Type', 'image/png') 24 | .expect(function(res) { 25 | expect(res.headers.link).to.not.exist(); // eslint-disable-line security/detect-non-literal-fs-filename 26 | }) 27 | .expect(200, done); 28 | }); 29 | 30 | it('should not parse JSON responses', function(done) { 31 | request(this.server) 32 | .get('/api/whoami') 33 | .expect('Content-Type', 'application/json') 34 | .expect(function(res) { 35 | expect(res.headers.link).to.not.exist(); // eslint-disable-line security/detect-non-literal-fs-filename 36 | }) 37 | .expect(200, done); 38 | }); 39 | }); 40 | 41 | describe('should include base href', function() { 42 | before(function() { 43 | this.server = createServer({ 44 | images: true, 45 | scripts: false, 46 | styles: false, 47 | }); 48 | }); 49 | 50 | it('should include for all affected URLs', function(done) { 51 | request(this.server) 52 | .get('/base-href/all') 53 | .expect('Content-Type', 'text/html; charset=utf-8') 54 | .expect( 55 | 'Link', 56 | '; rel=preload; as=image, ; rel=preload; as=image, ; rel=preload; as=image, ; rel=preload; as=image, ; rel=preload; as=image' 57 | ) 58 | .expect(200, done); 59 | }); 60 | }); 61 | 62 | describe('should parse out images', function() { 63 | before(function() { 64 | this.server = createServer({ 65 | images: true, 66 | scripts: false, 67 | styles: false, 68 | }); 69 | }); 70 | 71 | it('should create Link header for single image', function(done) { 72 | request(this.server) 73 | .get('/blog/single-image') 74 | .expect('Content-Type', 'text/html; charset=utf-8') 75 | .expect('Link', '; rel=preload; as=image') 76 | .expect(200, done); 77 | }); 78 | 79 | it('should create Link header for multiple images', function(done) { 80 | request(this.server) 81 | .get('/blog/mutli-image') 82 | .expect('Content-Type', 'text/html; charset=utf-8') 83 | .expect( 84 | 'Link', 85 | '; rel=preload; as=image, ; rel=preload; as=image' 86 | ) 87 | .expect(200, done); 88 | }); 89 | 90 | it('should not create Link header for scripts', function(done) { 91 | request(this.server) 92 | .get('/blog/single-image-and-script') 93 | .expect('Content-Type', 'text/html; charset=utf-8') 94 | .expect('Link', '; rel=preload; as=image') 95 | .expect(200, done); 96 | }); 97 | 98 | it('should not create Link header for inline images', function(done) { 99 | request(this.server) 100 | .get('/blog/inline-image') 101 | .expect('Content-Type', 'text/html; charset=utf-8') 102 | .expect('Link', '; rel=preload; as=image') 103 | .expect(200, done); 104 | }); 105 | }); 106 | 107 | describe('should parse out scripts', function() { 108 | before(function() { 109 | this.server = createServer({ 110 | images: false, 111 | scripts: true, 112 | styles: false, 113 | }); 114 | }); 115 | 116 | it('should create Link header for single script', function(done) { 117 | request(this.server) 118 | .get('/blog/single-script') 119 | .expect('Content-Type', 'text/html; charset=utf-8') 120 | .expect('Link', '; rel=preload; as=script') 121 | .expect(200, done); 122 | }); 123 | 124 | it('should ignore nomodule scripts', function(done) { 125 | request(this.server) 126 | .get('/blog/nomodule-script') 127 | .expect('Content-Type', 'text/html; charset=utf-8') 128 | .expect(200, done); 129 | }); 130 | 131 | it('should include crossorigin property on scripts', function(done) { 132 | request(this.server) 133 | .get('/blog/module-script') 134 | .expect('Content-Type', 'text/html; charset=utf-8') 135 | .expect('Link', '; rel=preload; as=script; crossorigin=anonymous') 136 | .expect(200, done); 137 | }); 138 | 139 | it('should create Link header for multiple scripts', function(done) { 140 | request(this.server) 141 | .get('/blog/multi-script') 142 | .expect('Content-Type', 'text/html; charset=utf-8') 143 | .expect( 144 | 'Link', 145 | '; rel=preload; as=script, ; rel=preload; as=script' 146 | ) 147 | .expect(200, done); 148 | }); 149 | 150 | it('should not create Link header for images', function(done) { 151 | request(this.server) 152 | .get('/blog/single-script-and-image') 153 | .expect('Content-Type', 'text/html; charset=utf-8') 154 | .expect('Link', '; rel=preload; as=script') 155 | .expect(200, done); 156 | }); 157 | 158 | it('should not create Link header for inline scripts', function(done) { 159 | request(this.server) 160 | .get('/blog/including-inline-script') 161 | .expect('Content-Type', 'text/html; charset=utf-8') 162 | .expect('Link', '; rel=preload; as=script') 163 | .expect(200, done); 164 | }); 165 | }); 166 | 167 | describe('should parse out stylesheets', function() { 168 | before(function() { 169 | this.server = createServer({ 170 | images: false, 171 | scripts: false, 172 | styles: true, 173 | }); 174 | }); 175 | 176 | it('should create Link header for single style', function(done) { 177 | request(this.server) 178 | .get('/blog/single-style') 179 | .expect('Content-Type', 'text/html; charset=utf-8') 180 | .expect('Link', '; rel=preload; as=style') 181 | .expect(200, done); 182 | }); 183 | 184 | it('should create Link header for mutliple styles', function(done) { 185 | request(this.server) 186 | .get('/blog/multi-style') 187 | .expect('Content-Type', 'text/html; charset=utf-8') 188 | .expect( 189 | 'Link', 190 | '; rel=preload; as=style, ; rel=preload; as=style' 191 | ) 192 | .expect(200, done); 193 | }); 194 | 195 | it('should not create Link header for scripts', function(done) { 196 | request(this.server) 197 | .get('/blog/single-style-and-script') 198 | .expect('Content-Type', 'text/html; charset=utf-8') 199 | .expect('Link', '; rel=preload; as=style') 200 | .expect(200, done); 201 | }); 202 | 203 | it('should not create Link header for other types', function(done) { 204 | request(this.server) 205 | .get('/blog/other-link-types') 206 | .expect('Content-Type', 'text/html; charset=utf-8') 207 | .expect('Link', '; rel=preload; as=style') 208 | .expect(200, done); 209 | }); 210 | }); 211 | 212 | describe('parses all types', function() { 213 | before(function() { 214 | this.server = createServer(); 215 | }); 216 | 217 | it('should create Link headers for all types', function(done) { 218 | request(this.server) 219 | .get('/blog/all') 220 | .expect('Content-Type', 'text/html; charset=utf-8') 221 | .expect( 222 | 'Link', 223 | '; rel=preload; as=image, ; rel=preload; as=script, ; rel=preload; as=style' 224 | ) 225 | .expect(200, done); 226 | }); 227 | }); 228 | 229 | it('should parse on 404 HTML pages', function(done) { 230 | var server = createServer(); 231 | 232 | request(server) 233 | .get('/404') 234 | .expect('Content-Type', 'text/html; charset=utf-8') 235 | .expect('Link', '; rel=preload; as=image') 236 | .expect(404, done); 237 | }); 238 | 239 | it('should URL encode link headers', function(done) { 240 | var server = createServer(); 241 | 242 | request(server) 243 | .get('/encoded') 244 | .expect('Content-Type', 'text/html; charset=utf-8') 245 | .expect('Link', '; rel=preload; as=image') 246 | .expect(200, done); 247 | }); 248 | }); 249 | 250 | describe('cache', function() { 251 | beforeEach(function() { 252 | this.disposer = td.function(); 253 | 254 | this.server = createEtagServer({ 255 | cache: { 256 | max: 2, 257 | dispose: this.disposer, 258 | }, 259 | }); 260 | }); 261 | 262 | it('should use the values from cache if etags', function() { 263 | var server = this.server; 264 | 265 | return request(server) 266 | .get('/etag/setEtag100') 267 | .expect('Content-Type', 'text/html; charset=utf-8') 268 | .expect('Link', '; rel=preload; as=image') 269 | .expect(200) 270 | .then(function() { 271 | return request(server) 272 | .get('/etag/alsoServeEtag100') 273 | .expect('Content-Type', 'text/html; charset=utf-8') 274 | .expect( 275 | 'Link', 276 | '; rel=preload; as=image' 277 | ) 278 | .expect(function(res) { 279 | expect(res.headers.link).to.not.contain('droids'); // eslint-disable-line security/detect-non-literal-fs-filename 280 | }) 281 | .expect(200); 282 | }); 283 | }); 284 | 285 | it('should evict items from cache', function() { 286 | var server = this.server; 287 | var disposer = this.disposer; 288 | 289 | return request(server) 290 | .get('/etag/etag1/1') 291 | .expect('Content-Type', 'text/html; charset=utf-8') 292 | .expect('Link', '; rel=preload; as=image') 293 | .expect(200) 294 | .then(function() { 295 | // add a second item to the cache 296 | return request(server) 297 | .get('/etag/etag2/2') 298 | .expect('Content-Type', 'text/html; charset=utf-8') 299 | .expect( 300 | 'Link', 301 | '; rel=preload; as=image' 302 | ) 303 | .expect(200); 304 | }) 305 | .then(function() { 306 | return td.verify(disposer(), { 307 | times: 0, 308 | ignoreExtraArgs: true, 309 | }); 310 | }) 311 | .then(function() { 312 | // add a third item to the cache (should evict the first request) 313 | return request(server) 314 | .get('/etag/etag3/3') 315 | .expect('Content-Type', 'text/html; charset=utf-8') 316 | .expect( 317 | 'Link', 318 | '; rel=preload; as=image' 319 | ) 320 | .expect(200); 321 | }) 322 | .then(function() { 323 | return td.verify( 324 | disposer('1', [['/images/etag1.png?count=1', undefined, 'image']]) 325 | ); 326 | }) 327 | .then(function() { 328 | return request(server) 329 | .get('/etag/etag1/4') 330 | .expect('Content-Type', 'text/html; charset=utf-8') 331 | .expect( 332 | 'Link', 333 | '; rel=preload; as=image' 334 | ) 335 | .expect(200); 336 | }); 337 | }); 338 | }); 339 | 340 | describe('attributes', function() { 341 | it('should add a custom attribute', function(done) { 342 | var server = createServer({ 343 | attributes: ['nopush'], 344 | }); 345 | 346 | request(server) 347 | .get('/404') 348 | .expect('Content-Type', 'text/html; charset=utf-8') 349 | .expect('Link', '; rel=preload; as=image; nopush') 350 | .expect(404, done); 351 | }); 352 | 353 | it('should add the custom attributes', function(done) { 354 | var server = createServer({ 355 | attributes: ['nopush', 'x-http2-push-only'], 356 | }); 357 | 358 | request(server) 359 | .get('/404') 360 | .expect('Content-Type', 'text/html; charset=utf-8') 361 | .expect( 362 | 'Link', 363 | '; rel=preload; as=image; nopush; x-http2-push-only' 364 | ) 365 | .expect(404, done); 366 | }); 367 | }); 368 | }); 369 | 370 | function createServer(options) { 371 | var preloader = preload(options); 372 | var router = detour(); 373 | 374 | router.route('/static/image.png', { 375 | GET: function(req, res) { 376 | res.statusCode = 200; 377 | res.setHeader('Content-Type', 'image/png'); 378 | res.write(''); 379 | res.end(); 380 | }, 381 | }); 382 | 383 | router.route('/api/whoami', { 384 | GET: function(req, res) { 385 | res.statusCode = 200; 386 | res.setHeader('Content-Type', 'application/json'); 387 | res.write(JSON.stringify({result: 'awesome'})); 388 | res.end(); 389 | }, 390 | }); 391 | 392 | router.route('/blog/single-image', { 393 | GET: function(req, res) { 394 | res.statusCode = 200; 395 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 396 | res.write('Cairo'); 397 | res.end(); 398 | }, 399 | }); 400 | 401 | router.route('/blog/mutli-image', { 402 | GET: function(req, res) { 403 | res.statusCode = 200; 404 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 405 | res.write( 406 | 'CairoLondon' 407 | ); 408 | res.end(); 409 | }, 410 | }); 411 | 412 | router.route('/blog/single-image-and-script', { 413 | GET: function(req, res) { 414 | res.statusCode = 200; 415 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 416 | res.write( 417 | 'Cairo' 418 | ); 419 | res.end(); 420 | }, 421 | }); 422 | 423 | router.route('/blog/inline-image', { 424 | GET: function(req, res) { 425 | res.statusCode = 200; 426 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 427 | res.write( 428 | 'transparentCairo' 429 | ); 430 | res.end(); 431 | }, 432 | }); 433 | 434 | router.route('/blog/single-script', { 435 | GET: function(req, res) { 436 | res.statusCode = 200; 437 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 438 | res.write(''); 439 | res.end(); 440 | }, 441 | }); 442 | 443 | router.route('/blog/nomodule-script', { 444 | GET: function(req, res) { 445 | res.statusCode = 200; 446 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 447 | res.write(''); 448 | res.end(); 449 | }, 450 | }); 451 | 452 | router.route('/blog/module-script', { 453 | GET: function(req, res) { 454 | res.statusCode = 200; 455 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 456 | res.write(''); 457 | res.end(); 458 | }, 459 | }); 460 | 461 | router.route('/blog/multi-script', { 462 | GET: function(req, res) { 463 | res.statusCode = 200; 464 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 465 | res.write( 466 | '' 467 | ); 468 | res.end(); 469 | }, 470 | }); 471 | 472 | router.route('/blog/single-script-and-image', { 473 | GET: function(req, res) { 474 | res.statusCode = 200; 475 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 476 | res.write( 477 | 'Cairo' 478 | ); 479 | res.end(); 480 | }, 481 | }); 482 | 483 | router.route('/blog/including-inline-script', { 484 | GET: function(req, res) { 485 | res.statusCode = 200; 486 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 487 | res.write( 488 | '' 489 | ); 490 | res.end(); 491 | }, 492 | }); 493 | 494 | router.route('/blog/single-style', { 495 | GET: function(req, res) { 496 | res.statusCode = 200; 497 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 498 | res.write(''); 499 | res.end(); 500 | }, 501 | }); 502 | 503 | router.route('/blog/multi-style', { 504 | GET: function(req, res) { 505 | res.statusCode = 200; 506 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 507 | res.write( 508 | '' 509 | ); 510 | res.end(); 511 | }, 512 | }); 513 | 514 | router.route('/blog/single-style-and-script', { 515 | GET: function(req, res) { 516 | res.statusCode = 200; 517 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 518 | res.write( 519 | '' 520 | ); 521 | res.end(); 522 | }, 523 | }); 524 | 525 | router.route('/blog/other-link-types', { 526 | GET: function(req, res) { 527 | res.statusCode = 200; 528 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 529 | res.write( 530 | '' 531 | ); 532 | res.end(); 533 | }, 534 | }); 535 | 536 | router.route('/blog/all', { 537 | GET: function(req, res) { 538 | res.statusCode = 200; 539 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 540 | res.write( 541 | 'Cairo' 542 | ); 543 | res.end(); 544 | }, 545 | }); 546 | 547 | router.route('/encoded', { 548 | GET: function(req, res) { 549 | res.statusCode = 200; 550 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 551 | res.write('Reykjavík'); 552 | res.end(); 553 | }, 554 | }); 555 | 556 | router.route('/base-href/all', { 557 | GET: function(req, res) { 558 | res.statusCode = 200; 559 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 560 | res.write( 561 | '' 562 | ); 563 | res.end(); 564 | }, 565 | }); 566 | 567 | router.on(404, function(req, res) { 568 | res.statusCode = 404; 569 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 570 | res.write( 571 | 'This is not the page you\'re looking for!' 572 | ); 573 | res.end(); 574 | }); 575 | 576 | var server = http.createServer(function(req, res) { 577 | preloader(req, res, function() { 578 | router.middleware(req, res); 579 | }); 580 | }); 581 | 582 | return server; 583 | } 584 | 585 | function createEtagServer(options) { 586 | var preloader = preload(options); 587 | var router = detour(); 588 | 589 | router.route('/etag/setEtag100', { 590 | GET: function(req, res) { 591 | res.statusCode = 200; 592 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 593 | res.setHeader('Etag', '100'); 594 | res.write('Cairo'); 595 | res.end(); 596 | }, 597 | }); 598 | 599 | router.route('/etag/alsoServeEtag100', { 600 | GET: function(req, res) { 601 | res.statusCode = 200; 602 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 603 | res.setHeader('Etag', '100'); 604 | res.write( 605 | 'This is not the page you\'re looking for!' 606 | ); 607 | res.end(); 608 | }, 609 | }); 610 | 611 | router.route('/etag/etag1/:count', { 612 | GET: function(req, res) { 613 | res.statusCode = 200; 614 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 615 | res.setHeader('Etag', '1'); 616 | res.write( 617 | 'This is not the page you\'re looking for!' 620 | ); 621 | res.end(); 622 | }, 623 | }); 624 | 625 | router.route('/etag/etag2/:count', { 626 | GET: function(req, res) { 627 | res.statusCode = 200; 628 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 629 | res.setHeader('Etag', '2'); 630 | res.write( 631 | 'This is not the page you\'re looking for!' 634 | ); 635 | res.end(); 636 | }, 637 | }); 638 | 639 | router.route('/etag/etag3/:count', { 640 | GET: function(req, res) { 641 | res.statusCode = 200; 642 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 643 | res.setHeader('Etag', '3'); 644 | res.write( 645 | 'This is not the page you\'re looking for!' 648 | ); 649 | res.end(); 650 | }, 651 | }); 652 | 653 | var server = http.createServer(function(req, res) { 654 | preloader(req, res, function() { 655 | router.middleware(req, res); 656 | }); 657 | }); 658 | 659 | return server; 660 | } 661 | --------------------------------------------------------------------------------