├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test ├── file-server.js ├── index.html └── spdy.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store* 2 | *.log 3 | *.gz 4 | 5 | node_modules 6 | coverage 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | node_js: 2 | - "1" 3 | - "2" 4 | sudo: false 5 | language: node_js 6 | script: "npm run-script test-travis" 7 | after_script: "npm install coveralls@2 && cat ./coverage/lcov.info | coveralls" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 Jonathan Ong me@jongleberry.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Koa File Server 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | [![Gittip][gittip-image]][gittip-url] 8 | 9 | An opinionated file server. Designed to sit behind a CDN. 10 | 11 | - `sha256` etags and consequential 304s 12 | - Caches `fs.stat()` calls 13 | - Caches etag calculations 14 | - OPTIONS and 405 support 15 | - `index.html` files 16 | - Optionally serve hidden files 17 | - Caches gzipped versions of files 18 | - SPDY Push support 19 | 20 | Does not support: 21 | 22 | - Dynamic files - assumes static files never change. 23 | You will have to delete files from the cache yourself if files change. 24 | - Directory listing 25 | - Path decoding 26 | 27 | ## API 28 | 29 | ```js 30 | var app = require('koa')() 31 | app.use(require('compress')()) 32 | app.use(require('koa-file-server')(options)) 33 | ``` 34 | 35 | Options are: 36 | 37 | - `root` - root directory. nothing above this root directory can be served 38 | - `maxage` - cache control max age 39 | - `etag` - options for etags 40 | - `algorithm` - hashing algorithm to use 41 | - `encoding` - encoding to use 42 | - `index` - serve `index.html` files 43 | - `hidden` - show hidden files which leading `.`s 44 | 45 | ### var file = yield* send(this, [path]) 46 | 47 | ```js 48 | var send = require('koa-file-server')(options).send 49 | ``` 50 | 51 | `serve.send()` allows you to serve files as a utility. 52 | This is helpful for arbitrary paths. 53 | The middleware also adds `var file = yield* this.fileServer.send(path)`. 54 | 55 | `path` defaults to `this.request.path.slice(1)`, 56 | removing the leading `/` to make the path relative. 57 | 58 | For an example, see the middleware's source code. 59 | 60 | ### var file = yield* push(this, path, [options]) 61 | 62 | ```js 63 | var push = require('koa-file-server')(options).push 64 | ``` 65 | 66 | Optionally SPDY Push a file. 67 | The middleware also adds `var file = yield* this.fileServer.send(path, [opts])`. 68 | 69 | Unlike `send()`, `path` is required. 70 | `path` must also be a relative path (without a leading `/`) relative to the `root`. 71 | The push stream's URL will be `'/' + path`. 72 | Errors will be thrown on unknown files. 73 | The only `option` is `priority: 7`. 74 | 75 | [npm-image]: https://img.shields.io/npm/v/koa-file-server.svg?style=flat 76 | [npm-url]: https://npmjs.org/package/koa-file-server 77 | [travis-image]: https://img.shields.io/travis/koajs/file-server.svg?style=flat 78 | [travis-url]: https://travis-ci.org/koajs/file-server 79 | [coveralls-image]: https://img.shields.io/coveralls/koajs/file-server.svg?style=flat 80 | [coveralls-url]: https://coveralls.io/r/koajs/file-server?branch=master 81 | [gittip-image]: https://img.shields.io/gittip/jonathanong.svg?style=flat 82 | [gittip-url]: https://www.gittip.com/jonathanong/ 83 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | var compressible = require('compressible') 3 | var resolve = require('resolve-path') 4 | var hash = require('hash-stream') 5 | var mime = require('mime-types') 6 | var spdy = require('spdy-push') 7 | var assert = require('assert') 8 | var zlib = require('mz/zlib') 9 | var Path = require('path') 10 | var fs = require('mz/fs') 11 | 12 | var extname = Path.extname 13 | var basename = Path.basename 14 | 15 | var methods = 'HEAD,GET,OPTIONS' 16 | var notfound = { 17 | ENOENT: true, 18 | ENAMETOOLONG: true, 19 | ENOTDIR: true, 20 | } 21 | 22 | module.exports = function (root, options) { 23 | if (typeof root === 'object') { 24 | options = root 25 | root = null 26 | } 27 | 28 | options = options || {} 29 | root = root || options.root || process.cwd() 30 | 31 | var cache = Object.create(null) 32 | var maxage = options.maxage 33 | var cachecontrol = maxage != null 34 | ? ('public, max-age=' + (maxage / 1000 | 0)) 35 | : '' 36 | var etagoptions = options.etag || {} 37 | var algorithm = etagoptions.algorithm || 'sha256' 38 | var encoding = etagoptions.encoding || 'base64' 39 | var index = options.index 40 | var hidden = options.hidden 41 | 42 | // this.fileServer.send(), etc. 43 | function FileServer(context) { 44 | this.context = context 45 | } 46 | 47 | FileServer.prototype.send = function* (path) { 48 | return yield* send(this.context, path) 49 | } 50 | 51 | FileServer.prototype.push = function* (path, opts) { 52 | return yield* push(this.context, path, opts) 53 | } 54 | 55 | serve.send = send 56 | serve.push = push 57 | serve.cache = cache 58 | return serve 59 | 60 | // middleware 61 | function* serve(next) { 62 | this.fileServer = new FileServer(this) 63 | 64 | yield* next 65 | 66 | // response is handled 67 | if (this.response.body) return 68 | if (this.response.status !== 404) return 69 | 70 | yield* send(this) 71 | } 72 | 73 | // utility 74 | function* send(ctx, path) { 75 | var req = ctx.request 76 | var res = ctx.response 77 | 78 | path = path || req.path.slice(1) || '' 79 | 80 | // index file support 81 | var directory = path === '' || path.slice(-1) === '/' 82 | if (index && directory) path += 'index.html' 83 | 84 | // regular paths can not be absolute 85 | path = resolve(root, path) 86 | 87 | // hidden file support 88 | if (!hidden && leadingDot(path)) return 89 | 90 | var file = yield* get(path) 91 | if (!file) return // 404 92 | 93 | // proper method handling 94 | var method = req.method 95 | switch (method) { 96 | case 'HEAD': 97 | case 'GET': 98 | break // continue 99 | case 'OPTIONS': 100 | res.set('Allow', methods) 101 | res.status = 204 102 | return file 103 | default: 104 | res.set('Allow', methods) 105 | res.status = 405 106 | return file 107 | } 108 | 109 | res.status = 200 110 | res.etag = file.etag 111 | res.lastModified = file.stats.mtime 112 | res.type = file.type 113 | if (cachecontrol) res.set('Cache-Control', cachecontrol) 114 | 115 | if (req.fresh) { 116 | res.status = 304 117 | return file 118 | } 119 | 120 | if (method === 'HEAD') return file 121 | 122 | if (file.compress && req.acceptsEncodings('gzip', 'identity') === 'gzip') { 123 | res.set('Content-Encoding', 'gzip') 124 | res.length = file.compress.stats.size 125 | res.body = fs.createReadStream(file.compress.path) 126 | } else { 127 | res.set('Content-Encoding', 'identity') 128 | res.length = file.stats.size 129 | res.body = fs.createReadStream(path) 130 | } 131 | 132 | return file 133 | } 134 | 135 | function* push(ctx, path, opts) { 136 | assert(path, 'you must define a path!') 137 | if (!ctx.res.isSpdy) return 138 | 139 | opts = opts || {} 140 | 141 | assert(path[0] !== '/', 'you can only push relative paths') 142 | var uri = path // original path 143 | 144 | // index file support 145 | var directory = path === '' || path.slice(-1) === '/' 146 | if (index && directory) path += 'index.html' 147 | 148 | // regular paths can not be absolute 149 | path = resolve(root, path) 150 | 151 | var file = yield* get(path) 152 | assert(file, 'can not push file: ' + uri) 153 | 154 | var options = { 155 | path: '/' + uri, 156 | priority: opts.priority, 157 | } 158 | 159 | var headers = options.headers = { 160 | 'content-type': file.type, 161 | etag: file.etag, 162 | 'last-modified': file.stats.mtime.toUTCString(), 163 | } 164 | 165 | if (cachecontrol) headers['cache-control'] = cachecontrol 166 | 167 | if (file.compress) { 168 | headers['content-encoding'] = 'gzip' 169 | headers['content-length'] = file.compress.stats.size 170 | options.filename = file.compress.path 171 | } else { 172 | headers['content-encoding'] = 'identity' 173 | headers['content-length'] = file.stats.size 174 | options.filename = path 175 | } 176 | 177 | spdy(ctx.res) 178 | .push(options) 179 | .send() 180 | .catch(ctx.onerror) 181 | 182 | return file 183 | } 184 | 185 | // get the file from cache if possible 186 | function* get(path) { 187 | var val = cache[path] 188 | if (val && val.compress && (yield fs.exists(val.compress.path))) return val 189 | 190 | var stats = yield fs.stat(path).catch(ignoreStatError) 191 | // we don't want to cache 404s because 192 | // the cache object will get infinitely large 193 | if (!stats || !stats.isFile()) return 194 | stats.path = path 195 | 196 | var file = cache[path] = { 197 | stats: stats, 198 | etag: '"' + (yield hash(path, algorithm)).toString(encoding) + '"', 199 | type: mime.contentType(extname(path)) || 'application/octet-stream', 200 | } 201 | 202 | if (!compressible(file.type)) return file 203 | 204 | // if we can compress this file, we create a .gz 205 | var compress = file.compress = { 206 | path: path + '.gz' 207 | } 208 | 209 | // delete old .gz files in case the file has been updated 210 | try { 211 | yield fs.unlink(compress.path) 212 | } catch (err) {} 213 | 214 | // save to a random file name first 215 | var tmp = path + '.' + random() + '.gz' 216 | yield function (done) { 217 | fs.createReadStream(path) 218 | .on('error', done) 219 | .pipe(zlib.createGzip()) 220 | .on('error', done) 221 | .pipe(fs.createWriteStream(tmp)) 222 | .on('error', done) 223 | .on('finish', done) 224 | } 225 | 226 | compress.stats = yield fs.stat(tmp).catch(ignoreStatError) 227 | 228 | // if the gzip size is larger than the original file, 229 | // don't bother gzipping 230 | if (compress.stats.size > stats.size) { 231 | delete file.compress 232 | yield fs.unlink(tmp) 233 | } else { 234 | // otherwise, rename to the correct path 235 | yield fs.rename(tmp, compress.path) 236 | } 237 | 238 | return file 239 | } 240 | } 241 | 242 | function ignoreStatError(err) { 243 | if (notfound[err.code]) return 244 | err.status = 500 245 | throw err 246 | } 247 | 248 | function leadingDot(path) { 249 | return '.' === basename(path)[0] 250 | } 251 | 252 | function random() { 253 | return Math.random().toString(36).slice(2) 254 | } 255 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-file-server", 3 | "description": "file serving middleware for koa", 4 | "version": "2.3.1", 5 | "author": { 6 | "name": "Jonathan Ong", 7 | "email": "me@jongleberry.com", 8 | "url": "http://jongleberry.com", 9 | "twitter": "https://twitter.com/jongleberry" 10 | }, 11 | "repository": "koajs/file-server", 12 | "dependencies": { 13 | "compressible": "2", 14 | "debug": "*", 15 | "hash-stream": "^1.0.0", 16 | "mime-types": "2", 17 | "mz": "^2.0.0", 18 | "resolve-path": "^1.0.0", 19 | "spdy-push": "^1.0.0" 20 | }, 21 | "devDependencies": { 22 | "bluebird": "2", 23 | "co": "3", 24 | "istanbul-harmony": "0", 25 | "koa": "*", 26 | "mocha": "^2.2.5", 27 | "should": "3", 28 | "spdy": "*", 29 | "spdy-keys": "*", 30 | "supertest": "^1.0.1" 31 | }, 32 | "scripts": { 33 | "test": "NODE_ENV=test mocha --require should --reporter spec --bail", 34 | "test-cov": "node node_modules/.bin/istanbul cover node_modules/mocha/bin/_mocha -- --reporter dot --require should", 35 | "test-travis": "node node_modules/.bin/istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --reporter dot --require should" 36 | }, 37 | "license": "MIT", 38 | "files": [ 39 | "index.js" 40 | ], 41 | "keywords": [ 42 | "koa", 43 | "file", 44 | "server", 45 | "static" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /test/file-server.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs') 3 | var koa = require('koa') 4 | var path = require('path') 5 | var assert = require('assert') 6 | var request = require('supertest') 7 | 8 | var staticServer = require('..') 9 | 10 | var app = koa() 11 | app.use(staticServer()) 12 | var server = app.listen() 13 | 14 | describe('root', function () { 15 | it('should root priority than options.root', function (done) { 16 | var app = koa() 17 | app.use(staticServer(__dirname, { 18 | root: path.dirname(__dirname) 19 | })) 20 | var server = app.listen(); 21 | request(server) 22 | .get('/file-server.js') 23 | .expect('content-type', /application\/javascript/) 24 | .expect(200, done) 25 | }) 26 | 27 | it('should options.root work', function (done) { 28 | var app = koa() 29 | app.use(staticServer({ 30 | root: __dirname 31 | })) 32 | var server = app.listen(); 33 | request(server) 34 | .get('/file-server.js') 35 | .expect('content-type', /application\/javascript/) 36 | .expect(200, done) 37 | }) 38 | }) 39 | 40 | describe('headers', function () { 41 | var etag 42 | 43 | it('should set content-* and last-modified headers', function (done) { 44 | request(server) 45 | .get('/test/file-server.js') 46 | .expect('content-type', /application\/javascript/) 47 | .expect(200, function (err, res) { 48 | if (err) return done(err) 49 | 50 | assert.ok(res.headers['content-length']) 51 | assert.ok(res.headers['last-modified']) 52 | done() 53 | }) 54 | }) 55 | 56 | it('should set an etag', function (done) { 57 | request(server) 58 | .get('/test/file-server.js') 59 | .expect(200, function (err, res) { 60 | if (err) return done(err) 61 | 62 | assert.ok(etag = res.headers['etag']) 63 | done() 64 | }) 65 | }) 66 | 67 | it('if-none-match should serve 304', function (done) { 68 | request(server) 69 | .get('/test/file-server.js') 70 | .set('if-none-match', etag) 71 | .expect(304, done) 72 | }) 73 | 74 | it('should set Allow w/ OPTIONS', function (done) { 75 | request(server) 76 | .options('/test/file-server.js') 77 | .expect('allow', /HEAD/) 78 | .expect('allow', /GET/) 79 | .expect('allow', /OPTIONS/) 80 | .expect(204, done) 81 | }) 82 | 83 | it('should set Allow w/ 405', function (done) { 84 | request(server) 85 | .post('/test/file-server.js') 86 | .expect('allow', /HEAD/) 87 | .expect('allow', /GET/) 88 | .expect('allow', /OPTIONS/) 89 | .expect(405, done) 90 | }) 91 | 92 | it('should not set cache-control by default', function (done) { 93 | request(server) 94 | .get('/test/file-server.js') 95 | .expect(200, function (err, res) { 96 | if (err) return done(err) 97 | 98 | assert.ok(!res.headers['cache-control']) 99 | done() 100 | }) 101 | }) 102 | 103 | it('should set cache-control with maxage', function (done) { 104 | var app = koa() 105 | app.use(staticServer({ 106 | maxage: 1000 107 | })) 108 | var server = app.listen() 109 | 110 | request(server) 111 | .get('/test/file-server.js') 112 | .expect('cache-control', 'public, max-age=1') 113 | .expect(200, done) 114 | }) 115 | }) 116 | 117 | describe('non-files', function (done) { 118 | it('should not be served when a directory', function (done) { 119 | request(server) 120 | .get('/test') 121 | .expect(404, done) 122 | }) 123 | }) 124 | 125 | describe('index files', function (done) { 126 | it('should not be served by default', function (done) { 127 | request(server) 128 | .get('/test/') 129 | .expect(404, done) 130 | }) 131 | 132 | it('should be served when enabled', function (done) { 133 | var app = koa() 134 | app.use(staticServer({ 135 | index: true 136 | })) 137 | var server = app.listen() 138 | 139 | request(server) 140 | .get('/test/') 141 | .expect('content-type', 'text/html; charset=utf-8') 142 | .expect(200, done) 143 | }) 144 | }) 145 | 146 | describe('hidden files', function () { 147 | it('should not be served by default', function (done) { 148 | request(server) 149 | .get('/.gitignore') 150 | .expect(404, done) 151 | }) 152 | 153 | it('should be served when enabled', function (done) { 154 | var app = koa() 155 | app.use(staticServer({ 156 | hidden: true 157 | })) 158 | var server = app.listen() 159 | 160 | request(server) 161 | .get('/.gitignore') 162 | .expect(200, done) 163 | }) 164 | }) 165 | 166 | describe('malicious paths', function () { 167 | it('..', function (done) { 168 | request(server) 169 | .get('/../klajsdfkljasdf') 170 | .expect(403, done) 171 | }) 172 | 173 | it('//', function (done) { 174 | request(server) 175 | .get('//asdfasdffs') 176 | .expect(400, done) 177 | }) 178 | 179 | it('/./', function (done) { 180 | request(server) 181 | .get('/./index.js') 182 | .expect(200, done) 183 | }) 184 | }) 185 | 186 | describe('compression', function () { 187 | it('should compress large files', function (done) { 188 | request(server) 189 | .get('/index.js') 190 | .expect('Content-Encoding', 'gzip') 191 | .expect('Content-Type', /application\/javascript/) 192 | .expect(200, done) 193 | }) 194 | 195 | it('should not compress small files', function (done) { 196 | request(server) 197 | .get('/test/index.html') 198 | .expect('Content-Encoding', 'identity') 199 | .expect('Content-Type', /text\/html/) 200 | .expect(200, done) 201 | }) 202 | 203 | it('should not compress uncompressible files', function (done) { 204 | request(server) 205 | .get('/LICENSE') 206 | .expect('Content-Encoding', 'identity') 207 | .expect(200, done) 208 | }) 209 | }) 210 | 211 | describe('.fileServer.send()', function () { 212 | it('should send a file', function (done) { 213 | app.use(function* (next) { 214 | if (this.request.path !== '/asdfasdf.js') return yield* next 215 | 216 | yield* this.fileServer.send('test/file-server.js') 217 | }) 218 | 219 | request(app.listen()) 220 | .get('/asdfasdf.js') 221 | .expect('content-type', /application\/javascript/) 222 | .expect(200, done) 223 | }) 224 | }) 225 | 226 | describe('404s', function () { 227 | it('should return 404', function (done) { 228 | app.use(function* (next) { 229 | if (this.request.path !== '/404') return yield* next 230 | 231 | yield* this.fileServer.send('lkjasldfjasdf.js') 232 | }) 233 | 234 | request(app.listen()) 235 | .get('/404') 236 | .expect(404, done) 237 | }) 238 | }) 239 | 240 | describe('if .gz file is no longer existed', function () { 241 | it('should create it again', function (done) { 242 | var app = koa() 243 | app.use(staticServer(__dirname, { 244 | root: path.dirname(__dirname) 245 | })) 246 | request(app.callback()) 247 | .get('/spdy.js') 248 | .expect('Content-Encoding', 'gzip') 249 | .expect('content-type', /application\/javascript/) 250 | .expect(200, function() { 251 | fs.unlink(path.join(__dirname, 'spdy.js.gz'), function() { 252 | request(app.callback()) 253 | .get('/spdy.js') 254 | .expect('Content-Encoding', 'gzip') 255 | .expect('content-type', /application\/javascript/) 256 | .expect(200, done) 257 | }) 258 | }) 259 | }) 260 | }) 261 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/spdy.js: -------------------------------------------------------------------------------- 1 | 2 | var co = require('co') 3 | var koa = require('koa') 4 | var path = require('path') 5 | var assert = require('assert') 6 | var https = require('https') 7 | var spdy = require('spdy') 8 | 9 | var staticServer = require('..') 10 | 11 | describe('spdy push', function () { 12 | it('should push a file', co(function* () { 13 | var stream = yield* request('index.js') 14 | stream.url.should.equal('/index.js') 15 | stream.headers['content-type'].should.match(/application\/javascript/) 16 | stream.headers['content-encoding'].should.equal('gzip') 17 | stream.headers['content-length'].should.be.ok 18 | stream.headers['etag'].should.be.ok 19 | stream.headers['last-modified'].should.be.ok 20 | })) 21 | 22 | it('should not gzip small files', co(function* () { 23 | var stream = yield* request('test/index.html') 24 | stream.url.should.equal('/test/index.html') 25 | stream.headers['content-type'].should.match(/text\/html/) 26 | stream.headers['content-encoding'].should.equal('identity') 27 | stream.headers['content-length'].should.be.ok 28 | stream.headers['etag'].should.be.ok 29 | stream.headers['last-modified'].should.be.ok 30 | })) 31 | 32 | it('should throw on / files', co(function* () { 33 | var res = yield* request('/index.js') 34 | res.statusCode.should.equal(500) 35 | })) 36 | 37 | it('should throw on unknown files', co(function* () { 38 | var res = yield* request('asdfasdf') 39 | res.statusCode.should.equal(500) 40 | })) 41 | }) 42 | 43 | function* request(path) { 44 | var app = koa() 45 | app.use(staticServer()) 46 | app.use(function* () { 47 | this.response.status = 204 48 | yield* this.fileServer.push(path) 49 | }) 50 | 51 | var server = spdy.createServer(require('spdy-keys'), app.callback()) 52 | yield function (done) { 53 | server.listen(done) 54 | } 55 | 56 | var res 57 | var agent = spdy.createAgent({ 58 | host: '127.0.0.1', 59 | port: server.address().port, 60 | rejectUnauthorized: false, 61 | }) 62 | // note: agent may throw errors! 63 | 64 | // we need to add a listener to the `push` event 65 | // otherwise the agent will just destroy all the push streams 66 | var streams = [] 67 | agent.on('push', function (stream) { 68 | if (res) res.emit('push', stream) 69 | streams.push(stream) 70 | }) 71 | 72 | var req = https.request({ 73 | host: '127.0.0.1', 74 | agent: agent, 75 | method: 'GET', 76 | path: '/', 77 | }) 78 | 79 | res = yield function (done) { 80 | req.once('response', done.bind(null, null)) 81 | req.once('error', done) 82 | req.end() 83 | } 84 | 85 | res.streams = streams 86 | res.agent = agent 87 | 88 | if (res.statusCode === 204) { 89 | if (!res.streams.length) { 90 | yield function (done) { 91 | res.once('push', done.bind(null, null)) 92 | } 93 | } 94 | 95 | return res.streams[0] 96 | } 97 | 98 | return res 99 | } 100 | --------------------------------------------------------------------------------