diff --git a/.github/.kodiak.toml b/.github/____.kodiak.toml similarity index 96% rename from .github/.kodiak.toml rename to .github/____.kodiak.toml index 81215362..5ce98679 100644 --- a/.github/.kodiak.toml +++ b/.github/____.kodiak.toml @@ -2,8 +2,8 @@ # Minimal config. version is the only required field. version = 1 -[merge] -automerge_label = "ship it" +[merge] +automerge_label = "automerge" require_automerge_label = true block_on_neutral_required_check_runs = true blocking_labels = ["wip", "do not merge"] diff --git a/.github/____dependabot.yml b/.github/____dependabot.yml new file mode 100644 index 00000000..c1e3cb41 --- /dev/null +++ b/.github/____dependabot.yml @@ -0,0 +1,21 @@ +# Versions and updates, dependabot.yml + +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + + # Maintain dependencies for npm + - package-ecosystem: npm + directory: / + schedule: + interval: daily + labels: [automerge] + allow: + # Allow both direct and indirect updates for all packages + - dependency-type: all + rebase-strategy: auto + # reviewers: [] diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 38a96784..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Versions and updates, dependabot.yml - -version: 2 -updates: - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - # Maintain dependencies for npm - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - time: "02:00" - # No one cares about dependency labels, they should be auto-merged - labels: [] - allow: - # Allow both direct and indirect updates for all packages - - dependency-type: "all" - rebase-strategy: "auto" - # reviewers: [] diff --git a/.github/workflows/____deps-automation.yml b/.github/workflows/____deps-automation.yml new file mode 100644 index 00000000..c9e1efdb --- /dev/null +++ b/.github/workflows/____deps-automation.yml @@ -0,0 +1,32 @@ +name: Dependabot Automation +on: pull_request + +permissions: + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + # ignored job because the `xxxxx` typo. Intentional, we use Kodiak + if: + github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == + 'node-formidable/xxxx' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + - name: Approve a PR + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Enable auto-merge for Dependabot PRs + if: + contains(steps.metadata.outputs.dependency-names, 'my-dependency') && + steps.metadata.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index 29b5eb3f..668de551 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,85 @@ -# Ignore everything! -* -*~* +.wrangler +*.tsbuildinfo +*.*cache +.cache +.turbo +# Vercel +.vercel -# de-ignores: add here what you want to be committed -!logo.png -!logo.jpg -!test-legacy -!tool +# Build Outputs +.next/ +out/ +build +dist -!*.*js* -!*.ts* -!*.md* -!.*rc -!.*ignore -!LICENSE -!.editorconfig +# Package managers lockfiles +#package-lock.json +shrinkwrap.json +pnpm-lock.json -!package.json -!yarn.lock -!pnpm-lock.yaml +# Logs +logs +*.log +*~ -!**/src -!**/src/** +# Runtime data +pids +*.pid +*.seed +*.pid.lock -!**/test -!**/test/** -!**/test-node -!**/test-node/** +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov -!**/*tests* -!**/*tests*/** +# Coverage directory used by tools like istanbul +coverage -!**/.github -!**/.github/** +# nyc test coverage +.nyc_output -!**/example* -!**/example*/** +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt -!**/benchmark* -!**/benchmark*/** +# Bower dependency directory (https://bower.io/) +bower_components -# re-ignores: add here what you want to be ignored again -test/tmp +# node-waf configuration +.lock-wscript -# !src/*.js -# !src/*.ts -# !test -# !test/*.js -# !test/*.ts -# !test/**/*.js -# !test/**/*.ts -# !*/__tests__ +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release +# Dependency directories +node_modules/ +jspm_packages/ -# *.tsbuildinfo -# .*cache -# *.cache +# TypeScript v1 declaration files +typings/ -# test/tmp -# *.upload -# *.un~ +# Optional npm cache directory +.npm -# # Build environment -# dist +# Optional eslint cache +.eslintcache -# # Package managers lockfiles -# package-lock.json -# shrinkwrap.json +# Optional REPL history +.node_repl_history -# # Logs -# logs -# *.log -# *~ +# Output of 'npm pack' +*.tgz -# # Runtime data -# pids -# *.pid -# *.seed -# *.pid.lock +# Yarn Integrity file +.yarn-integrity -# # Directory for instrumented libs generated by jscoverage/JSCover -# lib-cov +# dotenv environment variables file +.env +.env* +!.env.example -# # Coverage directory used by tools like istanbul -# /coverage +# next.js build output +.next -# # nyc test coverage -# .nyc_output +.astro -# # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -# .grunt - -# # Bower dependency directory (https://bower.io/) -# bower_components - -# # node-waf configuration -# .lock-wscript - -# # Compiled binary addons (https://nodejs.org/api/addons.html) -# build/Release - -# # Dependency directories -# node_modules/ -# jspm_packages/ - -# # TypeScript v1 declaration files -# typings/ - -# # Optional npm cache directory -# .npm - -# # Optional eslint cache -# .eslintcache - -# # Optional REPL history -# .node_repl_history - -# # Output of 'npm pack' -# *.tgz - -# # Yarn Integrity file -# .yarn-integrity - -# # dotenv environment variables file -# .env - -# # next.js build output -# .next -benchmark/testuploads/ +# Local Netlify folder +.netlify diff --git a/.prettierignore b/.prettierignore index e0d0cdf8..1e6a769c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,135 +1,96 @@ -# Ignore everything! -* - -# de-ignores: add here what you want to be committed - -!*.*js* -!*.ts* -!*.md* -!*.y*ml - -!**/src -!**/src/** - -!**/test -!**/test/** - -!**/*tests* -!**/*tests*/** - -!**/.github -!**/.github/** - -!**/benchmark* -!**/benchmark*/**/*.js - -!**/example* -!**/example*/** - -# re-ignores: add here what you want to be ignored again -test/tmp - -test/fixture/file -test/fixture/http - -*.upload - -CHANGELOG.md - -# CHANGELOG.md -# LICENSE* -# dist - -# test/tmp -# test/fixture/http -# test/fixture/file -# test/fixture/multi* -# test/tools - -# # fixtures -# # __fixture__ -# # __fixtures__ -# *.map -# *.lock -# *.js.snap -# coverage -# *.ico -# *.png -# *.svg -# *.jpeg -# *.jpg - -# !.all-contributorsrc -# !.*rc.js -# !.verb*.md -# patches -# **/static/**/*.css - -# *.tsbuildinfo -# .*cache -# *.cache - -# # Package managers lockfiles -# package-lock.json -# shrinkwrap.json -# pnpm-lock.json - -# # Logs -# logs -# *.log -# *~ - -# # Runtime data -# pids -# *.pid -# *.seed -# *.pid.lock - -# # Directory for instrumented libs generated by jscoverage/JSCover -# lib-cov - -# # Coverage directory used by tools like istanbul -# coverage - -# # nyc test coverage -# .nyc_output - -# # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -# .grunt - -# # Bower dependency directory (https://bower.io/) -# bower_components - -# # node-waf configuration -# .lock-wscript - -# # Compiled binary addons (https://nodejs.org/api/addons.html) -# build/Release - -# # Dependency directories -# node_modules/ -# jspm_packages/ - -# # TypeScript v1 declaration files -# typings/ - -# # Optional npm cache directory -# .npm - -# # Optional eslint cache -# .eslintcache - -# # Optional REPL history -# .node_repl_history - -# # Output of 'npm pack' -# *.tgz - -# # Yarn Integrity file -# .yarn-integrity - -# # dotenv environment variables file -# .env - -# # next.js build output -# .next +old-source/ +packages/formidable-next/src/super-headers.js +packages/formidable-next/src/super-readable-stream.js + +**/super-headers.js +**/CHANGELOG.md +**/fixture/** +**/fixtures/** + +# because we use ESLint for most things - both linting and formatting (with ESLint Stylistic) +**/*.{js,jsx,ts,tsx} +**/tsconfig*.json +**/tsconfig.*.json +**/package.json + +.wrangler +*.tsbuildinfo +*.*cache +.cache +.turbo +# Vercel +.vercel + +.vscode +# Build Outputs +.next/ +out/ +build +dist + +# Package managers lockfiles +package-lock.json +shrinkwrap.json +pnpm-lock.json +bun.lock + +# Logs +logs +*.log +*~ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env* +!.env.example + +.astro diff --git a/.tidelift.yml b/.tidelift.yml deleted file mode 100644 index 0689859b..00000000 --- a/.tidelift.yml +++ /dev/null @@ -1,13 +0,0 @@ -exceptions: - - name: dot-prop - platform: npm - tests: - - vulnerable - - name: utest - platform: npm - tests: - - unlicensed - - name: only - platform: npm - tests: - - unlicensed diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..d2f30039 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,271 @@ +{ + "[astro]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[html]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[json5]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[jsonc]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[toml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[yaml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[yml]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "chat.mcp.discovery.enabled": true, + "editor.acceptSuggestionOnEnter": "on", + "editor.autoClosingBrackets": "always", + "editor.codeActions.triggerOnFocusChange": true, + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.fixAll.eslint": "always", + "source.fixAll.prettier": "always" + }, + "editor.codeLens": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.detectIndentation": false, + "editor.folding": true, + "editor.fontFamily": "Fira Code", + "editor.fontLigatures": true, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.formatOnType": false, + "editor.inlineSuggest.enabled": true, + "editor.multiCursorModifier": "ctrlCmd", + "editor.rulers": [ + 80, + 100, + 120 + ], + "editor.tabSize": 2, + "editor.wordWrap": "off", + "emmet.showExpandedAbbreviation": "never", + "errorLens.enabledDiagnosticLevels": [ + "error" + // "warning" + ], + "errorLens.messageBackgroundMode": "none", + "errorLens.onSave": true, + "eslint.codeAction.showDocumentation": { + "enable": true + }, + "eslint.enable": true, + "eslint.format.enable": true, + "eslint.rules.customizations": [ + { + "rule": "style/*", + "severity": "off" + }, + { + "rule": "*-indent", + "severity": "off" + }, + { + "rule": "*-spacing", + "severity": "off" + }, + { + "rule": "*-spaces", + "severity": "off" + }, + { + "rule": "*-order", + "severity": "off" + }, + { + "rule": "*-dangle", + "severity": "off" + }, + { + "rule": "*-newline", + "severity": "off" + }, + { + "rule": "*quotes", + "severity": "off" + }, + { + "rule": "*semi", + "severity": "off" + } + ], + "eslint.run": "onSave", + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "json5", + "jsonc", + "yaml", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + "explorer.compactFolders": false, + "explorer.confirmDelete": false, + "extensions.ignoreRecommendations": true, + "files.associations": { + ".npmrc": "ini", + "*.mdx": "mdx", + "*.njk": "html", + "*.postcss": "tailwindcss", + "*rc": "yaml" + }, + "files.autoSave": "onFocusChange", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "git.openRepositoryInParentFolders": "never", + "github.copilot.enable": { + "*": true, + "markdown": true, + "plaintext": false + }, + "github.copilot.nextEditSuggestions.enabled": true, + "mcp": { + "servers": { + "coincap-mcp": { + "args": [ + "coincap-mcp" + ], + "command": "bunx", + "type": "stdio" + }, + "context7": { + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ], + "command": "bunx", + "type": "stdio" + }, + "openapi-mcp-slop": { + "args": [ + "openapi-mcp-server@latest", + "run" + ], + "command": "bunx", + "type": "stdio" + }, + "tckdev-oss-repo-docs": { + "type": "sse", + "url": "https://gitmcp.io/tunnckoCore/opensource" + } + } + }, + "npm-intellisense.importES6": true, + "npm-intellisense.importLinebreak": ";\r\n", + "npm-intellisense.importQuotes": "'", + "npm.packageManager": "auto", + "prettier.enable": false, + "prettier.printWidth": 200, + "prettier.semi": true, + "prettier.singleQuote": true, + "search.exclude": { + "**/.git": true, + "**/.github": false, + "**/.nuxt": true, + "**/.output": true, + "**/.pnpm": true, + "**/.vscode": true, + "**/.yarn": true, + "**/*.snap": true, + "**/*.svg": true, + "**/assets": true, + "**/bower_components": true, + "**/CHANGELOG*": true, + "**/dist/**": true, + "**/LICENSE*": true, + "**/logs": true, + "**/node_modules": true, + "**/out/**": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/public": true, + "**/temp": true, + "**/yarn.lock": true + }, + "solidity.telemetry": false, + "svelte.enable-ts-plugin": true, + "terminal.integrated.fontFamily": "monospace", + "typescript.updateImportsOnFileMove.enabled": "always", + "update.mode": "start", + "window.menuBarVisibility": "toggle", + "workbench.colorCustomizations": { + "terminal.ansiBlack": "#282a36", + "terminal.ansiBlue": "#57c7ff", + "terminal.ansiBrightBlack": "#686868", + "terminal.ansiBrightBlue": "#57c7ff", + "terminal.ansiBrightCyan": "#9aedfe", + "terminal.ansiBrightGreen": "#5af78e", + "terminal.ansiBrightMagenta": "#ff6ac1", + "terminal.ansiBrightRed": "#ff5c57", + "terminal.ansiBrightWhite": "#eff0eb", + "terminal.ansiBrightYellow": "#f3f99d", + "terminal.ansiCyan": "#9aedfe", + "terminal.ansiGreen": "#5af78e", + "terminal.ansiMagenta": "#ff6ac1", + "terminal.ansiRed": "#ff5c57", + "terminal.ansiWhite": "#f1f1f0", + "terminal.ansiYellow": "#f3f99d", + "terminal.foreground": "#eff0eb" + }, + "workbench.colorTheme": "Catppuccin Macchiato", + "workbench.iconTheme": "material-icon-theme", + "workbench.sideBar.location": "right", + "workbench.startupEditor": "none", + "workbench.statusBar.visible": true, + "zenMode.hideActivityBar": false +} diff --git a/README.md b/README.md index a3990fd5..d53d3525 100644 --- a/README.md +++ b/README.md @@ -1,891 +1,3 @@ -

- npm formidable package logo -

