├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.yaml ├── .github ├── assets │ ├── bytescale-javascript-sdk-2.png │ ├── bytescale-javascript-sdk.png │ └── bytescale-javascript-sdk.svg └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── LICENSE ├── MIGRATE.md ├── README.md ├── RELEASING.md ├── babel.config.js ├── build └── RemoveWebpackArtifacts.sh ├── jest.config.js ├── jest.config.pretest.js ├── openapitools.json ├── package-lock.json ├── package.json ├── src ├── index.auth-sw.js ├── index.browser.ts ├── index.node.ts ├── index.worker.ts ├── private │ ├── AuthSessionState.ts │ ├── ConsoleUtils.ts │ ├── EnvChecker.ts │ ├── FairMutex.ts │ ├── FilePathUtils.ts │ ├── NodeChunkedStream.ts │ ├── Scheduler.ts │ ├── ServiceWorkerUtils.ts │ ├── StreamUtils.ts │ ├── TypeUtils.ts │ ├── UploadManagerBase.ts │ ├── UploadManagerBrowserWorkerBase.ts │ ├── UploadManagerFetchUtils.ts │ ├── dtos │ │ ├── AuthSwConfigDto.ts │ │ ├── AuthSwConfigEntryDto.ts │ │ ├── AuthSwHeaderDto.ts │ │ ├── AuthSwSetConfigDto.ts │ │ ├── SetAccessTokenRequestDto.ts │ │ └── SetAccessTokenResponseDto.ts │ └── model │ │ ├── AddCancellationHandler.ts │ │ ├── AuthManagerInterface.ts │ │ ├── AuthSession.ts │ │ ├── OnPartProgress.ts │ │ ├── PreUploadInfo.ts │ │ ├── PutUploadPartResult.ts │ │ ├── ServiceWorkerConfig.ts │ │ ├── ServiceWorkerInitStatus.ts │ │ ├── UploadManagerInterface.ts │ │ └── UploadSourceProcessed.ts └── public │ ├── browser │ ├── AuthManagerBrowser.ts │ ├── UploadManagerBrowser.ts │ └── index.ts │ ├── node │ ├── AuthManagerNode.ts │ ├── ProgressStream.ts │ ├── UploadManagerNode.ts │ └── index.ts │ ├── shared │ ├── CommonTypes.ts │ ├── UrlBuilder.ts │ ├── UrlBuilderTypes.ts │ ├── generated │ │ ├── .openapi-generator-ignore │ │ ├── .openapi-generator │ │ │ ├── FILES │ │ │ └── VERSION │ │ ├── apis │ │ │ ├── CacheApi.ts │ │ │ ├── FileApi.ts │ │ │ ├── FolderApi.ts │ │ │ ├── JobApi.ts │ │ │ ├── UploadApi.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── models │ │ │ └── index.ts │ │ └── runtime.ts │ └── index.ts │ └── worker │ ├── README.md │ ├── UploadManagerWorker.ts │ └── index.ts ├── tests ├── ChunkedStream.test.ts ├── UploadManager.test.ts ├── UrlBuilder.test.ts └── utils │ ├── RandomStream.ts │ ├── StreamToBuffer.ts │ └── TempUtils.ts ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.base.js ├── webpack.config.cdn.js ├── webpack.config.externals.js └── webpack.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > .01% 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | # ESLint at project-level as linting settings may differ between browser-based and node-based projects. 2 | # Prettier at repository-level as formatting settings will be the same across all projects. 3 | # IMPORTANT: Sync with all other '.eslintrc.yaml' files! 4 | root: true 5 | parser: "@typescript-eslint/parser" 6 | parserOptions: 7 | project: "./tsconfig.json" 8 | plugins: 9 | - "@typescript-eslint" 10 | - "return-types-object-literals" 11 | overrides: 12 | - files: 13 | - "*.js" 14 | rules: 15 | "@typescript-eslint/explicit-function-return-type": off 16 | "return-types-object-literals/require-return-types-for-object-literals": off 17 | rules: 18 | "return-types-object-literals/require-return-types-for-object-literals": error 19 | "no-else-return": error 20 | "object-shorthand": error 21 | "@typescript-eslint/no-var-requires": error 22 | "@typescript-eslint/no-redeclare": off 23 | "@typescript-eslint/no-extraneous-class": off 24 | "@typescript-eslint/no-namespace": off 25 | "@typescript-eslint/member-ordering": 26 | - error 27 | - classes: 28 | order: as-written 29 | memberTypes: 30 | # Index signature 31 | - "signature" 32 | 33 | # Fields 34 | - "static-field" 35 | - "decorated-field" 36 | - "instance-field" 37 | - "abstract-field" 38 | 39 | - "field" 40 | 41 | # Constructors 42 | - "public-constructor" 43 | - "protected-constructor" 44 | - "private-constructor" 45 | 46 | - "constructor" 47 | 48 | # Methods 49 | - "public-static-method" 50 | - "protected-static-method" 51 | 52 | - "public-decorated-method" 53 | - "protected-decorated-method" 54 | 55 | - "public-instance-method" 56 | - "protected-instance-method" 57 | 58 | - "public-abstract-method" 59 | - "protected-abstract-method" 60 | 61 | - "public-method" 62 | - "protected-method" 63 | 64 | - "private-static-method" 65 | - "private-decorated-method" 66 | - "private-instance-method" 67 | - "private-abstract-method" 68 | - "private-method" 69 | 70 | - "static-method" 71 | - "instance-method" 72 | - "abstract-method" 73 | 74 | - "decorated-method" 75 | 76 | - "method" 77 | default: 78 | order: alphabetically 79 | memberTypes: 80 | # Index signature 81 | - "signature" 82 | 83 | # Fields 84 | - "static-field" 85 | - "decorated-field" 86 | - "instance-field" 87 | - "abstract-field" 88 | 89 | - "field" 90 | 91 | # Constructors 92 | - "public-constructor" 93 | - "protected-constructor" 94 | - "private-constructor" 95 | 96 | - "constructor" 97 | 98 | # Methods 99 | - "public-static-method" 100 | - "protected-static-method" 101 | 102 | - "public-decorated-method" 103 | - "protected-decorated-method" 104 | 105 | - "public-instance-method" 106 | - "protected-instance-method" 107 | 108 | - "public-abstract-method" 109 | - "protected-abstract-method" 110 | 111 | - "public-method" 112 | - "protected-method" 113 | 114 | - "private-static-method" 115 | - "private-decorated-method" 116 | - "private-instance-method" 117 | - "private-abstract-method" 118 | - "private-method" 119 | 120 | - "static-method" 121 | - "instance-method" 122 | - "abstract-method" 123 | 124 | - "decorated-method" 125 | 126 | - "method" 127 | extends: 128 | - standard-with-typescript 129 | - prettier 130 | - prettier/@typescript-eslint 131 | env: 132 | node: true 133 | -------------------------------------------------------------------------------- /.github/assets/bytescale-javascript-sdk-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytescale/bytescale-javascript-sdk/a34c1f0a9052159172ea7a5c3202e68077ed4ddd/.github/assets/bytescale-javascript-sdk-2.png -------------------------------------------------------------------------------- /.github/assets/bytescale-javascript-sdk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytescale/bytescale-javascript-sdk/a34c1f0a9052159172ea7a5c3202e68077ed4ddd/.github/assets/bytescale-javascript-sdk.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["*"] 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | ci: 15 | name: "Continuous Integration" 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: "Checkout code" 19 | uses: actions/checkout@v3 20 | - name: "Install node & npm" 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 16 24 | - name: "Install dependencies" 25 | run: npm ci 26 | - name: "Type checks" 27 | run: npm run typecheck 28 | - name: "Linting" 29 | run: npm run lint 30 | - name: "Tests" 31 | run: npm test 32 | env: 33 | BYTESCALE_ACCOUNT_ID: ${{ secrets.BYTESCALE_ACCOUNT_ID }} 34 | BYTESCALE_SECRET_API_KEY: ${{ secrets.BYTESCALE_SECRET_API_KEY }} 35 | - name: "Publish" 36 | if: github.ref == 'refs/heads/main' 37 | env: 38 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 39 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 40 | AWS_EC2_METADATA_DISABLED: true 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Automatically generated by GitHub / is not in our secrets. 42 | NPM_AUTH_TOKEN: ${{ secrets.BYTESCALE_NPM_AUTH_TOKEN }} 43 | run: | 44 | npm set //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} 45 | npm run publish:executeIfReleaseCommit 46 | - name: "Notification on success" 47 | if: github.ref == 'refs/heads/main' 48 | uses: rtCamp/action-slack-notify@v2 49 | env: 50 | SLACK_CHANNEL: deployments 51 | SLACK_COLOR: "#17BB5E" 52 | SLACK_TITLE: "Built: @bytescale/sdk :rocket:" 53 | SLACK_FOOTER: "This package was successfully built." 54 | MSG_MINIMAL: true 55 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 56 | - name: "Notification on failure" 57 | if: github.ref == 'refs/heads/main' && failure() 58 | uses: rtCamp/action-slack-notify@v2 59 | env: 60 | SLACK_CHANNEL: deployments 61 | SLACK_COLOR: "#BB1717" 62 | SLACK_TITLE: "Failed: @bytescale/sdk :boom:" 63 | SLACK_FOOTER: "No packages published." 64 | MSG_MINIMAL: true 65 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | Icon 6 | ._* 7 | .Spotlight-V100 8 | .Trashes 9 | 10 | # Windows 11 | Thumbs.db 12 | ehthumbs.db 13 | Desktop.ini 14 | $RECYCLE.BIN/ 15 | *.cab 16 | *.msi 17 | *.msm 18 | *.msp 19 | 20 | # IntelliJ 21 | .idea/ 22 | *.iml 23 | 24 | # Node.js 25 | node_modules/ 26 | peer_modules/ 27 | npm-debug.log* 28 | 29 | # NPM Packages 30 | *.tgz 31 | dist/ 32 | 33 | # Serverless directories 34 | .serverless 35 | 36 | # Webpack directories 37 | .webpack 38 | 39 | # Standard 40 | tmp/ 41 | .tmp/ 42 | *.log 43 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "jsxBracketSameLine": true, 5 | "jsxSingleQuote": false, 6 | "printWidth": 120, 7 | "proseWrap": "preserve", 8 | "quoteProps": "consistent", 9 | "semi": true, 10 | "singleQuote": false, 11 | "tabWidth": 2, 12 | "trailingComma": "none", 13 | "useTabs": false 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Upload Ltd 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 | -------------------------------------------------------------------------------- /MIGRATE.md: -------------------------------------------------------------------------------- 1 | # Migration Guide 2 | 3 | ## From Upload.js (`upload-js`) 4 | 5 | Steps: 6 | 7 | 1. Install `@bytescale/sdk` 8 | 2. Uninstall `upload-js` 9 | 3. Replace `"upload-js"` with `"@bytescale/sdk"` in your `import` statements 10 | 4. Replace `Upload({ apiKey })` with `new UploadManager({ apiKey })` 11 | 5. Replace `.uploadFile(file, options)` with `.upload({ data: file, ...options })` 12 | 13 | ### Before 14 | 15 | ```javascript 16 | import { Upload } from "upload-js"; 17 | 18 | const upload = Upload({ apiKey: "free" }); 19 | 20 | // 21 | // Uploading files... 22 | // 23 | const { fileUrl } = await upload.uploadFile(file, { 24 | onBegin: ({ cancel }) => { 25 | /* Optional. To cancel, you would call 'cancel()' */ 26 | }, 27 | ...additionalParams 28 | }); 29 | 30 | // 31 | // Making URLs.... 32 | // 33 | upload.url("/my-uploaded-image.jpg", "thumbnail"); 34 | 35 | // 36 | // JWT authorization... 37 | // 38 | await upload.beginAuthSession("https://my-auth-url", async () => ({ Authorization: "Bearer AuthTokenForMyApi" })); 39 | ``` 40 | 41 | ### After 42 | 43 | ```javascript 44 | import { AuthManager, UrlBuilder, UploadManager } from "@bytescale/sdk"; 45 | 46 | // 47 | // Uploading files... 48 | // 49 | const uploadManager = new UploadManager({ 50 | fetchApi: nodeFetch, // import nodeFetch from "node-fetch"; // Only required for Node.js. TypeScript: 'nodeFetch as any' may be necessary. 51 | apiKey: "free" // Get API keys from: www.bytescale.com 52 | }); 53 | const cancellationToken = { 54 | isCancelled: false // Set to 'true' at any point to cancel the upload. 55 | }; 56 | const { fileUrl } = await uploadManager.upload({ 57 | data: file, 58 | cancellationToken, // optional 59 | ...additionalParams 60 | }); 61 | 62 | // 63 | // Making URLs... 64 | // 65 | UrlBuilder.url({ 66 | accountId, 67 | filePath: "/my-uploaded-image.jpg", 68 | options: { 69 | transformation: "preset", 70 | transformationPreset: "thumbnail" 71 | } 72 | }); 73 | 74 | // 75 | // JWT authorization... 76 | // 77 | await AuthManager.beginAuthSession({ 78 | accountId, 79 | authUrl: "https://my-auth-url", 80 | authHeaders: async () => ({ Authorization: "Bearer AuthTokenForMyApi" }) 81 | }); 82 | ``` 83 | 84 | ## From Upload JavaScript SDK (`upload-js-full`) 85 | 86 | Steps: 87 | 88 | 1. Install `@bytescale/sdk` 89 | 2. Uninstall `upload-js-full` 90 | 3. Replace `"upload-js-full"` with `"@bytescale/sdk"` in your `import` statements. 91 | 4. Replace `new Configuration({ apiKey: 'YOUR_API_KEY' })` with `{ apiKey: 'YOUR_API_KEY' }` 92 | 5. Remove `accountId` from `uploadManager.upload({...options...})` (it's no-longer required). 93 | 94 | ## See also 95 | 96 | Bytescale migration guides listed below: 97 | 98 | - [Migrating from `upload-js` to `@bytescale/sdk`](https://github.com/bytescale/bytescale-javascript-sdk/blob/main/MIGRATE.md) 99 | - [Migrating from `uploader` to `@bytescale/upload-widget`](https://github.com/bytescale/bytescale-upload-widget/blob/main/MIGRATE.md) 100 | - [Migrating from `react-uploader` to `@bytescale/upload-widget-react`](https://github.com/bytescale/bytescale-upload-widget-react/blob/main/MIGRATE.md) 101 | - [Migrating from `angular-uploader` to `@bytescale/upload-widget-angular`](https://github.com/bytescale/bytescale-upload-widget-angular/blob/main/MIGRATE.md) 102 | - [Migrating from `@upload-io/vue-uploader` to `@bytescale/upload-widget-vue`](https://github.com/bytescale/bytescale-upload-widget-vue/blob/main/MIGRATE.md) 103 | - [Migrating from `@upload-io/jquery-uploader` to `@bytescale/upload-widget-jquery`](https://github.com/bytescale/bytescale-upload-widget-jquery/blob/main/MIGRATE.md) 104 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | To release a new version: 4 | 5 | 1. `gco main` 6 | 7 | 2. Set `x.y.z` into `package.json` 8 | 9 | 3. `gcmsg 'Release x.y.z'` 10 | 11 | 4. `gp` 12 | 13 | The CI process will automatically `git tag` and `npm publish`. 14 | 15 | (It does this by matching on `^Release (\S+)` commit messages on the `main` branch.) 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["babel-plugin-transform-async-to-promises"], 3 | presets: ["@babel/preset-env"] 4 | }; 5 | -------------------------------------------------------------------------------- /build/RemoveWebpackArtifacts.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | cd "$(dirname "$0")" 5 | cd .. 6 | 7 | replaceInFiles() { 8 | if [[ "$OSTYPE" == "darwin"* ]]; then 9 | find "$1" -type f -iname "$2" -exec sed -i '' -e "$3" {} \; 10 | else 11 | find "$1" -type f -iname "$2" -exec sed -i -e "$3" {} \; 12 | fi 13 | } 14 | 15 | # Next.js has a bug which causes it to break with Webpack-compiled libraries: 16 | # https://github.com/vercel/next.js/issues/52542 17 | # The following is a (bad) workaround that fixes the issue by find/replacing the webpack-specific variable names that clash with Next.js's build system. 18 | replaceInFiles dist/ "*js" "s|__webpack_|__lib_|g" 19 | 20 | # We only want to rename 'module' to 'lib' for ESM modules. 21 | replaceInFiles dist/ "*.mjs" "s|module|lib|g" 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | setupFilesAfterEnv: ["/jest.config.pretest.js"], 4 | // [...] 5 | preset: "ts-jest/presets/default-esm", // or other ESM presets 6 | moduleNameMapper: { 7 | "^(\\.{1,2}/.*)\\.js$": "$1" 8 | }, 9 | transform: {} 10 | }; 11 | -------------------------------------------------------------------------------- /jest.config.pretest.js: -------------------------------------------------------------------------------- 1 | global.console = { 2 | log: console.log, // console.log are ignored in tests 3 | 4 | // Keep native behaviour for other methods, use those to print out things in your own tests, not `console.log` 5 | error: console.error, 6 | warn: console.warn, 7 | info: console.info, 8 | debug: console.debug 9 | }; 10 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "7.0.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bytescale/sdk", 3 | "version": "3.52.0", 4 | "description": "Bytescale JavaScript SDK", 5 | "author": "Bytescale (https://www.bytescale.com)", 6 | "license": "MIT", 7 | "types": "dist/types/index.d.ts", 8 | "main": "dist/node/cjs/main.js", 9 | "browser": "dist/browser/cjs/main.js", 10 | "exports": { 11 | ".": { 12 | "node": { 13 | "require": { 14 | "types": "./dist/types/index.d.ts", 15 | "default": "./dist/node/cjs/main.js" 16 | }, 17 | "import": { 18 | "types": "./dist/types/index.d.ts", 19 | "default": "./dist/node/esm/main.mjs" 20 | } 21 | }, 22 | "worker": { 23 | "require": { 24 | "types": "./dist/types/index.d.ts", 25 | "default": "./dist/worker/cjs/main.js" 26 | }, 27 | "import": { 28 | "types": "./dist/types/index.d.ts", 29 | "default": "./dist/worker/esm/main.mjs" 30 | } 31 | }, 32 | "default": { 33 | "require": { 34 | "types": "./dist/types/index.d.ts", 35 | "default": "./dist/browser/cjs/main.js" 36 | }, 37 | "import": { 38 | "types": "./dist/types/index.d.ts", 39 | "default": "./dist/browser/esm/main.mjs" 40 | } 41 | } 42 | } 43 | }, 44 | "homepage": "https://www.bytescale.com/docs/sdks/javascript", 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/bytescale/bytescale-javascript-sdk.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/bytescale/bytescale-javascript-sdk/issues" 51 | }, 52 | "files": [ 53 | "/dist/**/*", 54 | "/tests/**/*" 55 | ], 56 | "scripts": { 57 | "clean": "rm -rf dist && rm -f bytescale-sdk.tgz", 58 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 59 | "typecheck": "tsc --noEmit", 60 | "test": "NODE_OPTIONS=--experimental-vm-modules npx jest --runInBand --silent=false --verbose=false", 61 | "prepack": "npm run clean && webpack && tsc-alias && rm dist/types/index.node.d.ts && rm dist/types/index.worker.d.ts && mv dist/types/index.browser.d.ts dist/types/index.d.ts", 62 | "postpack": "[ ! -f bytescale-sdk-*.tgz ] || mv bytescale-sdk-*.tgz bytescale-sdk.tgz", 63 | "prepack:cdn": "npm run clean && webpack --config webpack.config.cdn.js && find dist -name \"*.ts\" -type f -delete && for f in dist/*.js; do cp -- \"$f\" \"${f%.js}\"; done", 64 | "publish:executeIfReleaseCommit": "bash -c 'COMMIT=$(git log -1 --pretty=%B) && [ \"${COMMIT:0:8}\" != \"Release \" ] || npm run publish:execute'", 65 | "publish:execute": "npm run publish:cdn && npm publish && npm run publish:createGitHubRelease", 66 | "publish:createGitHubRelease": "gh release create v$(node -p \"require('./package.json').version\")", 67 | "publish:cdn": "npm run publish:cdn:sdk && npm run publish:cdn:authSw", 68 | "publish:cdn:sdk": "npm run prepack:cdn && aws s3 cp --recursive --content-type text/javascript dist/ s3://upload-js-releases/sdk/ && aws cloudfront create-invalidation --distribution-id E250290WAJ43YY --paths '/sdk/*'", 69 | "publish:cdn:authSw": "aws s3 cp --content-type text/javascript src/index.auth-sw.js s3://upload-js-releases/auth-sw/v1 && aws cloudfront create-invalidation --distribution-id E250290WAJ43YY --paths '/auth-sw/*'" 70 | }, 71 | "husky": { 72 | "hooks": { 73 | "pre-commit": "lint-staged" 74 | } 75 | }, 76 | "lint-staged": { 77 | "*/**/*.{ts,tsx}": [ 78 | "bash -c \"tsc --noEmit\"" 79 | ], 80 | "*/**/*.{js,jsx,ts,tsx}": [ 81 | "eslint" 82 | ], 83 | "*.{js,jsx,ts,tsx,json,css,html,md,yaml,yml}": [ 84 | "prettier -w" 85 | ] 86 | }, 87 | "devDependencies": { 88 | "@babel/cli": "7.24.1", 89 | "@babel/core": "7.24.1", 90 | "@babel/preset-env": "7.24.1", 91 | "@types/jest": "29.2.4", 92 | "@typescript-eslint/eslint-plugin": "4.33.0", 93 | "@typescript-eslint/parser": "4.33.0", 94 | "babel-loader": "8.3.0", 95 | "babel-plugin-transform-async-to-promises": "0.8.18", 96 | "eslint": "7.32.0", 97 | "eslint-config-prettier": "6.15.0", 98 | "eslint-config-standard-with-typescript": "19.0.1", 99 | "eslint-plugin-import": "2.22.1", 100 | "eslint-plugin-node": "11.1.0", 101 | "eslint-plugin-promise": "4.2.1", 102 | "eslint-plugin-return-types-object-literals": "1.0.1", 103 | "eslint-plugin-standard": "4.1.0", 104 | "husky": "4.3.8", 105 | "jest": "29.3.1", 106 | "lint-staged": "10.5.1", 107 | "node-fetch": "3.3.0", 108 | "prettier": "2.8.8", 109 | "ts-jest": "29.0.3", 110 | "ts-loader": "9.5.1", 111 | "tsc-alias": "1.2.10", 112 | "typescript": "4.9.5", 113 | "webpack": "5.94.0", 114 | "webpack-cli": "4.10.0", 115 | "webpack-node-externals": "2.5.2", 116 | "webpack-shell-plugin-next": "2.3.1" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/index.auth-sw.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | /** 3 | * Bytescale Auth Service Worker (SW) 4 | * 5 | * This script should be referenced by the "serviceWorkerScript" field in the "AuthManager.beginAuthSession" method of 6 | * the Bytescale JavaScript SDK to append "Authorization" headers to HTTP requests sent to the Bytescale CDN. This 7 | * approach serves as an alternative to cookie-based authentication, which is incompatible with certain modern browsers. 8 | * 9 | * Documentation: 10 | * - https://www.bytescale.com/docs/types/BeginAuthSessionParams#serviceWorkerScript 11 | */ 12 | let transientCache; // [{urlPrefix, headers, expires?}] (See: AuthSwConfigDto) 13 | const persistentCacheName = "bytescale-sw-config"; 14 | const persistentCacheKey = "config"; 15 | 16 | console.log(`[bytescale] Auth SW Registered`); 17 | 18 | self.addEventListener("install", function (event) { 19 | // Typically service workers go: 'installing' -> 'waiting' -> 'activated'. 20 | // However, we skip the 'waiting' phase as we want this service worker to be used immediately after it's installed, 21 | // instead of requiring a page refresh if the browser already has an old version of the service worker installed. 22 | event.waitUntil(self.skipWaiting()); 23 | }); 24 | 25 | self.addEventListener("activate", function (event) { 26 | // Immediately allow the service worker to intercept "fetch" events (instead of requiring a page refresh) if this is 27 | // the first time this service worker is being installed. 28 | event.waitUntil(self.clients.claim()); 29 | }); 30 | 31 | self.addEventListener("message", event => { 32 | // Allows communication with the windows/tabs that have are able to generate the JWT (as they have the auth session with the user's API). 33 | // See: AuthSwSetConfigDto 34 | if (event.data) { 35 | switch (event.data.type) { 36 | // Auth sessions are started/ended by calling SET_CONFIG with auth config or with 'undefined' config, respectively. 37 | // We use 'undefined' to end the auth session instead of unregistering the worker, as there may be multiple tabs 38 | // in the user's application, so while the user may sign out in one tab, they may remain signed in to another tab, 39 | // which may subsequently send a follow-up 'SET_CONFIG' which will resume auth. 40 | case "SET_BYTESCALE_AUTH_CONFIG": 41 | setConfig(event.data.config).then( 42 | () => {}, 43 | e => console.error(`[bytescale] Auth SW failed to persist config.`, e) 44 | ); 45 | break; 46 | } 47 | } 48 | }); 49 | 50 | self.addEventListener("fetch", function (event) { 51 | // Faster and intercepts only the required requests. 52 | // Called in almost all cases. 53 | const interceptSync = config => { 54 | const newRequest = interceptRequest(event, config); 55 | if (newRequest !== undefined) { 56 | event.respondWith(handleRequestErrors(newRequest)); 57 | } 58 | }; 59 | 60 | // Slower and intercepts all requests (while still only rewriting the relevant requests). 61 | // Called only for the initial request after this Service Worker is restarted after going idle (e.g. after 30s on Firefox/Windows). 62 | const interceptAsync = async () => 63 | await handleRequestErrors(interceptRequest(event, await getConfig()) ?? event.request); 64 | 65 | // Makes it clearer to developers that the request failed for normal reasons (not reasons caused by this script). 66 | const handleRequestErrors = async request => { 67 | try { 68 | return await fetch(request); 69 | } catch (e) { 70 | throw new Error("Network request failed: see previous browser errors for the cause."); 71 | } 72 | }; 73 | 74 | // Optimization: avoids running async code (which necessitates intercepting all requests) when the config is already cached locally. 75 | if (transientCache !== undefined) { 76 | interceptSync(transientCache); 77 | } else { 78 | event.respondWith(interceptAsync()); 79 | } 80 | }); 81 | 82 | function interceptRequest(event, config) { 83 | const url = event.request.url; 84 | 85 | if (config !== undefined) { 86 | // Config is an array to support multiple different accounts within a single website, if needed. 87 | for (const { expires, urlPrefix, headers } of config) { 88 | if (expires === undefined || expires > Date.now()) { 89 | if (url.startsWith(urlPrefix) && event.request.method.toUpperCase() === "GET") { 90 | const newHeaders = new Headers(event.request.headers); 91 | for (const { key, value } of headers) { 92 | // Preserve existing headers in the request. This is crucial for 'fetch' requests that might already 93 | // include an "Authorization" header, enabling access to certain resources. For instance, the Bytescale 94 | // Dashboard uses an explicit "Authorization" header in a 'fetch' request to allow account admins to 95 | // download private files. In these scenarios, it's important not to replace these headers with the global 96 | // JWT managed by the AuthManager. 97 | if (!newHeaders.has(key)) { 98 | newHeaders.set(key, value); 99 | } 100 | } 101 | 102 | return new Request(event.request, { 103 | mode: "cors", // Required for adding custom HTTP headers. 104 | headers: newHeaders 105 | }); 106 | } 107 | } 108 | } 109 | } 110 | 111 | return undefined; 112 | } 113 | 114 | async function getConfig() { 115 | if (transientCache !== undefined) { 116 | return transientCache; 117 | } 118 | 119 | const cache = await getCache(); 120 | const configResponse = await cache.match(persistentCacheKey); 121 | if (configResponse !== undefined) { 122 | const config = await configResponse.json(); 123 | transientCache = config; 124 | return config; 125 | } 126 | 127 | return undefined; 128 | } 129 | 130 | async function setConfig(config) { 131 | // Ensures "fetch" events can start seeing the config immediately. Persistent config is only required for when this 132 | // service worker expires (after 30s on some browsers, like FireFox on Windows). 133 | transientCache = config; 134 | 135 | const cache = await getCache(); 136 | await cache.put(persistentCacheKey, new Response(JSON.stringify(config))); 137 | } 138 | 139 | function getCache() { 140 | return caches.open(persistentCacheName); 141 | } 142 | -------------------------------------------------------------------------------- /src/index.browser.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export * from "./public/shared"; 4 | export * from "./public/browser"; 5 | -------------------------------------------------------------------------------- /src/index.node.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export * from "./public/shared"; 4 | export * from "./public/node"; 5 | -------------------------------------------------------------------------------- /src/index.worker.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export * from "./public/shared"; 4 | export * from "./public/worker"; 5 | -------------------------------------------------------------------------------- /src/private/AuthSessionState.ts: -------------------------------------------------------------------------------- 1 | import { AuthSession } from "./model/AuthSession"; 2 | import { FairMutex } from "./FairMutex"; 3 | 4 | /** 5 | * Maintains a global session state, even across package versions. 6 | * 7 | * This is to allow users to start auth sessions via the Bytescale JavaScript SDK, where due to versioning or other 8 | * bundling issues, the Bytescale Upload Widget has been bundled with a different Bytescale JavaScript SDK. In this 9 | * scenario, the user wouldn't be able to start an auth session with the Bytescale Upload Widget. Therefore, we use 10 | * global state (i.e. on the window) to ensure the session state can be shared between the user's instance of the 11 | * Bytescale JavaScript SDK and the Upload Widget's version of the Bytescale JavaScript SDK. 12 | * 13 | * Users also frequently have problems caused by them not keeping track of *Api and *Manager instances correctly, so 14 | * making this global prevents a lot of common mistakes. 15 | */ 16 | export class AuthSessionState { 17 | private static readonly stateKey = "BytescaleSessionState"; 18 | private static readonly mutexKey = "BytescaleSessionStateMutex"; 19 | 20 | /** 21 | * Called in the browser only. 22 | */ 23 | static getMutex(): FairMutex { 24 | const key = AuthSessionState.mutexKey; 25 | let mutex = (window as any)[key] as FairMutex | undefined; 26 | 27 | if (mutex === undefined) { 28 | mutex = new FairMutex(); 29 | (window as any)[key] = mutex; 30 | } 31 | 32 | return mutex; 33 | } 34 | 35 | /** 36 | * Called in the browser only. 37 | */ 38 | static setSession(session: AuthSession | undefined): void { 39 | (window as any)[AuthSessionState.stateKey] = session; 40 | } 41 | 42 | /** 43 | * Called in the browser and in Node.js (so we check the env before calling env-specific code). 44 | */ 45 | static getSession(): AuthSession | undefined { 46 | if (typeof window === "undefined") { 47 | return undefined; 48 | } 49 | return (window as any)[AuthSessionState.stateKey]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/private/ConsoleUtils.ts: -------------------------------------------------------------------------------- 1 | export class ConsoleUtils { 2 | static debug(message: string): void { 3 | console.log(ConsoleUtils.prefix(message)); 4 | } 5 | 6 | static warn(message: string): void { 7 | console.warn(ConsoleUtils.prefix(message)); 8 | } 9 | 10 | static error(message: string): void { 11 | console.error(ConsoleUtils.prefix(message)); 12 | } 13 | 14 | private static prefix(message: string): string { 15 | return `[bytescale-sdk] ${message}`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/private/EnvChecker.ts: -------------------------------------------------------------------------------- 1 | export class EnvChecker { 2 | static isBrowser(): boolean { 3 | return typeof window !== "undefined"; 4 | } 5 | 6 | static methodRequiresBrowser(methodName: string): Error { 7 | if (!EnvChecker.isBrowser()) { 8 | return new Error( 9 | `You must call '${methodName}' in your client-side code. (You have called it in your server-side code.)` 10 | ); 11 | } 12 | 13 | return new Error( 14 | `The '${methodName}' method cannot be called because you have bundled a non-browser implementation of the Bytescale JavaScript SDK into your client-side code. Please ensure your bundling tool (Webpack, Rollup, etc.) is honouring the "browser" field of the "package.json" file for this package, as this will allow the browser implementation of the Bytescale JavaScript SDK to be included in your client-side code.` 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/private/FairMutex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A lightweight fair mutex. (Other libraries contain too many features and we want to keep size down). 3 | * 4 | * Characteristics: 5 | * - Non-reentrant. 6 | * - Fair. 7 | * - This means multiple callers awaiting 'lock' will be granted the mutex in the order they requested it. 8 | * - This is important, as in React, developers calling 'AuthManager.endAuthSession' in a 'useEffect' cleanup need it 9 | * to take effect immediately, such that subsequent 'AuthManager.beginAuthSession' calls will always succeed. 10 | * - When calling `safe` consecutively with no 'awaits' in-between, the current context will synchronously acquire 11 | * the mutex every time. 12 | */ 13 | export class FairMutex { 14 | private locked = false; 15 | private readonly queue: Array<{ resolve: () => void }> = []; 16 | 17 | async safe(callback: () => Promise): Promise { 18 | await this.lock(); 19 | try { 20 | return await callback(); 21 | } finally { 22 | this.unlock(); 23 | } 24 | } 25 | 26 | private async lock(): Promise { 27 | if (this.locked) { 28 | let unlockNext: (() => void) | undefined; 29 | const lockPromise = new Promise(resolve => { 30 | unlockNext = resolve; 31 | }); 32 | if (unlockNext === undefined) { 33 | throw new Error("unlockNext was undefined"); 34 | } 35 | this.queue.push({ resolve: unlockNext }); 36 | await lockPromise; 37 | } 38 | this.locked = true; 39 | } 40 | 41 | private unlock(): void { 42 | if (!this.locked) { 43 | throw new Error("Mutex is not locked."); 44 | } 45 | const nextInQueue = this.queue.shift(); 46 | if (nextInQueue !== undefined) { 47 | nextInQueue.resolve(); 48 | } else { 49 | this.locked = false; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/private/FilePathUtils.ts: -------------------------------------------------------------------------------- 1 | export class FilePathUtils { 2 | static encodeFilePath(filePath: string): string { 3 | // To convert a Bytescale File Path into a Bytescale File URL: 4 | // a) Call encodeURIComponent() on the file path. (This allows file paths to contain ANY character.) 5 | // b) Replace all occurrences of "%2F" with "/". (Allows file paths to appear as hierarchical paths on the URL.) 6 | // c) Replace all occurrences of "!" with "%21". (Prevents file paths with "!" inside being treated as "query bangs".) 7 | // SYNC: FileUrlUtils.makeFileUrl (internal) 8 | return encodeURIComponent(filePath).replace(/%2F/g, "/").replace(/!/g, "%21"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/private/NodeChunkedStream.ts: -------------------------------------------------------------------------------- 1 | import type * as stream from "stream"; 2 | import { StreamUtils } from "./StreamUtils"; 3 | 4 | interface Consumer { 5 | bytesRemaining: number; 6 | stream: stream.Readable; 7 | } 8 | 9 | /** 10 | * For Node.js streams only: 11 | * 12 | * Converts a stream into a stream of streams, where the next stream is requested via '.take(sizeInBytes: number): Stream' 13 | * 14 | * This allows the source stream to be sequentially read (in serial) as a sequence of sub-streams, for the purpose of 15 | * issuing PutObject requests for a multipart upload, whereby each request requires its own stream, but where that stream 16 | * needs to be a slice of the source stream. 17 | */ 18 | export class NodeChunkedStream { 19 | private buffer: Buffer = Buffer.alloc(0); 20 | private consumer: Consumer | undefined; 21 | private isSourceFullyConsumed = false; // true if the source stream indicates it has finished. 22 | private isFinishedConsuming = false; // true if _we_ indicate we have finished reading all we want from the stream. 23 | private resolver: (() => void) | undefined = undefined; 24 | 25 | constructor(private readonly source: NodeJS.ReadableStream) {} 26 | 27 | /** 28 | * If the source stream is larger than the 'size' the user is consuming (i.e. they're only wanting to upload a subset 29 | * of the stream) then the stream won't be resolved by the 'end' event inside 'runChunkPipeline', so calling this 30 | * method is necessary. 31 | */ 32 | finishedConsuming(): void { 33 | this.isFinishedConsuming = true; 34 | if (this.resolver !== undefined) { 35 | this.resolver(); 36 | } 37 | } 38 | 39 | /** 40 | * Promise resolves when the entire stream has finished processing, or an error occurs. 41 | * You must call 'take' a sufficient number of times after calling this method in order for this promise to resolve. 42 | */ 43 | async runChunkPipeline(): Promise { 44 | return await new Promise((resolve, reject) => { 45 | this.resolver = resolve; 46 | 47 | const onError = (error: any): void => { 48 | removeListeners(); 49 | reject(error); 50 | }; 51 | const onEnd = (): void => { 52 | this.isSourceFullyConsumed = true; 53 | removeListeners(); 54 | resolve(); 55 | }; 56 | const onData = (buffer: Buffer): void => { 57 | try { 58 | if (this.isFinishedConsuming) { 59 | return; 60 | } 61 | 62 | if (this.consumer === undefined) { 63 | console.warn( 64 | "Stream yielded data while paused. The data will be buffered, but excessive buffering can cause memory issues." 65 | ); 66 | this.buffer = Buffer.concat([this.buffer, buffer]); 67 | return; 68 | } 69 | if (this.consumer.bytesRemaining <= 0) { 70 | throw new Error("Consumer requires zero bytes, so should not be consuming from the stream."); 71 | } 72 | if (this.buffer.byteLength > 0) { 73 | throw new Error( 74 | "Buffer was expected to be empty (as it should have been flushed to the consumer when '.take' was called)." 75 | ); 76 | } 77 | 78 | const splitResult = this.splitBuffer(buffer, this.consumer.bytesRemaining); 79 | if (splitResult === undefined) { 80 | return; // Received empty data. 81 | } 82 | 83 | const [consumed, remaining] = splitResult; 84 | this.buffer = remaining; 85 | this.consumer.bytesRemaining -= consumed.byteLength; 86 | this.consumer.stream.push(consumed); 87 | if (this.consumer.bytesRemaining === 0) { 88 | StreamUtils.endStream(this.consumer.stream); 89 | this.consumer = undefined; 90 | this.source.pause(); 91 | } 92 | } catch (e) { 93 | removeListeners(); 94 | reject(e); 95 | } 96 | }; 97 | 98 | const removeListeners = (): void => { 99 | this.source.removeListener("data", onData); 100 | this.source.removeListener("error", onError); 101 | this.source.removeListener("end", onEnd); 102 | }; 103 | 104 | this.source.on("data", onData); 105 | this.source.on("error", onError); 106 | this.source.on("end", onEnd); 107 | 108 | this.source.pause(); // Resumed when 'take' is called. 109 | }); 110 | } 111 | 112 | /** 113 | * Only call 'take' after the previously returned stream has been fully consumed. 114 | */ 115 | take(bytes: number): NodeJS.ReadableStream { 116 | if (this.consumer !== undefined) { 117 | throw new Error("The stream from the previous 'take' call must be fully consumed before calling 'take' again."); 118 | } 119 | 120 | if (bytes <= 0) { 121 | return StreamUtils.empty(); 122 | } 123 | 124 | const readable = StreamUtils.create(); 125 | const consumedFromBuffer = this.consumeFromBuffer(bytes); 126 | const consumedFromBufferLength = consumedFromBuffer?.length ?? 0; 127 | const bytesToConsumeFromStream = bytes - consumedFromBufferLength; 128 | 129 | if (consumedFromBuffer !== undefined) { 130 | readable.push(consumedFromBuffer); 131 | } 132 | 133 | if (bytesToConsumeFromStream > 0) { 134 | if (this.isSourceFullyConsumed) { 135 | throw new Error( 136 | `Stream finished processing earlier than expected. The "size" parameter is likely larger than the stream's actual contents.` 137 | ); 138 | } 139 | 140 | this.consumer = { 141 | bytesRemaining: bytesToConsumeFromStream, 142 | stream: readable 143 | }; 144 | this.source.resume(); 145 | } else { 146 | StreamUtils.endStream(readable); 147 | } 148 | 149 | return readable; 150 | } 151 | 152 | private consumeFromBuffer(bytes: number): Buffer | undefined { 153 | const splitResult = this.splitBuffer(this.buffer, bytes); 154 | if (splitResult === undefined) { 155 | return undefined; 156 | } 157 | 158 | const [consumed, remaining] = splitResult; 159 | this.buffer = remaining; 160 | return consumed; 161 | } 162 | 163 | private splitBuffer(buffer: Buffer, maxBytes: number): [Buffer, Buffer] | undefined { 164 | if (buffer.byteLength === 0) { 165 | return undefined; 166 | } 167 | 168 | const bytesToConsume = Math.min(maxBytes, buffer.byteLength); 169 | if (bytesToConsume === buffer.byteLength) { 170 | return [buffer, Buffer.alloc(0)]; // Optimization 171 | } 172 | 173 | const consumed = buffer.subarray(0, bytesToConsume); 174 | const remaining = buffer.subarray(bytesToConsume); 175 | return [consumed, remaining]; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/private/Scheduler.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleUtils } from "./ConsoleUtils"; 2 | 3 | /** 4 | * ------------------------- 5 | * Q: Why not use 'setTimeout'? 6 | * ------------------------- 7 | * A: setTimeout can be paused (e.g., during hibernation), risking JWT expiration before it triggers. 8 | * We therefore use a scheduler to check wall-clock time every second and execute the callback at the scheduled time. 9 | * ------------------------- 10 | */ 11 | export class Scheduler { 12 | private callbacks: { [handle: number]: { callback: () => void; epoch: number } } = {}; 13 | private nextId: number = 0; 14 | private intervalId: number | undefined = undefined; 15 | 16 | schedule(epoch: number, callback: () => void): number { 17 | const handle = this.nextId++; 18 | this.callbacks[handle] = { epoch, callback }; 19 | this.startInterval(); 20 | return handle; 21 | } 22 | 23 | unschedule(handle: number): void { 24 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 25 | delete this.callbacks[handle]; 26 | 27 | if (Object.keys(this.callbacks).length === 0) { 28 | this.stopInterval(); 29 | } 30 | } 31 | 32 | private startInterval(): void { 33 | if (this.intervalId === undefined) { 34 | this.intervalId = setInterval(() => this.checkCallbacks(), 1000) as any; 35 | } 36 | } 37 | 38 | private stopInterval(): void { 39 | if (this.intervalId !== undefined) { 40 | clearInterval(this.intervalId); 41 | this.intervalId = undefined; 42 | } 43 | } 44 | 45 | private checkCallbacks(): void { 46 | const now = Date.now(); 47 | 48 | for (const handleStr in this.callbacks) { 49 | const handle = parseInt(handleStr); 50 | 51 | if (this.callbacks[handle].epoch <= now) { 52 | try { 53 | this.callbacks[handle].callback(); 54 | } catch (e: any) { 55 | ConsoleUtils.error(`Unhandled error from scheduled callback: ${e as string}`); 56 | } 57 | 58 | this.unschedule(handle); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/private/ServiceWorkerUtils.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleUtils } from "./ConsoleUtils"; 2 | import { ServiceWorkerInitStatus } from "./model/ServiceWorkerInitStatus"; 3 | import { ServiceWorkerConfig, ServiceWorkerConfigInitialized } from "./model/ServiceWorkerConfig"; 4 | import { assertUnreachable } from "./TypeUtils"; 5 | 6 | export class ServiceWorkerUtils { 7 | canUseServiceWorkers(): boolean { 8 | return "serviceWorker" in navigator; 9 | } 10 | 11 | async sendMessage( 12 | message: TMessage, 13 | config: ServiceWorkerConfig, 14 | serviceWorkerScriptFieldName: string 15 | ): Promise { 16 | const result = await this.ensureActiveServiceWorkerExists(message, config, serviceWorkerScriptFieldName); 17 | 18 | // Message delivery is at least once. We must always assume this as there is only 1 service worker instance and 19 | // potentially multiple browser tabs with AuthManager sessions running. As such, even if we ensure exactly-once 20 | // delivery within a single tab, there will still be at-least-once delivery across all. As such, we simplfy our 21 | // code by using at-least-once delivery here too (hence the following 'postMessage' will already have been called 22 | // in the scenario where a new worker is newly installed). 23 | result.serviceWorker.postMessage(message); 24 | 25 | return result.config; 26 | } 27 | 28 | private async ensureActiveServiceWorkerExists( 29 | initMessage: TMessage, 30 | config: ServiceWorkerConfig, 31 | serviceWorkerScriptFieldName: string 32 | ): Promise { 33 | switch (config.type) { 34 | case "Uninitialized": { 35 | await this.unregisterOnHardReload(); 36 | break; 37 | } 38 | case "Initialized": { 39 | const serviceWorker = await this.getActiveServiceWorker(config.serviceWorkerScope); 40 | if (serviceWorker !== undefined) { 41 | return { 42 | serviceWorker, 43 | config 44 | }; 45 | } 46 | break; 47 | } 48 | default: 49 | assertUnreachable(config); 50 | } 51 | 52 | return await this.registerServiceWorker(config.serviceWorkerScript, initMessage, serviceWorkerScriptFieldName); 53 | } 54 | 55 | /** 56 | * Idempotent. 57 | * 58 | * Only returns once the service worker has been activated. 59 | * 60 | * We don't need to unregister it: we just need to clear the config when auth ends. 61 | */ 62 | private async registerServiceWorker( 63 | serviceWorkerScript: string, 64 | initMessage: TMessage, 65 | serviceWorkerScriptFieldName: string 66 | ): Promise { 67 | if (!serviceWorkerScript.startsWith("/")) { 68 | throw new Error( 69 | `The '${serviceWorkerScriptFieldName}' field must start with a '/' and reference a script at the root of your website.` 70 | ); 71 | } 72 | 73 | const forwardSlashCount = serviceWorkerScript.split("/").length - 1; 74 | if (forwardSlashCount > 1) { 75 | ConsoleUtils.warn( 76 | `The '${serviceWorkerScriptFieldName}' field should be a root script (e.g. '/script.js'). The Bytescale SDK can only authorize requests originating from webpages that are at the same level as the script or below.` 77 | ); 78 | } 79 | 80 | try { 81 | const registration = await navigator.serviceWorker.register(serviceWorkerScript); 82 | return { 83 | serviceWorker: await this.waitForActiveServiceWorker(registration, initMessage), 84 | config: { 85 | serviceWorkerScript, 86 | serviceWorkerScope: registration.scope, // Can be undefined (despite what the DOM TS defs say). 87 | type: "Initialized" 88 | } 89 | }; 90 | } catch (e) { 91 | throw new Error(`Failed to install Bytescale Service Worker (SW). ${(e as Error).name}: ${(e as Error).message}`); 92 | } 93 | } 94 | 95 | private async waitForActiveServiceWorker( 96 | registration: ServiceWorkerRegistration, 97 | initMessage: TMessage 98 | ): Promise { 99 | // We must check the 'installing' state before the 'active' state (see comment below). 100 | // The state will be 'installing' when the service worker is installed for the first time, or if there have been 101 | // code changes to the service worker, else the state will be 'active'. 102 | const installing = registration.installing; 103 | if (installing !== null) { 104 | const waitForActive = new Promise(resolve => { 105 | const stateChangeHandler = (e: Event): void => { 106 | const sw = e.target as ServiceWorker; 107 | if (sw.state === "activated") { 108 | installing.removeEventListener("statechange", stateChangeHandler); 109 | resolve(sw); 110 | } 111 | }; 112 | installing.addEventListener("statechange", stateChangeHandler); 113 | }); 114 | 115 | // We send the message during the INSTALL phase instead of ACTIVE phase to ensure that when replacing an 116 | // existing Service Worker instance, the new instance will be ready to run (with config received from this 117 | // 'postMessage' call) prior to it being activated, so that it can immediately start attaching auth headers to 118 | // intercepted 'fetch' requests. 119 | installing.postMessage(initMessage); 120 | 121 | return await waitForActive; 122 | } 123 | 124 | // We must check the 'installing' state before the 'active' state, because if we've just installed a new service 125 | // worker, then the new service worker will be in the 'installing' slot whereas the old service worker will be in 126 | // the 'active' slot. So, if we checked this first, we would always return the old service worker, and therefore the 127 | // new service worker would never be initialized. 128 | if (registration.active !== null) { 129 | return registration.active; 130 | } 131 | 132 | // We expect the service worker to use 'skipWaiting', so we don't expect 'waiting' service workers. 133 | throw new Error("Service worker was neither 'installing' or 'active'."); 134 | } 135 | 136 | private async getActiveServiceWorker(serviceWorkerScope: string | undefined): Promise { 137 | const registrations = (await this.getActiveRegistrations()).filter(x => x.scope === serviceWorkerScope); 138 | return registrations[0]?.active ?? undefined; 139 | } 140 | 141 | /** 142 | * Existing active Service Workers will not receive 'fetch' events in sessions that start with a hard reload, so 143 | * we must unregister them and register a new one. (See: https://github.com/mswjs/msw/issues/98#issuecomment-612118211) 144 | */ 145 | private async unregisterOnHardReload(): Promise { 146 | const isHardReload = navigator.serviceWorker.controller === null; 147 | if (!isHardReload) { 148 | return; 149 | } 150 | 151 | const registrations = await this.getActiveRegistrations(); 152 | for (const registration of registrations) { 153 | await registration.unregister(); 154 | } 155 | } 156 | 157 | private async getActiveRegistrations(): Promise { 158 | const registrations = await navigator.serviceWorker.getRegistrations(); 159 | return registrations.filter(registration => registration.active !== null); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/private/StreamUtils.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from "stream"; 2 | import type * as stream from "stream"; 3 | 4 | export class StreamUtils { 5 | static create(): stream.Readable { 6 | return new Readable({ 7 | read(_size) { 8 | // No implementation needed here. 9 | // The stream will wait for data to be pushed externally. 10 | } 11 | }); 12 | } 13 | 14 | static endStream(readable: stream.Readable): void { 15 | readable.push(null); 16 | } 17 | 18 | static empty(): NodeJS.ReadableStream { 19 | return Readable.from([]); 20 | } 21 | 22 | static fromBuffer(buffer: Buffer): NodeJS.ReadableStream { 23 | return Readable.from(buffer); 24 | } 25 | 26 | static fromArrayBuffer(buffer: ArrayBuffer): NodeJS.ReadableStream { 27 | return StreamUtils.fromBuffer(Buffer.from(buffer)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/private/TypeUtils.ts: -------------------------------------------------------------------------------- 1 | export function assertUnreachable(x: never): never { 2 | throw new Error(`Didn't expect to get here: ${JSON.stringify(x as any)}`); 3 | } 4 | 5 | export function isDefinedEntry(object: [K, T | undefined | null]): object is [K, T] { 6 | return object[1] !== undefined && object[1] !== null; 7 | } 8 | -------------------------------------------------------------------------------- /src/private/UploadManagerBase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeginMultipartUploadResponse, 3 | BytescaleApiClientConfig, 4 | BytescaleApiClientConfigUtils, 5 | CancelledError, 6 | CompleteMultipartUploadResponse, 7 | UploadApi, 8 | UploadPart 9 | } from "../public/shared/generated"; 10 | import { UploadManagerInterface } from "./model/UploadManagerInterface"; 11 | import { OnPartProgress } from "./model/OnPartProgress"; 12 | import { PreUploadInfo } from "./model/PreUploadInfo"; 13 | import { UploadSourceBlob } from "./model/UploadSourceProcessed"; 14 | import { PutUploadPartResult } from "./model/PutUploadPartResult"; 15 | import { AddCancellationHandler } from "./model/AddCancellationHandler"; 16 | import { UploadManagerParams, UploadProgress, UploadSource, UploadResult } from "../public/shared/CommonTypes"; 17 | 18 | /** 19 | * Methods common to UploadManagerNode and UploadManagerBrowser. 20 | */ 21 | export abstract class UploadManagerBase implements UploadManagerInterface { 22 | protected readonly stringMimeType = "text/plain"; 23 | private readonly accountId: string; 24 | private readonly defaultMaxConcurrentUploadParts = 4; 25 | private readonly intervalMs = 500; 26 | private readonly uploadApi: UploadApi; 27 | 28 | constructor(protected readonly config: BytescaleApiClientConfig) { 29 | this.uploadApi = new UploadApi(config); 30 | this.accountId = BytescaleApiClientConfigUtils.getAccountId(config); 31 | } 32 | 33 | async upload(request: UploadManagerParams): Promise { 34 | this.assertNotCancelled(request); 35 | 36 | const source = this.processUploadSource(request.data); 37 | const preUploadInfo = this.getPreUploadInfo(request, source); 38 | const bytesTotal = preUploadInfo.size; 39 | const makeOnProgressForPart = this.makeOnProgressForPartFactory(request, bytesTotal); 40 | const init = this.preUpload(source); 41 | 42 | // Raise initial progress event SYNCHRONOUSLY. 43 | if (request.onProgress !== undefined) { 44 | request.onProgress(this.makeProgressEvent(0, bytesTotal)); 45 | } 46 | 47 | const uploadInfo = await this.beginUpload(request, preUploadInfo); 48 | const partCount = uploadInfo.uploadParts.count; 49 | const parts = [...Array(partCount).keys()]; 50 | const { cancel, addCancellationHandler } = this.makeCancellationMethods(); 51 | 52 | const intervalHandle = setInterval(this.onIntervalTick(request, cancel), this.intervalMs); 53 | let uploadedParts: CompleteMultipartUploadResponse[]; 54 | try { 55 | uploadedParts = await this.mapAsync( 56 | parts, 57 | preUploadInfo.maxConcurrentUploadParts, 58 | async part => 59 | await this.uploadPart(request, source, part, uploadInfo, makeOnProgressForPart(), addCancellationHandler) 60 | ); 61 | await this.postUpload(init); 62 | } finally { 63 | clearInterval(intervalHandle); 64 | } 65 | 66 | const etag = uploadedParts.flatMap(x => (x.status === "Completed" ? [x.etag] : []))[0]; 67 | return { 68 | ...uploadInfo.file, 69 | etag // The 'etag' in the original 'uploadInfo.file' will be null, so we set it to the final etag value here. 70 | }; 71 | } 72 | 73 | protected getBlobInfo({ value: { name, size, type } }: UploadSourceBlob): Partial & { size: number } { 74 | return { 75 | // Some browsers/OSs return 'type: ""' for files with unknown MIME types, like HEICs, which causes a validation 76 | // error from the Bytescale API as "" is not a valid MIME type, so we coalesce to undefined here. 77 | mime: type === "" ? undefined : type, 78 | size, 79 | originalFileName: name, 80 | maxConcurrentUploadParts: undefined 81 | }; 82 | } 83 | 84 | protected abstract processUploadSource(data: UploadSource): TSource; 85 | protected abstract getPreUploadInfoPartial( 86 | request: UploadManagerParams, 87 | source: TSource 88 | ): Partial & { size: number }; 89 | protected abstract preUpload(source: TSource): TInit; 90 | protected abstract postUpload(init: TInit): Promise; 91 | protected abstract doPutUploadPart( 92 | part: UploadPart, 93 | contentLength: number, 94 | source: TSource, 95 | onProgress: (totalBytesTransferred: number) => void, 96 | addCancellationHandler: AddCancellationHandler 97 | ): Promise; 98 | 99 | private onIntervalTick(request: UploadManagerParams, cancel: () => void): () => void { 100 | return () => { 101 | if (this.isCancelled(request)) { 102 | cancel(); 103 | } 104 | }; 105 | } 106 | 107 | private makeCancellationMethods(): { addCancellationHandler: AddCancellationHandler; cancel: () => void } { 108 | const cancellationHandlers: Array<() => void> = []; 109 | const addCancellationHandler: AddCancellationHandler = (ca: () => void): void => { 110 | cancellationHandlers.push(ca); 111 | }; 112 | const cancel = (): void => cancellationHandlers.forEach(x => x()); 113 | return { cancel, addCancellationHandler }; 114 | } 115 | 116 | /** 117 | * Returns a callback, which when called, returns a callback that can be used by ONE specific part to report its progress. 118 | */ 119 | private makeOnProgressForPartFactory(request: UploadManagerParams, bytesTotal: number): () => OnPartProgress { 120 | const { onProgress } = request; 121 | if (onProgress === undefined) { 122 | return () => () => {}; 123 | } 124 | 125 | let bytesSent = 0; 126 | return () => { 127 | let bytesSentForPart = 0; 128 | return bytesSentTotalForPart => { 129 | const delta = bytesSentTotalForPart - bytesSentForPart; 130 | bytesSentForPart += delta; 131 | bytesSent += delta; 132 | onProgress(this.makeProgressEvent(bytesSent, bytesTotal)); 133 | }; 134 | }; 135 | } 136 | 137 | private makeProgressEvent(bytesSent: number, bytesTotal: number): UploadProgress { 138 | return { bytesTotal, bytesSent, progress: Math.round((bytesSent / bytesTotal) * 100) }; 139 | } 140 | 141 | private assertNotCancelled(request: UploadManagerParams): void { 142 | if (this.isCancelled(request)) { 143 | throw new CancelledError(); 144 | } 145 | } 146 | 147 | private isCancelled(request: UploadManagerParams): boolean { 148 | return request.cancellationToken?.isCancelled === true; 149 | } 150 | 151 | private async beginUpload( 152 | request: UploadManagerParams, 153 | { size, mime, originalFileName }: PreUploadInfo 154 | ): Promise { 155 | return await this.uploadApi.beginMultipartUpload({ 156 | accountId: this.accountId, 157 | beginMultipartUploadRequest: { 158 | metadata: request.metadata, 159 | mime, 160 | originalFileName, 161 | path: request.path, 162 | protocol: "1.1", 163 | size, 164 | tags: request.tags 165 | } 166 | }); 167 | } 168 | 169 | private async uploadPart( 170 | request: UploadManagerParams, 171 | source: TSource, 172 | partIndex: number, 173 | uploadInfo: BeginMultipartUploadResponse, 174 | onProgress: OnPartProgress, 175 | addCancellationHandler: AddCancellationHandler 176 | ): Promise { 177 | this.assertNotCancelled(request); 178 | const part = await this.getUploadPart(partIndex, uploadInfo); 179 | this.assertNotCancelled(request); 180 | const etag = await this.putUploadPart(part, source, onProgress, addCancellationHandler); 181 | this.assertNotCancelled(request); 182 | return await this.uploadApi.completeUploadPart({ 183 | accountId: this.accountId, 184 | uploadId: uploadInfo.uploadId, 185 | uploadPartIndex: partIndex, 186 | completeUploadPartRequest: { 187 | etag 188 | } 189 | }); 190 | } 191 | 192 | /** 193 | * Returns etag for the part. 194 | */ 195 | private async putUploadPart( 196 | part: UploadPart, 197 | source: TSource, 198 | onProgress: OnPartProgress, 199 | addCancellationHandler: AddCancellationHandler 200 | ): Promise { 201 | const contentLength = part.range.inclusiveEnd + 1 - part.range.inclusiveStart; 202 | const { status, etag } = await this.doPutUploadPart( 203 | part, 204 | contentLength, 205 | source, 206 | onProgress, 207 | addCancellationHandler 208 | ); 209 | 210 | if (Math.floor(status / 100) !== 2) { 211 | throw new Error(`Failed to upload part (${status}).`); 212 | } 213 | 214 | if (etag === undefined) { 215 | throw new Error("No 'etag' response header found in upload part response."); 216 | } 217 | 218 | // Always send 100% for part, as some UploadManager implementations either don't report progress, or may not report the last chunk uploaded. 219 | onProgress(contentLength); 220 | 221 | return etag; 222 | } 223 | 224 | private async getUploadPart(partIndex: number, uploadInfo: BeginMultipartUploadResponse): Promise { 225 | if (partIndex === 0) { 226 | return uploadInfo.uploadParts.first; 227 | } 228 | return await this.uploadApi.getUploadPart({ 229 | uploadId: uploadInfo.uploadId, 230 | accountId: this.accountId, 231 | uploadPartIndex: partIndex 232 | }); 233 | } 234 | 235 | private getPreUploadInfo(request: UploadManagerParams, source: TSource): PreUploadInfo { 236 | const partial = this.getPreUploadInfoPartial(request, source); 237 | return { 238 | maxConcurrentUploadParts: partial.maxConcurrentUploadParts ?? this.defaultMaxConcurrentUploadParts, 239 | originalFileName: request.originalFileName ?? partial.originalFileName, 240 | mime: request.mime ?? partial.mime, 241 | size: partial.size 242 | }; 243 | } 244 | 245 | private async mapAsync(items: T[], concurrency: number, callback: (item: T) => Promise): Promise { 246 | const result: T2[] = []; 247 | const workQueue = [...items]; 248 | await Promise.all( 249 | [...Array(concurrency).keys()].map(async () => { 250 | while (workQueue.length > 0) { 251 | const work = workQueue.shift(); // IMPORTANT: use 'shift' instead of 'pop' to ensure 'items' are processed in order when 'concurrency = 1'. 252 | if (work !== undefined) { 253 | result.push(await callback(work)); 254 | } 255 | } 256 | }) 257 | ); 258 | return result; 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/private/UploadManagerBrowserWorkerBase.ts: -------------------------------------------------------------------------------- 1 | import { UploadManagerBase } from "./UploadManagerBase"; 2 | import { UploadSourceProcessedBrowser, UploadSourceProcessedWorker } from "./model/UploadSourceProcessed"; 3 | import { PreUploadInfo } from "./model/PreUploadInfo"; 4 | import { UploadPart } from "../public/shared/generated"; 5 | import { assertUnreachable } from "./TypeUtils"; 6 | import { BlobLike, UploadManagerParams, UploadSource } from "../public/shared/CommonTypes"; 7 | 8 | type BrowserOrWorkerUploadSource = UploadSourceProcessedBrowser | UploadSourceProcessedWorker; 9 | 10 | /** 11 | * The "browser" and "worker" runtimes support the same input types, but the former uses XHR and the latter uses Fetch. 12 | */ 13 | export abstract class UploadManagerBrowserWorkerBase extends UploadManagerBase { 14 | protected processUploadSource(data: UploadSource): BrowserOrWorkerUploadSource { 15 | if (typeof data === "string") { 16 | // 'Blob' must be from 'global' (i.e. not imported) as we're in a browser context here, so is globally available. 17 | return { type: "Blob", value: new Blob([data], { type: this.stringMimeType }) }; 18 | } 19 | if ((data as Partial).byteLength !== undefined) { 20 | return { type: "ArrayBuffer", value: data as ArrayBuffer }; 21 | } 22 | if ((data as Partial).size !== undefined) { 23 | return { type: "Blob", value: data as BlobLike }; 24 | } 25 | 26 | throw new Error( 27 | `Unsupported type for 'data' parameter. Please provide a String, Blob, ArrayBuffer, or File object (from a file input element).` 28 | ); 29 | } 30 | 31 | protected getPreUploadInfoPartial( 32 | _request: UploadManagerParams, 33 | data: BrowserOrWorkerUploadSource 34 | ): Partial & { size: number } { 35 | switch (data.type) { 36 | case "Blob": 37 | return this.getBlobInfo(data); 38 | case "ArrayBuffer": 39 | return { 40 | mime: undefined, 41 | size: data.value.byteLength, 42 | originalFileName: undefined, 43 | maxConcurrentUploadParts: undefined 44 | }; 45 | default: 46 | assertUnreachable(data); 47 | } 48 | } 49 | 50 | protected preUpload(_source: BrowserOrWorkerUploadSource): undefined { 51 | return undefined; 52 | } 53 | 54 | protected async postUpload(_init: undefined): Promise { 55 | // NO-OP. 56 | } 57 | 58 | protected getRequestBody(part: UploadPart, blob: BrowserOrWorkerUploadSource): Blob | ArrayBuffer { 59 | return part.range.inclusiveEnd === -1 60 | ? new Blob() 61 | : (blob.value.slice(part.range.inclusiveStart, part.range.inclusiveEnd + 1) as Blob | ArrayBuffer); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/private/UploadManagerFetchUtils.ts: -------------------------------------------------------------------------------- 1 | import { BytescaleApiClientConfig, BytescaleApiClientConfigUtils, UploadPart } from "../public/shared"; 2 | import { AddCancellationHandler } from "./model/AddCancellationHandler"; 3 | 4 | export class UploadManagerFetchUtils { 5 | static async doPutUploadPart( 6 | config: BytescaleApiClientConfig, 7 | part: UploadPart, 8 | content: BodyInit, 9 | contentLength: number, 10 | addCancellationHandler: AddCancellationHandler 11 | ): Promise<{ etag: string | undefined; status: number }> { 12 | const fetchApi = BytescaleApiClientConfigUtils.getFetchApi(config); 13 | 14 | // Configure cancellation: 15 | const controller = new AbortController(); 16 | const signal = controller.signal; 17 | addCancellationHandler(() => controller.abort()); 18 | 19 | const headers: HeadersInit = { 20 | // Required to prevent fetch using "Transfer-Encoding: Chunked" when body is a stream. 21 | "content-length": contentLength.toString() 22 | }; 23 | 24 | const response = await fetchApi(part.uploadUrl, { 25 | method: "PUT", 26 | headers, 27 | body: content, 28 | signal, 29 | duplex: "half", 30 | cache: "no-store" // Required for Next.js's Fetch implementation, which caches POST/PUT requests by default. 31 | }); 32 | 33 | return { 34 | etag: response.headers.get("etag") ?? undefined, 35 | status: response.status 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/private/dtos/AuthSwConfigDto.ts: -------------------------------------------------------------------------------- 1 | import { AuthSwConfigEntryDto } from "./AuthSwConfigEntryDto"; 2 | 3 | export type AuthSwConfigDto = AuthSwConfigEntryDto[]; 4 | -------------------------------------------------------------------------------- /src/private/dtos/AuthSwConfigEntryDto.ts: -------------------------------------------------------------------------------- 1 | import { AuthSwHeaderDto } from "./AuthSwHeaderDto"; 2 | 3 | export interface AuthSwConfigEntryDto { 4 | expires: number | undefined; 5 | headers: AuthSwHeaderDto[]; 6 | urlPrefix: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/private/dtos/AuthSwHeaderDto.ts: -------------------------------------------------------------------------------- 1 | export interface AuthSwHeaderDto { 2 | key: string; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/private/dtos/AuthSwSetConfigDto.ts: -------------------------------------------------------------------------------- 1 | import { AuthSwConfigDto } from "./AuthSwConfigDto"; 2 | 3 | export interface AuthSwSetConfigDto { 4 | config: AuthSwConfigDto; 5 | 6 | /** 7 | * We use a specific name for this type, since you can only register one service worker per scope, meaning it's 8 | * possible the user will want to use their own service worker, which means we'll need to support having the Bytescale 9 | * Auth Service Worker being a component of the user's service worker. Thus, we use a specific name to avoid conflict 10 | * with the user's events. 11 | */ 12 | type: "SET_BYTESCALE_AUTH_CONFIG"; 13 | } 14 | -------------------------------------------------------------------------------- /src/private/dtos/SetAccessTokenRequestDto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sync with: bytescale > edge > SetAccessTokenRequestDto.ts 3 | */ 4 | export interface SetAccessTokenRequestDto { 5 | accessToken: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/private/dtos/SetAccessTokenResponseDto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sync with: upload > edge > SetAccessTokenResponseDto.ts 3 | */ 4 | export interface SetAccessTokenResponseDto { 5 | accessToken: string; 6 | ttlSeconds: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/private/model/AddCancellationHandler.ts: -------------------------------------------------------------------------------- 1 | export type AddCancellationHandler = (cancellationHandler: () => void) => void; 2 | -------------------------------------------------------------------------------- /src/private/model/AuthManagerInterface.ts: -------------------------------------------------------------------------------- 1 | import { BytescaleApiClientConfig } from "../../public/shared"; 2 | 3 | export interface BeginAuthSessionParams { 4 | /** 5 | * The account ID to authorize requests for. 6 | */ 7 | accountId: string; 8 | 9 | /** 10 | * Headers to send to your backend API. 11 | * 12 | * IMPORTANT: do not call 'AuthManager.beginAuthSession' or 'AuthManager.endAuthSession' inside this callback, as this will cause a deadlock. 13 | */ 14 | authHeaders: () => Promise>; 15 | 16 | /** 17 | * The fully-qualified URL for your backend API's auth endpoint (the endpoint that returns a JWT as plain text). 18 | */ 19 | authUrl: string; 20 | 21 | /** 22 | * Optional configuration. 23 | */ 24 | options?: Pick; 25 | 26 | /** 27 | * Enables support for modern browsers that block third-party cookies (like Safari). 28 | * 29 | * The value of this field must be the path to the service worker JavaScript file hosted at the root of your website. 30 | * 31 | * OVERVIEW: 32 | * 33 | * This feature works by running a "service worker" in the background that adds "Authorization" and "Authorization-Token" 34 | * request headers to HTTP requests made to the Bytescale CDN. This allows the Bytescale CDN to authorize requests 35 | * to private files using JWTs issued by your application. Historically, these requests have been authorized using 36 | * JWT cookies (i.e. JWTs sent to the Bytescale CDN via the "Cookies" header). However, modern browsers are starting 37 | * to block these cookies, meaning "Authorization" request headers must be used instead. Authorization headers can 38 | * only be added to requests originating from page elements like "" elements through the use of service workers. 39 | * 40 | * INSTRUCTIONS: 41 | * 42 | * 1. Your JWT must include the 'accountId' field at the root of the 'payload' section of the JWT (i.e. next to the 'exp' field). 43 | * 44 | * 2. Create a JavaScript file that contains the following line: 45 | * 46 | * importScripts("https://js.bytescale.com/auth-sw/v1"); 47 | * 48 | * 3. Host this JavaScript file from your website: 49 | * 50 | * 3a. It MUST be under the ROOT directory of your website. 51 | * (e.g. "/bytescale-auth-sw.js") 52 | * 53 | * 3b. It MUST be on the SAME DOMAIN as your website. 54 | * (e.g. "www.example.com" and not "assets.example.com") 55 | * 56 | * 4. Specify the absolute path to your JavaScript file in the 'beginAuthSession' call. 57 | * (e.g. { ..., serviceWorkerScript: "/bytescale-auth-sw.js" }) 58 | * 59 | * Examples: 60 | * 61 | * - CORRECT: "/bytescale-auth-sw.js" 62 | * - INCORRECT: "bytescale-auth-sw.js" 63 | * - INCORRECT: "/scripts/bytescale-auth-sw.js" 64 | * - INCORRECT: "https://example.com/bytescale-auth-sw.js" 65 | * 66 | * Why does the script need to be hosted on the website's domain, under the root directory?: 67 | * 68 | * Service workers can only interact with events raised by pages at the same level or below them, hence why your 69 | * script must be hosted on your website's domain in the root directory. 70 | */ 71 | serviceWorkerScript?: string; 72 | } 73 | 74 | export interface AuthManagerInterface { 75 | /** 76 | * Begins a JWT auth session with the Bytescale API and Bytescale CDN. 77 | * 78 | * Specifically, calling this method will cause the SDK to periodically acquire a JWT from your JWT endpoint. The SDK will then automatically include this JWT in all subsequent Bytescale API requests (via the 'authorization-token' request header) and also in all Bytescale CDN download requests (via a session cookie, or an 'authorization' header if service workers are being used). 79 | * 80 | * You can only call this method if 'isAuthSessionActive() === false', else an error will be returned. 81 | * 82 | * You can only call this method in the browser (not Node.js). 83 | * 84 | * You should call this method after the user has signed-in to your web app. 85 | * 86 | * After calling this method: 87 | * 88 | * 1) You must await the returned promise before attempting to perform any downloads or API operations that require authentication. 89 | * 90 | * The auth process works as follows: 91 | * 92 | * 1) After you call this method, the AuthManager will periodically fetch a JWT in plain text from the given 'authUrl'. 93 | * 94 | * 2) The JWT will be added as a request header via 'authorization-token' to all Bytescale API requests made via this SDK. This allows the user to upload private files and perform administrative operations permitted by the JWT, such as deleting files, etc. 95 | * 96 | * 3) The JWT will be also saved to a cookie scoped to the Bytescale CDN if service workers are not being used (see the 'serviceWorkerScript' field). This allows the user to view private files via the URL in the browser, including elements on the page that reference private images, etc. If service workers are being used, then the JWT will be submitted to the Bytescale CDN via the 'authorization' header instead. 97 | */ 98 | beginAuthSession: (params: BeginAuthSessionParams) => Promise; 99 | 100 | /** 101 | * Ends an authenticated Bytescale API and Bytescale CDN session. 102 | * 103 | * This method idempotent, meaning you can call it regardless of the value of 'isAuthSessionActive()', and no error will be thrown. 104 | * 105 | * You can only call this method in the browser (not Node.js). 106 | * 107 | * You should call this method after the user has signed-out of your web app. 108 | */ 109 | endAuthSession: () => Promise; 110 | 111 | /** 112 | * Checks if an authenticated Bytescale API and Bytescale CDN session is active. 113 | */ 114 | isAuthSessionActive: () => boolean; 115 | 116 | /** 117 | * Checks if an authenticated Bytescale API and Bytescale CDN session is active and ready to authenticate HTTP requests. 118 | */ 119 | isAuthSessionReady: () => boolean; 120 | } 121 | -------------------------------------------------------------------------------- /src/private/model/AuthSession.ts: -------------------------------------------------------------------------------- 1 | import { BeginAuthSessionParams } from "./AuthManagerInterface"; 2 | import { ServiceWorkerConfig } from "./ServiceWorkerConfig"; 3 | 4 | export interface AuthSession { 5 | accessToken: string | undefined; 6 | accessTokenRefreshHandle: number | undefined; 7 | authServiceWorker: ServiceWorkerConfig | undefined; 8 | isActive: boolean; 9 | params: BeginAuthSessionParams; 10 | } 11 | -------------------------------------------------------------------------------- /src/private/model/OnPartProgress.ts: -------------------------------------------------------------------------------- 1 | export type OnPartProgress = (bytesSentTotalForPart: number) => void; 2 | -------------------------------------------------------------------------------- /src/private/model/PreUploadInfo.ts: -------------------------------------------------------------------------------- 1 | export interface PreUploadInfo { 2 | maxConcurrentUploadParts: number; 3 | mime: string | undefined; 4 | originalFileName: string | undefined; 5 | size: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/private/model/PutUploadPartResult.ts: -------------------------------------------------------------------------------- 1 | export interface PutUploadPartResult { 2 | etag: string | undefined; 3 | status: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/private/model/ServiceWorkerConfig.ts: -------------------------------------------------------------------------------- 1 | export type ServiceWorkerConfig = ServiceWorkerConfigUninitialized | ServiceWorkerConfigInitialized; 2 | 3 | export interface ServiceWorkerConfigUninitialized { 4 | serviceWorkerScript: string; 5 | type: "Uninitialized"; 6 | } 7 | 8 | export interface ServiceWorkerConfigInitialized { 9 | serviceWorkerScope: string | undefined; // Scope can still be undefined even when initialized. 10 | serviceWorkerScript: string; 11 | type: "Initialized"; 12 | } 13 | -------------------------------------------------------------------------------- /src/private/model/ServiceWorkerInitStatus.ts: -------------------------------------------------------------------------------- 1 | import { ServiceWorkerConfigInitialized } from "./ServiceWorkerConfig"; 2 | 3 | export interface ServiceWorkerInitStatus { 4 | config: ServiceWorkerConfigInitialized; 5 | serviceWorker: ServiceWorker; 6 | } 7 | -------------------------------------------------------------------------------- /src/private/model/UploadManagerInterface.ts: -------------------------------------------------------------------------------- 1 | import { UploadManagerParams } from "../../public/shared/CommonTypes"; 2 | import { FileDetails } from "../../public/shared/generated"; 3 | 4 | export interface UploadManagerInterface { 5 | upload: (request: UploadManagerParams) => Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/private/model/UploadSourceProcessed.ts: -------------------------------------------------------------------------------- 1 | import { BlobLike } from "../../public/shared/CommonTypes"; 2 | import { NodeChunkedStream } from "../NodeChunkedStream"; 3 | 4 | export interface UploadSourceBlob { 5 | type: "Blob"; 6 | value: BlobLike; 7 | } 8 | export interface UploadSourceBuffer { 9 | type: "Buffer"; 10 | value: Buffer; 11 | } 12 | export interface UploadSourceArrayBuffer { 13 | type: "ArrayBuffer"; 14 | value: ArrayBuffer; 15 | } 16 | export interface UploadSourceStream { 17 | type: "Stream"; 18 | value: NodeChunkedStream; 19 | } 20 | 21 | export type UploadSourceProcessedIsomorphic = UploadSourceBlob | UploadSourceArrayBuffer; 22 | export type UploadSourceProcessedBrowser = UploadSourceProcessedIsomorphic; 23 | export type UploadSourceProcessedWorker = UploadSourceProcessedIsomorphic; 24 | export type UploadSourceProcessedNode = UploadSourceStream | UploadSourceBuffer | UploadSourceProcessedIsomorphic; 25 | -------------------------------------------------------------------------------- /src/public/browser/AuthManagerBrowser.ts: -------------------------------------------------------------------------------- 1 | import { AuthManagerInterface, BeginAuthSessionParams } from "../../private/model/AuthManagerInterface"; 2 | import { AuthSessionState } from "../../private/AuthSessionState"; 3 | import { ConsoleUtils } from "../../private/ConsoleUtils"; 4 | import { BaseAPI, BytescaleApiClientConfigUtils } from "../shared/generated"; 5 | import { AuthSession } from "../../private/model/AuthSession"; 6 | import { SetAccessTokenRequestDto } from "../../private/dtos/SetAccessTokenRequestDto"; 7 | import { SetAccessTokenResponseDto } from "../../private/dtos/SetAccessTokenResponseDto"; 8 | import { AuthSwSetConfigDto } from "../../private/dtos/AuthSwSetConfigDto"; 9 | import { ServiceWorkerUtils } from "../../private/ServiceWorkerUtils"; 10 | import { Scheduler } from "../../private/Scheduler"; 11 | 12 | class AuthManagerImpl implements AuthManagerInterface { 13 | private readonly authSessionMutex; 14 | private readonly serviceWorkerScriptFieldName: keyof BeginAuthSessionParams = "serviceWorkerScript"; 15 | private readonly contentType = "content-type"; 16 | private readonly contentTypeJson = "application/json"; 17 | private readonly contentTypeText = "text/plain"; 18 | private readonly minJwtTtlSeconds = 10; 19 | private readonly retryAuthAfterErrorSeconds = 5; 20 | private readonly refreshBeforeExpirySeconds = 20; 21 | private readonly scheduler = new Scheduler(); 22 | 23 | constructor(private readonly serviceWorkerUtils: ServiceWorkerUtils) { 24 | this.authSessionMutex = AuthSessionState.getMutex(); 25 | } 26 | 27 | isAuthSessionActive(): boolean { 28 | return AuthSessionState.getSession() !== undefined; 29 | } 30 | 31 | isAuthSessionReady(): boolean { 32 | return AuthSessionState.getSession()?.accessToken !== undefined; 33 | } 34 | 35 | async beginAuthSession(params: BeginAuthSessionParams): Promise { 36 | const session = await this.authSessionMutex.safe(async () => { 37 | // We check both 'session' and 'sessionDisposing' here, as we don't want to call 'beginAuthSession' until the session is fully disposed. 38 | if (this.isAuthSessionActive()) { 39 | throw new Error( 40 | "Auth session already active. Please call 'await endAuthSession()' and then call 'await beginAuthSession(...)' to start a new auth session." 41 | ); 42 | } 43 | 44 | const newSession: AuthSession = { 45 | accessToken: undefined, 46 | accessTokenRefreshHandle: undefined, 47 | params, 48 | isActive: true, 49 | authServiceWorker: 50 | params.serviceWorkerScript !== undefined && this.serviceWorkerUtils.canUseServiceWorkers() 51 | ? { 52 | serviceWorkerScript: params.serviceWorkerScript, 53 | type: "Uninitialized" 54 | } 55 | : undefined 56 | }; 57 | 58 | AuthSessionState.setSession(newSession); 59 | 60 | return newSession; 61 | }); 62 | 63 | // IMPORTANT: must be called outside the above, else re-entrant deadlock will occur. 64 | await this.refreshAccessToken(session); 65 | } 66 | 67 | async endAuthSession(): Promise { 68 | await this.authSessionMutex.safe(async () => { 69 | const session = AuthSessionState.getSession(); 70 | if (session === undefined) { 71 | return; 72 | } 73 | 74 | AuthSessionState.setSession(undefined); 75 | session.isActive = false; 76 | 77 | if (session.accessTokenRefreshHandle !== undefined) { 78 | this.scheduler.unschedule(session.accessTokenRefreshHandle); 79 | } 80 | 81 | await this.deleteAccessToken(session.params); 82 | 83 | if (session.authServiceWorker !== undefined) { 84 | // Prevent service worker from authorizing subsequent requests. 85 | await this.serviceWorkerUtils.sendMessage( 86 | { type: "SET_BYTESCALE_AUTH_CONFIG", config: [] }, 87 | session.authServiceWorker, 88 | this.serviceWorkerScriptFieldName 89 | ); 90 | } 91 | }); 92 | } 93 | 94 | private async refreshAccessToken(session: AuthSession): Promise { 95 | await this.authSessionMutex.safe(async () => { 96 | if (!session.isActive) { 97 | return; 98 | } 99 | 100 | const secondsFromNow = (seconds: number): number => Date.now() + seconds * 1000; 101 | 102 | let expires = secondsFromNow(this.retryAuthAfterErrorSeconds); 103 | 104 | try { 105 | const jwt = await this.getAccessToken(session.params, await session.params.authHeaders()); 106 | 107 | // We don't use cookie-based auth if the browser supports service worker-based auth, as using both will cause 108 | // confusion for us in the future (i.e. we may question "do we need to use both together? was there a reason?"). 109 | // Also: if the user has omitted "allowedOrigins" from their JWT, then service worker-based auth is more secure 110 | // than cookie-based auth, which is another reason to prevent these cookies from being set unless required. 111 | const setCookie = session.authServiceWorker === undefined; 112 | 113 | const setTokenResult = await this.setAccessToken(session.params, jwt, setCookie); 114 | 115 | if (session.authServiceWorker !== undefined) { 116 | await this.serviceWorkerUtils.sendMessage( 117 | { 118 | type: "SET_BYTESCALE_AUTH_CONFIG", 119 | config: [ 120 | { 121 | headers: [{ key: "Authorization", value: `Bearer ${jwt}` }], 122 | expires: secondsFromNow(setTokenResult.ttlSeconds), 123 | urlPrefix: `${this.getCdnUrl(session.params)}/${session.params.accountId}/` 124 | } 125 | ] 126 | }, 127 | session.authServiceWorker, 128 | this.serviceWorkerScriptFieldName 129 | ); 130 | 131 | // Allow time for the service worker to receive and process the message. Since this is asynchronous and not 132 | // synchronized, we need to wait for a sufficient amount of time to ensure the service worker is ready to 133 | // authenticate requests, so that after 'beginAuthSession' completes, users can start making requests. 134 | await new Promise(resolve => setTimeout(resolve, 100)); 135 | } 136 | 137 | const desiredTtl = setTokenResult.ttlSeconds - this.refreshBeforeExpirySeconds; 138 | const actualTtl = Math.max(desiredTtl, this.minJwtTtlSeconds); 139 | if (desiredTtl !== actualTtl) { 140 | ConsoleUtils.warn(`JWT expiration is too short: waiting for ${actualTtl} seconds before refreshing.`); 141 | } 142 | 143 | expires = secondsFromNow(actualTtl); 144 | 145 | // Set this at the end, as it's also used to signal 'isAuthSessionReady', so must be set after configuring the Service Worker, etc. 146 | session.accessToken = setTokenResult.accessToken; 147 | } catch (e) { 148 | // Use 'warn' instead of 'error' since this happens frequently, i.e. user goes through a tunnel, and some customers report these errors to systems like Sentry, so we don't want to spam. 149 | ConsoleUtils.warn(`Unable to refresh JWT access token: ${e as string}`); 150 | } finally { 151 | // 'setTimeout' can be paused (e.g., during hibernation), risking JWT expiration before it triggers. We use a 152 | // scheduler to check wall-clock time every second and execute the callback at the scheduled time (below). 153 | session.accessTokenRefreshHandle = this.scheduler.schedule(expires, () => { 154 | this.refreshAccessToken(session).then( 155 | () => {}, 156 | // Should not occur, as this method shouldn't throw errors. 157 | e => ConsoleUtils.error(`Unexpected error when refreshing JWT access token: ${e as string}`) 158 | ); 159 | }); 160 | } 161 | }); 162 | } 163 | 164 | private getAccessTokenUrl(params: BeginAuthSessionParams, setCookie: boolean): string { 165 | return `${this.getCdnUrl(params)}/api/v1/access_tokens/${params.accountId}?set-cookie=${ 166 | setCookie ? "true" : "false" 167 | }`; 168 | } 169 | 170 | private getCdnUrl(params: BeginAuthSessionParams): string { 171 | return BytescaleApiClientConfigUtils.getCdnUrl(params.options ?? {}); 172 | } 173 | 174 | private async deleteAccessToken(params: BeginAuthSessionParams): Promise { 175 | await BaseAPI.fetch( 176 | this.getAccessTokenUrl(params, true), 177 | { 178 | method: "DELETE", 179 | credentials: "include", // Required, else Bytescale CDN response's `Set-Cookie` header will be silently ignored. 180 | headers: {} 181 | }, 182 | { 183 | isBytescaleApi: true, 184 | fetchApi: params.options?.fetchApi 185 | } 186 | ); 187 | } 188 | 189 | private async setAccessToken( 190 | params: BeginAuthSessionParams, 191 | jwt: string, 192 | setCookie: boolean 193 | ): Promise { 194 | const request: SetAccessTokenRequestDto = { 195 | accessToken: jwt 196 | }; 197 | const response = await BaseAPI.fetch( 198 | this.getAccessTokenUrl(params, setCookie), 199 | { 200 | method: "PUT", 201 | credentials: "include", // Required, else Bytescale CDN response's `Set-Cookie` header will be silently ignored. 202 | headers: { 203 | [this.contentType]: this.contentTypeJson 204 | }, 205 | body: JSON.stringify(request) 206 | }, 207 | { 208 | isBytescaleApi: true, 209 | fetchApi: params.options?.fetchApi 210 | } 211 | ); 212 | 213 | return await response.json(); 214 | } 215 | 216 | private async getAccessToken(params: BeginAuthSessionParams, headers: Record): Promise { 217 | const endpointName = "Your auth API endpoint"; 218 | const requiredContentType = this.contentTypeText; 219 | const result = await BaseAPI.fetch( 220 | params.authUrl, 221 | { 222 | method: "GET", 223 | headers 224 | }, 225 | { 226 | isBytescaleApi: false, 227 | fetchApi: params.options?.fetchApi 228 | } 229 | ); 230 | 231 | const actualContentType = result.headers.get(this.contentType) ?? ""; 232 | 233 | // Support content types like "text/plain; charset=utf-8" and "text/plain" 234 | if (actualContentType.split(";")[0] !== requiredContentType) { 235 | throw new Error( 236 | `${endpointName} returned "${actualContentType}" for the ${this.contentType} response header, but the Bytescale SDK requires "${requiredContentType}".` 237 | ); 238 | } 239 | 240 | const jwt = await result.text(); 241 | 242 | if (jwt.length === 0) { 243 | throw new Error(`${endpointName} returned an empty string. Please return a valid JWT instead.`); 244 | } 245 | 246 | if (jwt.trim().length !== jwt.length) { 247 | // Whitespace can be a nightmare to spot/debug, so we fail early here. 248 | throw new Error(`${endpointName} returned whitespace around the JWT, please remove it.`); 249 | } 250 | 251 | return jwt; 252 | } 253 | } 254 | 255 | /** 256 | * Alternative way of implementing a static class (i.e. all methods static). We do this so we can use a interface on the class (interfaces can't define static methods). 257 | */ 258 | export const AuthManager = new AuthManagerImpl(new ServiceWorkerUtils()); 259 | -------------------------------------------------------------------------------- /src/public/browser/UploadManagerBrowser.ts: -------------------------------------------------------------------------------- 1 | import { UploadSourceProcessedBrowser } from "../../private/model/UploadSourceProcessed"; 2 | import { CancelledError, UploadPart } from "../shared/generated"; 3 | import { PutUploadPartResult } from "../../private/model/PutUploadPartResult"; 4 | import { AddCancellationHandler } from "../../private/model/AddCancellationHandler"; 5 | import { UploadManagerBrowserWorkerBase } from "../../private/UploadManagerBrowserWorkerBase"; 6 | 7 | export class UploadManager extends UploadManagerBrowserWorkerBase { 8 | protected async doPutUploadPart( 9 | part: UploadPart, 10 | _contentLength: number, 11 | source: UploadSourceProcessedBrowser, 12 | onProgress: (bytesSentDelta: number) => void, 13 | addCancellationHandler: AddCancellationHandler 14 | ): Promise { 15 | const xhr = new XMLHttpRequest(); 16 | let pending = true; 17 | addCancellationHandler(() => { 18 | if (pending) { 19 | xhr.abort(); 20 | } 21 | }); 22 | 23 | try { 24 | return await new Promise((resolve, reject) => { 25 | xhr.upload.addEventListener( 26 | "progress", 27 | evt => { 28 | if (evt.lengthComputable) { 29 | onProgress(evt.loaded); 30 | } 31 | }, 32 | false 33 | ); 34 | xhr.addEventListener("load", () => { 35 | const etag = xhr.getResponseHeader("etag") ?? undefined; 36 | resolve({ etag, status: xhr.status }); 37 | }); 38 | 39 | xhr.onabort = () => reject(new CancelledError()); 40 | xhr.onerror = () => reject(new Error("File upload error.")); 41 | xhr.ontimeout = () => reject(new Error("File upload timeout.")); 42 | 43 | xhr.open("PUT", part.uploadUrl); 44 | xhr.send(this.getRequestBody(part, source)); 45 | }); 46 | } finally { 47 | pending = false; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/public/browser/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UploadManagerBrowser"; 2 | export * from "./AuthManagerBrowser"; 3 | -------------------------------------------------------------------------------- /src/public/node/AuthManagerNode.ts: -------------------------------------------------------------------------------- 1 | import { AuthManagerInterface, BeginAuthSessionParams } from "../../private/model/AuthManagerInterface"; 2 | import { EnvChecker } from "../../private/EnvChecker"; 3 | 4 | class AuthManagerImpl implements AuthManagerInterface { 5 | async beginAuthSession(_params: BeginAuthSessionParams): Promise { 6 | throw EnvChecker.methodRequiresBrowser("beginAuthSession"); 7 | } 8 | 9 | async endAuthSession(): Promise { 10 | throw EnvChecker.methodRequiresBrowser("endAuthSession"); 11 | } 12 | 13 | isAuthSessionActive(): boolean { 14 | return false; 15 | } 16 | 17 | isAuthSessionReady(): boolean { 18 | return false; 19 | } 20 | } 21 | 22 | /** 23 | * Alternative way of implementing a static class (i.e. all methods static). We do this so we can use a interface on the class (interfaces can't define static methods). 24 | */ 25 | export const AuthManager = new AuthManagerImpl(); 26 | -------------------------------------------------------------------------------- /src/public/node/ProgressStream.ts: -------------------------------------------------------------------------------- 1 | import { Transform, TransformCallback, TransformOptions } from "stream"; 2 | 3 | interface ProgressStreamOptions extends TransformOptions { 4 | /** 5 | * Optional callback to handle progress updates. 6 | * @param totalBytesTransferred - The total number of bytes transferred so far. 7 | */ 8 | onProgress?: (totalBytesTransferred: number) => void; 9 | } 10 | 11 | export class ProgressStream extends Transform { 12 | private readonly onProgress: ((bytesTransferred: number) => void) | undefined; 13 | private totalBytes: number = 0; 14 | 15 | constructor(options?: ProgressStreamOptions) { 16 | super(options); 17 | this.onProgress = options?.onProgress; 18 | } 19 | 20 | _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { 21 | this.totalBytes += chunk.length; 22 | 23 | // Invoke the progress callback if provided 24 | if (this.onProgress !== undefined) { 25 | this.onProgress(this.totalBytes); 26 | } 27 | 28 | // Pass the chunk along 29 | this.push(chunk); 30 | callback(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/public/node/UploadManagerNode.ts: -------------------------------------------------------------------------------- 1 | import { UploadManagerBase } from "../../private/UploadManagerBase"; 2 | import { UploadSourceProcessedNode } from "../../private/model/UploadSourceProcessed"; 3 | import { NodeChunkedStream } from "../../private/NodeChunkedStream"; 4 | import { PreUploadInfo } from "../../private/model/PreUploadInfo"; 5 | import { UploadPart } from "../shared/generated"; 6 | import { assertUnreachable } from "../../private/TypeUtils"; 7 | import { Blob } from "buffer"; 8 | import { AddCancellationHandler } from "../../private/model/AddCancellationHandler"; 9 | import { StreamUtils } from "../../private/StreamUtils"; 10 | import { BlobLike, UploadManagerParams, UploadSource } from "../shared/CommonTypes"; 11 | import { UploadManagerFetchUtils } from "../../private/UploadManagerFetchUtils"; 12 | import { ProgressStream } from "./ProgressStream"; 13 | 14 | type UploadManagerNodeInit = 15 | | undefined 16 | | { 17 | chunkedStream: NodeChunkedStream; 18 | chunkedStreamPromise: Promise; 19 | }; 20 | 21 | export class UploadManager extends UploadManagerBase { 22 | protected processUploadSource(data: UploadSource): UploadSourceProcessedNode { 23 | if (typeof data === "string") { 24 | // 'Blob' must be from 'buffer' as we're in a node context here, so isn't globally available. 25 | return { type: "Blob", value: new Blob([data], { type: this.stringMimeType }) }; 26 | } 27 | if ((data as Partial).on !== undefined) { 28 | return { type: "Stream", value: new NodeChunkedStream(data as NodeJS.ReadableStream) }; 29 | } 30 | if ((data as Partial).subarray !== undefined) { 31 | return { type: "Buffer", value: data as Buffer }; 32 | } 33 | if ((data as Partial).byteLength !== undefined) { 34 | return { type: "ArrayBuffer", value: data as ArrayBuffer }; 35 | } 36 | if ((data as Partial).size !== undefined) { 37 | return { type: "Blob", value: data as BlobLike }; 38 | } 39 | 40 | throw new Error( 41 | `Unsupported type for 'data' parameter. Please provide a String, Blob, Buffer, ArrayBuffer, or ReadableStream (Node.js).` 42 | ); 43 | } 44 | 45 | protected getPreUploadInfoPartial( 46 | request: UploadManagerParams, 47 | data: UploadSourceProcessedNode 48 | ): Partial & { size: number } { 49 | switch (data.type) { 50 | case "Blob": 51 | return this.getBlobInfo(data); 52 | case "Buffer": 53 | case "ArrayBuffer": 54 | return { 55 | mime: undefined, 56 | size: data.value.byteLength, 57 | originalFileName: undefined, 58 | maxConcurrentUploadParts: undefined 59 | }; 60 | case "Stream": 61 | if (request.size === undefined) { 62 | throw new Error("You must include the 'size' parameter when using a stream for the 'data' parameter."); 63 | } 64 | return { 65 | mime: undefined, 66 | size: request.size, 67 | originalFileName: undefined, 68 | maxConcurrentUploadParts: 1 // Uploading from a stream concurrently is complex, so we serialize it. 69 | }; 70 | default: 71 | assertUnreachable(data); 72 | } 73 | } 74 | 75 | protected preUpload(source: UploadSourceProcessedNode): UploadManagerNodeInit { 76 | if (source.type !== "Stream") { 77 | return undefined; 78 | } 79 | 80 | const chunkedStream = source.value; 81 | const chunkedStreamPromise = chunkedStream.runChunkPipeline(); 82 | return { chunkedStreamPromise, chunkedStream }; 83 | } 84 | 85 | protected async postUpload(init: UploadManagerNodeInit): Promise { 86 | if (init === undefined) { 87 | return; 88 | } 89 | 90 | init.chunkedStream.finishedConsuming(); 91 | 92 | // Raise any errors from the stream chunking task. 93 | await init.chunkedStreamPromise; 94 | } 95 | 96 | protected async doPutUploadPart( 97 | part: UploadPart, 98 | contentLength: number, 99 | source: UploadSourceProcessedNode, 100 | onProgress: (totalBytesTransferred: number) => void, 101 | addCancellationHandler: AddCancellationHandler 102 | ): Promise<{ etag: string | undefined; status: number }> { 103 | const inputStream = await this.sliceDataForRequest(source, part); 104 | const progressStream = new ProgressStream({ onProgress }); 105 | inputStream.pipe(progressStream); 106 | 107 | return await UploadManagerFetchUtils.doPutUploadPart( 108 | this.config, 109 | part, 110 | this.coerceRequestBody(progressStream), 111 | contentLength, 112 | addCancellationHandler 113 | ); 114 | } 115 | 116 | private coerceRequestBody(data: NodeJS.ReadableStream): BodyInit { 117 | return data as any; // node-fetch supports 'NodeJS.ReadableStream' 118 | } 119 | 120 | private async sliceDataForRequest(data: UploadSourceProcessedNode, part: UploadPart): Promise { 121 | if (part.range.inclusiveEnd === -1) { 122 | return StreamUtils.empty(); 123 | } 124 | 125 | const start = part.range.inclusiveStart; 126 | const endExclusive = part.range.inclusiveEnd + 1; 127 | const partSize = endExclusive - start; 128 | 129 | switch (data.type) { 130 | case "Blob": 131 | return StreamUtils.fromArrayBuffer(await (data.value.slice(start, endExclusive) as Blob).arrayBuffer()); 132 | case "ArrayBuffer": 133 | return StreamUtils.fromArrayBuffer(data.value.slice(start, endExclusive)); 134 | case "Buffer": 135 | return StreamUtils.fromBuffer(data.value.subarray(start, endExclusive)); 136 | case "Stream": 137 | return data.value.take(partSize); // Assumes stream is read using one worker (which it is). 138 | default: 139 | assertUnreachable(data); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/public/node/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UploadManagerNode"; 2 | export * from "./AuthManagerNode"; 3 | -------------------------------------------------------------------------------- /src/public/shared/CommonTypes.ts: -------------------------------------------------------------------------------- 1 | import { BeginMultipartUploadRequest, FileDetails } from "./generated"; 2 | 3 | /** 4 | * Workaround for tsc aliases, where we cannot export implementation-less modules in our dists. 5 | */ 6 | export const CommonTypesNoOp = false; 7 | 8 | export interface BlobLike { 9 | readonly name?: string; 10 | readonly size: number; 11 | readonly type?: string; 12 | // eslint-disable-next-line @typescript-eslint/member-ordering 13 | slice: (start?: number, end?: number) => BlobLike; 14 | } 15 | 16 | export interface CancellationToken { 17 | isCancelled: boolean; 18 | } 19 | 20 | export interface UploadProgress { 21 | bytesSent: number; 22 | bytesTotal: number; 23 | progress: number; 24 | } 25 | 26 | export type UploadResult = Omit & { 27 | /** 28 | * The file's ETag, short for "entity tag", reflects the file's version and changes whenever the file is modified. 29 | */ 30 | etag: string; 31 | }; 32 | 33 | export type UploadSource = NodeJS.ReadableStream | BlobLike | Buffer | string; 34 | 35 | export interface UploadManagerParams extends Omit { 36 | cancellationToken?: CancellationToken; 37 | data: UploadSource; 38 | maxConcurrentUploadParts?: number; 39 | onProgress?: (status: UploadProgress) => void; 40 | 41 | /** 42 | * Only required if 'data' is a 'ReadableStream'. 43 | */ 44 | size?: number; 45 | } 46 | -------------------------------------------------------------------------------- /src/public/shared/UrlBuilder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | KeyValuePair, 3 | NonDeprecatedCommonQueryParams, 4 | UrlBuilderOptions, 5 | UrlBuilderOptionsTransformationOnly, 6 | UrlBuilderParams, 7 | UrlBuilderTransformationApiOptions, 8 | UrlBuilderTransformationOptions 9 | } from "./UrlBuilderTypes"; 10 | import { BytescaleApiClientConfigUtils, encodeBytescaleQuerystringKVP } from "./generated"; 11 | import { isDefinedEntry } from "../../private/TypeUtils"; 12 | import { FilePathUtils } from "../../private/FilePathUtils"; 13 | 14 | export class UrlBuilder { 15 | /** 16 | * Builds a URL to either a raw file or a transformed file. 17 | * 18 | * Example 1) Getting a publicly-accessible raw file URL: 19 | * 20 | * new UrlBuilder().url({ accountId: "1234abc", filePath: "/example.jpg" }) 21 | * 22 | * Example 2) Getting a publicly-accessible image URL, resized to 500x500: 23 | * 24 | * new UrlBuilder().url({ accountId: "1234abc", filePath: "/example.jpg", options: { transformation: { type: "image", params: { w: 500, h: 500, fit: "crop" } } } }) 25 | * 26 | * Example 3) Getting a privately-accessible image URL, resized to 500x500 (requires 'AuthManager.beginAuthSession' to be called before accessing the URL): 27 | * 28 | * new UrlBuilder().url({ accountId: "1234abc", filePath: "/example.jpg", options: { transformation: { type: "image", params: { w: 500, h: 500, fit: "crop" } }, auth: true } }) 29 | * 30 | * Example 4) Getting a publicly-accessible image URL, resized using a transformation preset called "thumbnail" that was created manually in the Bytescale Dashboard: 31 | * 32 | * new UrlBuilder().url({ accountId: "1234abc", filePath: "/example.jpg", options: { transformation: { type: "preset", preset: "thumbnail" } } }) 33 | */ 34 | static url(params: UrlBuilderParams): string { 35 | return params.options?.transformation === undefined 36 | ? this.raw(params) 37 | : this.transformation(params, params.options); 38 | } 39 | 40 | private static raw(params: UrlBuilderParams): string { 41 | const baseUrl = this.getBaseUrl(params, "raw"); 42 | const commonParams = this.getCommonQueryParams(params.options ?? {}); 43 | return this.addQueryParams(baseUrl, commonParams); 44 | } 45 | 46 | private static transformation(params: UrlBuilderParams, trans: UrlBuilderTransformationOptions): string { 47 | const baseUrl = this.getBaseUrl( 48 | params, 49 | trans.transformation === "preset" ? trans.transformationPreset : trans.transformation 50 | ); 51 | const transParams = trans.transformation === "preset" ? [] : this.getTransformationParams(trans); 52 | const commonParams = this.getCommonQueryParams(params.options ?? {}); 53 | const transCommonParams = this.getCommonTransformationQueryParams(trans); 54 | 55 | // This format puts "artifact" at the end, which isn't required, but is convention. 56 | return this.addQueryParams(baseUrl, [...transParams, ...commonParams, ...transCommonParams]); 57 | } 58 | 59 | private static getBaseUrl(params: UrlBuilderParams, prefix: string): string { 60 | const cdnUrl = params.options?.cdnUrl ?? BytescaleApiClientConfigUtils.defaultCdnUrl; 61 | const filePathEncoded = FilePathUtils.encodeFilePath(params.filePath); 62 | return `${cdnUrl}/${params.accountId}/${prefix}${filePathEncoded}`; 63 | } 64 | 65 | private static getCommonTransformationQueryParams(trans: UrlBuilderTransformationOptions): KeyValuePair[] { 66 | return this.makeQueryParams( 67 | { 68 | cacheOnly: null, 69 | cachePermanently: null, 70 | // Keep this as the last param: this is required for certain transformations, such as async HLS jobs. For example, 71 | // given an artifact '!f=hls-h264&artifact=/video.m3u8' that returns a master M3U8 playlist containing relative 72 | // links to child M3U8 playlists (e.g. 'child1.m3u8'), when the child URLs inside the master M3U8 file are resolved 73 | // by the browser, the 'child1.m3u8' path essentially replaces everything after the '/' on the master M3U8 URL. 74 | // Thus, if query params existed after the 'artifact' param, they would be wiped out, causing the child M3U8 75 | // playlist to suddenly reference a different transformation. 76 | artifact: null 77 | }, 78 | { 79 | cacheOnly: "cache_only", 80 | cachePermanently: "cache_perm" 81 | } 82 | )(trans); 83 | } 84 | 85 | private static getCommonQueryParams(params: UrlBuilderOptions): KeyValuePair[] { 86 | return this.makeQueryParams( 87 | { 88 | cache: null, 89 | cacheTtl: null, 90 | cacheTtl404: null, 91 | version: null, 92 | forceDownloadPrompt: null 93 | }, 94 | { 95 | cacheTtl: "cache_ttl", 96 | cacheTtl404: "cache_ttl_404", 97 | forceDownloadPrompt: "download" 98 | } 99 | )(params); 100 | } 101 | 102 | /** 103 | * Masks the querystring params per the 'keys' array. 104 | * 105 | * Order sensitive: querystring params will appear per the order of the 'keys' array. 106 | */ 107 | private static makeQueryParams( 108 | keyPrototype: Record, 109 | keyOverrides: Partial> 110 | ): (data: Partial>) => KeyValuePair[] { 111 | return data => { 112 | const result: KeyValuePair[] = []; 113 | const keys = Object.keys(keyPrototype) as T[]; 114 | keys.forEach(key => { 115 | const value = data[key]; 116 | if (value !== undefined) { 117 | result.push([keyOverrides[key] ?? key, value.toString()]); 118 | } 119 | }); 120 | return result; 121 | }; 122 | } 123 | 124 | private static getTransformationParams(trans: UrlBuilderTransformationApiOptions): KeyValuePair[] { 125 | const params = trans.transformationParams; 126 | if (params === undefined) { 127 | return []; 128 | } 129 | 130 | const serializeObj = (obj: any): KeyValuePair[] => 131 | Object.entries(obj) 132 | .filter(isDefinedEntry) 133 | .map(([key, value]) => [key, (value as string | number | boolean).toString()]); 134 | 135 | return Array.isArray(params) ? params.flatMap(serializeObj) : serializeObj(params); 136 | } 137 | 138 | private static addQueryParams(baseUrl: string, params: KeyValuePair[]): string { 139 | if (params.length === 0) { 140 | return baseUrl; 141 | } 142 | return `${baseUrl}?${params.map(([key, value]) => encodeBytescaleQuerystringKVP(key, value)).join("&")}`; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/public/shared/UrlBuilderTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Workaround for tsc aliases, where we cannot export implementation-less modules in our dists. 3 | */ 4 | export const UrlBuilderTypesNoOp = false; 5 | 6 | export type ParameterGroup = Record; 7 | 8 | export type KeyValuePair = [string, string]; 9 | 10 | export interface UrlBuilderParams { 11 | /** 12 | * The account that manages the file. 13 | */ 14 | accountId: string; 15 | 16 | /** 17 | * The file to download. 18 | * 19 | * Must begin with: "/" 20 | */ 21 | filePath: string; 22 | 23 | /** 24 | * Optional parameters to control how the URL is constructed. 25 | */ 26 | options?: UrlBuilderOptions; 27 | } 28 | 29 | export type UrlBuilderOptions = UrlBuilderOptionsRaw | UrlBuilderTransformationOptions; 30 | 31 | export type UrlBuilderTransformationOptions = UrlBuilderTransformationApiOptions | UrlBuilderOptionsPreset; 32 | 33 | export type UrlBuilderTransformationApiOptions = 34 | | UrlBuilderOptionsImage 35 | | UrlBuilderOptionsVideo 36 | | UrlBuilderOptionsAudio 37 | | UrlBuilderOptionsArchive 38 | | UrlBuilderOptionsAntivirus; 39 | 40 | export interface UrlBuilderOptionsBase extends NonDeprecatedCommonQueryParams, DeprecatedCommonQueryParams { 41 | /** 42 | * The base URL of the Bytescale CDN. (Excludes trailing "/".) 43 | */ 44 | cdnUrl?: string; 45 | } 46 | 47 | export interface NonDeprecatedCommonQueryParams { 48 | /** 49 | * Specifies whether to use caching for this request, or to always re-request the file. 50 | * 51 | * For file transformations, setting 'true' will cause the file to be re-processed on every request. 52 | * 53 | * Default: false 54 | */ 55 | cache?: boolean; 56 | 57 | /** 58 | * Specifies the maximum amount of time, in seconds, the file will be cached on the user's device and in the Bytescale CDN's edge cache. 59 | * 60 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 61 | */ 62 | cacheTtl?: number; 63 | 64 | /** 65 | * Specifies the maximum amount of time, in seconds, that 404 responses will be cached in the Bytescale CDN's edge cache. 66 | * 67 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 68 | */ 69 | cacheTtl404?: number; 70 | 71 | /** 72 | * Forces the browser to display a download prompt for the file, instead of displaying the file in the browser. 73 | * 74 | * When set to true, the Bytescale CDN will add a 'content-disposition: attachment' header to the HTTP response. 75 | * 76 | * Default: false 77 | */ 78 | forceDownloadPrompt?: boolean; 79 | 80 | /** 81 | * Downloads the latest version of your file (if you have overwritten it) when added to the URL with a unique value. 82 | * 83 | * The value of the version parameter can be anything, e.g. an incremental number, a timestamp, etc. 84 | * 85 | * You only need to provide and update this value if/when you overwrite your file. 86 | */ 87 | version?: string; 88 | } 89 | 90 | export interface DeprecatedCommonQueryParams { 91 | /** 92 | * @deprecated This field has no effect: the 'auth' querystring parameter is no-longer required by the Bytescale CDN. You may remove this field from your code. 93 | */ 94 | auth?: boolean; 95 | } 96 | 97 | export interface UrlBuilderOptionsRaw extends UrlBuilderOptionsBase { 98 | transformation?: undefined; 99 | } 100 | 101 | export interface UrlBuilderOptionsImage extends UrlBuilderOptionsTransformationApi { 102 | /** 103 | * Set to "image" to use Bytescale's Image Processing API: 104 | * 105 | * https://www.bytescale.com/docs/image-processing-api 106 | */ 107 | transformation: "image"; 108 | } 109 | 110 | export interface UrlBuilderOptionsVideo extends UrlBuilderOptionsTransformationApi { 111 | /** 112 | * Set to "video" to use Bytescale's Video Processing API: 113 | * 114 | * https://www.bytescale.com/docs/video-processing-api 115 | */ 116 | transformation: "video"; 117 | } 118 | 119 | export interface UrlBuilderOptionsAudio extends UrlBuilderOptionsTransformationApi { 120 | /** 121 | * Set to "audio" to use Bytescale's Audio Processing API: 122 | * 123 | * https://www.bytescale.com/docs/audio-processing-api 124 | */ 125 | transformation: "audio"; 126 | } 127 | 128 | export interface UrlBuilderOptionsAntivirus extends UrlBuilderOptionsTransformationApi { 129 | /** 130 | * Set to "antivirus" to use Bytescale's Antivirus API: 131 | * 132 | * https://www.bytescale.com/docs/antivirus-api 133 | */ 134 | transformation: "antivirus"; 135 | } 136 | 137 | export interface UrlBuilderOptionsArchive extends UrlBuilderOptionsTransformationApi { 138 | /** 139 | * Set to "archive" to use Bytescale's Archive Processing API: 140 | * 141 | * https://www.bytescale.com/docs/archive-processing-api 142 | */ 143 | transformation: "archive"; 144 | } 145 | 146 | export interface UrlBuilderOptionsPreset extends UrlBuilderOptionsTransformation { 147 | transformation: "preset"; 148 | 149 | /** 150 | * The name of the transformation preset, as displayed in the Bytescale Dashboard. 151 | * 152 | * To specify transformation parameters on-the-fly, set "transformation" to a File Processing API (e.g. "image", "video", "audio"), and then use the "transformationParams" field to pass parameters to the File Processing API. 153 | */ 154 | transformationPreset: string; 155 | } 156 | 157 | export interface UrlBuilderOptionsTransformationApi extends UrlBuilderOptionsTransformation { 158 | /** 159 | * Use the "transformationParams" field to pass parameters to the File Processing API. 160 | * 161 | * Use the "transformation" field to specify which File Processing API to use: 162 | * 163 | * - https://www.bytescale.com/docs/image-processing-api 164 | * - https://www.bytescale.com/docs/video-processing-api 165 | * - https://www.bytescale.com/docs/audio-processing-api 166 | * - https://www.bytescale.com/docs/archive-processing-api 167 | * 168 | * To repeat a parameter (e.g. when adding multiple text layers to an image), use an array instead of an object, e.g. 169 | * 170 | * [ { text: "hello" }, { text: "world" } ] 171 | * 172 | * Order is sensitive both within and across parameter groups for certain transformation operations, please consult 173 | * the documentation for the File Processing API you are using (see links above). 174 | */ 175 | transformationParams?: T | T[]; 176 | } 177 | 178 | export type UrlBuilderOptionsTransformation = UrlBuilderOptionsTransformationOnly & UrlBuilderOptionsBase; 179 | 180 | export interface UrlBuilderOptionsTransformationOnly { 181 | /** 182 | * The transformation artifact to download. 183 | * 184 | * Some transformations produce multiple files. The 'artifact' parameter is used to select which file to download. 185 | * 186 | * Must begin with: "/" 187 | * 188 | * Default: "/" 189 | */ 190 | artifact?: string; 191 | 192 | /** 193 | * Only serve transformations from the cache; do not perform new transformations on cache miss. 194 | * 195 | * If true, then if the transformation result does not exist in the cache, a 404 will be returned. No transformations will be performed. 196 | * 197 | * If false, then if the transformation result does not exist in the cache, a new transformation will be performed to produce the result. 198 | * 199 | * Default: false 200 | */ 201 | cacheOnly?: boolean; 202 | 203 | /** 204 | * Specifies whether to permanently cache the transformed result in the Bytescale CDN. 205 | * 206 | * Permanently cached files can be deleted via a manual action in the Bytescale Dashboard. 207 | * 208 | * When cache=false this parameter is automatically set to false. 209 | * 210 | * When cachePermanently="auto" the permanent cache will only be used for files that take more than 1000ms to process. 211 | * 212 | * When the permanent cache is used, approximately 200ms of latency is added to the initial request. Thereafter, files will be served from the Bytescale CDN's edge cache or permanent cache, so will have minimal latency. 213 | * 214 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 215 | */ 216 | cachePermanently?: "auto" | boolean; 217 | } 218 | -------------------------------------------------------------------------------- /src/public/shared/generated/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /src/public/shared/generated/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .openapi-generator-ignore 2 | apis/CacheApi.ts 3 | apis/FileApi.ts 4 | apis/FolderApi.ts 5 | apis/JobApi.ts 6 | apis/UploadApi.ts 7 | apis/index.ts 8 | index.ts 9 | models/index.ts 10 | runtime.ts 11 | -------------------------------------------------------------------------------- /src/public/shared/generated/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 6.2.1 -------------------------------------------------------------------------------- /src/public/shared/generated/apis/CacheApi.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * @bytescale/api 5 | * Bytescale API 6 | * 7 | * The version of the OpenAPI document: 2.0.0 8 | * Contact: hello@bytescale.com 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | // @ts-ignore 16 | import * as runtime from "../runtime"; 17 | import type { 18 | // @ts-ignore 19 | AsyncResponse, 20 | // @ts-ignore 21 | ErrorResponse, 22 | // @ts-ignore 23 | ResetCacheRequest 24 | } from "../models"; 25 | 26 | // Omitted by generator (so we add manually). 27 | // @ts-ignore 28 | import type { TransformationParams } from "../models"; 29 | 30 | export interface ResetCacheOperationParams { 31 | accountId: string; 32 | 33 | resetCacheRequest: ResetCacheRequest; 34 | } 35 | 36 | export class CacheApi extends runtime.BaseAPI { 37 | /** 38 | * Resets the Bytescale CDN cache for a specific path, path prefix, or for your entire account. You can choose to reset the edge cache, or permanent cache, or both caches. *Warning:* Resetting the permanent cache (by setting ```resetPermanentCache: true```) may lead to a significant increase in processing time if numerous file transformations need to be re-performed upon their next request. *Recommended:* Prevent cache resets by adding a ```?v=``` querystring parameter to your URLs. This ensures your URLs change when your files change, eliminating the need for cache resets. The `etag` field is returned by GetFileDetails and all upload operations, and can be saved to your database. *Example patterns:* - ```\"/_*\"``` - ```\"/raw/example.jpg\"``` - ```\"/image/example.jpg\"``` - ```\"/image/customers/abc/_*\"``` You may only use ```*``` at the end of the pattern. You must not include your account ID prefix in the pattern. 39 | */ 40 | async resetCache(params: ResetCacheOperationParams): Promise { 41 | const query: any = {}; 42 | const headers: runtime.HTTPHeaders = {}; 43 | 44 | headers["Content-Type"] = "application/json"; 45 | 46 | const response = await this.request( 47 | { 48 | path: `/v2/accounts/{accountId}/cache/reset`.replace( 49 | `{${"accountId"}}`, 50 | // @ts-ignore 51 | this.encodePathParam("accountId", params.accountId) 52 | ), 53 | method: "POST", 54 | headers, 55 | query, 56 | body: params.resetCacheRequest 57 | }, 58 | undefined, 59 | [][0] 60 | ); 61 | 62 | return await new runtime.JSONApiResponse(response).value(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/public/shared/generated/apis/FileApi.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * @bytescale/api 5 | * Bytescale API 6 | * 7 | * The version of the OpenAPI document: 2.0.0 8 | * Contact: hello@bytescale.com 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | // @ts-ignore 16 | import * as runtime from "../runtime"; 17 | import type { 18 | // @ts-ignore 19 | AsyncResponse, 20 | // @ts-ignore 21 | CopyFileBatchRequest, 22 | // @ts-ignore 23 | CopyFileRequest, 24 | // @ts-ignore 25 | CopyFileResponse, 26 | // @ts-ignore 27 | DeleteFileBatchRequest, 28 | // @ts-ignore 29 | ErrorResponse, 30 | // @ts-ignore 31 | FileDetails, 32 | // @ts-ignore 33 | ProcessFileAndSaveRequest, 34 | // @ts-ignore 35 | ProcessFileAndSaveResponse 36 | } from "../models"; 37 | 38 | // Omitted by generator (so we add manually). 39 | // @ts-ignore 40 | import type { TransformationParams } from "../models"; 41 | 42 | export interface CopyFileOperationParams { 43 | accountId: string; 44 | 45 | copyFileRequest: CopyFileRequest; 46 | } 47 | 48 | export interface CopyFileBatchOperationParams { 49 | accountId: string; 50 | 51 | copyFileBatchRequest: CopyFileBatchRequest; 52 | } 53 | 54 | export interface DeleteFileParams { 55 | accountId: string; 56 | 57 | filePath: string; 58 | } 59 | 60 | export interface DeleteFileBatchOperationParams { 61 | accountId: string; 62 | 63 | deleteFileBatchRequest: DeleteFileBatchRequest; 64 | } 65 | 66 | export interface DownloadFileParams { 67 | accountId: string; 68 | 69 | filePath: string; 70 | 71 | /** 72 | * Specifies whether to cache the raw file in the Bytescale CDN. 73 | * 74 | * Default: true 75 | */ 76 | cache?: boolean; 77 | 78 | /** 79 | * Specifies the maximum amount of time, in seconds, the file will be cached on the user's device and in the Bytescale CDN's edge cache. 80 | * 81 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 82 | */ 83 | cacheTtl?: number; 84 | 85 | /** 86 | * Specifies the maximum amount of time, in seconds, that 404 responses will be cached in the Bytescale CDN's edge cache. 87 | * 88 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 89 | */ 90 | cacheTtl404?: number; 91 | 92 | /** 93 | * Expires the URL at the given Unix epoch timestamp. 94 | * 95 | * The value can be provided in either milliseconds or seconds since January 1, 1970, 00:00:00 UTC. 96 | * 97 | * Must less than 7 days in the future. 98 | * 99 | * See: Secure URLs 100 | */ 101 | exp?: number; 102 | 103 | /** 104 | * Downloads the latest version of your file (if you have overwritten it) when added to the URL with a unique value. 105 | * 106 | * The value of the `version` parameter can be anything, e.g. an incremental number, a timestamp, etc. 107 | * 108 | * You only need to provide and update this value if/when you overwrite your file. 109 | */ 110 | version?: string; 111 | } 112 | 113 | export interface GetFileDetailsParams { 114 | accountId: string; 115 | 116 | filePath: string; 117 | } 118 | 119 | export interface ProcessFileParams { 120 | accountId: string; 121 | 122 | filePath: string; 123 | 124 | /** 125 | * The name of the File Processing API (e.g. `image`, `video`, `audio`) or transformation preset (created in the Bytescale Dashboard) to use when processing the file. 126 | */ 127 | transformation: string; 128 | 129 | /** 130 | * Each File Processing API requires additional query string parameters to specify the desired transformation behavior. 131 | * 132 | * For details, refer to the relevant documentation: 133 | * 134 | * - https://www.bytescale.com/docs/image-processing-api 135 | * - https://www.bytescale.com/docs/video-processing-api 136 | * - https://www.bytescale.com/docs/audio-processing-api 137 | * - https://www.bytescale.com/docs/archive-processing-api 138 | * - https://www.bytescale.com/docs/antivirus-api 139 | */ 140 | transformationParams?: TransformationParams; 141 | 142 | /** 143 | * Some transformations output multiple files, called artifacts. 144 | * 145 | * You can download each individual transformation artifact by specifying its path with this parameter 146 | */ 147 | artifact?: string; 148 | 149 | /** 150 | * Specifies whether to cache the transformed result. 151 | * 152 | * If set to `false` the transformation will be executed on every request. 153 | * 154 | * *Recommendation:* instead of disabling the cache, a more performant solution is to use the `version` or `v` parameter and to increment it each time you require an updated result. 155 | * 156 | * Default: true 157 | */ 158 | cache?: boolean; 159 | 160 | /** 161 | * Only serve transformations from the cache; do not perform new transformations on cache miss. 162 | * 163 | * If `true`, then if the transformation result does not exist in the cache, a 404 will be returned. No transformations will be performed. 164 | * 165 | * If `false`, then if the transformation result does not exist in the cache, a new transformation will be performed to produce the result. 166 | * 167 | * Default: `false` 168 | */ 169 | cacheOnly?: boolean; 170 | 171 | /** 172 | * Specifies whether to cache the transformed result in the Bytescale CDN perma-cache. 173 | * 174 | * Perma-caching works by storing your file permanently, or until a manual cache purge is performed. 175 | * 176 | * When `cache=false` this parameter is automatically set to `false`. 177 | * 178 | * When `cache-perm=auto` the perma-cache will only be used for files that take more than 500ms to process. 179 | * 180 | * When the perma-cache is used, approximately 200ms of latency is added to the initial request. Thereafter, files will be served from the Bytescale CDN's edge cache or perma-cache, so will have minimal latency. 181 | * 182 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 183 | */ 184 | cachePerm?: ProcessFileCachePermEnum; 185 | 186 | /** 187 | * Specifies the maximum amount of time, in seconds, the transformed result will be cached on the user's device and in the Bytescale CDN's edge cache. 188 | * 189 | * If the file is perma-cached, then the file will not be reprocessed on edge cache misses. 190 | * 191 | * If the file is not perma-cached, then the file will be reprocessed on edge cache misses. 192 | * 193 | * For more information on perma-caching, see: `cache-perm` 194 | * 195 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 196 | */ 197 | cacheTtl?: number; 198 | 199 | /** 200 | * Specifies the maximum amount of time, in seconds, that 404 responses will be cached in the Bytescale CDN's edge cache. 201 | * 202 | * Default: Please refer to your account's default cache settings in the Bytescale Dashboard. 203 | */ 204 | cacheTtl404?: number; 205 | 206 | /** 207 | * Expires the URL at the given Unix epoch timestamp. 208 | * 209 | * The value can be provided in either milliseconds or seconds since January 1, 1970, 00:00:00 UTC. 210 | * 211 | * Must less than 7 days in the future. 212 | * 213 | * See: Secure URLs 214 | */ 215 | exp?: number; 216 | 217 | /** 218 | * Add this parameter and increment its value to force the file to be reprocessed. 219 | * 220 | * The Bytescale CDN caches files based on the full URL (including the querystring), meaning this parameter is useful when dealing with changes made to transformation presets. By contrast, File Processing APIs (like the Image Processing API) shouldn't ever require this parameter, since the URL/querystring naturally changes each time you adjust a parameter, causing a cache miss and the file to be reprocessed with the new querystring parameters. 221 | * 222 | * The value of the `version` parameter can be anything, e.g. an incremental number, a timestamp, etc. 223 | * 224 | * You only need to provide and update this value if/when you make changes to a transformation preset's settings. 225 | */ 226 | version?: string; 227 | } 228 | 229 | export interface ProcessFileAndSaveOperationParams { 230 | accountId: string; 231 | 232 | filePath: string; 233 | 234 | /** 235 | * The name of the File Processing API (e.g. `image`, `video`, `audio`) or transformation preset (created in the Bytescale Dashboard) to use when processing the file. 236 | */ 237 | transformation: string; 238 | 239 | /** 240 | * 241 | */ 242 | processFileAndSaveRequest: ProcessFileAndSaveRequest; 243 | 244 | /** 245 | * Each File Processing API requires additional query string parameters to specify the desired transformation behavior. 246 | * 247 | * For details, refer to the relevant documentation: 248 | * 249 | * - https://www.bytescale.com/docs/image-processing-api 250 | * - https://www.bytescale.com/docs/video-processing-api 251 | * - https://www.bytescale.com/docs/audio-processing-api 252 | * - https://www.bytescale.com/docs/archive-processing-api 253 | * - https://www.bytescale.com/docs/antivirus-api 254 | */ 255 | transformationParams?: TransformationParams; 256 | } 257 | 258 | export class FileApi extends runtime.BaseAPI { 259 | /** 260 | * Copies a file synchronously. 261 | */ 262 | async copyFile(params: CopyFileOperationParams): Promise { 263 | const query: any = {}; 264 | const headers: runtime.HTTPHeaders = {}; 265 | 266 | headers["Content-Type"] = "application/json"; 267 | 268 | const response = await this.request( 269 | { 270 | path: `/v2/accounts/{accountId}/files/copy`.replace( 271 | `{${"accountId"}}`, 272 | // @ts-ignore 273 | this.encodePathParam("accountId", params.accountId) 274 | ), 275 | method: "POST", 276 | headers, 277 | query, 278 | body: params.copyFileRequest 279 | }, 280 | undefined, 281 | [][0] 282 | ); 283 | 284 | return await new runtime.JSONApiResponse(response).value(); 285 | } 286 | 287 | /** 288 | * Copies multiple files asynchronously. 289 | */ 290 | async copyFileBatch(params: CopyFileBatchOperationParams): Promise { 291 | const query: any = {}; 292 | const headers: runtime.HTTPHeaders = {}; 293 | 294 | headers["Content-Type"] = "application/json"; 295 | 296 | const response = await this.request( 297 | { 298 | path: `/v2/accounts/{accountId}/files/copy/batch`.replace( 299 | `{${"accountId"}}`, 300 | // @ts-ignore 301 | this.encodePathParam("accountId", params.accountId) 302 | ), 303 | method: "POST", 304 | headers, 305 | query, 306 | body: params.copyFileBatchRequest 307 | }, 308 | undefined, 309 | [][0] 310 | ); 311 | 312 | return await new runtime.JSONApiResponse(response).value(); 313 | } 314 | 315 | /** 316 | * Deletes a file synchronously. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 317 | */ 318 | async deleteFile(params: DeleteFileParams): Promise { 319 | const query: any = {}; 320 | if (params.filePath !== undefined) { 321 | query["filePath"] = params.filePath; 322 | } 323 | 324 | const headers: runtime.HTTPHeaders = {}; 325 | 326 | const response = await this.request( 327 | { 328 | path: `/v2/accounts/{accountId}/files`.replace( 329 | `{${"accountId"}}`, 330 | // @ts-ignore 331 | this.encodePathParam("accountId", params.accountId) 332 | ), 333 | method: "DELETE", 334 | headers, 335 | query 336 | }, 337 | undefined, 338 | [][0] 339 | ); 340 | 341 | return await new runtime.VoidApiResponse(response).value(); 342 | } 343 | 344 | /** 345 | * Deletes multiple files asynchronously. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 346 | */ 347 | async deleteFileBatch(params: DeleteFileBatchOperationParams): Promise { 348 | const query: any = {}; 349 | const headers: runtime.HTTPHeaders = {}; 350 | 351 | headers["Content-Type"] = "application/json"; 352 | 353 | const response = await this.request( 354 | { 355 | path: `/v2/accounts/{accountId}/files/batch`.replace( 356 | `{${"accountId"}}`, 357 | // @ts-ignore 358 | this.encodePathParam("accountId", params.accountId) 359 | ), 360 | method: "DELETE", 361 | headers, 362 | query, 363 | body: params.deleteFileBatchRequest 364 | }, 365 | undefined, 366 | [][0] 367 | ); 368 | 369 | return await new runtime.JSONApiResponse(response).value(); 370 | } 371 | 372 | /** 373 | * Downloads a file in its original/unprocessed state. 374 | */ 375 | async downloadFile(params: DownloadFileParams): Promise { 376 | const query: any = {}; 377 | if (params.cache !== undefined) { 378 | query["cache"] = params.cache; 379 | } 380 | 381 | if (params.cacheTtl !== undefined) { 382 | query["cache-ttl"] = params.cacheTtl; 383 | } 384 | 385 | if (params.cacheTtl404 !== undefined) { 386 | query["cache-ttl-404"] = params.cacheTtl404; 387 | } 388 | 389 | if (params.exp !== undefined) { 390 | query["exp"] = params.exp; 391 | } 392 | 393 | if (params.version !== undefined) { 394 | query["version"] = params.version; 395 | } 396 | 397 | const headers: runtime.HTTPHeaders = {}; 398 | 399 | const response = await this.request( 400 | { 401 | path: `/{accountId}/raw{filePath}` 402 | .replace( 403 | `{${"accountId"}}`, 404 | // @ts-ignore 405 | this.encodePathParam("accountId", params.accountId) 406 | ) 407 | .replace( 408 | `{${"filePath"}}`, 409 | // @ts-ignore 410 | this.encodePathParam("filePath", params.filePath) 411 | ), 412 | method: "GET", 413 | headers, 414 | query 415 | }, 416 | undefined, 417 | ["https://upcdn.io"][0] 418 | ); 419 | 420 | return new runtime.BinaryResult(response); 421 | } 422 | 423 | /** 424 | * Gets the full details (e.g. metadata, tags, etc.) for a file. 425 | */ 426 | async getFileDetails(params: GetFileDetailsParams): Promise { 427 | const query: any = {}; 428 | if (params.filePath !== undefined) { 429 | query["filePath"] = params.filePath; 430 | } 431 | 432 | const headers: runtime.HTTPHeaders = {}; 433 | 434 | const response = await this.request( 435 | { 436 | path: `/v2/accounts/{accountId}/files/details`.replace( 437 | `{${"accountId"}}`, 438 | // @ts-ignore 439 | this.encodePathParam("accountId", params.accountId) 440 | ), 441 | method: "GET", 442 | headers, 443 | query 444 | }, 445 | undefined, 446 | [][0] 447 | ); 448 | 449 | return await new runtime.JSONApiResponse(response).value(); 450 | } 451 | 452 | /** 453 | * Processes a file and returns the result. 454 | */ 455 | async processFile(params: ProcessFileParams): Promise { 456 | const query: any = {}; 457 | if (params.transformationParams !== undefined) { 458 | query["<transformation-params>"] = params.transformationParams; 459 | } 460 | 461 | if (params.artifact !== undefined) { 462 | query["artifact"] = params.artifact; 463 | } 464 | 465 | if (params.cache !== undefined) { 466 | query["cache"] = params.cache; 467 | } 468 | 469 | if (params.cacheOnly !== undefined) { 470 | query["cache-only"] = params.cacheOnly; 471 | } 472 | 473 | if (params.cachePerm !== undefined) { 474 | query["cache-perm"] = params.cachePerm; 475 | } 476 | 477 | if (params.cacheTtl !== undefined) { 478 | query["cache-ttl"] = params.cacheTtl; 479 | } 480 | 481 | if (params.cacheTtl404 !== undefined) { 482 | query["cache-ttl-404"] = params.cacheTtl404; 483 | } 484 | 485 | if (params.exp !== undefined) { 486 | query["exp"] = params.exp; 487 | } 488 | 489 | if (params.version !== undefined) { 490 | query["version"] = params.version; 491 | } 492 | 493 | const headers: runtime.HTTPHeaders = {}; 494 | 495 | const response = await this.request( 496 | { 497 | path: `/{accountId}/{transformation}{filePath}` 498 | .replace( 499 | `{${"accountId"}}`, 500 | // @ts-ignore 501 | this.encodePathParam("accountId", params.accountId) 502 | ) 503 | .replace( 504 | `{${"filePath"}}`, 505 | // @ts-ignore 506 | this.encodePathParam("filePath", params.filePath) 507 | ) 508 | .replace( 509 | `{${"transformation"}}`, 510 | // @ts-ignore 511 | this.encodePathParam("transformation", params.transformation) 512 | ), 513 | method: "GET", 514 | headers, 515 | query 516 | }, 517 | undefined, 518 | ["https://upcdn.io"][0] 519 | ); 520 | 521 | return new runtime.BinaryResult(response); 522 | } 523 | 524 | /** 525 | * Processes a file and saves the result. 526 | */ 527 | async processFileAndSave(params: ProcessFileAndSaveOperationParams): Promise { 528 | const query: any = {}; 529 | if (params.transformationParams !== undefined) { 530 | query["<transformation-params>"] = params.transformationParams; 531 | } 532 | 533 | const headers: runtime.HTTPHeaders = {}; 534 | 535 | headers["Content-Type"] = "application/json"; 536 | 537 | const response = await this.request( 538 | { 539 | path: `/{accountId}/save/{transformation}{filePath}` 540 | .replace( 541 | `{${"accountId"}}`, 542 | // @ts-ignore 543 | this.encodePathParam("accountId", params.accountId) 544 | ) 545 | .replace( 546 | `{${"filePath"}}`, 547 | // @ts-ignore 548 | this.encodePathParam("filePath", params.filePath) 549 | ) 550 | .replace( 551 | `{${"transformation"}}`, 552 | // @ts-ignore 553 | this.encodePathParam("transformation", params.transformation) 554 | ), 555 | method: "POST", 556 | headers, 557 | query, 558 | body: params.processFileAndSaveRequest 559 | }, 560 | undefined, 561 | ["https://upcdn.io"][0] 562 | ); 563 | 564 | return await new runtime.JSONApiResponse(response).value(); 565 | } 566 | } 567 | 568 | /** 569 | * @export 570 | */ 571 | export type ProcessFileCachePermEnum = "auto" | "false" | "true"; 572 | -------------------------------------------------------------------------------- /src/public/shared/generated/apis/FolderApi.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * @bytescale/api 5 | * Bytescale API 6 | * 7 | * The version of the OpenAPI document: 2.0.0 8 | * Contact: hello@bytescale.com 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | // @ts-ignore 16 | import * as runtime from "../runtime"; 17 | import type { 18 | // @ts-ignore 19 | AsyncResponse, 20 | // @ts-ignore 21 | CopyFolderBatchRequest, 22 | // @ts-ignore 23 | CopyFolderRequest, 24 | // @ts-ignore 25 | DeleteFolderBatchRequest, 26 | // @ts-ignore 27 | DeleteFolderRequest, 28 | // @ts-ignore 29 | ErrorResponse, 30 | // @ts-ignore 31 | FolderDetails, 32 | // @ts-ignore 33 | ListFolderResponse, 34 | // @ts-ignore 35 | PutFolderRequest 36 | } from "../models"; 37 | 38 | // Omitted by generator (so we add manually). 39 | // @ts-ignore 40 | import type { TransformationParams } from "../models"; 41 | 42 | export interface CopyFolderOperationParams { 43 | accountId: string; 44 | 45 | copyFolderRequest: CopyFolderRequest; 46 | } 47 | 48 | export interface CopyFolderBatchOperationParams { 49 | accountId: string; 50 | 51 | copyFolderBatchRequest: CopyFolderBatchRequest; 52 | } 53 | 54 | export interface DeleteFolderOperationParams { 55 | accountId: string; 56 | 57 | deleteFolderRequest: DeleteFolderRequest; 58 | } 59 | 60 | export interface DeleteFolderBatchOperationParams { 61 | accountId: string; 62 | 63 | deleteFolderBatchRequest: DeleteFolderBatchRequest; 64 | } 65 | 66 | export interface GetFolderDetailsParams { 67 | accountId: string; 68 | 69 | folderPath: string; 70 | } 71 | 72 | export interface ListFolderParams { 73 | accountId: string; 74 | 75 | folderPath: string; 76 | 77 | cursor?: string; 78 | 79 | dryRun?: boolean; 80 | 81 | includeFiles?: boolean; 82 | 83 | includeOverriddenStorage?: boolean; 84 | 85 | includePhysicalFolders?: boolean; 86 | 87 | includeVirtualFolders?: boolean; 88 | 89 | limit?: number; 90 | 91 | recursive?: boolean; 92 | } 93 | 94 | export interface PutFolderOperationParams { 95 | accountId: string; 96 | 97 | putFolderRequest: PutFolderRequest; 98 | } 99 | 100 | export class FolderApi extends runtime.BaseAPI { 101 | /** 102 | * Copies a folder asynchronously. You can use ListFolder to preview the operation using the `dryRun` parameter. 103 | */ 104 | async copyFolder(params: CopyFolderOperationParams): Promise { 105 | const query: any = {}; 106 | const headers: runtime.HTTPHeaders = {}; 107 | 108 | headers["Content-Type"] = "application/json"; 109 | 110 | const response = await this.request( 111 | { 112 | path: `/v2/accounts/{accountId}/folders/copy`.replace( 113 | `{${"accountId"}}`, 114 | // @ts-ignore 115 | this.encodePathParam("accountId", params.accountId) 116 | ), 117 | method: "POST", 118 | headers, 119 | query, 120 | body: params.copyFolderRequest 121 | }, 122 | undefined, 123 | [][0] 124 | ); 125 | 126 | return await new runtime.JSONApiResponse(response).value(); 127 | } 128 | 129 | /** 130 | * Copies multiple folders asynchronously. You can use ListFolder to preview the operation using the `dryRun` parameter. 131 | */ 132 | async copyFolderBatch(params: CopyFolderBatchOperationParams): Promise { 133 | const query: any = {}; 134 | const headers: runtime.HTTPHeaders = {}; 135 | 136 | headers["Content-Type"] = "application/json"; 137 | 138 | const response = await this.request( 139 | { 140 | path: `/v2/accounts/{accountId}/folders/copy/batch`.replace( 141 | `{${"accountId"}}`, 142 | // @ts-ignore 143 | this.encodePathParam("accountId", params.accountId) 144 | ), 145 | method: "POST", 146 | headers, 147 | query, 148 | body: params.copyFolderBatchRequest 149 | }, 150 | undefined, 151 | [][0] 152 | ); 153 | 154 | return await new runtime.JSONApiResponse(response).value(); 155 | } 156 | 157 | /** 158 | * Deletes a folder asynchronously. You can use ListFolder to preview the operation using the `dryRun` parameter. *External storage:* external files are only deleted when you directly delete a file or subfolder of a folder that has external storage configured. If you delete the folder itself, only the mapping is removed. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 159 | */ 160 | async deleteFolder(params: DeleteFolderOperationParams): Promise { 161 | const query: any = {}; 162 | const headers: runtime.HTTPHeaders = {}; 163 | 164 | headers["Content-Type"] = "application/json"; 165 | 166 | const response = await this.request( 167 | { 168 | path: `/v2/accounts/{accountId}/folders`.replace( 169 | `{${"accountId"}}`, 170 | // @ts-ignore 171 | this.encodePathParam("accountId", params.accountId) 172 | ), 173 | method: "DELETE", 174 | headers, 175 | query, 176 | body: params.deleteFolderRequest 177 | }, 178 | undefined, 179 | [][0] 180 | ); 181 | 182 | return await new runtime.JSONApiResponse(response).value(); 183 | } 184 | 185 | /** 186 | * Deletes multiple folders asynchronously. You can use ListFolder to preview the operation using the `dryRun` parameter. *External storage:* external files are only deleted when you directly delete a file or subfolder of a folder that has external storage configured. If you delete the folder itself, only the mapping is removed. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 187 | */ 188 | async deleteFolderBatch(params: DeleteFolderBatchOperationParams): Promise { 189 | const query: any = {}; 190 | const headers: runtime.HTTPHeaders = {}; 191 | 192 | headers["Content-Type"] = "application/json"; 193 | 194 | const response = await this.request( 195 | { 196 | path: `/v2/accounts/{accountId}/folders/batch`.replace( 197 | `{${"accountId"}}`, 198 | // @ts-ignore 199 | this.encodePathParam("accountId", params.accountId) 200 | ), 201 | method: "DELETE", 202 | headers, 203 | query, 204 | body: params.deleteFolderBatchRequest 205 | }, 206 | undefined, 207 | [][0] 208 | ); 209 | 210 | return await new runtime.JSONApiResponse(response).value(); 211 | } 212 | 213 | /** 214 | * Gets the full details (e.g. permission, storage layer, etc.) for a folder. Returns an empty object if no settings have been configured for this folder. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 215 | */ 216 | async getFolderDetails(params: GetFolderDetailsParams): Promise { 217 | const query: any = {}; 218 | if (params.folderPath !== undefined) { 219 | query["folderPath"] = params.folderPath; 220 | } 221 | 222 | const headers: runtime.HTTPHeaders = {}; 223 | 224 | const response = await this.request( 225 | { 226 | path: `/v2/accounts/{accountId}/folders`.replace( 227 | `{${"accountId"}}`, 228 | // @ts-ignore 229 | this.encodePathParam("accountId", params.accountId) 230 | ), 231 | method: "GET", 232 | headers, 233 | query 234 | }, 235 | undefined, 236 | [][0] 237 | ); 238 | 239 | return await new runtime.JSONApiResponse(response).value(); 240 | } 241 | 242 | /** 243 | * Lists the folder\'s contents. The result may be paginated: subsequent pages can be requested by passing the ```cursor``` from the response into the next request. Pagination is complete when the response includes `isPaginationComplete=true`. 244 | */ 245 | async listFolder(params: ListFolderParams): Promise { 246 | const query: any = {}; 247 | if (params.cursor !== undefined) { 248 | query["cursor"] = params.cursor; 249 | } 250 | 251 | if (params.dryRun !== undefined) { 252 | query["dryRun"] = params.dryRun; 253 | } 254 | 255 | if (params.folderPath !== undefined) { 256 | query["folderPath"] = params.folderPath; 257 | } 258 | 259 | if (params.includeFiles !== undefined) { 260 | query["includeFiles"] = params.includeFiles; 261 | } 262 | 263 | if (params.includeOverriddenStorage !== undefined) { 264 | query["includeOverriddenStorage"] = params.includeOverriddenStorage; 265 | } 266 | 267 | if (params.includePhysicalFolders !== undefined) { 268 | query["includePhysicalFolders"] = params.includePhysicalFolders; 269 | } 270 | 271 | if (params.includeVirtualFolders !== undefined) { 272 | query["includeVirtualFolders"] = params.includeVirtualFolders; 273 | } 274 | 275 | if (params.limit !== undefined) { 276 | query["limit"] = params.limit; 277 | } 278 | 279 | if (params.recursive !== undefined) { 280 | query["recursive"] = params.recursive; 281 | } 282 | 283 | const headers: runtime.HTTPHeaders = {}; 284 | 285 | const response = await this.request( 286 | { 287 | path: `/v2/accounts/{accountId}/folders/list`.replace( 288 | `{${"accountId"}}`, 289 | // @ts-ignore 290 | this.encodePathParam("accountId", params.accountId) 291 | ), 292 | method: "GET", 293 | headers, 294 | query 295 | }, 296 | undefined, 297 | [][0] 298 | ); 299 | 300 | return await new runtime.JSONApiResponse(response).value(); 301 | } 302 | 303 | /** 304 | * Creates or updates the folder specified by the `folderPath`. If the folder\'s ancestors do not exist, they will be created automatically (with empty FolderSettings). Note: you don\'t need to create folders before uploading files to them. Requires a `secret_*` API key. Alternatively, you can use a `public_*` API key and JWT-based auth. 305 | */ 306 | async putFolder(params: PutFolderOperationParams): Promise { 307 | const query: any = {}; 308 | const headers: runtime.HTTPHeaders = {}; 309 | 310 | headers["Content-Type"] = "application/json"; 311 | 312 | const response = await this.request( 313 | { 314 | path: `/v2/accounts/{accountId}/folders`.replace( 315 | `{${"accountId"}}`, 316 | // @ts-ignore 317 | this.encodePathParam("accountId", params.accountId) 318 | ), 319 | method: "PUT", 320 | headers, 321 | query, 322 | body: params.putFolderRequest 323 | }, 324 | undefined, 325 | [][0] 326 | ); 327 | 328 | return await new runtime.JSONApiResponse(response).value(); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/public/shared/generated/apis/JobApi.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * @bytescale/api 5 | * Bytescale API 6 | * 7 | * The version of the OpenAPI document: 2.0.0 8 | * Contact: hello@bytescale.com 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | // @ts-ignore 16 | import * as runtime from "../runtime"; 17 | import type { 18 | // @ts-ignore 19 | AccountJobType, 20 | // @ts-ignore 21 | ErrorResponse, 22 | // @ts-ignore 23 | JobSummary, 24 | // @ts-ignore 25 | ListRecentJobsResponse 26 | } from "../models"; 27 | 28 | // Omitted by generator (so we add manually). 29 | // @ts-ignore 30 | import type { TransformationParams } from "../models"; 31 | 32 | export interface CancelJobParams { 33 | accountId: string; 34 | 35 | jobId: string; 36 | 37 | jobType: AccountJobType; 38 | } 39 | 40 | export interface GetJobParams { 41 | accountId: string; 42 | 43 | jobId: string; 44 | 45 | jobType: AccountJobType; 46 | } 47 | 48 | export interface ListRecentJobsParams { 49 | accountId: string; 50 | 51 | jobType: Array; 52 | } 53 | 54 | export class JobApi extends runtime.BaseAPI { 55 | /** 56 | * Cancels an in-progress background job. Requires a `secret_*` API key. 57 | */ 58 | async cancelJob(params: CancelJobParams): Promise { 59 | const query: any = {}; 60 | const headers: runtime.HTTPHeaders = {}; 61 | 62 | const response = await this.request( 63 | { 64 | path: `/v2/accounts/{accountId}/jobs/{jobType}/{jobId}` 65 | .replace( 66 | `{${"accountId"}}`, 67 | // @ts-ignore 68 | this.encodePathParam("accountId", params.accountId) 69 | ) 70 | .replace( 71 | `{${"jobId"}}`, 72 | // @ts-ignore 73 | this.encodePathParam("jobId", params.jobId) 74 | ) 75 | .replace( 76 | `{${"jobType"}}`, 77 | // @ts-ignore 78 | this.encodePathParam("jobType", params.jobType) 79 | ), 80 | method: "DELETE", 81 | headers, 82 | query 83 | }, 84 | undefined, 85 | [][0] 86 | ); 87 | 88 | return await new runtime.VoidApiResponse(response).value(); 89 | } 90 | 91 | /** 92 | * Gets information on a background job. Requires a `secret_*` API key. 93 | */ 94 | async getJob(params: GetJobParams): Promise { 95 | const query: any = {}; 96 | const headers: runtime.HTTPHeaders = {}; 97 | 98 | const response = await this.request( 99 | { 100 | path: `/v2/accounts/{accountId}/jobs/{jobType}/{jobId}` 101 | .replace( 102 | `{${"accountId"}}`, 103 | // @ts-ignore 104 | this.encodePathParam("accountId", params.accountId) 105 | ) 106 | .replace( 107 | `{${"jobId"}}`, 108 | // @ts-ignore 109 | this.encodePathParam("jobId", params.jobId) 110 | ) 111 | .replace( 112 | `{${"jobType"}}`, 113 | // @ts-ignore 114 | this.encodePathParam("jobType", params.jobType) 115 | ), 116 | method: "GET", 117 | headers, 118 | query 119 | }, 120 | undefined, 121 | [][0] 122 | ); 123 | 124 | return await new runtime.JSONApiResponse(response).value(); 125 | } 126 | 127 | /** 128 | * Lists the 10 most recently created jobs for the specified job type(s). Requires a `secret_*` API key. 129 | */ 130 | async listRecentJobs(params: ListRecentJobsParams): Promise { 131 | const query: any = {}; 132 | if (params.jobType) { 133 | query["jobType"] = params.jobType; 134 | } 135 | 136 | const headers: runtime.HTTPHeaders = {}; 137 | 138 | const response = await this.request( 139 | { 140 | path: `/v2/accounts/{accountId}/jobs`.replace( 141 | `{${"accountId"}}`, 142 | // @ts-ignore 143 | this.encodePathParam("accountId", params.accountId) 144 | ), 145 | method: "GET", 146 | headers, 147 | query 148 | }, 149 | undefined, 150 | [][0] 151 | ); 152 | 153 | return await new runtime.JSONApiResponse(response).value(); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/public/shared/generated/apis/UploadApi.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * @bytescale/api 5 | * Bytescale API 6 | * 7 | * The version of the OpenAPI document: 2.0.0 8 | * Contact: hello@bytescale.com 9 | * 10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 11 | * https://openapi-generator.tech 12 | * Do not edit the class manually. 13 | */ 14 | 15 | // @ts-ignore 16 | import * as runtime from "../runtime"; 17 | import type { 18 | // @ts-ignore 19 | BasicUploadResponse, 20 | // @ts-ignore 21 | BeginMultipartUploadRequest, 22 | // @ts-ignore 23 | BeginMultipartUploadResponse, 24 | // @ts-ignore 25 | CompleteMultipartUploadResponse, 26 | // @ts-ignore 27 | CompleteUploadPartRequest, 28 | // @ts-ignore 29 | ErrorResponse, 30 | // @ts-ignore 31 | UploadFromUrlRequest, 32 | // @ts-ignore 33 | UploadPart, 34 | // @ts-ignore 35 | UploadPartList 36 | } from "../models"; 37 | 38 | // Omitted by generator (so we add manually). 39 | // @ts-ignore 40 | import type { TransformationParams } from "../models"; 41 | 42 | export interface BeginMultipartUploadOperationParams { 43 | accountId: string; 44 | 45 | /** 46 | * 47 | */ 48 | beginMultipartUploadRequest: BeginMultipartUploadRequest; 49 | } 50 | 51 | export interface CompleteUploadPartOperationParams { 52 | accountId: string; 53 | 54 | uploadId: string; 55 | 56 | uploadPartIndex: number; 57 | 58 | completeUploadPartRequest: CompleteUploadPartRequest; 59 | } 60 | 61 | export interface GetUploadPartParams { 62 | accountId: string; 63 | 64 | uploadId: string; 65 | 66 | uploadPartIndex: number; 67 | } 68 | 69 | export interface ListUploadPartsParams { 70 | accountId: string; 71 | 72 | uploadId: string; 73 | } 74 | 75 | export interface UploadFromUrlOperationParams { 76 | accountId: string; 77 | 78 | /** 79 | * 80 | */ 81 | uploadFromUrlRequest: UploadFromUrlRequest; 82 | } 83 | 84 | export class UploadApi extends runtime.BaseAPI { 85 | /** 86 | * Begins a new multipart file upload process. 87 | */ 88 | async beginMultipartUpload(params: BeginMultipartUploadOperationParams): Promise { 89 | const query: any = {}; 90 | const headers: runtime.HTTPHeaders = {}; 91 | 92 | headers["Content-Type"] = "application/json"; 93 | 94 | const response = await this.request( 95 | { 96 | path: `/v2/accounts/{accountId}/uploads`.replace( 97 | `{${"accountId"}}`, 98 | // @ts-ignore 99 | this.encodePathParam("accountId", params.accountId) 100 | ), 101 | method: "POST", 102 | headers, 103 | query, 104 | body: params.beginMultipartUploadRequest 105 | }, 106 | undefined, 107 | [][0] 108 | ); 109 | 110 | return await new runtime.JSONApiResponse(response).value(); 111 | } 112 | 113 | /** 114 | * Marks an upload part as uploaded. You must call this endpoint after you have successfully issued a `PUT` request to the `uploadUrl` on the corresponding UploadPart. 115 | */ 116 | async completeUploadPart(params: CompleteUploadPartOperationParams): Promise { 117 | const query: any = {}; 118 | const headers: runtime.HTTPHeaders = {}; 119 | 120 | headers["Content-Type"] = "application/json"; 121 | 122 | const response = await this.request( 123 | { 124 | path: `/v2/accounts/{accountId}/uploads/{uploadId}/parts/{uploadPartIndex}` 125 | .replace( 126 | `{${"accountId"}}`, 127 | // @ts-ignore 128 | this.encodePathParam("accountId", params.accountId) 129 | ) 130 | .replace( 131 | `{${"uploadId"}}`, 132 | // @ts-ignore 133 | this.encodePathParam("uploadId", params.uploadId) 134 | ) 135 | .replace( 136 | `{${"uploadPartIndex"}}`, 137 | // @ts-ignore 138 | this.encodePathParam("uploadPartIndex", params.uploadPartIndex) 139 | ), 140 | method: "PUT", 141 | headers, 142 | query, 143 | body: params.completeUploadPartRequest 144 | }, 145 | undefined, 146 | [][0] 147 | ); 148 | 149 | return await new runtime.JSONApiResponse(response).value(); 150 | } 151 | 152 | /** 153 | * Gets a remaining upload part for a multipart file upload. 154 | */ 155 | async getUploadPart(params: GetUploadPartParams): Promise { 156 | const query: any = {}; 157 | const headers: runtime.HTTPHeaders = {}; 158 | 159 | const response = await this.request( 160 | { 161 | path: `/v2/accounts/{accountId}/uploads/{uploadId}/parts/{uploadPartIndex}` 162 | .replace( 163 | `{${"accountId"}}`, 164 | // @ts-ignore 165 | this.encodePathParam("accountId", params.accountId) 166 | ) 167 | .replace( 168 | `{${"uploadId"}}`, 169 | // @ts-ignore 170 | this.encodePathParam("uploadId", params.uploadId) 171 | ) 172 | .replace( 173 | `{${"uploadPartIndex"}}`, 174 | // @ts-ignore 175 | this.encodePathParam("uploadPartIndex", params.uploadPartIndex) 176 | ), 177 | method: "GET", 178 | headers, 179 | query 180 | }, 181 | undefined, 182 | [][0] 183 | ); 184 | 185 | return await new runtime.JSONApiResponse(response).value(); 186 | } 187 | 188 | /** 189 | * Lists the remaining upload parts for a multipart file upload. An empty array is returned when the upload is complete. 190 | */ 191 | async listUploadParts(params: ListUploadPartsParams): Promise { 192 | const query: any = {}; 193 | const headers: runtime.HTTPHeaders = {}; 194 | 195 | const response = await this.request( 196 | { 197 | path: `/v2/accounts/{accountId}/uploads/{uploadId}/parts` 198 | .replace( 199 | `{${"accountId"}}`, 200 | // @ts-ignore 201 | this.encodePathParam("accountId", params.accountId) 202 | ) 203 | .replace( 204 | `{${"uploadId"}}`, 205 | // @ts-ignore 206 | this.encodePathParam("uploadId", params.uploadId) 207 | ), 208 | method: "GET", 209 | headers, 210 | query 211 | }, 212 | undefined, 213 | [][0] 214 | ); 215 | 216 | return await new runtime.JSONApiResponse(response).value(); 217 | } 218 | 219 | /** 220 | * Upload from a URL with a single HTTP request: 221 | */ 222 | async uploadFromUrl(params: UploadFromUrlOperationParams): Promise { 223 | const query: any = {}; 224 | const headers: runtime.HTTPHeaders = {}; 225 | 226 | headers["Content-Type"] = "application/json"; 227 | 228 | const response = await this.request( 229 | { 230 | path: `/v2/accounts/{accountId}/uploads/url`.replace( 231 | `{${"accountId"}}`, 232 | // @ts-ignore 233 | this.encodePathParam("accountId", params.accountId) 234 | ), 235 | method: "POST", 236 | headers, 237 | query, 238 | body: params.uploadFromUrlRequest 239 | }, 240 | undefined, 241 | [][0] 242 | ); 243 | 244 | return await new runtime.JSONApiResponse(response).value(); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/public/shared/generated/apis/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export * from "./CacheApi"; 4 | export * from "./FileApi"; 5 | export * from "./FolderApi"; 6 | export * from "./JobApi"; 7 | export * from "./UploadApi"; 8 | -------------------------------------------------------------------------------- /src/public/shared/generated/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | export * from "./runtime"; 4 | export * from "./apis"; 5 | export * from "./models"; 6 | -------------------------------------------------------------------------------- /src/public/shared/generated/runtime.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | import { ErrorResponse } from "./models"; 4 | import { AuthSessionState } from "../../../private/AuthSessionState"; 5 | import { ConsoleUtils } from "../../../private/ConsoleUtils"; 6 | import { FilePathUtils } from "../../../private/FilePathUtils"; 7 | 8 | /** 9 | * @bytescale/api 10 | * Bytescale API 11 | * 12 | * The version of the OpenAPI document: 2.0.0 13 | * Contact: hello@bytescale.com 14 | * 15 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). 16 | * https://openapi-generator.tech 17 | * Do not edit the class manually. 18 | */ 19 | 20 | export interface BytescaleApiClientConfig { 21 | /** 22 | * Only required for Node.js. Must be an instance of requires("node-fetch"). 23 | * 24 | * Not required for the browser. 25 | */ 26 | fetchApi?: FetchAPI; 27 | 28 | /** 29 | * Must begin with "public_" or "secret_". 30 | * 31 | * Please note: if you require JWT-based auth, you must provide an API key to this field, and then call 'AuthManager.beginAuthSession' to start a JWT-based auth session. The JWT's permissions will be merged with the API key's permissions, with precedence given to the JWT. 32 | */ 33 | apiKey: string; 34 | 35 | /** 36 | * The base URL of the Bytescale API. (Excludes trailing "/".) 37 | */ 38 | apiUrl?: string; 39 | 40 | /** 41 | * The base URL of the Bytescale CDN. (Excludes trailing "/".) 42 | */ 43 | cdnUrl?: string; 44 | 45 | /** 46 | * Enables additional debug information. 47 | */ 48 | debug?: boolean; 49 | 50 | /** 51 | * Headers to include in all API requests. 52 | * 53 | * These headers take precedence over any headers automatically added by the SDK (e.g. "Authorization", "Content-Type", etc.). 54 | */ 55 | headers?: HTTPHeaders | (() => Promise | HTTPHeaders); // This should be present on all Bytescale SDKs, as it's how we instruct users to pass the "Authorization-Token" request header for non-cookie-based JWT auth. 56 | } 57 | 58 | export class BytescaleApiClientConfigUtils { 59 | static defaultApiUrl = "https://api.bytescale.com"; 60 | static defaultCdnUrl = "https://upcdn.io"; 61 | private static readonly specialApiKeys = ["free", "demo"]; 62 | private static readonly specialApiKeyAccountId = "W142hJk"; 63 | private static readonly accountIdLength = 7; // Sync with: upload/shared/**/AccountIdUtils 64 | 65 | static getApiUrl(config: BytescaleApiClientConfig): string { 66 | return config.apiUrl ?? BytescaleApiClientConfigUtils.defaultApiUrl; 67 | } 68 | 69 | static getCdnUrl(config: Pick): string { 70 | return config.cdnUrl ?? BytescaleApiClientConfigUtils.defaultCdnUrl; 71 | } 72 | 73 | static getFetchApi(config: Pick): FetchAPI { 74 | return config.fetchApi ?? fetch; 75 | } 76 | 77 | static getAccountId(config: Pick): string { 78 | let accountId: string; 79 | 80 | if (BytescaleApiClientConfigUtils.specialApiKeys.includes(config.apiKey)) { 81 | accountId = BytescaleApiClientConfigUtils.specialApiKeyAccountId; 82 | } else { 83 | accountId = config.apiKey.split("_")[1]?.substr(0, BytescaleApiClientConfigUtils.accountIdLength) ?? ""; 84 | if (accountId.length !== BytescaleApiClientConfigUtils.accountIdLength) { 85 | throw new Error(`Invalid Bytescale API key.`); 86 | } 87 | } 88 | 89 | return accountId; 90 | } 91 | 92 | static validate(config: BytescaleApiClientConfig): void { 93 | // Defensive programming, for users not using TypeScript. Mainly because this is used by UploadWidget users. 94 | if ((config ?? undefined) === undefined) { 95 | throw new Error(`Config parameter required.`); 96 | } 97 | if ((config.apiKey ?? undefined) === undefined) { 98 | throw new Error(`Please provide an API key via the 'apiKey' config parameter.`); 99 | } 100 | if (config.apiKey.trim() !== config.apiKey) { 101 | // We do not support API keys with whitespace (by trimming ourselves) because otherwise we'd need to support this 102 | // everywhere in perpetuity (since removing the trimming would be a breaking change). 103 | throw new Error(`API key needs trimming (whitespace detected).`); 104 | } 105 | 106 | // This performs futher validation on the API key... 107 | BytescaleApiClientConfigUtils.getAccountId(config); 108 | } 109 | } 110 | 111 | /** 112 | * This is the base class for all generated API classes. 113 | */ 114 | export class BaseAPI { 115 | constructor(protected readonly config: BytescaleApiClientConfig) { 116 | BytescaleApiClientConfigUtils.validate(config); 117 | } 118 | 119 | /** 120 | * Returns a successful response (2**) else throws an error. 121 | */ 122 | static async fetch( 123 | url: string, 124 | init: RequestInit, 125 | config: Pick & { isBytescaleApi: boolean } 126 | ): Promise { 127 | let response: Response; 128 | try { 129 | response = await BytescaleApiClientConfigUtils.getFetchApi(config)(url, { 130 | ...init, 131 | 132 | // This is specifically added to cater for Next.js's Fetch implementation, which caches POST requests... 133 | // 134 | // "fetch requests that use the POST method are also automatically cached." 135 | // - https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#caching-data 136 | // 137 | // However, this is probably a good idea, even for all GET requests, as if the user is refreshing a JWT 138 | // or downloading a file via 'FileApi.downloadFile', then they'll likely want the latest. 139 | cache: "no-store", 140 | 141 | // Node does not support full duplex, so default fetch implementations will fail with "RequestInit: duplex option is required when sending a body" 142 | // unless the following is set. 143 | // https://github.com/nodejs/node/issues/46221#issuecomment-1383246036 144 | duplex: "half" 145 | }); 146 | } catch (e) { 147 | // Network-level errors, CORS errors, or HTTP-level errors from intermediary services (e.g. AWS or the user's own infrastructure/proxies). 148 | // HTTP-level errors from external services (e.g. AWS or the user's own proxy) will appear as CORS errors as their response headers won't include the appropriate CORS values. 149 | throw new Error( 150 | config.isBytescaleApi 151 | ? `Unable to resolve the Bytescale API: ${ 152 | (e as Error).message 153 | } If the problem persists, and your network connection is OK, then please contact support@bytescale.com and provide: (a) time of failed request in UTC (b) screenshot of failed network response header + body (c) screenshot of failed network request header + body (d) browser and OS version.` 154 | : `Unable to resolve URL (${url}): ${(e as Error).message}` 155 | ); 156 | } 157 | 158 | if (response.status >= 200 && response.status < 300) { 159 | return response; 160 | } 161 | 162 | if (config.isBytescaleApi) { 163 | let errorText = undefined; 164 | let errorJson = undefined; 165 | try { 166 | errorText = await response.text(); 167 | errorJson = JSON.parse(errorText); 168 | } catch (_e) { 169 | // Error will be thrown below. 170 | } 171 | if (typeof errorJson?.error?.code === "string") { 172 | throw new BytescaleApiError(errorJson as ErrorResponse); 173 | } 174 | 175 | if (config.debug === true) { 176 | ConsoleUtils.debug("Error response header:"); 177 | response.headers.forEach((headerValue, headerKey) => ConsoleUtils.debug(`${headerKey}: ${headerValue}`)); 178 | ConsoleUtils.debug("Error response body:"); 179 | ConsoleUtils.debug(errorText ?? ""); 180 | } 181 | 182 | // HTTP-level errors from intermediary services (e.g. AWS or the user's own infrastructure/proxies). On the browser, 183 | // this error is unlikely to be triggered since these errors will masqurade as CORS errors (see above) but in Node.js 184 | // this error will appear from any intermediary service failure. Also occurs when calling ProcessFile for an 185 | // asynchronous media job, where the transformation is initiated using the primary artifact: in this instance, 186 | // a 404 JSON response is returned containing the transformation job until it completes. 187 | throw new BytescaleGenericError(response, errorText, errorJson); 188 | } 189 | 190 | throw new Error(`Failure status code (${response.status}) received for request: ${init.method ?? "GET"} ${url}`); 191 | } 192 | 193 | protected async request( 194 | context: RequestOpts, 195 | initOverrides: RequestInit | InitOverrideFunction | undefined, 196 | baseUrlOverride: string | undefined 197 | ): Promise { 198 | const apiKey = this.config.apiKey; 199 | context.headers["Authorization"] = `Bearer ${apiKey}`; // authorization-header authentication 200 | 201 | const session = AuthSessionState.getSession(); 202 | if (session?.accessToken !== undefined) { 203 | context.headers["Authorization-Token"] = session.accessToken; 204 | } 205 | 206 | // Key: any possible value for 'baseUrlOverride' 207 | // Value: user-overridden value for that base URL from the config. 208 | const nonDefaultBasePaths = { 209 | [BytescaleApiClientConfigUtils.defaultCdnUrl]: BytescaleApiClientConfigUtils.getCdnUrl(this.config) 210 | }; 211 | 212 | const { url, init } = await this.createFetchParams( 213 | context, 214 | initOverrides, 215 | baseUrlOverride === undefined ? undefined : nonDefaultBasePaths[baseUrlOverride] ?? baseUrlOverride 216 | ); 217 | 218 | return BaseAPI.fetch(url, init, { ...this.config, isBytescaleApi: true }); 219 | } 220 | 221 | protected encodePathParam(paramName: string, paramValue: string): string { 222 | if (paramName === "filePath") { 223 | if (!paramValue.startsWith("/")) { 224 | // Non-obvious errors are returned by the Bytescale CDN if forward slashes are omitted, so catch it client-side: 225 | throw new Error("The 'filePath' parameter must begin with a '/' character."); 226 | } 227 | return FilePathUtils.encodeFilePath(paramValue); 228 | } 229 | 230 | return encodeURIComponent(paramValue); 231 | } 232 | 233 | private async createFetchParams( 234 | context: RequestOpts, 235 | initOverrides: RequestInit | InitOverrideFunction | undefined, 236 | baseUrlOverride: string | undefined 237 | ) { 238 | let url = (baseUrlOverride ?? BytescaleApiClientConfigUtils.getApiUrl(this.config)) + context.path; 239 | if (context.query !== undefined && Object.keys(context.query).length !== 0) { 240 | // only add the querystring to the URL if there are query parameters. 241 | // this is done to avoid urls ending with a "?" character which buggy webservers 242 | // do not handle correctly sometimes. 243 | url += "?" + querystring(context.query); 244 | } 245 | const configHeaders = this.config.headers; 246 | const headers = { 247 | ...context.headers, 248 | // Headers from config take precedence, to allow us to override the "Authorization" header (which is added earlier 249 | // on) with a JWT session token. 250 | ...(configHeaders === undefined 251 | ? {} 252 | : typeof configHeaders === "function" 253 | ? await configHeaders() 254 | : configHeaders) 255 | }; 256 | Object.keys(headers).forEach(key => (headers[key] === undefined ? delete headers[key] : {})); 257 | 258 | const initOverrideFn = typeof initOverrides === "function" ? initOverrides : async () => initOverrides; 259 | 260 | const initParams = { 261 | method: context.method, 262 | headers, 263 | body: context.body 264 | }; 265 | 266 | const overriddenInit: RequestInit = { 267 | ...initParams, 268 | ...(await initOverrideFn({ 269 | init: initParams, 270 | context 271 | })) 272 | }; 273 | 274 | const init: RequestInit = { 275 | ...overriddenInit, 276 | body: JSON.stringify(overriddenInit.body) 277 | }; 278 | 279 | return { url, init }; 280 | } 281 | } 282 | 283 | export class CancelledError extends Error { 284 | override name: "CancelledError" = "CancelledError"; 285 | 286 | constructor() { 287 | super("Operation cancelled by caller."); 288 | } 289 | } 290 | 291 | /** 292 | * Thrown when the Bytescale API cannot be reached or when an error is returned that cannot be parsed as a JSON error response. 293 | */ 294 | export class BytescaleGenericError extends Error { 295 | override name: "BytescaleGenericError" = "BytescaleGenericError"; 296 | constructor( 297 | public readonly response: Response, 298 | public readonly responseText: string | undefined, 299 | public readonly responseJson: object | undefined 300 | ) { 301 | super(`Unable to connect to the Bytescale API (${response.status}): please try again.`); 302 | } 303 | } 304 | 305 | /** 306 | * Thrown when the Bytescale API returns a JSON error response. 307 | */ 308 | export class BytescaleApiError extends Error { 309 | override name: "BytescaleApiError" = "BytescaleApiError"; 310 | public readonly errorCode: string; 311 | public readonly details: any | undefined; 312 | 313 | constructor(response: ErrorResponse) { 314 | super(response.error.message); 315 | 316 | this.errorCode = response.error.code; 317 | this.details = response.error.details; 318 | } 319 | } 320 | 321 | export type FetchAPI = (input: RequestInfo | URL, init?: RequestInit & { duplex: "half" }) => Promise; 322 | 323 | export type Json = any; 324 | export type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD"; 325 | export type HTTPHeaders = { [key: string]: string }; 326 | export type HTTPQuery = { 327 | [key: string]: string | number | null | boolean | HTTPQuery; 328 | }; 329 | export type HTTPBody = Json | FormData | URLSearchParams; 330 | export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; body?: HTTPBody }; 331 | 332 | export type InitOverrideFunction = (requestContext: { 333 | init: HTTPRequestInit; 334 | context: RequestOpts; 335 | }) => Promise; 336 | 337 | export interface RequestOpts { 338 | path: string; 339 | method: HTTPMethod; 340 | headers: HTTPHeaders; 341 | query?: HTTPQuery; 342 | body?: HTTPBody; 343 | } 344 | 345 | function moveElementToEnd(array: T[], element: T): T[] { 346 | return [...array.filter(x => x !== element), ...array.filter(x => x === element)]; 347 | } 348 | 349 | export function querystring(params: HTTPQuery): string { 350 | // The 'artifact' param must be the last param for certain transformations, such as async HLS jobs. For example, 351 | // given an artifact '!f=hls-h264&artifact=/video.m3u8' that returns a master M3U8 playlist containing relative 352 | // links to child M3U8 playlists (e.g. 'child1.m3u8'), when the child URLs inside the master M3U8 file are resolved 353 | // by the browser, the 'child1.m3u8' path essentially replaces everything after the '/' on the master M3U8 URL. 354 | // Thus, if query params existed after the 'artifact' param, they would be wiped out, causing the child M3U8 355 | // playlist to suddenly reference a different transformation. 356 | const keysReordered = moveElementToEnd(Object.keys(params), "artifact"); 357 | 358 | return keysReordered 359 | .map(key => querystringSingleKey(key, params[key])) 360 | .filter(part => part.length > 0) 361 | .join("&"); 362 | } 363 | 364 | function querystringSingleKey(key: string, value: string | number | null | undefined | boolean | HTTPQuery): string { 365 | if (value instanceof Object) { 366 | // Matches 'array' or 'object' (which we want). 367 | return querystring(value as HTTPQuery); 368 | } 369 | return encodeBytescaleQuerystringKVP(key, String(value)); 370 | } 371 | 372 | /** 373 | * Handles artifacts specially as these must use "/" instead of "%2F" in order for relative paths within the artifact's 374 | * contents to work (assumes user has replaced "?" with "!"). For example, M3U8 artifacts that contain relative URLs to 375 | * other M3U8s and/or media segments will only work if the user replaces "?" with "!" in the URL _and_ the artifact 376 | * query param value has been written using "/" instead of "%2F", as this then means the URLs become relative to the 377 | * artifact, as opposed to the file path. 378 | */ 379 | export function encodeBytescaleQuerystringKVP(key: string, value: string) { 380 | if (key === "a" || key === "artifact") { 381 | return `${key}=${encodeURIComponent(value).replace(/%2F/g, "/")}`; 382 | } 383 | return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; 384 | } 385 | 386 | export interface ApiResponse { 387 | raw: Response; 388 | value(): Promise; 389 | } 390 | 391 | export class JSONApiResponse { 392 | constructor(public raw: Response) {} 393 | 394 | async value(): Promise { 395 | return await this.raw.json(); 396 | } 397 | } 398 | 399 | export class VoidApiResponse { 400 | constructor(public raw: Response) {} 401 | 402 | async value(): Promise { 403 | return undefined; 404 | } 405 | } 406 | 407 | export class BinaryResult { 408 | constructor(public raw: Response) {} 409 | 410 | stream(): ReadableStream { 411 | if (this.raw.bodyUsed) { 412 | throw new Error("Response body has already been consumed."); 413 | } 414 | if (this.raw.body === null) { 415 | throw new Error("Response body does not exist."); 416 | } 417 | return this.raw.body; 418 | } 419 | 420 | async text(): Promise { 421 | return await this.raw.text(); 422 | } 423 | 424 | async blob(): Promise { 425 | return await this.raw.blob(); 426 | } 427 | 428 | async json(): Promise { 429 | return await this.raw.json(); 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/public/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./generated"; 2 | export * from "./CommonTypes"; 3 | export * from "./UrlBuilder"; 4 | export * from "./UrlBuilderTypes"; 5 | -------------------------------------------------------------------------------- /src/public/worker/README.md: -------------------------------------------------------------------------------- 1 | # Worker Runtime 2 | 3 | The `worker` runtime is for Next.js Edge runtimes. 4 | 5 | This is a server-side runtime. 6 | 7 | The Next.js Edge runtime is similar to that of a browser, but with a few exceptions (some of these are obvious): 8 | 9 | - All DOM-related features are not present. 10 | - 'window' is not preset. 11 | - XHR is not present (you need to use fetch). 12 | - ReadableStream is present (and should be used instead of Node.js's Readable) 13 | 14 | As such, the `worker` runtime is designed to use `fetch` with support for `ReadableSteam`, but a NoOp implementation for `AuthManager`, since this is a server-side environment. 15 | -------------------------------------------------------------------------------- /src/public/worker/UploadManagerWorker.ts: -------------------------------------------------------------------------------- 1 | import { UploadSourceProcessedWorker } from "../../private/model/UploadSourceProcessed"; 2 | import { UploadPart } from "../shared/generated"; 3 | import { PutUploadPartResult } from "../../private/model/PutUploadPartResult"; 4 | import { AddCancellationHandler } from "../../private/model/AddCancellationHandler"; 5 | import { UploadManagerBrowserWorkerBase } from "../../private/UploadManagerBrowserWorkerBase"; 6 | import { UploadManagerFetchUtils } from "../../private/UploadManagerFetchUtils"; 7 | 8 | export class UploadManager extends UploadManagerBrowserWorkerBase { 9 | protected async doPutUploadPart( 10 | part: UploadPart, 11 | contentLength: number, 12 | source: UploadSourceProcessedWorker, 13 | _onProgress: (bytesSentDelta: number) => void, 14 | addCancellationHandler: AddCancellationHandler 15 | ): Promise { 16 | return await UploadManagerFetchUtils.doPutUploadPart( 17 | this.config, 18 | part, 19 | this.getRequestBody(part, source), 20 | contentLength, 21 | addCancellationHandler 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/public/worker/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UploadManagerWorker"; 2 | export * from "../node/AuthManagerNode"; 3 | -------------------------------------------------------------------------------- /tests/ChunkedStream.test.ts: -------------------------------------------------------------------------------- 1 | import { NodeChunkedStream } from "../src/private/NodeChunkedStream"; 2 | import { createRandomStreamFactory } from "./utils/RandomStream"; 3 | import { streamToBuffer } from "./utils/StreamToBuffer"; 4 | 5 | describe("ChunkedStream", () => { 6 | test( 7 | "forward the entire stream", 8 | async () => { 9 | const expectedSize = Math.pow(1024, 2) * 100; // 100MB 10 | const maxPartSize = Math.pow(1024, 2) * 2; // 2MB 11 | const expectedData = await createRandomStreamFactory(expectedSize); 12 | const chunkedStream = new NodeChunkedStream(expectedData()); 13 | const worker = chunkedStream.runChunkPipeline(); 14 | let remaining = expectedSize; 15 | const buffersFromParts = Array(); 16 | 17 | while (remaining > 0) { 18 | const nextPartSize = (): number => { 19 | const next = Math.min(remaining, Math.round(Math.random() * maxPartSize) + 1); 20 | remaining -= next; 21 | return next; 22 | }; 23 | 24 | const partSize = nextPartSize(); 25 | 26 | const subStream = chunkedStream.take(partSize); 27 | const subStreamBuffer = await streamToBuffer(subStream); 28 | 29 | expect(subStreamBuffer.byteLength).toEqual(partSize); 30 | expect(subStreamBuffer.length).toEqual(partSize); 31 | 32 | buffersFromParts.push(subStreamBuffer); 33 | } 34 | 35 | chunkedStream.finishedConsuming(); 36 | await worker; 37 | 38 | const actual = Buffer.concat(buffersFromParts); 39 | const expected = await streamToBuffer(expectedData()); 40 | 41 | const buffersEqual = Buffer.compare(expected, actual) === 0; 42 | 43 | expect(actual.length).toEqual(expected.length); 44 | expect(actual.byteLength).toEqual(expected.byteLength); 45 | expect(buffersEqual).toEqual(true); 46 | }, 47 | 10 * 60 * 1000 48 | ); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/UploadManager.test.ts: -------------------------------------------------------------------------------- 1 | import { BytescaleApiClientConfig, FileApi, UploadManager } from "../src/index.node"; 2 | import fetch from "node-fetch"; 3 | import * as buffer from "buffer"; 4 | import { createRandomStreamFactory } from "./utils/RandomStream"; 5 | import { streamToBuffer } from "./utils/StreamToBuffer"; 6 | import { promises as fsAsync } from "fs"; 7 | import { prepareTempDirectory } from "./utils/TempUtils"; 8 | import * as Path from "path"; 9 | 10 | if (process.env.BYTESCALE_SECRET_API_KEY === undefined) { 11 | throw new Error("Expected env var: BYTESCALE_SECRET_API_KEY"); 12 | } 13 | if (process.env.BYTESCALE_ACCOUNT_ID === undefined) { 14 | throw new Error("Expected env var: BYTESCALE_ACCOUNT_ID"); 15 | } 16 | const accountId = process.env.BYTESCALE_ACCOUNT_ID; 17 | 18 | const configuration: BytescaleApiClientConfig = { 19 | fetchApi: fetch as any, 20 | apiKey: process.env.BYTESCALE_SECRET_API_KEY // e.g. "secret_xxxxx" 21 | }; 22 | 23 | const uploadManager = new UploadManager(configuration); 24 | const fileApi = new FileApi(configuration); 25 | const largeFileSize = Math.pow(1024, 2) * 500; // 500MB 26 | 27 | async function testStreamingUpload(expectedSize: number): Promise { 28 | const expectedData = await createRandomStreamFactory(expectedSize); 29 | const uploadedFile = await uploadManager.upload({ 30 | data: expectedData(), 31 | size: expectedSize 32 | }); 33 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 34 | const actualData = (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).stream(); 35 | const expectedDataBuffer = await streamToBuffer(expectedData()); 36 | const actualDataBuffer = await streamToBuffer(actualData as any); 37 | const buffersEqual = Buffer.compare(expectedDataBuffer, actualDataBuffer) === 0; 38 | const actualSize = fileDetails.size; 39 | 40 | expect(actualSize).toEqual(expectedSize); 41 | expect(actualDataBuffer.length).toEqual(expectedDataBuffer.length); 42 | expect(actualDataBuffer.byteLength).toEqual(expectedDataBuffer.byteLength); 43 | 44 | if (!buffersEqual) { 45 | const dir = await prepareTempDirectory(); 46 | await fsAsync.writeFile(Path.join(dir, "expected.txt"), expectedDataBuffer); 47 | await fsAsync.writeFile(Path.join(dir, "actual.txt"), actualDataBuffer); 48 | } 49 | 50 | expect(buffersEqual).toEqual(true); 51 | } 52 | 53 | describe("UploadManager", () => { 54 | test("upload an empty string", async () => { 55 | const expectedData = ""; 56 | const expectedSize = 0; 57 | const uploadedFile = await uploadManager.upload({ 58 | data: expectedData 59 | }); 60 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 61 | const actualData = await (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).text(); 62 | const actualSize = fileDetails.size; 63 | expect(actualData).toEqual(expectedData); 64 | expect(actualSize).toEqual(expectedSize); 65 | }); 66 | 67 | test("upload a string", async () => { 68 | const expectedData = "Example Data"; 69 | const expectedMime = "text/plain"; 70 | const uploadedFile = await uploadManager.upload({ 71 | data: expectedData 72 | }); 73 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 74 | const actualData = await (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).text(); 75 | const actualMime = fileDetails.mime; 76 | expect(actualData).toEqual(expectedData); 77 | expect(actualMime).toEqual(expectedMime); 78 | }); 79 | 80 | test("upload a BLOB", async () => { 81 | const expectedData = JSON.stringify({ someValue: 42 }); 82 | const expectedMime = "application/json"; 83 | const uploadedFile = await uploadManager.upload({ 84 | data: new buffer.Blob([expectedData], { type: expectedMime }) 85 | }); 86 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 87 | const actualData = await (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).text(); 88 | const actualMime = fileDetails.mime; 89 | expect(actualData).toEqual(expectedData); 90 | expect(actualMime).toEqual(expectedMime); 91 | }); 92 | 93 | test("upload a buffer (override MIME)", async () => { 94 | const expectedData = "Example Data"; 95 | const expectedMime = "text/plain"; 96 | const uploadedFile = await uploadManager.upload({ 97 | data: Buffer.from(expectedData, "utf8"), 98 | mime: expectedMime 99 | }); 100 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 101 | const actualData = await (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).text(); 102 | const actualMime = fileDetails.mime; 103 | expect(actualData).toEqual(expectedData); 104 | expect(actualMime).toEqual(expectedMime); 105 | }); 106 | 107 | test("upload a small buffer", async () => { 108 | const expectedData = "Example Data"; 109 | const expectedMime = "application/octet-stream"; 110 | const uploadedFile = await uploadManager.upload({ 111 | data: Buffer.from(expectedData, "utf8") 112 | }); 113 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 114 | const actualData = await (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).text(); 115 | const actualMime = fileDetails.mime; 116 | expect(actualData).toEqual(expectedData); 117 | expect(actualMime).toEqual(expectedMime); 118 | }); 119 | 120 | test( 121 | "upload a large buffer", 122 | async () => { 123 | const expectedSize = largeFileSize; 124 | const expectedData = await streamToBuffer((await createRandomStreamFactory(expectedSize))()); 125 | const uploadedFile = await uploadManager.upload({ 126 | data: expectedData 127 | }); 128 | const fileDetails = await fileApi.getFileDetails({ accountId, filePath: uploadedFile.filePath }); 129 | const actualStream = (await fileApi.downloadFile({ accountId, filePath: uploadedFile.filePath })).stream(); 130 | const actualData = await streamToBuffer(actualStream as any); 131 | const actualSize = fileDetails.size; 132 | const buffersEqual = Buffer.compare(expectedData, actualData) === 0; 133 | 134 | expect(actualSize).toEqual(expectedSize); 135 | expect(actualData.length).toEqual(expectedData.length); 136 | expect(actualData.byteLength).toEqual(expectedData.byteLength); 137 | expect(buffersEqual).toEqual(true); 138 | }, 139 | 10 * 60 * 1000 140 | ); 141 | 142 | test( 143 | "upload a small stream", 144 | async () => { 145 | await testStreamingUpload(Math.pow(1024, 2) * 2); // 2MB 146 | }, 147 | 10 * 60 * 1000 148 | ); 149 | 150 | test( 151 | "upload a large stream", 152 | async () => { 153 | await testStreamingUpload(largeFileSize); // 500MB 154 | }, 155 | 10 * 60 * 1000 156 | ); 157 | }); 158 | -------------------------------------------------------------------------------- /tests/UrlBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { UrlBuilder } from "../src/public/shared"; 2 | 3 | describe("UrlBuilder", () => { 4 | test("raw file without params", () => { 5 | const actual = UrlBuilder.url({ accountId: "1234abc", filePath: "/example.jpg" }); 6 | const expected = "https://upcdn.io/1234abc/raw/example.jpg"; 7 | expect(actual).toEqual(expected); 8 | }); 9 | 10 | test("raw file with one param", () => { 11 | const actual = UrlBuilder.url({ accountId: "1234abc", filePath: "/example.jpg", options: { auth: true } }); 12 | const expected = "https://upcdn.io/1234abc/raw/example.jpg"; 13 | expect(actual).toEqual(expected); 14 | }); 15 | 16 | test("raw file with multiple params", () => { 17 | const actual = UrlBuilder.url({ 18 | accountId: "1234abc", 19 | filePath: "/example.jpg", 20 | options: { auth: true, cache: true, version: "42" } 21 | }); 22 | const expected = "https://upcdn.io/1234abc/raw/example.jpg?cache=true&version=42"; 23 | expect(actual).toEqual(expected); 24 | }); 25 | 26 | test("raw file with rewritten params", () => { 27 | const actual = UrlBuilder.url({ 28 | accountId: "1234abc", 29 | filePath: "/example.jpg", 30 | options: { auth: true, cache: true, cacheTtl: 100 } // cacheTtl is rewritten to cache_ttl 31 | }); 32 | const expected = "https://upcdn.io/1234abc/raw/example.jpg?cache=true&cache_ttl=100"; 33 | expect(actual).toEqual(expected); 34 | }); 35 | 36 | test("transformation preset without params", () => { 37 | const actual = UrlBuilder.url({ 38 | accountId: "1234abc", 39 | filePath: "/example.jpg", 40 | options: { transformation: "preset", transformationPreset: "thumbnail" } 41 | }); 42 | const expected = "https://upcdn.io/1234abc/thumbnail/example.jpg"; 43 | expect(actual).toEqual(expected); 44 | }); 45 | 46 | test("transformation preset with one param", () => { 47 | const actual = UrlBuilder.url({ 48 | accountId: "1234abc", 49 | filePath: "/example.jpg", 50 | options: { transformation: "preset", transformationPreset: "thumbnail", artifact: "/foo" } 51 | }); 52 | const expected = "https://upcdn.io/1234abc/thumbnail/example.jpg?artifact=/foo"; 53 | expect(actual).toEqual(expected); 54 | }); 55 | 56 | test("transformation preset with multiple params", () => { 57 | const actual = UrlBuilder.url({ 58 | accountId: "1234abc", 59 | filePath: "/example.jpg", 60 | options: { transformation: "preset", transformationPreset: "thumbnail", artifact: "/foo" } 61 | }); 62 | const expected = "https://upcdn.io/1234abc/thumbnail/example.jpg?artifact=/foo"; 63 | expect(actual).toEqual(expected); 64 | }); 65 | 66 | test("transformation preset with rewritten params", () => { 67 | const actual = UrlBuilder.url({ 68 | accountId: "1234abc", 69 | filePath: "/example.jpg", 70 | options: { 71 | transformation: "preset", 72 | transformationPreset: "thumbnail", 73 | artifact: "/foo", 74 | cachePermanently: false 75 | } 76 | }); 77 | const expected = "https://upcdn.io/1234abc/thumbnail/example.jpg?cache_perm=false&artifact=/foo"; 78 | expect(actual).toEqual(expected); 79 | }); 80 | 81 | test("transformation API without params", () => { 82 | const actual = UrlBuilder.url({ 83 | accountId: "1234abc", 84 | filePath: "/example.jpg", 85 | options: { 86 | transformation: "image" 87 | } 88 | }); 89 | const expected = "https://upcdn.io/1234abc/image/example.jpg"; 90 | expect(actual).toEqual(expected); 91 | }); 92 | 93 | test("transformation API with one param", () => { 94 | const actual = UrlBuilder.url({ 95 | accountId: "1234abc", 96 | filePath: "/example.jpg", 97 | options: { 98 | transformation: "image", 99 | transformationParams: { 100 | w: 42 101 | } 102 | } 103 | }); 104 | const expected = "https://upcdn.io/1234abc/image/example.jpg?w=42"; 105 | expect(actual).toEqual(expected); 106 | }); 107 | 108 | test("transformation API with a mix of params", () => { 109 | const actual = UrlBuilder.url({ 110 | accountId: "1234abc", 111 | filePath: "/example.jpg", 112 | options: { 113 | transformation: "image", 114 | transformationParams: { 115 | w: 42, 116 | h: 50 117 | }, 118 | cachePermanently: "auto", 119 | artifact: "/foo", 120 | version: "50", 121 | auth: true 122 | } 123 | }); 124 | const expected = "https://upcdn.io/1234abc/image/example.jpg?w=42&h=50&version=50&cache_perm=auto&artifact=/foo"; 125 | expect(actual).toEqual(expected); 126 | }); 127 | 128 | test("elide null and undefined", () => { 129 | const actual = UrlBuilder.url({ 130 | accountId: "1234abc", 131 | filePath: "/example.jpg", 132 | options: { 133 | transformation: "image", 134 | transformationParams: { 135 | w: undefined, 136 | h: null, 137 | r: 52 138 | }, 139 | auth: true 140 | } 141 | }); 142 | const expected = "https://upcdn.io/1234abc/image/example.jpg?r=52"; 143 | expect(actual).toEqual(expected); 144 | }); 145 | 146 | // In the future, after we release our new domain structure (byte.cm), we will offer these 2 params: 147 | // - subdomain 148 | // - domain 149 | // If the user specifies either of these, we use the new syntax, which excludes the account ID prefix. 150 | // We will also automatically use their default subdomain (which is a function of their account ID) if unspecified. 151 | // This means customers on legacy custom CNAMEs are not (and will not) be supported by the UrlBuilder. 152 | // test("custom CNAME", () => { 153 | // const actual = new UrlBuilder({ cdnUrl: "https://example.com" }).url({ 154 | // accountId: "1234abc", 155 | // filePath: "/example.jpg", 156 | // options: { 157 | // transformation: "image", 158 | // transformationParams: { 159 | // w: 42, 160 | // h: 50 161 | // } 162 | // } 163 | // }); 164 | // const expected = "https://example.com/1234abc/image/example.jpg?w=42&h=50"; 165 | // expect(actual).toEqual(expected); 166 | // }); 167 | }); 168 | -------------------------------------------------------------------------------- /tests/utils/RandomStream.ts: -------------------------------------------------------------------------------- 1 | // import randomGen from "random-seed"; 2 | import { Readable } from "stream"; 3 | import fs, { promises as fsAsync } from "fs"; 4 | import { prepareTempDirectory } from "./TempUtils"; 5 | import * as Path from "path"; 6 | 7 | /** 8 | * IMPORTANT: 9 | * It's difficult to efficiently create a pseudorandom stream, since 'read' may be called a different number of times 10 | * (i.e. with different read sizes) across two streams, so if the implementation updates the entrophy based on these 11 | * read calls, it will produce two different results for the streams. 12 | * 13 | * As such, we create a random stream, save to file, then stream from the file. 14 | */ 15 | function createRandomStream(sizeInBytes: number): NodeJS.ReadableStream { 16 | const dictionary = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(""); 17 | 18 | // Ensure the chunk size is small enough to cause enough entropy (as we pick a new character for reach chunk). 19 | const minRandomBlocks = 10; 20 | const maxChunkSize = Math.min(64 * 1024, Math.ceil(sizeInBytes / minRandomBlocks)); 21 | 22 | let producedSize = 0; 23 | let iteration = 0; 24 | return new Readable({ 25 | read(requestedReadSize) { 26 | const maxRequestedReadSize = Math.min(requestedReadSize, maxChunkSize); 27 | 28 | // This ensures streams of the same size are unique, else they would have the same contents, assuming chunk size is the same. 29 | // We use 0.8-1 to prevent really small chunks, which may create inefficiencies. 30 | const randomness = randomBetween(0.8, 1); 31 | 32 | let readSize = Math.ceil(maxRequestedReadSize * randomness); 33 | let shouldEnd = false; 34 | 35 | if (producedSize + readSize >= sizeInBytes) { 36 | readSize = sizeInBytes - producedSize; 37 | shouldEnd = true; 38 | } 39 | 40 | const character = dictionary[iteration % dictionary.length]; 41 | const prefix = `${iteration}`.padEnd(10); 42 | 43 | iteration++; 44 | producedSize += readSize; 45 | 46 | if (readSize > prefix.length + 2) { 47 | // Makes debugging a little easier, if we need to read the files. 48 | this.push(Buffer.from(prefix)); 49 | this.push(Buffer.alloc(readSize - (prefix.length + 1), character)); 50 | this.push(Buffer.alloc(1, "\n")); 51 | } else { 52 | this.push(Buffer.alloc(readSize, character)); 53 | } 54 | 55 | if (shouldEnd) { 56 | this.push(null); 57 | } 58 | } 59 | }); 60 | } 61 | 62 | function randomBetween(min: number, max: number): number { 63 | return Math.random() * (max - min) + min; 64 | } 65 | 66 | async function writeToDisk(reader: NodeJS.ReadableStream, path: string): Promise { 67 | await new Promise((resolve, reject) => { 68 | const writer = fs.createWriteStream(path); 69 | writer.on("close", resolve); 70 | writer.on("error", reject); 71 | reader.pipe(writer); 72 | }); 73 | } 74 | 75 | /** 76 | * Creates a method that will return a stream that holds the exact same (random) data each time. 77 | * @param sizeInBytes 78 | */ 79 | export async function createRandomStreamFactory(sizeInBytes: number): Promise<() => NodeJS.ReadableStream> { 80 | const dir = await prepareTempDirectory(); 81 | const file = Path.join(dir, "random.txt"); 82 | 83 | await writeToDisk(createRandomStream(sizeInBytes), file); 84 | 85 | const fileInfo = await fsAsync.stat(file); 86 | if (fileInfo.size !== sizeInBytes) { 87 | throw new Error("Created a random source file with incorrect size!"); 88 | } 89 | 90 | return () => fs.createReadStream(file); 91 | } 92 | -------------------------------------------------------------------------------- /tests/utils/StreamToBuffer.ts: -------------------------------------------------------------------------------- 1 | export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { 2 | return await new Promise((resolve, reject) => { 3 | const buffers = Array(); 4 | stream.on("data", chunk => buffers.push(chunk)); 5 | stream.on("end", () => resolve(Buffer.concat(buffers))); 6 | stream.on("error", err => reject(err)); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /tests/utils/TempUtils.ts: -------------------------------------------------------------------------------- 1 | import { promises as fsAsync } from "fs"; 2 | import * as Path from "path"; 3 | 4 | export async function prepareTempDirectory(): Promise { 5 | const folder = Path.resolve(`tmp/${Date.now()}`); 6 | await fsAsync.rm(folder, { recursive: true, force: true }); 7 | await fsAsync.mkdir(folder, { recursive: true }); 8 | return folder; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "es2015", "es2016", "es2017"], 4 | "target": "es2017", 5 | "module": "es2020", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "esModuleInterop": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "declaration": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "removeComments": false, 15 | "preserveConstEnums": true, 16 | "preserveSymlinks": true, 17 | "skipLibCheck": true, 18 | "outDir": "./dist/types", 19 | "baseUrl": "./src", 20 | "paths": {} 21 | }, 22 | "include": ["./src/**/*"], 23 | "exclude": ["./node_modules/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "es2015", "es2016", "es2017"], 4 | "target": "es2017", 5 | "module": "commonJs", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "declaration": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "removeComments": false, 16 | "preserveConstEnums": true, 17 | "preserveSymlinks": true, 18 | "skipLibCheck": true, 19 | "outDir": "./dist/types", 20 | "baseUrl": "./src", 21 | "paths": {} 22 | }, 23 | "include": ["./**/*"], 24 | "exclude": ["./node_modules/**/*"] 25 | } 26 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: 0 */ 2 | const path = require("path"); 3 | const WebpackShellPluginNext = require("webpack-shell-plugin-next"); 4 | 5 | module.exports = { 6 | // Added by the deriving config. 7 | // output: { 8 | // libraryTarget: "commonjs2", 9 | // path: path.resolve(__dirname, "dist"), 10 | // filename: "main.js" 11 | // }, 12 | // externals: [nodeExternals(), ...externals], 13 | cache: false, 14 | mode: "production", 15 | optimization: { 16 | // Packages on NPM shouldn't be minimized. 17 | minimize: false, 18 | // Several options to make the generated code a little easier to read (for debugging). 19 | chunkIds: "named", 20 | moduleIds: "named", 21 | mangleExports: false 22 | }, 23 | target: "browserslist", 24 | plugins: [ 25 | new WebpackShellPluginNext({ 26 | safe: true, // Required to make Webpack fail on script failure (else string-style scripts, as opposed to function scripts, silently fail when blocking && !parallel) 27 | // Next.js has a bug which causes it to break with Webpack-compiled libraries: 28 | // https://github.com/vercel/next.js/issues/52542 29 | // The following is a (bad) workaround that fixes the issue by find/replacing the webpack-specific variable names that clash with Next.js's build system. 30 | onBuildEnd: { 31 | scripts: [`(cd build && ./RemoveWebpackArtifacts.sh)`], 32 | blocking: true, 33 | parallel: false 34 | } 35 | }) 36 | ], 37 | resolve: { 38 | extensions: [".ts"] 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.ts$/, 44 | use: [ 45 | { 46 | loader: "babel-loader" // Options are in 'babel.config.js' 47 | }, 48 | { 49 | loader: "ts-loader", 50 | options: { 51 | configFile: "tsconfig.build.json" 52 | } 53 | } 54 | ], 55 | include: [path.resolve(__dirname, "src")] 56 | } 57 | ] 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /webpack.config.cdn.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: 0 */ 2 | const config = require("./webpack.config.base.js"); 3 | const externals = require("./webpack.config.externals.js"); 4 | const version = require("./package.json").version; 5 | const majorVersion = parseInt(version.split(".")[0]); 6 | const path = require("path"); 7 | 8 | if (isNaN(majorVersion)) { 9 | throw new Error("Unable to parse version number in package.json"); 10 | } 11 | 12 | /** 13 | * Creates the dist that's published to 'https://js.bytescale.com/sdk/v*'. 14 | */ 15 | module.exports = { 16 | ...config, 17 | entry: "./src/index.browser.ts", 18 | output: { 19 | path: path.resolve(__dirname, "dist"), 20 | filename: `v${majorVersion}.js`, 21 | libraryTarget: "umd", 22 | library: "Bytescale" // Causes all exports of "index.ts" to appear as members of a global "Bytescale" object. 23 | }, 24 | optimization: {}, // Re-enable optimizations (i.e. minification) for CDN bundle. (See base config.) 25 | // Important: causes all dependencies to be bundled into one JS file (except "stream" and "buffer" which doesn't exist 26 | // in the browser, so we still treat as external). 27 | externals, 28 | resolve: { 29 | ...config.resolve, 30 | modules: [ 31 | // Default value (resolve relative 'node_modules' from current dir, and up the ancestors). 32 | "node_modules" 33 | ] 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /webpack.config.externals.js: -------------------------------------------------------------------------------- 1 | module.exports = ["stream", "buffer"]; 2 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: 0 */ 2 | const config = require("./webpack.config.base.js"); 3 | const nodeBuiltInModules = require("./webpack.config.externals.js"); 4 | const path = require("path"); 5 | const nodeExternals = require("webpack-node-externals"); 6 | 7 | /** 8 | * 'esModuleInterop' in 'tsconfig.json' must be set to FALSE, else it injects '__importStar' around the 'buffer' and 'stream' imports. 9 | */ 10 | const baseCJS = { 11 | ...config, 12 | output: { 13 | filename: "main.js", 14 | libraryTarget: "commonjs2" 15 | }, 16 | externalsType: "commonjs", 17 | externals: [nodeExternals({ importType: "commonjs" })] 18 | }; 19 | 20 | const nodeCJS = { 21 | ...baseCJS, 22 | entry: "./src/index.node.ts", 23 | output: { 24 | ...baseCJS.output, 25 | path: path.join(__dirname, "dist/node/cjs") 26 | }, 27 | externals: [...baseCJS.externals, ...nodeBuiltInModules] 28 | }; 29 | 30 | const workerCJS = { 31 | ...baseCJS, 32 | entry: "./src/index.worker.ts", 33 | output: { 34 | ...baseCJS.output, 35 | path: path.join(__dirname, "dist/worker/cjs") 36 | } 37 | }; 38 | 39 | const browserCJS = { 40 | ...baseCJS, 41 | entry: "./src/index.browser.ts", 42 | output: { 43 | ...baseCJS.output, 44 | path: path.join(__dirname, "dist/browser/cjs") 45 | } 46 | }; 47 | 48 | const baseESM = { 49 | ...config, 50 | output: { 51 | filename: "main.mjs", 52 | library: { type: "module" }, 53 | module: true, 54 | environment: { 55 | module: true 56 | } 57 | }, 58 | externalsType: "module", 59 | externals: [nodeExternals({ importType: "module" }), ...nodeBuiltInModules], 60 | experiments: { outputModule: true } 61 | }; 62 | 63 | const nodeESM = { 64 | ...baseESM, 65 | entry: "./src/index.node.ts", 66 | output: { 67 | ...baseESM.output, 68 | path: path.join(__dirname, "dist/node/esm") 69 | } 70 | }; 71 | 72 | const workerESM = { 73 | ...baseESM, 74 | entry: "./src/index.worker.ts", 75 | output: { 76 | ...baseESM.output, 77 | path: path.join(__dirname, "dist/worker/esm") 78 | } 79 | }; 80 | 81 | const browserESM = { 82 | ...baseESM, 83 | entry: "./src/index.browser.ts", 84 | output: { 85 | ...baseESM.output, 86 | path: path.join(__dirname, "dist/browser/esm") 87 | } 88 | }; 89 | 90 | module.exports = [nodeCJS, nodeESM, workerCJS, workerESM, browserCJS, browserESM]; 91 | --------------------------------------------------------------------------------