├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── HISTORY.md ├── LICENSE ├── README.md ├── benchmarks ├── collapseLeadingSlashes.js ├── containsDotFile.js ├── isUtf8MimeType.js ├── normalizeList.js └── parseBytesRange.js ├── eslint.config.js ├── examples ├── index.html └── simple.js ├── index.js ├── lib ├── collapseLeadingSlashes.js ├── containsDotFile.js ├── contentRange.js ├── createHtmlDocument.js ├── createHttpError.js ├── isUtf8MimeType.js ├── normalizeList.js ├── parseBytesRange.js ├── parseTokenList.js └── send.js ├── package.json ├── test ├── collapseLeadingSlashes.test.js ├── containsDotFile.test.js ├── fixtures │ ├── .hidden.txt │ ├── .mine │ │ ├── .hidden.txt │ │ └── name.txt │ ├── do..ts.txt │ ├── empty.txt │ ├── images │ │ └── node-js.png │ ├── name.d │ │ └── name.txt │ ├── name.dir │ │ └── name.txt │ ├── name.html │ ├── name.txt │ ├── no_ext │ ├── nums.txt │ ├── pets │ │ ├── .hidden.txt │ │ └── index.html │ ├── snow ☃ │ │ └── index.html │ ├── some thing.txt │ ├── thing.html.html │ └── tobi.html ├── isUtf8MimeType.test.js ├── mime.test.js ├── normalizeList.test.js ├── parseBytesRange.test.js ├── send.1.test.js ├── send.2.test.js ├── send.3.test.js └── utils.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | license-check: true 28 | lint: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 0.18.0 / 2022-03-23 2 | =================== 3 | 4 | * Fix emitted 416 error missing headers property 5 | * Limit the headers removed for 304 response 6 | * deps: depd@2.0.0 7 | - Replace internal `eval` usage with `Function` constructor 8 | - Use instance methods on `process` to check for listeners 9 | * deps: destroy@1.2.0 10 | * deps: http-errors@2.0.0 11 | - deps: depd@2.0.0 12 | - deps: statuses@2.0.1 13 | * deps: on-finished@2.4.1 14 | * deps: statuses@2.0.1 15 | 16 | 0.17.2 / 2021-12-11 17 | =================== 18 | 19 | * pref: ignore empty http tokens 20 | * deps: http-errors@1.8.1 21 | - deps: inherits@2.0.4 22 | - deps: toidentifier@1.0.1 23 | - deps: setprototypeof@1.2.0 24 | * deps: ms@2.1.3 25 | 26 | 0.17.1 / 2019-05-10 27 | =================== 28 | 29 | * Set stricter CSP header in redirect & error responses 30 | * deps: range-parser@~1.2.1 31 | 32 | 0.17.0 / 2019-05-03 33 | =================== 34 | 35 | * deps: http-errors@~1.7.2 36 | - Set constructor name when possible 37 | - Use `toidentifier` module to make class names 38 | - deps: depd@~1.1.2 39 | - deps: setprototypeof@1.1.1 40 | - deps: statuses@'>= 1.5.0 < 2' 41 | * deps: mime@1.6.0 42 | - Add extensions for JPEG-2000 images 43 | - Add new `font/*` types from IANA 44 | - Add WASM mapping 45 | - Update `.bdoc` to `application/bdoc` 46 | - Update `.bmp` to `image/bmp` 47 | - Update `.m4a` to `audio/mp4` 48 | - Update `.rtf` to `application/rtf` 49 | - Update `.wav` to `audio/wav` 50 | - Update `.xml` to `application/xml` 51 | - Update generic extensions to `application/octet-stream`: 52 | `.deb`, `.dll`, `.dmg`, `.exe`, `.iso`, `.msi` 53 | - Use mime-score module to resolve extension conflicts 54 | * deps: ms@2.1.1 55 | - Add `week`/`w` support 56 | - Fix negative number handling 57 | * deps: statuses@~1.5.0 58 | * perf: remove redundant `path.normalize` call 59 | 60 | 0.16.2 / 2018-02-07 61 | =================== 62 | 63 | * Fix incorrect end tag in default error & redirects 64 | * deps: depd@~1.1.2 65 | - perf: remove argument reassignment 66 | * deps: encodeurl@~1.0.2 67 | - Fix encoding `%` as last character 68 | * deps: statuses@~1.4.0 69 | 70 | 0.16.1 / 2017-09-29 71 | =================== 72 | 73 | * Fix regression in edge-case behavior for empty `path` 74 | 75 | 0.16.0 / 2017-09-27 76 | =================== 77 | 78 | * Add `immutable` option 79 | * Fix missing `` in default error & redirects 80 | * Use instance methods on steam to check for listeners 81 | * deps: mime@1.4.1 82 | - Add 70 new types for file extensions 83 | - Set charset as "UTF-8" for .js and .json 84 | * perf: improve path validation speed 85 | 86 | 0.15.6 / 2017-09-22 87 | =================== 88 | 89 | * deps: debug@2.6.9 90 | * perf: improve `If-Match` token parsing 91 | 92 | 0.15.5 / 2017-09-20 93 | =================== 94 | 95 | * deps: etag@~1.8.1 96 | - perf: replace regular expression with substring 97 | * deps: fresh@0.5.2 98 | - Fix handling of modified headers with invalid dates 99 | - perf: improve ETag match loop 100 | - perf: improve `If-None-Match` token parsing 101 | 102 | 0.15.4 / 2017-08-05 103 | =================== 104 | 105 | * deps: debug@2.6.8 106 | * deps: depd@~1.1.1 107 | - Remove unnecessary `Buffer` loading 108 | * deps: http-errors@~1.6.2 109 | - deps: depd@1.1.1 110 | 111 | 0.15.3 / 2017-05-16 112 | =================== 113 | 114 | * deps: debug@2.6.7 115 | - deps: ms@2.0.0 116 | * deps: ms@2.0.0 117 | 118 | 0.15.2 / 2017-04-26 119 | =================== 120 | 121 | * deps: debug@2.6.4 122 | - Fix `DEBUG_MAX_ARRAY_LENGTH` 123 | - deps: ms@0.7.3 124 | * deps: ms@1.0.0 125 | 126 | 0.15.1 / 2017-03-04 127 | =================== 128 | 129 | * Fix issue when `Date.parse` does not return `NaN` on invalid date 130 | * Fix strict violation in broken environments 131 | 132 | 0.15.0 / 2017-02-25 133 | =================== 134 | 135 | * Support `If-Match` and `If-Unmodified-Since` headers 136 | * Add `res` and `path` arguments to `directory` event 137 | * Remove usage of `res._headers` private field 138 | - Improves compatibility with Node.js 8 nightly 139 | * Send complete HTML document in redirect & error responses 140 | * Set default CSP header in redirect & error responses 141 | * Use `res.getHeaderNames()` when available 142 | * Use `res.headersSent` when available 143 | * deps: debug@2.6.1 144 | - Allow colors in workers 145 | - Deprecated `DEBUG_FD` environment variable set to `3` or higher 146 | - Fix error when running under React Native 147 | - Use same color for same namespace 148 | - deps: ms@0.7.2 149 | * deps: etag@~1.8.0 150 | * deps: fresh@0.5.0 151 | - Fix false detection of `no-cache` request directive 152 | - Fix incorrect result when `If-None-Match` has both `*` and ETags 153 | - Fix weak `ETag` matching to match spec 154 | - perf: delay reading header values until needed 155 | - perf: enable strict mode 156 | - perf: hoist regular expressions 157 | - perf: remove duplicate conditional 158 | - perf: remove unnecessary boolean coercions 159 | - perf: skip checking modified time if ETag check failed 160 | - perf: skip parsing `If-None-Match` when no `ETag` header 161 | - perf: use `Date.parse` instead of `new Date` 162 | * deps: http-errors@~1.6.1 163 | - Make `message` property enumerable for `HttpError`s 164 | - deps: setprototypeof@1.0.3 165 | 166 | 0.14.2 / 2017-01-23 167 | =================== 168 | 169 | * deps: http-errors@~1.5.1 170 | - deps: inherits@2.0.3 171 | - deps: setprototypeof@1.0.2 172 | - deps: statuses@'>= 1.3.1 < 2' 173 | * deps: ms@0.7.2 174 | * deps: statuses@~1.3.1 175 | 176 | 0.14.1 / 2016-06-09 177 | =================== 178 | 179 | * Fix redirect error when `path` contains raw non-URL characters 180 | * Fix redirect when `path` starts with multiple forward slashes 181 | 182 | 0.14.0 / 2016-06-06 183 | =================== 184 | 185 | * Add `acceptRanges` option 186 | * Add `cacheControl` option 187 | * Attempt to combine multiple ranges into single range 188 | * Correctly inherit from `Stream` class 189 | * Fix `Content-Range` header in 416 responses when using `start`/`end` options 190 | * Fix `Content-Range` header missing from default 416 responses 191 | * Ignore non-byte `Range` headers 192 | * deps: http-errors@~1.5.0 193 | - Add `HttpError` export, for `err instanceof createError.HttpError` 194 | - Support new code `421 Misdirected Request` 195 | - Use `setprototypeof` module to replace `__proto__` setting 196 | - deps: inherits@2.0.1 197 | - deps: statuses@'>= 1.3.0 < 2' 198 | - perf: enable strict mode 199 | * deps: range-parser@~1.2.0 200 | - Fix incorrectly returning -1 when there is at least one valid range 201 | - perf: remove internal function 202 | * deps: statuses@~1.3.0 203 | - Add `421 Misdirected Request` 204 | - perf: enable strict mode 205 | * perf: remove argument reassignment 206 | 207 | 0.13.2 / 2016-03-05 208 | =================== 209 | 210 | * Fix invalid `Content-Type` header when `send.mime.default_type` unset 211 | 212 | 0.13.1 / 2016-01-16 213 | =================== 214 | 215 | * deps: depd@~1.1.0 216 | - Support web browser loading 217 | - perf: enable strict mode 218 | * deps: destroy@~1.0.4 219 | - perf: enable strict mode 220 | * deps: escape-html@~1.0.3 221 | - perf: enable strict mode 222 | - perf: optimize string replacement 223 | - perf: use faster string coercion 224 | * deps: range-parser@~1.0.3 225 | - perf: enable strict mode 226 | 227 | 0.13.0 / 2015-06-16 228 | =================== 229 | 230 | * Allow Node.js HTTP server to set `Date` response header 231 | * Fix incorrectly removing `Content-Location` on 304 response 232 | * Improve the default redirect response headers 233 | * Send appropriate headers on default error response 234 | * Use `http-errors` for standard emitted errors 235 | * Use `statuses` instead of `http` module for status messages 236 | * deps: escape-html@1.0.2 237 | * deps: etag@~1.7.0 238 | - Improve stat performance by removing hashing 239 | * deps: fresh@0.3.0 240 | - Add weak `ETag` matching support 241 | * deps: on-finished@~2.3.0 242 | - Add defined behavior for HTTP `CONNECT` requests 243 | - Add defined behavior for HTTP `Upgrade` requests 244 | - deps: ee-first@1.1.1 245 | * perf: enable strict mode 246 | * perf: remove unnecessary array allocations 247 | 248 | 0.12.3 / 2015-05-13 249 | =================== 250 | 251 | * deps: debug@~2.2.0 252 | - deps: ms@0.7.1 253 | * deps: depd@~1.0.1 254 | * deps: etag@~1.6.0 255 | - Improve support for JXcore 256 | - Support "fake" stats objects in environments without `fs` 257 | * deps: ms@0.7.1 258 | - Prevent extraordinarily long inputs 259 | * deps: on-finished@~2.2.1 260 | 261 | 0.12.2 / 2015-03-13 262 | =================== 263 | 264 | * Throw errors early for invalid `extensions` or `index` options 265 | * deps: debug@~2.1.3 266 | - Fix high intensity foreground color for bold 267 | - deps: ms@0.7.0 268 | 269 | 0.12.1 / 2015-02-17 270 | =================== 271 | 272 | * Fix regression sending zero-length files 273 | 274 | 0.12.0 / 2015-02-16 275 | =================== 276 | 277 | * Always read the stat size from the file 278 | * Fix mutating passed-in `options` 279 | * deps: mime@1.3.4 280 | 281 | 0.11.1 / 2015-01-20 282 | =================== 283 | 284 | * Fix `root` path disclosure 285 | 286 | 0.11.0 / 2015-01-05 287 | =================== 288 | 289 | * deps: debug@~2.1.1 290 | * deps: etag@~1.5.1 291 | - deps: crc@3.2.1 292 | * deps: ms@0.7.0 293 | - Add `milliseconds` 294 | - Add `msecs` 295 | - Add `secs` 296 | - Add `mins` 297 | - Add `hrs` 298 | - Add `yrs` 299 | * deps: on-finished@~2.2.0 300 | 301 | 0.10.1 / 2014-10-22 302 | =================== 303 | 304 | * deps: on-finished@~2.1.1 305 | - Fix handling of pipelined requests 306 | 307 | 0.10.0 / 2014-10-15 308 | =================== 309 | 310 | * deps: debug@~2.1.0 311 | - Implement `DEBUG_FD` env variable support 312 | * deps: depd@~1.0.0 313 | * deps: etag@~1.5.0 314 | - Improve string performance 315 | - Slightly improve speed for weak ETags over 1KB 316 | 317 | 0.9.3 / 2014-09-24 318 | ================== 319 | 320 | * deps: etag@~1.4.0 321 | - Support "fake" stats objects 322 | 323 | 0.9.2 / 2014-09-15 324 | ================== 325 | 326 | * deps: depd@0.4.5 327 | * deps: etag@~1.3.1 328 | * deps: range-parser@~1.0.2 329 | 330 | 0.9.1 / 2014-09-07 331 | ================== 332 | 333 | * deps: fresh@0.2.4 334 | 335 | 0.9.0 / 2014-09-07 336 | ================== 337 | 338 | * Add `lastModified` option 339 | * Use `etag` to generate `ETag` header 340 | * deps: debug@~2.0.0 341 | 342 | 0.8.5 / 2014-09-04 343 | ================== 344 | 345 | * Fix malicious path detection for empty string path 346 | 347 | 0.8.4 / 2014-09-04 348 | ================== 349 | 350 | * Fix a path traversal issue when using `root` 351 | 352 | 0.8.3 / 2014-08-16 353 | ================== 354 | 355 | * deps: destroy@1.0.3 356 | - renamed from dethroy 357 | * deps: on-finished@2.1.0 358 | 359 | 0.8.2 / 2014-08-14 360 | ================== 361 | 362 | * Work around `fd` leak in Node.js 0.10 for `fs.ReadStream` 363 | * deps: dethroy@1.0.2 364 | 365 | 0.8.1 / 2014-08-05 366 | ================== 367 | 368 | * Fix `extensions` behavior when file already has extension 369 | 370 | 0.8.0 / 2014-08-05 371 | ================== 372 | 373 | * Add `extensions` option 374 | 375 | 0.7.4 / 2014-08-04 376 | ================== 377 | 378 | * Fix serving index files without root dir 379 | 380 | 0.7.3 / 2014-07-29 381 | ================== 382 | 383 | * Fix incorrect 403 on Windows and Node.js 0.11 384 | 385 | 0.7.2 / 2014-07-27 386 | ================== 387 | 388 | * deps: depd@0.4.4 389 | - Work-around v8 generating empty stack traces 390 | 391 | 0.7.1 / 2014-07-26 392 | ================== 393 | 394 | * deps: depd@0.4.3 395 | - Fix exception when global `Error.stackTraceLimit` is too low 396 | 397 | 0.7.0 / 2014-07-20 398 | ================== 399 | 400 | * Deprecate `hidden` option; use `dotfiles` option 401 | * Add `dotfiles` option 402 | * deps: debug@1.0.4 403 | * deps: depd@0.4.2 404 | - Add `TRACE_DEPRECATION` environment variable 405 | - Remove non-standard grey color from color output 406 | - Support `--no-deprecation` argument 407 | - Support `--trace-deprecation` argument 408 | 409 | 0.6.0 / 2014-07-11 410 | ================== 411 | 412 | * Deprecate `from` option; use `root` option 413 | * Deprecate `send.etag()` -- use `etag` in `options` 414 | * Deprecate `send.hidden()` -- use `hidden` in `options` 415 | * Deprecate `send.index()` -- use `index` in `options` 416 | * Deprecate `send.maxage()` -- use `maxAge` in `options` 417 | * Deprecate `send.root()` -- use `root` in `options` 418 | * Cap `maxAge` value to 1 year 419 | * deps: debug@1.0.3 420 | - Add support for multiple wildcards in namespaces 421 | 422 | 0.5.0 / 2014-06-28 423 | ================== 424 | 425 | * Accept string for `maxAge` (converted by `ms`) 426 | * Add `headers` event 427 | * Include link in default redirect response 428 | * Use `EventEmitter.listenerCount` to count listeners 429 | 430 | 0.4.3 / 2014-06-11 431 | ================== 432 | 433 | * Do not throw un-catchable error on file open race condition 434 | * Use `escape-html` for HTML escaping 435 | * deps: debug@1.0.2 436 | - fix some debugging output colors on node.js 0.8 437 | * deps: finished@1.2.2 438 | * deps: fresh@0.2.2 439 | 440 | 0.4.2 / 2014-06-09 441 | ================== 442 | 443 | * fix "event emitter leak" warnings 444 | * deps: debug@1.0.1 445 | * deps: finished@1.2.1 446 | 447 | 0.4.1 / 2014-06-02 448 | ================== 449 | 450 | * Send `max-age` in `Cache-Control` in correct format 451 | 452 | 0.4.0 / 2014-05-27 453 | ================== 454 | 455 | * Calculate ETag with md5 for reduced collisions 456 | * Fix wrong behavior when index file matches directory 457 | * Ignore stream errors after request ends 458 | - Goodbye `EBADF, read` 459 | * Skip directories in index file search 460 | * deps: debug@0.8.1 461 | 462 | 0.3.0 / 2014-04-24 463 | ================== 464 | 465 | * Fix sending files with dots without root set 466 | * Coerce option types 467 | * Accept API options in options object 468 | * Set etags to "weak" 469 | * Include file path in etag 470 | * Make "Can't set headers after they are sent." catchable 471 | * Send full entity-body for multi range requests 472 | * Default directory access to 403 when index disabled 473 | * Support multiple index paths 474 | * Support "If-Range" header 475 | * Control whether to generate etags 476 | * deps: mime@1.2.11 477 | 478 | 0.2.0 / 2014-01-29 479 | ================== 480 | 481 | * update range-parser and fresh 482 | 483 | 0.1.4 / 2013-08-11 484 | ================== 485 | 486 | * update fresh 487 | 488 | 0.1.3 / 2013-07-08 489 | ================== 490 | 491 | * Revert "Fix fd leak" 492 | 493 | 0.1.2 / 2013-07-03 494 | ================== 495 | 496 | * Fix fd leak 497 | 498 | 0.1.0 / 2012-08-25 499 | ================== 500 | 501 | * add options parameter to send() that is passed to fs.createReadStream() [kanongil] 502 | 503 | 0.0.4 / 2012-08-16 504 | ================== 505 | 506 | * allow custom "Accept-Ranges" definition 507 | 508 | 0.0.3 / 2012-07-16 509 | ================== 510 | 511 | * fix normalization of the root directory. Closes #3 512 | 513 | 0.0.2 / 2012-07-09 514 | ================== 515 | 516 | * add passing of req explicitly for now (YUCK) 517 | 518 | 0.0.1 / 2010-01-03 519 | ================== 520 | 521 | * Initial release 522 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2012 TJ Holowaychuk 4 | Copyright (c) 2014-2022 Douglas Christopher Wilson 5 | Copyright (c) 2023 The Fastify Team 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/send 2 | 3 | [![CI](https://github.com/fastify/send/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/send/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/send.svg?style=flat)](https://www.npmjs.com/package/@fastify/send) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Send is a library for streaming files from the file system as an HTTP response 8 | supporting partial responses (Ranges), conditional-GET negotiation (If-Match, 9 | If-Unmodified-Since, If-None-Match, If-Modified-Since), high test coverage, 10 | and granular events which may be leveraged to take appropriate actions in your 11 | application or framework. 12 | 13 | ## Installation 14 | 15 | This is a [Node.js](https://nodejs.org/en/) module available through the 16 | [npm registry](https://www.npmjs.com/). Installation is done using the 17 | [`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): 18 | 19 | ```bash 20 | $ npm install @fastify/send 21 | ``` 22 | 23 | ### TypeScript 24 | 25 | `@types/mime@3` must be used if wanting to use TypeScript; 26 | `@types/mime@4` removed the `mime` types. 27 | 28 | ```bash 29 | $ npm install -D @types/mime@3 30 | ``` 31 | 32 | ## API 33 | 34 | ```js 35 | const send = require('@fastify/send') 36 | ``` 37 | 38 | ### send(req, path, [options]) 39 | 40 | Provide `statusCode`, `headers`, and `stream` for the given path to send to a 41 | `res`. The `req` is the Node.js HTTP request and the `path `is a urlencoded path 42 | to send (urlencoded, not the actual file-system path). 43 | 44 | #### Options 45 | 46 | ##### acceptRanges 47 | 48 | Enable or disable accepting ranged requests, defaults to true. 49 | Disabling this will not send `Accept-Ranges` and ignore the contents 50 | of the `Range` request header. 51 | 52 | ##### cacheControl 53 | 54 | Enable or disable setting `Cache-Control` response header, defaults to 55 | true. Disabling this will ignore the `immutable` and `maxAge` options. 56 | 57 | ##### contentType 58 | 59 | By default, this library uses the `mime` module to set the `Content-Type` 60 | of the response based on the file extension of the requested file. 61 | 62 | To disable this functionality, set `contentType` to `false`. 63 | The `Content-Type` header will need to be set manually if disabled. 64 | 65 | ##### dotfiles 66 | 67 | Set how "dotfiles" are treated when encountered. A dotfile is a file 68 | or directory that begins with a dot ("."). Note this check is done on 69 | the path itself without checking if the path exists on the 70 | disk. If `root` is specified, only the dotfiles above the root are 71 | checked (i.e. the root itself can be within a dotfile when set 72 | to "deny"). 73 | 74 | - `'allow'` No special treatment for dotfiles. 75 | - `'deny'` Send a 403 for any request for a dotfile. 76 | - `'ignore'` Pretend like the dotfile does not exist and 404. 77 | 78 | The default value is _similar_ to `'ignore'`, with the exception that 79 | this default will not ignore the files within a directory that begins 80 | with a dot, for backward-compatibility. 81 | 82 | ##### end 83 | 84 | Byte offset at which the stream ends, defaults to the length of the file 85 | minus 1. The end is inclusive in the stream, meaning `end: 3` will include 86 | the 4th byte in the stream. 87 | 88 | ##### etag 89 | 90 | Enable or disable etag generation, defaults to true. 91 | 92 | ##### extensions 93 | 94 | If a given file doesn't exist, try appending one of the given extensions, 95 | in the given order. By default, this is disabled (set to `false`). An 96 | example value that will serve extension-less HTML files: `['html', 'htm']`. 97 | This is skipped if the requested file already has an extension. 98 | 99 | ##### immutable 100 | 101 | Enable or disable the `immutable` directive in the `Cache-Control` response 102 | header, defaults to `false`. If set to `true`, the `maxAge` option should 103 | also be specified to enable caching. The `immutable` directive will prevent 104 | supported clients from making conditional requests during the life of the 105 | `maxAge` option to check if the file has changed. 106 | 107 | ##### index 108 | 109 | By default send supports "index.html" files, to disable this 110 | set `false` or to supply a new index pass a string or an array 111 | in preferred order. 112 | 113 | ##### lastModified 114 | 115 | Enable or disable `Last-Modified` header, defaults to true. Uses the file 116 | system's last modified value. 117 | 118 | ##### maxAge 119 | 120 | Provide a max-age in milliseconds for HTTP caching, defaults to 0. 121 | This can also be a string accepted by the 122 | [ms](https://www.npmjs.org/package/ms#readme) module. 123 | 124 | ##### maxContentRangeChunkSize 125 | 126 | Specify the maximum response content size, defaults to the entire file size. 127 | This will be used when `acceptRanges` is true. 128 | 129 | ##### root 130 | 131 | Serve files relative to `path`. 132 | 133 | ##### start 134 | 135 | Byte offset at which the stream starts, defaults to 0. The start is inclusive, 136 | meaning `start: 2` will include the 3rd byte in the stream. 137 | 138 | ##### highWaterMark 139 | 140 | When provided, this option sets the maximum number of bytes that the internal 141 | buffer will hold before pausing reads from the underlying resource. 142 | If you omit this option (or pass undefined), Node.js falls back to 143 | its built-in default for readable binary streams. 144 | 145 | ### .mime 146 | 147 | The `mime` export is the global instance of the 148 | [`mime` npm module](https://www.npmjs.com/package/mime). 149 | 150 | This is used to configure the MIME types that are associated with file extensions 151 | as well as other options for how to resolve the MIME type of a file (like the 152 | default type to use for an unknown file extension). 153 | 154 | ## Caching 155 | 156 | It does _not_ perform internal caching, you should use a reverse proxy cache 157 | such as Varnish for this, or those fancy things called CDNs. If your 158 | application is small enough that it would benefit from single-node memory 159 | caching, it's small enough that it does not need caching at all ;). 160 | 161 | ## Debugging 162 | 163 | To enable `debug()` instrumentation output export __NODE_DEBUG__: 164 | 165 | ``` 166 | $ NODE_DEBUG=send node app 167 | ``` 168 | 169 | ## Running tests 170 | 171 | ``` 172 | $ npm install 173 | $ npm test 174 | ``` 175 | 176 | ## Examples 177 | 178 | ### Serve a specific file 179 | 180 | This simple example will send a specific file to all requests. 181 | 182 | ```js 183 | const http = require('node:http') 184 | const send = require('send') 185 | 186 | const server = http.createServer(async function onRequest (req, res) { 187 | const { statusCode, headers, stream } = await send(req, '/path/to/index.html') 188 | res.writeHead(statusCode, headers) 189 | stream.pipe(res) 190 | }) 191 | 192 | server.listen(3000) 193 | ``` 194 | 195 | ### Serve all files from a directory 196 | 197 | This simple example will just serve up all the files in a 198 | given directory as the top-level. For example, a request 199 | `GET /foo.txt` will send back `/www/public/foo.txt`. 200 | 201 | ```js 202 | const http = require('node:http') 203 | const parseUrl = require('parseurl') 204 | const send = require('@fastify/send') 205 | 206 | const server = http.createServer(async function onRequest (req, res) { 207 | const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) 208 | res.writeHead(statusCode, headers) 209 | stream.pipe(res) 210 | }) 211 | 212 | server.listen(3000) 213 | ``` 214 | 215 | ### Custom file types 216 | 217 | ```js 218 | const http = require('node:http') 219 | const parseUrl = require('parseurl') 220 | const send = require('@fastify/send') 221 | 222 | // Default unknown types to text/plain 223 | send.mime.default_type = 'text/plain' 224 | 225 | // Add a custom type 226 | send.mime.define({ 227 | 'application/x-my-type': ['x-mt', 'x-mtt'] 228 | }) 229 | 230 | const server = http.createServer(function onRequest (req, res) { 231 | const { statusCode, headers, stream } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) 232 | res.writeHead(statusCode, headers) 233 | stream.pipe(res) 234 | }) 235 | 236 | server.listen(3000) 237 | ``` 238 | 239 | ### Custom directory index view 240 | 241 | This is an example of serving up a structure of directories with a 242 | custom function to render a listing of a directory. 243 | 244 | ```js 245 | const http = require('node:http') 246 | const fs = require('node:fs') 247 | const parseUrl = require('parseurl') 248 | const send = require('@fastify/send') 249 | 250 | // Transfer arbitrary files from within /www/example.com/public/* 251 | // with a custom handler for directory listing 252 | const server = http.createServer(async function onRequest (req, res) { 253 | const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { index: false, root: '/www/public' }) 254 | if(type === 'directory') { 255 | // get directory list 256 | const list = await readdir(metadata.path) 257 | // render an index for the directory 258 | res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) 259 | res.end(list.join('\n') + '\n') 260 | } else { 261 | res.writeHead(statusCode, headers) 262 | stream.pipe(res) 263 | } 264 | }) 265 | 266 | server.listen(3000) 267 | ``` 268 | 269 | ### Serving from a root directory with custom error-handling 270 | 271 | ```js 272 | const http = require('node:http') 273 | const parseUrl = require('parseurl') 274 | const send = require('@fastify/send') 275 | 276 | const server = http.createServer(async function onRequest (req, res) { 277 | // transfer arbitrary files from within 278 | // /www/example.com/public/* 279 | const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) 280 | switch (type) { 281 | case 'directory': { 282 | // your custom directory handling logic: 283 | res.writeHead(301, { 284 | 'Location': metadata.requestPath + '/' 285 | }) 286 | res.end('Redirecting to ' + metadata.requestPath + '/') 287 | break 288 | } 289 | case 'error': { 290 | // your custom error-handling logic: 291 | res.writeHead(metadata.error.status ?? 500, {}) 292 | res.end(metadata.error.message) 293 | break 294 | } 295 | default: { 296 | // your custom headers 297 | // serve all files for download 298 | res.setHeader('Content-Disposition', 'attachment') 299 | res.writeHead(statusCode, headers) 300 | stream.pipe(res) 301 | } 302 | } 303 | }) 304 | 305 | server.listen(3000) 306 | ``` 307 | 308 | ## License 309 | 310 | Licensed under [MIT](./LICENSE). 311 | -------------------------------------------------------------------------------- /benchmarks/collapseLeadingSlashes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const benchmark = require('benchmark') 4 | const collapseLeadingSlashes = require('../lib/collapseLeadingSlashes').collapseLeadingSlashes 5 | 6 | const nonLeading = 'bla.json' 7 | const hasLeading = '///./json' 8 | 9 | new benchmark.Suite() 10 | .add(nonLeading, function () { collapseLeadingSlashes(nonLeading) }, { minSamples: 100 }) 11 | .add(hasLeading, function () { collapseLeadingSlashes(hasLeading) }, { minSamples: 100 }) 12 | .on('cycle', function onCycle (event) { console.log(String(event.target)) }) 13 | .run({ async: false }) 14 | -------------------------------------------------------------------------------- /benchmarks/containsDotFile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const benchmark = require('benchmark') 4 | const { containsDotFile } = require('../lib/containsDotFile') 5 | 6 | const hasDotFileSimple = '.github'.split('/') 7 | const hasDotFile = './.github'.split('/') 8 | const noDotFile = './index.html'.split('/') 9 | 10 | new benchmark.Suite() 11 | .add(hasDotFileSimple.join('/'), function () { containsDotFile(hasDotFileSimple) }, { minSamples: 100 }) 12 | .add(noDotFile.join('/'), function () { containsDotFile(noDotFile) }, { minSamples: 100 }) 13 | .add(hasDotFile.join('/'), function () { containsDotFile(hasDotFile) }, { minSamples: 100 }) 14 | .on('cycle', function onCycle (event) { console.log(String(event.target)) }) 15 | .run({ async: false }) 16 | -------------------------------------------------------------------------------- /benchmarks/isUtf8MimeType.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const benchmark = require('benchmark') 4 | const isUtf8MimeType = require('../lib/isUtf8MimeType').isUtf8MimeType 5 | 6 | const applicationJson = 'application/json' 7 | const applicationJavascript = 'application/javascript' 8 | const textJson = 'text/json' 9 | const textHtml = 'text/html' 10 | const textJavascript = 'text/javascript' 11 | const imagePng = 'image/png' 12 | 13 | new benchmark.Suite() 14 | .add('isUtf8MimeType', function () { 15 | isUtf8MimeType(applicationJson) 16 | isUtf8MimeType(applicationJavascript) 17 | isUtf8MimeType(imagePng) 18 | isUtf8MimeType(textJson) 19 | isUtf8MimeType(textHtml) 20 | isUtf8MimeType(textJavascript) 21 | }, { minSamples: 100 }) 22 | .on('cycle', function onCycle (event) { console.log(String(event.target)) }) 23 | .run({ async: false }) 24 | -------------------------------------------------------------------------------- /benchmarks/normalizeList.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const benchmark = require('benchmark') 4 | const { normalizeList } = require('../lib/normalizeList') 5 | 6 | const validSingle = 'a' 7 | const validArray = ['a', 'b', 'c'] 8 | 9 | new benchmark.Suite() 10 | .add('false', function () { normalizeList(false) }, { minSamples: 100 }) 11 | .add('valid single', function () { normalizeList(validSingle) }, { minSamples: 100 }) 12 | .add('valid array', function () { normalizeList(validArray) }, { minSamples: 100 }) 13 | .on('cycle', function onCycle (event) { console.log(String(event.target)) }) 14 | .run({ async: false }) 15 | -------------------------------------------------------------------------------- /benchmarks/parseBytesRange.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const benchmark = require('benchmark') 4 | const { parseBytesRange } = require('../lib/parseBytesRange') 5 | 6 | const size150 = 150 7 | 8 | const rangeSingle = 'bytes=0-100' 9 | const rangeMultiple = 'bytes=0-4,90-99,5-75,100-199,101-102' 10 | 11 | new benchmark.Suite() 12 | .add('size: 150, bytes=0-100', function () { parseBytesRange(size150, rangeSingle) }, { minSamples: 100 }) 13 | .add('size: 150, bytes=0-4,90-99,5-75,100-199,101-102', function () { parseBytesRange(size150, rangeMultiple) }, { minSamples: 100 }) 14 | .on('cycle', function onCycle (event) { console.log(String(event.target)) }) 15 | .run({ async: false }) 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 |