+# formidable monorepo -# formidable [![npm version][npmv-img]][npmv-url] [![MIT license][license-img]][license-url] [![Libera Manifesto][libera-manifesto-img]][libera-manifesto-url] [![Twitter][twitter-img]][twitter-url] - -> A Node.js module for parsing form data, especially file uploads. - -[![Code style][codestyle-img]][codestyle-url] -[![linux build status][linux-build-img]][build-url] -[![macos build status][macos-build-img]][build-url] - - -If you have any _how-to_ kind of questions, please read the [Contributing -Guide][contributing-url] and [Code of Conduct][code_of_conduct-url] -documents.
For bugs reports and feature requests, [please create an -issue][open-issue-url] or ping [@wgw_eth / @wgw_lol][twitter-url] -at Twitter. - -[![Conventional Commits][ccommits-img]][ccommits-url] -[![Minimum Required Nodejs][nodejs-img]][npmv-url] -[![Buy me a Kofi][kofi-img]][kofi-url] -[![Make A Pull Request][prs-welcome-img]][prs-welcome-url] - - - -This project is [semantically versioned](https://semver.org) and if you want support in migrating between versions you can schedule us for training or support us through donations, so we can prioritize. - -> [!CAUTION] -> As of April 2025, old versions like v1 and v2 are still the most used, while they are deperecated for years -- they are also vulnerable to attacks if you are not implementing it properly. **Please upgrade!** We are here to help, and AI Editors & Agents could help a lot in such codemod-like migrations. - -> [!TIP] -> If you are starting a fresh project, you can check out the `formidable-mini` which is a super minimal version of Formidable (not quite configurable yet, but when it does it could become the basis for `formidable@v4`), using web standards like FormData API and File API, and you can use it to stream uploads directly to S3 or other such services. - - - -[![][npm-weekly-img]][npmv-url] [![][npm-monthly-img]][npmv-url] -[![][npm-yearly-img]][npmv-url] [![][npm-alltime-img]][npmv-url] - -## Project Status: Maintained - -> [!NOTE] -> Check [VERSION NOTES](https://github.com/node-formidable/formidable/blob/master/VERSION_NOTES.md) for more information on v1, v2, and v3 plans, NPM dist-tags and branches._ - -This module was initially developed by -[**@felixge**](https://github.com/felixge) for -[Transloadit](http://transloadit.com/), a service focused on uploading and -encoding images and videos. It has been battle-tested against hundreds of GBs of -file uploads from a large variety of clients and is considered production-ready -and is used in production for years. - -Currently, we are few maintainers trying to deal with it. :) More contributors -are always welcome! :heart: Jump on -[issue #412](https://github.com/felixge/node-formidable/issues/412) which is -closed, but if you are interested we can discuss it and add you after strict -rules, like enabling Two-Factor Auth in your npm and GitHub accounts. - -## Highlights - -- [Fast (~900-2500 mb/sec)](#benchmarks) & streaming multipart parser -- Automatically writing file uploads to disk (optional, see - [`options.fileWriteStreamHandler`](#options)) -- [Plugins API](#useplugin-plugin) - allowing custom parsers and plugins -- Low memory footprint -- Graceful error handling -- Very high test coverage - -## Install - -This package is a dual ESM/commonjs package. - -> [!NOTE] -> This project requires `Node.js >= 20`. Install it using [yarn](https://yarnpkg.com) or [npm](https://npmjs.com).
_We highly recommend to use Yarn when you think to contribute to this project._ - -This is a low-level package, and if you're using a high-level framework it _may_ -already be included. Check the examples below and the [examples/](https://github.com/node-formidable/formidable/tree/master/examples) folder. - -``` -# v2 -npm install formidable@v2 - -# v3 -npm install formidable -npm install formidable@v3 -``` - -_**Note:** Future not ready releases will be published on `*-next` dist-tags for the corresponding version._ - - -## Examples - -For more examples look at the `examples/` directory. - -### with Node.js http module - -Parse an incoming file upload, with the -[Node.js's built-in `http` module](https://nodejs.org/api/http.html). - -```js -import http from 'node:http'; -import formidable, {errors as formidableErrors} from 'formidable'; - -const server = http.createServer(async (req, res) => { - if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') { - // parse a file upload - const form = formidable({}); - let fields; - let files; - try { - [fields, files] = await form.parse(req); - } catch (err) { - // example to check for a very specific error - if (err.code === formidableErrors.maxFieldsExceeded) { - - } - console.error(err); - res.writeHead(err.httpCode || 400, { 'Content-Type': 'text/plain' }); - res.end(String(err)); - return; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ fields, files }, null, 2)); - return; - } - - // show a file upload form - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` -

With Node.js "http" module

-
-
Text field title:
-
File:
- -
- `); -}); - -server.listen(8080, () => { - console.log('Server listening on http://localhost:8080/ ...'); -}); -``` - -### with Express.js - -There are multiple variants to do this, but Formidable just need Node.js Request -stream, so something like the following example should work just fine, without -any third-party [Express.js](https://ghub.now.sh/express) middleware. - -Or try the -[examples/with-express.js](https://github.com/node-formidable/formidable/blob/master/examples/with-express.js) - -```js -import express from 'express'; -import formidable from 'formidable'; - -const app = express(); - -app.get('/', (req, res) => { - res.send(` -

With "express" npm package

-
-
Text field title:
-
File:
- -
- `); -}); - -app.post('/api/upload', (req, res, next) => { - const form = formidable({}); - - form.parse(req, (err, fields, files) => { - if (err) { - next(err); - return; - } - res.json({ fields, files }); - }); -}); - -app.listen(3000, () => { - console.log('Server listening on http://localhost:3000 ...'); -}); -``` - -### with Koa and Formidable - -Of course, with [Koa v1, v2 or future v3](https://ghub.now.sh/koa) the things -are very similar. You can use `formidable` manually as shown below or through -the [koa-better-body](https://ghub.now.sh/koa-better-body) package which is -using `formidable` under the hood and support more features and different -request bodies, check its documentation for more info. - -_Note: this example is assuming Koa v2. Be aware that you should pass `ctx.req` -which is Node.js's Request, and **NOT** the `ctx.request` which is Koa's Request -object - there is a difference._ - -```js -import Koa from 'Koa'; -import formidable from 'formidable'; - -const app = new Koa(); - -app.on('error', (err) => { - console.error('server error', err); -}); - -app.use(async (ctx, next) => { - if (ctx.url === '/api/upload' && ctx.method.toLowerCase() === 'post') { - const form = formidable({}); - - // not very elegant, but that's for now if you don't want to use `koa-better-body` - // or other middlewares. - await new Promise((resolve, reject) => { - form.parse(ctx.req, (err, fields, files) => { - if (err) { - reject(err); - return; - } - - ctx.set('Content-Type', 'application/json'); - ctx.status = 200; - ctx.state = { fields, files }; - ctx.body = JSON.stringify(ctx.state, null, 2); - resolve(); - }); - }); - await next(); - return; - } - - // show a file upload form - ctx.set('Content-Type', 'text/html'); - ctx.status = 200; - ctx.body = ` -

With "koa" npm package

-
-
Text field title:
-
File:
- -
- `; -}); - -app.use((ctx) => { - console.log('The next middleware is called'); - console.log('Results:', ctx.state); -}); - -app.listen(3000, () => { - console.log('Server listening on http://localhost:3000 ...'); -}); -``` - -## Benchmarks - -The benchmark is quite old, from the old codebase. But maybe quite true though. -Previously the numbers was around ~500 mb/sec. Currently with moving to the new -Node.js Streams API it's faster. You can clearly see the differences between the -Node versions. - -_Note: a lot better benchmarking could and should be done in future._ - -Benchmarked on 8GB RAM, Xeon X3440 (2.53 GHz, 4 cores, 8 threads) - -``` -~/github/node-formidable master -❯ nve --parallel 8 10 12 13 node benchmark/bench-multipart-parser.js - - ⬢ Node 8 - -1261.08 mb/sec - - ⬢ Node 10 - -1113.04 mb/sec - - ⬢ Node 12 - -2107.00 mb/sec - - ⬢ Node 13 - -2566.42 mb/sec -``` - -![benchmark January 29th, 2020](./benchmark/2020-01-29_xeon-x3440.png) - -## API - -### Formidable / IncomingForm - -All shown are equivalent. - -_Please pass [`options`](#options) to the function/constructor, not by assigning -them to the instance `form`_ - -```js -import formidable from 'formidable'; -const form = formidable(options); -``` - -### Options - -See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js) -(the `DEFAULT_OPTIONS` constant). - -- `options.encoding` **{string}** - default `'utf-8'`; sets encoding for - incoming form fields, -- `options.uploadDir` **{string}** - default `os.tmpdir()`; the directory for - placing file uploads in. You can move them later by using `fs.rename()`. -- `options.keepExtensions` **{boolean}** - default `false`; to include the - extensions of the original files or not -- `options.allowEmptyFiles` **{boolean}** - default `false`; allow upload empty - files -- `options.minFileSize` **{number}** - default `1` (1byte); the minium size of - uploaded file. -- `options.maxFiles` **{number}** - default `Infinity`; - limit the amount of uploaded files, set Infinity for unlimited -- `options.maxFileSize` **{number}** - default `200 * 1024 * 1024` (200mb); - limit the size of each uploaded file. -- `options.maxTotalFileSize` **{number}** - default `options.maxFileSize`; - limit the size of the batch of uploaded files. -- `options.maxFields` **{number}** - default `1000`; limit the number of fields, set Infinity for unlimited -- `options.maxFieldsSize` **{number}** - default `20 * 1024 * 1024` (20mb); - limit the amount of memory all fields together (except files) can allocate in - bytes. -- `options.hashAlgorithm` **{string | false}** - default `false`; include checksums calculated - for incoming files, set this to some hash algorithm, see - [crypto.createHash](https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_options) - for available algorithms -- `options.fileWriteStreamHandler` **{function}** - default `null`, which by - default writes to host machine file system every file parsed; The function - should return an instance of a - [Writable stream](https://nodejs.org/api/stream.html#stream_class_stream_writable) - that will receive the uploaded file data. With this option, you can have any - custom behavior regarding where the uploaded file data will be streamed for. - If you are looking to write the file uploaded in other types of cloud storages - (AWS S3, Azure blob storage, Google cloud storage) or private file storage, - this is the option you're looking for. When this option is defined the default - behavior of writing the file in the host machine file system is lost. -- `options.filename` **{function}** - default `undefined` Use it to control - newFilename. Must return a string. Will be joined with options.uploadDir. - -- `options.filter` **{function}** - default function that always returns true. - Use it to filter files before they are uploaded. Must return a boolean. Will not make the form.parse error - -- `options.createDirsFromUploads` **{boolean}** - default false. If true, makes direct folder uploads possible. Use `` to create a form to upload folders. Has to be used with the options `options.uploadDir` and `options.filename` where `options.filename` has to return a string with the character `/` for folders to be created. The base will be `options.uploadDir`. - - -#### `options.filename` **{function}** function (name, ext, part, form) -> string - -where part can be decomposed as - -```js -const { originalFilename, mimetype} = part; -``` - -_**Note:** If this size of combined fields, or size of some file is exceeded, an -`'error'` event is fired._ - -```js -// The amount of bytes received for this form so far. -form.bytesReceived; -``` - -```js -// The expected number of bytes in this form. -form.bytesExpected; -``` - -#### `options.filter` **{function}** function ({name, originalFilename, mimetype}) -> boolean - -Behaves like Array.filter: Returning false will simply ignore the file and go to the next. - -```js -const options = { - filter: function ({name, originalFilename, mimetype}) { - // keep only images - return mimetype && mimetype.includes("image"); - } -}; -``` - -**Note:** use an outside variable to cancel all uploads upon the first error - -**Note:** use form.emit('error') to make form.parse error - -```js -let cancelUploads = false;// create variable at the same scope as form -const options = { - filter: function ({name, originalFilename, mimetype}) { - // keep only images - const valid = mimetype && mimetype.includes("image"); - if (!valid) { - form.emit('error', new formidableErrors.default('invalid type', 0, 400)); // optional make form.parse error - cancelUploads = true; //variable to make filter return false after the first problem - } - return valid && !cancelUploads; - } -}; -``` - - -### .parse(request, ?callback) - -Parses an incoming Node.js `request` containing form data. If `callback` is not provided a promise is returned. - -```js -const form = formidable({ uploadDir: __dirname }); - -form.parse(req, (err, fields, files) => { - console.log('fields:', fields); - console.log('files:', files); -}); - -// with Promise -const [fields, files] = await form.parse(req); -``` - -You may overwrite this method if you are interested in directly accessing the -multipart stream. Doing so will disable any `'field'` / `'file'` events -processing which would occur otherwise, making you fully responsible for -handling the processing. - -About `uploadDir`, given the following directory structure -``` -project-name -├── src -│ └── server.js -│ -└── uploads - └── image.jpg -``` - -`__dirname` would be the same directory as the source file itself (src) - - -```js - `${__dirname}/../uploads` -``` - -to put files in uploads. - -Omitting `__dirname` would make the path relative to the current working directory. This would be the same if server.js is launched from src but not project-name. - - -`null` will use default which is `os.tmpdir()` - -Note: If the directory does not exist, the uploaded files are __silently discarded__. To make sure it exists: - -```js -import {createNecessaryDirectoriesSync} from "filesac"; - - -const uploadPath = `${__dirname}/../uploads`; -createNecessaryDirectoriesSync(`${uploadPath}/x`); -``` - - -In the example below, we listen on couple of events and direct them to the -`data` listener, so you can do whatever you choose there, based on whether its -before the file been emitted, the header value, the header name, on field, on -file and etc. - -Or the other way could be to just override the `form.onPart` as it's shown a bit -later. - -```js -form.once('error', console.error); - -form.on('fileBegin', (formname, file) => { - form.emit('data', { name: 'fileBegin', formname, value: file }); -}); - -form.on('file', (formname, file) => { - form.emit('data', { name: 'file', formname, value: file }); -}); - -form.on('field', (fieldName, fieldValue) => { - form.emit('data', { name: 'field', key: fieldName, value: fieldValue }); -}); - -form.once('end', () => { - console.log('Done!'); -}); - -// If you want to customize whatever you want... -form.on('data', ({ name, key, value, buffer, start, end, formname, ...more }) => { - if (name === 'partBegin') { - } - if (name === 'partData') { - } - if (name === 'headerField') { - } - if (name === 'headerValue') { - } - if (name === 'headerEnd') { - } - if (name === 'headersEnd') { - } - if (name === 'field') { - console.log('field name:', key); - console.log('field value:', value); - } - if (name === 'file') { - console.log('file:', formname, value); - } - if (name === 'fileBegin') { - console.log('fileBegin:', formname, value); - } -}); -``` - -### .use(plugin: Plugin) - -A method that allows you to extend the Formidable library. By default we include -4 plugins, which essentially are adapters to plug the different built-in parsers. - -**The plugins added by this method are always enabled.** - -_See [src/plugins/](./src/plugins/) for more detailed look on default plugins._ - -The `plugin` param has such signature: - -```typescript -function(formidable: Formidable, options: Options): void; -``` - -The architecture is simple. The `plugin` is a function that is passed with the -Formidable instance (the `form` across the README examples) and the options. - -**Note:** the plugin function's `this` context is also the same instance. - -```js -const form = formidable({ keepExtensions: true }); - -form.use((self, options) => { - // self === this === form - console.log('woohoo, custom plugin'); - // do your stuff; check `src/plugins` for inspiration -}); - -form.parse(req, (error, fields, files) => { - console.log('done!'); -}); -``` - -**Important to note**, is that inside plugin `this.options`, `self.options` and -`options` MAY or MAY NOT be the same. General best practice is to always use the -`this`, so you can later test your plugin independently and more easily. - -If you want to disable some parsing capabilities of Formidable, you can disable -the plugin which corresponds to the parser. For example, if you want to disable -multipart parsing (so the [src/parsers/Multipart.js](./src/parsers/Multipart.js) -which is used in [src/plugins/multipart.js](./src/plugins/multipart.js)), then -you can remove it from the `options.enabledPlugins`, like so - -```js -import formidable, {octetstream, querystring, json} from "formidable"; -const form = formidable({ - hashAlgorithm: 'sha1', - enabledPlugins: [octetstream, querystring, json], -}); -``` - -**Be aware** that the order _MAY_ be important too. The names corresponds 1:1 to -files in [src/plugins/](./src/plugins) folder. - -Pull requests for new built-in plugins MAY be accepted - for example, more -advanced querystring parser. Add your plugin as a new file in `src/plugins/` -folder (lowercased) and follow how the other plugins are made. - -### form.onPart - -If you want to use Formidable to only handle certain parts for you, you can do -something similar. Or see -[#387](https://github.com/node-formidable/node-formidable/issues/387) for -inspiration, you can for example validate the mime-type. - -```js -const form = formidable(); - -form.onPart = (part) => { - part.on('data', (buffer) => { - // do whatever you want here - }); -}; -``` - -For example, force Formidable to be used only on non-file "parts" (i.e., html -fields) - -```js -const form = formidable(); - -form.onPart = function (part) { - // let formidable handle only non-file parts - if (part.originalFilename === '' || !part.mimetype) { - // used internally, please do not override! - form._handlePart(part); - } -}; -``` - -### File - -```ts -export interface File { - // The size of the uploaded file in bytes. - // If the file is still being uploaded (see `'fileBegin'` event), - // this property says how many bytes of the file have been written to disk yet. - file.size: number; - - // The path this file is being written to. You can modify this in the `'fileBegin'` event in - // case you are unhappy with the way formidable generates a temporary path for your files. - file.filepath: string; - - // The name this file had according to the uploading client. - file.originalFilename: string | null; - - // calculated based on options provided - file.newFilename: string | null; - - // The mime type of this file, according to the uploading client. - file.mimetype: string | null; - - // A Date object (or `null`) containing the time this file was last written to. - // Mostly here for compatibility with the [W3C File API Draft](http://dev.w3.org/2006/webapi/FileAPI/). - file.mtime: Date | null; - - file.hashAlgorithm: false | |'sha1' | 'md5' | 'sha256' - // If `options.hashAlgorithm` calculation was set, you can read the hex digest out of this var (at the end it will be a string) - file.hash: string | object | null; -} -``` - -#### file.toJSON() - -This method returns a JSON-representation of the file, allowing you to -`JSON.stringify()` the file which is useful for logging and responding to -requests. - -### Events - -#### `'progress'` - -Emitted after each incoming chunk of data that has been parsed. Can be used to -roll your own progress bar. **Warning** Use this only for server side progress bar. On the client side better use `XMLHttpRequest` with `xhr.upload.onprogress =` - -```js -form.on('progress', (bytesReceived, bytesExpected) => {}); -``` - -#### `'field'` - -Emitted whenever a field / value pair has been received. - -```js -form.on('field', (name, value) => {}); -``` - -#### `'fileBegin'` - -Emitted whenever a new file is detected in the upload stream. Use this event if -you want to stream the file to somewhere else while buffering the upload on the -file system. - -```js -form.on('fileBegin', (formName, file) => { - // accessible here - // formName the name in the form () or http filename for octetstream - // file.originalFilename http filename or null if there was a parsing error - // file.newFilename generated hexoid or what options.filename returned - // file.filepath default pathname as per options.uploadDir and options.filename - // file.filepath = CUSTOM_PATH // to change the final path -}); -``` - -#### `'file'` - -Emitted whenever a field / file pair has been received. `file` is an instance of -`File`. - -```js -form.on('file', (formname, file) => { - // same as fileBegin, except - // it is too late to change file.filepath - // file.hash is available if options.hash was used -}); -``` - -#### `'error'` - -Emitted when there is an error processing the incoming form. A request that -experiences an error is automatically paused, you will have to manually call -`request.resume()` if you want the request to continue firing `'data'` events. - -May have `error.httpCode` and `error.code` attached. - -```js -form.on('error', (err) => {}); -``` - -#### `'aborted'` - -Emitted when the request was aborted by the user. Right now this can be due to a -'timeout' or 'close' event on the socket. After this event is emitted, an -`error` event will follow. In the future there will be a separate 'timeout' -event (needs a change in the node core). - -```js -form.on('aborted', () => {}); -``` - -#### `'end'` - -Emitted when the entire request has been received, and all contained files have -finished flushing to disk. This is a great place for you to send your response. - -```js -form.on('end', () => {}); -``` - - -### Helpers - -#### firstValues - -Gets first values of fields, like pre 3.0.0 without multiples pass in a list of optional exceptions where arrays of strings is still wanted (`` fields. + */ + get body(): ReadableStream { + return this.#bodyRaw; + } + + /** + * Whether the body of this part has been consumed. + */ + get bodyUsed(): boolean { + return this.#bodyUsed; + } + + /** + * The headers associated with this part. + */ + get headers(): SuperHeaders { + if (!this.#headers) { + this.#headers = new SuperHeaders(new TextDecoder().decode(this.#headersRaw)); + } + + return this.#headers; + } + + /** + * The filename of the part, if it is a file upload. + */ + get filename(): string { + return this.headers.contentDisposition.preferredFilename || ''; + } + + /** + * The media type of the part. + */ + get type(): string { + return this.headers.contentType.mediaType || (this.filename ? 'application/octet-stream' : ''); + } + + /** + * The name of the part, usually the `name` of the field in the `
` that submitted the request. + */ + get name(): string { + return this.headers.contentDisposition.name || ''; + } + + /** + * The content of file body as a Async Iterable, similar to `part.body` but there's the raw ReadableStream + */ + async *stream(): AsyncIterable { + if (this.#bodyUsed) { + throw new FormidableError( + 'Body is already consumed or is being consumed', + 'ERR_BODY_CONSUMED', + ); + } + + this.#bodyUsed = true; + + try { + const reader = this.#bodyRaw.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + } catch (err) { + console.error('Error reading stream:', err); + throw err; + } + } + + /** + * The body of this part buffered into a single `Uint8Array`. In `multipart/form-data` messages, this is useful + * for reading the value of files that were uploaded using `` fields. + */ + async bytes(): Promise { + if (this.#bodyUsed) { + throw new FormidableError( + 'Body is already consumed or is being consumed', + 'ERR_BODY_CONSUMED', + ); + } + + this.#bodyUsed = true; + + try { + const reader = this.#bodyRaw.getReader(); + const chunks: Uint8Array[] = []; + let totalLength = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalLength += value.length; + } + } finally { + reader.releaseLock(); + } + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + return result; + } catch (err) { + console.error('Error buffering bytes:', err); + throw err; + } + } + + async slice(start: number, end?: number): Promise { + const reader = this.#bodyRaw.getReader(); + const chunks: Uint8Array[] = []; + let bytesRead = 0; + let bytesToSkip = start; + let bytesToCollect = end === undefined ? Infinity : end - start; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + const chunk = value; + + // Skip bytes before the start + if (bytesToSkip > 0) { + const skipAmount = Math.min(bytesToSkip, chunk.length); + bytesToSkip -= skipAmount; + // If we skipped the whole chunk, continue to the next one + if (skipAmount === chunk.length) { + continue; + } + // Otherwise, update the chunk to the part after skipping + chunk.subarray(skipAmount); + } + + // Collect bytes within the boundaries + if (bytesToCollect > 0) { + const collectAmount = Math.min(bytesToCollect, chunk.length); + chunks.push(chunk.subarray(0, collectAmount)); + bytesToCollect -= collectAmount; + bytesRead += collectAmount; + + // If we've collected enough bytes, we can stop + if (bytesToCollect <= 0) { + // Cancel the rest of the stream to free up resources + reader.cancel(); + break; + } + } + + // If we've passed the end boundary without collecting enough, stop + if (end !== undefined && bytesRead >= end - start) { + reader.cancel(); + break; + } + } + } finally { + reader.releaseLock(); + } + + // Concatenate the collected chunks into a single Uint8Array + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + return result; + } + + /** + * The body of the part as a string. In `multipart/form-data` messages, this is useful for reading the value + * of parts that originated from `` fields. + * + * Note: Do not use this for binary data, use `await part.bytes()` or stream `part.body` directly instead. + */ + async text(failSafe = false): Promise { + try { + return new TextDecoder().decode(await this.bytes()); + } catch { + if (failSafe) { + console.warn( + 'Failed to parse text for part "%s" field (probably it is a binary file)', + this.name, + ); + return ''; + } + + throw new FormidableError( + `Failed to parse text for part "${this.name}" field (probably it is a binary file)`, + 'ERR_FAILED_TO_PARSE_TEXT', + ); + } + } + + async json(failSafe = false): Promise { + try { + return JSON.parse(await this.text(false)) as T; + } catch { + if (failSafe) { + console.warn('Failed to parse json for part "%s" field', this.name); + return null; + } + + throw new FormidableError( + `Failed to parse json for part "${this.name}" field`, + 'ERR_FAILED_TO_PARSE_JSON', + ); + } + } + + /** + * True if this part originated from a file upload. + */ + isFile(): boolean { + // We check for filename first as it's a stronger indicator for multipart/form-data + // For other multipart types, application/octet-stream might be the only hint. + return Boolean(this.filename != '' || this.type === 'application/octet-stream'); + } + + toString(): string { + const obj = this.toObject(); + return JSON.stringify(obj, null, 2); + } + + toObject(): { + isFile: boolean; + name: string; + type: string; + size: number; + filename: string; + headers: Record; + } { + return { + isFile: this.isFile(), + name: this.name, + type: this.type, + size: this.size, + filename: this.filename, + headers: Object.fromEntries( + [...this.headers.entries()].map(([key, value]) => [key.toLowerCase(), value.toLowerCase()]), + ), + }; + } +} diff --git a/packages/formidable-next/src/buffer-search.ts b/packages/formidable-next/src/buffer-search.ts new file mode 100644 index 00000000..f9afad71 --- /dev/null +++ b/packages/formidable-next/src/buffer-search.ts @@ -0,0 +1,94 @@ +export interface SearchFunction { + (haystack: Uint8Array, start?: number): number; +} + +export function createSearch(pattern: string): SearchFunction { + const needle = new TextEncoder().encode(pattern); + + let search: SearchFunction; + if ('Buffer' in globalThis && !('Bun' in globalThis || 'Deno' in globalThis)) { + // Use the built-in Buffer.indexOf method on Node.js for better perf. + // eslint-disable-next-line prefer-reflect + search = (haystack, start = 0) => Buffer.prototype.indexOf.call(haystack, needle, start); + } else { + const needleEnd = needle.length - 1; + const skipTable = new Uint8Array(256).fill(needle.length); + for (let i = 0; i < needleEnd; ++i) { + const byte = needle[i]; + if (byte !== undefined) { + skipTable[byte] = needleEnd - i; + } + } + // Initialize skip table with pattern length as default + for (let byte = 0; byte < 256; byte++) { + skipTable[byte] = needle.length; + } + // Update skip table for each byte in the pattern except the last one + for (let i = 0; i < needleEnd; i++) { + const byte = needle[i]; + if (byte !== undefined) { + skipTable[byte] = needleEnd - i; + } + } + search = (haystack, start = 0) => { + const haystackLength = haystack.length; + let i = start + needleEnd; + + while (i < haystackLength) { + for (let j = needleEnd, k = i; j >= 0 && haystack[k] === needle[j]; --j, --k) { + if (j === 0) { + return k; + } + } + + const kk = haystack[i] ?? 0; + i += skipTable[kk] ?? needle.length; + } + + return -1; + }; + } + + return search; +} + +export interface PartialTailSearchFunction { + (haystack: Uint8Array): number; +} + +export function createPartialTailSearch(pattern: string): PartialTailSearchFunction { + const needle = new TextEncoder().encode(pattern); + + const byteIndexes: Record = {}; + for (const [i, byte] of needle.entries()) { + if (byteIndexes[byte] === undefined) { + byteIndexes[byte] = []; + } + byteIndexes[byte].push(i); + } + + return function tailSearch(haystack: Uint8Array): number { + const haystackEnd = haystack.length - 1; + const lastByte = haystack[haystackEnd]; + + if (lastByte !== undefined && lastByte in byteIndexes) { + const indexes = byteIndexes[lastByte] as number[]; + + for (let i = indexes.length - 1; i >= 0; --i) { + const index = indexes[i]; + let j: number = index as number; + let k: number = haystackEnd; + + while (j >= 0 && k >= 0 && haystack[k] === needle[j]) { + if (j === 0) { + return k; + } + j--; + k--; + } + } + } + + return -1; + }; +} diff --git a/packages/formidable-next/src/entrypoints/index.ts b/packages/formidable-next/src/entrypoints/index.ts new file mode 100644 index 00000000..ffef8590 --- /dev/null +++ b/packages/formidable-next/src/entrypoints/index.ts @@ -0,0 +1,2 @@ +export * from '../multipart-request.ts'; +export * from '../multipart-web.ts'; diff --git a/packages/formidable-next/src/multipart-request.ts b/packages/formidable-next/src/multipart-request.ts new file mode 100644 index 00000000..39dec1be --- /dev/null +++ b/packages/formidable-next/src/multipart-request.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT + +/* eslint-disable prefer-named-capture-group */ +/* eslint-disable require-unicode-regexp */ +import { + FormidableError, + type FormidableOptions, + type FormidablePartHandler, + parseMultipart, +} from './multipart-web.ts'; + +/** + * Extracts the boundary string from a `multipart/*` content type. + */ +export function getMultipartBoundary(contentType?: string | null): string | null { + if (!contentType) { + return null; + } + + const [_, matchOne = null, matchTwo = null] + = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '') || []; + + return matchOne || matchTwo || null; +} + +/** + * Returns true if the given request contains multipart data. + */ +export function isMultipartRequest(request: Request): boolean { + const contentType = request.headers.get('Content-Type'); + + return Boolean(contentType && contentType.startsWith('multipart/')); +} + +/** + * Parse a multipart [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and yield each part as + * a `MultipartPart` object. Useful in HTTP server contexts for handling incoming `multipart/*` requests. + */ +export async function parseMultipartRequest( + request: Request, + handler: FormidablePartHandler, +): Promise; +export async function parseMultipartRequest( + request: Request, + options: Omit, + handler: FormidablePartHandler, +): Promise; +export async function parseMultipartRequest( + request: Request, + options: Omit | FormidablePartHandler, + handler?: FormidablePartHandler, +): Promise { + if (typeof options === 'function') { + handler = options; + options = {} as Omit; + } + + if (!isMultipartRequest(request)) { + throw new FormidableError('Request is not a multipart request', 'ERR_NO_MULTIPART_BODY'); + } + if (!request.body) { + throw new FormidableError('Request body is empty', 'ERR_NO_REQUEST_BODY'); + } + + const boundary = getMultipartBoundary(request.headers.get('Content-Type')); + if (!boundary) { + throw new FormidableError('Invalid Content-Type header: missing boundary', 'ERR_NO_BOUNDARY'); + } + + await parseMultipart(request.body, { ...options, boundary }, handler!); +} diff --git a/packages/formidable-next/src/multipart-web.ts b/packages/formidable-next/src/multipart-web.ts new file mode 100644 index 00000000..9839896e --- /dev/null +++ b/packages/formidable-next/src/multipart-web.ts @@ -0,0 +1,633 @@ +// SPDX-License-Identifier: MIT + +/* eslint-disable node/handle-callback-err */ +import { + createPartialTailSearch, + createSearch, + type PartialTailSearchFunction, + type SearchFunction, +} from './buffer-search.ts'; +// eslint-disable-next-line node/no-missing-import +import { SuperHeaders } from './super-headers.js'; + +export const formidableDefaultOptions: FormidableOptions = { + maxAllHeadersSize: 8 * 1024, // 8kb, size for all headers combined + maxFieldKeySize: 255, // size of the key of text fields + maxFieldSize: 100 * 1024, // 100kb, size of each text field value + maxFileKeySize: 255, // size of the key of file fields + maxFilenameSize: 255, // size of the file original filename + maxFileSize: 100 * 1024 * 1024, // 100MB + maxHeaderKeySize: 255, // size of the key per each header + maxHeaderSize: 2 * 1024, // 1kb, size of key + value of each header + maxHeaderValueSize: 1 * 1024, // size of the key per each header + onHandlerError: (_err: FormidableError) => {}, +}; + +export type FormidableInputSource = + | ReadableStream + | Uint8Array + | Iterable + | AsyncIterable; + +export interface FormidableOptionsAll { + boundary: string; + maxAllHeadersSize?: number; // 8kb for all headers + maxHeaderKeySize?: number; // size of the key per each header + maxHeaderValueSize?: number; // size of the value of each header + maxHeaderSize?: number; // 1kb, size of key + value of each header + maxFilenameSize?: number; // size of the file original filename + maxFileKeySize?: number; // size of the key of file fields + maxFileSize?: number; // 100MB + maxFieldKeySize?: number; // size of the key of text fields + maxFieldSize?: number; // size of the value of text fields + onHandlerError?: (error: FormidableError) => void | Promise; +} + +export type FormidableOptions = Omit; +export type FormidableParserOptions = FormidableOptions; +export type FormidablePartHandler = (part: FormidablePart) => void | Promise; + +/** + * Parse a `multipart/*` buffer or stream and yield each part it finds as a `FormidablePart` object. + * + * ```ts + * import { parseMultipart } from 'formidable'; + * + * let boundary = '----WebKitFormBoundaryzv5Z4JY8k9lG0yQW'; + * + * await parseMultipart(message, { boundary }, async (part) => { + * if (part.isFile) { + * console.log(part.filename); + * + * if (part.mediaType.startsWith('text/')) { + * let text = await part.text(); + * // ... + * } else { + * let buffer = await part.bytes(); + * // ... + * } + * } else { + * let text = await part.text(); + * // ... + * } + * }); + * ``` + * + * Note: This is a low-level API that requires manual handling of the stream and boundary. If you're + * building a web server, consider using `parseMultipartRequest(request)` instead. + */ +export async function parseMultipart( + message: FormidableInputSource, + options: FormidableOptionsAll, + handler: FormidablePartHandler, +): Promise { + const { boundary, ...opts } = options; + if (!boundary) { + throw new FormidableError('Invalid Content-Type header: missing boundary', 'ERR_NO_BOUNDARY'); + } + const parser = new FormidableParser(boundary, opts as FormidableOptions); + + await parser.parse(message, handler); +} + +const findDoubleNewline = createSearch('\r\n\r\n'); + +const MultipartParserStateStart = 0; +const MultipartParserStateAfterBoundary = 1; +const MultipartParserStateHeader = 2; +const MultipartParserStateBody = 3; +const MultipartParserStateDone = 4; + +export class FormidableError extends Error { + code: string; + + constructor(message: string, code: string = 'ERR_UNKNOWN') { + super(message); + this.name = 'FormidableError'; + this.code = code; + } +} + +/** + * A parser for `multipart/*` HTTP messages. + */ +export class FormidableParser { + boundary: string; + options: Required; + #findOpeningBoundary: SearchFunction; + #openingBoundaryLength: number; + #findBoundary: SearchFunction; + #findPartialTailBoundary: PartialTailSearchFunction; + #boundaryLength: number; + #state = MultipartParserStateStart; + #buffer: Uint8Array | null = null; + #bodyController: ReadableStreamDefaultController | null = null; + #bodyLength = 0; + + constructor(boundary: string, options?: FormidableOptions) { + this.boundary = boundary; + this.options = { ...formidableDefaultOptions, ...options } as Required; + + this.#findOpeningBoundary = createSearch(`--${boundary}`); + this.#openingBoundaryLength = 2 + boundary.length; // length of '--' + boundary + + this.#findBoundary = createSearch(`\r\n--${boundary}`); + this.#findPartialTailBoundary = createPartialTailSearch(`\r\n--${boundary}`); + this.#boundaryLength = 4 + boundary.length; // length of '\r\n--' + boundary + } + + /** + * Parse a stream/buffer multipart message and call the given handler for each part it contains. + * Resolves when the parse is finished and all handlers resolve. + */ + async parse(message: FormidableInputSource, handler?: FormidablePartHandler): Promise { + if (this.#state !== MultipartParserStateStart) { + this.reset(); + } + + const promises: Promise[] = []; + + function handlePart(part: FormidablePart): void { + const result = handler?.(part); + if (isPromise(result)) { + promises.push(result); + + // This hack marks the promise as "handled" in Node.js to suppress + // "unhandledRejection" warnings and avoid crashing the process. + result.catch(() => {}); + } + } + + if (message instanceof Uint8Array) { + await this.write(message, handlePart); + } else if (message instanceof ReadableStream) { + for await (const chunk of readStreamHelper(message)) { + await this.write(chunk, handlePart); + } + } else if (isAsyncIterable(message) || isIterable(message)) { + for await (const chunk of message) { + await this.write(chunk, handlePart); + } + } else { + throw new FormidableError( + 'Cannot parse multipart message; expected a stream or buffer', + 'ERR_INVALID_INPUT', + ); + } + + if (this.#state !== MultipartParserStateDone) { + throw new FormidableError('Unexpected end of stream', 'ERR_UNEXPECTED_END'); + } + + await Promise.all(promises); + } + + reset(): void { + this.#state = MultipartParserStateStart; + this.#buffer = null; + this.#bodyController = null; + this.#bodyLength = 0; + } + + // eslint-disable-next-line max-statements + async write(chunk: Uint8Array, handler: FormidablePartHandler): Promise { + if (this.#state === MultipartParserStateDone) { + throw new FormidableError( + 'Unexpected data after end of stream', + 'ERR_UNEXPECTED_DATA_AFTER_END', + ); + } + + let index = 0; + let chunkLength = chunk.length; + + if (this.#buffer !== null) { + const newChunk = new Uint8Array(this.#buffer.length + chunkLength); + newChunk.set(this.#buffer, 0); + newChunk.set(chunk, this.#buffer.length); + chunk = newChunk; + chunkLength = chunk.length; + this.#buffer = null; + } + + while (true) { + if (this.#state === MultipartParserStateBody) { + if (chunkLength - index < this.#boundaryLength) { + this.#buffer = chunk.subarray(index); + break; + } + + const boundaryIndex = this.#findBoundary(chunk, index); + + if (boundaryIndex === -1) { + // No boundary found, but there may be a partial match at the end of the chunk. + const partialTailIndex = this.#findPartialTailBoundary(chunk); + + if (partialTailIndex === -1) { + this.writeBody(index === 0 ? chunk : chunk.subarray(index)); + } else { + this.writeBody(chunk.subarray(index, partialTailIndex)); + this.#buffer = chunk.subarray(partialTailIndex); + } + + break; + } + + this.writeBody(chunk.subarray(index, boundaryIndex)); + this.closeBody(); + + index = boundaryIndex + this.#boundaryLength; + + this.#state = MultipartParserStateAfterBoundary; + } + + if (this.#state === MultipartParserStateAfterBoundary) { + if (chunkLength - index < 2) { + this.#buffer = chunk.subarray(index); + break; + } + + if (chunk[index] === 45 && chunk[index + 1] === 45) { + this.#state = MultipartParserStateDone; + break; + } + + index += 2; // Skip \r\n after boundary + + this.#state = MultipartParserStateHeader; + } + + if (this.#state === MultipartParserStateHeader) { + if (chunkLength - index < 4) { + this.#buffer = chunk.subarray(index); + break; + } + + const headerEndIndex = findDoubleNewline(chunk, index); + + if (headerEndIndex === -1) { + if (chunkLength - index > this.options.maxAllHeadersSize) { + throw new FormidableError( + `Multipart headers size exceeds maximum allowed size of ${this.options.maxAllHeadersSize} bytes`, + 'ERR_MAX_ALL_HEADERS_SIZE', + ); + } + + this.#buffer = chunk.subarray(index); + break; + } + + if (headerEndIndex - index > this.options.maxAllHeadersSize) { + throw new FormidableError( + `Multipart headers size exceeds maximum allowed size of ${this.options.maxAllHeadersSize} bytes`, + 'ERR_MAX_ALL_HEADERS_SIZE', + ); + } + + const header = chunk.subarray(index, headerEndIndex); + const part = new FormidablePart( + header, + new ReadableStream({ + start: (controller) => { + this.#bodyController = controller; + this.#bodyLength = 0; + }, + }), + ); + + await this.checkLimits(part); + handler(part); + + index = headerEndIndex + 4; // Skip header + \r\n\r\n + + this.#state = MultipartParserStateBody; + + continue; + } + + if (this.#state === MultipartParserStateStart) { + if (chunkLength < this.#openingBoundaryLength) { + this.#buffer = chunk; + break; + } + + if (this.#findOpeningBoundary(chunk) !== 0) { + throw new FormidableError( + 'Invalid multipart stream: missing initial boundary', + 'ERR_NO_BOUNDARY', + ); + } + + index = this.#openingBoundaryLength; + + this.#state = MultipartParserStateAfterBoundary; + } + } + } + + async checkLimits(part: FormidablePart): Promise { + const hasHeaderLimits + = this.options.maxHeaderSize > 0 + || this.options.maxHeaderKeySize > 0 + || this.options.maxHeaderValueSize > 0; + + if (hasHeaderLimits) { + for (const [headerKey, headerValue] of part.headers.entries()) { + const header = `${headerKey}: ${headerValue}`.toLowerCase(); + + if (header.length > this.options.maxHeaderSize) { + throw new FormidableError( + `Header (${header}) exceeds maximum allowed size of ${this.options.maxHeaderSize} bytes`, + 'ERR_MAX_HEADER_SIZE', + ); + } + if (headerKey.length > this.options.maxHeaderKeySize) { + throw new FormidableError( + `Header key (${headerKey}) exceeds maximum allowed size of ${this.options.maxHeaderKeySize} bytes`, + 'ERR_MAX_HEADER_KEY_SIZE', + ); + } + + if (headerValue.length > this.options.maxHeaderValueSize) { + throw new FormidableError( + `Header value (${headerValue}) exceeds maximum allowed size of ${this.options.maxHeaderValueSize} bytes`, + 'ERR_MAX_HEADER_VALUE_SIZE', + ); + } + } + } + + const isfile = typeof part.isFile === 'function' ? part.isFile() : part.isFile; + const limitName = isfile ? 'maxFileKeySize' : 'maxFieldKeySize'; + + if (part.name && part.name.length > this.options[limitName]) { + throw new FormidableError( + `${isfile ? 'File' : 'Field'} key (${part.name}) exceeds maximum allowed size of ${this.options[limitName]} bytes`, + 'ERR_MAX_FIELD_KEY_SIZE', + ); + } + + if (part.filename && part.filename.length > this.options.maxFilenameSize) { + throw new FormidableError( + `Filename exceeds maximum allowed size of ${this.options.maxFilenameSize} bytes. Filename: ${part.filename}. Field name: ${part.name}`, + 'ERR_MAX_FILENAME_SIZE', + ); + } + + if (!isfile && (await part.text()).length > this.options.maxFieldSize) { + throw new FormidableError( + `Field value exceeds maximum allowed size of ${this.options.maxFieldSize} bytes. Field name: ${part.name}`, + 'ERR_MAX_FIELD_SIZE', + ); + } + } + + writeBody(chunk: Uint8Array): void { + if (this.#bodyLength + chunk.length > this.options.maxFileSize) { + const error = new FormidableError( + `File size exceeds maximum allowed size of ${this.options.maxFileSize} bytes`, + 'ERR_MAX_FILE_SIZE', + ); + this.#bodyController?.error(error); + throw error; + } + + this.#bodyController?.enqueue(chunk); + this.#bodyLength += chunk.length; + } + + closeBody(): void { + this.#bodyController?.close(); + this.#bodyController = null; + } +} + +/** + * A part of a `multipart/*` HTTP message. + */ +export class FormidablePart { + #headersRaw: Uint8Array; + #bodyRaw: ReadableStream; + #headers?: SuperHeaders; + #bodyUsed = false; + + constructor(headersRaw: Uint8Array, bodyRaw: ReadableStream) { + this.#headersRaw = headersRaw; + this.#bodyRaw = bodyRaw; + } + + /** + * The body of this part as a `ReadableStream`. In `multipart/form-data` messages, this is useful + * for streaming the value of files that were uploaded using `` fields. + */ + get body(): ReadableStream { + return this.#bodyRaw; + } + + /** + * Whether the body of this part has been consumed. + */ + get bodyUsed(): boolean { + return this.#bodyUsed; + } + + /** + * The headers associated with this part. + */ + get headers(): SuperHeaders { + if (!this.#headers) { + this.#headers = new SuperHeaders(new TextDecoder().decode(this.#headersRaw)); + } + + return this.#headers; + } + + /** + * The filename of the part, if it is a file upload. + */ + get filename(): string { + return this.headers.contentDisposition.preferredFilename || ''; + } + + /** + * The media type of the part. + */ + get type(): string { + return this.headers.contentType.mediaType || (this.filename ? 'application/octet-stream' : ''); + } + + /** + * The name of the part, usually the `name` of the field in the `` that submitted the request. + */ + get name(): string { + return this.headers.contentDisposition.name || ''; + } + + /** + * True if this part originated from a file upload. + */ + isFile(): boolean { + // eslint-disable-next-line eqeqeq + return Boolean(this.filename != '' || this.type === 'application/octet-stream'); + } + + /** + * Create a stream of the part's body as async iterable, the `part.body` is the raw ReadableStream + */ + async* stream(): AsyncIterable { + if (this.#bodyUsed) { + throw new FormidableError( + 'Body is already consumed or is being consumed', + 'ERR_BODY_CONSUMED', + ); + } + + this.#bodyUsed = true; + + for await (const chunk of readStreamHelper(this.#bodyRaw)) { + yield chunk; + } + } + + /** + * The content of this part as an `ArrayBuffer`. + */ + async arrayBuffer(): Promise { + return (await this.bytes()).buffer as ArrayBuffer; + } + + /** + * The body of this part buffered into a single `Uint8Array`. In `multipart/form-data` messages, this is useful + * for reading the value of files that were uploaded using `` fields. + */ + async bytes(): Promise { + if (this.#bodyUsed) { + throw new FormidableError( + 'Body is already consumed or is being consumed', + 'ERR_BODY_CONSUMED', + ); + } + + this.#bodyUsed = true; + + const chunks: Uint8Array[] = []; + let totalLength = 0; + for await (const chunk of readStreamHelper(this.#bodyRaw)) { + chunks.push(chunk); + totalLength += chunk.length; + } + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + return result; + } + + /** + * The body of the part as a string. In `multipart/form-data` messages, this is useful for reading the value + * of parts that originated from `` fields. + * + * Note: Do not use this for binary data, use `await part.bytes()` or stream `part.body` directly instead. + */ + async text(failSafe = false): Promise { + try { + return new TextDecoder().decode(await this.bytes()); + } catch { + if (failSafe) { + console.warn( + 'Failed to parse text for part "%s" field (probably it is a binary file)', + this.name, + ); + return ''; + } + + throw new FormidableError( + `Failed to parse text for part "${this.name}" field (probably it is a binary file)`, + 'ERR_FAILED_TO_PARSE_TEXT', + ); + } + } + + /** + * Parses the JSON content of the part. It may fail or throw if the content cannot be parsed. + * + * @param failSafe If true, will not throw an error on failure to parse. + * @returns The parsed JSON object or null if failSafe is true and parsing fails. + */ + async json(failSafe = false): Promise { + try { + // intentionally force failSafe=false, so that either .text() or JSON.parse throw + return JSON.parse(await this.text(false)) as T; + } catch { + if (failSafe) { + console.warn('Failed to parse json for part "%s" field', this.name); + return null; + } + + throw new FormidableError( + `Failed to parse json for part "${this.name}" field`, + 'ERR_FAILED_TO_PARSE_JSON', + ); + } + } + + toString(): string { + const obj = this.toObject(); + + return JSON.stringify(obj, null, 2); + } + + toObject(): { + isFile: boolean; + name: string; + type: string; + // size: number; + filename: string; + headers: Record; + } { + return { + // size: this.size, + filename: this.filename, + headers: Object.fromEntries( + [...this.headers.entries()].map(([key, value]) => [key.toLowerCase(), value.toLowerCase()]), + ), + isFile: this.isFile(), + name: this.name, + type: this.type, + }; + } +} + +export async function* readStreamHelper( + stream: ReadableStream, +): AsyncIterable { + const reader = stream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + yield value; + } + } finally { + reader.releaseLock(); + } +} + +function isIterable(value: unknown): value is Iterable { + return typeof value === 'object' && value != null && Symbol.iterator in value; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return typeof value === 'object' && value != null && Symbol.asyncIterator in value; +} + +function isPromise(value: unknown): value is Promise { + return typeof value === 'object' && value != null && typeof (value as any).then === 'function'; +} diff --git a/packages/formidable-next/src/super-headers.js b/packages/formidable-next/src/super-headers.js new file mode 100644 index 00000000..3f8fdf06 --- /dev/null +++ b/packages/formidable-next/src/super-headers.js @@ -0,0 +1,1641 @@ +// MIT: https://unpkg.com/@mjackson/headers@0.10.0/dist/headers.js +// Issue: https://github.com/mjackson/remix-the-web/issues/70 +// Turns out we cannot polyfill it upstream. + +import { Headers } from 'headers-polyfill'; + +// src/lib/param-values.ts +function parseParams(input, delimiter = ';') { + let parser = + delimiter === ';' + ? /(?:^|;)\s*([^=;\s]+)(\s*=\s*(?:"((?:[^"\\]|\\.)*)"|((?:[^;]|\\\;)+))?)?/g + : /(?:^|,)\s*([^=,\s]+)(\s*=\s*(?:"((?:[^"\\]|\\.)*)"|((?:[^,]|\\\,)+))?)?/g; + let params = []; + let match; + while ((match = parser.exec(input)) !== null) { + let key = match[1].trim(); + let value; + if (match[2]) { + value = (match[3] || match[4] || '').replace(/\\(.)/g, '$1').trim(); + } + params.push([key, value]); + } + return params; +} +function quote(value) { + if (value.includes('"') || value.includes(';') || value.includes(' ')) { + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; +} + +// src/lib/utils.ts +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} +function isIterable(value) { + return value != null && typeof value[Symbol.iterator] === 'function'; +} +function isValidDate(date) { + return date instanceof Date && !isNaN(date.getTime()); +} +function quoteEtag(tag) { + return tag === '*' ? tag : /^(W\/)?".*"$/.test(tag) ? tag : `"${tag}"`; +} + +// src/lib/accept.ts +var Accept = class { + #map; + constructor(init) { + this.#map = /* @__PURE__ */ new Map(); + if (init) { + if (typeof init === 'string') { + for (let piece of init.split(/\s*,\s*/)) { + let params = parseParams(piece); + if (params.length < 1) continue; + let mediaType = params[0][0]; + let weight = 1; + for (let i = 1; i < params.length; i++) { + let [key, value] = params[i]; + if (key === 'q') { + weight = Number(value); + break; + } + } + this.#map.set(mediaType.toLowerCase(), weight); + } + } else if (isIterable(init)) { + for (let mediaType of init) { + if (Array.isArray(mediaType)) { + this.#map.set(mediaType[0].toLowerCase(), mediaType[1]); + } else { + this.#map.set(mediaType.toLowerCase(), 1); + } + } + } else { + for (let mediaType of Object.getOwnPropertyNames(init)) { + this.#map.set(mediaType.toLowerCase(), init[mediaType]); + } + } + this.#sort(); + } + } + #sort() { + this.#map = new Map([...this.#map].sort((a, b) => b[1] - a[1])); + } + /** + * An array of all media types in the header. + */ + get mediaTypes() { + return Array.from(this.#map.keys()); + } + /** + * An array of all weights (q values) in the header. + */ + get weights() { + return Array.from(this.#map.values()); + } + /** + * The number of media types in the `Accept` header. + */ + get size() { + return this.#map.size; + } + /** + * Returns `true` if the header matches the given media type (i.e. it is "acceptable"). + * @param mediaType The media type to check. + * @returns `true` if the media type is acceptable, `false` otherwise. + */ + accepts(mediaType) { + return this.getWeight(mediaType) > 0; + } + /** + * Gets the weight of a given media type. Also supports wildcards, so e.g. `text/*` will match `text/html`. + * @param mediaType The media type to get the weight of. + * @returns The weight of the media type. + */ + getWeight(mediaType) { + let [type, subtype] = mediaType.toLowerCase().split('/'); + for (let [key, value] of this) { + let [t, s] = key.split('/'); + if ( + (t === type || t === '*' || type === '*') && + (s === subtype || s === '*' || subtype === '*') + ) { + return value; + } + } + return 0; + } + /** + * Returns the most preferred media type from the given list of media types. + * @param mediaTypes The list of media types to choose from. + * @returns The most preferred media type or `null` if none match. + */ + getPreferred(mediaTypes) { + let sorted = mediaTypes + .map((mediaType) => [mediaType, this.getWeight(mediaType)]) + .sort((a, b) => b[1] - a[1]); + let first = sorted[0]; + return first !== void 0 && first[1] > 0 ? first[0] : null; + } + /** + * Returns the weight of a media type. If it is not in the header verbatim, this returns `null`. + * @param mediaType The media type to get the weight of. + * @returns The weight of the media type, or `null` if it is not in the header. + */ + get(mediaType) { + return this.#map.get(mediaType.toLowerCase()) ?? null; + } + /** + * Sets a media type with the given weight. + * @param mediaType The media type to set. + * @param weight The weight of the media type. Defaults to 1. + */ + set(mediaType, weight = 1) { + this.#map.set(mediaType.toLowerCase(), weight); + this.#sort(); + } + /** + * Removes the given media type from the header. + * @param mediaType The media type to remove. + */ + delete(mediaType) { + this.#map.delete(mediaType.toLowerCase()); + } + /** + * Checks if a media type is in the header. + * @param mediaType The media type to check. + * @returns `true` if the media type is in the header (verbatim), `false` otherwise. + */ + has(mediaType) { + return this.#map.has(mediaType.toLowerCase()); + } + /** + * Removes all media types from the header. + */ + clear() { + this.#map.clear(); + } + entries() { + return this.#map.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + forEach(callback, thisArg) { + for (let [mediaType, weight] of this) { + callback.call(thisArg, mediaType, weight, this); + } + } + toString() { + let pairs = []; + for (let [mediaType, weight] of this.#map) { + pairs.push(`${mediaType}${weight === 1 ? '' : `;q=${weight}`}`); + } + return pairs.join(','); + } +}; + +// src/lib/accept-encoding.ts +var AcceptEncoding = class { + #map; + constructor(init) { + this.#map = /* @__PURE__ */ new Map(); + if (init) { + if (typeof init === 'string') { + for (let piece of init.split(/\s*,\s*/)) { + let params = parseParams(piece); + if (params.length < 1) continue; + let encoding = params[0][0]; + let weight = 1; + for (let i = 1; i < params.length; i++) { + let [key, value] = params[i]; + if (key === 'q') { + weight = Number(value); + break; + } + } + this.#map.set(encoding.toLowerCase(), weight); + } + } else if (isIterable(init)) { + for (let value of init) { + if (Array.isArray(value)) { + this.#map.set(value[0].toLowerCase(), value[1]); + } else { + this.#map.set(value.toLowerCase(), 1); + } + } + } else { + for (let encoding of Object.getOwnPropertyNames(init)) { + this.#map.set(encoding.toLowerCase(), init[encoding]); + } + } + this.#sort(); + } + } + #sort() { + this.#map = new Map([...this.#map].sort((a, b) => b[1] - a[1])); + } + /** + * An array of all encodings in the header. + */ + get encodings() { + return Array.from(this.#map.keys()); + } + /** + * An array of all weights (q values) in the header. + */ + get weights() { + return Array.from(this.#map.values()); + } + /** + * The number of encodings in the header. + */ + get size() { + return this.#map.size; + } + /** + * Returns `true` if the header matches the given encoding (i.e. it is "acceptable"). + * @param encoding The encoding to check. + * @returns `true` if the encoding is acceptable, `false` otherwise. + */ + accepts(encoding) { + return encoding.toLowerCase() === 'identity' || this.getWeight(encoding) > 0; + } + /** + * Gets the weight an encoding. Performs wildcard matching so `*` matches all encodings. + * @param encoding The encoding to get. + * @returns The weight of the encoding, or `0` if it is not in the header. + */ + getWeight(encoding) { + let lower = encoding.toLowerCase(); + for (let [enc, weight] of this) { + if (enc === lower || enc === '*' || lower === '*') { + return weight; + } + } + return 0; + } + /** + * Returns the most preferred encoding from the given list of encodings. + * @param encodings The encodings to choose from. + * @returns The most preferred encoding or `null` if none match. + */ + getPreferred(encodings) { + let sorted = encodings + .map((encoding) => [encoding, this.getWeight(encoding)]) + .sort((a, b) => b[1] - a[1]); + let first = sorted[0]; + return first !== void 0 && first[1] > 0 ? first[0] : null; + } + /** + * Gets the weight of an encoding. If it is not in the header verbatim, this returns `null`. + * @param encoding The encoding to get. + * @returns The weight of the encoding, or `null` if it is not in the header. + */ + get(encoding) { + return this.#map.get(encoding.toLowerCase()) ?? null; + } + /** + * Sets an encoding with the given weight. + * @param encoding The encoding to set. + * @param weight The weight of the encoding. Defaults to 1. + */ + set(encoding, weight = 1) { + this.#map.set(encoding.toLowerCase(), weight); + this.#sort(); + } + /** + * Removes the given encoding from the header. + * @param encoding The encoding to remove. + */ + delete(encoding) { + this.#map.delete(encoding.toLowerCase()); + } + /** + * Checks if the header contains a given encoding. + * @param encoding The encoding to check. + * @returns `true` if the encoding is in the header, `false` otherwise. + */ + has(encoding) { + return this.#map.has(encoding.toLowerCase()); + } + /** + * Removes all encodings from the header. + */ + clear() { + this.#map.clear(); + } + entries() { + return this.#map.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + forEach(callback, thisArg) { + for (let [encoding, weight] of this) { + callback.call(thisArg, encoding, weight, this); + } + } + toString() { + let pairs = []; + for (let [encoding, weight] of this.#map) { + pairs.push(`${encoding}${weight === 1 ? '' : `;q=${weight}`}`); + } + return pairs.join(','); + } +}; + +// src/lib/accept-language.ts +var AcceptLanguage = class { + #map; + constructor(init) { + this.#map = /* @__PURE__ */ new Map(); + if (init) { + if (typeof init === 'string') { + for (let piece of init.split(/\s*,\s*/)) { + let params = parseParams(piece); + if (params.length < 1) continue; + let language = params[0][0]; + let weight = 1; + for (let i = 1; i < params.length; i++) { + let [key, value] = params[i]; + if (key === 'q') { + weight = Number(value); + break; + } + } + this.#map.set(language.toLowerCase(), weight); + } + } else if (isIterable(init)) { + for (let value of init) { + if (Array.isArray(value)) { + this.#map.set(value[0].toLowerCase(), value[1]); + } else { + this.#map.set(value.toLowerCase(), 1); + } + } + } else { + for (let language of Object.getOwnPropertyNames(init)) { + this.#map.set(language.toLowerCase(), init[language]); + } + } + this.#sort(); + } + } + #sort() { + this.#map = new Map([...this.#map].sort((a, b) => b[1] - a[1])); + } + /** + * An array of all languages in the header. + */ + get languages() { + return Array.from(this.#map.keys()); + } + /** + * An array of all weights (q values) in the header. + */ + get weights() { + return Array.from(this.#map.values()); + } + /** + * The number of languages in the header. + */ + get size() { + return this.#map.size; + } + /** + * Returns `true` if the header matches the given language (i.e. it is "acceptable"). + * @param language The locale identifier of the language to check. + * @returns `true` if the language is acceptable, `false` otherwise. + */ + accepts(language) { + return this.getWeight(language) > 0; + } + /** + * Gets the weight of a language with the given locale identifier. Performs wildcard and subtype + * matching, so `en` matches `en-US` and `en-GB`, and `*` matches all languages. + * @param language The locale identifier of the language to get. + * @returns The weight of the language, or `0` if it is not in the header. + */ + getWeight(language) { + let [base, subtype] = language.toLowerCase().split('-'); + for (let [key, value] of this) { + let [b, s] = key.split('-'); + if ( + (b === base || b === '*' || base === '*') && + (s === subtype || s === void 0 || subtype === void 0) + ) { + return value; + } + } + return 0; + } + /** + * Returns the most preferred language from the given list of languages. + * @param languages The locale identifiers of the languages to choose from. + * @returns The most preferred language or `null` if none match. + */ + getPreferred(languages) { + let sorted = languages + .map((language) => [language, this.getWeight(language)]) + .sort((a, b) => b[1] - a[1]); + let first = sorted[0]; + return first !== void 0 && first[1] > 0 ? first[0] : null; + } + /** + * Gets the weight of a language with the given locale identifier. If it is not in the header + * verbatim, this returns `null`. + * @param language The locale identifier of the language to get. + * @returns The weight of the language, or `null` if it is not in the header. + */ + get(language) { + return this.#map.get(language.toLowerCase()) ?? null; + } + /** + * Sets a language with the given weight. + * @param language The locale identifier of the language to set. + * @param weight The weight of the language. Defaults to 1. + */ + set(language, weight = 1) { + this.#map.set(language.toLowerCase(), weight); + this.#sort(); + } + /** + * Removes a language with the given locale identifier. + * @param language The locale identifier of the language to remove. + */ + delete(language) { + this.#map.delete(language.toLowerCase()); + } + /** + * Checks if the header contains a language with the given locale identifier. + * @param language The locale identifier of the language to check. + * @returns `true` if the language is in the header, `false` otherwise. + */ + has(language) { + return this.#map.has(language.toLowerCase()); + } + /** + * Removes all languages from the header. + */ + clear() { + this.#map.clear(); + } + entries() { + return this.#map.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + forEach(callback, thisArg) { + for (let [language, weight] of this) { + callback.call(thisArg, language, weight, this); + } + } + toString() { + let pairs = []; + for (let [language, weight] of this.#map) { + pairs.push(`${language}${weight === 1 ? '' : `;q=${weight}`}`); + } + return pairs.join(','); + } +}; + +// src/lib/cache-control.ts +var CacheControl = class { + maxAge; + maxStale; + minFresh; + sMaxage; + noCache; + noStore; + noTransform; + onlyIfCached; + mustRevalidate; + proxyRevalidate; + mustUnderstand; + private; + public; + immutable; + staleWhileRevalidate; + staleIfError; + constructor(init) { + if (init) { + if (typeof init === 'string') { + let params = parseParams(init, ','); + if (params.length > 0) { + for (let [name, value] of params) { + switch (name) { + case 'max-age': + this.maxAge = Number(value); + break; + case 'max-stale': + this.maxStale = Number(value); + break; + case 'min-fresh': + this.minFresh = Number(value); + break; + case 's-maxage': + this.sMaxage = Number(value); + break; + case 'no-cache': + this.noCache = true; + break; + case 'no-store': + this.noStore = true; + break; + case 'no-transform': + this.noTransform = true; + break; + case 'only-if-cached': + this.onlyIfCached = true; + break; + case 'must-revalidate': + this.mustRevalidate = true; + break; + case 'proxy-revalidate': + this.proxyRevalidate = true; + break; + case 'must-understand': + this.mustUnderstand = true; + break; + case 'private': + this.private = true; + break; + case 'public': + this.public = true; + break; + case 'immutable': + this.immutable = true; + break; + case 'stale-while-revalidate': + this.staleWhileRevalidate = Number(value); + break; + case 'stale-if-error': + this.staleIfError = Number(value); + break; + } + } + } + } else { + this.maxAge = init.maxAge; + this.maxStale = init.maxStale; + this.minFresh = init.minFresh; + this.sMaxage = init.sMaxage; + this.noCache = init.noCache; + this.noStore = init.noStore; + this.noTransform = init.noTransform; + this.onlyIfCached = init.onlyIfCached; + this.mustRevalidate = init.mustRevalidate; + this.proxyRevalidate = init.proxyRevalidate; + this.mustUnderstand = init.mustUnderstand; + this.private = init.private; + this.public = init.public; + this.immutable = init.immutable; + this.staleWhileRevalidate = init.staleWhileRevalidate; + this.staleIfError = init.staleIfError; + } + } + } + toString() { + let parts = []; + if (this.public) { + parts.push('public'); + } + if (this.private) { + parts.push('private'); + } + if (typeof this.maxAge === 'number') { + parts.push(`max-age=${this.maxAge}`); + } + if (typeof this.sMaxage === 'number') { + parts.push(`s-maxage=${this.sMaxage}`); + } + if (this.noCache) { + parts.push('no-cache'); + } + if (this.noStore) { + parts.push('no-store'); + } + if (this.noTransform) { + parts.push('no-transform'); + } + if (this.onlyIfCached) { + parts.push('only-if-cached'); + } + if (this.mustRevalidate) { + parts.push('must-revalidate'); + } + if (this.proxyRevalidate) { + parts.push('proxy-revalidate'); + } + if (this.mustUnderstand) { + parts.push('must-understand'); + } + if (this.immutable) { + parts.push('immutable'); + } + if (typeof this.staleWhileRevalidate === 'number') { + parts.push(`stale-while-revalidate=${this.staleWhileRevalidate}`); + } + if (typeof this.staleIfError === 'number') { + parts.push(`stale-if-error=${this.staleIfError}`); + } + if (typeof this.maxStale === 'number') { + parts.push(`max-stale=${this.maxStale}`); + } + if (typeof this.minFresh === 'number') { + parts.push(`min-fresh=${this.minFresh}`); + } + return parts.join(', '); + } +}; + +// src/lib/content-disposition.ts +var ContentDisposition = class { + filename; + filenameSplat; + name; + type; + constructor(init) { + if (init) { + if (typeof init === 'string') { + let params = parseParams(init); + if (params.length > 0) { + this.type = params[0][0]; + for (let [name, value] of params.slice(1)) { + if (name === 'filename') { + this.filename = value; + } else if (name === 'filename*') { + this.filenameSplat = value; + } else if (name === 'name') { + this.name = value; + } + } + } + } else { + this.filename = init.filename; + this.filenameSplat = init.filenameSplat; + this.name = init.name; + this.type = init.type; + } + } + } + /** + * The preferred filename for the content, using the `filename*` parameter if present, falling back to the `filename` parameter. + * + * From [RFC 6266](https://tools.ietf.org/html/rfc6266): + * + * Many user agent implementations predating this specification do not understand the "filename*" parameter. + * Therefore, when both "filename" and "filename*" are present in a single header field value, recipients SHOULD + * pick "filename*" and ignore "filename". This way, senders can avoid special-casing specific user agents by + * sending both the more expressive "filename*" parameter, and the "filename" parameter as fallback for legacy recipients. + */ + get preferredFilename() { + let filenameSplat = this.filenameSplat; + if (filenameSplat) { + let decodedFilename = decodeFilenameSplat(filenameSplat); + if (decodedFilename) return decodedFilename; + } + return this.filename; + } + toString() { + if (!this.type) { + return ''; + } + let parts = [this.type]; + if (this.name) { + parts.push(`name=${quote(this.name)}`); + } + if (this.filename) { + parts.push(`filename=${quote(this.filename)}`); + } + if (this.filenameSplat) { + parts.push(`filename*=${quote(this.filenameSplat)}`); + } + return parts.join('; '); + } +}; +function decodeFilenameSplat(value) { + let match = value.match(/^([\w-]+)'([^']*)'(.+)$/); + if (!match) return null; + let [, charset, , encodedFilename] = match; + let decodedFilename = percentDecode(encodedFilename); + try { + let decoder = new TextDecoder(charset); + let bytes = new Uint8Array(decodedFilename.split('').map((char) => char.charCodeAt(0))); + return decoder.decode(bytes); + } catch (error) { + console.warn(`Failed to decode filename from charset ${charset}:`, error); + return decodedFilename; + } +} +function percentDecode(value) { + return value.replace(/\+/g, ' ').replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => { + return String.fromCharCode(parseInt(hex, 16)); + }); +} + +// src/lib/content-type.ts +var ContentType = class { + boundary; + charset; + mediaType; + constructor(init) { + if (init) { + if (typeof init === 'string') { + let params = parseParams(init); + if (params.length > 0) { + this.mediaType = params[0][0]; + for (let [name, value] of params.slice(1)) { + if (name === 'boundary') { + this.boundary = value; + } else if (name === 'charset') { + this.charset = value; + } + } + } + } else { + this.boundary = init.boundary; + this.charset = init.charset; + this.mediaType = init.mediaType; + } + } + } + toString() { + if (!this.mediaType) { + return ''; + } + let parts = [this.mediaType]; + if (this.charset) { + parts.push(`charset=${quote(this.charset)}`); + } + if (this.boundary) { + parts.push(`boundary=${quote(this.boundary)}`); + } + return parts.join('; '); + } +}; + +// src/lib/cookie.ts +var Cookie = class { + #map; + constructor(init) { + this.#map = /* @__PURE__ */ new Map(); + if (init) { + if (typeof init === 'string') { + let params = parseParams(init); + for (let [name, value] of params) { + this.#map.set(name, value ?? ''); + } + } else if (isIterable(init)) { + for (let [name, value] of init) { + this.#map.set(name, value); + } + } else { + for (let name of Object.getOwnPropertyNames(init)) { + this.#map.set(name, init[name]); + } + } + } + } + /** + * An array of the names of the cookies in the header. + */ + get names() { + return Array.from(this.#map.keys()); + } + /** + * An array of the values of the cookies in the header. + */ + get values() { + return Array.from(this.#map.values()); + } + /** + * The number of cookies in the header. + */ + get size() { + return this.#map.size; + } + /** + * Gets the value of a cookie with the given name from the header. + * @param name The name of the cookie. + * @returns The value of the cookie, or `null` if the cookie does not exist. + */ + get(name) { + return this.#map.get(name) ?? null; + } + /** + * Sets a cookie with the given name and value in the header. + * @param name The name of the cookie. + * @param value The value of the cookie. + */ + set(name, value) { + this.#map.set(name, value); + } + /** + * Removes a cookie with the given name from the header. + * @param name The name of the cookie. + */ + delete(name) { + this.#map.delete(name); + } + /** + * True if a cookie with the given name exists in the header. + * @param name The name of the cookie. + * @returns True if a cookie with the given name exists in the header. + */ + has(name) { + return this.#map.has(name); + } + /** + * Removes all cookies from the header. + */ + clear() { + this.#map.clear(); + } + entries() { + return this.#map.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + forEach(callback, thisArg) { + for (let [name, value] of this) { + callback.call(thisArg, name, value, this); + } + } + toString() { + let pairs = []; + for (let [name, value] of this.#map) { + pairs.push(`${name}=${quote(value)}`); + } + return pairs.join('; '); + } +}; + +// src/lib/if-none-match.ts +var IfNoneMatch = class { + tags = []; + constructor(init) { + if (init) { + if (typeof init === 'string') { + this.tags.push(...init.split(/\s*,\s*/).map(quoteEtag)); + } else if (Array.isArray(init)) { + this.tags.push(...init.map(quoteEtag)); + } else { + this.tags.push(...init.tags.map(quoteEtag)); + } + } + } + /** + * Checks if the header contains the given entity tag. + * + * Note: This method checks only for exact matches and does not consider wildcards. + * + * @param tag The entity tag to check for. + * @returns `true` if the tag is present in the header, `false` otherwise. + */ + has(tag) { + return this.tags.includes(quoteEtag(tag)); + } + /** + * Checks if this header matches the given entity tag. + * + * @param tag The entity tag to check for. + * @returns `true` if the tag is present in the header (or the header contains a wildcard), `false` otherwise. + */ + matches(tag) { + return this.has(tag) || this.tags.includes('*'); + } + toString() { + return this.tags.join(', '); + } +}; + +// src/lib/set-cookie.ts +var SetCookie = class { + domain; + expires; + httpOnly; + maxAge; + name; + path; + sameSite; + secure; + value; + constructor(init) { + if (init) { + if (typeof init === 'string') { + let params = parseParams(init); + if (params.length > 0) { + this.name = params[0][0]; + this.value = params[0][1]; + for (let [key, value] of params.slice(1)) { + switch (key.toLowerCase()) { + case 'domain': + this.domain = value; + break; + case 'expires': { + if (typeof value === 'string') { + let date = new Date(value); + if (isValidDate(date)) { + this.expires = date; + } + } + break; + } + case 'httponly': + this.httpOnly = true; + break; + case 'max-age': { + if (typeof value === 'string') { + let v = parseInt(value, 10); + if (!isNaN(v)) this.maxAge = v; + } + break; + } + case 'path': + this.path = value; + break; + case 'samesite': + if (typeof value === 'string' && /strict|lax|none/i.test(value)) { + this.sameSite = capitalize(value); + } + break; + case 'secure': + this.secure = true; + break; + } + } + } + } else { + this.domain = init.domain; + this.expires = init.expires; + this.httpOnly = init.httpOnly; + this.maxAge = init.maxAge; + this.name = init.name; + this.path = init.path; + this.sameSite = init.sameSite; + this.secure = init.secure; + this.value = init.value; + } + } + } + toString() { + if (!this.name) { + return ''; + } + let parts = [`${this.name}=${quote(this.value || '')}`]; + if (this.domain) { + parts.push(`Domain=${this.domain}`); + } + if (this.path) { + parts.push(`Path=${this.path}`); + } + if (this.expires) { + parts.push(`Expires=${this.expires.toUTCString()}`); + } + if (this.maxAge) { + parts.push(`Max-Age=${this.maxAge}`); + } + if (this.secure) { + parts.push('Secure'); + } + if (this.httpOnly) { + parts.push('HttpOnly'); + } + if (this.sameSite) { + parts.push(`SameSite=${this.sameSite}`); + } + return parts.join('; '); + } +}; + +// src/lib/header-names.ts +var HeaderWordCasingExceptions = { + ct: 'CT', + etag: 'ETag', + te: 'TE', + www: 'WWW', + x: 'X', + xss: 'XSS', +}; +function canonicalHeaderName(name) { + return name + .toLowerCase() + .split('-') + .map((word) => HeaderWordCasingExceptions[word] || word.charAt(0).toUpperCase() + word.slice(1)) + .join('-'); +} + +// src/lib/super-headers.ts +var CRLF = '\r\n'; +var AcceptKey = 'accept'; +var AcceptEncodingKey = 'accept-encoding'; +var AcceptLanguageKey = 'accept-language'; +var AcceptRangesKey = 'accept-ranges'; +var AgeKey = 'age'; +var CacheControlKey = 'cache-control'; +var ConnectionKey = 'connection'; +var ContentDispositionKey = 'content-disposition'; +var ContentEncodingKey = 'content-encoding'; +var ContentLanguageKey = 'content-language'; +var ContentLengthKey = 'content-length'; +var ContentTypeKey = 'content-type'; +var CookieKey = 'cookie'; +var DateKey = 'date'; +var ETagKey = 'etag'; +var ExpiresKey = 'expires'; +var HostKey = 'host'; +var IfModifiedSinceKey = 'if-modified-since'; +var IfNoneMatchKey = 'if-none-match'; +var IfUnmodifiedSinceKey = 'if-unmodified-since'; +var LastModifiedKey = 'last-modified'; +var LocationKey = 'location'; +var RefererKey = 'referer'; +var SetCookieKey = 'set-cookie'; +var SuperHeaders = class _SuperHeaders extends Headers { + #map; + #setCookies = []; + constructor(init) { + super(); + this.#map = /* @__PURE__ */ new Map(); + if (init) { + if (typeof init === 'string') { + let lines = init.split(CRLF); + for (let line of lines) { + let match = line.match(/^([^:]+):(.*)/); + if (match) { + this.append(match[1].trim(), match[2].trim()); + } + } + } else if (isIterable(init)) { + for (let [name, value] of init) { + this.append(name, value); + } + } else if (typeof init === 'object') { + for (let name of Object.getOwnPropertyNames(init)) { + let value = init[name]; + let descriptor = Object.getOwnPropertyDescriptor(_SuperHeaders.prototype, name); + if (descriptor?.set) { + descriptor.set.call(this, value); + } else { + this.set(name, value.toString()); + } + } + } + } + } + /** + * Appends a new header value to the existing set of values for a header, + * or adds the header if it does not already exist. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/append) + */ + append(name, value) { + let key = name.toLowerCase(); + if (key === SetCookieKey) { + this.#setCookies.push(value); + } else { + let existingValue = this.#map.get(key); + this.#map.set(key, existingValue ? `${existingValue}, ${value}` : value); + } + } + /** + * Removes a header. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/delete) + */ + delete(name) { + let key = name.toLowerCase(); + if (key === SetCookieKey) { + this.#setCookies = []; + } else { + this.#map.delete(key); + } + } + /** + * Returns a string of all the values for a header, or `null` if the header does not exist. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/get) + */ + get(name) { + let key = name.toLowerCase(); + if (key === SetCookieKey) { + return this.getSetCookie().join(', '); + } else { + let value = this.#map.get(key); + if (typeof value === 'string') { + return value; + } else if (value != null) { + let str = value.toString(); + return str === '' ? null : str; + } else { + return null; + } + } + } + /** + * Returns an array of all values associated with the `Set-Cookie` header. This is + * useful when building headers for a HTTP response since multiple `Set-Cookie` headers + * must be sent on separate lines. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie) + */ + getSetCookie() { + return this.#setCookies.map((v) => (typeof v === 'string' ? v : v.toString())); + } + /** + * Returns `true` if the header is present in the list of headers. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/has) + */ + has(name) { + let key = name.toLowerCase(); + return key === SetCookieKey ? this.#setCookies.length > 0 : this.get(key) != null; + } + /** + * Sets a new value for the given header. If the header already exists, the new value + * will replace the existing value. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/set) + */ + set(name, value) { + let key = name.toLowerCase(); + if (key === SetCookieKey) { + this.#setCookies = [value]; + } else { + this.#map.set(key, value); + } + } + /** + * Returns an iterator of all header keys (lowercase). + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/keys) + */ + *keys() { + for (let [key] of this) yield key; + } + /** + * Returns an iterator of all header values. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/values) + */ + *values() { + for (let [, value] of this) yield value; + } + /** + * Returns an iterator of all header key/value pairs. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/entries) + */ + *entries() { + for (let [key] of this.#map) { + let str = this.get(key); + if (str) yield [key, str]; + } + for (let value of this.getSetCookie()) { + yield [SetCookieKey, value]; + } + } + [Symbol.iterator]() { + return this.entries(); + } + /** + * Invokes the `callback` for each header key/value pair. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Headers/forEach) + */ + forEach(callback, thisArg) { + for (let [key, value] of this) { + callback.call(thisArg, value, key, this); + } + } + /** + * Returns a string representation of the headers suitable for use in a HTTP message. + */ + toString() { + let lines = []; + for (let [key, value] of this) { + lines.push(`${canonicalHeaderName(key)}: ${value}`); + } + return lines.join(CRLF); + } + // Header-specific getters and setters + /** + * The `Accept` header is used by clients to indicate the media types that are acceptable + * in the response. + * + * [MDN `Accept` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2) + */ + get accept() { + return this.#getHeaderValue(AcceptKey, Accept); + } + set accept(value) { + this.#setHeaderValue(AcceptKey, Accept, value); + } + /** + * The `Accept-Encoding` header contains information about the content encodings that the client + * is willing to accept in the response. + * + * [MDN `Accept-Encoding` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.4) + */ + get acceptEncoding() { + return this.#getHeaderValue(AcceptEncodingKey, AcceptEncoding); + } + set acceptEncoding(value) { + this.#setHeaderValue(AcceptEncodingKey, AcceptEncoding, value); + } + /** + * The `Accept-Language` header contains information about preferred natural language for the + * response. + * + * [MDN `Accept-Language` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5) + */ + get acceptLanguage() { + return this.#getHeaderValue(AcceptLanguageKey, AcceptLanguage); + } + set acceptLanguage(value) { + this.#setHeaderValue(AcceptLanguageKey, AcceptLanguage, value); + } + /** + * The `Accept-Ranges` header indicates the server's acceptance of range requests. + * + * [MDN `Accept-Ranges` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7233#section-2.3) + */ + get acceptRanges() { + return this.#getStringValue(AcceptRangesKey); + } + set acceptRanges(value) { + this.#setStringValue(AcceptRangesKey, value); + } + /** + * The `Age` header contains the time in seconds an object was in a proxy cache. + * + * [MDN `Age` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.1) + */ + get age() { + return this.#getNumberValue(AgeKey); + } + set age(value) { + this.#setNumberValue(AgeKey, value); + } + /** + * The `Cache-Control` header contains directives for caching mechanisms in both requests and responses. + * + * [MDN `Cache-Control` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2) + */ + get cacheControl() { + return this.#getHeaderValue(CacheControlKey, CacheControl); + } + set cacheControl(value) { + this.#setHeaderValue(CacheControlKey, CacheControl, value); + } + /** + * The `Connection` header controls whether the network connection stays open after the current + * transaction finishes. + * + * [MDN `Connection` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-6.1) + */ + get connection() { + return this.#getStringValue(ConnectionKey); + } + set connection(value) { + this.#setStringValue(ConnectionKey, value); + } + /** + * The `Content-Disposition` header is a response-type header that describes how the payload is displayed. + * + * [MDN `Content-Disposition` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) + * + * [RFC 6266](https://datatracker.ietf.org/doc/html/rfc6266) + */ + get contentDisposition() { + return this.#getHeaderValue(ContentDispositionKey, ContentDisposition); + } + set contentDisposition(value) { + this.#setHeaderValue(ContentDispositionKey, ContentDisposition, value); + } + /** + * The `Content-Encoding` header specifies the encoding of the resource. + * + * Note: If multiple encodings have been used, this value may be a comma-separated list. However, most often this + * header will only contain a single value. + * + * [MDN `Content-Encoding` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-encoding) + */ + get contentEncoding() { + return this.#getStringValue(ContentEncodingKey); + } + set contentEncoding(value) { + this.#setStringValue(ContentEncodingKey, Array.isArray(value) ? value.join(', ') : value); + } + /** + * The `Content-Language` header describes the natural language(s) of the intended audience for the response content. + * + * Note: If the response content is intended for multiple audiences, this value may be a comma-separated list. However, + * most often this header will only contain a single value. + * + * [MDN `Content-Language` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-language) + */ + get contentLanguage() { + return this.#getStringValue(ContentLanguageKey); + } + set contentLanguage(value) { + this.#setStringValue(ContentLanguageKey, Array.isArray(value) ? value.join(', ') : value); + } + /** + * The `Content-Length` header indicates the size of the entity-body in bytes. + * + * [MDN `Content-Length` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2) + */ + get contentLength() { + return this.#getNumberValue(ContentLengthKey); + } + set contentLength(value) { + this.#setNumberValue(ContentLengthKey, value); + } + /** + * The `Content-Type` header indicates the media type of the resource. + * + * [MDN `Content-Type` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.5) + */ + get contentType() { + return this.#getHeaderValue(ContentTypeKey, ContentType); + } + set contentType(value) { + this.#setHeaderValue(ContentTypeKey, ContentType, value); + } + /** + * The `Cookie` request header contains stored HTTP cookies previously sent by the server with + * the `Set-Cookie` header. + * + * [MDN `Cookie` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc6265#section-5.4) + */ + get cookie() { + return this.#getHeaderValue(CookieKey, Cookie); + } + set cookie(value) { + this.#setHeaderValue(CookieKey, Cookie, value); + } + /** + * The `Date` header contains the date and time at which the message was sent. + * + * [MDN `Date` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.2) + */ + get date() { + return this.#getDateValue(DateKey); + } + set date(value) { + this.#setDateValue(DateKey, value); + } + /** + * The `ETag` header provides a unique identifier for the current version of the resource. + * + * [MDN `ETag` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-2.3) + */ + get etag() { + return this.#getStringValue(ETagKey); + } + set etag(value) { + this.#setStringValue(ETagKey, typeof value === 'string' ? quoteEtag(value) : value); + } + /** + * The `Expires` header contains the date/time after which the response is considered stale. + * + * [MDN `Expires` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7234#section-5.3) + */ + get expires() { + return this.#getDateValue(ExpiresKey); + } + set expires(value) { + this.#setDateValue(ExpiresKey, value); + } + /** + * The `Host` header specifies the domain name of the server and (optionally) the TCP port number. + * + * [MDN `Host` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7230#section-5.4) + */ + get host() { + return this.#getStringValue(HostKey); + } + set host(value) { + this.#setStringValue(HostKey, value); + } + /** + * The `If-Modified-Since` header makes a request conditional on the last modification date of the + * requested resource. + * + * [MDN `If-Modified-Since` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.3) + */ + get ifModifiedSince() { + return this.#getDateValue(IfModifiedSinceKey); + } + set ifModifiedSince(value) { + this.#setDateValue(IfModifiedSinceKey, value); + } + /** + * The `If-None-Match` header makes a request conditional on the absence of a matching ETag. + * + * [MDN `If-None-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.2) + */ + get ifNoneMatch() { + return this.#getHeaderValue(IfNoneMatchKey, IfNoneMatch); + } + set ifNoneMatch(value) { + this.#setHeaderValue(IfNoneMatchKey, IfNoneMatch, value); + } + /** + * The `If-Unmodified-Since` header makes a request conditional on the last modification date of the + * requested resource. + * + * [MDN `If-Unmodified-Since` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.4) + */ + get ifUnmodifiedSince() { + return this.#getDateValue(IfUnmodifiedSinceKey); + } + set ifUnmodifiedSince(value) { + this.#setDateValue(IfUnmodifiedSinceKey, value); + } + /** + * The `Last-Modified` header contains the date and time at which the resource was last modified. + * + * [MDN `Last-Modified` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-2.2) + */ + get lastModified() { + return this.#getDateValue(LastModifiedKey); + } + set lastModified(value) { + this.#setDateValue(LastModifiedKey, value); + } + /** + * The `Location` header indicates the URL to redirect to. + * + * [MDN `Location` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.2) + */ + get location() { + return this.#getStringValue(LocationKey); + } + set location(value) { + this.#setStringValue(LocationKey, value); + } + /** + * The `Referer` header contains the address of the previous web page from which a link to the + * currently requested page was followed. + * + * [MDN `Referer` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7231#section-5.5.2) + */ + get referer() { + return this.#getStringValue(RefererKey); + } + set referer(value) { + this.#setStringValue(RefererKey, value); + } + /** + * The `Set-Cookie` header is used to send cookies from the server to the user agent. + * + * [MDN `Set-Cookie` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1) + */ + get setCookie() { + let setCookies = this.#setCookies; + for (let i = 0; i < setCookies.length; ++i) { + if (typeof setCookies[i] === 'string') { + setCookies[i] = new SetCookie(setCookies[i]); + } + } + return setCookies; + } + set setCookie(value) { + if (value != null) { + this.#setCookies = (Array.isArray(value) ? value : [value]).map((v) => + typeof v === 'string' ? v : new SetCookie(v), + ); + } else { + this.#setCookies = []; + } + } + // Helpers + #getHeaderValue(key, ctor) { + let value = this.#map.get(key); + if (value !== void 0) { + if (typeof value === 'string') { + let obj2 = new ctor(value); + this.#map.set(key, obj2); + return obj2; + } else { + return value; + } + } + let obj = new ctor(); + this.#map.set(key, obj); + return obj; + } + #setHeaderValue(key, ctor, value) { + if (value != null) { + this.#map.set(key, typeof value === 'string' ? value : new ctor(value)); + } else { + this.#map.delete(key); + } + } + #getDateValue(key) { + let value = this.#map.get(key); + return value === void 0 ? null : new Date(value); + } + #setDateValue(key, value) { + if (value != null) { + this.#map.set( + key, + typeof value === 'string' + ? value + : (typeof value === 'number' ? new Date(value) : value).toUTCString(), + ); + } else { + this.#map.delete(key); + } + } + #getNumberValue(key) { + let value = this.#map.get(key); + return value === void 0 ? null : parseInt(value, 10); + } + #setNumberValue(key, value) { + if (value != null) { + this.#map.set(key, typeof value === 'string' ? value : value.toString()); + } else { + this.#map.delete(key); + } + } + #getStringValue(key) { + let value = this.#map.get(key); + return value === void 0 ? null : value; + } + #setStringValue(key, value) { + if (value != null) { + this.#map.set(key, value); + } else { + this.#map.delete(key); + } + } +}; +export { + Accept, + AcceptEncoding, + AcceptLanguage, + CacheControl, + ContentDisposition, + ContentType, + Cookie, + SuperHeaders as default, + IfNoneMatch, + SetCookie, + SuperHeaders +}; + diff --git a/packages/formidable-next/tsdown.config.ts b/packages/formidable-next/tsdown.config.ts new file mode 100644 index 00000000..4af6ff5b --- /dev/null +++ b/packages/formidable-next/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown/config'; + +export default defineConfig({ + dts: true, + entry: './src/entrypoints/index.ts', + format: ['esm', 'cjs'], + outputOptions: { + dir: './dist', + }, + platform: 'node', +}); diff --git a/.all-contributorsrc b/packages/formidable-v3/.all-contributorsrc similarity index 100% rename from .all-contributorsrc rename to packages/formidable-v3/.all-contributorsrc diff --git a/CHANGELOG.md b/packages/formidable-v3/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/formidable-v3/CHANGELOG.md diff --git a/packages/formidable-v3/README.md b/packages/formidable-v3/README.md new file mode 100644 index 00000000..a5574d78 --- /dev/null +++ b/packages/formidable-v3/README.md @@ -0,0 +1,891 @@ +

