├── .dockerignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── models │ │ └── file.entity.ts └── main.ts ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | 👋 Hi! This file was autogenerated by tslint-to-eslint-config. 3 | https://github.com/typescript-eslint/tslint-to-eslint-config 4 | 5 | It represents the closest reasonable ESLint configuration to this 6 | project's original TSLint configuration. 7 | 8 | We recommend eventually switching this configuration to extend from 9 | the recommended rulesets in typescript-eslint. 10 | https://github.com/typescript-eslint/tslint-to-eslint-config/blob/master/docs/FAQs.md 11 | 12 | Happy linting! 💖 13 | */ 14 | module.exports = { 15 | "env": { 16 | "browser": true, 17 | "es6": true, 18 | "node": true 19 | }, 20 | "extends": [ 21 | "plugin:@typescript-eslint/recommended", 22 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 23 | ], 24 | "parser": "@typescript-eslint/parser", 25 | "parserOptions": { 26 | "project": "tsconfig.json", 27 | "sourceType": "module" 28 | }, 29 | "plugins": [ 30 | "eslint-plugin-import", 31 | "eslint-plugin-jsdoc", 32 | "eslint-plugin-prefer-arrow", 33 | "@typescript-eslint" 34 | ], 35 | "rules": { 36 | "@typescript-eslint/adjacent-overload-signatures": "error", 37 | "@typescript-eslint/array-type": [ 38 | "error", 39 | { 40 | "default": "array" 41 | } 42 | ], 43 | "@typescript-eslint/ban-types": [ 44 | "error", 45 | { 46 | "types": { 47 | "Object": { 48 | "message": "Avoid using the `Object` type. Did you mean `object`?" 49 | }, 50 | "Function": { 51 | "message": "Avoid using the `Function` type. Prefer a specific function type, like `() => void`." 52 | }, 53 | "Boolean": { 54 | "message": "Avoid using the `Boolean` type. Did you mean `boolean`?" 55 | }, 56 | "Number": { 57 | "message": "Avoid using the `Number` type. Did you mean `number`?" 58 | }, 59 | "String": { 60 | "message": "Avoid using the `String` type. Did you mean `string`?" 61 | }, 62 | "Symbol": { 63 | "message": "Avoid using the `Symbol` type. Did you mean `symbol`?" 64 | } 65 | } 66 | } 67 | ], 68 | "@typescript-eslint/consistent-type-assertions": "error", 69 | "@typescript-eslint/dot-notation": "error", 70 | "@typescript-eslint/explicit-member-accessibility": [ 71 | "off", 72 | { 73 | "accessibility": "explicit" 74 | } 75 | ], 76 | "@typescript-eslint/member-delimiter-style": [ 77 | "off", 78 | { 79 | "multiline": { 80 | "delimiter": "none", 81 | "requireLast": true 82 | }, 83 | "singleline": { 84 | "delimiter": "semi", 85 | "requireLast": false 86 | } 87 | } 88 | ], 89 | "@typescript-eslint/member-ordering": "off", 90 | "@typescript-eslint/naming-convention": "off", 91 | "@typescript-eslint/no-empty-function": "error", 92 | "@typescript-eslint/no-empty-interface": "error", 93 | "@typescript-eslint/no-explicit-any": "off", 94 | "@typescript-eslint/no-misused-new": "error", 95 | "@typescript-eslint/no-namespace": "error", 96 | "@typescript-eslint/no-parameter-properties": "off", 97 | "@typescript-eslint/no-shadow": [ 98 | "error", 99 | { 100 | "hoist": "all" 101 | } 102 | ], 103 | "@typescript-eslint/no-unused-expressions": "error", 104 | "@typescript-eslint/no-use-before-define": "off", 105 | "@typescript-eslint/no-var-requires": "error", 106 | "@typescript-eslint/prefer-for-of": "error", 107 | "@typescript-eslint/prefer-function-type": "error", 108 | "@typescript-eslint/prefer-namespace-keyword": "error", 109 | "@typescript-eslint/quotes": [ 110 | "error", 111 | "single" 112 | ], 113 | "@typescript-eslint/semi": [ 114 | "off", 115 | null 116 | ], 117 | "@typescript-eslint/triple-slash-reference": [ 118 | "error", 119 | { 120 | "path": "always", 121 | "types": "prefer-import", 122 | "lib": "always" 123 | } 124 | ], 125 | "@typescript-eslint/unified-signatures": "error", 126 | "arrow-parens": [ 127 | "off", 128 | "always" 129 | ], 130 | "complexity": "off", 131 | "constructor-super": "error", 132 | "dot-notation": "error", 133 | "eqeqeq": [ 134 | "error", 135 | "smart" 136 | ], 137 | "guard-for-in": "error", 138 | "id-blacklist": [ 139 | "error", 140 | "any", 141 | "Number", 142 | "number", 143 | "String", 144 | "string", 145 | "Boolean", 146 | "boolean", 147 | "Undefined", 148 | "undefined" 149 | ], 150 | "id-match": "error", 151 | "import/order": "off", 152 | "jsdoc/check-alignment": "error", 153 | "jsdoc/check-indentation": "error", 154 | "jsdoc/newline-after-description": "error", 155 | "max-classes-per-file": [ 156 | "error", 157 | 1 158 | ], 159 | "max-len": [ 160 | "error", 161 | { 162 | "code": 160 163 | } 164 | ], 165 | "new-parens": "error", 166 | "no-bitwise": "error", 167 | "no-caller": "error", 168 | "no-cond-assign": "error", 169 | "no-console": "error", 170 | "no-debugger": "error", 171 | "no-empty": "error", 172 | "no-empty-function": "error", 173 | "no-eval": "error", 174 | "no-fallthrough": "off", 175 | "no-invalid-this": "off", 176 | "no-new-wrappers": "error", 177 | "no-shadow": "error", 178 | "no-throw-literal": "error", 179 | "no-trailing-spaces": "error", 180 | "no-undef-init": "error", 181 | "no-underscore-dangle": "error", 182 | "no-unsafe-finally": "error", 183 | "no-unused-expressions": "error", 184 | "no-unused-labels": "error", 185 | "no-use-before-define": "off", 186 | "no-var": "error", 187 | "object-shorthand": "error", 188 | "one-var": [ 189 | "error", 190 | "never" 191 | ], 192 | "prefer-arrow/prefer-arrow-functions": "error", 193 | "prefer-const": "error", 194 | "quotes": "error", 195 | "radix": "error", 196 | "semi": "off", 197 | "spaced-comment": [ 198 | "error", 199 | "always", 200 | { 201 | "markers": [ 202 | "/" 203 | ] 204 | } 205 | ], 206 | "use-isnan": "error", 207 | "valid-typeof": "off" 208 | } 209 | }; 210 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [davidschuette] 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: [ 'javascript' ] 34 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 35 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # Next.js build output 78 | .next 79 | 80 | # Nuxt.js build / generate output 81 | .nuxt 82 | dist 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | 105 | # IDE 106 | .vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false, 5 | "tabWidth": 2, 6 | "jsxSingleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach by Process ID", 11 | "processId": "${command:PickProcess}", 12 | "skipFiles": ["/**"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ########################## 2 | # Build Stage 3 | 4 | FROM node:16-alpine 5 | 6 | # Create app directory 7 | WORKDIR /usr/src/api 8 | 9 | # Install app dependencies 10 | 11 | COPY ./package*.json ./ 12 | RUN npm ci --no-progress --no-audit --prefer-offline 13 | 14 | COPY ./tsconfig*.json ./ 15 | COPY ./tslint*.json ./ 16 | 17 | # Bundle app source 18 | COPY ./src/. ./src 19 | 20 | # Run build 21 | RUN npm run build 22 | 23 | ########################## 24 | # Run Stage 25 | 26 | FROM node:16-alpine 27 | 28 | WORKDIR /usr/src/api 29 | 30 | # Install packages needed for production 31 | COPY --from=0 /usr/src/api/package*.json ./ 32 | RUN npm ci --only-production --no-progress --no-audit --prefer-offline 33 | 34 | # Copy compiled files 35 | COPY --from=0 /usr/src/api/dist . 36 | 37 | ENV NODE_ENV=docker 38 | 39 | # Start process 40 | CMD node src/main.js 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 davidschuette 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS File Streaming 2 | 3 | [![CodeQL](https://github.com/davidschuette/nestjs-file-streaming/actions/workflows/codeql-analysis.yml/badge.svg?branch=master)](https://github.com/davidschuette/nestjs-file-streaming/actions/workflows/codeql-analysis.yml) 4 | 5 | ## Features 6 | 7 | - Efficient upload / download 8 | - Very low RAM usage 9 | - Great for providing large files without storing them in the filesystem 10 | - Can be used to efficiently stream video files (skipping in the timeline will result in a partial download) 11 | - Accepts `range` header to support partial downloads 12 | 13 | ## Used packages 14 | 15 | - [`Fastify Adapter`](https://www.npmjs.com/package/fastify) for performance 16 | - [`Mongoose`](https://www.npmjs.com/package/mongoose) to connect to MongoDB 17 | - [`MongoDB GridFS`](https://www.npmjs.com/package/mongoose) for streaming chunked files to and from Mongo DB 18 | - [`fastify-multipart`](https://www.npmjs.com/package/fastify-multipart) to parse Multipart forms 19 | 20 | ## Setup 21 | 22 | ### Docker 23 | 24 | - `docker-compose up -d` 25 | - Swagger documentation can be found at `http://localhost:3101/api/` 26 | 27 | ### Local 28 | 29 | - Start a MongoDB instance with default configuration 30 | - Use `npm start` to compile and start the server 31 | - Swagger documentation can be found at `http://localhost:3101/api/` 32 | 33 | ## Usage 34 | 35 | - Upload a file: `POST` to `http://localhost:3101/` as multipart/form-data with `file` field 36 | - Download an uploaded file: `GET` to `http://localhost:3101/` 37 | - `GET` to `http://localhost:3101` for list of uploaded videos 38 | - More information can be found in the Swagger Documentation 39 | 40 | ## _Caution! This is not a production grade server_ 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | networks: 4 | demo-net: 5 | name: demo-net 6 | driver: bridge 7 | 8 | volumes: 9 | streaming-api-db-data: 10 | 11 | services: 12 | streaming-api: 13 | container_name: streaming-api 14 | image: streaming-api 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | restart: always 19 | networks: 20 | - demo-net 21 | ports: 22 | - 3101:3101 23 | depends_on: 24 | - streaming-api-db 25 | 26 | streaming-api-db: 27 | container_name: streaming-api-db 28 | image: mongo:5.0.1 29 | restart: always 30 | volumes: 31 | - streaming-api-db-data:/data/db 32 | networks: 33 | - demo-net 34 | 35 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "video-streaming", 3 | "version": "1.0.0", 4 | "description": "Can be found in README.md.", 5 | "author": "David Schütte", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "npm run build && npm run start:prod", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/src/main", 15 | "lint": "tslint -p tsconfig.json -c tslint.json" 16 | }, 17 | "dependencies": { 18 | "@nestjs/common": "^8.0.4", 19 | "@nestjs/core": "^8.0.4", 20 | "@nestjs/mongoose": "^9.0.0", 21 | "@nestjs/platform-fastify": "^8.0.4", 22 | "@nestjs/swagger": "^5.0.8", 23 | "fastify-multipart": "^5.2.1", 24 | "fastify-swagger": "^4.12.6", 25 | "mongoose": "^6.1.1", 26 | "reflect-metadata": "^0.1.13", 27 | "rimraf": "^3.0.2", 28 | "rxjs": "^7.4.0" 29 | }, 30 | "devDependencies": { 31 | "@nestjs/cli": "^8.1.6", 32 | "@nestjs/schematics": "^8.0.5", 33 | "@types/node": "^16.11.13", 34 | "@typescript-eslint/eslint-plugin": "^5.7.0", 35 | "@typescript-eslint/parser": "^5.7.0", 36 | "eslint": "^8.4.1", 37 | "prettier": "^2.5.1", 38 | "ts-loader": "^9.2.6", 39 | "ts-node": "^10.4.0", 40 | "tsconfig-paths": "^3.12.0", 41 | "typescript": "^4.5.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { AppController } from './app.controller' 3 | import { AppService } from './app.service' 4 | 5 | describe('AppController', () => { 6 | let appController: AppController 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile() 13 | 14 | appController = app.get(AppController) 15 | }) 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!') 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | Post, 6 | Req, 7 | Res, 8 | StreamableFile, 9 | } from '@nestjs/common' 10 | import { 11 | ApiConsumes, 12 | ApiCreatedResponse, 13 | ApiOkResponse, 14 | ApiOperation, 15 | ApiTags, 16 | } from '@nestjs/swagger' 17 | import { FastifyReply, FastifyRequest } from 'fastify' 18 | import { AppService } from './app.service' 19 | import { File } from './models/file.entity' 20 | 21 | type Request = FastifyRequest 22 | type Response = FastifyReply 23 | 24 | @ApiTags('File') 25 | @Controller() 26 | export class AppController { 27 | constructor(private readonly appService: AppService) {} 28 | 29 | @ApiOperation({ 30 | summary: 'Upload a file.', 31 | requestBody: { 32 | content: { 33 | 'multipart/form-data': { 34 | schema: { 35 | type: 'object', 36 | properties: { file: { type: 'string', format: 'binary' } }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }) 42 | @ApiConsumes('multipart/form-data') 43 | @ApiCreatedResponse({ 44 | schema: { 45 | properties: { 46 | id: { 47 | type: 'string', 48 | example: '5e2b4cb75876c93e38b6e6aa', 49 | }, 50 | }, 51 | }, 52 | }) 53 | @Post() 54 | uploadFile(@Req() request: Request): Promise<{ id: string }> { 55 | return this.appService.upload(request) 56 | } 57 | 58 | @ApiOperation({ 59 | summary: 'Get a list of all uploaded files.', 60 | }) 61 | @ApiOkResponse({ 62 | schema: { 63 | type: 'array', 64 | items: { 65 | type: 'object', 66 | properties: { 67 | _id: { type: 'string', example: '5e2b447e4aadb800bccfb339' }, 68 | length: { type: 'number', example: 730416 }, 69 | chunkSize: { type: 'number', example: 261120 }, 70 | uploadDate: { type: 'Date', example: '2020-01-24T19:24:46.366Z' }, 71 | filename: { type: 'string', example: 'IMG_0359.jpeg' }, 72 | md5: { type: 'string', example: 'ba230f0322784443c84ffbc5b6160c30' }, 73 | contentType: { type: 'string', example: 'image/jpeg' }, 74 | }, 75 | }, 76 | }, 77 | }) 78 | @Get() 79 | getAllFiles(): Promise { 80 | return this.appService.getList() 81 | } 82 | 83 | @ApiOperation({ summary: 'Download a file.' }) 84 | @Get(':id') 85 | downloadFile( 86 | @Param('id') id: string, 87 | @Req() request: Request, 88 | @Res({ passthrough: true }) response: Response, 89 | ): Promise { 90 | return this.appService.download(id, request, response) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { MongooseModule } from '@nestjs/mongoose' 3 | import { AppController } from './app.controller' 4 | import { AppService } from './app.service' 5 | import { FileModel } from './models/file.entity' 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forRoot( 10 | `mongodb://${ 11 | process.env.NODE_ENV === 'docker' ? 'streaming-api-db' : 'localhost' 12 | }:27017/streaming`, 13 | { 14 | useUnifiedTopology: true, 15 | useNewUrlParser: true, 16 | }, 17 | ), 18 | MongooseModule.forFeature([{ name: 'fs.files', schema: FileModel }]), 19 | ], 20 | controllers: [AppController], 21 | providers: [AppService], 22 | }) 23 | export class AppModule {} 24 | -------------------------------------------------------------------------------- /src/app/app.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | ServiceUnavailableException, 6 | StreamableFile, 7 | } from '@nestjs/common' 8 | import { InjectConnection, InjectModel } from '@nestjs/mongoose' 9 | import { FastifyReply, FastifyRequest } from 'fastify' 10 | import { GridFSBucket, ObjectId } from 'mongodb' 11 | import { Connection, Model, mongo } from 'mongoose' 12 | import { Stream } from 'stream' 13 | import { File } from './models/file.entity' 14 | 15 | type Request = FastifyRequest 16 | type Response = FastifyReply 17 | 18 | @Injectable() 19 | export class AppService { 20 | private readonly bucket: GridFSBucket 21 | 22 | constructor( 23 | @InjectModel('fs.files') private readonly fileModel: Model, 24 | @InjectConnection() private readonly connection: Connection, 25 | ) { 26 | this.bucket = new mongo.GridFSBucket(this.connection.db) 27 | } 28 | 29 | async upload(request: Request): Promise<{ id: string }> { 30 | return new Promise((resolve, reject) => { 31 | try { 32 | request.multipart( 33 | (field, file: Stream, filename, encoding, mimetype) => { 34 | const id = new ObjectId() 35 | const uploadStream = this.bucket.openUploadStreamWithId( 36 | id, 37 | filename, 38 | { 39 | contentType: mimetype, 40 | }, 41 | ) 42 | 43 | file.on('end', () => { 44 | resolve({ 45 | id: uploadStream.id.toString(), 46 | }) 47 | }) 48 | 49 | file.pipe(uploadStream) 50 | }, 51 | (err) => { 52 | console.error(err) 53 | reject(new ServiceUnavailableException()) 54 | }, 55 | ) 56 | } catch (e) { 57 | console.error(e) 58 | reject(new ServiceUnavailableException()) 59 | } 60 | }) 61 | } 62 | 63 | async download( 64 | id: string, 65 | request: Request, 66 | response: Response, 67 | ): Promise { 68 | try { 69 | if (!ObjectId.isValid(id)) { 70 | throw new BadRequestException(null, 'InvalidVideoId') 71 | } 72 | 73 | const oId = new ObjectId(id) 74 | const fileInfo = await this.fileModel.findOne({ _id: id }).exec() 75 | 76 | if (!fileInfo) { 77 | throw new NotFoundException(null, 'VideoNotFound') 78 | } 79 | 80 | if (request.headers.range) { 81 | const range = request.headers.range.substr(6).split('-') 82 | const start = parseInt(range[0], 10) 83 | const end = parseInt(range[1], 10) || null 84 | const readstream = this.bucket.openDownloadStream(oId, { 85 | start, 86 | end, 87 | }) 88 | 89 | response.status(206) 90 | response.headers({ 91 | 'Accept-Ranges': 'bytes', 92 | 'Content-Type': fileInfo.contentType, 93 | 'Content-Range': `bytes ${start}-${end ? end : fileInfo.length - 1}/${ 94 | fileInfo.length 95 | }`, 96 | 'Content-Length': (end ? end : fileInfo.length) - start, 97 | 'Content-Disposition': `attachment; filename="${fileInfo.filename}"`, 98 | }) 99 | 100 | return new StreamableFile(readstream) 101 | } else { 102 | const readstream = this.bucket.openDownloadStream(oId) 103 | 104 | response.status(200) 105 | response.headers({ 106 | 'Accept-Range': 'bytes', 107 | 'Content-Type': fileInfo.contentType, 108 | 'Content-Length': fileInfo.length, 109 | 'Content-Disposition': `attachment; filename="${fileInfo.filename}"`, 110 | }) 111 | 112 | response.send(readstream) 113 | } 114 | } catch (e) { 115 | console.error(e) 116 | throw new ServiceUnavailableException() 117 | } 118 | } 119 | 120 | getList() { 121 | return this.fileModel.find().exec() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/app/models/file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaTypes, Document } from 'mongoose' 2 | 3 | export const FileModel = new Schema({ 4 | _id: SchemaTypes.ObjectId, 5 | length: SchemaTypes.Number, 6 | chunkSize: SchemaTypes.Number, 7 | uploadDate: SchemaTypes.Date, 8 | filename: SchemaTypes.String, 9 | md5: SchemaTypes.String, 10 | contentType: SchemaTypes.String, 11 | }) 12 | 13 | export interface File extends Document { 14 | _id: string 15 | length: number 16 | chunkSize: number 17 | uploadDate: string 18 | filename: string 19 | md5: string 20 | contentType: string 21 | } 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core' 2 | import { 3 | FastifyAdapter, 4 | NestFastifyApplication, 5 | } from '@nestjs/platform-fastify' 6 | import { AppModule } from './app/app.module' 7 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' 8 | import fastifyMulipart from 'fastify-multipart' 9 | import { version } from '../package.json' 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create( 13 | AppModule, 14 | new FastifyAdapter(), 15 | ) 16 | 17 | app.enableShutdownHooks() 18 | 19 | app.register(fastifyMulipart) 20 | 21 | const options = new DocumentBuilder() 22 | .setTitle('NestJS Fastify Streaming Server') 23 | .setDescription('Stream files to and from a MongoDB.') 24 | .setVersion(version) 25 | .addTag('File') 26 | .build() 27 | const document = SwaggerModule.createDocument(app, options) 28 | SwaggerModule.setup('api', app, document) 29 | 30 | await app.listen(3101, '0.0.0.0', () => { 31 | console.log('Server listening at http://0.0.0.0:' + 3101 + '/api/') 32 | }) 33 | } 34 | bootstrap() 35 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "resolveJsonModule": true 14 | }, 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 160], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false, 16 | "semicolon": false 17 | }, 18 | "rulesDirectory": [] 19 | } 20 | --------------------------------------------------------------------------------