Hello, World

2 | -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('node:http') 4 | const send = require('..') 5 | const path = require('node:path') 6 | 7 | const indexPath = path.join(__dirname, 'index.html') 8 | 9 | const server = http.createServer(async function onRequest (req, res) { 10 | const { statusCode, headers, stream } = await send(req, indexPath) 11 | res.writeHead(statusCode, headers) 12 | stream.pipe(res) 13 | }) 14 | 15 | server.listen(3000) 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * send 3 | * Copyright(c) 2012 TJ Holowaychuk 4 | * Copyright(c) 2014-2022 Douglas Christopher Wilson 5 | * MIT Licensed 6 | */ 7 | 8 | 'use strict' 9 | 10 | /** 11 | * Module dependencies. 12 | * @private 13 | */ 14 | const isUtf8MimeType = require('./lib/isUtf8MimeType').isUtf8MimeType 15 | const mime = require('mime') 16 | const send = require('./lib/send').send 17 | 18 | /** 19 | * Module exports. 20 | * @public 21 | */ 22 | 23 | module.exports = send 24 | module.exports.default = send 25 | module.exports.send = send 26 | 27 | module.exports.isUtf8MimeType = isUtf8MimeType 28 | module.exports.mime = mime 29 | -------------------------------------------------------------------------------- /lib/collapseLeadingSlashes.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Collapse all leading slashes into a single slash 5 | * 6 | * @param {string} str 7 | * @private 8 | */ 9 | 10 | function collapseLeadingSlashes (str) { 11 | if ( 12 | str[0] !== '/' || 13 | str[1] !== '/' 14 | ) { 15 | return str 16 | } 17 | for (let i = 2, il = str.length; i < il; ++i) { 18 | if (str[i] !== '/') { 19 | return str.slice(i - 1) 20 | } 21 | } 22 | /* c8 ignore next */ 23 | } 24 | 25 | module.exports.collapseLeadingSlashes = collapseLeadingSlashes 26 | -------------------------------------------------------------------------------- /lib/containsDotFile.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * send 3 | * Copyright(c) 2012 TJ Holowaychuk 4 | * Copyright(c) 2014-2022 Douglas Christopher Wilson 5 | * MIT Licensed 6 | */ 7 | 'use strict' 8 | /** 9 | * Determine if path parts contain a dotfile. 10 | * 11 | * @api private 12 | */ 13 | function containsDotFile (parts) { 14 | for (let i = 0, il = parts.length; i < il; ++i) { 15 | if (parts[i].length !== 1 && parts[i][0] === '.') { 16 | return true 17 | } 18 | } 19 | 20 | return false 21 | } 22 | 23 | module.exports.containsDotFile = containsDotFile 24 | -------------------------------------------------------------------------------- /lib/contentRange.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * send 3 | * Copyright(c) 2012 TJ Holowaychuk 4 | * Copyright(c) 2014-2022 Douglas Christopher Wilson 5 | * MIT Licensed 6 | */ 7 | 'use strict' 8 | /** 9 | * Create a Content-Range header. 10 | * 11 | * @param {string} type 12 | * @param {number} size 13 | * @param {array} [range] 14 | */ 15 | function contentRange (type, size, range) { 16 | return type + ' ' + (range ? range.start + '-' + range.end : '*') + '/' + size 17 | } 18 | exports.contentRange = contentRange 19 | -------------------------------------------------------------------------------- /lib/createHtmlDocument.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * send 3 | * Copyright(c) 2012 TJ Holowaychuk 4 | * Copyright(c) 2014-2022 Douglas Christopher Wilson 5 | * MIT Licensed 6 | */ 7 | 'use strict' 8 | /** 9 | * Create a minimal HTML document. 10 | * 11 | * @param {string} title 12 | * @param {string} body 13 | * @private 14 | */ 15 | function createHtmlDocument (title, body) { 16 | const html = '\n' + 17 | '\n' + 18 | '\n' + 19 | '\n' + 20 | '' + title + '\n' + 21 | '\n' + 22 | '\n' + 23 | '
' + body + '
\n' + 24 | '\n' + 25 | '\n' 26 | 27 | return [html, Buffer.byteLength(html)] 28 | } 29 | exports.createHtmlDocument = createHtmlDocument 30 | -------------------------------------------------------------------------------- /lib/createHttpError.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const createError = require('http-errors') 4 | 5 | /** 6 | * Create a HttpError object from simple arguments. 7 | * 8 | * @param {number} status 9 | * @param {Error|object} err 10 | * @private 11 | */ 12 | 13 | function createHttpError (status, err) { 14 | if (!err) { 15 | return createError(status) 16 | } 17 | 18 | return err instanceof Error 19 | ? createError(status, err, { expose: false }) 20 | : createError(status, err) 21 | } 22 | 23 | module.exports.createHttpError = createHttpError 24 | -------------------------------------------------------------------------------- /lib/isUtf8MimeType.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function isUtf8MimeType (value) { 4 | const len = value.length 5 | return ( 6 | (len > 21 && value.indexOf('application/javascript') === 0) || 7 | (len > 14 && value.indexOf('application/json') === 0) || 8 | (len > 5 && value.indexOf('text/') === 0) 9 | ) 10 | } 11 | 12 | module.exports.isUtf8MimeType = isUtf8MimeType 13 | -------------------------------------------------------------------------------- /lib/normalizeList.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Normalize the index option into an array. 5 | * 6 | * @param {boolean|string|array} val 7 | * @param {string} name 8 | * @private 9 | */ 10 | 11 | function normalizeList (val, name) { 12 | if (typeof val === 'string') { 13 | return [val] 14 | } else if (val === false) { 15 | return [] 16 | } else if (Array.isArray(val)) { 17 | for (let i = 0, il = val.length; i < il; ++i) { 18 | if (typeof val[i] !== 'string') { 19 | throw new TypeError(name + ' must be array of strings or false') 20 | } 21 | } 22 | return val 23 | } else { 24 | throw new TypeError(name + ' must be array of strings or false') 25 | } 26 | } 27 | 28 | module.exports.normalizeList = normalizeList 29 | -------------------------------------------------------------------------------- /lib/parseBytesRange.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /*! 4 | * Based on range-parser 5 | * 6 | * Copyright(c) 2012-2014 TJ Holowaychuk 7 | * Copyright(c) 2015-2016 Douglas Christopher Wilson 8 | * MIT Licensed 9 | */ 10 | 11 | /** 12 | * Parse "Range" header `str` relative to the given file `size`. 13 | * 14 | * @param {Number} size 15 | * @param {String} str 16 | * @return {Array} 17 | * @public 18 | */ 19 | 20 | function parseBytesRange (size, str) { 21 | // split the range string 22 | const values = str.slice(str.indexOf('=') + 1) 23 | const ranges = [] 24 | 25 | const len = values.length 26 | let i = 0 27 | let il = 0 28 | let j = 0 29 | let start 30 | let end 31 | let commaIdx = values.indexOf(',') 32 | let dashIdx = values.indexOf('-') 33 | let prevIdx = -1 34 | 35 | // parse all ranges 36 | while (true) { 37 | commaIdx === -1 && (commaIdx = len) 38 | start = parseInt(values.slice(prevIdx + 1, dashIdx), 10) 39 | end = parseInt(values.slice(dashIdx + 1, commaIdx), 10) 40 | 41 | // -nnn 42 | // eslint-disable-next-line no-self-compare 43 | if (start !== start) { // fast path of isNaN(number) 44 | start = size - end 45 | end = size - 1 46 | // nnn- 47 | // eslint-disable-next-line no-self-compare 48 | } else if (end !== end) { // fast path of isNaN(number) 49 | end = size - 1 50 | // limit last-byte-pos to current length 51 | } else if (end > size - 1) { 52 | end = size - 1 53 | } 54 | 55 | // add range only on valid ranges 56 | if ( 57 | // eslint-disable-next-line no-self-compare 58 | start === start && // fast path of isNaN(number) 59 | // eslint-disable-next-line no-self-compare 60 | end === end && // fast path of isNaN(number) 61 | start > -1 && 62 | start <= end 63 | ) { 64 | // add range 65 | ranges.push({ 66 | start, 67 | end, 68 | index: j++ 69 | }) 70 | } 71 | 72 | if (commaIdx === len) { 73 | break 74 | } 75 | prevIdx = commaIdx++ 76 | dashIdx = values.indexOf('-', commaIdx) 77 | commaIdx = values.indexOf(',', commaIdx) 78 | } 79 | 80 | // unsatisfiable 81 | if ( 82 | j < 2 83 | ) { 84 | return ranges 85 | } 86 | 87 | ranges.sort(sortByRangeStart) 88 | 89 | il = j 90 | j = 0 91 | i = 1 92 | while (i < il) { 93 | const range = ranges[i++] 94 | const current = ranges[j] 95 | 96 | if (range.start > current.end + 1) { 97 | // next range 98 | ranges[++j] = range 99 | } else if (range.end > current.end) { 100 | // extend range 101 | current.end = range.end 102 | current.index > range.index && (current.index = range.index) 103 | } 104 | } 105 | 106 | // trim ordered array 107 | ranges.length = j + 1 108 | 109 | // generate combined range 110 | ranges.sort(sortByRangeIndex) 111 | 112 | return ranges 113 | } 114 | 115 | /** 116 | * Sort function to sort ranges by index. 117 | * @private 118 | */ 119 | 120 | function sortByRangeIndex (a, b) { 121 | return a.index - b.index 122 | } 123 | 124 | /** 125 | * Sort function to sort ranges by start position. 126 | * @private 127 | */ 128 | 129 | function sortByRangeStart (a, b) { 130 | return a.start - b.start 131 | } 132 | 133 | module.exports.parseBytesRange = parseBytesRange 134 | -------------------------------------------------------------------------------- /lib/parseTokenList.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Parse a HTTP token list. 5 | * 6 | * @param {string} str 7 | * @private 8 | */ 9 | 10 | const slice = String.prototype.slice 11 | 12 | function parseTokenList (str, cb) { 13 | let end = 0 14 | let start = 0 15 | let result 16 | 17 | // gather tokens 18 | for (let i = 0, len = str.length; i < len; i++) { 19 | switch (str.charCodeAt(i)) { 20 | case 0x20: /* */ 21 | if (start === end) { 22 | start = end = i + 1 23 | } 24 | break 25 | case 0x2c: /* , */ 26 | if (start !== end) { 27 | result = cb(slice.call(str, start, end)) 28 | if (result !== undefined) { 29 | return result 30 | } 31 | } 32 | start = end = i + 1 33 | break 34 | default: 35 | end = i + 1 36 | break 37 | } 38 | } 39 | 40 | // final token 41 | if (start !== end) { 42 | return cb(slice.call(str, start, end)) 43 | } 44 | } 45 | 46 | module.exports.parseTokenList = parseTokenList 47 | -------------------------------------------------------------------------------- /lib/send.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fs = require('node:fs') 4 | const path = require('node:path') 5 | const stream = require('node:stream') 6 | const debug = require('node:util').debuglog('send') 7 | 8 | const decode = require('fast-decode-uri-component') 9 | const escapeHtml = require('escape-html') 10 | const mime = require('mime') 11 | const ms = require('@lukeed/ms') 12 | 13 | const { collapseLeadingSlashes } = require('./collapseLeadingSlashes') 14 | const { containsDotFile } = require('../lib/containsDotFile') 15 | const { contentRange } = require('../lib/contentRange') 16 | const { createHtmlDocument } = require('../lib/createHtmlDocument') 17 | const { isUtf8MimeType } = require('../lib/isUtf8MimeType') 18 | const { normalizeList } = require('../lib/normalizeList') 19 | const { parseBytesRange } = require('../lib/parseBytesRange') 20 | const { parseTokenList } = require('./parseTokenList') 21 | const { createHttpError } = require('./createHttpError') 22 | 23 | /** 24 | * Path function references. 25 | * @private 26 | */ 27 | 28 | const extname = path.extname 29 | const join = path.join 30 | const normalize = path.normalize 31 | const resolve = path.resolve 32 | const sep = path.sep 33 | 34 | /** 35 | * Stream function references. 36 | * @private 37 | */ 38 | const Readable = stream.Readable 39 | 40 | /** 41 | * Regular expression for identifying a bytes Range header. 42 | * @private 43 | */ 44 | 45 | const BYTES_RANGE_REGEXP = /^ *bytes=/ 46 | 47 | /** 48 | * Maximum value allowed for the max age. 49 | * @private 50 | */ 51 | 52 | const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000 // 1 year 53 | 54 | /** 55 | * Regular expression to match a path with a directory up component. 56 | * @private 57 | */ 58 | 59 | const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/ 60 | 61 | const ERROR_RESPONSES = { 62 | 400: createHtmlDocument('Error', 'Bad Request'), 63 | 403: createHtmlDocument('Error', 'Forbidden'), 64 | 404: createHtmlDocument('Error', 'Not Found'), 65 | 412: createHtmlDocument('Error', 'Precondition Failed'), 66 | 416: createHtmlDocument('Error', 'Range Not Satisfiable'), 67 | 500: createHtmlDocument('Error', 'Internal Server Error') 68 | } 69 | 70 | const validDotFilesOptions = [ 71 | 'allow', 72 | 'ignore', 73 | 'deny' 74 | ] 75 | 76 | function normalizeMaxAge (_maxage) { 77 | let maxage 78 | if (typeof _maxage === 'string') { 79 | maxage = ms.parse(_maxage) 80 | } else { 81 | maxage = Number(_maxage) 82 | } 83 | 84 | // eslint-disable-next-line no-self-compare 85 | if (maxage !== maxage) { 86 | // fast path of isNaN(number) 87 | return 0 88 | } 89 | 90 | return Math.min(Math.max(0, maxage), MAX_MAXAGE) 91 | } 92 | 93 | function normalizeOptions (options) { 94 | options = options ?? {} 95 | 96 | const acceptRanges = options.acceptRanges !== undefined 97 | ? Boolean(options.acceptRanges) 98 | : true 99 | 100 | const cacheControl = options.cacheControl !== undefined 101 | ? Boolean(options.cacheControl) 102 | : true 103 | 104 | const contentType = options.contentType !== undefined 105 | ? Boolean(options.contentType) 106 | : true 107 | 108 | const etag = options.etag !== undefined 109 | ? Boolean(options.etag) 110 | : true 111 | 112 | const dotfiles = options.dotfiles !== undefined 113 | ? validDotFilesOptions.indexOf(options.dotfiles) 114 | : 1 // 'ignore' 115 | if (dotfiles === -1) { 116 | throw new TypeError('dotfiles option must be "allow", "deny", or "ignore"') 117 | } 118 | 119 | const extensions = options.extensions !== undefined 120 | ? normalizeList(options.extensions, 'extensions option') 121 | : [] 122 | 123 | const immutable = options.immutable !== undefined 124 | ? Boolean(options.immutable) 125 | : false 126 | 127 | const index = options.index !== undefined 128 | ? normalizeList(options.index, 'index option') 129 | : ['index.html'] 130 | 131 | const lastModified = options.lastModified !== undefined 132 | ? Boolean(options.lastModified) 133 | : true 134 | 135 | const maxage = normalizeMaxAge(options.maxAge ?? options.maxage) 136 | 137 | const maxContentRangeChunkSize = options.maxContentRangeChunkSize !== undefined 138 | ? Number(options.maxContentRangeChunkSize) 139 | : null 140 | 141 | const root = options.root 142 | ? resolve(options.root) 143 | : null 144 | 145 | const highWaterMark = Number.isSafeInteger(options.highWaterMark) && options.highWaterMark > 0 146 | ? options.highWaterMark 147 | : null 148 | 149 | return { 150 | acceptRanges, 151 | cacheControl, 152 | contentType, 153 | etag, 154 | dotfiles, 155 | extensions, 156 | immutable, 157 | index, 158 | lastModified, 159 | maxage, 160 | maxContentRangeChunkSize, 161 | root, 162 | highWaterMark, 163 | start: options.start, 164 | end: options.end 165 | } 166 | } 167 | 168 | function normalizePath (_path, root) { 169 | // decode the path 170 | let path = decode(_path) 171 | if (path == null) { 172 | return { statusCode: 400 } 173 | } 174 | 175 | // null byte(s) 176 | if (~path.indexOf('\0')) { 177 | return { statusCode: 400 } 178 | } 179 | 180 | let parts 181 | if (root !== null) { 182 | // normalize 183 | if (path) { 184 | path = normalize('.' + sep + path) 185 | } 186 | 187 | // malicious path 188 | if (UP_PATH_REGEXP.test(path)) { 189 | debug('malicious path "%s"', path) 190 | return { statusCode: 403 } 191 | } 192 | 193 | // explode path parts 194 | parts = path.split(sep) 195 | 196 | // join / normalize from optional root dir 197 | path = normalize(join(root, path)) 198 | } else { 199 | // ".." is malicious without "root" 200 | if (UP_PATH_REGEXP.test(path)) { 201 | debug('malicious path "%s"', path) 202 | return { statusCode: 403 } 203 | } 204 | 205 | // explode path parts 206 | parts = normalize(path).split(sep) 207 | 208 | // resolve the path 209 | path = resolve(path) 210 | } 211 | 212 | return { path, parts } 213 | } 214 | 215 | /** 216 | * Check if the pathname ends with "/". 217 | * 218 | * @return {boolean} 219 | * @private 220 | */ 221 | 222 | function hasTrailingSlash (path) { 223 | return path[path.length - 1] === '/' 224 | } 225 | 226 | /** 227 | * Check if this is a conditional GET request. 228 | * 229 | * @return {Boolean} 230 | * @api private 231 | */ 232 | 233 | function isConditionalGET (request) { 234 | return request.headers['if-match'] || 235 | request.headers['if-unmodified-since'] || 236 | request.headers['if-none-match'] || 237 | request.headers['if-modified-since'] 238 | } 239 | 240 | function isNotModifiedFailure (request, headers) { 241 | // Always return stale when Cache-Control: no-cache 242 | // to support end-to-end reload requests 243 | // https://tools.ietf.org/html/rfc2616#section-14.9.4 244 | if ( 245 | 'cache-control' in request.headers && 246 | request.headers['cache-control'].indexOf('no-cache') !== -1 247 | ) { 248 | return false 249 | } 250 | 251 | // if-none-match 252 | if ('if-none-match' in request.headers) { 253 | const ifNoneMatch = request.headers['if-none-match'] 254 | 255 | if (ifNoneMatch === '*') { 256 | return true 257 | } 258 | 259 | const etag = headers.ETag 260 | 261 | if (typeof etag !== 'string') { 262 | return false 263 | } 264 | 265 | const etagL = etag.length 266 | const isMatching = parseTokenList(ifNoneMatch, function (match) { 267 | const mL = match.length 268 | 269 | if ( 270 | (etagL === mL && match === etag) || 271 | (etagL > mL && 'W/' + match === etag) 272 | ) { 273 | return true 274 | } 275 | }) 276 | 277 | if (isMatching) { 278 | return true 279 | } 280 | 281 | /** 282 | * A recipient MUST ignore If-Modified-Since if the request contains an 283 | * If-None-Match header field; the condition in If-None-Match is considered 284 | * to be a more accurate replacement for the condition in If-Modified-Since, 285 | * and the two are only combined for the sake of interoperating with older 286 | * intermediaries that might not implement If-None-Match. 287 | * 288 | * @see RFC 9110 section 13.1.3 289 | */ 290 | return false 291 | } 292 | 293 | // if-modified-since 294 | if ('if-modified-since' in request.headers) { 295 | const ifModifiedSince = request.headers['if-modified-since'] 296 | const lastModified = headers['Last-Modified'] 297 | 298 | if (!lastModified || (Date.parse(lastModified) <= Date.parse(ifModifiedSince))) { 299 | return true 300 | } 301 | } 302 | 303 | return false 304 | } 305 | 306 | /** 307 | * Check if the request preconditions failed. 308 | * 309 | * @return {boolean} 310 | * @private 311 | */ 312 | 313 | function isPreconditionFailure (request, headers) { 314 | // if-match 315 | const ifMatch = request.headers['if-match'] 316 | if (ifMatch) { 317 | const etag = headers.ETag 318 | 319 | if (ifMatch !== '*') { 320 | const isMatching = parseTokenList(ifMatch, function (match) { 321 | if ( 322 | match === etag || 323 | 'W/' + match === etag 324 | ) { 325 | return true 326 | } 327 | }) || false 328 | 329 | if (isMatching !== true) { 330 | return true 331 | } 332 | } 333 | } 334 | 335 | // if-unmodified-since 336 | if ('if-unmodified-since' in request.headers) { 337 | const ifUnmodifiedSince = request.headers['if-unmodified-since'] 338 | const unmodifiedSince = Date.parse(ifUnmodifiedSince) 339 | // eslint-disable-next-line no-self-compare 340 | if (unmodifiedSince === unmodifiedSince) { // fast path of isNaN(number) 341 | const lastModified = Date.parse(headers['Last-Modified']) 342 | if ( 343 | // eslint-disable-next-line no-self-compare 344 | lastModified !== lastModified ||// fast path of isNaN(number) 345 | lastModified > unmodifiedSince 346 | ) { 347 | return true 348 | } 349 | } 350 | } 351 | 352 | return false 353 | } 354 | 355 | /** 356 | * Check if the range is fresh. 357 | * 358 | * @return {Boolean} 359 | * @api private 360 | */ 361 | 362 | function isRangeFresh (request, headers) { 363 | if (!('if-range' in request.headers)) { 364 | return true 365 | } 366 | 367 | const ifRange = request.headers['if-range'] 368 | 369 | // if-range as etag 370 | if (ifRange.indexOf('"') !== -1) { 371 | const etag = headers.ETag 372 | return (etag && ifRange.indexOf(etag) !== -1) || false 373 | } 374 | 375 | const ifRangeTimestamp = Date.parse(ifRange) 376 | // eslint-disable-next-line no-self-compare 377 | if (ifRangeTimestamp !== ifRangeTimestamp) { // fast path of isNaN(number) 378 | return false 379 | } 380 | 381 | // if-range as modified date 382 | const lastModified = Date.parse(headers['Last-Modified']) 383 | 384 | return ( 385 | // eslint-disable-next-line no-self-compare 386 | lastModified !== lastModified || // fast path of isNaN(number) 387 | lastModified <= ifRangeTimestamp 388 | ) 389 | } 390 | 391 | // we provide stat function that will always resolve 392 | // without throwing 393 | function tryStat (path) { 394 | return new Promise((resolve) => { 395 | fs.stat(path, function onstat (error, stat) { 396 | resolve({ error, stat }) 397 | }) 398 | }) 399 | } 400 | 401 | function sendError (statusCode, err) { 402 | const headers = {} 403 | 404 | // add error headers 405 | if (err && err.headers) { 406 | for (const headerName in err.headers) { 407 | headers[headerName] = err.headers[headerName] 408 | } 409 | } 410 | 411 | const doc = ERROR_RESPONSES[statusCode] 412 | 413 | // basic response 414 | headers['Content-Type'] = 'text/html; charset=utf-8' 415 | headers['Content-Length'] = doc[1] 416 | headers['Content-Security-Policy'] = "default-src 'none'" 417 | headers['X-Content-Type-Options'] = 'nosniff' 418 | 419 | return { 420 | statusCode, 421 | headers, 422 | stream: Readable.from(doc[0]), 423 | // metadata 424 | type: 'error', 425 | metadata: { error: createHttpError(statusCode, err) } 426 | } 427 | } 428 | 429 | function sendStatError (err) { 430 | // POSIX throws ENAMETOOLONG and ENOTDIR, Windows only ENOENT 431 | /* c8 ignore start */ 432 | switch (err.code) { 433 | case 'ENAMETOOLONG': 434 | case 'ENOTDIR': 435 | case 'ENOENT': 436 | return sendError(404, err) 437 | default: 438 | return sendError(500, err) 439 | } 440 | /* c8 ignore stop */ 441 | } 442 | 443 | /** 444 | * Respond with 304 not modified. 445 | * 446 | * @api private 447 | */ 448 | 449 | function sendNotModified (headers, path, stat) { 450 | debug('not modified') 451 | 452 | delete headers['Content-Encoding'] 453 | delete headers['Content-Language'] 454 | delete headers['Content-Length'] 455 | delete headers['Content-Range'] 456 | delete headers['Content-Type'] 457 | 458 | return { 459 | statusCode: 304, 460 | headers, 461 | stream: Readable.from(''), 462 | // metadata 463 | type: 'file', 464 | metadata: { path, stat } 465 | } 466 | } 467 | 468 | function sendFileDirectly (request, path, stat, options) { 469 | let len = stat.size 470 | let offset = options.start ?? 0 471 | 472 | let statusCode = 200 473 | const headers = {} 474 | 475 | debug('send "%s"', path) 476 | 477 | // set header fields 478 | if (options.acceptRanges) { 479 | debug('accept ranges') 480 | headers['Accept-Ranges'] = 'bytes' 481 | } 482 | 483 | if (options.cacheControl) { 484 | let cacheControl = 'public, max-age=' + Math.floor(options.maxage / 1000) 485 | 486 | if (options.immutable) { 487 | cacheControl += ', immutable' 488 | } 489 | 490 | debug('cache-control %s', cacheControl) 491 | headers['Cache-Control'] = cacheControl 492 | } 493 | 494 | if (options.lastModified) { 495 | const modified = stat.mtime.toUTCString() 496 | debug('modified %s', modified) 497 | headers['Last-Modified'] = modified 498 | } 499 | 500 | if (options.etag) { 501 | const etag = 'W/"' + stat.size.toString(16) + '-' + stat.mtime.getTime().toString(16) + '"' 502 | debug('etag %s', etag) 503 | headers.ETag = etag 504 | } 505 | 506 | // set content-type 507 | if (options.contentType) { 508 | let type = mime.getType(path) || mime.default_type 509 | debug('content-type %s', type) 510 | if (type && isUtf8MimeType(type)) { 511 | type += '; charset=utf-8' 512 | } 513 | if (type) { 514 | headers['Content-Type'] = type 515 | } 516 | } 517 | 518 | // conditional GET support 519 | if (isConditionalGET(request)) { 520 | if (isPreconditionFailure(request, headers)) { 521 | return sendError(412) 522 | } 523 | 524 | if (isNotModifiedFailure(request, headers)) { 525 | return sendNotModified(headers, path, stat) 526 | } 527 | } 528 | 529 | // adjust len to start/end options 530 | len = Math.max(0, len - offset) 531 | if (options.end !== undefined) { 532 | const bytes = options.end - offset + 1 533 | if (len > bytes) len = bytes 534 | } 535 | 536 | // Range support 537 | if (options.acceptRanges) { 538 | const rangeHeader = request.headers.range 539 | 540 | if ( 541 | rangeHeader !== undefined && 542 | BYTES_RANGE_REGEXP.test(rangeHeader) 543 | ) { 544 | // If-Range support 545 | if (isRangeFresh(request, headers)) { 546 | // parse 547 | const ranges = parseBytesRange(len, rangeHeader) 548 | 549 | // unsatisfiable 550 | if (ranges.length === 0) { 551 | debug('range unsatisfiable') 552 | 553 | // Content-Range 554 | headers['Content-Range'] = contentRange('bytes', len) 555 | 556 | // 416 Requested Range Not Satisfiable 557 | return sendError(416, { 558 | headers: { 'Content-Range': headers['Content-Range'] } 559 | }) 560 | // valid (syntactically invalid/multiple ranges are treated as a regular response) 561 | } else if (ranges.length === 1) { 562 | debug('range %j', ranges) 563 | 564 | // Content-Range 565 | statusCode = 206 566 | if (options.maxContentRangeChunkSize) { 567 | ranges[0].end = Math.min(ranges[0].end, ranges[0].start + options.maxContentRangeChunkSize - 1) 568 | } 569 | headers['Content-Range'] = contentRange('bytes', len, ranges[0]) 570 | 571 | // adjust for requested range 572 | offset += ranges[0].start 573 | len = ranges[0].end - ranges[0].start + 1 574 | } 575 | } else { 576 | debug('range stale') 577 | } 578 | } 579 | } 580 | 581 | // content-length 582 | headers['Content-Length'] = len 583 | 584 | // HEAD support 585 | if (request.method === 'HEAD') { 586 | return { 587 | statusCode, 588 | headers, 589 | stream: Readable.from(''), 590 | // metadata 591 | type: 'file', 592 | metadata: { path, stat } 593 | } 594 | } 595 | 596 | const stream = fs.createReadStream(path, { 597 | highWaterMark: options.highWaterMark, 598 | start: offset, 599 | end: Math.max(offset, offset + len - 1) 600 | }) 601 | 602 | return { 603 | statusCode, 604 | headers, 605 | stream, 606 | // metadata 607 | type: 'file', 608 | metadata: { path, stat } 609 | } 610 | } 611 | 612 | function sendRedirect (path, options) { 613 | if (hasTrailingSlash(options.path)) { 614 | return sendError(403) 615 | } 616 | 617 | const loc = encodeURI(collapseLeadingSlashes(options.path + '/')) 618 | const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc)) 619 | 620 | const headers = {} 621 | headers['Content-Type'] = 'text/html; charset=utf-8' 622 | headers['Content-Length'] = doc[1] 623 | headers['Content-Security-Policy'] = "default-src 'none'" 624 | headers['X-Content-Type-Options'] = 'nosniff' 625 | headers.Location = loc 626 | 627 | return { 628 | statusCode: 301, 629 | headers, 630 | stream: Readable.from(doc[0]), 631 | // metadata 632 | type: 'directory', 633 | metadata: { requestPath: options.path, path } 634 | } 635 | } 636 | 637 | async function sendIndex (request, path, options) { 638 | let err 639 | for (let i = 0; i < options.index.length; i++) { 640 | const index = options.index[i] 641 | const p = join(path, index) 642 | const { error, stat } = await tryStat(p) 643 | if (error) { 644 | err = error 645 | continue 646 | } 647 | if (stat.isDirectory()) continue 648 | return sendFileDirectly(request, p, stat, options) 649 | } 650 | 651 | if (err) { 652 | return sendStatError(err) 653 | } 654 | 655 | return sendError(404) 656 | } 657 | 658 | async function sendFile (request, path, options) { 659 | const { error, stat } = await tryStat(path) 660 | if (error && error.code === 'ENOENT' && !extname(path) && path[path.length - 1] !== sep) { 661 | let err = error 662 | // not found, check extensions 663 | for (let i = 0; i < options.extensions.length; i++) { 664 | const extension = options.extensions[i] 665 | const p = path + '.' + extension 666 | const { error, stat } = await tryStat(p) 667 | if (error) { 668 | err = error 669 | continue 670 | } 671 | if (stat.isDirectory()) { 672 | err = null 673 | continue 674 | } 675 | return sendFileDirectly(request, p, stat, options) 676 | } 677 | if (err) { 678 | return sendStatError(err) 679 | } 680 | return sendError(404) 681 | } 682 | if (error) return sendStatError(error) 683 | if (stat.isDirectory()) return sendRedirect(path, options) 684 | return sendFileDirectly(request, path, stat, options) 685 | } 686 | 687 | async function send (request, _path, options) { 688 | const opts = normalizeOptions(options) 689 | opts.path = _path 690 | 691 | const parsed = normalizePath(_path, opts.root) 692 | const { path, parts } = parsed 693 | if (parsed.statusCode !== undefined) { 694 | return sendError(parsed.statusCode) 695 | } 696 | 697 | // dotfile handling 698 | if ( 699 | ( 700 | debug.enabled || // if debugging is enabled, then check for all cases to log allow case 701 | opts.dotfiles !== 0 // if debugging is not enabled, then only check if 'deny' or 'ignore' is set 702 | ) && 703 | containsDotFile(parts) 704 | ) { 705 | switch (opts.dotfiles) { 706 | /* c8 ignore start */ /* unreachable, because NODE_DEBUG can not be set after process is running */ 707 | case 0: // 'allow' 708 | debug('allow dotfile "%s"', path) 709 | break 710 | /* c8 ignore stop */ 711 | case 2: // 'deny' 712 | debug('deny dotfile "%s"', path) 713 | return sendError(403) 714 | case 1: // 'ignore' 715 | default: 716 | debug('ignore dotfile "%s"', path) 717 | return sendError(404) 718 | } 719 | } 720 | 721 | // index file support 722 | if (opts.index.length && hasTrailingSlash(_path)) { 723 | return sendIndex(request, path, opts) 724 | } 725 | 726 | return sendFile(request, path, opts) 727 | } 728 | 729 | module.exports.send = send 730 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/send", 3 | "description": "Better streaming static file server with Range and conditional-GET support", 4 | "version": "4.1.0", 5 | "author": "TJ Holowaychuk ", 6 | "contributors": [ 7 | "Douglas Christopher Wilson ", 8 | "James Wyatt Cready ", 9 | "Jesús Leganés Combarro ", 10 | { 11 | "name": "Matteo Collina", 12 | "email": "hello@matteocollina.com" 13 | }, 14 | { 15 | "name": "Frazer Smith", 16 | "email": "frazer.dev@icloud.com", 17 | "url": "https://github.com/fdawgs" 18 | }, 19 | { 20 | "name": "Aras Abbasi", 21 | "email": "aras.abbasi@gmail.com" 22 | } 23 | ], 24 | "main": "index.js", 25 | "type": "commonjs", 26 | "types": "types/index.d.ts", 27 | "license": "MIT", 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/fastify/send.git" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/fastify/send/issues" 34 | }, 35 | "homepage": "https://github.com/fastify/send#readme", 36 | "funding": [ 37 | { 38 | "type": "github", 39 | "url": "https://github.com/sponsors/fastify" 40 | }, 41 | { 42 | "type": "opencollective", 43 | "url": "https://opencollective.com/fastify" 44 | } 45 | ], 46 | "keywords": [ 47 | "static", 48 | "file", 49 | "server" 50 | ], 51 | "dependencies": { 52 | "@lukeed/ms": "^2.0.2", 53 | "escape-html": "~1.0.3", 54 | "fast-decode-uri-component": "^1.0.1", 55 | "http-errors": "^2.0.0", 56 | "mime": "^3" 57 | }, 58 | "devDependencies": { 59 | "@fastify/pre-commit": "^2.1.0", 60 | "@types/node": "^22.0.0", 61 | "after": "0.8.2", 62 | "benchmark": "^2.1.4", 63 | "c8": "^10.1.3", 64 | "eslint": "^9.17.0", 65 | "neostandard": "^0.12.0", 66 | "supertest": "6.3.4", 67 | "tsd": "^0.32.0" 68 | }, 69 | "scripts": { 70 | "lint": "eslint", 71 | "lint:fix": "eslint --fix", 72 | "test": "npm run test:unit && npm run test:typescript", 73 | "test:coverage": "c8 --reporter html node --test", 74 | "test:typescript": "tsd", 75 | "test:unit": "c8 --100 node --test" 76 | }, 77 | "pre-commit": [ 78 | "lint", 79 | "test" 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /test/collapseLeadingSlashes.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { collapseLeadingSlashes } = require('../lib/collapseLeadingSlashes') 5 | 6 | test('collapseLeadingSlashes', function (t) { 7 | const testCases = [ 8 | ['abcd', 'abcd'], 9 | ['text/json', 'text/json'], 10 | ['/text/json', '/text/json'], 11 | ['//text/json', '/text/json'], 12 | ['///text/json', '/text/json'], 13 | ['/.//text/json', '/.//text/json'], 14 | ['//./text/json', '/./text/json'], 15 | ['///./text/json', '/./text/json'] 16 | ] 17 | t.plan(testCases.length) 18 | 19 | for (const testCase of testCases) { 20 | t.assert.deepStrictEqual(collapseLeadingSlashes(testCase[0]), testCase[1]) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /test/containsDotFile.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { containsDotFile } = require('../lib/containsDotFile') 5 | 6 | test('containsDotFile', function (t) { 7 | const testCases = [ 8 | ['/.github', true], 9 | ['.github', true], 10 | ['index.html', false], 11 | ['./index.html', false] 12 | ] 13 | t.plan(testCases.length) 14 | 15 | for (const testCase of testCases) { 16 | t.assert.deepStrictEqual(containsDotFile(testCase[0].split('/')), testCase[1], testCase[0]) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /test/fixtures/.hidden.txt: -------------------------------------------------------------------------------- 1 | secret -------------------------------------------------------------------------------- /test/fixtures/.mine/.hidden.txt: -------------------------------------------------------------------------------- 1 | secret 2 | -------------------------------------------------------------------------------- /test/fixtures/.mine/name.txt: -------------------------------------------------------------------------------- 1 | tobi -------------------------------------------------------------------------------- /test/fixtures/do..ts.txt: -------------------------------------------------------------------------------- 1 | ... -------------------------------------------------------------------------------- /test/fixtures/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastify/send/1b54ebfce9a47325787663f4484b54dd855fa07a/test/fixtures/empty.txt -------------------------------------------------------------------------------- /test/fixtures/images/node-js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastify/send/1b54ebfce9a47325787663f4484b54dd855fa07a/test/fixtures/images/node-js.png -------------------------------------------------------------------------------- /test/fixtures/name.d/name.txt: -------------------------------------------------------------------------------- 1 | loki -------------------------------------------------------------------------------- /test/fixtures/name.dir/name.txt: -------------------------------------------------------------------------------- 1 | tobi -------------------------------------------------------------------------------- /test/fixtures/name.html: -------------------------------------------------------------------------------- 1 |

tobi

-------------------------------------------------------------------------------- /test/fixtures/name.txt: -------------------------------------------------------------------------------- 1 | tobi -------------------------------------------------------------------------------- /test/fixtures/no_ext: -------------------------------------------------------------------------------- 1 | foobar -------------------------------------------------------------------------------- /test/fixtures/nums.txt: -------------------------------------------------------------------------------- 1 | 123456789 -------------------------------------------------------------------------------- /test/fixtures/pets/.hidden.txt: -------------------------------------------------------------------------------- 1 | secret 2 | -------------------------------------------------------------------------------- /test/fixtures/pets/index.html: -------------------------------------------------------------------------------- 1 | tobi 2 | loki 3 | jane -------------------------------------------------------------------------------- /test/fixtures/snow ☃/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastify/send/1b54ebfce9a47325787663f4484b54dd855fa07a/test/fixtures/snow ☃/index.html -------------------------------------------------------------------------------- /test/fixtures/some thing.txt: -------------------------------------------------------------------------------- 1 | hey -------------------------------------------------------------------------------- /test/fixtures/thing.html.html: -------------------------------------------------------------------------------- 1 |

trap!

-------------------------------------------------------------------------------- /test/fixtures/tobi.html: -------------------------------------------------------------------------------- 1 |

tobi

-------------------------------------------------------------------------------- /test/isUtf8MimeType.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { isUtf8MimeType } = require('../lib/isUtf8MimeType') 5 | 6 | test('isUtf8MimeType', function (t) { 7 | const testCases = [ 8 | ['application/json', true], 9 | ['text/json', true], 10 | ['application/javascript', true], 11 | ['text/javascript', true], 12 | ['application/json+v5', true], 13 | ['text/xml', true], 14 | ['text/html', true], 15 | ['image/png', false] 16 | ] 17 | t.plan(testCases.length) 18 | 19 | for (const testCase of testCases) { 20 | t.assert.deepStrictEqual(isUtf8MimeType(testCase[0], 'test'), testCase[1]) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /test/mime.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const path = require('node:path') 5 | const request = require('supertest') 6 | const send = require('..') 7 | const { shouldNotHaveHeader, createServer } = require('./utils') 8 | 9 | const fixtures = path.join(__dirname, 'fixtures') 10 | 11 | test('send.mime', async function (t) { 12 | t.plan(2) 13 | 14 | await t.test('should be exposed', function (t) { 15 | t.plan(1) 16 | t.assert.ok(send.mime) 17 | }) 18 | 19 | await t.test('.default_type', async function (t) { 20 | t.plan(3) 21 | 22 | t.before(() => { 23 | this.default_type = send.mime.default_type 24 | }) 25 | 26 | t.afterEach(() => { 27 | send.mime.default_type = this.default_type 28 | }) 29 | 30 | await t.test('should change the default type', async function (t) { 31 | send.mime.default_type = 'text/plain' 32 | 33 | await request(createServer({ root: fixtures })) 34 | .get('/no_ext') 35 | .expect('Content-Type', 'text/plain; charset=utf-8') 36 | .expect(200) 37 | }) 38 | 39 | await t.test('should not add Content-Type for undefined default', async function (t) { 40 | t.plan(1) 41 | send.mime.default_type = undefined 42 | 43 | await request(createServer({ root: fixtures })) 44 | .get('/no_ext') 45 | .expect(shouldNotHaveHeader('Content-Type', t)) 46 | .expect(200) 47 | }) 48 | 49 | await t.test('should return Content-Type without charset', async function (t) { 50 | await request(createServer({ root: fixtures })) 51 | .get('/images/node-js.png') 52 | .expect('Content-Type', 'image/png') 53 | .expect(200) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/normalizeList.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { normalizeList } = require('../lib/normalizeList') 5 | 6 | test('normalizeList', function (t) { 7 | const testCases = [ 8 | [undefined, new TypeError('test must be array of strings or false')], 9 | [false, []], 10 | [[], []], 11 | ['', ['']], 12 | [[''], ['']], 13 | [['a'], ['a']], 14 | ['a', ['a']], 15 | [true, new TypeError('test must be array of strings or false')], 16 | [1, new TypeError('test must be array of strings or false')], 17 | [[1], new TypeError('test must be array of strings or false')] 18 | ] 19 | t.plan(testCases.length) 20 | 21 | for (const testCase of testCases) { 22 | if (testCase[1] instanceof Error) { 23 | t.assert.throws(() => normalizeList(testCase[0], 'test'), testCase[1]) 24 | } else { 25 | t.assert.deepStrictEqual(normalizeList(testCase[0], 'test'), testCase[1]) 26 | } 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /test/parseBytesRange.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { parseBytesRange } = require('../lib/parseBytesRange') 5 | 6 | test('parseBytesRange', async function (t) { 7 | t.plan(13) 8 | 9 | await t.test('should return empty array if all specified ranges are invalid', function (t) { 10 | t.plan(3) 11 | t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-20'), []) 12 | t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-999'), []) 13 | t.assert.deepStrictEqual(parseBytesRange(200, 'bytes=500-999,1000-1499'), []) 14 | }) 15 | 16 | await t.test('should parse str', function (t) { 17 | t.plan(2) 18 | const range = parseBytesRange(1000, 'bytes=0-499') 19 | t.assert.deepStrictEqual(range.length, 1) 20 | t.assert.deepStrictEqual(range[0], { start: 0, end: 499, index: 0 }) 21 | }) 22 | 23 | await t.test('should cap end at size', function (t) { 24 | t.plan(2) 25 | const range = parseBytesRange(200, 'bytes=0-499') 26 | t.assert.deepStrictEqual(range.length, 1) 27 | t.assert.deepStrictEqual(range[0], { start: 0, end: 199, index: 0 }) 28 | }) 29 | 30 | await t.test('should parse str', function (t) { 31 | t.plan(2) 32 | const range = parseBytesRange(1000, 'bytes=40-80') 33 | t.assert.deepStrictEqual(range.length, 1) 34 | t.assert.deepStrictEqual(range[0], { start: 40, end: 80, index: 0 }) 35 | }) 36 | 37 | await t.test('should parse str asking for last n bytes', function (t) { 38 | t.plan(2) 39 | const range = parseBytesRange(1000, 'bytes=-400') 40 | t.assert.deepStrictEqual(range.length, 1) 41 | t.assert.deepStrictEqual(range[0], { start: 600, end: 999, index: 0 }) 42 | }) 43 | 44 | await t.test('should parse str with only start', function (t) { 45 | t.plan(2) 46 | const range = parseBytesRange(1000, 'bytes=400-') 47 | t.assert.deepStrictEqual(range.length, 1) 48 | t.assert.deepStrictEqual(range[0], { start: 400, end: 999, index: 0 }) 49 | }) 50 | 51 | await t.test('should parse "bytes=0-"', function (t) { 52 | t.plan(2) 53 | const range = parseBytesRange(1000, 'bytes=0-') 54 | t.assert.deepStrictEqual(range.length, 1) 55 | t.assert.deepStrictEqual(range[0], { start: 0, end: 999, index: 0 }) 56 | }) 57 | 58 | await t.test('should parse str with no bytes', function (t) { 59 | t.plan(2) 60 | const range = parseBytesRange(1000, 'bytes=0-0') 61 | t.assert.deepStrictEqual(range.length, 1) 62 | t.assert.deepStrictEqual(range[0], { start: 0, end: 0, index: 0 }) 63 | }) 64 | 65 | await t.test('should parse str asking for last byte', function (t) { 66 | t.plan(2) 67 | const range = parseBytesRange(1000, 'bytes=-1') 68 | t.assert.deepStrictEqual(range.length, 1) 69 | t.assert.deepStrictEqual(range[0], { start: 999, end: 999, index: 0 }) 70 | }) 71 | 72 | await t.test('should parse str with some invalid ranges', function (t) { 73 | t.plan(2) 74 | const range = parseBytesRange(200, 'bytes=0-499,1000-,500-999') 75 | t.assert.deepStrictEqual(range.length, 1) 76 | t.assert.deepStrictEqual(range[0], { start: 0, end: 199, index: 0 }) 77 | }) 78 | 79 | await t.test('should combine overlapping ranges', function (t) { 80 | t.plan(3) 81 | const range = parseBytesRange(150, 'bytes=0-4,90-99,5-75,100-199,101-102') 82 | t.assert.deepStrictEqual(range.length, 2) 83 | t.assert.deepStrictEqual(range[0], { start: 0, end: 75, index: 0 }) 84 | t.assert.deepStrictEqual(range[1], { start: 90, end: 149, index: 1 }) 85 | }) 86 | 87 | await t.test('should retain original order /1', function (t) { 88 | t.plan(3) 89 | const range = parseBytesRange(150, 'bytes=90-99,5-75,100-199,101-102,0-4') 90 | t.assert.deepStrictEqual(range.length, 2) 91 | t.assert.deepStrictEqual(range[0], { start: 90, end: 149, index: 0 }) 92 | t.assert.deepStrictEqual(range[1], { start: 0, end: 75, index: 1 }) 93 | }) 94 | 95 | await t.test('should retain original order /2', function (t) { 96 | t.plan(4) 97 | const range = parseBytesRange(150, 'bytes=-1,20-100,0-1,101-120') 98 | t.assert.deepStrictEqual(range.length, 3) 99 | t.assert.deepStrictEqual(range[0], { start: 149, end: 149, index: 0 }) 100 | t.assert.deepStrictEqual(range[1], { start: 20, end: 120, index: 1 }) 101 | t.assert.deepStrictEqual(range[2], { start: 0, end: 1, index: 2 }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /test/send.1.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const fs = require('node:fs') 5 | const http = require('node:http') 6 | const path = require('node:path') 7 | const request = require('supertest') 8 | const { send } = require('..') 9 | const { shouldNotHaveHeader, createServer } = require('./utils') 10 | const { getDefaultHighWaterMark } = require('node:stream') 11 | 12 | // test server 13 | 14 | const fixtures = path.join(__dirname, 'fixtures') 15 | 16 | test('send(file, options)', async function (t) { 17 | t.plan(12) 18 | 19 | await t.test('acceptRanges', async function (t) { 20 | t.plan(6) 21 | 22 | await t.test('should support disabling accept-ranges', async function (t) { 23 | t.plan(1) 24 | 25 | await request(createServer({ acceptRanges: false, root: fixtures })) 26 | .get('/nums.txt') 27 | .expect(shouldNotHaveHeader('Accept-Ranges', t)) 28 | .expect(200) 29 | }) 30 | 31 | await t.test('should ignore requested range', async function (t) { 32 | t.plan(2) 33 | 34 | await request(createServer({ acceptRanges: false, root: fixtures })) 35 | .get('/nums.txt') 36 | .set('Range', 'bytes=0-2') 37 | .expect(shouldNotHaveHeader('Accept-Ranges', t)) 38 | .expect(shouldNotHaveHeader('Content-Range', t)) 39 | .expect(200, '123456789') 40 | }) 41 | 42 | await t.test('should limit high return size /1', async function (t) { 43 | t.plan(3) 44 | 45 | await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) 46 | .get('/nums.txt') 47 | .set('Range', 'bytes=0-2') 48 | .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) 49 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 0-0/9')) 50 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) 51 | .expect(206, '1') 52 | }) 53 | 54 | await t.test('should limit high return size /2', async function (t) { 55 | t.plan(3) 56 | 57 | await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) 58 | .get('/nums.txt') 59 | .set('Range', 'bytes=1-2') 60 | .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) 61 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-1/9')) 62 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) 63 | .expect(206, '2') 64 | }) 65 | 66 | await t.test('should limit high return size /3', async function (t) { 67 | t.plan(3) 68 | 69 | await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 1, root: fixtures })) 70 | .get('/nums.txt') 71 | .set('Range', 'bytes=1-3') 72 | .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) 73 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-1/9')) 74 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '1', 'should content-length must be as same as maxContentRangeChunkSize')) 75 | .expect(206, '2') 76 | }) 77 | 78 | await t.test('should limit high return size /4', async function (t) { 79 | t.plan(3) 80 | 81 | await request(createServer({ acceptRanges: true, maxContentRangeChunkSize: 4, root: fixtures })) 82 | .get('/nums.txt') 83 | .set('Range', 'bytes=1-2,3-6') 84 | .expect((res) => t.assert.deepStrictEqual(res.headers['accept-ranges'], 'bytes')) 85 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-range'], 'bytes 1-4/9')) 86 | .expect((res) => t.assert.deepStrictEqual(res.headers['content-length'], '4', 'should content-length must be as same as maxContentRangeChunkSize')) 87 | .expect(206, '2345') 88 | }) 89 | }) 90 | 91 | await t.test('cacheControl', async function (t) { 92 | t.plan(2) 93 | 94 | await t.test('should support disabling cache-control', async function (t) { 95 | t.plan(1) 96 | await request(createServer({ cacheControl: false, root: fixtures })) 97 | .get('/name.txt') 98 | .expect(shouldNotHaveHeader('Cache-Control', t)) 99 | .expect(200) 100 | }) 101 | 102 | await t.test('should ignore maxAge option', async function (t) { 103 | t.plan(1) 104 | 105 | await request(createServer({ cacheControl: false, maxAge: 1000, root: fixtures })) 106 | .get('/name.txt') 107 | .expect(shouldNotHaveHeader('Cache-Control', t)) 108 | .expect(200) 109 | }) 110 | }) 111 | 112 | await t.test('contentType', async function (t) { 113 | t.plan(1) 114 | 115 | await t.test('should support disabling content-type', async function (t) { 116 | t.plan(1) 117 | 118 | await request(createServer({ contentType: false, root: fixtures })) 119 | .get('/name.txt') 120 | .expect(shouldNotHaveHeader('Content-Type', t)) 121 | .expect(200) 122 | }) 123 | }) 124 | 125 | await t.test('etag', async function (t) { 126 | t.plan(1) 127 | 128 | await t.test('should support disabling etags', async function (t) { 129 | t.plan(1) 130 | 131 | await request(createServer({ etag: false, root: fixtures })) 132 | .get('/name.txt') 133 | .expect(shouldNotHaveHeader('ETag', t)) 134 | .expect(200) 135 | }) 136 | }) 137 | 138 | await t.test('extensions', async function (t) { 139 | t.plan(9) 140 | 141 | await t.test('should reject numbers', async function (t) { 142 | await request(createServer({ extensions: 42, root: fixtures })) 143 | .get('/pets/') 144 | .expect(500, /TypeError: extensions option/) 145 | }) 146 | 147 | await t.test('should reject true', async function (t) { 148 | await request(createServer({ extensions: true, root: fixtures })) 149 | .get('/pets/') 150 | .expect(500, /TypeError: extensions option/) 151 | }) 152 | 153 | await t.test('should be not be enabled by default', async function (t) { 154 | await request(createServer({ root: fixtures })) 155 | .get('/tobi') 156 | .expect(404) 157 | }) 158 | 159 | await t.test('should be configurable', async function (t) { 160 | await request(createServer({ extensions: 'txt', root: fixtures })) 161 | .get('/name') 162 | .expect(200, 'tobi') 163 | }) 164 | 165 | await t.test('should support disabling extensions', async function (t) { 166 | await request(createServer({ extensions: false, root: fixtures })) 167 | .get('/name') 168 | .expect(404) 169 | }) 170 | 171 | await t.test('should support fallbacks', async function (t) { 172 | await request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) 173 | .get('/name') 174 | .expect(200, '

tobi

') 175 | }) 176 | 177 | await t.test('should 404 if nothing found', async function (t) { 178 | await request(createServer({ extensions: ['htm', 'html', 'txt'], root: fixtures })) 179 | .get('/bob') 180 | .expect(404) 181 | }) 182 | 183 | await t.test('should skip directories', async function (t) { 184 | await request(createServer({ extensions: ['file', 'dir'], root: fixtures })) 185 | .get('/name') 186 | .expect(404) 187 | }) 188 | 189 | await t.test('should not search if file has extension', async function (t) { 190 | await request(createServer({ extensions: 'html', root: fixtures })) 191 | .get('/thing.html') 192 | .expect(404) 193 | }) 194 | }) 195 | 196 | await t.test('lastModified', async function (t) { 197 | t.plan(1) 198 | 199 | await t.test('should support disabling last-modified', async function (t) { 200 | t.plan(1) 201 | 202 | await request(createServer({ lastModified: false, root: fixtures })) 203 | .get('/name.txt') 204 | .expect(shouldNotHaveHeader('Last-Modified', t)) 205 | .expect(200) 206 | }) 207 | }) 208 | 209 | await t.test('dotfiles', async function (t) { 210 | t.plan(5) 211 | 212 | await t.test('should default to "ignore"', async function (t) { 213 | await request(createServer({ root: fixtures })) 214 | .get('/.hidden.txt') 215 | .expect(404) 216 | }) 217 | 218 | await t.test('should reject bad value', async function (t) { 219 | await request(createServer({ dotfiles: 'bogus' })) 220 | .get('/name.txt') 221 | .expect(500, /dotfiles/) 222 | }) 223 | 224 | await t.test('when "allow"', async function (t) { 225 | t.plan(3) 226 | 227 | await t.test('should send dotfile', async function (t) { 228 | await request(createServer({ dotfiles: 'allow', root: fixtures })) 229 | .get('/.hidden.txt') 230 | .expect(200, 'secret') 231 | }) 232 | 233 | await t.test('should send within dotfile directory', async function (t) { 234 | await request(createServer({ dotfiles: 'allow', root: fixtures })) 235 | .get('/.mine/name.txt') 236 | .expect(200, /tobi/) 237 | }) 238 | 239 | await t.test('should 404 for non-existent dotfile', async function (t) { 240 | await request(createServer({ dotfiles: 'allow', root: fixtures })) 241 | .get('/.nothere') 242 | .expect(404) 243 | }) 244 | }) 245 | 246 | await t.test('when "deny"', async function (t) { 247 | t.plan(10) 248 | 249 | await t.test('should 403 for dotfile', async function (t) { 250 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 251 | .get('/.hidden.txt') 252 | .expect(403) 253 | }) 254 | 255 | await t.test('should 403 for dotfile directory', async function (t) { 256 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 257 | .get('/.mine') 258 | .expect(403) 259 | }) 260 | 261 | await t.test('should 403 for dotfile directory with trailing slash', async function (t) { 262 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 263 | .get('/.mine/') 264 | .expect(403) 265 | }) 266 | 267 | await t.test('should 403 for file within dotfile directory', async function (t) { 268 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 269 | .get('/.mine/name.txt') 270 | .expect(403) 271 | }) 272 | 273 | await t.test('should 403 for non-existent dotfile', async function (t) { 274 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 275 | .get('/.nothere') 276 | .expect(403) 277 | }) 278 | 279 | await t.test('should 403 for non-existent dotfile directory', async function (t) { 280 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 281 | .get('/.what/name.txt') 282 | .expect(403) 283 | }) 284 | 285 | await t.test('should 403 for dotfile in directory', async function (t) { 286 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 287 | .get('/pets/.hidden.txt') 288 | .expect(403) 289 | }) 290 | 291 | await t.test('should 403 for dotfile in dotfile directory', async function (t) { 292 | await request(createServer({ dotfiles: 'deny', root: fixtures })) 293 | .get('/.mine/.hidden.txt') 294 | .expect(403) 295 | }) 296 | 297 | await t.test('should send files in root dotfile directory', async function (t) { 298 | await request(createServer({ dotfiles: 'deny', root: path.join(fixtures, '.mine') })) 299 | .get('/name.txt') 300 | .expect(200, /tobi/) 301 | }) 302 | 303 | await t.test('should 403 for dotfile without root', async function (t) { 304 | const server = http.createServer(async function onRequest (req, res) { 305 | const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'deny' }) 306 | res.writeHead(statusCode, headers) 307 | stream.pipe(res) 308 | }) 309 | 310 | await request(server) 311 | .get('/name.txt') 312 | .expect(403) 313 | }) 314 | }) 315 | 316 | await t.test('when "ignore"', async function (t) { 317 | t.plan(8) 318 | 319 | await t.test('should 404 for dotfile', async function (t) { 320 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 321 | .get('/.hidden.txt') 322 | .expect(404) 323 | }) 324 | 325 | await t.test('should 404 for dotfile directory', async function (t) { 326 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 327 | .get('/.mine') 328 | .expect(404) 329 | }) 330 | 331 | await t.test('should 404 for dotfile directory with trailing slash', async function (t) { 332 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 333 | .get('/.mine/') 334 | .expect(404) 335 | }) 336 | 337 | await t.test('should 404 for file within dotfile directory', async function (t) { 338 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 339 | .get('/.mine/name.txt') 340 | .expect(404) 341 | }) 342 | 343 | await t.test('should 404 for non-existent dotfile', async function (t) { 344 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 345 | .get('/.nothere') 346 | .expect(404) 347 | }) 348 | 349 | await t.test('should 404 for non-existent dotfile directory', async function (t) { 350 | await request(createServer({ dotfiles: 'ignore', root: fixtures })) 351 | .get('/.what/name.txt') 352 | .expect(404) 353 | }) 354 | 355 | await t.test('should send files in root dotfile directory', async function (t) { 356 | await request(createServer({ dotfiles: 'ignore', root: path.join(fixtures, '.mine') })) 357 | .get('/name.txt') 358 | .expect(200, /tobi/) 359 | }) 360 | 361 | await t.test('should 404 for dotfile without root', async function (t) { 362 | const server = http.createServer(async function onRequest (req, res) { 363 | const { statusCode, headers, stream } = await send(req, fixtures + '/.mine' + req.url, { dotfiles: 'ignore' }) 364 | res.writeHead(statusCode, headers) 365 | stream.pipe(res) 366 | }) 367 | 368 | await request(server) 369 | .get('/name.txt') 370 | .expect(404) 371 | }) 372 | }) 373 | }) 374 | 375 | await t.test('immutable', async function (t) { 376 | t.plan(2) 377 | 378 | await t.test('should default to false', async function (t) { 379 | await request(createServer({ root: fixtures })) 380 | .get('/name.txt') 381 | .expect('Cache-Control', 'public, max-age=0') 382 | }) 383 | 384 | await t.test('should set immutable directive in Cache-Control', async function (t) { 385 | await request(createServer({ immutable: true, maxAge: '1h', root: fixtures })) 386 | .get('/name.txt') 387 | .expect('Cache-Control', 'public, max-age=3600, immutable') 388 | }) 389 | }) 390 | 391 | await t.test('maxAge', async function (t) { 392 | t.plan(4) 393 | 394 | await t.test('should default to 0', async function (t) { 395 | await request(createServer({ root: fixtures })) 396 | .get('/name.txt') 397 | .expect('Cache-Control', 'public, max-age=0') 398 | }) 399 | 400 | await t.test('should floor to integer', async function (t) { 401 | await request(createServer({ maxAge: 123956, root: fixtures })) 402 | .get('/name.txt') 403 | .expect('Cache-Control', 'public, max-age=123') 404 | }) 405 | 406 | await t.test('should accept string', async function (t) { 407 | await request(createServer({ maxAge: '30d', root: fixtures })) 408 | .get('/name.txt') 409 | .expect('Cache-Control', 'public, max-age=2592000') 410 | }) 411 | 412 | await t.test('should max at 1 year', async function (t) { 413 | await request(createServer({ maxAge: '2y', root: fixtures })) 414 | .get('/name.txt') 415 | .expect('Cache-Control', 'public, max-age=31536000') 416 | }) 417 | }) 418 | 419 | await t.test('index', async function (t) { 420 | t.plan(10) 421 | 422 | await t.test('should reject numbers', async function (t) { 423 | await request(createServer({ root: fixtures, index: 42 })) 424 | .get('/pets/') 425 | .expect(500, /TypeError: index option/) 426 | }) 427 | 428 | await t.test('should reject true', async function (t) { 429 | await request(createServer({ root: fixtures, index: true })) 430 | .get('/pets/') 431 | .expect(500, /TypeError: index option/) 432 | }) 433 | 434 | await t.test('should default to index.html', async function (t) { 435 | await request(createServer({ root: fixtures })) 436 | .get('/pets/') 437 | .expect(fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) 438 | }) 439 | 440 | await t.test('should be configurable', async function (t) { 441 | await request(createServer({ root: fixtures, index: 'tobi.html' })) 442 | .get('/') 443 | .expect(200, '

tobi

') 444 | }) 445 | 446 | await t.test('should support disabling', async function (t) { 447 | await request(createServer({ root: fixtures, index: false })) 448 | .get('/pets/') 449 | .expect(403) 450 | }) 451 | 452 | await t.test('should support fallbacks', async function (t) { 453 | await request(createServer({ root: fixtures, index: ['default.htm', 'index.html'] })) 454 | .get('/pets/') 455 | .expect(200, fs.readFileSync(path.join(fixtures, 'pets', 'index.html'), 'utf8')) 456 | }) 457 | 458 | await t.test('should 404 if no index file found (file)', async function (t) { 459 | await request(createServer({ root: fixtures, index: 'default.htm' })) 460 | .get('/pets/') 461 | .expect(404) 462 | }) 463 | 464 | await t.test('should 404 if no index file found (dir)', async function (t) { 465 | await request(createServer({ root: fixtures, index: 'pets' })) 466 | .get('/') 467 | .expect(404) 468 | }) 469 | 470 | await t.test('should not follow directories', async function (t) { 471 | await request(createServer({ root: fixtures, index: ['pets', 'name.txt'] })) 472 | .get('/') 473 | .expect(200, 'tobi') 474 | }) 475 | 476 | await t.test('should work without root', async function (t) { 477 | const server = http.createServer(async function (req, res) { 478 | const p = path.join(fixtures, 'pets').replace(/\\/g, '/') + '/' 479 | const { statusCode, headers, stream } = await send(req, p, { index: ['index.html'] }) 480 | res.writeHead(statusCode, headers) 481 | stream.pipe(res) 482 | }) 483 | 484 | await request(server) 485 | .get('/') 486 | .expect(200, /tobi/) 487 | }) 488 | }) 489 | 490 | await t.test('root', async function (t) { 491 | t.plan(2) 492 | 493 | await t.test('when given', async function (t) { 494 | t.plan(8) 495 | 496 | await t.test('should join root', async function (t) { 497 | await request(createServer({ root: fixtures })) 498 | .get('/pets/../name.txt') 499 | .expect(200, 'tobi') 500 | }) 501 | 502 | await t.test('should work with trailing slash', async function (t) { 503 | const app = http.createServer(async function (req, res) { 504 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/' }) 505 | res.writeHead(statusCode, headers) 506 | stream.pipe(res) 507 | }) 508 | 509 | await request(app) 510 | .get('/name.txt') 511 | .expect(200, 'tobi') 512 | }) 513 | 514 | await t.test('should work with empty path', async function (t) { 515 | const app = http.createServer(async function (req, res) { 516 | const { statusCode, headers, stream } = await send(req, '', { root: fixtures }) 517 | res.writeHead(statusCode, headers) 518 | stream.pipe(res) 519 | }) 520 | 521 | await request(app) 522 | .get('/name.txt') 523 | .expect(301, /Redirecting to/) 524 | }) 525 | 526 | // 527 | // NOTE: This is not a real part of the API, but 528 | // over time this has become something users 529 | // are doing, so this will prevent unseen 530 | // regressions around this use-case. 531 | // 532 | await t.test('should try as file with empty path', async function (t) { 533 | const app = http.createServer(async function (req, res) { 534 | const { statusCode, headers, stream } = await send(req, '', { root: path.join(fixtures, 'name.txt') }) 535 | res.writeHead(statusCode, headers) 536 | stream.pipe(res) 537 | }) 538 | 539 | await request(app) 540 | .get('/') 541 | .expect(200, 'tobi') 542 | }) 543 | 544 | await t.test('should restrict paths to within root', async function (t) { 545 | await request(createServer({ root: fixtures })) 546 | .get('/pets/../../send.js') 547 | .expect(403) 548 | }) 549 | 550 | await t.test('should allow .. in root', async function (t) { 551 | const app = http.createServer(async function (req, res) { 552 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/../fixtures' }) 553 | res.writeHead(statusCode, headers) 554 | stream.pipe(res) 555 | }) 556 | 557 | await request(app) 558 | .get('/pets/../../send.js') 559 | .expect(403) 560 | }) 561 | 562 | await t.test('should not allow root transversal', async function (t) { 563 | await request(createServer({ root: path.join(fixtures, 'name.d') })) 564 | .get('/../name.dir/name.txt') 565 | .expect(403) 566 | }) 567 | 568 | await t.test('should not allow root path disclosure', async function (t) { 569 | await request(createServer({ root: fixtures })) 570 | .get('/pets/../../fixtures/name.txt') 571 | .expect(403) 572 | }) 573 | }) 574 | 575 | await t.test('when missing', async function (t) { 576 | t.plan(2) 577 | 578 | await t.test('should consider .. malicious', async function (t) { 579 | const app = http.createServer(async function (req, res) { 580 | const { statusCode, headers, stream } = await send(req, fixtures + req.url) 581 | res.writeHead(statusCode, headers) 582 | stream.pipe(res) 583 | }) 584 | 585 | await request(app) 586 | .get('/../send.js') 587 | .expect(403) 588 | }) 589 | 590 | await t.test('should still serve files with dots in name', async function (t) { 591 | const app = http.createServer(async function (req, res) { 592 | const { statusCode, headers, stream } = await send(req, fixtures + req.url) 593 | res.writeHead(statusCode, headers) 594 | stream.pipe(res) 595 | }) 596 | 597 | await request(app) 598 | .get('/do..ts.txt') 599 | .expect(200, '...') 600 | }) 601 | }) 602 | }) 603 | 604 | await t.test('highWaterMark', async function (t) { 605 | t.plan(3) 606 | 607 | await t.test('should support highWaterMark', async function (t) { 608 | t.plan(1) 609 | const app = http.createServer(async function (req, res) { 610 | const { statusCode, headers, stream } = await send(req, req.url, { highWaterMark: 512 * 1024, root: fixtures + '/' }) 611 | res.writeHead(statusCode, headers) 612 | t.assert.deepStrictEqual(stream.readableHighWaterMark, 524288) 613 | stream.pipe(res) 614 | }) 615 | await request(app) 616 | .get('/name.txt') 617 | .expect(200, 'tobi') 618 | }) 619 | 620 | await t.test('should use default value', async function (t) { 621 | t.plan(1) 622 | const app = http.createServer(async function (req, res) { 623 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures + '/' }) 624 | res.writeHead(statusCode, headers) 625 | t.assert.deepStrictEqual(stream.readableHighWaterMark, getDefaultHighWaterMark(false)) 626 | stream.pipe(res) 627 | }) 628 | await request(app) 629 | .get('/name.txt') 630 | .expect(200, 'tobi') 631 | }) 632 | 633 | await t.test('should ignore negative number', async function (t) { 634 | t.plan(1) 635 | const app = http.createServer(async function (req, res) { 636 | const { statusCode, headers, stream } = await send(req, req.url, { highWaterMark: -54, root: fixtures + '/' }) 637 | res.writeHead(statusCode, headers) 638 | t.assert.deepStrictEqual(stream.readableHighWaterMark, getDefaultHighWaterMark(false)) 639 | stream.pipe(res) 640 | }) 641 | await request(app) 642 | .get('/name.txt') 643 | .expect(200, 'tobi') 644 | }) 645 | }) 646 | }) 647 | -------------------------------------------------------------------------------- /test/send.2.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const http = require('node:http') 5 | const path = require('node:path') 6 | const request = require('supertest') 7 | const send = require('../lib/send').send 8 | const { shouldNotHaveBody, createServer, shouldNotHaveHeader } = require('./utils') 9 | 10 | const dateRegExp = /^\w{3}, \d+ \w+ \d+ \d+:\d+:\d+ \w+$/ 11 | const fixtures = path.join(__dirname, 'fixtures') 12 | 13 | test('send(file)', async function (t) { 14 | t.plan(22) 15 | 16 | await t.test('should stream the file contents', async function (t) { 17 | const app = http.createServer(async function (req, res) { 18 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 19 | res.writeHead(statusCode, headers) 20 | stream.pipe(res) 21 | }) 22 | 23 | await request(app) 24 | .get('/name.txt') 25 | .expect('Content-Length', '4') 26 | .expect(200, 'tobi') 27 | }) 28 | 29 | await t.test('should stream a zero-length file', async function (t) { 30 | const app = http.createServer(async function (req, res) { 31 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 32 | res.writeHead(statusCode, headers) 33 | stream.pipe(res) 34 | }) 35 | 36 | await request(app) 37 | .get('/empty.txt') 38 | .expect('Content-Length', '0') 39 | .expect(200, '') 40 | }) 41 | 42 | await t.test('should decode the given path as a URI', async function (t) { 43 | const app = http.createServer(async function (req, res) { 44 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 45 | res.writeHead(statusCode, headers) 46 | stream.pipe(res) 47 | }) 48 | 49 | await request(app) 50 | .get('/some%20thing.txt') 51 | .expect(200, 'hey') 52 | }) 53 | 54 | await t.test('should serve files with dots in name', async function (t) { 55 | const app = http.createServer(async function (req, res) { 56 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 57 | res.writeHead(statusCode, headers) 58 | stream.pipe(res) 59 | }) 60 | 61 | await request(app) 62 | .get('/do..ts.txt') 63 | .expect(200, '...') 64 | }) 65 | 66 | await t.test('should treat a malformed URI as a bad request', async function (t) { 67 | const app = http.createServer(async function (req, res) { 68 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 69 | res.writeHead(statusCode, headers) 70 | stream.pipe(res) 71 | }) 72 | 73 | await request(app) 74 | .get('/some%99thing.txt') 75 | .expect(400, /Bad Request/) 76 | }) 77 | 78 | await t.test('should 400 on NULL bytes', async function (t) { 79 | const app = http.createServer(async function (req, res) { 80 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 81 | res.writeHead(statusCode, headers) 82 | stream.pipe(res) 83 | }) 84 | 85 | await request(app) 86 | .get('/some%00thing.txt') 87 | .expect(400, /Bad Request/) 88 | }) 89 | 90 | await t.test('should treat an ENAMETOOLONG as a 404', async function (t) { 91 | const app = http.createServer(async function (req, res) { 92 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 93 | res.writeHead(statusCode, headers) 94 | stream.pipe(res) 95 | }) 96 | 97 | const path = Array(100).join('foobar') 98 | await request(app) 99 | .get('/' + path) 100 | .expect(404) 101 | }) 102 | 103 | await t.test('should support HEAD', async function (t) { 104 | t.plan(1) 105 | 106 | const app = http.createServer(async function (req, res) { 107 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 108 | res.writeHead(statusCode, headers) 109 | stream.pipe(res) 110 | }) 111 | 112 | await request(app) 113 | .head('/name.txt') 114 | .expect(200) 115 | .expect('Content-Length', '4') 116 | .expect(shouldNotHaveBody(t)) 117 | }) 118 | 119 | await t.test('should add an ETag header field', async function (t) { 120 | const app = http.createServer(async function (req, res) { 121 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 122 | res.writeHead(statusCode, headers) 123 | stream.pipe(res) 124 | }) 125 | 126 | await request(app) 127 | .get('/name.txt') 128 | .expect('etag', /^W\/"[^"]+"$/) 129 | }) 130 | 131 | await t.test('should add a Date header field', async function (t) { 132 | const app = http.createServer(async function (req, res) { 133 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 134 | res.writeHead(statusCode, headers) 135 | stream.pipe(res) 136 | }) 137 | 138 | await request(app) 139 | .get('/name.txt') 140 | .expect('date', dateRegExp) 141 | }) 142 | 143 | await t.test('should add a Last-Modified header field', async function (t) { 144 | const app = http.createServer(async function (req, res) { 145 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 146 | res.writeHead(statusCode, headers) 147 | stream.pipe(res) 148 | }) 149 | 150 | await request(app) 151 | .get('/name.txt') 152 | .expect('last-modified', dateRegExp) 153 | }) 154 | 155 | await t.test('should add a Accept-Ranges header field', async function (t) { 156 | const app = http.createServer(async function (req, res) { 157 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 158 | res.writeHead(statusCode, headers) 159 | stream.pipe(res) 160 | }) 161 | 162 | await request(app) 163 | .get('/name.txt') 164 | .expect('Accept-Ranges', 'bytes') 165 | }) 166 | 167 | await t.test('should 404 if the file does not exist', async function (t) { 168 | const app = http.createServer(async function (req, res) { 169 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 170 | res.writeHead(statusCode, headers) 171 | stream.pipe(res) 172 | }) 173 | 174 | await request(app) 175 | .get('/meow') 176 | .expect(404, /Not Found/) 177 | }) 178 | 179 | await t.test('should 404 if the filename is too long', async function (t) { 180 | const app = http.createServer(async function (req, res) { 181 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 182 | res.writeHead(statusCode, headers) 183 | stream.pipe(res) 184 | }) 185 | 186 | const longFilename = new Array(512).fill('a').join('') 187 | 188 | await request(app) 189 | .get('/' + longFilename) 190 | .expect(404, /Not Found/) 191 | }) 192 | 193 | await t.test('should 404 if the requested resource is not a directory', async function (t) { 194 | const app = http.createServer(async function (req, res) { 195 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 196 | res.writeHead(statusCode, headers) 197 | stream.pipe(res) 198 | }) 199 | 200 | await request(app) 201 | .get('/nums.txt/invalid') 202 | .expect(404, /Not Found/) 203 | }) 204 | 205 | await t.test('should not override content-type', async function (t) { 206 | const app = http.createServer(async function (req, res) { 207 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 208 | res.writeHead(statusCode, { 209 | ...headers, 210 | 'Content-Type': 'application/x-custom' 211 | }) 212 | stream.pipe(res) 213 | }) 214 | await request(app) 215 | .get('/name.txt') 216 | .expect('Content-Type', 'application/x-custom') 217 | }) 218 | 219 | await t.test('should set Content-Type via mime map', async function (t) { 220 | const app = http.createServer(async function (req, res) { 221 | const { statusCode, headers, stream } = await send(req, req.url, { root: fixtures }) 222 | res.writeHead(statusCode, headers) 223 | stream.pipe(res) 224 | }) 225 | 226 | await request(app) 227 | .get('/name.txt') 228 | .expect('Content-Type', 'text/plain; charset=utf-8') 229 | .expect(200) 230 | 231 | await request(app) 232 | .get('/tobi.html') 233 | .expect('Content-Type', 'text/html; charset=utf-8') 234 | .expect(200) 235 | }) 236 | 237 | await t.test('send directory', async function (t) { 238 | t.plan(5) 239 | 240 | await t.test('should redirect directories to trailing slash', async function (t) { 241 | await request(createServer({ root: fixtures })) 242 | .get('/pets') 243 | .expect('Location', '/pets/') 244 | .expect(301) 245 | }) 246 | 247 | await t.test('should respond with an HTML redirect', async function (t) { 248 | await request(createServer({ root: fixtures })) 249 | .get('/pets') 250 | .expect('Location', '/pets/') 251 | .expect('Content-Type', /html/) 252 | .expect(301, />Redirecting to \/pets\/Redirecting to \/snow%20%E2%98%83\/Not Found 2 | // Piotr Błażejewicz 3 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 4 | 5 | /// 6 | 7 | import { Dirent } from 'node:fs' 8 | import * as stream from 'node:stream' 9 | 10 | /** 11 | * Create a new SendStream for the given path to send to a res. 12 | * The req is the Node.js HTTP request and the path is a urlencoded path to send (urlencoded, not the actual file-system path). 13 | */ 14 | declare function send (req: stream.Readable, path: string, options?: send.SendOptions): Promise 15 | 16 | type Send = typeof send 17 | 18 | declare class Mime { 19 | constructor (typeMap: TypeMap, ...mimes: TypeMap[]) 20 | 21 | getType (path: string): string | null 22 | getExtension (mime: string): string | null 23 | define (typeMap: TypeMap, force?: boolean): void 24 | } 25 | 26 | interface TypeMap { 27 | [key: string]: string[]; 28 | } 29 | 30 | declare namespace send { 31 | export const mime: Mime 32 | export const isUtf8MimeType: (value: string) => boolean 33 | 34 | export interface SendOptions { 35 | /** 36 | * Enable or disable accepting ranged requests, defaults to true. 37 | * Disabling this will not send Accept-Ranges and ignore the contents of the Range request header. 38 | */ 39 | acceptRanges?: boolean | undefined; 40 | 41 | /** 42 | * Enable or disable setting Cache-Control response header, defaults to true. 43 | * Disabling this will ignore the maxAge option. 44 | */ 45 | cacheControl?: boolean | undefined; 46 | 47 | /** 48 | * Enable or disable setting Content-Type response header, defaults to true. 49 | */ 50 | contentType?: boolean | undefined; 51 | 52 | /** 53 | * Set how "dotfiles" are treated when encountered. 54 | * A dotfile is a file or directory that begins with a dot ("."). 55 | * Note this check is done on the path itself without checking if the path actually exists on the disk. 56 | * If root is specified, only the dotfiles above the root are checked (i.e. the root itself can be within a dotfile when when set to "deny"). 57 | * 'allow' No special treatment for dotfiles. 58 | * 'deny' Send a 403 for any request for a dotfile. 59 | * 'ignore' Pretend like the dotfile does not exist and 404. 60 | * The default value is similar to 'ignore', with the exception that this default will not ignore the files within a directory that begins with a dot, for backward-compatibility. 61 | */ 62 | dotfiles?: 'allow' | 'deny' | 'ignore' | undefined; 63 | 64 | /** 65 | * Byte offset at which the stream ends, defaults to the length of the file minus 1. 66 | * The end is inclusive in the stream, meaning end: 3 will include the 4th byte in the stream. 67 | */ 68 | end?: number | undefined; 69 | 70 | /** 71 | * Enable or disable etag generation, defaults to true. 72 | */ 73 | etag?: boolean | undefined; 74 | 75 | /** 76 | * If a given file doesn't exist, try appending one of the given extensions, in the given order. 77 | * By default, this is disabled (set to false). 78 | * An example value that will serve extension-less HTML files: ['html', 'htm']. 79 | * This is skipped if the requested file already has an extension. 80 | */ 81 | extensions?: string[] | string | boolean | undefined; 82 | 83 | /** 84 | * Enable or disable the immutable directive in the Cache-Control response header, defaults to false. 85 | * If set to true, the maxAge option should also be specified to enable caching. 86 | * The immutable directive will prevent supported clients from making conditional requests during the life of the maxAge option to check if the file has changed. 87 | * @default false 88 | */ 89 | immutable?: boolean | undefined; 90 | 91 | /** 92 | * By default send supports "index.html" files, to disable this set false or to supply a new index pass a string or an array in preferred order. 93 | */ 94 | index?: string[] | string | boolean | undefined; 95 | 96 | /** 97 | * Enable or disable Last-Modified header, defaults to true. 98 | * Uses the file system's last modified value. 99 | */ 100 | lastModified?: boolean | undefined; 101 | 102 | /** 103 | * Provide a max-age in milliseconds for http caching, defaults to 0. 104 | * This can also be a string accepted by the ms module. 105 | */ 106 | maxAge?: string | number | undefined; 107 | 108 | /** 109 | * Limit max response content size when acceptRanges is true, defaults to the entire file size. 110 | */ 111 | maxContentRangeChunkSize?: number | undefined; 112 | 113 | /** 114 | * Serve files relative to path. 115 | */ 116 | root?: string | undefined; 117 | 118 | /** 119 | * Byte offset at which the stream starts, defaults to 0. 120 | * The start is inclusive, meaning start: 2 will include the 3rd byte in the stream. 121 | */ 122 | start?: number | undefined; 123 | 124 | /** 125 | * Maximum number of bytes that the internal buffer will hold. 126 | * If omitted, Node.js falls back to its built-in default. 127 | */ 128 | highWaterMark?: number | undefined; 129 | } 130 | 131 | export interface BaseSendResult { 132 | statusCode: number 133 | headers: Record 134 | stream: stream.Readable 135 | } 136 | 137 | export interface FileSendResult extends BaseSendResult { 138 | type: 'file' 139 | metadata: { 140 | path: string 141 | stat: Dirent 142 | } 143 | } 144 | 145 | export interface DirectorySendResult extends BaseSendResult { 146 | type: 'directory' 147 | metadata: { 148 | path: string 149 | requestPath: string 150 | } 151 | } 152 | 153 | export interface ErrorSendResult extends BaseSendResult { 154 | type: 'error' 155 | metadata: { 156 | error: Error 157 | } 158 | } 159 | 160 | export type SendResult = FileSendResult | DirectorySendResult | ErrorSendResult 161 | 162 | export const send: Send 163 | 164 | export { send as default } 165 | } 166 | 167 | export = send 168 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { Dirent } from 'node:fs' 2 | import { resolve } from 'node:path' 3 | import { Readable } from 'node:stream' 4 | import { expectType } from 'tsd' 5 | import send, { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from '..' 6 | 7 | send.mime.define({ 8 | 'application/x-my-type': ['x-mt', 'x-mtt'] 9 | }) 10 | 11 | expectType<(value: string) => boolean>(send.isUtf8MimeType) 12 | expectType(send.isUtf8MimeType('application/json')) 13 | 14 | const req: any = {} 15 | 16 | { 17 | const result = await send(req, '/test.html', { 18 | acceptRanges: true, 19 | maxContentRangeChunkSize: 10, 20 | immutable: true, 21 | maxAge: 0, 22 | root: resolve(__dirname, '/wwwroot') 23 | }) 24 | 25 | expectType(result) 26 | expectType(result.statusCode) 27 | expectType>(result.headers) 28 | expectType(result.stream) 29 | } 30 | 31 | { 32 | const result = await send(req, '/test.html', { contentType: true, maxAge: 0, root: resolve(__dirname, '/wwwroot') }) 33 | 34 | expectType(result) 35 | expectType(result.statusCode) 36 | expectType>(result.headers) 37 | expectType(result.stream) 38 | } 39 | 40 | { 41 | const result = await send(req, '/test.html', { contentType: false, root: resolve(__dirname, '/wwwroot') }) 42 | 43 | expectType(result) 44 | expectType(result.statusCode) 45 | expectType>(result.headers) 46 | expectType(result.stream) 47 | } 48 | 49 | const result = await send(req, '/test.html') 50 | switch (result.type) { 51 | case 'file': { 52 | expectType(result) 53 | expectType(result.metadata.path) 54 | expectType(result.metadata.stat) 55 | break 56 | } 57 | case 'directory': { 58 | expectType(result) 59 | expectType(result.metadata.path) 60 | expectType(result.metadata.requestPath) 61 | break 62 | } 63 | case 'error': { 64 | expectType(result) 65 | expectType(result.metadata.error) 66 | } 67 | } 68 | --------------------------------------------------------------------------------