+ npm formidable package logo +

+ +# formidable [![npm version][npmv-img]][npmv-url] [![MIT license][license-img]][license-url] [![Libera Manifesto][libera-manifesto-img]][libera-manifesto-url] [![Twitter][twitter-img]][twitter-url] + +> A Node.js module for parsing form data, especially file uploads. + +[![Code style][codestyle-img]][codestyle-url] +[![linux build status][linux-build-img]][build-url] +[![macos build status][macos-build-img]][build-url] + + +If you have any _how-to_ kind of questions, please read the [Contributing +Guide][contributing-url] and [Code of Conduct][code_of_conduct-url] +documents.
For bugs reports and feature requests, [please create an +issue][open-issue-url] or ping [@wgw_eth / @wgw_lol][twitter-url] +at Twitter. + +[![Conventional Commits][ccommits-img]][ccommits-url] +[![Minimum Required Nodejs][nodejs-img]][npmv-url] +[![Buy me a Kofi][kofi-img]][kofi-url] +[![Make A Pull Request][prs-welcome-img]][prs-welcome-url] + + + +This project is [semantically versioned](https://semver.org) and if you want support in migrating between versions you can schedule us for training or support us through donations, so we can prioritize. + +> [!CAUTION] +> As of April 2025, old versions like v1 and v2 are still the most used, while they are deperecated for years -- they are also vulnerable to attacks if you are not implementing it properly. **Please upgrade!** We are here to help, and AI Editors & Agents could help a lot in such codemod-like migrations. + +> [!TIP] +> If you are starting a fresh project, you can try `formidable@next` which is a super minimal version of Formidable. It uses web standards like FormData API and File API, Web Streams, and you can use it to stream uploads directly to S3 or other such services. + + + +[![][npm-weekly-img]][npmv-url] [![][npm-monthly-img]][npmv-url] +[![][npm-yearly-img]][npmv-url] [![][npm-alltime-img]][npmv-url] + +## Project Status: Maintained + +> [!NOTE] +> Check [VERSION NOTES](https://github.com/node-formidable/formidable/blob/master/VERSION_NOTES.md) for more information on v1, v2, and v3 plans, NPM dist-tags and branches._ + +This module was initially developed by +[**@felixge**](https://github.com/felixge) for +[Transloadit](http://transloadit.com/), a service focused on uploading and +encoding images and videos. It has been battle-tested against hundreds of GBs of +file uploads from a large variety of clients and is considered production-ready +and is used in production for years. + +Currently, we are few maintainers trying to deal with it. :) More contributors +are always welcome! :heart: Jump on +[issue #412](https://github.com/felixge/node-formidable/issues/412) which is +closed, but if you are interested we can discuss it and add you after strict +rules, like enabling Two-Factor Auth in your npm and GitHub accounts. + +## Highlights + +- [Fast (~900-2500 mb/sec)](#benchmarks) & streaming multipart parser +- Automatically writing file uploads to disk (optional, see + [`options.fileWriteStreamHandler`](#options)) +- [Plugins API](#useplugin-plugin) - allowing custom parsers and plugins +- Low memory footprint +- Graceful error handling +- Very high test coverage + +## Install + +This package is a dual ESM/commonjs package. + +> [!NOTE] +> This project requires `Node.js >= 20`. Install it using [yarn](https://yarnpkg.com) or [npm](https://npmjs.com).
_We highly recommend to use Yarn when you think to contribute to this project._ + +This is a low-level package, and if you're using a high-level framework it _may_ +already be included. Check the examples below and the [examples/](https://github.com/node-formidable/formidable/tree/master/examples) folder. + +``` +# v2 +npm install formidable@v2 + +# v3 +npm install formidable +npm install formidable@v3 +``` + +_**Note:** Future not ready releases will be published on `*-next` dist-tags for the corresponding version._ + + +## Examples + +For more examples look at the `examples/` directory. + +### with Node.js http module + +Parse an incoming file upload, with the +[Node.js's built-in `http` module](https://nodejs.org/api/http.html). + +```js +import http from 'node:http'; +import formidable, {errors as formidableErrors} from 'formidable'; + +const server = http.createServer(async (req, res) => { + if (req.url === '/api/upload' && req.method.toLowerCase() === 'post') { + // parse a file upload + const form = formidable({}); + let fields; + let files; + try { + [fields, files] = await form.parse(req); + } catch (err) { + // example to check for a very specific error + if (err.code === formidableErrors.maxFieldsExceeded) { + + } + console.error(err); + res.writeHead(err.httpCode || 400, { 'Content-Type': 'text/plain' }); + res.end(String(err)); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ fields, files }, null, 2)); + return; + } + + // show a file upload form + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` +

With Node.js "http" module

+ +
Text field title:
+
File:
+ + + `); +}); + +server.listen(8080, () => { + console.log('Server listening on http://localhost:8080/ ...'); +}); +``` + +### with Express.js + +There are multiple variants to do this, but Formidable just need Node.js Request +stream, so something like the following example should work just fine, without +any third-party [Express.js](https://ghub.now.sh/express) middleware. + +Or try the +[examples/with-express.js](https://github.com/node-formidable/formidable/blob/master/examples/with-express.js) + +```js +import express from 'express'; +import formidable from 'formidable'; + +const app = express(); + +app.get('/', (req, res) => { + res.send(` +

With "express" npm package

+
+
Text field title:
+
File:
+ +
+ `); +}); + +app.post('/api/upload', (req, res, next) => { + const form = formidable({}); + + form.parse(req, (err, fields, files) => { + if (err) { + next(err); + return; + } + res.json({ fields, files }); + }); +}); + +app.listen(3000, () => { + console.log('Server listening on http://localhost:3000 ...'); +}); +``` + +### with Koa and Formidable + +Of course, with [Koa v1, v2 or future v3](https://ghub.now.sh/koa) the things +are very similar. You can use `formidable` manually as shown below or through +the [koa-better-body](https://ghub.now.sh/koa-better-body) package which is +using `formidable` under the hood and support more features and different +request bodies, check its documentation for more info. + +_Note: this example is assuming Koa v2. Be aware that you should pass `ctx.req` +which is Node.js's Request, and **NOT** the `ctx.request` which is Koa's Request +object - there is a difference._ + +```js +import Koa from 'Koa'; +import formidable from 'formidable'; + +const app = new Koa(); + +app.on('error', (err) => { + console.error('server error', err); +}); + +app.use(async (ctx, next) => { + if (ctx.url === '/api/upload' && ctx.method.toLowerCase() === 'post') { + const form = formidable({}); + + // not very elegant, but that's for now if you don't want to use `koa-better-body` + // or other middlewares. + await new Promise((resolve, reject) => { + form.parse(ctx.req, (err, fields, files) => { + if (err) { + reject(err); + return; + } + + ctx.set('Content-Type', 'application/json'); + ctx.status = 200; + ctx.state = { fields, files }; + ctx.body = JSON.stringify(ctx.state, null, 2); + resolve(); + }); + }); + await next(); + return; + } + + // show a file upload form + ctx.set('Content-Type', 'text/html'); + ctx.status = 200; + ctx.body = ` +

With "koa" npm package

+
+
Text field title:
+
File:
+ +
+ `; +}); + +app.use((ctx) => { + console.log('The next middleware is called'); + console.log('Results:', ctx.state); +}); + +app.listen(3000, () => { + console.log('Server listening on http://localhost:3000 ...'); +}); +``` + +## Benchmarks + +The benchmark is quite old, from the old codebase. But maybe quite true though. +Previously the numbers was around ~500 mb/sec. Currently with moving to the new +Node.js Streams API it's faster. You can clearly see the differences between the +Node versions. + +_Note: a lot better benchmarking could and should be done in future._ + +Benchmarked on 8GB RAM, Xeon X3440 (2.53 GHz, 4 cores, 8 threads) + +``` +~/github/node-formidable master +❯ nve --parallel 8 10 12 13 node benchmark/bench-multipart-parser.js + + ⬢ Node 8 + +1261.08 mb/sec + + ⬢ Node 10 + +1113.04 mb/sec + + ⬢ Node 12 + +2107.00 mb/sec + + ⬢ Node 13 + +2566.42 mb/sec +``` + +![benchmark January 29th, 2020](./benchmark/2020-01-29_xeon-x3440.png) + +## API + +### Formidable / IncomingForm + +All shown are equivalent. + +_Please pass [`options`](#options) to the function/constructor, not by assigning +them to the instance `form`_ + +```js +import formidable from 'formidable'; +const form = formidable(options); +``` + +### Options + +See it's defaults in [src/Formidable.js DEFAULT_OPTIONS](./src/Formidable.js) +(the `DEFAULT_OPTIONS` constant). + +- `options.encoding` **{string}** - default `'utf-8'`; sets encoding for + incoming form fields, +- `options.uploadDir` **{string}** - default `os.tmpdir()`; the directory for + placing file uploads in. You can move them later by using `fs.rename()`. +- `options.keepExtensions` **{boolean}** - default `false`; to include the + extensions of the original files or not +- `options.allowEmptyFiles` **{boolean}** - default `false`; allow upload empty + files +- `options.minFileSize` **{number}** - default `1` (1byte); the minium size of + uploaded file. +- `options.maxFiles` **{number}** - default `Infinity`; + limit the amount of uploaded files, set Infinity for unlimited +- `options.maxFileSize` **{number}** - default `200 * 1024 * 1024` (200mb); + limit the size of each uploaded file. +- `options.maxTotalFileSize` **{number}** - default `options.maxFileSize`; + limit the size of the batch of uploaded files. +- `options.maxFields` **{number}** - default `1000`; limit the number of fields, set Infinity for unlimited +- `options.maxFieldsSize` **{number}** - default `20 * 1024 * 1024` (20mb); + limit the amount of memory all fields together (except files) can allocate in + bytes. +- `options.hashAlgorithm` **{string | false}** - default `false`; include checksums calculated + for incoming files, set this to some hash algorithm, see + [crypto.createHash](https://nodejs.org/api/crypto.html#crypto_crypto_createhash_algorithm_options) + for available algorithms +- `options.fileWriteStreamHandler` **{function}** - default `null`, which by + default writes to host machine file system every file parsed; The function + should return an instance of a + [Writable stream](https://nodejs.org/api/stream.html#stream_class_stream_writable) + that will receive the uploaded file data. With this option, you can have any + custom behavior regarding where the uploaded file data will be streamed for. + If you are looking to write the file uploaded in other types of cloud storages + (AWS S3, Azure blob storage, Google cloud storage) or private file storage, + this is the option you're looking for. When this option is defined the default + behavior of writing the file in the host machine file system is lost. +- `options.filename` **{function}** - default `undefined` Use it to control + newFilename. Must return a string. Will be joined with options.uploadDir. + +- `options.filter` **{function}** - default function that always returns true. + Use it to filter files before they are uploaded. Must return a boolean. Will not make the form.parse error + +- `options.createDirsFromUploads` **{boolean}** - default false. If true, makes direct folder uploads possible. Use `` to create a form to upload folders. Has to be used with the options `options.uploadDir` and `options.filename` where `options.filename` has to return a string with the character `/` for folders to be created. The base will be `options.uploadDir`. + + +#### `options.filename` **{function}** function (name, ext, part, form) -> string + +where part can be decomposed as + +```js +const { originalFilename, mimetype} = part; +``` + +_**Note:** If this size of combined fields, or size of some file is exceeded, an +`'error'` event is fired._ + +```js +// The amount of bytes received for this form so far. +form.bytesReceived; +``` + +```js +// The expected number of bytes in this form. +form.bytesExpected; +``` + +#### `options.filter` **{function}** function ({name, originalFilename, mimetype}) -> boolean + +Behaves like Array.filter: Returning false will simply ignore the file and go to the next. + +```js +const options = { + filter: function ({name, originalFilename, mimetype}) { + // keep only images + return mimetype && mimetype.includes("image"); + } +}; +``` + +**Note:** use an outside variable to cancel all uploads upon the first error + +**Note:** use form.emit('error') to make form.parse error + +```js +let cancelUploads = false;// create variable at the same scope as form +const options = { + filter: function ({name, originalFilename, mimetype}) { + // keep only images + const valid = mimetype && mimetype.includes("image"); + if (!valid) { + form.emit('error', new formidableErrors.default('invalid type', 0, 400)); // optional make form.parse error + cancelUploads = true; //variable to make filter return false after the first problem + } + return valid && !cancelUploads; + } +}; +``` + + +### .parse(request, ?callback) + +Parses an incoming Node.js `request` containing form data. If `callback` is not provided a promise is returned. + +```js +const form = formidable({ uploadDir: __dirname }); + +form.parse(req, (err, fields, files) => { + console.log('fields:', fields); + console.log('files:', files); +}); + +// with Promise +const [fields, files] = await form.parse(req); +``` + +You may overwrite this method if you are interested in directly accessing the +multipart stream. Doing so will disable any `'field'` / `'file'` events +processing which would occur otherwise, making you fully responsible for +handling the processing. + +About `uploadDir`, given the following directory structure +``` +project-name +├── src +│ └── server.js +│ +└── uploads + └── image.jpg +``` + +`__dirname` would be the same directory as the source file itself (src) + + +```js + `${__dirname}/../uploads` +``` + +to put files in uploads. + +Omitting `__dirname` would make the path relative to the current working directory. This would be the same if server.js is launched from src but not project-name. + + +`null` will use default which is `os.tmpdir()` + +Note: If the directory does not exist, the uploaded files are __silently discarded__. To make sure it exists: + +```js +import {createNecessaryDirectoriesSync} from "filesac"; + + +const uploadPath = `${__dirname}/../uploads`; +createNecessaryDirectoriesSync(`${uploadPath}/x`); +``` + + +In the example below, we listen on couple of events and direct them to the +`data` listener, so you can do whatever you choose there, based on whether its +before the file been emitted, the header value, the header name, on field, on +file and etc. + +Or the other way could be to just override the `form.onPart` as it's shown a bit +later. + +```js +form.once('error', console.error); + +form.on('fileBegin', (formname, file) => { + form.emit('data', { name: 'fileBegin', formname, value: file }); +}); + +form.on('file', (formname, file) => { + form.emit('data', { name: 'file', formname, value: file }); +}); + +form.on('field', (fieldName, fieldValue) => { + form.emit('data', { name: 'field', key: fieldName, value: fieldValue }); +}); + +form.once('end', () => { + console.log('Done!'); +}); + +// If you want to customize whatever you want... +form.on('data', ({ name, key, value, buffer, start, end, formname, ...more }) => { + if (name === 'partBegin') { + } + if (name === 'partData') { + } + if (name === 'headerField') { + } + if (name === 'headerValue') { + } + if (name === 'headerEnd') { + } + if (name === 'headersEnd') { + } + if (name === 'field') { + console.log('field name:', key); + console.log('field value:', value); + } + if (name === 'file') { + console.log('file:', formname, value); + } + if (name === 'fileBegin') { + console.log('fileBegin:', formname, value); + } +}); +``` + +### .use(plugin: Plugin) + +A method that allows you to extend the Formidable library. By default we include +4 plugins, which essentially are adapters to plug the different built-in parsers. + +**The plugins added by this method are always enabled.** + +_See [src/plugins/](./src/plugins/) for more detailed look on default plugins._ + +The `plugin` param has such signature: + +```typescript +function(formidable: Formidable, options: Options): void; +``` + +The architecture is simple. The `plugin` is a function that is passed with the +Formidable instance (the `form` across the README examples) and the options. + +**Note:** the plugin function's `this` context is also the same instance. + +```js +const form = formidable({ keepExtensions: true }); + +form.use((self, options) => { + // self === this === form + console.log('woohoo, custom plugin'); + // do your stuff; check `src/plugins` for inspiration +}); + +form.parse(req, (error, fields, files) => { + console.log('done!'); +}); +``` + +**Important to note**, is that inside plugin `this.options`, `self.options` and +`options` MAY or MAY NOT be the same. General best practice is to always use the +`this`, so you can later test your plugin independently and more easily. + +If you want to disable some parsing capabilities of Formidable, you can disable +the plugin which corresponds to the parser. For example, if you want to disable +multipart parsing (so the [src/parsers/Multipart.js](./src/parsers/Multipart.js) +which is used in [src/plugins/multipart.js](./src/plugins/multipart.js)), then +you can remove it from the `options.enabledPlugins`, like so + +```js +import formidable, {octetstream, querystring, json} from "formidable"; +const form = formidable({ + hashAlgorithm: 'sha1', + enabledPlugins: [octetstream, querystring, json], +}); +``` + +**Be aware** that the order _MAY_ be important too. The names corresponds 1:1 to +files in [src/plugins/](./src/plugins) folder. + +Pull requests for new built-in plugins MAY be accepted - for example, more +advanced querystring parser. Add your plugin as a new file in `src/plugins/` +folder (lowercased) and follow how the other plugins are made. + +### form.onPart + +If you want to use Formidable to only handle certain parts for you, you can do +something similar. Or see +[#387](https://github.com/node-formidable/node-formidable/issues/387) for +inspiration, you can for example validate the mime-type. + +```js +const form = formidable(); + +form.onPart = (part) => { + part.on('data', (buffer) => { + // do whatever you want here + }); +}; +``` + +For example, force Formidable to be used only on non-file "parts" (i.e., html +fields) + +```js +const form = formidable(); + +form.onPart = function (part) { + // let formidable handle only non-file parts + if (part.originalFilename === '' || !part.mimetype) { + // used internally, please do not override! + form._handlePart(part); + } +}; +``` + +### File + +```ts +export interface File { + // The size of the uploaded file in bytes. + // If the file is still being uploaded (see `'fileBegin'` event), + // this property says how many bytes of the file have been written to disk yet. + file.size: number; + + // The path this file is being written to. You can modify this in the `'fileBegin'` event in + // case you are unhappy with the way formidable generates a temporary path for your files. + file.filepath: string; + + // The name this file had according to the uploading client. + file.originalFilename: string | null; + + // calculated based on options provided + file.newFilename: string | null; + + // The mime type of this file, according to the uploading client. + file.mimetype: string | null; + + // A Date object (or `null`) containing the time this file was last written to. + // Mostly here for compatibility with the [W3C File API Draft](http://dev.w3.org/2006/webapi/FileAPI/). + file.mtime: Date | null; + + file.hashAlgorithm: false | |'sha1' | 'md5' | 'sha256' + // If `options.hashAlgorithm` calculation was set, you can read the hex digest out of this var (at the end it will be a string) + file.hash: string | object | null; +} +``` + +#### file.toJSON() + +This method returns a JSON-representation of the file, allowing you to +`JSON.stringify()` the file which is useful for logging and responding to +requests. + +### Events + +#### `'progress'` + +Emitted after each incoming chunk of data that has been parsed. Can be used to +roll your own progress bar. **Warning** Use this only for server side progress bar. On the client side better use `XMLHttpRequest` with `xhr.upload.onprogress =` + +```js +form.on('progress', (bytesReceived, bytesExpected) => {}); +``` + +#### `'field'` + +Emitted whenever a field / value pair has been received. + +```js +form.on('field', (name, value) => {}); +``` + +#### `'fileBegin'` + +Emitted whenever a new file is detected in the upload stream. Use this event if +you want to stream the file to somewhere else while buffering the upload on the +file system. + +```js +form.on('fileBegin', (formName, file) => { + // accessible here + // formName the name in the form () or http filename for octetstream + // file.originalFilename http filename or null if there was a parsing error + // file.newFilename generated hexoid or what options.filename returned + // file.filepath default pathname as per options.uploadDir and options.filename + // file.filepath = CUSTOM_PATH // to change the final path +}); +``` + +#### `'file'` + +Emitted whenever a field / file pair has been received. `file` is an instance of +`File`. + +```js +form.on('file', (formname, file) => { + // same as fileBegin, except + // it is too late to change file.filepath + // file.hash is available if options.hash was used +}); +``` + +#### `'error'` + +Emitted when there is an error processing the incoming form. A request that +experiences an error is automatically paused, you will have to manually call +`request.resume()` if you want the request to continue firing `'data'` events. + +May have `error.httpCode` and `error.code` attached. + +```js +form.on('error', (err) => {}); +``` + +#### `'aborted'` + +Emitted when the request was aborted by the user. Right now this can be due to a +'timeout' or 'close' event on the socket. After this event is emitted, an +`error` event will follow. In the future there will be a separate 'timeout' +event (needs a change in the node core). + +```js +form.on('aborted', () => {}); +``` + +#### `'end'` + +Emitted when the entire request has been received, and all contained files have +finished flushing to disk. This is a great place for you to send your response. + +```js +form.on('end', () => {}); +``` + + +### Helpers + +#### firstValues + +Gets first values of fields, like pre 3.0.0 without multiples pass in a list of optional exceptions where arrays of strings is still wanted (`
' + - '
' + - '' + - '', - ); - }); -}); - -server.on('connection', (socket) => { - connections += 1; - - socket.id = connections; - socket.filename = `connection-${socket.id}.http`; - socket.file = fs.createWriteStream(socket.filename); - socket.pipe(socket.file); - - console.log('--> %s', socket.filename); - socket.on('close', () => { - console.log('<-- %s', socket.filename); - }); -}); - -const port = process.env.PORT || 8080; -server.listen(port, () => { - console.log('Recording connections on port %s', port); -}); diff --git a/tool/rollup.config.js b/tool/rollup.config.js deleted file mode 100644 index d95a2a40..00000000 --- a/tool/rollup.config.js +++ /dev/null @@ -1,105 +0,0 @@ -/* eslint-disable */ -import cjs from '@rollup/plugin-commonjs'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; -import packageJson from '../package.json' with { type: "json" }; - -const {dependencies} = packageJson; -const plugins = [nodeResolve(), cjs()]; -const cjsOptions = { - format: `cjs`, - exports: `named`, -} - -const external = [...Object.keys(dependencies)]; - -export default [ - { - input: `src/index.js`, - output: [ - { - file: `dist/index.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/helpers/firstValues.js`, - output: [ - { - file: `dist/helpers/firstValues.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/helpers/readBooleans.js`, - output: [ - { - file: `dist/helpers/readBooleans.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/parsers/JSON.js`, - output: [ - { - file: `dist/parsers/JSON.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/parsers/Multipart.js`, - output: [ - { - file: `dist/parsers/Multipart.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/parsers/Querystring.js`, - output: [ - { - file: `dist/parsers/Querystring.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/parsers/OctetStream.js`, - output: [ - { - file: `dist/parsers/OctetStream.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, - { - input: `src/parsers/StreamingQuerystring.js`, - output: [ - { - file: `dist/parsers/StreamingQuerystring.cjs`, - ...cjsOptions, - }, - ], - external, - plugins, - }, -]; - diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b4ce5e84 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["eslint-config-xaxa/tsconfig.json"], + "include": ["src/**/*.ts", "**/*.ts", "**/*.tsx", "**/*.astro", "**/*.js"], + "exclude": ["node_modules", "**/dist/**"] +} diff --git a/xpnpm-workspace.yaml b/xpnpm-workspace.yaml new file mode 100644 index 00000000..7f808bf1 --- /dev/null +++ b/xpnpm-workspace.yaml @@ -0,0 +1,9 @@ +packages: + - packages/* + - scripts + +shamefullyHoist: true + +onlyBuiltDependencies: + - esbuild + - unrs-resolver diff --git a/xprettier.config.js b/xprettier.config.js new file mode 100644 index 00000000..aaf25b8b --- /dev/null +++ b/xprettier.config.js @@ -0,0 +1,2 @@ +export { default } from 'eslint-config-xaxa/prettier'; + diff --git a/xtsconfig.node.json b/xtsconfig.node.json new file mode 100644 index 00000000..d87df6ba --- /dev/null +++ b/xtsconfig.node.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + // "compilerOptions": { + // "strict": true, + // "lib": ["DOM", "DOM.Iterable", "ES2020"], + // "module": "ES2015", + // "moduleResolution": "Bundler", + // "target": "ES5", + // "allowImportingTsExtensions": true, + // "rewriteRelativeImportExtensions": true, + // "verbatimModuleSyntax": true, + // }, + "exclude": [ + "node_modules", + "**/dist/**" + ], + "extends": [ + "eslint-config-xaxa/tsconfig.json" + ], + "include": [ + "src/**/*.ts", + "src/**/*.js", + "**/*.ts", + "**/*.tsx", + "**/*.astro", + "**/*.js" + // "**/src/foo.md/*.js", + ] +}