├── .c8rc.json ├── .changeset └── config.json ├── .editorconfig ├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── lint.yml │ ├── release.yml │ └── typescript.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .lintstagedrc.json ├── .npmignore ├── .npmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── ava.config.js ├── biome.jsonc ├── flake.lock ├── flake.nix ├── license ├── package.json ├── pnpm-lock.yaml ├── readme.md ├── shell.nix ├── src ├── Blob.test.ts ├── Blob.ts ├── BlobPart.ts ├── BodyInit.test.ts ├── File.test.ts ├── File.ts ├── FormData.test.ts ├── FormData.ts ├── __helper__ │ └── sleep.ts ├── blobHelpers.test.ts ├── blobHelpers.ts ├── browser.ts ├── fileFromPath.test.ts ├── fileFromPath.ts ├── hasInstance.test.ts ├── index.ts ├── isAsyncIterable.ts ├── isBlob.ts ├── isFile.ts ├── isFunction.ts ├── isObject.ts └── isReadableStreamFallback.ts ├── tsconfig.ava.json ├── tsconfig.json └── tsup.config.ts /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "exclude": ["src/__helper__", "src/**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "access": "public", 4 | "baseBranch": "main", 5 | "commit": false, 6 | "changelog": [ 7 | "@changesets/changelog-github", 8 | 9 | { 10 | "repo": "octet-stream/form-data" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{ts,json,md,gitignore,npmignore,lintstagedrc}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.ts" 8 | - "tsconfig.json" 9 | - "ava.config.js" 10 | - "package.json" 11 | - "pnpm-lock.yaml" 12 | - ".github/workflows/ci.yml" 13 | pull_request: 14 | branches: [main] 15 | paths: 16 | - "**.ts" 17 | - "tsconfig.json" 18 | - "ava.config.js" 19 | - "package.json" 20 | - "pnpm-lock.yaml" 21 | - ".github/workflows/ci.yml" 22 | 23 | jobs: 24 | ci: 25 | strategy: 26 | matrix: 27 | os: [ubuntu-latest, macOS-latest, windows-latest] 28 | node: [18.x, 20.x, 21.x] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: ${{matrix.node}} 37 | 38 | - name: Setup pnpm 39 | id: pnpm-install 40 | uses: pnpm/action-setup@v4 41 | with: 42 | run_install: false 43 | 44 | - name: Get pnpm store directory 45 | id: pnpm-cache 46 | shell: bash 47 | run: | 48 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 49 | 50 | - uses: actions/cache@v4 51 | name: Setup pnpm cache 52 | with: 53 | path: ${{steps.pnpm-cache.outputs.STORE_PATH}} 54 | key: ${{runner.os}}-pnpm-store-${{hashFiles('**/pnpm-lock.yaml')}} 55 | restore-keys: | 56 | ${{runner.os}}-pnpm-store- 57 | 58 | - name: Install dependencies 59 | run: pnpm i --frozen-lockfile 60 | 61 | - run: pnpm run ci 62 | 63 | - name: Upload codecov report 64 | uses: codecov/codecov-action@v4 65 | if: matrix.node == '20.x' && matrix.os == 'ubuntu-latest' 66 | env: 67 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 68 | with: 69 | file: ./coverage/coverage-final.json 70 | flags: unittests 71 | fail_ci_if_error: false 72 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.ts" 8 | - "**.js" 9 | - "package.json" 10 | - "pnpm-lock.yaml" 11 | - "biome.json" 12 | - ".github/workflows/lint.yml" 13 | pull_request: 14 | branches: [main] 15 | paths: 16 | - "**.ts" 17 | - "**.js" 18 | - "package.json" 19 | - "pnpm-lock.yaml" 20 | - "biome.json" 21 | - ".github/workflows/lint.yml" 22 | 23 | jobs: 24 | lint: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 20 33 | 34 | - name: Setup pnpm 35 | id: pnpm-install 36 | uses: pnpm/action-setup@v4 37 | with: 38 | run_install: false 39 | 40 | - name: Get pnpm store directory 41 | id: pnpm-cache 42 | shell: bash 43 | run: | 44 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 45 | 46 | - uses: actions/cache@v4 47 | name: Setup pnpm cache 48 | with: 49 | path: ${{steps.pnpm-cache.outputs.STORE_PATH}} 50 | key: ${{runner.os}}-pnpm-store-${{hashFiles('**/pnpm-lock.yaml')}} 51 | restore-keys: | 52 | ${{runner.os}}-pnpm-store- 53 | 54 | - name: Install dependencies 55 | run: pnpm i --frozen-lockfile 56 | 57 | - run: pnpm run lint 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-22.04 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | 24 | - name: Setup pnpm 25 | id: pnpm-install 26 | uses: pnpm/action-setup@v4 27 | with: 28 | run_install: false 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 35 | 36 | - uses: actions/cache@v4 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{steps.pnpm-cache.outputs.STORE_PATH}} 40 | key: ${{runner.os}}-pnpm-store-${{hashFiles('**/pnpm-lock.yaml')}} 41 | restore-keys: | 42 | ${{runner.os}}-pnpm-store- 43 | 44 | - name: Install dependencies 45 | run: pnpm i --frozen-lockfile 46 | 47 | - name: Create a Pull Request for a new release 48 | id: changesets 49 | env: 50 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 51 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 52 | uses: changesets/action@v1 53 | with: 54 | title: A new release 55 | commit: Bump version 56 | publish: pnpm run release 57 | version: pnpm exec changeset version 58 | -------------------------------------------------------------------------------- /.github/workflows/typescript.yml: -------------------------------------------------------------------------------- 1 | name: TypeScript Types 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.ts" 8 | - "package.json" 9 | - "pnpm-lock.yaml" 10 | - "tsconfig.json" 11 | - ".github/workflows/typescript.yml" 12 | pull_request: 13 | branches: [main] 14 | paths: 15 | - "**.ts" 16 | - "package.json" 17 | - "pnpm-lock.yaml" 18 | - "tsconfig.json" 19 | - ".github/workflows/typescript.yml" 20 | 21 | jobs: 22 | typescript: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | 32 | - name: Setup pnpm 33 | uses: pnpm/action-setup@v4 34 | with: 35 | run_install: false 36 | 37 | - name: Get pnpm store directory 38 | id: pnpm-cache 39 | shell: bash 40 | run: | 41 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 42 | 43 | - uses: actions/cache@v4 44 | name: Setup pnpm cache 45 | with: 46 | path: ${{steps.pnpm-cache.outputs.STORE_PATH}} 47 | key: ${{runner.os}}-pnpm-store-${{hashFiles('**/pnpm-lock.yaml')}} 48 | restore-keys: | 49 | ${{runner.os}}-pnpm-store- 50 | 51 | - name: Install dependencies 52 | run: pnpm i --frozen-lockfile 53 | 54 | - name: Run TypeScript 55 | run: pnpm run lint:types 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | yarn.lock 3 | package-lock.json 4 | yarn-error.log 5 | yarn-error.log 6 | npm-debug.log 7 | node_modules 8 | *.map 9 | ..map 10 | *.old 11 | @type 12 | 13 | /.nyc_output 14 | /.direnv 15 | /coverage 16 | 17 | /lib 18 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint:types 2 | pnpm lint-staged 3 | git update-index --again 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | pnpm coverage 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,js,json,jsonc}": "pnpm biome check --write --no-errors-on-unmatched", 3 | "*.nix": "nixfmt" 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .changeset 3 | .github 4 | node_modules 5 | coverage 6 | __helper__ 7 | coverage 8 | .codecov 9 | .husky 10 | .eslintignore 11 | package-lock.json 12 | yarn-error.log 13 | pnpm-lock.yaml 14 | .c8rc.json 15 | 16 | /code-of-conduct.md 17 | /.editorconfig 18 | /.eslintrc.json 19 | /.lintstagedrc 20 | /ava.config.js 21 | /yarn.lock 22 | /test.mjs 23 | /tsconfig.json 24 | /tsconfig.eslint.json 25 | /tsconfig.d.ts.json 26 | /tsconfig.ava.json 27 | /src/**/*.ts 28 | 29 | /CHANGELOG.md 30 | /tsup.config.ts 31 | 32 | !/src/node-domexception.d.ts 33 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "quickfix.biome": "explicit" 7 | } 8 | }, 9 | "[javascript]": { 10 | "editor.defaultFormatter": "biomejs.biome", 11 | "editor.formatOnSave": true, 12 | "editor.codeActionsOnSave": { 13 | "quickfix.biome": "explicit" 14 | } 15 | }, 16 | "[jsonc]": { 17 | "editor.defaultFormatter": "biomejs.biome", 18 | "editor.formatOnSave": true, 19 | "editor.codeActionsOnSave": { 20 | "quickfix.biome": "explicit" 21 | } 22 | }, 23 | "[json]": { 24 | "editor.defaultFormatter": "biomejs.biome", 25 | "editor.formatOnSave": true, 26 | "editor.codeActionsOnSave": { 27 | "quickfix.biome": "explicit" 28 | } 29 | }, 30 | "typescript.tsdk": "node_modules/typescript/lib", 31 | 32 | "[nix]": { 33 | "editor.defaultFormatter": "jnoortheen.nix-ide", 34 | "editor.formatOnSave": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # formdata-node 2 | 3 | ## 6.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [`996b4b5`](https://github.com/octet-stream/form-data/commit/996b4b528b7a54aa1f8c7ce0e002d044613958e9) Thanks [@octet-stream](https://github.com/octet-stream)! - Remove removeComments from tsconfig.json 8 | 9 | ## 6.0.2 10 | 11 | ### Patch Changes 12 | 13 | - [`d88ffae`](https://github.com/octet-stream/form-data/commit/d88ffae5a66dd4d75b7ae4639578f3d97731dad9) Thanks [@octet-stream](https://github.com/octet-stream)! - Remove tsup config and changelog from distro 14 | 15 | ## 6.0.1 16 | 17 | ### Patch Changes 18 | 19 | - [`32fa6da`](https://github.com/octet-stream/form-data/commit/32fa6da19096fbdd29401452d40e61ef9619343a) Thanks [@octet-stream](https://github.com/octet-stream)! - Remove changeset config from distro 20 | 21 | ## 6.0.0 22 | 23 | ### Major Changes 24 | 25 | - [`324a9a5`](https://github.com/octet-stream/form-data/commit/324a9a59ac6d6ca623269545355b8000de227cc2) Thanks [@octet-stream](https://github.com/octet-stream)! - Drop [node-domexception](https://github.com/jimmywarting/node-domexception) in favour of Node.js' builtins. Consider polyfilling [DOMException](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) if you want to run this package in older environment 26 | 27 | - [`324a9a5`](https://github.com/octet-stream/form-data/commit/324a9a59ac6d6ca623269545355b8000de227cc2) Thanks [@octet-stream](https://github.com/octet-stream)! - Bring back CJS support via tsup. You can now import package in both ES and CJS modules 28 | 29 | - [`324a9a5`](https://github.com/octet-stream/form-data/commit/324a9a59ac6d6ca623269545355b8000de227cc2) Thanks [@octet-stream](https://github.com/octet-stream)! - Drop [web-streams-polyfill](https://github.com/MattiasBuelens/web-streams-polyfill) in favour of Node.js' builtins. Consider polyfilling [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) if you want to run this package in older environment 30 | 31 | - [`47a3ff8`](https://github.com/octet-stream/form-data/commit/47a3ff8bc131dec70251927de066891b0b930b69) Thanks [@octet-stream](https://github.com/octet-stream)! - Add ReadableStream w/o Symbol.asyncIterator support in Blob 32 | 33 | - [`0f68880`](https://github.com/octet-stream/form-data/commit/0f688808f8c9eeefe8fdb384e7c5b2e7094bdfeb) Thanks [@octet-stream](https://github.com/octet-stream)! - Add typings tests to make sure FormData, Blob and File compatible with globally available BodyInit type 34 | 35 | - [`47a3ff8`](https://github.com/octet-stream/form-data/commit/47a3ff8bc131dec70251927de066891b0b930b69) Thanks [@octet-stream](https://github.com/octet-stream)! - Drop Node.js 16. Now minimal required version is 18.0.0 36 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | failFast: true, 3 | extensions: { 4 | ts: "module" 5 | }, 6 | files: ["src/**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "linter": { 4 | "rules": { 5 | "recommended": true, 6 | "complexity": { 7 | "noForEach": "off" 8 | }, 9 | "style": { 10 | "noParameterAssign": "off", 11 | "noArguments": "off" 12 | }, 13 | "suspicious": { 14 | "noGlobalIsNan": "off" 15 | }, 16 | "correctness": { 17 | "noVoidTypeReturn": "off" 18 | } 19 | } 20 | }, 21 | "formatter": { 22 | "indentStyle": "space" 23 | }, 24 | "javascript": { 25 | "formatter": { 26 | "semicolons": "asNeeded", 27 | "bracketSpacing": false, 28 | "trailingCommas": "none", 29 | "arrowParentheses": "asNeeded" 30 | } 31 | }, 32 | "json": { 33 | "formatter": { 34 | "trailingCommas": "none" 35 | } 36 | }, 37 | "files": { 38 | "include": ["**/*.ts", "**/*.js"], 39 | "ignore": ["lib", "coverage"] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1748437600, 6 | "narHash": "sha256-hYKMs3ilp09anGO7xzfGs3JqEgUqFMnZ8GMAqI6/k04=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "7282cb574e0607e65224d33be8241eae7cfe0979", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-25.05", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "systems": "systems" 23 | } 24 | }, 25 | "systems": { 26 | "locked": { 27 | "lastModified": 1681028828, 28 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 | "owner": "nix-systems", 30 | "repo": "default", 31 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "nix-systems", 36 | "repo": "default", 37 | "type": "github" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; 4 | systems.url = "github:nix-systems/default"; 5 | }; 6 | 7 | outputs = 8 | { nixpkgs, systems, ... }: 9 | { 10 | devShells = nixpkgs.lib.genAttrs (import systems) ( 11 | system: 12 | let 13 | pkgs = import nixpkgs { inherit system; }; 14 | in 15 | { 16 | default = import ./shell.nix { inherit pkgs; }; 17 | } 18 | ); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present Nick K. 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formdata-node", 3 | "version": "6.0.3", 4 | "type": "module", 5 | "description": "Spec-compliant FormData implementation for Node.js", 6 | "repository": "octet-stream/form-data", 7 | "sideEffects": false, 8 | "keywords": [ 9 | "form-data", 10 | "node", 11 | "form", 12 | "upload", 13 | "files-upload", 14 | "ponyfill" 15 | ], 16 | "author": "Nick K. ", 17 | "license": "MIT", 18 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977", 19 | "engines": { 20 | "node": ">= 18" 21 | }, 22 | "main": "./lib/form-data.js", 23 | "module": "./lib/browser.js", 24 | "browser": "./lib/browser.js", 25 | "exports": { 26 | "./package.json": "./package.json", 27 | ".": { 28 | "node": { 29 | "import": { 30 | "types": "./lib/form-data.d.ts", 31 | "default": "./lib/form-data.js" 32 | }, 33 | "require": { 34 | "types": "./lib/form-data.d.cts", 35 | "default": "./lib/form-data.cjs" 36 | } 37 | }, 38 | "browser": { 39 | "import": { 40 | "types": "./lib/browser.d.ts", 41 | "default": "./lib/browser.js" 42 | }, 43 | "require": { 44 | "types": "./lib/browser.d.cts", 45 | "default": "./lib/browser.cjs" 46 | } 47 | }, 48 | "default": { 49 | "types": "./lib/form-data.d.ts", 50 | "import": "./lib/form-data.js" 51 | } 52 | }, 53 | "./file-from-path": { 54 | "import": { 55 | "types": "./@lib/file-from-path.d.ts", 56 | "default": "./lib/file-from-path.js" 57 | }, 58 | "require": { 59 | "types": "./@lib/file-from-path.d.cts", 60 | "default": "./lib/file-from-path.cjs" 61 | } 62 | } 63 | }, 64 | "types": "./lib/form-data.d.ts", 65 | "typesVersions": { 66 | "*": { 67 | "file-from-path": [ 68 | "./lib/file-from-path.d.ts" 69 | ] 70 | } 71 | }, 72 | "scripts": { 73 | "lint:types": "tsc --noEmit", 74 | "lint": "pnpm biome lint --write --no-errors-on-unmatched", 75 | "coverage": "c8 pnpm test", 76 | "report:html": "c8 -r=html pnpm test", 77 | "ci": "c8 pnpm test && c8 report --reporter=json", 78 | "build": "pnpm exec del-cli lib && pnpm exec tsup", 79 | "test": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" ava", 80 | "release": "pnpm run build && pnpm exec changeset publish", 81 | "prepare": "npx is-in-ci || husky install" 82 | }, 83 | "pnpm": { 84 | "updateConfig": { 85 | "ignoreDependencies": [ 86 | "@changesets/cli" 87 | ] 88 | } 89 | }, 90 | "devDependencies": { 91 | "@biomejs/biome": "1.8.3", 92 | "@changesets/changelog-github": "0.5.0", 93 | "@changesets/cli": "2.27.1", 94 | "@types/node": "20.14.9", 95 | "@types/sinon": "17.0.3", 96 | "ava": "6.1.3", 97 | "c8": "10.1.2", 98 | "cross-env": "7.0.3", 99 | "del-cli": "5.1.0", 100 | "husky": "9.0.11", 101 | "lint-staged": "16.1.0", 102 | "node-fetch": "3.3.2", 103 | "sinon": "18.0.0", 104 | "ts-expect": "1.3.0", 105 | "ts-node": "10.9.2", 106 | "tsup": "8.1.0", 107 | "typescript": "5.5.3" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # FormData 2 | 3 | Spec-compliant [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) implementation for Node.js 4 | 5 | [![Code Coverage](https://codecov.io/github/octet-stream/form-data/coverage.svg?branch=main)](https://codecov.io/github/octet-stream/form-data?branch=main) 6 | [![CI](https://github.com/octet-stream/form-data/workflows/CI/badge.svg)](https://github.com/octet-stream/form-data/actions/workflows/ci.yml) 7 | [![ESLint](https://github.com/octet-stream/form-data/workflows/ESLint/badge.svg)](https://github.com/octet-stream/form-data/actions/workflows/eslint.yml) 8 | [![TypeScript Types](https://github.com/octet-stream/form-data/actions/workflows/typescript.yml/badge.svg)](https://github.com/octet-stream/form-data/actions/workflows/typescript.yml) 9 | 10 | ## Requirements 11 | 12 | For this module to work consider polyfilling: [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), and [DOMException](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) (if you use `file-from-path` utilities) 13 | 14 | ## Highlights 15 | 16 | 1. Spec-compliant: implements every method of the [`FormData interface`](https://developer.mozilla.org/en-US/docs/Web/API/FormData). 17 | 2. Supports Blobs and Files sourced from anywhere: you can use builtin [`fileFromPath`](#filefrompathpath-filename-options---promisefile) and [`fileFromPathSync`](#filefrompathsyncpath-filename-options---file) helpers to create a File from FS, or you can implement your `BlobDataItem` object to use a different source of data. 18 | 3. Written on TypeScript and ships with TS typings. 19 | 4. Isomorphic, but only re-exports native FormData object for browsers. If you need a polyfill for browsers, use [`formdata-polyfill`](https://github.com/jimmywarting/FormData) 20 | 5. It's a [`ponyfill`](https://ponyfill.com/)! Which means, no effect has been caused on `globalThis` or native `FormData` implementation. 21 | 22 | ## Blob/File support 23 | 24 | While `formdata-node` ships with its own `File` and `Blob` implementations, these might eventually be removed in favour of Node.js' [`Blob`](https://nodejs.org/dist/latest-v18.x/docs/api/buffer.html#class-blob) (introduced in v14.18) and File (when it will be introduced). In order to help you smoothen that transition period, our own `Blob` and `File`, as well as `FormData` itself, provides support `Blob` objects created by Node.js' implementation. 25 | 26 | ## Installation 27 | 28 | You can install this package with npm: 29 | 30 | ``` 31 | npm install formdata-node 32 | ``` 33 | 34 | Or yarn: 35 | 36 | ``` 37 | yarn add formdata-node 38 | ``` 39 | 40 | Or pnpm 41 | 42 | ``` 43 | pnpm add formdata-node 44 | ``` 45 | 46 | ## ESM/CJS support 47 | 48 | This package is build for and bundled for both ESM and CommonJS, so you can use it in both environments. 49 | 50 | ## Usage 51 | 52 | 1. Let's take a look at minimal example with [got](https://github.com/sindresorhus/got): 53 | 54 | ```js 55 | import {FormData} from "formdata-node" 56 | 57 | // I assume Got >= 12.x is used for this example 58 | import got from "got" 59 | 60 | const form = new FormData() 61 | 62 | form.set("greeting", "Hello, World!") 63 | 64 | const data = await got.post("https://httpbin.org/post", {body: form}).json() 65 | 66 | console.log(data.form.greeting) // => Hello, World! 67 | ``` 68 | 69 | 2. If your HTTP client does not support spec-compliant FormData, you can use [`form-data-encoder`](https://github.com/octet-stream/form-data-encoder) to encode entries: 70 | 71 | ```js 72 | import {Readable} from "stream" 73 | 74 | import {FormDataEncoder} from "form-data-encoder" 75 | import {FormData} from "formdata-node" 76 | 77 | // Note that `node-fetch` >= 3.x have builtin support for spec-compliant FormData, sou you'll only need the `form-data-encoder` if you use `node-fetch` <= 2.x. 78 | import fetch from "node-fetch" 79 | 80 | const form = new FormData() 81 | 82 | form.set("field", "Some value") 83 | 84 | const encoder = new FormDataEncoder(form) 85 | 86 | const options = { 87 | method: "post", 88 | headers: encoder.headers, 89 | body: Readable.from(encoder) 90 | } 91 | 92 | await fetch("https://httpbin.org/post", options) 93 | ``` 94 | 95 | 3. Sending files over form-data: 96 | 97 | ```js 98 | import {FormData, File} from "formdata-node" // You can use `File` from fetch-blob >= 3.x 99 | 100 | import fetch from "node-fetch" 101 | 102 | const form = new FormData() 103 | const file = new File(["My hovercraft is full of eels"], "file.txt") 104 | 105 | form.set("file", file) 106 | 107 | await fetch("https://httpbin.org/post", {method: "post", body: form}) 108 | ``` 109 | 110 | 4. Blobs as field's values allowed too: 111 | 112 | ```js 113 | import {FormData, Blob} from "formdata-node" // You can use `Blob` from fetch-blob 114 | 115 | const form = new FormData() 116 | const blob = new Blob(["Some content"], {type: "text/plain"}) 117 | 118 | form.set("blob", blob) 119 | 120 | // Will always be returned as `File` 121 | let file = form.get("blob") 122 | 123 | // The created file has "blob" as the name by default 124 | console.log(file.name) // -> blob 125 | 126 | // To change that, you need to set filename argument manually 127 | form.set("file", blob, "some-file.txt") 128 | 129 | file = form.get("file") 130 | 131 | console.log(file.name) // -> some-file.txt 132 | ``` 133 | 134 | 5. You can use 3rd party Blob as FormData value, as vell as for BlobParts in out Blob implementation: 135 | 136 | ```js 137 | import {FormData, Blob} from "formdata-node" 138 | import {Blob as FetchBlob} from "fetch-blob" 139 | 140 | const input = new FetchBlob(["a", "b", "c"]) 141 | 142 | const blob = new Blob([input]) // Accepts 3rd party blobs as BlobParts 143 | 144 | await blob.text() // -> abc 145 | 146 | const form = new FormData() 147 | 148 | form.set("file", input) 149 | 150 | const file = form.get("file") // -> File 151 | 152 | await file.text() // -> abc 153 | ``` 154 | 155 | 6. You can also use Node.js' Blob implementation in these scenarios: 156 | 157 | ```js 158 | import {Blob as NodeBlob} from "node:buffer" 159 | 160 | import {FormData, Blob} from "formdata-node" 161 | 162 | const input = new NodeBlob(["a", "b", "c"]) 163 | 164 | const blob = new Blob([input]) // Accepts Node.js' Blob implementation as BlobParts 165 | 166 | await blob.text() // -> abc 167 | 168 | const form = new FormData() 169 | 170 | form.set("file", input) 171 | 172 | const file = form.get("file") // -> File 173 | 174 | await file.text() // -> abc 175 | ``` 176 | 177 | 7. You can also append files using `fileFromPath` or `fileFromPathSync` helpers. It does the same thing as [`fetch-blob/from`](https://github.com/node-fetch/fetch-blob#blob-part-backed-up-by-filesystem), but returns a `File` instead of `Blob`: 178 | 179 | ```js 180 | import {fileFromPath} from "formdata-node/file-from-path" 181 | import {FormData} from "formdata-node" 182 | 183 | import fetch from "node-fetch" 184 | 185 | const form = new FormData() 186 | 187 | form.set("file", await fileFromPath("/path/to/a/file")) 188 | 189 | await fetch("https://httpbin.org/post", {method: "post", body: form}) 190 | ``` 191 | 192 | 8. You can still use files sourced from any stream, but unlike in v2 you'll need some extra work to achieve that: 193 | 194 | ```js 195 | import {Readable} from "stream" 196 | 197 | import {FormData} from "formdata-node" 198 | 199 | class BlobFromStream { 200 | #stream 201 | 202 | constructor(stream, size) { 203 | this.#stream = stream 204 | this.size = size 205 | } 206 | 207 | stream() { 208 | return this.#stream 209 | } 210 | 211 | get [Symbol.toStringTag]() { 212 | return "Blob" 213 | } 214 | } 215 | 216 | const content = Buffer.from("Stream content") 217 | 218 | const stream = new Readable({ 219 | read() { 220 | this.push(content) 221 | this.push(null) 222 | } 223 | }) 224 | 225 | const form = new FormData() 226 | 227 | form.set("stream", new BlobFromStream(stream, content.length), "file.txt") 228 | 229 | await fetch("https://httpbin.org/post", {method: "post", body: form}) 230 | ``` 231 | 232 | 9. Note that if you don't know the length of that stream, you'll also need to handle form-data encoding manually or use [`form-data-encoder`](https://github.com/octet-stream/form-data-encoder) package. This is necessary to control which headers will be sent with your HTTP request: 233 | 234 | ```js 235 | import {Readable} from "stream" 236 | 237 | import {Encoder} from "form-data-encoder" 238 | import {FormData} from "formdata-node" 239 | 240 | const form = new FormData() 241 | 242 | // You can use file-shaped or blob-shaped objects as FormData value instead of creating separate class 243 | form.set("stream", { 244 | type: "text/plain", 245 | name: "file.txt", 246 | [Symbol.toStringTag]: "File", 247 | stream() { 248 | return getStreamFromSomewhere() 249 | } 250 | }) 251 | 252 | const encoder = new Encoder(form) 253 | 254 | const options = { 255 | method: "post", 256 | headers: { 257 | "content-type": encoder.contentType 258 | }, 259 | body: Readable.from(encoder) 260 | } 261 | 262 | await fetch("https://httpbin.org/post", {method: "post", body: form}) 263 | ``` 264 | 265 | ## Comparison 266 | 267 | | | formdata-node | formdata-polyfill | undici FormData | form-data | 268 | | ---------------- | ------------- | ----------------- | --------------- | -------------------- | 269 | | .append() | ✔️ | ✔️ | ✔️ | ✔️1 | 270 | | .set() | ✔️ | ✔️ | ✔️ | ❌ | 271 | | .get() | ✔️ | ✔️ | ✔️ | ❌ | 272 | | .getAll() | ✔️ | ✔️ | ✔️ | ❌ | 273 | | .forEach() | ✔️ | ✔️ | ✔️ | ❌ | 274 | | .keys() | ✔️ | ✔️ | ✔️ | ❌ | 275 | | .values() | ✔️ | ✔️ | ✔️ | ❌ | 276 | | .entries() | ✔️ | ✔️ | ✔️ | ❌ | 277 | | Symbol.iterator | ✔️ | ✔️ | ✔️ | ❌ | 278 | | ESM | ✔️ | ✔️ | ✔️2 | ✔️2 | 279 | | Blob | ✔️3 | ✔️4 | ✔️3 | ❌ | 280 | | Browser polyfill | ❌ | ✔️ | ✔️ | ❌ | 281 | | Builtin encoder | ❌ | ✔️ | ✔️5 | ✔️ | 282 | 283 | 1 Does not support Blob and File in entry value, but allows streams and Buffer (which is not spec-compliant, however). 284 | 285 | 2 Can be imported in ESM, because Node.js support for CJS modules in ESM context, but it does not have ESM entry point. 286 | 287 | 3 Have builtin implementations of Blob and/or File, allows native Blob and File as entry value. 288 | 289 | 4 Support Blob and File via fetch-blob package, allows native Blob and File as entry value. 290 | 291 | 5 Have `multipart/form-data` encoder as part of their `fetch` implementation. 292 | 293 | ✔️ - For FormData methods, indicates that the method is present and spec-compliant. For features, shows its presence. 294 | 295 | ❌ - Indicates that method or feature is not implemented. 296 | 297 | ## API 298 | 299 | ### `class FormData` 300 | 301 | ##### `constructor() -> {FormData}` 302 | 303 | Creates a new FormData instance. 304 | 305 | #### Instance methods 306 | 307 | ##### `set(name, value[, filename]) -> {void}` 308 | 309 | Set a new value for an existing key inside **FormData**, 310 | or add the new field if it does not already exist. 311 | 312 | - **{string}** name – The name of the field whose data is contained in `value`. 313 | - **{unknown}** value – The field's value. This can be [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 314 | or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). If none of these are specified the value is converted to a string. 315 | - **{string}** [filename = undefined] – The filename reported to the server, when a Blob or File is passed as the second parameter. The default filename for Blob objects is "blob". The default filename for File objects is the file's filename. 316 | 317 | ##### `append(name, value[, filename]) -> {void}` 318 | 319 | Appends a new value onto an existing key inside a FormData object, 320 | or adds the key if it does not already exist. 321 | 322 | The difference between `set()` and `append()` is that if the specified key already exists, `set()` will overwrite all existing values with the new one, whereas `append()` will append the new value onto the end of the existing set of values. 323 | 324 | - **{string}** name – The name of the field whose data is contained in `value`. 325 | - **{unknown}** value – The field's value. This can be [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 326 | or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). If none of these are specified the value is converted to a string. 327 | - **{string}** [filename = undefined] – The filename reported to the server, when a Blob or File is passed as the second parameter. The default filename for Blob objects is "blob". The default filename for File objects is the file's filename. 328 | 329 | ##### `get(name) -> {FormDataValue}` 330 | 331 | Returns the first value associated with a given key from within a `FormData` object. 332 | If you expect multiple values and want all of them, use the `getAll()` method instead. 333 | 334 | - **{string}** name – A name of the value you want to retrieve. 335 | 336 | ##### `getAll(name) -> {Array}` 337 | 338 | Returns all the values associated with a given key from within a `FormData` object. 339 | 340 | - **{string}** name – A name of the value you want to retrieve. 341 | 342 | ##### `has(name) -> {boolean}` 343 | 344 | Returns a boolean stating whether a `FormData` object contains a certain key. 345 | 346 | - **{string}** – A string representing the name of the key you want to test for. 347 | 348 | ##### `delete(name) -> {void}` 349 | 350 | Deletes a key and its value(s) from a `FormData` object. 351 | 352 | - **{string}** name – The name of the key you want to delete. 353 | 354 | ##### `forEach(callback[, thisArg]) -> {void}` 355 | 356 | Executes a given **callback** for each field of the FormData instance 357 | 358 | - **{function}** callback – Function to execute for each element, taking three arguments: 359 | + **{FormDataValue}** value – A value(s) of the current field. 360 | + **{string}** name – Name of the current field. 361 | + **{FormData}** form – The FormData instance that **forEach** is being applied to 362 | - **{unknown}** [thisArg = null] – Value to use as **this** context when executing the given **callback** 363 | 364 | ##### `keys() -> {Generator}` 365 | 366 | Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all keys contained in this `FormData` object. 367 | Each key is a `string`. 368 | 369 | ##### `values() -> {Generator}` 370 | 371 | Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all values contained in this object `FormData` object. 372 | Each value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue). 373 | 374 | ##### `entries() -> {Generator<[string, FormDataValue]>}` 375 | 376 | Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through key/value pairs contained in this `FormData` object. 377 | The key of each pair is a string; the value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue). 378 | 379 | ##### `[Symbol.iterator]() -> {Generator<[string, FormDataValue]>}` 380 | 381 | An alias for [`FormData#entries()`](#entries---iterator) 382 | 383 | ### `class Blob` 384 | 385 | The `Blob` object represents a blob, which is a file-like object of immutable, raw data; 386 | they can be read as text or binary data, or converted into a ReadableStream 387 | so its methods can be used for processing the data. 388 | 389 | ##### `constructor(blobParts[, options]) -> {Blob}` 390 | 391 | Creates a new `Blob` instance. The `Blob` constructor accepts following arguments: 392 | 393 | - **{(ArrayBufferLike | ArrayBufferView | File | Blob | string)[]}** blobParts – An `Array` strings, or [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [`ArrayBufferView`](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView), [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects, or a mix of any of such objects, that will be put inside the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob); 394 | - **{object}** [options = {}] - An options object containing optional attributes for the file. Available options are as follows; 395 | - **{string}** [options.type = ""] - Returns the media type ([`MIME`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)) of the blob represented by a `Blob` object. 396 | 397 | #### Instance properties 398 | 399 | ##### `type -> {string}` 400 | 401 | Returns the [`MIME type`](https://developer.mozilla.org/en-US/docs/Glossary/MIME_type) of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). 402 | 403 | ##### `size -> {number}` 404 | 405 | Returns the size of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) in bytes. 406 | 407 | #### Instance methods 408 | 409 | ##### `slice([start, end, contentType]) -> {Blob}` 410 | 411 | Creates and returns a new [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object which contains data from a subset of the blob on which it's called. 412 | 413 | - **{number}** [start = 0] An index into the `Blob` indicating the first byte to include in the new `Blob`. If you specify a negative value, it's treated as an offset from the end of the `Blob` toward the beginning. For example, -10 would be the 10th from last byte in the `Blob`. The default value is 0. If you specify a value for start that is larger than the size of the source `Blob`, the returned `Blob` has size 0 and contains no data. 414 | 415 | - **{number}** [end = `blob`.size] An index into the `Blob` indicating the first byte that will *not* be included in the new `Blob` (i.e. the byte exactly at this index is not included). If you specify a negative value, it's treated as an offset from the end of the `Blob` toward the beginning. For example, -10 would be the 10th from last byte in the `Blob`. The default value is size. 416 | 417 | - **{string}** [contentType = ""] The content type to assign to the new ``Blob``; this will be the value of its type property. The default value is an empty string. 418 | 419 | ##### `stream() -> {ReadableStream}` 420 | 421 | Returns a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) which upon reading returns the data contained within the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). 422 | 423 | ##### `arrayBuffer() -> {Promise}` 424 | 425 | Returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves with the contents of the blob as binary data contained in an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer). 426 | 427 | ##### `text() -> {Promise}` 428 | 429 | Returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves with a string containing the contents of the blob, interpreted as UTF-8. 430 | 431 | ### `class File extends Blob` 432 | 433 | The `File` class provides information about files. The `File` class inherits `Blob`. 434 | 435 | ##### `constructor(fileBits, filename[, options]) -> {File}` 436 | 437 | Creates a new `File` instance. The `File` constructor accepts following arguments: 438 | 439 | - **{(ArrayBufferLike | ArrayBufferView | File | Blob | string)[]}** fileBits – An `Array` strings, or [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [`ArrayBufferView`](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView), [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects, or a mix of any of such objects, that will be put inside the [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File); 440 | - **{string}** filename – Representing the file name. 441 | - **{object}** [options = {}] - An options object containing optional attributes for the file. Available options are as follows; 442 | - **{number}** [options.lastModified = Date.now()] – provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date; 443 | - **{string}** [options.type = ""] - Returns the media type ([`MIME`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)) of the file represented by a `File` object. 444 | 445 | ### `fileFromPath(path[, filename, options]) -> {Promise}` 446 | 447 | Available from `formdata-node/file-from-path` subpath. 448 | 449 | Creates a `File` referencing the one on a disk by given path. 450 | 451 | - **{string}** path - Path to a file 452 | - **{string}** [filename] - Optional name of the file. Will be passed as the second argument in `File` constructor. If not presented, the name will be taken from the file's path. 453 | - **{object}** [options = {}] - Additional `File` options, except for `lastModified`. 454 | - **{string}** [options.type = ""] - Returns the media type ([`MIME`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)) of the file represented by a `File` object. 455 | 456 | ### `fileFromPathSync(path[, filename, options]) -> {File}` 457 | 458 | Available from `formdata-node/file-from-path` subpath. 459 | 460 | Creates a `File` referencing the one on a disk by given path. Synchronous version of the `fileFromPath`. 461 | - **{string}** path - Path to a file 462 | - **{string}** [filename] - Optional name of the file. Will be passed as the second argument in `File` constructor. If not presented, the name will be taken from the file's path. 463 | - **{object}** [options = {}] - Additional `File` options, except for `lastModified`. 464 | - **{string}** [options.type = ""] - Returns the media type ([`MIME`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)) of the file represented by a `File` object. 465 | 466 | ### `isFile(value) -> {boolean}` 467 | 468 | Available from `formdata-node/file-from-path` subpath. 469 | 470 | Checks if given value is a File, Blob or file-look-a-like object. 471 | 472 | - **{unknown}** value - A value to test 473 | 474 | ## Related links 475 | 476 | - [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) documentation on MDN 477 | - [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) documentation on MDN 478 | - [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) documentation on MDN 479 | - [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue) documentation on MDN. 480 | - [`formdata-polyfill`](https://github.com/jimmywarting/FormData) HTML5 `FormData` for Browsers & NodeJS. 481 | - [`node-fetch`](https://github.com/node-fetch/node-fetch) a light-weight module that brings the Fetch API to Node.js 482 | - [`fetch-blob`](https://github.com/node-fetch/fetch-blob) a Blob implementation on node.js, originally from `node-fetch`. 483 | - [`form-data-encoder`](https://github.com/octet-stream/form-data-encoder) spec-compliant `multipart/form-data` encoder implementation. 484 | - [`then-busboy`](https://github.com/octet-stream/then-busboy) a promise-based wrapper around Busboy. Process multipart/form-data content and returns it as a single object. Will be helpful to handle your data on the server-side applications. 485 | - [`@octetstream/object-to-form-data`](https://github.com/octet-stream/object-to-form-data) converts JavaScript object to FormData. 486 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs ? import { }, 3 | }: 4 | with pkgs; 5 | mkShell { 6 | packages = [ 7 | nixd 8 | nixfmt-rfc-style 9 | nodejs_22 10 | corepack_22 11 | ]; 12 | } 13 | -------------------------------------------------------------------------------- /src/Blob.test.ts: -------------------------------------------------------------------------------- 1 | import {ReadableStream} from "node:stream/web" 2 | import {buffer} from "node:stream/consumers" 3 | 4 | import test from "ava" 5 | 6 | import {readStream} from "./blobHelpers.js" 7 | import {Blob} from "./Blob.js" 8 | 9 | test("Constructor creates a new Blob when called without arguments", t => { 10 | const blob = new Blob() 11 | 12 | t.true(blob instanceof Blob) 13 | }) 14 | 15 | test("Empty Blob returned by Blob constructor has the size of 0", t => { 16 | const blob = new Blob() 17 | 18 | t.is(blob.size, 0) 19 | }) 20 | 21 | test("The size property is read-only", t => { 22 | const blob = new Blob() 23 | 24 | try { 25 | // @ts-expect-error expected for tests 26 | blob.size = 42 27 | } catch { 28 | /* noop */ 29 | } 30 | 31 | t.is(blob.size, 0) 32 | }) 33 | 34 | test("The size property cannot be removed", t => { 35 | const blob = new Blob() 36 | 37 | try { 38 | // @ts-expect-error expected for tests 39 | // biome-ignore lint/performance/noDelete: expected for tests 40 | delete blob.size 41 | } catch { 42 | /* noop */ 43 | } 44 | 45 | t.true("size" in blob) 46 | }) 47 | 48 | test("Blob type is an empty string by default", t => { 49 | const blob = new Blob() 50 | 51 | t.is(blob.type, "") 52 | }) 53 | 54 | test("The type property is read-only", t => { 55 | const expected = "text/plain" 56 | const blob = new Blob([], {type: expected}) 57 | 58 | try { 59 | // @ts-expect-error expected for tests 60 | blob.type = "application/json" 61 | } catch { 62 | /* noop */ 63 | } 64 | 65 | t.is(blob.type, expected) 66 | }) 67 | 68 | test("The type property cannot be removed", t => { 69 | const blob = new Blob() 70 | 71 | try { 72 | // @ts-expect-error expected for tests 73 | // biome-ignore lint/performance/noDelete: expected for tests 74 | delete blob.type 75 | } catch { 76 | /* noop */ 77 | } 78 | 79 | t.true("type" in blob) 80 | }) 81 | 82 | test("Constructor throws an error when first argument is not an object", t => { 83 | const rounds: unknown[] = [null, true, false, 0, 1, 1.5, "FAIL"] 84 | 85 | rounds.forEach(round => { 86 | // @ts-expect-error 87 | const trap = () => new Blob(round) 88 | 89 | t.throws(trap, { 90 | instanceOf: TypeError, 91 | message: 92 | "Failed to construct 'Blob': " + 93 | "The provided value cannot be converted to a sequence." 94 | }) 95 | }) 96 | }) 97 | 98 | test("Constructor throws an error when first argument is not an iterable object", t => { 99 | // eslint-disable-next-line prefer-regex-literals 100 | const rounds = [new Date(), /(?:)/, {}, {0: "FAIL", length: 1}] 101 | 102 | rounds.forEach(round => { 103 | // @ts-expect-error 104 | const trap = () => new Blob(round) 105 | 106 | t.throws(trap, { 107 | instanceOf: TypeError, 108 | message: 109 | "Failed to construct 'Blob': " + 110 | "The object must have a callable @@iterator property." 111 | }) 112 | }) 113 | }) 114 | 115 | test("Creates a new Blob from an array of strings", async t => { 116 | const source = ["one", "two", "three"] 117 | const blob = new Blob(source) 118 | 119 | t.is(await blob.text(), source.join("")) 120 | }) 121 | 122 | test("Creates a new Blob from an array of Uint8Array", async t => { 123 | const encoder = new TextEncoder() 124 | const source = ["one", "two", "three"] 125 | 126 | const blob = new Blob(source.map(part => encoder.encode(part))) 127 | 128 | t.is(await blob.text(), source.join("")) 129 | }) 130 | 131 | test("Creates a new Blob from an array of ArrayBuffer", async t => { 132 | const encoder = new TextEncoder() 133 | const source = ["one", "two", "three"] 134 | 135 | const blob = new Blob(source.map(part => encoder.encode(part).buffer)) 136 | 137 | t.is(await blob.text(), source.join("")) 138 | }) 139 | 140 | test("Creates a new Blob from an array of Blob", async t => { 141 | const source = ["one", "two", "three"] 142 | 143 | const blob = new Blob(source.map(part => new Blob([part]))) 144 | 145 | t.is(await blob.text(), source.join("")) 146 | }) 147 | 148 | test("Accepts a String object as a sequence", async t => { 149 | const expected = "abc" 150 | 151 | // eslint-disable-next-line no-new-wrappers 152 | const blob = new Blob(new String(expected)) 153 | 154 | t.is(await blob.text(), expected) 155 | }) 156 | 157 | test("Accepts Uint8Array as a sequence", async t => { 158 | const expected = [1, 2, 3] 159 | const blob = new Blob(new Uint8Array(expected)) 160 | 161 | t.is(await blob.text(), expected.join("")) 162 | }) 163 | 164 | test("Accepts iterable object as a sequence", async t => { 165 | const blob = new Blob({[Symbol.iterator]: Array.prototype[Symbol.iterator]}) 166 | 167 | t.is(blob.size, 0) 168 | t.is(await blob.text(), "") 169 | }) 170 | 171 | test("Constructor reads blobParts from iterable object", async t => { 172 | const source = ["one", "two", "three"] 173 | const expected = source.join("") 174 | 175 | const blob = new Blob({ 176 | *[Symbol.iterator]() { 177 | yield* source 178 | } 179 | }) 180 | 181 | t.is(blob.size, new TextEncoder().encode(expected).byteLength) 182 | t.is(await blob.text(), expected) 183 | }) 184 | 185 | test("Blob has the size measured from the blobParts", t => { 186 | const source = ["one", "two", "three"] 187 | const expected = new TextEncoder().encode(source.join("")).byteLength 188 | 189 | const blob = new Blob(source) 190 | 191 | t.is(blob.size, expected) 192 | }) 193 | 194 | test("Accepts type for Blob as an option in the second argument", t => { 195 | const expected = "text/markdown" 196 | 197 | const blob = new Blob(["Some *Markdown* content"], {type: expected}) 198 | 199 | t.is(blob.type, expected) 200 | }) 201 | 202 | test("Casts elements of the blobPart array to a string", async t => { 203 | const source: unknown[] = [ 204 | null, 205 | undefined, 206 | true, 207 | false, 208 | 0, 209 | 1, 210 | 211 | // eslint-disable-next-line no-new-wrappers 212 | new String("string object"), 213 | 214 | [], 215 | {0: "FAIL", length: 1}, 216 | { 217 | toString() { 218 | return "stringA" 219 | } 220 | }, 221 | { 222 | toString: undefined, 223 | valueOf() { 224 | return "stringB" 225 | } 226 | } 227 | ] 228 | 229 | const expected = source.map(element => String(element)).join("") 230 | 231 | const blob = new Blob(source) 232 | 233 | t.is(await blob.text(), expected) 234 | }) 235 | 236 | test("undefined value has no affect on property bag argument", t => { 237 | const blob = new Blob([], undefined) 238 | 239 | t.is(blob.type, "") 240 | }) 241 | 242 | test("null value has no affect on property bag argument", t => { 243 | // @ts-expect-error Ignored, because that is what we are testing for 244 | const blob = new Blob([], null) 245 | 246 | t.is(blob.type, "") 247 | }) 248 | 249 | test("Invalid type in property bag will result in an empty string", t => { 250 | const blob = new Blob([], {type: "\u001Ftext/plain"}) 251 | 252 | t.is(blob.type, "") 253 | }) 254 | 255 | test("Throws an error if invalid property bag passed", t => { 256 | const rounds = [123, 123.4, true, false, "FAIL"] 257 | 258 | rounds.forEach(round => { 259 | // @ts-expect-error 260 | const trap = () => new Blob([], round) 261 | 262 | t.throws(trap, { 263 | instanceOf: TypeError, 264 | message: 265 | "Failed to construct 'Blob': " + 266 | "parameter 2 cannot convert to dictionary." 267 | }) 268 | }) 269 | }) 270 | 271 | test(".slice() a new blob when called without arguments", async t => { 272 | const blob = new Blob(["a", "b", "c"]) 273 | const sliced = blob.slice() 274 | 275 | t.is(sliced.size, blob.size) 276 | t.is(await sliced.text(), await blob.text()) 277 | }) 278 | 279 | test(".slice() an empty blob with the start and the end set to 0", async t => { 280 | const blob = new Blob(["a", "b", "c"]) 281 | const sliced = blob.slice(0, 0) 282 | 283 | t.is(sliced.size, 0) 284 | t.is(await sliced.text(), "") 285 | }) 286 | 287 | test(".slice() slices the Blob within given range", async t => { 288 | const text = "The MIT License" 289 | const blob = new Blob([text]).slice(0, 3) 290 | 291 | t.is(await blob.text(), "The") 292 | }) 293 | 294 | test(".slice() slices the Blob from arbitary start", async t => { 295 | const text = "The MIT License" 296 | const blob = new Blob([text]).slice(4, 15) 297 | 298 | t.is(await blob.text(), "MIT License") 299 | }) 300 | 301 | test(".slice() slices the Blob from the end when start argument is negative", async t => { 302 | const text = "The MIT License" 303 | const blob = new Blob([text]).slice(-7) 304 | 305 | t.is(await blob.text(), "License") 306 | }) 307 | 308 | test(".slice() slices the Blob from the start when end argument is negative", async t => { 309 | const text = "The MIT License" 310 | const blob = new Blob([text]).slice(0, -8) 311 | 312 | t.is(await blob.text(), "The MIT") 313 | }) 314 | 315 | test(".slice() slices Blob in blob parts", async t => { 316 | const text = "The MIT License" 317 | const blob = new Blob([new Blob([text]), new Blob([text])]).slice(8, 18) 318 | 319 | t.is(await blob.text(), "LicenseThe") 320 | }) 321 | 322 | test(".slice() slices within multiple parts", async t => { 323 | const blob = new Blob(["Hello", "world"]).slice(4, 7) 324 | 325 | t.is(await blob.text(), "owo") 326 | }) 327 | 328 | test(".slice() throws away unwanted parts", async t => { 329 | const blob = new Blob(["a", "b", "c"]).slice(1, 2) 330 | 331 | t.is(await blob.text(), "b") 332 | }) 333 | 334 | test(".slice() takes type as the 3rd argument", t => { 335 | const expected = "text/plain" 336 | const blob = new Blob([], {type: "text/html"}).slice(0, 0, expected) 337 | 338 | t.is(blob.type, expected) 339 | }) 340 | 341 | test(".text() returns a the Blob content as string when awaited", async t => { 342 | const blob = new Blob([ 343 | "a", 344 | new TextEncoder().encode("b"), 345 | new Blob(["c"]), 346 | new TextEncoder().encode("d").buffer 347 | ]) 348 | 349 | t.is(await blob.text(), "abcd") 350 | }) 351 | 352 | test(".arrayBuffer() returns the Blob content as ArrayBuffer when awaited", async t => { 353 | const source = new TextEncoder().encode("abc") 354 | const blob = new Blob([source]) 355 | 356 | t.true(Buffer.from(await blob.arrayBuffer()).equals(source)) 357 | }) 358 | 359 | test(".stream() returns ReadableStream", t => { 360 | const stream = new Blob().stream() 361 | 362 | t.true(stream instanceof ReadableStream) 363 | }) 364 | 365 | test(".stream() allows to read Blob as a stream", async t => { 366 | const source = Buffer.from("Some content") 367 | 368 | // ! Blob.stream() return type falls back to TypeScript typings for web which lacks Symbol.asyncIterator method, so we read stream with out readStream helper 369 | const actual = await buffer(readStream(new Blob([source]).stream())) 370 | 371 | t.true(actual.equals(source)) 372 | }) 373 | 374 | test(".stream() returned ReadableStream can be cancelled", async t => { 375 | const stream = new Blob(["Some content"]).stream() 376 | 377 | // Cancel the stream before start reading, or this will throw an error 378 | await stream.cancel() 379 | 380 | const reader = stream.getReader() 381 | 382 | const {done, value: chunk} = await reader.read() 383 | 384 | t.true(done) 385 | t.is(chunk, undefined) 386 | }) 387 | -------------------------------------------------------------------------------- /src/Blob.ts: -------------------------------------------------------------------------------- 1 | /*! Based on fetch-blob. MIT License. Jimmy Wärting & David Frank */ 2 | 3 | import type {BlobPart} from "./BlobPart.js" 4 | 5 | import {isFunction} from "./isFunction.js" 6 | import {consumeBlobParts, sliceBlob} from "./blobHelpers.js" 7 | 8 | /** 9 | * Reflects minimal valid Blob for BlobParts. 10 | */ 11 | export interface BlobLike { 12 | type: string 13 | 14 | size: number 15 | 16 | slice(start?: number, end?: number, contentType?: string): BlobLike 17 | 18 | arrayBuffer(): Promise 19 | 20 | [Symbol.toStringTag]: string 21 | } 22 | 23 | export type BlobParts = unknown[] | Iterable 24 | 25 | export interface BlobPropertyBag { 26 | /** 27 | * The [`MIME type`](https://developer.mozilla.org/en-US/docs/Glossary/MIME_type) of the data that will be stored into the blob. 28 | * The default value is the empty string, (`""`). 29 | */ 30 | type?: string 31 | } 32 | 33 | /** 34 | * The **Blob** object represents a blob, which is a file-like object of immutable, raw data; 35 | * they can be read as text or binary data, or converted into a ReadableStream 36 | * so its methods can be used for processing the data. 37 | */ 38 | export class Blob { 39 | /** 40 | * An `Array` of [`ArrayBufferView`](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView) or [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects, or a mix of any of such objects, that will be put inside the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). 41 | */ 42 | readonly #parts: BlobPart[] = [] 43 | 44 | /** 45 | * Returns the [`MIME type`](https://developer.mozilla.org/en-US/docs/Glossary/MIME_type) of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). 46 | */ 47 | readonly #type: string = "" 48 | 49 | /** 50 | * Returns the size of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) in bytes. 51 | */ 52 | readonly #size: number = 0 53 | 54 | static [Symbol.hasInstance](value: unknown): value is Blob { 55 | return Boolean( 56 | value && 57 | typeof value === "object" && 58 | isFunction((value as Blob).constructor) && 59 | (isFunction((value as Blob).stream) || 60 | isFunction((value as Blob).arrayBuffer)) && 61 | /^(Blob|File)$/.test((value as Blob)[Symbol.toStringTag]) 62 | ) 63 | } 64 | 65 | /** 66 | * Returns a new [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object. 67 | * The content of the blob consists of the concatenation of the values given in the parameter array. 68 | * 69 | * @param blobParts An `Array` strings, or [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [`ArrayBufferView`](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView), [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects, or a mix of any of such objects, that will be put inside the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). 70 | * @param options An optional object of type `BlobPropertyBag`. 71 | */ 72 | constructor(blobParts: BlobParts = [], options: BlobPropertyBag = {}) { 73 | options ??= {} 74 | 75 | if (typeof blobParts !== "object" || blobParts === null) { 76 | throw new TypeError( 77 | "Failed to construct 'Blob': " + 78 | "The provided value cannot be converted to a sequence." 79 | ) 80 | } 81 | 82 | if (!isFunction(blobParts[Symbol.iterator])) { 83 | throw new TypeError( 84 | "Failed to construct 'Blob': " + 85 | "The object must have a callable @@iterator property." 86 | ) 87 | } 88 | 89 | if (typeof options !== "object" && !isFunction(options)) { 90 | throw new TypeError( 91 | "Failed to construct 'Blob': parameter 2 cannot convert to dictionary." 92 | ) 93 | } 94 | 95 | // Normalize blobParts first 96 | const encoder = new TextEncoder() 97 | for (const raw of blobParts) { 98 | let part: BlobPart 99 | if (ArrayBuffer.isView(raw)) { 100 | part = new Uint8Array( 101 | raw.buffer.slice( 102 | raw.byteOffset, 103 | 104 | raw.byteOffset + raw.byteLength 105 | ) 106 | ) 107 | } else if (raw instanceof ArrayBuffer) { 108 | part = new Uint8Array(raw.slice(0)) 109 | } else if (raw instanceof Blob) { 110 | part = raw 111 | } else { 112 | part = encoder.encode(String(raw)) 113 | } 114 | 115 | this.#size += ArrayBuffer.isView(part) ? part.byteLength : part.size 116 | this.#parts.push(part) 117 | } 118 | 119 | const type = options.type === undefined ? "" : String(options.type) 120 | 121 | this.#type = /^[\x20-\x7E]*$/.test(type) ? type : "" 122 | } 123 | 124 | /** 125 | * Returns the [`MIME type`](https://developer.mozilla.org/en-US/docs/Glossary/MIME_type) of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). 126 | */ 127 | get type(): string { 128 | return this.#type 129 | } 130 | 131 | /** 132 | * Returns the size of the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) in bytes. 133 | */ 134 | get size(): number { 135 | return this.#size 136 | } 137 | 138 | /** 139 | * Creates and returns a new [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) object which contains data from a subset of the blob on which it's called. 140 | * 141 | * @param start An index into the Blob indicating the first byte to include in the new Blob. If you specify a negative value, it's treated as an offset from the end of the Blob toward the beginning. For example, -10 would be the 10th from last byte in the Blob. The default value is 0. If you specify a value for start that is larger than the size of the source Blob, the returned Blob has size 0 and contains no data. 142 | * @param end An index into the Blob indicating the first byte that will *not* be included in the new Blob (i.e. the byte exactly at this index is not included). If you specify a negative value, it's treated as an offset from the end of the Blob toward the beginning. For example, -10 would be the 10th from last byte in the Blob. The default value is size. 143 | * @param contentType The content type to assign to the new Blob; this will be the value of its type property. The default value is an empty string. 144 | */ 145 | slice(start?: number, end?: number, contentType?: string): Blob { 146 | return new Blob(sliceBlob(this.#parts, this.size, start, end), { 147 | type: contentType 148 | }) 149 | } 150 | 151 | /** 152 | * Returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves with a string containing the contents of the blob, interpreted as UTF-8. 153 | */ 154 | async text(): Promise { 155 | const decoder = new TextDecoder() 156 | 157 | let result = "" 158 | for await (const chunk of consumeBlobParts(this.#parts)) { 159 | result += decoder.decode(chunk, {stream: true}) 160 | } 161 | 162 | result += decoder.decode() 163 | 164 | return result 165 | } 166 | 167 | /** 168 | * Returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves with the contents of the blob as binary data contained in an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer). 169 | */ 170 | async arrayBuffer(): Promise { 171 | const view = new Uint8Array(this.size) 172 | 173 | let offset = 0 174 | for await (const chunk of consumeBlobParts(this.#parts)) { 175 | view.set(chunk, offset) 176 | offset += chunk.length 177 | } 178 | 179 | return view.buffer 180 | } 181 | 182 | /** 183 | * Returns a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) which upon reading returns the data contained within the [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). 184 | */ 185 | stream(): ReadableStream { 186 | const iterator = consumeBlobParts(this.#parts, true) 187 | 188 | return new ReadableStream({ 189 | async pull(controller) { 190 | const {value, done} = await iterator.next() 191 | 192 | if (done) { 193 | return queueMicrotask(() => controller.close()) 194 | } 195 | 196 | controller.enqueue(value) 197 | }, 198 | 199 | async cancel() { 200 | await iterator.return() 201 | } 202 | }) 203 | } 204 | 205 | get [Symbol.toStringTag](): string { 206 | return "Blob" 207 | } 208 | } 209 | 210 | // Not sure why, but these properties are enumerable. 211 | // Also fetch-blob defines "size", "type" and "slice" as such 212 | Object.defineProperties(Blob.prototype, { 213 | type: {enumerable: true}, 214 | size: {enumerable: true}, 215 | slice: {enumerable: true}, 216 | stream: {enumerable: true}, 217 | text: {enumerable: true}, 218 | arrayBuffer: {enumerable: true} 219 | }) 220 | -------------------------------------------------------------------------------- /src/BlobPart.ts: -------------------------------------------------------------------------------- 1 | import type {Blob, BlobLike} from "./Blob.js" 2 | 3 | export type BlobPart = BlobLike | Blob | Uint8Array 4 | -------------------------------------------------------------------------------- /src/BodyInit.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | import test from "ava" 4 | 5 | import {expectType} from "ts-expect" 6 | import type {BodyInit as NodeFetchBodyInit} from "node-fetch" 7 | 8 | import {FormData} from "./FormData.js" 9 | import {Blob} from "./Blob.js" 10 | import {File} from "./File.js" 11 | 12 | test("FormData is assignable to BodyInit", t => { 13 | expectType(new FormData()) 14 | 15 | t.pass() 16 | }) 17 | 18 | test("FormData is assignable to node-fetch BodyInit", t => { 19 | expectType(new FormData()) 20 | 21 | t.pass() 22 | }) 23 | 24 | test("Blob is assignable to BodyInit", t => { 25 | expectType(new Blob()) 26 | 27 | t.pass() 28 | }) 29 | 30 | test("Blob is assignable to node-fetch BodyInit", t => { 31 | expectType(new Blob()) 32 | 33 | t.pass() 34 | }) 35 | 36 | test("File is assignable to BodyInit", t => { 37 | expectType(new File([], "test.txt")) 38 | 39 | t.pass() 40 | }) 41 | 42 | test("File is assignable to node-fetch BodyInit", t => { 43 | expectType(new File([], "test.txt")) 44 | 45 | t.pass() 46 | }) 47 | -------------------------------------------------------------------------------- /src/File.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | 3 | import {File} from "./File.js" 4 | 5 | test("Takes a name as the second argument", t => { 6 | const expected = "file.txt" 7 | const file = new File(["Some content"], expected) 8 | 9 | t.is(file.name, expected) 10 | }) 11 | 12 | test("Casts the name argument to string", t => { 13 | // @ts-expect-error expected for tests 14 | const file = new File(["Some content"], 42) 15 | 16 | t.is(file.name, "42") 17 | }) 18 | 19 | test("The name property keeps its value after being reassigned", t => { 20 | const expected = "file.txt" 21 | const file = new File(["Some content"], expected) 22 | 23 | // Browsers won't throw errors in this case, 24 | // even though they seem to use the same approach with getters 25 | // to make the property read-only. But in Node.js the reassignment will cause an error. 26 | // Maybe it's platform specific behaviour? 27 | try { 28 | // @ts-expect-error expected for tests 29 | file.name = "another-file.txt" 30 | } catch { 31 | /* noop */ 32 | } 33 | 34 | t.is(file.name, expected) 35 | }) 36 | 37 | test("Has the lastModified field", t => { 38 | const file = new File(["Some content"], "file.txt") 39 | 40 | t.is(typeof file.lastModified, "number") 41 | }) 42 | 43 | test("The lastModified property keeps its value after being reassigned", t => { 44 | const file = new File(["Some content"], "file.txt") 45 | 46 | const {lastModified: expected} = file 47 | 48 | try { 49 | // @ts-expect-error expected for tests 50 | file.lastModified = Date.now() + 3000 51 | } catch { 52 | /* noop */ 53 | } 54 | 55 | t.is(file.lastModified, expected) 56 | }) 57 | 58 | test("Takes the lastModified value from options", t => { 59 | const expected = Date.now() + 3000 60 | const file = new File(["Some content"], "file.txt", {lastModified: expected}) 61 | 62 | t.is(file.lastModified, expected) 63 | }) 64 | 65 | test("Converts Date object in lastModified option to a number", t => { 66 | const now = new Date() 67 | 68 | // @ts-expect-error expected for tests 69 | const file = new File(["Some content"], "file.txt", {lastModified: now}) 70 | 71 | t.is(file.lastModified, Number(now)) 72 | }) 73 | 74 | test("Interpretes undefined value in lastModified option as Date.now()", t => { 75 | const lastModified = 76 | new File(["Some content"], "file.txt", { 77 | lastModified: undefined 78 | }).lastModified - Date.now() 79 | 80 | t.true(lastModified <= 0 && lastModified >= -20) 81 | }) 82 | 83 | test("Interpretes true value in lastModified option as 1", t => { 84 | // @ts-expect-error expected for tests 85 | const file = new File(["Some content"], "file.txt", {lastModified: true}) 86 | 87 | t.is(file.lastModified, 1) 88 | }) 89 | 90 | test("Interpretes null value in lastModified option as 0", t => { 91 | // @ts-expect-error expected for tests 92 | const file = new File(["Some content"], "file.txt", {lastModified: null}) 93 | 94 | t.is(file.lastModified, 0) 95 | }) 96 | 97 | test("Interpretes NaN value in lastModified option as 0", t => { 98 | t.plan(3) 99 | 100 | const values = ["Not a Number", [], {}] 101 | 102 | // I can't really see anything about this in the spec, 103 | // but this is how browsers handle type casting for this option... 104 | values.forEach(lastModified => { 105 | // @ts-expect-error expected for tests 106 | const file = new File(["Some content"], "file.txt", {lastModified}) 107 | 108 | t.is(file.lastModified, 0) 109 | }) 110 | }) 111 | 112 | test("Throws TypeError when constructed with less than 2 arguments", t => { 113 | // @ts-expect-error expected for tests 114 | const trap = () => new File(["Some content"]) 115 | 116 | t.throws(trap, { 117 | instanceOf: TypeError, 118 | message: 119 | "Failed to construct 'File': " + 120 | "2 arguments required, but only 1 present." 121 | }) 122 | }) 123 | 124 | test("Throws TypeError when constructed without arguments", t => { 125 | // @ts-expect-error expected for tests 126 | const trap = () => new File() 127 | 128 | t.throws(trap, { 129 | instanceOf: TypeError, 130 | message: 131 | "Failed to construct 'File': " + 132 | "2 arguments required, but only 0 present." 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /src/File.ts: -------------------------------------------------------------------------------- 1 | import type {BlobPropertyBag, BlobParts as FileBits} from "./Blob.js" 2 | import {Blob} from "./Blob.js" 3 | 4 | export interface FileLike { 5 | /** 6 | * Name of the file referenced by the File object. 7 | */ 8 | readonly name: string 9 | 10 | /** 11 | * Size of the file parts in bytes 12 | */ 13 | readonly size: number 14 | 15 | /** 16 | * Returns the media type ([`MIME`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types)) of the file represented by a `File` object. 17 | */ 18 | readonly type: string 19 | 20 | /** 21 | * The last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. 22 | */ 23 | readonly lastModified: number 24 | 25 | [Symbol.toStringTag]: string 26 | 27 | /** 28 | * Returns a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) which upon reading returns the data contained within the [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). 29 | */ 30 | stream(): AsyncIterable 31 | } 32 | 33 | export interface FilePropertyBag extends BlobPropertyBag { 34 | /** 35 | * The last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. 36 | */ 37 | lastModified?: number 38 | } 39 | 40 | /** 41 | * The **File** interface provides information about files and allows JavaScript to access their content. 42 | */ 43 | export class File extends Blob { 44 | static [Symbol.hasInstance](value: unknown): value is File { 45 | return ( 46 | value instanceof Blob && 47 | value[Symbol.toStringTag] === "File" && 48 | typeof (value as File).name === "string" 49 | ) 50 | } 51 | 52 | /** 53 | * Returns the name of the file referenced by the File object. 54 | */ 55 | readonly #name: string 56 | 57 | /** 58 | * The last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. 59 | */ 60 | readonly #lastModified: number = 0 61 | 62 | /** 63 | * Creates a new File instance. 64 | * 65 | * @param fileBits An `Array` strings, or [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer), [`ArrayBufferView`](https://developer.mozilla.org/en-US/docs/Web/API/ArrayBufferView), [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects, or a mix of any of such objects, that will be put inside the [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). 66 | * @param name The name of the file. 67 | * @param options An options object containing optional attributes for the file. 68 | */ 69 | constructor(fileBits: FileBits, name: string, options: FilePropertyBag = {}) { 70 | super(fileBits, options) 71 | 72 | if (arguments.length < 2) { 73 | throw new TypeError( 74 | `Failed to construct 'File': 2 arguments required, but only ${arguments.length} present.` 75 | ) 76 | } 77 | 78 | this.#name = String(name) 79 | 80 | // Simulate WebIDL type casting for NaN value in lastModified option. 81 | const lastModified = 82 | options.lastModified === undefined 83 | ? Date.now() 84 | : Number(options.lastModified) 85 | 86 | if (!Number.isNaN(lastModified)) { 87 | this.#lastModified = lastModified 88 | } 89 | } 90 | 91 | /** 92 | * Name of the file referenced by the File object. 93 | */ 94 | get name(): string { 95 | return this.#name 96 | } 97 | 98 | /* c8 ignore next 3 */ 99 | get webkitRelativePath(): string { 100 | return "" 101 | } 102 | 103 | /** 104 | * The last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. 105 | */ 106 | get lastModified(): number { 107 | return this.#lastModified 108 | } 109 | 110 | get [Symbol.toStringTag](): string { 111 | return "File" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/FormData.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | 3 | import sinon from "sinon" 4 | 5 | import {Blob} from "./Blob.js" 6 | import {File} from "./File.js" 7 | import {FormData} from "./FormData.js" 8 | 9 | const {spy} = sinon 10 | 11 | test("Recognizes FormData instances", t => { 12 | t.true(new FormData() instanceof FormData) 13 | }) 14 | 15 | test("Recognizes custom FormData implementation as FormData instance", t => { 16 | class MyFormData { 17 | append() {} 18 | 19 | set() {} 20 | 21 | get() {} 22 | 23 | getAll() {} 24 | 25 | has() {} 26 | 27 | delete() {} 28 | 29 | entries() {} 30 | 31 | values() {} 32 | 33 | keys() {} 34 | 35 | forEach() {} 36 | 37 | [Symbol.iterator]() {} 38 | 39 | get [Symbol.toStringTag]() { 40 | return "FormData" 41 | } 42 | } 43 | 44 | t.true(new MyFormData() instanceof FormData) 45 | }) 46 | 47 | test("Returns false for instanceof checks with null", t => { 48 | // @ts-expect-error expected for tests 49 | t.false(null instanceof FormData) 50 | }) 51 | 52 | test("Returns false for instanceof checks with undefined", t => { 53 | // @ts-expect-error expected for tests 54 | t.false(undefined instanceof FormData) 55 | }) 56 | 57 | test(".set() creates a new File if 3rd argument is present", t => { 58 | const file = new File(["Some content"], "file.txt") 59 | const form = new FormData() 60 | 61 | form.set("file", file, "renamed-file.txt") 62 | 63 | t.not(form.get("file"), file) 64 | }) 65 | 66 | test("File created from Blob has proper default name", t => { 67 | const form = new FormData() 68 | 69 | form.set("file", new Blob(["Some content"])) 70 | 71 | t.is((form.get("file") as File).name, "blob") 72 | }) 73 | 74 | test("Assigns a filename argument to Blob field", t => { 75 | const expected = "some-file.txt" 76 | 77 | const blob = new Blob(["Some content"]) 78 | const form = new FormData() 79 | 80 | form.set("file", blob, expected) 81 | 82 | t.is((form.get("file") as File).name, expected) 83 | }) 84 | 85 | test("User-defined filename has higher precedence", t => { 86 | const expected = "some-file.txt" 87 | 88 | const file = new File(["Some content"], "file.txt") 89 | const form = new FormData() 90 | 91 | form.set("file", file, expected) 92 | 93 | t.is((form.get("file") as File).name, expected) 94 | }) 95 | 96 | test("Third argument overrides File.name even if it was set to null", t => { 97 | const file = new File(["Some content"], "file.txt") 98 | const form = new FormData() 99 | 100 | // @ts-expect-error expected for tests 101 | form.set("file", file, null) 102 | 103 | t.is((form.get("file") as File).name, "null") 104 | }) 105 | 106 | test(".set() appends a string field", t => { 107 | const form = new FormData() 108 | 109 | form.set("field", "string") 110 | 111 | t.is(form.get("field"), "string") 112 | }) 113 | 114 | test(".set() replaces a field with the same name", t => { 115 | const form = new FormData() 116 | 117 | form.set("field", "one") 118 | 119 | t.is(form.get("field"), "one") 120 | 121 | form.set("field", "two") 122 | 123 | t.is(form.get("field"), "two") 124 | }) 125 | 126 | test(".set() replaces existent field values created with .append()", t => { 127 | const form = new FormData() 128 | 129 | form.append("field", "one") 130 | form.append("field", "two") 131 | 132 | t.deepEqual(form.getAll("field"), ["one", "two"]) 133 | 134 | form.set("field", "one") 135 | 136 | t.deepEqual(form.getAll("field"), ["one"]) 137 | }) 138 | 139 | test(".append() append a new field", t => { 140 | const form = new FormData() 141 | 142 | form.append("field", "string") 143 | 144 | t.deepEqual(form.getAll("field"), ["string"]) 145 | }) 146 | 147 | test(".append() appends to an existent field", t => { 148 | const form = new FormData() 149 | 150 | form.append("field", "one") 151 | form.append("field", "two") 152 | 153 | t.deepEqual(form.getAll("field"), ["one", "two"]) 154 | }) 155 | 156 | test(".append() appends to an existent field even if it was created with .set()", t => { 157 | const form = new FormData() 158 | 159 | form.set("field", "one") 160 | form.append("field", "two") 161 | 162 | t.deepEqual(form.getAll("field"), ["one", "two"]) 163 | }) 164 | 165 | test(".has() returns false for non-existent field", t => { 166 | const form = new FormData() 167 | 168 | t.false(form.has("field")) 169 | }) 170 | 171 | test(".delete() removes a field", t => { 172 | const form = new FormData() 173 | 174 | form.set("field", "Some data") 175 | 176 | t.true(form.has("field")) 177 | 178 | form.delete("field") 179 | 180 | t.false(form.has("field")) 181 | }) 182 | 183 | test(".get() returns null for non-existent field", t => { 184 | const form = new FormData() 185 | 186 | t.is(form.get("field"), null) 187 | }) 188 | 189 | test(".get() returns number values as string", t => { 190 | const form = new FormData() 191 | 192 | form.set("field", 42) 193 | 194 | t.is(form.get("field"), "42") 195 | }) 196 | 197 | test(".get() returns only first value from the field", t => { 198 | const form = new FormData() 199 | 200 | form.append("field", "one") 201 | form.append("field", "two") 202 | 203 | t.is(form.get("field"), "one") 204 | }) 205 | 206 | test(".get() returns Blob as a File", t => { 207 | const blob = new Blob(["Some text"]) 208 | const form = new FormData() 209 | 210 | form.set("blob", blob) 211 | 212 | t.true(form.get("blob") instanceof File) 213 | }) 214 | 215 | test(".get() returns File as-is", t => { 216 | const file = new File(["Some text"], "file.txt") 217 | const form = new FormData() 218 | 219 | form.set("file", file) 220 | 221 | t.true(form.get("file") instanceof File) 222 | }) 223 | 224 | test(".get() returns the same File that was added to FormData", t => { 225 | const file = new File(["Some text"], "file.txt") 226 | const form = new FormData() 227 | 228 | form.set("file", file) 229 | 230 | t.is(form.get("file"), file) 231 | }) 232 | 233 | test(".getAll() returns an empty array for non-existent field", t => { 234 | const form = new FormData() 235 | 236 | t.deepEqual(form.getAll("field"), []) 237 | }) 238 | 239 | test(".getAll() returns all values associated with given key", t => { 240 | const expected = ["one", "two", "three"] 241 | const form = new FormData() 242 | 243 | form.append("field", expected[0]) 244 | form.append("field", expected[1]) 245 | form.append("field", expected[2]) 246 | 247 | const actual = form.getAll("field") 248 | 249 | t.is(actual.length, 3) 250 | t.deepEqual(actual, expected) 251 | }) 252 | 253 | test(".forEach() callback should not be called when FormData has no fields", t => { 254 | const cb = spy() 255 | 256 | const fd = new FormData() 257 | 258 | fd.forEach(cb) 259 | 260 | t.false(cb.called) 261 | }) 262 | 263 | test(".forEach() callback should be called with the nullish context by default", t => { 264 | const cb = spy() 265 | 266 | const form = new FormData() 267 | 268 | form.set("name", "John Doe") 269 | 270 | form.forEach(cb) 271 | 272 | t.is(cb.firstCall.thisValue, undefined) 273 | }) 274 | 275 | test(".forEach() callback should be called with the specified context", t => { 276 | const cb = spy() 277 | 278 | const ctx = new Map() 279 | 280 | const form = new FormData() 281 | 282 | form.set("name", "John Doe") 283 | 284 | form.forEach(cb, ctx) 285 | 286 | t.true(cb.firstCall.thisValue instanceof Map) 287 | t.is(cb.firstCall.thisValue, ctx) 288 | }) 289 | 290 | test(".forEach() callback should be called with value, name and FormData itself", t => { 291 | const cb = spy() 292 | 293 | const form = new FormData() 294 | 295 | form.set("name", "John Doe") 296 | 297 | form.forEach(cb) 298 | 299 | const [value, key, instance] = cb.firstCall.args 300 | 301 | t.is(value, "John Doe") 302 | t.is(key, "name") 303 | t.is(instance, form) 304 | }) 305 | 306 | test(".forEach() callback should be called once on each filed", t => { 307 | const cb = spy() 308 | 309 | const form = new FormData() 310 | 311 | form.set("first", "value") 312 | form.set("second", 42) 313 | form.set("third", [1, 2, 3]) 314 | 315 | form.forEach(cb) 316 | 317 | t.true(cb.calledThrice) 318 | }) 319 | 320 | test(".values() is done on the first call when there's no data", t => { 321 | const form = new FormData() 322 | 323 | const curr = form.values().next() 324 | 325 | t.deepEqual(curr, { 326 | done: true, 327 | value: undefined 328 | }) 329 | }) 330 | 331 | test(".values() Returns the first value on the first call", t => { 332 | const form = new FormData() 333 | 334 | form.set("first", "value") 335 | form.set("second", 42) 336 | form.set("third", [1, 2, 3]) 337 | 338 | const curr = form.values().next() 339 | 340 | t.deepEqual(curr, { 341 | done: false, 342 | value: "value" 343 | }) 344 | }) 345 | 346 | test(".value() yields every value from FormData", t => { 347 | const form = new FormData() 348 | 349 | form.set("first", "value") 350 | form.set("second", 42) 351 | form.set("third", [1, 2, 3]) 352 | 353 | t.deepEqual([...form.values()], ["value", "42", "1,2,3"]) 354 | }) 355 | 356 | test(".keys() is done on the first call when there's no data", t => { 357 | const form = new FormData() 358 | 359 | const curr = form.keys().next() 360 | 361 | t.deepEqual(curr, { 362 | done: true, 363 | value: undefined 364 | }) 365 | }) 366 | 367 | test(".keys() Returns the first value on the first call", t => { 368 | const form = new FormData() 369 | 370 | form.set("first", "value") 371 | form.set("second", 42) 372 | form.set("third", [1, 2, 3]) 373 | 374 | const curr = form.keys().next() 375 | 376 | t.deepEqual(curr, { 377 | done: false, 378 | value: "first" 379 | }) 380 | }) 381 | 382 | test(".keys() yields every key from FormData", t => { 383 | const form = new FormData() 384 | 385 | form.set("first", "value") 386 | form.set("second", 42) 387 | form.set("third", [1, 2, 3]) 388 | 389 | t.deepEqual([...form.keys()], ["first", "second", "third"]) 390 | }) 391 | 392 | test(".toString() returns a proper string", t => { 393 | t.is(new FormData().toString(), "[object FormData]") 394 | }) 395 | 396 | test(".set() throws TypeError when called with less than 2 arguments", t => { 397 | const form = new FormData() 398 | 399 | // @ts-expect-error expected for tests 400 | const trap = () => form.set("field") 401 | 402 | t.throws(trap, { 403 | instanceOf: TypeError, 404 | message: 405 | "Failed to execute 'set' on 'FormData': " + 406 | "2 arguments required, but only 1 present." 407 | }) 408 | }) 409 | 410 | test( 411 | ".set() throws TypeError when the filename argument is present, " + 412 | "but the value is not a File", 413 | 414 | t => { 415 | const form = new FormData() 416 | 417 | const trap = () => form.set("field", "Some value", "field.txt") 418 | 419 | t.throws(trap, { 420 | instanceOf: TypeError, 421 | message: 422 | "Failed to execute 'set' on 'FormData': " + 423 | "parameter 2 is not of type 'Blob'." 424 | }) 425 | } 426 | ) 427 | 428 | test(".append() throws TypeError when called with less than 2 arguments", t => { 429 | const form = new FormData() 430 | 431 | // @ts-expect-error expected for tests 432 | const trap = () => form.append("field") 433 | 434 | t.throws(trap, { 435 | instanceOf: TypeError, 436 | message: 437 | "Failed to execute 'append' on 'FormData': " + 438 | "2 arguments required, but only 1 present." 439 | }) 440 | }) 441 | 442 | test( 443 | ".append() throws TypeError when the filename argument is present, " + 444 | "but the value is not a File", 445 | 446 | t => { 447 | const form = new FormData() 448 | 449 | const trap = () => form.append("field", "Some value", "field.txt") 450 | 451 | t.throws(trap, { 452 | instanceOf: TypeError, 453 | message: 454 | "Failed to execute 'append' on 'FormData': " + 455 | "parameter 2 is not of type 'Blob'." 456 | }) 457 | } 458 | ) 459 | -------------------------------------------------------------------------------- /src/FormData.ts: -------------------------------------------------------------------------------- 1 | import {isFunction} from "./isFunction.js" 2 | import {isBlob} from "./isBlob.js" 3 | import {isFile} from "./isFile.js" 4 | import {File} from "./File.js" 5 | 6 | /** 7 | * A `string` or `File` that represents a single value from a set of `FormData` key-value pairs. 8 | */ 9 | export type FormDataEntryValue = string | File 10 | 11 | /** 12 | * The list of entry values. Must be a non-empty array. 13 | */ 14 | type FormDataEntryValues = [FormDataEntryValue, ...FormDataEntryValue[]] 15 | 16 | /** 17 | * Private options for FormData#setEntry() method 18 | */ 19 | interface FormDataSetFieldOptions { 20 | /** 21 | * The name of the field whose data is contained in `value`. 22 | */ 23 | name: string 24 | 25 | /** 26 | * The field's value. This can be [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 27 | or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). If none of these are specified the value is converted to a string. 28 | */ 29 | rawValue: unknown 30 | 31 | /** 32 | * The filename reported to the server, when a Blob or File is passed as the second parameter. The default filename for Blob objects is "blob". The default filename for File objects is the file's filename. 33 | */ 34 | fileName?: string 35 | 36 | /** 37 | * Indicates how the existent entry must be modified: 38 | * 39 | * 1. If the `FormData#set()` is called, the value should be `false` and existent entry values list will be replaced. 40 | * 2. If the `FormData#append()` is called, the value should be `true` and the new entry value will be added at the end of the existent entry values list. 41 | */ 42 | append: boolean 43 | 44 | /** 45 | * An amout of arguments, passed to `FormData#set()` or `FormData#append()` methods. 46 | * This value is only necessary for dynamic error messages. 47 | */ 48 | argsLength: number 49 | } 50 | 51 | /** 52 | * Provides a way to easily construct a set of key/value pairs representing form fields and their values, which can then be easily sent using fetch(). 53 | * 54 | * Note that this object is not a part of Node.js, so you might need to check if an HTTP client of your choice support spec-compliant FormData. 55 | * However, if your HTTP client does not support FormData, you can use [`form-data-encoder`](https://npmjs.com/package/form-data-encoder) package to handle "multipart/form-data" encoding. 56 | */ 57 | export class FormData { 58 | /** 59 | * Stores internal data for every entry 60 | */ 61 | readonly #entries = new Map() 62 | 63 | static [Symbol.hasInstance](value: unknown): value is FormData { 64 | if (!value) { 65 | return false 66 | } 67 | 68 | const val = value as FormData 69 | 70 | return Boolean( 71 | isFunction(val.constructor) && 72 | val[Symbol.toStringTag] === "FormData" && 73 | isFunction(val.append) && 74 | isFunction(val.set) && 75 | isFunction(val.get) && 76 | isFunction(val.getAll) && 77 | isFunction(val.has) && 78 | isFunction(val.delete) && 79 | isFunction(val.entries) && 80 | isFunction(val.values) && 81 | isFunction(val.keys) && 82 | isFunction(val[Symbol.iterator]) && 83 | isFunction(val.forEach) 84 | ) 85 | } 86 | 87 | #setEntry({ 88 | name, 89 | rawValue, 90 | append, 91 | fileName, 92 | argsLength 93 | }: FormDataSetFieldOptions): void { 94 | const methodName = append ? "append" : "set" 95 | 96 | // FormData required at least 2 arguments to be set. 97 | if (argsLength < 2) { 98 | throw new TypeError( 99 | `Failed to execute '${methodName}' on 'FormData': ` + 100 | `2 arguments required, but only ${argsLength} present.` 101 | ) 102 | } 103 | 104 | // Make sure the name of the entry is always a string. 105 | name = String(name) 106 | 107 | // Normalize value to a string or File 108 | let value: FormDataEntryValue 109 | if (isFile(rawValue)) { 110 | // Check if fileName argument is present 111 | value = 112 | fileName === undefined 113 | ? rawValue // if there's no fileName, let the value be rawValue 114 | : new File([rawValue], fileName, { 115 | // otherwise, create new File with given fileName 116 | type: rawValue.type, 117 | lastModified: rawValue.lastModified 118 | }) 119 | } else if (isBlob(rawValue)) { 120 | // Use "blob" as default filename if the 3rd argument is not present 121 | value = new File([rawValue], fileName === undefined ? "blob" : fileName, { 122 | type: rawValue.type 123 | }) 124 | } else if (fileName) { 125 | // If value is not a File or Blob, but the filename is present, throw following error: 126 | throw new TypeError( 127 | `Failed to execute '${methodName}' on 'FormData': parameter 2 is not of type 'Blob'.` 128 | ) 129 | } else { 130 | // A non-file entries must be converted to string 131 | value = String(rawValue) 132 | } 133 | 134 | // Get an entry associated with given name 135 | const values = this.#entries.get(name) 136 | 137 | // If there's no such entry, create a new set of values with the name. 138 | if (!values) { 139 | return void this.#entries.set(name, [value]) 140 | } 141 | 142 | // If the entry exists: 143 | // Replace a value of the existing entry when the "set" method is called. 144 | if (!append) { 145 | return void this.#entries.set(name, [value]) 146 | } 147 | 148 | // Otherwise append a new value to the existing entry. 149 | values.push(value) 150 | } 151 | 152 | /** 153 | * Appends a new value onto an existing key inside a FormData object, 154 | * or adds the key if it does not already exist. 155 | * 156 | * The difference between `set()` and `append()` is that if the specified key already exists, `set()` will overwrite all existing values with the new one, whereas `append()` will append the new value onto the end of the existing set of values. 157 | * 158 | * @param name The name of the field whose data is contained in `value`. 159 | * @param value The field's value. This can be [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 160 | or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). If none of these are specified the value is converted to a string. 161 | * @param fileName The filename reported to the server, when a Blob or File is passed as the second parameter. The default filename for Blob objects is "blob". The default filename for File objects is the file's filename. 162 | */ 163 | append(name: string, value: unknown, fileName?: string): void { 164 | this.#setEntry({ 165 | name, 166 | fileName, 167 | append: true, 168 | rawValue: value, 169 | argsLength: arguments.length 170 | }) 171 | } 172 | 173 | /** 174 | * Set a new value for an existing key inside FormData, 175 | * or add the new field if it does not already exist. 176 | * 177 | * @param name The name of the field whose data is contained in `value`. 178 | * @param value The field's value. This can be [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 179 | or [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). If none of these are specified the value is converted to a string. 180 | * @param fileName The filename reported to the server, when a Blob or File is passed as the second parameter. The default filename for Blob objects is "blob". The default filename for File objects is the file's filename. 181 | * 182 | */ 183 | set(name: string, value: unknown, fileName?: string): void { 184 | this.#setEntry({ 185 | name, 186 | fileName, 187 | append: false, 188 | rawValue: value, 189 | argsLength: arguments.length 190 | }) 191 | } 192 | 193 | /** 194 | * Returns the first value associated with a given key from within a `FormData` object. 195 | * If you expect multiple values and want all of them, use the `getAll()` method instead. 196 | * 197 | * @param {string} name A name of the value you want to retrieve. 198 | * 199 | * @returns A `FormDataEntryValue` containing the value. If the key doesn't exist, the method returns null. 200 | */ 201 | get(name: string): FormDataEntryValue | null { 202 | const field = this.#entries.get(String(name)) 203 | 204 | if (!field) { 205 | return null 206 | } 207 | 208 | return field[0] 209 | } 210 | 211 | /** 212 | * Returns all the values associated with a given key from within a `FormData` object. 213 | * 214 | * @param {string} name A name of the value you want to retrieve. 215 | * 216 | * @returns An array of `FormDataEntryValue` whose key matches the value passed in the `name` parameter. If the key doesn't exist, the method returns an empty list. 217 | */ 218 | getAll(name: string): FormDataEntryValue[] { 219 | const field = this.#entries.get(String(name)) 220 | 221 | if (!field) { 222 | return [] 223 | } 224 | 225 | return field.slice() 226 | } 227 | 228 | /** 229 | * Returns a boolean stating whether a `FormData` object contains a certain key. 230 | * 231 | * @param name A string representing the name of the key you want to test for. 232 | * 233 | * @return A boolean value. 234 | */ 235 | has(name: string): boolean { 236 | return this.#entries.has(String(name)) 237 | } 238 | 239 | /** 240 | * Deletes a key and its value(s) from a `FormData` object. 241 | * 242 | * @param name The name of the key you want to delete. 243 | */ 244 | delete(name: string): void { 245 | this.#entries.delete(String(name)) 246 | } 247 | 248 | /** 249 | * Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all keys contained in this `FormData` object. 250 | * Each key is a `string`. 251 | */ 252 | *keys(): IterableIterator { 253 | for (const key of this.#entries.keys()) { 254 | yield key 255 | } 256 | } 257 | 258 | /** 259 | * Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through the `FormData` key/value pairs. 260 | * The key of each pair is a string; the value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue). 261 | */ 262 | *entries(): IterableIterator<[string, FormDataEntryValue]> { 263 | for (const name of this.keys()) { 264 | const values = this.getAll(name) 265 | 266 | // Yield each value of a field, like browser-side FormData does. 267 | for (const value of values) { 268 | yield [name, value] 269 | } 270 | } 271 | } 272 | 273 | /** 274 | * Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all values contained in this object `FormData` object. 275 | * Each value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue). 276 | */ 277 | *values(): IterableIterator { 278 | for (const [, value] of this) { 279 | yield value 280 | } 281 | } 282 | 283 | /** 284 | * An alias for FormData#entries() 285 | */ 286 | [Symbol.iterator]() { 287 | return this.entries() 288 | } 289 | 290 | /** 291 | * Executes given callback function for each field of the FormData instance 292 | */ 293 | forEach( 294 | callback: (value: FormDataEntryValue, key: string, form: FormData) => void, 295 | 296 | thisArg?: unknown 297 | ): void { 298 | for (const [name, value] of this) { 299 | callback.call(thisArg, value, name, this) 300 | } 301 | } 302 | 303 | get [Symbol.toStringTag](): string { 304 | return "FormData" 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/__helper__/sleep.ts: -------------------------------------------------------------------------------- 1 | const sleep = (ms: number) => 2 | new Promise(resolve => { 3 | setTimeout(resolve, ms) 4 | }) 5 | 6 | export default sleep 7 | -------------------------------------------------------------------------------- /src/blobHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import {ReadableStream} from "node:stream/web" 2 | import {text} from "node:stream/consumers" 3 | 4 | import test from "ava" 5 | 6 | import {stub} from "sinon" 7 | 8 | import { 9 | getStreamIterator, 10 | consumeBlobParts, 11 | clonePart, 12 | MAX_CHUNK_SIZE 13 | } from "./blobHelpers.js" 14 | import {Blob} from "./Blob.js" 15 | 16 | import {isAsyncIterable} from "./isAsyncIterable.js" 17 | 18 | test("getStreamIterator: returns AsyncIterable for ReadableStream", t => { 19 | const stream = new ReadableStream() 20 | 21 | t.true(isAsyncIterable(getStreamIterator(stream))) 22 | }) 23 | 24 | test("getStreamIterator: iterates over given stream", async t => { 25 | const expected = "Some text" 26 | 27 | const stream = new ReadableStream({ 28 | pull(controller) { 29 | controller.enqueue(new TextEncoder().encode(expected)) 30 | controller.close() 31 | } 32 | }) 33 | 34 | let actual = "" 35 | const decoder = new TextDecoder() 36 | for await (const chunk of getStreamIterator(stream)) { 37 | actual += decoder.decode(chunk, {stream: true}) 38 | } 39 | 40 | actual += decoder.decode() 41 | 42 | t.is(actual, expected) 43 | }) 44 | 45 | test( 46 | "getStreamIterator: returns AsyncIterable " + 47 | "for streams w/o Symbol.asyncIterator", 48 | 49 | t => { 50 | const stream = new ReadableStream() 51 | 52 | const streamStub = stub(stream, Symbol.asyncIterator).get(() => undefined) 53 | 54 | t.false(getStreamIterator(stream) instanceof ReadableStream) 55 | 56 | streamStub.reset() 57 | } 58 | ) 59 | 60 | test("getStreamIterator: iterates over the stream using fallback", async t => { 61 | const expected = "Some text" 62 | 63 | const stream = new ReadableStream({ 64 | pull(controller) { 65 | controller.enqueue(new TextEncoder().encode(expected)) 66 | controller.close() 67 | } 68 | }) 69 | 70 | stub(stream, Symbol.asyncIterator).get(() => undefined) 71 | 72 | let actual = "" 73 | const decoder = new TextDecoder() 74 | for await (const chunk of getStreamIterator(stream)) { 75 | actual += decoder.decode(chunk, {stream: true}) 76 | } 77 | 78 | actual += decoder.decode() 79 | 80 | t.is(actual, expected) 81 | }) 82 | 83 | test("clonePart: Slices big chunks into smaller pieces", async t => { 84 | const buf = Buffer.alloc(MAX_CHUNK_SIZE * 2 + MAX_CHUNK_SIZE / 2) 85 | 86 | const chunks: Uint8Array[] = [] 87 | for await (const chunk of clonePart(buf)) { 88 | chunks.push(chunk) 89 | } 90 | 91 | t.is(chunks.length, 3) 92 | t.is(chunks[0].byteLength, MAX_CHUNK_SIZE) 93 | t.is(chunks[1].byteLength, MAX_CHUNK_SIZE) 94 | t.is(chunks[2].byteLength, MAX_CHUNK_SIZE / 2) 95 | }) 96 | 97 | test("consumeBlobParts: Reads Node.js' blob w/o .stream() method", async t => { 98 | const input = "I beat Twilight Sparkle and all I got was this lousy t-shirt" 99 | const blob = new Blob([input]) 100 | 101 | const blobStub = stub(blob, "stream").get(() => undefined) 102 | 103 | const actual = await text(consumeBlobParts([blob], true)) 104 | 105 | t.is(actual, input) 106 | 107 | blobStub.reset() 108 | }) 109 | 110 | test("getStreamIterator: throws TypeError for unsupported data sources", t => { 111 | // @ts-expect-error 112 | const trap = () => getStreamIterator({}) 113 | 114 | t.throws(trap, { 115 | instanceOf: TypeError, 116 | message: 117 | "Unsupported data source: Expected either " + 118 | "ReadableStream or async iterable." 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /src/blobHelpers.ts: -------------------------------------------------------------------------------- 1 | /*! Based on fetch-blob. MIT License. Jimmy Wärting & David Frank */ 2 | 3 | import type {Blob, BlobLike} from "./Blob.js" 4 | import type {BlobPart} from "./BlobPart.js" 5 | 6 | import {isFunction} from "./isFunction.js" 7 | import {isAsyncIterable} from "./isAsyncIterable.js" 8 | import {isReadableStreamFallback} from "./isReadableStreamFallback.js" 9 | 10 | export const MAX_CHUNK_SIZE = 65536 // 64 KiB (same size chrome slice theirs blob into Uint8array's) 11 | 12 | export async function* clonePart( 13 | value: Uint8Array 14 | ): AsyncGenerator { 15 | if (value.byteLength <= MAX_CHUNK_SIZE) { 16 | yield value 17 | 18 | return 19 | } 20 | 21 | let offset = 0 22 | while (offset < value.byteLength) { 23 | const size = Math.min(value.byteLength - offset, MAX_CHUNK_SIZE) 24 | const buffer = value.buffer.slice(offset, offset + size) 25 | 26 | offset += buffer.byteLength 27 | 28 | yield new Uint8Array(buffer) 29 | } 30 | } 31 | 32 | /** 33 | * Reads from given ReadableStream 34 | * 35 | * @param readable A ReadableStream to read from 36 | */ 37 | export async function* readStream( 38 | readable: ReadableStream 39 | ): AsyncGenerator { 40 | const reader = readable.getReader() 41 | 42 | while (true) { 43 | const {done, value} = await reader.read() 44 | 45 | if (done) { 46 | break 47 | } 48 | 49 | yield value 50 | } 51 | } 52 | 53 | export async function* chunkStream( 54 | stream: AsyncIterable 55 | ): AsyncGenerator { 56 | for await (const value of stream) { 57 | yield* clonePart(value) 58 | } 59 | } 60 | 61 | /** 62 | * Turns ReadableStream into async iterable when the `Symbol.asyncIterable` is not implemented on given stream. 63 | * 64 | * @param source A ReadableStream to create async iterator for 65 | */ 66 | export const getStreamIterator = ( 67 | source: ReadableStream | AsyncIterable 68 | ): AsyncIterable => { 69 | if (isAsyncIterable(source)) { 70 | return chunkStream(source) 71 | } 72 | 73 | if (isReadableStreamFallback(source)) { 74 | return chunkStream(readStream(source)) 75 | } 76 | 77 | // Throw an error otherwise (for example, in case if encountered Node.js Readable stream without Symbol.asyncIterator method) 78 | throw new TypeError( 79 | "Unsupported data source: Expected either ReadableStream or async iterable." 80 | ) 81 | } 82 | 83 | /** 84 | * Consumes builtin Node.js Blob that does not have stream method. 85 | */ 86 | async function* consumeNodeBlob( 87 | blob: BlobLike 88 | ): AsyncGenerator { 89 | let position = 0 90 | while (position !== blob.size) { 91 | const chunk = blob.slice( 92 | position, 93 | 94 | Math.min(blob.size, position + MAX_CHUNK_SIZE) 95 | ) 96 | 97 | const buffer = await chunk.arrayBuffer() 98 | 99 | position += buffer.byteLength 100 | 101 | yield new Uint8Array(buffer) 102 | } 103 | } 104 | 105 | /** 106 | * Creates an iterator allowing to go through blob parts and consume their content 107 | * 108 | * @param parts blob parts from Blob class 109 | */ 110 | export async function* consumeBlobParts( 111 | parts: BlobPart[], 112 | clone = false 113 | ): AsyncGenerator { 114 | for (const part of parts) { 115 | if (ArrayBuffer.isView(part)) { 116 | if (clone) { 117 | yield* clonePart(part) 118 | } else { 119 | yield part 120 | } 121 | } else if (isFunction((part as Blob).stream)) { 122 | yield* getStreamIterator((part as Blob).stream()) 123 | } else { 124 | // Special case for an old Node.js Blob that have no stream() method. 125 | yield* consumeNodeBlob(part as BlobLike) 126 | } 127 | } 128 | } 129 | 130 | export function* sliceBlob( 131 | blobParts: BlobPart[], 132 | blobSize: number, 133 | start = 0, 134 | end?: number 135 | ): Generator { 136 | end ??= blobSize 137 | 138 | let relativeStart = 139 | start < 0 ? Math.max(blobSize + start, 0) : Math.min(start, blobSize) 140 | 141 | let relativeEnd = 142 | end < 0 ? Math.max(blobSize + end, 0) : Math.min(end, blobSize) 143 | 144 | const span = Math.max(relativeEnd - relativeStart, 0) 145 | 146 | let added = 0 147 | for (const part of blobParts) { 148 | if (added >= span) { 149 | break 150 | } 151 | 152 | const partSize = ArrayBuffer.isView(part) ? part.byteLength : part.size 153 | if (relativeStart && partSize <= relativeStart) { 154 | // Skip the beginning and change the relative 155 | // start & end position as we skip the unwanted parts 156 | relativeStart -= partSize 157 | relativeEnd -= partSize 158 | } else { 159 | let chunk: BlobPart 160 | 161 | if (ArrayBuffer.isView(part)) { 162 | chunk = part.subarray(relativeStart, Math.min(partSize, relativeEnd)) 163 | added += chunk.byteLength 164 | } else { 165 | chunk = part.slice(relativeStart, Math.min(partSize, relativeEnd)) 166 | added += chunk.size 167 | } 168 | 169 | relativeEnd -= partSize 170 | relativeStart = 0 171 | 172 | yield chunk 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | const globalObject = ((): typeof globalThis => { 2 | // new standardized access to the global object 3 | if (typeof globalThis !== "undefined") { 4 | return globalThis 5 | } 6 | 7 | // WebWorker specific access 8 | if (typeof self !== "undefined") { 9 | return self 10 | } 11 | 12 | return window 13 | })() 14 | 15 | export const {FormData, Blob, File} = globalObject 16 | -------------------------------------------------------------------------------- /src/fileFromPath.test.ts: -------------------------------------------------------------------------------- 1 | import {stat, readFile, utimes} from "node:fs/promises" 2 | import {resolve, basename} from "node:path" 3 | 4 | import test from "ava" 5 | 6 | import {File} from "./File.js" 7 | import { 8 | fileFromPathSync, 9 | fileFromPath, 10 | type FileFromPathOptions 11 | } from "./fileFromPath.js" 12 | 13 | import sleep from "./__helper__/sleep.js" 14 | 15 | const filePath = resolve("license") 16 | 17 | test("Returns File instance", async t => { 18 | t.true((await fileFromPath(filePath)) instanceof File) 19 | }) 20 | 21 | test("sync: Returns File instance", t => { 22 | t.true(fileFromPathSync(filePath) instanceof File) 23 | }) 24 | 25 | test("Creates a file from path", async t => { 26 | const expected: Buffer = await readFile(filePath) 27 | 28 | const file = await fileFromPath(filePath) 29 | 30 | const actual = Buffer.from(await file.arrayBuffer()) 31 | 32 | t.true(actual.equals(expected)) 33 | }) 34 | 35 | test("sync: Creates a file from path", async t => { 36 | const expected: Buffer = await readFile(filePath) 37 | const file = fileFromPathSync(filePath) 38 | 39 | const actual = Buffer.from(await file.arrayBuffer()) 40 | 41 | t.true(actual.equals(expected)) 42 | }) 43 | 44 | test("Has name taken from file path", async t => { 45 | const file = await fileFromPath(filePath) 46 | 47 | t.is(file.name, basename(filePath)) 48 | }) 49 | 50 | test("Has an empty string as file type by default", async t => { 51 | const file = await fileFromPath("readme.md") 52 | 53 | t.is(file.type, "") 54 | }) 55 | 56 | test("Has lastModified field taken from file stats", async t => { 57 | const {mtimeMs} = await stat(filePath) 58 | 59 | const file = await fileFromPath(filePath) 60 | 61 | t.is(file.lastModified, mtimeMs) 62 | }) 63 | 64 | test("Has the size property reflecting the one of the actual file", async t => { 65 | const {size} = await stat(filePath) 66 | 67 | const file = await fileFromPath(filePath) 68 | 69 | t.is(file.size, size) 70 | }) 71 | 72 | test("Allows to set file name as the second argument", async t => { 73 | const expected = "some-file.txt" 74 | 75 | const file = await fileFromPath(filePath, expected) 76 | 77 | t.is(file.name, expected) 78 | }) 79 | 80 | test("sync: Allows to set file name as the second argument", t => { 81 | const expected = "some-file.txt" 82 | const file = fileFromPathSync(filePath, expected) 83 | 84 | t.is(file.name, expected) 85 | }) 86 | 87 | test("Allows to set file options from second argument", async t => { 88 | const expected: FileFromPathOptions = {type: "text/plain"} 89 | 90 | const file = await fileFromPath(filePath, expected) 91 | 92 | t.deepEqual( 93 | { 94 | type: file.type 95 | }, 96 | expected 97 | ) 98 | }) 99 | 100 | test("sync: Allows to set file options from second argument", t => { 101 | const expected: FileFromPathOptions = {type: "text/plain"} 102 | 103 | const file = fileFromPathSync(filePath, expected) 104 | 105 | t.deepEqual( 106 | { 107 | type: file.type 108 | }, 109 | 110 | expected 111 | ) 112 | }) 113 | 114 | test("Can be read as text", async t => { 115 | const expected = await readFile(filePath, "utf-8") 116 | const file = await fileFromPath(filePath) 117 | 118 | const actual = await file.text() 119 | 120 | t.is(actual, expected) 121 | }) 122 | 123 | test("Can be read as ArrayBuffer", async t => { 124 | const expected = await readFile(filePath) 125 | const file = await fileFromPath(filePath) 126 | 127 | const actual = await file.arrayBuffer() 128 | 129 | t.true(actual instanceof ArrayBuffer, "The result must be an ArrayBuffer") 130 | t.true(Buffer.from(actual).equals(expected)) 131 | }) 132 | 133 | test("Can be sliced", async t => { 134 | const file = await fileFromPath(filePath) 135 | 136 | const actual = await file.slice(0, 15).text() 137 | 138 | t.is(actual, "The MIT License") 139 | }) 140 | 141 | test("Can be sliced from the arbitrary start", async t => { 142 | const file = await fileFromPath(filePath) 143 | 144 | const actual = await file.slice(4, 15).text() 145 | 146 | t.is(actual, "MIT License") 147 | }) 148 | 149 | test("Can be sliced from Blob returned from .slice() method", async t => { 150 | const license = new File([await readFile(filePath)], "license") 151 | const file = await fileFromPath(filePath) 152 | 153 | const expected = license.slice(4, 11).slice(2, 5) 154 | const actual = file.slice(4, 11).slice(2, 5) 155 | 156 | t.is(await actual.text(), await expected.text()) 157 | }) 158 | 159 | test("Reads from empty file", async t => { 160 | const file = await fileFromPath(filePath) 161 | 162 | const sliced = file.slice(0, 0) 163 | 164 | t.is(sliced.size, 0, "Must have 0 size") 165 | t.is(await sliced.text(), "", "Must return empty string") 166 | }) 167 | 168 | test("Fails attempt to read modified file", async t => { 169 | const path = resolve("readme.md") 170 | const file = await fileFromPath(path) 171 | 172 | await sleep(100) // wait 100ms 173 | 174 | const now = new Date() 175 | 176 | await utimes(path, now, now) 177 | 178 | await t.throwsAsync(() => file.text(), { 179 | any: true, 180 | instanceOf: DOMException, 181 | name: "NotReadableError", 182 | message: 183 | "The requested file could not be read, " + 184 | "typically due to permission problems that have occurred " + 185 | "after a reference to a file was acquired." 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /src/fileFromPath.ts: -------------------------------------------------------------------------------- 1 | import {statSync, createReadStream} from "node:fs" 2 | import {stat} from "node:fs/promises" 3 | import type {Stats} from "node:fs" 4 | import {basename} from "node:path" 5 | 6 | import type {FileLike, FilePropertyBag} from "./File.js" 7 | import {isObject} from "./isObject.js" 8 | import {File} from "./File.js" 9 | 10 | export * from "./isFile.js" 11 | 12 | export type FileFromPathOptions = Omit 13 | 14 | interface FileFromPathInput { 15 | path: string 16 | 17 | start?: number 18 | 19 | size: number 20 | 21 | lastModified: number 22 | } 23 | 24 | /** 25 | * Represends an object referencing a file on a disk 26 | * Based on [`fetch-blob/from.js`](https://github.com/node-fetch/fetch-blob/blob/a3b0d62b9d88e0fa80af2e36f50ce25222535692/from.js#L32-L72) implementation 27 | * 28 | * @api private 29 | */ 30 | class FileFromPath implements Omit { 31 | #path: string 32 | 33 | #start: number 34 | 35 | name: string 36 | 37 | size: number 38 | 39 | lastModified: number 40 | 41 | constructor(input: FileFromPathInput) { 42 | this.#path = input.path 43 | this.#start = input.start || 0 44 | this.name = basename(this.#path) 45 | this.size = input.size 46 | this.lastModified = input.lastModified 47 | } 48 | 49 | slice(start: number, end: number): FileFromPath { 50 | return new FileFromPath({ 51 | path: this.#path, 52 | lastModified: this.lastModified, 53 | start: this.#start + start, 54 | size: end - start 55 | }) 56 | } 57 | 58 | async *stream(): AsyncGenerator { 59 | const {mtimeMs} = await stat(this.#path) 60 | 61 | if (mtimeMs > this.lastModified) { 62 | throw new DOMException( 63 | "The requested file could not be read, " + 64 | "typically due to permission problems that have occurred " + 65 | "after a reference to a file was acquired.", 66 | 67 | "NotReadableError" 68 | ) 69 | } 70 | 71 | if (this.size) { 72 | yield* createReadStream(this.#path, { 73 | start: this.#start, 74 | end: this.#start + this.size - 1 75 | }) 76 | } 77 | } 78 | 79 | get [Symbol.toStringTag]() { 80 | return "File" 81 | } 82 | } 83 | 84 | function createFileFromPath( 85 | path: string, 86 | {mtimeMs, size}: Stats, 87 | filenameOrOptions?: string | FileFromPathOptions, 88 | options: FileFromPathOptions = {} 89 | ): File { 90 | let filename: string | undefined 91 | if (isObject(filenameOrOptions)) { 92 | ;[options, filename] = [filenameOrOptions, undefined] 93 | } else { 94 | filename = filenameOrOptions 95 | } 96 | 97 | const file = new FileFromPath({path, size, lastModified: mtimeMs}) 98 | 99 | if (!filename) { 100 | filename = file.name 101 | } 102 | 103 | return new File([file], filename, { 104 | ...options, 105 | lastModified: file.lastModified 106 | }) 107 | } 108 | 109 | /** 110 | * Creates a `File` referencing the one on a disk by given path. Synchronous version of the `fileFromPath` 111 | * 112 | * @param path Path to a file 113 | * @param filename Optional name of the file. Will be passed as the second argument in `File` constructor. If not presented, the name will be taken from the file's path. 114 | * @param options Additional `File` options, except for `lastModified`. 115 | * 116 | * @example 117 | * 118 | * ```js 119 | * import {FormData, File} from "formdata-node" 120 | * import {fileFromPathSync} from "formdata-node/file-from-path" 121 | * 122 | * const form = new FormData() 123 | * 124 | * const file = fileFromPathSync("/path/to/some/file.txt") 125 | * 126 | * form.set("file", file) 127 | * 128 | * form.get("file") // -> Your `File` object 129 | * ``` 130 | */ 131 | export function fileFromPathSync(path: string): File 132 | export function fileFromPathSync(path: string, filename?: string): File 133 | export function fileFromPathSync( 134 | path: string, 135 | options?: FileFromPathOptions 136 | ): File 137 | export function fileFromPathSync( 138 | path: string, 139 | filename?: string, 140 | options?: FileFromPathOptions 141 | ): File 142 | export function fileFromPathSync( 143 | path: string, 144 | filenameOrOptions?: string | FileFromPathOptions, 145 | options: FileFromPathOptions = {} 146 | ): File { 147 | const stats = statSync(path) 148 | 149 | return createFileFromPath(path, stats, filenameOrOptions, options) 150 | } 151 | 152 | /** 153 | * Creates a `File` referencing the one on a disk by given path. 154 | * 155 | * @param path Path to a file 156 | * @param filename Optional name of the file. Will be passed as the second argument in `File` constructor. If not presented, the name will be taken from the file's path. 157 | * @param options Additional `File` options, except for `lastModified`. 158 | * 159 | * @example 160 | * 161 | * ```js 162 | * import {FormData, File} from "formdata-node" 163 | * import {fileFromPath} from "formdata-node/file-from-path" 164 | * 165 | * const form = new FormData() 166 | * 167 | * const file = await fileFromPath("/path/to/some/file.txt") 168 | * 169 | * form.set("file", file) 170 | * 171 | * form.get("file") // -> Your `File` object 172 | * ``` 173 | */ 174 | export async function fileFromPath(path: string): Promise 175 | export async function fileFromPath( 176 | path: string, 177 | filename?: string 178 | ): Promise 179 | export async function fileFromPath( 180 | path: string, 181 | options?: FileFromPathOptions 182 | ): Promise 183 | export async function fileFromPath( 184 | path: string, 185 | filename?: string, 186 | options?: FileFromPathOptions 187 | ): Promise 188 | export async function fileFromPath( 189 | path: string, 190 | filenameOrOptions?: string | FileFromPathOptions, 191 | options?: FileFromPathOptions 192 | ): Promise { 193 | const stats = await stat(path) 194 | 195 | return createFileFromPath(path, stats, filenameOrOptions, options) 196 | } 197 | -------------------------------------------------------------------------------- /src/hasInstance.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | 3 | import {Blob} from "./Blob.js" 4 | import {File} from "./File.js" 5 | 6 | test("Blob object is recognized as Blob", t => { 7 | const blob = new Blob() 8 | 9 | t.true(blob instanceof Blob) 10 | }) 11 | 12 | test("Blob object is not recognized as File", t => { 13 | const blob = new Blob() 14 | 15 | t.false(blob instanceof File) 16 | }) 17 | 18 | test("Blob-ish object is recognized as Blob", t => { 19 | const blob = { 20 | [Symbol.toStringTag]: "Blob", 21 | stream() {} 22 | } 23 | 24 | t.true(blob instanceof Blob) 25 | }) 26 | 27 | test("Blob-ish objects with only arrayBuffer method is recognized as Blob", t => { 28 | const blobAlike = { 29 | arrayBuffer() {}, 30 | [Symbol.toStringTag]: "Blob" 31 | } 32 | 33 | t.true(blobAlike instanceof Blob) 34 | }) 35 | 36 | test("Blob-ish object is not recognized as File", t => { 37 | const blob = { 38 | [Symbol.toStringTag]: "Blob", 39 | stream() {} 40 | } 41 | 42 | t.false(blob instanceof File) 43 | }) 44 | 45 | test("Blob-ish objects with only arrayBuffer method is not recognized as File", t => { 46 | const blobAlike = { 47 | arrayBuffer() {}, 48 | [Symbol.toStringTag]: "Blob" 49 | } 50 | 51 | t.false(blobAlike instanceof File) 52 | }) 53 | 54 | test("File is recognized as Blob instance", t => { 55 | const file = new File([], "file.txt") 56 | 57 | t.true(file instanceof Blob) 58 | }) 59 | 60 | test("File is recognized as File instance", t => { 61 | const file = new File([], "file.txt") 62 | 63 | t.true(file instanceof File) 64 | }) 65 | 66 | test("File-ish object is recognized as Blob", t => { 67 | const file = { 68 | name: "", 69 | [Symbol.toStringTag]: "File", 70 | stream() {} 71 | } 72 | 73 | t.true(file instanceof Blob) 74 | }) 75 | 76 | test("File-ish object is recognized as File", t => { 77 | const file = { 78 | name: "", 79 | [Symbol.toStringTag]: "File", 80 | stream() {} 81 | } 82 | 83 | t.true(file instanceof File) 84 | }) 85 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FormData.js" 2 | export * from "./Blob.js" 3 | export * from "./File.js" 4 | -------------------------------------------------------------------------------- /src/isAsyncIterable.ts: -------------------------------------------------------------------------------- 1 | import {isFunction} from "./isFunction.js" 2 | import {isObject} from "./isObject.js" 3 | 4 | /** 5 | * Checks if the object implements `Symbol.asyncIterator` method 6 | */ 7 | export const isAsyncIterable = ( 8 | value: unknown 9 | ): value is AsyncIterable => 10 | isObject(value) && 11 | isFunction((value as AsyncIterable)[Symbol.asyncIterator]) 12 | -------------------------------------------------------------------------------- /src/isBlob.ts: -------------------------------------------------------------------------------- 1 | import {Blob} from "./Blob.js" 2 | 3 | export const isBlob = (value: unknown): value is Blob => value instanceof Blob 4 | -------------------------------------------------------------------------------- /src/isFile.ts: -------------------------------------------------------------------------------- 1 | import {File} from "./File.js" 2 | 3 | /** 4 | * Checks if given value is a File, Blob or file-look-a-like object. 5 | * 6 | * @param value A value to test 7 | */ 8 | export const isFile = (value: unknown): value is File => value instanceof File 9 | -------------------------------------------------------------------------------- /src/isFunction.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/suspicious/noExplicitAny: Allowed to cover any possible function type 2 | export type AnyFunction = (...args: any[]) => any 3 | 4 | export const isFunction = (value: unknown): value is AnyFunction => 5 | typeof value === "function" 6 | -------------------------------------------------------------------------------- /src/isObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if given `value` is non-array object 3 | */ 4 | // biome-ignore lint/suspicious/noExplicitAny: Allowd to handle any object 5 | export const isObject = (value: unknown): value is Record => 6 | typeof value === "object" && value != null && !Array.isArray(value) 7 | -------------------------------------------------------------------------------- /src/isReadableStreamFallback.ts: -------------------------------------------------------------------------------- 1 | import {isFunction} from "./isFunction.js" 2 | 3 | export const isReadableStreamFallback = ( 4 | value: unknown 5 | ): value is ReadableStream => 6 | !!value && 7 | typeof value === "object" && 8 | !Array.isArray(value) && 9 | isFunction((value as ReadableStream).getReader) 10 | -------------------------------------------------------------------------------- /tsconfig.ava.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "ts-node": { 4 | "transpileOnly": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "compilerOptions": { 7 | "outDir": "lib", 8 | "target": "es2021", 9 | "module": "node16", 10 | "noImplicitAny": true, 11 | "baseUrl": "src", 12 | "checkJs": false, 13 | "allowJs": false, 14 | "skipLibCheck": false, 15 | "strict": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "moduleResolution": "node16", 19 | "forceConsistentCasingInFileNames": true, 20 | "allowSyntheticDefaultImports": true 21 | }, 22 | "ts-node": { 23 | "transpileOnly": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "tsup" 2 | 3 | export default defineConfig({ 4 | entry: { 5 | "form-data": "src/index.ts", 6 | "file-from-path": "src/fileFromPath.ts", 7 | browser: "src/browser.ts" 8 | }, 9 | outDir: "lib", 10 | format: ["esm", "cjs"], 11 | dts: true, 12 | splitting: false 13 | }) 14 | --------------------------------------------------------------------------------