├── .github
└── workflows
│ ├── create-documentation-pr.yml
│ ├── create-release-from-changelog.yml
│ ├── release.yml
│ ├── test.yml
│ └── update-documentation.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── doc
└── UploaderStaticWrapper.md
├── package-lock.json
├── package.json
├── sample
├── index.html
└── progressive.html
├── src
├── abstract-uploader.ts
├── index.ts
├── progressive-video-uploader.ts
├── promise-queue.ts
├── static-wrapper.ts
└── video-uploader.ts
├── test
├── abstract-uploader.ts
├── package-lock.json
├── package.json
├── progressive-video-uploader.test.ts
├── tsconfig.json
└── video-uploader.test.ts
├── tsconfig.json
├── tslint.json
└── webpack.config.js
/.github/workflows/create-documentation-pr.yml:
--------------------------------------------------------------------------------
1 | name: Create documentation PR
2 | on:
3 | # Trigger the workflow on pull requests targeting the main branch
4 | pull_request:
5 | types: [assigned, unassigned, opened, reopened, synchronize, edited, labeled, unlabeled, edited, closed]
6 | branches:
7 | - main
8 |
9 | jobs:
10 | create_documentation_pr:
11 | if: github.event.action != 'closed'
12 |
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Check out current repository code
17 | uses: actions/checkout@v2
18 |
19 | - name: Create the documentation pull request
20 | uses: apivideo/api.video-create-readme-file-pull-request-action@main
21 | with:
22 | source-file-path: "README.md"
23 | destination-repository: apivideo/api.video-documentation
24 | destination-path: sdks/vod
25 | destination-filename: apivideo-typescript-uploader.md
26 | pat: "${{ secrets.PAT }}"
27 |
--------------------------------------------------------------------------------
/.github/workflows/create-release-from-changelog.yml:
--------------------------------------------------------------------------------
1 | name: Create draft release from CHANGELOG.md
2 |
3 | on:
4 | push:
5 | paths:
6 | - 'CHANGELOG.md'
7 |
8 | jobs:
9 | update-documentation:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Create draft release if needed
14 | uses: apivideo/api.video-release-from-changelog-action@main
15 | with:
16 | github-auth-token: ${{ secrets.GITHUB_TOKEN }}
17 | prefix: v
18 |
19 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release package to npmjs
2 | on:
3 | release:
4 | types: [published]
5 | jobs:
6 | deploy:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: actions/setup-node@v2
11 | with:
12 | registry-url: 'https://registry.npmjs.org'
13 | - run: npm install --no-save
14 | - run: npm publish --access=public
15 | env:
16 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run unit tests
2 | on: [push]
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | strategy:
7 | matrix:
8 | node: ['16']
9 | steps:
10 | - uses: actions/checkout@v2
11 | - name: Setup node ${{ matrix.node }}
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: ${{ matrix.node }}
15 | - run: npm install --no-save
16 | - run: npm test
17 |
--------------------------------------------------------------------------------
/.github/workflows/update-documentation.yml:
--------------------------------------------------------------------------------
1 | name: Update readme.io documentation
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | update-documentation:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | - name: Update readme.io documentation
12 | uses: apivideo/api.video-readmeio-document-sync-action@1.2
13 | with:
14 | document-slug: video-uploader
15 | markdown-file-path: README.md
16 | readme-io-api-key: ${{ secrets.README_IO_API_KEY }}
17 | make-relative-links-absolute: true
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | tsconfig.json
2 | tslint.json
3 | webpack.config.js
4 | node_modules
5 | test
6 | dist/test
7 | .github
8 | sample
9 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "useTabs": false,
4 | "printWidth": 120
5 | }
6 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All changes to this project will be documented in this file.
3 |
4 | ## [1.1.6] - 2023-10-31
5 | - Add static wrapper
6 |
7 | ## [1.1.5] - 2023-10-26
8 | - Add cancel() methods
9 | - Add part number in progressive upload onProgress()
10 |
11 | ## [1.1.4] - 2023-08-21
12 | - Include `mp4` in VideoUploadResponse type
13 |
14 | ## [1.1.3] - 2023-01-23
15 | - Add onPlayable() method to define a listener called when the video is playable
16 |
17 | ## [1.1.2] - 2023-01-19
18 | - Video upload: add maxVideoDuration option.
19 |
20 | ## [1.1.1] - 2023-01-17
21 | - Progressive upload: add mergeSmallPartsBeforeUpload option.
22 |
23 | ## [1.1.0] - 2022-07-06
24 | - Video upload & Progressive upload: allow user to set a customized video name.
25 |
26 | ## [1.0.11] - 2022-07-06
27 | - Add origin headers
28 |
29 | ## [1.0.10] - 2022-06-29
30 | - Retry even if the server is not responding.
31 | - Add possibility to define a custom retry policy.
32 |
33 | ## [1.0.9] - 2022-05-24
34 | - Progressive upload: prevent last part to be empty
35 |
36 | ## [1.0.8] - 2022-04-27
37 | - Create a AbstractUploader
38 | - Add origin header
39 | - Add the possibility to provide a refresh token
40 |
41 | ## [1.0.7] - 2022-04-26
42 | - Don't retry on 401 errors
43 | - Mutualize some code
44 |
45 | ## [1.0.6] - 2022-04-22
46 | - Improve errors management
47 |
48 | ## [1.0.5] - 2022-04-21
49 | - Fix date attributes types
50 | - Add authentication using an API key
51 |
52 | ## [1.0.4] - 2022-03-23
53 | - Export `VideoUploadResponse` type
54 |
55 | ## [1.0.3] - 2022-01-25
56 | - Fix typo in `VideoUploadResponse`type: `hsl` instead of `hls`
57 |
58 | ## [1.0.2] - 2021-11-24
59 | - Fix: prevent concurrent requests
60 |
61 | ## [1.0.1] - 2021-11-23
62 | - Add missing return types in upload methods
63 |
64 | ## [1.0.0] - 2021-11-16
65 | - Bump dependancies
66 |
67 | ## [0.0.6] - 2021-11-10
68 | - Add progressive upload feature
69 |
70 | ## [0.0.5] - 2021-08-12
71 | - Fix chunk size (default 50MB, min 5MB, max 128MB)
72 | - Fix upload progress listener
73 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to api.video
2 |
3 | :movie_camera::+1::tada: Thank you for taking the time to contribute and participate in the implementation of a Video First World! :tada::+1::movie_camera:
4 |
5 | The following is a set of guidelines for contributing to api.video and its packages, which are hosted in the [api.video Organization](https://github.com/apivideo) on GitHub.
6 |
7 | #### Table of contents
8 |
9 | - [Contributing to api.video](#contributing-to-apivideo)
10 | - [Table of contents](#table-of-contents)
11 | - [Code of conduct](#code-of-conduct)
12 | - [I just have a question!](#i-just-have-a-question)
13 | - [How can I contribute?](#how-can-i-contribute)
14 | - [Reporting bugs](#reporting-bugs)
15 | - [Before submitting a bug report](#before-submitting-a-bug-report)
16 | - [How do I submit a (good) bug report?](#how-do-i-submit-a-good-bug-report)
17 | - [Suggesting enhancements](#suggesting-enhancements)
18 | - [How do I submit a (good) enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
19 | - [Pull requests](#pull-requests)
20 | - [Style guides](#style-guides)
21 | - [Git commit messages](#git-commit-messages)
22 | - [Documentation style guide](#documentation-style-guide)
23 | - [Additional notes](#additional-notes)
24 | - [Issue and pull request labels](#issue-and-pull-request-labels)
25 | - [Type of issue and issue state](#type-of-issue-and-issue-state)
26 | - [Topic categories](#topic-categories)
27 | - [Pull request labels](#pull-request-labels)
28 |
29 | ## Code of conduct
30 |
31 | This project and everyone participating in it is governed by the [api.video Code of Conduct](https://github.com/apivideo/.github/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [help@api.video](mailto:help@api.video).
32 |
33 | ## I just have a question!
34 |
35 | > **Note:** [Please don't file an issue to ask a question.] You'll get faster results by using the resources below.
36 |
37 | We have an official message board with a detailed FAQ and where the community chimes in with helpful advice if you have questions.
38 |
39 | * [The official api.video's Community](https://community.api.video/)
40 | * [api.video FAQ](https://community.api.video/c/faq/)
41 |
42 |
43 | ## How can I contribute?
44 |
45 | ### Reporting bugs
46 |
47 | This section guides you through submitting a bug report for api.video. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer:, and find related reports :mag_right:.
48 |
49 | Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). Fill out [the required template](https://github.com/apivideo/.github/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml), the information it asks for helps us resolve issues faster.
50 |
51 | > **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one.
52 |
53 | #### Before submitting a bug report
54 |
55 | * **Check the [The official api.video's Community](https://community.api.video/)** for a list of common questions and problems.
56 | * **Determine which repository the problem should be reported in**.
57 | * **Perform a [cursory search](https://github.com/search?q=is%3Aissue+user%3Aapivideo)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one.
58 |
59 | #### How do I submit a (good) bug report?
60 |
61 | Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined which repository your bug is related to, create an issue on that repository and provide the following information by filling in [the template](https://github.com/apivideo/.github/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml).
62 |
63 | Explain the problem and include additional details to help maintainers reproduce the problem:
64 |
65 | * **Use a clear and descriptive title** for the issue to identify the problem.
66 | * **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, **don't just say what you did, but explain how you did it**.
67 | * **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines).
68 | * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior.
69 | * **Explain which behavior you expected to see instead and why.**
70 | * **Include screenshots or videos** which show you following the described steps and clearly demonstrate the problem.
71 | * **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below.
72 |
73 | Provide more context by answering these questions:
74 |
75 | * **Did the problem start happening recently** (e.g. after updating to a new version of api.video) or was this always a problem?
76 | * If the problem started happening recently.**
77 | * **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens.
78 |
79 | Include details about your configuration and environment:
80 |
81 | * **Which version of the api.video package are you using?**
82 | * **What's the name and version of the OS you're using?**
83 |
84 | ### Suggesting enhancements
85 |
86 | This section guides you through submitting an enhancement suggestion for api.video project, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:.
87 |
88 | When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in [the template](https://github.com/apivideo/.github/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml), including the steps that you imagine you would take if the feature you're requesting existed.
89 |
90 |
91 | #### How do I submit a (good) enhancement suggestion?
92 |
93 | Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined which repository your enhancement suggestion is related to, create an issue on that repository and provide the following information:
94 |
95 | * **Use a clear and descriptive title** for the issue to identify the suggestion.
96 | * **Provide a step-by-step description of the suggested enhancement** in as many details as possible.
97 | * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines).
98 | * **Describe the current behavior** and **explain which behavior you expected to see instead** and why.
99 | * **Include screenshots** which help you demonstrate the steps or point out the part of api.video which the suggestion is related to.
100 | * **Explain why this enhancement would be useful** to most api.video users and isn't something that can or should be implemented as a community package.
101 | * **Specify which version of the api.video package you're using.**
102 | * **Specify the name and version of the OS you're using.**
103 |
104 |
105 | ### Pull requests
106 |
107 | The process described here has several goals:
108 |
109 | - Maintain api.video's quality
110 | - Fix problems that are important to users
111 | - Engage the community in working toward the best possible api.video
112 | - Enable a sustainable system for api.video's maintainers to review contributions
113 |
114 | Please follow these steps to have your contribution considered by the maintainers:
115 |
116 | 1. Explain what, why and how you resolved the issue. If you have a related issue, please mention it.
117 | 2. Follow the [style guides](#style-guides)
118 | 3. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing What if the status checks are failing?
If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.
119 |
120 | While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
121 |
122 | ## Style guides
123 |
124 | ### Git commit messages
125 |
126 | * Use the present tense ("Add feature" not "Added feature")
127 | * Limit the first line to 72 characters or less
128 | * Reference issues and pull requests after the first line
129 | * Consider starting the commit message with an applicable emoji:
130 | * :art: `:art:` when improving the format/structure of the code
131 | * :racehorse: `:racehorse:` when improving performance
132 | * :non-potable_water: `:non-potable_water:` when plugging memory leaks
133 | * :memo: `:memo:` when writing docs
134 | * :penguin: `:penguin:` when fixing something on Linux
135 | * :apple: `:apple:` when fixing something on macOS
136 | * :checkered_flag: `:checkered_flag:` when fixing something on Windows
137 | * :bug: `:bug:` when fixing a bug
138 | * :fire: `:fire:` when removing code or files
139 | * :green_heart: `:green_heart:` when fixing the CI build
140 | * :white_check_mark: `:white_check_mark:` when adding tests
141 | * :lock: `:lock:` when dealing with security
142 | * :arrow_up: `:arrow_up:` when upgrading dependencies
143 | * :arrow_down: `:arrow_down:` when downgrading dependencies
144 | * :shirt: `:shirt:` when removing linter warnings
145 |
146 | ### Documentation style guide
147 |
148 | * Use [Markdown](https://daringfireball.net/projects/markdown).
149 |
150 |
151 | ## Additional notes
152 |
153 | ### Issue and pull request labels
154 |
155 | This section lists the labels we use to help us track and manage issues and pull requests on all api.video repositories.
156 |
157 | [GitHub search](https://help.github.com/articles/searching-issues/) makes it easy to use labels for finding groups of issues or pull requests you're interested in. We encourage you to read about [other search filters](https://help.github.com/articles/searching-issues/) which will help you write more focused queries.
158 |
159 |
160 | #### Type of issue and issue state
161 |
162 | | Label name | `apivideo` :mag_right: | Description |
163 | | --- | --- | --- |
164 | | `enhancement` | [search][search-apivideo-org-label-enhancement] | Feature requests. |
165 | | `bug` | [search][search-apivideo-org-label-bug] | Confirmed bugs or reports that are very likely to be bugs. |
166 | | `question` | [search][search-apivideo-org-label-question] | Questions more than bug reports or feature requests (e.g. how do I do X). |
167 | | `feedback` | [search][search-apivideo-org-label-feedback] | General feedback more than bug reports or feature requests. |
168 | | `help-wanted` | [search][search-apivideo-org-label-help-wanted] | The api.video team would appreciate help from the community in resolving these issues. |
169 | | `more-information-needed` | [search][search-apivideo-org-label-more-information-needed] | More information needs to be collected about these problems or feature requests (e.g. steps to reproduce). |
170 | | `needs-reproduction` | [search][search-apivideo-org-label-needs-reproduction] | Likely bugs, but haven't been reliably reproduced. |
171 | | `blocked` | [search][search-apivideo-org-label-blocked] | Issues blocked on other issues. |
172 | | `duplicate` | [search][search-apivideo-org-label-duplicate] | Issues which are duplicates of other issues, i.e. they have been reported before. |
173 | | `wontfix` | [search][search-apivideo-org-label-wontfix] | The api.video team has decided not to fix these issues for now, either because they're working as intended or for some other reason. |
174 | | `invalid` | [search][search-apivideo-org-label-invalid] | Issues which aren't valid (e.g. user errors). |
175 | | `package-idea` | [search][search-apivideo-org-label-package-idea] | Feature request which might be good candidates for new packages, instead of extending api.video packages. |
176 | | `wrong-repo` | [search][search-apivideo-org-label-wrong-repo] | Issues reported on the wrong repository. |
177 |
178 | #### Topic categories
179 |
180 | | Label name | `apivideo` :mag_right: | Description |
181 | | --- | --- | --- |
182 | | `windows` | [search][search-apivideo-org-label-windows] | Related to api.video running on Windows. |
183 | | `linux` | [search][search-apivideo-org-label-linux] | Related to api.video running on Linux. |
184 | | `mac` | [search][search-apivideo-org-label-mac] | Related to api.video running on macOS. |
185 | | `documentation` | [search][search-apivideo-org-label-documentation] | Related to any type of documentation. |
186 | | `performance` | [search][search-apivideo-org-label-performance] | Related to performance. |
187 | | `security` | [search][search-apivideo-org-label-security] | Related to security. |
188 | | `ui` | [search][search-apivideo-org-label-ui] | Related to visual design. |
189 | | `api` | [search][search-apivideo-org-label-api] | Related to api.video's public APIs. |
190 |
191 | #### Pull request labels
192 |
193 | | Label name | `apivideo` :mag_right: | Description
194 | | --- | --- | --- |
195 | | `work-in-progress` | [search][search-apivideo-org-label-work-in-progress] | Pull requests which are still being worked on, more changes will follow. |
196 | | `needs-review` | [search][search-apivideo-org-label-needs-review] | Pull requests which need code review, and approval from maintainers or api.video team. |
197 | | `under-review` | [search][search-apivideo-org-label-under-review] | Pull requests being reviewed by maintainers or api.video team. |
198 | | `requires-changes` | [search][search-apivideo-org-label-requires-changes] | Pull requests which need to be updated based on review comments and then reviewed again. |
199 | | `needs-testing` | [search][search-apivideo-org-label-needs-testing] | Pull requests which need manual testing. |
200 |
201 | [search-apivideo-org-label-enhancement]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aenhancement
202 | [search-apivideo-org-label-bug]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Abug
203 | [search-apivideo-org-label-question]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aquestion
204 | [search-apivideo-org-label-feedback]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Afeedback
205 | [search-apivideo-org-label-help-wanted]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Ahelp-wanted
206 | [search-apivideo-org-label-more-information-needed]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Amore-information-needed
207 | [search-apivideo-org-label-needs-reproduction]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aneeds-reproduction
208 | [search-apivideo-org-label-windows]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Awindows
209 | [search-apivideo-org-label-linux]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Alinux
210 | [search-apivideo-org-label-mac]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Amac
211 | [search-apivideo-org-label-documentation]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Adocumentation
212 | [search-apivideo-org-label-performance]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aperformance
213 | [search-apivideo-org-label-security]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Asecurity
214 | [search-apivideo-org-label-ui]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aui
215 | [search-apivideo-org-label-api]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aapi
216 | [search-apivideo-org-label-blocked]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Ablocked
217 | [search-apivideo-org-label-duplicate]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Aduplicate
218 | [search-apivideo-org-label-wontfix]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Awontfix
219 | [search-apivideo-org-label-invalid]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Ainvalid
220 | [search-apivideo-org-label-package-idea]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Apackage-idea
221 | [search-apivideo-org-label-wrong-repo]: https://github.com/search?q=is%3Aopen+is%3Aissue+user%3Aapivideo+label%3Awrong-repo
222 | [search-apivideo-org-label-work-in-progress]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aapivideo%2Fapivideo+label%3Awork-in-progress
223 | [search-apivideo-org-label-needs-review]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aapivideo%2Fapivideo+label%3Aneeds-review
224 | [search-apivideo-org-label-under-review]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aapivideo%2Fapivideo+label%3Aunder-review
225 | [search-apivideo-org-label-requires-changes]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aapivideo%2Fapivideo+label%3Arequires-changes
226 | [search-apivideo-org-label-needs-testing]: https://github.com/search?q=is%3Aopen+is%3Apr+repo%3Aapivideo%2Fapivideo+label%3Aneeds-testing
227 |
228 | [help-wanted]:https://github.com/search?q=is%3Aopen+is%3Aissue+label%3Ahelp-wanted+user%3Aapivideo+sort%3Acomments-desc
229 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 api.video
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | [](https://twitter.com/intent/follow?screen_name=api_video) [](https://github.com/apivideo/api.video-typescript-uploader) [](https://community.api.video)
3 | 
4 |
api.video typescript video uploader
5 |
6 |  
7 |
8 |
9 | [api.video](https://api.video) is the video infrastructure for product builders. Lightning fast video APIs for integrating, scaling, and managing on-demand & low latency live streaming features in your app.
10 |
11 | ## Table of contents
12 |
13 | - [Table of contents](#table-of-contents)
14 | - [Project description](#project-description)
15 | - [Getting started](#getting-started)
16 | - [Installation](#installation)
17 | - [Installation method #1: requirejs](#installation-method-1-requirejs)
18 | - [Installation method #2: typescript](#installation-method-2-typescript)
19 | - [Simple include in a javascript project](#simple-include-in-a-javascript-project)
20 | - [Documentation - Standard upload](#documentation---standard-upload)
21 | - [Instantiation](#instantiation)
22 | - [Options](#options)
23 | - [Using a delegated upload token (recommended):](#using-a-delegated-upload-token-recommended)
24 | - [Using an access token (discouraged):](#using-an-access-token-discouraged)
25 | - [Using an API key (**strongly** discouraged):](#using-an-api-key-strongly-discouraged)
26 | - [Common options](#common-options)
27 | - [Example](#example)
28 | - [Methods](#methods)
29 | - [`upload()`](#upload)
30 | - [`onProgress()`](#onprogress)
31 | - [`cancel()`](#cancel)
32 | - [`onPlayable()`](#onplayable)
33 | - [Documentation - Progressive upload](#documentation---progressive-upload)
34 | - [Instantiation](#instantiation-1)
35 | - [Options](#options-1)
36 | - [Using a delegated upload token (recommended):](#using-a-delegated-upload-token-recommended-1)
37 | - [Using an access token (discouraged):](#using-an-access-token-discouraged-1)
38 | - [Common options](#common-options-1)
39 | - [Example](#example-1)
40 | - [Methods](#methods-1)
41 | - [`uploadPart(file: Blob)`](#uploadpartfile-blob)
42 | - [`uploadLastPart(file: Blob)`](#uploadlastpartfile-blob)
43 | - [`onProgress()`](#onprogress-1)
44 | - [`cancel()`](#cancel-1)
45 | - [`onPlayable()`](#onplayable-1)
46 | - [Static wrapper](#static-wrapper)
47 |
48 |
49 |
61 | ## Project description
62 |
63 | Typescript library to upload videos to api.video using delegated upload token (or usual access token) from the front-end.
64 |
65 | It allows you to upload videos in two ways:
66 | - standard upload: to send a whole video file in one go
67 | - progressive upload: to send a video file by chunks, without needing to know the final size of the video file
68 |
69 | ## Getting started
70 |
71 | ### Installation
72 |
73 | #### Installation method #1: requirejs
74 |
75 | If you use requirejs you can add the library as a dependency to your project with
76 |
77 | ```sh
78 | $ npm install --save @api.video/video-uploader
79 | ```
80 |
81 | You can then use the library in your script:
82 |
83 | ```javascript
84 | // standard upload:
85 | var { VideoUploader } = require('@api.video/video-uploader');
86 |
87 | var uploader = new VideoUploader({
88 | // ... (see bellow)
89 | });
90 |
91 | // progressive upload:
92 | var { ProgressiveUploader } = require('@api.video/video-uploader');
93 |
94 | var uploader = new ProgressiveUploader({
95 | // ... (see bellow)
96 | });
97 | ```
98 |
99 | #### Installation method #2: typescript
100 |
101 | If you use Typescript you can add the library as a dependency to your project with
102 |
103 | ```sh
104 | $ npm install --save @api.video/video-uploader
105 | ```
106 |
107 | You can then use the library in your script:
108 |
109 | ```typescript
110 | // standard upload:
111 | import { VideoUploader } from '@api.video/video-uploader'
112 |
113 | const uploader = new VideoUploader({
114 | // ... (see bellow)
115 | });
116 |
117 | // progressive upload:
118 | import { ProgressiveUploader } from '@api.video/video-uploader'
119 |
120 | const uploader = new ProgressiveUploader({
121 | // ... (see bellow)
122 | });
123 | ```
124 |
125 |
126 | #### Simple include in a javascript project
127 |
128 | Include the library in your HTML file like so:
129 |
130 | ```html
131 |
132 | ...
133 |
134 |
135 | ```
136 |
137 | Then, once the `window.onload` event has been trigered, create your player using `new VideoUploader()`:
138 | ```html
139 | ...
140 |
143 |
151 | ```
152 |
153 | ## Documentation - Standard upload
154 |
155 | ### Instantiation
156 |
157 | #### Options
158 |
159 | The upload library is instantiated using an `options` object. Options to provide depend on the way you want to authenticate to the API: either using a delegated upload token (recommanded), or using a usual access token.
160 |
161 | ##### Using a delegated upload token (recommended):
162 |
163 | Using delegated upload tokens for authentication is best options when uploading from the client side. To know more about delegated upload token, read the dedicated article on api.video's blog: [Delegated Uploads](https://api.video/blog/tutorials/delegated-uploads/).
164 |
165 |
166 | | Option name | Mandatory | Type | Description |
167 | | ----------------------------: | --------- | ------ | ----------------------- |
168 | | uploadToken | **yes** | string | your upload token |
169 | | videoId | no | string | id of an existing video |
170 | | _common options (see bellow)_ | | | |
171 |
172 | ##### Using an access token (discouraged):
173 |
174 | **Warning**: be aware that exposing your access token client-side can lead to huge security issues. Use this method only if you know what you're doing :).
175 |
176 |
177 | | Option name | Mandatory | Type | Description |
178 | | ----------------------------: | --------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
179 | | accessToken | **yes** | string | your access token |
180 | | refreshToken | **no** | string | your refresh token (please not that if you don't provide a refresh token, your upload may fails due to the access token lifetime of 60 minutes) |
181 | | videoId | **yes** | string | id of an existing video |
182 | | _common options (see bellow)_ | | | |
183 |
184 |
185 | ##### Using an API key (**strongly** discouraged):
186 |
187 | **Warning**: be aware that exposing your API key client-side can lead to huge security issues. Use this method only if you know what you're doing :).
188 |
189 |
190 | | Option name | Mandatory | Type | Description |
191 | | ----------------------------: | --------- | ------ | ----------------------- |
192 | | API Key | **yes** | string | your api.video API key |
193 | | videoId | **yes** | string | id of an existing video |
194 | | _common options (see bellow)_ | | | |
195 |
196 |
197 | ##### Common options
198 |
199 |
200 | | Option name | Mandatory | Type | Description |
201 | | ---------------: | --------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
202 | | file | **yes** | File | the file you want to upload |
203 | | videoName | no | string | the name of your video (overrides the original file name for regular uploads, overrides the default "file" name for progressive uploads) |
204 | | chunkSize | no | number | number of bytes of each upload chunk (default: 50MB, min: 5MB, max: 128MB) |
205 | | apiHost | no | string | api.video host (default: ws.api.video) |
206 | | retries | no | number | number of retries when an API call fails (default: 5) |
207 | | retryStrategy | no | (retryCount: number, error: VideoUploadError) => number \| null | function that returns the number of ms to wait before retrying a failed upload. Returns null to stop retrying |
208 | | maxVideoDuration | no | number | maximum duration allowed for the file (in seconds) |
209 |
210 |
211 | #### Example
212 |
213 | ```javascript
214 | const uploader = new VideoUploader({
215 | file: files[0],
216 | uploadToken: "YOUR_DELEGATED_TOKEN",
217 | chunkSize: 1024*1024*10, // 10MB
218 | retries: 10,
219 | });
220 | ```
221 |
222 | ### Methods
223 |
224 | #### `upload()`
225 |
226 | The upload() method starts the upload. It takes no parameter. It returns a Promise that resolves once the file is uploaded. If an API call fails more than the specified number of retries, then the promise is rejected.
227 | On success, the promise embeds the `video` object returned by the API.
228 | On fail, the promise embeds the status code & error message returned by the API.
229 |
230 | **Example**
231 |
232 | ```javascript
233 | // ... uploader instantiation
234 |
235 | uploader.upload()
236 | .then((video) => console.log(video))
237 | .catch((error) => console.log(error.status, error.message));
238 | ```
239 |
240 | #### `onProgress()`
241 |
242 | The onProgress() method let you defined an upload progress listener. It takes a callback function with one parameter: the onProgress events.
243 | An onProgress event contains the following attributes:
244 | - uploadedBytes (number): total number of bytes uploaded for this upload
245 | - totalBytes (number): total size of the file
246 | - chunksCount (number): number of upload chunks
247 | - chunksBytes (number): size of a chunk
248 | - currentChunk (number): index of the chunk being uploaded
249 | - currentChunkUploadedBytes (number): number of bytes uploaded for the current chunk
250 |
251 | #### `cancel()`
252 |
253 | The cancel() method cancels the upload. It takes no parameter.
254 |
255 |
256 | **Example**
257 |
258 | ```javascript
259 | // ... uploader instantiation
260 |
261 | uploader.onProgress((event) => {
262 | console.log(`total number of bytes uploaded for this upload: ${event.uploadedBytes}.`);
263 | console.log(`total size of the file: ${event.totalBytes}.`);
264 | console.log(`number of upload chunks: ${event.chunksCount} .`);
265 | console.log(`size of a chunk: ${event.chunksBytes}.`);
266 | console.log(`index of the chunk being uploaded: ${event.currentChunk}.`);
267 | console.log(`number of bytes uploaded for the current chunk: ${event.currentChunkUploadedBytes}.`);
268 | });
269 | ```
270 |
271 |
272 | #### `onPlayable()`
273 |
274 | The onPlayable() method let you defined a listener that will be called when the video is playable. It takes a callback function with one parameter: the `video` object returned by the API.
275 |
276 | **Example**
277 |
278 | ```html
279 |
280 |
281 |
289 | ```
290 |
291 | ## Documentation - Progressive upload
292 |
293 |
294 | ### Instantiation
295 |
296 | #### Options
297 |
298 | The progressive upload object is instantiated using an `options` object. Options to provide depend on the way you want to authenticate to the API: either using a delegated upload token (recommanded), or using a usual access token.
299 |
300 | ##### Using a delegated upload token (recommended):
301 |
302 | Using delegated upload tokens for authentication is best options when uploading from the client side. To know more about delegated upload token, read the dedicated article on api.video's blog: [Delegated Uploads](https://api.video/blog/tutorials/delegated-uploads/).
303 |
304 |
305 | | Option name | Mandatory | Type | Description |
306 | | ----------------------------: | --------- | ------ | ----------------------- |
307 | | uploadToken | **yes** | string | your upload token |
308 | | videoId | no | string | id of an existing video |
309 | | _common options (see bellow)_ | | | |
310 |
311 | ##### Using an access token (discouraged):
312 |
313 | **Warning**: be aware that exposing your access token client-side can lead to huge security issues. Use this method only if you know what you're doing :).
314 |
315 |
316 | | Option name | Mandatory | Type | Description |
317 | | ----------------------------: | --------- | ------ | ----------------------- |
318 | | accessToken | **yes** | string | your access token |
319 | | videoId | **yes** | string | id of an existing video |
320 | | _common options (see bellow)_ | | | |
321 |
322 |
323 | ##### Common options
324 |
325 |
326 | | Option name | Mandatory | Type | Description |
327 | | --------------------------: | --------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
328 | | apiHost | no | string | api.video host (default: ws.api.video) |
329 | | retries | no | number | number of retries when an API call fails (default: 5) |
330 | | retryStrategy | no | (retryCount: number, error: VideoUploadError) => number \| null | function that returns the number of ms to wait before retrying a failed upload. Returns null to stop retrying |
331 | | preventEmptyParts | no | boolean | if true, the upload will succeed even if an empty Blob is passed to uploadLastPart(). This may alter performances a bit in some cases (default: false) |
332 | | mergeSmallPartsBeforeUpload | no | boolean | if false, parts smaller than 5MB will not be merged before upload, resulting in an error (default: true) |
333 |
334 |
335 | #### Example
336 |
337 | ```javascript
338 | const uploader = new ProgressiveUploader({
339 | uploadToken: "YOUR_DELEGATED_TOKEN",
340 | retries: 10,
341 | });
342 | ```
343 |
344 | ### Methods
345 |
346 | #### `uploadPart(file: Blob)`
347 |
348 | The upload() method starts the upload. It takes no parameter. It returns a Promise that resolves once the file is uploaded. If an API call fails more than the specified number of retries, then the promise is rejected.
349 | On success, the promise embeds the `video` object returned by the API.
350 | On fail, the promise embeds the status code & error message returned by the API.
351 |
352 | **Example**
353 |
354 | ```javascript
355 | // ... uploader instantiation
356 |
357 | uploader.uploadPart(blob)
358 | .catch((error) => console.log(error.status, error.message));
359 | ```
360 |
361 | #### `uploadLastPart(file: Blob)`
362 |
363 | The upload() method starts the upload. It takes no parameter. It returns a Promise that resolves once the file is uploaded. If an API call fails more than the specified number of retries, then the promise is rejected.
364 | On success, the promise embeds the `video` object returned by the API.
365 | On fail, the promise embeds the status code & error message returned by the API.
366 |
367 | **Example**
368 |
369 | ```javascript
370 | // ... uploader instantiation
371 |
372 | uploader.uploadLastPart(blob)
373 | .then((video) => console.log(video))
374 | .catch((error) => console.log(error.status, error.message));
375 | ```
376 |
377 | #### `onProgress()`
378 |
379 | The onProgress() method let you defined an upload progress listener. It takes a callback function with one parameter: the onProgress events.
380 | An onProgress event contains the following attributes:
381 | - uploadedBytes (number): total number of bytes uploaded for this upload
382 | - totalBytes (number): total size of the file
383 | - part (number): index of the part being uploaded
384 |
385 | **Example**
386 |
387 | ```javascript
388 | // ... uploader instantiation
389 |
390 | uploader.onProgress((event) => {
391 | console.log(`total number of bytes uploaded for this upload: ${event.uploadedBytes}.`);
392 | console.log(`total size of the file: ${event.totalBytes}.`);
393 | console.log(`current part: ${event.part}.`);
394 | });
395 | ```
396 |
397 | #### `cancel()`
398 |
399 | The cancel() method cancels the upload. It takes no parameter.
400 |
401 | #### `onPlayable()`
402 |
403 | The onPlayable() method let you defined a listener that will be called when the video is playable. It takes a callback function with one parameter: the `video` object returned by the API.
404 |
405 | **Example**
406 |
407 | ```html
408 |
409 |
410 |
418 | ```
419 |
420 |
421 | ## Static wrapper
422 |
423 | For situations where managing object instances is impractical, consider using the [UploaderStaticWrapper](https://github.com/apivideo/api.video-typescript-uploader/blob/main/doc/UploaderStaticWrapper.md) class, which offers static method equivalents for all functionalities.
424 |
--------------------------------------------------------------------------------
/doc/UploaderStaticWrapper.md:
--------------------------------------------------------------------------------
1 | # Uploader static wrapper documentation
2 |
3 | ## Overview
4 |
5 | The `UploaderStaticWrapper` class serves as a static interface to the underlying object-oriented uploader library.
6 |
7 | This static abstraction is particularly beneficial in contexts where direct object manipulation can be challenging, such as when working within cross-platform frameworks like Flutter or React Native, or in no-code solutions.
8 |
9 | By providing a suite of static methods, `UploaderStaticWrapper` allows developers to leverage the power of the library without the complexity of handling instances or managing object lifecycles.
10 |
11 | This approach simplifies integration, making it more accessible for a wider range of development environments where traditional object-oriented paradigms are less suitable or harder to implement.
12 |
13 | ## Common functions
14 |
15 | ### `UploaderStaticWrapper.setApplicationName(name: string, version: string)`
16 |
17 | Sets the application name and version for the SDK.
18 |
19 | - **Parameters:**
20 | - `name: string` - The name of the application using the SDK.
21 | - `version: string` - The version of the application.
22 |
23 | ### `UploaderStaticWrapper.setChunkSize(chunkSize: number)`
24 |
25 | Sets the chunk size for the video upload.
26 |
27 | - **Parameters:**
28 | - `chunkSize: number` - The size of each chunk in bytes.
29 |
30 |
31 | ### `UploaderStaticWrapper.cancelAll()`
32 |
33 | Cancels all ongoing uploads, both progressive and standard.
34 |
35 |
36 | ## Standard uploads functions
37 |
38 |
39 | ### `UploaderStaticWrapper.uploadWithUploadToken(blob: Blob, uploadToken: string, videoName: string, onProgress: (event: number) => void, videoId?: string)`
40 |
41 | Uploads a video with an upload token.
42 |
43 | - **Parameters:**
44 | - `blob: Blob` - The video file to be uploaded.
45 | - `uploadToken: string` - The upload token provided by the backend.
46 | - `videoName: string` - The name of the video.
47 | - `onProgress: (event: number) => void` - The callback to call on progress updates.
48 | - `videoId?: string` - The ID of the video to be uploaded (optional).
49 |
50 | - **Returns:**
51 | - `Promise` - A promise resolving to a JSON representation of the `VideoUploadResponse` object.
52 |
53 | ### `UploaderStaticWrapper.uploadWithApiKey(blob: Blob, apiKey: string, onProgress: (event: number) => void, videoId: string)`
54 |
55 | Uploads a video with an API key.
56 |
57 | - **Parameters:**
58 | - `blob: Blob` - The video file to be uploaded.
59 | - `apiKey: string` - The API key provided by the backend.
60 | - `onProgress: (event: number) => void` - The callback to call on progress updates.
61 | - `videoId: string` - The ID of the video to be uploaded (optional).
62 |
63 | - **Returns:**
64 | - `Promise` - A promise resolving to a JSON representation of the `VideoUploadResponse` object.
65 |
66 | ## Progressive uploads functions
67 |
68 | ### `UploaderStaticWrapper.createProgressiveUploadWithUploadTokenSession(sessionId: string, uploadToken: string, videoId: string)`
69 |
70 | Creates a new progressive upload session with an upload token.
71 |
72 | - **Parameters:**
73 | - `sessionId: string` - The unique session identifier.
74 | - `uploadToken: string` - The upload token provided by the backend.
75 | - `videoId: string` - The ID of the video to be uploaded.
76 |
77 | ### `UploaderStaticWrapper.createProgressiveUploadWithApiKeySession(sessionId: string, apiKey: string, videoId: string)`
78 |
79 | Creates a new progressive upload session with an API key.
80 |
81 | - **Parameters:**
82 | - `sessionId: string` - The unique session identifier.
83 | - `apiKey: string` - The API key provided by the backend.
84 | - `videoId: string` - The ID of the video to be uploaded.
85 |
86 | ### `UploaderStaticWrapper.uploadPart(sessionId: string, blob: Blob, onProgress: (progress: number) => void)`
87 |
88 | Uploads a part of a video in a progressive upload session.
89 |
90 | - **Parameters:**
91 | - `sessionId: string` - The unique session identifier.
92 | - `blob: Blob` - The video part.
93 | - `onProgress: (progress: number) => void` - The callback to call on progress updates.
94 |
95 | - **Returns:**
96 | - `Promise` - A promise resolving to a JSON representation of the `VideoUploadResponse` object.
97 | -
98 | ### `UploaderStaticWrapper.uploadLastPart(sessionId: string, blob: Blob, onProgress: (progress: number) => void)`
99 |
100 | Uploads the last part of a video in a progressive upload session and finalizes the upload.
101 |
102 | - **Parameters:**
103 | - `sessionId: string` - The unique session identifier.
104 | - `blob: Blob` - The video part.
105 | - `onProgress: (progress: number) => void` - The callback to call on progress updates.
106 |
107 | - **Returns:**
108 | - `Promise` - A promise resolving to a JSON representation of the `VideoUploadResponse` object.
109 |
110 | ### `UploaderStaticWrapper.disposeProgressiveUploadSession(sessionId: string)`
111 |
112 | Disposes a progressive upload session by its ID.
113 |
114 | - **Parameters:**
115 | - `sessionId: string` - The unique session identifier to dispose.
116 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@api.video/video-uploader",
3 | "version": "1.1.6",
4 | "description": "api.video video uploader",
5 | "repository": {
6 | "type": "git",
7 | "url": "git@github.com:apivideo/api.video-typescript-uploader.git"
8 | },
9 | "author": "api.video (https://api.video/)",
10 | "license": "MIT",
11 | "keywords": [
12 | "video",
13 | "upload",
14 | "apivideo"
15 | ],
16 | "main": "dist/index.js",
17 | "module": "dist/index.js",
18 | "types": "dist/src/index.d.ts",
19 | "scripts": {
20 | "tslint": "tslint --project .",
21 | "build": "npm run tslint && webpack --mode production",
22 | "prepublishOnly": "npm run build",
23 | "test": "npm run build && mocha -r ts-node/register -r jsdom-global/register 'test/**/*.ts'",
24 | "watch": "npx webpack --watch --mode=development"
25 | },
26 | "devDependencies": {
27 | "@types/chai": "^4.3.3",
28 | "@types/jsdom": "^20.0.0",
29 | "@types/mocha": "^10.0.0",
30 | "chai": "^4.3.6",
31 | "jsdom": "^20.0.1",
32 | "jsdom-global": "^3.0.2",
33 | "mocha": "^10.0.0",
34 | "ts-loader": "^9.4.1",
35 | "ts-node": "^10.9.1",
36 | "tslint": "^6.1.3",
37 | "typescript": "^4.8.4",
38 | "webpack": "^5.74.0",
39 | "webpack-cli": "^4.10.0",
40 | "xhr-mock": "^2.5.1"
41 | },
42 | "dependencies": {
43 | "core-js": "^3.25.5"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/sample/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
16 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/sample/progressive.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
14 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/abstract-uploader.ts:
--------------------------------------------------------------------------------
1 | export const MIN_CHUNK_SIZE = 1024 * 1024 * 5; // 5mb
2 | export const DEFAULT_CHUNK_SIZE = 1024 * 1024 * 50; // 50mb
3 | export const MAX_CHUNK_SIZE = 1024 * 1024 * 128; // 128mb
4 | export const DEFAULT_RETRIES = 6;
5 | export const DEFAULT_API_HOST = "ws.api.video";
6 |
7 | export declare type VideoUploadResponse = {
8 | readonly videoId: string;
9 | readonly title?: string;
10 | readonly description?: string;
11 | readonly _public?: boolean;
12 | readonly panoramic?: boolean;
13 | readonly mp4Support?: boolean;
14 | readonly publishedAt?: Date;
15 | readonly createdAt?: Date;
16 | readonly updatedAt?: Date;
17 | readonly tags?: string[];
18 | readonly metadata?: {
19 | readonly key?: string;
20 | readonly value?: string;
21 | }[];
22 | readonly source?: {
23 | readonly type?: string;
24 | readonly uri?: string;
25 | };
26 | readonly assets?: {
27 | readonly iframe?: string;
28 | readonly player?: string;
29 | readonly hls?: string;
30 | readonly thumbnail?: string;
31 | readonly mp4?: string;
32 | };
33 | };
34 |
35 | type RetryStrategy = (
36 | retryCount: number,
37 | error: VideoUploadError,
38 | ) => number | null;
39 |
40 | interface Origin {
41 | name: string;
42 | version: string;
43 | }
44 |
45 | export interface CommonOptions {
46 | apiHost?: string;
47 | retries?: number;
48 | videoName?: string;
49 | retryStrategy?: RetryStrategy;
50 | origin?: {
51 | application?: Origin;
52 | sdk?: Origin;
53 | };
54 | }
55 |
56 | export interface WithUploadToken {
57 | uploadToken: string;
58 | videoId?: string;
59 | }
60 |
61 | export interface WithAccessToken {
62 | accessToken: string;
63 | refreshToken?: string;
64 | videoId: string;
65 | }
66 |
67 | export interface WithApiKey {
68 | apiKey: string;
69 | videoId: string;
70 | }
71 |
72 | export type VideoUploadError = {
73 | status?: number;
74 | type?: string;
75 | title?: string;
76 | reason?: string;
77 | raw: string;
78 | };
79 |
80 | type HXRRequestParams = {
81 | parts?: {
82 | currentPart: number;
83 | totalParts: number | "*";
84 | };
85 | onProgress?: (e: ProgressEvent) => void;
86 | body: Document | XMLHttpRequestBodyInit | null;
87 | };
88 |
89 | export interface CancelableOperation {
90 | cancel: () => void;
91 | result: Promise;
92 | }
93 |
94 | let PACKAGE_VERSION = "";
95 | try {
96 | // @ts-ignore
97 | PACKAGE_VERSION = __PACKAGE_VERSION__ || "";
98 | } catch (e) {
99 | // ignore
100 | }
101 |
102 | export const DEFAULT_RETRY_STRATEGY = (maxRetries: number) => {
103 | return (retryCount: number, error: VideoUploadError) => {
104 | if (
105 | (error.status && error.status >= 400 && error.status < 500) ||
106 | retryCount >= maxRetries
107 | ) {
108 | return null;
109 | }
110 | return Math.floor(200 + 2000 * retryCount * (retryCount + 1));
111 | };
112 | };
113 |
114 | export abstract class AbstractUploader {
115 | protected uploadEndpoint: string;
116 | protected videoId?: string;
117 | protected retries: number;
118 | protected headers: { [name: string]: string } = {};
119 | protected onProgressCallbacks: ((e: T) => void)[] = [];
120 | protected onPlayableCallbacks: ((e: VideoUploadResponse) => void)[] = [];
121 | protected refreshToken?: string;
122 | protected apiHost: string;
123 | protected retryStrategy: RetryStrategy;
124 | protected abortControllers: { [id: string]: AbortController } = {};
125 |
126 | constructor(
127 | options: CommonOptions & (WithAccessToken | WithUploadToken | WithApiKey),
128 | ) {
129 | this.apiHost = options.apiHost || DEFAULT_API_HOST;
130 |
131 | if (options.hasOwnProperty("uploadToken")) {
132 | const optionsWithUploadToken = options as WithUploadToken;
133 | if (optionsWithUploadToken.videoId) {
134 | this.videoId = optionsWithUploadToken.videoId;
135 | }
136 | this.uploadEndpoint = `https://${this.apiHost}/upload?token=${optionsWithUploadToken.uploadToken}`;
137 | } else if (options.hasOwnProperty("accessToken")) {
138 | const optionsWithAccessToken = options as WithAccessToken;
139 | if (!optionsWithAccessToken.videoId) {
140 | throw new Error("'videoId' is missing");
141 | }
142 | this.refreshToken = optionsWithAccessToken.refreshToken;
143 | this.uploadEndpoint = `https://${this.apiHost}/videos/${optionsWithAccessToken.videoId}/source`;
144 | this.headers.Authorization = `Bearer ${optionsWithAccessToken.accessToken}`;
145 | } else if (options.hasOwnProperty("apiKey")) {
146 | const optionsWithApiKey = options as WithApiKey;
147 | if (!optionsWithApiKey.videoId) {
148 | throw new Error("'videoId' is missing");
149 | }
150 | this.uploadEndpoint = `https://${this.apiHost}/videos/${optionsWithApiKey.videoId}/source`;
151 | this.headers.Authorization = `Basic ${btoa(
152 | optionsWithApiKey.apiKey + ":",
153 | )}`;
154 | } else {
155 | throw new Error(
156 | `You must provide either an accessToken, an uploadToken or an API key`,
157 | );
158 | }
159 | this.headers["AV-Origin-Client"] = "typescript-uploader:" + PACKAGE_VERSION;
160 | this.retries = options.retries || DEFAULT_RETRIES;
161 | this.retryStrategy =
162 | options.retryStrategy || DEFAULT_RETRY_STRATEGY(this.retries);
163 |
164 | if (options.origin) {
165 | if (options.origin.application) {
166 | AbstractUploader.validateOrigin(
167 | "application",
168 | options.origin.application,
169 | );
170 | this.headers[
171 | "AV-Origin-App"
172 | ] = `${options.origin.application.name}:${options.origin.application.version}`;
173 | }
174 | if (options.origin.sdk) {
175 | AbstractUploader.validateOrigin("sdk", options.origin.sdk);
176 | this.headers[
177 | "AV-Origin-Sdk"
178 | ] = `${options.origin.sdk.name}:${options.origin.sdk.version}`;
179 | }
180 | }
181 | }
182 |
183 | public onProgress(cb: (e: T) => void) {
184 | this.onProgressCallbacks.push(cb);
185 | }
186 |
187 | public onPlayable(cb: (e: VideoUploadResponse) => void) {
188 | this.onPlayableCallbacks.push(cb);
189 | }
190 |
191 | protected async waitForPlayable(video: VideoUploadResponse) {
192 | const hls = video.assets?.hls;
193 |
194 | while (true) {
195 | await this.sleep(500);
196 |
197 | const hlsRes = await fetch(hls!);
198 |
199 | if (hlsRes.status === 202) {
200 | continue;
201 | }
202 |
203 | if ((await hlsRes.text()).length === 0) {
204 | continue;
205 | }
206 |
207 | break;
208 | }
209 |
210 | this.onPlayableCallbacks.forEach((cb) => cb(video));
211 | }
212 |
213 | protected parseErrorResponse(xhr: XMLHttpRequest): VideoUploadError {
214 | try {
215 | const parsedResponse = JSON.parse(xhr.response);
216 |
217 | return {
218 | status: xhr.status,
219 | raw: xhr.response,
220 | ...parsedResponse,
221 | };
222 | } catch (e) {
223 | // empty
224 | }
225 |
226 | return {
227 | status: xhr.status,
228 | raw: xhr.response,
229 | reason: "UNKWOWN",
230 | };
231 | }
232 |
233 | protected apiResponseToVideoUploadResponse(
234 | response: any,
235 | ): VideoUploadResponse {
236 | const res = {
237 | ...response,
238 | _public: response.public,
239 | publishedAt: response.publishedAt
240 | ? new Date(response.publishedAt)
241 | : undefined,
242 | createdAt: response.createdAt ? new Date(response.createdAt) : undefined,
243 | updatedAt: response.updatedAt ? new Date(response.updatedAt) : undefined,
244 | };
245 | delete res.public;
246 | return res;
247 | }
248 |
249 | protected sleep(duration: number): Promise {
250 | return new Promise((resolve, reject) => {
251 | setTimeout(() => resolve(), duration);
252 | });
253 | }
254 |
255 | protected xhrWithRetrier(
256 | params: HXRRequestParams,
257 | ): CancelableOperation {
258 | return this.withRetrier((abortController: AbortController) =>
259 | this.createXhrPromise(params, abortController),
260 | );
261 | }
262 |
263 | protected createFormData(
264 | file: Blob,
265 | fileName: string,
266 | startByte?: number,
267 | endByte?: number,
268 | ): FormData {
269 | const chunk = startByte || endByte ? file.slice(startByte, endByte) : file;
270 | const chunkForm = new FormData();
271 | if (this.videoId) {
272 | chunkForm.append("videoId", this.videoId);
273 | }
274 | chunkForm.append("file", chunk, fileName);
275 | return chunkForm;
276 | }
277 |
278 | public doRefreshToken(): Promise {
279 | return new Promise((resolve, reject) => {
280 | const xhr = new window.XMLHttpRequest();
281 | xhr.open("POST", `https://${this.apiHost}/auth/refresh`);
282 | for (const headerName of Object.keys(this.headers)) {
283 | if (headerName !== "Authorization")
284 | xhr.setRequestHeader(headerName, this.headers[headerName]);
285 | }
286 | xhr.onreadystatechange = (_) => {
287 | if (xhr.readyState === 4 && xhr.status >= 400) {
288 | reject(this.parseErrorResponse(xhr));
289 | }
290 | };
291 | xhr.onload = (_) => {
292 | const response = JSON.parse(xhr.response);
293 | if (response.refresh_token && response.access_token) {
294 | this.headers.Authorization = `Bearer ${response.access_token}`;
295 | this.refreshToken = response.refresh_token;
296 | resolve();
297 | return;
298 | }
299 | reject(this.parseErrorResponse(xhr));
300 | };
301 | xhr.send(
302 | JSON.stringify({
303 | refreshToken: this.refreshToken,
304 | }),
305 | );
306 | });
307 | }
308 | private createXhrPromise(
309 | params: HXRRequestParams,
310 | abortController: AbortController,
311 | ): Promise {
312 | return new Promise((resolve, reject) => {
313 | const xhr = new window.XMLHttpRequest();
314 | xhr.open("POST", `${this.uploadEndpoint}`, true);
315 | abortController.signal.addEventListener("abort", () => {
316 | xhr.abort();
317 | reject({
318 | status: undefined,
319 | raw: undefined,
320 | reason: "ABORTED",
321 | });
322 | });
323 | if (params.parts) {
324 | xhr.setRequestHeader(
325 | "Content-Range",
326 | `part ${params.parts.currentPart}/${params.parts.totalParts}`,
327 | );
328 | }
329 | for (const headerName of Object.keys(this.headers)) {
330 | xhr.setRequestHeader(headerName, this.headers[headerName]);
331 | }
332 | if (params.onProgress) {
333 | xhr.upload.onprogress = (e) => params.onProgress!(e);
334 | }
335 | xhr.onreadystatechange = (_) => {
336 | if (xhr.readyState === 4) {
337 | // DONE
338 | if (xhr.status === 401 && this.refreshToken) {
339 | return this.doRefreshToken()
340 | .then(() => this.createXhrPromise(params, abortController))
341 | .then((res) => resolve(res))
342 | .catch((e) => reject(e));
343 | } else if (xhr.status >= 400) {
344 | reject(this.parseErrorResponse(xhr));
345 | return;
346 | }
347 | }
348 | };
349 | xhr.onerror = (e) => {
350 | reject({
351 | status: undefined,
352 | raw: undefined,
353 | reason: "NETWORK_ERROR",
354 | });
355 | };
356 | xhr.ontimeout = (e) => {
357 | reject({
358 | status: undefined,
359 | raw: undefined,
360 | reason: "NETWORK_TIMEOUT",
361 | });
362 | };
363 | xhr.onload = (_) => {
364 | if (xhr.status < 400) {
365 | resolve(
366 | this.apiResponseToVideoUploadResponse(JSON.parse(xhr.response)),
367 | );
368 | }
369 | };
370 | xhr.send(params.body);
371 | });
372 | }
373 |
374 | private withRetrier(
375 | fn: (abortController: AbortController) => Promise,
376 | ): CancelableOperation {
377 | // generate a unique random id for this upload
378 | const id =
379 | Math.random().toString(36).substring(2, 15) +
380 | Math.random().toString(36).substring(2, 15);
381 | const abortController = new AbortController();
382 | this.abortControllers[id] = abortController;
383 |
384 | const promise = new Promise(
385 | async (resolve, reject) => {
386 | let retriesCount = 0;
387 | while (true) {
388 | try {
389 | const res = await fn(abortController);
390 | resolve(res);
391 | return;
392 | } catch (e: any) {
393 | if (e.reason === "ABORTED") {
394 | reject(e);
395 | return;
396 | }
397 | const retryDelay = this.retryStrategy(retriesCount, e);
398 | if (retryDelay === null) {
399 | reject(e);
400 | return;
401 | }
402 | console.log(
403 | `video upload: ${e.reason || "ERROR"
404 | }, will be retried in ${retryDelay} ms`,
405 | );
406 | await this.sleep(retryDelay);
407 | retriesCount++;
408 | }
409 | }
410 | },
411 | );
412 |
413 | return {
414 | cancel: () => {
415 | this.abortControllers[id].abort();
416 | delete this.abortControllers[id];
417 | },
418 | result: promise,
419 | };
420 | }
421 |
422 | private static validateOrigin(type: string, origin: Origin) {
423 | if (!origin.name) {
424 | throw new Error(`${type} name is required`);
425 | }
426 | if (!origin.version) {
427 | throw new Error(`${type} version is required`);
428 | }
429 | if (!/^[\w-]{1,50}$/.test(origin.name)) {
430 | throw new Error(
431 | `Invalid ${type} name value. Allowed characters: A-Z, a-z, 0-9, '-', '_'. Max length: 50.`,
432 | );
433 | }
434 | if (!/^\d{1,3}(\.\d{1,3}(\.\d{1,3})?)?$/.test(origin.version)) {
435 | throw new Error(
436 | `Invalid ${type} version value. The version should match the xxx[.yyy][.zzz] pattern.`,
437 | );
438 | }
439 | }
440 | }
441 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { VideoUploader } from "./video-uploader";
2 |
3 | export { UploadProgressEvent, VideoUploader, VideoUploaderOptionsWithAccessToken, VideoUploaderOptionsWithUploadToken } from "./video-uploader";
4 | export { ProgressiveUploadProgressEvent, ProgressiveUploader, ProgressiveUploaderOptionsWithAccessToken, ProgressiveUploaderOptionsWithUploadToken } from './progressive-video-uploader';
5 | export { VideoUploadResponse, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE } from './abstract-uploader';
6 | export { UploaderStaticWrapper } from './static-wrapper';
--------------------------------------------------------------------------------
/src/progressive-video-uploader.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AbstractUploader,
3 | CancelableOperation,
4 | CommonOptions,
5 | MIN_CHUNK_SIZE,
6 | VideoUploadResponse,
7 | WithAccessToken,
8 | WithApiKey,
9 | WithUploadToken,
10 | } from "./abstract-uploader";
11 | import { PromiseQueue } from "./promise-queue";
12 |
13 | export interface ProgressiveUploadCommonOptions {
14 | preventEmptyParts?: boolean;
15 | mergeSmallPartsBeforeUpload?: boolean;
16 | }
17 |
18 | export interface ProgressiveUploaderOptionsWithUploadToken
19 | extends ProgressiveUploadCommonOptions,
20 | CommonOptions,
21 | WithUploadToken { }
22 | export interface ProgressiveUploaderOptionsWithAccessToken
23 | extends ProgressiveUploadCommonOptions,
24 | CommonOptions,
25 | WithAccessToken { }
26 | export interface ProgressiveUploaderOptionsWithApiKey
27 | extends ProgressiveUploadCommonOptions,
28 | CommonOptions,
29 | WithApiKey { }
30 |
31 | export interface ProgressiveUploadProgressEvent {
32 | part: number;
33 | uploadedBytes: number;
34 | totalBytes: number;
35 | }
36 |
37 | export class ProgressiveUploader extends AbstractUploader {
38 | private currentPartNum = 1;
39 | private currentPartBlobs: Blob[] = [];
40 | private currentPartBlobsSize = 0;
41 | private queue = new PromiseQueue();
42 | private preventEmptyParts: boolean;
43 | private fileName: string;
44 | private mergeSmallPartsBeforeUpload: boolean;
45 | private currentChunkCancel?: () => void;
46 | private canceled = false;
47 |
48 | constructor(
49 | options:
50 | | ProgressiveUploaderOptionsWithAccessToken
51 | | ProgressiveUploaderOptionsWithUploadToken
52 | | ProgressiveUploaderOptionsWithApiKey,
53 | ) {
54 | super(options);
55 | this.preventEmptyParts = options.preventEmptyParts || false;
56 | this.fileName = options.videoName || "file";
57 | this.mergeSmallPartsBeforeUpload =
58 | options.mergeSmallPartsBeforeUpload ?? true;
59 | }
60 |
61 | public uploadPart(file: Blob): Promise {
62 | if (!this.mergeSmallPartsBeforeUpload && file.size < MIN_CHUNK_SIZE) {
63 | throw new Error(
64 | `Each part must have a minimal size of 5MB. The current part has a size of ${this.currentPartBlobsSize / 1024 / 1024
65 | }MB.`,
66 | );
67 | }
68 | this.currentPartBlobsSize += file.size;
69 | this.currentPartBlobs.push(file);
70 |
71 | if (
72 | (this.preventEmptyParts &&
73 | this.currentPartBlobsSize - file.size >= MIN_CHUNK_SIZE) ||
74 | (!this.preventEmptyParts &&
75 | this.currentPartBlobsSize >= MIN_CHUNK_SIZE) ||
76 | !this.mergeSmallPartsBeforeUpload
77 | ) {
78 | let toSend: any[];
79 | if (this.preventEmptyParts) {
80 | toSend = this.currentPartBlobs.slice(0, -1);
81 | this.currentPartBlobs = this.currentPartBlobs.slice(-1);
82 | this.currentPartBlobsSize =
83 | this.currentPartBlobs.length === 0
84 | ? 0
85 | : this.currentPartBlobs[0].size;
86 | } else {
87 | toSend = this.currentPartBlobs;
88 | this.currentPartBlobs = [];
89 | this.currentPartBlobsSize = 0;
90 | }
91 |
92 | return this.queue.add(() => {
93 | if (toSend.length > 0) {
94 | const cancelableOperation = this.upload(new Blob(toSend));
95 | this.currentChunkCancel = cancelableOperation.cancel;
96 | const promise = cancelableOperation.result.then((res) => {
97 | this.videoId = res.videoId;
98 | return res;
99 | });
100 | this.currentPartNum++;
101 | return promise;
102 | }
103 | return new Promise((resolve) => resolve());
104 | });
105 | }
106 | return Promise.resolve();
107 | }
108 |
109 | public cancel(): void {
110 | this.canceled = true;
111 | if (this.currentChunkCancel) {
112 | this.currentChunkCancel();
113 | }
114 | }
115 |
116 | public async uploadLastPart(file: Blob): Promise {
117 | this.currentPartBlobs.push(file);
118 | const res = await this.queue.add(() => {
119 | const cancelableOperation = this.upload(
120 | new Blob(this.currentPartBlobs),
121 | true,
122 | );
123 | this.currentChunkCancel = cancelableOperation.cancel;
124 | return cancelableOperation.result;
125 | });
126 |
127 | if (this.onPlayableCallbacks.length > 0) {
128 | this.waitForPlayable(res!);
129 | }
130 |
131 | return res;
132 | }
133 |
134 | private upload(
135 | file: Blob,
136 | isLast: boolean = false,
137 | ): CancelableOperation {
138 | const fileSize = file.size;
139 | const currentPartNum = this.currentPartNum;
140 |
141 | if (this.canceled) {
142 | throw new Error("Upload canceled");
143 | }
144 |
145 | return this.xhrWithRetrier({
146 | body: this.createFormData(file, this.fileName),
147 | parts: {
148 | currentPart: currentPartNum,
149 | totalParts: isLast ? currentPartNum : "*",
150 | },
151 | onProgress: (event: ProgressEvent) =>
152 | this.onProgressCallbacks.forEach((cb) =>
153 | cb({
154 | part: currentPartNum,
155 | uploadedBytes: event.loaded,
156 | totalBytes: fileSize,
157 | }),
158 | ),
159 | });
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/src/promise-queue.ts:
--------------------------------------------------------------------------------
1 |
2 | type QueueEntry = {
3 | provider: ()=>Promise,
4 | callback: (result: any, error?: any) => void
5 | };
6 |
7 | export class PromiseQueue {
8 | private queue: QueueEntry[];
9 | private working = false;
10 |
11 | constructor() {
12 | this.queue = [];
13 | }
14 |
15 | public add(provider: () => Promise): Promise {
16 | return new Promise((resolve, reject) => {
17 | const entry = {
18 | provider,
19 | callback: (res: any, error?: any) => error ? reject(error) : resolve(res)
20 | };
21 | this.queue = this.queue.concat(entry);
22 | if(!this.working) {
23 | this.working = true;
24 | this.dequeue();
25 | }
26 | });
27 | }
28 |
29 | private dequeue() {
30 | if(this.queue.length === 0) {
31 | this.working = false;
32 | return;
33 | };
34 |
35 | const current = this.queue.shift() as QueueEntry;
36 |
37 | current.provider.call(this).then((res) => {
38 | current.callback(res);
39 | this.dequeue();
40 | }).catch(err => {
41 | current.callback(undefined, err);
42 | this.dequeue();
43 | });
44 | }
45 | }
--------------------------------------------------------------------------------
/src/static-wrapper.ts:
--------------------------------------------------------------------------------
1 | import { VideoUploadResponse } from "./abstract-uploader";
2 | import {
3 | ProgressiveUploader,
4 | ProgressiveUploaderOptionsWithAccessToken,
5 | ProgressiveUploaderOptionsWithApiKey,
6 | ProgressiveUploaderOptionsWithUploadToken,
7 | } from "./progressive-video-uploader";
8 | import {
9 | VideoUploader,
10 | VideoUploaderOptionsWithAccessToken,
11 | VideoUploaderOptionsWithApiKey,
12 | VideoUploaderOptionsWithUploadToken,
13 | } from "./video-uploader";
14 |
15 | type ProgressiveSession = {
16 | uploader: ProgressiveUploader;
17 | partsOnProgress: Record void>;
18 | currentPart: number;
19 | };
20 |
21 | export class UploaderStaticWrapper {
22 | static application: { name: string; version: string };
23 | static sdk: { name: string; version: string };
24 | static chunkSize: number = 50;
25 | static progressiveUploadSessions: Record = {};
26 | static standardUploaders: VideoUploader[] = [];
27 |
28 | /**
29 | * Sets the application name and version for the SDK.
30 | * @param name - The name of the application using the SDK.
31 | * @param version - The version of the application.
32 | */
33 | static setApplicationName(name: string, version: string): void {
34 | this.application = { name, version };
35 | }
36 |
37 | /**
38 | * Sets the sdk name and version for the SDK.
39 | * @param name - The name of the sdk using the SDK.
40 | * @param version - The version of the sdk.
41 | */
42 | static setSdkName(name: string, version: string): void {
43 | this.sdk = { name, version };
44 | }
45 |
46 | /**
47 | * Sets the chunk size for the video upload.
48 | * @param chunkSize - The size of each chunk in MB.
49 | */
50 | static setChunkSize(chunkSize: number): void {
51 | this.chunkSize = chunkSize;
52 | }
53 |
54 | /**
55 | * Creates a new progressive upload session with an upload token.
56 | * @param sessionId - The unique session identifier.
57 | * @param uploadToken - The upload token provided by the backend.
58 | * @param videoId - The ID of the video to be uploaded.
59 | */
60 | static createProgressiveUploadWithUploadTokenSession(
61 | sessionId: string,
62 | uploadToken: string,
63 | videoId: string
64 | ): void {
65 | return this.progressiveUploadCreationHelper({
66 | sessionId,
67 | uploadToken,
68 | videoId: videoId || undefined,
69 | });
70 | }
71 |
72 | /**
73 | * Creates a new progressive upload session with an API key.
74 | * @param sessionId - The unique session identifier.
75 | * @param apiKey - The API key provided by the backend.
76 | * @param videoId - The ID of the video to be uploaded.
77 | */
78 | static createProgressiveUploadWithApiKeySession(sessionId: string, apiKey: string, videoId: string): void {
79 | return this.progressiveUploadCreationHelper({
80 | sessionId,
81 | apiKey,
82 | videoId: videoId || undefined,
83 | });
84 | }
85 |
86 | /**
87 | * Uploads a part of a video in a progressive upload session.
88 | * @param sessionId - The unique session identifier.
89 | * @param file - The blob of the video part.
90 | * @param onProgress - The callback to call on progress updates.
91 | * @returns A string containing the JSON representation of the VideoUploadResponse object.
92 | */
93 | static async uploadPart(sessionId: string, file: Blob, onProgress: (progress: number) => void): Promise {
94 | return await this.uploadPartHelper(sessionId, file, onProgress, async (session, blob) => {
95 | return await session.uploader.uploadPart(blob);
96 | });
97 | }
98 |
99 | /**
100 | * Uploads the last part of a video in a progressive upload session and finalizes the upload.
101 | * @param sessionId - The unique session identifier.
102 | * @param file - The blob of the video part.
103 | * @param onProgress - The callback to call on progress updates.
104 | * @returns A string containing the JSON representation of the VideoUploadResponse object.
105 | */
106 | static async uploadLastPart(
107 | sessionId: string,
108 | file: Blob,
109 | onProgress: (progress: number) => void
110 | ): Promise {
111 | return await this.uploadPartHelper(sessionId, file, onProgress, async (session, blob) => {
112 | return await session.uploader.uploadLastPart(blob);
113 | });
114 | }
115 |
116 | /**
117 | * Cancels all ongoing uploads, both progressive and standard.
118 | */
119 | static cancelAll(): void {
120 | const sessions = this.progressiveUploadSessions;
121 | for (const session of Object.values(sessions)) {
122 | session.uploader.cancel();
123 | }
124 |
125 | for (const uploader of this.standardUploaders) {
126 | uploader.cancel();
127 | }
128 | this.progressiveUploadSessions = {};
129 | this.standardUploaders = [];
130 | }
131 |
132 | /**
133 | * Disposes a progressive upload session by its ID.
134 | * @param sessionId - The unique session identifier to dispose.
135 | */
136 | static disposeProgressiveUploadSession(sessionId: string): void {
137 | delete this.progressiveUploadSessions[sessionId];
138 | }
139 |
140 | /**
141 | * Uploads a video with an upload token.
142 | * @param file - The video file to be uploaded.
143 | * @param uploadToken - The upload token provided by the backend.
144 | * @param videoName - The name of the video.
145 | * @param onProgress - The callback to call on progress updates.
146 | * @param videoId - The ID of the video to be uploaded (optional).
147 | * @returns A string containing the JSON representation of the VideoUploadResponse object.
148 | */
149 | static async uploadWithUploadToken(
150 | file: Blob,
151 | uploadToken: string,
152 | videoName: string,
153 | onProgress: (event: number) => void,
154 | videoId?: string
155 | ): Promise {
156 | return this.uploadHelper(file, onProgress, {
157 | uploadToken,
158 | videoName,
159 | videoId,
160 | });
161 | }
162 |
163 | /**
164 | * Uploads a video with an API key.
165 | * @param file - The video file to be uploaded.
166 | * @param apiKey - The API key provided by the backend.
167 | * @param onProgress - The callback to call on progress updates.
168 | * @param videoId - The ID of the video to be uploaded.
169 | * @returns A string containing the JSON representation of the VideoUploadResponse object.
170 | */
171 | static async uploadWithApiKey(
172 | file: Blob,
173 | apiKey: string,
174 | onProgress: (event: number) => void,
175 | videoId: string
176 | ): Promise {
177 | return this.uploadHelper(file, onProgress, {
178 | apiKey,
179 | videoId,
180 | });
181 | }
182 |
183 | // Private methods below
184 |
185 | private static getProgressiveSession(sessionId: string): ProgressiveSession {
186 | return this.progressiveUploadSessions[sessionId];
187 | }
188 |
189 | private static storeProgressiveSession(sessionId: string, progressiveSession: ProgressiveSession): void {
190 | this.progressiveUploadSessions[sessionId] = progressiveSession;
191 | }
192 |
193 | private static storeStandardUploader(uploader: VideoUploader): void {
194 | this.standardUploaders.push(uploader);
195 | }
196 |
197 | private static progressiveUploadCreationHelper(options: {
198 | sessionId: string;
199 | uploadToken?: string;
200 | apiKey?: string;
201 | videoId?: string;
202 | }): void {
203 | const uploader = new ProgressiveUploader({
204 | ...(options as
205 | | ProgressiveUploaderOptionsWithAccessToken
206 | | ProgressiveUploaderOptionsWithUploadToken
207 | | ProgressiveUploaderOptionsWithApiKey),
208 | origin: this.getOriginHeader(),
209 | });
210 |
211 | uploader.onProgress((e) => {
212 | const onProgress = this.getProgressiveSession(options.sessionId).partsOnProgress[e.part];
213 | if (onProgress) {
214 | onProgress(e.uploadedBytes / e.totalBytes);
215 | }
216 | });
217 |
218 | this.storeProgressiveSession(options.sessionId, {
219 | uploader,
220 | partsOnProgress: {},
221 | currentPart: 1,
222 | });
223 | }
224 |
225 | private static async uploadPartHelper(
226 | sessionId: string,
227 | file: Blob,
228 | onProgress: (event: number) => void,
229 | uploadCallback: (session: ProgressiveSession, blob: Blob) => Promise
230 | ): Promise {
231 | const session = this.getProgressiveSession(sessionId);
232 |
233 | if (onProgress != null) {
234 | session.partsOnProgress[session.currentPart] = onProgress;
235 | }
236 |
237 | session.currentPart++;
238 |
239 | try {
240 | return JSON.stringify(await uploadCallback(session, file));
241 | } catch (e: any) {
242 | if (e.reason === "ABORTED") {
243 | throw new Error(e.reason);
244 | }
245 | throw new Error(e.title);
246 | }
247 | }
248 |
249 | private static async uploadHelper(
250 | blob: Blob,
251 | onProgress: (event: number) => void,
252 | options: {
253 | uploadToken?: string;
254 | apiKey?: string;
255 | videoName?: string;
256 | videoId?: string;
257 | }
258 | ): Promise {
259 | const uploader = new VideoUploader({
260 | file: blob,
261 | chunkSize: 1024 * 1024 * this.chunkSize,
262 | origin: this.getOriginHeader(),
263 | ...options,
264 | } as VideoUploaderOptionsWithAccessToken | VideoUploaderOptionsWithUploadToken | VideoUploaderOptionsWithApiKey);
265 |
266 | this.storeStandardUploader(uploader);
267 |
268 | if (onProgress != null) {
269 | uploader.onProgress((e) => onProgress(e.uploadedBytes / e.totalBytes));
270 | }
271 | try {
272 | return JSON.stringify(await uploader.upload());
273 | } catch (e: any) {
274 | if (e.reason === "ABORTED") {
275 | throw new Error(e.reason);
276 | }
277 | throw new Error(e.title);
278 | }
279 | }
280 |
281 | private static getOriginHeader() {
282 | return {
283 | ...(this.sdk?.name && this.sdk?.version ? {sdk: this.sdk} : {}),
284 | ...(this.application?.name && this.application?.version ? {application: this.application} : {}),
285 | };
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/src/video-uploader.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AbstractUploader,
3 | CancelableOperation,
4 | CommonOptions,
5 | DEFAULT_CHUNK_SIZE,
6 | MAX_CHUNK_SIZE,
7 | MIN_CHUNK_SIZE,
8 | VideoUploadResponse,
9 | WithAccessToken,
10 | WithApiKey,
11 | WithUploadToken,
12 | } from "./abstract-uploader";
13 |
14 | interface UploadOptions {
15 | file: File;
16 | chunkSize?: number;
17 | maxVideoDuration?: number;
18 | }
19 |
20 | export interface VideoUploaderOptionsWithUploadToken
21 | extends CommonOptions,
22 | UploadOptions,
23 | WithUploadToken { }
24 | export interface VideoUploaderOptionsWithAccessToken
25 | extends CommonOptions,
26 | UploadOptions,
27 | WithAccessToken { }
28 | export interface VideoUploaderOptionsWithApiKey
29 | extends CommonOptions,
30 | UploadOptions,
31 | WithApiKey { }
32 |
33 | export interface UploadProgressEvent {
34 | uploadedBytes: number;
35 | totalBytes: number;
36 | chunksCount: number;
37 | chunksBytes: number;
38 | currentChunk: number;
39 | currentChunkUploadedBytes: number;
40 | }
41 |
42 | export class VideoUploader extends AbstractUploader {
43 | private file: File;
44 | private chunkSize: number;
45 | private chunksCount: number;
46 | private fileSize: number;
47 | private fileName: string;
48 | private maxVideoDuration?: number;
49 | private currentChunkCancel?: () => void;
50 | private canceled = false;
51 |
52 | constructor(
53 | options:
54 | | VideoUploaderOptionsWithAccessToken
55 | | VideoUploaderOptionsWithUploadToken
56 | | VideoUploaderOptionsWithApiKey
57 | ) {
58 | super(options);
59 |
60 | if (!options.file) {
61 | throw new Error("'file' is missing");
62 | }
63 |
64 | if (
65 | options.chunkSize &&
66 | (options.chunkSize < MIN_CHUNK_SIZE || options.chunkSize > MAX_CHUNK_SIZE)
67 | ) {
68 | throw new Error(
69 | `Invalid chunk size. Minimal allowed value: ${MIN_CHUNK_SIZE / 1024 / 1024
70 | }MB, maximum allowed value: ${MAX_CHUNK_SIZE / 1024 / 1024}MB.`
71 | );
72 | }
73 |
74 | this.chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
75 | this.file = options.file;
76 | this.fileSize = this.file.size;
77 | this.fileName = options.videoName || this.file.name;
78 |
79 | this.chunksCount = Math.ceil(this.fileSize / this.chunkSize);
80 | this.maxVideoDuration = options.maxVideoDuration;
81 | }
82 |
83 | public async upload(): Promise {
84 | if (this.maxVideoDuration !== undefined && !document) {
85 | throw Error(
86 | "document is undefined. Impossible to use the maxVideoDuration option. Remove it and try again."
87 | );
88 | }
89 | if (this.maxVideoDuration !== undefined && (await this.isVideoTooLong())) {
90 | throw Error(`The submitted video is too long.`);
91 | }
92 | let res: VideoUploadResponse;
93 | for (let i = 0; i < this.chunksCount && !this.canceled; i++) {
94 | const cancelableOperation = this.uploadCurrentChunk(i);
95 | this.currentChunkCancel = cancelableOperation.cancel;
96 | res = await cancelableOperation.result;
97 | this.videoId = res.videoId;
98 | }
99 |
100 | if (this.onPlayableCallbacks.length > 0) {
101 | this.waitForPlayable(res!);
102 | }
103 |
104 | return res!;
105 | }
106 |
107 | public cancel(): void {
108 | this.canceled = true;
109 | if (this.currentChunkCancel) {
110 | this.currentChunkCancel();
111 | }
112 | }
113 |
114 | private async isVideoTooLong(): Promise {
115 | return new Promise((resolve) => {
116 | const video = document.createElement("video");
117 | video.preload = "metadata";
118 | video.onloadedmetadata = () => {
119 | window.URL.revokeObjectURL(video.src);
120 | resolve(video.duration > this.maxVideoDuration!);
121 | };
122 | video.src = URL.createObjectURL(this.file);
123 | });
124 | }
125 |
126 | private uploadCurrentChunk(
127 | chunkNumber: number
128 | ): CancelableOperation {
129 | const firstByte = chunkNumber * this.chunkSize;
130 | const computedLastByte = (chunkNumber + 1) * this.chunkSize;
131 | const lastByte =
132 | computedLastByte > this.fileSize ? this.fileSize : computedLastByte;
133 | const chunksCount = Math.ceil(this.fileSize / this.chunkSize);
134 |
135 | const progressEventToUploadProgressEvent = (
136 | event: ProgressEvent
137 | ): UploadProgressEvent => {
138 | return {
139 | uploadedBytes: event.loaded + firstByte,
140 | totalBytes: this.fileSize,
141 | chunksCount: this.chunksCount,
142 | chunksBytes: this.chunkSize,
143 | currentChunk: chunkNumber + 1,
144 | currentChunkUploadedBytes: event.loaded,
145 | };
146 | };
147 |
148 | return this.xhrWithRetrier({
149 | onProgress: (event) =>
150 | this.onProgressCallbacks.forEach((cb) =>
151 | cb(progressEventToUploadProgressEvent(event))
152 | ),
153 | body: this.createFormData(this.file, this.fileName, firstByte, lastByte),
154 | parts: {
155 | currentPart: chunkNumber + 1,
156 | totalParts: chunksCount,
157 | },
158 | });
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/test/abstract-uploader.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { AbstractUploader, DEFAULT_RETRY_STRATEGY } from '../src/abstract-uploader';
3 |
4 | describe('Default retrier', () => {
5 | const retrier = DEFAULT_RETRY_STRATEGY(10);
6 |
7 | it('don\'t retry if it should not', () => {
8 | expect(retrier(11, { status: 500, raw: "" })).to.be.equal(null)
9 | expect(retrier(1, { status: 401, raw: "" })).to.be.equal(null)
10 | });
11 |
12 | it('retry if it should', () => {
13 | expect(retrier(1, { status: 500, raw: "" })).to.be.equal(4200);
14 | expect(retrier(8, { status: 502, raw: "" })).to.be.equal(144200);
15 | expect(retrier(8, { raw: "" })).to.be.equal(144200);
16 | });
17 | });
18 |
19 | describe('Origin header validation', () => {
20 | const validateOrigin = (AbstractUploader as any).validateOrigin as (name: string, origin: { name?: string, version?: string }) => void;
21 | it('should properly validate name', () => {
22 | expect(() => validateOrigin("test", { version: "aa" })).to.throw("test name is required");
23 | expect(() => validateOrigin("test", { name:"frf ds", version: "aa" })).to.throw("Invalid test name value. Allowed characters: A-Z, a-z, 0-9, '-', '_'. Max length: 50.");
24 | });
25 | it('should properly validate version', () => {
26 | expect(() => validateOrigin("test", { name: "aa" })).to.throw("test version is required");
27 | expect(() => validateOrigin("test", { name:"name", version: "a b" })).to.throw("Invalid test version value. The version should match the xxx[.yyy][.zzz] pattern.");
28 | });
29 | });
--------------------------------------------------------------------------------
/test/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test",
3 | "version": "1.0.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "version": "1.0.0",
9 | "license": "ISC",
10 | "devDependencies": {
11 | "typescript": "^4.6.3"
12 | }
13 | },
14 | "node_modules/typescript": {
15 | "version": "4.6.3",
16 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
17 | "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
18 | "dev": true,
19 | "bin": {
20 | "tsc": "bin/tsc",
21 | "tsserver": "bin/tsserver"
22 | },
23 | "engines": {
24 | "node": ">=4.2.0"
25 | }
26 | }
27 | },
28 | "dependencies": {
29 | "typescript": {
30 | "version": "4.6.3",
31 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
32 | "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
33 | "dev": true
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "typescript": "^4.6.3"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/test/progressive-video-uploader.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { ProgressiveUploader, ProgressiveUploadProgressEvent } from '../src/index';
3 | import mock from 'xhr-mock';
4 |
5 | describe('Instanciation', () => {
6 | it('throws if required param is missing', () => {
7 | // @ts-ignore
8 | expect(() => new ProgressiveUploader({
9 | })).to.throw("You must provide either an accessToken, an uploadToken or an API key");
10 |
11 | // @ts-ignore
12 | expect(() => new ProgressiveUploader({
13 | accessToken: "aa",
14 | })).to.throw("'videoId' is missing");
15 | });
16 | });
17 |
18 |
19 | describe('Requests synchronization', () => {
20 | beforeEach(() => mock.setup());
21 | afterEach(() => mock.teardown());
22 |
23 | it('requests are made sequentially', (done) => {
24 | const uploadToken = "the-upload-token";
25 | const uploader = new ProgressiveUploader({ uploadToken });
26 |
27 | let isRequesting = false;
28 |
29 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
30 | expect(isRequesting).to.be.equal(false, "concurrent request")
31 | isRequesting = true;
32 | return new Promise((resolve, _) => setTimeout(() => {
33 | isRequesting = false;
34 | resolve(res.status(201).body(`{"videoId": "123"}`));
35 | }, 500));
36 | });
37 |
38 | uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
39 | uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
40 | uploader.uploadLastPart(new File([new ArrayBuffer(3 * 1024 * 1024)], "filename")).then((r) => done());
41 | });
42 | });
43 |
44 | describe('Content-range', () => {
45 | beforeEach(() => mock.setup());
46 | afterEach(() => mock.teardown());
47 |
48 | it('content-range headers are properly set', async () => {
49 | const uploadToken = "the-upload-token";
50 |
51 | const uploader = new ProgressiveUploader({ uploadToken });
52 |
53 | const expectedRanges = [
54 | 'part 1/*',
55 | 'part 2/*',
56 | 'part 3/3',
57 | ];
58 |
59 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
60 | expect(req.header("content-range")).to.be.eq(expectedRanges.shift());
61 | return res.status(201).body("{}");
62 | });
63 |
64 | await uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
65 | await uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
66 | await uploader.uploadLastPart(new File([new ArrayBuffer(3 * 1024 * 1024)], "filename"));
67 |
68 | expect(expectedRanges).has.lengthOf(0);
69 | });
70 | });
71 |
72 | describe('Prevent empty part', () => {
73 | beforeEach(() => mock.setup());
74 | afterEach(() => mock.teardown());
75 |
76 | it('content-range headers are properly set', async () => {
77 | const uploadToken = "the-upload-token";
78 |
79 | const uploader = new ProgressiveUploader({ uploadToken, preventEmptyParts: true });
80 |
81 | const expectedRanges = [
82 | 'part 1/*',
83 | 'part 2/*',
84 | 'part 3/3',
85 | ];
86 |
87 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
88 | expect(req.header("content-range")).to.be.eq(expectedRanges.shift());
89 | return res.status(201).body("{}");
90 | });
91 |
92 | await uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
93 | await uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename"));
94 | await uploader.uploadPart(new File([new ArrayBuffer(3 * 1024 * 1024)], "filename"));
95 | await uploader.uploadLastPart(new Blob());
96 |
97 | expect(expectedRanges).has.lengthOf(0);
98 | });
99 | });
100 |
101 |
102 | describe('Access token auth', () => {
103 | beforeEach(() => mock.setup());
104 | afterEach(() => mock.teardown());
105 |
106 | it('token value is correct', (done) => {
107 | const accessToken = "1234";
108 | const videoId = "9876";
109 |
110 | const uploader = new ProgressiveUploader({
111 | accessToken,
112 | videoId,
113 | });
114 |
115 |
116 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => {
117 | expect(req.header("content-range")).to.be.eq("part 1/1");
118 | expect(req.header("authorization")).to.be.eq(`Bearer ${accessToken}`);
119 | return res.status(201).body("{}");
120 | });
121 |
122 | uploader.uploadLastPart(new File([new ArrayBuffer(200)], "filename")).then(() => {
123 | done();
124 | });
125 |
126 | });
127 | });
128 |
129 |
130 | describe('Progress listener', () => {
131 | beforeEach(() => mock.setup());
132 | afterEach(() => mock.teardown());
133 |
134 | it('progress event values are correct', (done) => {
135 | const videoId = "9876";
136 | let lastUploadProgressEvent: ProgressiveUploadProgressEvent;
137 |
138 | const uploader = new ProgressiveUploader({
139 | accessToken: "1234",
140 | videoId
141 | });
142 |
143 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => res.status(201).body("{}"));
144 |
145 | uploader.onProgress((e: ProgressiveUploadProgressEvent) => lastUploadProgressEvent = e);
146 |
147 | uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename")).then(() => {
148 | expect(lastUploadProgressEvent).to.deep.equal({
149 | ...lastUploadProgressEvent,
150 | totalBytes: 5242880
151 | });
152 | done();
153 | });
154 | });
155 | });
156 |
157 | describe('Errors & retries', () => {
158 | beforeEach(() => mock.setup());
159 | afterEach(() => mock.teardown());
160 |
161 | it('upload retries', (done) => {
162 |
163 | const uploadToken = "the-upload-token";
164 |
165 | const uploader = new ProgressiveUploader({
166 | uploadToken,
167 | retryStrategy: (retryCount, error) => retryCount > 3 ? null : 10,
168 | });
169 |
170 | let postCounts = 0;
171 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
172 | postCounts++;
173 | if (postCounts === 3) {
174 | return res.status(201).body("{}");
175 | }
176 | return res.status(500).body('{"error": "oups"}');
177 | });
178 |
179 | uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename")).then(() => {
180 | expect(postCounts).to.be.eq(3);
181 | done();
182 | });
183 | }).timeout(10000);
184 |
185 | it('failing upload returns the status from the api', (done) => {
186 |
187 | const uploadToken = "the-upload-token";
188 |
189 | const uploader = new ProgressiveUploader({
190 | uploadToken,
191 | retryStrategy: (retryCount, error) => retryCount > 3 ? null : 10,
192 | });
193 |
194 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
195 | return res.status(500).body('{"error": "oups"}');
196 | });
197 |
198 | uploader.uploadPart(new File([new ArrayBuffer(5 * 1024 * 1024)], "filename")).then(() => {
199 | throw new Error('should not succeed');
200 | }).catch((e) => {
201 | expect(e).to.be.eqls({ status: 500, raw: '{"error": "oups"}', error: 'oups' });
202 | done();
203 | });
204 | }).timeout(20000);
205 | });
206 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Enable incremental compilation */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 |
26 | /* Modules */
27 | "module": "commonjs", /* Specify what module code is generated. */
28 | // "rootDir": "./", /* Specify the root folder within your source files. */
29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
36 | "resolveJsonModule": true, /* Enable importing .json files */
37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */
38 |
39 | /* JavaScript Support */
40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
43 |
44 | /* Emit */
45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
50 | // "outDir": "./", /* Specify an output folder for all emitted files. */
51 | // "removeComments": true, /* Disable emitting comments. */
52 | // "noEmit": true, /* Disable emitting files from a compilation. */
53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
61 | // "newLine": "crlf", /* Set the newline character for emitting files. */
62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
68 |
69 | /* Interop Constraints */
70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
75 |
76 | /* Type Checking */
77 | "strict": true, /* Enable all strict type-checking options. */
78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
96 |
97 | /* Completeness */
98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/test/video-uploader.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import mock from 'xhr-mock';
3 | import { VideoUploader, UploadProgressEvent } from '../src/index';
4 |
5 | describe('Instanciation', () => {
6 | it('throws if required param is missing', () => {
7 | // @ts-ignore
8 | expect(() => new VideoUploader({
9 | uploadToken: "aa",
10 | })).to.throw("'file' is missing");
11 |
12 | // @ts-ignore
13 | expect(() => new VideoUploader({
14 | accessToken: "aa",
15 | file: new File([""], "")
16 | })).to.throw("'videoId' is missing");
17 | });
18 |
19 | it('throws if chunk size is invalid', () => {
20 | expect(() => new VideoUploader({
21 | uploadToken: "aa",
22 | file: new File([""], ""),
23 | chunkSize: 1024 * 1024 * 1
24 | })).to.throw("Invalid chunk size. Minimal allowed value: 5MB, maximum allowed value: 128MB.");
25 | });
26 | });
27 |
28 | describe('Content-range', () => {
29 | beforeEach(() => mock.setup());
30 | afterEach(() => mock.teardown());
31 |
32 | it('content-range headers are properly set', (done) => {
33 | const uploadToken = "the-upload-token";
34 |
35 | const uploader = new VideoUploader({
36 | file: new File([new ArrayBuffer(17000000)], "filename"),
37 | uploadToken,
38 | chunkSize: 5 * 1024 * 1024,
39 | });
40 |
41 | const expectedRanges = [
42 | 'part 1/4',
43 | 'part 2/4',
44 | 'part 3/4',
45 | 'part 4/4',
46 | ];
47 |
48 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
49 | expect(req.header("content-range")).to.be.eq(expectedRanges.shift());
50 | return res.status(201).body("{}");
51 | });
52 |
53 | uploader.upload().then(() => {
54 | expect(expectedRanges).has.lengthOf(0);
55 | done();
56 | });
57 | });
58 | });
59 |
60 | describe('Access token auth', () => {
61 | beforeEach(() => mock.setup());
62 | afterEach(() => mock.teardown());
63 |
64 | it('token value is correct', (done) => {
65 | const accessToken = "1234";
66 | const videoId = "9876";
67 |
68 | const uploader = new VideoUploader({
69 | file: new File([new ArrayBuffer(200)], "filename"),
70 | accessToken,
71 | videoId,
72 | });
73 |
74 |
75 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => {
76 | expect(req.header("content-range")).to.be.eq("part 1/1");
77 | expect(req.header("authorization")).to.be.eq(`Bearer ${accessToken}`);
78 | return res.status(201).body("{}");
79 | });
80 |
81 | uploader.upload().then(() => {
82 | done();
83 | });
84 |
85 | });
86 | });
87 |
88 |
89 | describe('Origin headers', () => {
90 | beforeEach(() => mock.setup());
91 | afterEach(() => mock.teardown());
92 |
93 | it('token value is correct', (done) => {
94 | const accessToken = "1234";
95 | const videoId = "9876";
96 |
97 | const uploader = new VideoUploader({
98 | file: new File([new ArrayBuffer(200)], "filename"),
99 | accessToken,
100 | videoId,
101 | origin: {
102 | application: {
103 | name: "application-name",
104 | version: "1.0.0"
105 | },
106 | sdk: {
107 | name: "sdk-name",
108 | version: "2.0.0"
109 | }
110 | }
111 | });
112 |
113 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => {
114 | expect(req.header("av-origin-app")).to.be.eq("application-name:1.0.0");
115 | expect(req.header("av-origin-sdk")).to.be.eq(`sdk-name:2.0.0`);
116 | return res.status(201).body("{}");
117 | });
118 |
119 | uploader.upload().then(() => {
120 | done();
121 | });
122 |
123 | });
124 | });
125 |
126 |
127 |
128 | describe('Refresh token', () => {
129 | beforeEach(() => mock.setup());
130 | afterEach(() => mock.teardown());
131 |
132 | it('refresh token value is correct', (done) => {
133 | const accessToken1 = "1234";
134 | const accessToken2 = "5678";
135 | const refreshToken1 = "9876";
136 | const refreshToken2 = "5432";
137 | const videoId = "9876";
138 |
139 | const uploader = new VideoUploader({
140 | file: new File([new ArrayBuffer(200)], "filename"),
141 | accessToken: accessToken1,
142 | refreshToken: refreshToken1,
143 | videoId,
144 | });
145 |
146 | let sourceCalls = 0;
147 |
148 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => {
149 | sourceCalls++;
150 | expect(req.header("content-range")).to.be.eq("part 1/1");
151 |
152 | if (sourceCalls === 1) {
153 | expect(req.header("authorization")).to.be.eq(`Bearer ${accessToken1}`);
154 | return res.status(401).body("{}");
155 | }
156 |
157 | expect(req.header("authorization")).to.be.eq(`Bearer ${accessToken2}`);
158 | return res.status(201).body("{}");
159 | });
160 |
161 | mock.post(`https://ws.api.video/auth/refresh`, (req, res) => {
162 |
163 | expect(JSON.parse(req.body()).refreshToken).to.be.eq(refreshToken1);
164 | return res.status(201).body(JSON.stringify({
165 | access_token: accessToken2,
166 | refresh_token: refreshToken2,
167 | }));
168 | });
169 |
170 |
171 | uploader.upload().then(() => {
172 | done();
173 | });
174 |
175 | });
176 | });
177 |
178 |
179 | describe('Delegated upload', () => {
180 | beforeEach(() => mock.setup());
181 | afterEach(() => mock.teardown());
182 |
183 | it('videoId is transmitted', (done) => {
184 | const videoId = "9876";
185 | const uploadToken = "1234";
186 |
187 | const uploader = new VideoUploader({
188 | file: new File([new ArrayBuffer(2000)], "filename"),
189 | uploadToken,
190 | videoId,
191 | });
192 |
193 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
194 | expect(req.body().getAll("videoId")).to.be.eql([videoId]);
195 | return res.status(201).body('{}');
196 | });
197 |
198 | uploader.upload().then(() => done());
199 | });
200 |
201 | it('video name is file name', (done) => {
202 | const uploadToken = "the-upload-token";
203 | const videoId = "9876";
204 | const fileName = "filename"
205 |
206 | const uploader = new VideoUploader({
207 | file: new File([new ArrayBuffer(10)], fileName),
208 | uploadToken,
209 | videoId
210 | });
211 |
212 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
213 | expect(req.body().get("file").name).to.be.eql(fileName);
214 | return res.status(201).body("{}");
215 | });
216 |
217 | uploader.upload().then(() => done());
218 | })
219 |
220 | it('video name is customized', (done) => {
221 | const uploadToken = "the-upload-token";
222 | const videoId = "9876";
223 | const fileName = "filename"
224 | const videoName = "video name"
225 |
226 | const uploader = new VideoUploader({
227 | file: new File([new ArrayBuffer(10)], fileName),
228 | uploadToken,
229 | videoId,
230 | videoName
231 | });
232 |
233 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
234 | expect(req.body().get("file").name).to.be.eql(videoName);
235 | return res.status(201).body("{}");
236 | });
237 |
238 | uploader.upload().then(() => done());
239 | })
240 | });
241 |
242 | describe('Progress listener', () => {
243 | beforeEach(() => mock.setup());
244 | afterEach(() => mock.teardown());
245 |
246 | it('progress event values are correct', (done) => {
247 | const videoId = "9876";
248 | let lastUploadProgressEvent: UploadProgressEvent;
249 |
250 | const uploader = new VideoUploader({
251 | file: new File([new ArrayBuffer(6000000)], "filename"),
252 | accessToken: "1234",
253 | videoId,
254 | chunkSize: 5 * 1024 * 1024
255 | });
256 |
257 | mock.post(`https://ws.api.video/videos/${videoId}/source`, (req, res) => res.status(201).body("{}"));
258 |
259 | uploader.onProgress((e: UploadProgressEvent) => lastUploadProgressEvent = e);
260 |
261 | uploader.upload().then(() => {
262 | expect(lastUploadProgressEvent).to.deep.equal({
263 | ...lastUploadProgressEvent,
264 | totalBytes: 6000000,
265 | chunksCount: 2,
266 | chunksBytes: 5 * 1024 * 1024,
267 | currentChunk: 2,
268 | });
269 | done();
270 | });
271 | });
272 | });
273 |
274 | describe('Errors & retries', () => {
275 | beforeEach(() => mock.setup());
276 | afterEach(() => mock.teardown());
277 |
278 | it('upload retries', (done) => {
279 |
280 | const uploadToken = "the-upload-token";
281 |
282 | const uploader = new VideoUploader({
283 | file: new File([new ArrayBuffer(200)], "filename"),
284 | uploadToken,
285 | retryStrategy: (retryCount, error) => retryCount > 3 ? null : 10,
286 | });
287 |
288 | let postCounts = 0;
289 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
290 | postCounts++;
291 | if (postCounts === 3) {
292 | return res.status(201).body("{}");
293 | }
294 | return res.status(500).body('{"error": "oups"}');
295 | });
296 |
297 | uploader.upload().then(() => {
298 | expect(postCounts).to.be.eq(3);
299 | done();
300 | });
301 | }).timeout(10000);
302 |
303 | it('failing upload returns the status from the api', (done) => {
304 |
305 | const uploadToken = "the-upload-token";
306 |
307 | const uploader = new VideoUploader({
308 | file: new File([new ArrayBuffer(6000000)], "filename"),
309 | uploadToken,
310 | chunkSize: 5 * 1024 * 1024,
311 | retryStrategy: (retryCount, error) => retryCount > 3 ? null : 10,
312 | });
313 |
314 | let postCounts = 0;
315 | mock.post(`https://ws.api.video/upload?token=${uploadToken}`, (req, res) => {
316 | postCounts++;
317 | if (postCounts > 1) {
318 | return res.status(500).body('{"error": "oups"}');
319 | }
320 | return res.status(201).body("{}");
321 | });
322 |
323 | uploader.upload().then(() => {
324 | throw new Error('should not succeed');
325 | }).catch((e) => {
326 | expect(e).to.be.eqls({ status: 500, raw: '{"error": "oups"}', error: 'oups' });
327 | done();
328 | });
329 | }).timeout(10000);
330 | });
331 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES3",
4 | "lib": ["ES2015", "dom"],
5 | "outDir": "./dist",
6 | "strict": true,
7 | "skipLibCheck": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "declaration": true,
10 | "rootDirs": ["./src", "./test"]
11 | }
12 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended"
5 | ],
6 | "jsRules": {},
7 | "rules": {
8 | "no-console": [true, "warning"]
9 | },
10 | "rulesDirectory": []
11 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 |
3 | module.exports = {
4 | entry: {
5 | uploader: ['core-js/stable/promise', './src/index.ts']
6 | },
7 | module: {
8 | rules: [
9 | {
10 | test: /\.tsx?$/,
11 | use: 'ts-loader',
12 | exclude: /node_modules/,
13 | },
14 | ],
15 | },
16 | resolve: {
17 | extensions: ['.ts', '.js'],
18 | },
19 | output: {
20 | libraryTarget: 'umd',
21 | filename: 'index.js',
22 | globalObject: 'this'
23 | },
24 | plugins: [
25 | new webpack.DefinePlugin({
26 | __PACKAGE_VERSION__: JSON.stringify(require('./package.json').version),
27 | })
28 | ]
29 | };
30 |
--------------------------------------------------------------------------------