├── .dockerignore ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── deploy-dev.yml │ ├── deploy-prod.yml │ ├── lint.yml │ ├── pretty.yml │ ├── tests.yml │ └── typescript.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.Lambda ├── LICENSE ├── README.md ├── aws └── ecs │ └── taskdef-dev.json ├── docker-compose.yml ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── dev │ └── demo.ts ├── lambda.ts ├── manifests │ ├── handlers │ │ ├── dash │ │ │ ├── index.ts │ │ │ ├── segment.test.ts │ │ │ └── segment.ts │ │ └── hls │ │ │ ├── master.test.ts │ │ │ ├── master.ts │ │ │ ├── media.test.ts │ │ │ └── media.ts │ ├── routes │ │ ├── index.ts │ │ └── routeConstants.ts │ └── utils │ │ ├── configs.test.ts │ │ ├── configs.ts │ │ ├── corruptions │ │ ├── delay.test.ts │ │ ├── delay.ts │ │ ├── statusCode.test.ts │ │ ├── statusCode.ts │ │ ├── throttle.test.ts │ │ ├── throttle.ts │ │ ├── timeout.test.ts │ │ └── timeout.ts │ │ ├── dashManifestUtils.test.ts │ │ ├── dashManifestUtils.ts │ │ ├── hlsManifestUtils.test.ts │ │ └── hlsManifestUtils.ts ├── routes.ts ├── segments │ ├── constants.ts │ ├── handlers │ │ ├── segment.ts │ │ └── segments.test.ts │ └── routes │ │ ├── index.ts │ │ └── throttlingProxy.ts ├── server.test.ts ├── server.ts ├── shared │ ├── aws.utils.ts │ ├── types.ts │ ├── utils.test.ts │ └── utils.ts └── testvectors │ ├── dash │ ├── dash1_compressed │ │ ├── manifest.xml │ │ └── proxy-manifest.xml │ ├── dash1_multitrack │ │ ├── manifest.xml │ │ └── proxy-manifest.xml │ ├── dash1_relative_sequence │ │ ├── manifest.xml │ │ └── proxy-manifest.xml │ └── dash_period_baseurl │ │ ├── manifest.xml │ │ └── proxy-manifest.xml │ └── hls │ ├── hls1_cmaf │ ├── manifest.m3u8 │ └── manifest_1.m3u8 │ ├── hls1_multitrack │ ├── manifest.m3u8 │ ├── manifest_1.m3u8 │ ├── manifest_2.m3u8 │ ├── manifest_3.m3u8 │ ├── manifest_audio-en.m3u8 │ ├── manifest_audio-sv.m3u8 │ └── manifest_subs-en.m3u8 │ └── hls2_multitrack │ ├── manifest.m3u8 │ └── manifest_1.m3u8 └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .git 4 | .DS_Store 5 | dev 6 | .env 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "plugins": ["@typescript-eslint", "prettier"], 14 | "rules": { 15 | "prettier/prettier": "error" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/deploy-dev.yml: -------------------------------------------------------------------------------- 1 | name: Build image and deploy to dev environment 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*-dev' 7 | 8 | env: 9 | AWS_REGION: eu-north-1 10 | ECR_REGISTRY: 590877988961.dkr.ecr.eu-north-1.amazonaws.com 11 | ECR_REPOSITORY: chaos-stream-proxy 12 | ECS_SERVICE: chaos-stream-proxy 13 | ECS_CLUSTER: development 14 | ECS_TASK_DEFINITION: aws/ecs/taskdef-dev.json 15 | CONTAINER_NAME: chaos-stream-proxy 16 | 17 | defaults: 18 | run: 19 | shell: bash 20 | 21 | jobs: 22 | buildpushdeploy: 23 | name: Build, Push and Deploy 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | 30 | - name: Get the tag 31 | id: get_tag 32 | run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3) 33 | 34 | - name: Configure AWS ECR credentials 35 | uses: aws-actions/configure-aws-credentials@v1 36 | with: 37 | aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }} 38 | aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }} 39 | aws-region: ${{ env.AWS_REGION }} 40 | 41 | - name: Login to Amazon ECR 42 | id: login-ecr 43 | uses: aws-actions/amazon-ecr-login@v1 44 | 45 | - name: Build docker image and push to Amazon ECR 46 | uses: mr-smithers-excellent/docker-build-push@v5 47 | with: 48 | image: ${{ env.ECR_REPOSITORY }} 49 | registry: ${{ steps.login-ecr.outputs.registry }} 50 | dockerfile: Dockerfile 51 | 52 | - name: Configure AWS ECS credentials 53 | uses: aws-actions/configure-aws-credentials@v1 54 | with: 55 | aws-access-key-id: ${{ secrets.ECS_AWS_ACCESS_KEY_ID }} 56 | aws-secret-access-key: ${{ secrets.ECS_AWS_SECRET_ACCESS_KEY }} 57 | aws-region: ${{ env.AWS_REGION }} 58 | 59 | - name: Fill in the new image ID in the Amazon ECS task definition 60 | id: task-def 61 | uses: aws-actions/amazon-ecs-render-task-definition@v1 62 | with: 63 | task-definition: ${{ env.ECS_TASK_DEFINITION }} 64 | container-name: ${{ env.CONTAINER_NAME }} 65 | image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.get_tag.outputs.TAG }} 66 | 67 | - name: Deploy Amazon ECS task definition 68 | uses: aws-actions/amazon-ecs-deploy-task-definition@v1 69 | with: 70 | task-definition: ${{ steps.task-def.outputs.task-definition }} 71 | service: ${{ env.ECS_SERVICE }} 72 | cluster: ${{ env.ECS_CLUSTER }} 73 | wait-for-service-stability: true 74 | -------------------------------------------------------------------------------- /.github/workflows/deploy-prod.yml: -------------------------------------------------------------------------------- 1 | name: Build image and deploy to prod environment 2 | 3 | on: 4 | release: 5 | types: [ "published" ] 6 | 7 | env: 8 | AWS_REGION: eu-north-1 9 | ECR_REGISTRY: 590877988961.dkr.ecr.eu-north-1.amazonaws.com 10 | ECR_REPOSITORY: chaos-stream-proxy 11 | ECS_SERVICE: chaos-stream-proxy 12 | ECS_CLUSTER: production 13 | ECS_TASK_DEFINITION: aws/ecs/taskdef-dev.json 14 | CONTAINER_NAME: chaos-stream-proxy 15 | 16 | defaults: 17 | run: 18 | shell: bash 19 | 20 | jobs: 21 | buildpushdeploy: 22 | name: Build, Push and Deploy 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | - name: Get the tag 30 | id: get_tag 31 | run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3) 32 | 33 | - name: Configure AWS ECR credentials 34 | uses: aws-actions/configure-aws-credentials@v1 35 | with: 36 | aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }} 37 | aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }} 38 | aws-region: ${{ env.AWS_REGION }} 39 | 40 | - name: Login to Amazon ECR 41 | id: login-ecr 42 | uses: aws-actions/amazon-ecr-login@v1 43 | 44 | - name: Build docker image and push to Amazon ECR 45 | uses: mr-smithers-excellent/docker-build-push@v5 46 | with: 47 | image: ${{ env.ECR_REPOSITORY }} 48 | registry: ${{ steps.login-ecr.outputs.registry }} 49 | dockerfile: Dockerfile 50 | 51 | - name: Configure AWS ECS credentials 52 | uses: aws-actions/configure-aws-credentials@v1 53 | with: 54 | aws-access-key-id: ${{ secrets.ECS_AWS_ACCESS_KEY_ID }} 55 | aws-secret-access-key: ${{ secrets.ECS_AWS_SECRET_ACCESS_KEY }} 56 | aws-region: ${{ env.AWS_REGION }} 57 | 58 | - name: Fill in the new image ID in the Amazon ECS task definition 59 | id: task-def 60 | uses: aws-actions/amazon-ecs-render-task-definition@v1 61 | with: 62 | task-definition: ${{ env.ECS_TASK_DEFINITION }} 63 | container-name: ${{ env.CONTAINER_NAME }} 64 | image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.get_tag.outputs.TAG }} 65 | 66 | - name: Deploy Amazon ECS task definition 67 | uses: aws-actions/amazon-ecs-deploy-task-definition@v1 68 | with: 69 | task-definition: ${{ steps.task-def.outputs.task-definition }} 70 | service: ${{ env.ECS_SERVICE }} 71 | cluster: ${{ env.ECS_CLUSTER }} 72 | wait-for-service-stability: true 73 | 74 | publish: 75 | name: Push Docker Image to Docker Hub 76 | runs-on: ubuntu-latest 77 | 78 | steps: 79 | - name: Checkout 80 | uses: actions/checkout@v3 81 | 82 | - name: Set up QEMU 83 | uses: docker/setup-qemu-action@v2 84 | 85 | - name: Set up Docker Buildx 86 | uses: docker/setup-buildx-action@v2 87 | 88 | - name: Log in to Docker Hub 89 | uses: docker/login-action@v2 90 | with: 91 | username: ${{ secrets.HUB_DOCKER_USERNAME }} 92 | password: ${{ secrets.HUB_DOCKER_PASSWORD }} 93 | 94 | - name: Extract metadata (tags, labels) for Docker 95 | id: meta 96 | uses: docker/metadata-action@v4 97 | with: 98 | images: eyevinntechnology/chaos-stream-proxy 99 | 100 | - name: Build and push Docker image 101 | uses: docker/build-push-action@v3 102 | with: 103 | context: . 104 | push: true 105 | tags: ${{ steps.meta.outputs.tags }} 106 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: [pull_request] 3 | 4 | jobs: 5 | lint: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Eslint 17 | run: npm run lint 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/pretty.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | on: [pull_request] 3 | 4 | jobs: 5 | pretty: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Prettier 17 | run: npm run pretty 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [14.x, 16.x] 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: npm install and test 24 | run: | 25 | npm install 26 | npm test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.github/workflows/typescript.yml: -------------------------------------------------------------------------------- 1 | name: Type Check 2 | on: [pull_request] 3 | 4 | jobs: 5 | ts: 6 | if: "!contains(github.event.pull_request.title, 'WIP!')" 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Use Node.js 11 | uses: actions/setup-node@v3 12 | with: 13 | node-version: '18.x' 14 | - name: Install Dependencies 15 | run: npm ci 16 | - name: Run Type Check 17 | run: npm run typecheck -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | dist/ 4 | .env 5 | dev 6 | .history/ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.yml 3 | README.md 4 | .history/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Submitting Issues 2 | 3 | We use GitHub issues to track public bugs. If you are submitting a bug, please provide 4 | as much info as possible to make it easier to reproduce the issue and update unit tests. 5 | 6 | # Contributing Code 7 | 8 | This project uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary). 9 | 10 | We follow the [GitHub Flow](https://guides.github.com/introduction/flow/index.html) so all contributions happen through pull requests. We actively welcome your pull requests: 11 | 12 | 1. Fork the repo and create your branch from master. 13 | 2. If you've added code that should be tested, add tests. 14 | 3. If you've changed APIs, update the documentation. 15 | 4. Ensure the test suite passes. 16 | 5. Issue that pull request! 17 | 18 | When submitting code changes your submissions are understood to be under the same MIT License that covers the project. Feel free to contact Eyevinn Technology if that's a concern. 19 | 20 | # Code of Conduct 21 | 22 | ## Our Pledge 23 | 24 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 25 | 26 | ## Our Standards 27 | 28 | Examples of behavior that contributes to creating a positive environment include: 29 | 30 | - Using welcoming and inclusive language 31 | - Being respectful of differing viewpoints and experiences 32 | - Gracefully accepting constructive criticism 33 | - Focusing on what is best for the community 34 | - Showing empathy towards other community members 35 | 36 | Examples of unacceptable behavior by participants include: 37 | 38 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 39 | - Trolling, insulting/derogatory comments, and personal or political attacks 40 | - Public or private harassment 41 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 42 | - Other conduct which could reasonably be considered inappropriate in a professional setting 43 | 44 | ## Our Responsibilities 45 | 46 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 47 | 48 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 49 | 50 | ## Scope 51 | 52 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 53 | 54 | ## Enforcement 55 | 56 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 57 | 58 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 59 | 60 | ## Attribution 61 | 62 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:gallium-alpine3.16 2 | EXPOSE 80 8000 8080 3 | LABEL maintainer="Eyevinn Technology " 4 | 5 | WORKDIR /app 6 | ADD . . 7 | RUN npm install 8 | RUN npm run build 9 | CMD ["npm", "start"] 10 | -------------------------------------------------------------------------------- /Dockerfile.Lambda: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/nodejs:16 as builder 2 | EXPOSE 80 443 8000 3 | LABEL maintainer="Eyevinn Technology " 4 | 5 | WORKDIR ${LAMBDA_TASK_ROOT} 6 | COPY . ${LAMBDA_TASK_ROOT} 7 | RUN npm install 8 | RUN npm run build 9 | 10 | 11 | WORKDIR ${LAMBDA_TASK_ROOT}/dist 12 | CMD ["dist/lambda.handler"] 13 | 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apache License 4 | Version 2.0, January 2004 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by 15 | the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all 18 | other entities that control, are controlled by, or are under common 19 | control with that entity. For the purposes of this definition, 20 | "control" means (i) the power, direct or indirect, to cause the 21 | direction or management of such entity, whether by contract or 22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 23 | outstanding shares, or (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity 26 | exercising permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but 34 | not limited to compiled object code, generated documentation, 35 | and conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or 38 | Object form, made available under the License, as indicated by a 39 | copyright notice that is included in or attached to the work 40 | (an example is provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the purposes 46 | of this License, Derivative Works shall not include works that remain 47 | separable from, or merely link (or bind by name) to the interfaces of, 48 | the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including 51 | the original version of the Work and any modifications or additions 52 | to that Work or Derivative Works thereof, that is intentionally 53 | submitted to Licensor for inclusion in the Work by the copyright owner 54 | or by an individual or Legal Entity authorized to submit on behalf of 55 | the copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent 57 | to the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control systems, 59 | and issue tracking systems that are managed by, or on behalf of, the 60 | Licensor for the purpose of discussing and improving the Work, but 61 | excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, 72 | publicly display, publicly perform, sublicense, and distribute the 73 | Work and such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 78 | (except as stated in this section) patent license to make, have made, 79 | use, offer to sell, sell, import, and otherwise transfer the Work, 80 | where such license applies only to those patent claims licensable 81 | by such Contributor that are necessarily infringed by their 82 | Contribution(s) alone or by combination of their Contribution(s) 83 | with the Work to which such Contribution(s) was submitted. If You 84 | institute patent litigation against any entity (including a 85 | cross-claim or counterclaim in a lawsuit) alleging that the Work 86 | or a Contribution incorporated within the Work constitutes direct 87 | or contributory patent infringement, then any patent licenses 88 | granted to You under this License for that Work shall terminate 89 | as of the date such litigation is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the 92 | Work or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You 94 | meet the following conditions: 95 | 96 | (a) You must give any other recipients of the Work or 97 | Derivative Works a copy of this License; and 98 | 99 | (b) You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | (c) You must retain, in the Source form of any Derivative Works 103 | that You distribute, all copyright, patent, trademark, and 104 | attribution notices from the Source form of the Work, 105 | excluding those notices that do not pertain to any part of 106 | the Derivative Works; and 107 | 108 | (d) If the Work includes a "NOTICE" text file as part of its 109 | distribution, then any Derivative Works that You distribute must 110 | include a readable copy of the attribution notices contained 111 | within such NOTICE file, excluding those notices that do not 112 | pertain to any part of the Derivative Works, in at least one 113 | of the following places: within a NOTICE text file distributed 114 | as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, 116 | within a display generated by the Derivative Works, if and 117 | wherever such third-party notices normally appear. The contents 118 | of the NOTICE file are for informational purposes only and 119 | do not modify the License. You may add Your own attribution 120 | notices within Derivative Works that You distribute, alongside 121 | or as an addendum to the NOTICE text from the Work, provided 122 | that such additional attribution notices cannot be construed 123 | as modifying the License. 124 | 125 | You may add Your own copyright statement to Your modifications and 126 | may provide additional or different license terms and conditions 127 | for use, reproduction, or distribution of Your modifications, or 128 | for any such Derivative Works as a whole, provided Your use, 129 | reproduction, and distribution of the Work otherwise complies with 130 | the conditions stated in this License. 131 | 132 | 5. Submission of Contributions. Unless You explicitly state otherwise, 133 | any Contribution intentionally submitted for inclusion in the Work 134 | by You to the Licensor shall be under the terms and conditions of 135 | this License, without any additional terms or conditions. 136 | Notwithstanding the above, nothing herein shall supersede or modify 137 | the terms of any separate license agreement you may have executed 138 | with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. This License does not grant permission to use the trade 141 | names, trademarks, service marks, or product names of the Licensor, 142 | except as required for reasonable and customary use in describing the 143 | origin of the Work and reproducing the content of the NOTICE file. 144 | 145 | 7. Disclaimer of Warranty. Unless required by applicable law or 146 | agreed to in writing, Licensor provides the Work (and each 147 | Contributor provides its Contributions) on an "AS IS" BASIS, 148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 149 | implied, including, without limitation, any warranties or conditions 150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 151 | PARTICULAR PURPOSE. You are solely responsible for determining the 152 | appropriateness of using or redistributing the Work and assume any 153 | risks associated with Your exercise of permissions under this License. 154 | 155 | 8. Limitation of Liability. In no event and under no legal theory, 156 | whether in tort (including negligence), contract, or otherwise, 157 | unless required by applicable law (such as deliberate and grossly 158 | negligent acts) or agreed to in writing, shall any Contributor be 159 | liable to You for damages, including any direct, indirect, special, 160 | incidental, or consequential damages of any character arising as a 161 | result of this License or out of the use or inability to use the 162 | Work (including but not limited to damages for loss of goodwill, 163 | work stoppage, computer failure or malfunction, or any and all 164 | other commercial damages or losses), even if such Contributor 165 | has been advised of the possibility of such damages. 166 | 167 | 9. Accepting Warranty or Additional Liability. While redistributing 168 | the Work or Derivative Works thereof, You may choose to offer, 169 | and charge a fee for, acceptance of support, warranty, indemnity, 170 | or other liability obligations and/or rights consistent with this 171 | License. However, in accepting such obligations, You may act only 172 | on Your own behalf and on Your sole responsibility, not on behalf 173 | of any other Contributor, and only if You agree to indemnify, 174 | defend, and hold each Contributor harmless for any liability 175 | incurred by, or claims asserted against, such Contributor by reason 176 | of your accepting any such warranty or additional liability. 177 | 178 | END OF TERMS AND CONDITIONS 179 | -------------------------------------------------------------------------------- /aws/ecs/taskdef-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "executionRoleArn": "arn:aws:iam::590877988961:role/ecsTaskExecutionRole", 3 | "containerDefinitions": [ 4 | { 5 | "logConfiguration": { 6 | "logDriver": "awslogs", 7 | "secretOptions": null, 8 | "options": { 9 | "awslogs-group": "/ecs/chaos-stream-proxy", 10 | "awslogs-region": "eu-north-1", 11 | "awslogs-stream-prefix": "ecs" 12 | } 13 | }, 14 | "portMappings": [ 15 | { 16 | "hostPort": 8000, 17 | "protocol": "tcp", 18 | "containerPort": 8000 19 | } 20 | ], 21 | "image": "590877988961.dkr.ecr.eu-north-1.amazonaws.com/chaos-stream-proxy:latest-dev", 22 | "name": "chaos-stream-proxy" 23 | } 24 | ], 25 | "memory": "512", 26 | "family": "chaos-stream-proxy", 27 | "requiresCompatibilities": ["FARGATE"], 28 | "networkMode": "awsvpc", 29 | "runtimePlatform": { 30 | "operatingSystemFamily": "LINUX", 31 | "cpuArchitecture": null 32 | }, 33 | "cpu": "256" 34 | } 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | chaos-stream-proxy: 5 | build: . 6 | image: chaos-stream-proxy-api:local 7 | environment: 8 | - PORT=8000 9 | ports: 10 | - 8000:8000 -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ['/dist/', '/node_modules/'] 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eyevinn/chaos-stream-proxy", 3 | "version": "0.8.0", 4 | "description": "Add some chaos to your HTTP streams to validate player behaviour", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "tsc", 8 | "dev": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"node dist/server.js\"", 9 | "start": "node dist/server.js", 10 | "build": "tsc", 11 | "test:watch": "jest --watch", 12 | "test": "jest", 13 | "lint": "eslint .", 14 | "typecheck": "tsc --noEmit -p tsconfig.json", 15 | "pretty": "prettier --check --ignore-unknown .", 16 | "deploy:dev": "git tag $(git rev-parse --short HEAD)-dev && git push --tags", 17 | "postversion": "git push && git push --tags" 18 | }, 19 | "dependencies": { 20 | "@aws-sdk/client-ssm": "^3.306.0", 21 | "@eyevinn/m3u8": ">=0.5.6", 22 | "@fastify/aws-lambda": "^3.2.0", 23 | "aws-lambda": "^1.0.7", 24 | "dotenv": "^8.2.0", 25 | "fastify": "^3.29.5", 26 | "fastify-cors": "^6.0.2", 27 | "jest": "^27.5.1", 28 | "lodash": "^4.17.15", 29 | "nock": "^13.2.4", 30 | "node-cache": "^5.1.2", 31 | "node-fetch": "^2.5.7", 32 | "stream-throttle": "^0.1.3", 33 | "xml2js": "^0.4.19" 34 | }, 35 | "devDependencies": { 36 | "@babel/preset-typescript": "^7.7.4", 37 | "@commitlint/cli": "^16.2.1", 38 | "@commitlint/config-conventional": "^8.0.0", 39 | "@eyevinn/dev-lambda": "^0.1.2", 40 | "@types/aws-lambda": "^8.10.92", 41 | "@types/clone": "^2.1.1", 42 | "@types/jest": "^27.4.0", 43 | "@types/node": "^17.0.18", 44 | "@types/node-fetch": "^2.5.7", 45 | "@types/stream-throttle": "^0.1.1", 46 | "@types/xml2js": "^0.4.11", 47 | "@typescript-eslint/eslint-plugin": "^5.13.0", 48 | "@typescript-eslint/parser": "^5.13.0", 49 | "clone": "^2.1.2", 50 | "eslint": "^8.37.0", 51 | "eslint_d": "^8.0.0", 52 | "eslint-config-airbnb-base": "^15.0.0", 53 | "eslint-config-prettier": "^8.8.0", 54 | "eslint-plugin-import": "^2.18.0", 55 | "eslint-plugin-prettier": "^4.2.1", 56 | "husky": "^2.7.0", 57 | "lint-staged": "^12.3.4", 58 | "prettier": "2.8.8", 59 | "ts-jest": "^27.1.3", 60 | "tsc-watch": "^5.0.3", 61 | "typescript": "^3.7.3" 62 | }, 63 | "author": "Eyevinn Technology AB ", 64 | "contributors": [ 65 | "Nicholas Frederiksen (Eyevinn Technology AB)", 66 | "Johan Lautakoski (Eyevinn Technology AB)", 67 | "Ludwig Thurfjell (Eyevinn Technology AB)", 68 | "Jonas Birmé (Eyevinn Technology AB)", 69 | "Albin Larsson (Eyevinn Technology AB)", 70 | "Andreas Garcia (Eyevinn Technology AB)", 71 | "Francis Gniady (Eyevinn Technology AB)", 72 | "Christine Qiu (Eyevinn Technology AB)", 73 | "Emil Karim (Eyevinn Technology AB)" 74 | ], 75 | "license": "Apache-2.0", 76 | "keywords": [ 77 | "hls", 78 | "proxy", 79 | "test", 80 | "qa", 81 | "tools" 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /src/dev/demo.ts: -------------------------------------------------------------------------------- 1 | import { LambdaELB } from '@eyevinn/dev-lambda'; 2 | import { proxy } from '../lambda'; 3 | import { ALBResult, ALBEvent } from 'aws-lambda'; 4 | 5 | new LambdaELB({ 6 | handler: <(event: ALBEvent) => Promise>proxy 7 | }).run(); 8 | -------------------------------------------------------------------------------- /src/lambda.ts: -------------------------------------------------------------------------------- 1 | import { awsLambdaFastify } from '@fastify/aws-lambda'; 2 | import { app } from './server'; 3 | 4 | export const proxy = awsLambdaFastify(app); 5 | 6 | // or 7 | // const proxy = awsLambdaFastify(init(), { binaryMimeTypes: ['application/octet-stream'] }) 8 | 9 | //exports.handler = proxy; 10 | // or 11 | //exports.handler = (event, context, callback) => proxy(event, context, callback); 12 | // or 13 | // exports.handler = (event, context) => proxy(event, context); 14 | // or 15 | exports.handler = async (event, context) => proxy(event, context); 16 | 17 | /* 18 | how to push your image to aws ecr 19 | docker build . -t chaos_stream_proxy -f DockerfileLambda && docker tag chaos_stream_proxy:latest YOUR_AWS_ACCOUND_ID.dkr.ecr.eu-central-1.amazonaws.com/chaos_stream_proxy:latest && docker push YOUR_AWS_ACCOUND_ID.dkr.ecr.eu-central-1.amazonaws.com/chaos_stream_proxy:latest 20 | */ 21 | -------------------------------------------------------------------------------- /src/manifests/handlers/dash/index.ts: -------------------------------------------------------------------------------- 1 | import { ALBEvent, ALBResult } from 'aws-lambda'; 2 | import fetch from 'node-fetch'; 3 | import { generateErrorResponse, isValidUrl } from '../../../shared/utils'; 4 | import dashManifestUtils from '../../utils/dashManifestUtils'; 5 | 6 | export default async function dashHandler(event: ALBEvent): Promise { 7 | /** 8 | * #1 - const originalUrl = req.body.query("url"); 9 | * #2 - const originalManifest = await fetch(originalUrl); 10 | * #3 - create proxy manifest and return response from it 11 | */ 12 | const { url } = event.queryStringParameters; 13 | 14 | if (!url || !isValidUrl(url)) { 15 | return generateErrorResponse({ 16 | status: 400, 17 | message: "Missing a valid 'url' query parameter" 18 | }); 19 | } 20 | 21 | try { 22 | const originalDashManifestResponse = await fetch(url); 23 | const responseCopy = originalDashManifestResponse.clone(); 24 | if (!originalDashManifestResponse.ok) { 25 | return generateErrorResponse({ 26 | status: originalDashManifestResponse.status, 27 | message: 'Unsuccessful Source Manifest fetch' 28 | }); 29 | } 30 | const reqQueryParams = new URLSearchParams(event.queryStringParameters); 31 | const text = await responseCopy.text(); 32 | const dashUtils = dashManifestUtils(); 33 | const proxyManifest = dashUtils.createProxyDASHManifest( 34 | text, 35 | reqQueryParams 36 | ); 37 | 38 | return { 39 | statusCode: 200, 40 | headers: { 41 | 'Content-Type': 'application/dash+xml', 42 | 'Access-Control-Allow-Origin': '*', 43 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 44 | }, 45 | body: proxyManifest 46 | }; 47 | } catch (err) { 48 | // for unexpected errors 49 | return generateErrorResponse({ 50 | status: 500, 51 | message: err.message || err 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/manifests/handlers/dash/segment.test.ts: -------------------------------------------------------------------------------- 1 | import dashSegmentHandler from './segment'; 2 | 3 | describe('dashSegmentHandler', () => { 4 | it('handles when a representationId contains underscore', async () => { 5 | const result = await dashSegmentHandler({ 6 | queryStringParameters: { 7 | url: 'https://stream.with_underscore.com/live-$RepresentationID$-$Time$.dash' 8 | }, 9 | path: '/segment_82008145102133_123_audio_track_0_0_nor=128000_128000', 10 | requestContext: { 11 | elb: { targetGroupArn: '' } 12 | }, 13 | isBase64Encoded: false, 14 | httpMethod: 'GET', 15 | body: '', 16 | headers: {} 17 | }); 18 | expect(result.headers.Location).toBe( 19 | 'https://stream.with_underscore.com/live-audio_track_0_0_nor=128000_128000-82008145102133.dash' 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/manifests/handlers/dash/segment.ts: -------------------------------------------------------------------------------- 1 | import { ALBEvent, ALBResult } from 'aws-lambda'; 2 | import { ServiceError } from '../../../shared/types'; 3 | import { 4 | composeALBEvent, 5 | generateErrorResponse, 6 | isValidUrl, 7 | segmentUrlParamString, 8 | STATEFUL, 9 | newState 10 | } from '../../../shared/utils'; 11 | import delaySCC from '../../utils/corruptions/delay'; 12 | import statusCodeSCC from '../../utils/corruptions/statusCode'; 13 | import timeoutSCC from '../../utils/corruptions/timeout'; 14 | import throttleSCC from '../../utils/corruptions/throttle'; 15 | import path from 'path'; 16 | import dashManifestUtils from '../../utils/dashManifestUtils'; 17 | import { corruptorConfigUtils } from '../../utils/configs'; 18 | import segmentHandler from '../../../segments/handlers/segment'; 19 | 20 | export default async function dashSegmentHandler( 21 | event: ALBEvent 22 | ): Promise { 23 | /** 24 | * #1 - const originalUrl = req.body.query("url"); 25 | * #2 - const originalManifest = await fetch(originalUrl); 26 | * #3 - build proxy version and response with the correct header 27 | */ 28 | const { url } = event.queryStringParameters; 29 | 30 | if (!url || !isValidUrl(url)) { 31 | const errorRes: ServiceError = { 32 | status: 400, 33 | message: "Missing a valid 'url' query parameter" 34 | }; 35 | return generateErrorResponse(errorRes); 36 | } 37 | 38 | try { 39 | const urlSearchParams = new URLSearchParams(event.queryStringParameters); 40 | const pathStem = path.basename(event.path).replace('.mp4', ''); 41 | // Get the number part after "segment_" 42 | const [, reqSegmentIndexOrTimeStr, bitrateStr, ...representationIdStrList] = 43 | pathStem.split('_'); 44 | const representationIdStr = representationIdStrList.join('_'); 45 | // Build correct Source Segment url 46 | // segment templates may contain a width parameter "$Number%0[width]d$", and then we need to zero-pad them to that length 47 | 48 | let segmentUrl = url; 49 | 50 | if (segmentUrl.includes('$Time$')) { 51 | segmentUrl = segmentUrl.replace('$Time$', reqSegmentIndexOrTimeStr); 52 | } else { 53 | segmentUrl = segmentUrl 54 | .replace(/\$Number%0(\d+)d\$/, (_, width) => 55 | reqSegmentIndexOrTimeStr.padStart(Number(width), '0') 56 | ) 57 | .replace('$Number$', reqSegmentIndexOrTimeStr); 58 | } 59 | const reqSegmentIndexInt = parseInt(reqSegmentIndexOrTimeStr); 60 | 61 | const stateKey = STATEFUL 62 | ? newState({ initialSequenceNumber: undefined }) 63 | : undefined; 64 | 65 | // Replace RepresentationID in url if present 66 | if (representationIdStr) { 67 | segmentUrl = segmentUrl.replace( 68 | '$RepresentationID$', 69 | representationIdStr 70 | ); 71 | } 72 | 73 | if (bitrateStr) { 74 | urlSearchParams.set('bitrate', bitrateStr); 75 | } 76 | // Break down Corruption Objects 77 | // Send source URL with a corruption json (if it is appropriate) to segmentHandler... 78 | const configUtils = corruptorConfigUtils(urlSearchParams); 79 | configUtils 80 | .register(delaySCC) 81 | .register(statusCodeSCC) 82 | .register(timeoutSCC) 83 | .register(throttleSCC); 84 | const [error, allMutations] = configUtils.getAllManifestConfigs( 85 | reqSegmentIndexInt, 86 | true 87 | ); 88 | if (error) { 89 | return generateErrorResponse(error); 90 | } 91 | const dashUtils = dashManifestUtils(); 92 | const mergedMaps = dashUtils.utils.mergeMap( 93 | reqSegmentIndexInt, 94 | allMutations, 95 | stateKey 96 | ); 97 | const segUrl = new URL(segmentUrl); 98 | const cleanSegUrl = segUrl.origin + segUrl.pathname; 99 | let eventParamsString: string; 100 | if (mergedMaps.size < 1) { 101 | eventParamsString = `url=${cleanSegUrl}`; 102 | } else { 103 | eventParamsString = segmentUrlParamString(cleanSegUrl, mergedMaps); 104 | } 105 | const albEvent = await composeALBEvent( 106 | event.httpMethod, 107 | `${event.path}?${eventParamsString}`, 108 | event.headers 109 | ); 110 | return await segmentHandler(albEvent); 111 | } catch (err) { 112 | const errorRes: ServiceError = { 113 | status: 500, 114 | message: err.message ? err.message : err 115 | }; 116 | //for unexpected errors 117 | return generateErrorResponse(errorRes); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/manifests/handlers/hls/master.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { ALBEvent } from 'aws-lambda'; 3 | import hlsManifestUtils from '../../utils/hlsManifestUtils'; 4 | import { 5 | isValidUrl, 6 | parseM3U8Text, 7 | refineALBEventQuery, 8 | generateErrorResponse, 9 | STATEFUL, 10 | newState 11 | } from '../../../shared/utils'; 12 | 13 | // To be able to reuse the handlers for AWS lambda function - input should be ALBEvent 14 | export default async function hlsMasterHandler(event: ALBEvent) { 15 | const query = refineALBEventQuery(event.queryStringParameters); 16 | 17 | if (!query.url || !isValidUrl(query.url)) { 18 | return generateErrorResponse({ 19 | status: 400, 20 | message: "Missing a valid 'url' query parameter" 21 | }); 22 | } 23 | try { 24 | const originalMasterManifestResponse = await fetch(query.url); 25 | if (!originalMasterManifestResponse.ok) { 26 | return generateErrorResponse({ 27 | status: originalMasterManifestResponse.status, 28 | message: 'Unsuccessful Source Manifest fetch' 29 | }); 30 | } 31 | const originalResHeaders = {}; 32 | originalMasterManifestResponse.headers.forEach( 33 | (value, key) => (originalResHeaders[key] = value) 34 | ); 35 | const masterM3U = await parseM3U8Text(originalMasterManifestResponse); 36 | 37 | // How to handle if M3U is actually a Media and Not a Master... 38 | if (masterM3U.items.PlaylistItem.length > 0) { 39 | return generateErrorResponse({ 40 | status: 400, 41 | message: 'Input HLS stream URL is not a Multivariant Playlist' 42 | }); 43 | } 44 | 45 | const stateKey = STATEFUL 46 | ? newState({ initialSequenceNumber: undefined }) 47 | : undefined; 48 | 49 | const reqQueryParams = new URLSearchParams(query); 50 | const manifestUtils = hlsManifestUtils(); 51 | const proxyManifest = manifestUtils.createProxyMasterManifest( 52 | masterM3U, 53 | reqQueryParams, 54 | stateKey 55 | ); 56 | 57 | return { 58 | statusCode: 200, 59 | headers: { 60 | 'Content-Type': 'application/vnd.apple.mpegurl', 61 | 'Access-Control-Allow-Origin': '*', 62 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 63 | }, 64 | body: proxyManifest 65 | }; 66 | } catch (err) { 67 | // Unexpected errors 68 | return generateErrorResponse({ 69 | status: 500, 70 | message: err.message ? err.message : err 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/manifests/handlers/hls/media.ts: -------------------------------------------------------------------------------- 1 | import fetch, { Response } from 'node-fetch'; 2 | import { ALBEvent, ALBResult } from 'aws-lambda'; 3 | import { 4 | fixUrl, 5 | STATEFUL, 6 | generateErrorResponse, 7 | getState, 8 | isValidUrl, 9 | parseM3U8Text, 10 | putState, 11 | refineALBEventQuery 12 | } from '../../../shared/utils'; 13 | import delaySCC from '../../utils/corruptions/delay'; 14 | import statusCodeSCC from '../../utils/corruptions/statusCode'; 15 | import timeoutSCC from '../../utils/corruptions/timeout'; 16 | import throttleSCC from '../../utils/corruptions/throttle'; 17 | import path from 'path'; 18 | import hlsManifestUtils from '../../utils/hlsManifestUtils'; 19 | import { corruptorConfigUtils } from '../../utils/configs'; 20 | 21 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 22 | 23 | export default async function hlsMediaHandler( 24 | event: ALBEvent 25 | ): Promise { 26 | // To be able to reuse the handlers for AWS lambda function - input should be ALBEvent 27 | const query = refineALBEventQuery(event.queryStringParameters); 28 | 29 | // Check for original manifest url in query params 30 | if (!isValidUrl(query.url)) { 31 | return generateErrorResponse({ 32 | status: 400, 33 | message: "Missing a valid 'url' query parameter" 34 | }); 35 | } 36 | 37 | try { 38 | const originalMediaManifestResponse: Response = await fetch( 39 | fixUrl(query.url) 40 | ); 41 | if (!originalMediaManifestResponse.ok) { 42 | return generateErrorResponse({ 43 | status: originalMediaManifestResponse.status, 44 | message: 'Unsuccessful Source Manifest fetch' 45 | }); 46 | } 47 | 48 | const originalResHeaders = {}; 49 | originalMediaManifestResponse.headers.forEach( 50 | (value, key) => (originalResHeaders[key] = value) 51 | ); 52 | 53 | const mediaM3U = await parseM3U8Text(originalMediaManifestResponse); 54 | const reqQueryParams = new URLSearchParams(query); 55 | const manifestUtils = hlsManifestUtils(); 56 | const configUtils = corruptorConfigUtils(reqQueryParams); 57 | 58 | configUtils 59 | .register(delaySCC) 60 | .register(statusCodeSCC) 61 | .register(timeoutSCC) 62 | .register(throttleSCC); 63 | 64 | const mediaSequence = mediaM3U.get('mediaSequence'); 65 | let mediaSequenceOffset = 0; 66 | if (STATEFUL) { 67 | const stateKey = reqQueryParams.get('state'); 68 | if (stateKey) { 69 | const state = getState(stateKey); 70 | if (state.initialSequenceNumber == undefined) { 71 | putState(stateKey, { 72 | ...state, 73 | initialSequenceNumber: mediaSequence 74 | }); 75 | mediaSequenceOffset = mediaSequence; 76 | } else { 77 | mediaSequenceOffset = state.initialSequenceNumber; 78 | } 79 | } 80 | } 81 | const [error, allMutations, levelMutations] = 82 | configUtils.getAllManifestConfigs( 83 | mediaSequence, 84 | false, 85 | mediaSequenceOffset, 86 | mediaM3U.items.PlaylistItem.length 87 | ); 88 | if (error) { 89 | return generateErrorResponse(error); 90 | } 91 | 92 | const sourceBaseURL = path.dirname(query.url); 93 | const proxyManifest = manifestUtils.createProxyMediaManifest( 94 | mediaM3U, 95 | sourceBaseURL, 96 | allMutations 97 | ); 98 | 99 | if (levelMutations) { 100 | // apply media manifest Delay 101 | const level = reqQueryParams.get('level') 102 | ? Number(reqQueryParams.get('level')) 103 | : undefined; 104 | if (level && levelMutations.get(level)) { 105 | const delay = Number(levelMutations.get(level).get('delay').fields?.ms); 106 | console.log(`Applying ${delay}ms delay to ${query.url}`); 107 | await sleep(delay); 108 | } 109 | } 110 | return { 111 | statusCode: 200, 112 | headers: { 113 | 'Content-Type': 'application/vnd.apple.mpegurl', 114 | 'Access-Control-Allow-Origin': '*', 115 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 116 | }, 117 | body: proxyManifest 118 | }; 119 | } catch (err) { 120 | // Unexpected errors 121 | return generateErrorResponse({ 122 | status: 500, 123 | message: err.message || err 124 | }); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/manifests/routes/index.ts: -------------------------------------------------------------------------------- 1 | import hlsMasterHandler from '../handlers/hls/master'; 2 | import hlsMediaHandler from '../handlers/hls/media'; 3 | import dashHandler from '../handlers/dash/index'; 4 | import { FastifyInstance } from 'fastify'; 5 | import { composeALBEvent } from '../../shared/utils'; 6 | import { 7 | HLS_PROXY_MASTER, 8 | HLS_PROXY_MEDIA, 9 | DASH_PROXY_MASTER, 10 | DASH_PROXY_SEGMENT 11 | } from '../../segments/constants'; 12 | import dashSegmentHandler from '../handlers/dash/segment'; 13 | 14 | export default async function manifestRoutes(fastify: FastifyInstance) { 15 | fastify.get(HLS_PROXY_MASTER, async (req, res) => { 16 | const event = await composeALBEvent(req.method, req.url, req.headers); 17 | const response = await hlsMasterHandler(event); 18 | res.code(response.statusCode).headers(response.headers).send(response.body); 19 | }); 20 | 21 | fastify.get(HLS_PROXY_MEDIA, async (req, res) => { 22 | const event = await composeALBEvent(req.method, req.url, req.headers); 23 | const response = await hlsMediaHandler(event); 24 | res.code(response.statusCode).headers(response.headers).send(response.body); 25 | }); 26 | fastify.get(DASH_PROXY_MASTER, async (req, res) => { 27 | const event = await composeALBEvent(req.method, req.url, req.headers); 28 | const response = await dashHandler(event); 29 | res.code(response.statusCode).headers(response.headers).send(response.body); 30 | }); 31 | fastify.get(DASH_PROXY_SEGMENT + '/*', async (req, res) => { 32 | const event = await composeALBEvent(req.method, req.url, req.headers); 33 | const response = await dashSegmentHandler(event); 34 | // If response is undefined it means the request was intentionally timed out and we must not respond 35 | if (response) { 36 | res 37 | .code(response.statusCode) 38 | .headers(response.headers) 39 | .send(response.body); 40 | } 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/manifests/routes/routeConstants.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eyevinn/chaos-stream-proxy/4756c73829f3bc24f0cdd55889806c7c44df03ed/src/manifests/routes/routeConstants.ts -------------------------------------------------------------------------------- /src/manifests/utils/configs.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | corruptorConfigUtils, 3 | SegmentCorruptorQueryConfig, 4 | CorruptorConfig, 5 | CorruptorIndexMap, 6 | CorruptorLevelMap 7 | } from './configs'; 8 | import statusCodeConfig from './corruptions/statusCode'; 9 | import throttleConfig from './corruptions/throttle'; 10 | 11 | describe('configs', () => { 12 | describe('utils', () => { 13 | describe('JSONifyUrlParamValue', () => { 14 | it('should handle happy param', () => { 15 | // Arrange 16 | const configs = corruptorConfigUtils(new URLSearchParams()); 17 | 18 | // Act 19 | const actual = configs.utils.getJSONParsableString( 20 | '[{i:0,sq:1,extra:2}]' 21 | ); 22 | const expected = JSON.stringify([{ i: 0, sq: 1, extra: 2 }]); 23 | 24 | // Assert 25 | expect(actual).toEqual(expected); 26 | }); 27 | 28 | it('should handle empty param', () => { 29 | // Arrange 30 | const configs = corruptorConfigUtils(new URLSearchParams()); 31 | 32 | // Act 33 | const actual = configs.utils.getJSONParsableString(''); 34 | 35 | const expected = ''; 36 | 37 | // Assert 38 | expect(actual).toEqual(expected); 39 | }); 40 | 41 | it("should handle 'unparseable' json format", () => { 42 | // Arrange 43 | const configs = corruptorConfigUtils(new URLSearchParams()); 44 | 45 | // Act 46 | const actual = configs.utils.getJSONParsableString( 47 | '{i:0,sq:1,extra:2}]' 48 | ); 49 | const expected = '{"i":0,"sq":1,"extra":2}]'; 50 | 51 | // Assert 52 | expect(actual).toEqual(expected); 53 | }); 54 | }); 55 | }); 56 | 57 | describe('register', () => { 58 | it('should handle happy register correct', () => { 59 | // Arrange 60 | const configs = corruptorConfigUtils(new URLSearchParams()); 61 | const config: SegmentCorruptorQueryConfig = { 62 | name: 'test', 63 | getManifestConfigs: () => [null, []], 64 | getSegmentConfigs: () => [null, { fields: null }] 65 | }; 66 | 67 | // Act 68 | configs.register(config); 69 | 70 | // Assert 71 | expect(configs).toHaveProperty('registered', [config]); 72 | }); 73 | 74 | it('should handle register without name', () => { 75 | // Arrange 76 | const configs = corruptorConfigUtils(new URLSearchParams()); 77 | const config: SegmentCorruptorQueryConfig = { 78 | name: '', 79 | getManifestConfigs: () => [null, []], 80 | getSegmentConfigs: () => [null, { fields: null }] 81 | }; 82 | 83 | // Act 84 | configs.register(config); 85 | 86 | // Assert 87 | expect(configs).toHaveProperty('registered', []); 88 | }); 89 | }); 90 | 91 | describe('getAllManifestConfigs', () => { 92 | const env = process.env; 93 | beforeEach(() => { 94 | jest.resetModules(); 95 | process.env = { ...env }; 96 | }); 97 | 98 | afterEach(() => { 99 | process.env = env; 100 | }); 101 | 102 | it('should handle matching config with url query params', () => { 103 | // Arrange 104 | const configs = corruptorConfigUtils( 105 | new URLSearchParams( 106 | 'test1=[{i:0,ms:150}]&test2=[{i:1,ms:250}]&test3=[{l:1,ms:400}]' 107 | ) 108 | ); 109 | const config1: SegmentCorruptorQueryConfig = { 110 | name: 'test1', 111 | getManifestConfigs: () => [null, [{ i: 0, fields: { ms: 150 } }]], 112 | getSegmentConfigs: () => [null, { fields: null }] 113 | }; 114 | const config2: SegmentCorruptorQueryConfig = { 115 | name: 'test2', 116 | getManifestConfigs: () => [null, [{ i: 1, fields: { ms: 250 } }]], 117 | getSegmentConfigs: () => [null, { fields: null }] 118 | }; 119 | const config3: SegmentCorruptorQueryConfig = { 120 | name: 'test3', 121 | getManifestConfigs: () => [null, [{ l: 1, fields: { ms: 400 } }]], 122 | getSegmentConfigs: () => [null, { fields: null }] 123 | }; 124 | 125 | // Act 126 | configs.register(config1); 127 | configs.register(config2); 128 | configs.register(config3); 129 | const [err, actualIndex, actualLevel] = configs.getAllManifestConfigs(0); 130 | const expectedIndex = new CorruptorIndexMap([ 131 | [ 132 | 0, 133 | new Map([ 134 | [ 135 | 'test1', 136 | { 137 | fields: { ms: 150 }, 138 | i: 0 139 | } 140 | ] 141 | ]) 142 | ], 143 | [ 144 | 1, 145 | new Map([ 146 | [ 147 | 'test2', 148 | { 149 | fields: { ms: 250 }, 150 | i: 1 151 | } 152 | ] 153 | ]) 154 | ] 155 | ]); 156 | const expectedLevel = new CorruptorLevelMap([ 157 | [ 158 | 1, 159 | new Map([ 160 | [ 161 | 'test3', 162 | { 163 | fields: { ms: 400 }, 164 | l: 1 165 | } 166 | ] 167 | ]) 168 | ] 169 | ]); 170 | // Assert 171 | expect(err).toBeNull(); 172 | expect(actualIndex).toEqual(expectedIndex); 173 | expect(actualLevel).toEqual(expectedLevel); 174 | }); 175 | 176 | describe('in stateful mode', () => { 177 | let statefulConfig; 178 | beforeAll(() => { 179 | process.env.STATEFUL = 'true'; 180 | statefulConfig = require('./configs'); 181 | }); 182 | 183 | it('should handle media sequence offsets', () => { 184 | // Arrange 185 | const configs = statefulConfig.corruptorConfigUtils( 186 | new URLSearchParams( 187 | 'statusCode=[{rsq:15,code:400}]&throttle=[{sq:15,rate:1000}]' 188 | ) 189 | ); 190 | 191 | configs.register(statusCodeConfig).register(throttleConfig); 192 | 193 | // Act 194 | 195 | const [err, actual] = configs.getAllManifestConfigs(0, false, 100); 196 | // Assert 197 | expect(err).toBeNull(); 198 | expect(actual.get(115)).toEqual( 199 | new Map([ 200 | [ 201 | 'statusCode', 202 | { 203 | fields: { code: 400 }, 204 | sq: 115 205 | } 206 | ] 207 | ]) 208 | ); 209 | expect(actual.get(15)).toEqual( 210 | new Map([ 211 | [ 212 | 'throttle', 213 | { 214 | fields: { rate: 1000 }, 215 | sq: 15 216 | } 217 | ] 218 | ]) 219 | ); 220 | }); 221 | it('should handle media sequence offsets with negative rsq value', () => { 222 | // Arrange 223 | const configs = statefulConfig.corruptorConfigUtils( 224 | new URLSearchParams( 225 | 'statusCode=[{rsq:-1,code:400}]&throttle=[{sq:15,rate:1000}]' 226 | ) 227 | ); 228 | 229 | configs.register(statusCodeConfig).register(throttleConfig); 230 | 231 | // Act 232 | 233 | const [err, actual] = configs.getAllManifestConfigs(0, false, 100, 15); 234 | // Assert 235 | expect(err).toBeNull(); 236 | expect(actual.get(115)).toEqual( 237 | new Map([ 238 | [ 239 | 'statusCode', 240 | { 241 | fields: { code: 400 }, 242 | sq: 115 243 | } 244 | ] 245 | ]) 246 | ); 247 | expect(actual.get(15)).toEqual( 248 | new Map([ 249 | [ 250 | 'throttle', 251 | { 252 | fields: { rate: 1000 }, 253 | sq: 15 254 | } 255 | ] 256 | ]) 257 | ); 258 | }); 259 | }); 260 | 261 | describe('not in stateful mode', () => { 262 | let nonStatefulConfig; 263 | beforeAll(() => { 264 | delete process.env.STATEFUL; // Ensure STATEFUL is not set 265 | nonStatefulConfig = require('./configs'); 266 | }); 267 | 268 | it('should handle DASH mode without stateful', () => { 269 | // Arrange 270 | const configs = nonStatefulConfig.corruptorConfigUtils( 271 | new URLSearchParams( 272 | 'statusCode=[{rsq:15,code:400}]&throttle=[{sq:15,rate:1000}]' 273 | ) 274 | ); 275 | 276 | configs.register(statusCodeConfig).register(throttleConfig); 277 | 278 | // Act 279 | 280 | const [err, actual] = configs.getAllManifestConfigs(0, true); 281 | 282 | // Assert 283 | expect(err).toEqual({ 284 | status: 400, 285 | message: 286 | 'Relative sequence numbers on DASH are only supported when proxy is running in stateful mode' 287 | }); 288 | expect(actual).toBeNull(); 289 | }); 290 | 291 | it('should handle HLS mode without stateful', () => { 292 | // Arrange 293 | const configs = nonStatefulConfig.corruptorConfigUtils( 294 | new URLSearchParams( 295 | 'statusCode=[{rsq:15,code:400}]&throttle=[{sq:15,rate:1000}]' 296 | ) 297 | ); 298 | 299 | configs.register(statusCodeConfig).register(throttleConfig); 300 | 301 | // Act 302 | 303 | const [err, actual] = configs.getAllManifestConfigs(0, false); 304 | 305 | // Assert 306 | expect(err).toEqual({ 307 | status: 400, 308 | message: 309 | 'Relative sequence numbers on HLS are only supported when proxy is running in stateful mode' 310 | }); 311 | expect(actual).toBeNull(); 312 | }); 313 | }); 314 | }); 315 | 316 | describe('getAllSegmentConfigs', () => { 317 | it('should handle config with empty valued url param', () => { 318 | // Arrange 319 | const configs = corruptorConfigUtils(new URLSearchParams('test=')); 320 | const config: SegmentCorruptorQueryConfig = { 321 | name: 'test', 322 | getManifestConfigs: () => [null, []], 323 | getSegmentConfigs: () => [null, { fields: {} }] 324 | }; 325 | configs.register(config); 326 | 327 | // Act 328 | const actual = configs.getAllSegmentConfigs(); 329 | const expected = [ 330 | null, 331 | new Map().set('test', { fields: {} }) 332 | ]; 333 | 334 | // Assert 335 | expect(actual).toEqual(expected); 336 | }); 337 | 338 | it('should handle empty url params', () => { 339 | // Arrange 340 | const configs = corruptorConfigUtils(new URLSearchParams('')); 341 | const config: SegmentCorruptorQueryConfig = { 342 | name: 'test', 343 | getManifestConfigs: () => [null, []], 344 | getSegmentConfigs: () => [null, { fields: {} }] 345 | }; 346 | configs.register(config); 347 | 348 | // Act 349 | const actual = configs.getAllSegmentConfigs(); 350 | const expected = [null, new Map()]; 351 | 352 | // Assert 353 | expect(actual).toEqual(expected); 354 | }); 355 | }); 356 | }); 357 | -------------------------------------------------------------------------------- /src/manifests/utils/configs.ts: -------------------------------------------------------------------------------- 1 | import { STATEFUL } from '../../shared/utils'; 2 | import { ServiceError, TargetIndex, TargetLevel } from '../../shared/types'; 3 | 4 | // export type SegmentCorruptorConfigItem = { 5 | // index: TargetIndex; 6 | // level: TargetLevel; 7 | // seq: TargetIndex; 8 | // name: string; 9 | // queryValue: string; 10 | // }; 11 | 12 | export interface SegmentCorruptorQueryConfig { 13 | getManifestConfigs: ( 14 | config: Record[] 15 | ) => [ServiceError | null, CorruptorConfig[] | null]; 16 | getSegmentConfigs( 17 | delayConfigString: string 18 | ): [ServiceError | null, CorruptorConfig | null]; 19 | name: string; 20 | } 21 | 22 | // TODO sequence might not be relevant as a generic property 23 | export interface CorruptorConfig { 24 | i?: TargetIndex; 25 | l?: TargetLevel; 26 | sq?: TargetIndex; 27 | br?: TargetIndex; 28 | /** 29 | * - If fields is null, it means it's a no-op and ignored when parsing to query string 30 | * It's primarely used to indicate when the default * index operator should be overridden 31 | * with nothing. 32 | * 33 | * ex: ...&config=[{i:*, fields:{something:123}}, {i:0}] <-- index 0 will not get any config url query in the manifest. 34 | * 35 | * - If field is empty object {}, it will parse as follow: 36 | * 37 | * ex: ...&keyWithEmptyFields=&... 38 | */ 39 | fields?: { [key: string]: string | number | boolean } | null; 40 | } 41 | 42 | export type IndexedCorruptorConfigMap = Map; 43 | export type LevelCorruptorConfigMap = Map; 44 | 45 | export type CorruptorConfigMap = Map; 46 | 47 | export interface CorruptorConfigUtils { 48 | /** 49 | * Joins all registered query configurations (from /utils/corruptions/.. preferably) 50 | * into a map indexed by either a numeric index or a 'default' * operator. 51 | * 52 | * Value for each key is a map of all corruptions for selected index (eg key: "delay", value: {ms:150}) 53 | */ 54 | getAllManifestConfigs: ( 55 | mseq?: number, 56 | isDash?: boolean, 57 | mseqOffset?: number, 58 | playlistSize?: number 59 | ) => [ 60 | ServiceError | null, 61 | IndexedCorruptorConfigMap | null, 62 | LevelCorruptorConfigMap | null 63 | ]; 64 | 65 | getAllSegmentConfigs: () => [ServiceError | null, CorruptorConfigMap | null]; 66 | 67 | /** 68 | * Registers a config. It can be any type that implements CorruptorConfig. 69 | */ 70 | register: (config: SegmentCorruptorQueryConfig) => CorruptorConfigUtils; 71 | 72 | utils: { 73 | getJSONParsableString(value: string): string; 74 | }; 75 | } 76 | 77 | export class CorruptorIndexMap extends Map { 78 | deepSet( 79 | index: TargetIndex, 80 | configName: string, 81 | value: CorruptorConfig, 82 | overwrite = true 83 | ) { 84 | if (!this.has(index)) { 85 | this.set(index, new Map()); 86 | } 87 | const indexMap = this.get(index); 88 | if (overwrite || !indexMap.has(configName)) { 89 | indexMap.set(configName, value); 90 | } 91 | } 92 | } 93 | 94 | export class CorruptorLevelMap extends Map { 95 | deepSet( 96 | level: TargetLevel, 97 | configName: string, 98 | value: CorruptorConfig, 99 | overwrite = true 100 | ) { 101 | if (!this.has(level)) { 102 | this.set(level, new Map()); 103 | } 104 | const indexMap = this.get(level); 105 | if (overwrite || !indexMap.has(configName)) { 106 | indexMap.set(configName, value); 107 | } 108 | } 109 | } 110 | 111 | export const corruptorConfigUtils = function ( 112 | urlSearchParams: URLSearchParams 113 | ): CorruptorConfigUtils { 114 | return Object.assign({ 115 | utils: { 116 | getJSONParsableString(value: string): string { 117 | return decodeURIComponent(value) 118 | .replace(/\s/g, '') 119 | .replace( 120 | /({|,)(?:\s*)(?:')?([A-Za-z_$.][A-Za-z0-9_ \-.$]*)(?:')?(?:\s*):/g, 121 | '$1"$2":' 122 | ) 123 | .replace(/:\*/g, ':"*"'); 124 | } 125 | }, 126 | register(config: SegmentCorruptorQueryConfig) { 127 | if (!this.registered) { 128 | this.registered = []; 129 | } 130 | if (config.name) { 131 | this.registered.push(config); 132 | } 133 | return this; 134 | }, 135 | getAllManifestConfigs( 136 | mseq = 0, 137 | isDash = false, 138 | mseqOffset = 0, 139 | playlistSize = 0 140 | ) { 141 | const outputMap = new CorruptorIndexMap(); 142 | const levelMap = new CorruptorLevelMap(); 143 | const configs = ( 144 | (this.registered || []) as SegmentCorruptorQueryConfig[] 145 | ).filter(({ name }) => urlSearchParams.get(name)); 146 | const segmentBitrate = Number(urlSearchParams.get('bitrate')); 147 | 148 | for (const config of configs) { 149 | // JSONify and remove whitespace 150 | const parsableSearchParam = this.utils.getJSONParsableString( 151 | urlSearchParams.get(config.name) 152 | ); 153 | let params = JSON.parse(parsableSearchParam); 154 | 155 | if (Array.isArray(params)) { 156 | // Check if we are trying to use stateful feature without statefulness enabled 157 | const hasRelativeSequences = params.some( 158 | (param) => param.rsq != undefined 159 | ); 160 | if (hasRelativeSequences && !STATEFUL && !isDash) { 161 | return [ 162 | { 163 | status: 400, 164 | message: 165 | 'Relative sequence numbers on HLS are only supported when proxy is running in stateful mode' 166 | }, 167 | null 168 | ]; 169 | } 170 | if (hasRelativeSequences && !STATEFUL && isDash) { 171 | return [ 172 | { 173 | status: 400, 174 | message: 175 | 'Relative sequence numbers on DASH are only supported when proxy is running in stateful mode' 176 | }, 177 | null 178 | ]; 179 | } 180 | 181 | // If bitrate is set, filter out segments that doesn't match 182 | params = params.filter((config) => { 183 | if ( 184 | !config?.br || 185 | config?.br === '*' || 186 | config?.br === segmentBitrate 187 | ) { 188 | return true; 189 | } else if (Array.isArray(config?.br)) { 190 | return config?.br.includes(segmentBitrate); 191 | } else { 192 | return false; 193 | } 194 | }); 195 | 196 | // Replace relative sequence numbers with absolute ones 197 | params = params.map((param) => { 198 | if (param.rsq) { 199 | const rsq = Number(param.rsq); 200 | param['sq'] = 201 | rsq < 0 && playlistSize > 0 202 | ? mseqOffset + playlistSize + rsq + 1 203 | : Number(param.rsq) + mseqOffset; 204 | delete param.rsq; 205 | } 206 | return param; 207 | }); 208 | } 209 | 210 | const [error, configList] = config.getManifestConfigs(params); 211 | if (error) { 212 | return [error, null]; 213 | } 214 | 215 | configList.forEach((item) => { 216 | if (item.i != undefined) { 217 | outputMap.deepSet(item.i, config.name, item, false); 218 | } else if (item.sq != undefined) { 219 | if (item.sq === '*' || (isDash && item.sq === mseq)) { 220 | outputMap.deepSet(item.sq, config.name, item, false); 221 | } else { 222 | outputMap.deepSet(item.sq - mseq, config.name, item, false); 223 | } 224 | } 225 | if (item.l != undefined) { 226 | levelMap.deepSet(item.l, config.name, item, false); 227 | } 228 | }); 229 | } 230 | return [null, outputMap, levelMap]; 231 | }, 232 | getAllSegmentConfigs() { 233 | const outputMap = new Map(); 234 | for (let i = 0; i < this.registered.length; i++) { 235 | const SCC = this.registered[i]; 236 | if (urlSearchParams.get(SCC.name) !== null) { 237 | // To make all object key names double quoted and remove whitespace 238 | const parsedSearchParam = this.utils.getJSONParsableString( 239 | urlSearchParams.get(SCC.name) 240 | ); 241 | 242 | const [error, configResult] = 243 | SCC.getSegmentConfigs(parsedSearchParam); // should only contain 1 item this time 244 | if (error) { 245 | return [error, null]; 246 | } 247 | outputMap.set(SCC.name, configResult); 248 | } 249 | } 250 | return [null, outputMap]; 251 | } 252 | }) as CorruptorConfigUtils; 253 | }; 254 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/delay.test.ts: -------------------------------------------------------------------------------- 1 | import delayConfig from './delay'; 2 | 3 | describe('manifest.utils.corruptions.delay', () => { 4 | describe('getManifestConfigs', () => { 5 | const { getManifestConfigs, name } = delayConfig; 6 | it('should have correct name', () => { 7 | // Assert 8 | expect(name).toEqual('delay'); 9 | }); 10 | it('should handle valid input', () => { 11 | // Arrange 12 | const delayValue = [ 13 | { i: 0, ms: 150 }, 14 | { i: 0, ms: 500 }, 15 | { sq: 0, ms: 1500 }, 16 | { sq: 0, ms: 2500 } 17 | ]; 18 | 19 | // Act 20 | const actual = getManifestConfigs(delayValue); 21 | const expected = [ 22 | null, 23 | [ 24 | { i: 0, fields: { ms: 150 } }, 25 | { sq: 0, fields: { ms: 1500 } } 26 | ] 27 | ]; 28 | 29 | // Assert 30 | expect(actual).toEqual(expected); 31 | }); 32 | 33 | it('should handle all * indexes', () => { 34 | // Arrange 35 | const delayValue: Record[] = [ 36 | { sq: 5, ms: 50 }, 37 | { i: 0 }, 38 | { i: 1, ms: 10 }, 39 | { i: '*', ms: 150 }, 40 | { i: 2 }, 41 | { i: 3, ms: 20 } 42 | ]; 43 | 44 | // Act 45 | const actual = getManifestConfigs(delayValue); 46 | const expected = [ 47 | null, 48 | [ 49 | { i: 0, fields: null }, 50 | { i: 1, fields: { ms: 10 } }, 51 | { i: '*', fields: { ms: 150 } }, 52 | { i: 2, fields: null }, 53 | { i: 3, fields: { ms: 20 } }, 54 | { sq: 5, fields: { ms: 50 } } 55 | ] 56 | ]; 57 | 58 | // Assert 59 | expect(actual).toEqual(expected); 60 | }); 61 | 62 | it('should handle all * sequences', () => { 63 | // Arrange 64 | const delayValue: Record[] = [ 65 | { sq: 0 }, 66 | { sq: 5, ms: 50 }, 67 | { sq: '*', ms: 150 }, 68 | { sq: 1 } 69 | ]; 70 | 71 | // Act 72 | const actual = getManifestConfigs(delayValue); 73 | const expected = [ 74 | null, 75 | [ 76 | { sq: 0, fields: null }, 77 | { sq: 5, fields: { ms: 50 } }, 78 | { sq: '*', fields: { ms: 150 } }, 79 | { sq: 1, fields: null } 80 | ] 81 | ]; 82 | 83 | // Assert 84 | expect(actual).toEqual(expected); 85 | }); 86 | 87 | it('should handle no index and no sequence correct', () => { 88 | // Arrange 89 | const delayValue = [{ ms: 150 }]; 90 | 91 | // Act 92 | const actual = getManifestConfigs(delayValue); 93 | const expected = [ 94 | { 95 | message: 96 | "Incorrect delay query format. Either 'i', 'l' or 'sq' is required in a single query object.", 97 | status: 400 98 | }, 99 | null 100 | ]; 101 | 102 | // Assert 103 | expect(actual).toEqual(expected); 104 | }); 105 | 106 | it('should handle both index and sequence in the query object', () => { 107 | // Arrange 108 | const delayValue = [{ ms: 150, i: 0, sq: 2 }]; 109 | 110 | // Act 111 | const actual = getManifestConfigs(delayValue); 112 | const expected = [ 113 | { 114 | message: 115 | "Incorrect delay query format. 'i' and 'sq' are mutually exclusive in a single query object.", 116 | status: 400 117 | }, 118 | null 119 | ]; 120 | 121 | // Assert 122 | expect(actual).toEqual(expected); 123 | }); 124 | it('should handle illegal characters in query object', () => { 125 | // Arrange 126 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 127 | const delayValue = [{ ms: 'hehe', i: false, sq: { he: 'he' } }] as any; 128 | 129 | // Act 130 | const actual = getManifestConfigs(delayValue); 131 | const expected = [ 132 | { 133 | message: 134 | 'Incorrect delay query format. Expected format: [{i?:number, l?:number, sq?:number, br?:number, ms:number}, ...n] where i and sq are mutually exclusive.', 135 | status: 400 136 | }, 137 | null 138 | ]; 139 | 140 | // Assert 141 | expect(actual).toEqual(expected); 142 | }); 143 | 144 | it('should handle invalid format', () => { 145 | // Arrange 146 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 147 | const delayValue = "*{ssd''#Fel" as any; 148 | 149 | // Act 150 | const actual = getManifestConfigs(delayValue); 151 | const expected = [ 152 | { 153 | message: 154 | 'Incorrect delay query format. Expected format: [{i?:number, l?:number, sq?:number, br?:number, ms:number}, ...n] where i and sq are mutually exclusive.', 155 | status: 400 156 | }, 157 | null 158 | ]; 159 | 160 | // Assert 161 | expect(actual).toEqual(expected); 162 | }); 163 | 164 | it('should handle multiple defaults *', () => { 165 | // Arrange 166 | const delayValue: Record[] = [ 167 | { i: '*', ms: 10 }, 168 | { sq: '*', ms: 100 } 169 | ]; 170 | 171 | // Act 172 | const actual = getManifestConfigs(delayValue); 173 | const expected = [null, [{ i: '*', fields: { ms: 10 } }]]; 174 | 175 | // Assert 176 | expect(actual).toEqual(expected); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/delay.ts: -------------------------------------------------------------------------------- 1 | import { unparsableError } from '../../../shared/utils'; 2 | import { ServiceError, TargetIndex } from '../../../shared/types'; 3 | import { CorruptorConfig, SegmentCorruptorQueryConfig } from '../configs'; 4 | 5 | interface DelayConfig extends CorruptorConfig { 6 | ms?: number; 7 | } 8 | 9 | // TODO: Move to a constants file and group with and 10 | const delayExpectedQueryFormatMsg = 11 | 'Incorrect delay query format. Expected format: [{i?:number, l?:number, sq?:number, br?:number, ms:number}, ...n] where i and sq are mutually exclusive.'; 12 | 13 | function getManifestConfigError(value: { [key: string]: unknown }): string { 14 | const o = value as DelayConfig; 15 | 16 | if (o.ms && typeof o.ms !== 'number') { 17 | return delayExpectedQueryFormatMsg; 18 | } 19 | 20 | if (o.i === undefined && o.sq === undefined && o.l === undefined) { 21 | return "Incorrect delay query format. Either 'i', 'l' or 'sq' is required in a single query object."; 22 | } 23 | 24 | if ( 25 | !(o.i === '*' || typeof o.i === 'number') && 26 | !(o.sq === '*' || typeof o.sq === 'number') && 27 | !(typeof o.l === 'number') 28 | ) { 29 | return delayExpectedQueryFormatMsg; 30 | } 31 | 32 | if (o.i !== undefined && o.sq !== undefined) { 33 | return "Incorrect delay query format. 'i' and 'sq' are mutually exclusive in a single query object."; 34 | } 35 | 36 | if (Number(o.sq) < 0) { 37 | return 'Incorrect delay query format. Field sq must be 0 or positive.'; 38 | } 39 | 40 | if (Number(o.i) < 0) { 41 | return 'Incorrect delay query format. Field i must be 0 or positive.'; 42 | } 43 | 44 | if (Number(o.l) < 0) { 45 | return 'Incorrect delay query format. Field l must be 0 or positive.'; 46 | } 47 | 48 | return ''; 49 | } 50 | function isValidSegmentConfig(value: { [key: string]: unknown }): boolean { 51 | if (value.ms && typeof value.ms !== 'number') { 52 | return false; 53 | } 54 | return true; 55 | } 56 | 57 | const delayConfig: SegmentCorruptorQueryConfig = { 58 | getManifestConfigs( 59 | configs: Record[] 60 | ): [ServiceError | null, CorruptorConfig[] | null] { 61 | // Verify it's at least an array 62 | if (!Array.isArray(configs)) { 63 | return [ 64 | { 65 | message: delayExpectedQueryFormatMsg, 66 | status: 400 67 | }, 68 | null 69 | ]; 70 | } 71 | 72 | // Verify integrity of array content 73 | for (let i = 0; i < configs.length; i++) { 74 | const error = getManifestConfigError(configs[i]); 75 | if (error) { 76 | return [{ message: error, status: 400 }, null]; 77 | } 78 | } 79 | 80 | const configIndexMap = new Map(); 81 | const configSqMap = new Map(); 82 | const configLevelMap = new Map(); 83 | 84 | for (let i = 0; i < configs.length; i++) { 85 | const config = configs[i]; 86 | const corruptorConfig: CorruptorConfig = { 87 | fields: null 88 | }; 89 | 90 | if (config.ms) { 91 | corruptorConfig.fields = { 92 | ms: config.ms 93 | }; 94 | } 95 | 96 | // Index default 97 | if (config.i === '*') { 98 | // If default is already set, we skip 99 | if (!configIndexMap.has(config.i) && !configSqMap.has(config.i)) { 100 | corruptorConfig.i = config.i; 101 | configIndexMap.set(config.i, corruptorConfig); 102 | } 103 | } 104 | 105 | // Index numeric 106 | if (typeof config.i === 'number' && !configIndexMap.has(config.i)) { 107 | corruptorConfig.i = config.i; 108 | configIndexMap.set(config.i, corruptorConfig); 109 | } 110 | 111 | // Level numeric 112 | if (typeof config.l === 'number' && !configLevelMap.has(config.l)) { 113 | corruptorConfig.l = config.l; 114 | configLevelMap.set(config.l, corruptorConfig); 115 | } 116 | 117 | // Sequence default 118 | if (config.sq === '*') { 119 | // If default is already set, we skip 120 | if (!configIndexMap.has(config.sq) && !configSqMap.has(config.sq)) { 121 | corruptorConfig.sq = config.sq; 122 | configSqMap.set(config.sq, corruptorConfig); 123 | } 124 | } 125 | 126 | // Sequence numeric 127 | if (typeof config.sq === 'number' && !configSqMap.has(config.sq)) { 128 | corruptorConfig.sq = config.sq; 129 | configSqMap.set(config.sq, corruptorConfig); 130 | } 131 | } 132 | const corruptorConfigs: CorruptorConfig[] = []; 133 | 134 | for (const value of configIndexMap.values()) { 135 | corruptorConfigs.push(value); 136 | } 137 | 138 | for (const value of configSqMap.values()) { 139 | corruptorConfigs.push(value); 140 | } 141 | 142 | for (const value of configLevelMap.values()) { 143 | corruptorConfigs.push(value); 144 | } 145 | return [null, corruptorConfigs]; 146 | }, 147 | getSegmentConfigs( 148 | delayConfigString: string 149 | ): [ServiceError | null, CorruptorConfig | null] { 150 | const config = JSON.parse(delayConfigString); 151 | if (!isValidSegmentConfig(config)) { 152 | return [ 153 | unparsableError( 154 | 'delay', 155 | delayConfigString, 156 | '{i?:number, sq?:number, ms:number}' 157 | ), 158 | null 159 | ]; 160 | } 161 | 162 | return [ 163 | null, 164 | { 165 | i: config.i, 166 | l: config.l, 167 | sq: config.sq, 168 | fields: { 169 | ms: config.ms 170 | } 171 | } 172 | ]; 173 | }, 174 | name: 'delay' 175 | }; 176 | 177 | export default delayConfig; 178 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/statusCode.test.ts: -------------------------------------------------------------------------------- 1 | import statusCodeConfig from './statusCode'; 2 | 3 | describe('manifest.utils.corruptions.statusCode', () => { 4 | describe('getManifestConfigs', () => { 5 | const { getManifestConfigs, name } = statusCodeConfig; 6 | it('should have correct name', () => { 7 | // Assert 8 | expect(name).toEqual('statusCode'); 9 | }); 10 | it('should handle valid input', () => { 11 | // Arrange 12 | const statusValue = [ 13 | { i: 0, code: 150 }, 14 | { i: 0, code: 500 }, 15 | { sq: 0, code: 1500 } 16 | ]; 17 | 18 | // Act 19 | const actual = getManifestConfigs(statusValue); 20 | const expected = [ 21 | null, 22 | [ 23 | { i: 0, fields: { code: 150 } }, 24 | { sq: 0, fields: { code: 1500 } } 25 | ] 26 | ]; 27 | 28 | // Assert 29 | expect(actual).toEqual(expected); 30 | }); 31 | 32 | it('should handle all * indexes', () => { 33 | // Arrange 34 | const statusValue: Record[] = [ 35 | { i: '*', code: 150 }, 36 | { i: 0 } 37 | ]; 38 | 39 | // Act 40 | const actual = getManifestConfigs(statusValue); 41 | const expected = [ 42 | null, 43 | [ 44 | { i: '*', fields: { code: 150 } }, 45 | { i: 0, fields: null } 46 | ] 47 | ]; 48 | 49 | // Assert 50 | expect(actual).toEqual(expected); 51 | }); 52 | 53 | it('should handle all * sequences', () => { 54 | // Arrange 55 | const statusValue: Record[] = [ 56 | { sq: '*', code: 150 }, 57 | { sq: 0 } 58 | ]; 59 | 60 | // Act 61 | const actual = getManifestConfigs(statusValue); 62 | const expected = [ 63 | null, 64 | [ 65 | { sq: '*', fields: { code: 150 } }, 66 | { sq: 0, fields: null } 67 | ] 68 | ]; 69 | 70 | // Assert 71 | expect(actual).toEqual(expected); 72 | }); 73 | 74 | it('should handle no index and no sequence correct', () => { 75 | // Arrange 76 | const statusValue = [{ code: 150 }]; 77 | 78 | // Act 79 | const actual = getManifestConfigs(statusValue); 80 | const expected = [ 81 | { 82 | message: 83 | "Incorrect statusCode query format. Either 'i' or 'sq' is required in a single query object.", 84 | status: 400 85 | }, 86 | null 87 | ]; 88 | 89 | // Assert 90 | expect(actual).toEqual(expected); 91 | }); 92 | 93 | it('should handle both index and sequence in the query object', () => { 94 | // Arrange 95 | const statusValue = [{ code: 150, i: 0, sq: 2 }]; 96 | 97 | // Act 98 | const actual = getManifestConfigs(statusValue); 99 | const expected = [ 100 | { 101 | message: 102 | "Incorrect statusCode query format. 'i' and 'sq' are mutually exclusive in a single query object.", 103 | status: 400 104 | }, 105 | null 106 | ]; 107 | 108 | // Assert 109 | expect(actual).toEqual(expected); 110 | }); 111 | 112 | it('should handle illegal characters in query object', () => { 113 | // Arrange 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | const statusValue = [{ code: 'hehe', i: false, sq: { he: 'he' } }] as any; 116 | 117 | // Act 118 | const actual = getManifestConfigs(statusValue); 119 | const expected = [ 120 | { 121 | message: 122 | 'Incorrect statusCode query format. Expected format: [{i?:number, sq?:number, br?:number, code:number}, ...n] where i and sq are mutually exclusive.', 123 | status: 400 124 | }, 125 | null 126 | ]; 127 | 128 | // Assert 129 | expect(actual).toEqual(expected); 130 | }); 131 | 132 | it('should handle invalid format', () => { 133 | // Arrange 134 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 135 | const statusValue = 'Faulty' as any; 136 | 137 | // Act 138 | const actual = getManifestConfigs(statusValue); 139 | const expected = [ 140 | { 141 | message: 142 | 'Incorrect statusCode query format. Expected format: [{i?:number, sq?:number, br?:number, code:number}, ...n] where i and sq are mutually exclusive.', 143 | status: 400 144 | }, 145 | null 146 | ]; 147 | 148 | // Assert 149 | expect(actual).toEqual(expected); 150 | }); 151 | 152 | it('should handle multiple defaults *', () => { 153 | // Arrange 154 | const statusValue: Record[] = [ 155 | { code: 400, i: '*' }, 156 | { code: 401, sq: '*' }, 157 | { code: 404, i: '*' } 158 | ]; 159 | // Act 160 | const actual = getManifestConfigs(statusValue); 161 | const expected = [ 162 | null, 163 | [ 164 | { 165 | fields: { 166 | code: 400 167 | }, 168 | i: '*' 169 | } 170 | ] 171 | ]; 172 | // Assert 173 | expect(actual).toEqual(expected); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/statusCode.ts: -------------------------------------------------------------------------------- 1 | import { unparsableError } from '../../../shared/utils'; 2 | import { ServiceError, TargetIndex } from '../../../shared/types'; 3 | import { CorruptorConfig, SegmentCorruptorQueryConfig } from '../configs'; 4 | 5 | // TODO: Move to a constants file and group with and 6 | const statusCodeExpectedQueryFormatMsg = 7 | 'Incorrect statusCode query format. Expected format: [{i?:number, sq?:number, br?:number, code:number}, ...n] where i and sq are mutually exclusive.'; 8 | 9 | interface StatusCodeConfig { 10 | i?: TargetIndex; 11 | sq?: TargetIndex; 12 | ch?: number; 13 | code: number; 14 | } 15 | 16 | function getManifestConfigError(value: { [key: string]: any }): string { 17 | const o = value as StatusCodeConfig; 18 | if (o.code && typeof o.code !== 'number') { 19 | return statusCodeExpectedQueryFormatMsg; 20 | } 21 | 22 | if (o.i === undefined && o.sq === undefined) { 23 | return "Incorrect statusCode query format. Either 'i' or 'sq' is required in a single query object."; 24 | } 25 | 26 | if ( 27 | !(o.i === '*' || typeof o.i === 'number') && 28 | !(o.sq === '*' || typeof o.sq === 'number') 29 | ) { 30 | return statusCodeExpectedQueryFormatMsg; 31 | } 32 | 33 | if (o.i !== undefined && o.sq !== undefined) { 34 | return "Incorrect statusCode query format. 'i' and 'sq' are mutually exclusive in a single query object."; 35 | } 36 | 37 | if (Number(o.sq) < 0) { 38 | return 'Incorrect statusCode query format. Field sq must be 0 or positive.'; 39 | } 40 | 41 | if (Number(o.i) < 0) { 42 | return 'Incorrect statusCode query format. Field i must be 0 or positive.'; 43 | } 44 | 45 | return ''; 46 | } 47 | function isValidSegmentConfig(value: object): boolean { 48 | return typeof (value as StatusCodeConfig)?.code === 'number'; 49 | } 50 | 51 | const statusCodeConfig: SegmentCorruptorQueryConfig = { 52 | getManifestConfigs( 53 | configs: Record[] 54 | ): [ServiceError | null, CorruptorConfig[] | null] { 55 | // Verify it's at least an array 56 | if (!Array.isArray(configs)) { 57 | return [ 58 | { 59 | message: statusCodeExpectedQueryFormatMsg, 60 | status: 400 61 | }, 62 | null 63 | ]; 64 | } 65 | 66 | const configIndexMap = new Map(); 67 | const configSqMap = new Map(); 68 | 69 | for (const config of configs) { 70 | // Verify integrity of array content 71 | const error = getManifestConfigError(config); 72 | if (error) { 73 | return [{ message: error, status: 400 }, null]; 74 | } 75 | 76 | const { code, i, sq } = config; 77 | const fields = code ? { code } : null; 78 | 79 | // If * is already set, we skip 80 | if (!configIndexMap.has('*') && !configSqMap.has('*')) { 81 | // Index 82 | if (i === '*') { 83 | configIndexMap.set('*', { fields, i }); 84 | } 85 | // Sequence 86 | else if (sq === '*') { 87 | configSqMap.set('*', { fields, sq }); 88 | } 89 | } 90 | 91 | // Index numeric 92 | if (typeof i === 'number' && !configIndexMap.has(i)) { 93 | configIndexMap.set(i, { fields, i }); 94 | } 95 | 96 | // Sequence numeric 97 | if (typeof sq === 'number' && !configSqMap.has(sq)) { 98 | configSqMap.set(sq, { fields, sq }); 99 | } 100 | } 101 | 102 | const corruptorConfigs = [ 103 | ...configIndexMap.values(), 104 | ...configSqMap.values() 105 | ]; 106 | 107 | return [null, corruptorConfigs]; 108 | }, 109 | getSegmentConfigs( 110 | statusCodeConfigString: string 111 | ): [ServiceError | null, CorruptorConfig | null] { 112 | const config = JSON.parse(statusCodeConfigString); 113 | 114 | if (!isValidSegmentConfig(config)) { 115 | return [ 116 | unparsableError( 117 | 'statusCode', 118 | statusCodeConfigString, 119 | '{i?:number, sq?:number, br?:string, code:number}' 120 | ), 121 | null 122 | ]; 123 | } 124 | return [ 125 | null, 126 | { 127 | i: config.i, 128 | sq: config.sq, 129 | fields: { 130 | code: config.code 131 | } 132 | } 133 | ]; 134 | }, 135 | name: 'statusCode' 136 | }; 137 | 138 | export default statusCodeConfig; 139 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/throttle.test.ts: -------------------------------------------------------------------------------- 1 | import throttleConfig from './throttle'; 2 | 3 | describe('manifest.utils.corruptions.throttle', () => { 4 | describe('getManifestConfigs', () => { 5 | const { getManifestConfigs, name } = throttleConfig; 6 | it('should have correct name', () => { 7 | // Assert 8 | expect(name).toEqual('throttle'); 9 | }); 10 | it('should handle valid input', () => { 11 | // Arrange 12 | const throttleValue = [ 13 | { i: 0, rate: 1000 }, 14 | { i: 0, rate: 5000 }, 15 | { sq: 0, rate: 15000 } 16 | ]; 17 | 18 | // Act 19 | const actual = getManifestConfigs(throttleValue); 20 | const expected = [ 21 | null, 22 | [ 23 | { i: 0, fields: { rate: 1000 } }, 24 | { sq: 0, fields: { rate: 15000 } } 25 | ] 26 | ]; 27 | 28 | // Assert 29 | expect(actual).toEqual(expected); 30 | }); 31 | 32 | it('should handle all * indexes', () => { 33 | // Arrange 34 | const throttleValue: Record[] = [ 35 | { i: '*', rate: 1500 }, 36 | { i: 0 } 37 | ]; 38 | 39 | // Act 40 | const actual = getManifestConfigs(throttleValue); 41 | const expected = [ 42 | null, 43 | [ 44 | { i: '*', fields: { rate: 1500 } }, 45 | { i: 0, fields: null } 46 | ] 47 | ]; 48 | 49 | // Assert 50 | expect(actual).toEqual(expected); 51 | }); 52 | 53 | it('should handle all * sequences', () => { 54 | // Arrange 55 | const throttleValue: Record[] = [ 56 | { sq: '*', rate: 1500 }, 57 | { sq: 0 } 58 | ]; 59 | 60 | // Act 61 | const actual = getManifestConfigs(throttleValue); 62 | const expected = [ 63 | null, 64 | [ 65 | { sq: '*', fields: { rate: 1500 } }, 66 | { sq: 0, fields: null } 67 | ] 68 | ]; 69 | 70 | // Assert 71 | expect(actual).toEqual(expected); 72 | }); 73 | 74 | it('should handle no index and no sequence correct', () => { 75 | // Arrange 76 | const throttleValue = [{ rate: 1500 }]; 77 | 78 | // Act 79 | const actual = getManifestConfigs(throttleValue); 80 | const expected = [ 81 | { 82 | message: 83 | "Incorrect throttle query format. Either 'i' or 'sq' is required in a single query object.", 84 | status: 400 85 | }, 86 | null 87 | ]; 88 | 89 | // Assert 90 | expect(actual).toEqual(expected); 91 | }); 92 | 93 | it('should handle both index and sequence in the query object', () => { 94 | // Arrange 95 | const throttleValue = [{ rate: 1500, i: 0, sq: 2 }]; 96 | 97 | // Act 98 | const actual = getManifestConfigs(throttleValue); 99 | const expected = [ 100 | { 101 | message: 102 | "Incorrect throttle query format. 'i' and 'sq' are mutually exclusive in a single query object.", 103 | status: 400 104 | }, 105 | null 106 | ]; 107 | 108 | // Assert 109 | expect(actual).toEqual(expected); 110 | }); 111 | 112 | it('should handle illegal characters in query object', () => { 113 | // Arrange 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | const throttleValue = [ 116 | { rate: 'hehe', i: false, sq: { he: 'he' } } 117 | ] as any; 118 | 119 | // Act 120 | const actual = getManifestConfigs(throttleValue); 121 | const expected = [ 122 | { 123 | message: 124 | 'Incorrect throttle query format. Expected format: [{i?:number, sq?:number, br?:number, rate:number}, ...n] where i and sq are mutually exclusive.', 125 | status: 400 126 | }, 127 | null 128 | ]; 129 | 130 | // Assert 131 | expect(actual).toEqual(expected); 132 | }); 133 | 134 | it('should handle invalid format', () => { 135 | // Arrange 136 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 137 | const throttleValue = 'Faulty' as any; 138 | 139 | // Act 140 | const actual = getManifestConfigs(throttleValue); 141 | const expected = [ 142 | { 143 | message: 144 | 'Incorrect throttle query format. Expected format: [{i?:number, sq?:number, br?:number, rate:number}, ...n] where i and sq are mutually exclusive.', 145 | status: 400 146 | }, 147 | null 148 | ]; 149 | 150 | // Assert 151 | expect(actual).toEqual(expected); 152 | }); 153 | 154 | it('should handle multiple defaults *', () => { 155 | // Arrange 156 | const throttleValue: Record[] = [ 157 | { rate: 1000, i: '*' }, 158 | { rate: 5000, sq: '*' }, 159 | { rate: 10000, i: '*' } 160 | ]; 161 | // Act 162 | const actual = getManifestConfigs(throttleValue); 163 | const expected = [ 164 | null, 165 | [ 166 | { 167 | fields: { 168 | rate: 1000 169 | }, 170 | i: '*' 171 | } 172 | ] 173 | ]; 174 | // Assert 175 | expect(actual).toEqual(expected); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/throttle.ts: -------------------------------------------------------------------------------- 1 | import { unparsableError } from '../../../shared/utils'; 2 | import { ServiceError, TargetIndex } from '../../../shared/types'; 3 | import { CorruptorConfig, SegmentCorruptorQueryConfig } from '../configs'; 4 | 5 | interface ThrottleConfig extends CorruptorConfig { 6 | i?: TargetIndex; 7 | sq?: TargetIndex; 8 | br?: TargetIndex; 9 | rate?: number; 10 | } 11 | 12 | // TODO:Flytta till en i en constants fil, och gruppera med and 13 | const throttleExpectedQueryFormatMsg = 14 | 'Incorrect throttle query format. Expected format: [{i?:number, sq?:number, br?:number, rate:number}, ...n] where i and sq are mutually exclusive.'; 15 | 16 | function getManifestConfigError(value: { [key: string]: unknown }): string { 17 | const o = value as ThrottleConfig; 18 | 19 | if (o.rate && typeof o.rate !== 'number') { 20 | return throttleExpectedQueryFormatMsg; 21 | } 22 | 23 | if (o.i === undefined && o.sq === undefined) { 24 | return "Incorrect throttle query format. Either 'i' or 'sq' is required in a single query object."; 25 | } 26 | 27 | if ( 28 | !(o.i === '*' || typeof o.i === 'number') && 29 | !(o.sq === '*' || typeof o.sq === 'number') 30 | ) { 31 | return throttleExpectedQueryFormatMsg; 32 | } 33 | 34 | if (o.i !== undefined && o.sq !== undefined) { 35 | return "Incorrect throttle query format. 'i' and 'sq' are mutually exclusive in a single query object."; 36 | } 37 | 38 | if (Number(o.sq) < 0) { 39 | return 'Incorrect throttle query format. Field sq must be 0 or positive.'; 40 | } 41 | 42 | if (Number(o.i) < 0) { 43 | return 'Incorrect throttle query format. Field i must be 0 or positive.'; 44 | } 45 | 46 | return ''; 47 | } 48 | function isValidSegmentConfig(value: { [key: string]: unknown }): boolean { 49 | if (value.rate && typeof value.rate !== 'number') { 50 | return false; 51 | } 52 | return true; 53 | } 54 | 55 | const throttleConfig: SegmentCorruptorQueryConfig = { 56 | getManifestConfigs( 57 | configs: Record[] 58 | ): [ServiceError | null, CorruptorConfig[] | null] { 59 | // Verify it's at least an array 60 | if (!Array.isArray(configs)) { 61 | return [ 62 | { 63 | message: throttleExpectedQueryFormatMsg, 64 | status: 400 65 | }, 66 | null 67 | ]; 68 | } 69 | 70 | const configIndexMap = new Map(); 71 | const configSqMap = new Map(); 72 | 73 | for (const config of configs) { 74 | // Verify integrity of array content 75 | const error = getManifestConfigError(config); 76 | if (error) { 77 | return [{ message: error, status: 400 }, null]; 78 | } 79 | 80 | const { rate, i, sq } = config; 81 | const fields = rate ? { rate } : null; 82 | 83 | // If * is already set, we skip 84 | if (!configIndexMap.has('*') && !configSqMap.has('*')) { 85 | // Index 86 | if (i === '*') { 87 | configIndexMap.set('*', { fields, i }); 88 | } 89 | // Sequence 90 | else if (sq === '*') { 91 | configSqMap.set('*', { fields, sq }); 92 | } 93 | } 94 | 95 | // Index numeric 96 | if (typeof i === 'number' && !configIndexMap.has(i)) { 97 | configIndexMap.set(i, { fields, i }); 98 | } 99 | 100 | // Sequence numeric 101 | if (typeof sq === 'number' && !configSqMap.has(sq)) { 102 | configSqMap.set(sq, { fields, sq }); 103 | } 104 | } 105 | 106 | const corruptorConfigs = [ 107 | ...configIndexMap.values(), 108 | ...configSqMap.values() 109 | ]; 110 | 111 | return [null, corruptorConfigs]; 112 | }, 113 | getSegmentConfigs( 114 | throttleConfigString: string 115 | ): [ServiceError | null, CorruptorConfig | null] { 116 | const config = JSON.parse(throttleConfigString); 117 | if (!isValidSegmentConfig(config)) { 118 | return [ 119 | unparsableError( 120 | 'throttle', 121 | throttleConfigString, 122 | '{i?:number, sq?:number, rate:number}' 123 | ), 124 | null 125 | ]; 126 | } 127 | 128 | return [ 129 | null, 130 | { 131 | i: config.i, 132 | sq: config.sq, 133 | fields: { 134 | rate: config.rate 135 | } 136 | } 137 | ]; 138 | }, 139 | name: 'throttle' 140 | }; 141 | 142 | export default throttleConfig; 143 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/timeout.test.ts: -------------------------------------------------------------------------------- 1 | import timeoutConfig from './timeout'; 2 | 3 | describe('manifest.utils.corruptions.timeout', () => { 4 | describe('getManifestConfigs', () => { 5 | const { getManifestConfigs, name } = timeoutConfig; 6 | it('should have correct name', () => { 7 | // Assert 8 | expect(name).toEqual('timeout'); 9 | }); 10 | it('should handle valid input', () => { 11 | // Arrange 12 | const timeoutValue = [{ i: 0 }, { sq: 0 }]; 13 | 14 | // Act 15 | const actual = getManifestConfigs(timeoutValue); 16 | const expected = [ 17 | null, 18 | [ 19 | { i: 0, fields: {} }, 20 | { sq: 0, fields: {} } 21 | ] 22 | ]; 23 | 24 | // Assert 25 | expect(actual).toEqual(expected); 26 | }); 27 | 28 | it('should handle all * indexes', () => { 29 | // Arrange 30 | const timeoutValue: Record[] = [ 31 | { i: 0 }, 32 | { i: '*' } 33 | ]; 34 | 35 | // Act 36 | const actual = getManifestConfigs(timeoutValue); 37 | const expected = [ 38 | null, 39 | [ 40 | { i: 0, fields: null }, 41 | { i: '*', fields: {} } 42 | ] 43 | ]; 44 | 45 | // Assert 46 | expect(actual).toEqual(expected); 47 | }); 48 | 49 | it('should handle all * sequences', () => { 50 | // Arrange 51 | const timeoutValue: Record[] = [ 52 | { sq: 0 }, 53 | { sq: '*' } 54 | ]; 55 | 56 | // Act 57 | const actual = getManifestConfigs(timeoutValue); 58 | const expected = [ 59 | null, 60 | [ 61 | { sq: 0, fields: null }, 62 | { sq: '*', fields: {} } 63 | ] 64 | ]; 65 | 66 | // Assert 67 | expect(actual).toEqual(expected); 68 | }); 69 | 70 | it('should handle no index and no sequence correct', () => { 71 | // Arrange 72 | const timeoutValue: Record[] = [ 73 | { irrelevant: 123 } 74 | ]; 75 | 76 | // Act 77 | const actual = getManifestConfigs(timeoutValue); 78 | const expected = [ 79 | { 80 | message: 81 | "Incorrect timeout query format. Either 'i' or 'sq' is required in a single query object.", 82 | status: 400 83 | }, 84 | null 85 | ]; 86 | 87 | // Assert 88 | expect(actual).toEqual(expected); 89 | }); 90 | 91 | it('should handle both index and sequence in the query object', () => { 92 | // Arrange 93 | const timeoutValue: Record[] = [{ i: 0, sq: 2 }]; 94 | 95 | // Act 96 | const actual = getManifestConfigs(timeoutValue); 97 | const expected = [ 98 | { 99 | message: 100 | "Incorrect timeout query format. 'i' and 'sq' are mutually exclusive in a single query object.", 101 | status: 400 102 | }, 103 | null 104 | ]; 105 | 106 | // Assert 107 | expect(actual).toEqual(expected); 108 | }); 109 | 110 | it('should handle illegal characters in query object', () => { 111 | // Arrange 112 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 113 | const timeoutValue = [{ ms: 'hehe', i: false, sq: { he: 'he' } }] as any; 114 | 115 | // Act 116 | const actual = getManifestConfigs(timeoutValue); 117 | const expected = [ 118 | { 119 | message: 120 | 'Incorrect timeout query format. Expected format: [{i?:number, sq?:number, br?:number}, ...n] where i and sq are mutually exclusive.', 121 | status: 400 122 | }, 123 | null 124 | ]; 125 | 126 | // Assert 127 | expect(actual).toEqual(expected); 128 | }); 129 | 130 | it('should handle invalid format', () => { 131 | // Arrange 132 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 133 | const timeoutValue = 'Fel' as any; 134 | 135 | // Act 136 | const actual = getManifestConfigs(timeoutValue); 137 | const expected = [ 138 | { 139 | message: 140 | 'Incorrect timeout query format. Expected format: [{i?:number, sq?:number, br?:number}, ...n] where i and sq are mutually exclusive.', 141 | status: 400 142 | }, 143 | null 144 | ]; 145 | 146 | // Assert 147 | expect(actual).toEqual(expected); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/manifests/utils/corruptions/timeout.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError, TargetIndex } from '../../../shared/types'; 2 | import { CorruptorConfig, SegmentCorruptorQueryConfig } from '../configs'; 3 | 4 | // TODO: Move to a constants file and group with and 5 | const timeoutExpectedQueryFormatMsg = 6 | 'Incorrect timeout query format. Expected format: [{i?:number, sq?:number, br?:number}, ...n] where i and sq are mutually exclusive.'; 7 | 8 | interface TimeoutConfig { 9 | i?: TargetIndex; 10 | sq?: TargetIndex; 11 | br?: TargetIndex; 12 | ch?: number; 13 | } 14 | 15 | function getManifestConfigError(value: { [key: string]: unknown }): string { 16 | const o = value as TimeoutConfig; 17 | 18 | if (o.i === undefined && o.sq === undefined) { 19 | return "Incorrect timeout query format. Either 'i' or 'sq' is required in a single query object."; 20 | } 21 | 22 | if ( 23 | !(o.i === '*' || typeof o.i === 'number') && 24 | !(o.sq === '*' || typeof o.sq === 'number') 25 | ) { 26 | return timeoutExpectedQueryFormatMsg; 27 | } 28 | 29 | if (o.i !== undefined && o.sq !== undefined) { 30 | return "Incorrect timeout query format. 'i' and 'sq' are mutually exclusive in a single query object."; 31 | } 32 | 33 | if (Number(o.sq) < 0) { 34 | return 'Incorrect timeout query format. Field sq must be 0 or positive.'; 35 | } 36 | 37 | if (Number(o.i) < 0) { 38 | return 'Incorrect timeout query format. Field i must be 0 or positive.'; 39 | } 40 | 41 | return ''; 42 | } 43 | 44 | const timeoutConfig: SegmentCorruptorQueryConfig = { 45 | getManifestConfigs( 46 | configs: Record[] 47 | ): [ServiceError | null, CorruptorConfig[] | null] { 48 | // Verify it's at least an array 49 | if (!Array.isArray(configs)) { 50 | return [ 51 | { 52 | message: timeoutExpectedQueryFormatMsg, 53 | status: 400 54 | }, 55 | null 56 | ]; 57 | } 58 | 59 | // Verify integrity of array content 60 | for (let i = 0; i < configs.length; i++) { 61 | const error = getManifestConfigError(configs[i]); 62 | if (error) { 63 | return [{ message: error, status: 400 }, null]; 64 | } 65 | } 66 | 67 | const configIndexMap = new Map(); 68 | const configSqMap = new Map(); 69 | 70 | const noopNumericConfigs = function () { 71 | configIndexMap.forEach((val, key) => { 72 | if (typeof key === 'number') { 73 | val.fields = null; 74 | configIndexMap.set(key, val); 75 | } 76 | }); 77 | 78 | configSqMap.forEach((val, key) => { 79 | if (typeof key === 'number') { 80 | val.fields = null; 81 | configSqMap.set(key, val); 82 | } 83 | }); 84 | }; 85 | 86 | for (let i = 0; i < configs.length; i++) { 87 | const config = configs[i]; 88 | const corruptorConfig: CorruptorConfig = { 89 | fields: {} 90 | }; 91 | 92 | if (config.i === '*') { 93 | // If default is already set, we skip 94 | if (!configIndexMap.has(config.i) && !configSqMap.has(config.i)) { 95 | corruptorConfig.i = config.i; 96 | configIndexMap.set(config.i, corruptorConfig); 97 | 98 | // We need to noop all numeric 99 | noopNumericConfigs(); 100 | } 101 | } 102 | 103 | if (typeof config.i === 'number') { 104 | // If there's any default, make it noop 105 | if (configIndexMap.has('*') || configSqMap.has('*')) { 106 | corruptorConfig.fields = null; 107 | } 108 | corruptorConfig.i = config.i; 109 | configIndexMap.set(config.i, corruptorConfig); 110 | } 111 | 112 | if (config.sq === '*') { 113 | // If default is already set, we skip 114 | if (!configIndexMap.has(config.sq) && !configSqMap.has(config.sq)) { 115 | corruptorConfig.sq = config.sq; 116 | configSqMap.set(config.sq, corruptorConfig); 117 | } 118 | 119 | // We need to noop all numbers 120 | noopNumericConfigs(); 121 | } 122 | 123 | if (typeof config.sq === 'number') { 124 | // If there's any default, make it noop 125 | if (configIndexMap.has(config.i) || configSqMap.has(config.i)) { 126 | corruptorConfig.fields = null; 127 | } 128 | corruptorConfig.sq = config.sq; 129 | configSqMap.set(config.sq, corruptorConfig); 130 | } 131 | } 132 | 133 | const corruptorConfigs: CorruptorConfig[] = []; 134 | 135 | for (const value of configIndexMap.values()) { 136 | corruptorConfigs.push(value); 137 | } 138 | 139 | for (const value of configSqMap.values()) { 140 | corruptorConfigs.push(value); 141 | } 142 | return [null, corruptorConfigs]; 143 | }, 144 | 145 | getSegmentConfigs(/* timeoutConfigString: string */): [ 146 | ServiceError | null, 147 | CorruptorConfig | null 148 | ] { 149 | return [null, { fields: {} }]; 150 | }, 151 | name: 'timeout' 152 | }; 153 | 154 | export default timeoutConfig; 155 | -------------------------------------------------------------------------------- /src/manifests/utils/dashManifestUtils.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import * as xml2js from 'xml2js'; 3 | import path from 'path'; 4 | import dashManifestUtils from './dashManifestUtils'; 5 | import { TargetIndex } from '../../shared/types'; 6 | import { CorruptorConfigMap, CorruptorConfig } from './configs'; 7 | 8 | describe('dashManifestTools', () => { 9 | describe('createProxyDASHManifest', () => { 10 | it('should replace initialization urls & media urls in dash manifest, with absolute source url & proxy url with query parameters respectively', async () => { 11 | // Arrange 12 | const mockManifestPath = 13 | '../../testvectors/dash/dash1_multitrack/manifest.xml'; 14 | const mockDashManifest = fs.readFileSync( 15 | path.join(__dirname, mockManifestPath), 16 | 'utf8' 17 | ); 18 | const queryString = 19 | 'url=https://mock.mock.com/stream/dash/asset44/manifest.mpd&statusCode=[{i:0,code:404},{i:2,code:401}]&timeout=[{i:3}]&delay=[{i:2,ms:2000}]'; 20 | const urlSearchParams = new URLSearchParams(queryString); 21 | // Act 22 | const manifestUtils = dashManifestUtils(); 23 | const proxyManifest: string = manifestUtils.createProxyDASHManifest( 24 | mockDashManifest, 25 | urlSearchParams 26 | ); 27 | // Assert 28 | const parser = new xml2js.Parser(); 29 | const builder = new xml2js.Builder(); 30 | const proxyManifestPath = 31 | '../../testvectors/dash/dash1_multitrack/proxy-manifest.xml'; 32 | const dashFile: string = fs.readFileSync( 33 | path.join(__dirname, proxyManifestPath), 34 | 'utf8' 35 | ); 36 | let DASH_JSON; 37 | parser.parseString(dashFile, function (err, result) { 38 | DASH_JSON = result; 39 | }); 40 | const expected: string = builder.buildObject(DASH_JSON); 41 | expect(proxyManifest).toEqual(expected); 42 | }); 43 | 44 | it('should replace initialization urls & media urls in compressed dash manifest with base urls, with absolute source url & proxy url with query parameters respectively', async () => { 45 | // Arrange 46 | const mockManifestPath = 47 | '../../testvectors/dash/dash1_compressed/manifest.xml'; 48 | const mockDashManifest = fs.readFileSync( 49 | path.join(__dirname, mockManifestPath), 50 | 'utf8' 51 | ); 52 | const queryString = 53 | 'url=https://mock.mock.com/stream/manifest.mpd&statusCode=[{i:0,code:404},{i:2,code:401}]&timeout=[{i:3}]&delay=[{i:2,ms:2000}]'; 54 | const urlSearchParams = new URLSearchParams(queryString); 55 | // Act 56 | const manifestUtils = dashManifestUtils(); 57 | const proxyManifest: string = manifestUtils.createProxyDASHManifest( 58 | mockDashManifest, 59 | urlSearchParams 60 | ); 61 | // Assert 62 | const parser = new xml2js.Parser(); 63 | const builder = new xml2js.Builder(); 64 | const proxyManifestPath = 65 | '../../testvectors/dash/dash1_compressed/proxy-manifest.xml'; 66 | const dashFile: string = fs.readFileSync( 67 | path.join(__dirname, proxyManifestPath), 68 | 'utf8' 69 | ); 70 | let DASH_JSON; 71 | parser.parseString(dashFile, function (err, result) { 72 | DASH_JSON = result; 73 | }); 74 | const expected: string = builder.buildObject(DASH_JSON); 75 | expect(proxyManifest).toEqual(expected); 76 | }); 77 | 78 | it('should replace relative sequence numbers in corruptions with absolute ones', async () => { 79 | // Arrange 80 | const mockManifestPath = 81 | '../../testvectors/dash/dash1_relative_sequence/manifest.xml'; 82 | const mockDashManifest = fs.readFileSync( 83 | path.join(__dirname, mockManifestPath), 84 | 'utf8' 85 | ); 86 | const queryString = 87 | 'url=https://mock.mock.com/stream/dash/asset44/manifest.mpd&statusCode=[{rsq:10,code:404},{rsq:20,code:401}]&timeout=[{rsq:30}]&delay=[{rsq:40,ms:2000}]'; 88 | const urlSearchParams = new URLSearchParams(queryString); 89 | // Act 90 | const manifestUtils = dashManifestUtils(); 91 | const proxyManifest: string = manifestUtils.createProxyDASHManifest( 92 | mockDashManifest, 93 | urlSearchParams 94 | ); 95 | // Assert 96 | const parser = new xml2js.Parser(); 97 | const builder = new xml2js.Builder(); 98 | const proxyManifestPath = 99 | '../../testvectors/dash/dash1_relative_sequence/proxy-manifest.xml'; 100 | const dashFile: string = fs.readFileSync( 101 | path.join(__dirname, proxyManifestPath), 102 | 'utf8' 103 | ); 104 | let DASH_JSON; 105 | parser.parseString(dashFile, function (err, result) { 106 | DASH_JSON = result; 107 | }); 108 | const expected: string = builder.buildObject(DASH_JSON); 109 | expect(proxyManifest).toEqual(expected); 110 | }); 111 | 112 | it('should use the period baseUrl if it exists', async () => { 113 | // Arrange 114 | const mockManifestPath = 115 | '../../testvectors/dash/dash_period_baseurl/manifest.xml'; 116 | const mockDashManifest = fs.readFileSync( 117 | path.join(__dirname, mockManifestPath), 118 | 'utf8' 119 | ); 120 | const manifestUtils = dashManifestUtils(); 121 | const proxyManifest: string = manifestUtils.createProxyDASHManifest( 122 | mockDashManifest, 123 | new URLSearchParams( 124 | 'url=https://mock.mock.com/stream/dash/period_base_url/manifest.mpd' 125 | ) 126 | ); 127 | const proxyManifestPath = 128 | '../../testvectors/dash/dash_period_baseurl/proxy-manifest.xml'; 129 | const dashFile: string = fs.readFileSync( 130 | path.join(__dirname, proxyManifestPath), 131 | 'utf8' 132 | ); 133 | let DASH_JSON; 134 | const parser = new xml2js.Parser(); 135 | const builder = new xml2js.Builder(); 136 | parser.parseString(dashFile, function (err, result) { 137 | DASH_JSON = result; 138 | }); 139 | const expected: string = builder.buildObject(DASH_JSON); 140 | expect(proxyManifest).toEqual(expected); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('utils.mergeMap', () => { 146 | it('should handle priority without default corrtupions', () => { 147 | // Assign 148 | const mockReqSegIndex = 3; 149 | const mockAllCorruptions = new Map(); 150 | mockAllCorruptions.set( 151 | mockReqSegIndex, 152 | new Map() 153 | .set('a', { fields: { ms: 100 } }) 154 | .set('b', { fields: { code: 300 } }) 155 | ); 156 | 157 | // Act 158 | const actual = dashManifestUtils().utils.mergeMap( 159 | mockReqSegIndex, 160 | mockAllCorruptions, 161 | undefined 162 | ); 163 | const expected = new Map() 164 | .set('a', { fields: { ms: 100 } }) 165 | .set('b', { fields: { code: 300 } }); 166 | 167 | // Assert 168 | expect(actual).toEqual(expected); 169 | }); 170 | 171 | it('should handle priority with default corrtupions', () => { 172 | // Assign 173 | const mockAllCorruptions = new Map(); 174 | mockAllCorruptions 175 | .set( 176 | 0, 177 | new Map() 178 | .set('a', { fields: { ms: 100 } }) 179 | .set('b', { fields: { code: 300 } }) 180 | ) 181 | .set( 182 | 2, 183 | new Map().set('a', { 184 | fields: null 185 | }) 186 | ) 187 | .set( 188 | '*', 189 | new Map().set('a', { fields: { ms: 50 } }) 190 | ); 191 | // Act 192 | const actual: CorruptorConfigMap[] = []; 193 | for (let segIdx = 0; segIdx < 3; segIdx++) { 194 | actual.push( 195 | dashManifestUtils().utils.mergeMap( 196 | segIdx, 197 | mockAllCorruptions, 198 | undefined 199 | ) 200 | ); 201 | } 202 | 203 | const expected = [ 204 | new Map() 205 | .set('a', { fields: { ms: 100 } }) 206 | .set('b', { fields: { code: 300 } }), 207 | new Map().set('a', { fields: { ms: 50 } }), 208 | new Map() 209 | ]; 210 | // Assert 211 | expect(actual).toEqual(expected); 212 | }); 213 | 214 | it('should handle multiple defaults with one noop', () => { 215 | // Arrange 216 | const mockAllCorruptions = new Map(); 217 | mockAllCorruptions 218 | .set( 219 | 2, 220 | new Map().set('a', { 221 | fields: null 222 | }) 223 | ) 224 | .set( 225 | '*', 226 | new Map() 227 | .set('a', { fields: { ms: 50 } }) 228 | .set('b', { fields: { code: 500 } }) 229 | ); 230 | // Act 231 | const actual: CorruptorConfigMap[] = []; 232 | for (let segIdx = 0; segIdx < 3; segIdx++) { 233 | actual.push( 234 | dashManifestUtils().utils.mergeMap( 235 | segIdx, 236 | mockAllCorruptions, 237 | undefined 238 | ) 239 | ); 240 | } 241 | const expected = [ 242 | new Map() 243 | .set('a', { fields: { ms: 50 } }) 244 | .set('b', { fields: { code: 500 } }), 245 | new Map() 246 | .set('a', { fields: { ms: 50 } }) 247 | .set('b', { fields: { code: 500 } }), 248 | new Map().set('b', { fields: { code: 500 } }) 249 | ]; 250 | // Assert 251 | expect(actual).toEqual(expected); 252 | }); 253 | 254 | it('should handle empty fields prop correct', () => { 255 | // Arrange 256 | const mockReqSegIndex = 3; 257 | const mockAllCorruptions = new Map().set( 258 | mockReqSegIndex, 259 | new Map().set('a', { fields: {} }) 260 | ); 261 | // Act 262 | const actual = dashManifestUtils().utils.mergeMap( 263 | mockReqSegIndex, 264 | mockAllCorruptions, 265 | undefined 266 | ); 267 | const expected = new Map().set('a', { 268 | fields: {} 269 | }); 270 | // Assert 271 | expect(actual).toEqual(expected); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /src/manifests/utils/dashManifestUtils.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from '../../shared/types'; 2 | import * as xml2js from 'xml2js'; 3 | import { IndexedCorruptorConfigMap, CorruptorConfigMap } from './configs'; 4 | import { proxyPathBuilder } from '../../shared/utils'; 5 | import { URLSearchParams } from 'url'; 6 | 7 | interface DASHManifestUtils { 8 | mergeMap: ( 9 | segmentListSize: number, 10 | configsMap: IndexedCorruptorConfigMap, 11 | stateKey: string | undefined 12 | ) => CorruptorConfigMap; 13 | } 14 | 15 | export interface DASHManifestTools { 16 | createProxyDASHManifest: ( 17 | dashManifestText: string, 18 | originalUrlQuery: URLSearchParams 19 | ) => Manifest; // look def again 20 | utils: DASHManifestUtils; 21 | } 22 | 23 | export default function (): DASHManifestTools { 24 | const utils = { 25 | mergeMap( 26 | targetSegmentIndex: number, 27 | configsMap: IndexedCorruptorConfigMap 28 | ): CorruptorConfigMap { 29 | const outputMap = new Map(); 30 | const d = configsMap.get('*'); 31 | if (d) { 32 | for (const name of d.keys()) { 33 | const { fields } = d.get(name); 34 | outputMap.set(name, { fields: { ...fields } }); 35 | } 36 | } 37 | // Populate any explicitly defined corruptions into the list 38 | const configCorruptions = configsMap.get(targetSegmentIndex); 39 | if (configCorruptions) { 40 | // Map values always take precedence 41 | for (const name of configCorruptions.keys()) { 42 | // If fields isn't set, it means it's a skip if *, otherwise no-op 43 | if (!configCorruptions.get(name).fields) { 44 | outputMap.delete(name); 45 | continue; 46 | } 47 | outputMap.set(name, configCorruptions.get(name)); 48 | } 49 | } 50 | return outputMap; 51 | } 52 | }; 53 | return { 54 | utils, 55 | createProxyDASHManifest( 56 | dashManifestText: string, 57 | originalUrlQuery: URLSearchParams 58 | ): string { 59 | const parser = new xml2js.Parser(); 60 | const builder = new xml2js.Builder(); 61 | 62 | let DASH_JSON; 63 | parser.parseString(dashManifestText, function (err, result) { 64 | DASH_JSON = result; 65 | }); 66 | 67 | let globalBaseUrl; 68 | if (DASH_JSON.MPD.BaseURL) { 69 | // There should only ever be one baseurl according to schema 70 | globalBaseUrl = DASH_JSON.MPD.BaseURL[0]; 71 | } 72 | // Remove base url from manifest since we are using relative paths for proxy 73 | delete DASH_JSON.MPD.BaseURL; 74 | 75 | let staticQueryUrl: URLSearchParams; 76 | 77 | DASH_JSON.MPD.Period.map((period) => { 78 | let baseUrl = globalBaseUrl; 79 | if (period.BaseURL?.[0]) { 80 | baseUrl = period.BaseURL[0]; 81 | // Remove base url from manifest since we are using relative paths for proxy 82 | delete period.BaseURL; 83 | } 84 | period.AdaptationSet.map((adaptationSet) => { 85 | // If there is a SegmentTemplate directly in the adaptationSet there should only be one 86 | // But if it has no media property it is invalid and we should try the Representation instead 87 | if (adaptationSet.SegmentTemplate?.[0]?.$?.media) { 88 | const segmentTemplate = adaptationSet.SegmentTemplate[0]; 89 | 90 | // Media attr 91 | const mediaUrl = segmentTemplate.$.media; 92 | const hasTime = mediaUrl.toString().includes('$Time$'); 93 | // Convert relative segment offsets to absolute ones 94 | // Also clones params to avoid mutating input argument 95 | const [urlQuery, changed] = convertRelativeToAbsoluteSegmentOffsets( 96 | DASH_JSON.MPD, 97 | segmentTemplate, 98 | originalUrlQuery, 99 | false 100 | ); 101 | 102 | if (changed) staticQueryUrl = new URLSearchParams(urlQuery); 103 | const proxy = proxyPathBuilder( 104 | mediaUrl.match(/^http/) ? mediaUrl : baseUrl + mediaUrl, 105 | urlQuery, 106 | hasTime 107 | ? 'proxy-segment/segment_$Time$_$Bandwidth$_$RepresentationID$' 108 | : 'proxy-segment/segment_$Number$_$Bandwidth$_$RepresentationID$' 109 | ); 110 | segmentTemplate.$.media = proxy; 111 | // Initialization attr. 112 | const initUrl = segmentTemplate.$.initialization; 113 | if (!initUrl.match(/^http/)) { 114 | try { 115 | // Use original query url if baseUrl is undefined, combine if relative, or use just baseUrl if its absolute 116 | if (!baseUrl) { 117 | baseUrl = originalUrlQuery.get('url'); 118 | } else if (!baseUrl.match(/^http/)) { 119 | baseUrl = new URL(baseUrl, originalUrlQuery.get('url')).href; 120 | } 121 | const absoluteInitUrl = new URL(initUrl, baseUrl).href; 122 | segmentTemplate.$.initialization = absoluteInitUrl; 123 | } catch (e) { 124 | throw new Error(e); 125 | } 126 | } 127 | } else { 128 | // Uses segment ids 129 | adaptationSet.Representation.map((representation) => { 130 | if (representation.SegmentTemplate) { 131 | representation.SegmentTemplate.map((segmentTemplate) => { 132 | // Media attr. 133 | const mediaUrl = segmentTemplate.$.media; 134 | const hasTime = mediaUrl.toString().includes('$Time$'); 135 | // Convert relative segment offsets to absolute ones 136 | // Also clones params to avoid mutating input argument 137 | const [urlQuery, changed] = 138 | convertRelativeToAbsoluteSegmentOffsets( 139 | DASH_JSON.MPD, 140 | segmentTemplate, 141 | originalUrlQuery, 142 | true 143 | ); 144 | 145 | if (changed) staticQueryUrl = new URLSearchParams(urlQuery); 146 | 147 | if (representation.$.bandwidth) { 148 | urlQuery.set('bitrate', representation.$.bandwidth); 149 | } 150 | 151 | const proxy = proxyPathBuilder( 152 | mediaUrl, 153 | urlQuery, 154 | hasTime 155 | ? 'proxy-segment/segment_$Time$.mp4' 156 | : 'proxy-segment/segment_$Number$.mp4' 157 | ); 158 | segmentTemplate.$.media = proxy; 159 | // Initialization attr. 160 | const masterDashUrl = originalUrlQuery.get('url'); 161 | const initUrl = segmentTemplate.$.initialization; 162 | if (!initUrl.match(/^http/)) { 163 | try { 164 | const absoluteInitUrl = new URL(initUrl, masterDashUrl) 165 | .href; 166 | segmentTemplate.$.initialization = absoluteInitUrl; 167 | } catch (e) { 168 | throw new Error(e); 169 | } 170 | } 171 | }); 172 | } 173 | }); 174 | } 175 | }); 176 | }); 177 | 178 | if (staticQueryUrl) 179 | DASH_JSON.MPD.Location = 'proxy-master.mpd?' + staticQueryUrl; 180 | 181 | const manifest = builder.buildObject(DASH_JSON); 182 | 183 | return manifest; 184 | } 185 | }; 186 | } 187 | 188 | function convertRelativeToAbsoluteSegmentOffsets( 189 | mpd: any, 190 | segmentTemplate: any, 191 | originalUrlQuery: URLSearchParams, 192 | segmentTemplateTimelineFormat: boolean 193 | ): [URLSearchParams, boolean] { 194 | let firstSegment: number; 195 | 196 | if (segmentTemplateTimelineFormat) { 197 | firstSegment = Number(segmentTemplate.$.startNumber); 198 | } else { 199 | // Calculate first segment number 200 | const walltime = new Date().getTime(); 201 | const availabilityStartTime = new Date( 202 | mpd.$.availabilityStartTime 203 | ).getTime(); 204 | 205 | let duration: number; 206 | if (segmentTemplate.$.duration) { 207 | duration = Number(segmentTemplate.$.duration); 208 | } else { 209 | duration = Number(segmentTemplate.SegmentTimeline[0].S[0].$.d); 210 | } 211 | 212 | const timescale = Number( 213 | segmentTemplate.$.timescale ? segmentTemplate.$.timescale : '1' 214 | ); 215 | const startNumber = Number(segmentTemplate.$.startNumber); 216 | 217 | firstSegment = Math.round( 218 | (walltime - availabilityStartTime) / 1000 / (duration / timescale) + 219 | startNumber 220 | ); 221 | } 222 | 223 | const urlQuery = new URLSearchParams(originalUrlQuery); 224 | 225 | const corruptions = ['statusCode', 'delay', 'timeout', 'throttle']; 226 | 227 | let changed = false; 228 | 229 | for (const corruption of corruptions) { 230 | const fieldsJson = urlQuery.get(corruption); 231 | 232 | if (fieldsJson) { 233 | const fields = JSON.parse(getJSONParsableString(fieldsJson)); 234 | 235 | fields.map((field) => { 236 | const relativeOffset = field.rsq; 237 | 238 | if (relativeOffset) { 239 | changed = true; 240 | delete field.rsq; 241 | field.sq = firstSegment + relativeOffset; 242 | } 243 | }); 244 | 245 | const fieldsSerialized = JSON.stringify(fields).replace(/"/g, ''); 246 | urlQuery.set(corruption, fieldsSerialized); 247 | } 248 | } 249 | 250 | return [urlQuery, changed]; 251 | } 252 | 253 | export function getJSONParsableString(value: string): string { 254 | return decodeURIComponent(value) 255 | .replace(/\s/g, '') 256 | .replace( 257 | /({|,)(?:\s*)(?:')?([A-Za-z_$.][A-Za-z0-9_ \-.$]*)(?:')?(?:\s*):/g, 258 | '$1"$2":' 259 | ) 260 | .replace(/:\*/g, ':"*"'); 261 | } 262 | -------------------------------------------------------------------------------- /src/manifests/utils/hlsManifestUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { TargetIndex } from '../../shared/types'; 2 | import { parseM3U8Stream, segmentUrlParamString } from '../../shared/utils'; 3 | import { 4 | CorruptorConfig, 5 | CorruptorConfigMap, 6 | corruptorConfigUtils, 7 | SegmentCorruptorQueryConfig 8 | } from './configs'; 9 | import hlsManifestTools from './hlsManifestUtils'; 10 | import { createReadStream } from 'fs'; 11 | import path from 'path'; 12 | import hlsManifestUtils from './hlsManifestUtils'; 13 | 14 | describe('hlsManifestTools', () => { 15 | describe('createProxyMasterManifest', () => { 16 | it("should replace variant urls in Master manifest, with querystring and swap 'url' value with source media url", async () => { 17 | // Arrange 18 | const readStream = createReadStream( 19 | path.join( 20 | __dirname, 21 | '../../testvectors/hls/hls1_multitrack/manifest.m3u8' 22 | ) 23 | ); 24 | const masterM3U = await parseM3U8Stream(readStream); 25 | const queryString = 26 | 'url=https://mock.mock.com/stream/hls/manifest.m3u8&statusCode=[{i:0,code:404},{i:2,code:401}]&timeout=[{i:3}]&delay=[{i:2,ms:2000}]'; 27 | const urlSearchParams = new URLSearchParams(queryString); 28 | 29 | // Act 30 | const manifestUtils = hlsManifestUtils(); 31 | const proxyManifest: string = manifestUtils.createProxyMasterManifest( 32 | masterM3U, 33 | urlSearchParams, 34 | undefined 35 | ); 36 | 37 | // Assert 38 | const expected = `#EXTM3U 39 | #EXT-X-VERSION:3 40 | #EXT-X-INDEPENDENT-SEGMENTS 41 | #EXT-X-STREAM-INF:BANDWIDTH=4255267,AVERAGE-BANDWIDTH=4255267,CODECS="avc1.4d4032,mp4a.40.2",RESOLUTION=2560x1440,FRAME-RATE=25,AUDIO="audio",SUBTITLES="subs" 42 | proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_1.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D&bitrate=4255267&level=1 43 | #EXT-X-STREAM-INF:BANDWIDTH=3062896,AVERAGE-BANDWIDTH=3062896,CODECS="avc1.4d4028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=25,AUDIO="audio",SUBTITLES="subs" 44 | proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_2.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D&bitrate=3062896&level=2 45 | #EXT-X-STREAM-INF:BANDWIDTH=2316761,AVERAGE-BANDWIDTH=2316761,CODECS="avc1.4d4028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=25,AUDIO="audio",SUBTITLES="subs" 46 | proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_3.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D&bitrate=2316761&level=3 47 | 48 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="en",NAME="English stereo",CHANNELS="2",DEFAULT=YES,AUTOSELECT=YES,URI="proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_audio-en.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D" 49 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="sv",NAME="Swedish stereo",CHANNELS="2",DEFAULT=NO,AUTOSELECT=YES,URI="proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_audio-sv.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D" 50 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="en",NAME="English",FORCED=NO,DEFAULT=NO,AUTOSELECT=YES,URI="proxy-media.m3u8?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_subs-en.m3u8&statusCode=%5B%7Bi%3A0%2Ccode%3A404%7D%2C%7Bi%3A2%2Ccode%3A401%7D%5D&timeout=%5B%7Bi%3A3%7D%5D&delay=%5B%7Bi%3A2%2Cms%3A2000%7D%5D" 51 | 52 | `; 53 | expect(proxyManifest).toEqual(expected); 54 | }); 55 | }); 56 | 57 | describe('createProxyMediaManifest', () => { 58 | it("should replace segment urls in Media manifest, with querystring and swap 'url' value with source segment url", async () => { 59 | // Arrange 60 | const mockCorruptionName = '_test_'; 61 | const readStream = createReadStream( 62 | path.join( 63 | __dirname, 64 | '../../testvectors/hls/hls1_multitrack/manifest_1.m3u8' 65 | ) 66 | ); 67 | const mediaM3U = await parseM3U8Stream(readStream); 68 | const mockBaseUrl = 'https://mock.mock.com/stream/hls'; 69 | const queryString = `url=${mockBaseUrl}/manifest_1.m3u8&${mockCorruptionName}=[{i:0,key:404},{i:2,key:401}]`; 70 | const urlSearchParams = new URLSearchParams(queryString); 71 | 72 | const configs = corruptorConfigUtils(urlSearchParams); 73 | const config: SegmentCorruptorQueryConfig = { 74 | name: mockCorruptionName, 75 | getManifestConfigs: () => [ 76 | null, 77 | [ 78 | { i: 0, fields: { key: 404 } }, 79 | { i: 2, fields: { key: 401 } } 80 | ] 81 | ], 82 | getSegmentConfigs: () => [null, { fields: null }] 83 | }; 84 | configs.register(config); 85 | const [, allMutations] = configs.getAllManifestConfigs( 86 | mediaM3U.get('mediaSequence') 87 | ); 88 | // Act 89 | const manifestUtils = hlsManifestUtils(); 90 | const proxyManifest: string = manifestUtils.createProxyMediaManifest( 91 | mediaM3U, 92 | mockBaseUrl, 93 | allMutations 94 | ); 95 | // Assert 96 | const expected = `#EXTM3U 97 | #EXT-X-VERSION:3 98 | #EXT-X-TARGETDURATION:10 99 | #EXT-X-MEDIA-SEQUENCE:1 100 | #EXT-X-PLAYLIST-TYPE:VOD 101 | #EXTINF:10.0000, 102 | ../../segments/proxy-segment?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_1_00001.ts&_test_=%7Bkey%3A404%7D 103 | #EXTINF:10.0000, 104 | https://mock.mock.com/stream/hls/manifest_1_00002.ts 105 | #EXTINF:10.0000, 106 | ../../segments/proxy-segment?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_1_00003.ts&_test_=%7Bkey%3A401%7D 107 | #EXT-X-ENDLIST 108 | `; 109 | expect(proxyManifest).toEqual(expected); 110 | }); 111 | 112 | it("should replace segment urls in Media manifest, with querystring and swap 'url' value with source segment url, except for targeted noop indexes", async () => { 113 | // Arrange 114 | const mockCorruptionName = '_test_'; 115 | const readStream = createReadStream( 116 | path.join( 117 | __dirname, 118 | '../../testvectors/hls/hls1_multitrack/manifest_1.m3u8' 119 | ) 120 | ); 121 | const mediaM3U = await parseM3U8Stream(readStream); 122 | const mockBaseUrl = 'https://mock.mock.com/stream/hls'; 123 | const queryString = `url=${mockBaseUrl}/manifest_1.m3u8&${mockCorruptionName}=[{i:"*",key:404},{i:2}]`; 124 | const urlSearchParams = new URLSearchParams(queryString); 125 | 126 | const configs = corruptorConfigUtils(urlSearchParams); 127 | const config: SegmentCorruptorQueryConfig = { 128 | name: mockCorruptionName, 129 | getManifestConfigs: () => [ 130 | null, 131 | [ 132 | { i: '*', fields: { key: 404 } }, 133 | { i: 2, fields: null } 134 | ] 135 | ], 136 | getSegmentConfigs: () => [null, { fields: null }] 137 | }; 138 | configs.register(config); 139 | const [, allMutations] = configs.getAllManifestConfigs( 140 | mediaM3U.get('mediaSequence') 141 | ); 142 | // Act 143 | const manifestUtils = hlsManifestUtils(); 144 | const proxyManifest: string = manifestUtils.createProxyMediaManifest( 145 | mediaM3U, 146 | mockBaseUrl, 147 | allMutations 148 | ); 149 | // Assert 150 | const expected = `#EXTM3U 151 | #EXT-X-VERSION:3 152 | #EXT-X-TARGETDURATION:10 153 | #EXT-X-MEDIA-SEQUENCE:1 154 | #EXT-X-PLAYLIST-TYPE:VOD 155 | #EXTINF:10.0000, 156 | ../../segments/proxy-segment?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_1_00001.ts&_test_=%7Bkey%3A404%7D 157 | #EXTINF:10.0000, 158 | ../../segments/proxy-segment?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fhls%2Fmanifest_1_00002.ts&_test_=%7Bkey%3A404%7D 159 | #EXTINF:10.0000, 160 | https://mock.mock.com/stream/hls/manifest_1_00003.ts 161 | #EXT-X-ENDLIST 162 | `; 163 | expect(proxyManifest).toEqual(expected); 164 | }); 165 | }); 166 | 167 | describe('utils.segmentUrlParamString', () => { 168 | it('should handle fields object', () => { 169 | const someMap = new Map(); 170 | someMap.set('test', { fields: { n: 150, s: 'hej' }, i: 1, sq: 2 }); 171 | const query = segmentUrlParamString('hello', someMap); 172 | expect(query).toEqual('url=hello&test={n:150,s:hej}'); 173 | }); 174 | 175 | it('should handle key with empty fields object', () => { 176 | const someMap = new Map(); 177 | someMap.set('timeout', { i: 1, sq: 2, fields: {} }); 178 | const query = segmentUrlParamString('hello', someMap); 179 | expect(query).toEqual('url=hello&timeout='); 180 | }); 181 | }); 182 | 183 | describe('utils.mergeMap', () => { 184 | it('should handle priority without default corrtupions', () => { 185 | // Assign 186 | const someValues = new Map(); 187 | someValues.set( 188 | 0, 189 | new Map() 190 | .set('a', { fields: { ms: 100 } }) 191 | .set('b', { fields: { code: 300 } }) 192 | ); 193 | const size = 3; 194 | 195 | // Act 196 | const actual = hlsManifestTools().utils.mergeMap(size, someValues); 197 | const expected = [ 198 | new Map() 199 | .set('a', { fields: { ms: 100 } }) 200 | .set('b', { fields: { code: 300 } }), 201 | null, 202 | null 203 | ]; 204 | 205 | // Assert 206 | expect(actual).toEqual(expected); 207 | }); 208 | 209 | it('should handle priority with default corrtupions', () => { 210 | // Assign 211 | const someValues = new Map(); 212 | someValues 213 | .set( 214 | 0, 215 | new Map() 216 | .set('a', { fields: { ms: 100 } }) 217 | .set('b', { fields: { code: 300 } }) 218 | ) 219 | .set( 220 | 2, 221 | new Map().set('a', { 222 | fields: null 223 | }) 224 | ) 225 | .set( 226 | '*', 227 | new Map().set('a', { fields: { ms: 50 } }) 228 | ); 229 | const size = 3; 230 | 231 | // Act 232 | const actual = hlsManifestTools().utils.mergeMap(size, someValues); 233 | const expected = [ 234 | new Map() 235 | .set('a', { fields: { ms: 100 } }) 236 | .set('b', { fields: { code: 300 } }), 237 | new Map().set('a', { fields: { ms: 50 } }), 238 | null 239 | ]; 240 | 241 | // Assert 242 | expect(actual).toEqual(expected); 243 | }); 244 | 245 | it('should handle multiple defaults with one noop', () => { 246 | // Arrange 247 | const someValues = new Map(); 248 | someValues 249 | .set( 250 | 2, 251 | new Map().set('a', { 252 | fields: null 253 | }) 254 | ) 255 | .set( 256 | '*', 257 | new Map() 258 | .set('a', { fields: { ms: 50 } }) 259 | .set('b', { fields: { code: 500 } }) 260 | ); 261 | const size = 3; 262 | 263 | // Act 264 | const actual = hlsManifestTools().utils.mergeMap(size, someValues); 265 | const expected = [ 266 | new Map() 267 | .set('a', { fields: { ms: 50 } }) 268 | .set('b', { fields: { code: 500 } }), 269 | new Map() 270 | .set('a', { fields: { ms: 50 } }) 271 | .set('b', { fields: { code: 500 } }), 272 | new Map().set('b', { fields: { code: 500 } }) 273 | ]; 274 | 275 | // Assert 276 | expect(actual).toEqual(expected); 277 | }); 278 | 279 | it('should handle empty fields prop correct', () => { 280 | // Arrange 281 | const someValues = new Map().set( 282 | 0, 283 | new Map().set('a', { fields: {} }) 284 | ); 285 | const size = 2; 286 | 287 | // Act 288 | const actual = hlsManifestTools().utils.mergeMap(size, someValues); 289 | const expected = [ 290 | new Map().set('a', { fields: {} }), 291 | null 292 | ]; 293 | 294 | // Assert 295 | expect(actual).toEqual(expected); 296 | }); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /src/manifests/utils/hlsManifestUtils.ts: -------------------------------------------------------------------------------- 1 | import { M3U, Manifest } from '../../shared/types'; 2 | import { proxyPathBuilder, segmentUrlParamString } from '../../shared/utils'; 3 | import { CorruptorConfigMap, IndexedCorruptorConfigMap } from './configs'; 4 | import clone from 'clone'; 5 | 6 | interface HLSManifestUtils { 7 | mergeMap: ( 8 | segmentListSize: number, 9 | configsMap: IndexedCorruptorConfigMap 10 | ) => CorruptorConfigMap[]; 11 | } 12 | 13 | export interface HLSManifestTools { 14 | createProxyMediaManifest: ( 15 | originalM3U: M3U, 16 | sourceBaseURL: string, 17 | mutations: IndexedCorruptorConfigMap 18 | ) => Manifest; // look def again 19 | createProxyMasterManifest: ( 20 | originalM3U: M3U, 21 | originalUrlQuery: URLSearchParams, 22 | stateKey: string | undefined 23 | ) => Manifest; 24 | utils: HLSManifestUtils; 25 | } 26 | 27 | export default function (): HLSManifestTools { 28 | const utils = Object.assign({ 29 | mergeMap( 30 | segmentListSize: number, 31 | configsMap: IndexedCorruptorConfigMap 32 | ): CorruptorConfigMap[] { 33 | const corruptions = [...new Array(segmentListSize)].map(() => { 34 | const d = configsMap.get('*'); 35 | if (!d) { 36 | return null; 37 | } 38 | const c: CorruptorConfigMap = new Map(); 39 | for (const name of d.keys()) { 40 | const { fields } = d.get(name); 41 | c.set(name, { fields: { ...fields } }); 42 | } 43 | 44 | return c; 45 | }); 46 | 47 | // Populate any explicitly defined corruptions into the list 48 | for (let i = 0; i < corruptions.length; i++) { 49 | const configCorruptions = configsMap.get(i); 50 | 51 | if (configCorruptions) { 52 | // Map values always take precedence 53 | for (const name of configCorruptions.keys()) { 54 | if (!corruptions[i]) { 55 | corruptions[i] = new Map(); 56 | } 57 | 58 | // If fields isn't set, it means it's a skip if *, otherwise no-op 59 | if (!configCorruptions.get(name).fields) { 60 | corruptions[i].delete(name); 61 | continue; 62 | } 63 | 64 | corruptions[i].set(name, configCorruptions.get(name)); 65 | } 66 | } 67 | 68 | // If we nooped anything, let's make sure it's null 69 | if (!corruptions[i]?.size) { 70 | corruptions[i] = null; 71 | } 72 | } 73 | 74 | return corruptions; 75 | } 76 | }); 77 | 78 | return Object.assign({ 79 | utils, 80 | createProxyMasterManifest( 81 | originalM3U: M3U, 82 | originalUrlQuery: URLSearchParams, 83 | stateKey: string | undefined 84 | ) { 85 | const m3u: M3U = clone(originalM3U); 86 | 87 | // [Video] 88 | let abrLevel = 1; 89 | m3u.items.StreamItem = m3u.items.StreamItem.map((streamItem) => { 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 | const bitRate = (streamItem as any)?.attributes?.attributes?.bandwidth; 92 | const currentUri = streamItem.get('uri'); 93 | // Clone params to avoid mutating input argument 94 | const urlQuery = new URLSearchParams(originalUrlQuery); 95 | if (bitRate) { 96 | urlQuery.set('bitrate', bitRate); 97 | urlQuery.set('level', abrLevel.toString()); 98 | abrLevel++; 99 | } 100 | if (stateKey) { 101 | urlQuery.set('state', stateKey); 102 | } 103 | const proxy = proxyPathBuilder( 104 | currentUri, 105 | urlQuery, 106 | 'proxy-media.m3u8' 107 | ); 108 | streamItem.set('uri', proxy); 109 | return streamItem; 110 | }); 111 | 112 | // [Audio/Subtitles/IFrame] 113 | m3u.items.MediaItem = m3u.items.MediaItem.map((mediaItem) => { 114 | const urlQuery = new URLSearchParams(originalUrlQuery); 115 | const currentUri = mediaItem.get('uri'); 116 | // #EXT-X-MEDIA URI,is only required with type SUBTITLES, optional for AUDIO and VIDEO 117 | if (mediaItem.get('type') !== 'SUBTITLES' && currentUri == undefined) { 118 | return mediaItem; 119 | } 120 | 121 | if (stateKey) { 122 | urlQuery.set('state', stateKey); 123 | } 124 | const proxy = proxyPathBuilder( 125 | currentUri, 126 | urlQuery, 127 | 'proxy-media.m3u8' 128 | ); 129 | mediaItem.set('uri', proxy); 130 | return mediaItem; 131 | }); 132 | 133 | return m3u.toString(); 134 | 135 | //--------------------------------------------------------------- 136 | // TODO: *Edge case*, cover case where StreamItem.get('uri') 137 | // is a http://.... url, and not a relative 138 | //--------------------------------------------------------------- 139 | }, 140 | createProxyMediaManifest( 141 | originalM3U: M3U, 142 | sourceBaseURL: string, 143 | configsMap: IndexedCorruptorConfigMap 144 | ) { 145 | const m3u: M3U = clone(originalM3U); 146 | const playlistSize = m3u.items.PlaylistItem.length; 147 | 148 | // configs for each index 149 | const corruptions = this.utils.mergeMap(playlistSize, configsMap); 150 | 151 | // Attach corruptions to manifest 152 | for (let i = 0; i < playlistSize; i++) { 153 | const item = m3u.items.PlaylistItem[i]; 154 | const corruption = corruptions[i]; 155 | let sourceSegURL: string = item.get('uri'); 156 | if (!sourceSegURL.match(/^http/)) { 157 | sourceSegURL = `${sourceBaseURL}/${item.get('uri')}`; 158 | } 159 | let sourceMapURL: string | undefined = item.get('map-uri'); 160 | if (sourceMapURL && !sourceMapURL.match(/^http/)) { 161 | sourceMapURL = `${sourceBaseURL}/${sourceMapURL}`; 162 | item.set('map-uri', sourceMapURL); 163 | } 164 | 165 | if (!corruption) { 166 | item.set('uri', sourceSegURL); 167 | continue; 168 | } 169 | 170 | const params = segmentUrlParamString(sourceSegURL, corruption); 171 | const proxy = proxyPathBuilder( 172 | item.get('uri'), 173 | new URLSearchParams(params), 174 | '../../segments/proxy-segment' 175 | ); 176 | item.set('uri', proxy); 177 | } 178 | return m3u.toString(); 179 | } 180 | }); 181 | } 182 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import segmentRoutes from './segments/routes'; 3 | import manifestRoutes from './manifests/routes'; 4 | import { 5 | generateHeartbeatResponse, 6 | addCustomVersionHeader 7 | } from './shared/utils'; 8 | import throttlingProxyRoutes from './segments/routes/throttlingProxy'; 9 | 10 | const apiPrefix = (version: number): string => `api/v${version}`; 11 | 12 | export default async function heartbeatRoute(fastify: FastifyInstance) { 13 | fastify.get('/', async (req, res) => { 14 | const response = await generateHeartbeatResponse(); 15 | res.code(response.statusCode).headers(response.headers).send(response.body); 16 | }); 17 | } 18 | 19 | export function registerRoutes(app: FastifyInstance) { 20 | app.register(heartbeatRoute); 21 | const opts = { prefix: apiPrefix(2) }; 22 | app.register(segmentRoutes, opts); 23 | app.register(manifestRoutes, opts); 24 | app.register(throttlingProxyRoutes, opts); 25 | addCustomVersionHeader(app); 26 | } 27 | -------------------------------------------------------------------------------- /src/segments/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | these are not in use 3 | export const GET_ROUTE_URL = "/proxy-segment"; 4 | export const CORRUPTION_ORDER = ["delay", "status", "timeout"]; 5 | */ 6 | 7 | export const HLS_PROXY_MASTER = '/manifests/hls/proxy-master.m3u8'; 8 | export const HLS_PROXY_MEDIA = '/manifests/hls/proxy-media.m3u8'; 9 | export const SEGMENTS_PROXY_SEGMENT = '/segments/proxy-segment'; 10 | export const DASH_PROXY_MASTER = '/manifests/dash/proxy-master.mpd'; 11 | export const DASH_PROXY_SEGMENT = '/manifests/dash/proxy-segment'; 12 | export const THROTTLING_PROXY = '/throttle'; 13 | -------------------------------------------------------------------------------- /src/segments/handlers/segment.ts: -------------------------------------------------------------------------------- 1 | import { ALBEvent, ALBResult } from 'aws-lambda'; 2 | import { ServiceError } from '../../shared/types'; 3 | import delaySCC from '../../manifests/utils/corruptions/delay'; 4 | import statusCodeSCC from '../../manifests/utils/corruptions/statusCode'; 5 | import timeoutSCC from '../../manifests/utils/corruptions/timeout'; 6 | import throttleSCC from '../../manifests/utils/corruptions/throttle'; 7 | import { corruptorConfigUtils } from '../../manifests/utils/configs'; 8 | import { 9 | generateErrorResponse, 10 | isValidUrl, 11 | refineALBEventQuery 12 | } from '../../shared/utils'; 13 | import { THROTTLING_PROXY } from '../constants'; 14 | 15 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 16 | 17 | export default async function segmentHandler( 18 | event: ALBEvent 19 | ): Promise { 20 | // To be able to reuse the handlers for AWS lambda function - input should be ALBEvent 21 | const query = refineALBEventQuery(event.queryStringParameters); 22 | 23 | if (!query.url || !isValidUrl(query.url)) { 24 | const errorRes: ServiceError = { 25 | status: 400, 26 | message: "Missing a valid 'url' query parameter" 27 | }; 28 | return generateErrorResponse(errorRes); 29 | } 30 | try { 31 | const configUtils = corruptorConfigUtils(new URLSearchParams(query)); 32 | configUtils 33 | .register(delaySCC) 34 | .register(statusCodeSCC) 35 | .register(timeoutSCC) 36 | .register(throttleSCC); 37 | 38 | const [error, allSegmentCorr] = configUtils.getAllSegmentConfigs(); 39 | if (error) { 40 | return generateErrorResponse(error); 41 | } 42 | // apply Timeout 43 | if (allSegmentCorr.get('timeout')) { 44 | console.log(`Timing out ${query.url}`); 45 | return; 46 | } 47 | // apply Delay 48 | if (allSegmentCorr.get('delay')) { 49 | const delay = Number(allSegmentCorr.get('delay').fields?.ms); 50 | console.log(`Applying ${delay}ms delay to ${query.url}`); 51 | await sleep(delay); 52 | } 53 | // apply Status Code 54 | if ( 55 | allSegmentCorr.get('statusCode') && 56 | allSegmentCorr.get('statusCode').fields.code !== 'undefined' 57 | ) { 58 | const code = allSegmentCorr.get('statusCode').fields.code; 59 | console.log(`Applying corruption with status ${code} to ${query.url}`); 60 | return { 61 | statusCode: code, 62 | headers: { 63 | 'Content-Type': 'application/json', 64 | 'Access-Control-Allow-Origin': '*', 65 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 66 | }, 67 | body: JSON.stringify({ 68 | message: '[Stream Corruptor]: Applied Status Code Corruption' 69 | }) 70 | }; 71 | } 72 | // apply Throttle 73 | if ( 74 | allSegmentCorr.get('throttle') && 75 | allSegmentCorr.get('throttle').fields.rate !== 'undefined' 76 | ) { 77 | const rate = Number(allSegmentCorr.get('throttle').fields.rate); 78 | return { 79 | statusCode: 302, 80 | headers: { 81 | 'Access-Control-Allow-Origin': '*', 82 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 83 | Location: 84 | '/api/v2' + THROTTLING_PROXY + '?url=' + query.url + '&rate=' + rate 85 | }, 86 | body: 'stream corruptor throttling redirect' 87 | }; 88 | } 89 | // Redirect to Source File 90 | return { 91 | statusCode: 302, 92 | headers: { 93 | 'Access-Control-Allow-Origin': '*', 94 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 95 | Location: query.url 96 | }, 97 | body: 'stream corruptor redirect' 98 | }; 99 | } catch (err) { 100 | const errorRes: ServiceError = { 101 | status: 500, 102 | message: err.message ? err.message : err 103 | }; 104 | return generateErrorResponse(errorRes); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/segments/handlers/segments.test.ts: -------------------------------------------------------------------------------- 1 | import { ALBEvent, ALBResult } from 'aws-lambda'; 2 | import segmentHandler from './segment'; 3 | 4 | describe('segments.handlers.segment', () => { 5 | describe('segmentHandler', () => { 6 | it("should return code 400 when 'url' query parameter is missing in request", async () => { 7 | // Arrange 8 | const queryParams = { 9 | statusCode: '[{i:2,code:400}]', 10 | delay: '[{i:1,ms:2000}]' 11 | }; 12 | const event: ALBEvent = { 13 | requestContext: { 14 | elb: { 15 | targetGroupArn: '' 16 | } 17 | }, 18 | path: '/stream/hls/manifest.m3u8', 19 | httpMethod: 'GET', 20 | headers: { 21 | accept: 'application/x-mpegURL;charset=UTF-8', 22 | 'accept-language': 'en-US,en;q=0.8', 23 | 'content-type': 'text/plain', 24 | host: 'lambda-846800462-us-east-2.elb.amazonaws.com', 25 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6)', 26 | 'x-amzn-trace-id': 'Root=1-5bdb40ca-556d8b0c50dc66f0511bf520', 27 | 'x-forwarded-for': '72.21.198.xx', 28 | 'x-forwarded-port': '443', 29 | 'x-forwarded-proto': 'https' 30 | }, 31 | isBase64Encoded: false, 32 | queryStringParameters: queryParams, 33 | body: '' 34 | }; 35 | 36 | // Act 37 | const response = await segmentHandler(event); 38 | 39 | // Assert 40 | const expected: ALBResult = { 41 | statusCode: 400, 42 | headers: { 43 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 44 | 'Access-Control-Allow-Origin': '*', 45 | 'Content-Type': 'application/json' 46 | }, 47 | body: JSON.stringify({ 48 | reason: "Missing a valid 'url' query parameter" 49 | }) 50 | }; 51 | expect(response.statusCode).toEqual(expected.statusCode); 52 | expect(response.headers).toEqual(expected.headers); 53 | expect(response.body).toEqual(expected.body); 54 | }); 55 | 56 | it("should return code 400 when 'url' query parameter is not a Valid URL", async () => { 57 | // Arrange 58 | const queryParams = { 59 | url: 'not_valid_url.com/segment_1.ts', 60 | statusCode: '[{i:2,code:400}]', 61 | delay: '[{i:1,ms:2000}]' 62 | }; 63 | const event: ALBEvent = { 64 | requestContext: { 65 | elb: { 66 | targetGroupArn: '' 67 | } 68 | }, 69 | path: '/stream/hls/manifest.m3u8', 70 | httpMethod: 'GET', 71 | headers: { 72 | accept: 'application/x-mpegURL;charset=UTF-8', 73 | 'accept-language': 'en-US,en;q=0.8', 74 | 'content-type': 'text/plain', 75 | host: 'lambda-846800462-us-east-2.elb.amazonaws.com', 76 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6)', 77 | 'x-amzn-trace-id': 'Root=1-5bdb40ca-556d8b0c50dc66f0511bf520', 78 | 'x-forwarded-for': '72.21.198.xx', 79 | 'x-forwarded-port': '443', 80 | 'x-forwarded-proto': 'https' 81 | }, 82 | isBase64Encoded: false, 83 | queryStringParameters: queryParams, 84 | body: '' 85 | }; 86 | 87 | // Act 88 | const response = await segmentHandler(event); 89 | 90 | // Assert 91 | const expected: ALBResult = { 92 | statusCode: 400, 93 | headers: { 94 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 95 | 'Access-Control-Allow-Origin': '*', 96 | 'Content-Type': 'application/json' 97 | }, 98 | body: JSON.stringify({ 99 | reason: "Missing a valid 'url' query parameter" 100 | }) 101 | }; 102 | expect(response.statusCode).toEqual(expected.statusCode); 103 | expect(response.headers).toEqual(expected.headers); 104 | expect(response.body).toEqual(expected.body); 105 | }); 106 | 107 | it('should return code 400 when query parameter are not valid', async () => { 108 | // Arrange 109 | const queryParams = { 110 | url: 'http://mock.mock.com/segment_1.ts', 111 | delay: '{i:1,ms:"bad value"}' 112 | }; 113 | const event: ALBEvent = { 114 | requestContext: { 115 | elb: { 116 | targetGroupArn: '' 117 | } 118 | }, 119 | path: '/stream/hls/manifest.m3u8', 120 | httpMethod: 'GET', 121 | headers: { 122 | accept: 'application/x-mpegURL;charset=UTF-8', 123 | 'accept-language': 'en-US,en;q=0.8', 124 | 'content-type': 'text/plain', 125 | host: 'lambda-846800462-us-east-2.elb.amazonaws.com', 126 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6)', 127 | 'x-amzn-trace-id': 'Root=1-5bdb40ca-556d8b0c50dc66f0511bf520', 128 | 'x-forwarded-for': '72.21.198.xx', 129 | 'x-forwarded-port': '443', 130 | 'x-forwarded-proto': 'https' 131 | }, 132 | isBase64Encoded: false, 133 | queryStringParameters: queryParams, 134 | body: '' 135 | }; 136 | 137 | // Act 138 | const response = await segmentHandler(event); 139 | 140 | // Assert 141 | const expected: ALBResult = { 142 | statusCode: 400, 143 | headers: { 144 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 145 | 'Access-Control-Allow-Origin': '*', 146 | 'Content-Type': 'application/json' 147 | }, 148 | body: '{"reason":"Incorrect delay value format at \'delay={\\"i\\":1,\\"ms\\":\\"badvalue\\"}\'. Must be: delay={i?:number, sq?:number, ms:number}"}' 149 | }; 150 | expect(response.statusCode).toEqual(expected.statusCode); 151 | expect(response.headers).toEqual(expected.headers); 152 | expect(response.body).toEqual(expected.body); 153 | }); 154 | 155 | it('should return code 500 at unexpected error', async () => { 156 | // TODO 157 | expect(true).toEqual(true); 158 | }); 159 | 160 | it('should return code 302 normally', async () => { 161 | // Arrange 162 | const queryParams = { 163 | url: 'http://mock.mock.com/segment_1.ts', 164 | delay: '{i:1,ms:20}' 165 | }; 166 | const event: ALBEvent = { 167 | requestContext: { 168 | elb: { 169 | targetGroupArn: '' 170 | } 171 | }, 172 | path: '/stream/hls/manifest.m3u8', 173 | httpMethod: 'GET', 174 | headers: { 175 | accept: 'application/x-mpegURL;charset=UTF-8', 176 | 'accept-language': 'en-US,en;q=0.8', 177 | 'content-type': 'text/plain', 178 | host: 'lambda-846800462-us-east-2.elb.amazonaws.com', 179 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6)', 180 | 'x-amzn-trace-id': 'Root=1-5bdb40ca-556d8b0c50dc66f0511bf520', 181 | 'x-forwarded-for': '72.21.198.xx', 182 | 'x-forwarded-port': '443', 183 | 'x-forwarded-proto': 'https' 184 | }, 185 | isBase64Encoded: false, 186 | queryStringParameters: queryParams, 187 | body: '' 188 | }; 189 | 190 | // Act 191 | const response = await segmentHandler(event); 192 | 193 | // Assert 194 | const expected: ALBResult = { 195 | statusCode: 302, 196 | headers: { 197 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 198 | 'Access-Control-Allow-Origin': '*', 199 | Location: 'http://mock.mock.com/segment_1.ts' 200 | } 201 | }; 202 | expect(response.statusCode).toEqual(expected.statusCode); 203 | expect(response.headers).toEqual(expected.headers); 204 | }); 205 | 206 | it('should return code statusCode when doing status code corruption', async () => { 207 | // Arrange 208 | const queryParams = { 209 | url: 'http://mock.mock.com/segment_1.ts', 210 | statusCode: '{i:1,code:305}' 211 | }; 212 | const event: ALBEvent = { 213 | requestContext: { 214 | elb: { 215 | targetGroupArn: '' 216 | } 217 | }, 218 | path: '/stream/hls/manifest.m3u8', 219 | httpMethod: 'GET', 220 | headers: { 221 | accept: 'application/x-mpegURL;charset=UTF-8', 222 | 'accept-language': 'en-US,en;q=0.8', 223 | 'content-type': 'text/plain', 224 | host: 'lambda-846800462-us-east-2.elb.amazonaws.com', 225 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6)', 226 | 'x-amzn-trace-id': 'Root=1-5bdb40ca-556d8b0c50dc66f0511bf520', 227 | 'x-forwarded-for': '72.21.198.xx', 228 | 'x-forwarded-port': '443', 229 | 'x-forwarded-proto': 'https' 230 | }, 231 | isBase64Encoded: false, 232 | queryStringParameters: queryParams, 233 | body: '' 234 | }; 235 | 236 | // Act 237 | const response = await segmentHandler(event); 238 | 239 | // Assert 240 | const expected: ALBResult = { 241 | statusCode: 305, 242 | headers: { 243 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 244 | 'Access-Control-Allow-Origin': '*', 245 | 'Content-Type': 'application/json' 246 | }, 247 | body: JSON.stringify({ 248 | message: '[Stream Corruptor]: Applied Status Code Corruption' 249 | }) 250 | }; 251 | expect(response.statusCode).toEqual(expected.statusCode); 252 | expect(response.headers).toEqual(expected.headers); 253 | expect(response.body).toEqual(expected.body); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /src/segments/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import segmentHandler from '../handlers/segment'; 3 | import { composeALBEvent, handleOptionsRequest } from '../../shared/utils'; 4 | import { SEGMENTS_PROXY_SEGMENT } from '../constants'; 5 | 6 | export default async function segmentRoutes(fastify: FastifyInstance) { 7 | fastify.get(SEGMENTS_PROXY_SEGMENT, async (req, res) => { 8 | const event = await composeALBEvent(req.method, req.url, req.headers); 9 | const response = await segmentHandler(event); 10 | // If response is undefined it means the request was intentionally timed out and we must not respond 11 | if (!response) { 12 | return; 13 | } 14 | if (response.statusCode === 302) { 15 | res.headers({ 16 | 'Access-Control-Allow-Origin': '*' 17 | }); 18 | res.redirect(302, response.headers.Location as string); 19 | return; 20 | } 21 | res.code(response.statusCode).headers(response.headers).send(response.body); 22 | }); 23 | fastify.options('/*', async (req, res) => { 24 | const response = await handleOptionsRequest(); 25 | res.code(response.statusCode).headers(response.headers); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/segments/routes/throttlingProxy.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import { composeALBEvent } from '../../shared/utils'; 3 | import { THROTTLING_PROXY } from '../constants'; 4 | import fetch from 'node-fetch'; 5 | import { Throttle } from 'stream-throttle'; 6 | 7 | export default async function throttlingProxyRoutes(fastify: FastifyInstance) { 8 | fastify.get(THROTTLING_PROXY, async (req, res) => { 9 | const event = await composeALBEvent(req.method, req.url, req.headers); 10 | 11 | const query = event.queryStringParameters; 12 | if (!query) { 13 | res.code(501); 14 | return; 15 | } 16 | const url = query['url']; 17 | const rate = Number(query['rate']); 18 | 19 | const middle = await fetch(url); 20 | if (middle.status != 200) { 21 | res.code(500).send('Invalid return code for segment from remote'); 22 | } 23 | 24 | const headers = {}; 25 | middle.headers.forEach((v, k) => { 26 | headers[k] = v; 27 | }); 28 | 29 | const throttle = new Throttle({ rate }); 30 | 31 | res.code(middle.status).headers(headers).send(middle.body.pipe(throttle)); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/server.test.ts: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import { registerRoutes } from './routes'; 3 | import { 4 | HLS_PROXY_MASTER, 5 | HLS_PROXY_MEDIA, 6 | SEGMENTS_PROXY_SEGMENT 7 | } from './segments/constants'; 8 | 9 | describe('Chaos Stream Proxy server', () => { 10 | let app = null; 11 | beforeEach(() => { 12 | app = fastify(); 13 | registerRoutes(app); 14 | }); 15 | 16 | it.each([HLS_PROXY_MASTER, HLS_PROXY_MEDIA, SEGMENTS_PROXY_SEGMENT])( 17 | 'route %p contains x-version header', 18 | async (route) => { 19 | const response = await app.inject(route); 20 | expect(response.headers).toEqual( 21 | expect.objectContaining({ 22 | 'x-version': process.env.npm_package_version 23 | }) 24 | ); 25 | } 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import { registerRoutes } from './routes'; 3 | import FastifyCors from 'fastify-cors'; 4 | 5 | const PORT: number = process.env.PORT ? parseInt(process.env.PORT, 10) : 8000; 6 | const INTERFACE: string = process.env.INTERFACE || '0.0.0.0'; 7 | 8 | // App 9 | export const app = fastify(); 10 | app.register(FastifyCors, {}); 11 | 12 | // Routes 13 | registerRoutes(app); 14 | 15 | // Start 16 | if (require.main === module) { 17 | // called directly i.e. "node app" 18 | app.listen(PORT, INTERFACE, (err, address) => { 19 | if (err) console.error(err); 20 | console.log('\nChaos Stream Proxy listening on:', address, '\n'); 21 | }); 22 | } else { 23 | // required as a module => executed on aws lambda 24 | module.exports = app; 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/aws.utils.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from 'node-cache'; 2 | import SSM from 'aws-sdk/clients/ssm'; 3 | import dotenv from 'dotenv'; 4 | dotenv.config(); 5 | 6 | const ssm = new SSM({ region: process.env.AWS_REGION ?? 'eu-central-1' }); 7 | const cache = new NodeCache({ stdTTL: 60 }); 8 | 9 | export async function loadParameterFromSSMCached( 10 | parameterName: string 11 | ): Promise { 12 | const cachedValue = cache.get(parameterName); 13 | 14 | if (cachedValue !== undefined) { 15 | return cachedValue.toString(); 16 | } 17 | 18 | try { 19 | console.log('parameterName', parameterName); 20 | const response = await ssm.getParameter({ Name: parameterName }).promise(); 21 | 22 | if (!response.Parameter || response.Parameter.Value === undefined) { 23 | throw new Error( 24 | `Parameter ${parameterName} not found in SSM Parameter Store` 25 | ); 26 | } 27 | 28 | const value = response.Parameter.Value; 29 | cache.set(parameterName, value); 30 | 31 | return value; 32 | } catch (error) { 33 | console.log('SSM getParameter', error); 34 | return ''; 35 | } 36 | } 37 | 38 | export function addSSMUrlParametersToUrl(url: string): Promise { 39 | if ( 40 | !url.includes('delay') && 41 | !url.includes('statusCode') && 42 | !url.includes('timeout') && 43 | !url.includes('throttle') && 44 | url.includes('proxy-master') 45 | ) { 46 | const parameterName = 47 | process.env.AWS_SSM_PARAM_KEY ?? 48 | '/ChaosStreamProxy/Development/UrlParams'; 49 | return new Promise((resolve, reject) => { 50 | loadParameterFromSSMCached(parameterName) 51 | .then((value) => { 52 | console.log(`The value of ${parameterName} is ${value}`); 53 | resolve(url + value); 54 | }) 55 | .catch((error) => { 56 | console.log(`Error getting ${parameterName}: ${error}`); 57 | reject(error); 58 | }); 59 | }); 60 | } else { 61 | return Promise.resolve(url); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export type CorruptionIndex = number | '*'; 2 | 3 | export type ServiceError = { 4 | status: number; 5 | message: string; 6 | }; 7 | 8 | export type TargetIndex = number | '*'; 9 | 10 | export type TargetLevel = number; 11 | 12 | /** 13 | * Cherrypicking explicitly what we need to type from 14 | * https://github.com/Eyevinn/node-m3u8/blob/master/m3u/Item.js 15 | * This obviously needs to be addressed 16 | */ 17 | 18 | /* eslint-disable */ 19 | export type M3UItem = { 20 | get: (key: 'uri' | 'type' | 'map-uri') => string | any; 21 | set: (key: 'uri' | 'map-uri', value: string) => void; 22 | }; 23 | 24 | export type M3U = { 25 | items: { 26 | PlaylistItem: M3UItem[]; 27 | StreamItem: M3UItem[]; 28 | IframeStreamItem: M3UItem[]; 29 | MediaItem: M3UItem[]; 30 | }; 31 | properties: {}; 32 | toString(): string; 33 | get(key: any): any; 34 | set(key: any, value: any): void; 35 | serialize(): any; 36 | unserialize(): any; 37 | }; 38 | /* eslint-enable */ 39 | 40 | export type Manifest = string; 41 | -------------------------------------------------------------------------------- /src/shared/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { URLSearchParams } from 'url'; 2 | import { proxyPathBuilder } from './utils'; 3 | 4 | describe('shared.utils', () => { 5 | describe('proxyPathBuilder', () => { 6 | it('should return correct format with value in all parameters', () => { 7 | // Arrange 8 | const itemUri = 'video-variants/variant_3.m3u8'; 9 | const urlSearchParams = new URLSearchParams( 10 | 'url=https://mock.stream.origin.se/hls/vods/asset41/master.m3u8&delay=[{i:3,ms:200}]' 11 | ); 12 | 13 | // Act 14 | const actual = proxyPathBuilder( 15 | itemUri, 16 | urlSearchParams, 17 | 'proxy-media.m3u8' 18 | ); 19 | const expected = `proxy-media.m3u8?url=${encodeURIComponent( 20 | 'https://mock.stream.origin.se/hls/vods/asset41/video-variants/variant_3.m3u8' 21 | )}&delay=${encodeURIComponent('[{i:3,ms:200}]')}`; 22 | 23 | // Assert 24 | expect(actual).toEqual(expected); 25 | }); 26 | 27 | it('should return correct format with value in all parameters, when source url is already an absolut url', () => { 28 | // Arrange 29 | const itemUri = 'https://different.origin.se/hls/variant_3.m3u8'; 30 | const urlSearchParams = new URLSearchParams( 31 | 'url=https://mock.stream.origin.se/hls/master.m3u8&delay=[{i:3,ms:200}]' 32 | ); 33 | 34 | // Act 35 | const actual = proxyPathBuilder( 36 | itemUri, 37 | urlSearchParams, 38 | 'proxy-media.m3u8' 39 | ); 40 | const expected = `proxy-media.m3u8?url=${encodeURIComponent( 41 | 'https://different.origin.se/hls/variant_3.m3u8' 42 | )}&delay=${encodeURIComponent('[{i:3,ms:200}]')}`; 43 | 44 | // Assert 45 | expect(actual).toEqual(expected); 46 | }); 47 | 48 | it("should return correct format with value in all parameters, when source url string has '../'", () => { 49 | // Arrange 50 | const itemUri = '../../../pathB/path3/variant_3.m3u8'; 51 | const urlSearchParams = new URLSearchParams( 52 | 'url=https://mock.stream.origin.se/hls/pathA/path1/path2/master.m3u8&delay=[{i:3,ms:200}]' 53 | ); 54 | 55 | // Act 56 | const actual = proxyPathBuilder( 57 | itemUri, 58 | urlSearchParams, 59 | 'proxy-media.m3u8' 60 | ); 61 | const expected = `proxy-media.m3u8?url=${encodeURIComponent( 62 | 'https://mock.stream.origin.se/hls/pathB/path3/variant_3.m3u8' 63 | )}&delay=${encodeURIComponent('[{i:3,ms:200}]')}`; 64 | 65 | // Assert 66 | expect(actual).toEqual(expected); 67 | }); 68 | 69 | it('should handle empty parameters', () => { 70 | // Arrange 71 | const itemUri = ''; 72 | const urlSearchParams = null; 73 | 74 | // Act 75 | const actual = proxyPathBuilder( 76 | itemUri, 77 | urlSearchParams, 78 | 'proxy-media.m3u8' 79 | ); 80 | const expected = ''; 81 | 82 | // Assert 83 | expect(actual).toEqual(expected); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'node-fetch'; 2 | import m3u8 from '@eyevinn/m3u8'; 3 | import { M3U, ServiceError } from './types'; 4 | import clone from 'clone'; 5 | import { ALBEvent, ALBResult, ALBEventQueryStringParameters } from 'aws-lambda'; 6 | import { ReadStream } from 'fs'; 7 | import { IncomingHttpHeaders } from 'http'; 8 | import path from 'path'; 9 | import { CorruptorConfigMap } from '../manifests/utils/configs'; 10 | import { 11 | FastifyReply, 12 | FastifyRequest, 13 | RequestPayload, 14 | FastifyInstance 15 | } from 'fastify'; 16 | import { addSSMUrlParametersToUrl } from './aws.utils'; 17 | import { URLSearchParams } from 'url'; 18 | import dotenv from 'dotenv'; 19 | import { Readable } from 'stream'; 20 | import NodeCache from 'node-cache'; 21 | import { randomInt } from 'crypto'; 22 | dotenv.config(); 23 | 24 | const version = process.env.npm_package_version; 25 | 26 | export const handleOptionsRequest = async (): Promise => { 27 | return { 28 | statusCode: 204, 29 | headers: { 30 | 'Access-Control-Allow-Origin': '*', 31 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 32 | 'Access-Control-Allow-Headers': 'Content-Type, Origin', 33 | 'Access-Control-Max-Age': '86400' 34 | } 35 | }; 36 | }; 37 | 38 | export const generateErrorResponse = ( 39 | err: ServiceError 40 | ): Promise => { 41 | const response: ALBResult = { 42 | statusCode: err.status, 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | 'Access-Control-Allow-Origin': '*', 46 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 47 | } 48 | }; 49 | response.body = JSON.stringify({ reason: err.message }); 50 | 51 | return Promise.resolve(response); 52 | }; 53 | 54 | export const generateHeartbeatResponse = (): Promise => { 55 | const response: ALBResult = { 56 | statusCode: 200, 57 | headers: { 58 | 'Content-Type': 'application/json', 59 | 'Access-Control-Allow-Origin': '*', 60 | 'Access-Control-Allow-Headers': 'Content-Type, Origin' 61 | } 62 | }; 63 | response.body = JSON.stringify({ message: 'OK! 💚', version }); 64 | 65 | return Promise.resolve(response); 66 | }; 67 | 68 | export const isValidUrl = (string) => { 69 | if (!string) return false; 70 | try { 71 | const url = decodeURIComponent(string); 72 | new URL(url); // eslint-disable-line 73 | 74 | return true; 75 | } catch (_) { 76 | return false; 77 | } 78 | }; 79 | 80 | export async function composeALBEvent( 81 | httpMethod: string, 82 | url: string, 83 | incomingHeaders: IncomingHttpHeaders 84 | ): Promise { 85 | // Get Chaos Parameters from AWS SSM 86 | if (AppSettings.loadUrlParametersFromAwsSSM) { 87 | url = await addSSMUrlParametersToUrl(url); 88 | } 89 | 90 | // Create ALBEvent from Fastify Request... 91 | const [path, queryString] = url.split('?'); 92 | const queryStringParameters = Object.fromEntries( 93 | new URLSearchParams(queryString) 94 | ); 95 | const requestContext = { elb: { targetGroupArn: '' } }; 96 | const headers: Record = {}; 97 | // IncomingHttpHeaders type is Record because set-cookie is an array 98 | for (const [name, value] of Object.entries(incomingHeaders)) { 99 | if (typeof value === 'string') { 100 | headers[name] = value; 101 | } 102 | } 103 | 104 | return { 105 | requestContext, 106 | path, 107 | httpMethod, 108 | headers, 109 | queryStringParameters, 110 | body: '', 111 | isBase64Encoded: false 112 | }; 113 | } 114 | 115 | export const unparsableError = ( 116 | name: string, 117 | unparsableQuery: string, 118 | format: string 119 | ) => ({ 120 | status: 400, 121 | message: `Incorrect ${name} value format at '${name}=${unparsableQuery}'. Must be: ${name}=${format}` 122 | }); 123 | 124 | export async function parseM3U8Text(res: Response): Promise { 125 | /* [NOTE] 126 | Function handles case for when Media Playlist doesn't have the '#EXT-X-PLAYLIST-TYPE:VOD' tag, 127 | but is still a vod since it has the #EXT-X-ENDLIST tag. 128 | We set PLAYLIST-TYPE here if that is the case to ensure, 129 | that 'm3u.toString()' will later return a m3u8 string with the endlist tag. 130 | */ 131 | let setPlaylistTypeToVod = false; 132 | const parser = m3u8.createStream(); 133 | const m3u8String = await res.text(); 134 | if (m3u8String.indexOf('#EXT-X-ENDLIST') !== -1) { 135 | setPlaylistTypeToVod = true; 136 | } 137 | 138 | const stream = new Readable(); 139 | stream.push(m3u8String); 140 | stream.push(null); 141 | stream.pipe(parser); 142 | 143 | return new Promise((resolve, reject) => { 144 | parser.on('m3u', (m3u: M3U) => { 145 | if (setPlaylistTypeToVod && m3u.get('playlistType') !== 'VOD') { 146 | m3u.set('playlistType', 'VOD'); 147 | } 148 | resolve(m3u); 149 | }); 150 | parser.on('error', (err) => { 151 | reject(err); 152 | }); 153 | }); 154 | } 155 | 156 | export function parseM3U8Stream(stream: ReadStream): Promise { 157 | const parser = m3u8.createStream(); 158 | stream.pipe(parser); 159 | return new Promise((resolve, reject) => { 160 | parser.on('m3u', (m3u: M3U) => { 161 | resolve(m3u); 162 | }); 163 | parser.on('error', (err) => { 164 | reject(err); 165 | }); 166 | }); 167 | } 168 | 169 | // @todo: Clarify what this function actually does 170 | // Older comment: "This is needed because the internet is a bit broken..." 171 | export function refineALBEventQuery( 172 | originalQuery: ALBEventQueryStringParameters = {} 173 | ) { 174 | const queryStringParameters = clone(originalQuery); 175 | const searchParams = new URLSearchParams( 176 | Object.keys(queryStringParameters) 177 | .map((k) => `${k}=${queryStringParameters[k]}`) 178 | .join('&') 179 | ); 180 | for (const k of searchParams.keys()) { 181 | queryStringParameters[k] = searchParams.get(k); 182 | } 183 | return queryStringParameters; 184 | } 185 | 186 | type ProxyBasenames = 187 | | 'proxy-media.m3u8' 188 | | '../../segments/proxy-segment' 189 | | 'proxy-segment/segment_$Number$.mp4' 190 | | 'proxy-segment/segment_$Time$.mp4' 191 | | 'proxy-segment/segment_$Number$_$RepresentationID$' 192 | | 'proxy-segment/segment_$Time$_$RepresentationID$' 193 | | 'proxy-segment/segment_$Number$_$Bandwidth$_$RepresentationID$' 194 | | 'proxy-segment/segment_$Time$_$Bandwidth$_$RepresentationID$'; 195 | 196 | /** 197 | * Adjust paths based on directory navigation 198 | * @param originPath ex. "http://abc.origin.com/streams/vod1/subfolder1/subfolder2" 199 | * @param uri ex. "../../subfolder3/media/segment.ts" 200 | * @returns ex. [ "http://abc.origin.com/streams/vod1", "subfolder3/media/segment.ts" ] 201 | */ 202 | const cleanUpPathAndURI = (originPath: string, uri: string): string[] => { 203 | const matchList: string[] | null = uri.match(/\.\.\//g); 204 | if (matchList) { 205 | const jumpsToParentDir = matchList.length; 206 | if (jumpsToParentDir > 0) { 207 | const splitPath = originPath.split('/'); 208 | for (let i = 0; i < jumpsToParentDir; i++) { 209 | splitPath.pop(); 210 | } 211 | originPath = splitPath.join('/'); 212 | let str2split = ''; 213 | for (let i = 0; i < jumpsToParentDir; i++) { 214 | str2split += '../'; 215 | } 216 | uri = uri.split(str2split).pop(); 217 | } 218 | } 219 | return [originPath, uri]; 220 | }; 221 | 222 | export function proxyPathBuilder( 223 | itemUri: string, 224 | urlSearchParams: URLSearchParams, 225 | proxy: ProxyBasenames 226 | ): string { 227 | if (!urlSearchParams) { 228 | return ''; 229 | } 230 | const allQueries = new URLSearchParams(urlSearchParams); 231 | let sourceItemURL = ''; 232 | // Do not build an absolute source url If ItemUri is already an absolut url. 233 | if (itemUri.match(/^http/)) { 234 | sourceItemURL = itemUri; 235 | } else { 236 | const sourceURL = allQueries.get('url'); 237 | const baseURL: string = path.dirname(sourceURL); 238 | const [_baseURL, _itemUri] = cleanUpPathAndURI(baseURL, itemUri); 239 | sourceItemURL = `${_baseURL}/${_itemUri}`; 240 | } 241 | if (sourceItemURL) { 242 | allQueries.set('url', sourceItemURL); 243 | } 244 | const allQueriesString = allQueries.toString(); 245 | return `${proxy}${allQueriesString ? `?${allQueriesString}` : ''}`; 246 | } 247 | 248 | export function segmentUrlParamString( 249 | sourceSegURL: string, 250 | configMap: CorruptorConfigMap 251 | ): string { 252 | let query = `url=${sourceSegURL}`; 253 | 254 | for (const name of configMap.keys()) { 255 | const fields = configMap.get(name).fields; 256 | const keys = Object.keys(fields); 257 | const corruptionInner = keys 258 | .map((key) => `${key}:${fields[key]}`) 259 | .join(','); 260 | const values = corruptionInner ? `{${corruptionInner}}` : ''; 261 | query += `&${name}=${values}`; 262 | } 263 | return query; 264 | } 265 | 266 | export const SERVICE_ORIGIN = 267 | process.env.SERVICE_ORIGIN || 'http://localhost:8000'; 268 | 269 | export function joinNotNull(strings: (string | null)[], delimiter: string) { 270 | return strings.filter((s) => s != null).join(delimiter); 271 | } 272 | 273 | export function addCustomVersionHeader(app: FastifyInstance): void { 274 | app.addHook( 275 | 'onSend', 276 | async ( 277 | request: FastifyRequest, 278 | reply: FastifyReply, 279 | payload: RequestPayload 280 | ): Promise => { 281 | reply.headers({ 282 | 'Access-Control-Allow-Headers': joinNotNull( 283 | [reply.getHeader('Access-Control-Allow-Headers'), 'X-Version'], 284 | ', ' 285 | ), 286 | 'Access-Control-Expose-Headers': joinNotNull( 287 | [reply.getHeader('Access-Control-Expose-Headers'), 'X-Version'], 288 | ', ' 289 | ), 290 | 'X-Version': process.env.npm_package_version 291 | }); 292 | return payload; 293 | } 294 | ); 295 | } 296 | 297 | export function fixUrl(url: string) { 298 | return url.replace(/;/g, '%3B'); 299 | } 300 | 301 | export class AppSettings { 302 | static loadUrlParametersFromAwsSSM: boolean = 303 | process.env.LOAD_PARAMS_FROM_AWS_SSM === 'true'; 304 | } 305 | 306 | export const STATEFUL: boolean = process.env.STATEFUL 307 | ? process.env.STATEFUL == 'true' 308 | : false; 309 | export const TTL: number = process.env.TTL ? parseInt(process.env.TTL) : 300; 310 | 311 | const stateCache: NodeCache = STATEFUL 312 | ? new NodeCache({ stdTTL: TTL }) 313 | : undefined; 314 | 315 | type RequestState = { 316 | initialSequenceNumber?: number; 317 | }; 318 | 319 | export function getState(stateKey: string): RequestState | undefined { 320 | if (STATEFUL) return stateCache.get(stateKey); 321 | else return undefined; 322 | } 323 | 324 | export function putState( 325 | key: string, 326 | state: RequestState 327 | ): boolean | ServiceError { 328 | if (STATEFUL) return stateCache.set(key, state); 329 | else return { status: 400, message: 'Stateful feature not enabled' }; 330 | } 331 | 332 | const alpha = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 333 | function randomStateKey(length: number): string { 334 | let key = ''; 335 | for (let i = 0; i < length; i++) { 336 | key += alpha.charAt(randomInt(0, alpha.length)); 337 | } 338 | return key; 339 | } 340 | 341 | export function newState(state: RequestState): string { 342 | let key = randomStateKey(16); 343 | while (stateCache.get(key) != undefined) key = randomStateKey(16); 344 | stateCache.set(key, state); 345 | return key; 346 | } 347 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_compressed/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | relative_base/ 18 | 21 | 31 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 62 | 63 | 67 | 68 | 69 | 70 | 71 | 78 | 79 | 80 | 81 | 84 | 85 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_compressed/proxy-manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 17 | 20 | 30 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 61 | 62 | 66 | 67 | 68 | 69 | 70 | 77 | 78 | 79 | 80 | 83 | 84 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_multitrack/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_multitrack/proxy-manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_relative_sequence/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash1_relative_sequence/proxy-manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | proxy-master.mpd?url=https%3A%2F%2Fmock.mock.com%2Fstream%2Fdash%2Fasset44%2Fmanifest.mpd&statusCode=%5B%7Bcode%3A404%2Csq%3A11%7D%2C%7Bcode%3A401%2Csq%3A21%7D%5D&timeout=%5B%7Bsq%3A31%7D%5D&delay=%5B%7Bms%3A2000%2Csq%3A41%7D%5D 94 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash_period_baseurl/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://vm2.dashif.org/livesim-dev/periods_60/ 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/testvectors/dash/dash_period_baseurl/proxy-manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_cmaf/manifest.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-STREAM-INF:BANDWIDTH=4255267,AVERAGE-BANDWIDTH=4255267,CODECS="avc1.4d4032,mp4a.40.2",RESOLUTION=2560x1440,FRAME-RATE=25.000,AUDIO="audio",SUBTITLES="subs" 5 | manifest_1.m3u8 6 | 7 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_cmaf/manifest_1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:6 3 | #EXT-X-PLAYLIST-TYPE:VOD 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-INDEPENDENT-SEGMENTS 6 | #EXT-X-TARGETDURATION:4 7 | #USP-X-TIMESTAMP-MAP:MPEGTS=900000,LOCAL=1970-01-01T00:00:00Z 8 | #EXT-X-MAP:URI="subdir/142d4370-56c9-11ee-a816-2da19aea6ed1_20571919-video=6500000.m4s" 9 | #EXT-X-PROGRAM-DATE-TIME:1970-01-01T00:00:00Z 10 | #EXTINF:3.84, no desc 11 | subdir/142d4370-56c9-11ee-a816-2da19aea6ed1_20571919-video=6500000-1.m4s 12 | #EXTINF:3.84, no desc 13 | subdir/142d4370-56c9-11ee-a816-2da19aea6ed1_20571919-video=6500000-2.m4s 14 | #EXTINF:3.84, no desc 15 | subdir/142d4370-56c9-11ee-a816-2da19aea6ed1_20571919-video=6500000-3.m4s 16 | #EXT-X-ENDLIST 17 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-STREAM-INF:BANDWIDTH=4255267,AVERAGE-BANDWIDTH=4255267,CODECS="avc1.4d4032,mp4a.40.2",RESOLUTION=2560x1440,FRAME-RATE=25.000,AUDIO="audio",SUBTITLES="subs" 5 | manifest_1.m3u8 6 | #EXT-X-STREAM-INF:BANDWIDTH=3062896,AVERAGE-BANDWIDTH=3062896,CODECS="avc1.4d4028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=25.000,AUDIO="audio",SUBTITLES="subs" 7 | manifest_2.m3u8 8 | #EXT-X-STREAM-INF:BANDWIDTH=2316761,AVERAGE-BANDWIDTH=2316761,CODECS="avc1.4d4028,mp4a.40.2",RESOLUTION=1920x1080,FRAME-RATE=25.000,AUDIO="audio",SUBTITLES="subs" 9 | manifest_3.m3u8 10 | 11 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="en",NAME="English stereo",CHANNELS="2",DEFAULT=YES,AUTOSELECT=YES,URI="manifest_audio-en.m3u8" 12 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",LANGUAGE="sv",NAME="Swedish stereo",CHANNELS="2",DEFAULT=NO,AUTOSELECT=YES,URI="manifest_audio-sv.m3u8" 13 | 14 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="en",NAME="English",FORCED=NO,DEFAULT=NO,AUTOSELECT=YES,URI="manifest_subs-en.m3u8" 15 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_1_00001.ts 8 | #EXTINF:10, 9 | manifest_1_00002.ts 10 | #EXTINF:10, 11 | manifest_1_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_2.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_2_00001.ts 8 | #EXTINF:10, 9 | manifest_2_00002.ts 10 | #EXTINF:10, 11 | manifest_2_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_3.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_3_00001.ts 8 | #EXTINF:10, 9 | manifest_3_00002.ts 10 | #EXTINF:10, 11 | manifest_3_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_audio-en.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_audio-en_00001.ts 8 | #EXTINF:10, 9 | manifest_audio-en_00002.ts 10 | #EXTINF:10, 11 | manifest_audio-en_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_audio-sv.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_audio-sv_00001.ts 8 | #EXTINF:10, 9 | manifest_audio-sv_00002.ts 10 | #EXTINF:10, 11 | manifest_audio-sv_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls1_multitrack/manifest_subs-en.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_subs-en_00001.ts 8 | #EXTINF:10, 9 | manifest_subs-en_00002.ts 10 | #EXTINF:10, 11 | manifest_subs-en_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls2_multitrack/manifest.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-STREAM-INF:BANDWIDTH=4255267,AVERAGE-BANDWIDTH=4255267,CODECS="avc1.4d4032,mp4a.40.2",RESOLUTION=2560x1440,FRAME-RATE=25.000,AUDIO="audio",SUBTITLES="subs" 5 | https://mock.mock.com/stream/files/video/asset_000001/manifest_1.m3u8 6 | 7 | -------------------------------------------------------------------------------- /src/testvectors/hls/hls2_multitrack/manifest_1.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:3 3 | #EXT-X-TARGETDURATION:10 4 | #EXT-X-MEDIA-SEQUENCE:1 5 | #EXT-X-PLAYLIST-TYPE:VOD 6 | #EXTINF:10, 7 | manifest_1_00001.ts 8 | #EXTINF:10, 9 | manifest_1_00002.ts 10 | #EXTINF:10, 11 | manifest_1_00003.ts 12 | #EXT-X-ENDLIST 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "noImplicitAny": false, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "paths": { 12 | "*": ["node_modules/*"] 13 | } 14 | }, 15 | "include": ["src/**/*", "src/server.ts"] 16 | } 17 | --------------------------------------------------------------------------------