├── .codeclimate.yml ├── .eslintignore ├── .eslintrc.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── send-seekable.js └── test └── test.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - javascript 8 | eslint: 9 | enabled: true 10 | channel: "eslint-2" 11 | fixme: 12 | enabled: true 13 | ratings: 14 | paths: 15 | - "**.js" 16 | exclude_paths: 17 | - test/ 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | ecmaVersion: 5 3 | 4 | env: 5 | amd: true 6 | browser: true 7 | es6: true 8 | jquery: true 9 | node: true 10 | 11 | # http://eslint.org/docs/rules/ 12 | rules: 13 | # Possible Errors 14 | comma-dangle: [2, never] 15 | no-cond-assign: 2 16 | no-console: 0 17 | no-constant-condition: 2 18 | no-control-regex: 2 19 | no-debugger: 2 20 | no-dupe-args: 2 21 | no-dupe-keys: 2 22 | no-duplicate-case: 2 23 | no-empty: 2 24 | no-empty-character-class: 2 25 | no-ex-assign: 2 26 | no-extra-boolean-cast: 2 27 | no-extra-parens: 0 28 | no-extra-semi: 2 29 | no-func-assign: 2 30 | no-inner-declarations: [2, functions] 31 | no-invalid-regexp: 2 32 | no-irregular-whitespace: 2 33 | no-negated-in-lhs: 2 34 | no-obj-calls: 2 35 | no-regex-spaces: 2 36 | no-sparse-arrays: 2 37 | no-unexpected-multiline: 2 38 | no-unreachable: 2 39 | use-isnan: 2 40 | valid-jsdoc: 0 41 | valid-typeof: 2 42 | 43 | # Best Practices 44 | accessor-pairs: 2 45 | block-scoped-var: 0 46 | complexity: [2, 6] 47 | consistent-return: 0 48 | curly: [1, "multi-line"] 49 | default-case: 0 50 | dot-location: 0 51 | dot-notation: 0 52 | eqeqeq: 2 53 | guard-for-in: 2 54 | no-alert: 2 55 | no-caller: 2 56 | no-case-declarations: 2 57 | no-div-regex: 2 58 | no-else-return: 0 59 | no-empty-pattern: 2 60 | no-eq-null: 2 61 | no-eval: 2 62 | no-extend-native: 2 63 | no-extra-bind: 2 64 | no-fallthrough: 2 65 | no-floating-decimal: 0 66 | no-implicit-coercion: 0 67 | no-implied-eval: 2 68 | no-invalid-this: 0 69 | no-iterator: 2 70 | no-labels: 0 71 | no-lone-blocks: 2 72 | no-loop-func: 2 73 | no-magic-number: 0 74 | no-multi-spaces: 0 75 | no-multi-str: 0 76 | no-native-reassign: 2 77 | no-new-func: 2 78 | no-new-wrappers: 2 79 | no-new: 2 80 | no-octal-escape: 2 81 | no-octal: 2 82 | no-proto: 2 83 | no-redeclare: 2 84 | no-return-assign: 2 85 | no-script-url: 2 86 | no-self-compare: 2 87 | no-sequences: 0 88 | no-throw-literal: 0 89 | no-unused-expressions: 2 90 | no-useless-call: 2 91 | no-useless-concat: 2 92 | no-void: 2 93 | no-warning-comments: 0 94 | no-with: 2 95 | radix: 2 96 | vars-on-top: 0 97 | wrap-iife: 2 98 | yoda: 0 99 | 100 | # Strict 101 | strict: 1 102 | 103 | # Variables 104 | init-declarations: 0 105 | no-catch-shadow: 2 106 | no-delete-var: 2 107 | no-label-var: 2 108 | no-shadow-restricted-names: 2 109 | no-shadow: 0 110 | no-undef-init: 2 111 | no-undef: 0 112 | no-undefined: 0 113 | no-unused-vars: 1 114 | no-use-before-define: [1, nofunc] 115 | 116 | # Node.js and CommonJS 117 | callback-return: 2 118 | global-require: 2 119 | handle-callback-err: 2 120 | no-mixed-requires: 0 121 | no-new-require: 0 122 | no-path-concat: 2 123 | no-process-exit: 1 124 | no-restricted-modules: 0 125 | no-sync: 0 126 | 127 | # Stylistic Issues 128 | array-bracket-spacing: 0 129 | block-spacing: 0 130 | brace-style: 0 131 | camelcase: 0 132 | comma-spacing: 1 133 | comma-style: 0 134 | computed-property-spacing: 0 135 | consistent-this: 0 136 | eol-last: 1 137 | func-names: 0 138 | func-style: 0 139 | id-length: 0 140 | id-match: 0 141 | indent: 0 142 | jsx-quotes: 0 143 | key-spacing: [0, {"align": "value"}] 144 | keyword-spacing: 0 145 | linebreak-style: 0 146 | lines-around-comment: 0 147 | max-depth: 0 148 | max-len: 0 149 | max-nested-callbacks: 0 150 | max-params: 0 151 | max-statements: [2, 30] 152 | new-cap: 0 153 | new-parens: 0 154 | newline-after-var: 0 155 | no-array-constructor: 0 156 | no-bitwise: 0 157 | no-continue: 0 158 | no-inline-comments: 0 159 | no-lonely-if: 0 160 | no-mixed-spaces-and-tabs: [1, "smart-tabs"] 161 | no-multiple-empty-lines: 0 162 | no-negated-condition: 0 163 | no-nested-ternary: 0 164 | no-new-object: 0 165 | no-plusplus: 0 166 | no-restricted-syntax: 0 167 | no-spaced-func: 0 168 | no-ternary: 0 169 | no-trailing-spaces: 1 170 | no-underscore-dangle: 0 171 | no-unneeded-ternary: 0 172 | object-curly-spacing: 0 173 | one-var: 0 174 | operator-assignment: 0 175 | operator-linebreak: 0 176 | padded-blocks: 0 177 | quote-props: 0 178 | quotes: [1, single, avoid-escape] 179 | require-jsdoc: 0 180 | semi-spacing: 0 181 | semi: 1 182 | sort-vars: 0 183 | space-before-blocks: 0 184 | space-before-function-paren: 1 185 | space-in-parens: 0 186 | space-infix-ops: 0 187 | space-unary-ops: 0 188 | spaced-comment: 0 189 | wrap-regex: 0 190 | 191 | # ECMAScript 6 192 | arrow-body-style: 0 193 | arrow-parens: 0 194 | arrow-spacing: 0 195 | constructor-super: 0 196 | generator-star-spacing: 0 197 | no-class-assign: 0 198 | no-confusing-arrow: 0 199 | no-const-assign: 0 200 | no-dupe-class-members: 0 201 | no-this-before-super: 0 202 | no-var: 0 203 | object-shorthand: 0 204 | prefer-arrow-callback: 0 205 | prefer-const: 0 206 | prefer-reflect: 0 207 | prefer-spread: 0 208 | prefer-template: 0 209 | require-yield: 0 210 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Gabriel Lebec 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/send-seekable.svg?maxAge=3600)](https://www.npmjs.com/package/send-seekable) 2 | [![Codeship](https://img.shields.io/codeship/641e1c10-0600-0134-54e5-56f9205ea8b9.svg)](https://codeship.com/projects/154589) 3 | [![Code Climate](https://img.shields.io/codeclimate/github/glebec/send-seekable.svg?maxAge=3600)]() 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/glebec/send-seekable.svg)](https://greenkeeper.io/) 5 | 6 | # Send-Seekable 7 | 8 | ### Express.js/connect middleware for serving partial content (206) byte-range responses from buffers or streams 9 | 10 | Need to support seeking in a (reproducible) buffer or stream? Attach this middleware to your `GET` route and you can now `res.sendSeekable` your resource: 11 | 12 | ```js 13 | const Express = require('express') 14 | const sendSeekable = require('send-seekable'); 15 | 16 | const app = new Express(); 17 | app.use(sendSeekable); 18 | 19 | const exampleBuffer = new Buffer('Weave a circle round him thrice'); 20 | // this route accepts HTTP request with Range header, e.g. `bytes=10-15` 21 | app.get('/', function (req, res, next) { 22 | res.sendSeekable(exampleBuffer); 23 | }) 24 | 25 | app.listen(1337); 26 | ``` 27 | 28 | ## Installation 29 | 30 | ```sh 31 | npm install send-seekable --save 32 | ``` 33 | 34 | ## Features 35 | 36 | ### Supported 37 | 38 | * Node version 0.12.0 or higher 39 | * `GET` and `HEAD` requests (the latter is handled automatically by Express; the server will still produce the necessary buffer or stream as if preparing for a `GET`, but will refrain from actually transmitting the body) 40 | * Sending buffers (as they are) 41 | * Sending streams (requires predetermined metadata content length, in bytes) 42 | * Byte range requests 43 | - From a given byte: `bytes=2391-` 44 | - From a given byte to a later byte: `bytes=3340-7839` 45 | - The last X bytes: `bytes=-4936` 46 | 47 | ### Limitations 48 | 49 | * Does not handle multi-range requests (`bytes=834-983,1056-1181,1367-`) 50 | * Does not cache buffers or streams; you must provide a buffer or stream containing identical content upon each request to a specific route 51 | 52 | ## Context and Use Case 53 | 54 | HTTP clients sometimes request a *portion* of a resource, to cut down on transmission time, payload size, and/or server processing. A typical example is an HTML5 `audio` element with a `src` set to a route on your server. Clicking on the audio progress bar ideally allows the browser to *seek* to that section of the audio file. To do so, the browser may send an HTTP request with a `Range` header specifying which bytes are desired. 55 | 56 | Express.js automatically handles range requests for routes terminating in a `res.sendFile`. This is relatively easy to support as the underlying `fs.createReadStream` can be called with `start` and `end` bytes. However, Express does not natively support range requests for buffers or streams. This makes sense: for buffers, you need to either re-create/fetch the buffer (custom logic) or cache it (bad for memory). For streams it is even harder: streams don't know their total byte size, they can't "rewind" to an earlier portion, and they cannot be cached as simply as buffers. 57 | 58 | Regardless, sometimes you can't — or won't — store a resource on disk. Provided you can re-create the stream or buffer, it would be convenient for Express to slice the content to the client's desired range. This module enables that. 59 | 60 | ## API / Guide 61 | 62 | ### `sendSeekable (req, res, next)` 63 | 64 | ```js 65 | const sendSeekable = require('send-seekable'); 66 | ``` 67 | 68 | A Connect/Express-style middleware function. It simply adds the method `res.sendSeekable`, which you can call as needed. 69 | 70 | Attaching `sendSeekable` as app-wide middleware is an easy way to "set and forget." Your app and routes work exactly as they did before; you must deliberately call `res.sendSeekable` to actually change a route's behavior. 71 | 72 | ```js 73 | // works for all routes in this app / sub-routers 74 | app.use(sendSeekable); 75 | ``` 76 | 77 | ```js 78 | // works for all routes in this router / sub-routers 79 | router.use(sendSeekable); 80 | ``` 81 | 82 | Alternatively, if you only need to support seeking for a small number of routes, you can attach the middleware selectively — adding the `res.sendSeekable` method just where needed. In practice however there is no performance difference. 83 | 84 | ```js 85 | // attached to this specific route 86 | app.get('/', sendSeekable, function (req, res, next){ /* ... */ }); 87 | ``` 88 | 89 | ```js 90 | // also attached to this route 91 | router.get('/', sendSeekable, function (req, res, next) { /* ... */ }); 92 | ``` 93 | 94 | ### `res.sendSeekable(stream|buffer, )` 95 | 96 | Param | Type | Details 97 | ---|---|--- 98 | `stream|buffer` | A Node.js `Stream` instance or `Buffer` instance | the content you want to be able to serve in response to partial content requests 99 | `config` | `Object` | Optional for buffers; required for streams. Has two properties: `.type` is the optional MIME-type of the content (e.g. `audio/mp4`), and `.length` is the total size of the content in bytes (required for streams). More on this below. 100 | 101 | ```js 102 | const exampleBuffer = new Buffer('And close your eyes with holy dread'); 103 | 104 | app.get('/', sendSeekable, function (req, res, next) { 105 | res.sendSeekable(exampleBuffer); 106 | }) 107 | ``` 108 | 109 | With the middleware module mounted, your `res` objects now have a new `sendSeekable` method which you can use to support partial content requests on either a buffer or stream. 110 | 111 | For either case, **it is assumed that the buffer or stream contains identical content on every request**. If your route dynamically produces buffers or streams containing different content, with different total byte lengths, the client's range requests may not line up with the new content. 112 | 113 | #### Sending Buffers 114 | 115 | As an example: if you have binary data stored in a database, and can fetch it as a Node.js Buffer instance, you can support partial content ranges using `res.sendSeekable`. 116 | 117 | ```js 118 | app.use(sendSeekable); 119 | 120 | const exampleBuffer = new Buffer('For he on honey-dew hath fed'); 121 | // minimum use pattern 122 | app.get('/', function (req, res, next) { 123 | res.sendSeekable(exampleBuffer); 124 | }) 125 | ``` 126 | 127 | ```js 128 | // the buffer does not have to be cached, so long as you always produce or retrieve the same contents 129 | function makeSameBufferEveryTime () { 130 | return new Buffer('And drunk the milk of Paradise'); 131 | } 132 | app.get('/', function (req, res, next) { 133 | const newBuffer = makeSameBufferEveryTime(); 134 | res.sendSeekable(newBuffer) 135 | }) 136 | ``` 137 | 138 | The `config` object is not required for sending buffers, but it is recommended in order to set the MIME-type of your response — especially in the case of sending audio or video. 139 | 140 | ```js 141 | // with optional MIME-type configured 142 | app.get('/', function (req, res, next) { 143 | const audiBuffer = fetchAudioBuffer(); 144 | res.sendSeekable(audioBuffer, { type: 'audio/mp4' }); 145 | }) 146 | ``` 147 | 148 | You can also set this using vanilla Express methods, of course. 149 | 150 | ```js 151 | // with optional MIME-type configured 152 | app.get('/', function (req, res, next) { 153 | const audioBuffer = fetchAudioBuffer(); 154 | res.set('Content-Type', 'audio/mp4'); 155 | res.sendSeekable(audioBuffer); 156 | }) 157 | ``` 158 | 159 | #### Sending Streams 160 | 161 | Sending streams is almost as easy with some significant caveats. 162 | 163 | First, you must know the total byte size of your stream contents ahead of time, and specify it as `config.length`. 164 | 165 | ```js 166 | app.get('/', function (req, res, next) { 167 | const audio = instantiateAudioData(); 168 | res.sendSeekable(audio.stream, { 169 | type: audio.type, // e.g. 'audio/mp4' 170 | length: audio.size // e.g. 4287092 171 | }); 172 | }); 173 | ``` 174 | 175 | Second, note that you **CANNOT** simply send the same stream object each time; you must *re-create* a stream representing *identical content*. So, this will not work: 176 | 177 | ```js 178 | const audioStream = radioStream(onlineRadioStationURL); 179 | // DOES NOT WORK IF `audioStream` REPRESENTS CHANGNING CONTENT OVER TIME 180 | app.get('/', function (req, res, next) { 181 | res.sendSeekable(audioStream, { 182 | type: 'audio/mp4', 183 | length: 4287092 184 | }); 185 | }); 186 | ``` 187 | 188 | Whereas, something like this is ok: 189 | 190 | ```js 191 | // Works assuming audio file #123 is always the same 192 | app.get('/', function (req, res, next) { 193 | // a new stream with the same contents, every time there is a request 194 | const audioStream = database.fetchAudioFileById(123); 195 | res.sendSeekable(audioStream, { 196 | type: 'audio/mp4', 197 | length: 4287092 198 | }); 199 | }); 200 | ``` 201 | 202 | ## Mechanics 203 | 204 | It can be helpful to understand precisely how `sendSeekable` works under thw hood. The short explanation is that `res.sendSeekable` determines whether a `GET` request is a standard content request or range request, sets the response headers accordingly, and slices the content to send if neccessary. A typical sequence of events might look like this: 205 | 206 | ### Initial request 207 | 208 | 1. CLIENT: makes plain `GET` request to `/api/audio/123` 209 | 1. SERVER: routes request to that route 210 | 1. `req` and `res` objects pass through the `sendSeekable` middleware 211 | 1. `sendSeekable`: adds `res.sendSeekable` method 212 | 1. ROUTE: fetches audio #123 and associated (pre-recorded) metadata such as file size and MIME-type (you are responsible for this logic) 213 | 1. ROUTE: calls `res.sendSeekable` with the buffer and `config` object 214 | 1. `res.sendSeekable`: places the `Accept-Ranges: bytes` header on `res` 215 | 1. `res.sendSeekable`: adds appropriate `Content-Length` and `Content-Type` headers 216 | 1. `res.sendSeekable`: streams the entire buffer to the client with `200` (ok) status 217 | 1. CLIENT: receives entire file from server 218 | 1. CLIENT: notes the `Accept-Ranges: bytes` header on the response 219 | 220 | ### Subsequent range request 221 | 222 | Next the user attempts to seek in the audio progress bar to a position corresponding to byte 1048250. Note that steps 2–7 are identical to the initial request steps 2–7: 223 | 224 | 1. CLIENT: makes new `GET` request to `/api/audio/123`, with `Range` header set to `bytes=1048250-` (i.e. from byte 1048250 to the end) 225 | 1. SERVER: routes request to that route 226 | 1. `req` and `res` objects pass through the `sendSeekable` middleware 227 | 1. `sendSeekable`: places `res.sendSeekable` method 228 | 1. ROUTE: fetches audio #123 and associated (pre-recorded) metadata such as file size and MIME-type (you are responsible for this logic) 229 | 1. ROUTE: calls `res.sendSeekable` with the buffer and `config` object 230 | 1. `res.sendSeekable`: places the `Accept-Ranges: bytes` header on `res` 231 | 1. `res.sendSeekable`: parses the range header on the request 232 | 1. `res.sendSeekable`: slices the buffer to the requested range 233 | 1. `res.sendSeekable`: sets the `Content-Range` header, as well as `Content-Length` and `Content-Type` 234 | 1. `res.sendSeekable`: streams the byte range to the client with `206` (partial content) status 235 | 1. CLIENT: receives the requested range 236 | 237 | ## Contributing 238 | 239 | Pull requests are welcome. Send-seekable includes a thorough test suite written for the Mocha framework. You may find it easier to develop for Send-seekable by running the test suite in file watch mode via: 240 | 241 | ```sh 242 | npm run develop 243 | ``` 244 | 245 | Please add to the test specs (in `test/test.js`) for any new features / functionality. Pull requests without tests, or with failing tests, will be gently reminded to include tests. 246 | 247 | ## License 248 | 249 | MIT 250 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "send-seekable", 3 | "version": "1.0.4", 4 | "description": "Express.js/connect middleware for serving partial content (206) requests for buffers or streams", 5 | "main": "send-seekable.js", 6 | "engines": { 7 | "node": ">=0.12.0" 8 | }, 9 | "scripts": { 10 | "test": "mocha", 11 | "develop": "mocha --watch --reporter min" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "glebec/send-seekable.git" 16 | }, 17 | "keywords": [ 18 | "partial", 19 | "content", 20 | "206", 21 | "seek", 22 | "seekable", 23 | "stream", 24 | "buffer", 25 | "express", 26 | "middleware", 27 | "range", 28 | "ranges", 29 | "bytes", 30 | "accept-ranges", 31 | "content-range" 32 | ], 33 | "author": "Gabriel Lebec (https://github.com/glebec)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/glebec/express-send-seekable/issues" 37 | }, 38 | "homepage": "https://github.com/glebec/express-send-seekable#readme", 39 | "dependencies": { 40 | "range-parser": "~1.2.0", 41 | "range-stream": "~1.1.0", 42 | "simple-bufferstream": "~1.0.0" 43 | }, 44 | "devDependencies": { 45 | "chai": "^3.5.0", 46 | "express": "^4.14.0", 47 | "mocha": "^3.3.0", 48 | "sinon": "^2.1.0", 49 | "sinon-chai": "^2.9.0", 50 | "supertest": "^2.0.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /send-seekable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var rangeStream = require('range-stream'); 3 | var parseRange = require('range-parser'); 4 | var sbuff = require('simple-bufferstream'); 5 | 6 | module.exports = function (req, res, next) { 7 | // every new request gets a thin wrapper over the generic function 8 | res.sendSeekable = function (stream, config) { 9 | return sendSeekable (stream, config, req, res, next); 10 | }; 11 | next(); 12 | }; 13 | 14 | // the generic handler for serving up partial streams 15 | function sendSeekable (stream, config, req, res, next) { 16 | if (stream instanceof Buffer) { 17 | config = config || {}; 18 | config.length = stream.length; 19 | stream = sbuff(stream); 20 | } 21 | if (!config.length) { 22 | var err = new Error('send-seekable requires `length` option'); 23 | return next(err); 24 | } 25 | // indicate this resource can be partially requested 26 | res.set('Accept-Ranges', 'bytes'); 27 | // incorporate config 28 | if (config.length) res.set('Content-Length', config.length); 29 | if (config.type) res.set('Content-Type', config.type); 30 | // if this is a partial request 31 | if (req.headers.range) { 32 | // parse ranges 33 | var ranges = parseRange(config.length, req.headers.range); 34 | if (ranges === -2) return res.sendStatus(400); // malformed range 35 | if (ranges === -1) { 36 | // unsatisfiable range 37 | res.set('Content-Range', '*/' + config.length); 38 | return res.sendStatus(416); 39 | } 40 | if (ranges.type !== 'bytes') return stream.pipe(res); 41 | if (ranges.length > 1) { 42 | return next(new Error('send-seekable can only serve single ranges')); 43 | } 44 | var start = ranges[0].start; 45 | var end = ranges[0].end; 46 | // formatting response 47 | res.status(206); 48 | res.set('Content-Length', (end - start) + 1); // end is inclusive 49 | res.set('Content-Range', 'bytes ' + start + '-' + end + '/' + config.length); 50 | // slicing the stream to partial content 51 | stream = stream.pipe(rangeStream(start, end)); 52 | } 53 | return stream.pipe(res); 54 | } 55 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | var Express = require('express'); 6 | var test = require('supertest'); 7 | var chai = require('chai'); 8 | var expect = chai.expect; 9 | var sinon = require('sinon'); 10 | var sinonChai = require('sinon-chai'); 11 | var parseRange = require('range-parser'); 12 | chai.use(sinonChai); 13 | 14 | var sendSeekable = require('../send-seekable');; 15 | 16 | describe('The `express-send-seekable` module', function () { 17 | 18 | it('is a middleware function', function () { 19 | expect(sendSeekable).to.be.an.instanceof(Function); 20 | expect(sendSeekable).to.have.length(3); 21 | }); 22 | 23 | it('places a `sendSeekable` method on `res`', function () { 24 | var res = {}; 25 | sendSeekable({}, res, function next () {}); 26 | expect(res.sendSeekable).to.be.an.instanceof(Function); 27 | }); 28 | 29 | it('calls `next`', function () { 30 | var next = sinon.spy(); 31 | sendSeekable({}, {}, next); 32 | expect(next).to.have.been.calledOnce; // Sinon getter prop, not a function 33 | expect(next).to.have.been.calledWith(); // distinct from `undefined` 34 | }); 35 | 36 | }); 37 | 38 | describe('`res.sendSeekable`', function () { 39 | 40 | var appTester, content, config; 41 | beforeEach(function () { 42 | var app = new Express(); 43 | app.get('/', sendSeekable, function (req, res) { 44 | res.sendSeekable(content, config); 45 | }); 46 | app.use(function (err, req, res, next) { 47 | res.sendStatus(500); 48 | }); 49 | appTester = test(app); 50 | }); 51 | 52 | afterEach(function () { 53 | content = undefined; 54 | config = undefined; 55 | }); 56 | 57 | function testInvariantBehavior () { 58 | it('sets the `Accept-Ranges` header to `bytes`', function (done) { 59 | appTester.expect('Accept-Ranges', 'bytes', done); 60 | }); 61 | 62 | it('sets the `Date` header to a nonempty string', function (done) { 63 | appTester.expect('Date', /.+/, done); 64 | }); 65 | } 66 | 67 | describe('when passed a buffer:', function () { 68 | 69 | function TestBuffer () { 70 | return new Buffer('Where Alph, the sacred river, ran'); 71 | } 72 | 73 | testSupportedContent(TestBuffer); 74 | 75 | }); 76 | 77 | describe('when passed a stream:', function () { 78 | 79 | var testFilePath = path.join(__dirname, 'test.js'); 80 | var len = fs.statSync(testFilePath).size; 81 | var contents = fs.readFileSync(testFilePath, 'utf8'); 82 | 83 | function TestStream () { 84 | var stream = fs.createReadStream(path.join(__dirname, 'test.js'), { 85 | encoding: 'utf8' 86 | }); 87 | stream.length = len; 88 | stream.toString = function () { return contents; }; 89 | return stream; 90 | } 91 | 92 | testSupportedContent(TestStream, { length: len }); 93 | 94 | }); 95 | 96 | function testSupportedContent (Content, testConfig) { 97 | 98 | content = new Content(); 99 | if (content.length < 20) throw Error('test fixture needs content > 20'); 100 | var middle = +Math.floor(content.length / 2); 101 | var later = +Math.floor(content.length / 2) + 5; 102 | var end = +content.length - 1; 103 | var beyond = +content.length + 50; 104 | 105 | beforeEach(function () { 106 | content = new Content(); 107 | config = testConfig; 108 | }); 109 | 110 | describe('on HEAD request', function () { 111 | 112 | beforeEach(function () { 113 | appTester = appTester.head('/'); 114 | }); 115 | 116 | it('sets a 200 status', function (done) { 117 | appTester.expect(200, done); 118 | }); 119 | 120 | it('sends no body', function (done) { 121 | appTester.expect(undefined, done); 122 | }); 123 | 124 | it('sets the `Content-Length` header to the content byte length', function (done) { 125 | appTester.expect('Content-Length', content.length.toString(), done); 126 | }); 127 | 128 | testInvariantBehavior(); 129 | 130 | }); 131 | 132 | describe('on GET request', function () { 133 | 134 | beforeEach(function () { 135 | appTester = appTester.get('/'); 136 | }); 137 | 138 | describe('for a resource', function () { 139 | 140 | it('sets a 200 status', function (done) { 141 | appTester.expect(200, done); 142 | }); 143 | 144 | it('sends the entire content', function (done) { 145 | appTester.expect(content.toString(), done); 146 | }); 147 | 148 | it('sets the `Content-Length` header to the content byte length', function (done) { 149 | appTester.expect('Content-Length', content.length.toString(), done); 150 | }); 151 | 152 | it('sets the `Content-Type` header if configured', function (done) { 153 | var type = 'random string ' + (Math.random() * 999); 154 | if (!config) config = {}; 155 | config.type = type; 156 | appTester.expect('Content-Type', type, done); 157 | }); 158 | 159 | it('does not set the `Content-Range` header', function (done) { 160 | appTester.expect(function (res) { 161 | expect(res.headers['content-range']).to.not.exist; // Chai getter 162 | }).end(done); 163 | }); 164 | 165 | testInvariantBehavior(); 166 | 167 | }); 168 | 169 | describe('for valid byte range', function () { 170 | 171 | function testRange (firstByte, lastByte) { 172 | 173 | var trueFirst, trueLast; 174 | beforeEach(function () { 175 | if (typeof firstByte !== 'number') firstByte = ''; 176 | if (typeof lastByte !== 'number') lastByte = ''; 177 | // set requested content range 178 | var rangeString = 'bytes=' + firstByte + '-' + lastByte; 179 | appTester = appTester.set('Range', rangeString); 180 | // determine actual range 181 | var range = parseRange(content.length, rangeString); 182 | trueFirst = range[0].start; 183 | trueLast = range[0].end; 184 | }); 185 | 186 | it('sets a 206 status', function (done) { 187 | appTester.expect(206, done); 188 | }); 189 | 190 | it('sends the requested range', function (done) { 191 | var range = content.toString().slice(trueFirst, trueLast + 1); 192 | appTester.expect(range, done); 193 | }); 194 | 195 | it('sets the `Content-Length` header to the number of bytes returned', function (done) { 196 | appTester.expect(function (res) { 197 | expect(res.headers['content-length']).to.equal(String(res.text.length)); 198 | }).end(done); 199 | }); 200 | 201 | it('sets the `Content-Range` header to the range returned', function (done) { 202 | var len = content.length; 203 | var rangeString = 'bytes ' + trueFirst + '-' + trueLast + '/' + len; 204 | appTester.expect('Content-Range', rangeString, done); 205 | }); 206 | 207 | it('sets the `Content-Type` header if configured', function (done) { 208 | var type = 'random string ' + (Math.random() * 999); 209 | if (!config) config = {}; 210 | config.type = type; 211 | appTester.expect('Content-Type', type, done); 212 | }); 213 | 214 | testInvariantBehavior(); 215 | 216 | } 217 | 218 | describe('[0, unspecified]', function () { 219 | testRange(0); 220 | }); 221 | 222 | describe('[0, 0]', function () { 223 | testRange(0, 0); 224 | }); 225 | 226 | describe('[0, a middle point]', function () { 227 | testRange(0, middle); 228 | }); 229 | 230 | describe('[0, the end]', function () { 231 | testRange(0, end); 232 | }); 233 | 234 | describe('[0, beyond the end]', function () { 235 | testRange(0, beyond); 236 | }); 237 | 238 | describe('[a middle point, unspecified]', function () { 239 | testRange(middle); 240 | }); 241 | 242 | describe('[a middle point, the same point]', function () { 243 | testRange(middle, middle); 244 | }); 245 | 246 | describe('[a middle point, a later point]', function () { 247 | testRange(middle, later); 248 | }); 249 | 250 | describe('[a middle point, the end]', function () { 251 | testRange(middle, end); 252 | }); 253 | 254 | describe('[a middle point, beyond the end]', function () { 255 | testRange(middle, beyond); 256 | }); 257 | 258 | describe('[the end, unspecified]', function () { 259 | testRange(end); 260 | }); 261 | 262 | describe('[the end, the end]', function () { 263 | testRange(end, end); 264 | }); 265 | 266 | describe('[the end, beyond the end]', function () { 267 | testRange(end, beyond); 268 | }); 269 | 270 | describe('[the last byte]', function () { 271 | testRange(null, 1); 272 | }); 273 | 274 | describe('[the last byte to the middle]', function () { 275 | testRange(null, middle); 276 | }); 277 | 278 | describe('[the last byte to the beginning]', function () { 279 | testRange(null, end + 1); 280 | }); 281 | 282 | }); 283 | 284 | describe('for invalid byte range', function () { 285 | 286 | describe('', function () { 287 | 288 | it('sets a 400 status', function (done) { 289 | appTester.set('Range', 'hello') 290 | .expect(400, done); 291 | }); 292 | 293 | it('does not set the `Content-Range` header', function (done) { 294 | appTester.expect(function (res) { 295 | expect(res.headers['content-range']).to.not.exist; // Chai getter 296 | }).end(done); 297 | }); 298 | 299 | }); 300 | 301 | function testUnsatisfiableRange (range) { 302 | 303 | beforeEach(function () { 304 | appTester = appTester.set('Range', 'bytes=' + range); 305 | }); 306 | 307 | it('sets a 416 status', function (done) { 308 | appTester.expect(416, done); 309 | }); 310 | 311 | it('sets the `Content-Range` header to `*/total`', function (done) { 312 | appTester.expect('Content-Range', '*/' + content.length, done); 313 | }); 314 | 315 | } 316 | 317 | describe('', function () { 318 | testUnsatisfiableRange(later + '-' + middle); 319 | }); 320 | 321 | describe('', function () { 322 | testUnsatisfiableRange(beyond + '-'); 323 | }); 324 | 325 | describe('', function () { 326 | testUnsatisfiableRange('-' + beyond); 327 | }); 328 | 329 | describe('', function () { 330 | testUnsatisfiableRange('-'); 331 | }); 332 | 333 | }); 334 | 335 | describe('for unsupported byte range', function () { 336 | 337 | it(' throws an error', function (done) { 338 | appTester.set('Range', 'bytes=0-4,10-14').expect(500, done); 339 | }); 340 | 341 | it('does not set the `Content-Range` header', function (done) { 342 | appTester.expect(function (res) { 343 | expect(res.headers['content-range']).to.not.exist; // Chai getter 344 | }).end(done); 345 | }); 346 | 347 | }); 348 | 349 | }); 350 | 351 | } 352 | 353 | }); 354 | --------------------------------------------------------------------------------