├── .github └── workflows │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── scripts └── version.js ├── src ├── form-data-parser.ts └── lib │ ├── form-data.spec.ts │ └── form-data.ts ├── tsconfig.json ├── tsconfig.lib.json └── tsconfig.spec.json /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: pnpm/action-setup@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: '20.x' 24 | registry-url: 'https://registry.npmjs.org' 25 | cache: 'pnpm' 26 | 27 | - run: pnpm install 28 | 29 | - name: Publish to npm 30 | run: npm publish --provenance --access public 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tsimp 2 | dist 3 | node_modules 4 | 5 | *.tsbuildinfo -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # form-data-parser CHANGELOG 2 | 3 | ## v0.1.0 (Aug 24, 2024) 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## IMPORTANT: This repository has moved to [@mjackson/remix-the-web](https://github.com/mjackson/remix-the-web/tree/main/form-data-parser) 2 | 3 | # form-data-parser 4 | 5 | `form-data-parser` is a wrapper around `request.formData()` that provides pluggable support for file upload handling. This is useful in server contexts where large files should be streamed to disk or some cloud storage service like [AWS S3](https://aws.amazon.com/s3/) or [Cloudflare R2](https://www.cloudflare.com/developer-platform/r2/) instead of being buffered in memory. 6 | 7 | ## Features 8 | 9 | - Drop-in replacement for `request.formData()` with support for streaming file uploads 10 | - Built on the standard [web Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) and [File API](https://developer.mozilla.org/en-US/docs/Web/API/File) 11 | - Does not buffer any content, minimal memory usage 12 | - Automatically falls back to native `request.formData()` implementation for non-`multipart/form-data` requests 13 | 14 | ## The Problem 15 | 16 | The web fetch API's built-in [`request.formData()` method](https://developer.mozilla.org/en-US/docs/Web/API/Request/formData) is not a great fit for server environments because it doesn't provide a way to stream file uploads. This means that when you call `request.formData()` in a server environment on a request that was submitted by a `
`, any file uploads contained in the request are buffered in memory. For small files this may not be an issue, but it's a total non-starter for large files that exceed the server's memory capacity. 17 | 18 | `form-data-parser` fixes this issue by providing an API to handle streaming file data. 19 | 20 | ## Installation 21 | 22 | Install from [npm](https://www.npmjs.com/): 23 | 24 | ```sh 25 | npm install @mjackson/form-data-parser 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```ts 31 | import { LocalFileStorage } from "@mjackson/file-storage/local"; 32 | import { type FileUpload, parseFormData } from "@mjackson/form-data-parser"; 33 | 34 | const fileStorage = new LocalFileStorage("/uploads/user-avatars"); 35 | 36 | async function uploadHandler(fileUpload: FileUpload) { 37 | // Is this file upload from the field? 38 | if (fileUpload.fieldName === "user-avatar") { 39 | let storageKey = `user-${user.id}-avatar`; 40 | 41 | // FileUpload objects are not meant to stick around for very long (they are 42 | // streaming data from the request.body!) so we should store them as soon as 43 | // possible. 44 | await fileStorage.put(storageKey, fileUpload); 45 | 46 | // Return a File for the FormData object. This is a LazyFile that knows how 47 | // to access the file's content if needed (using e.g. file.stream()) but 48 | // waits until it is requested to actually read anything. 49 | return fileStorage.get(storageKey); 50 | } 51 | 52 | // Ignore any files we don't recognize the name of... 53 | } 54 | 55 | async function requestHandler(request: Request) { 56 | let formData = await parseFormData(request, uploadHandler); 57 | 58 | let file = formData.get("user-avatar"); // File (LazyFile) 59 | file.name; // "my-avatar.jpg" (name of the file on the user's computer) 60 | file.size; // number 61 | file.type; // "image/jpeg" 62 | } 63 | ``` 64 | 65 | ## Related Packages 66 | 67 | - [`multipart-parser`](https://github.com/mjackson/multipart-parser) - The parser used internally for parsing `multipart/form-data` HTTP messages 68 | - [`file-storage`](https://github.com/mjackson/file-storage) - A simple interface for storing `FileUpload` objects you get from the parser 69 | 70 | ## License 71 | 72 | See [LICENSE](https://github.com/mjackson/form-data-parser/blob/main/LICENSE) 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mjackson/form-data-parser", 3 | "version": "0.1.0", 4 | "description": "A request.formData() wrapper with pluggable file upload handling", 5 | "author": "Michael Jackson ", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/mjackson/form-data-parser.git" 9 | }, 10 | "license": "MIT", 11 | "files": [ 12 | "dist", 13 | "LICENSE", 14 | "README.md" 15 | ], 16 | "type": "module", 17 | "exports": { 18 | ".": "./dist/form-data-parser.js", 19 | "./package.json": "./package.json" 20 | }, 21 | "dependencies": { 22 | "@mjackson/multipart-parser": "^0.6.1" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^22.4.1", 26 | "prettier": "^3.3.3", 27 | "tsimp": "^2.0.11", 28 | "typescript": "^5.5.4" 29 | }, 30 | "scripts": { 31 | "clean": "git clean -fdX .", 32 | "build": "tsc --outDir dist --project tsconfig.lib.json", 33 | "test": "node --import tsimp/import --test ./src/**/*.spec.ts", 34 | "prepare": "pnpm run build", 35 | "version": "node scripts/version.js" 36 | }, 37 | "packageManager": "pnpm@9.7.1", 38 | "keywords": [ 39 | "form-data", 40 | "FormData", 41 | "multipart", 42 | "parser" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | '@mjackson/multipart-parser': 12 | specifier: ^0.6.1 13 | version: 0.6.1 14 | devDependencies: 15 | '@types/node': 16 | specifier: ^22.4.1 17 | version: 22.4.1 18 | prettier: 19 | specifier: ^3.3.3 20 | version: 3.3.3 21 | tsimp: 22 | specifier: ^2.0.11 23 | version: 2.0.11(typescript@5.5.4) 24 | typescript: 25 | specifier: ^5.5.4 26 | version: 5.5.4 27 | 28 | packages: 29 | 30 | '@isaacs/cached@1.0.1': 31 | resolution: {integrity: sha512-7kGcJ9Hc1f4qpTApWz3swxbF9Qv1NF/GxuPtXeTptbsgvJIoufSd0h854Nq/2bw80F5C1onsFgEI05l+q0e4vw==} 32 | 33 | '@isaacs/catcher@1.0.4': 34 | resolution: {integrity: sha512-g2klMwbnguClWNnCeQ1zYaDJsvPbIbnjdJPDE0z09MqoejJDZSLK5vIKiClq2Bkg5ubuI8vaN6wfIUi5GYzMVA==} 35 | 36 | '@isaacs/cliui@8.0.2': 37 | resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 38 | engines: {node: '>=12'} 39 | 40 | '@mjackson/headers@0.5.1': 41 | resolution: {integrity: sha512-sJpFgecPT/zJvwk3GRNVWNs8EkwaJoUNU2D0VMlp+gDJs6cuSTm1q/aCZi3ZtuV6CgDEQ4l2ZjUG3A9JrQlbNA==} 42 | 43 | '@mjackson/multipart-parser@0.6.1': 44 | resolution: {integrity: sha512-blI4HObze3ge1VQnSZSmhWv9tCm06Y23vVNBTffndhhmkdUpRsoIkw/3GXjcJXEOiciR3MjeTkBskJz6DMgspg==} 45 | 46 | '@pkgjs/parseargs@0.11.0': 47 | resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 48 | engines: {node: '>=14'} 49 | 50 | '@types/node@22.4.1': 51 | resolution: {integrity: sha512-1tbpb9325+gPnKK0dMm+/LMriX0vKxf6RnB0SZUqfyVkQ4fMgUSySqhxE/y8Jvs4NyF1yHzTfG9KlnkIODxPKg==} 52 | 53 | ansi-regex@5.0.1: 54 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 55 | engines: {node: '>=8'} 56 | 57 | ansi-regex@6.0.1: 58 | resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} 59 | engines: {node: '>=12'} 60 | 61 | ansi-styles@4.3.0: 62 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 63 | engines: {node: '>=8'} 64 | 65 | ansi-styles@6.2.1: 66 | resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} 67 | engines: {node: '>=12'} 68 | 69 | balanced-match@1.0.2: 70 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 71 | 72 | brace-expansion@2.0.1: 73 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 74 | 75 | color-convert@2.0.1: 76 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 77 | engines: {node: '>=7.0.0'} 78 | 79 | color-name@1.1.4: 80 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 81 | 82 | cross-spawn@7.0.3: 83 | resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} 84 | engines: {node: '>= 8'} 85 | 86 | eastasianwidth@0.2.0: 87 | resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} 88 | 89 | emoji-regex@8.0.0: 90 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 91 | 92 | emoji-regex@9.2.2: 93 | resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 94 | 95 | foreground-child@3.3.0: 96 | resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} 97 | engines: {node: '>=14'} 98 | 99 | glob@10.4.5: 100 | resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} 101 | hasBin: true 102 | 103 | is-fullwidth-code-point@3.0.0: 104 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 105 | engines: {node: '>=8'} 106 | 107 | isexe@2.0.0: 108 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 109 | 110 | jackspeak@3.4.3: 111 | resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 112 | 113 | lru-cache@10.4.3: 114 | resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 115 | 116 | minimatch@9.0.5: 117 | resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 118 | engines: {node: '>=16 || 14 >=14.17'} 119 | 120 | minipass@7.1.2: 121 | resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} 122 | engines: {node: '>=16 || 14 >=14.17'} 123 | 124 | mkdirp@3.0.1: 125 | resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} 126 | engines: {node: '>=10'} 127 | hasBin: true 128 | 129 | package-json-from-dist@1.0.0: 130 | resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} 131 | 132 | path-key@3.1.1: 133 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 134 | engines: {node: '>=8'} 135 | 136 | path-scurry@1.11.1: 137 | resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} 138 | engines: {node: '>=16 || 14 >=14.18'} 139 | 140 | pirates@4.0.6: 141 | resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} 142 | engines: {node: '>= 6'} 143 | 144 | prettier@3.3.3: 145 | resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} 146 | engines: {node: '>=14'} 147 | hasBin: true 148 | 149 | rimraf@5.0.10: 150 | resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} 151 | hasBin: true 152 | 153 | shebang-command@2.0.0: 154 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 155 | engines: {node: '>=8'} 156 | 157 | shebang-regex@3.0.0: 158 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 159 | engines: {node: '>=8'} 160 | 161 | signal-exit@4.1.0: 162 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 163 | engines: {node: '>=14'} 164 | 165 | sock-daemon@1.4.2: 166 | resolution: {integrity: sha512-IzbegWshWWR+UzQ7487mbdYNmfJ1jXUXQBUHooqtpylO+aW0vMVbFN2d2ug3CSPZ0wbG7ZTTGwpUuthIDFIOGg==} 167 | engines: {node: 16 >=16.17.0 || 18 >= 18.6.0 || >=20} 168 | 169 | socket-post-message@1.0.3: 170 | resolution: {integrity: sha512-UhJaB3xR2oF+HvddFOq2cBZi4zVKOHvdiBo+BaScNxsEUg3TLWSP8BkweKfe07kfH1thjn1hJR0af/w1EtBFjg==} 171 | 172 | string-width@4.2.3: 173 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 174 | engines: {node: '>=8'} 175 | 176 | string-width@5.1.2: 177 | resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} 178 | engines: {node: '>=12'} 179 | 180 | strip-ansi@6.0.1: 181 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 182 | engines: {node: '>=8'} 183 | 184 | strip-ansi@7.1.0: 185 | resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} 186 | engines: {node: '>=12'} 187 | 188 | tsimp@2.0.11: 189 | resolution: {integrity: sha512-wRhMmvar8tWHN3ZmykD8f4B4sjCn/f8DFM67LRY+stf/LPa2Kq8ATE2PIi570/DiDJA8kjjxzos3EgP0LmnFLA==} 190 | engines: {node: 16 >=16.17.0 || 18 >= 18.6.0 || >=20} 191 | hasBin: true 192 | peerDependencies: 193 | typescript: ^5.1.0 194 | 195 | typescript@5.5.4: 196 | resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} 197 | engines: {node: '>=14.17'} 198 | hasBin: true 199 | 200 | undici-types@6.19.8: 201 | resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 202 | 203 | walk-up-path@3.0.1: 204 | resolution: {integrity: sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA==} 205 | 206 | which@2.0.2: 207 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 208 | engines: {node: '>= 8'} 209 | hasBin: true 210 | 211 | wrap-ansi@7.0.0: 212 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 213 | engines: {node: '>=10'} 214 | 215 | wrap-ansi@8.1.0: 216 | resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} 217 | engines: {node: '>=12'} 218 | 219 | snapshots: 220 | 221 | '@isaacs/cached@1.0.1': 222 | dependencies: 223 | '@isaacs/catcher': 1.0.4 224 | 225 | '@isaacs/catcher@1.0.4': {} 226 | 227 | '@isaacs/cliui@8.0.2': 228 | dependencies: 229 | string-width: 5.1.2 230 | string-width-cjs: string-width@4.2.3 231 | strip-ansi: 7.1.0 232 | strip-ansi-cjs: strip-ansi@6.0.1 233 | wrap-ansi: 8.1.0 234 | wrap-ansi-cjs: wrap-ansi@7.0.0 235 | 236 | '@mjackson/headers@0.5.1': {} 237 | 238 | '@mjackson/multipart-parser@0.6.1': 239 | dependencies: 240 | '@mjackson/headers': 0.5.1 241 | 242 | '@pkgjs/parseargs@0.11.0': 243 | optional: true 244 | 245 | '@types/node@22.4.1': 246 | dependencies: 247 | undici-types: 6.19.8 248 | 249 | ansi-regex@5.0.1: {} 250 | 251 | ansi-regex@6.0.1: {} 252 | 253 | ansi-styles@4.3.0: 254 | dependencies: 255 | color-convert: 2.0.1 256 | 257 | ansi-styles@6.2.1: {} 258 | 259 | balanced-match@1.0.2: {} 260 | 261 | brace-expansion@2.0.1: 262 | dependencies: 263 | balanced-match: 1.0.2 264 | 265 | color-convert@2.0.1: 266 | dependencies: 267 | color-name: 1.1.4 268 | 269 | color-name@1.1.4: {} 270 | 271 | cross-spawn@7.0.3: 272 | dependencies: 273 | path-key: 3.1.1 274 | shebang-command: 2.0.0 275 | which: 2.0.2 276 | 277 | eastasianwidth@0.2.0: {} 278 | 279 | emoji-regex@8.0.0: {} 280 | 281 | emoji-regex@9.2.2: {} 282 | 283 | foreground-child@3.3.0: 284 | dependencies: 285 | cross-spawn: 7.0.3 286 | signal-exit: 4.1.0 287 | 288 | glob@10.4.5: 289 | dependencies: 290 | foreground-child: 3.3.0 291 | jackspeak: 3.4.3 292 | minimatch: 9.0.5 293 | minipass: 7.1.2 294 | package-json-from-dist: 1.0.0 295 | path-scurry: 1.11.1 296 | 297 | is-fullwidth-code-point@3.0.0: {} 298 | 299 | isexe@2.0.0: {} 300 | 301 | jackspeak@3.4.3: 302 | dependencies: 303 | '@isaacs/cliui': 8.0.2 304 | optionalDependencies: 305 | '@pkgjs/parseargs': 0.11.0 306 | 307 | lru-cache@10.4.3: {} 308 | 309 | minimatch@9.0.5: 310 | dependencies: 311 | brace-expansion: 2.0.1 312 | 313 | minipass@7.1.2: {} 314 | 315 | mkdirp@3.0.1: {} 316 | 317 | package-json-from-dist@1.0.0: {} 318 | 319 | path-key@3.1.1: {} 320 | 321 | path-scurry@1.11.1: 322 | dependencies: 323 | lru-cache: 10.4.3 324 | minipass: 7.1.2 325 | 326 | pirates@4.0.6: {} 327 | 328 | prettier@3.3.3: {} 329 | 330 | rimraf@5.0.10: 331 | dependencies: 332 | glob: 10.4.5 333 | 334 | shebang-command@2.0.0: 335 | dependencies: 336 | shebang-regex: 3.0.0 337 | 338 | shebang-regex@3.0.0: {} 339 | 340 | signal-exit@4.1.0: {} 341 | 342 | sock-daemon@1.4.2: 343 | dependencies: 344 | rimraf: 5.0.10 345 | signal-exit: 4.1.0 346 | socket-post-message: 1.0.3 347 | 348 | socket-post-message@1.0.3: {} 349 | 350 | string-width@4.2.3: 351 | dependencies: 352 | emoji-regex: 8.0.0 353 | is-fullwidth-code-point: 3.0.0 354 | strip-ansi: 6.0.1 355 | 356 | string-width@5.1.2: 357 | dependencies: 358 | eastasianwidth: 0.2.0 359 | emoji-regex: 9.2.2 360 | strip-ansi: 7.1.0 361 | 362 | strip-ansi@6.0.1: 363 | dependencies: 364 | ansi-regex: 5.0.1 365 | 366 | strip-ansi@7.1.0: 367 | dependencies: 368 | ansi-regex: 6.0.1 369 | 370 | tsimp@2.0.11(typescript@5.5.4): 371 | dependencies: 372 | '@isaacs/cached': 1.0.1 373 | '@isaacs/catcher': 1.0.4 374 | foreground-child: 3.3.0 375 | mkdirp: 3.0.1 376 | pirates: 4.0.6 377 | rimraf: 5.0.10 378 | signal-exit: 4.1.0 379 | sock-daemon: 1.4.2 380 | typescript: 5.5.4 381 | walk-up-path: 3.0.1 382 | 383 | typescript@5.5.4: {} 384 | 385 | undici-types@6.19.8: {} 386 | 387 | walk-up-path@3.0.1: {} 388 | 389 | which@2.0.2: 390 | dependencies: 391 | isexe: 2.0.0 392 | 393 | wrap-ansi@7.0.0: 394 | dependencies: 395 | ansi-styles: 4.3.0 396 | string-width: 4.2.3 397 | strip-ansi: 6.0.1 398 | 399 | wrap-ansi@8.1.0: 400 | dependencies: 401 | ansi-styles: 6.2.1 402 | string-width: 5.1.2 403 | strip-ansi: 7.1.0 404 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | import * as cp from "node:child_process"; 2 | import * as fs from "node:fs"; 3 | import * as path from "node:path"; 4 | import * as process from "node:process"; 5 | 6 | const __dirname = path.dirname(new URL(import.meta.url).pathname); 7 | const packageVersion = process.env.npm_package_version; 8 | 9 | // Update CHANGELOG.md with the current version 10 | let changelogPath = path.resolve(__dirname, "../CHANGELOG.md"); 11 | let changelog = fs.readFileSync(changelogPath, "utf-8"); 12 | let match = /^## HEAD\n/m.exec(changelog); 13 | if (match) { 14 | let today = new Date().toLocaleDateString("en-US", { 15 | month: "short", 16 | day: "numeric", 17 | year: "numeric" 18 | }); 19 | 20 | changelog = 21 | changelog.slice(0, match.index) + 22 | `## v${packageVersion} (${today})\n` + 23 | changelog.slice(match.index + match[0].length); 24 | 25 | fs.writeFileSync(changelogPath, changelog); 26 | cp.execSync("git add CHANGELOG.md"); 27 | } else { 28 | console.error('Could not find "## HEAD" in CHANGELOG.md'); 29 | process.exit(1); 30 | } 31 | -------------------------------------------------------------------------------- /src/form-data-parser.ts: -------------------------------------------------------------------------------- 1 | export { type FileUploadHandler, parseFormData } from "./lib/form-data.js"; 2 | -------------------------------------------------------------------------------- /src/lib/form-data.spec.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert/strict"; 2 | import { describe, it, mock } from "node:test"; 3 | 4 | import { type FileUploadHandler, parseFormData } from "./form-data.js"; 5 | 6 | describe("parseFormData", () => { 7 | it("parses a application/x-www-form-urlencoded request", async () => { 8 | let request = new Request("http://localhost:8080", { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/x-www-form-urlencoded" 12 | }, 13 | body: "text=Hello%2C%20World!" 14 | }); 15 | 16 | let formData = await parseFormData(request); 17 | 18 | assert.equal(formData.get("text"), "Hello, World!"); 19 | }); 20 | 21 | it("parses a multipart/form-data request", async () => { 22 | let request = new Request("http://localhost:8080", { 23 | method: "POST", 24 | headers: { 25 | "Content-Type": 26 | "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW" 27 | }, 28 | body: [ 29 | "------WebKitFormBoundary7MA4YWxkTrZu0gW", 30 | 'Content-Disposition: form-data; name="text"', 31 | "", 32 | "Hello, World!", 33 | "------WebKitFormBoundary7MA4YWxkTrZu0gW", 34 | 'Content-Disposition: form-data; name="file"; filename="example.txt"', 35 | "Content-Type: text/plain", 36 | "", 37 | "This is an example file.", 38 | "------WebKitFormBoundary7MA4YWxkTrZu0gW--" 39 | ].join("\r\n") 40 | }); 41 | 42 | let formData = await parseFormData(request); 43 | 44 | assert.equal(formData.get("text"), "Hello, World!"); 45 | 46 | let file = formData.get("file"); 47 | assert.ok(file instanceof File); 48 | assert.equal(file.name, "example.txt"); 49 | assert.equal(file.type, "text/plain"); 50 | assert.equal(await file.text(), "This is an example file."); 51 | }); 52 | 53 | it("calls the file upload handler for each file part", async () => { 54 | let request = new Request("http://localhost:8080", { 55 | method: "POST", 56 | headers: { 57 | "Content-Type": 58 | "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW" 59 | }, 60 | body: [ 61 | "------WebKitFormBoundary7MA4YWxkTrZu0gW", 62 | 'Content-Disposition: form-data; name="file1"; filename="example.txt"', 63 | "Content-Type: text/plain", 64 | "", 65 | "This is an example file.", 66 | "------WebKitFormBoundary7MA4YWxkTrZu0gW", 67 | 'Content-Disposition: form-data; name="file2"; filename="example.txt"', 68 | "Content-Type: text/plain", 69 | "", 70 | "This is another example file.", 71 | "------WebKitFormBoundary7MA4YWxkTrZu0gW--" 72 | ].join("\r\n") 73 | }); 74 | 75 | let fileUploadHandler = mock.fn(); 76 | 77 | await parseFormData(request, fileUploadHandler); 78 | 79 | assert.equal(fileUploadHandler.mock.calls.length, 2); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/lib/form-data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isMultipartRequest, 3 | parseMultipartRequest, 4 | MultipartPart 5 | } from "@mjackson/multipart-parser"; 6 | 7 | /** 8 | * A `File` that was uploaded as part of a `multipart/form-data` request. 9 | * 10 | * This object is intended to be used as an intermediary for handling file uploads. The file should 11 | * be saved to disk or a cloud storage service as quickly as possible to avoid buffering and 12 | * backpressure building up in the input stream. 13 | * 14 | * Note: Although this is a `File` object its `size` is unknown, so any attempt to access 15 | * `file.size` or use `file.slice()` will throw an error. 16 | */ 17 | export class FileUpload extends File { 18 | #part: MultipartPart; 19 | 20 | constructor(part: MultipartPart) { 21 | super([], part.filename ?? "", { type: part.mediaType }); 22 | this.#part = part; 23 | } 24 | 25 | arrayBuffer(): Promise { 26 | return this.#part.arrayBuffer(); 27 | } 28 | 29 | bytes(): Promise { 30 | return this.#part.bytes(); 31 | } 32 | 33 | /** 34 | * The name of the field used to upload the file. 35 | */ 36 | get fieldName(): string | undefined { 37 | return this.#part.name; 38 | } 39 | 40 | get size(): number { 41 | throw new Error( 42 | "Cannot get the size of a file upload without buffering the entire file" 43 | ); 44 | } 45 | 46 | slice(): Blob { 47 | throw new Error( 48 | "Cannot slice a file upload without buffering the entire file" 49 | ); 50 | } 51 | 52 | stream(): ReadableStream { 53 | return this.#part.body; 54 | } 55 | 56 | text(): Promise { 57 | return this.#part.text(); 58 | } 59 | } 60 | 61 | /** 62 | * A function used for handling file uploads. 63 | */ 64 | export interface FileUploadHandler { 65 | (file: FileUpload): File | void | Promise; 66 | } 67 | 68 | async function defaultFileUploadHandler(file: FileUpload): Promise { 69 | // Do the slow thing and buffer the entire file in memory. 70 | let buffer = await file.arrayBuffer(); 71 | return new File([buffer], file.name, { type: file.type }); 72 | } 73 | 74 | /** 75 | * Parses a `Request` body into a `FormData` object. This is useful for accessing the data contained 76 | * in a HTTP `POST` request generated by a HTML `` element. 77 | * 78 | * The major difference between this function and using the built-in `request.formData()` API is the 79 | * ability to customize the handling of file uploads. Instead of buffering the entire file in memory, 80 | * the `uploadHandler` allows you to store the file on disk or in a cloud storage service. 81 | */ 82 | export async function parseFormData( 83 | request: Request, 84 | uploadHandler: FileUploadHandler = defaultFileUploadHandler 85 | ): Promise { 86 | if (isMultipartRequest(request)) { 87 | let formData = new FormData(); 88 | 89 | for await (let part of parseMultipartRequest(request)) { 90 | if (!part.name) continue; 91 | 92 | if (part.isFile) { 93 | let file = await uploadHandler(new FileUpload(part)); 94 | if (file) { 95 | formData.append(part.name, file); 96 | } 97 | } else { 98 | formData.append(part.name, await part.text()); 99 | } 100 | } 101 | 102 | return formData; 103 | } 104 | 105 | return request.formData(); 106 | } 107 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "ES2020"], 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "strict": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "rootDir": "src" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.spec.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | --------------------------------------------------------------------------------